@nodefony/drizzle 10.0.0-alpha.1

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.
Files changed (143) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +162 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +105 -0
  6. package/dist/nodefony/command/migrateShared.js +247 -0
  7. package/dist/nodefony/command/orm-generate.js +356 -0
  8. package/dist/nodefony/command/orm-migrate-baseline.js +208 -0
  9. package/dist/nodefony/command/orm-migrate-repair.js +114 -0
  10. package/dist/nodefony/command/orm-migrate-status.js +67 -0
  11. package/dist/nodefony/command/orm-migrate.js +141 -0
  12. package/dist/nodefony/command/orm-reset.js +166 -0
  13. package/dist/nodefony/config/config.js +107 -0
  14. package/dist/nodefony/config/defineModuleConfig.js +63 -0
  15. package/dist/nodefony/entity/auditEventEntity.js +93 -0
  16. package/dist/nodefony/entity/colKit.js +260 -0
  17. package/dist/nodefony/entity/idempotencyEntity.js +74 -0
  18. package/dist/nodefony/entity/sessionEntity.js +75 -0
  19. package/dist/nodefony/entity/tokenEntity.js +198 -0
  20. package/dist/nodefony/entity/totpSecretEntity.js +98 -0
  21. package/dist/nodefony/entity/userTable.js +141 -0
  22. package/dist/nodefony/entity/webAuthnCredentialEntity.js +114 -0
  23. package/dist/nodefony/entity/webhookEndpointEntity.js +106 -0
  24. package/dist/nodefony/interfaces/IDrizzleConfig.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/migrations-schema/mysql.js +48 -0
  27. package/dist/nodefony/migrations-schema/postgres.js +48 -0
  28. package/dist/nodefony/migrations-schema/sqlite.js +48 -0
  29. package/dist/nodefony/registerStores.js +218 -0
  30. package/dist/nodefony/service/DrizzleService.js +282 -0
  31. package/dist/nodefony/src/DrizzleAuditStore.js +203 -0
  32. package/dist/nodefony/src/DrizzleIdempotencyStore.js +278 -0
  33. package/dist/nodefony/src/DrizzleTokenStore.js +244 -0
  34. package/dist/nodefony/src/DrizzleTotpSecretStore.js +151 -0
  35. package/dist/nodefony/src/DrizzleUserRepository.js +217 -0
  36. package/dist/nodefony/src/DrizzleWebAuthnCredentialStore.js +159 -0
  37. package/dist/nodefony/src/DrizzleWebhookStore.js +169 -0
  38. package/dist/nodefony/src/SessionStorage.js +259 -0
  39. package/dist/nodefony/src/connectorTarget.js +59 -0
  40. package/dist/nodefony/src/likeSql.js +50 -0
  41. package/dist/nodefony/src/migrator/DrizzleMigrator.js +775 -0
  42. package/dist/nodefony/src/migrator/adopt.js +553 -0
  43. package/dist/nodefony/src/migrator/appSchema.js +414 -0
  44. package/dist/nodefony/src/migrator/catalog.js +76 -0
  45. package/dist/nodefony/src/migrator/destructive.js +213 -0
  46. package/dist/nodefony/src/migrator/divergence.js +84 -0
  47. package/dist/nodefony/src/migrator/drivers/index.js +39 -0
  48. package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +147 -0
  49. package/dist/nodefony/src/migrator/drivers/postgresDriver.js +151 -0
  50. package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +121 -0
  51. package/dist/nodefony/src/migrator/explain.js +565 -0
  52. package/dist/nodefony/src/migrator/hash.js +47 -0
  53. package/dist/nodefony/src/migrator/history.js +219 -0
  54. package/dist/nodefony/src/migrator/index.js +16 -0
  55. package/dist/nodefony/src/migrator/kit.js +296 -0
  56. package/dist/nodefony/src/migrator/name.js +68 -0
  57. package/dist/nodefony/src/migrator/paths.js +88 -0
  58. package/dist/nodefony/src/migrator/refusals.js +143 -0
  59. package/dist/nodefony/src/migrator/resolve.js +281 -0
  60. package/dist/nodefony/src/migrator/schemaDiff.js +86 -0
  61. package/dist/nodefony/src/migrator/sources.js +419 -0
  62. package/dist/nodefony/src/migrator/status.js +231 -0
  63. package/dist/nodefony/src/migrator/types.js +91 -0
  64. package/dist/nodefony/src/orm-core/DrizzleOrm.js +1154 -0
  65. package/dist/nodefony/src/orm-core/DrizzleRepository.js +610 -0
  66. package/dist/nodefony/src/orm-core/DrizzleTransaction.js +106 -0
  67. package/dist/nodefony/src/orm-core/index.js +4 -0
  68. package/dist/nodefony/src/queryKit.js +318 -0
  69. package/dist/nodefony/src/safeTarget.js +55 -0
  70. package/dist/types/index.d.ts +76 -0
  71. package/dist/types/nodefony/command/migrateShared.d.ts +137 -0
  72. package/dist/types/nodefony/command/orm-generate.d.ts +53 -0
  73. package/dist/types/nodefony/command/orm-migrate-baseline.d.ts +87 -0
  74. package/dist/types/nodefony/command/orm-migrate-repair.d.ts +66 -0
  75. package/dist/types/nodefony/command/orm-migrate-status.d.ts +38 -0
  76. package/dist/types/nodefony/command/orm-migrate.d.ts +65 -0
  77. package/dist/types/nodefony/command/orm-reset.d.ts +55 -0
  78. package/dist/types/nodefony/config/config.d.ts +110 -0
  79. package/dist/types/nodefony/config/defineModuleConfig.d.ts +24 -0
  80. package/dist/types/nodefony/entity/auditEventEntity.d.ts +56 -0
  81. package/dist/types/nodefony/entity/colKit.d.ts +130 -0
  82. package/dist/types/nodefony/entity/idempotencyEntity.d.ts +52 -0
  83. package/dist/types/nodefony/entity/sessionEntity.d.ts +50 -0
  84. package/dist/types/nodefony/entity/tokenEntity.d.ts +53 -0
  85. package/dist/types/nodefony/entity/totpSecretEntity.d.ts +61 -0
  86. package/dist/types/nodefony/entity/userTable.d.ts +65 -0
  87. package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +57 -0
  88. package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +58 -0
  89. package/dist/types/nodefony/interfaces/IDrizzleConfig.d.ts +17 -0
  90. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  91. package/dist/types/nodefony/migrations-schema/mysql.d.ts +9 -0
  92. package/dist/types/nodefony/migrations-schema/postgres.d.ts +9 -0
  93. package/dist/types/nodefony/migrations-schema/sqlite.d.ts +9 -0
  94. package/dist/types/nodefony/registerStores.d.ts +52 -0
  95. package/dist/types/nodefony/service/DrizzleService.d.ts +27 -0
  96. package/dist/types/nodefony/src/DrizzleAuditStore.d.ts +65 -0
  97. package/dist/types/nodefony/src/DrizzleIdempotencyStore.d.ts +129 -0
  98. package/dist/types/nodefony/src/DrizzleTokenStore.d.ts +146 -0
  99. package/dist/types/nodefony/src/DrizzleTotpSecretStore.d.ts +60 -0
  100. package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +67 -0
  101. package/dist/types/nodefony/src/DrizzleWebAuthnCredentialStore.d.ts +62 -0
  102. package/dist/types/nodefony/src/DrizzleWebhookStore.d.ts +79 -0
  103. package/dist/types/nodefony/src/SessionStorage.d.ts +72 -0
  104. package/dist/types/nodefony/src/connectorTarget.d.ts +48 -0
  105. package/dist/types/nodefony/src/likeSql.d.ts +29 -0
  106. package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +94 -0
  107. package/dist/types/nodefony/src/migrator/adopt.d.ts +280 -0
  108. package/dist/types/nodefony/src/migrator/appSchema.d.ts +223 -0
  109. package/dist/types/nodefony/src/migrator/catalog.d.ts +100 -0
  110. package/dist/types/nodefony/src/migrator/destructive.d.ts +123 -0
  111. package/dist/types/nodefony/src/migrator/divergence.d.ts +61 -0
  112. package/dist/types/nodefony/src/migrator/drivers/index.d.ts +26 -0
  113. package/dist/types/nodefony/src/migrator/drivers/mysqlDriver.d.ts +83 -0
  114. package/dist/types/nodefony/src/migrator/drivers/postgresDriver.d.ts +81 -0
  115. package/dist/types/nodefony/src/migrator/drivers/sqliteDriver.d.ts +53 -0
  116. package/dist/types/nodefony/src/migrator/explain.d.ts +424 -0
  117. package/dist/types/nodefony/src/migrator/hash.d.ts +39 -0
  118. package/dist/types/nodefony/src/migrator/history.d.ts +139 -0
  119. package/dist/types/nodefony/src/migrator/index.d.ts +20 -0
  120. package/dist/types/nodefony/src/migrator/kit.d.ts +141 -0
  121. package/dist/types/nodefony/src/migrator/name.d.ts +52 -0
  122. package/dist/types/nodefony/src/migrator/paths.d.ts +54 -0
  123. package/dist/types/nodefony/src/migrator/refusals.d.ts +170 -0
  124. package/dist/types/nodefony/src/migrator/resolve.d.ts +201 -0
  125. package/dist/types/nodefony/src/migrator/schemaDiff.d.ts +112 -0
  126. package/dist/types/nodefony/src/migrator/sources.d.ts +119 -0
  127. package/dist/types/nodefony/src/migrator/status.d.ts +132 -0
  128. package/dist/types/nodefony/src/migrator/types.d.ts +273 -0
  129. package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +241 -0
  130. package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +98 -0
  131. package/dist/types/nodefony/src/orm-core/DrizzleTransaction.d.ts +71 -0
  132. package/dist/types/nodefony/src/orm-core/index.d.ts +11 -0
  133. package/dist/types/nodefony/src/queryKit.d.ts +136 -0
  134. package/dist/types/nodefony/src/safeTarget.d.ts +42 -0
  135. package/docs/index.md +954 -0
  136. package/docs/migrations.md +691 -0
  137. package/migrations/mysql/0000_framework_init.sql +137 -0
  138. package/migrations/mysql/meta/_journal.json +13 -0
  139. package/migrations/postgres/0000_framework_init.sql +128 -0
  140. package/migrations/postgres/meta/_journal.json +13 -0
  141. package/migrations/sqlite/0000_framework_init.sql +127 -0
  142. package/migrations/sqlite/meta/_journal.json +13 -0
  143. package/package.json +126 -0
