turbine-orm 0.45.0 → 0.46.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.
@@ -25,6 +25,7 @@ exports.formatTimestamp = formatTimestamp;
25
25
  exports.getPendingMigrations = getPendingMigrations;
26
26
  exports.listMigrationFiles = listMigrationFiles;
27
27
  exports.parseMigrationContent = parseMigrationContent;
28
+ exports.splitSqlStatements = splitSqlStatements;
28
29
  exports.parseMigrationSQL = parseMigrationSQL;
29
30
  exports.buildDiffMigrationBody = buildDiffMigrationBody;
30
31
  exports.createMigration = createMigration;
@@ -146,21 +147,37 @@ function listMigrationFiles(migrationsDir) {
146
147
  return files;
147
148
  }
148
149
  /**
149
- * Parse migration content string into UP and DOWN sections.
150
+ * The `-- turbine:no-transaction` directive: when present in a migration's
151
+ * header (before `-- UP`), the runner applies the file WITHOUT wrapping it in
152
+ * BEGIN/COMMIT and runs one statement per `client.query()` call. Required for
153
+ * `CREATE INDEX CONCURRENTLY`, which Postgres forbids inside any transaction,
154
+ * including the implicit transaction a multi-statement simple query creates.
155
+ */
156
+ const NO_TRANSACTION_DIRECTIVE = /^--\s*turbine:no-transaction\s*$/i;
157
+ /**
158
+ * Parse migration content string into UP and DOWN sections plus directives.
150
159
  * Exported for unit testing.
151
160
  */
