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.
Files changed (142) hide show
  1. package/README.md +34 -32
  2. package/dist/adapters/cockroachdb.js +21 -3
  3. package/dist/adapters/index.d.ts +15 -0
  4. package/dist/adapters/yugabytedb.js +20 -3
  5. package/dist/cjs/adapters/cockroachdb.js +21 -3
  6. package/dist/cjs/adapters/index.d.ts +15 -0
  7. package/dist/cjs/adapters/yugabytedb.js +20 -3
  8. package/dist/cjs/cli/destructive.d.ts +18 -4
  9. package/dist/cjs/cli/destructive.js +230 -122
  10. package/dist/cjs/cli/index.d.ts +21 -4
  11. package/dist/cjs/cli/index.js +119 -22
  12. package/dist/cjs/cli/mcp.d.ts +28 -8
  13. package/dist/cjs/cli/mcp.js +170 -127
  14. package/dist/cjs/cli/migrate.d.ts +134 -13
  15. package/dist/cjs/cli/migrate.js +349 -241
  16. package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
  17. package/dist/cjs/cli/pii-predicate-guard.js +390 -0
  18. package/dist/cjs/cli/prisma-resolve.js +75 -4
  19. package/dist/cjs/cli/prisma-schema.d.ts +17 -1
  20. package/dist/cjs/cli/prisma-schema.js +83 -17
  21. package/dist/cjs/cli/sql-statements.d.ts +125 -0
  22. package/dist/cjs/cli/sql-statements.js +378 -0
  23. package/dist/cjs/cli/studio.js +49 -118
  24. package/dist/cjs/cli/ui.d.ts +1 -1
  25. package/dist/cjs/client.d.ts +43 -0
  26. package/dist/cjs/client.js +125 -6
  27. package/dist/cjs/dialect.d.ts +123 -0
  28. package/dist/cjs/dialect.js +33 -0
  29. package/dist/cjs/errors.d.ts +74 -1
  30. package/dist/cjs/errors.js +239 -25
  31. package/dist/cjs/index-advisor.d.ts +33 -1
  32. package/dist/cjs/index-advisor.js +32 -1
  33. package/dist/cjs/introspect.d.ts +48 -0
  34. package/dist/cjs/introspect.js +222 -91
  35. package/dist/cjs/mssql.js +43 -1
  36. package/dist/cjs/mysql.d.ts +5 -2
  37. package/dist/cjs/mysql.js +202 -17
  38. package/dist/cjs/nested-write.js +6 -1
  39. package/dist/cjs/pipeline-submittable.js +17 -3
  40. package/dist/cjs/pipeline.js +75 -9
  41. package/dist/cjs/powdb.d.ts +23 -0
  42. package/dist/cjs/powdb.js +33 -1
  43. package/dist/cjs/powql.d.ts +61 -9
  44. package/dist/cjs/powql.js +186 -49
  45. package/dist/cjs/prisma-compat.js +160 -41
  46. package/dist/cjs/query/aggregates.d.ts +1 -1
  47. package/dist/cjs/query/aggregates.js +80 -18
  48. package/dist/cjs/query/batched-loader.d.ts +10 -0
  49. package/dist/cjs/query/batched-loader.js +268 -7
  50. package/dist/cjs/query/builder.d.ts +73 -0
  51. package/dist/cjs/query/builder.js +225 -28
  52. package/dist/cjs/query/filters.d.ts +162 -0
  53. package/dist/cjs/query/filters.js +250 -1
  54. package/dist/cjs/query/relations.d.ts +10 -10
  55. package/dist/cjs/query/relations.js +93 -12
  56. package/dist/cjs/query/types.d.ts +14 -1
  57. package/dist/cjs/query/utils.d.ts +146 -2
  58. package/dist/cjs/query/utils.js +210 -4
  59. package/dist/cjs/query/warn-registry.d.ts +10 -0
  60. package/dist/cjs/query/warn-registry.js +10 -0
  61. package/dist/cjs/query/where-compile.d.ts +30 -0
  62. package/dist/cjs/query/where-compile.js +41 -0
  63. package/dist/cjs/query/where.d.ts +128 -13
  64. package/dist/cjs/query/where.js +215 -77
  65. package/dist/cjs/query/writes.d.ts +1 -1
  66. package/dist/cjs/query/writes.js +39 -15
  67. package/dist/cjs/schema-builder.d.ts +2 -1
  68. package/dist/cjs/schema-sql.d.ts +94 -4
  69. package/dist/cjs/schema-sql.js +506 -30
  70. package/dist/cjs/schema.d.ts +3 -1
  71. package/dist/cjs/sqlite.d.ts +6 -0
  72. package/dist/cjs/sqlite.js +151 -10
  73. package/dist/cjs/typed-sql.d.ts +29 -1
  74. package/dist/cjs/typed-sql.js +30 -12
  75. package/dist/cli/destructive.d.ts +18 -4
  76. package/dist/cli/destructive.js +229 -121
  77. package/dist/cli/index.d.ts +21 -4
  78. package/dist/cli/index.js +120 -24
  79. package/dist/cli/mcp.d.ts +28 -8
  80. package/dist/cli/mcp.js +172 -129
  81. package/dist/cli/migrate.d.ts +134 -13
  82. package/dist/cli/migrate.js +347 -238
  83. package/dist/cli/pii-predicate-guard.d.ts +112 -0
  84. package/dist/cli/pii-predicate-guard.js +386 -0
  85. package/dist/cli/prisma-resolve.js +75 -4
  86. package/dist/cli/prisma-schema.d.ts +17 -1
  87. package/dist/cli/prisma-schema.js +83 -17
  88. package/dist/cli/sql-statements.d.ts +125 -0
  89. package/dist/cli/sql-statements.js +373 -0
  90. package/dist/cli/studio.js +49 -118
  91. package/dist/cli/ui.d.ts +1 -1
  92. package/dist/client.d.ts +43 -0
  93. package/dist/client.js +126 -7
  94. package/dist/dialect.d.ts +123 -0
  95. package/dist/dialect.js +33 -0
  96. package/dist/errors.d.ts +74 -1
  97. package/dist/errors.js +228 -19
  98. package/dist/index-advisor.d.ts +33 -1
  99. package/dist/index-advisor.js +31 -1
  100. package/dist/introspect.d.ts +48 -0
  101. package/dist/introspect.js +221 -91
  102. package/dist/mssql.js +44 -2
  103. package/dist/mysql.d.ts +5 -2
  104. package/dist/mysql.js +203 -18
  105. package/dist/nested-write.js +7 -2
  106. package/dist/pipeline-submittable.js +18 -4
  107. package/dist/pipeline.js +76 -10
  108. package/dist/powdb.d.ts +23 -0
  109. package/dist/powdb.js +33 -2
  110. package/dist/powql.d.ts +61 -9
  111. package/dist/powql.js +187 -50
  112. package/dist/prisma-compat.js +160 -41
  113. package/dist/query/aggregates.d.ts +1 -1
  114. package/dist/query/aggregates.js +82 -20
  115. package/dist/query/batched-loader.d.ts +10 -0
  116. package/dist/query/batched-loader.js +270 -9
  117. package/dist/query/builder.d.ts +73 -0
  118. package/dist/query/builder.js +226 -30
  119. package/dist/query/filters.d.ts +162 -0
  120. package/dist/query/filters.js +246 -1
  121. package/dist/query/relations.d.ts +10 -10
  122. package/dist/query/relations.js +94 -14
  123. package/dist/query/types.d.ts +14 -1
  124. package/dist/query/utils.d.ts +146 -2
  125. package/dist/query/utils.js +204 -3
  126. package/dist/query/warn-registry.d.ts +10 -0
  127. package/dist/query/warn-registry.js +10 -0
  128. package/dist/query/where-compile.d.ts +30 -0
  129. package/dist/query/where-compile.js +40 -1
  130. package/dist/query/where.d.ts +128 -13
  131. package/dist/query/where.js +216 -80
  132. package/dist/query/writes.d.ts +1 -1
  133. package/dist/query/writes.js +40 -16
  134. package/dist/schema-builder.d.ts +2 -1
  135. package/dist/schema-sql.d.ts +94 -4
  136. package/dist/schema-sql.js +505 -30
  137. package/dist/schema.d.ts +3 -1
  138. package/dist/sqlite.d.ts +6 -0
  139. package/dist/sqlite.js +151 -10
  140. package/dist/typed-sql.d.ts +29 -1
  141. package/dist/typed-sql.js +30 -12
  142. package/package.json +6 -4
