@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.
- package/dist/nodefony/command/orm-generate.js +45 -1
- package/dist/nodefony/command/orm-reset.js +39 -2
- package/dist/nodefony/service/DrizzleService.js +9 -3
- package/dist/nodefony/src/migrator/identityTables.js +53 -0
- package/dist/nodefony/src/migrator/kit.js +8 -10
- package/dist/nodefony/src/migrator/rebuildCopy.js +89 -0
- package/dist/nodefony/src/migrator/refusals.js +83 -1
- package/dist/types/nodefony/command/orm-reset.d.ts +16 -2
- package/dist/types/nodefony/src/migrator/identityTables.d.ts +92 -0
- package/dist/types/nodefony/src/migrator/kit.d.ts +4 -3
- package/dist/types/nodefony/src/migrator/rebuildCopy.d.ts +41 -0
- package/dist/types/nodefony/src/migrator/refusals.d.ts +61 -0
- package/docs/migrations.md +20 -0
- package/package.json +13 -13
|
@@ -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
|
-
|
|
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
|
|
68
|
-
* nodefony orm:reset --yes
|
|
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
|
|
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
|
|
58
|
-
* eu lieu — l'absence de preuve n'est JAMAIS lue comme « rien à
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
47
|
-
* nodefony orm:reset --yes
|
|
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
|
|
50
|
-
* eu lieu — l'absence de preuve n'est JAMAIS lue comme « rien à
|
|
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
|
|
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
|
*
|
package/docs/migrations.md
CHANGED
|
@@ -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.
|
|
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.
|
|
64
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
65
|
-
"@nodefony/orm-core": "^10.0.0-alpha.
|
|
66
|
-
"@nodefony/security": "^10.0.0-alpha.
|
|
67
|
-
"@nodefony/user": "^10.0.0-alpha.
|
|
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.
|
|
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.
|
|
80
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
81
|
-
"@nodefony/orm-core": "^10.0.0-alpha.
|
|
82
|
-
"@nodefony/security": "^10.0.0-alpha.
|
|
83
|
-
"@nodefony/user": "^10.0.0-alpha.
|
|
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.
|
|
86
|
+
"nodefony": "^10.0.0-alpha.6",
|
|
87
87
|
"pg": "^8.23.0"
|
|
88
88
|
},
|
|
89
89
|
"repository": {
|