152
161
  function parseMigrationContent(content) {
153
162
  const lines = content.split('\n');
154
163
  let section = 'none';
164
+ let noTransaction = false;
155
165
  const upLines = [];
156
166
  const downLines = [];
157
167
  for (const line of lines) {
158
- const trimmed = line.trim().toUpperCase();
159
- if (trimmed === '-- UP') {
168
+ const trimmed = line.trim();
169
+ const upper = trimmed.toUpperCase();
170
+ // The directive is only honored in the header (before -- UP), so it can
171
+ // never be smuggled in via a DOWN-section comment.
172
+ if (section === 'none' && NO_TRANSACTION_DIRECTIVE.test(trimmed)) {
173
+ noTransaction = true;
174
+ continue;
175
+ }
176
+ if (upper === '-- UP') {
160
177
  section = 'up';
161
178
  continue;
162
179
  }
163
- if (trimmed === '-- DOWN') {
180
+ if (upper === '-- DOWN') {
164
181
  section = 'down';
165
182
  continue;
166
183
  }
@@ -172,8 +189,144 @@ function parseMigrationContent(content) {
172
189
  return {
173
190
  up: upLines.join('\n').trim(),
174
191
  down: downLines.join('\n').trim(),
192
+ noTransaction,
175
193
  };
176
194
  }
195
+ /**
196
+ * Split a SQL script into individual statements on top-level semicolons.
197
+ *
198
+ * A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
199
+ * string, a double-quoted identifier, a dollar-quoted body, a line comment
200
+ * (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
201
+ * split. This is the one production-destroying failure mode of no-transaction
202
+ * migrations (a partial statement executed against production), so the behavior
203
+ * is pinned by exhaustive unit tests.
204
+ *
205
+ * Comment-only fragments are dropped; every returned statement is trimmed and
206
+ * carries no trailing semicolon.
207
+ */
208
+ function splitSqlStatements(sql) {
209
+ const statements = [];
210
+ let current = '';
211
+ let i = 0;
212
+ const n = sql.length;
213
+ while (i < n) {
214
+ const ch = sql[i];
215
+ const next = sql[i + 1];
216
+ // Line comment: consume to end of line (kept verbatim in the statement).
217
+ if (ch === '-' && next === '-') {
218
+ let j = i;
219
+ while (j < n && sql[j] !== '\n')
220
+ j++;
221
+ current += sql.slice(i, j);
222
+ i = j;
223
+ continue;
224
+ }
225
+ // Block comment (Postgres allows nesting: /* /* */ */).
226
+ if (ch === '/' && next === '*') {
227
+ let depth = 1;
228
+ let j = i + 2;
229
+ current += '/*';
230
+ while (j < n && depth > 0) {
231
+ if (sql[j] === '/' && sql[j + 1] === '*') {
232
+ depth++;
233
+ current += '/*';
234
+ j += 2;
235
+ }
236
+ else if (sql[j] === '*' && sql[j + 1] === '/') {
237
+ depth--;
238
+ current += '*/';
239
+ j += 2;
240
+ }
241
+ else {
242
+ current += sql[j];
243
+ j++;
244
+ }
245
+ }
246
+ i = j;
247
+ continue;
248
+ }
249
+ // Single-quoted string ('' is an escaped quote, stays inside the string).
250
+ if (ch === "'") {
251
+ let j = i + 1;
252
+ current += "'";
253
+ while (j < n) {
254
+ if (sql[j] === "'" && sql[j + 1] === "'") {
255
+ current += "''";
256
+ j += 2;
257
+ continue;
258
+ }
259
+ if (sql[j] === "'") {
260
+ current += "'";
261
+ j++;
262
+ break;
263
+ }
264
+ current += sql[j];
265
+ j++;
266
+ }
267
+ i = j;
268
+ continue;
269
+ }
270
+ // Double-quoted identifier ("" is an escaped quote).
271
+ if (ch === '"') {
272
+ let j = i + 1;
273
+ current += '"';
274
+ while (j < n) {
275
+ if (sql[j] === '"' && sql[j + 1] === '"') {
276
+ current += '""';
277
+ j += 2;
278
+ continue;
279
+ }
280
+ if (sql[j] === '"') {
281
+ current += '"';
282
+ j++;
283
+ break;
284
+ }
285
+ current += sql[j];
286
+ j++;
287
+ }
288
+ i = j;
289
+ continue;
290
+ }
291
+ // Dollar-quoted body ($tag$ ... $tag$; tag is empty or an identifier, never
292
+ // digit-leading, so a `$1` parameter placeholder is not mistaken for one).
293
+ if (ch === '$') {
294
+ const tagMatch = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/.exec(sql.slice(i));
295
+ if (tagMatch) {
296
+ const tag = tagMatch[0];
297
+ const end = sql.indexOf(tag, i + tag.length);
298
+ if (end === -1) {
299
+ current += sql.slice(i);
300
+ i = n;
301
+ continue;
302
+ }
303
+ current += sql.slice(i, end + tag.length);
304
+ i = end + tag.length;
305
+ continue;
306
+ }
307
+ }
308
+ // Top-level statement terminator.
309
+ if (ch === ';') {
310
+ const trimmed = current.trim();
311
+ if (trimmed)
312
+ statements.push(trimmed);
313
+ current = '';
314
+ i++;
315
+ continue;
316
+ }
317
+ current += ch;
318
+ i++;
319
+ }
320
+ const tail = current.trim();
321
+ if (tail)
322
+ statements.push(tail);
323
+ return statements.filter((s) => !isCommentOnlyStatement(s));
324
+ }
325
+ /** True when a fragment contains nothing but comments and whitespace. */
326
+ function isCommentOnlyStatement(stmt) {
327
+ const withoutComments = stmt.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
328
+ return withoutComments.trim().length === 0;
329
+ }
177
330
  /**
178
331
  * Parse a migration file into UP and DOWN sections.
179
332
  */
@@ -332,6 +485,9 @@ function buildDiffMigrationBody(diff) {
332
485
  * - `autoContent`: pre-populate UP/DOWN from a schema diff.
333
486
  * - `options.recipe`: scaffold a named recipe (see {@link MIGRATION_RECIPES}).
334
487
  * Mutually exclusive with `autoContent`; an unknown recipe throws.
488
+ * - `options.header`: extra header line(s) injected BEFORE `-- UP` (the only
489
+ * place a `-- turbine:no-transaction` directive is honored). Used with
490
+ * `autoContent`.
335
491
  */
336
492
  function createMigration(migrationsDir, name, autoContent, options) {
337
493
  (0, node_fs_1.mkdirSync)(migrationsDir, { recursive: true });
@@ -360,10 +516,11 @@ ${body.down}
360
516
  `;
361
517
  }
362
518
  else if (autoContent) {
363
- template = `-- Migration: ${name} (auto-generated from schema diff)
519
+ const headerBlock = options?.header ? `${options.header}\n` : '';
520
+ template = `-- Migration: ${name} (auto-generated)
364
521
  -- Created: ${now.toISOString()}
365
522
  -- Review this file before running: npx turbine migrate up
366
-
523
+ ${headerBlock}
367
524
  -- UP
368
525
  ${autoContent.up}
369
526
 
@@ -646,25 +803,54 @@ async function migrateUp(connectionString, migrationsDir, options) {
646
803
  const results = [];
647
804
  const errors = [];
648
805
  const outOfOrder = [];
806
+ const noTransactionApplied = [];
807
+ const flagOutOfOrder = (file) => {
808
+ // Flag an out-of-order apply: this file's timestamp is older than a
809
+ // migration that was already applied before this run started.
810
+ if (newestPrior && file.timestamp && file.timestamp < newestPrior.ts) {
811
+ outOfOrder.push({ applied: file.filename, newestPrior: `${newestPrior.name}.sql` });
812
+ }
813
+ };
649
814
  for (const file of pending) {
650
- const { up } = parseMigrationSQL(file.path);
815
+ const parsed = parseMigrationSQL(file.path);
816
+ const up = parsed.up;
651
817
  if (!up) {
652
818
  errors.push({ file, error: 'No UP section found in migration file' });
653
819
  continue;
654
820
  }
655
821
  const content = (0, node_fs_1.readFileSync)(file.path, 'utf-8');
656
822
  const hash = checksum(content);
823
+ const insertApplied = dialect.buildMigrationInsertApplied(quotedTrackingTable(dialect));
824
+ if (parsed.noTransaction) {
825
+ // No BEGIN/COMMIT: run ONE statement per query() call (a multi-statement
826
+ // simple query would be wrapped in an implicit transaction, breaking
827
+ // CREATE INDEX CONCURRENTLY). Recording happens only after all statements
828
+ // succeed: a mid-file failure leaves earlier (idempotent) statements
829
+ // applied and the migration unrecorded, so a rerun resumes it.
830
+ options?.onNoTransaction?.(file);
831
+ noTransactionApplied.push(file);
832
+ try {
833
+ for (const stmt of splitSqlStatements(up)) {
834
+ await client.query(stmt);
835
+ }
836
+ await client.query(insertApplied, [file.name, hash]);
837
+ results.push(file);
838
+ flagOutOfOrder(file);
839
+ }
840
+ catch (err) {
841
+ const msg = err instanceof Error ? err.message : String(err);
842
+ errors.push({ file, error: msg });
843
+ break;
844
+ }
845
+ continue;
846
+ }
657
847
  try {
658
848
  await client.query('BEGIN');
659
849
  await client.query(up);
660
- await client.query(dialect.buildMigrationInsertApplied(quotedTrackingTable(dialect)), [file.name, hash]);
850
+ await client.query(insertApplied, [file.name, hash]);
661
851
  await client.query('COMMIT');
662
852
  results.push(file);
663
- // Flag an out-of-order apply: this file's timestamp is older than a
664
- // migration that was already applied before this run started.
665
- if (newestPrior && file.timestamp && file.timestamp < newestPrior.ts) {
666
- outOfOrder.push({ applied: file.filename, newestPrior: `${newestPrior.name}.sql` });
667
- }
853
+ flagOutOfOrder(file);
668
854
  }
669
855
  catch (err) {
670
856
  await client.query('ROLLBACK');
@@ -674,7 +860,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
674
860
  break;
675
861
  }
676
862
  }
677
- return { applied: results, errors, destructive, outOfOrder };
863
+ return { applied: results, errors, destructive, outOfOrder, noTransaction: noTransactionApplied };
678
864
  }
679
865
  finally {
680
866
  await releaseLock(client, lockId, adapter);
@@ -771,15 +957,34 @@ async function migrateDown(connectionString, migrationsDir, options) {
771
957
  });
772
958
  continue;
773
959
  }
774
- const { down } = parseMigrationSQL(file.path);
960
+ const parsed = parseMigrationSQL(file.path);
961
+ const down = parsed.down;
775
962
  if (!down) {
776
963
  errors.push({ file, error: 'No DOWN section found in migration file' });
777
964
  continue;
778
965
  }
966
+ const deleteApplied = dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect));
967
+ if (parsed.noTransaction) {
968
+ // Untransacted rollback (DROP INDEX CONCURRENTLY IF EXISTS), one
969
+ // statement per query() call: same contract as the untransacted UP.
970
+ try {
971
+ for (const stmt of splitSqlStatements(down)) {
972
+ await client.query(stmt);
973
+ }
974
+ await client.query(deleteApplied, [migration.name]);
975
+ results.push(file);
976
+ }
977
+ catch (err) {
978
+ const msg = err instanceof Error ? err.message : String(err);
979
+ errors.push({ file, error: msg });
980
+ break;
981
+ }
982
+ continue;
983
+ }
779
984
  try {
780
985
  await client.query('BEGIN');
781
986
  await client.query(down);
782
- await client.query(dialect.buildMigrationDeleteApplied(quotedTrackingTable(dialect)), [migration.name]);
987
+ await client.query(deleteApplied, [migration.name]);
783
988
  await client.query('COMMIT');
784
989
  results.push(file);
785
990
  }
Binary file