@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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ Adapter [Drizzle ORM](https://orm.drizzle.team/) pour Nodefony, branché sur
11
11
  ## Installation
12
12
 
13
13
  ```bash
14
- npm install @nodefony/drizzle drizzle-orm better-sqlite3
14
+ npm install @nodefony/drizzle@alpha drizzle-orm better-sqlite3
15
15
  ```
16
16
 
17
17
  ## Utilisation comme module Nodefony (bootable)
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
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";
@@ -36,8 +37,8 @@ import OrmMigrateStatus from "./nodefony/command/orm-migrate-status.js";
36
37
  import OrmMigrateBaseline from "./nodefony/command/orm-migrate-baseline.js";
37
38
  import OrmMigrateRepair from "./nodefony/command/orm-migrate-repair.js";
38
39
  import OrmReset from "./nodefony/command/orm-reset.js";
39
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
40
- import __decorate from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
40
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
41
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
41
42
  import SessionStorage from "./nodefony/src/SessionStorage.js";
42
43
  import "./nodefony/src/migrator/index.js";
43
44
  import { DrizzleUserRepository } from "./nodefony/src/DrizzleUserRepository.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**.
@@ -64,23 +64,82 @@ const PATTERNS = [
64
64
  }
65
65
  ];
66
66
  /**
67
- * Reconnaît la recréation de table de SQLite, et l'exclut de la perte de données.
68
- *
69
- * SQLite ne sait pas modifier une colonne : l'outil de génération produit alors
70
- * la ronde connue — créer `__new_x`, y recopier les lignes de `x`, supprimer
71
- * `x`, renommer. Ce `DROP TABLE` est **structurel**, les données ont été
72
- * recopiées juste avant ; le signaler comme une perte ferait crier au loup à
73
- * chaque changement de colonne, et le garde serait désarmé au bout de trois
74
- * fois.
75
- *
76
- * ⚠️ Ce n'est pas une garantie que rien n'est perdu : si une colonne
77
- * n'apparaît pas dans le `INSERT … SELECT`, ses données disparaissent bel et
78
- * bien. C'est pourquoi la recréation reste SIGNALÉE, avec le geste — lire le
79
- * SQL —, et seulement déclassée en avertissement.
67
+ * Le SQL d'une migration, qu'on le reçoive découpé ou d'un bloc.
68
+ *
69
+ * Les deux formes existent pour de bon : l'audit d'APPLICATION travaille sur
70
+ * les instructions déjà séparées, celui de GÉNÉRATION sur le fichier entier.
80
71
  */
