@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,213 @@
|
|
|
1
|
+
//#region nodefony/src/migrator/destructive.ts
|
|
2
|
+
/** Longueur au-delà de laquelle une instruction est tronquée à l'affichage. */
|
|
3
|
+
const MAX_STATEMENT = 300;
|
|
4
|
+
/**
|
|
5
|
+
* Formes reconnues, de la plus grave à la plus bénigne.
|
|
6
|
+
*
|
|
7
|
+
* L'ordre compte : la première qui correspond gagne, pour qu'une instruction ne
|
|
8
|
+
* soit pas signalée deux fois sous deux noms.
|
|
9
|
+
*/
|
|
10
|
+
const PATTERNS = [
|
|
11
|
+
{
|
|
12
|
+
kind: "drop-database",
|
|
13
|
+
severity: "data-loss",
|
|
14
|
+
re: /\bDROP\s+(DATABASE|SCHEMA)\b/i,
|
|
15
|
+
what: "supprime une base ou un schéma ENTIER — tout ce qu'il contient disparaît"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
kind: "drop-table",
|
|
19
|
+
severity: "data-loss",
|
|
20
|
+
re: /\bDROP\s+TABLE\b/i,
|
|
21
|
+
what: "supprime une table et TOUTES ses lignes"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
kind: "drop-column",
|
|
25
|
+
severity: "data-loss",
|
|
26
|
+
re: /\bDROP\s+COLUMN\b|\bALTER\s+TABLE\b[\s\S]*?\bDROP\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|PRIMARY\b|FOREIGN\b|UNIQUE\b|CHECK\b|DEFAULT\b|NOT\s+NULL\b|IDENTITY\b|EXPRESSION\b)[`"']?\w+/i,
|
|
27
|
+
what: "supprime une colonne et TOUTES ses valeurs"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
kind: "truncate",
|
|
31
|
+
severity: "data-loss",
|
|
32
|
+
re: /\bTRUNCATE\b/i,
|
|
33
|
+
what: "vide une table de toutes ses lignes"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
kind: "delete-all",
|
|
37
|
+
severity: "data-loss",
|
|
38
|
+
re: /\bDELETE\s+FROM\b(?![\s\S]*\bWHERE\b)/i,
|
|
39
|
+
what: "supprime toutes les lignes d'une table (aucun filtre `WHERE`)"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
kind: "alter-type",
|
|
43
|
+
severity: "breaking",
|
|
44
|
+
re: /\b(ALTER\s+COLUMN[\s\S]*\bTYPE\b|MODIFY\s+COLUMN\b|ALTER\s+COLUMN[\s\S]*\bSET\s+DATA\s+TYPE\b)/i,
|
|
45
|
+
what: "change le type d'une colonne — la conversion peut tronquer une valeur ou échouer sur les lignes existantes"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
kind: "set-not-null",
|
|
49
|
+
severity: "breaking",
|
|
50
|
+
re: /\bSET\s+NOT\s+NULL\b/i,
|
|
51
|
+
what: "rend une colonne obligatoire — échoue si des lignes ont une valeur vide"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
kind: "rename",
|
|
55
|
+
severity: "breaking",
|
|
56
|
+
re: /\bRENAME\s+(TABLE|COLUMN|TO)\b/i,
|
|
57
|
+
what: "renomme une table ou une colonne — le code de la version précédente ne la trouvera plus"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
kind: "drop-constraint",
|
|
61
|
+
severity: "breaking",
|
|
62
|
+
re: /\bDROP\s+(CONSTRAINT|INDEX|PRIMARY\s+KEY|FOREIGN\s+KEY)\b/i,
|
|
63
|
+
what: "retire une garantie d'intégrité ou un index — les données restent, les protections non"
|
|
64
|
+
}
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* Reconnaît la recréation de table de SQLite, et l'exclut de la perte de données.
|
|
68
|
+
*
|
|
69
|
+
* SQLite ne sait pas modifier une colonne : l'outil de génération produit alors
|
|
70
|
+
* la ronde connue — créer `__new_x`, y recopier les lignes de `x`, supprimer
|
|
71
|
+
* `x`, renommer. Ce `DROP TABLE` est **structurel**, les données ont été
|
|
72
|
+
* recopiées juste avant ; le signaler comme une perte ferait crier au loup à
|
|
73
|
+
* chaque changement de colonne, et le garde serait désarmé au bout de trois
|
|
74
|
+
* fois.
|
|
75
|
+
*
|
|
76
|
+
* ⚠️ Ce n'est pas une garantie que rien n'est perdu : si une colonne
|
|
77
|
+
* n'apparaît pas dans le `INSERT … SELECT`, ses données disparaissent bel et
|
|
78
|
+
* bien. C'est pourquoi la recréation reste SIGNALÉE, avec le geste — lire le
|
|
79
|
+
* SQL —, et seulement déclassée en avertissement.
|
|
80
|
+
*/
|
|
81
|
+
function isTableRebuild(statements) {
|
|
82
|
+
const joint = statements.join("\n");
|
|
83
|
+
return /\bCREATE\s+TABLE\s+[`"']?__new_/i.test(joint) && /\bINSERT\s+INTO\s+[`"']?__new_/i.test(joint);
|
|
84
|
+
}
|
|
85
|
+
/** Borne une instruction pour l'affichage, sans jamais la déformer. */
|
|
86
|
+
function truncate(statement) {
|
|
87
|
+
const plat = statement.replace(/\s+/g, " ").trim();
|
|
88
|
+
return plat.length > MAX_STATEMENT ? `${plat.slice(0, MAX_STATEMENT)}…` : plat;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Cherche, dans les migrations en attente, ce qui détruit ou casse.
|
|
92
|
+
*
|
|
93
|
+
* @param files - migrations qui vont être appliquées.
|
|
94
|
+
* @returns une trouvaille par instruction reconnue, dans l'ordre d'application.
|
|
95
|
+
*/
|
|
96
|
+
function scanDestructive(files) {
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const rebuild = isTableRebuild(file.statements);
|
|
100
|
+
for (const statement of file.statements) for (const p of PATTERNS) {
|
|
101
|
+
if (!p.re.test(statement)) continue;
|
|
102
|
+
const structurel = rebuild && (p.kind === "drop-table" || p.kind === "rename");
|
|
103
|
+
out.push({
|
|
104
|
+
source: file.source,
|
|
105
|
+
tag: file.tag,
|
|
106
|
+
path: file.path,
|
|
107
|
+
severity: structurel ? "breaking" : p.severity,
|
|
108
|
+
kind: structurel ? "table-rebuild" : p.kind,
|
|
109
|
+
what: structurel ? "recrée la table pour modifier une colonne (SQLite ne sait pas faire autrement) — les lignes sont recopiées, MAIS une colonne absente du `INSERT … SELECT` serait perdue : lire le SQL" : p.what,
|
|
110
|
+
statement: truncate(statement)
|
|
111
|
+
});
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** Les trouvailles qui font vraiment disparaître des données. */
|
|
118
|
+
function dataLoss(findings) {
|
|
119
|
+
return findings.filter((f) => f.severity === "data-loss");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Rend le bilan lisible par un humain — le fait, puis ce qu'il faut faire.
|
|
123
|
+
*
|
|
124
|
+
* @param findings - trouvailles à présenter.
|
|
125
|
+
* @param bloquant - la commande refuse-t-elle d'appliquer ?
|
|
126
|
+
* @returns le texte, sans mise en forme (l'appelant colore s'il le veut).
|
|
127
|
+
*/
|
|
128
|
+
function renderDestructive(findings, bloquant) {
|
|
129
|
+
let out = `${bloquant ? `Ces migrations DÉTRUISENT des données — rien n'a été appliqué.` : `Ces migrations touchent à des données existantes.`}\n\n`;
|
|
130
|
+
for (const f of findings) {
|
|
131
|
+
const marque = f.severity === "data-loss" ? "✗" : "!";
|
|
132
|
+
out += ` ${marque} ${f.source}/${f.tag} — ${f.what}\n`;
|
|
133
|
+
out += ` ${f.statement}\n`;
|
|
134
|
+
}
|
|
135
|
+
if (bloquant) out += "\nCe n'est pas un refus de principe : une fois appliquée, une suppression ne se rattrape que par une restauration de la base — c'est-à-dire une interruption de service et une décision, jamais un retour arrière.\n\nL'outil ne sauvegarde pas la base, et aucun outil de migration ne le fait : il n'en a ni les droits, ni la place, ni le temps, et le faire donnerait une assurance qui n'existe pas. La sauvegarde est le métier de l'exploitation (instantané de volume, restauration à un instant donné).\n\nLa vraie protection est de ne PAS détruire dans la même version que le code : ajoute la nouvelle forme, déploie, recopie les données, et ne supprime l'ancienne qu'à la version suivante. Le retour arrière porte alors sur le code, et la base n'a jamais besoin d'être restaurée.\n";
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
/** Les gestes proposés face à un refus destructif. */
|
|
139
|
+
function destructiveActions(connector) {
|
|
140
|
+
const suffixe = connector === "default" ? "" : ` --connector ${connector}`;
|
|
141
|
+
return [`nodefony orm:migrate${suffixe} --dry-run`, `nodefony orm:migrate${suffixe} --allow-destructive`];
|
|
142
|
+
}
|
|
143
|
+
/** Résumé d'une ligne, pour un message ou un journal. */
|
|
144
|
+
function summarizeDestructive(findings, connector) {
|
|
145
|
+
const losses = dataLoss(findings);
|
|
146
|
+
const names = [...new Set(losses.map((f) => `${f.source}/${f.tag}`))];
|
|
147
|
+
return `Le connecteur « ${connector} » a ${losses.length} instruction(s) qui SUPPRIMENT des données, dans ${names.length} migration(s) : ${names.join(", ")}.`;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Les instructions qui touchent des lignes DÉJÀ EN BASE — sans détruire.
|
|
151
|
+
*
|
|
152
|
+
* Rien à voir avec {@link scanDestructive}, qui cherche ce qui fait perdre de
|
|
153
|
+
* la donnée. Celles-ci sont parfaitement légitimes : ajouter une colonne à une
|
|
154
|
+
* table peuplée, remplir cette colonne, changer une contrainte. Elles ont un
|
|
155
|
+
* point commun qui les rend intéressantes — **après elles, quelqu'un se demande
|
|
156
|
+
* si les données ont suivi**, et c'est à cet instant précis, mesuré sur le banc
|
|
157
|
+
* de découvrabilité, qu'un agent a répondu « réinitialisons la base pour
|
|
158
|
+
* vérifier », emportant la ligne témoin qu'il devait justement préserver.
|
|
159
|
+
*
|
|
160
|
+
* Un lot purement `CREATE TABLE` ne pose pas la question : il n'y avait rien
|
|
161
|
+
* avant. C'est ce qui distingue un schéma initial d'une évolution.
|
|
162
|
+
*/
|
|
163
|
+
const ROW_TOUCHING_PATTERNS = [
|
|
164
|
+
/\bALTER\s+TABLE\b/i,
|
|
165
|
+
/\bUPDATE\s+/i,
|
|
166
|
+
/\bINSERT\s+INTO\b/i,
|
|
167
|
+
/\bDELETE\s+FROM\b/i
|
|
168
|
+
];
|
|
169
|
+
/**
|
|
170
|
+
* Ce lot de migrations touche-t-il des lignes qui existaient déjà ?
|
|
171
|
+
*
|
|
172
|
+
* @param files - migrations sur le point d'être appliquées.
|
|
173
|
+
* @returns vrai dès qu'une instruction modifie une table existante.
|
|
174
|
+
*/
|
|
175
|
+
function touchesExistingRows(files) {
|
|
176
|
+
for (const file of files) for (const statement of file.statements) if (ROW_TOUCHING_PATTERNS.some((re) => re.test(statement))) return true;
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Comment vérifier qu'une migration a préservé les données — **en UN geste**.
|
|
181
|
+
*
|
|
182
|
+
* Cette phrase existe parce que la sortie de SUCCÈS ne disait rien. Le produit
|
|
183
|
+
* annonçait « ✓ 1 migration appliquée » et s'arrêtait là ; l'agent à qui l'on
|
|
184
|
+
* demandait de prouver que les données avaient suivi n'avait aucun moyen sous
|
|
185
|
+
* les yeux, et celui qu'il inventait — repartir d'une base vide — détruit
|
|
186
|
+
* précisément ce qu'il fallait observer.
|
|
187
|
+
*
|
|
188
|
+
* 🔴 Elle NOMME la base, et c'est ce qui manquait au premier jet. Dire « une
|
|
189
|
+
* copie de la base » sans dire laquelle laisse deviner un chemin : mesuré au
|
|
190
|
+
* banc, l'agent a suivi le conseil, visé le mauvais fichier, vu son `cp`
|
|
191
|
+
* échouer en silence, puis FABRIQUÉ une base au client SQL — avec une table
|
|
192
|
+
* d'historique inventée. La migration a été refusée sur cette base bancale, et
|
|
193
|
+
* c'est ce refus qui l'a renvoyé détruire la vraie. Le produit connaissait
|
|
194
|
+
* pourtant l'emplacement : il le publie dans sa propre sortie.
|
|
195
|
+
*
|
|
196
|
+
* Elle nomme aussi les deux interpréteurs : « VAR=x commande » est de la
|
|
197
|
+
* syntaxe POSIX, que celui de Windows refuse.
|
|
198
|
+
*
|
|
199
|
+
* @param connector - connecteur visé, pour composer la commande.
|
|
200
|
+
* @param target - la base telle que le rapport la publie (`driver.target`),
|
|
201
|
+
* et son dialecte : on copie un FICHIER en sqlite, on exporte ailleurs.
|
|
202
|
+
* @returns la phrase à rendre après une application réussie.
|
|
203
|
+
*/
|
|
204
|
+
function checkDataAdvice(connector, target) {
|
|
205
|
+
const suffixe = connector === "default" ? "" : ` --connector ${connector}`;
|
|
206
|
+
const sqlite = target?.dialect === "sqlite";
|
|
207
|
+
const named = target?.target ?? "<la base de ce connecteur>";
|
|
208
|
+
const copy = sqlite ? `copie le fichier « ${named} » (par exemple vers « essai.db ») — ne la RECRÉE pas à la main, l'historique du framework a ses propres colonnes` : `fabrique la copie par un export du moteur (« pg_dump » / « mysqldump ») depuis « ${named} » — ne la RECRÉE pas à la main, l'historique du framework a ses propres colonnes`;
|
|
209
|
+
const url = sqlite ? "sqlite:essai.db" : "<url de la copie>";
|
|
210
|
+
return `Ces migrations ont modifié des tables qui portaient déjà des lignes. Pour VÉRIFIER que les données ont suivi, ne repars pas d'une base vide : ce sont ces lignes-là qui sont la réponse, les effacer efface la question. Deux moyens. Compter et regarder sur place — « SELECT COUNT(*) » sur les tables touchées, et les valeurs des colonnes nouvellement remplies. Ou rejouer le même lot sur une COPIE, sans toucher à celle-ci : ${copy}, puis désigne-la par NF_MIGRATE_DATABASE_URL :\n NF_MIGRATE_DATABASE_URL=${url} nodefony orm:migrate${suffixe}\n (PowerShell : $env:NF_MIGRATE_DATABASE_URL = "${url}" ; nodefony orm:migrate${suffixe})`;
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
export { checkDataAdvice, dataLoss, destructiveActions, renderDestructive, scanDestructive, summarizeDestructive, touchesExistingRows };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { verdictOf } from "./explain.js";
|
|
2
|
+
import { hasGap } from "./schemaDiff.js";
|
|
3
|
+
import { ormRegistry } from "@nodefony/orm-core";
|
|
4
|
+
//#region nodefony/src/migrator/divergence.ts
|
|
5
|
+
/** Le connecteur enregistré sait-il se comparer à ce que le code déclare ? */
|
|
6
|
+
function comparable(connector) {
|
|
7
|
+
const orm = ormRegistry.get(connector);
|
|
8
|
+
return typeof orm?.compareToDeclared === "function" && typeof orm.isConnected === "function" ? orm : null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Ce que la base porte face à ce que le code DÉCLARE, sans condition de verdict.
|
|
12
|
+
*
|
|
13
|
+
* Séparé de {@link describeDivergence} parce que les deux répondent à des
|
|
14
|
+
* questions différentes, et que la seconde REFUSE de répondre avant que le plan
|
|
15
|
+
* soit à jour — ce qui est juste pour un rapport d'état (un écart n'a de sens
|
|
16
|
+
* qu'une fois tout appliqué) et faux pour qui doit décider AVANT d'agir.
|
|
17
|
+
*
|
|
18
|
+
* Le cas qui a exigé cette séparation : l'adoption d'une base existante. Elle
|
|
19
|
+
* doit constater l'état RÉEL avant d'écrire quoi que ce soit dans l'historique
|
|
20
|
+
* — après, il est trop tard, l'affirmation est déjà gravée.
|
|
21
|
+
*
|
|
22
|
+
* Ne modifie jamais rien, et ne jette jamais : une base muette n'est pas une
|
|
23
|
+
* divergence, c'est une panne, qui a sa propre voie de signalement.
|
|
24
|
+
*
|
|
25
|
+
* @param connector - nom du connecteur à interroger.
|
|
26
|
+
* @returns les écarts nommés, ou `null` (rien à dire, ou rien d'interrogeable).
|
|
27
|
+
*/
|
|
28
|
+
async function gapAgainstDeclared(connector) {
|
|
29
|
+
const comparison = await comparisonAgainstDeclared(connector);
|
|
30
|
+
return comparison !== null && hasGap(comparison) ? comparison : null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* La comparaison BRUTE — ce que la base porte face au code, écart ou non.
|
|
34
|
+
*
|
|
35
|
+
* Séparée de {@link gapAgainstDeclared}, qui ne rend que les écarts : il existe
|
|
36
|
+
* une question à laquelle « aucun écart » est une réponse pleine, et non un
|
|
37
|
+
* silence. Celle-ci : *la base porte-t-elle DÉJÀ les tables que je m'apprête à
|
|
38
|
+
* créer ?* Une base parfaitement conforme y répond « oui », et c'est justement
|
|
39
|
+
* le cas où il ne faut pas écrire un `CREATE TABLE`.
|
|
40
|
+
*
|
|
41
|
+
* Ne modifie jamais rien, et ne jette jamais : une base muette n'est pas une
|
|
42
|
+
* conformité, c'est une absence de réponse — rendue `null` pour que personne
|
|
43
|
+
* ne conclue à sa place.
|
|
44
|
+
*
|
|
45
|
+
* @param connector - nom du connecteur à interroger.
|
|
46
|
+
* @returns la comparaison, ou `null` si rien n'était interrogeable.
|
|
47
|
+
*/
|
|
48
|
+
async function comparisonAgainstDeclared(connector) {
|
|
49
|
+
const orm = comparable(connector);
|
|
50
|
+
if (!orm?.isConnected()) return null;
|
|
51
|
+
try {
|
|
52
|
+
return await orm.compareToDeclared();
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Ce qui diverge, NOMMÉ — ou `null` quand il n'y a rien à dire.
|
|
59
|
+
*
|
|
60
|
+
* Producteur UNIQUE de la troisième source : le verdict, la phrase française,
|
|
61
|
+
* la charge utile `--json` et la sonde de disponibilité lisent tous ce même
|
|
62
|
+
* retour. Rendre un booléen ici et recalculer le détail ailleurs ferait deux
|
|
63
|
+
* lectures de la base pour une seule question, et deux réponses qui finiraient
|
|
64
|
+
* par se contredire.
|
|
65
|
+
*
|
|
66
|
+
* Répond `null` sans rien interroger dans tous les cas où la réponse ne
|
|
67
|
+
* changerait rien : plan déjà porteur d'un verdict, connecteur absent du
|
|
68
|
+
* registre, connecteur non connecté, ORM d'une autre nature (mongoose n'a pas
|
|
69
|
+
* de schéma déclaré à comparer). Répond `null` aussi quand la comparaison a eu
|
|
70
|
+
* lieu et n'a rien trouvé — l'absence d'écart ne garde pas d'objet vide en
|
|
71
|
+
* mémoire, et l'appelant n'a qu'un test à écrire.
|
|
72
|
+
*
|
|
73
|
+
* **Ne modifie jamais rien** — le rattrapage additif est le travail du mode de
|
|
74
|
+
* schéma dérivé, au démarrage, et de lui seul.
|
|
75
|
+
*
|
|
76
|
+
* @param plan - plan calculé par l'applicateur, en lecture seule.
|
|
77
|
+
* @returns les écarts nommés, ou `null` s'il n'y en a pas à publier.
|
|
78
|
+
*/
|
|
79
|
+
async function describeDivergence(plan) {
|
|
80
|
+
if (verdictOf(plan) !== "up-to-date") return null;
|
|
81
|
+
return gapAgainstDeclared(plan.connector);
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { comparisonAgainstDeclared, describeDivergence, gapAgainstDeclared };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { MYSQL_LOCK_NAME_SQL, MYSQL_LOCK_PREFIX, MysqlMigrationDriver } from "./mysqlDriver.js";
|
|
2
|
+
import { PG_LOCK_KEY, PostgresMigrationDriver } from "./postgresDriver.js";
|
|
3
|
+
import { SqliteMigrationDriver } from "./sqliteDriver.js";
|
|
4
|
+
//#region nodefony/src/migrator/drivers/index.ts
|
|
5
|
+
/**
|
|
6
|
+
* Ouvre le pilote à connexion unique du dialecte demandé.
|
|
7
|
+
*
|
|
8
|
+
* L'URL doit être une connexion **directe** au serveur : un répartiteur de
|
|
9
|
+
* connexions en mode transaction casse les verrous consultatifs de session, et
|
|
10
|
+
* le verrou de l'applicateur en est un.
|
|
11
|
+
*
|
|
12
|
+
* @param target - dialecte et coordonnées de la base.
|
|
13
|
+
* @returns le pilote, déjà connecté.
|
|
14
|
+
* @throws Error si les coordonnées manquent pour ce dialecte.
|
|
15
|
+
*/
|
|
16
|
+
async function openMigrationDriver(target) {
|
|
17
|
+
switch (target.dialect) {
|
|
18
|
+
case "sqlite": {
|
|
19
|
+
if (!target.filename) throw new Error("Migrations : le dialecte sqlite exige un `filename`. Une base en mémoire ne survivrait pas à la commande, et migrer une base jetable en rendant le code du succès est le pire des verdicts. Pour une base éphémère, passer explicitement « :memory: ».");
|
|
20
|
+
const driver = new SqliteMigrationDriver(target.filename);
|
|
21
|
+
await driver.connect();
|
|
22
|
+
return driver;
|
|
23
|
+
}
|
|
24
|
+
case "postgres": {
|
|
25
|
+
if (!target.url) throw new Error("Migrations : le dialecte postgres exige une `url` de connexion directe.");
|
|
26
|
+
const driver = new PostgresMigrationDriver(target.url);
|
|
27
|
+
await driver.connect();
|
|
28
|
+
return driver;
|
|
29
|
+
}
|
|
30
|
+
case "mysql": {
|
|
31
|
+
if (!target.url) throw new Error("Migrations : le dialecte mysql exige une `url` de connexion directe.");
|
|
32
|
+
const driver = new MysqlMigrationDriver(target.url);
|
|
33
|
+
await driver.connect();
|
|
34
|
+
return driver;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { MYSQL_LOCK_NAME_SQL, MYSQL_LOCK_PREFIX, MysqlMigrationDriver, PG_LOCK_KEY, PostgresMigrationDriver, SqliteMigrationDriver, openMigrationDriver };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { MigrationLockTimeoutError } from "../types.js";
|
|
2
|
+
import { schemaReader } from "../catalog.js";
|
|
3
|
+
//#region nodefony/src/migrator/drivers/mysqlDriver.ts
|
|
4
|
+
/**
|
|
5
|
+
* Préfixe de l'identité du verrou MySQL — **contrat inter-versions**.
|
|
6
|
+
*
|
|
7
|
+
* Au même titre que le nom de la table d'historique : deux versions du
|
|
8
|
+
* framework qui ne s'excluent plus, c'est pendant un déploiement que ça se
|
|
9
|
+
* paie.
|
|
10
|
+
*/
|
|
11
|
+
const MYSQL_LOCK_PREFIX = "nodefony:migrations:";
|
|
12
|
+
/**
|
|
13
|
+
* Expression SQL de l'identité du verrou — évaluée par le SERVEUR.
|
|
14
|
+
*
|
|
15
|
+
* **Pourquoi qualifier par `DATABASE()`** : `GET_LOCK` est global au SERVEUR,
|
|
16
|
+
* pas à la base. Sans cette qualification, deux applications sans aucun rapport
|
|
17
|
+
* hébergées sur la même instance se sérialiseraient — en silence, ce qui est le
|
|
18
|
+
* pire des symptômes.
|
|
19
|
+
*
|
|
20
|
+
* **Pourquoi un repli haché** : MySQL borne le nom d'un verrou à 64 caractères
|
|
21
|
+
* (MariaDB est plus permissif, mais c'est la contrainte la plus stricte qui
|
|
22
|
+
* fait règle). Le préfixe en consomme 20 ; au-delà de 44 caractères de nom de
|
|
23
|
+
* base, l'appel échouerait — et l'échec d'un verrou est un blocage total, avec
|
|
24
|
+
* un message que rien ne rattache au nom de la base. Le repli reste
|
|
25
|
+
* DÉTERMINISTE (fonction du seul nom de base), donc deux versions du framework
|
|
26
|
+
* calculent toujours la même identité : le contrat tient.
|
|
27
|
+
*/
|
|
28
|
+
const MYSQL_LOCK_NAME_SQL = `IF(CHAR_LENGTH(DATABASE()) <= 44, CONCAT('${MYSQL_LOCK_PREFIX}', DATABASE()), CONCAT('${MYSQL_LOCK_PREFIX}#', LEFT(SHA2(DATABASE(), 256), 32)))`;
|
|
29
|
+
/**
|
|
30
|
+
* Pilote MySQL / MariaDB de l'applicateur — **une seule connexion**.
|
|
31
|
+
*
|
|
32
|
+
* `GET_LOCK` est un verrou de SESSION : un pool le rendrait inopérant. Il
|
|
33
|
+
* s'auto-libère à la mort de la connexion — aucun verrou zombie.
|
|
34
|
+
*
|
|
35
|
+
* 🔴 **Le DDL n'est PAS transactionnel ici** : un `CREATE TABLE` valide
|
|
36
|
+
* implicitement la transaction en cours. Un échec à mi-course laisse donc la
|
|
37
|
+
* base dans un état partiel, avec un marqueur d'échec persistant — et c'est
|
|
38
|
+
* exactement pourquoi il ne doit JAMAIS y avoir de reprise aveugle : c'est la
|
|
39
|
+
* réparation, après inspection humaine, qui tranche.
|
|
40
|
+
*/
|
|
41
|
+
var MysqlMigrationDriver = class {
|
|
42
|
+
dialect = "mysql";
|
|
43
|
+
transactionalDdl = false;
|
|
44
|
+
#cxOrNull = null;
|
|
45
|
+
#locked = false;
|
|
46
|
+
#lost = null;
|
|
47
|
+
#url;
|
|
48
|
+
/**
|
|
49
|
+
* @param url - URL de connexion DIRECTE au serveur.
|
|
50
|
+
*/
|
|
51
|
+
constructor(url) {
|
|
52
|
+
this.#url = url;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Ouvre la connexion dédiée.
|
|
56
|
+
*
|
|
57
|
+
* @throws Error si le pilote `mysql2` n'est pas installé.
|
|
58
|
+
*/
|
|
59
|
+
async connect() {
|
|
60
|
+
let createConnection;
|
|
61
|
+
try {
|
|
62
|
+
const ns = await import("mysql2/promise");
|
|
63
|
+
const resolved = ns.createConnection ?? ns.default?.createConnection;
|
|
64
|
+
if (!resolved) throw new Error("`mysql2` did not expose `createConnection`");
|
|
65
|
+
createConnection = resolved;
|
|
66
|
+
} catch (e) {
|
|
67
|
+
throw new Error(`Le dialecte mysql exige le pilote optionnel \`mysql2\` (\`npm i mysql2\`). ${e.message}`, { cause: e });
|
|
68
|
+
}
|
|
69
|
+
const cx = await createConnection(this.#url);
|
|
70
|
+
cx.on("error", (error) => {
|
|
71
|
+
this.#lost = error?.message ?? String(error);
|
|
72
|
+
});
|
|
73
|
+
this.#lost = null;
|
|
74
|
+
this.#cxOrNull = cx;
|
|
75
|
+
}
|
|
76
|
+
/** Connexion ouverte, ou une erreur qui dit quoi faire. */
|
|
77
|
+
/** Motif de la perte de connexion, si elle a été constatée. */
|
|
78
|
+
get lostReason() {
|
|
79
|
+
return this.#lost;
|
|
80
|
+
}
|
|
81
|
+
get #cx() {
|
|
82
|
+
if (this.#cxOrNull === null) throw new Error(this.#lost === null ? "MysqlMigrationDriver : `connect()` n'a pas été appelé." : `MysqlMigrationDriver : connexion perdue — ${this.#lost}`);
|
|
83
|
+
return this.#cxOrNull;
|
|
84
|
+
}
|
|
85
|
+
/** @inheritdoc */
|
|
86
|
+
async exec(sql) {
|
|
87
|
+
await this.#cx.query(sql);
|
|
88
|
+
}
|
|
89
|
+
/** @inheritdoc */
|
|
90
|
+
async query(sql, params = []) {
|
|
91
|
+
const [rows] = await this.#cx.query(sql, params);
|
|
92
|
+
return Array.isArray(rows) ? rows : [];
|
|
93
|
+
}
|
|
94
|
+
/** Lecture du catalogue — implémentation PARTAGÉE avec l'ORM. */
|
|
95
|
+
#catalog = schemaReader("mysql", (sql, params) => this.query(sql, params));
|
|
96
|
+
/** @inheritdoc */
|
|
97
|
+
sameColumnName(declared, actual) {
|
|
98
|
+
return this.#catalog.sameColumnName(declared, actual);
|
|
99
|
+
}
|
|
100
|
+
/** @inheritdoc */
|
|
101
|
+
tableExists(table) {
|
|
102
|
+
return this.#catalog.tableExists(table);
|
|
103
|
+
}
|
|
104
|
+
/** @inheritdoc */
|
|
105
|
+
columnsOf(table) {
|
|
106
|
+
return this.#catalog.columnsOf(table);
|
|
107
|
+
}
|
|
108
|
+
/** @inheritdoc */
|
|
109
|
+
begin() {
|
|
110
|
+
return this.exec("START TRANSACTION");
|
|
111
|
+
}
|
|
112
|
+
/** @inheritdoc */
|
|
113
|
+
commit() {
|
|
114
|
+
return this.exec("COMMIT");
|
|
115
|
+
}
|
|
116
|
+
/** @inheritdoc */
|
|
117
|
+
rollback() {
|
|
118
|
+
return this.exec("ROLLBACK");
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Prend le verrou nommé, avec le délai natif de `GET_LOCK`.
|
|
122
|
+
*
|
|
123
|
+
* @param timeoutMs - délai maximal d'attente.
|
|
124
|
+
* @throws Error si le verrou n'est pas obtenu dans le délai.
|
|
125
|
+
*/
|
|
126
|
+
async lock(timeoutMs) {
|
|
127
|
+
const seconds = Math.max(1, Math.ceil(timeoutMs / 1e3));
|
|
128
|
+
const got = (await this.query(`SELECT GET_LOCK(${MYSQL_LOCK_NAME_SQL}, ?) AS got`, [seconds]))[0]?.got;
|
|
129
|
+
if (Number(got) !== 1) throw new MigrationLockTimeoutError(timeoutMs, `Verrou de migration MySQL non obtenu en ${timeoutMs} ms (${got === null ? "erreur du serveur" : "délai dépassé"}) : une autre migration est en cours sur cette base.`);
|
|
130
|
+
this.#locked = true;
|
|
131
|
+
}
|
|
132
|
+
/** @inheritdoc */
|
|
133
|
+
async unlock() {
|
|
134
|
+
if (!this.#locked) return;
|
|
135
|
+
this.#locked = false;
|
|
136
|
+
await this.query(`SELECT RELEASE_LOCK(${MYSQL_LOCK_NAME_SQL}) AS released`);
|
|
137
|
+
}
|
|
138
|
+
/** @inheritdoc */
|
|
139
|
+
async close() {
|
|
140
|
+
const cx = this.#cxOrNull;
|
|
141
|
+
this.#cxOrNull = null;
|
|
142
|
+
this.#locked = false;
|
|
143
|
+
if (cx) await cx.end().catch(() => void 0);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
//#endregion
|
|
147
|
+
export { MYSQL_LOCK_NAME_SQL, MYSQL_LOCK_PREFIX, MysqlMigrationDriver };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { MigrationLockTimeoutError } from "../types.js";
|
|
2
|
+
import { schemaReader, toDollarParams } from "../catalog.js";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
//#region nodefony/src/migrator/drivers/postgresDriver.ts
|
|
5
|
+
/**
|
|
6
|
+
* Clé du verrou consultatif PostgreSQL — **figée à vie**.
|
|
7
|
+
*
|
|
8
|
+
* Elle est dérivée par une RÈGLE, pas choisie : les huit premiers octets de
|
|
9
|
+
* `sha256("nodefony:migrations")`, lus en entier signé big-endian. Une identité
|
|
10
|
+
* « gravée » dont la valeur serait tirée au hasard d'un commit ne serait pas
|
|
11
|
+
* gravée du tout — on ne pourrait plus la recalculer pour la vérifier.
|
|
12
|
+
*
|
|
13
|
+
* La changer ferait que deux versions du framework ne s'excluent plus
|
|
14
|
+
* mutuellement : exactement pendant un déploiement, le seul moment qui compte.
|
|
15
|
+
*/
|
|
16
|
+
const PG_LOCK_KEY = createHash("sha256").update("nodefony:migrations").digest().readBigInt64BE(0);
|
|
17
|
+
/**
|
|
18
|
+
* Pilote PostgreSQL de l'applicateur — **une seule connexion**, pas un pool.
|
|
19
|
+
*
|
|
20
|
+
* `pg_advisory_lock` est un verrou de SESSION : avec un pool, il serait pris
|
|
21
|
+
* sur une connexion, le DDL exécuté sur une autre et la libération faite sur
|
|
22
|
+
* une troisième — le verrou ne protégerait rien. D'où une connexion tenue du
|
|
23
|
+
* verrou à sa libération.
|
|
24
|
+
*
|
|
25
|
+
* Le verrou s'auto-libère à la mort de la connexion : un process tué en plein
|
|
26
|
+
* vol ne laisse **aucun verrou zombie** à lever à la main. C'est l'argument qui
|
|
27
|
+
* a fait écarter une table de verrou.
|
|
28
|
+
*/
|
|
29
|
+
var PostgresMigrationDriver = class {
|
|
30
|
+
dialect = "postgres";
|
|
31
|
+
transactionalDdl = true;
|
|
32
|
+
#client = null;
|
|
33
|
+
#locked = false;
|
|
34
|
+
#lost = null;
|
|
35
|
+
#url;
|
|
36
|
+
/**
|
|
37
|
+
* @param url - URL de connexion DIRECTE au serveur.
|
|
38
|
+
*/
|
|
39
|
+
constructor(url) {
|
|
40
|
+
this.#url = url;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Ouvre la connexion dédiée.
|
|
44
|
+
*
|
|
45
|
+
* @throws Error si le pilote `pg` n'est pas installé.
|
|
46
|
+
*/
|
|
47
|
+
async connect() {
|
|
48
|
+
let ClientCtor;
|
|
49
|
+
try {
|
|
50
|
+
const ns = await import("pg");
|
|
51
|
+
const resolved = ns.Client ?? ns.default?.Client;
|
|
52
|
+
if (!resolved) throw new Error("`pg` did not expose a `Client` constructor");
|
|
53
|
+
ClientCtor = resolved;
|
|
54
|
+
} catch (e) {
|
|
55
|
+
throw new Error(`Le dialecte postgres exige le pilote optionnel \`pg\` (\`npm i pg\`). ${e.message}`, { cause: e });
|
|
56
|
+
}
|
|
57
|
+
const client = new ClientCtor({ connectionString: this.#url });
|
|
58
|
+
client.on("error", (error) => {
|
|
59
|
+
this.#lost = error?.message ?? String(error);
|
|
60
|
+
});
|
|
61
|
+
await client.connect();
|
|
62
|
+
this.#lost = null;
|
|
63
|
+
this.#client = client;
|
|
64
|
+
}
|
|
65
|
+
/** Connexion ouverte, ou une erreur qui dit quoi faire. */
|
|
66
|
+
get #cx() {
|
|
67
|
+
if (this.#client === null) throw new Error(this.#lost === null ? "PostgresMigrationDriver : `connect()` n'a pas été appelé." : `PostgresMigrationDriver : connexion perdue — ${this.#lost}`);
|
|
68
|
+
return this.#client;
|
|
69
|
+
}
|
|
70
|
+
/** Motif de la perte de connexion, si elle a été constatée. */
|
|
71
|
+
get lostReason() {
|
|
72
|
+
return this.#lost;
|
|
73
|
+
}
|
|
74
|
+
/** @inheritdoc */
|
|
75
|
+
async exec(sql) {
|
|
76
|
+
await this.#cx.query(sql);
|
|
77
|
+
}
|
|
78
|
+
/** @inheritdoc */
|
|
79
|
+
async query(sql, params = []) {
|
|
80
|
+
return (await this.#cx.query(toDollarParams(sql), params)).rows;
|
|
81
|
+
}
|
|
82
|
+
/** Lecture du catalogue — implémentation PARTAGÉE avec l'ORM. */
|
|
83
|
+
#catalog = schemaReader("postgres", (sql, params) => this.query(sql, params));
|
|
84
|
+
/** @inheritdoc */
|
|
85
|
+
sameColumnName(declared, actual) {
|
|
86
|
+
return this.#catalog.sameColumnName(declared, actual);
|
|
87
|
+
}
|
|
88
|
+
/** @inheritdoc */
|
|
89
|
+
tableExists(table) {
|
|
90
|
+
return this.#catalog.tableExists(table);
|
|
91
|
+
}
|
|
92
|
+
/** @inheritdoc */
|
|
93
|
+
columnsOf(table) {
|
|
94
|
+
return this.#catalog.columnsOf(table);
|
|
95
|
+
}
|
|
96
|
+
/** @inheritdoc */
|
|
97
|
+
begin() {
|
|
98
|
+
return this.exec("BEGIN");
|
|
99
|
+
}
|
|
100
|
+
/** @inheritdoc */
|
|
101
|
+
commit() {
|
|
102
|
+
return this.exec("COMMIT");
|
|
103
|
+
}
|
|
104
|
+
/** @inheritdoc */
|
|
105
|
+
rollback() {
|
|
106
|
+
return this.exec("ROLLBACK");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Prend le verrou consultatif, en attente BORNÉE.
|
|
110
|
+
*
|
|
111
|
+
* `pg_advisory_lock` attend sans limite : un pod bloqué derrière un job mort
|
|
112
|
+
* attendrait pour toujours, sans rien dire. On sonde donc avec
|
|
113
|
+
* `pg_try_advisory_lock` jusqu'au délai imparti, ce qui rend l'attente
|
|
114
|
+
* observable et l'échec explicite.
|
|
115
|
+
*
|
|
116
|
+
* `lock_timeout` est posé pour son VRAI rôle, distinct : borner l'attente des
|
|
117
|
+
* verrous de TABLE que prennent les `ALTER` — sans lui, un `ALTER` coincé
|
|
118
|
+
* derrière une transaction longue bloque toute la table en file d'attente.
|
|
119
|
+
*
|
|
120
|
+
* @param timeoutMs - délai maximal d'attente du verrou.
|
|
121
|
+
* @returns une promesse résolue une fois le verrou tenu.
|
|
122
|
+
* @throws Error si le verrou n'est pas obtenu dans le délai.
|
|
123
|
+
*/
|
|
124
|
+
async lock(timeoutMs) {
|
|
125
|
+
await this.exec(`SET lock_timeout = '${Math.max(1, timeoutMs)}ms'`);
|
|
126
|
+
const deadline = Date.now() + timeoutMs;
|
|
127
|
+
for (;;) {
|
|
128
|
+
if ((await this.query(`SELECT pg_try_advisory_lock(?::bigint) AS locked`, [PG_LOCK_KEY.toString()]))[0]?.locked === true) {
|
|
129
|
+
this.#locked = true;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (Date.now() >= deadline) throw new MigrationLockTimeoutError(timeoutMs, `Verrou de migration PostgreSQL non obtenu en ${timeoutMs} ms (clé ${PG_LOCK_KEY.toString()}) : une autre migration est en cours.`);
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** @inheritdoc */
|
|
137
|
+
async unlock() {
|
|
138
|
+
if (!this.#locked) return;
|
|
139
|
+
this.#locked = false;
|
|
140
|
+
await this.query(`SELECT pg_advisory_unlock(?::bigint)`, [PG_LOCK_KEY.toString()]);
|
|
141
|
+
}
|
|
142
|
+
/** @inheritdoc */
|
|
143
|
+
async close() {
|
|
144
|
+
const client = this.#client;
|
|
145
|
+
this.#client = null;
|
|
146
|
+
this.#locked = false;
|
|
147
|
+
if (client) await client.end().catch(() => void 0);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
export { PG_LOCK_KEY, PostgresMigrationDriver, toDollarParams };
|