turbine-orm 0.77.1 → 0.78.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 (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
@@ -45,7 +45,9 @@ export const DESTRUCTIVE_KIND_LABEL = {
45
45
  'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
46
46
  'merge-delete': 'deletes matched rows (MERGE ... THEN DELETE)',
47
47
  rename: 'renames a table or column, every query and view referencing the old name breaks',
48
+ 'rewrite-rule': 'installs a rewrite rule that runs a destructive statement every time a later ordinary statement matches it',
48
49
  'dynamic-destructive': 'runs destructive SQL assembled at run time, what it destroys cannot be known without running it',
50
+ 'dynamic-unclassified': 'runs SQL assembled at run time whose statement the scanner cannot read; confirm what it does yourself',
49
51
  };
50
52
  /** Unquote a "quoted" identifier for display. */
51
53
  const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
@@ -150,6 +152,25 @@ const RULES = [
150
152
  target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
151
153
  also: (stmt) => !hasTopLevelWhere(stmt),
152
154
  },
155
+ {
156
+ // `CREATE RULE r AS ON INSERT TO t DO INSTEAD DELETE FROM u`. The rule
157
+ // destroys nothing when it is created, which is why it passed: the DELETE
158
+ // runs later, on every ordinary INSERT into `t`, and the first one emptied
159
+ // the table. Deferred destruction is still destruction the migration
160
+ // installs. `DO ALSO` counts too (the action runs in addition), and the
161
+ // action may be a parenthesized list, whose FIRST statement decides.
162
+ kind: 'rewrite-rule',
163
+ regex: new RegExp(String.raw `^CREATE\s+(?:OR\s+REPLACE\s+)?RULE\s+${IDENT}\s+AS\s+ON\s+(?:SELECT|INSERT|UPDATE|DELETE)\s+TO\s+${IDENT}${OUTSIDE_QUOTES}\bDO\s+(?:ALSO\s+|INSTEAD\s+)?\(?\s*(DELETE|UPDATE|TRUNCATE|DROP)\b`, 'i'),
164
+ target: (m) => `${qualified(m, 4)} (${(m[7] ?? '').toUpperCase()} on every matching statement)`,
165
+ // An UPDATE action is judged the way a top-level UPDATE is: destructive
166
+ // without a WHERE, ordinary with one. `CREATE RULE v_upd AS ON UPDATE TO v
167
+ // DO INSTEAD UPDATE base SET n = 1 WHERE id = 2` is the standard updatable
168
+ // -view idiom and was being reported, which is a false positive on a shape
169
+ // that appears in ordinary migrations. DELETE, TRUNCATE and DROP stay
170
+ // unconditional: a DELETE inside a rule destroys on every matching
171
+ // statement whether or not it is narrowed, and the other two take no WHERE.
172
+ also: (stmt) => !/\bDO\s+(?:ALSO\s+|INSTEAD\s+)?\(?\s*UPDATE\b/i.test(stmt) || !hasTopLevelWhere(stmt),
173
+ },
153
174
  // Renames come LAST: they destroy no data, so any statement that is BOTH a
154
175
  // rename and a data-loss operation should report the data loss instead.
155
176
  {
@@ -222,6 +243,28 @@ function matchRules(candidate) {
222
243
  }
223
244
  return null;
224
245
  }
246
+ /**
247
+ * The DML inside `COPY (DELETE FROM t RETURNING *) TO ...`.
248
+ *
249
+ * The same trick as a data-modifying CTE, through a different wrapper: the
250
+ * statement reads as a COPY, which nothing here treats as destructive, and the
251
+ * DELETE inside it runs and empties the table. Verified live on PostgreSQL 17
252
+ * through node-postgres, `TO STDOUT` included, so it needs no server-side file
253
+ * permission and no `TO PROGRAM`. `cteSubstatements` does not reach it because
254
+ * that one only fires on a leading `WITH`.
255
+ */
256
+ function copySubstatements(stmt) {
257
+ if (!/^COPY\s*\(/i.test(stmt))
258
+ return [];
259
+ const open = stmt.indexOf('(');
260
+ if (open === -1)
261
+ return [];
262
+ const inner = stmt.slice(open + 1, closingParenIndex(stmt, open)).trim();
263
+ // A `COPY (SELECT ...) TO` is the ordinary export form and destroys nothing.
264
+ // A leading WITH is handed on so its own CTE bodies are read by the rule
265
+ // above rather than being judged as one blob here.
266
+ return /^(?:DELETE|UPDATE|INSERT|MERGE|WITH)\b/i.test(inner) ? [inner] : [];
267
+ }
225
268
  /**
226
269
  * Data-modifying CTE bodies: `WITH d AS (DELETE FROM t ...) SELECT ...` runs a
227
270
  * real DELETE even though the statement reads as a SELECT. Each candidate is cut
@@ -294,7 +337,7 @@ function proceduralCandidates(body) {
294
337
  const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
295
338
  let m = re.exec(text);
296
339
  while (m !== null) {
297
- out.push({ text: text.slice(m.index), before: text.slice(0, m.index) });
340
+ out.push({ text: text.slice(m.index), before: text.slice(0, m.index), statementCode: text });
298
341
  m = re.exec(text);
299
342
  }
300
343
  }
@@ -322,6 +365,7 @@ export function scanDestructiveSql(sql) {
322
365
  const candidates = [
323
366
  stripLeadingCtes(body),
324
367
  ...cteSubstatements(body),
368
+ ...copySubstatements(body).flatMap((inner) => [inner, ...cteSubstatements(inner)]),
325
369
  ].map((text) => ({
326
370
  text,
327
371
  display,
@@ -339,10 +383,20 @@ export function scanDestructiveSql(sql) {
339
383
  candidates.push({
340
384
  text: part.text,
341
385
  display: `${display} [in block: ${part.text.replace(/\s+/g, ' ').slice(0, 60)}]`,
386
+ statementCode: part.statementCode,
342
387
  });
343
388
  proceduralParts.push(part);
344
389
  }
345
390
  }
391
+ // Sub-statements of a procedural body that some pass has already named.
392
+ // ONE body holds several statements and each is judged on its own, so a
393
+ // pass that reports one must not silence the others: `DO $$ BEGIN EXECUTE
394
+ // 'ALTER TABLE t DROP COLUMN x'; EXECUTE 'D'||'ROP TABLE victim'; END $$`
395
+ // reported only the DROP COLUMN, and the DROP TABLE then ran under a
396
+ // confirmation the operator gave for something else. Equally, the
397
+ // unclassified backstop must not re-report a statement an earlier pass
398
+ // already named, which is what this set is for.
399
+ const spokenFor = new Set();
346
400
  let matched = false;
347
401
  for (const candidate of candidates) {
348
402
  const hit = matchRules(candidate.text);
@@ -350,9 +404,15 @@ export function scanDestructiveSql(sql) {
350
404
  continue;
351
405
  found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
352
406
  matched = true;
353
- break;
407
+ if (candidate.statementCode !== undefined)
408
+ spokenFor.add(candidate.statementCode);
409
+ // A statement outside a procedural body has exactly one verdict, as
410
+ // before. Inside one, keep going: the remaining sub-statements have not
411
+ // been judged yet.
412
+ if (!procedural)
413
+ break;
354
414
  }
355
- if (matched)
415
+ if (matched && !procedural)
356
416
  continue;
357
417
  // Nothing matched a rule. Inside a PROCEDURAL body that is not the end of
358
418
  // the question, because the rules all need a parseable object name and
@@ -375,7 +435,10 @@ export function scanDestructiveSql(sql) {
375
435
  // `RAISE NOTICE 'DROP the mic'` would prompt, and a guard that fires on
376
436
  // prose teaches operators to confirm without reading, which costs more than
377
437
  // it saves.
438
+ let dynamicHit = false;
378
439
  for (const part of proceduralParts) {
440
+ if (spokenFor.has(part.statementCode))
441
+ continue;
379
442
  const kind = dynamicDestructiveKind(part);
380
443
  if (!kind)
381
444
  continue;
@@ -384,11 +447,210 @@ export function scanDestructiveSql(sql) {
384
447
  kind,
385
448
  target: DYNAMIC_TARGET,
386
449
  });
387
- break;
450
+ dynamicHit = true;
451
+ spokenFor.add(part.statementCode);
452
+ }
453
+ // Still nothing, in a procedural body. Every pass above needs to SEE a verb,
454
+ // and an EXECUTE whose text is assembled so that no verb is visible
455
+ // (`'D' || 'ROP TABLE t'`, `chr(68) || ...`, `reverse(...)`, an escape-
456
+ // encoded literal, a variable built across statements) walked past all of
457
+ // them and dropped the table live. The scanner cannot classify such a
458
+ // statement, and "cannot classify" must not be reported as "clean": that is
459
+ // the consent gate deciding in the author's favour on no evidence. So it
460
+ // asks, with a kind whose label says exactly that.
461
+ for (const block of procedural ? statement.blocks : []) {
462
+ for (const unreadable of unclassifiableExecutes(block, matched || dynamicHit)) {
463
+ if (spokenFor.has(unreadable.code))
464
+ continue;
465
+ spokenFor.add(unreadable.code);
466
+ found.push({
467
+ statement: `${display} [in block: ${`EXECUTE ${unreadable.expr}`.replace(/\s+/g, ' ').slice(0, 60)}]`,
468
+ kind: 'dynamic-unclassified',
469
+ target: DYNAMIC_TARGET,
470
+ });
471
+ }
388
472
  }
389
473
  }
390
474
  return found;
391
475
  }
476
+ /**
477
+ * Statement openers that destroy nothing, so an assembled `EXECUTE` whose text
478
+ * visibly begins with one of them is left alone. `UPDATE` is here because its
479
+ * destructive form is the ABSENCE of a `WHERE`, which the tail of an assembled
480
+ * fragment cannot answer; flagging every dynamic `UPDATE ... WHERE` is the
481
+ * false-positive cost this module refuses to pay.
482
+ */
483
+ const HARMLESS_OPENER = /^(?:SELECT|INSERT|UPDATE|CREATE|COMMENT|GRANT|REVOKE|ANALYZE|ANALYSE|REFRESH|VACUUM|REINDEX|CLUSTER|SET|RESET|SHOW|NOTIFY|LOCK|CALL|PERFORM|EXPLAIN)\b/i;
484
+ /**
485
+ * Openers whose rules deliberately stay SILENT on a benign form, so silence
486
+ * from them is a decision rather than a failure to parse. `ALTER TABLE ... ADD
487
+ * COLUMN` loses nothing and is not reported on purpose; a `MERGE` without a
488
+ * `THEN DELETE` likewise; a `WITH` whose CTE bodies are all reads likewise.
489
+ * Reporting these again here would contradict that silence.
490
+ */
491
+ const VERB_SILENT_WHEN_BENIGN = /^(?:ALTER|MERGE|WITH)\b/i;
492
+ /**
493
+ * Openers whose rules ALWAYS report when they can read the statement. Silence
494
+ * from one of these is not a verdict, it is the rules failing to parse, so the
495
+ * skip is conditional on an earlier pass having actually reported.
496
+ *
497
+ * The shape that made the distinction necessary: two adjacent string literals
498
+ * are ONE string in PostgreSQL, so `EXECUTE 'DROP TABLE '\n'victims'` reads as
499
+ * `DROP` to the opener test while every earlier pass declines, the literal
500
+ * rules because a quote is not an identifier and the assembly test because
501
+ * continuation is lexical and carries no `||`, `concat` or `format`. The
502
+ * opener was trusted, the statement was skipped, and the table went.
503
+ */
504
+ const VERB_ALWAYS_REPORTS = /^(?:DROP|TRUNCATE|DELETE)\b/i;
505
+ /**
506
+ * `EXECUTE` in a position where plpgsql can begin a statement.
507
+ *
508
+ * Matched against a statement's `stripped` view, so the word can never be one
509
+ * that lived inside a string literal. `GRANT`/`REVOKE EXECUTE ON FUNCTION` are
510
+ * excluded by construction: their preceding token is a verb, not one of the
511
+ * introducers here.
512
+ */
513
+ const EXECUTE_AT_STATEMENT_START = /(?:^|;|\bTHEN\b|\bELSE\b|\bLOOP\b|\bBEGIN\b|\bDECLARE\b)\s*EXECUTE\s/i;
514
+ /**
515
+ * Functions whose FIRST literal argument is the statement text (or its
516
+ * template), so the opener can be read through the call.
517
+ */
518
+ const TEMPLATE_FN = /^(?:format|replace|regexp_replace|concat|concat_ws|array_to_string)\s*\(/i;
519
+ /** The leading string literal of `text`: its raw content and whether it can hide a verb. */
520
+ function leadingLiteral(text) {
521
+ const m = /^(U&'|[EeBbXx]'|'|\$([A-Za-z_][\w]*)?\$)/.exec(text);
522
+ if (!m)
523
+ return null;
524
+ const open = m[1];
525
+ if (open.startsWith('$')) {
526
+ const close = text.indexOf(open, open.length);
527
+ if (close === -1)
528
+ return null;
529
+ return { content: text.slice(open.length, close), rest: text.slice(close + open.length), escaped: false };
530
+ }
531
+ // Single-quoted: `''` is an escaped quote inside the literal.
532
+ let i = open.length;
533
+ let content = '';
534
+ while (i < text.length) {
535
+ const ch = text[i];
536
+ if (ch === "'") {
537
+ if (text[i + 1] === "'") {
538
+ content += "'";
539
+ i += 2;
540
+ continue;
541
+ }
542
+ break;
543
+ }
544
+ content += ch;
545
+ i++;
546
+ }
547
+ if (i >= text.length)
548
+ return null;
549
+ // `E'\x44ROP'` and `U&'\0044ROP'` spell a verb the scanner cannot see; an
550
+ // escape string with no backslash in it hides nothing.
551
+ const escaped = open !== "'" && content.includes('\\');
552
+ return { content, rest: text.slice(i + 1), escaped };
553
+ }
554
+ /**
555
+ * The first word of the statement an `EXECUTE` argument would run, read as far
556
+ * as the text allows, plus whether the argument is one PLAIN literal (in which
557
+ * case the literal passes already had their chance and this pass stays out).
558
+ * `null` opener = the scanner cannot see a verb at all.
559
+ */
560
+ function executeOpener(expr) {
561
+ const text = expr.trim();
562
+ const lit = leadingLiteral(text);
563
+ if (lit) {
564
+ const word = /^\s*([A-Za-z_][\w]*)/.exec(lit.content)?.[1] ?? null;
565
+ // Plain: the literal IS the whole argument (bar an INTO / USING clause),
566
+ // spelled without escapes.
567
+ const plain = !lit.escaped && /^\s*(?:;|INTO\b|USING\b|$)/i.test(lit.rest);
568
+ return { opener: lit.escaped ? null : word, plain };
569
+ }
570
+ if (TEMPLATE_FN.test(text)) {
571
+ // Read the opener from the first literal argument that carries a word: a
572
+ // `concat_ws(' ', ...)` separator or a `format('%s', ...)` placeholder is
573
+ // not a verb, and neither is a `concat('DR', 'OP', ...)` fragment.
574
+ const inner = text.slice(text.indexOf('(') + 1);
575
+ let cursor = inner;
576
+ for (let guard = 0; guard < 16; guard++) {
577
+ const lit2 = leadingLiteral(cursor.trimStart());
578
+ if (!lit2)
579
+ break;
580
+ const word = /^\s*([A-Za-z_][\w]*)/.exec(lit2.content)?.[1];
581
+ if (word)
582
+ return { opener: lit2.escaped ? null : word, plain: false };
583
+ const comma = lit2.rest.indexOf(',');
584
+ if (comma === -1)
585
+ break;
586
+ cursor = lit2.rest.slice(comma + 1);
587
+ }
588
+ return { opener: null, plain: false };
589
+ }
590
+ // Any other function call, a parenthesised expression, or a bare variable
591
+ // (`EXECUTE s`, built across earlier statements): nothing readable.
592
+ return { opener: null, plain: false };
593
+ }
594
+ /**
595
+ * The EXECUTE argument in `body`, when it is assembled or encoded such that no
596
+ * statement verb is visible to the passes above. Returns the offending
597
+ * fragment for display, or `null` when every EXECUTE in the body either is a
598
+ * plain literal (the literal rules own it) or visibly begins with a verb some
599
+ * other rule has already judged.
600
+ */
601
+ function unclassifiableExecutes(body, verbPassesReported) {
602
+ const out = [];
603
+ for (const statement of tokenizeSql(body)) {
604
+ // Decide PRESENCE against `stripped`, whose literals are emptied, and read
605
+ // the ARGUMENT out of `code`, which keeps them. Reading both out of `code`
606
+ // fired on the word rather than on the statement: `RAISE NOTICE 'EXECUTE
607
+ // the plan'` and `INSERT INTO log(msg) VALUES ('EXECUTE me later')` both
608
+ // prompted, and a guard that fires on prose teaches operators to confirm
609
+ // without reading, which is the cost this module's own doctrine refuses to
610
+ // pay. `stripped` cannot contain a word that only ever appeared inside a
611
+ // literal, so it settles the question the offsets cannot.
612
+ //
613
+ // The keyword must also sit where a plpgsql STATEMENT can start, not
614
+ // merely somewhere in the text: `GRANT EXECUTE ON FUNCTION f() TO app` is
615
+ // an ordinary permission grant and was being reported as unreadable
616
+ // dynamic SQL, which fails an ordinary migration under `migrate deploy`
617
+ // where there is no terminal to confirm at. The introducers below are the
618
+ // positions plpgsql actually allows one at, since `tokenizeSql` splits on
619
+ // top-level semicolons and a body's `IF ... THEN EXECUTE ...` therefore
620
+ // arrives as one statement.
621
+ if (!EXECUTE_AT_STATEMENT_START.test(statement.stripped))
622
+ continue;
623
+ const m = /\bEXECUTE\s+([\s\S]+)$/i.exec(statement.code);
624
+ if (!m)
625
+ continue;
626
+ const expr = m[1];
627
+ const { opener, plain } = executeOpener(expr);
628
+ if (plain)
629
+ continue;
630
+ // `EXECUTE s` where `s` is a bare variable: the statement it runs was
631
+ // assembled by some EARLIER sub-statement of the same block, so when a pass
632
+ // has already named one, the operator has been told what this body does and
633
+ // a second "cannot classify" entry for the same thing is noise that
634
+ // contradicts the first. When nothing was named, this is the whole finding
635
+ // and it stands: an assignment the rules could not read leaves
636
+ // `verbPassesReported` false.
637
+ if (opener === null && verbPassesReported && /^[A-Za-z_]\w*\s*;?$/.test(expr.trim()))
638
+ continue;
639
+ if (opener !== null) {
640
+ if (HARMLESS_OPENER.test(opener))
641
+ continue;
642
+ if (VERB_SILENT_WHEN_BENIGN.test(opener))
643
+ continue;
644
+ // The remaining skip is a claim that an earlier pass already reported
645
+ // this statement, so it holds only when one actually did. See
646
+ // VERB_ALWAYS_REPORTS for the shape where none does.
647
+ if (verbPassesReported && VERB_ALWAYS_REPORTS.test(opener))
648
+ continue;
649
+ }
650
+ out.push({ code: statement.code, expr });
651
+ }
652
+ return out;
653
+ }
392
654
  /** Shown in place of an object name that does not exist until the block runs. */
393
655
  export const DYNAMIC_TARGET = '<name assembled at run time>';
394
656
  /**
@@ -453,16 +715,41 @@ const ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quot
453
715
  const WRAPPING_ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quote_nullable|array_to_string|regexp_replace|replace)\s*\([^)]*$/i;
454
716
  /** Dynamic SQL only runs if something runs it. Scoped to the candidate's own statement. */
455
717
  const RUNS_DYNAMIC_SQL = /\bEXECUTE\b/i;
718
+ /**
719
+ * `ALTER TABLE <assembled name> DROP [COLUMN] ...`: the sub-action that loses
720
+ * rows, with the same exclusions as the static `drop-column` rule (`DROP
721
+ * CONSTRAINT` / `DEFAULT` / `NOT NULL` / `IDENTITY` / `EXPRESSION` lose none).
722
+ * Matches when the keyword `COLUMN`, an `IF EXISTS`, or a literal column name
723
+ * follows the `DROP`; a `DROP` followed by nothing literal is handled below.
724
+ * {@link OUTSIDE_QUOTES} keeps `ADD COLUMN "drop me"` out, as it does statically.
725
+ */
726
+ const DYNAMIC_ALTER_DROP_COLUMN = new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\s+(?!CONSTRAINT\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b)(?:COLUMN\b|IF\s+EXISTS\b|${IDENT})`, 'i');
727
+ /** `ALTER TABLE <assembled name> ALTER [COLUMN] <col> [SET DATA] TYPE ...`. */
728
+ const DYNAMIC_ALTER_COLUMN_TYPE = new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bALTER\s+(?:COLUMN\s+)?${IDENT}\s+(?:SET\s+DATA\s+)?TYPE\b`, 'i');
729
+ /** The `DROP <thing>` sub-actions of ALTER TABLE that lose no rows. */
730
+ const DYNAMIC_ALTER_DROP_HARMLESS = new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\s+(?:CONSTRAINT|DEFAULT|NOT\s+NULL|IDENTITY|EXPRESSION)\b`, 'i');
456
731
  /**
457
732
  * The kind a runtime-assembled procedural fragment should be reported as, or
458
733
  * `null` when it is not dynamic (so a rule already had its chance) or its verb
459
734
  * is not one that destroys data on its own.
460
735
  *
461
- * `ALTER` and `UPDATE` are deliberately absent even though
462
- * {@link proceduralCandidates} collects them: their destructive forms are
463
- * narrow (`ALTER COLUMN ... TYPE`, an `UPDATE` with no `WHERE`) and neither is
464
- * decidable from a fragment whose tail is a runtime expression, so including
465
- * them would flag every dynamic `UPDATE ... WHERE` in the file.
736
+ * `ALTER TABLE` IS decided here, by the sub-action that follows the assembled
737
+ * table name, because in the shape that matters that sub-action is literal
738
+ * text: a multi-tenant loop assembles the TABLE (`'ALTER TABLE ' ||
739
+ * quote_ident(t) || ' DROP COLUMN legacy_phone'`) and writes out what it does
740
+ * to it. That statement dropped the column from every tenant table while the
741
+ * scan reported a clean inventory, and its `DROP TABLE` twin was already being
742
+ * flagged, so an operator who had seen the guard fire once would assume this
743
+ * was covered. Only the forms that lose rows are reported (`DROP [COLUMN]`,
744
+ * `ALTER COLUMN ... TYPE`), with the same exclusions as their static rules; an
745
+ * assembled `ADD COLUMN` stays silent. A `DROP` whose object is itself in the
746
+ * runtime expression (`' DROP ' || what`) is `dynamic-destructive`, the bare
747
+ * `DROP` precedent below: alarming is fine, wrong is not.
748
+ *
749
+ * `UPDATE` is still deliberately absent even though {@link proceduralCandidates}
750
+ * collects it: its destructive form is the ABSENCE of a `WHERE`, and absence
751
+ * is not decidable from a fragment whose tail is a runtime expression, so
752
+ * including it would flag every dynamic `UPDATE ... WHERE` in the file.
466
753
  */
467
754
  function dynamicDestructiveKind({ text, before }) {
468
755
  const assembled = DYNAMIC_ASSEMBLY.test(text) ||
@@ -470,6 +757,17 @@ function dynamicDestructiveKind({ text, before }) {
470
757
  (RUNS_DYNAMIC_SQL.test(before) && WRAPPING_ASSEMBLY_FN.test(before));
471
758
  if (!assembled)
472
759
  return null;
760
+ if (/^ALTER\s+TABLE\b/i.test(text)) {
761
+ if (DYNAMIC_ALTER_DROP_COLUMN.test(text))
762
+ return 'drop-column';
763
+ if (DYNAMIC_ALTER_COLUMN_TYPE.test(text))
764
+ return 'alter-column-type';
765
+ if (DYNAMIC_ALTER_DROP_HARMLESS.test(text))
766
+ return null;
767
+ if (new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\b`, 'i').test(text))
768
+ return 'dynamic-destructive';
769
+ return null;
770
+ }
473
771
  if (/^DROP\s+TABLE\b/i.test(text))
474
772
  return 'drop-table';
475
773
  if (/^DROP\s+SCHEMA\b/i.test(text))