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
@@ -258,8 +258,13 @@ function parseAttributes(fragment, line) {
258
258
  else {
259
259
  j = k;
260
260
  }
261
- // `@db.VarChar(255)` etc. - keep only the head so `db` is the recorded name.
262
- attrs.push({ name: rawName.split('.')[0], args, block, line });
261
+ // `@db.VarChar(255)` etc. The HEAD is the recorded name (so consumers keep
262
+ // matching on `db`), and everything after the first dot is kept as the
263
+ // native type rather than discarded.
264
+ const dot = rawName.indexOf('.');
265
+ const head = dot === -1 ? rawName : rawName.slice(0, dot);
266
+ const nativeType = dot === -1 ? undefined : rawName.slice(dot + 1);
267
+ attrs.push({ name: head, ...(nativeType ? { nativeType } : {}), args, block, line });
263
268
  i = j;
264
269
  }
265
270
  return attrs;
@@ -292,16 +297,28 @@ function matchBrace(s, open) {
292
297
  }
293
298
  return -1;
294
299
  }
295
- /** Scan the top level for `keyword Name { ... }` blocks via brace matching. */
296
- function scanBlocks(src) {
300
+ /**
301
+ * Scan the top level for `keyword Name { ... }` blocks via brace matching.
302
+ *
303
+ * A header whose keyword is not one we handle is skipped, and now RECORDED:
304
+ * the switch in `parsePrismaSchema` has a `default` branch that pushes an
305
+ * "unsupported block" warning, but nothing could ever reach it because the
306
+ * filter here dropped those blocks first. The body is deliberately still
307
+ * re-scanned (unchanged behaviour) so a recognized block nested under an
308
+ * unrecognized one is not lost.
309
+ */
310
+ function scanBlocks(src, warnings) {
297
311
  const blocks = [];
298
312
  const headerRe = /(^|\n)[ \t]*([a-zA-Z]+)[ \t]+([A-Za-z_]\w*)[ \t]*\{/g;
299
313
  let m;
300
314
  // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
301
315
  while ((m = headerRe.exec(src)) !== null) {
302
316
  const keyword = m[2];
303
- if (!BLOCK_KEYWORDS.has(keyword))
317
+ if (!BLOCK_KEYWORDS.has(keyword)) {
318
+ warnings.push(`Skipped unsupported block "${keyword} ${m[3]}" (line ${lineAt(src, m.index + m[1].length)}): the parser ` +
319
+ `understands model / view / type / enum / datasource / generator blocks only.`);
304
320
  continue;
321
+ }
305
322
  const braceOpen = src.indexOf('{', m.index);
306
323
  const close = matchBrace(src, braceOpen);
307
324
  const headerLine = lineAt(src, m.index + m[1].length);
@@ -360,23 +377,57 @@ function parseCompoundKey(attr, line) {
360
377
  function truncate(s, n = 60) {
361
378
  return s.length > n ? `${s.slice(0, n)}...` : s;
362
379
  }
380
+ /**
381
+ * Field attributes that are RECOGNIZED but change what the model means, and
382
+ * that the name mapper does not act on. Each gets a warning naming what is
383
+ * being lost, so it surfaces in the report instead of being dropped in silence.
384
+ */
385
+ const NOTED_FIELD_ATTRS = {
386
+ ignore: '@ignore excludes the field from the Prisma client. Turbine generates from the DATABASE, so the column IS present on the generated client.',
387
+ };
388
+ /** Same, for block (`@@`) attributes. */
389
+ const NOTED_BLOCK_ATTRS = {
390
+ ignore: '@@ignore excludes the model from the Prisma client. Turbine generates from the DATABASE, so the table IS present on the generated client.',
391
+ schema: '@@schema (multi-schema datasource) is not represented. Turbine introspects ONE Postgres namespace per run, and two models in different schemas can resolve to the same bare table name.',
392
+ fulltext: '@@fulltext indexes are not represented in the generated metadata.',
393
+ };
363
394
  /** Parse a single field declaration line. Returns null for a non-field line. */
364
395
  function parseFieldLine(text, line, warnings) {
365
- // First token = field name, second token = type. Both are simple words; the
366
- // type may carry a trailing `[]` and/or `?`.
367
- const m = text.match(/^([A-Za-z_]\w*)\s+([A-Za-z_]\w*)(\[\])?(\?)?/);
396
+ // First token = field name, second token = type. The type is a simple word or
397
+ // an `Unsupported("...")` call, and may carry a trailing `[]` and/or `?`.
398
+ //
399
+ // `Unsupported(...)` HAS to be matched here rather than left to the bare-word
400
+ // branch: `data Unsupported("tsvector")?` matched only up to `Unsupported`,
401
+ // so the `?` sat behind an unconsumed `(` and the field recorded
402
+ // `optional: false`. A nullable column read as required is a required/optional
403
+ // INVERSION, which is exactly the kind of thing a migration report exists to
404
+ // catch.
405
+ const m = text.match(/^([A-Za-z_]\w*)\s+(Unsupported\(\s*"(?:[^"\\]|\\.)*"\s*\)|[A-Za-z_]\w*)(\[\])?(\?)?/);
368
406
  if (!m) {
369
407
  // Not a field (e.g. a stray token); skip leniently.
370
408
  warnings.push(`Skipped unrecognized line ${line}: "${truncate(text)}"`);
371
409
  return null;
372
410
  }
373
411
  const name = m[1];
374
- const type = m[2];
412
+ const rawType = m[2];
375
413
  const isList = m[3] === '[]';
376
414
  const optional = m[4] === '?';
377
415
  const rest = text.slice(m[0].length);
378
416
  const attrs = parseAttributes(rest, line);
379
- return { name, type, optional, isList, attrs, line };
417
+ let type = rawType;
418
+ let unsupported;
419
+ if (rawType.startsWith('Unsupported')) {
420
+ type = 'Unsupported';
421
+ unsupported = unquote(rawType.slice(rawType.indexOf('(') + 1, rawType.lastIndexOf(')')).trim());
422
+ warnings.push(`Line ${line}: field "${name}" is Unsupported("${unsupported}"). Prisma cannot read or write it; ` +
423
+ `Turbine generates the column from the database, so it IS present on the generated client.`);
424
+ }
425
+ for (const attr of attrs) {
426
+ const note = NOTED_FIELD_ATTRS[attr.name];
427
+ if (note)
428
+ warnings.push(`Line ${line}: field "${name}": ${note}`);
429
+ }
430
+ return { name, type, ...(unsupported !== undefined ? { unsupported } : {}), optional, isList, attrs, line };
380
431
  }
381
432
  function parseModelBody(block, kind, src, warnings) {
382
433
  const model = {
@@ -402,7 +453,14 @@ function parseModelBody(block, kind, src, warnings) {
402
453
  else if (attr.name === 'id' || attr.name === 'unique') {
403
454
  model.compoundKeys.push(parseCompoundKey(attr, line));
404
455
  }
405
- // @@index, @@schema, and anything else: recorded in blockAttrs, unused.
456
+ else {
457
+ // @@index and anything else: recorded in blockAttrs, unused. The
458
+ // few that change what the model MEANS get a warning rather than
459
+ // silence (see NOTED_BLOCK_ATTRS).
460
+ const note = NOTED_BLOCK_ATTRS[attr.name];
461
+ if (note)
462
+ warnings.push(`Line ${line}: model "${block.name}": ${note}`);
463
+ }
406
464
  }
407
465
  continue;
408
466
  }
@@ -419,7 +477,10 @@ function parseEnumBody(block, src) {
419
477
  if (text.startsWith('@@')) {
420
478
  for (const attr of parseAttributes(text, line)) {
421
479
  if (attr.name === 'map') {
422
- const arg = attr.args.find((a) => a.key === undefined);
480
+ // `@@map("x")` and `@@map(name: "x")` are both valid; reading only the
481
+ // positional form silently dropped the mapped enum-type name, matching
482
+ // the field-level `@map` bug in prisma-resolve.ts.
483
+ const arg = attr.args.find((a) => a.key === undefined || a.key === 'name');
423
484
  if (arg?.kind === 'string' && arg.value)
424
485
  en.map = arg.value;
425
486
  }
@@ -523,13 +584,19 @@ export function resolvePrismaDatasourceUrl(ast, env) {
523
584
  export function parsePrismaSchema(source) {
524
585
  const src = stripComments(source);
525
586
  const ast = { models: [], enums: [], datasources: [], warnings: [] };
526
- for (const block of scanBlocks(src)) {
587
+ for (const block of scanBlocks(src, ast.warnings)) {
527
588
  switch (block.keyword) {
528
589
  case 'model':
529
590
  ast.models.push(parseModelBody(block, 'model', src, ast.warnings));
530
591
  break;
531
592
  case 'view':
532
593
  ast.models.push(parseModelBody(block, 'view', src, ast.warnings));
594
+ // A Prisma `view` is READ-ONLY in the Prisma client. Turbine resolves it
595
+ // against an introspected view and emits an ordinary delegate, so
596
+ // create/update/delete become reachable where Prisma refused them. Said
597
+ // out loud, because `type` blocks already warn and this one did not.
598
+ ast.warnings.push(`Block "view ${block.name}" is read-only in Prisma; the generated Turbine delegate is not. ` +
599
+ `Writes through it will reach the database if the view is updatable.`);
533
600
  break;
534
601
  case 'type':
535
602
  // Composite/embedded types (MongoDB) are not tables. Parse leniently so
@@ -544,11 +611,10 @@ export function parsePrismaSchema(source) {
544
611
  // Not a table, but it declares the connection string the CLI can reuse.
545
612
  ast.datasources.push(parseDatasourceBody(block, src));
546
613
  break;
547
- case 'generator':
548
- // Configuration block - irrelevant to name mapping.
549
- break;
550
614
  default:
551
- ast.warnings.push(`Skipped unsupported block "${block.keyword} ${block.name}".`);
615
+ // 'generator': configuration, irrelevant to name mapping. Any other
616
+ // keyword never reaches here (scanBlocks filters and warns), so this
617
+ // stays a silent catch-all rather than a second warning site.
552
618
  break;
553
619
  }
554
620
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The ONE SQL statement tokenizer the migration tooling speaks.
3
+ *
4
+ * ## Why this module exists
5
+ *
6
+ * `migrate.ts` executes migration SQL and `destructive.ts` decides whether that
7
+ * SQL is allowed to run. Both need to answer the same two questions, "where does
8
+ * one statement end", and "which characters are code rather than comment or
9
+ * literal", and until v0.66 each answered them with its own hand-written lexer.
10
+ * They disagreed, and the one that was wrong was the GUARD:
11
+ *
12
+ * - `destructive.ts` ended a block comment at the first `*\/`
13
+ * (`sql.indexOf('*\/', i + 2)`). Postgres NESTS block comments, so a
14
+ * commented-out block that itself contains a comment, the ordinary shape of
15
+ * "I disabled this for now", reopened as CODE partway through and the
16
+ * scanner resynchronised in the middle of the file.
17
+ * - `destructive.ts` then split statements with `text.split(';')`, so a
18
+ * semicolon inside a quoted identifier (`DROP TABLE "we;ird"`) cut a
19
+ * statement in half and neither half matched a rule.
20
+ *
21
+ * Both bugs FAIL OPEN. The worst measured case printed a partial inventory: for
22
+ * a file holding a nested commented-out block, a `DROP TABLE`, and a `DELETE`,
23
+ * the guard listed only the DELETE. The operator confirms the inventory they
24
+ * were shown, and the unlisted DROP TABLE runs under that confirmation.
25
+ *
26
+ * The tokenizer that replaced them arrived with the same shape of hole one
27
+ * layer down, and it is worth naming the shape rather than the instances: EVERY
28
+ * disagreement with the server's own lexer fails open, because the guard reads
29
+ * a file the server will not execute and the server executes a file the guard
30
+ * never read. Two more were found and fixed after the rewrite, both verified
31
+ * dropping a table live on PostgreSQL 16: a `$` INSIDE an identifier read as a
32
+ * dollar-quote opener (see {@link IDENT_CONT}), and the inner semicolons of a
33
+ * PG14+ `BEGIN ATOMIC` routine body read as statement terminators (see
34
+ * {@link opensAtomicBody}). The rule the module is written to now is to copy
35
+ * Postgres's lexical rules exactly, and where a case is genuinely ambiguous, to
36
+ * pick the reading that shows the operator MORE, never less.
37
+ *
38
+ * A second lexer that agrees today drifts tomorrow, so there is exactly one
39
+ * here and both callers consume it. {@link tokenizeSql} does the whole job in a
40
+ * single pass and hands back, per statement, BOTH the verbatim source (what
41
+ * `migrate.ts` executes, byte for byte) and the comment/literal-stripped source
42
+ * (what `destructive.ts` matches its rules against). Neither view can describe a
43
+ * different set of statements than the other, because there is only one walk.
44
+ *
45
+ * Pure leaf: no imports at all, and in particular none from `migrate.ts` or
46
+ * `destructive.ts` (`migrate.ts` already imports `destructive.ts`, so the shared
47
+ * code cannot live in either of them).
48
+ */
49
+ /** One top-level statement, in both of the forms its two consumers need. */
50
+ export interface SqlStatement {
51
+ /**
52
+ * The statement exactly as written, trimmed, with its terminating semicolon
53
+ * removed. This is what gets EXECUTED, so it is sliced straight out of the
54
+ * source rather than reassembled: a tokenizer that rebuilds the text can
55
+ * silently alter it, and a mangled statement run against production is the
56
+ * failure this module exists to prevent.
57
+ */
58
+ raw: string;
59
+ /**
60
+ * The same statement with comments removed, string literals emptied to `''`,
61
+ * and dollar-quoted bodies emptied to `''`. Quoted identifiers are kept
62
+ * VERBATIM, because the destructive rules match on object names.
63
+ */
64
+ stripped: string;
65
+ /**
66
+ * The same statement with comments removed and EVERYTHING ELSE verbatim:
67
+ * string literals, quoted identifiers, and dollar-quoted bodies all keep
68
+ * their contents.
69
+ *
70
+ * This is the view dynamic SQL has to be read through. A `DO` block keeps its
71
+ * payload in a LITERAL (`EXECUTE 'DROP TABLE users'`), which is precisely
72
+ * what `stripped` empties, so the destructive scanner cannot use `stripped`
73
+ * for a procedural body and cannot use `raw` either (a comment in the body
74
+ * would hide the statement after it). Removing the comments here means the
75
+ * scanner never has to strip them itself, which is what it used to do, with a
76
+ * pair of regexes that were the exact hand-written lexer this module exists
77
+ * to delete.
78
+ */
79
+ code: string;
80
+ /**
81
+ * Executable SQL carried INSIDE this statement, in source order, with its
82
+ * delimiters excluded: every dollar-quoted body, plus a PG14+ `BEGIN ATOMIC`
83
+ * routine body. A `DO` block or a routine source is code rather than data, so
84
+ * the destructive scanner re-scans these.
85
+ */
86
+ blocks: string[];
87
+ /** True when the statement holds nothing but comments and whitespace. */
88
+ commentOnly: boolean;
89
+ }
90
+ /**
91
+ * Split a SQL script into its top-level statements.
92
+ *
93
+ * A semicolon inside a single-quoted string (including a backslash-escaping
94
+ * `E'...'` string), a double-quoted identifier, a dollar-quoted body, a PG14+
95
+ * `BEGIN ATOMIC` routine body, a line comment, or a block comment (which
96
+ * Postgres allows to NEST) does not terminate a statement. Nor does a `$` that
97
+ * merely continues an identifier open a dollar-quoted body, which is the same
98
+ * question asked from the other side (see IDENT_CONT and opensAtomicBody for
99
+ * what each of those two cost when they were wrong).
100
+ *
101
+ * Every returned statement is trimmed and carries no
102
+ * trailing semicolon; nothing is dropped, so a comment-only fragment comes back
103
+ * flagged rather than missing (the executor skips those, the scanner ignores
104
+ * them, and neither has to re-derive the fact).
105
+ */
106
+ export declare function tokenizeSql(sql: string): SqlStatement[];
107
+ /**
108
+ * Split a SQL script into individual executable statements on top-level
109
+ * semicolons, dropping comment-only fragments.
110
+ *
111
+ * This is the list `-- turbine:no-transaction` migrations issue one statement
112
+ * per `client.query()` call, which is the one production-destroying failure
113
+ * mode of the migration runner (a partial statement executed against
114
+ * production), so the behavior is pinned by exhaustive unit tests.
115
+ */
116
+ export declare function splitSqlStatements(sql: string): string[];
117
+ /**
118
+ * The whole script with comments removed, string and dollar-quoted literals
119
+ * emptied, and quoted identifiers preserved: the view the destructive rules are
120
+ * written against, joined back into one string.
121
+ *
122
+ * Statement-level consumers should prefer {@link tokenizeSql}, whose per-
123
+ * statement `stripped` is the same text without having to re-split it.
124
+ */
125
+ export declare function stripCommentsAndStrings(sql: string): string;
@@ -0,0 +1,373 @@
1
+ /**
2
+ * The ONE SQL statement tokenizer the migration tooling speaks.
3
+ *
4
+ * ## Why this module exists
5
+ *
6
+ * `migrate.ts` executes migration SQL and `destructive.ts` decides whether that
7
+ * SQL is allowed to run. Both need to answer the same two questions, "where does
8
+ * one statement end", and "which characters are code rather than comment or
9
+ * literal", and until v0.66 each answered them with its own hand-written lexer.
10
+ * They disagreed, and the one that was wrong was the GUARD:
11
+ *
12
+ * - `destructive.ts` ended a block comment at the first `*\/`
13
+ * (`sql.indexOf('*\/', i + 2)`). Postgres NESTS block comments, so a
14
+ * commented-out block that itself contains a comment, the ordinary shape of
15
+ * "I disabled this for now", reopened as CODE partway through and the
16
+ * scanner resynchronised in the middle of the file.
17
+ * - `destructive.ts` then split statements with `text.split(';')`, so a
18
+ * semicolon inside a quoted identifier (`DROP TABLE "we;ird"`) cut a
19
+ * statement in half and neither half matched a rule.
20
+ *
21
+ * Both bugs FAIL OPEN. The worst measured case printed a partial inventory: for
22
+ * a file holding a nested commented-out block, a `DROP TABLE`, and a `DELETE`,
23
+ * the guard listed only the DELETE. The operator confirms the inventory they
24
+ * were shown, and the unlisted DROP TABLE runs under that confirmation.
25
+ *
26
+ * The tokenizer that replaced them arrived with the same shape of hole one
27
+ * layer down, and it is worth naming the shape rather than the instances: EVERY
28
+ * disagreement with the server's own lexer fails open, because the guard reads
29
+ * a file the server will not execute and the server executes a file the guard
30
+ * never read. Two more were found and fixed after the rewrite, both verified
31
+ * dropping a table live on PostgreSQL 16: a `$` INSIDE an identifier read as a
32
+ * dollar-quote opener (see {@link IDENT_CONT}), and the inner semicolons of a
33
+ * PG14+ `BEGIN ATOMIC` routine body read as statement terminators (see
34
+ * {@link opensAtomicBody}). The rule the module is written to now is to copy
35
+ * Postgres's lexical rules exactly, and where a case is genuinely ambiguous, to
36
+ * pick the reading that shows the operator MORE, never less.
37
+ *
38
+ * A second lexer that agrees today drifts tomorrow, so there is exactly one
39
+ * here and both callers consume it. {@link tokenizeSql} does the whole job in a
40
+ * single pass and hands back, per statement, BOTH the verbatim source (what
41
+ * `migrate.ts` executes, byte for byte) and the comment/literal-stripped source
42
+ * (what `destructive.ts` matches its rules against). Neither view can describe a
43
+ * different set of statements than the other, because there is only one walk.
44
+ *
45
+ * Pure leaf: no imports at all, and in particular none from `migrate.ts` or
46
+ * `destructive.ts` (`migrate.ts` already imports `destructive.ts`, so the shared
47
+ * code cannot live in either of them).
48
+ */
49
+ /**
50
+ * A dollar-quote tag. Postgres allows digits after the first character
51
+ * (`$do1$`), so a tag regex that stops at letters reads the body as code and
52
+ * misses everything inside it. The leading character can never be a digit,
53
+ * which is what keeps a `$1` bind placeholder from opening a quoted body.
54
+ *
55
+ * This pattern answers "is this a well-formed tag", NOT "does a body open
56
+ * here". The second question is decided before the pattern is ever run, by
57
+ * whether the walk is standing at the start of a token at all: see
58
+ * {@link IDENT_CONT}.
59
+ */
60
+ const DOLLAR_TAG = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/;
61
+ /**
62
+ * Postgres identifier characters, copied from its own lexer rather than from
63
+ * intuition (`scan.l`: `ident_start [A-Za-z\200-\377_]`, `ident_cont
64
+ * [A-Za-z\200-\377_0-9\$]`). Every non-ASCII character is an identifier
65
+ * character, and so is `$` in every position but the first.
66
+ *
67
+ * That `$` is the whole reason the walk consumes an identifier as ONE token.
68
+ * Postgres lexes by LONGEST MATCH, so in `SELECT x$y$ FROM t` the identifier
69
+ * rule claims all four characters of `x$y$` and the dollar-quoting rule never
70
+ * gets a look at that `$`. Reading `$y$` as an opener instead starts a body
71
+ * whose tag never appears again, which swallows the REST OF THE FILE:
72
+ *
73
+ * input: SELECT x$y$ FROM t;
74
+ * DROP TABLE users;
75
+ * guard: destructive(0): (none)
76
+ * split: statements(1) <- the whole file
77
+ *
78
+ * Measured on PostgreSQL 16.14, that file returns a column named `x$y$` and
79
+ * then drops the table, under an inventory that listed nothing. Collapsing a
80
+ * file to one statement also breaks the one-statement-per-round-trip contract
81
+ * `-- turbine:no-transaction` migrations depend on, which is this module's
82
+ * other production-destroying failure mode.
83
+ *
84
+ * Consuming the identifier run first IS Postgres's rule rather than an
85
+ * approximation of it, which is what keeps the converse working: a digit
86
+ * cannot START an identifier, so the `$$` in `SELECT 1$$x$$` still opens a
87
+ * body (PostgreSQL reports its syntax error at the string `$$x$$`, not at the
88
+ * number). Verified against the same server: `SELECT a$$b`, `SELECT$$x$$`, and
89
+ * `AS$$SELECT 1$$` are each ONE identifier token, and `naïve$col$` is a legal
90
+ * column name.
91
+ */
92
+ const IDENT_START = /[A-Za-z_\u0080-\uFFFF]/;
93
+ const IDENT_CONT = /[A-Za-z0-9_$\u0080-\uFFFF]/;
94
+ /**
95
+ * A statement head that can carry a PG14+ SQL-standard routine body. Requiring
96
+ * it is what stops an ordinary transaction-control `BEGIN` from ever putting
97
+ * the walk into body mode, where semicolons stop terminating statements.
98
+ */
99
+ const SQL_BODY_HEAD = /^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\b/i;
100
+ /**
101
+ * `BEGIN` as the immediately preceding token. The leading character class
102
+ * spells out what `\b` gets wrong here: `$` is an identifier character in
103
+ * Postgres, so `\bBEGIN` would also match the tail of `x$BEGIN`.
104
+ */
105
+ const BEGIN_BEFORE_ATOMIC = /(?:^|[^A-Za-z0-9_$\u0080-\uFFFF])BEGIN\s+$/i;
106
+ /**
107
+ * True when the identifier just scanned opens a PG14+ SQL-standard routine
108
+ * body: `CREATE FUNCTION ... BEGIN ATOMIC <stmt>; <stmt>; END`.
109
+ *
110
+ * Those inner semicolons are not statement terminators, and splitting there
111
+ * cost the guard the entire body. `CREATE FUNCTION purge() RETURNS void
112
+ * LANGUAGE SQL BEGIN ATOMIC DELETE FROM users; END;` became three fragments:
113
+ * the first headed `CREATE FUNCTION`, which matches no destructive rule, and
114
+ * the rest headless. A function whose only job is to empty a table therefore
115
+ * reported a clean inventory (verified live: the function creates, runs, and
116
+ * leaves zero rows). It also handed the no-transaction runner a fragment that
117
+ * cannot execute on its own.
118
+ *
119
+ * The two keywords must be ADJACENT (whitespace and comments aside) because
120
+ * both are unreserved. `CREATE FUNCTION f() RETURNS TABLE (begin int, atomic
121
+ * int)` is a legal header, verified on PostgreSQL 16, and reading that as a
122
+ * body would swallow every following statement up to the next `END`: the
123
+ * fail-OPEN direction, which is the one this guard cannot afford.
124
+ */
125
+ function opensAtomicBody(word, before) {
126
+ return /^ATOMIC$/i.test(word) && BEGIN_BEFORE_ATOMIC.test(before) && SQL_BODY_HEAD.test(before);
127
+ }
128
+ /**
129
+ * True when the quote at `quoteAt` opens a Postgres escape string (`E'...'`),
130
+ * whose body treats a backslash as an escape character.
131
+ *
132
+ * The `E` must be a standalone token, so an identifier that merely ends in `e`
133
+ * does not turn the following literal into an E-string. Ordinary literals are
134
+ * left alone on purpose: with the modern `standard_conforming_strings = on`
135
+ * default, `'a\'` IS a complete string.
136
+ *
137
+ * Getting this wrong is not cosmetic. `E'p\'q'` is ONE literal; a tokenizer
138
+ * that ends it at the backslash-quote reads the rest of the file as string
139
+ * content, which merges following statements together (fatal for a
140
+ * `-- turbine:no-transaction` migration, which must issue one statement per
141
+ * round trip) and hides every later statement from the destructive guard.
142
+ */
143
+ function isEscapeStringPrefix(sql, quoteAt) {
144
+ const prev = sql[quoteAt - 1];
145
+ if (prev !== 'E' && prev !== 'e')
146
+ return false;
147
+ const before = sql[quoteAt - 2];
148
+ return before === undefined || !/[A-Za-z0-9_$"]/.test(before);
149
+ }
150
+ /**
151
+ * Split a SQL script into its top-level statements.
152
+ *
153
+ * A semicolon inside a single-quoted string (including a backslash-escaping
154
+ * `E'...'` string), a double-quoted identifier, a dollar-quoted body, a PG14+
155
+ * `BEGIN ATOMIC` routine body, a line comment, or a block comment (which
156
+ * Postgres allows to NEST) does not terminate a statement. Nor does a `$` that
157
+ * merely continues an identifier open a dollar-quoted body, which is the same
158
+ * question asked from the other side (see IDENT_CONT and opensAtomicBody for
159
+ * what each of those two cost when they were wrong).
160
+ *
161
+ * Every returned statement is trimmed and carries no
162
+ * trailing semicolon; nothing is dropped, so a comment-only fragment comes back
163
+ * flagged rather than missing (the executor skips those, the scanner ignores
164
+ * them, and neither has to re-derive the fact).
165
+ */
166
+ export function tokenizeSql(sql) {
167
+ const out = [];
168
+ const n = sql.length;
169
+ let stripped = '';
170
+ let code = '';
171
+ let blocks = [];
172
+ let start = 0;
173
+ let i = 0;
174
+ // Inside a `BEGIN ATOMIC` routine body: 0 means no. The count tracks the
175
+ // `CASE ... END` expressions nested in it, which are the only other `END` a
176
+ // SQL-standard body can hold (`BEGIN ATOMIC SELECT CASE WHEN ... END; END`
177
+ // is legal, verified on PostgreSQL 16), so a body cannot end at the first
178
+ // `END` it happens to contain.
179
+ let atomicDepth = 0;
180
+ let atomicStart = 0;
181
+ const flush = (rawEnd) => {
182
+ const raw = sql.slice(start, rawEnd).trim();
183
+ const strippedTrimmed = stripped.trim();
184
+ if (raw.length > 0) {
185
+ out.push({
186
+ raw,
187
+ stripped: strippedTrimmed,
188
+ code: code.trim(),
189
+ blocks,
190
+ commentOnly: strippedTrimmed.length === 0,
191
+ });
192
+ }
193
+ stripped = '';
194
+ code = '';
195
+ blocks = [];
196
+ };
197
+ while (i < n) {
198
+ const ch = sql[i];
199
+ const next = sql[i + 1];
200
+ // Line comment: runs to the end of the line. The newline itself is left for
201
+ // the generic branch below, so line structure survives into `stripped`.
202
+ if (ch === '-' && next === '-') {
203
+ let j = i;
204
+ while (j < n && sql[j] !== '\n')
205
+ j++;
206
+ i = j;
207
+ code += ' ';
208
+ continue;
209
+ }
210
+ // Block comment. Postgres nests these, so depth is counted rather than
211
+ // scanning for the first `*\/`: `/* a /* b */ c */` is ONE comment, and a
212
+ // scanner that stops at the inner terminator treats ` c */ ...` as code.
213
+ if (ch === '/' && next === '*') {
214
+ let depth = 1;
215
+ let j = i + 2;
216
+ while (j < n && depth > 0) {
217
+ if (sql[j] === '/' && sql[j + 1] === '*') {
218
+ depth++;
219
+ j += 2;
220
+ }
221
+ else if (sql[j] === '*' && sql[j + 1] === '/') {
222
+ depth--;
223
+ j += 2;
224
+ }
225
+ else {
226
+ j++;
227
+ }
228
+ }
229
+ i = j;
230
+ stripped += ' ';
231
+ code += ' ';
232
+ continue;
233
+ }
234
+ // Single-quoted literal. `''` always escapes a quote; inside an E-string a
235
+ // backslash escapes the next character too.
236
+ if (ch === "'") {
237
+ const escapes = isEscapeStringPrefix(sql, i);
238
+ let j = i + 1;
239
+ while (j < n) {
240
+ if (escapes && sql[j] === '\\') {
241
+ j += 2;
242
+ continue;
243
+ }
244
+ if (sql[j] === "'" && sql[j + 1] === "'") {
245
+ j += 2;
246
+ continue;
247
+ }
248
+ if (sql[j] === "'")
249
+ break;
250
+ j++;
251
+ }
252
+ const end = Math.min(j + 1, n);
253
+ stripped += "''";
254
+ code += sql.slice(i, end);
255
+ i = end;
256
+ continue;
257
+ }
258
+ // Quoted identifier (`""` escapes a quote). Kept VERBATIM in `stripped`,
259
+ // because the destructive rules match on object names, but consumed as ONE
260
+ // token: an apostrophe inside a quoted name (`"customer's_orders"`) would
261
+ // otherwise open a string literal and hide every following statement, and a
262
+ // semicolon inside one (`"we;ird"`) would otherwise split a statement in
263
+ // half so that neither half matched anything.
264
+ if (ch === '"') {
265
+ let j = i + 1;
266
+ while (j < n) {
267
+ if (sql[j] === '"' && sql[j + 1] === '"') {
268
+ j += 2;
269
+ continue;
270
+ }
271
+ if (sql[j] === '"')
272
+ break;
273
+ j++;
274
+ }
275
+ const end = Math.min(j + 1, n);
276
+ stripped += sql.slice(i, end);
277
+ code += sql.slice(i, end);
278
+ i = end;
279
+ continue;
280
+ }
281
+ // Identifier or keyword run, consumed as ONE token because Postgres does.
282
+ // This branch has to sit ABOVE the dollar-quote branch: a `$` that is part
283
+ // of an identifier is not an opener, and reading it as one swallows the
284
+ // rest of the file (see IDENT_CONT). It also gives the `BEGIN ATOMIC`
285
+ // tracking below a token boundary to work from, so `BEGINATOMIC` or a
286
+ // column named `atomic$` can never be mistaken for the keyword pair.
287
+ if (IDENT_START.test(ch)) {
288
+ let j = i + 1;
289
+ while (j < n && IDENT_CONT.test(sql[j]))
290
+ j++;
291
+ const word = sql.slice(i, j);
292
+ if (atomicDepth === 0) {
293
+ if (opensAtomicBody(word, stripped)) {
294
+ atomicDepth = 1;
295
+ atomicStart = j;
296
+ }
297
+ }
298
+ else if (/^CASE$/i.test(word)) {
299
+ atomicDepth++;
300
+ }
301
+ else if (/^END$/i.test(word) && --atomicDepth === 0) {
302
+ blocks.push(sql.slice(atomicStart, i));
303
+ }
304
+ stripped += word;
305
+ code += word;
306
+ i = j;
307
+ continue;
308
+ }
309
+ // Dollar-quoted body ($tag$ ... $tag$).
310
+ if (ch === '$') {
311
+ const tag = DOLLAR_TAG.exec(sql.slice(i))?.[0];
312
+ if (tag) {
313
+ const close = sql.indexOf(tag, i + tag.length);
314
+ const next = close === -1 ? n : close + tag.length;
315
+ blocks.push(sql.slice(i + tag.length, close === -1 ? n : close));
316
+ stripped += "''";
317
+ code += sql.slice(i, next);
318
+ i = next;
319
+ continue;
320
+ }
321
+ }
322
+ // Top-level statement terminator. The semicolons INSIDE a `BEGIN ATOMIC`
323
+ // routine body are not top level, so while one is open this falls through
324
+ // to the generic branch and the body stays one statement (see
325
+ // opensAtomicBody).
326
+ if (ch === ';' && atomicDepth === 0) {
327
+ flush(i);
328
+ i++;
329
+ start = i;
330
+ continue;
331
+ }
332
+ stripped += ch;
333
+ code += ch;
334
+ i++;
335
+ }
336
+ // A `BEGIN ATOMIC` body still open at EOF (a file that ends mid-routine, or
337
+ // an `END` this walk failed to find) is handed over anyway rather than
338
+ // dropped. Dropping it is the fail-OPEN direction: the body is executable
339
+ // SQL, and it would reach no scanner at all, whereas handing over a body
340
+ // that the server may reject as unterminated costs at most a confirmation
341
+ // prompt for a file that was never going to run.
342
+ if (atomicDepth > 0)
343
+ blocks.push(sql.slice(atomicStart));
344
+ flush(n);
345
+ return out;
346
+ }
347
+ /**
348
+ * Split a SQL script into individual executable statements on top-level
349
+ * semicolons, dropping comment-only fragments.
350
+ *
351
+ * This is the list `-- turbine:no-transaction` migrations issue one statement
352
+ * per `client.query()` call, which is the one production-destroying failure
353
+ * mode of the migration runner (a partial statement executed against
354
+ * production), so the behavior is pinned by exhaustive unit tests.
355
+ */
356
+ export function splitSqlStatements(sql) {
357
+ return tokenizeSql(sql)
358
+ .filter((s) => !s.commentOnly)
359
+ .map((s) => s.raw);
360
+ }
361
+ /**
362
+ * The whole script with comments removed, string and dollar-quoted literals
363
+ * emptied, and quoted identifiers preserved: the view the destructive rules are
364
+ * written against, joined back into one string.
365
+ *
366
+ * Statement-level consumers should prefer {@link tokenizeSql}, whose per-
367
+ * statement `stripped` is the same text without having to re-split it.
368
+ */
369
+ export function stripCommentsAndStrings(sql) {
370
+ return tokenizeSql(sql)
371
+ .map((s) => s.stripped)
372
+ .join('; ');
373
+ }