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

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.
@@ -5,6 +5,7 @@ import { describeTargetSafely } from "../safeTarget.js";
5
5
  import { failureFrom } from "../migrator/status.js";
6
6
  import { DrizzleRepository } from "./DrizzleRepository.js";
7
7
  import { DrizzleTransaction } from "./DrizzleTransaction.js";
8
+ import { explainOmittedForeignKeys, planTableCreation } from "./ddlPlan.js";
8
9
  import { createRequire } from "node:module";
9
10
  import { CONNECT_TIMEOUT_MS, Orm, entityRegistry, parseConnectionTarget, withConnectDeadline } from "@nodefony/orm-core";
10
11
  import fs from "node:fs";
@@ -15,6 +16,32 @@ import { PgDialect, PgTable, getTableConfig as getTableConfig$1 } from "drizzle-
15
16
  import { MySqlDialect, MySqlTable, getTableConfig as getTableConfig$2 } from "drizzle-orm/mysql-core";
16
17
  //#region nodefony/src/orm-core/DrizzleOrm.ts
17
18
  /**
19
+ * Traduit les clés étrangères d'une table Drizzle en noms SQL émettables.
20
+ *
21
+ * @param foreignKeys - contraintes lues sur la table (`getTableConfig`).
22
+ * @param nameOf - comment lire le nom SQL d'une table du dialecte courant.
23
+ * @param skip - tables visées dont la contrainte doit être ABANDONNÉE (cycle ou
24
+ * cible absente de cette base) ; l'appelant les a déjà journalisées.
25
+ * @returns les contraintes à écrire dans le `CREATE TABLE`.
26
+ */
27
+ function resolveForeignKeys(foreignKeys, nameOf, skip) {
28
+ const resolved = [];
29
+ for (const fk of foreignKeys) {
30
+ const reference = fk.reference();
31
+ const foreignTable = nameOf(reference.foreignTable);
32
+ if (skip?.has(foreignTable)) continue;
33
+ resolved.push({
34
+ name: fk.getName(),
35
+ columns: reference.columns.map((column) => column.name),
36
+ foreignTable,
37
+ foreignColumns: reference.foreignColumns.map((column) => column.name),
38
+ onDelete: fk.onDelete,
39
+ onUpdate: fk.onUpdate
40
+ });
41
+ }
42
+ return resolved;
43
+ }
44
+ /**
18
45
  * Rend le prédicat d'une contrainte `CHECK` en SQL littéral, avec la grammaire
19
46
  * du dialecte demandé.
20
47
  *
@@ -245,7 +272,7 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
245
272
  * `"…"` (SQL standard, SQLite/PG) vs backtick MySQL (qui ne lit `"…"` qu'en
246
273
  * mode ANSI_QUOTES, jamais garanti). La prod reste pilotée par drizzle-kit.
247
274
  */
248
- #buildCreateTable(name, columns, quote = "\"", checks = [], dialect = "sqlite") {
275
+ #buildCreateTable(name, columns, quote = "\"", checks = [], dialect = "sqlite", foreignKeys = []) {
249
276
  const defs = columns.map((col) => {
250
277
  const parts = [`${quote}${col.name}${quote}`, col.getSQLType()];
251
278
  if (col.primary) parts.push("PRIMARY KEY");
@@ -258,6 +285,12 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
258
285
  if (predicate === null) continue;
259
286
  defs.push(`CONSTRAINT ${quote}${check.name}${quote} CHECK (${predicate})`);
260
287
  }
288
+ for (const fk of foreignKeys) {
289
+ const source = fk.columns.map((c) => `${quote}${c}${quote}`).join(", ");
290
+ const target = fk.foreignColumns.map((c) => `${quote}${c}${quote}`).join(", ");
291
+ const actions = [fk.onDelete ? ` ON DELETE ${fk.onDelete.toUpperCase()}` : "", fk.onUpdate ? ` ON UPDATE ${fk.onUpdate.toUpperCase()}` : ""].join("");
292
+ defs.push(`CONSTRAINT ${quote}${fk.name}${quote} FOREIGN KEY (${source}) REFERENCES ${quote}${fk.foreignTable}${quote} (${target})${actions}`);
293
+ }
261
294
  return `CREATE TABLE IF NOT EXISTS ${quote}${name}${quote} (${defs.join(", ")})`;
262
295
  }
