turbine-orm 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
package/dist/cjs/cli/migrate.js
CHANGED
|
@@ -16,22 +16,26 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
16
16
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
17
17
|
};
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
-
exports.MIGRATION_RECIPES = void 0;
|
|
19
|
+
exports.MIGRATION_RECIPES = exports.splitSqlStatements = void 0;
|
|
20
20
|
exports.migrationTimestamp = migrationTimestamp;
|
|
21
|
+
exports.assertNoEmbeddedTransactions = assertNoEmbeddedTransactions;
|
|
21
22
|
exports.collectUpDestructive = collectUpDestructive;
|
|
22
23
|
exports.parseMigrationFilename = parseMigrationFilename;
|
|
24
|
+
exports.headerSafeName = headerSafeName;
|
|
23
25
|
exports.sanitizeName = sanitizeName;
|
|
24
26
|
exports.formatTimestamp = formatTimestamp;
|
|
25
27
|
exports.getPendingMigrations = getPendingMigrations;
|
|
26
28
|
exports.listMigrationFiles = listMigrationFiles;
|
|
27
29
|
exports.parseMigrationContent = parseMigrationContent;
|
|
28
|
-
exports.
|
|
30
|
+
exports.findTransactionControlStatements = findTransactionControlStatements;
|
|
29
31
|
exports.parseMigrationSQL = parseMigrationSQL;
|
|
30
32
|
exports.canUpgradeLegacyChecksum = canUpgradeLegacyChecksum;
|
|
31
33
|
exports.isChecksumValid = isChecksumValid;
|
|
32
34
|
exports.buildDiffMigrationBody = buildDiffMigrationBody;
|
|
33
35
|
exports.createMigration = createMigration;
|
|
34
36
|
exports.deriveLockId = deriveLockId;
|
|
37
|
+
exports.acquireMigrationLock = acquireMigrationLock;
|
|
38
|
+
exports.releaseMigrationLock = releaseMigrationLock;
|
|
35
39
|
exports.runMigrationInTransaction = runMigrationInTransaction;
|
|
36
40
|
exports.validateChecksums = validateChecksums;
|
|
37
41
|
exports.formatChecksumMismatchError = formatChecksumMismatchError;
|
|
@@ -39,6 +43,7 @@ exports.planMigrationDeploy = planMigrationDeploy;
|
|
|
39
43
|
exports.inspectMigrationDeploy = inspectMigrationDeploy;
|
|
40
44
|
exports.migrateUp = migrateUp;
|
|
41
45
|
exports.migrateDeploy = migrateDeploy;
|
|
46
|
+
exports.rollbackMigrations = rollbackMigrations;
|
|
42
47
|
exports.migrateDown = migrateDown;
|
|
43
48
|
exports.migrateStatus = migrateStatus;
|
|
44
49
|
const node_crypto_1 = require("node:crypto");
|
|
@@ -49,11 +54,55 @@ const index_js_1 = require("../adapters/index.js");
|
|
|
49
54
|
const dialect_js_1 = require("../dialect.js");
|
|
50
55
|
const errors_js_1 = require("../errors.js");
|
|
51
56
|
const destructive_js_1 = require("./destructive.js");
|
|
57
|
+
const sql_statements_js_1 = require("./sql-statements.js");
|
|
58
|
+
Object.defineProperty(exports, "splitSqlStatements", { enumerable: true, get: function () { return sql_statements_js_1.splitSqlStatements; } });
|
|
52
59
|
/** Extract the YYYYMMDDHHMMSS timestamp prefix from a migration name, or null. */
|
|
53
60
|
function migrationTimestamp(name) {
|
|
54
61
|
const m = name.match(/^(\d{14})(?:_|$)/);
|
|
55
62
|
return m ? m[1] : null;
|
|
56
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Refuse any migration in the batch that manages its own transactions.
|
|
66
|
+
*
|
|
67
|
+
* `-- turbine:no-transaction` files are exempt: they were never wrapped, so
|
|
68
|
+
* theirs is a real (and supported) transaction to manage. Pre-flight over the
|
|
69
|
+
* WHOLE batch, before anything runs, so a bad file at position 3 does not leave
|
|
70
|
+
* migrations 1 and 2 applied.
|
|
71
|
+
*
|
|
72
|
+
* @internal exported for tests.
|
|
73
|
+
*/
|
|
74
|
+
function assertNoEmbeddedTransactions(files, section) {
|
|
75
|
+
const offenders = [];
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const parsed = parseMigrationSQL(file.path);
|
|
78
|
+
if (parsed.noTransaction)
|
|
79
|
+
continue;
|
|
80
|
+
const statements = findTransactionControlStatements(section === 'up' ? parsed.up : parsed.down);
|
|
81
|
+
if (statements.length > 0)
|
|
82
|
+
offenders.push({ file: file.filename, statements });
|
|
83
|
+
}
|
|
84
|
+
if (offenders.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
const lines = [
|
|
87
|
+
`[turbine] Refusing to run migrations that manage transactions themselves (${section.toUpperCase()} section):`,
|
|
88
|
+
'',
|
|
89
|
+
];
|
|
90
|
+
for (const o of offenders) {
|
|
91
|
+
lines.push(` ${o.file}`);
|
|
92
|
+
for (const s of o.statements)
|
|
93
|
+
lines.push(` - ${s}`);
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push('`turbine migrate` already runs each migration file inside exactly ONE transaction.');
|
|
97
|
+
lines.push('An embedded COMMIT ends that wrapper: everything before it becomes durable,');
|
|
98
|
+
lines.push('everything after runs unprotected, and the migration is recorded nowhere, so a');
|
|
99
|
+
lines.push('rerun fails forever on "already exists".');
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push('Delete the BEGIN/COMMIT/ROLLBACK statements (the runner supplies the transaction),');
|
|
102
|
+
lines.push('or, if this migration genuinely cannot run inside one (CREATE INDEX CONCURRENTLY),');
|
|
103
|
+
lines.push('add `-- turbine:no-transaction` to the file header and manage it yourself.');
|
|
104
|
+
throw new errors_js_1.MigrationError(lines.join('\n'));
|
|
105
|
+
}
|
|
57
106
|
/** Scan a set of migration files' UP sections for data-destroying statements. */
|
|
58
107
|
function collectUpDestructive(files) {
|
|
59
108
|
const offenders = [];
|
|
@@ -88,8 +137,34 @@ function migrationDialect() {
|
|
|
88
137
|
function quotedTrackingTable(dialect) {
|
|
89
138
|
return dialect.quoteIdentifier(TRACKING_TABLE);
|
|
90
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Postgres error codes that mean "someone else created this table between our
|
|
142
|
+
* existence check and our CREATE".
|
|
143
|
+
*
|
|
144
|
+
* `CREATE TABLE IF NOT EXISTS` is NOT race-free: the existence check and the
|
|
145
|
+
* catalog insert are separate steps, so two concurrent sessions can both pass
|
|
146
|
+
* the check and the loser gets a hard error rather than a quiet no-op. Measured
|
|
147
|
+
* on a fresh database with 12 concurrent `migrate status` calls: 1 succeeded and
|
|
148
|
+
* 11 crashed, on `duplicate key value violates unique constraint
|
|
149
|
+
* "pg_type_typname_nsp_index"` (23505) and `relation "_turbine_migrations"
|
|
150
|
+
* already exists` (42P07). `migrate up`/`down` hold the migration lock before
|
|
151
|
+
* they reach here, but `migrate status` and the deploy inspector deliberately do
|
|
152
|
+
* not, and read-only commands should not need a lock to survive each other.
|
|
153
|
+
*/
|
|
154
|
+
const TABLE_ALREADY_EXISTS_CODES = new Set(['23505', '42P07']);
|
|
91
155
|
async function ensureTrackingTable(client, dialect = dialect_js_1.postgresDialect) {
|
|
92
|
-
|
|
156
|
+
const sql = dialect.buildMigrationTrackingTable(quotedTrackingTable(dialect));
|
|
157
|
+
try {
|
|
158
|
+
await client.query(sql);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
if (!TABLE_ALREADY_EXISTS_CODES.has(String(err.code)))
|
|
162
|
+
throw err;
|
|
163
|
+
// The winner has committed by the time we see its error, so the retry finds
|
|
164
|
+
// the table present and the statement really is a no-op. Retried ONCE: a
|
|
165
|
+
// second failure is not this race and must surface.
|
|
166
|
+
await client.query(sql);
|
|
167
|
+
}
|
|
93
168
|
}
|
|
94
169
|
async function getAppliedMigrations(client, dialect = dialect_js_1.postgresDialect) {
|
|
95
170
|
await ensureTrackingTable(client, dialect);
|
|
@@ -114,6 +189,27 @@ function parseMigrationFilename(filename) {
|
|
|
114
189
|
timestamp: match[1],
|
|
115
190
|
};
|
|
116
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* A migration name as it can safely appear in a file's `-- Migration:` header
|
|
194
|
+
* comment.
|
|
195
|
+
*
|
|
196
|
+
* The header sits ABOVE the `-- UP` marker, and the raw CLI argument used to be
|
|
197
|
+
* interpolated into it verbatim. A `--` comment ends at the first newline, so a
|
|
198
|
+
* name carrying one closes the comment and everything after it becomes file
|
|
199
|
+
* content: a name of `x\n-- turbine:no-transaction\n-- UP\nDROP TABLE users;`
|
|
200
|
+
* wrote both an execution directive and executable SQL into a migration the
|
|
201
|
+
* user never authored. Only the FILENAME was sanitized, which is the one place
|
|
202
|
+
* the injection could not reach.
|
|
203
|
+
*
|
|
204
|
+
* Collapsing every run of whitespace to a single space is the whole fix: the
|
|
205
|
+
* argument then cannot leave the one comment line it was written on, and `\s`
|
|
206
|
+
* covers `\r` and the Unicode line separators too, all of which Postgres also
|
|
207
|
+
* treats as ending a `--` comment. The readable spelling is preserved, unlike
|
|
208
|
+
* {@link sanitizeName}, because this is documentation for a human.
|
|
209
|
+
*/
|
|
210
|
+
function headerSafeName(name) {
|
|
211
|
+
return name.replace(/\s+/g, ' ').trim();
|
|
212
|
+
}
|
|
117
213
|
/**
|
|
118
214
|
* Sanitize a migration name: lowercase, replace non-alnum with _, collapse duplicates, trim.
|
|
119
215
|
*/
|
|
@@ -172,31 +268,52 @@ function listMigrationFiles(migrationsDir) {
|
|
|
172
268
|
* including the implicit transaction a multi-statement simple query creates.
|
|
173
269
|
*/
|
|
174
270
|
const NO_TRANSACTION_DIRECTIVE = /^--\s*turbine:no-transaction\s*$/i;
|
|
271
|
+
/**
|
|
272
|
+
* A section marker line.
|
|
273
|
+
*
|
|
274
|
+
* Matching the exact strings `-- UP` and `-- DOWN` was too strict for the ways
|
|
275
|
+
* people actually write them: `--DOWN`, `-- DOWN`, and `-- DOWN;` all read as
|
|
276
|
+
* ordinary comments, so the whole rollback section silently folded into the UP
|
|
277
|
+
* section and RAN as part of the migration. `migrate up` was saved from the
|
|
278
|
+
* worst of that by the destructive gate, but `migrate deploy` passes
|
|
279
|
+
* `allowDestructive: true` unconditionally, so a create-then-drop pair applied
|
|
280
|
+
* as one "successful" migration.
|
|
281
|
+
*
|
|
282
|
+
* Deliberately still anchored end-to-end: `-- UPDATE the widgets table` is a
|
|
283
|
+
* comment, not a marker.
|
|
284
|
+
*/
|
|
285
|
+
const SECTION_MARKER = /^--\s*(UP|DOWN)\s*;?\s*$/i;
|
|
175
286
|
/**
|
|
176
287
|
* Parse migration content string into UP and DOWN sections plus directives.
|
|
288
|
+
*
|
|
289
|
+
* Throws `MigrationError` when the file carries no `-- UP` marker at all.
|
|
290
|
+
* Returning `{ up: '', down: '' }` there meant the entire file was treated as
|
|
291
|
+
* preamble and the migration recorded as applied having executed nothing, which
|
|
292
|
+
* is worse than any error: the database is missing the change and the history
|
|
293
|
+
* says it is present. `source` (a path) is only used to name the file.
|
|
294
|
+
*
|
|
177
295
|
* Exported for unit testing.
|
|
178
296
|
*/
|
|
179
|
-
function parseMigrationContent(content) {
|
|
297
|
+
function parseMigrationContent(content, source) {
|
|
180
298
|
const lines = content.split('\n');
|
|
181
299
|
let section = 'none';
|
|
300
|
+
let sawUpMarker = false;
|
|
182
301
|
let noTransaction = false;
|
|
183
302
|
const upLines = [];
|
|
184
303
|
const downLines = [];
|
|
185
304
|
for (const line of lines) {
|
|
186
305
|
const trimmed = line.trim();
|
|
187
|
-
const upper = trimmed.toUpperCase();
|
|
188
306
|
// The directive is only honored in the header (before -- UP), so it can
|
|
189
307
|
// never be smuggled in via a DOWN-section comment.
|
|
190
308
|
if (section === 'none' && NO_TRANSACTION_DIRECTIVE.test(trimmed)) {
|
|
191
309
|
noTransaction = true;
|
|
192
310
|
continue;
|
|
193
311
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
section = 'down';
|
|
312
|
+
const marker = SECTION_MARKER.exec(trimmed);
|
|
313
|
+
if (marker) {
|
|
314
|
+
const isUp = marker[1].toUpperCase() === 'UP';
|
|
315
|
+
section = isUp ? 'up' : 'down';
|
|
316
|
+
sawUpMarker ||= isUp;
|
|
200
317
|
continue;
|
|
201
318
|
}
|
|
202
319
|
if (section === 'up')
|
|
@@ -204,6 +321,17 @@ function parseMigrationContent(content) {
|
|
|
204
321
|
else if (section === 'down')
|
|
205
322
|
downLines.push(line);
|
|
206
323
|
}
|
|
324
|
+
if (!sawUpMarker) {
|
|
325
|
+
throw new errors_js_1.MigrationError([
|
|
326
|
+
`[turbine] Migration file has no \`-- UP\` section marker${source ? `: ${source}` : '.'}`,
|
|
327
|
+
'',
|
|
328
|
+
'A migration must contain a line reading `-- UP` (a `-- DOWN` line is optional).',
|
|
329
|
+
'Without it the whole file is a header comment: nothing would run, and the',
|
|
330
|
+
'migration would still be recorded as applied.',
|
|
331
|
+
'',
|
|
332
|
+
'Accepted spellings: `-- UP`, `--UP`, `-- up`, `-- UP;` (leading/trailing spaces fine).',
|
|
333
|
+
].join('\n'));
|
|
334
|
+
}
|
|
207
335
|
return {
|
|
208
336
|
up: upLines.join('\n').trim(),
|
|
209
337
|
down: downLines.join('\n').trim(),
|
|
@@ -211,173 +339,50 @@ function parseMigrationContent(content) {
|
|
|
211
339
|
};
|
|
212
340
|
}
|
|
213
341
|
/**
|
|
214
|
-
*
|
|
342
|
+
* Statements that end (or restart) the transaction the runner wraps a migration
|
|
343
|
+
* file in.
|
|
215
344
|
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* is pinned by exhaustive unit tests.
|
|
345
|
+
* `runMigrationInTransaction` issues `BEGIN`, the file body, the tracking-table
|
|
346
|
+
* write, then `COMMIT`. An embedded `COMMIT;` in the body commits THAT wrapper:
|
|
347
|
+
* everything before it becomes durable, everything after runs unprotected, the
|
|
348
|
+
* tracking write happens outside any transaction the failure path can undo, and
|
|
349
|
+
* a mid-file error leaves the migration recorded nowhere. Rerunning then fails
|
|
350
|
+
* forever on "already exists". So the runner refuses the file instead.
|
|
223
351
|
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
352
|
+
* `END` is deliberately NOT in this list even though Postgres accepts it as a
|
|
353
|
+
* synonym for COMMIT: a PG14+ `CREATE FUNCTION ... BEGIN ATOMIC ... END;` body
|
|
354
|
+
* splits at its inner semicolons, leaving a bare `END` fragment, and refusing
|
|
355
|
+
* that would break working migrations to catch a spelling nobody writes.
|
|
356
|
+
* `ROLLBACK TO [SAVEPOINT] x` is excluded for the opposite reason: it is the one
|
|
357
|
+
* ROLLBACK form that leaves the wrapping transaction open, so it is legitimate.
|
|
226
358
|
*/
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
let current = '';
|
|
230
|
-
let i = 0;
|
|
231
|
-
const n = sql.length;
|
|
232
|
-
while (i < n) {
|
|
233
|
-
const ch = sql[i];
|
|
234
|
-
const next = sql[i + 1];
|
|
235
|
-
// Line comment: consume to end of line (kept verbatim in the statement).
|
|
236
|
-
if (ch === '-' && next === '-') {
|
|
237
|
-
let j = i;
|
|
238
|
-
while (j < n && sql[j] !== '\n')
|
|
239
|
-
j++;
|
|
240
|
-
current += sql.slice(i, j);
|
|
241
|
-
i = j;
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
// Block comment (Postgres allows nesting: /* /* */ */).
|
|
245
|
-
if (ch === '/' && next === '*') {
|
|
246
|
-
let depth = 1;
|
|
247
|
-
let j = i + 2;
|
|
248
|
-
current += '/*';
|
|
249
|
-
while (j < n && depth > 0) {
|
|
250
|
-
if (sql[j] === '/' && sql[j + 1] === '*') {
|
|
251
|
-
depth++;
|
|
252
|
-
current += '/*';
|
|
253
|
-
j += 2;
|
|
254
|
-
}
|
|
255
|
-
else if (sql[j] === '*' && sql[j + 1] === '/') {
|
|
256
|
-
depth--;
|
|
257
|
-
current += '*/';
|
|
258
|
-
j += 2;
|
|
259
|
-
}
|
|
260
|
-
else {
|
|
261
|
-
current += sql[j];
|
|
262
|
-
j++;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
i = j;
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
// Single-quoted string ('' is an escaped quote, stays inside the string).
|
|
269
|
-
// An E-prefixed string (E'...') additionally honors backslash escapes, so
|
|
270
|
-
// `E'p\'q'` is ONE string: treating the `\'` as a terminator would close the
|
|
271
|
-
// string early and let the next quote swallow a real statement terminator.
|
|
272
|
-
if (ch === "'") {
|
|
273
|
-
const backslashEscapes = isEscapeStringPrefix(sql, i);
|
|
274
|
-
let j = i + 1;
|
|
275
|
-
current += "'";
|
|
276
|
-
while (j < n) {
|
|
277
|
-
if (backslashEscapes && sql[j] === '\\' && j + 1 < n) {
|
|
278
|
-
current += sql[j] + sql[j + 1];
|
|
279
|
-
j += 2;
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
if (sql[j] === "'" && sql[j + 1] === "'") {
|
|
283
|
-
current += "''";
|
|
284
|
-
j += 2;
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
if (sql[j] === "'") {
|
|
288
|
-
current += "'";
|
|
289
|
-
j++;
|
|
290
|
-
break;
|
|
291
|
-
}
|
|
292
|
-
current += sql[j];
|
|
293
|
-
j++;
|
|
294
|
-
}
|
|
295
|
-
i = j;
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
// Double-quoted identifier ("" is an escaped quote).
|
|
299
|
-
if (ch === '"') {
|
|
300
|
-
let j = i + 1;
|
|
301
|
-
current += '"';
|
|
302
|
-
while (j < n) {
|
|
303
|
-
if (sql[j] === '"' && sql[j + 1] === '"') {
|
|
304
|
-
current += '""';
|
|
305
|
-
j += 2;
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
if (sql[j] === '"') {
|
|
309
|
-
current += '"';
|
|
310
|
-
j++;
|
|
311
|
-
break;
|
|
312
|
-
}
|
|
313
|
-
current += sql[j];
|
|
314
|
-
j++;
|
|
315
|
-
}
|
|
316
|
-
i = j;
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
// Dollar-quoted body ($tag$ ... $tag$; tag is empty or an identifier, never
|
|
320
|
-
// digit-leading, so a `$1` parameter placeholder is not mistaken for one).
|
|
321
|
-
if (ch === '$') {
|
|
322
|
-
const tagMatch = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/.exec(sql.slice(i));
|
|
323
|
-
if (tagMatch) {
|
|
324
|
-
const tag = tagMatch[0];
|
|
325
|
-
const end = sql.indexOf(tag, i + tag.length);
|
|
326
|
-
if (end === -1) {
|
|
327
|
-
current += sql.slice(i);
|
|
328
|
-
i = n;
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
current += sql.slice(i, end + tag.length);
|
|
332
|
-
i = end + tag.length;
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
// Top-level statement terminator.
|
|
337
|
-
if (ch === ';') {
|
|
338
|
-
const trimmed = current.trim();
|
|
339
|
-
if (trimmed)
|
|
340
|
-
statements.push(trimmed);
|
|
341
|
-
current = '';
|
|
342
|
-
i++;
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
current += ch;
|
|
346
|
-
i++;
|
|
347
|
-
}
|
|
348
|
-
const tail = current.trim();
|
|
349
|
-
if (tail)
|
|
350
|
-
statements.push(tail);
|
|
351
|
-
return statements.filter((s) => !isCommentOnlyStatement(s));
|
|
352
|
-
}
|
|
359
|
+
const TRANSACTION_CONTROL = /^(BEGIN|START\s+TRANSACTION|COMMIT|ROLLBACK|ABORT)\b/i;
|
|
360
|
+
const ROLLBACK_TO_SAVEPOINT = /^ROLLBACK\s+TO\b/i;
|
|
353
361
|
/**
|
|
354
|
-
*
|
|
355
|
-
*
|
|
362
|
+
* Top-level transaction-control statements in a migration body, as displayable
|
|
363
|
+
* text. Empty for a clean file. Comment- and literal-aware via the shared
|
|
364
|
+
* tokenizer, so a `COMMIT` inside a comment or a string is not flagged.
|
|
356
365
|
*
|
|
357
|
-
*
|
|
358
|
-
* (`some_table` cannot be followed by a quote in valid SQL, but the check keeps
|
|
359
|
-
* the tokenizer honest) does not turn the following literal into an E-string.
|
|
360
|
-
* Ordinary literals are left alone on purpose: with the modern
|
|
361
|
-
* `standard_conforming_strings = on` default, `'a\'` IS a complete string.
|
|
366
|
+
* @internal exported for tests.
|
|
362
367
|
*/
|
|
363
|
-
function
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return
|
|
368
|
+
function findTransactionControlStatements(body) {
|
|
369
|
+
const found = [];
|
|
370
|
+
for (const statement of (0, sql_statements_js_1.tokenizeSql)(body)) {
|
|
371
|
+
if (statement.commentOnly)
|
|
372
|
+
continue;
|
|
373
|
+
const head = statement.stripped;
|
|
374
|
+
if (!TRANSACTION_CONTROL.test(head) || ROLLBACK_TO_SAVEPOINT.test(head))
|
|
375
|
+
continue;
|
|
376
|
+
found.push(head.replace(/\s+/g, ' ').slice(0, 80));
|
|
377
|
+
}
|
|
378
|
+
return found;
|
|
374
379
|
}
|
|
375
380
|
/**
|
|
376
381
|
* Parse a migration file into UP and DOWN sections.
|
|
377
382
|
*/
|
|
378
383
|
function parseMigrationSQL(filePath) {
|
|
379
384
|
const content = (0, node_fs_1.readFileSync)(filePath, 'utf-8');
|
|
380
|
-
return parseMigrationContent(content);
|
|
385
|
+
return parseMigrationContent(content, filePath);
|
|
381
386
|
}
|
|
382
387
|
/**
|
|
383
388
|
* SHA-256 checksum for migration drift detection.
|
|
@@ -440,6 +445,12 @@ function buildBackfillRecipe() {
|
|
|
440
445
|
-- your table, the new column, the old column, and the transform, then uncomment
|
|
441
446
|
-- the phases you need and review before running \`npx turbine migrate up\`.
|
|
442
447
|
--
|
|
448
|
+
-- Do NOT add BEGIN/COMMIT of your own anywhere in this file. \`turbine migrate\`
|
|
449
|
+
-- already runs each migration file inside exactly ONE transaction, so every
|
|
450
|
+
-- statement here commits or rolls back together. An embedded COMMIT would end
|
|
451
|
+
-- that wrapper early, leaving the first half durable and the migration recorded
|
|
452
|
+
-- nowhere; the runner refuses a file that contains one.
|
|
453
|
+
--
|
|
443
454
|
-- Phase 1: add the new column as NULLABLE. This is a fast, non-blocking change
|
|
444
455
|
-- (no table rewrite, no long lock), so it is safe to ship ahead of the backfill.
|
|
445
456
|
-- ALTER TABLE "my_table" ADD COLUMN "new_col" text;
|
|
@@ -462,16 +473,14 @@ function buildBackfillRecipe() {
|
|
|
462
473
|
-- ALTER TABLE "my_table" ALTER COLUMN "new_col" SET NOT NULL;
|
|
463
474
|
--
|
|
464
475
|
-- Phase 4 (optional atomic swap): retire the old column and rename the new one
|
|
465
|
-
-- into its place
|
|
466
|
-
-- BEGIN
|
|
467
|
-
--
|
|
468
|
-
--
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
--
|
|
472
|
-
--
|
|
473
|
-
-- ALTER TABLE "my_table" RENAME COLUMN "old_col_retired" TO "old_col";
|
|
474
|
-
-- COMMIT;
|
|
476
|
+
-- into its place. Both renames land in the runner's single per-file
|
|
477
|
+
-- transaction, so readers never see a missing column: no BEGIN/COMMIT needed.
|
|
478
|
+
-- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "old_col_retired";
|
|
479
|
+
-- ALTER TABLE "my_table" RENAME COLUMN "new_col" TO "old_col";`;
|
|
480
|
+
const down = `-- Reverse the Phase 4 atomic swap (only if you ran it). Same single
|
|
481
|
+
-- transaction as the UP direction, so again no BEGIN/COMMIT of your own.
|
|
482
|
+
-- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "new_col";
|
|
483
|
+
-- ALTER TABLE "my_table" RENAME COLUMN "old_col_retired" TO "old_col";
|
|
475
484
|
--
|
|
476
485
|
-- If you stopped after phases 1 to 3, drop the added column instead:
|
|
477
486
|
-- ALTER TABLE "my_table" DROP COLUMN "new_col";`;
|
|
@@ -583,6 +592,9 @@ function createMigration(migrationsDir, name, autoContent, options) {
|
|
|
583
592
|
const now = new Date();
|
|
584
593
|
const ts = formatTimestamp(now);
|
|
585
594
|
const safeName = sanitizeName(name);
|
|
595
|
+
// The header comment is the ONLY place the caller's raw string reaches the
|
|
596
|
+
// file, and it sits above `-- UP`: see headerSafeName for what that allowed.
|
|
597
|
+
const headerName = headerSafeName(name);
|
|
586
598
|
const filename = `${ts}_${safeName}.sql`;
|
|
587
599
|
const filePath = (0, node_path_1.join)(migrationsDir, filename);
|
|
588
600
|
let template;
|
|
@@ -592,8 +604,10 @@ function createMigration(migrationsDir, name, autoContent, options) {
|
|
|
592
604
|
const known = Object.keys(exports.MIGRATION_RECIPES).join(', ') || '(none)';
|
|
593
605
|
throw new errors_js_1.MigrationError(`[turbine] Unknown migration recipe "${options.recipe}". Available recipes: ${known}`);
|
|
594
606
|
}
|
|
595
|
-
|
|
596
|
-
|
|
607
|
+
// A recipe builds the BODY, below `-- UP`, where a newline is not merely a
|
|
608
|
+
// comment break but directly executable, so it gets the safe name too.
|
|
609
|
+
const body = recipe.build(headerName);
|
|
610
|
+
template = `-- Migration: ${headerName} (${options.recipe} recipe scaffold)
|
|
597
611
|
-- Created: ${now.toISOString()}
|
|
598
612
|
-- Fill in the placeholders and review before running: npx turbine migrate up
|
|
599
613
|
|
|
@@ -606,7 +620,7 @@ ${body.down}
|
|
|
606
620
|
}
|
|
607
621
|
else if (autoContent) {
|
|
608
622
|
const headerBlock = options?.header ? `${options.header}\n` : '';
|
|
609
|
-
template = `-- Migration: ${
|
|
623
|
+
template = `-- Migration: ${headerName} (auto-generated)
|
|
610
624
|
-- Created: ${now.toISOString()}
|
|
611
625
|
-- Review this file before running: npx turbine migrate up
|
|
612
626
|
${headerBlock}
|
|
@@ -618,7 +632,7 @@ ${autoContent.down}
|
|
|
618
632
|
`;
|
|
619
633
|
}
|
|
620
634
|
else {
|
|
621
|
-
template = `-- Migration: ${
|
|
635
|
+
template = `-- Migration: ${headerName}
|
|
622
636
|
-- Created: ${now.toISOString()}
|
|
623
637
|
|
|
624
638
|
-- UP
|
|
@@ -671,14 +685,75 @@ async function getCurrentDatabaseName(client) {
|
|
|
671
685
|
const result = await client.query(`SELECT current_database()`);
|
|
672
686
|
return result.rows[0]?.current_database ?? '';
|
|
673
687
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
688
|
+
/** Open the second, lock-only connection. Separated so tests can fake it. */
|
|
689
|
+
async function openLockConnection(connectionString) {
|
|
690
|
+
const client = new pg_1.default.Client({ connectionString });
|
|
691
|
+
await client.connect();
|
|
692
|
+
return client;
|
|
678
693
|
}
|
|
679
|
-
|
|
694
|
+
/**
|
|
695
|
+
* Take the migration lock, on a DEDICATED connection when the adapter needs one.
|
|
696
|
+
*
|
|
697
|
+
* The CockroachDB and YugabyteDB adapters lock a row in `_turbine_lock` with
|
|
698
|
+
* `SELECT ... FOR UPDATE NOWAIT` and deliberately leave that transaction OPEN,
|
|
699
|
+
* because a row lock only exists for as long as its transaction does. The runner
|
|
700
|
+
* then applies every migration on the SAME connection, and
|
|
701
|
+
* `runMigrationInTransaction` issues BEGIN ... COMMIT per file. That COMMIT ends
|
|
702
|
+
* the LOCK's transaction: from migration 2 onward the run was unprotected, a
|
|
703
|
+
* concurrent `turbine migrate` could take the lock and replay those files, and
|
|
704
|
+
* `releaseLock`'s later COMMIT was a no-op that warned rather than failed, so
|
|
705
|
+
* nothing surfaced. The same collision had a second face: a
|
|
706
|
+
* `-- turbine:no-transaction` migration running FIRST executed inside the still
|
|
707
|
+
* open lock transaction, so `CREATE INDEX CONCURRENTLY` failed with "cannot run
|
|
708
|
+
* inside a transaction block" while the identical file placed second succeeded.
|
|
709
|
+
*
|
|
710
|
+
* A second connection separates the two transaction scopes, which is the only
|
|
711
|
+
* thing that makes the lock outlive a migration. The advisory-lock path (plain
|
|
712
|
+
* Postgres, AlloyDB, Timescale) is session-scoped rather than
|
|
713
|
+
* transaction-scoped, opens NO second connection, and is byte-identical to what
|
|
714
|
+
* it has always done.
|
|
715
|
+
*
|
|
716
|
+
* @internal exported for tests.
|
|
717
|
+
*/
|
|
718
|
+
async function acquireMigrationLock(runner, lockId, adapter, openLockConnection) {
|
|
680
719
|
const a = adapter ?? index_js_1.postgresql;
|
|
681
|
-
|
|
720
|
+
const lockClient = a.lockHoldsOpenTransaction ? await openLockConnection() : undefined;
|
|
721
|
+
try {
|
|
722
|
+
// pg.Client satisfies PgCompatPoolClient (query + release)
|
|
723
|
+
const acquired = await a.acquireLock((lockClient ?? runner), lockId);
|
|
724
|
+
if (!acquired && lockClient)
|
|
725
|
+
await lockClient.end();
|
|
726
|
+
return { acquired, lockId, adapter: a, lockClient: acquired ? lockClient : undefined };
|
|
727
|
+
}
|
|
728
|
+
catch (err) {
|
|
729
|
+
if (lockClient) {
|
|
730
|
+
try {
|
|
731
|
+
await lockClient.end();
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
// Best effort: the acquire error below is what the user needs to see.
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
throw err;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Release a lock taken by {@link acquireMigrationLock}, and close the dedicated
|
|
742
|
+
* connection when there is one. A refused lock owns nothing, so releasing it is
|
|
743
|
+
* a no-op rather than an unlock of somebody else's lock.
|
|
744
|
+
*
|
|
745
|
+
* @internal exported for tests.
|
|
746
|
+
*/
|
|
747
|
+
async function releaseMigrationLock(lock, runner) {
|
|
748
|
+
if (!lock.acquired)
|
|
749
|
+
return;
|
|
750
|
+
try {
|
|
751
|
+
await lock.adapter.releaseLock((lock.lockClient ?? runner), lock.lockId);
|
|
752
|
+
}
|
|
753
|
+
finally {
|
|
754
|
+
if (lock.lockClient)
|
|
755
|
+
await lock.lockClient.end();
|
|
756
|
+
}
|
|
682
757
|
}
|
|
683
758
|
/**
|
|
684
759
|
* Run one migration body (UP or DOWN) plus its tracking-table write inside a
|
|
@@ -870,10 +945,12 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
870
945
|
const dbName = await getCurrentDatabaseName(client);
|
|
871
946
|
const lockId = deriveLockId(dbName);
|
|
872
947
|
// Acquire lock to prevent concurrent migrations.
|
|
873
|
-
// The adapter determines the strategy (advisory lock vs table lock)
|
|
948
|
+
// The adapter determines the strategy (advisory lock vs table lock), and a
|
|
949
|
+
// table-lock adapter gets its OWN connection so the per-migration
|
|
950
|
+
// BEGIN/COMMIT below cannot end the transaction the lock lives in.
|
|
874
951
|
const adapter = options?.adapter;
|
|
875
|
-
const
|
|
876
|
-
if (!
|
|
952
|
+
const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(connectionString));
|
|
953
|
+
if (!lock.acquired) {
|
|
877
954
|
throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock, another migration is already running');
|
|
878
955
|
}
|
|
879
956
|
try {
|
|
@@ -904,6 +981,10 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
904
981
|
if (options?.step != null && options.step > 0) {
|
|
905
982
|
pending = pending.slice(0, options.step);
|
|
906
983
|
}
|
|
984
|
+
// Structural check before any policy gate: a file that manages its own
|
|
985
|
+
// transactions cannot be run safely at all, so it is refused for everyone,
|
|
986
|
+
// deploy included. Pre-flight over the whole batch, so nothing is applied.
|
|
987
|
+
assertNoEmbeddedTransactions(pending, 'up');
|
|
907
988
|
// Destructive statements in the pending batch, computed once. Returned in
|
|
908
989
|
// the result regardless of the gate so `deploy` can print a notice even
|
|
909
990
|
// though it proceeds by design.
|
|
@@ -941,8 +1022,12 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
941
1022
|
const parsed = parseMigrationSQL(file.path);
|
|
942
1023
|
const up = parsed.up;
|
|
943
1024
|
if (!up) {
|
|
1025
|
+
// STOP, do not skip. Continuing applied later migrations over the gap
|
|
1026
|
+
// this one left, which is the same hazard as continuing past a SQL
|
|
1027
|
+
// failure: the batch is ordered, and a later file may depend on this
|
|
1028
|
+
// one. The SQL-failure path below has always broken here.
|
|
944
1029
|
errors.push({ file, error: 'No UP section found in migration file' });
|
|
945
|
-
|
|
1030
|
+
break;
|
|
946
1031
|
}
|
|
947
1032
|
const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
|
|
948
1033
|
const hash = checksum(content);
|
|
@@ -956,7 +1041,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
956
1041
|
options?.onNoTransaction?.(file);
|
|
957
1042
|
noTransactionApplied.push(file);
|
|
958
1043
|
try {
|
|
959
|
-
for (const stmt of splitSqlStatements(up)) {
|
|
1044
|
+
for (const stmt of (0, sql_statements_js_1.splitSqlStatements)(up)) {
|
|
960
1045
|
await client.query(stmt);
|
|
961
1046
|
}
|
|
962
1047
|
await client.query(insertApplied, [file.name, hash]);
|
|
@@ -985,7 +1070,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
985
1070
|
return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied };
|
|
986
1071
|
}
|
|
987
1072
|
finally {
|
|
988
|
-
await
|
|
1073
|
+
await releaseMigrationLock(lock, client);
|
|
989
1074
|
}
|
|
990
1075
|
}
|
|
991
1076
|
finally {
|
|
@@ -1005,6 +1090,71 @@ async function migrateDeploy(connectionString, migrationsDir, options) {
|
|
|
1005
1090
|
adapter: options?.adapter,
|
|
1006
1091
|
});
|
|
1007
1092
|
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Roll back a prepared LIFO batch, newest first, stopping at the first
|
|
1095
|
+
* migration that cannot be rolled back.
|
|
1096
|
+
*
|
|
1097
|
+
* A rollback batch is strictly LIFO and must have NO GAPS. The
|
|
1098
|
+
* "file not found" and "no DOWN section" branches used to `continue`, so a
|
|
1099
|
+
* `--step 3` whose middle migration had no DOWN section rolled back 3 and then
|
|
1100
|
+
* 1: the oldest migration's schema was torn down while the data migration 2 had
|
|
1101
|
+
* seeded into it was still expected to exist, and the tracking table was left
|
|
1102
|
+
* claiming migration 2 alone was applied. The next `migrate up` then re-ran 1
|
|
1103
|
+
* and 3 and never re-ran 2, so that data was gone permanently. Both branches
|
|
1104
|
+
* now stop, which is what the SQL-failure branches have always done.
|
|
1105
|
+
*
|
|
1106
|
+
* Split out of {@link migrateDown} so the ordering contract is testable against
|
|
1107
|
+
* a fake client, without a database.
|
|
1108
|
+
*
|
|
1109
|
+
* @internal exported for tests; not part of the CLI's public surface.
|
|
1110
|
+
*/
|
|
1111
|
+
async function rollbackMigrations(client, toRollback, fileMap, deleteApplied) {
|
|
1112
|
+
const results = [];
|
|
1113
|
+
const errors = [];
|
|
1114
|
+
for (const migration of toRollback) {
|
|
1115
|
+
const file = fileMap.get(migration.name);
|
|
1116
|
+
if (!file) {
|
|
1117
|
+
errors.push({
|
|
1118
|
+
file: { filename: `${migration.name}.sql`, path: '', name: migration.name, timestamp: '' },
|
|
1119
|
+
error: `Migration file not found for "${migration.name}"`,
|
|
1120
|
+
});
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
const parsed = parseMigrationSQL(file.path);
|
|
1124
|
+
const down = parsed.down;
|
|
1125
|
+
if (!down) {
|
|
1126
|
+
errors.push({ file, error: 'No DOWN section found in migration file' });
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
if (parsed.noTransaction) {
|
|
1130
|
+
// Untransacted rollback (DROP INDEX CONCURRENTLY IF EXISTS), one
|
|
1131
|
+
// statement per query() call: same contract as the untransacted UP.
|
|
1132
|
+
try {
|
|
1133
|
+
for (const stmt of (0, sql_statements_js_1.splitSqlStatements)(down)) {
|
|
1134
|
+
await client.query(stmt);
|
|
1135
|
+
}
|
|
1136
|
+
await client.query(deleteApplied, [migration.name]);
|
|
1137
|
+
results.push(file);
|
|
1138
|
+
}
|
|
1139
|
+
catch (err) {
|
|
1140
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1141
|
+
errors.push({ file, error: msg });
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
const failure = await runMigrationInTransaction(client, down, {
|
|
1147
|
+
sql: deleteApplied,
|
|
1148
|
+
params: [migration.name],
|
|
1149
|
+
});
|
|
1150
|
+
if (failure !== null) {
|
|
1151
|
+
errors.push({ file, error: failure });
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
results.push(file);
|
|
1155
|
+
}
|
|
1156
|
+
return { rolledBack: results, errors };
|
|
1157
|
+
}
|
|
1008
1158
|
/**
|
|
1009
1159
|
* Rollback the last N migrations (DOWN).
|
|
1010
1160
|
*
|
|
@@ -1023,8 +1173,8 @@ async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
1023
1173
|
const dbName = await getCurrentDatabaseName(client);
|
|
1024
1174
|
const lockId = deriveLockId(dbName);
|
|
1025
1175
|
const adapter = options?.adapter;
|
|
1026
|
-
const
|
|
1027
|
-
if (!
|
|
1176
|
+
const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(connectionString));
|
|
1177
|
+
if (!lock.acquired) {
|
|
1028
1178
|
throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock, another migration is already running');
|
|
1029
1179
|
}
|
|
1030
1180
|
try {
|
|
@@ -1037,6 +1187,9 @@ async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
1037
1187
|
const fileMap = new Map(allFiles.map((f) => [f.name, f]));
|
|
1038
1188
|
// Reverse order, rollback most recent first
|
|
1039
1189
|
const toRollback = applied.reverse().slice(0, options?.step ?? 1);
|
|
1190
|
+
// Same structural pre-flight as migrateUp: a DOWN body is wrapped in the
|
|
1191
|
+
// same single transaction, so it cannot manage its own either.
|
|
1192
|
+
assertNoEmbeddedTransactions(toRollback.map((m) => fileMap.get(m.name)).filter((f) => f !== undefined), 'down');
|
|
1040
1193
|
// Same data-loss gate as migrateUp, DOWN sections routinely contain
|
|
1041
1194
|
// DROP TABLE (the legitimate reverse of a CREATE), which still destroys
|
|
1042
1195
|
// every row written since the migration ran. Explicit opt-in required.
|
|
@@ -1067,55 +1220,10 @@ async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
1067
1220
|
throw new errors_js_1.MigrationError(lines.join('\n'));
|
|
1068
1221
|
}
|
|
1069
1222
|
}
|
|
1070
|
-
|
|
1071
|
-
const errors = [];
|
|
1072
|
-
for (const migration of toRollback) {
|
|
1073
|
-
const file = fileMap.get(migration.name);
|
|
1074
|
-
if (!file) {
|
|
1075
|
-
errors.push({
|
|
1076
|
-
file: { filename: `${migration.name}.sql`, path: '', name: migration.name, timestamp: '' },
|
|
1077
|
-
error: `Migration file not found for "${migration.name}"`,
|
|
1078
|
-
});
|
|
1079
|
-
continue;
|
|
1080
|
-
}
|
|
1081
|
-
const parsed = parseMigrationSQL(file.path);
|
|
1082
|
-
const down = parsed.down;
|
|
1083
|
-
if (!down) {
|
|
1084
|
-
errors.push({ file, error: 'No DOWN section found in migration file' });
|
|
1085
|
-
continue;
|
|
1086
|
-
}
|
|
1087
|
-
const deleteApplied = dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect));
|
|
1088
|
-
if (parsed.noTransaction) {
|
|
1089
|
-
// Untransacted rollback (DROP INDEX CONCURRENTLY IF EXISTS), one
|
|
1090
|
-
// statement per query() call: same contract as the untransacted UP.
|
|
1091
|
-
try {
|
|
1092
|
-
for (const stmt of splitSqlStatements(down)) {
|
|
1093
|
-
await client.query(stmt);
|
|
1094
|
-
}
|
|
1095
|
-
await client.query(deleteApplied, [migration.name]);
|
|
1096
|
-
results.push(file);
|
|
1097
|
-
}
|
|
1098
|
-
catch (err) {
|
|
1099
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1100
|
-
errors.push({ file, error: msg });
|
|
1101
|
-
break;
|
|
1102
|
-
}
|
|
1103
|
-
continue;
|
|
1104
|
-
}
|
|
1105
|
-
const failure = await runMigrationInTransaction(client, down, {
|
|
1106
|
-
sql: deleteApplied,
|
|
1107
|
-
params: [migration.name],
|
|
1108
|
-
});
|
|
1109
|
-
if (failure !== null) {
|
|
1110
|
-
errors.push({ file, error: failure });
|
|
1111
|
-
break;
|
|
1112
|
-
}
|
|
1113
|
-
results.push(file);
|
|
1114
|
-
}
|
|
1115
|
-
return { rolledBack: results, errors };
|
|
1223
|
+
return await rollbackMigrations(client, toRollback, fileMap, dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect)));
|
|
1116
1224
|
}
|
|
1117
1225
|
finally {
|
|
1118
|
-
await
|
|
1226
|
+
await releaseMigrationLock(lock, client);
|
|
1119
1227
|
}
|
|
1120
1228
|
}
|
|
1121
1229
|
finally {
|