@@ -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.splitSqlStatements = splitSqlStatements;
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
- await client.query(dialect.buildMigrationTrackingTable(quotedTrackingTable(dialect)));
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
- if (upper === '-- UP') {
195
- section = 'up';
196
- continue;
197
- }
198
- if (upper === '-- DOWN') {
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
- * Split a SQL script into individual statements on top-level semicolons.
342
+ * Statements that end (or restart) the transaction the runner wraps a migration
343
+ * file in.
215
344
  *
216
- * A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
217
- * string (including a backslash-escaping `E'...'` string), a double-quoted
218
- * identifier, a dollar-quoted body, a line comment
219
- * (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
220
- * split. This is the one production-destroying failure mode of no-transaction
221
- * migrations (a partial statement executed against production), so the behavior
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
- * Comment-only fragments are dropped; every returned statement is trimmed and
225
- * carries no trailing semicolon.
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
- function splitSqlStatements(sql) {
228
- const statements = [];
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
- * True when the quote at `quoteAt` opens a Postgres escape string (`E'...'`),
355
- * whose body treats a backslash as an escape character.
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
- * The `E` must be a standalone token: an identifier that merely ends in `e`
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 isEscapeStringPrefix(sql, quoteAt) {
364
- const prev = sql[quoteAt - 1];
365
- if (prev !== 'E' && prev !== 'e')
366
- return false;
367
- const before = sql[quoteAt - 2];
368
- return before === undefined || !/[A-Za-z0-9_$"]/.test(before);
369
- }
370
- /** True when a fragment contains nothing but comments and whitespace. */
371
- function isCommentOnlyStatement(stmt) {
372
- const withoutComments = stmt.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
373
- return withoutComments.trim().length === 0;
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, in one transaction so readers never see a missing column.
466
- -- BEGIN;
467
- -- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "old_col_retired";
468
- -- ALTER TABLE "my_table" RENAME COLUMN "new_col" TO "old_col";
469
- -- COMMIT;`;
470
- const down = `-- Reverse the Phase 4 atomic swap (only if you ran it).
471
- -- BEGIN;
472
- -- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "new_col";
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
- const body = recipe.build(name);
596
- template = `-- Migration: ${name} (${options.recipe} recipe scaffold)
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: ${name} (auto-generated)
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: ${name}
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
- async function acquireLock(client, lockId, adapter) {
675
- const a = adapter ?? index_js_1.postgresql;
676
- // pg.Client satisfies PgCompatPoolClient (query + release)
677
- return a.acquireLock(client, lockId);
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
- async function releaseLock(client, lockId, adapter) {
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
- await a.releaseLock(client, lockId);
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 gotLock = await acquireLock(client, lockId, adapter);
876
- if (!gotLock) {
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
- continue;
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 releaseLock(client, lockId, adapter);
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 gotLock = await acquireLock(client, lockId, adapter);
1027
- if (!gotLock) {
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
- const results = [];
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 releaseLock(client, lockId, adapter);
1226
+ await releaseMigrationLock(lock, client);
1119
1227
  }
1120
1228
  }
1121
1229
  finally {