263
296
  /**
@@ -273,11 +306,11 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
273
306
  * pas. Émis à part, il arrive AUSSI sur une base de développement déjà créée —
274
307
  * ce qu'aucune clause de table ne permettrait.
275
308
  *
276
- * Les clés étrangères, elles, ne sont toujours PAS émises : elles se déclarent
277
- * DANS le `CREATE TABLE`, donc elles n'atteindraient jamais une base existante,
278
- * et elles imposeraient de créer les tables dans l'ordre de leurs dépendances
279
- * (indécidable sur un cycle). C'est le travail du DDL de production
280
- * (drizzle-kit), pas d'un dérivé de développement.
309
+ * Les clés étrangères, elles, ne peuvent PAS être émises à part : elles se
310
+ * déclarent DANS le `CREATE TABLE` (cf `#buildCreateTable`), donc elles
311
+ * n'atteignent jamais une base déjà créée — c'est le prix assumé, le même que
312
+ * pour les `CHECK`. Et elles imposent de créer les tables dans l'ordre de
313
+ * leurs dépendances : cf `#planCreation`, qui porte aussi le cas du cycle.
281
314
  *
282
315
  * @param table - nom de la table portant les index.
283
316
  * @param indexes - index déclarés (`getTableConfig(...).indexes`).
@@ -295,10 +328,43 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
295
328
  }
296
329
  return statements;
297
330
  }
331
+ /**
332
+ * Ordonne les tables pour que chaque cible naisse avant qui la désigne, et dit
333
+ * ce qu'il a fallu abandonner.
334
+ *
335
+ * Le tri lui-même vit à part ({@link planTableCreation}, module pur) ; cette
336
+ * méthode ne fait que lire les tables du dialecte courant et JOURNALISER.
337
+ * L'avertissement compte autant que le tri : une contrainte d'intégrité qui
338
+ * n'est pas posée est une garantie qu'on croit avoir.
339
+ *
340
+ * @param entities - les entités de ce connecteur.
341
+ * @param describe - comment lire le nom d'une table et ses cibles.
342
+ * @returns les entités dans l'ordre de création, et les cibles à ignorer par table.
343
+ */
344
+ #planCreation(entities, describe) {
345
+ const described = entities.map((entity) => ({
346
+ entity,
347
+ ...describe(entity)
348
+ }));
349
+ const plan = planTableCreation(described);
350
+ const skip = /* @__PURE__ */ new Map();
351
+ for (const entry of plan.omitted) {
352
+ const targets = skip.get(entry.table) ?? /* @__PURE__ */ new Set();
353
+ targets.add(entry.target);
354
+ skip.set(entry.table, targets);
355
+ }
356
+ const message = explainOmittedForeignKeys(plan.omitted);
357
+ if (message) this.log(message, "WARNING");
358
+ const rank = new Map(plan.order.map((name, index) => [name, index]));
359
+ return {
360
+ ordered: [...described].sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0)).map((entry) => entry.entity),
361
+ skip
362
+ };
363
+ }
298
364
  /** Dérive le `CREATE TABLE` SQLite depuis la table Drizzle (dev/test). */
299
- #createTableSQL(table) {
300
- const { name, columns, checks } = getTableConfig(table);
301
- return this.#buildCreateTable(name, columns, "\"", checks, "sqlite");
365
+ #createTableSQL(table, skip) {
366
+ const { name, columns, checks, foreignKeys } = getTableConfig(table);
367
+ return this.#buildCreateTable(name, columns, "\"", checks, "sqlite", resolveForeignKeys(foreignKeys, (target) => getTableConfig(target).name, skip));
302
368
  }
303
369
  /** Dérive les `CREATE INDEX` SQLite depuis la table Drizzle (dev/test). */
304
370
  #createIndexesSQL(table) {
@@ -306,9 +372,9 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
306
372
  return this.#buildCreateIndexes(name, indexes);
307
373
  }
308
374
  /** Dérive le `CREATE TABLE` Postgres depuis la table Drizzle (dev/test). */
309
- #createTablePgSQL(table) {
310
- const { name, columns, checks } = getTableConfig$1(table);
311
- return this.#buildCreateTable(name, columns, "\"", checks, "postgres");
375
+ #createTablePgSQL(table, skip) {
376
+ const { name, columns, checks, foreignKeys } = getTableConfig$1(table);
377
+ return this.#buildCreateTable(name, columns, "\"", checks, "postgres", resolveForeignKeys(foreignKeys, (target) => getTableConfig$1(target).name, skip));
312
378
  }
313
379
  /** Dérive les `CREATE INDEX` Postgres depuis la table Drizzle (dev/test). */
314
380
  #createIndexesPgSQL(table) {
@@ -316,9 +382,9 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
316
382
  return this.#buildCreateIndexes(name, indexes);
317
383
  }
318
384
  /** Dérive le `CREATE TABLE` MySQL depuis la table Drizzle (dev/test). */
319
- #createTableMysqlSQL(table) {
320
- const { name, columns, checks } = getTableConfig$2(table);
321
- return this.#buildCreateTable(name, columns, "`", checks, "mysql");
385
+ #createTableMysqlSQL(table, skip) {
386
+ const { name, columns, checks, foreignKeys } = getTableConfig$2(table);
387
+ return this.#buildCreateTable(name, columns, "`", checks, "mysql", resolveForeignKeys(foreignKeys, (target) => getTableConfig$2(target).name, skip));
322
388
  }
