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
@@ -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
- await client.query(dialect.buildMigrationTrackingTable(quotedTrackingTable(dialect)));
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
- if (upper === '-- UP') {
165
- section = 'up';
166
- continue;
167
- }
168
- if (upper === '-- DOWN') {
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
- * Split a SQL script into individual statements on top-level semicolons.
313
+ * Statements that end (or restart) the transaction the runner wraps a migration
314
+ * file in.
185
315
  *
186
- * A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
187
- * string (including a backslash-escaping `E'...'` string), a double-quoted
188
- * identifier, a dollar-quoted body, a line comment
189
- * (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
190
- * split. This is the one production-destroying failure mode of no-transaction
191
- * migrations (a partial statement executed against production), so the behavior
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
- * Comment-only fragments are dropped; every returned statement is trimmed and
195
- * carries no trailing semicolon.
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
- export function splitSqlStatements(sql) {
198
- const statements = [];
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
- * True when the quote at `quoteAt` opens a Postgres escape string (`E'...'`),
325
- * whose body treats a backslash as an escape character.
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
- * The `E` must be a standalone token: an identifier that merely ends in `e`
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 isEscapeStringPrefix(sql, quoteAt) {
334
- const prev = sql[quoteAt - 1];
335
- if (prev !== 'E' && prev !== 'e')
336
- return false;
337
- const before = sql[quoteAt - 2];
338
- return before === undefined || !/[A-Za-z0-9_$"]/.test(before);
339
- }
340
- /** True when a fragment contains nothing but comments and whitespace. */
341
- function isCommentOnlyStatement(stmt) {
342
- const withoutComments = stmt.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
343
- return withoutComments.trim().length === 0;
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, in one transaction so readers never see a missing column.
436
- -- BEGIN;
437
- -- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "old_col_retired";
438
- -- ALTER TABLE "my_table" RENAME COLUMN "new_col" TO "old_col";
439
- -- COMMIT;`;
440
- const down = `-- Reverse the Phase 4 atomic swap (only if you ran it).
441
- -- BEGIN;
442
- -- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "new_col";
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
- const body = recipe.build(name);
566
- template = `-- Migration: ${name} (${options.recipe} recipe scaffold)
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: ${name} (auto-generated)
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: ${name}
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
- async function acquireLock(client, lockId, adapter) {
645
- const a = adapter ?? postgresql;
646
- // pg.Client satisfies PgCompatPoolClient (query + release)
647
- return a.acquireLock(client, lockId);
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
- async function releaseLock(client, lockId, adapter) {
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
- await a.releaseLock(client, lockId);
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 gotLock = await acquireLock(client, lockId, adapter);
846
- if (!gotLock) {
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
- continue;
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 releaseLock(client, lockId, adapter);
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 gotLock = await acquireLock(client, lockId, adapter);
997
- if (!gotLock) {
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
- const results = [];
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 releaseLock(client, lockId, adapter);
1197
+ await releaseMigrationLock(lock, client);
1089
1198
  }
1090
1199
  }
1091
1200
  finally {