81
- function isTableRebuild(statements) {
82
- const joint = statements.join("\n");
83
- return /\bCREATE\s+TABLE\s+[`"']?__new_/i.test(joint) && /\bINSERT\s+INTO\s+[`"']?__new_/i.test(joint);
72
+ function asSql(sql) {
73
+ return typeof sql === "string" ? sql : sql.join("\n");
74
+ }
75
+ /** Un nom de table tel qu'il s'écrit en SQL, guillemets facultatifs. */
76
+ const IDENT = "[`\"']?(\\w+)[`\"']?";
77
+ /**
78
+ * Les tables qu'une migration RECONSTRUIT, par la ronde que SQLite impose.
79
+ *
80
+ * 🔴 **La convention de nommage n'est PAS la règle.** La version précédente
81
+ * cherchait le préfixe `__new_` que produit l'outil de génération : elle ne
82
+ * reconnaissait donc une reconstruction que si c'est LUI qui l'avait écrite.
83
+ * Une migration reprise à la main — cas courant, et mesuré sur un agent réel
84
+ * qui avait nommé sa table d'étape `articles_new` — retombait sur
85
+ * « supprime une table et TOUTES ses lignes », sur un SQL qui ne perd rien.
86
+ *
87
+ * Ce qui fait une reconstruction, c'est la RONDE, quels que soient les noms :
88
+ * une table d'étape est créée, les lignes de la table visée y sont recopiées,
89
+ * la visée est supprimée, et l'étape prend sa place. Les quatre doivent être
90
+ * présentes dans le même fichier, et se RÉPONDRE — c'est le renommage final
91
+ * qui dit quelle table d'étape appartient à quelle table visée.
92
+ *
93
+ * ⚠️ Ce n'est pas une garantie que rien n'est perdu : si une colonne n'apparaît
94
+ * pas dans le `INSERT … SELECT`, ses données disparaissent bel et bien. La
95
+ * reconstruction reste donc SIGNALÉE, avec le geste — lire le SQL —, et
96
+ * seulement déclassée en avertissement.
97
+ *
98
+ * @param sql - les instructions de la migration, ou le fichier entier.
99
+ * @returns les tables reconstruites, en minuscules ; vide s'il n'y en a aucune.
100
+ */
101
+ function rebuiltTables(sql) {
102
+ const joint = asSql(sql);
103
+ const out = /* @__PURE__ */ new Set();
104
+ const renames = joint.matchAll(new RegExp(`\\bALTER\\s+TABLE\\s+${IDENT}\\s+RENAME\\s+TO\\s+${IDENT}`, "gi"));
105
+ for (const rename of renames) {
106
+ const staging = rename[1];
107
+ const target = rename[2];
108
+ if (staging === void 0 || target === void 0) continue;
109
+ const created = new RegExp(`\\bCREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\`"']?${staging}[\`"']?\\b`, "i");
110
+ const copied = new RegExp(`\\bINSERT\\s+INTO\\s+[\`"']?${staging}[\`"']?[\\s\\S]{0,4000}?\\bFROM\\s+[\`"']?${target}[\`"']?\\b`, "i");
111
+ const dropped = new RegExp(`\\bDROP\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?[\`"']?${target}[\`"']?\\b`, "i");
112
+ if (created.test(joint) && copied.test(joint) && dropped.test(joint)) out.add(target.toLowerCase());
113
+ }
114
+ return out;
115
+ }
116
+ /**
117
+ * Les tables qu'un SQL SUPPRIME, dans l'ordre où il les nomme.
118
+ *
119
+ * Exportée pour que l'audit de génération et celui d'application lisent la
120
+ * MÊME chose : deux extractions écrites séparément divergeraient, et l'une des
121
+ * deux surfaces se mettrait à refuser ce que l'autre laisse passer.
122
+ *
123
+ * @param sql - les instructions de la migration, ou le fichier entier.
124
+ * @returns les noms supprimés, en minuscules.
125
+ */
126
+ function droppedTables(sql) {
127
+ return [...asSql(sql).matchAll(new RegExp(`\\bDROP\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?${IDENT}`, "gi"))].map((m) => m[1]).filter((n) => n !== void 0).map((n) => n.toLowerCase());
128
+ }
129
+ /**
130
+ * L'instruction fait-elle partie de la mécanique d'une reconstruction ?
131
+ *
132
+ * Le verdict porte sur la table que CETTE instruction touche, jamais sur le
133
+ * fichier entier : une migration qui reconstruit `articles` et supprime
134
+ * `brouillons` doit continuer de signaler la seconde.
135
+ */
136
+ function isRebuildMechanics(statement, kind, rebuilt) {
137
+ if (kind === "drop-table") return droppedTables(statement).some((name) => rebuilt.has(name));
138
+ if (kind === "rename") {
139
+ const target = new RegExp(`\\bRENAME\\s+TO\\s+${IDENT}`, "i").exec(statement)?.[1];
140
+ return target !== void 0 && rebuilt.has(target.toLowerCase());
141
+ }
142
+ return false;
84
143
  }
85
144
  /** Borne une instruction pour l'affichage, sans jamais la déformer. */
86
145
  function truncate(statement) {
@@ -96,10 +155,10 @@ function truncate(statement) {
96
155
  function scanDestructive(files) {
97
156
  const out = [];
98
157
  for (const file of files) {
99
- const rebuild = isTableRebuild(file.statements);
158
+ const rebuilt = rebuiltTables(file.statements);
100
159
  for (const statement of file.statements) for (const p of PATTERNS) {
101
160
  if (!p.re.test(statement)) continue;
102
- const structurel = rebuild && (p.kind === "drop-table" || p.kind === "rename");
161
+ const structurel = isRebuildMechanics(statement, p.kind, rebuilt);
103
162
  out.push({
104
163
  source: file.source,
105
164
  tag: file.tag,
@@ -210,4 +269,4 @@ function checkDataAdvice(connector, target) {
210
269
  return `Ces migrations ont modifié des tables qui portaient déjà des lignes. Pour VÉRIFIER que les données ont suivi, ne repars pas d'une base vide : ce sont ces lignes-là qui sont la réponse, les effacer efface la question. Deux moyens. Compter et regarder sur place — « SELECT COUNT(*) » sur les tables touchées, et les valeurs des colonnes nouvellement remplies. Ou rejouer le même lot sur une COPIE, sans toucher à celle-ci : ${copy}, puis désigne-la par NF_MIGRATE_DATABASE_URL :\n NF_MIGRATE_DATABASE_URL=${url} nodefony orm:migrate${suffixe}\n (PowerShell : $env:NF_MIGRATE_DATABASE_URL = "${url}" ; nodefony orm:migrate${suffixe})`;
211
270
  }
212
271
  //#endregion
213
- export { checkDataAdvice, dataLoss, destructiveActions, renderDestructive, scanDestructive, summarizeDestructive, touchesExistingRows };
272
+ export { checkDataAdvice, dataLoss, destructiveActions, droppedTables, rebuiltTables, renderDestructive, scanDestructive, summarizeDestructive, touchesExistingRows };
@@ -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 };
@@ -1,5 +1,6 @@
1
1
  import { FORMAT_MARKER } from "./types.js";
2
2
  import { MigrationToolError, generationFailed, generationNeedsAnswer, generationToolMissing, introspectFailed } from "./refusals.js";
3
+ import { droppedTables, rebuiltTables } from "./destructive.js";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
5
6
  import { spawnSync } from "node:child_process";
@@ -149,6 +150,10 @@ const DESTRUCTIVE_PATTERNS = [
149
150
  {
150
151
  id: "drop-table",
151
152
  pattern: /\bDROP\s+TABLE\b/i,
153
+ detect: (code) => {
154
+ const rebuilt = rebuiltTables(code);
155
+ return droppedTables(code).some((table) => !rebuilt.has(table));
156
+ },
152
157
  what: "supprime une table ET toutes ses lignes",
153
158
  todo: "sauvegarder, puis appliquer en deux temps (cf expand/contract)"
154
159
  },
@@ -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 };