@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.
- package/LICENSE +544 -0
- package/README.md +162 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +105 -0
- package/dist/nodefony/command/migrateShared.js +247 -0
- package/dist/nodefony/command/orm-generate.js +356 -0
- package/dist/nodefony/command/orm-migrate-baseline.js +208 -0
- package/dist/nodefony/command/orm-migrate-repair.js +114 -0
- package/dist/nodefony/command/orm-migrate-status.js +67 -0
- package/dist/nodefony/command/orm-migrate.js +141 -0
- package/dist/nodefony/command/orm-reset.js +166 -0
- package/dist/nodefony/config/config.js +107 -0
- package/dist/nodefony/config/defineModuleConfig.js +63 -0
- package/dist/nodefony/entity/auditEventEntity.js +93 -0
- package/dist/nodefony/entity/colKit.js +260 -0
- package/dist/nodefony/entity/idempotencyEntity.js +74 -0
- package/dist/nodefony/entity/sessionEntity.js +75 -0
- package/dist/nodefony/entity/tokenEntity.js +198 -0
- package/dist/nodefony/entity/totpSecretEntity.js +98 -0
- package/dist/nodefony/entity/userTable.js +141 -0
- package/dist/nodefony/entity/webAuthnCredentialEntity.js +114 -0
- package/dist/nodefony/entity/webhookEndpointEntity.js +106 -0
- package/dist/nodefony/interfaces/IDrizzleConfig.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/migrations-schema/mysql.js +48 -0
- package/dist/nodefony/migrations-schema/postgres.js +48 -0
- package/dist/nodefony/migrations-schema/sqlite.js +48 -0
- package/dist/nodefony/registerStores.js +218 -0
- package/dist/nodefony/service/DrizzleService.js +282 -0
- package/dist/nodefony/src/DrizzleAuditStore.js +203 -0
- package/dist/nodefony/src/DrizzleIdempotencyStore.js +278 -0
- package/dist/nodefony/src/DrizzleTokenStore.js +244 -0
- package/dist/nodefony/src/DrizzleTotpSecretStore.js +151 -0
- package/dist/nodefony/src/DrizzleUserRepository.js +217 -0
- package/dist/nodefony/src/DrizzleWebAuthnCredentialStore.js +159 -0
- package/dist/nodefony/src/DrizzleWebhookStore.js +169 -0
- package/dist/nodefony/src/SessionStorage.js +259 -0
- package/dist/nodefony/src/connectorTarget.js +59 -0
- package/dist/nodefony/src/likeSql.js +50 -0
- package/dist/nodefony/src/migrator/DrizzleMigrator.js +775 -0
- package/dist/nodefony/src/migrator/adopt.js +553 -0
- package/dist/nodefony/src/migrator/appSchema.js +414 -0
- package/dist/nodefony/src/migrator/catalog.js +76 -0
- package/dist/nodefony/src/migrator/destructive.js +213 -0
- package/dist/nodefony/src/migrator/divergence.js +84 -0
- package/dist/nodefony/src/migrator/drivers/index.js +39 -0
- package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +147 -0
- package/dist/nodefony/src/migrator/drivers/postgresDriver.js +151 -0
- package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +121 -0
- package/dist/nodefony/src/migrator/explain.js +565 -0
- package/dist/nodefony/src/migrator/hash.js +47 -0
- package/dist/nodefony/src/migrator/history.js +219 -0
- package/dist/nodefony/src/migrator/index.js +16 -0
- package/dist/nodefony/src/migrator/kit.js +296 -0
- package/dist/nodefony/src/migrator/name.js +68 -0
- package/dist/nodefony/src/migrator/paths.js +88 -0
- package/dist/nodefony/src/migrator/refusals.js +143 -0
- package/dist/nodefony/src/migrator/resolve.js +281 -0
- package/dist/nodefony/src/migrator/schemaDiff.js +86 -0
- package/dist/nodefony/src/migrator/sources.js +419 -0
- package/dist/nodefony/src/migrator/status.js +231 -0
- package/dist/nodefony/src/migrator/types.js +91 -0
- package/dist/nodefony/src/orm-core/DrizzleOrm.js +1154 -0
- package/dist/nodefony/src/orm-core/DrizzleRepository.js +610 -0
- package/dist/nodefony/src/orm-core/DrizzleTransaction.js +106 -0
- package/dist/nodefony/src/orm-core/index.js +4 -0
- package/dist/nodefony/src/queryKit.js +318 -0
- package/dist/nodefony/src/safeTarget.js +55 -0
- package/dist/types/index.d.ts +76 -0
- package/dist/types/nodefony/command/migrateShared.d.ts +137 -0
- package/dist/types/nodefony/command/orm-generate.d.ts +53 -0
- package/dist/types/nodefony/command/orm-migrate-baseline.d.ts +87 -0
- package/dist/types/nodefony/command/orm-migrate-repair.d.ts +66 -0
- package/dist/types/nodefony/command/orm-migrate-status.d.ts +38 -0
- package/dist/types/nodefony/command/orm-migrate.d.ts +65 -0
- package/dist/types/nodefony/command/orm-reset.d.ts +55 -0
- package/dist/types/nodefony/config/config.d.ts +110 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +24 -0
- package/dist/types/nodefony/entity/auditEventEntity.d.ts +56 -0
- package/dist/types/nodefony/entity/colKit.d.ts +130 -0
- package/dist/types/nodefony/entity/idempotencyEntity.d.ts +52 -0
- package/dist/types/nodefony/entity/sessionEntity.d.ts +50 -0
- package/dist/types/nodefony/entity/tokenEntity.d.ts +53 -0
- package/dist/types/nodefony/entity/totpSecretEntity.d.ts +61 -0
- package/dist/types/nodefony/entity/userTable.d.ts +65 -0
- package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +57 -0
- package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +58 -0
- package/dist/types/nodefony/interfaces/IDrizzleConfig.d.ts +17 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/migrations-schema/mysql.d.ts +9 -0
- package/dist/types/nodefony/migrations-schema/postgres.d.ts +9 -0
- package/dist/types/nodefony/migrations-schema/sqlite.d.ts +9 -0
- package/dist/types/nodefony/registerStores.d.ts +52 -0
- package/dist/types/nodefony/service/DrizzleService.d.ts +27 -0
- package/dist/types/nodefony/src/DrizzleAuditStore.d.ts +65 -0
- package/dist/types/nodefony/src/DrizzleIdempotencyStore.d.ts +129 -0
- package/dist/types/nodefony/src/DrizzleTokenStore.d.ts +146 -0
- package/dist/types/nodefony/src/DrizzleTotpSecretStore.d.ts +60 -0
- package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +67 -0
- package/dist/types/nodefony/src/DrizzleWebAuthnCredentialStore.d.ts +62 -0
- package/dist/types/nodefony/src/DrizzleWebhookStore.d.ts +79 -0
- package/dist/types/nodefony/src/SessionStorage.d.ts +72 -0
- package/dist/types/nodefony/src/connectorTarget.d.ts +48 -0
- package/dist/types/nodefony/src/likeSql.d.ts +29 -0
- package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +94 -0
- package/dist/types/nodefony/src/migrator/adopt.d.ts +280 -0
- package/dist/types/nodefony/src/migrator/appSchema.d.ts +223 -0
- package/dist/types/nodefony/src/migrator/catalog.d.ts +100 -0
- package/dist/types/nodefony/src/migrator/destructive.d.ts +123 -0
- package/dist/types/nodefony/src/migrator/divergence.d.ts +61 -0
- package/dist/types/nodefony/src/migrator/drivers/index.d.ts +26 -0
- package/dist/types/nodefony/src/migrator/drivers/mysqlDriver.d.ts +83 -0
- package/dist/types/nodefony/src/migrator/drivers/postgresDriver.d.ts +81 -0
- package/dist/types/nodefony/src/migrator/drivers/sqliteDriver.d.ts +53 -0
- package/dist/types/nodefony/src/migrator/explain.d.ts +424 -0
- package/dist/types/nodefony/src/migrator/hash.d.ts +39 -0
- package/dist/types/nodefony/src/migrator/history.d.ts +139 -0
- package/dist/types/nodefony/src/migrator/index.d.ts +20 -0
- package/dist/types/nodefony/src/migrator/kit.d.ts +141 -0
- package/dist/types/nodefony/src/migrator/name.d.ts +52 -0
- package/dist/types/nodefony/src/migrator/paths.d.ts +54 -0
- package/dist/types/nodefony/src/migrator/refusals.d.ts +170 -0
- package/dist/types/nodefony/src/migrator/resolve.d.ts +201 -0
- package/dist/types/nodefony/src/migrator/schemaDiff.d.ts +112 -0
- package/dist/types/nodefony/src/migrator/sources.d.ts +119 -0
- package/dist/types/nodefony/src/migrator/status.d.ts +132 -0
- package/dist/types/nodefony/src/migrator/types.d.ts +273 -0
- package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +241 -0
- package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +98 -0
- package/dist/types/nodefony/src/orm-core/DrizzleTransaction.d.ts +71 -0
- package/dist/types/nodefony/src/orm-core/index.d.ts +11 -0
- package/dist/types/nodefony/src/queryKit.d.ts +136 -0
- package/dist/types/nodefony/src/safeTarget.d.ts +42 -0
- package/docs/index.md +954 -0
- package/docs/migrations.md +691 -0
- package/migrations/mysql/0000_framework_init.sql +137 -0
- package/migrations/mysql/meta/_journal.json +13 -0
- package/migrations/postgres/0000_framework_init.sql +128 -0
- package/migrations/postgres/meta/_journal.json +13 -0
- package/migrations/sqlite/0000_framework_init.sql +127 -0
- package/migrations/sqlite/meta/_journal.json +13 -0
- 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 };
|