@nodefony/drizzle 10.0.0-alpha.7 → 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.
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { appMigrationsDir, buildMigrator, knownConnectors, readMigrationEnv, res
13
13
  import { DrizzleRepository } from "./nodefony/src/orm-core/DrizzleRepository.js";
14
14
  import { DrizzleTransaction } from "./nodefony/src/orm-core/DrizzleTransaction.js";
15
15
  import { DrizzleOrm } from "./nodefony/src/orm-core/DrizzleOrm.js";
16
+ import { seedEntityRow } from "./nodefony/src/orm-core/seedRow.js";
16
17
  import "./nodefony/src/orm-core/index.js";
17
18
  import DrizzleService from "./nodefony/service/DrizzleService.js";
18
19
  import { TOKEN_ENTITY_NAMES, accessTokenTable, createAccessTokenTable, createDeniedJtiTable, createSubjectRevocationTable, createTokenEntities, deniedJtiTable, registerTokenEntities, subjectRevocationTable } from "./nodefony/entity/tokenEntity.js";
@@ -102,4 +103,4 @@ let Drizzle = class Drizzle extends Module {
102
103
  Drizzle = __decorate([services([DrizzleService]), __decorateMetadata("design:paramtypes", [typeof Kernel === "undefined" ? Object : Kernel])], Drizzle);
103
104
  var drizzle_default = Drizzle;
104
105
  //#endregion
105
- export { APP_RANK, APP_SOURCE, AUDIT_ENTITY_NAMES, DDL_MODES, DEFAULT_LOCK_TIMEOUT_MS, DIVERGENCE_MODES, DrizzleAuditStore, DrizzleIdempotencyStore, DrizzleMigrator, DrizzleOrm, DrizzleRepository, DrizzleService, DrizzleTokenStore, DrizzleTotpSecretStore, DrizzleTransaction, DrizzleUserRepository, DrizzleWebAuthnCredentialStore, DrizzleWebhookStore, EXIT, FORMAT_MARKER, FRAMEWORK_CONNECTOR, FRAMEWORK_RANK, FRAMEWORK_SOURCE, HISTORY_TABLE, IDEMPOTENCY_ENTITY_NAME, MIGRATE_URL_ENV, MIGRATION_CHECK_MODES, MIGRATION_FORMAT_VERSION, MYSQL_LOCK_NAME_SQL, MigrationVerdictError, PG_LOCK_KEY, SESSION_CONNECTOR, SESSION_ENTITY_NAME, SessionStorage, TOKEN_ENTITY_NAMES, TOTP_SECRET_ENTITY, WEBAUTHN_CREDENTIAL_ENTITY, WEBHOOK_ENDPOINT_ENTITY, accessTokenTable, action, appMigrationsDir, auditEventTable, buildMigrator, buildReport, createAccessTokenTable, createAuditEntities, createAuditEventTable, createDeniedJtiTable, createIdempotencyEntities, createIdempotencyTable, createSessionEntity, createSessionTable, createSubjectRevocationTable, createTokenEntities, createTotpSecretEntity, createTotpSecretTable, createUserEntity, createUserTable, createWebAuthnCredentialEntity, createWebAuthnCredentialTable, createWebhookEndpointEntity, createWebhookEndpointTable, drizzle_default as default, defaultMigrationSources, defineDrizzleConfig, deniedJtiTable, drizzleConfigJsonSchema, drizzleConfigSchema, exitCodeOf, frameworkMigrationsDir, idempotencyKeyTable, isFrameworkFallbackEntity, knownConnectors, loadSources, meaningOf, migrationHash, openMigrationDriver, orderSources, readMigrationEnv, registerAuditEntities, registerDrizzleFrameworkStores, registerIdempotencyEntities, registerSessionEntity, registerTokenEntities, registerTotpSecretEntity, registerUserEntity, registerWebAuthnCredentialEntity, registerWebhookEndpointEntity, renderRefusal, renderStatus, resetAllowed, resolveCheckMode, resolveConnector, resolveDdlMode, resolveDivergenceMode, sessionTable, styleFor, subjectRevocationTable, totpSecretTable, userTable, verdictOf, webAuthnCredentialTable, webhookEndpointTable };
106
+ export { APP_RANK, APP_SOURCE, AUDIT_ENTITY_NAMES, DDL_MODES, DEFAULT_LOCK_TIMEOUT_MS, DIVERGENCE_MODES, DrizzleAuditStore, DrizzleIdempotencyStore, DrizzleMigrator, DrizzleOrm, DrizzleRepository, DrizzleService, DrizzleTokenStore, DrizzleTotpSecretStore, DrizzleTransaction, DrizzleUserRepository, DrizzleWebAuthnCredentialStore, DrizzleWebhookStore, EXIT, FORMAT_MARKER, FRAMEWORK_CONNECTOR, FRAMEWORK_RANK, FRAMEWORK_SOURCE, HISTORY_TABLE, IDEMPOTENCY_ENTITY_NAME, MIGRATE_URL_ENV, MIGRATION_CHECK_MODES, MIGRATION_FORMAT_VERSION, MYSQL_LOCK_NAME_SQL, MigrationVerdictError, PG_LOCK_KEY, SESSION_CONNECTOR, SESSION_ENTITY_NAME, SessionStorage, TOKEN_ENTITY_NAMES, TOTP_SECRET_ENTITY, WEBAUTHN_CREDENTIAL_ENTITY, WEBHOOK_ENDPOINT_ENTITY, accessTokenTable, action, appMigrationsDir, auditEventTable, buildMigrator, buildReport, createAccessTokenTable, createAuditEntities, createAuditEventTable, createDeniedJtiTable, createIdempotencyEntities, createIdempotencyTable, createSessionEntity, createSessionTable, createSubjectRevocationTable, createTokenEntities, createTotpSecretEntity, createTotpSecretTable, createUserEntity, createUserTable, createWebAuthnCredentialEntity, createWebAuthnCredentialTable, createWebhookEndpointEntity, createWebhookEndpointTable, drizzle_default as default, defaultMigrationSources, defineDrizzleConfig, deniedJtiTable, drizzleConfigJsonSchema, drizzleConfigSchema, exitCodeOf, frameworkMigrationsDir, idempotencyKeyTable, isFrameworkFallbackEntity, knownConnectors, loadSources, meaningOf, migrationHash, openMigrationDriver, orderSources, readMigrationEnv, registerAuditEntities, registerDrizzleFrameworkStores, registerIdempotencyEntities, registerSessionEntity, registerTokenEntities, registerTotpSecretEntity, registerUserEntity, registerWebAuthnCredentialEntity, registerWebhookEndpointEntity, renderRefusal, renderStatus, resetAllowed, resolveCheckMode, resolveConnector, resolveDdlMode, resolveDivergenceMode, seedEntityRow, sessionTable, styleFor, subjectRevocationTable, totpSecretTable, userTable, verdictOf, webAuthnCredentialTable, webhookEndpointTable };
@@ -3,7 +3,7 @@ import { EXIT, action } from "../src/migrator/explain.js";
3
3
  import { openMigrationDriver } from "../src/migrator/drivers/index.js";
4
4
  import { frameworkMigrationsDir } from "../src/migrator/paths.js";
5
5
  import { createdTables, frameworkTables, loadSources } from "../src/migrator/sources.js";
6
- import { appMigrationsDir } from "../src/migrator/resolve.js";
6
+ import { appMigrationsDir, generateApplyAllowed, readMigrationEnv } from "../src/migrator/resolve.js";
7
7
  import { summarizeGap } from "../src/migrator/schemaDiff.js";
8
8
  import { gapAgainstDeclared } from "../src/migrator/divergence.js";
9
9
  import { auditMigrationSql, runGenerate, stampFormatMarker } from "../src/migrator/kit.js";
@@ -62,6 +62,17 @@ const options = {
62
62
  * nodefony orm:migrate # ou : le travail de déploiement
63
63
  * ```
64
64
  *
65
+ * @example En développement — écrire ET appliquer en un seul geste
66
+ * ```bash
67
+ * nodefony orm:generate --name ajout_du_titre --apply
68
+ * ```
69
+ * `--apply` n'est rien d'autre qu'un `orm:migrate` enchaîné : la commande
70
+ * DÉLÈGUE, elle ne rejoue pas l'application pour son compte. Toutes les gardes
71
+ * du verbe d'application valent donc à l'identique — refus du destructif sans
72
+ * `--allow-destructive`, verrou, historique. Refusé hors développement : un
73
+ * déploiement n'écrit pas ses migrations au moment de les appliquer, il
74
+ * applique celles que la revue de code a validées.
75
+ *
65
76
  * @example Ce que le modèle déclaratif ne peut pas déduire
66
77
  * ```bash
67
78
  * nodefony orm:generate --custom --name vue_des_ventes
@@ -75,6 +86,7 @@ var OrmGenerate = class extends OrmMigrateCommand {
75
86
  this.addOption("-n, --name <nom>", "nom de la migration, en minuscules et « _ » — il entre dans le tag, qui est immuable une fois publié");
76
87
  this.addOption("--custom", "écrit un fichier SQL VIDE et son entrée de journal, sans rien déduire : vues, déclencheurs, clés étrangères, remplissages");
77
88
  this.addOption("--allow-destructive", "accepte une migration qui SUPPRIME des données — à ne poser qu'après avoir relu le fichier produit");
89
+ this.addOption("--apply", "applique la migration dans la foulée (développement seulement) — écrire puis appliquer en un seul geste");
78
90
  }
79
91
  /**
80
92
  * Racine de l'application — c'est depuis là que l'outil est lancé.
@@ -118,7 +130,7 @@ var OrmGenerate = class extends OrmMigrateCommand {
118
130
  dialect: resolution.dialect,
119
131
  name
120
132
  });
121
- return this.#emit({
133
+ return await this.#emit({
122
134
  formatVersion: 1,
123
135
  connector,
124
136
  generated: true,
@@ -230,7 +242,7 @@ var OrmGenerate = class extends OrmMigrateCommand {
230
242
  ], opts.json, EXIT.actionRequired);
231
243
  return this;
232
244
  }
233
- return this.#emit({
245
+ return await this.#emit({
234
246
  formatVersion: 1,
235
247
  connector,
236
248
  generated: false,
@@ -273,7 +285,7 @@ var OrmGenerate = class extends OrmMigrateCommand {
273
285
  ], opts.json, EXIT.actionRequired, discovery);
274
286
  return this;
275
287
  }
276
- return this.#emit({
288
+ return await this.#emit({
277
289
  formatVersion: 1,
278
290
  connector,
279
291
  generated: true,
@@ -384,17 +396,69 @@ var OrmGenerate = class extends OrmMigrateCommand {
384
396
  *
385
397
  * @returns `this`.
386
398
  */
387
- #emit(report, opts, headline) {
399
+ /**
400
+ * Écrit le compte rendu de génération, puis enchaîne l'application si
401
+ * `--apply` est posé.
402
+ *
403
+ * 🔴 **En sortie machine, `--apply` ne produit qu'UN document** : celui de
404
+ * l'application, qui porte le verdict final. Le compte rendu de génération
405
+ * part alors sur la sortie d'ERREUR — libre par contrat — plutôt que d'écrire
406
+ * deux objets à la suite sur la sortie standard, ce qui casserait tout `| jq`
407
+ * sans qu'aucune erreur ne le dise.
408
+ *
409
+ * @param report - le rapport de génération.
410
+ * @param opts - options reçues.
411
+ * @param headline - la première ligne, déjà rédigée par l'appelant.
412
+ * @returns `this`, la sortie ayant été écrite.
413
+ */
414
+ async #emit(report, opts, headline) {
388
415
  const style = this.style;
389
416
  let human = `${style.green("✓")} ${headline}\n`;
390
417
  if (report.files.length > 0) human += `\n${report.files.map((f) => ` + ${f}`).join("\n")}\n`;
391
418
  if (report.otherDialect.length > 0) human += `\n${style.dim(`Écrites pour un autre moteur, donc hors de cette migration :`)}\n` + report.otherDialect.map((o) => ` • ${o.table} (${o.dialect}) — ${o.file}`).join("\n") + "\n";
392
419
  if (report.unreadable.length > 0) human += `\n${style.dim("Fichiers d'entités non lus (aucune entité enregistrée n'en dépend) :")}\n` + report.unreadable.map((u) => ` • ${u.file} — ${u.cause}`).join("\n") + "\n";
393
420
  if (report.warnings.length > 0) human += `\n${style.bold("⚠️ À REGARDER avant d'appliquer")} (rien n'est détruit — mais l'application peut cesser de répondre le temps de l'opération, ou la migration ÉCHOUER si la table porte déjà des lignes) :\n` + report.warnings.map((r) => ` • ${r.id} : ${r.what}\n → ${r.todo}`).join("\n") + "\n";
394
- if (report.generated) human += `\n${style.bold("À faire :")}\n ${style.dim("relire le fichier, puis")} ${style.green("nodefony orm:migrate")}\n`;
421
+ if (report.generated && opts.apply !== true) human += `\n${style.bold("À faire :")}\n ${style.dim("relire le fichier, puis")} ${style.green("nodefony orm:migrate")}\n ${style.dim("ou, en développement, les deux d'un geste :")} ${style.green("nodefony orm:generate --apply")}\n`;
422
+ if (report.generated && opts.apply === true) {
423
+ if (opts.json === true) process.stderr.write(human.endsWith("\n") ? human : `${human}\n`);
424
+ else process.stdout.write(human.endsWith("\n") ? human : `${human}\n`);
425
+ await this.#applyNow(opts, report.connector);
426
+ return this;
427
+ }
395
428
  this.respond(report, human, EXIT.ok, opts.json);
396
429
  return this;
397
430
  }
431
+ /**
432
+ * Enchaîne l'application quand `--apply` est posé.
433
+ *
434
+ * DÉLÈGUE au verbe `orm:migrate` par son point d'entrée public
435
+ * ({@link Cli.getCommand}) : rejouer ici l'application aurait produit une
436
+ * SECONDE implémentation du même geste, qui aurait divergé de la première au
437
+ * premier réglage — et c'est précisément elle qui porte le refus du
438
+ * destructif, le verrou et l'écriture de l'historique.
439
+ *
440
+ * Refusé hors développement, sans drapeau pour le lever : générer puis
441
+ * appliquer dans le même souffle suppose qu'on relit le SQL entre les deux,
442
+ * ce que personne ne fait sur un serveur. En production, la migration a été
443
+ * écrite, relue et versionnée ailleurs ; `orm:migrate` l'applique.
444
+ *
445
+ * @param opts - options reçues, passées telles quelles au verbe d'application.
446
+ * @param connector - connecteur résolu, pour le message de refus.
447
+ * @returns `true` si la main a été passée (la sortie est déjà écrite).
448
+ */
449
+ async #applyNow(opts, connector) {
450
+ if (!generateApplyAllowed(readMigrationEnv(this.kernel))) {
451
+ this.fail(connector, "NF_MIGRATE_NOT_DEVELOPMENT", "« --apply » n'existe qu'en développement.", "Écrire une migration et l'appliquer dans le même geste suppose qu'on relit le SQL produit entre les deux — ce que personne ne fait sur un serveur. Hors développement, la migration a été écrite, relue et versionnée ailleurs : c'est « orm:migrate » qui l'applique, et lui seul.", [action("nodefony orm:migrate")], opts.json, EXIT.actionRequired);
452
+ return true;
453
+ }
454
+ const migrate = (this.kernel?.getModule("drizzle"))?.commands?.["orm:migrate"];
455
+ if (!migrate) {
456
+ this.fail(connector, "NF_MIGRATE_UNAVAILABLE", "Le verbe « orm:migrate » est introuvable : la migration a été ÉCRITE mais n'a pas été appliquée.", "« --apply » ne fait qu'enchaîner ce verbe ; sans lui, il n'y a rien à enchaîner.", [action("nodefony orm:migrate")], opts.json);
457
+ return true;
458
+ }
459
+ await migrate.generate(opts);
460
+ return true;
461
+ }
398
462
  };
