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