323
389
  /**
324
390
  * Dérive les `CREATE INDEX` MySQL depuis la table Drizzle (dev/test).
@@ -411,6 +477,7 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
411
477
  client.pragma("journal_mode = WAL");
412
478
  client.pragma("synchronous = NORMAL");
413
479
  }
480
+ client.pragma("foreign_keys = ON");
414
481
  this.#client = client;
415
482
  const db = sqliteDrizzle(client);
416
483
  this.#db = db;
@@ -440,8 +507,19 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
440
507
  const table = entity.schema;
441
508
  this.#tables[entity.name] = table;
442
509
  entity.model = table;
443
- if (!this.#deriveSchema) continue;
444
- client.exec(this.#createTableSQL(table));
510
+ }
511
+ if (!this.#deriveSchema) return;
512
+ const plan = this.#planCreation(entities, (entity) => {
513
+ const config = getTableConfig(entity.schema);
514
+ return {
515
+ name: config.name,
516
+ references: config.foreignKeys.map((fk) => getTableConfig(fk.reference().foreignTable).name)
517
+ };
518
+ });
519
+ for (const entity of plan.ordered) {
520
+ const table = entity.schema;
521
+ const name = getTableConfig(table).name;
522
+ client.exec(this.#createTableSQL(table, plan.skip.get(name)));
445
523
  }
446
524
  }
447
525
  /**
@@ -671,8 +749,19 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
671
749
  const table = entity.schema;
672
750
  this.#tables[entity.name] = table;
673
751
  entity.model = table;
674
- if (!this.#deriveSchema) continue;
675
- await pool.query(this.#createTablePgSQL(table));
752
+ }
753
+ if (!this.#deriveSchema) return;
754
+ const plan = this.#planCreation(entities, (entity) => {
755
+ const config = getTableConfig$1(entity.schema);
756
+ return {
757
+ name: config.name,
758
+ references: config.foreignKeys.map((fk) => getTableConfig$1(fk.reference().foreignTable).name)
759
+ };
760
+ });
761
+ for (const entity of plan.ordered) {
762
+ const table = entity.schema;
763
+ const name = getTableConfig$1(table).name;
764
+ await pool.query(this.#createTablePgSQL(table, plan.skip.get(name)));
676
765
  }
677
766
  }
678
767
  /**
@@ -849,8 +938,19 @@ var DrizzleOrm = class DrizzleOrm extends Orm {
849
938
  const table = entity.schema;
850
939
  this.#tables[entity.name] = table;
851
940
  entity.model = table;
852
- if (!this.#deriveSchema) continue;
853
- await pool.query(this.#createTableMysqlSQL(table));
941
+ }
942
+ if (!this.#deriveSchema) return;
943
+ const plan = this.#planCreation(entities, (entity) => {
944
+ const config = getTableConfig$2(entity.schema);
945
+ return {
946
+ name: config.name,
947
+ references: config.foreignKeys.map((fk) => getTableConfig$2(fk.reference().foreignTable).name)
948
+ };
949
+ });
950
+ for (const entity of plan.ordered) {
951
+ const table = entity.schema;
952
+ const name = getTableConfig$2(table).name;
953
+ await pool.query(this.#createTableMysqlSQL(table, plan.skip.get(name)));
854
954
  }
855
955
  }
856
956
  async disconnect() {
@@ -0,0 +1,95 @@
1
+ //#region nodefony/src/orm-core/ddlPlan.ts
2
+ /**
3
+ * Trie les tables pour que chaque cible naisse avant qui la désigne.
4
+ *
5
+ * Tri topologique (Kahn) sur le graphe des dépendances, avec deux écarts
6
+ * assumés au manuel :
7
+ *
8
+ * - **l'auto-référence ne compte pas** (`parent:ref:Category` dans `Category`) :
9
+ * les trois moteurs acceptent une contrainte qui vise la table en cours de
10
+ * création, dans le même `CREATE TABLE` ;
11
+ * - **un cycle ne fait pas échouer le démarrage**. Il n'y a aucun ordre correct,
12
+ * mais refuser de démarrer pour ça serait hors de proportion : on crée les
13
+ * tables du cycle dans un ordre stable, et on OMET les contraintes qui
14
+ * regardent en arrière. Elles sont rendues à l'appelant pour qu'il les dise —
15
+ * une garantie qui disparaît sans un mot est pire que son absence.
16
+ *
17
+ * À ordre de sortie stable : les tables prêtes sont prises par ordre
18
+ * alphabétique, jamais dans l'ordre d'arrivée. Deux démarrages de la même
19
+ * application produisent donc le même DDL, ce qui rend un journal comparable.
20
+ *
21
+ * @param tables - les tables du connecteur et leurs cibles.
22
+ * @returns l'ordre de création et les contraintes abandonnées.
23
+ */
24
+ function planTableCreation(tables) {
25
+ const known = new Set(tables.map((table) => table.name));
26
+ const omitted = [];
27
+ /** Dépendances RÉELLES : cible connue, et différente de la table elle-même. */
28
+ const dependencies = /* @__PURE__ */ new Map();
29
+ for (const table of tables) {
30
+ const deps = /* @__PURE__ */ new Set();
31
+ for (const target of table.references) {
32
+ if (target === table.name) continue;
33
+ if (!known.has(target)) {
34
+ omitted.push({
35
+ table: table.name,
36
+ target,
37
+ reason: "absent"
38
+ });
39
+ continue;
40
+ }
41
+ deps.add(target);
42
+ }
43
+ dependencies.set(table.name, deps);
44
+ }
45
+ const order = [];
46
+ const placed = /* @__PURE__ */ new Set();
47
+ const remaining = new Set(known);
48
+ for (;;) {
49
+ const ready = [...remaining].filter((name) => {
50
+ const deps = dependencies.get(name);
51
+ return !deps || [...deps].every((dep) => placed.has(dep));
52
+ }).sort();
53
+ if (ready.length === 0) break;
54
+ for (const name of ready) {
55
+ order.push(name);
56
+ placed.add(name);
57
+ remaining.delete(name);
58
+ }
59
+ }
60
+ for (const name of [...remaining].sort()) {
61
+ order.push(name);
62
+ placed.add(name);
63
+ }
64
+ const rank = new Map(order.map((name, index) => [name, index]));
65
+ for (const name of order) for (const target of dependencies.get(name) ?? []) if ((rank.get(target) ?? 0) > (rank.get(name) ?? 0)) omitted.push({
66
+ table: name,
67
+ target,
68
+ reason: "cycle"
69
+ });
70
+ return {
71
+ order,
72
+ omitted
73
+ };
74
+ }
75
+ /**
76
+ * Met en mots les contraintes abandonnées — le message que l'appelant journalise.
77
+ *
78
+ * Écrit pour être lu par celui qui découvre le problème dans ses journaux : il
79
+ * nomme les tables, la cause, et ce qu'il reste à faire. Un avertissement qui ne
80
+ * dit pas quoi faire se relit deux fois puis s'ignore.
81
+ *
82
+ * @param omitted - les contraintes non émises.
83
+ * @returns le texte à journaliser, ou `null` s'il n'y a rien à dire.
84
+ */
85
+ function explainOmittedForeignKeys(omitted) {
86
+ if (omitted.length === 0) return null;
87
+ const cycles = omitted.filter((entry) => entry.reason === "cycle");
88
+ const absent = omitted.filter((entry) => entry.reason === "absent");
89
+ const parts = [];
90
+ if (cycles.length > 0) parts.push(`cycle entre entités — contrainte(s) NON posée(s) : ${cycles.map((c) => `${c.table} → ${c.target}`).join(", ")}. Aucun ordre de création ne satisfait les deux sens ; une migration (\`orm:generate\`) les ajoute après coup par \`ALTER TABLE\`.`);
91
+ if (absent.length > 0) parts.push(`cible hors de cette base — contrainte(s) NON posée(s) : ${absent.map((a) => `${a.table} → ${a.target}`).join(", ")}. L'entité visée est déclarée sur un autre connecteur : l'intégrité référentielle ne traverse pas deux bases.`);
92
+ return parts.join(" ");
93
+ }
94
+ //#endregion
95
+ export { explainOmittedForeignKeys, planTableCreation };
@@ -1,4 +1,5 @@
1
1
  import { DrizzleRepository } from "./DrizzleRepository.js";
