@nodefony/drizzle 10.0.0-alpha.5 → 10.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ import { gapAgainstDeclared } from "../src/migrator/divergence.js";
9
9
  import { auditMigrationSql, runGenerate, stampFormatMarker } from "../src/migrator/kit.js";
10
10
  import { collectTables, entityFilesOf, missingProviders, registeredTables, usurpedTables, writeCustomMigration, writeKitConfig, writeSchemaModule } from "../src/migrator/appSchema.js";
11
11
  import { checkMigrationName } from "../src/migrator/name.js";
12
+ import { repairRebuildCopy } from "../src/migrator/rebuildCopy.js";
12
13
  import { readJournal, tablesPresentIn } from "../src/migrator/adopt.js";
13
14
  import { OrmMigrateCommand } from "./migrateShared.js";
14
15
  import { listTargets } from "nodefony";
@@ -252,7 +253,14 @@ var OrmGenerate = class extends OrmMigrateCommand {
252
253
  for (const tag of added) {
253
254
  const file = path.join(outDir, `${tag}.sql`);
254
255
  written.push(relative(file));
255
- const audit = auditMigrationSql(await fs.readFile(file, "utf8"), dialect);
256
+ let sql = await fs.readFile(file, "utf8");
257
+ const repair = repairRebuildCopy(sql, await this.#columnsBeforeMigration(outDir, tag));
258
+ if (repair.dropped.length > 0) {
259
+ await fs.writeFile(file, repair.sql, "utf8");
260
+ sql = repair.sql;
261
+ if (opts.json !== true) this.log(`Recopie corrigée dans ${relative(file)} — ${repair.dropped.join(", ")} ne peu${repair.dropped.length > 1 ? "vent" : "t"} pas être lue${repair.dropped.length > 1 ? "s" : ""} dans la table de départ, qui ne la${repair.dropped.length > 1 ? "s" : ""} porte pas encore. Ces colonnes recevront ce que leur « CREATE TABLE » leur donne (un défaut, ou vide) — c'est ce qu'aurait fait un « ADD COLUMN ». Le reste du fichier est intact.`, "WARNING");
262
+ }
263
+ const audit = auditMigrationSql(sql, dialect);
256
264
  destructive.push(...audit.destructive);
257
265
  warnings.push(...audit.blocking);
258
266
  }
@@ -336,6 +344,42 @@ var OrmGenerate = class extends OrmMigrateCommand {
336
344
  return ((await readJournal(outDir))?.entries ?? []).map((e) => e.tag);
337
345
  }
338
346
  /**
347
+ * Les colonnes que chaque table portait AVANT la migration qu'on vient
348
+ * d'écrire — lues dans le cliché qui la précède.
349
+ *
350
+ * C'est la même référence que celle sur laquelle l'outil de génération a
351
+ * calculé son écart : le cliché d'indice `N-1` décrit l'état d'arrivée de la
352
+ * migration précédente, donc l'état de DÉPART de celle-ci.
353
+ *
354
+ * Rend une fonction qui répond `null` pour toute table inconnue — absence de
355
+ * cliché, première migration, format inattendu. C'est ce `null` qui garantit
356
+ * qu'on ne réécrit jamais un SQL sur une supposition.
357
+ *
358
+ * @param outDir - dossier des migrations du dialecte.
359
+ * @param tag - étiquette de la migration écrite (`0004_ajout_du_titre`).
360
+ * @returns un lecteur de colonnes par nom de table.
361
+ */
362
+ async #columnsBeforeMigration(outDir, tag) {
363
+ const index = Number.parseInt(tag.slice(0, 4), 10);
364
+ if (!Number.isFinite(index) || index <= 0) return () => null;
365
+ const file = path.join(outDir, "meta", `${String(index - 1).padStart(4, "0")}_snapshot.json`);
366
+ let snapshot;
367
+ try {
368
+ snapshot = JSON.parse(await fs.readFile(file, "utf8"));
369
+ } catch {
370
+ return () => null;
371
+ }
372
+ const tables = snapshot.tables;
373
+ if (tables === void 0 || tables === null) return () => null;
374
+ const byName = /* @__PURE__ */ new Map();
375
+ for (const [key, table] of Object.entries(tables)) {
376
+ const columns = table?.columns;
377
+ if (columns === void 0 || columns === null) continue;
378
+ byName.set(key.split(".").pop(), Object.keys(columns));
379
+ }
380
+ return (name) => byName.get(name) ?? null;
381
+ }
382
+ /**
339
383
  * Écrit le rapport, en machine ou pour un humain.
340
384
  *
341
385
  * @returns `this`.
@@ -1,7 +1,9 @@
1
1
  import { action } from "../src/migrator/explain.js";
2
2
  import { openMigrationDriver } from "../src/migrator/drivers/index.js";
3
+ import { readHistory } from "../src/migrator/history.js";
3
4
  import { readMigrationEnv, resetAllowed } from "../src/migrator/resolve.js";
4
5
  import { OrmMigrateCommand } from "./migrateShared.js";
6
+ import { identityTablesAmong, rowsWorthKeeping } from "../src/migrator/identityTables.js";
5
7
  //#region nodefony/command/orm-reset.ts
6
8
  const options = {
7
9
  helpGroup: "BASE DE DONNÉES",
@@ -62,10 +64,23 @@ function quoteIdent(name, dialect) {
62
64
  * production : lui laisser désigner la cible d'un effacement serait offrir la
63
65
  * seule combinaison qu'il ne faut jamais rendre possible.
64
66
  *
67
+ * ## Ce que `--yes` ne suffit PAS à faire
68
+ *
69
+ * Une base qui porte des comptes, des passkeys ou des seconds facteurs n'est pas
70
+ * une base jetable, et `--yes` ne l'efface pas : la commande refuse, nomme
71
+ * chaque table avec son nombre de lignes, et demande `--drop-accounts` en plus.
72
+ * Vécu : un agent a lu un refus de migration qui proposait `orm:migrate:repair`,
73
+ * puis a tapé `orm:reset -y` — la seule voie qui détruit. Un refus bien rédigé
74
+ * ne suffit pas : la prose informe, seule la commande contraint.
75
+ *
76
+ * Une base VIDE n'est pas gênée, et c'est voulu : une garde qui se lève sur le
77
+ * cas normal s'apprend à être contournée avant le jour où elle a raison.
78
+ *
65
79
  * @example
66
80
  * ```bash
67
- * nodefony orm:reset # demande confirmation en terminal
68
- * nodefony orm:reset --yes # sans question (script)
81
+ * nodefony orm:reset # demande confirmation en terminal
82
+ * nodefony orm:reset --yes # sans question (script)
83
+ * nodefony orm:reset --yes --drop-accounts # ... comptes et passkeys compris
69
84
  * ```
70
85
  */
71
86
  var OrmReset = class extends OrmMigrateCommand {
@@ -73,6 +88,7 @@ var OrmReset = class extends OrmMigrateCommand {
73
88
  super("orm:reset", "vide la base de développement d'un connecteur", cli, options);
74
89
  this.addSharedOptions();
75
90
  this.addOption("-y, --yes", "ne pose pas la question — pour un script ; hors terminal, l'option est obligatoire");
91
+ this.addOption("--drop-accounts", "accepte d'effacer des comptes, des passkeys et des seconds facteurs — sans ce drapeau, `--yes` ne le fait pas");
76
92
  }
77
93
  async generate(opts = {}) {
78
94
  const resolved = this.resolveOrFail(opts, false);
@@ -99,6 +115,27 @@ var OrmReset = class extends OrmMigrateCommand {
99
115
  }, `${style.green("La base est déjà vide")} ${style.dim(`(${resolution.dialect} : ${target})`)} — rien à faire.\n`, 0, opts.json);
100
116
  return this;
101
117
  }
118
+ const identity = identityTablesAmong(tables);
119
+ if (identity.length > 0 && opts.dropAccounts !== true) {
120
+ const counted = [];
121
+ for (const entry of identity) {
122
+ const countRows = await driver.query(`SELECT COUNT(*) AS n FROM ${quoteIdent(entry.table, resolution.dialect)}`);
123
+ counted.push({
124
+ ...entry,
125
+ rows: Number(countRows[0]?.n ?? 0)
126
+ });
127
+ }
128
+ const atRisk = counted.filter((c) => rowsWorthKeeping(c, c.rows));
129
+ if (atRisk.length > 0) {
130
+ let repairable = false;
131
+ try {
132
+ repairable = (await readHistory(driver)).some((row) => !row.success || row.finishedAt === null);
133
+ } catch {}
134
+ const connectorArg = resolution.connector === "default" ? "" : ` --connector ${resolution.connector}`;
135
+ this.fail(resolution.connector, "NF_MIGRATE_RESET_HAS_ACCOUNTS", `Cette base porte des données qui ne se reconstituent pas : ${atRisk.map((c) => `${c.table} (${c.rows})`).join(", ")}.`, `${atRisk.map((c) => ` · ${c.table} — ${c.holds}`).join("\n")}\n\n` + (repairable ? "Une migration a échoué et son marqueur est posé : c'est probablement ce que tu cherches à débloquer. `orm:migrate:repair` lève le marqueur SANS rien effacer, et écrire la migration suivante vaut toujours mieux que défaire ce qui est appliqué.\n\n" : "") + "Si ces données ne comptent pour personne — base d'essai, exemplaire jetable —, relance avec `--drop-accounts` en plus de `--yes` : le drapeau dit que tu as vu la liste ci-dessus.", repairable ? [action(`nodefony orm:migrate:repair${connectorArg}`), action(`nodefony orm:migrate:status${connectorArg} --json`)] : [action(`nodefony orm:migrate:status${connectorArg} --json`)], opts.json, 1);
136
+ return this;
137
+ }
138
+ }
102
139
  if (opts.yes !== true) {
103
140
  if (opts.json === true || !process.stdin.isTTY) {
104
141
  this.fail(resolution.connector, "NF_MIGRATE_CONFIRM_REQUIRED", `Cette commande va supprimer ${tables.length} table(s) de « ${target} » (${resolution.dialect}) : ${tables.join(", ")}.`, "Hors terminal — ou en sortie machine, où un dialogue n'a pas sa place —, il n'y a personne pour répondre à une question, et un effacement ne se déduit pas d'un silence. Relance avec `--yes` si c'est bien ce que tu veux.", [action(`nodefony orm:reset --yes${resolution.connector === "default" ? "" : ` --connector ${resolution.connector}`}`)], opts.json, 1);
@@ -186,10 +186,12 @@ var DrizzleService = class extends Service {
186
186
  * donc vivant, refuse le trafic, dit pourquoi — et repart tout seul.
187
187
  */
188
188
  async #applySchemaPolicy(name, ddl, cfg, dialect, filename) {
189
- if (ddl === "auto") return;
190
189
  const kernel = this.kernel;
191
190
  const config = this.#config();
192
- const check = resolveCheckMode(config.migrations?.check, readMigrationEnv(kernel));
191
+ const env = readMigrationEnv(kernel);
192
+ const check = resolveCheckMode(config.migrations?.check, env);
193
+ const adviseOnly = ddl === "auto";
194
+ if (adviseOnly && (check === "off" || env.runtime !== "development")) return;
193
195
  const target = {
194
196
  dialect,
195
197
  filename,
@@ -202,6 +204,10 @@ var DrizzleService = class extends Service {
202
204
  sources,
203
205
  lockTimeoutMs: config.migrations?.lockTimeoutMs
204
206
  });
207
+ if (adviseOnly) {
208
+ await this.#publishReadiness(name, migrator, ddl, "warn");
209
+ return;
210
+ }
205
211
  if (ddl === "migrate") try {
206
212
  const prevu = await migrator.status();
207
213
  const losses = dataLoss(scanDestructive(prevu.pending));
@@ -247,7 +253,7 @@ var DrizzleService = class extends Service {
247
253
  });
248
254
  const enAvance = isAheadOnly(plan);
249
255
  const ok = report.exitCode === 0 || enAvance;
250
- kernel.setReadiness(readinessName(name), ok, report.summary, check === "fail");
256
+ kernel.setReadiness(readinessName(name), ok, report.summary, check === "fail", report.nextActions[0]?.command);
251
257
  if (enAvance) this.log(`Drizzle « ${name} » : la base porte ${plan.missing.length} migration(s) que ce code ne connaît pas — elle est EN AVANCE. C'est attendu pendant un déploiement progressif ou après un retour arrière ; rien à appliquer, le processus peut servir.`, "INFO");
252
258
  else if (!ok) this.log(`Drizzle « ${name} » : ${report.summary}\n ${meaningOf(report.verdict)}\n` + (check === "fail" ? " → le trafic est RETENU (/readyz répond 503) jusqu'à ce que ce soit réglé ; /livez reste vert, ce processus n'est pas malade.\n" : ` → le trafic passe quand même (migrations.check: "warn").\n`) + report.nextActions.map((a) => ` À faire : ${a.command}`).join("\n"), check === "fail" ? "CRITIC" : "WARNING");
253
259
  else if (check === "fail") this.log(`Drizzle « ${name} » : schéma à jour — le processus peut servir.`, "INFO");
@@ -0,0 +1,53 @@
1
+ //#region nodefony/src/migrator/identityTables.ts
2
+ const IDENTITY_TABLES = [
3
+ {
4
+ name: "User",
5
+ holds: "des comptes que rien ne repose — un semis ne recrée que l'admin",
6
+ reseededRows: 1
7
+ },
8
+ {
9
+ name: "webauthn_credential",
10
+ holds: "les passkeys — liées à l'appareil, personne ne peut les réémettre",
11
+ reseededRows: 0
12
+ },
13
+ {
14
+ name: "totp_secret",
15
+ holds: "les seconds facteurs — il faudra re-scanner un code",
16
+ reseededRows: 0
17
+ }
18
+ ];
19
+ /**
20
+ * Les tables d'identité PRÉSENTES parmi celles qu'on s'apprête à supprimer.
21
+ *
22
+ * La comparaison ignore la casse : sqlite conserve `User` tel quel, d'autres
23
+ * moteurs replient en minuscules, et une garde sensible à la casse serait
24
+ * inopérante sur la moitié des dialectes — c'est-à-dire exactement là où l'on
25
+ * croirait être protégé.
26
+ *
27
+ * @param tables - noms lus dans le catalogue de la base.
28
+ * @returns les entrées d'identité correspondantes, avec le nom RÉEL de la table.
29
+ */
30
+ function identityTablesAmong(tables) {
31
+ const found = [];
32
+ for (const known of IDENTITY_TABLES) {
33
+ const match = tables.find((t) => t.toLowerCase() === known.name.toLowerCase());
34
+ if (match !== void 0) found.push({
35
+ table: match,
36
+ holds: known.holds,
37
+ reseededRows: known.reseededRows
38
+ });
39
+ }
40
+ return found;
41
+ }
42
+ /**
43
+ * Y a-t-il quelque chose à PERDRE dans cette table ?
44
+ *
45
+ * @param found - table d'identité présente en base.
46
+ * @param rows - nombre de lignes constaté.
47
+ * @returns `true` si la perte dépasse ce qu'un semis repose.
48
+ */
49
+ function rowsWorthKeeping(found, rows) {
50
+ return rows > found.reseededRows;
51
+ }
52
+ //#endregion
53
+ export { IDENTITY_TABLES, identityTablesAmong, rowsWorthKeeping };
@@ -1,5 +1,5 @@
1
1
  import { FORMAT_MARKER } from "./types.js";
2
- import { MigrationToolError, generationToolMissing } from "./refusals.js";
2
+ import { MigrationToolError, generationFailed, generationNeedsAnswer, generationToolMissing, introspectFailed } from "./refusals.js";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "node:child_process";
@@ -54,8 +54,9 @@ function resolveDrizzleKitBin(from) {
54
54
  * relatif à `cwd`), `name` (nom imposé de la migration), `label` (ce qui est
55
55
  * cité dans l'erreur).
56
56
  * @returns la sortie complète de l'outil (sortie standard puis sortie d'erreur).
57
- * @throws Error si le code est non nul, ou si rien ne prouve que la génération a
58
- * eu lieu — l'absence de preuve n'est JAMAIS lue comme « rien à faire ».
57
+ * @throws MigrationToolError si le code est non nul, ou si rien ne prouve que la
58
+ * génération a eu lieu — l'absence de preuve n'est JAMAIS lue comme « rien à
59
+ * faire ». Le refus porte la sortie de l'outil, jamais une hypothèse.
59
60
  */
60
61
  function runGenerate({ cwd, configRel, name, label, regenerateCommand }) {
61
62
  const result = spawnSync(process.execPath, [
@@ -69,11 +70,8 @@ function runGenerate({ cwd, configRel, name, label, regenerateCommand }) {
69
70
  });
70
71
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
71
72
  if (result.status !== 0 || !generationHappened(output)) {
72
- if (isInteractivePromptFailure(output)) {
73
- const replay = regenerateCommand ?? `nodefony orm:generate --name ${name}`;
74
- throw new Error(`Un RENOMMAGE probable a été détecté sur ${label}, et il faut trancher.\n\n drizzle-kit ne peut pas deviner votre intention : une colonne qui\n disparaît et une autre qui apparaît, c'est soit un renommage — les\n données SUIVENT —, soit une suppression puis un ajout — les données\n sont PERDUES. Il pose donc la question, et il n'y a pas de terminal\n ici pour y répondre.\n\n Rejouer la commande dans un terminal interactif :\n ${replay}\n\n ⚠️ Après avoir répondu « renamed », RELIRE le fichier produit : quand\n une colonne est renommée ET que son type change, l'outil n'écrit que\n le renommage et OUBLIE le changement de type (drizzle-orm#3826).`);
75
- }
76
- throw new Error(`La génération n'a pas eu lieu sur ${label} (code ${result.status}). Ne rien conclure de ce silence : l'outil rend 0 même en échec.\n` + output.trim());
73
+ if (isInteractivePromptFailure(output)) throw new MigrationToolError(generationNeedsAnswer(label, regenerateCommand ?? `nodefony orm:generate --name ${name}`));
74
+ throw new MigrationToolError(generationFailed(label, result.status, output));
77
75
  }
78
76
  return output;
79
77
  }
@@ -95,7 +93,7 @@ function runGenerate({ cwd, configRel, name, label, regenerateCommand }) {
95
93
  * (configuration, chemin relatif à `cwd`), `label` (ce qui est cité en cas
96
94
  * d'échec).
97
95
  * @returns la sortie complète de l'outil.
98
- * @throws Error si le code est non nul.
96
+ * @throws MigrationToolError si le code est non nul.
99
97
  */
100
98
  function runIntrospect({ cwd, configRel, label }) {
101
99
  const result = spawnSync(process.execPath, [
@@ -107,7 +105,7 @@ function runIntrospect({ cwd, configRel, label }) {
107
105
  encoding: "utf8"
108
106
  });
109
107
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
110
- if (result.status !== 0) throw new Error(`La lecture du schéma de ${label} a échoué (code ${result.status}).\n` + output.trim());
108
+ if (result.status !== 0) throw new MigrationToolError(introspectFailed(label, result.status, output));
111
109
  return output;
112
110
  }
113
111
  /**
@@ -0,0 +1,89 @@
1
+ //#region nodefony/src/migrator/rebuildCopy.ts
2
+ /**
3
+ * Répare la recopie d'une recréation de table SQLite.
4
+ *
5
+ * 🔴 Le défaut que ce module ferme, mesuré sur deux applications fraîches :
6
+ * quand une migration doit à la fois MODIFIER une colonne et en AJOUTER une,
7
+ * SQLite ne sait pas altérer sur place — l'outil de génération produit alors la
8
+ * ronde connue (créer `__new_x`, y recopier les lignes de `x`, supprimer `x`,
9
+ * renommer). Sa recopie liste les colonnes de la table d'ARRIVÉE, la colonne
10
+ * neuve comprise, et va donc la LIRE dans la table de départ, où elle n'existe
11
+ * pas encore. La migration échoue sur `no such column` et laisse un marqueur
12
+ * qui bloque tous les passages suivants — l'utilisateur se retrouve devant
13
+ * trois commandes qui refusent, et la seule issue apparente est de détruire la
14
+ * base.
15
+ *
16
+ * La réparation est déterministe et sans jugement : une colonne que la table de
17
+ * départ ne porte pas ne peut pas être recopiée, donc elle sort des DEUX
18
+ * listes. Elle recevra ce que son `CREATE TABLE` lui donne — un défaut, ou
19
+ * `NULL`. C'est exactement ce qu'aurait fait un `ADD COLUMN`.
20
+ *
21
+ * ⚠️ On ne touche à RIEN dès qu'un élément de la recopie n'est pas une simple
22
+ * colonne citée — une expression, un appel de fonction, un alias — ni quand les
23
+ * colonnes de la table de départ sont inconnues. Réécrire ce qu'on n'a pas
24
+ * compris coûterait plus cher que le défaut qu'on répare.
25
+ */
26
+ /**
27
+ * La ronde de recréation, telle que l'outil de génération l'écrit.
28
+ *
29
+ * Tout est nommé : lire ces morceaux par POSITION obligerait à recompter à
30
+ * chaque retouche de l'expression, et une erreur y serait silencieuse — on
31
+ * réécrirait la mauvaise moitié de la recopie.
32
+ */
33
+ const REBUILD_COPY = /(?<head>INSERT\s+INTO\s+[`"']?__new_[A-Za-z0-9_]+[`"']?\s*\(\s*)(?<into>[^)]*?)(?<middle>\s*\)\s*SELECT\s+)(?<select>.*?)(?<tail>\s+FROM\s+[`"']?(?<source>[A-Za-z0-9_]+)[`"']?)/gis;
34
+ /** Une colonne citée, telle qu'elle apparaît dans les deux listes. */
35
+ const QUOTED_COLUMN = /^[`"']?(?<name>[A-Za-z0-9_]+)[`"']?$/u;
36
+ /** Découpe une liste de colonnes, en gardant l'écriture exacte de chacune. */
37
+ function splitColumns(list) {
38
+ return list.split(",").map((part) => part.trim()).filter((part) => part.length > 0);
39
+ }
40
+ /** Le nom nu d'une colonne citée, ou `null` si ce n'en est pas une. */
41
+ function bareName(token) {
42
+ return QUOTED_COLUMN.exec(token)?.groups?.name ?? null;
43
+ }
44
+ /**
45
+ * Retire de la recopie les colonnes que la table de départ ne porte pas.
46
+ *
47
+ * @param sql - le SQL de la migration, tel que l'outil vient de l'écrire.
48
+ * @param columnsOf - les colonnes d'une table AVANT cette migration ; `null`
49
+ * quand on ne les connaît pas — auquel cas rien n'est touché, car on ne
50
+ * réécrit jamais sur une supposition.
51
+ * @returns le SQL réparé et la liste de ce qui a été retiré.
52
+ */
53
+ function repairRebuildCopy(sql, columnsOf) {
54
+ const dropped = [];
55
+ return {
56
+ sql: sql.replace(REBUILD_COPY, (...args) => {
57
+ const match = args[0];
58
+ const groups = args[args.length - 1];
59
+ if (groups === void 0) return match;
60
+ const { head, into, middle, select, tail, source } = groups;
61
+ if (head === void 0 || into === void 0 || middle === void 0 || select === void 0 || tail === void 0 || source === void 0) return match;
62
+ const known = columnsOf(source);
63
+ if (known === null) return match;
64
+ const intoColumns = splitColumns(into);
65
+ const selectColumns = splitColumns(select);
66
+ if (intoColumns.length !== selectColumns.length) return match;
67
+ const carried = new Set(known);
68
+ const keptInto = [];
69
+ const keptSelect = [];
70
+ const removed = [];
71
+ for (let i = 0; i < intoColumns.length; i++) {
72
+ const target = bareName(intoColumns[i]);
73
+ const read = bareName(selectColumns[i]);
74
+ if (target === null || read === null) return match;
75
+ if (carried.has(read)) {
76
+ keptInto.push(intoColumns[i]);
77
+ keptSelect.push(selectColumns[i]);
78
+ } else removed.push(`${source}.${read}`);
79
+ }
80
+ if (removed.length === 0) return match;
81
+ if (keptInto.length === 0) return match;
82
+ dropped.push(...removed);
83
+ return `${head}${keptInto.join(", ")}${middle}${keptSelect.join(", ")}${tail}`;
84
+ }),
85
+ dropped
86
+ };
87
+ }
88
+ //#endregion
89
+ export { repairRebuildCopy };
@@ -41,6 +41,88 @@ function generationToolMissing() {
41
41
  };
42
42
  }
43
43
  /**
44
+ * Sortie d'un outil tiers, prête à être citée dans une explication.
45
+ *
46
+ * Elle est indentée pour se distinguer de la prose qui l'entoure, et BORNÉE par
47
+ * la fin : une pile d'appels se termine par ce qui a cassé, jamais par ce qui a
48
+ * démarré. La troncature s'ANNONCE — une sortie coupée en silence fait chercher
49
+ * une cause dans la moitié qu'on n'a pas montrée.
50
+ *
51
+ * @param output - sortie complète de l'outil (standard puis erreur).
52
+ * @param maxLines - nombre de lignes conservées, depuis la fin.
53
+ * @returns le bloc cité, ou une phrase disant qu'il n'y avait rien.
54
+ */
55
+ function formatToolOutput(output, maxLines = 40) {
56
+ const lines = output.split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 0);
57
+ if (lines.length === 0) return " (l'outil n'a rien écrit du tout)";
58
+ const kept = lines.slice(-maxLines);
59
+ return [...kept.length < lines.length ? [` […] ${lines.length - kept.length} ligne(s) plus haut, non citées`] : [], ...kept.map((line) => ` ${line}`)].join("\n");
60
+ }
61
+ /**
62
+ * L'outil de génération pose une QUESTION, et aucun terminal n'y répond.
63
+ *
64
+ * C'est le cas d'une colonne qui disparaît pendant qu'une autre apparaît :
65
+ * renommage (les données suivent) ou suppression puis ajout (les données sont
66
+ * perdues) — l'outil ne peut pas le deviner, et il a raison de demander.
67
+ *
68
+ * @param label - ce qui était généré, tel qu'on le cite à l'utilisateur.
69
+ * @param replay - la commande à rejouer dans un terminal interactif.
70
+ * @returns le refus, prêt pour la ligne de commande comme pour l'écran.
71
+ */
72
+ function generationNeedsAnswer(label, replay) {
73
+ return {
74
+ code: "NF_GENERATE_NEEDS_ANSWER",
75
+ summary: `Un RENOMMAGE probable a été détecté sur ${label}, et il faut trancher — rien n'a été écrit.`,
76
+ meaning: "Une colonne disparaît et une autre apparaît : c'est soit un renommage — les données SUIVENT —, soit une suppression puis un ajout — les données sont PERDUES. L'outil ne peut pas deviner l'intention, il pose donc la question, et il n'y a pas de terminal ici pour y répondre. La base n'est pas en cause : elle n'a même pas été interrogée. ⚠️ Après avoir répondu « renamed », RELIRE le fichier produit : quand une colonne est renommée ET que son type change, l'outil n'écrit que le renommage et oublie le changement de type (drizzle-orm#3826).",
77
+ nextActions: [action(replay)],
78
+ exitCode: 2
79
+ };
80
+ }
81
+ /**
82
+ * L'outil de génération s'est arrêté, et ce qu'il a dit est remonté TEL QUEL.
83
+ *
84
+ * 🔴 Ne JAMAIS remplacer sa sortie par une hypothèse. Le fourre-tout des
85
+ * commandes de migration explique tout par une base injoignable ou des droits
86
+ * manquants : sur un schéma qui retire une colonne, les deux sont FAUX, et ils
87
+ * envoient vérifier une base qui répond très bien pendant que la cause est dans
88
+ * le fichier d'entité qu'on vient d'éditer. Un message d'erreur est cru PARCE
89
+ * QU'il est précis.
90
+ *
91
+ * @param label - ce qui était généré, tel qu'on le cite à l'utilisateur.
92
+ * @param status - code de sortie observé (il vaut `0` même en échec).
93
+ * @param output - sortie complète de l'outil.
94
+ * @returns le refus, prêt pour la ligne de commande comme pour l'écran.
95
+ */
96
+ function generationFailed(label, status, output) {
97
+ return {
98
+ code: "NF_GENERATE_TOOL_FAILED",
99
+ summary: `La génération n'a pas eu lieu sur ${label} — rien n'a été écrit.`,
100
+ meaning: `Ne rien conclure du code de sortie : l'outil rend 0 même en échec (observé : ${status ?? "aucun"}). Ce qu'il a dit, mot pour mot :\n\n${formatToolOutput(output)}\n\nLa base n'est PAS en cause, et ce n'est pas une question de droits : écrire une migration ne l'interroge pas. La comparaison se fait entre les ENTITÉS déclarées et les instantanés des migrations déjà écrites — c'est donc du côté du schéma déclaré qu'il faut regarder.`,
101
+ nextActions: [action("nodefony inspect entities")],
102
+ exitCode: 2
103
+ };
104
+ }
105
+ /**
106
+ * La lecture du schéma d'une base existante a échoué.
107
+ *
108
+ * Contrairement à la génération, celle-ci INTERROGE la base : une base muette
109
+ * ou des droits insuffisants sont ici des explications légitimes.
110
+ *
111
+ * @param label - ce qui était lu, tel qu'on le cite à l'utilisateur.
112
+ * @param status - code de sortie observé.
113
+ * @param output - sortie complète de l'outil.
114
+ * @returns le refus, prêt pour la ligne de commande comme pour l'écran.
115
+ */
116
+ function introspectFailed(label, status, output) {
117
+ return {
118
+ code: "NF_INTROSPECT_FAILED",
119
+ summary: `La lecture du schéma de ${label} a échoué — rien n'a été écrit.`,
120
+ meaning: `Code de sortie : ${status ?? "aucun"}. Ce que l'outil a dit, mot pour mot :\n\n${formatToolOutput(output)}\n\nCette étape-ci, contrairement à la génération, INTERROGE la base : une base qui ne répond pas, ou un compte sans le droit de lire le catalogue, sont des explications plausibles — la sortie ci-dessus tranche.`,
121
+ nextActions: [action("nodefony inspect config --json")],
122
+ exitCode: 2
123
+ };
124
+ }
125
+ /**
44
126
  * Erreur portant un {@link IResolutionRefusal} déjà composé.
45
127
  *
46
128
  * Elle existe pour que la CAUSE porte son propre remède jusqu'à la sortie de la
@@ -140,4 +222,4 @@ function describeResolutionRefusal(wanted, resolution, config) {
140
222
  };
141
223
  }
142
224
  //#endregion
143
- export { MigrationToolError, describeResolutionRefusal, generationToolMissing, moduleAbsent, notConfigured };
225
+ export { MigrationToolError, describeResolutionRefusal, formatToolOutput, generationFailed, generationNeedsAnswer, generationToolMissing, introspectFailed, moduleAbsent, notConfigured };
@@ -3,6 +3,7 @@ import { OrmMigrateCommand, type IMigrateSharedOptions } from "./migrateShared.j
3
3
  /** Options propres à la remise à zéro d'une base de développement. */
4
4
  interface IResetOptions extends IMigrateSharedOptions {
5
5
  yes?: boolean;
6
+ dropAccounts?: boolean;
6
7
  }
7
8
  /**
8
9
  * `nodefony orm:reset` — vide la base de DÉVELOPPEMENT et la laisse prête à
@@ -41,10 +42,23 @@ interface IResetOptions extends IMigrateSharedOptions {
41
42
  * production : lui laisser désigner la cible d'un effacement serait offrir la
42
43
  * seule combinaison qu'il ne faut jamais rendre possible.
43
44
  *
45
+ * ## Ce que `--yes` ne suffit PAS à faire
46
+ *
47
+ * Une base qui porte des comptes, des passkeys ou des seconds facteurs n'est pas
48
+ * une base jetable, et `--yes` ne l'efface pas : la commande refuse, nomme
49
+ * chaque table avec son nombre de lignes, et demande `--drop-accounts` en plus.
50
+ * Vécu : un agent a lu un refus de migration qui proposait `orm:migrate:repair`,
51
+ * puis a tapé `orm:reset -y` — la seule voie qui détruit. Un refus bien rédigé
52
+ * ne suffit pas : la prose informe, seule la commande contraint.
53
+ *
54
+ * Une base VIDE n'est pas gênée, et c'est voulu : une garde qui se lève sur le
55
+ * cas normal s'apprend à être contournée avant le jour où elle a raison.
56
+ *
44
57
  * @example
45
58
  * ```bash
46
- * nodefony orm:reset # demande confirmation en terminal
47
- * nodefony orm:reset --yes # sans question (script)
59
+ * nodefony orm:reset # demande confirmation en terminal
60
+ * nodefony orm:reset --yes # sans question (script)
61
+ * nodefony orm:reset --yes --drop-accounts # ... comptes et passkeys compris
48
62
  * ```
49
63
  */
50
64
  declare class OrmReset extends OrmMigrateCommand {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tables dont la perte n'est PAS rattrapable — et le seuil à partir duquel elle
3
+ * ne l'est plus.
4
+ *
5
+ * ## Pourquoi une liste, et pas « toutes les tables »
6
+ *
7
+ * Vider une base de développement est un geste normal et fréquent : une garde
8
+ * qui crie à chaque fois s'apprend à être contournée, et le jour où elle aurait
9
+ * raison, personne ne la lira. Elle ne doit donc se lever que sur ce qui ne se
10
+ * reconstitue pas.
11
+ *
12
+ * ## Ce qui se reconstitue, et qui n'est donc PAS ici
13
+ *
14
+ * - une `session` se refait par un login ;
15
+ * - un `access_token` (jeton personnel, jeton de rafraîchissement) se réémet de
16
+ * la même façon, et il est de toute façon révocable et daté ;
17
+ * - un `audit_event` est une trace, un `idempotency_key` un cache.
18
+ *
19
+ * Une **passkey**, non : elle est liée à la puce de l'appareil, et personne ne
20
+ * peut la réémettre à la place de son porteur. Un **second facteur** non plus :
21
+ * il faut re-scanner un code. Un **compte**, cela dépend — voir ci-dessous.
22
+ *
23
+ * ## Le seuil : ce qu'un semis d'application repose tout seul
24
+ *
25
+ * Une application générée sème son compte d'administration à chaque démarrage
26
+ * (`nodefony/security/provisionUsers.ts`). Ce compte-là n'est pas perdu quand on
27
+ * vide la base : il revient au prochain boot. Compter `User` dès la première
28
+ * ligne ferait donc crier la garde sur TOUTE application fraîche — exactement le
29
+ * cas normal qu'il ne faut pas gêner. Au-delà, en revanche, quelqu'un a
30
+ * travaillé : un compte créé à la main n'a aucune source qui le repose.
31
+ *
32
+ * Vécu, et c'est la mesure qui fixe le seuil : l'accident a emporté **trois**
33
+ * comptes, dont deux créés à la main.
34
+ *
35
+ * ## Pourquoi une liste écrite ici
36
+ *
37
+ * Le cœur tient déjà la table des entités du framework
38
+ * (`cli/scaffold/reservedEntities.ts`), mais elle n'est pas publiée par le
39
+ * paquet `nodefony` et elle répond à une autre question — « ce nom est-il
40
+ * libre ? », pas « cette table porte-t-elle de l'irremplaçable ? ». La frontière
41
+ * de paquets rend la copie inévitable ; elle n'est pas laissée à la bonne
42
+ * volonté : `identityTables.test.ts` confronte chaque nom à celui du cœur, et
43
+ * tombe si l'un disparaît ou change d'orthographe.
44
+ *
45
+ * ⚠️ `User` appartient à l'APPLICATION (le cœur le marque `appOwned`) : c'est
46
+ * elle qui en porte le schéma et les migrations. Son nom est néanmoins celui que
47
+ * le framework lit partout, donc celui qu'on retrouve en base.
48
+ */
49
+ /** Une table d'identité : son nom en base, ce qu'elle porte, et son seuil. */
50
+ export interface IIdentityTable {
51
+ /** Nom tel qu'il apparaît dans le catalogue de la base. */
52
+ readonly name: string;
53
+ /** Ce qui disparaît avec elle, dit à l'utilisateur. */
54
+ readonly holds: string;
55
+ /**
56
+ * Nombre de lignes qu'un semis d'application repose au prochain démarrage.
57
+ *
58
+ * En deçà ou à égalité, il n'y a rien à perdre et la garde se tait. `0` =
59
+ * aucune ligne ne se repose, la première compte.
60
+ */
61
+ readonly reseededRows: number;
62
+ }
63
+ export declare const IDENTITY_TABLES: readonly IIdentityTable[];
64
+ /** Une table d'identité trouvée en base, avec son nom RÉEL et son seuil. */
65
+ export interface IIdentityTableFound {
66
+ /** Nom tel qu'il est réellement écrit dans le catalogue. */
67
+ readonly table: string;
68
+ /** Ce qui disparaît avec elle. */
69
+ readonly holds: string;
70
+ /** Lignes qu'un semis repose (cf {@link IIdentityTable.reseededRows}). */
71
+ readonly reseededRows: number;
72
+ }
73
+ /**
74
+ * Les tables d'identité PRÉSENTES parmi celles qu'on s'apprête à supprimer.
75
+ *
76
+ * La comparaison ignore la casse : sqlite conserve `User` tel quel, d'autres
77
+ * moteurs replient en minuscules, et une garde sensible à la casse serait
78
+ * inopérante sur la moitié des dialectes — c'est-à-dire exactement là où l'on
79
+ * croirait être protégé.
80
+ *
81
+ * @param tables - noms lus dans le catalogue de la base.
82
+ * @returns les entrées d'identité correspondantes, avec le nom RÉEL de la table.
83
+ */
84
+ export declare function identityTablesAmong(tables: readonly string[]): IIdentityTableFound[];
85
+ /**
86
+ * Y a-t-il quelque chose à PERDRE dans cette table ?
87
+ *
88
+ * @param found - table d'identité présente en base.
89
+ * @param rows - nombre de lignes constaté.
90
+ * @returns `true` si la perte dépasse ce qu'un semis repose.
91
+ */
92
+ export declare function rowsWorthKeeping(found: IIdentityTableFound, rows: number): boolean;
@@ -46,8 +46,9 @@ export declare function resolveDrizzleKitBin(from: string): string;
46
46
  * relatif à `cwd`), `name` (nom imposé de la migration), `label` (ce qui est
47
47
  * cité dans l'erreur).
48
48
  * @returns la sortie complète de l'outil (sortie standard puis sortie d'erreur).
49
- * @throws Error si le code est non nul, ou si rien ne prouve que la génération a
50
- * eu lieu — l'absence de preuve n'est JAMAIS lue comme « rien à faire ».
49
+ * @throws MigrationToolError si le code est non nul, ou si rien ne prouve que la
50
+ * génération a eu lieu — l'absence de preuve n'est JAMAIS lue comme « rien à
51
+ * faire ». Le refus porte la sortie de l'outil, jamais une hypothèse.
51
52
  */
52
53
  export declare function runGenerate({ cwd, configRel, name, label, regenerateCommand, }: {
53
54
  cwd: string;
@@ -75,7 +76,7 @@ export declare function runGenerate({ cwd, configRel, name, label, regenerateCom
75
76
  * (configuration, chemin relatif à `cwd`), `label` (ce qui est cité en cas
76
77
  * d'échec).
77
78
  * @returns la sortie complète de l'outil.
78
- * @throws Error si le code est non nul.
79
+ * @throws MigrationToolError si le code est non nul.
79
80
  */
80
81
  export declare function runIntrospect({ cwd, configRel, label, }: {
81
82
  cwd: string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Répare la recopie d'une recréation de table SQLite.
3
+ *
4
+ * 🔴 Le défaut que ce module ferme, mesuré sur deux applications fraîches :
5
+ * quand une migration doit à la fois MODIFIER une colonne et en AJOUTER une,
6
+ * SQLite ne sait pas altérer sur place — l'outil de génération produit alors la
7
+ * ronde connue (créer `__new_x`, y recopier les lignes de `x`, supprimer `x`,
8
+ * renommer). Sa recopie liste les colonnes de la table d'ARRIVÉE, la colonne
9
+ * neuve comprise, et va donc la LIRE dans la table de départ, où elle n'existe
10
+ * pas encore. La migration échoue sur `no such column` et laisse un marqueur
11
+ * qui bloque tous les passages suivants — l'utilisateur se retrouve devant
12
+ * trois commandes qui refusent, et la seule issue apparente est de détruire la
13
+ * base.
14
+ *
15
+ * La réparation est déterministe et sans jugement : une colonne que la table de
16
+ * départ ne porte pas ne peut pas être recopiée, donc elle sort des DEUX
17
+ * listes. Elle recevra ce que son `CREATE TABLE` lui donne — un défaut, ou
18
+ * `NULL`. C'est exactement ce qu'aurait fait un `ADD COLUMN`.
19
+ *
20
+ * ⚠️ On ne touche à RIEN dès qu'un élément de la recopie n'est pas une simple
21
+ * colonne citée — une expression, un appel de fonction, un alias — ni quand les
22
+ * colonnes de la table de départ sont inconnues. Réécrire ce qu'on n'a pas
23
+ * compris coûterait plus cher que le défaut qu'on répare.
24
+ */
25
+ /** Ce que la réparation a changé, pour pouvoir le DIRE plutôt que le taire. */
26
+ export interface IRebuildCopyRepair {
27
+ /** Le SQL, réparé si besoin ; identique à l'entrée sinon. */
28
+ readonly sql: string;
29
+ /** Les colonnes retirées de la recopie, en `table.colonne`. */
30
+ readonly dropped: readonly string[];
31
+ }
32
+ /**
33
+ * Retire de la recopie les colonnes que la table de départ ne porte pas.
34
+ *
35
+ * @param sql - le SQL de la migration, tel que l'outil vient de l'écrire.
36
+ * @param columnsOf - les colonnes d'une table AVANT cette migration ; `null`
37
+ * quand on ne les connaît pas — auquel cas rien n'est touché, car on ne
38
+ * réécrit jamais sur une supposition.
39
+ * @returns le SQL réparé et la liste de ce qui a été retiré.
40
+ */
41
+ export declare function repairRebuildCopy(sql: string, columnsOf: (table: string) => readonly string[] | null): IRebuildCopyRepair;
@@ -17,6 +17,8 @@ export type CommandFailureCode =
17
17
  | "NF_MIGRATE_UNAVAILABLE"
18
18
  /** Confirmation requise et non donnée. */
19
19
  | "NF_MIGRATE_CONFIRM_REQUIRED"
20
+ /** La base porte des comptes : `--yes` ne suffit pas à les effacer. */
21
+ | "NF_MIGRATE_RESET_HAS_ACCOUNTS"
20
22
  /** Des migrations en attente SUPPRIMENT des données, hors développement. */
21
23
  | "NF_MIGRATE_DESTRUCTIVE"
22
24
  /** Adopter TOUT graverait une affirmation fausse : la base ne suit pas. */
@@ -33,6 +35,12 @@ export type CommandFailureCode =
33
35
  | "NF_GENERATE_DATABASE_BEHIND"
34
36
  /** L'outil qui ÉCRIT les migrations n'est pas installé. */
35
37
  | "NF_GENERATE_TOOL_MISSING"
38
+ /** L'outil de génération pose une question, et il n'y a pas de terminal. */
39
+ | "NF_GENERATE_NEEDS_ANSWER"
40
+ /** L'outil de génération s'est arrêté ; ce qu'il a dit est remonté tel quel. */
41
+ | "NF_GENERATE_TOOL_FAILED"
42
+ /** La lecture du schéma d'une base existante a échoué. */
43
+ | "NF_INTROSPECT_FAILED"
36
44
  /** Le schéma initial serait écrit sur une base qui porte DÉJÀ ces tables. */
37
45
  | "NF_GENERATE_DATABASE_NOT_ADOPTED"
38
46
  /** L'adoption par lecture de la base, demandée alors qu'il existe déjà des migrations. */
@@ -124,6 +132,59 @@ export interface IResolutionRefusal {
124
132
  * @returns le refus, avec le geste qui répare.
125
133
  */
126
134
  export declare function generationToolMissing(): IResolutionRefusal;
135
+ /**
136
+ * Sortie d'un outil tiers, prête à être citée dans une explication.
137
+ *
138
+ * Elle est indentée pour se distinguer de la prose qui l'entoure, et BORNÉE par
139
+ * la fin : une pile d'appels se termine par ce qui a cassé, jamais par ce qui a
140
+ * démarré. La troncature s'ANNONCE — une sortie coupée en silence fait chercher
141
+ * une cause dans la moitié qu'on n'a pas montrée.
142
+ *
143
+ * @param output - sortie complète de l'outil (standard puis erreur).
144
+ * @param maxLines - nombre de lignes conservées, depuis la fin.
145
+ * @returns le bloc cité, ou une phrase disant qu'il n'y avait rien.
146
+ */
147
+ export declare function formatToolOutput(output: string, maxLines?: number): string;
148
+ /**
149
+ * L'outil de génération pose une QUESTION, et aucun terminal n'y répond.
150
+ *
151
+ * C'est le cas d'une colonne qui disparaît pendant qu'une autre apparaît :
152
+ * renommage (les données suivent) ou suppression puis ajout (les données sont
153
+ * perdues) — l'outil ne peut pas le deviner, et il a raison de demander.
154
+ *
155
+ * @param label - ce qui était généré, tel qu'on le cite à l'utilisateur.
156
+ * @param replay - la commande à rejouer dans un terminal interactif.
157
+ * @returns le refus, prêt pour la ligne de commande comme pour l'écran.
158
+ */
159
+ export declare function generationNeedsAnswer(label: string, replay: string): IResolutionRefusal;
160
+ /**
161
+ * L'outil de génération s'est arrêté, et ce qu'il a dit est remonté TEL QUEL.
162
+ *
163
+ * 🔴 Ne JAMAIS remplacer sa sortie par une hypothèse. Le fourre-tout des
164
+ * commandes de migration explique tout par une base injoignable ou des droits
165
+ * manquants : sur un schéma qui retire une colonne, les deux sont FAUX, et ils
166
+ * envoient vérifier une base qui répond très bien pendant que la cause est dans
167
+ * le fichier d'entité qu'on vient d'éditer. Un message d'erreur est cru PARCE
168
+ * QU'il est précis.
169
+ *
170
+ * @param label - ce qui était généré, tel qu'on le cite à l'utilisateur.
171
+ * @param status - code de sortie observé (il vaut `0` même en échec).
172
+ * @param output - sortie complète de l'outil.
173
+ * @returns le refus, prêt pour la ligne de commande comme pour l'écran.
174
+ */
175
+ export declare function generationFailed(label: string, status: number | null, output: string): IResolutionRefusal;
176
+ /**
177
+ * La lecture du schéma d'une base existante a échoué.
178
+ *
179
+ * Contrairement à la génération, celle-ci INTERROGE la base : une base muette
180
+ * ou des droits insuffisants sont ici des explications légitimes.
181
+ *
182
+ * @param label - ce qui était lu, tel qu'on le cite à l'utilisateur.
183
+ * @param status - code de sortie observé.
184
+ * @param output - sortie complète de l'outil.
185
+ * @returns le refus, prêt pour la ligne de commande comme pour l'écran.
186
+ */
187
+ export declare function introspectFailed(label: string, status: number | null, output: string): IResolutionRefusal;
127
188
  /**
128
189
  * Erreur portant un {@link IResolutionRefusal} déjà composé.
129
190
  *
@@ -106,6 +106,9 @@ nodefony orm:migrate:repair
106
106
 
107
107
  # Développement seulement : supprime et recrée la base du connecteur.
108
108
  nodefony orm:reset
109
+
110
+ # … et si la base porte des comptes, des passkeys ou des seconds facteurs :
111
+ nodefony orm:reset --yes --drop-accounts
109
112
  ```
110
113
 
111
114
  Toutes acceptent `--connector <nom>` (défaut : `default`) et `--json`. Le flux `--json` est **pur** :
@@ -508,6 +511,23 @@ conforme, la clé est ABSENTE** (jamais un objet vide) : `.divergence == null` s
508
511
  L'écran lisible en dit autant : le résumé nomme les trois premières entrées de chaque famille, et la
509
512
  liste complète ne se déroule que lorsqu'elle ne tient plus dans la phrase.
510
513
 
514
+ ### Ce que `--yes` ne suffit pas à effacer
515
+
516
+ `orm:reset` **refuse** sur une base qui porte de l'irremplaçable, et nomme ce qu'elle allait
517
+ supprimer avec le nombre de lignes : des comptes au-delà de celui que votre semis repose, des
518
+ passkeys (liées à l'appareil — personne ne peut les réémettre), des seconds facteurs. Il faut alors
519
+ `--drop-accounts` en plus de `--yes` : le drapeau dit que vous avez vu la liste.
520
+
521
+ Ce qui se reconstitue ne déclenche rien — une session et un jeton se refont par un login, une trace
522
+ d'audit est une trace. Et **une application fraîche n'est pas gênée** : le compte d'administration
523
+ que son semis repose à chaque démarrage ne compte pas comme une perte. Une garde qui se lève sur le
524
+ cas normal s'apprend à être contournée avant le jour où elle a raison.
525
+
526
+ Quand une migration a échoué et que son marqueur est posé, le refus nomme d'abord
527
+ `orm:migrate:repair` — qui lève le marqueur **sans rien effacer**. C'est le seul moment où l'on sait
528
+ que la voie non destructrice s'applique, et c'est exactement la situation où l'on est tenté de tout
529
+ remettre à zéro.
530
+
511
531
  Les gestes proposés suivent **l'environnement** : `orm:reset` efface, elle n'est acceptée qu'en
512
532
  développement, et elle n'est donc proposée que là. Ailleurs, la sortie renvoie vers l'écriture d'une
513
533
  migration correctrice (`orm:generate --custom`) puis son application.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/drizzle",
3
- "version": "10.0.0-alpha.5",
3
+ "version": "10.0.0-alpha.6",
4
4
  "description": "Moteur SQL de Nodefony sur Drizzle ORM (SQLite, PostgreSQL, MySQL) : dépôts typés, migrations de schéma et adoption d'une base existante",
5
5
  "nodefony": {
6
6
  "storeKind": "durable",
@@ -60,30 +60,30 @@
60
60
  "zod": "^4.6.1"
61
61
  },
62
62
  "devDependencies": {
63
- "@nodefony/framework": "^10.0.0-alpha.5",
64
- "@nodefony/http": "^10.0.0-alpha.5",
65
- "@nodefony/orm-core": "^10.0.0-alpha.5",
66
- "@nodefony/security": "^10.0.0-alpha.5",
67
- "@nodefony/user": "^10.0.0-alpha.5",
63
+ "@nodefony/framework": "^10.0.0-alpha.6",
64
+ "@nodefony/http": "^10.0.0-alpha.6",
65
+ "@nodefony/orm-core": "^10.0.0-alpha.6",
66
+ "@nodefony/security": "^10.0.0-alpha.6",
67
+ "@nodefony/user": "^10.0.0-alpha.6",
68
68
  "@types/better-sqlite3": "9.6.0",
69
69
  "@types/node": "26.5.1",
70
70
  "@types/pg": "8.23.1",
71
71
  "better-sqlite3": "13.0.3",
72
72
  "drizzle-kit": "0.31.10",
73
73
  "mysql2": "3.24.4",
74
- "nodefony": "^10.0.0-alpha.5",
74
+ "nodefony": "^10.0.0-alpha.6",
75
75
  "pg": "8.23.0",
76
76
  "rimraf": "6.1.3"
77
77
  },
78
78
  "peerDependencies": {
79
- "@nodefony/framework": "^10.0.0-alpha.5",
80
- "@nodefony/http": "^10.0.0-alpha.5",
81
- "@nodefony/orm-core": "^10.0.0-alpha.5",
82
- "@nodefony/security": "^10.0.0-alpha.5",
83
- "@nodefony/user": "^10.0.0-alpha.5",
79
+ "@nodefony/framework": "^10.0.0-alpha.6",
80
+ "@nodefony/http": "^10.0.0-alpha.6",
81
+ "@nodefony/orm-core": "^10.0.0-alpha.6",
82
+ "@nodefony/security": "^10.0.0-alpha.6",
83
+ "@nodefony/user": "^10.0.0-alpha.6",
84
84
  "better-sqlite3": "^13.0.0",
85
85
  "mysql2": "^3.24.0",
86
- "nodefony": "^10.0.0-alpha.5",
86
+ "nodefony": "^10.0.0-alpha.6",
87
87
  "pg": "^8.23.0"
88
88
  },
89
89
  "repository": {