@@ -0,0 +1,775 @@
1
+ import { MIGRATE_URL_ENV, MigrationLockTimeoutError, MigrationVerdictError } from "./types.js";
2
+ import { openMigrationDriver } from "./drivers/index.js";
3
+ import { deleteFailed, ensureHistorySchema, finishHistory, forgetEntries, insertHistory, readHistory } from "./history.js";
4
+ import "./paths.js";
5
+ import { createdTables, loadSources } from "./sources.js";
6
+ import path from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import os from "node:os";
9
+ //#region nodefony/src/migrator/DrizzleMigrator.ts
10
+ /** Délai d'attente du verrou, par défaut. */
11
+ const DEFAULT_LOCK_TIMEOUT_MS = 3e4;
12
+ /** Longueur maximale d'un message d'erreur conservé en base. */
13
+ const ERROR_MAX_LENGTH = 2e3;
14
+ /**
15
+ * Applicateur de migrations de schéma — **le composant qui fait passer une base
16
+ * d'une version à la suivante, et qui garde trace de son passage**.
17
+ *
18
+ * Cinq verbes : {@link DrizzleMigrator.status} (lecture seule),
19
+ * {@link DrizzleMigrator.migrate}, {@link DrizzleMigrator.baseline} (adopter une
20
+ * base déjà peuplée), {@link DrizzleMigrator.repair} (lever un marqueur d'échec
21
+ * après inspection).
22
+ *
23
+ * **Pourquoi un applicateur maison** plutôt que celui de drizzle-orm : ce
24
+ * dernier saute des migrations en silence, ne vérifie jamais les empreintes, ne
25
+ * pose aucun verrou et ne rend aucun état — quatre manques dont chacun se paie
26
+ * en production.
27
+ *
28
+ * **Application par IDENTITÉ, pas par horodatage** : la mise à jour du framework
29
+ * insère des migrations « dans le passé » de l'application, par construction.
30
+ * Un applicateur à repère haut sautrait ces migrations sans un mot ; ici, la
31
+ * liste des restantes est un ENSEMBLE — `(source, tag)` absent de l'historique.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const migrator = new DrizzleMigrator({
36
+ * connector: "default",
37
+ * dialect: "sqlite",
38
+ * filename: "var/databases/app.db",
39
+ * sources: [{ name: "framework", dir: frameworkMigrationsDir, rank: 0 }],
40
+ * });
41
+ * const plan = await migrator.status();
42
+ * if (plan.pending.length) await migrator.migrate();
43
+ * ```
44
+ */
45
+ /**
46
+ * Ce qu'il faut savoir pour REPRENDRE après une migration en échec.
47
+ *
48
+ * Écrit en toutes lettres parce que son absence a un coût constaté : le
49
+ * message disait vrai — le marqueur bloque la reprise — sans jamais dire que
50
+ * la reprise EXISTE. Un agent qui ne voit pas de sortie s'en invente une, et
51
+ * celle qu'il trouve seul est de recréer la base, ce qui emporte les données.
52
+ */
53
+ 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é.";
54
+ var DrizzleMigrator = class {
55
+ #options;
56
+ #now;
57
+ /**
58
+ * @param options - connecteur, cible de connexion et registre de sources.
59
+ */
60
+ constructor(options) {
61
+ this.#options = options;
62
+ this.#now = options.now ?? Date.now;
63
+ }
64
+ /** Connecteur servi par cet applicateur. */
65
+ get connector() {
66
+ return this.#options.connector;
67
+ }
68
+ /** Dialecte servi par cet applicateur. */
69
+ get dialect() {
70
+ return this.#options.dialect;
71
+ }
72
+ /**
73
+ * État complet de la migration — **lecture seule, sans verrou ni écriture**.
74
+ *
75
+ * Sert la ligne de commande, le plan d'administration, la porte d'agent et la
76
+ * sonde de disponibilité : un seul producteur pour quatre consommateurs. Elle
77
+ * ne crée pas la table d'historique : une sonde qui écrit dans la base n'est
78
+ * plus une sonde.
79
+ *
80
+ * @returns le plan : appliquées, restantes, dérives, échecs, adoption requise.
81
+ */
82
+ async status() {
83
+ const driver = await openMigrationDriver(this.#options);
84
+ try {
85
+ const history = await driver.tableExists("nodefony_migrations") ? await readHistory(driver) : [];
86
+ const loaded = await loadSources(this.#options.sources, this.#options.dialect);
87
+ return await this.#computePlan(driver, loaded.files, history, loaded.absent);
88
+ } finally {
89
+ await driver.close();
90
+ }
91
+ }
92
+ /**
93
+ * Applique les migrations restantes, sous verrou.
94
+ *
95
+ * L'ordre : verrou, amorçage de la table d'historique, chargement des
96
+ * sources, VALIDATION complète, puis application une par une. La validation
97
+ * précède toute écriture — un refus laisse la base intacte.
98
+ *
99
+ * @param options - assouplissements explicites, tous à `false` par défaut.
100
+ * @returns les migrations effectivement appliquées.
101
+ * @throws MigrationVerdictError si la validation refuse d'aller plus loin.
102
+ */
103
+ async migrate(options = {}) {
104
+ const driver = await openMigrationDriver(this.#options);
105
+ const runId = randomUUID();
106
+ const applied = [];
107
+ const isDryRun = options.dryRun === true;
108
+ try {
109
+ if (!isDryRun) {
110
+ await this.#takeLock(driver);
111
+ await ensureHistorySchema(driver);
112
+ }
113
+ const loaded = await loadSources(this.#options.sources, this.#options.dialect);
114
+ const history = isDryRun && !await driver.tableExists("nodefony_migrations") ? [] : await readHistory(driver);
115
+ const plan = await this.#computePlan(driver, loaded.files, history, loaded.absent);
116
+ this.#assertApplicable(plan, loaded.files, options);
117
+ if (isDryRun) return {
118
+ runId,
119
+ applied: []
120
+ };
121
+ for (const file of plan.pending) applied.push(await this.#apply(driver, file, runId));
122
+ return {
123
+ runId,
124
+ applied
125
+ };
126
+ } finally {
127
+ await driver.unlock().catch(() => void 0);
128
+ await driver.close().catch(() => void 0);
129
+ }
130
+ }
131
+ /**
132
+ * Adopte une base existante : inscrit des migrations **sans exécuter leur SQL**.
133
+ *
134
+ * Toujours **explicite**, jamais automatique : une adoption qui se
135
+ * déclencherait toute seule retirerait le filet qui protège de la pire des
136
+ * erreurs — se tromper de base. Rejouer l'adoption n'inscrit que ce qui
137
+ * manque.
138
+ *
139
+ * ⚠️ **Cette méthode ne vérifie PAS que la base porte l'état qu'elle
140
+ * inscrit.** Le contrôle vit chez son appelant (la commande, qui compare la
141
+ * base au schéma déclaré et refuse `NF_MIGRATE_BASELINE_AMBIGUOUS`). Elle
142
+ * traverse pourtant la frontière du paquet : un consommateur qui l'appelle
143
+ * directement DOIT constater l'écart d'abord (`gapAgainstDeclared`), sans
144
+ * quoi il grave un historique complet devant une base qui ne suit pas — et
145
+ * plus aucune commande n'offre alors de geste, sinon `repair --forget`.
146
+ *
147
+ * @param upTo - dernier tag inscrit (inclus) ; toutes les restantes si omis.
148
+ * @returns les migrations inscrites.
149
+ */
150
+ /**
151
+ * Prend le verrou d'applicateur, ou rend un VERDICT plutôt qu'une erreur nue.
152
+ *
153
+ * Un verrou tenu n'est pas une panne : c'est le déploiement d'à côté qui
154
+ * travaille, et la seule bonne réponse est d'attendre puis de reprendre.
155
+ * C'est précisément ce que le code `NF_MIGRATE_LOCK_TIMEOUT` dit à un
156
+ * orchestrateur — un code publié dans le contrat, avec sa phrase et son
157
+ * propre code de sortie, et que POURTANT personne n'émettait : les pilotes
158
+ * levaient une erreur nue, qui tombait dans le fourre-tout des pannes. Les
159
+ * branches qui attendaient ce code étaient donc inatteignables.
160
+ *
161
+ * @param driver - pilote ouvert sur la base.
162
+ * @throws MigrationVerdictError si le verrou n'est pas obtenu à temps.
163
+ */
164
+ async #takeLock(driver) {
165
+ const timeoutMs = this.#options.lockTimeoutMs ?? 3e4;
166
+ try {
167
+ await driver.lock(timeoutMs);
168
+ } catch (e) {
169
+ if (!(e instanceof MigrationLockTimeoutError)) throw e;
170
+ throw this.#verdict("NF_MIGRATE_LOCK_TIMEOUT", {
171
+ facts: { timeoutMs: String(e.timeoutMs) },
172
+ actions: [{
173
+ command: `nodefony orm:migrate:status --connector ${this.connector} --json`,
174
+ args: [
175
+ "orm:migrate:status",
176
+ "--connector",
177
+ this.connector,
178
+ "--json"
179
+ ]
180
+ }, {
181
+ command: `nodefony orm:migrate --connector ${this.connector}`,
182
+ args: [
183
+ "orm:migrate",
184
+ "--connector",
185
+ this.connector
186
+ ]
187
+ }]
188
+ }, e.message);
189
+ }
190
+ }
191
+ /**
192
+ * Dossier des migrations de l'application, tel qu'on l'écrit dans un GESTE.
193
+ *
194
+ * Dérivé du registre de sources, jamais littéral : une application peut
195
+ * ranger ses migrations ailleurs, et un geste qui nomme un dossier inexistant
196
+ * est un geste qu'on ne peut pas suivre.
197
+ *
198
+ * Écrit avec des barres obliques, toujours : ce chemin VOYAGE — il part dans
199
+ * une commande que quelqu'un copie, y compris sous Windows, où `git` les
200
+ * accepte et où le séparateur natif serait une échappée.
201
+ *
202
+ * @returns le chemin à citer, terminé par une barre.
203
+ */
204
+ #migrationsPath() {
205
+ const app = this.#options.sources.find((s) => s.name === "app");
206
+ if (app === void 0) return "migrations/";
207
+ return `${path.basename(app.dir).split(path.sep).join("/")}/`;
208
+ }
209
+ async baseline(upTo) {
210
+ const driver = await openMigrationDriver(this.#options);
211
+ const runId = randomUUID();
212
+ const adopted = [];
213
+ try {
214
+ await this.#takeLock(driver);
215
+ await ensureHistorySchema(driver);
216
+ const loaded = await loadSources(this.#options.sources, this.#options.dialect);
217
+ if (upTo !== void 0) this.#assertKnownTag(upTo, loaded.files);
218
+ const history = await readHistory(driver);
219
+ const known = new Set(history.map((row) => identity(row)));
220
+ for (const file of loaded.files) {
221
+ const isBoundary = upTo !== void 0 && file.tag === upTo;
222
+ if (!known.has(identity(file))) {
223
+ const at = this.#now();
224
+ await insertHistory(driver, {
225
+ source: file.source,
226
+ tag: file.tag,
227
+ hash: file.hash,
228
+ runId,
229
+ startedAt: at,
230
+ finishedAt: at,
231
+ executionMs: 0,
232
+ success: true,
233
+ error: null,
234
+ appliedBy: this.#appliedBy()
235
+ });
236
+ adopted.push({
237
+ source: file.source,
238
+ tag: file.tag,
239
+ executionMs: 0
240
+ });
241
+ }
242
+ if (isBoundary) break;
243
+ }
244
+ return adopted;
245
+ } finally {
246
+ await driver.unlock().catch(() => void 0);
247
+ await driver.close().catch(() => void 0);
248
+ }
249
+ }
250
+ /**
251
+ * Lève les marqueurs d'échec, **après inspection humaine**.
252
+ *
253
+ * Ce n'est pas une reprise : MySQL n'a pas de DDL transactionnel, donc une
254
+ * migration interrompue y laisse un état partiel que seul un humain peut
255
+ * qualifier. Réparer dit « j'ai regardé, la base est dans l'état que je
256
+ * crois » — ensuite seulement la migration se rejoue.
257
+ *
258
+ * @param options - source à réparer, et ré-alignement d'empreintes assumé.
259
+ * @returns ce qui a été levé et ré-aligné.
260
+ */
261
+ async repair(options = {}) {
262
+ if (options.source !== void 0) this.#assertKnownSource(options.source);
263
+ for (const target of options.forget ?? []) this.#assertKnownSource(target.source);
264
+ const driver = await openMigrationDriver(this.#options);
265
+ try {
266
+ await this.#takeLock(driver);
267
+ await ensureHistorySchema(driver);
268
+ const forgotten = options.forget === void 0 || options.forget.length === 0 ? [] : await forgetEntries(driver, options.forget);
269
+ const cleared = await deleteFailed(driver, options.source);
270
+ const rehashed = [];
271
+ if (options.updateHashes === true) {
272
+ const loaded = await loadSources(this.#options.sources, this.#options.dialect);
273
+ const byIdentity = new Map(loaded.files.map((file) => [identity(file), file]));
274
+ for (const row of await readHistory(driver)) {
275
+ const file = byIdentity.get(identity(row));
276
+ if (!file || file.hash === row.hash) continue;
277
+ await finishHistory(driver, {
278
+ ...row,
279
+ hash: file.hash
280
+ });
281
+ rehashed.push({
282
+ source: row.source,
283
+ tag: row.tag
284
+ });
285
+ }
286
+ }
287
+ return {
288
+ cleared,
289
+ rehashed,
290
+ forgotten
291
+ };
292
+ } finally {
293
+ await driver.unlock().catch(() => void 0);
294
+ await driver.close().catch(() => void 0);
295
+ }
296
+ }
297
+ /**
298
+ * Croise fichiers et historique — le calcul commun à `status` et `migrate`.
299
+ *
300
+ * @param driver - pilote ouvert (introspection de la garde d'adoption).
301
+ * @param files - fichiers de toutes les sources présentes.
302
+ * @param history - lignes de la table d'historique.
303
+ * @param absent - sources déclarées dont le dossier n'existe pas.
304
+ * @returns le plan, sans jamais lever : un refus est un CHAMP du plan.
305
+ */
306
+ async #computePlan(driver, files, history, absent) {
307
+ const byIdentity = new Map(files.map((file) => [identity(file), file]));
308
+ const declared = new Set(this.#options.sources.map((s) => s.name));
309
+ const present = new Set(this.#options.sources.filter((s) => !absent.includes(s.name)).map((s) => s.name));
310
+ const succeeded = /* @__PURE__ */ new Set();
311
+ const failed = [];
312
+ const drifted = [];
313
+ const missing = [];
314
+ const ignoredSources = /* @__PURE__ */ new Set();
315
+ for (const row of history) {
316
+ if (!declared.has(row.source)) {
317
+ ignoredSources.add(row.source);
318
+ continue;
319
+ }
320
+ if (!row.success || row.finishedAt === null) {
321
+ failed.push(row);
322
+ continue;
323
+ }
324
+ succeeded.add(identity(row));
325
+ const file = byIdentity.get(identity(row));
326
+ if (!file) {
327
+ if (present.has(row.source)) missing.push({
328
+ source: row.source,
329
+ tag: row.tag
330
+ });
331
+ continue;
332
+ }
333
+ if (file.hash !== row.hash) drifted.push({
334
+ source: row.source,
335
+ tag: row.tag,
336
+ expected: row.hash,
337
+ actual: file.hash
338
+ });
339
+ }
340
+ const pending = files.filter((file) => !succeeded.has(identity(file)));
341
+ return {
342
+ connector: this.#options.connector,
343
+ dialect: this.#options.dialect,
344
+ applied: history.filter((row) => row.success && row.finishedAt !== null),
345
+ pending,
346
+ drifted,
347
+ failed,
348
+ missing,
349
+ ignoredSources: [...ignoredSources],
350
+ baselineRequired: history.length === 0 && await hasAnyTable(driver, createdTables(pending))
351
+ };
352
+ }
353
+ /**
354
+ * Refuse d'appliquer un plan qui ne le permet pas — **fail-loud, avant toute
355
+ * écriture**.
356
+ *
357
+ * @param plan - plan calculé.
358
+ * @param files - fichiers chargés, qui portent l'index de journal.
359
+ * @param options - assouplissements explicitement demandés.
360
+ * @throws MigrationVerdictError portant le verdict structuré.
361
+ */
362
+ #assertApplicable(plan, files, options) {
363
+ const first = plan.failed[0];
364
+ if (first) throw this.#verdict("NF_MIGRATE_FAILED_MARKER", {
365
+ source: first.source,
366
+ tag: first.tag,
367
+ facts: {
368
+ failed: plan.failed.map((row) => `${row.source}/${row.tag}`),
369
+ error: first.error ?? "interrompue avant la fin"
370
+ },
371
+ actions: this.#recoveryActions()
372
+ }, `La migration « ${first.tag} » (source « ${first.source} ») a échoué ou n'a jamais fini. Inspecter la base, puis réparer avant de reprendre — une reprise aveugle n'est jamais sûre.\n\n${REPRISE_SANS_DESTRUCTION}`);
373
+ const drift = plan.drifted[0];
374
+ if (drift) throw this.#verdict("NF_MIGRATE_HASH_MISMATCH", {
375
+ source: drift.source,
376
+ tag: drift.tag,
377
+ facts: {
378
+ expected: drift.expected,
379
+ actual: drift.actual
380
+ },
381
+ actions: [{
382
+ command: `git checkout -- ${this.#migrationsPath()}`,
383
+ args: [
384
+ "checkout",
385
+ "--",
386
+ this.#migrationsPath()
387
+ ]
388
+ }, {
389
+ command: `nodefony orm:migrate:repair --update-hashes --connector ${this.connector}`,
390
+ args: [
391
+ "orm:migrate:repair",
392
+ "--update-hashes",
393
+ "--connector",
394
+ this.connector
395
+ ]
396
+ }]
397
+ }, `Le fichier de la migration « ${drift.tag} » (source « ${drift.source} ») a changé depuis son application. Une migration déjà jouée est immuable : écrire une NOUVELLE migration, ou assumer le ré-alignement.`);
398
+ const gone = plan.missing[0];
399
+ if (gone && options.ignoreMissing !== true) throw this.#verdict("NF_MIGRATE_MISSING_FILE", {
400
+ source: gone.source,
401
+ tag: gone.tag,
402
+ facts: { missing: plan.missing.map((m) => `${m.source}/${m.tag}`) },
403
+ actions: [{
404
+ command: `nodefony orm:migrate --ignore-missing --connector ${this.connector}`,
405
+ args: [
406
+ "orm:migrate",
407
+ "--ignore-missing",
408
+ "--connector",
409
+ this.connector
410
+ ]
411
+ }]
412
+ }, `La migration « ${gone.tag} » est enregistrée en base mais son fichier a disparu de la source « ${gone.source} », qui est pourtant installée.`);
413
+ if (options.outOfOrder !== true) {
414
+ const outOfOrder = this.#findOutOfOrder(plan, files);
415
+ if (outOfOrder) throw this.#verdict("NF_MIGRATE_OUT_OF_ORDER", {
416
+ source: outOfOrder.source,
417
+ tag: outOfOrder.tag,
418
+ facts: {
419
+ idx: outOfOrder.idx,
420
+ lastApplied: outOfOrder.lastApplied
421
+ },
422
+ actions: [{
423
+ command: `nodefony orm:migrate --out-of-order --connector ${this.connector}`,
424
+ args: [
425
+ "orm:migrate",
426
+ "--out-of-order",
427
+ "--connector",
428
+ this.connector
429
+ ]
430
+ }]
431
+ }, `La migration « ${outOfOrder.tag} » se range AVANT « ${outOfOrder.lastApplied} », déjà appliquée dans la source « ${outOfOrder.source} ». L'appliquer maintenant produirait une base dont l'histoire n'est pas celle des autres.`);
432
+ }
433
+ if (plan.baselineRequired) throw this.#verdict("NF_MIGRATE_BASELINE_REQUIRED", {
434
+ facts: { pending: plan.pending.map((file) => file.tag) },
435
+ actions: [{
436
+ command: `nodefony orm:migrate:baseline --connector ${this.connector}`,
437
+ args: [
438
+ "orm:migrate:baseline",
439
+ "--connector",
440
+ this.connector
441
+ ]
442
+ }]
443
+ }, "Cette base porte déjà les tables du schéma mais n'a aucun historique de migration : elle est antérieure aux migrations. L'adopter explicitement (baseline) avant d'appliquer quoi que ce soit.");
444
+ }
445
+ /**
446
+ * Cherche une migration restante antérieure à la dernière appliquée de SA source.
447
+ *
448
+ * Le contrôle est **par source** : la mise à jour du framework insère
449
+ * légitimement des migrations dans le passé de l'application — c'est
450
+ * l'intérieur d'une même source qui doit rester ordonné. L'index vient du
451
+ * JOURNAL, jamais d'une lecture du tag : un tag est une identité, pas un
452
+ * nombre, et rien n'oblige un module tiers à le préfixer de chiffres.
453
+ *
454
+ * @param plan - plan calculé.
455
+ * @param files - fichiers chargés, qui portent l'index de journal.
456
+ * @returns la première migration hors ordre, ou `undefined`.
457
+ */
458
+ #findOutOfOrder(plan, files) {
459
+ const appliedIdentities = new Set(plan.applied.map((row) => identity(row)));
460
+ const highest = /* @__PURE__ */ new Map();
461
+ for (const file of files) {
462
+ if (!appliedIdentities.has(identity(file))) continue;
463
+ const current = highest.get(file.source);
464
+ if (!current || file.idx > current.idx) highest.set(file.source, {
465
+ tag: file.tag,
466
+ idx: file.idx
467
+ });
468
+ }
469
+ for (const file of plan.pending) {
470
+ const high = highest.get(file.source);
471
+ if (high && file.idx < high.idx) return {
472
+ source: file.source,
473
+ tag: file.tag,
474
+ idx: file.idx,
475
+ lastApplied: high.tag
476
+ };
477
+ }
478
+ }
479
+ /**
480
+ * Applique UNE migration, selon ce que le dialecte garantit.
481
+ *
482
+ * PostgreSQL et SQLite ont un DDL transactionnel : le schéma et sa trace
483
+ * entrent ensemble ou pas du tout, et le marqueur d'échec est écrit HORS de
484
+ * la transaction annulée — sinon il disparaîtrait avec elle. MySQL n'a pas ce
485
+ * luxe : la trace de début est posée AVANT, et un process tué en plein vol
486
+ * laisse une ligne sans fin, que la validation refusera au prochain passage.
487
+ *
488
+ * @param driver - pilote sous verrou.
489
+ * @param file - migration à appliquer.
490
+ * @param runId - identifiant du run.
491
+ * @returns ce qui a été appliqué, et en combien de temps.
492
+ */
493
+ async #apply(driver, file, runId) {
494
+ const startedAt = this.#now();
495
+ const began = performance.now();
496
+ const row = {
497
+ source: file.source,
498
+ tag: file.tag,
499
+ hash: file.hash,
500
+ runId,
501
+ startedAt,
502
+ finishedAt: null,
503
+ executionMs: null,
504
+ success: false,
505
+ error: null,
506
+ appliedBy: this.#appliedBy()
507
+ };
508
+ if (!driver.transactionalDdl) {
509
+ await insertHistory(driver, row);
510
+ try {
511
+ for (const statement of file.statements) await driver.exec(statement);
512
+ } catch (e) {
513
+ await finishHistory(driver, {
514
+ ...row,
515
+ finishedAt: this.#now(),
516
+ executionMs: Math.round(performance.now() - began),
517
+ success: false,
518
+ error: truncate(e.message)
519
+ });
520
+ throw this.#applyFailure(file, e, false);
521
+ }
522
+ const executionMs = Math.round(performance.now() - began);
523
+ await finishHistory(driver, {
524
+ ...row,
525
+ finishedAt: this.#now(),
526
+ executionMs,
527
+ success: true
528
+ });
529
+ return {
530
+ source: file.source,
531
+ tag: file.tag,
532
+ executionMs
533
+ };
534
+ }
535
+ await driver.begin();
536
+ try {
537
+ if ((await readHistory(driver)).some((r) => r.source === file.source && r.tag === file.tag)) return {
538
+ source: file.source,
539
+ tag: file.tag,
540
+ executionMs: Math.round(performance.now() - began)
541
+ };
542
+ for (const statement of file.statements) await driver.exec(statement);
543
+ const executionMs = Math.round(performance.now() - began);
544
+ await insertHistory(driver, {
545
+ ...row,
546
+ finishedAt: this.#now(),
547
+ executionMs,
548
+ success: true
549
+ });
550
+ await driver.commit();
551
+ return {
552
+ source: file.source,
553
+ tag: file.tag,
554
+ executionMs
555
+ };
556
+ } catch (e) {
557
+ await driver.rollback().catch(() => void 0);
558
+ await insertHistory(driver, {
559
+ ...row,
560
+ finishedAt: this.#now(),
561
+ executionMs: Math.round(performance.now() - began),
562
+ success: false,
563
+ error: truncate(e.message)
564
+ }).catch(() => void 0);
565
+ throw this.#applyFailure(file, e, true);
566
+ }
567
+ }
568
+ /**
569
+ * Habille l'échec d'une migration en VERDICT, plutôt qu'en exception nue.
570
+ *
571
+ * C'est le cas d'incident nominal du produit : un travail de déploiement
572
+ * applique, et la quatrième migration bute sur une contrainte. Laissée nue,
573
+ * l'erreur tombait dans le fourre-tout des pannes, qui affirme trois choses
574
+ * fausses au pire moment — que la base n'a pas répondu (elle a très bien
575
+ * répondu, c'est le SQL qui a échoué), que rien n'a été modifié (le marqueur
576
+ * d'échec vient d'être posé, les migrations précédentes du même passage sont
577
+ * appliquées, et sur un moteur sans DDL transactionnel la moitié de la
578
+ * fautive peut être en place), et en rendant 2 — « la commande n'a pas pu
579
+ * travailler » — là où la grille range un échec de migration en 1, celui qui
580
+ * appelle un humain. Un orchestrateur qui réessaie sur 2 rejouait un échec
581
+ * déterministe.
582
+ *
583
+ * @param file - migration qui a échoué.
584
+ * @param cause - erreur rendue par le moteur.
585
+ * @param transactionnel - le DDL de ce moteur est-il transactionnel ?
586
+ * @returns le verdict à lever.
587
+ */
588
+ #applyFailure(file, cause, transactionnel) {
589
+ const state = transactionnel ? `Cette migration a été ANNULÉE — le schéma de ce moteur entre ou n'entre pas, jamais à moitié. Les migrations appliquées AVANT elle, dans ce même passage, restent en place.` : `Ce moteur n'annule pas le schéma : cette migration peut être appliquée À MOITIÉ. Inspecter la base avant toute reprise — c'est pour cela que la reprise n'est pas automatique.`;
590
+ return this.#verdict("NF_MIGRATE_FAILED_MARKER", {
591
+ source: file.source,
592
+ tag: file.tag,
593
+ facts: { error: truncate(cause.message) },
594
+ 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}`);
596
+ }
597
+ /**
598
+ * Les gestes qui sortent d'une migration en échec — **sans toucher à la base**.
599
+ *
600
+ * Ce refus ne proposait que d'inspecter et de lever le marqueur. Les deux
601
+ * sont vrais, et aucun ne dit ce qu'il advient du fichier fautif : il est
602
+ * toujours là, il rééchouera au passage suivant. Mesuré sur le banc de
603
+ * découvrabilité, un agent placé devant ce message a écrit « je vais
604
+ * réinitialiser la base et réécrire la migration » — non par désinvolture,
605
+ * mais parce que **recréer la base était le seul geste de reprise qu'il
606
+ * voyait**. Une migration qui échoue est pourtant l'incident le plus
607
+ * ordinaire du métier, et le produit sait en sortir de deux façons.
608
+ *
609
+ * Les deux sont donc NOMMÉES, dans l'ordre où on s'en sert : corriger le
610
+ * fichier puis reprendre — ce qui fonctionne parce qu'une entrée en échec
611
+ * n'entre jamais dans les fichiers dont l'empreinte est comparée
612
+ * (`#plan` : `success === false` sort avant le contrôle d'empreinte), donc
613
+ * un fichier corrigé après un échec est rejoué sans réclamer d'alignement —
614
+ * et mettre au point sur une base d'essai, ce qui est exactement ce que
615
+ * {@link MIGRATE_URL_ENV} sert à faire.
616
+ *
617
+ * @returns les gestes, du plus direct au plus assumé.
618
+ */
619
+ #recoveryActions() {
620
+ return [
621
+ {
622
+ command: `nodefony orm:migrate:status --connector ${this.connector} --json`,
623
+ args: [
624
+ "orm:migrate:status",
625
+ "--connector",
626
+ this.connector,
627
+ "--json"
628
+ ]
629
+ },
630
+ {
631
+ command: `nodefony orm:migrate:repair --connector ${this.connector}`,
632
+ args: [
633
+ "orm:migrate:repair",
634
+ "--connector",
635
+ this.connector
636
+ ]
637
+ },
638
+ {
639
+ command: `nodefony orm:migrate --connector ${this.connector}`,
640
+ args: [
641
+ "orm:migrate",
642
+ "--connector",
643
+ this.connector
644
+ ]
645
+ }
646
+ ];
647
+ }
648
+ /**
649
+ * Fabrique une erreur portant son verdict structuré.
650
+ *
651
+ * @param code - code stable du refus.
652
+ * @param detail - source, tag, faits et actions.
653
+ * @param message - phrase française pour un humain.
654
+ * @returns l'erreur, prête à être levée.
655
+ */
656
+ #verdict(code, detail, message) {
657
+ return new MigrationVerdictError({
658
+ code,
659
+ connector: this.connector,
660
+ source: detail.source,
661
+ tag: detail.tag,
662
+ facts: detail.facts,
663
+ nextActions: detail.actions
664
+ }, message);
665
+ }
666
+ /**
667
+ * Refuse un `--up-to` qui ne désigne aucune migration connue.
668
+ *
669
+ * Sans ce contrôle, la boucle d'adoption ne rencontre jamais sa condition
670
+ * d'arrêt et inscrit **tout** l'historique : une faute de frappe déclare à
671
+ * niveau des migrations que la base n'a jamais reçues, et elle ne les
672
+ * recevra plus jamais. Le geste le plus destructeur de la chaîne est aussi
673
+ * celui qui se tapait sans filet.
674
+ *
675
+ * @param upTo - tag demandé.
676
+ * @param files - fichiers connus, toutes sources confondues.
677
+ * @throws MigrationVerdictError quand le tag est inconnu.
678
+ */
679
+ #assertKnownTag(upTo, files) {
680
+ if (files.some((file) => file.tag === upTo)) return;
681
+ const tags = files.map((file) => file.tag);
682
+ const casedTag = tags.find((tag) => tag.toLowerCase() === upTo.toLowerCase());
683
+ const action = casedTag ?? tags[tags.length - 1];
684
+ throw this.#verdict("NF_MIGRATE_UNKNOWN_TAG", {
685
+ tag: upTo,
686
+ facts: {
687
+ known: tags,
688
+ ...casedTag ? { caseMismatch: casedTag } : {}
689
+ },
690
+ actions: action ? [{
691
+ command: `nodefony orm:migrate:baseline --connector ${this.connector} --up-to ${action}`,
692
+ args: [
693
+ "orm:migrate:baseline",
694
+ "--connector",
695
+ this.connector,
696
+ "--up-to",
697
+ action
698
+ ]
699
+ }] : [{
700
+ command: `nodefony orm:migrate:status --connector ${this.connector}`,
701
+ args: [
702
+ "orm:migrate:status",
703
+ "--connector",
704
+ this.connector
705
+ ]
706
+ }]
707
+ }, casedTag ? `Le tag « ${upTo} » n'existe pas, mais « ${casedTag} » oui : un tag de migration est SENSIBLE à la casse. Sans ce refus, l'adoption ne se serait arrêtée nulle part et aurait déclaré à niveau TOUTES les migrations connues.` : `Le tag « ${upTo} » ne désigne aucune migration connue. L'adoption s'arrête ici plutôt que de déclarer à niveau TOUT l'historique : une base ne reçoit jamais une migration qu'elle croit déjà avoir.`);
708
+ }
709
+ /**
710
+ * Refuse un `--source` que cette application ne déclare pas.
711
+ *
712
+ * Le filtre part sinon en SQL sur un nom qui n'existe pas : zéro ligne
713
+ * touchée, code 0, « Rien à réparer ». L'exploitant croit avoir réparé et
714
+ * relance une migration qui échouera pour la même raison qu'avant.
715
+ *
716
+ * @param source - nom demandé.
717
+ * @throws MigrationVerdictError quand la source n'est pas déclarée.
718
+ */
719
+ #assertKnownSource(source) {
720
+ const names = this.#options.sources.map((s) => s.name);
721
+ if (names.includes(source)) return;
722
+ const casedTag = names.find((name) => name.toLowerCase() === source.toLowerCase());
723
+ throw this.#verdict("NF_MIGRATE_UNKNOWN_SOURCE", {
724
+ source,
725
+ facts: {
726
+ known: names,
727
+ ...casedTag ? { caseMismatch: casedTag } : {}
728
+ },
729
+ actions: [{
730
+ command: casedTag ? `nodefony orm:migrate:repair --connector ${this.connector} --source ${casedTag}` : `nodefony orm:migrate:repair --connector ${this.connector}`,
731
+ args: casedTag ? [
732
+ "orm:migrate:repair",
733
+ "--connector",
734
+ this.connector,
735
+ "--source",
736
+ casedTag
737
+ ] : [
738
+ "orm:migrate:repair",
739
+ "--connector",
740
+ this.connector
741
+ ]
742
+ }]
743
+ }, casedTag ? `La source « ${source} » n'est pas déclarée, mais « ${casedTag} » oui : un nom de source est SENSIBLE à la casse. Réparer sur un nom inconnu ne touche rien et rend pourtant « rien à réparer ».` : `La source « ${source} » n'est pas déclarée par cette application. Réparer sur un nom inconnu ne touche rien et rend pourtant « rien à réparer » — le marqueur d'échec resterait en place.`);
744
+ }
745
+ /** Qui a appliqué — l'hôte du job, sauf indication contraire. */
746
+ #appliedBy() {
747
+ return this.#options.appliedBy ?? os.hostname();
748
+ }
749
+ };
750
+ /** Identité d'une migration : `(source, tag)`, jamais un horodatage. */
751
+ function identity(row) {
752
+ return `${row.source}\0${row.tag}`;
753
+ }
754
+ /**
755
+ * Au moins une de ces tables existe-t-elle déjà ?
756
+ *
757
+ * @param driver - pilote ouvert.
758
+ * @param tables - tables que les migrations restantes créeraient.
759
+ * @returns `true` dès la première trouvée.
760
+ */
761
+ async function hasAnyTable(driver, tables) {
762
+ for (const table of tables) if (await driver.tableExists(table)) return true;
763
+ return false;
764
+ }
765
+ /**
766
+ * Borne un message d'erreur avant de l'écrire en base.
767
+ *
768
+ * @param message - message brut.
769
+ * @returns le message, tronqué si nécessaire.
770
+ */
771
+ function truncate(message) {
772
+ return message.length > ERROR_MAX_LENGTH ? `${message.slice(0, ERROR_MAX_LENGTH)}…` : message;
773
+ }
774
+ //#endregion
775
+ export { DEFAULT_LOCK_TIMEOUT_MS, DrizzleMigrator };