2
2
  import { DrizzleTransaction } from "./DrizzleTransaction.js";
3
3
  import { DrizzleOrm } from "./DrizzleOrm.js";
4
- export { DrizzleOrm, DrizzleRepository, DrizzleTransaction };
4
+ import { seedEntityRow } from "./seedRow.js";
5
+ export { DrizzleOrm, DrizzleRepository, DrizzleTransaction, seedEntityRow };
@@ -0,0 +1,65 @@
1
+ import { getTableColumns } from "drizzle-orm";
2
+ //#region nodefony/src/orm-core/seedRow.ts
3
+ /**
4
+ * Compteur de semis — il rend chaque ligne semée DISTINCTE.
5
+ *
6
+ * Une entité cible porte souvent une colonne unique (un courriel, une
7
+ * référence). Semer deux fois la même valeur violerait sa contrainte, et le test
8
+ * échouerait sur son propre décor plutôt que sur ce qu'il éprouve.
9
+ */
10
+ let seedCounter = 0;
11
+ /**
12
+ * Valeur plausible pour une colonne, dérivée de ce que Drizzle en dit.
13
+ *
14
+ * On lit `dataType`, la classification **générique** de Drizzle (commune aux
15
+ * trois dialectes), et non `columnType`, qui nomme l'implémentation (`SQLiteText`,
16
+ * `PgVarchar`) et obligerait à connaître chaque moteur.
17
+ */
18
+ function sampleForColumn(column, n) {
19
+ if (column.enumValues?.length) return column.enumValues[0];
20
+ switch (column.dataType) {
21
+ case "number": return n;
22
+ case "bigint": return BigInt(n);
23
+ case "boolean": return true;
24
+ case "date": return /* @__PURE__ */ new Date();
25
+ case "json": return {};
26
+ case "buffer": return Buffer.from(`seed-${n}`);
27
+ default: return `seed-${n}`;
28
+ }
29
+ }
30
+ /**
31
+ * Insère une ligne minimale dans la table d'une entité, et rend son identifiant.
32
+ *
33
+ * Destiné aux **tests** : dès qu'une entité porte une relation (`author:ref:Author`),
34
+ * sa colonne est une vraie clé étrangère, et la base REFUSE un identifiant
35
+ * inventé. La ligne parente doit donc exister avant toute insertion — et c'est
36
+ * la seule façon d'éprouver la contrainte au lieu de la contourner (débrancher
37
+ * `foreign_keys` rendrait le test vert sans rien prouver).
38
+ *
39
+ * Ce qui est rempli : les colonnes **obligatoires sans valeur par défaut**, avec
40
+ * une valeur du type que Drizzle déclare. Le reste est laissé au schéma — une
41
+ * clé primaire auto-générée, un horodatage, un défaut applicatif se posent seuls.
42
+ *
43
+ * @param orm - la connexion sur laquelle semer.
44
+ * @param entity - l'entité cible ; son `schema` est la table Drizzle.
45
+ * @returns l'identifiant de la ligne créée, à poser dans la colonne qui la désigne.
46
+ * @throws Si la cible porte elle-même une clé étrangère obligatoire — le semis à
47
+ * un seul étage ne sait pas la satisfaire, et le message le dit plutôt que de
48
+ * laisser remonter un « FOREIGN KEY constraint failed » sans contexte.
49
+ */
50
+ async function seedEntityRow(orm, entity) {
51
+ const n = ++seedCounter;
52
+ const columns = getTableColumns(entity.schema);
53
+ const row = {};
54
+ for (const [name, column] of Object.entries(columns)) {
55
+ if (!column.notNull || column.hasDefault || column.primary) continue;
56
+ row[name] = sampleForColumn(column, n);
57
+ }
58
+ try {
59
+ return (await orm.getRepository(entity.name).create(row)).id;
60
+ } catch (cause) {
61
+ throw new Error(`seedEntityRow : impossible de semer une ligne de « ${entity.name} ». Si cette entité porte elle-même une relation obligatoire, sème d'abord sa cible. Cause : ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
62
+ }
63
+ }
64
+ //#endregion
65
+ export { seedEntityRow };
@@ -48,7 +48,7 @@ export { DrizzleMigrator, DEFAULT_LOCK_TIMEOUT_MS, openMigrationDriver, framewor
48
48
  export { MIGRATION_FORMAT_VERSION, EXIT, buildReport, verdictOf, exitCodeOf, meaningOf, renderStatus, renderRefusal, styleFor, action, type IMigrationReport, type IMigrationSourceReport, type IReportContext, type MigrationVerdictName, type IStyle, } from "./nodefony/src/migrator/explain.js";
49
49
  export { MIGRATE_URL_ENV, readMigrationEnv, resolveDdlMode, resolveCheckMode, resetAllowed, resolveConnector, resolveDivergenceMode, knownConnectors, appMigrationsDir, buildMigrator, type IMigrationEnv, type IConnectorResolution, } from "./nodefony/src/migrator/resolve.js";
50
50
  export { DDL_MODES, MIGRATION_CHECK_MODES, DIVERGENCE_MODES, type DdlMode, type MigrationCheckMode, type DivergenceMode, } from "./nodefony/config/config.js";
51
- export { DrizzleOrm, DrizzleRepository, DrizzleTransaction, } from "./nodefony/src/orm-core/index.js";
51
+ export { DrizzleOrm, DrizzleRepository, DrizzleTransaction, seedEntityRow, } from "./nodefony/src/orm-core/index.js";
52
52
  export type { DrizzleOrmOptions, DrizzleDb, DrizzleResolvedRelation, } from "./nodefony/src/orm-core/index.js";
53
53
  export { userTable, createUserTable, createUserEntity, registerUserEntity, } from "./nodefony/entity/userTable.js";
54
54
  export type { UserRow } from "./nodefony/entity/userTable.js";
@@ -5,6 +5,7 @@ interface IGenerateOptions extends IMigrateSharedOptions {
5
5
  name?: string;
6
6
  custom?: boolean;
7
7
  allowDestructive?: boolean;
8
+ apply?: boolean;
8
9
  }
9
10
  /**
10
11
  * `nodefony orm:generate` — écrit la migration qui fera passer la base au
@@ -39,6 +40,17 @@ interface IGenerateOptions extends IMigrateSharedOptions {
39
40
  * nodefony orm:migrate # ou : le travail de déploiement
40
41
  * ```
41
42
  *
43
+ * @example En développement — écrire ET appliquer en un seul geste
44
+ * ```bash
45
+ * nodefony orm:generate --name ajout_du_titre --apply
46
+ * ```
47
+ * `--apply` n'est rien d'autre qu'un `orm:migrate` enchaîné : la commande
48
+ * DÉLÈGUE, elle ne rejoue pas l'application pour son compte. Toutes les gardes
49
+ * du verbe d'application valent donc à l'identique — refus du destructif sans
50
+ * `--allow-destructive`, verrou, historique. Refusé hors développement : un
51
+ * déploiement n'écrit pas ses migrations au moment de les appliquer, il
52
+ * applique celles que la revue de code a validées.
53
+ *
42
54
  * @example Ce que le modèle déclaratif ne peut pas déduire
43
55
  * ```bash
44
56
  * nodefony orm:generate --custom --name vue_des_ventes
@@ -62,6 +62,42 @@ export interface IDestructiveFinding {
62
62
  /** L'instruction elle-même, bornée pour rester lisible. */
63
63
  statement: string;
64
64
  }
65
+ /**
66
+ * Les tables qu'une migration RECONSTRUIT, par la ronde que SQLite impose.
67
+ *
68
+ * 🔴 **La convention de nommage n'est PAS la règle.** La version précédente
69
+ * cherchait le préfixe `__new_` que produit l'outil de génération : elle ne
70
+ * reconnaissait donc une reconstruction que si c'est LUI qui l'avait écrite.
71
+ * Une migration reprise à la main — cas courant, et mesuré sur un agent réel
72
+ * qui avait nommé sa table d'étape `articles_new` — retombait sur
73
+ * « supprime une table et TOUTES ses lignes », sur un SQL qui ne perd rien.
74
+ *
75
+ * Ce qui fait une reconstruction, c'est la RONDE, quels que soient les noms :
76
+ * une table d'étape est créée, les lignes de la table visée y sont recopiées,
77
+ * la visée est supprimée, et l'étape prend sa place. Les quatre doivent être
78
+ * présentes dans le même fichier, et se RÉPONDRE — c'est le renommage final
79
+ * qui dit quelle table d'étape appartient à quelle table visée.
80
+ *
81
+ * ⚠️ Ce n'est pas une garantie que rien n'est perdu : si une colonne n'apparaît
82
+ * pas dans le `INSERT … SELECT`, ses données disparaissent bel et bien. La
83
+ * reconstruction reste donc SIGNALÉE, avec le geste — lire le SQL —, et
84
+ * seulement déclassée en avertissement.
85
+ *
86
+ * @param sql - les instructions de la migration, ou le fichier entier.
87
+ * @returns les tables reconstruites, en minuscules ; vide s'il n'y en a aucune.
88
+ */
89
+ export declare function rebuiltTables(sql: readonly string[] | string): ReadonlySet<string>;
90
+ /**
91
+ * Les tables qu'un SQL SUPPRIME, dans l'ordre où il les nomme.
92
+ *
93
+ * Exportée pour que l'audit de génération et celui d'application lisent la
94
+ * MÊME chose : deux extractions écrites séparément divergeraient, et l'une des
95
+ * deux surfaces se mettrait à refuser ce que l'autre laisse passer.
96
+ *
97
+ * @param sql - les instructions de la migration, ou le fichier entier.
98
+ * @returns les noms supprimés, en minuscules.
99
+ */
100
+ export declare function droppedTables(sql: readonly string[] | string): string[];
65
101
  /**
66
102
  * Cherche, dans les migrations en attente, ce qui détruit ou casse.
67
103
  *
@@ -422,3 +422,22 @@ export declare function refusalInMode(code: IMigrationVerdict["code"], ddl: stri
422
422
  meaning: string;
423
423
  actions: IMigrationAction[];
424
424
  } | null;
425
+ /**
426
+ * L'échec vient-il de ce que la base est DÉJÀ dans l'état que la migration vise ?
427
+ *
428
+ * Le cas est fréquent et sa sortie n'était nommée nulle part : le message de
429
+ * reprise proposait de corriger le fichier ou de travailler sur une base
430
+ * d'essai, deux voies qui ne mènent à rien quand le fichier est juste et la
431
+ * base déjà conforme. Un agent placé devant ce refus tourne alors en rond
432
+ * — `repair` lève le marqueur, `migrate` rejoue, le moteur redit la même
433
+ * chose — puis finit par recréer la base, ce qui emporte les données.
434
+ *
435
+ * Constaté sur un agent tiers : un `DROP INDEX messages_content_unique` sur une
436
+ * base dont l'unicité avait été posée INLINE par le DDL de démarrage, donc sans
437
+ * index nommé. Trente-cinq minutes, deux bases détruites, la boucle brisée à la
438
+ * main.
439
+ *
440
+ * @param message - le message d'erreur rendu par le moteur.
441
+ * @returns `true` si l'échec dit « c'est déjà ainsi ».
442
+ */
443
+ export declare function alreadyInTargetState(message: string): boolean;
@@ -50,6 +50,23 @@ export declare function readMigrationEnv(kernel: Kernel | null): IMigrationEnv;
50
50
  * @returns `true` si `orm:reset` est recevable.
51
51
  */
52
52
  export declare function resetAllowed(env: IMigrationEnv): boolean;
53
+ /**
54
+ * `orm:generate --apply` est-il recevable dans cet environnement ?
55
+ *
56
+ * Écrire une migration et l'appliquer dans le même souffle suppose qu'on relit
57
+ * le SQL produit entre les deux — ce que personne ne fait sur un serveur.
58
+ * Ailleurs qu'en développement, la migration a été écrite, relue et versionnée
59
+ * en amont : `orm:migrate` l'applique, et lui seul. Aucun drapeau ne lève ce
60
+ * refus, pour la même raison que {@link resetAllowed} n'en a pas.
61
+ *
62
+ * Même forme que `resetAllowed`, et c'est voulu : les deux disent « geste de
63
+ * développement », et deux rédactions de la même condition finiraient par
64
+ * diverger sans que rien ne le signale.
65
+ *
66
+ * @param env - environnement constaté.
67
+ * @returns `true` si l'enchaînement est recevable.
68
+ */
69
+ export declare function generateApplyAllowed(env: IMigrationEnv): boolean;
53
70
  /**
54
71
  * Le mode de schéma qui s'applique à un connecteur.
55
72
  *
@@ -62,11 +79,47 @@ export declare function resetAllowed(env: IMigrationEnv): boolean;
62
79
  * - tout le reste → `none` : personne ne touche au schéma au démarrage, un
63
80
  * travail externe applique les migrations avant que le trafic n'arrive.
64
81
  *
82
+ * 🔴 **Une application qui VERSIONNE des migrations bascule en `migrate`, même
83
+ * en développement.** Deux fabricants du même schéma ne s'accordent pas, et
84
+ * rien ne le signale tant qu'on ne les fait pas se rencontrer : le DDL dérivé
85
+ * pose une contrainte d'unicité INLINE et anonyme (`content text NOT NULL
86
+ * UNIQUE`), là où le générateur de migrations raisonne sur un index NOMMÉ
87
+ * (`messages_content_unique`). La migration suivante émet alors un
88
+ * `DROP INDEX` d'un index qui n'a jamais existé, échoue sur `no such index`,
89
+ * inscrit son marqueur — et la réparation ne peut rien y faire, puisque le
90
+ * fichier est inapplicable. Vécu sur un agent tiers : trente-cinq minutes de
91
+ * `repair`/`baseline`/`reset`, deux bases détruites, la boucle brisée à la main.
92
+ * La condition est délibérément « l'application EN A », pas « elle en a en
93
+ * attente » : dès la première migration écrite, le schéma leur appartient.
94
+ * Elle est bornée au développement RÉEL, jamais à `NODE_ENV=test` : une suite
95
+ * lance un exemplaire par worker, et appliquer des migrations depuis plusieurs
96
+ * processus au démarrage est précisément ce que la règle ci-dessus refuse.
97
+ * Un `ddl` écrit dans la configuration gagne toujours — c'est un choix, pas une
98
+ * déduction.
99
+ *
65
100
  * @param explicit - valeur écrite dans la configuration du connecteur, si elle l'est.
66
101
  * @param env - environnement constaté.
102
+ * @param appHasMigrations - l'application versionne au moins une migration.
67
103
  * @returns le mode effectif.
68
104
  */
69
- export declare function resolveDdlMode(explicit: DdlMode | undefined, env: IMigrationEnv): DdlMode;
105
+ export declare function resolveDdlMode(explicit: DdlMode | undefined, env: IMigrationEnv, appHasMigrations?: boolean): DdlMode;
106
+ /**
107
+ * L'application versionne-t-elle au moins une migration ?
108
+ *
109
+ * Lecture SYNCHRONE et bornée à un `readdirSync` par connecteur au démarrage —
110
+ * hors de tout chemin de requête. Elle doit l'être : le mode de schéma se
111
+ * décide avant la connexion, et le rendre asynchrone propagerait un `await`
112
+ * dans toute la chaîne de résolution pour une réponse qui ne change jamais
113
+ * pendant la vie du processus.
114
+ *
115
+ * Un dossier absent, illisible ou vide répond `false` — l'application n'a alors
116
+ * rien à appliquer, et `auto` reste le bon défaut de démarrage rapide.
117
+ *
118
+ * @param dir - dossier de migrations de l'application ({@link appMigrationsDir}).
119
+ * @returns `true` si au moins un fichier `.sql` y est versionné, à un niveau
120
+ * ou sous un sous-dossier de dialecte (`migrations/postgres/0001_x.sql`).
121
+ */
122
+ export declare function appVersionsMigrations(dir: string | undefined): boolean;
70
123
  /**
71
124
  * La conduite de la sonde de disponibilité face à un schéma en retard.
72
125
  *
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Ordre de création des tables, et contraintes qu'il faut renoncer à poser.
3
+ *
4
+ * Module **pur** (aucune base, aucun Drizzle) : c'est la seule partie du DDL
5
+ * dérivé qui demande un raisonnement, donc la seule qui mérite d'être éprouvée
6
+ * sans serveur — un cycle entre entités ne se reproduit pas à la demande sur une
7
+ * base réelle.
8
+ *
9
+ * Une clé étrangère se déclare **dans** le `CREATE TABLE` : la table visée doit
10
+ * donc exister AVANT celle qui la désigne. Un ordre quelconque échoue sur la
11
+ * première relation rencontrée, et l'application ne démarre pas.
12
+ */
13
+ /**
14
+ * Une clé étrangère RÉSOLUE en noms SQL — prête à être écrite dans un
15
+ * `CREATE TABLE`, sans plus aucune référence à un objet Drizzle.
16
+ */
17
+ export interface IResolvedForeignKey {
18
+ /** Nom de la contrainte — explicite, car MySQL l'exige unique pour la base. */
19
+ readonly name: string;
20
+ /** Colonnes porteuses, côté table courante. */
21
+ readonly columns: readonly string[];
22
+ /** Table visée. */
23
+ readonly foreignTable: string;
24
+ /** Colonnes visées, dans le même ordre que {@link columns}. */
25
+ readonly foreignColumns: readonly string[];
26
+ /** Politique d'effacement du parent (`restrict`, `set null`, `cascade`…). */
27
+ readonly onDelete?: string;
28
+ /** Politique de mise à jour de la clé du parent. */
29
+ readonly onUpdate?: string;
30
+ }
31
+ /** Une table à créer, et les tables que ses clés étrangères désignent. */
32
+ export interface ITableDependency {
33
+ /** Nom SQL de la table. */
34
+ readonly name: string;
35
+ /**
36
+ * Tables visées par ses clés étrangères, dans l'ordre de déclaration.
37
+ *
38
+ * Une table peut y figurer plusieurs fois (deux colonnes vers la même cible) ;
39
+ * elle peut aussi s'y désigner elle-même, ce qui ne crée aucune dépendance.
40
+ */
41
+ readonly references: readonly string[];
42
+ }
43
+ /** Une contrainte qu'on renonce à poser, et la raison qui le commande. */
44
+ export interface IOmittedForeignKey {
45
+ /** Table qui porte la clé étrangère. */
46
+ readonly table: string;
47
+ /** Table visée. */
48
+ readonly target: string;
49
+ /**
50
+ * `cycle` : la cible dépend à son tour de cette table, directement ou non —
51
+ * aucun ordre de création ne satisfait les deux.
52
+ * `absent` : la cible n'appartient pas à ce connecteur, donc à cette base.
53
+ */
54
+ readonly reason: "cycle" | "absent";
55
+ }
56
+ /** Ordre de création, et ce qu'il a fallu abandonner en chemin. */
57
+ export interface IDdlPlan {
58
+ /** Noms des tables, dans l'ordre où les créer. */
59
+ readonly order: readonly string[];
60
+ /** Clés étrangères non émises — jamais silencieuses, l'appelant les journalise. */
61
+ readonly omitted: readonly IOmittedForeignKey[];
62
+ }
63
+ /**
64
+ * Trie les tables pour que chaque cible naisse avant qui la désigne.
65
+ *
66
+ * Tri topologique (Kahn) sur le graphe des dépendances, avec deux écarts
67
+ * assumés au manuel :
68
+ *
69
+ * - **l'auto-référence ne compte pas** (`parent:ref:Category` dans `Category`) :
70
+ * les trois moteurs acceptent une contrainte qui vise la table en cours de
71
+ * création, dans le même `CREATE TABLE` ;
72
+ * - **un cycle ne fait pas échouer le démarrage**. Il n'y a aucun ordre correct,
73
+ * mais refuser de démarrer pour ça serait hors de proportion : on crée les
74
+ * tables du cycle dans un ordre stable, et on OMET les contraintes qui
75
+ * regardent en arrière. Elles sont rendues à l'appelant pour qu'il les dise —
76
+ * une garantie qui disparaît sans un mot est pire que son absence.
77
+ *
78
+ * À ordre de sortie stable : les tables prêtes sont prises par ordre
79
+ * alphabétique, jamais dans l'ordre d'arrivée. Deux démarrages de la même
80
+ * application produisent donc le même DDL, ce qui rend un journal comparable.
81
+ *
82
+ * @param tables - les tables du connecteur et leurs cibles.
83
+ * @returns l'ordre de création et les contraintes abandonnées.
84
+ */
85
+ export declare function planTableCreation(tables: readonly ITableDependency[]): IDdlPlan;
86
+ /**
87
+ * Met en mots les contraintes abandonnées — le message que l'appelant journalise.
88
+ *
89
+ * Écrit pour être lu par celui qui découvre le problème dans ses journaux : il
90
+ * nomme les tables, la cause, et ce qu'il reste à faire. Un avertissement qui ne
91
+ * dit pas quoi faire se relit deux fois puis s'ignore.
92
+ *
93
+ * @param omitted - les contraintes non émises.
94
+ * @returns le texte à journaliser, ou `null` s'il n'y a rien à dire.
95
+ */
96
+ export declare function explainOmittedForeignKeys(omitted: readonly IOmittedForeignKey[]): string | null;