399
463
  //#endregion
400
464
  export { OrmGenerate as default };
@@ -2,7 +2,7 @@ import { buildReport, isAheadOnly, meaningOf } from "../src/migrator/explain.js"
2
2
  import { defaultConnectorFilename } from "../src/connectorTarget.js";
3
3
  import { defaultMigrationSources } from "../src/migrator/paths.js";
4
4
  import { DrizzleMigrator } from "../src/migrator/DrizzleMigrator.js";
5
- import { appMigrationsDir, readMigrationEnv, resetAllowed, resolveCheckMode, resolveDdlMode } from "../src/migrator/resolve.js";
5
+ import { appMigrationsDir, appVersionsMigrations, readMigrationEnv, resetAllowed, resolveCheckMode, resolveDdlMode } from "../src/migrator/resolve.js";
6
6
  import { describeDivergence } from "../src/migrator/divergence.js";
7
7
  import { DrizzleOrm } from "../src/orm-core/DrizzleOrm.js";
8
8
  import "../src/orm-core/index.js";
@@ -122,7 +122,7 @@ var DrizzleService = class extends Service {
122
122
  /** Connecte un connecteur (crée le dossier de la base SQLite si nécessaire). */
123
123
  async #connectOne(name, cfg, connect = true) {
124
124
  const dialect = cfg.dialect ?? "sqlite";
125
- const ddl = resolveDdlMode(cfg.ddl, readMigrationEnv(this.kernel));
125
+ const ddl = resolveDdlMode(cfg.ddl, readMigrationEnv(this.kernel), appVersionsMigrations(appMigrationsDir(this.kernel, this.#config().migrations?.dir ?? "migrations")));
126
126
  let filename;
127
127
  if (dialect === "sqlite") {
128
128
  filename = cfg.filename ?? this.#defaultFilename(name);
@@ -1,4 +1,5 @@
1
1
  import { MIGRATE_URL_ENV, MigrationLockTimeoutError, MigrationVerdictError } from "./types.js";
2
+ import { alreadyInTargetState } from "./explain.js";
2
3
  import { openMigrationDriver } from "./drivers/index.js";
3
4
  import { deleteFailed, ensureHistorySchema, finishHistory, forgetEntries, insertHistory, readHistory } from "./history.js";
4
5
  import "./paths.js";
@@ -51,6 +52,21 @@ const ERROR_MAX_LENGTH = 2e3;
51
52
  * celle qu'il trouve seul est de recréer la base, ce qui emporte les données.
52
53
  */
53
54
  const REPRISE_SANS_DESTRUCTION = "REPRENDRE ne demande PAS de recréer la base. Deux voies, selon ce qui a échoué : corriger le fichier de migration puis lever le marqueur (« orm:migrate:repair ») et reprendre — un fichier corrigé après un échec est rejoué tel quel, son empreinte n'est pas comparée ; ou, si la mise au point demande plusieurs essais, la faire sur une base d'essai en posant « " + MIGRATE_URL_ENV + " », qui détourne la commande vers une AUTRE base et laisse celle-ci intacte — sous PowerShell, poser la variable s'écrit « $env:" + MIGRATE_URL_ENV + " = \"…\" », « env » n'y existant pas. Écrire la migration suivante est toujours préférable à défaire ce qui est déjà appliqué.";
55
+ /**
56
+ * La troisième voie de reprise — celle qu'aucun message ne nommait.
57
+ *
58
+ * Les deux voies écrites jusqu'ici (corriger le fichier, ou travailler sur une
59
+ * base d'essai) supposent toutes deux que le fichier a tort. Quand le moteur
60
+ * répond « c'est déjà ainsi », ni l'une ni l'autre ne mène quelque part : le
61
+ * SQL est juste, la base est conforme, et rejouer redira la même chose. Sans
62
+ * cette phrase, la boucle `repair` → `migrate` → même refus n'a pas de sortie
63
+ * — et la seule qu'un agent trouve seul est de recréer la base.
64
+ *
65
+ * @param source - source de la migration (`app` ou `framework`).
66
+ * @param tag - identité de la migration en échec.
67
+ * @returns le paragraphe à insérer dans le verdict.
68
+ */
69
+ const alreadyInTargetStateAdvice = (source, tag) => `Ce que dit ce message, c'est que la base est DÉJÀ dans l'état que cette migration vise : l'objet qu'elle crée existe, ou celui qu'elle retire n'a jamais existé sous ce nom. Corriger le fichier ne servira donc à rien, et le rejouer redira la même chose — c'est la boucle dans laquelle on tourne si on s'obstine. VÉRIFIE d'abord l'état réel (« nodefony inspect entities --json », ou le schéma de la table concernée) ; s'il correspond bien à ce que « ${source}/${tag} » voulait obtenir, la migration est SANS OBJET sur cette base : déclare-la appliquée plutôt que de la rejouer, avec « nodefony orm:migrate:baseline --up-to ${tag} ». Une cause courante en développement : la table a été créée par le schéma DÉRIVÉ du démarrage, qui pose les contraintes sans les nommer, là où une migration les nomme.`;
54
70
  var DrizzleMigrator = class {
55
71
  #options;
56
72
  #now;
@@ -592,7 +608,7 @@ var DrizzleMigrator = class {
592
608
  tag: file.tag,
593
609
  facts: { error: truncate(cause.message) },
594
610
  actions: this.#recoveryActions()
595
- }, `La migration « ${file.tag} » (source « ${file.source} ») a échoué : ${truncate(cause.message)}\n\n${state}\n\nSon échec est INSCRIT : le prochain passage refusera de reprendre tant que le marqueur n'aura pas été levé, après inspection.\n\n${REPRISE_SANS_DESTRUCTION}`);
611
+ }, `La migration « ${file.tag} » (source « ${file.source} ») a échoué : ${truncate(cause.message)}\n\n${state}\n\nSon échec est INSCRIT : le prochain passage refusera de reprendre tant que le marqueur n'aura pas été levé, après inspection.\n\n` + (alreadyInTargetState(cause.message) ? `${alreadyInTargetStateAdvice(file.source, file.tag)}\n\n` : "") + REPRISE_SANS_DESTRUCTION);
596
612
  }
597
613
  /**
598
614
  * Les gestes qui sortent d'une migration en échec — **sans toucher à la base**.
@@ -450,6 +450,8 @@ function renderStatus(report, style) {
450
450
  if (report.sources.length === 0) out += ` ${style.dim("aucune source de migrations")}\n`;
451
451
  if (report.divergence && !namesEverything(report.divergence)) out += renderDivergence(report.divergence, style);
452
452
  out += `\n${renderBlocks(style, report.summary, meaningOf(report.verdict), report.nextActions)}`;
453
+ if (report.exitCode === EXIT.actionRequired) out += `\n${style.dim("code de sortie 1 — une action est requise, ce n'est pas un échec.")}\n`;
454
+ else if (report.exitCode === EXIT.error) out += `\n${style.dim("code de sortie 2 — la commande n'a pas pu travailler.")}\n`;
453
455
  return out;
454
456
  }
455
457
  /** Combien d'écarts on déroule à l'écran avant de renvoyer au `--json`. */
@@ -561,5 +563,44 @@ const REFUSAL_MEANING = {
561
563
  NF_MIGRATE_JOURNAL_MISMATCH: "Le journal d'une source annonce une migration dont le fichier n'est pas dans le dossier. La source est incohérente avec elle-même : une copie incomplète, un fichier ignoré par le gestionnaire de versions, ou un paquet publié sans ses migrations. Rien n'a été appliqué — l'applicateur ne devine jamais le contenu d'un fichier absent.",
562
564
  NF_MIGRATE_UNKNOWN_SOURCE: "La source demandée n'est pas déclarée par cette application. Filtrer sur un nom inconnu ne touche aucune ligne et rend pourtant « rien à réparer » : le marqueur d'échec resterait en place, et la migration suivante échouerait pour la même raison."
563
565
  };
566
+ /**
567
+ * Les moteurs, chacun dans sa langue, disant « c'est DÉJÀ ainsi ».
568
+ *
569
+ * Reconnaître ce cas change le conseil du tout au tout : la migration n'a pas
570
+ * échoué parce que la base est cassée, mais parce qu'elle est **déjà dans
571
+ * l'état visé**. Le geste n'est alors ni de corriger le SQL ni de recréer la
572
+ * base — c'est de déclarer la migration appliquée.
573
+ */
574
+ const ALREADY_IN_TARGET_STATE = [
575
+ /\bno such (index|column|table|trigger|view)\b/iu,
576
+ /\b(table|index|trigger|view)\b.*\balready exists\b/iu,
577
+ /\bduplicate column name\b/iu,
578
+ /\balready exists\b/iu,
579
+ /\bdoes not exist\b/iu,
580
+ /\bduplicate key name\b/iu,
581
+ /\bcan't drop\b/iu,
582
+ /\bcheck that (column|it|the) .*exists\b/iu
583
+ ];
584
+ /**
585
+ * L'échec vient-il de ce que la base est DÉJÀ dans l'état que la migration vise ?
586
+ *
587
+ * Le cas est fréquent et sa sortie n'était nommée nulle part : le message de
588
+ * reprise proposait de corriger le fichier ou de travailler sur une base
589
+ * d'essai, deux voies qui ne mènent à rien quand le fichier est juste et la
590
+ * base déjà conforme. Un agent placé devant ce refus tourne alors en rond
591
+ * — `repair` lève le marqueur, `migrate` rejoue, le moteur redit la même
592
+ * chose — puis finit par recréer la base, ce qui emporte les données.
593
+ *
594
+ * Constaté sur un agent tiers : un `DROP INDEX messages_content_unique` sur une
595
+ * base dont l'unicité avait été posée INLINE par le DDL de démarrage, donc sans
596
+ * index nommé. Trente-cinq minutes, deux bases détruites, la boucle brisée à la
597
+ * main.
598
+ *
599
+ * @param message - le message d'erreur rendu par le moteur.
600
+ * @returns `true` si l'échec dit « c'est déjà ainsi ».
601
+ */
602
+ function alreadyInTargetState(message) {
603
+ return ALREADY_IN_TARGET_STATE.some((pattern) => pattern.test(message));
604
+ }
564
605
  //#endregion
565
- export { EXIT, MIGRATION_FORMAT_VERSION, action, buildReport, describeDiscovery, divergenceIsBlocking, exitCodeOf, isAheadOnly, meaningOf, refusalInMode, renderRefusal, renderStatus, styleFor, verdictOf };
606
+ export { EXIT, MIGRATION_FORMAT_VERSION, action, alreadyInTargetState, buildReport, describeDiscovery, divergenceIsBlocking, exitCodeOf, isAheadOnly, meaningOf, refusalInMode, renderRefusal, renderStatus, styleFor, verdictOf };
@@ -4,6 +4,7 @@ import { defaultMigrationSources } from "./paths.js";
4
4
  import { DrizzleMigrator } from "./DrizzleMigrator.js";
5
5
  import { parseDatabaseUrl, sqliteFilenameFromUrl } from "nodefony";
6
6
  import { ormRegistry } from "@nodefony/orm-core";
7
+ import fs from "node:fs";
7
8
  import path from "node:path";
8
9
  //#region nodefony/src/migrator/resolve.ts
9
10
  /**
@@ -52,6 +53,25 @@ function resetAllowed(env) {
52
53
  return env.runtime === "development" && env.nodeEnv !== "test";
53
54
  }
54
55
  /**
56
+ * `orm:generate --apply` est-il recevable dans cet environnement ?
57
+ *
58
+ * Écrire une migration et l'appliquer dans le même souffle suppose qu'on relit
59
+ * le SQL produit entre les deux — ce que personne ne fait sur un serveur.
60
+ * Ailleurs qu'en développement, la migration a été écrite, relue et versionnée
61
+ * en amont : `orm:migrate` l'applique, et lui seul. Aucun drapeau ne lève ce
62
+ * refus, pour la même raison que {@link resetAllowed} n'en a pas.
63
+ *
64
+ * Même forme que `resetAllowed`, et c'est voulu : les deux disent « geste de
65
+ * développement », et deux rédactions de la même condition finiraient par
66
+ * diverger sans que rien ne le signale.
67
+ *
68
+ * @param env - environnement constaté.
69
+ * @returns `true` si l'enchaînement est recevable.
70
+ */
71
+ function generateApplyAllowed(env) {
72
+ return env.runtime === "development" && env.nodeEnv !== "test";
73
+ }
74
+ /**
55
75
  * Le mode de schéma qui s'applique à un connecteur.
56
76
  *
57
77
  * Règle des défauts, et son pourquoi : **appliquer des migrations au démarrage
@@ -63,13 +83,63 @@ function resetAllowed(env) {
63
83
  * - tout le reste → `none` : personne ne touche au schéma au démarrage, un
64
84
  * travail externe applique les migrations avant que le trafic n'arrive.
65
85
  *
86
+ * 🔴 **Une application qui VERSIONNE des migrations bascule en `migrate`, même
87
+ * en développement.** Deux fabricants du même schéma ne s'accordent pas, et
88
+ * rien ne le signale tant qu'on ne les fait pas se rencontrer : le DDL dérivé
89
+ * pose une contrainte d'unicité INLINE et anonyme (`content text NOT NULL
90
+ * UNIQUE`), là où le générateur de migrations raisonne sur un index NOMMÉ
91
+ * (`messages_content_unique`). La migration suivante émet alors un
92
+ * `DROP INDEX` d'un index qui n'a jamais existé, échoue sur `no such index`,
93
+ * inscrit son marqueur — et la réparation ne peut rien y faire, puisque le
94
+ * fichier est inapplicable. Vécu sur un agent tiers : trente-cinq minutes de
95
+ * `repair`/`baseline`/`reset`, deux bases détruites, la boucle brisée à la main.
96
+ * La condition est délibérément « l'application EN A », pas « elle en a en
97
+ * attente » : dès la première migration écrite, le schéma leur appartient.
98
+ * Elle est bornée au développement RÉEL, jamais à `NODE_ENV=test` : une suite
99
+ * lance un exemplaire par worker, et appliquer des migrations depuis plusieurs
100
+ * processus au démarrage est précisément ce que la règle ci-dessus refuse.
101
+ * Un `ddl` écrit dans la configuration gagne toujours — c'est un choix, pas une
102
+ * déduction.
103
+ *
66
104
  * @param explicit - valeur écrite dans la configuration du connecteur, si elle l'est.
67
105
  * @param env - environnement constaté.
106
+ * @param appHasMigrations - l'application versionne au moins une migration.
68
107
  * @returns le mode effectif.
69
108
  */
70
- function resolveDdlMode(explicit, env) {
109
+ function resolveDdlMode(explicit, env, appHasMigrations = false) {
71
110
  if (explicit) return explicit;
72
- return env.runtime === "development" || env.nodeEnv === "test" ? "auto" : "none";
111
+ if (env.runtime === "development" || env.nodeEnv === "test") return appHasMigrations && env.nodeEnv !== "test" ? "migrate" : "auto";
112
+ return "none";
113
+ }
114
+ /**
115
+ * L'application versionne-t-elle au moins une migration ?
116
+ *
117
+ * Lecture SYNCHRONE et bornée à un `readdirSync` par connecteur au démarrage —
118
+ * hors de tout chemin de requête. Elle doit l'être : le mode de schéma se
119
+ * décide avant la connexion, et le rendre asynchrone propagerait un `await`
120
+ * dans toute la chaîne de résolution pour une réponse qui ne change jamais
121
+ * pendant la vie du processus.
122
+ *
123
+ * Un dossier absent, illisible ou vide répond `false` — l'application n'a alors
124
+ * rien à appliquer, et `auto` reste le bon défaut de démarrage rapide.
125
+ *
126
+ * @param dir - dossier de migrations de l'application ({@link appMigrationsDir}).
127
+ * @returns `true` si au moins un fichier `.sql` y est versionné, à un niveau
128
+ * ou sous un sous-dossier de dialecte (`migrations/postgres/0001_x.sql`).
129
+ */
130
+ function appVersionsMigrations(dir) {
131
+ if (!dir) return false;
132
+ try {
133
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
134
+ if (entry.isFile() && entry.name.endsWith(".sql")) return true;
135
+ if (entry.isDirectory() && entry.name !== "meta") {
136
+ if (fs.readdirSync(path.join(dir, entry.name)).some((name) => name.endsWith(".sql"))) return true;
137
+ }
138
+ }
139
+ } catch {
140
+ return false;
141
+ }
142
+ return false;
73
143
  }
74
144
  /**
75
145
  * La conduite de la sonde de disponibilité face à un schéma en retard.
@@ -217,7 +287,7 @@ function resolveConnector(connector, config, env, kernel, options = {}) {
217
287
  url: fromMigrateUrl && dialect !== "sqlite" ? parsedUrl.url : base.url
218
288
  },
219
289
  fromMigrateUrl,
220
- ddl: resolveDdlMode(declared.ddl, env)
290
+ ddl: resolveDdlMode(declared.ddl, env, appVersionsMigrations(appMigrationsDir(kernel, config.migrations.dir)))
221
291
  };
222
292
  }
223
293
  /**
@@ -278,4 +348,4 @@ function resolveDivergenceMode(config) {
278
348
  return config.migrations.divergence;
279
349
  }
280
350
  //#endregion
281
- export { MIGRATE_URL_ENV, appMigrationsDir, buildMigrator, knownConnectors, readMigrationEnv, resetAllowed, resolveCheckMode, resolveConnector, resolveDdlMode, resolveDivergenceMode };
351
+ export { MIGRATE_URL_ENV, appMigrationsDir, appVersionsMigrations, buildMigrator, generateApplyAllowed, knownConnectors, readMigrationEnv, resetAllowed, resolveCheckMode, resolveConnector, resolveDdlMode, resolveDivergenceMode };
@@ -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
@@ -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;
@@ -9,3 +9,4 @@ export type { DrizzleOrmOptions } from "./DrizzleOrm.js";
9
9
  export { DrizzleRepository } from "./DrizzleRepository.js";
10
10
  export type { DrizzleDb, DrizzleTable, DrizzleColumn, DrizzleResolvedRelation, } from "./DrizzleRepository.js";
11
11
  export { DrizzleTransaction } from "./DrizzleTransaction.js";
12
+ export { seedEntityRow } from "./seedRow.js";
@@ -0,0 +1,23 @@
1
+ import type { IEntity } from "@nodefony/orm-core";
2
+ import type { DrizzleOrm } from "./DrizzleOrm.js";
3
+ /**
4
+ * Insère une ligne minimale dans la table d'une entité, et rend son identifiant.
5
+ *
6
+ * Destiné aux **tests** : dès qu'une entité porte une relation (`author:ref:Author`),
7
+ * sa colonne est une vraie clé étrangère, et la base REFUSE un identifiant
8
+ * inventé. La ligne parente doit donc exister avant toute insertion — et c'est
9
+ * la seule façon d'éprouver la contrainte au lieu de la contourner (débrancher
10
+ * `foreign_keys` rendrait le test vert sans rien prouver).
11
+ *
12
+ * Ce qui est rempli : les colonnes **obligatoires sans valeur par défaut**, avec
13
+ * une valeur du type que Drizzle déclare. Le reste est laissé au schéma — une
14
+ * clé primaire auto-générée, un horodatage, un défaut applicatif se posent seuls.
15
+ *
16
+ * @param orm - la connexion sur laquelle semer.
17
+ * @param entity - l'entité cible ; son `schema` est la table Drizzle.
18
+ * @returns l'identifiant de la ligne créée, à poser dans la colonne qui la désigne.
19
+ * @throws Si la cible porte elle-même une clé étrangère obligatoire — le semis à
20
+ * un seul étage ne sait pas la satisfaire, et le message le dit plutôt que de
21
+ * laisser remonter un « FOREIGN KEY constraint failed » sans contexte.
22
+ */
23
+ export declare function seedEntityRow(orm: DrizzleOrm, entity: IEntity): Promise<string | number>;
@@ -162,6 +162,34 @@ Le dialecte est celui de votre connecteur : vos entités sont du Drizzle **natif
162
162
  un moteur. C'est ce qui vous laisse toute la puissance du moteur dans une entité — et ce qui fait
163
163
  qu'une migration vaut pour lui seul.
164
164
 
165
+ ### En développement : écrire et appliquer d'un seul geste — `--apply`
166
+
167
+ ```bash
168
+ nodefony orm:generate --name ajout_du_titre --apply
169
+ ```
170
+
171
+ Vous éditez votre entité, vous tapez **une** commande, la colonne est en base. C'est le geste
172
+ quotidien du développement, et il ne vous demande pas de retenir un second verbe.
173
+
174
+ `--apply` n'invente rien : il **enchaîne `orm:migrate`**. Toutes ses gardes valent donc à
175
+ l'identique — une migration qui supprime des données reste refusée sans `--allow-destructive`, le
176
+ verrou est pris, l'historique est écrit. En sortie machine (`--json`), un seul document est produit,
177
+ celui de l'application : c'est lui qui porte le verdict.
178
+
179
+ > **Le drapeau n'existe qu'en développement**, et aucun autre drapeau ne le déverrouille. Écrire une
180
+ > migration et l'appliquer dans le même souffle suppose qu'on relit le SQL produit entre les deux —
181
+ > ce que personne ne fait sur un serveur. Ailleurs, la migration a été écrite, relue et versionnée
182
+ > ailleurs : c'est `orm:migrate` qui l'applique, et lui seul.
183
+
184
+ > **Et le mode de schéma bascule tout seul.** Dès que votre application versionne sa première
185
+ > migration, le démarrage cesse de dériver les tables depuis le code (`ddl: "auto"`) et applique vos
186
+ > migrations (`ddl: "migrate"`) — même en développement. Deux fabricants du même schéma ne
187
+ > s'accordent pas : le DDL dérivé pose une unicité **collée à la colonne** (`content text NOT NULL
188
+ UNIQUE`), le générateur de migrations raisonne sur un **index nommé** (`messages_content_unique`),
189
+ > et la première migration qui tente de retirer la seconde échoue sur un objet qui n'a jamais existé.
190
+ > Un `ddl` écrit dans votre configuration gagne toujours ; et sous `NODE_ENV=test`, rien ne change
191
+ > (une suite lance un exemplaire par worker, et ses bases partent vides).
192
+
165
193
  ### Ce que la commande refuse, et pourquoi c'est une bonne nouvelle
166
194
 
167
195
  **Une migration est immuable dès qu'une base l'a reçue.** Une migration à laquelle il manque une
@@ -201,7 +229,8 @@ réclame aucun outil tiers.
201
229
  Déclarer une référence vers une table du framework (`session`, `audit_event`…) est légitime : ce
202
230
  qui est refusé, c'est de **ré-exporter** cette table depuis vos fichiers. Les tables du framework
203
231
  sont exclues du plan de votre application — elles ont leurs propres migrations, appliquées avant les
204
- vôtres. Pour une vraie clé étrangère SQL, écrivez une migration libre.
232
+ vôtres. La contrainte, elle, est bien écrite dans votre migration : elle désigne une table que le
233
+ framework a déjà créée.
205
234
 
206
235
  > [!IMPORTANT]
207
236
  > **`User` n'en fait PAS partie.** L'identité est du domaine : la table `User` appartient à votre
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/drizzle",
3
- "version": "10.0.0-alpha.7",
3
+ "version": "10.0.0-alpha.8",
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.5"
61
61
  },
62
62
  "devDependencies": {
63
- "@nodefony/framework": "^10.0.0-alpha.7",
64
- "@nodefony/http": "^10.0.0-alpha.7",
65
- "@nodefony/orm-core": "^10.0.0-alpha.7",
66
- "@nodefony/security": "^10.0.0-alpha.7",
67
- "@nodefony/user": "^10.0.0-alpha.7",
63
+ "@nodefony/framework": "^10.0.0-alpha.8",
64
+ "@nodefony/http": "^10.0.0-alpha.8",
65
+ "@nodefony/orm-core": "^10.0.0-alpha.8",
66
+ "@nodefony/security": "^10.0.0-alpha.8",
67
+ "@nodefony/user": "^10.0.0-alpha.8",
68
68
  "@types/better-sqlite3": "9.6.0",
69
- "@types/node": "26.6.1",
69
+ "@types/node": "26.6.2",
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.7",
74
+ "nodefony": "^10.0.0-alpha.8",
75
75
  "pg": "8.23.0",
76
76
  "rimraf": "6.1.3"
77
77
  },
78
78
  "peerDependencies": {
79
- "@nodefony/framework": "^10.0.0-alpha.7",
80
- "@nodefony/http": "^10.0.0-alpha.7",
81
- "@nodefony/orm-core": "^10.0.0-alpha.7",
82
- "@nodefony/security": "^10.0.0-alpha.7",
83
- "@nodefony/user": "^10.0.0-alpha.7",
79
+ "@nodefony/framework": "^10.0.0-alpha.8",
80
+ "@nodefony/http": "^10.0.0-alpha.8",
81
+ "@nodefony/orm-core": "^10.0.0-alpha.8",
82
+ "@nodefony/security": "^10.0.0-alpha.8",
83
+ "@nodefony/user": "^10.0.0-alpha.8",
84
84
  "better-sqlite3": "^13.0.0",
85
85
  "mysql2": "^3.24.0",
86
- "nodefony": "^10.0.0-alpha.7",
86
+ "nodefony": "^10.0.0-alpha.8",
87
87
  "pg": "^8.23.0"
88
88
  },
89
89
  "repository": {