@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 +2 -1
- package/dist/nodefony/command/orm-generate.js +70 -6
- package/dist/nodefony/service/DrizzleService.js +2 -2
- package/dist/nodefony/src/migrator/DrizzleMigrator.js +17 -1
- package/dist/nodefony/src/migrator/explain.js +42 -1
- package/dist/nodefony/src/migrator/resolve.js +74 -4
- package/dist/nodefony/src/orm-core/DrizzleOrm.js +121 -21
- package/dist/nodefony/src/orm-core/ddlPlan.js +95 -0
- package/dist/nodefony/src/orm-core/index.js +2 -1
- package/dist/nodefony/src/orm-core/seedRow.js +65 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/nodefony/command/orm-generate.d.ts +12 -0
- package/dist/types/nodefony/src/migrator/explain.d.ts +19 -0
- package/dist/types/nodefony/src/migrator/resolve.d.ts +54 -1
- package/dist/types/nodefony/src/orm-core/ddlPlan.d.ts +96 -0
- package/dist/types/nodefony/src/orm-core/index.d.ts +1 -0
- package/dist/types/nodefony/src/orm-core/seedRow.d.ts +23 -0
- package/docs/migrations.md +30 -1
- package/package.json +14 -14
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
277
|
-
* DANS le `CREATE TABLE
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
853
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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>;
|
package/docs/migrations.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
64
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
65
|
-
"@nodefony/orm-core": "^10.0.0-alpha.
|
|
66
|
-
"@nodefony/security": "^10.0.0-alpha.
|
|
67
|
-
"@nodefony/user": "^10.0.0-alpha.
|
|
63
|
+
"@nodefony/framework": "^10.0.0-alpha.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.
|
|
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.
|
|
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.
|
|
80
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
81
|
-
"@nodefony/orm-core": "^10.0.0-alpha.
|
|
82
|
-
"@nodefony/security": "^10.0.0-alpha.
|
|
83
|
-
"@nodefony/user": "^10.0.0-alpha.
|
|
79
|
+
"@nodefony/framework": "^10.0.0-alpha.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.
|
|
86
|
+
"nodefony": "^10.0.0-alpha.8",
|
|
87
87
|
"pg": "^8.23.0"
|
|
88
88
|
},
|
|
89
89
|
"repository": {
|