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/cli/migrate.js
CHANGED
|
@@ -19,11 +19,61 @@ import { postgresql } from '../adapters/index.js';
|
|
|
19
19
|
import { postgresDialect } from '../dialect.js';
|
|
20
20
|
import { MigrationError } from '../errors.js';
|
|
21
21
|
import { DESTRUCTIVE_KIND_LABEL, scanDestructiveSql } from './destructive.js';
|
|
22
|
+
import { splitSqlStatements, tokenizeSql } from './sql-statements.js';
|
|
23
|
+
/**
|
|
24
|
+
* Re-exported from `./sql-statements.js`, which owns the one tokenizer this
|
|
25
|
+
* module and `destructive.js` both speak. It used to live here, and the guard
|
|
26
|
+
* carried a second, subtly different copy: see that module's header for what
|
|
27
|
+
* they disagreed about and what it cost.
|
|
28
|
+
*/
|
|
29
|
+
export { splitSqlStatements };
|
|
22
30
|
/** Extract the YYYYMMDDHHMMSS timestamp prefix from a migration name, or null. */
|
|
23
31
|
export function migrationTimestamp(name) {
|
|
24
32
|
const m = name.match(/^(\d{14})(?:_|$)/);
|
|
25
33
|
return m ? m[1] : null;
|
|
26
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Refuse any migration in the batch that manages its own transactions.
|
|
37
|
+
*
|
|
38
|
+
* `-- turbine:no-transaction` files are exempt: they were never wrapped, so
|
|
39
|
+
* theirs is a real (and supported) transaction to manage. Pre-flight over the
|
|
40
|
+
* WHOLE batch, before anything runs, so a bad file at position 3 does not leave
|
|
41
|
+
* migrations 1 and 2 applied.
|
|
42
|
+
*
|
|
43
|
+
* @internal exported for tests.
|
|
44
|
+
*/
|
|
45
|
+
export function assertNoEmbeddedTransactions(files, section) {
|
|
46
|
+
const offenders = [];
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const parsed = parseMigrationSQL(file.path);
|
|
49
|
+
if (parsed.noTransaction)
|
|
50
|
+
continue;
|
|
51
|
+
const statements = findTransactionControlStatements(section === 'up' ? parsed.up : parsed.down);
|
|
52
|
+
if (statements.length > 0)
|
|
53
|
+
offenders.push({ file: file.filename, statements });
|
|
54
|
+
}
|
|
55
|
+
if (offenders.length === 0)
|
|
56
|
+
return;
|
|
57
|
+
const lines = [
|
|
58
|
+
`[turbine] Refusing to run migrations that manage transactions themselves (${section.toUpperCase()} section):`,
|
|
59
|
+
'',
|
|
60
|
+
];
|
|
61
|
+
for (const o of offenders) {
|
|
62
|
+
lines.push(` ${o.file}`);
|
|
63
|
+
for (const s of o.statements)
|
|
64
|
+
lines.push(` - ${s}`);
|
|
65
|
+
}
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push('`turbine migrate` already runs each migration file inside exactly ONE transaction.');
|
|
68
|
+
lines.push('An embedded COMMIT ends that wrapper: everything before it becomes durable,');
|
|
69
|
+
lines.push('everything after runs unprotected, and the migration is recorded nowhere, so a');
|
|
70
|
+
lines.push('rerun fails forever on "already exists".');
|
|
71
|
+
lines.push('');
|
|
72
|
+
lines.push('Delete the BEGIN/COMMIT/ROLLBACK statements (the runner supplies the transaction),');
|
|
73
|
+
lines.push('or, if this migration genuinely cannot run inside one (CREATE INDEX CONCURRENTLY),');
|
|
74
|
+
lines.push('add `-- turbine:no-transaction` to the file header and manage it yourself.');
|
|
75
|
+
throw new MigrationError(lines.join('\n'));
|
|
76
|
+
}
|
|
27
77
|
/** Scan a set of migration files' UP sections for data-destroying statements. */
|
|
28
78
|
export function collectUpDestructive(files) {
|
|
29
79
|
const offenders = [];
|
|
@@ -58,8 +108,34 @@ function migrationDialect() {
|
|
|
58
108
|
function quotedTrackingTable(dialect) {
|
|
59
109
|
return dialect.quoteIdentifier(TRACKING_TABLE);
|
|
60
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Postgres error codes that mean "someone else created this table between our
|
|
113
|
+
* existence check and our CREATE".
|
|
114
|
+
*
|
|
115
|
+
* `CREATE TABLE IF NOT EXISTS` is NOT race-free: the existence check and the
|
|
116
|
+
* catalog insert are separate steps, so two concurrent sessions can both pass
|
|
117
|
+
* the check and the loser gets a hard error rather than a quiet no-op. Measured
|
|
118
|
+
* on a fresh database with 12 concurrent `migrate status` calls: 1 succeeded and
|
|
119
|
+
* 11 crashed, on `duplicate key value violates unique constraint
|
|
120
|
+
* "pg_type_typname_nsp_index"` (23505) and `relation "_turbine_migrations"
|
|
121
|
+
* already exists` (42P07). `migrate up`/`down` hold the migration lock before
|
|
122
|
+
* they reach here, but `migrate status` and the deploy inspector deliberately do
|
|
123
|
+
* not, and read-only commands should not need a lock to survive each other.
|
|
124
|
+
*/
|
|
125
|
+
const TABLE_ALREADY_EXISTS_CODES = new Set(['23505', '42P07']);
|
|
61
126
|
async function ensureTrackingTable(client, dialect = postgresDialect) {
|
|
62
|
-
|
|
127
|
+
const sql = dialect.buildMigrationTrackingTable(quotedTrackingTable(dialect));
|
|
128
|
+
try {
|
|
129
|
+
await client.query(sql);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (!TABLE_ALREADY_EXISTS_CODES.has(String(err.code)))
|
|
133
|
+
throw err;
|
|
134
|
+
// The winner has committed by the time we see its error, so the retry finds
|
|
135
|
+
// the table present and the statement really is a no-op. Retried ONCE: a
|
|
136
|
+
// second failure is not this race and must surface.
|
|
137
|
+
await client.query(sql);
|
|
138
|
+
}
|
|
63
139
|
}
|
|
64
140
|
async function getAppliedMigrations(client, dialect = postgresDialect) {
|
|
65
141
|
await ensureTrackingTable(client, dialect);
|
|
@@ -84,6 +160,27 @@ export function parseMigrationFilename(filename) {
|
|
|
84
160
|
timestamp: match[1],
|
|
85
161
|
};
|
|
86
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* A migration name as it can safely appear in a file's `-- Migration:` header
|
|
165
|
+
* comment.
|
|
166
|
+
*
|
|
167
|
+
* The header sits ABOVE the `-- UP` marker, and the raw CLI argument used to be
|
|
168
|
+
* interpolated into it verbatim. A `--` comment ends at the first newline, so a
|
|
169
|
+
* name carrying one closes the comment and everything after it becomes file
|
|
170
|
+
* content: a name of `x\n-- turbine:no-transaction\n-- UP\nDROP TABLE users;`
|
|
171
|
+
* wrote both an execution directive and executable SQL into a migration the
|
|
172
|
+
* user never authored. Only the FILENAME was sanitized, which is the one place
|
|
173
|
+
* the injection could not reach.
|
|
174
|
+
*
|
|
175
|
+
* Collapsing every run of whitespace to a single space is the whole fix: the
|
|
176
|
+
* argument then cannot leave the one comment line it was written on, and `\s`
|
|
177
|
+
* covers `\r` and the Unicode line separators too, all of which Postgres also
|
|
178
|
+
* treats as ending a `--` comment. The readable spelling is preserved, unlike
|
|
179
|
+
* {@link sanitizeName}, because this is documentation for a human.
|
|
180
|
+
*/
|
|
181
|
+
export function headerSafeName(name) {
|
|
182
|
+
return name.replace(/\s+/g, ' ').trim();
|
|
183
|
+
}
|
|
87
184
|
/**
|
|
88
185
|
* Sanitize a migration name: lowercase, replace non-alnum with _, collapse duplicates, trim.
|
|
89
186
|
*/
|
|
@@ -142,31 +239,52 @@ export function listMigrationFiles(migrationsDir) {
|
|
|
142
239
|
* including the implicit transaction a multi-statement simple query creates.
|
|
143
240
|
*/
|
|
144
241
|
const NO_TRANSACTION_DIRECTIVE = /^--\s*turbine:no-transaction\s*$/i;
|
|
242
|
+
/**
|
|
243
|
+
* A section marker line.
|
|
244
|
+
*
|
|
245
|
+
* Matching the exact strings `-- UP` and `-- DOWN` was too strict for the ways
|
|
246
|
+
* people actually write them: `--DOWN`, `-- DOWN`, and `-- DOWN;` all read as
|
|
247
|
+
* ordinary comments, so the whole rollback section silently folded into the UP
|
|
248
|
+
* section and RAN as part of the migration. `migrate up` was saved from the
|
|
249
|
+
* worst of that by the destructive gate, but `migrate deploy` passes
|
|
250
|
+
* `allowDestructive: true` unconditionally, so a create-then-drop pair applied
|
|
251
|
+
* as one "successful" migration.
|
|
252
|
+
*
|
|
253
|
+
* Deliberately still anchored end-to-end: `-- UPDATE the widgets table` is a
|
|
254
|
+
* comment, not a marker.
|
|
255
|
+
*/
|
|
256
|
+
const SECTION_MARKER = /^--\s*(UP|DOWN)\s*;?\s*$/i;
|
|
145
257
|
/**
|
|
146
258
|
* Parse migration content string into UP and DOWN sections plus directives.
|
|
259
|
+
*
|
|
260
|
+
* Throws `MigrationError` when the file carries no `-- UP` marker at all.
|
|
261
|
+
* Returning `{ up: '', down: '' }` there meant the entire file was treated as
|
|
262
|
+
* preamble and the migration recorded as applied having executed nothing, which
|
|
263
|
+
* is worse than any error: the database is missing the change and the history
|
|
264
|
+
* says it is present. `source` (a path) is only used to name the file.
|
|
265
|
+
*
|
|
147
266
|
* Exported for unit testing.
|
|
148
267
|
*/
|
|
149
|
-
export function parseMigrationContent(content) {
|
|
268
|
+
export function parseMigrationContent(content, source) {
|
|
150
269
|
const lines = content.split('\n');
|
|
151
270
|
let section = 'none';
|
|
271
|
+
let sawUpMarker = false;
|
|
152
272
|
let noTransaction = false;
|
|
153
273
|
const upLines = [];
|
|
154
274
|
const downLines = [];
|
|
155
275
|
for (const line of lines) {
|
|
156
276
|
const trimmed = line.trim();
|
|
157
|
-
const upper = trimmed.toUpperCase();
|
|
158
277
|
// The directive is only honored in the header (before -- UP), so it can
|
|
159
278
|
// never be smuggled in via a DOWN-section comment.
|
|
160
279
|
if (section === 'none' && NO_TRANSACTION_DIRECTIVE.test(trimmed)) {
|
|
161
280
|
noTransaction = true;
|
|
162
281
|
continue;
|
|
163
282
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
section = 'down';
|
|
283
|
+
const marker = SECTION_MARKER.exec(trimmed);
|
|
284
|
+
if (marker) {
|
|
285
|
+
const isUp = marker[1].toUpperCase() === 'UP';
|
|
286
|
+
section = isUp ? 'up' : 'down';
|
|
287
|
+
sawUpMarker ||= isUp;
|
|
170
288
|
continue;
|
|
171
289
|
}
|
|
172
290
|
if (section === 'up')
|
|
@@ -174,6 +292,17 @@ export function parseMigrationContent(content) {
|
|
|
174
292
|
else if (section === 'down')
|
|
175
293
|
downLines.push(line);
|
|
176
294
|
}
|
|
295
|
+
if (!sawUpMarker) {
|
|
296
|
+
throw new MigrationError([
|
|
297
|
+
`[turbine] Migration file has no \`-- UP\` section marker${source ? `: ${source}` : '.'}`,
|
|
298
|
+
'',
|
|
299
|
+
'A migration must contain a line reading `-- UP` (a `-- DOWN` line is optional).',
|
|
300
|
+
'Without it the whole file is a header comment: nothing would run, and the',
|
|
301
|
+
'migration would still be recorded as applied.',
|
|
302
|
+
'',
|
|
303
|
+
'Accepted spellings: `-- UP`, `--UP`, `-- up`, `-- UP;` (leading/trailing spaces fine).',
|
|
304
|
+
].join('\n'));
|
|
305
|
+
}
|
|
177
306
|
return {
|
|
178
307
|
up: upLines.join('\n').trim(),
|
|
179
308
|
down: downLines.join('\n').trim(),
|
|
@@ -181,173 +310,50 @@ export function parseMigrationContent(content) {
|
|
|
181
310
|
};
|
|
182
311
|
}
|
|
183
312
|
/**
|
|
184
|
-
*
|
|
313
|
+
* Statements that end (or restart) the transaction the runner wraps a migration
|
|
314
|
+
* file in.
|
|
185
315
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* is pinned by exhaustive unit tests.
|
|
316
|
+
* `runMigrationInTransaction` issues `BEGIN`, the file body, the tracking-table
|
|
317
|
+
* write, then `COMMIT`. An embedded `COMMIT;` in the body commits THAT wrapper:
|
|
318
|
+
* everything before it becomes durable, everything after runs unprotected, the
|
|
319
|
+
* tracking write happens outside any transaction the failure path can undo, and
|
|
320
|
+
* a mid-file error leaves the migration recorded nowhere. Rerunning then fails
|
|
321
|
+
* forever on "already exists". So the runner refuses the file instead.
|
|
193
322
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
323
|
+
* `END` is deliberately NOT in this list even though Postgres accepts it as a
|
|
324
|
+
* synonym for COMMIT: a PG14+ `CREATE FUNCTION ... BEGIN ATOMIC ... END;` body
|
|
325
|
+
* splits at its inner semicolons, leaving a bare `END` fragment, and refusing
|
|
326
|
+
* that would break working migrations to catch a spelling nobody writes.
|
|
327
|
+
* `ROLLBACK TO [SAVEPOINT] x` is excluded for the opposite reason: it is the one
|
|
328
|
+
* ROLLBACK form that leaves the wrapping transaction open, so it is legitimate.
|
|
196
329
|
*/
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
let current = '';
|
|
200
|
-
let i = 0;
|
|
201
|
-
const n = sql.length;
|
|
202
|
-
while (i < n) {
|
|
203
|
-
const ch = sql[i];
|
|
204
|
-
const next = sql[i + 1];
|
|
205
|
-
// Line comment: consume to end of line (kept verbatim in the statement).
|
|
206
|
-
if (ch === '-' && next === '-') {
|
|
207
|
-
let j = i;
|
|
208
|
-
while (j < n && sql[j] !== '\n')
|
|
209
|
-
j++;
|
|
210
|
-
current += sql.slice(i, j);
|
|
211
|
-
i = j;
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
// Block comment (Postgres allows nesting: /* /* */ */).
|
|
215
|
-
if (ch === '/' && next === '*') {
|
|
216
|
-
let depth = 1;
|
|
217
|
-
let j = i + 2;
|
|
218
|
-
current += '/*';
|
|
219
|
-
while (j < n && depth > 0) {
|
|
220
|
-
if (sql[j] === '/' && sql[j + 1] === '*') {
|
|
221
|
-
depth++;
|
|
222
|
-
current += '/*';
|
|
223
|
-
j += 2;
|
|
224
|
-
}
|
|
225
|
-
else if (sql[j] === '*' && sql[j + 1] === '/') {
|
|
226
|
-
depth--;
|
|
227
|
-
current += '*/';
|
|
228
|
-
j += 2;
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
current += sql[j];
|
|
232
|
-
j++;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
i = j;
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
// Single-quoted string ('' is an escaped quote, stays inside the string).
|
|
239
|
-
// An E-prefixed string (E'...') additionally honors backslash escapes, so
|
|
240
|
-
// `E'p\'q'` is ONE string: treating the `\'` as a terminator would close the
|
|
241
|
-
// string early and let the next quote swallow a real statement terminator.
|
|
242
|
-
if (ch === "'") {
|
|
243
|
-
const backslashEscapes = isEscapeStringPrefix(sql, i);
|
|
244
|
-
let j = i + 1;
|
|
245
|
-
current += "'";
|
|
246
|
-
while (j < n) {
|
|
247
|
-
if (backslashEscapes && sql[j] === '\\' && j + 1 < n) {
|
|
248
|
-
current += sql[j] + sql[j + 1];
|
|
249
|
-
j += 2;
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
if (sql[j] === "'" && sql[j + 1] === "'") {
|
|
253
|
-
current += "''";
|
|
254
|
-
j += 2;
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (sql[j] === "'") {
|
|
258
|
-
current += "'";
|
|
259
|
-
j++;
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
current += sql[j];
|
|
263
|
-
j++;
|
|
264
|
-
}
|
|
265
|
-
i = j;
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
// Double-quoted identifier ("" is an escaped quote).
|
|
269
|
-
if (ch === '"') {
|
|
270
|
-
let j = i + 1;
|
|
271
|
-
current += '"';
|
|
272
|
-
while (j < n) {
|
|
273
|
-
if (sql[j] === '"' && sql[j + 1] === '"') {
|
|
274
|
-
current += '""';
|
|
275
|
-
j += 2;
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
if (sql[j] === '"') {
|
|
279
|
-
current += '"';
|
|
280
|
-
j++;
|
|
281
|
-
break;
|
|
282
|
-
}
|
|
283
|
-
current += sql[j];
|
|
284
|
-
j++;
|
|
285
|
-
}
|
|
286
|
-
i = j;
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
// Dollar-quoted body ($tag$ ... $tag$; tag is empty or an identifier, never
|
|
290
|
-
// digit-leading, so a `$1` parameter placeholder is not mistaken for one).
|
|
291
|
-
if (ch === '$') {
|
|
292
|
-
const tagMatch = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/.exec(sql.slice(i));
|
|
293
|
-
if (tagMatch) {
|
|
294
|
-
const tag = tagMatch[0];
|
|
295
|
-
const end = sql.indexOf(tag, i + tag.length);
|
|
296
|
-
if (end === -1) {
|
|
297
|
-
current += sql.slice(i);
|
|
298
|
-
i = n;
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
current += sql.slice(i, end + tag.length);
|
|
302
|
-
i = end + tag.length;
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
// Top-level statement terminator.
|
|
307
|
-
if (ch === ';') {
|
|
308
|
-
const trimmed = current.trim();
|
|
309
|
-
if (trimmed)
|
|
310
|
-
statements.push(trimmed);
|
|
311
|
-
current = '';
|
|
312
|
-
i++;
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
current += ch;
|
|
316
|
-
i++;
|
|
317
|
-
}
|
|
318
|
-
const tail = current.trim();
|
|
319
|
-
if (tail)
|
|
320
|
-
statements.push(tail);
|
|
321
|
-
return statements.filter((s) => !isCommentOnlyStatement(s));
|
|
322
|
-
}
|
|
330
|
+
const TRANSACTION_CONTROL = /^(BEGIN|START\s+TRANSACTION|COMMIT|ROLLBACK|ABORT)\b/i;
|
|
331
|
+
const ROLLBACK_TO_SAVEPOINT = /^ROLLBACK\s+TO\b/i;
|
|
323
332
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
333
|
+
* Top-level transaction-control statements in a migration body, as displayable
|
|
334
|
+
* text. Empty for a clean file. Comment- and literal-aware via the shared
|
|
335
|
+
* tokenizer, so a `COMMIT` inside a comment or a string is not flagged.
|
|
326
336
|
*
|
|
327
|
-
*
|
|
328
|
-
* (`some_table` cannot be followed by a quote in valid SQL, but the check keeps
|
|
329
|
-
* the tokenizer honest) does not turn the following literal into an E-string.
|
|
330
|
-
* Ordinary literals are left alone on purpose: with the modern
|
|
331
|
-
* `standard_conforming_strings = on` default, `'a\'` IS a complete string.
|
|
337
|
+
* @internal exported for tests.
|
|
332
338
|
*/
|
|
333
|
-
function
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
return
|
|
339
|
+
export function findTransactionControlStatements(body) {
|
|
340
|
+
const found = [];
|
|
341
|
+
for (const statement of tokenizeSql(body)) {
|
|
342
|
+
if (statement.commentOnly)
|
|
343
|
+
continue;
|
|
344
|
+
const head = statement.stripped;
|
|
345
|
+
if (!TRANSACTION_CONTROL.test(head) || ROLLBACK_TO_SAVEPOINT.test(head))
|
|
346
|
+
continue;
|
|
347
|
+
found.push(head.replace(/\s+/g, ' ').slice(0, 80));
|
|
348
|
+
}
|
|
349
|
+
return found;
|
|
344
350
|
}
|
|
345
351
|
/**
|
|
346
352
|
* Parse a migration file into UP and DOWN sections.
|
|
347
353
|
*/
|
|
348
354
|
export function parseMigrationSQL(filePath) {
|
|
349
355
|
const content = readFileSync(filePath, 'utf-8');
|
|
350
|
-
return parseMigrationContent(content);
|
|
356
|
+
return parseMigrationContent(content, filePath);
|
|
351
357
|
}
|
|
352
358
|
/**
|
|
353
359
|
* SHA-256 checksum for migration drift detection.
|
|
@@ -410,6 +416,12 @@ function buildBackfillRecipe() {
|
|
|
410
416
|
-- your table, the new column, the old column, and the transform, then uncomment
|
|
411
417
|
-- the phases you need and review before running \`npx turbine migrate up\`.
|
|
412
418
|
--
|
|
419
|
+
-- Do NOT add BEGIN/COMMIT of your own anywhere in this file. \`turbine migrate\`
|
|
420
|
+
-- already runs each migration file inside exactly ONE transaction, so every
|
|
421
|
+
-- statement here commits or rolls back together. An embedded COMMIT would end
|
|
422
|
+
-- that wrapper early, leaving the first half durable and the migration recorded
|
|
423
|
+
-- nowhere; the runner refuses a file that contains one.
|
|
424
|
+
--
|
|
413
425
|
-- Phase 1: add the new column as NULLABLE. This is a fast, non-blocking change
|
|
414
426
|
-- (no table rewrite, no long lock), so it is safe to ship ahead of the backfill.
|
|
415
427
|
-- ALTER TABLE "my_table" ADD COLUMN "new_col" text;
|
|
@@ -432,16 +444,14 @@ function buildBackfillRecipe() {
|
|
|
432
444
|
-- ALTER TABLE "my_table" ALTER COLUMN "new_col" SET NOT NULL;
|
|
433
445
|
--
|
|
434
446
|
-- Phase 4 (optional atomic swap): retire the old column and rename the new one
|
|
435
|
-
-- into its place
|
|
436
|
-
-- BEGIN
|
|
437
|
-
--
|
|
438
|
-
--
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
--
|
|
442
|
-
--
|
|
443
|
-
-- ALTER TABLE "my_table" RENAME COLUMN "old_col_retired" TO "old_col";
|
|
444
|
-
-- COMMIT;
|
|
447
|
+
-- into its place. Both renames land in the runner's single per-file
|
|
448
|
+
-- transaction, so readers never see a missing column: no BEGIN/COMMIT needed.
|
|
449
|
+
-- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "old_col_retired";
|
|
450
|
+
-- ALTER TABLE "my_table" RENAME COLUMN "new_col" TO "old_col";`;
|
|
451
|
+
const down = `-- Reverse the Phase 4 atomic swap (only if you ran it). Same single
|
|
452
|
+
-- transaction as the UP direction, so again no BEGIN/COMMIT of your own.
|
|
453
|
+
-- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "new_col";
|
|
454
|
+
-- ALTER TABLE "my_table" RENAME COLUMN "old_col_retired" TO "old_col";
|
|
445
455
|
--
|
|
446
456
|
-- If you stopped after phases 1 to 3, drop the added column instead:
|
|
447
457
|
-- ALTER TABLE "my_table" DROP COLUMN "new_col";`;
|
|
@@ -553,6 +563,9 @@ export function createMigration(migrationsDir, name, autoContent, options) {
|
|
|
553
563
|
const now = new Date();
|
|
554
564
|
const ts = formatTimestamp(now);
|
|
555
565
|
const safeName = sanitizeName(name);
|
|
566
|
+
// The header comment is the ONLY place the caller's raw string reaches the
|
|
567
|
+
// file, and it sits above `-- UP`: see headerSafeName for what that allowed.
|
|
568
|
+
const headerName = headerSafeName(name);
|
|
556
569
|
const filename = `${ts}_${safeName}.sql`;
|
|
557
570
|
const filePath = join(migrationsDir, filename);
|
|
558
571
|
let template;
|
|
@@ -562,8 +575,10 @@ export function createMigration(migrationsDir, name, autoContent, options) {
|
|
|
562
575
|
const known = Object.keys(MIGRATION_RECIPES).join(', ') || '(none)';
|
|
563
576
|
throw new MigrationError(`[turbine] Unknown migration recipe "${options.recipe}". Available recipes: ${known}`);
|
|
564
577
|
}
|
|
565
|
-
|
|
566
|
-
|
|
578
|
+
// A recipe builds the BODY, below `-- UP`, where a newline is not merely a
|
|
579
|
+
// comment break but directly executable, so it gets the safe name too.
|
|
580
|
+
const body = recipe.build(headerName);
|
|
581
|
+
template = `-- Migration: ${headerName} (${options.recipe} recipe scaffold)
|
|
567
582
|
-- Created: ${now.toISOString()}
|
|
568
583
|
-- Fill in the placeholders and review before running: npx turbine migrate up
|
|
569
584
|
|
|
@@ -576,7 +591,7 @@ ${body.down}
|
|
|
576
591
|
}
|
|
577
592
|
else if (autoContent) {
|
|
578
593
|
const headerBlock = options?.header ? `${options.header}\n` : '';
|
|
579
|
-
template = `-- Migration: ${
|
|
594
|
+
template = `-- Migration: ${headerName} (auto-generated)
|
|
580
595
|
-- Created: ${now.toISOString()}
|
|
581
596
|
-- Review this file before running: npx turbine migrate up
|
|
582
597
|
${headerBlock}
|
|
@@ -588,7 +603,7 @@ ${autoContent.down}
|
|
|
588
603
|
`;
|
|
589
604
|
}
|
|
590
605
|
else {
|
|
591
|
-
template = `-- Migration: ${
|
|
606
|
+
template = `-- Migration: ${headerName}
|
|
592
607
|
-- Created: ${now.toISOString()}
|
|
593
608
|
|
|
594
609
|
-- UP
|
|
@@ -641,14 +656,75 @@ async function getCurrentDatabaseName(client) {
|
|
|
641
656
|
const result = await client.query(`SELECT current_database()`);
|
|
642
657
|
return result.rows[0]?.current_database ?? '';
|
|
643
658
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
659
|
+
/** Open the second, lock-only connection. Separated so tests can fake it. */
|
|
660
|
+
async function openLockConnection(connectionString) {
|
|
661
|
+
const client = new pg.Client({ connectionString });
|
|
662
|
+
await client.connect();
|
|
663
|
+
return client;
|
|
648
664
|
}
|
|
649
|
-
|
|
665
|
+
/**
|
|
666
|
+
* Take the migration lock, on a DEDICATED connection when the adapter needs one.
|
|
667
|
+
*
|
|
668
|
+
* The CockroachDB and YugabyteDB adapters lock a row in `_turbine_lock` with
|
|
669
|
+
* `SELECT ... FOR UPDATE NOWAIT` and deliberately leave that transaction OPEN,
|
|
670
|
+
* because a row lock only exists for as long as its transaction does. The runner
|
|
671
|
+
* then applies every migration on the SAME connection, and
|
|
672
|
+
* `runMigrationInTransaction` issues BEGIN ... COMMIT per file. That COMMIT ends
|
|
673
|
+
* the LOCK's transaction: from migration 2 onward the run was unprotected, a
|
|
674
|
+
* concurrent `turbine migrate` could take the lock and replay those files, and
|
|
675
|
+
* `releaseLock`'s later COMMIT was a no-op that warned rather than failed, so
|
|
676
|
+
* nothing surfaced. The same collision had a second face: a
|
|
677
|
+
* `-- turbine:no-transaction` migration running FIRST executed inside the still
|
|
678
|
+
* open lock transaction, so `CREATE INDEX CONCURRENTLY` failed with "cannot run
|
|
679
|
+
* inside a transaction block" while the identical file placed second succeeded.
|
|
680
|
+
*
|
|
681
|
+
* A second connection separates the two transaction scopes, which is the only
|
|
682
|
+
* thing that makes the lock outlive a migration. The advisory-lock path (plain
|
|
683
|
+
* Postgres, AlloyDB, Timescale) is session-scoped rather than
|
|
684
|
+
* transaction-scoped, opens NO second connection, and is byte-identical to what
|
|
685
|
+
* it has always done.
|
|
686
|
+
*
|
|
687
|
+
* @internal exported for tests.
|
|
688
|
+
*/
|
|
689
|
+
export async function acquireMigrationLock(runner, lockId, adapter, openLockConnection) {
|
|
650
690
|
const a = adapter ?? postgresql;
|
|
651
|
-
|
|
691
|
+
const lockClient = a.lockHoldsOpenTransaction ? await openLockConnection() : undefined;
|
|
692
|
+
try {
|
|
693
|
+
// pg.Client satisfies PgCompatPoolClient (query + release)
|
|
694
|
+
const acquired = await a.acquireLock((lockClient ?? runner), lockId);
|
|
695
|
+
if (!acquired && lockClient)
|
|
696
|
+
await lockClient.end();
|
|
697
|
+
return { acquired, lockId, adapter: a, lockClient: acquired ? lockClient : undefined };
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
if (lockClient) {
|
|
701
|
+
try {
|
|
702
|
+
await lockClient.end();
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
// Best effort: the acquire error below is what the user needs to see.
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
throw err;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Release a lock taken by {@link acquireMigrationLock}, and close the dedicated
|
|
713
|
+
* connection when there is one. A refused lock owns nothing, so releasing it is
|
|
714
|
+
* a no-op rather than an unlock of somebody else's lock.
|
|
715
|
+
*
|
|
716
|
+
* @internal exported for tests.
|
|
717
|
+
*/
|
|
718
|
+
export async function releaseMigrationLock(lock, runner) {
|
|
719
|
+
if (!lock.acquired)
|
|
720
|
+
return;
|
|
721
|
+
try {
|
|
722
|
+
await lock.adapter.releaseLock((lock.lockClient ?? runner), lock.lockId);
|
|
723
|
+
}
|
|
724
|
+
finally {
|
|
725
|
+
if (lock.lockClient)
|
|
726
|
+
await lock.lockClient.end();
|
|
727
|
+
}
|
|
652
728
|
}
|
|
653
729
|
/**
|
|
654
730
|
* Run one migration body (UP or DOWN) plus its tracking-table write inside a
|
|
@@ -840,10 +916,12 @@ export async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
840
916
|
const dbName = await getCurrentDatabaseName(client);
|
|
841
917
|
const lockId = deriveLockId(dbName);
|
|
842
918
|
// Acquire lock to prevent concurrent migrations.
|
|
843
|
-
// The adapter determines the strategy (advisory lock vs table lock)
|
|
919
|
+
// The adapter determines the strategy (advisory lock vs table lock), and a
|
|
920
|
+
// table-lock adapter gets its OWN connection so the per-migration
|
|
921
|
+
// BEGIN/COMMIT below cannot end the transaction the lock lives in.
|
|
844
922
|
const adapter = options?.adapter;
|
|
845
|
-
const
|
|
846
|
-
if (!
|
|
923
|
+
const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(connectionString));
|
|
924
|
+
if (!lock.acquired) {
|
|
847
925
|
throw new MigrationError('[turbine] Could not acquire migration lock, another migration is already running');
|
|
848
926
|
}
|
|
849
927
|
try {
|
|
@@ -874,6 +952,10 @@ export async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
874
952
|
if (options?.step != null && options.step > 0) {
|
|
875
953
|
pending = pending.slice(0, options.step);
|
|
876
954
|
}
|
|
955
|
+
// Structural check before any policy gate: a file that manages its own
|
|
956
|
+
// transactions cannot be run safely at all, so it is refused for everyone,
|
|
957
|
+
// deploy included. Pre-flight over the whole batch, so nothing is applied.
|
|
958
|
+
assertNoEmbeddedTransactions(pending, 'up');
|
|
877
959
|
// Destructive statements in the pending batch, computed once. Returned in
|
|
878
960
|
// the result regardless of the gate so `deploy` can print a notice even
|
|
879
961
|
// though it proceeds by design.
|
|
@@ -911,8 +993,12 @@ export async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
911
993
|
const parsed = parseMigrationSQL(file.path);
|
|
912
994
|
const up = parsed.up;
|
|
913
995
|
if (!up) {
|
|
996
|
+
// STOP, do not skip. Continuing applied later migrations over the gap
|
|
997
|
+
// this one left, which is the same hazard as continuing past a SQL
|
|
998
|
+
// failure: the batch is ordered, and a later file may depend on this
|
|
999
|
+
// one. The SQL-failure path below has always broken here.
|
|
914
1000
|
errors.push({ file, error: 'No UP section found in migration file' });
|
|
915
|
-
|
|
1001
|
+
break;
|
|
916
1002
|
}
|
|
917
1003
|
const content = readFileSync(file.path, 'utf-8');
|
|
918
1004
|
const hash = checksum(content);
|
|
@@ -955,7 +1041,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
955
1041
|
return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied };
|
|
956
1042
|
}
|
|
957
1043
|
finally {
|
|
958
|
-
await
|
|
1044
|
+
await releaseMigrationLock(lock, client);
|
|
959
1045
|
}
|
|
960
1046
|
}
|
|
961
1047
|
finally {
|
|
@@ -975,6 +1061,71 @@ export async function migrateDeploy(connectionString, migrationsDir, options) {
|
|
|
975
1061
|
adapter: options?.adapter,
|
|
976
1062
|
});
|
|
977
1063
|
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Roll back a prepared LIFO batch, newest first, stopping at the first
|
|
1066
|
+
* migration that cannot be rolled back.
|
|
1067
|
+
*
|
|
1068
|
+
* A rollback batch is strictly LIFO and must have NO GAPS. The
|
|
1069
|
+
* "file not found" and "no DOWN section" branches used to `continue`, so a
|
|
1070
|
+
* `--step 3` whose middle migration had no DOWN section rolled back 3 and then
|
|
1071
|
+
* 1: the oldest migration's schema was torn down while the data migration 2 had
|
|
1072
|
+
* seeded into it was still expected to exist, and the tracking table was left
|
|
1073
|
+
* claiming migration 2 alone was applied. The next `migrate up` then re-ran 1
|
|
1074
|
+
* and 3 and never re-ran 2, so that data was gone permanently. Both branches
|
|
1075
|
+
* now stop, which is what the SQL-failure branches have always done.
|
|
1076
|
+
*
|
|
1077
|
+
* Split out of {@link migrateDown} so the ordering contract is testable against
|
|
1078
|
+
* a fake client, without a database.
|
|
1079
|
+
*
|
|
1080
|
+
* @internal exported for tests; not part of the CLI's public surface.
|
|
1081
|
+
*/
|
|
1082
|
+
export async function rollbackMigrations(client, toRollback, fileMap, deleteApplied) {
|
|
1083
|
+
const results = [];
|
|
1084
|
+
const errors = [];
|
|
1085
|
+
for (const migration of toRollback) {
|
|
1086
|
+
const file = fileMap.get(migration.name);
|
|
1087
|
+
if (!file) {
|
|
1088
|
+
errors.push({
|
|
1089
|
+
file: { filename: `${migration.name}.sql`, path: '', name: migration.name, timestamp: '' },
|
|
1090
|
+
error: `Migration file not found for "${migration.name}"`,
|
|
1091
|
+
});
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
const parsed = parseMigrationSQL(file.path);
|
|
1095
|
+
const down = parsed.down;
|
|
1096
|
+
if (!down) {
|
|
1097
|
+
errors.push({ file, error: 'No DOWN section found in migration file' });
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
if (parsed.noTransaction) {
|
|
1101
|
+
// Untransacted rollback (DROP INDEX CONCURRENTLY IF EXISTS), one
|
|
1102
|
+
// statement per query() call: same contract as the untransacted UP.
|
|
1103
|
+
try {
|
|
1104
|
+
for (const stmt of splitSqlStatements(down)) {
|
|
1105
|
+
await client.query(stmt);
|
|
1106
|
+
}
|
|
1107
|
+
await client.query(deleteApplied, [migration.name]);
|
|
1108
|
+
results.push(file);
|
|
1109
|
+
}
|
|
1110
|
+
catch (err) {
|
|
1111
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1112
|
+
errors.push({ file, error: msg });
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
const failure = await runMigrationInTransaction(client, down, {
|
|
1118
|
+
sql: deleteApplied,
|
|
1119
|
+
params: [migration.name],
|
|
1120
|
+
});
|
|
1121
|
+
if (failure !== null) {
|
|
1122
|
+
errors.push({ file, error: failure });
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
results.push(file);
|
|
1126
|
+
}
|
|
1127
|
+
return { rolledBack: results, errors };
|
|
1128
|
+
}
|
|
978
1129
|
/**
|
|
979
1130
|
* Rollback the last N migrations (DOWN).
|
|
980
1131
|
*
|
|
@@ -993,8 +1144,8 @@ export async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
993
1144
|
const dbName = await getCurrentDatabaseName(client);
|
|
994
1145
|
const lockId = deriveLockId(dbName);
|
|
995
1146
|
const adapter = options?.adapter;
|
|
996
|
-
const
|
|
997
|
-
if (!
|
|
1147
|
+
const lock = await acquireMigrationLock(client, lockId, adapter, () => openLockConnection(connectionString));
|
|
1148
|
+
if (!lock.acquired) {
|
|
998
1149
|
throw new MigrationError('[turbine] Could not acquire migration lock, another migration is already running');
|
|
999
1150
|
}
|
|
1000
1151
|
try {
|
|
@@ -1007,6 +1158,9 @@ export async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
1007
1158
|
const fileMap = new Map(allFiles.map((f) => [f.name, f]));
|
|
1008
1159
|
// Reverse order, rollback most recent first
|
|
1009
1160
|
const toRollback = applied.reverse().slice(0, options?.step ?? 1);
|
|
1161
|
+
// Same structural pre-flight as migrateUp: a DOWN body is wrapped in the
|
|
1162
|
+
// same single transaction, so it cannot manage its own either.
|
|
1163
|
+
assertNoEmbeddedTransactions(toRollback.map((m) => fileMap.get(m.name)).filter((f) => f !== undefined), 'down');
|
|
1010
1164
|
// Same data-loss gate as migrateUp, DOWN sections routinely contain
|
|
1011
1165
|
// DROP TABLE (the legitimate reverse of a CREATE), which still destroys
|
|
1012
1166
|
// every row written since the migration ran. Explicit opt-in required.
|
|
@@ -1037,55 +1191,10 @@ export async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
1037
1191
|
throw new MigrationError(lines.join('\n'));
|
|
1038
1192
|
}
|
|
1039
1193
|
}
|
|
1040
|
-
|
|
1041
|
-
const errors = [];
|
|
1042
|
-
for (const migration of toRollback) {
|
|
1043
|
-
const file = fileMap.get(migration.name);
|
|
1044
|
-
if (!file) {
|
|
1045
|
-
errors.push({
|
|
1046
|
-
file: { filename: `${migration.name}.sql`, path: '', name: migration.name, timestamp: '' },
|
|
1047
|
-
error: `Migration file not found for "${migration.name}"`,
|
|
1048
|
-
});
|
|
1049
|
-
continue;
|
|
1050
|
-
}
|
|
1051
|
-
const parsed = parseMigrationSQL(file.path);
|
|
1052
|
-
const down = parsed.down;
|
|
1053
|
-
if (!down) {
|
|
1054
|
-
errors.push({ file, error: 'No DOWN section found in migration file' });
|
|
1055
|
-
continue;
|
|
1056
|
-
}
|
|
1057
|
-
const deleteApplied = dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect));
|
|
1058
|
-
if (parsed.noTransaction) {
|
|
1059
|
-
// Untransacted rollback (DROP INDEX CONCURRENTLY IF EXISTS), one
|
|
1060
|
-
// statement per query() call: same contract as the untransacted UP.
|
|
1061
|
-
try {
|
|
1062
|
-
for (const stmt of splitSqlStatements(down)) {
|
|
1063
|
-
await client.query(stmt);
|
|
1064
|
-
}
|
|
1065
|
-
await client.query(deleteApplied, [migration.name]);
|
|
1066
|
-
results.push(file);
|
|
1067
|
-
}
|
|
1068
|
-
catch (err) {
|
|
1069
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1070
|
-
errors.push({ file, error: msg });
|
|
1071
|
-
break;
|
|
1072
|
-
}
|
|
1073
|
-
continue;
|
|
1074
|
-
}
|
|
1075
|
-
const failure = await runMigrationInTransaction(client, down, {
|
|
1076
|
-
sql: deleteApplied,
|
|
1077
|
-
params: [migration.name],
|
|
1078
|
-
});
|
|
1079
|
-
if (failure !== null) {
|
|
1080
|
-
errors.push({ file, error: failure });
|
|
1081
|
-
break;
|
|
1082
|
-
}
|
|
1083
|
-
results.push(file);
|
|
1084
|
-
}
|
|
1085
|
-
return { rolledBack: results, errors };
|
|
1194
|
+
return await rollbackMigrations(client, toRollback, fileMap, dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect)));
|
|
1086
1195
|
}
|
|
1087
1196
|
finally {
|
|
1088
|
-
await
|
|
1197
|
+
await releaseMigrationLock(lock, client);
|
|
1089
1198
|
}
|
|
1090
1199
|
}
|
|
1091
1200
|
finally {
|