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
@@ -18,9 +18,17 @@
18
18
  * Row removal hides in more than a leading `DELETE`, so the scan also covers:
19
19
  * the optional-`COLUMN` shorthand (`ALTER TABLE t DROP email`), data-modifying
20
20
  * CTEs (`WITH d AS (DELETE ...) SELECT ...`), `MERGE ... THEN DELETE`, dynamic
21
- * SQL inside a `DO`/function body, and an `UPDATE` whose only WHERE sits inside
22
- * a subquery (which restricts nothing).
21
+ * SQL inside a `DO`/function body, an `UPDATE` whose only WHERE sits inside a
22
+ * subquery (which restricts nothing), a `DROP ... CASCADE` of a non-table
23
+ * object (which takes dependent COLUMNS with it), a detached partition, and an
24
+ * `EXPLAIN ANALYZE` of any of the above (which really executes it).
25
+ *
26
+ * The lexing, "where does a statement end" and "which characters are code",
27
+ * is NOT done here: it lives in `sql-statements.ts` and is shared with the
28
+ * migration runner. Two lexers is how this guard came to disagree with the
29
+ * executor about what a file contained (see that module's header).
23
30
  */
31
+ import { tokenizeSql } from './sql-statements.js';
24
32
  /** Human explanation per kind, used in CLI output. */
25
33
  export const DESTRUCTIVE_KIND_LABEL = {
26
34
  'drop-table': 'drops a table and ALL its rows',
@@ -29,106 +37,36 @@ export const DESTRUCTIVE_KIND_LABEL = {
29
37
  'drop-owned': 'drops every object owned by a role, and their rows',
30
38
  'drop-matview': 'drops a materialized view and its stored rows',
31
39
  'drop-column': 'drops a column and its data in every row',
40
+ 'drop-cascade': 'drops an object AND every dependent object, columns and their data included',
41
+ 'detach-partition': 'detaches a partition, every row in it leaves the table',
32
42
  truncate: 'deletes every row',
33
43
  delete: 'deletes rows',
34
44
  'update-without-where': 'rewrites every row (no WHERE clause)',
35
45
  'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
36
46
  'merge-delete': 'deletes matched rows (MERGE ... THEN DELETE)',
47
+ rename: 'renames a table or column, every query and view referencing the old name breaks',
48
+ 'dynamic-destructive': 'runs destructive SQL assembled at run time, what it destroys cannot be known without running it',
37
49
  };
50
+ /** Unquote a "quoted" identifier for display. */
51
+ const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
52
+ const IDENT = String.raw `("[^"]+"|[a-zA-Z_][\w$]*)(\.("[^"]+"|[a-zA-Z_][\w$]*))?`;
38
53
  /**
39
- * A dollar-quote tag. Postgres allows digits after the first character
40
- * (`$do1$`), so a tag regex that stops at letters reads the body as code and
41
- * misses everything inside it. Same shape as the splitter in `migrate.ts`.
54
+ * Render one `IDENT` capture triple (name, `.qualifier`, qualifier) as text,
55
+ * where `base` is the 1-based index of the triple's first group.
42
56
  */
43
- const DOLLAR_TAG = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/;
44
- /** Strip -- line comments, C-style block comments, and quoted literals. */
45
- function stripCommentsAndStrings(sql) {
46
- const blocks = [];
47
- let out = '';
48
- let i = 0;
49
- while (i < sql.length) {
50
- const two = sql.slice(i, i + 2);
51
- if (two === '--') {
52
- const nl = sql.indexOf('\n', i);
53
- i = nl === -1 ? sql.length : nl; // keep the newline
54
- }
55
- else if (two === '/*') {
56
- const end = sql.indexOf('*/', i + 2);
57
- i = end === -1 ? sql.length : end + 2;
58
- out += ' ';
59
- }
60
- else if (sql[i] === "'") {
61
- // Single-quoted literal. `''` always escapes a quote; inside an E-string
62
- // (`E'...'`) a backslash escapes the next character too, so `E'a\'b'` is
63
- // ONE literal. Without the E-string case the scan ends the literal at the
64
- // backslash-quote and treats the rest of the file as code, which
65
- // (worse) then hides every following statement from the guard.
66
- const escapes = isEscapeStringPrefix(sql, i);
67
- let j = i + 1;
68
- while (j < sql.length) {
69
- if (escapes && sql[j] === '\\')
70
- j += 2;
71
- else if (sql[j] === "'" && sql[j + 1] === "'")
72
- j += 2;
73
- else if (sql[j] === "'")
74
- break;
75
- else
76
- j++;
77
- }
78
- i = j + 1;
79
- out += "''";
80
- }
81
- else if (sql[i] === '"') {
82
- // Quoted identifier. Kept VERBATIM (rules match on identifiers), but it
83
- // has to be consumed as one token: an apostrophe inside a quoted name
84
- // (`"customer's_orders"`) would otherwise open a string literal and hide
85
- // every statement after it from the scan.
86
- let j = i + 1;
87
- while (j < sql.length) {
88
- if (sql[j] === '"' && sql[j + 1] === '"')
89
- j += 2;
90
- else if (sql[j] === '"')
91
- break;
92
- else
93
- j++;
94
- }
95
- out += sql.slice(i, Math.min(j + 1, sql.length));
96
- i = j + 1;
97
- }
98
- else if (sql[i] === '$' && DOLLAR_TAG.test(sql.slice(i))) {
99
- // dollar-quoted literal ($$...$$ / $tag$...$tag$)
100
- const tag = sql.slice(i).match(DOLLAR_TAG)?.[0] ?? '$$';
101
- const end = sql.indexOf(tag, i + tag.length);
102
- blocks.push({ at: out.length, body: sql.slice(i + tag.length, end === -1 ? sql.length : end) });
103
- i = end === -1 ? sql.length : end + tag.length;
104
- out += "''";
105
- }
106
- else {
107
- out += sql[i];
108
- i++;
109
- }
110
- }
111
- return { text: out, blocks };
112
- }
57
+ const qualified = (m, base) => m[base + 2] ? `${ident(m[base])}.${ident(m[base + 2])}` : ident(m[base]);
113
58
  /**
114
- * True when the quote at `quoteAt` opens an E-string (`E'...'`), where a
115
- * backslash escapes the next character. The preceding `E` must not itself be
116
- * part of an identifier, so `some_table'` never turns the following literal
117
- * into an E-string. Ordinary literals are left alone on purpose: with the
118
- * modern `standard_conforming_strings = on` default, `'a\'` IS a complete
119
- * string. Mirrors the same-named helper in `migrate.ts`; kept local so this
120
- * module stays a pure leaf with no CLI imports of its own.
59
+ * Filler between an `ALTER TABLE <name>` head and the sub-action keyword that
60
+ * follows it, for the multi-action forms.
61
+ *
62
+ * NOT `[\s\S]*?`: that lets the lazy walk stop INSIDE a quoted identifier,
63
+ * because a quoted name is kept verbatim in the stripped text. `ALTER TABLE t
64
+ * ADD COLUMN "drop me" int` was flagged `drop-column` on `t.me`, a pure false
65
+ * positive on a statement that adds data and removes none. Consuming either a
66
+ * WHOLE quoted identifier or a single non-quote character means the scan can
67
+ * never begin a keyword match part-way through a quoted name.
121
68
  */
122
- function isEscapeStringPrefix(sql, quoteAt) {
123
- const prev = sql[quoteAt - 1];
124
- if (prev !== 'E' && prev !== 'e')
125
- return false;
126
- const before = sql[quoteAt - 2];
127
- return before === undefined || !/[A-Za-z0-9_$"]/.test(before);
128
- }
129
- /** Unquote a "quoted" identifier for display. */
130
- const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
131
- const IDENT = String.raw `("[^"]+"|[a-zA-Z_][\w$]*)(\.("[^"]+"|[a-zA-Z_][\w$]*))?`;
69
+ const OUTSIDE_QUOTES = '(?:"[^"]*"|[^"])*?';
132
70
  /** Ordered rules, first match per statement wins. */
133
71
  const RULES = [
134
72
  {
@@ -157,6 +95,17 @@ const RULES = [
157
95
  regex: new RegExp(String.raw `^DROP\s+OWNED\s+BY\s+${IDENT}`, 'i'),
158
96
  target: (m) => ident(m[1]),
159
97
  },
98
+ {
99
+ // `DROP <object> ... CASCADE` on anything OTHER than a table. Without
100
+ // CASCADE these are safe: Postgres refuses the drop while a dependency
101
+ // exists. With it, the dependents go too, and a dependent of a TYPE or a
102
+ // DOMAIN is typically a COLUMN, so `DROP TYPE order_status CASCADE`
103
+ // silently removes `orders.status` and every value in it. The presence of
104
+ // CASCADE is therefore the whole rule, not an extra detail.
105
+ kind: 'drop-cascade',
106
+ regex: new RegExp(String.raw `^DROP\s+(TYPE|DOMAIN|EXTENSION|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|AGGREGATE)\s+(IF\s+EXISTS\s+)?${IDENT}${OUTSIDE_QUOTES}\bCASCADE\b`, 'i'),
107
+ target: (m) => `${m[1].toUpperCase()} ${qualified(m, 3)}`,
108
+ },
160
109
  {
161
110
  kind: 'truncate',
162
111
  regex: new RegExp(String.raw `^TRUNCATE\s+(TABLE\s+)?(ONLY\s+)?${IDENT}`, 'i'),
@@ -167,12 +116,19 @@ const RULES = [
167
116
  // column and its data exactly like the spelled-out form. The lookahead
168
117
  // excludes the other `DROP <thing>` sub-actions, none of which lose rows.
169
118
  kind: 'drop-column',
170
- regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bDROP\s+(?!CONSTRAINT\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b)(COLUMN\s+)?(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
119
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}${OUTSIDE_QUOTES}\bDROP\s+(?!CONSTRAINT\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b)(COLUMN\s+)?(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
171
120
  target: (m) => `${ident(m[3])}.${ident(m[8])}`,
172
121
  },
122
+ {
123
+ // A detached partition keeps its rows, but they leave the parent table:
124
+ // every query against the parent stops seeing them the moment this runs.
125
+ kind: 'detach-partition',
126
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}\s+DETACH\s+PARTITION\s+${IDENT}`, 'i'),
127
+ target: (m) => `${qualified(m, 3)}.${qualified(m, 6)}`,
128
+ },
173
129
  {
174
130
  kind: 'alter-column-type',
175
- regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bALTER\s+(COLUMN\s+)?${IDENT}\s+(SET\s+DATA\s+)?TYPE\b`, 'i'),
131
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}${OUTSIDE_QUOTES}\bALTER\s+(COLUMN\s+)?${IDENT}\s+(SET\s+DATA\s+)?TYPE\b`, 'i'),
176
132
  target: (m) => `${ident(m[3])}.${ident(m[7])}`,
177
133
  },
178
134
  {
@@ -183,7 +139,7 @@ const RULES = [
183
139
  {
184
140
  // MERGE's DELETE action removes rows from the target table.
185
141
  kind: 'merge-delete',
186
- regex: new RegExp(String.raw `^MERGE\s+INTO\s+(ONLY\s+)?${IDENT}\b[\s\S]*?\bTHEN\s+DELETE\b`, 'i'),
142
+ regex: new RegExp(String.raw `^MERGE\s+INTO\s+(ONLY\s+)?${IDENT}\b${OUTSIDE_QUOTES}\bTHEN\s+DELETE\b`, 'i'),
187
143
  target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
188
144
  },
189
145
  {
@@ -194,6 +150,25 @@ const RULES = [
194
150
  target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
195
151
  also: (stmt) => !hasTopLevelWhere(stmt),
196
152
  },
153
+ // Renames come LAST: they destroy no data, so any statement that is BOTH a
154
+ // rename and a data-loss operation should report the data loss instead.
155
+ {
156
+ // `ALTER TABLE t RENAME TO u`. Nothing is lost, but every query, view,
157
+ // function, and application reference to `t` breaks the moment it lands,
158
+ // and it is the second half of the sanctioned backfill swap, so an operator
159
+ // confirming a migration deserves to see it in the inventory.
160
+ kind: 'rename',
161
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}\s+RENAME\s+TO\s+${IDENT}`, 'i'),
162
+ target: (m) => qualified(m, 3),
163
+ },
164
+ {
165
+ // `ALTER TABLE t RENAME [COLUMN] old TO new`. The lookahead keeps the
166
+ // table-rename form (handled above) and `RENAME CONSTRAINT` (which breaks
167
+ // nothing a query can name) out of this rule.
168
+ kind: 'rename',
169
+ regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}\s+RENAME\s+(COLUMN\s+)?(?!TO\b|CONSTRAINT\b)${IDENT}\s+TO\s+${IDENT}`, 'i'),
170
+ target: (m) => `${qualified(m, 3)}.${qualified(m, 7)}`,
171
+ },
197
172
  ];
198
173
  /** True when the statement has a `WHERE` outside every parenthesized group. */
199
174
  function hasTopLevelWhere(stmt) {
@@ -280,25 +255,79 @@ function closingParenIndex(stmt, openAt) {
280
255
  }
281
256
  return stmt.length;
282
257
  }
258
+ /**
259
+ * An `EXPLAIN` prefix, either the parenthesized option list or the bare keyword
260
+ * form. Used to answer one question: does this EXPLAIN actually RUN the
261
+ * statement it wraps?
262
+ */
263
+ const EXPLAIN_PREFIX = /^EXPLAIN\s*(?:\(([^)]*)\)|((?:(?:ANALYZE|ANALYSE|VERBOSE)\s+)*))/i;
264
+ /**
265
+ * `EXPLAIN ANALYZE <DML>` EXECUTES the statement, it does not merely plan it.
266
+ * `EXPLAIN ANALYZE DELETE FROM t` empties `t`, and the guard used to see a
267
+ * statement whose head was `EXPLAIN` and match no rule at all. Returned is the
268
+ * inner statement when the EXPLAIN executes, otherwise the statement unchanged
269
+ * (a plain `EXPLAIN DELETE ...` only plans, and must NOT be flagged).
270
+ *
271
+ * `ANALYZE false` / `ANALYZE off` is treated as executing even though it does
272
+ * not: this module's asymmetry is that a false positive costs a confirmation
273
+ * prompt while a false negative costs data, and spelling out boolean option
274
+ * values here would trade the cheap error for the expensive one.
275
+ */
276
+ function stripExecutingExplain(stmt) {
277
+ const m = EXPLAIN_PREFIX.exec(stmt);
278
+ if (!m)
279
+ return stmt;
280
+ const options = m[1] ?? m[2] ?? '';
281
+ if (!/\bANALY[SZ]E\b/i.test(options))
282
+ return stmt;
283
+ return stmt.slice(m[0].length).trimStart();
284
+ }
283
285
  /** Statements whose dollar-quoted body is executable SQL rather than data. */
284
286
  const PROCEDURAL_STATEMENT = /^(DO\b|CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b)/i;
285
287
  /**
286
- * Candidate fragments inside a procedural body (a `DO $$ ... $$` block or a
287
- * function source). The body's own string literals are NOT stripped here: the
288
- * whole point is dynamic SQL, whose payload lives in a literal
289
- * (`EXECUTE 'DROP TABLE users'`). Rules are anchored, so every keyword-leading
290
- * position in the body is offered as its own candidate. This deliberately
291
- * over-reports (a body that merely mentions "drop table" in a message string is
292
- * flagged) in keeping with the module's false-positives-only asymmetry.
288
+ * Candidate fragments inside a procedural body (a `DO $$ ... $$` block, a
289
+ * function source, or a PG14+ `BEGIN ATOMIC` body). The body's own string
290
+ * literals are NOT stripped: the whole point is dynamic SQL, whose payload
291
+ * lives in a literal (`EXECUTE 'DROP TABLE users'`). Rules are anchored, so
292
+ * every keyword-leading position in the body is offered as its own candidate.
293
+ * This deliberately over-reports (a body that merely mentions "drop table" in a
294
+ * message string is flagged) in keeping with the module's
295
+ * false-positives-only asymmetry.
296
+ *
297
+ * Comments come out via the SHARED tokenizer, never a regex. The pair that used
298
+ * to do it here, `/\/\*[\s\S]*?\*\//g` and `/--[^\n]*\/g`, was the exact
299
+ * hand-written lexer the tokenizer was written to delete, still in place one
300
+ * level down and on the path that exists specifically to catch dynamic SQL.
301
+ * Neither pattern nests and neither respects string literals, so ONE earlier
302
+ * literal containing `--` or an unclosed `/*` blanked every destructive
303
+ * statement after it. Both of these reported an empty inventory and dropped the
304
+ * table on PostgreSQL 16.14:
305
+ *
306
+ * DO $$ DECLARE s text := 'x --'; BEGIN EXECUTE 'DROP TABLE users'; END $$;
307
+ * DO $$ DECLARE s text := 'a /*'; BEGIN EXECUTE 'DROP TABLE users';
308
+ * RAISE NOTICE '% b *\/', s; END $$;
309
+ *
310
+ * The `--` shape is the worse of the two because it is reachable by ACCIDENT:
311
+ * any single-line body whose earlier literal holds a `--` (a date range, a
312
+ * separator, a placeholder) hides everything that follows it.
313
+ *
314
+ * Tokenizing also bounds each candidate at its own statement instead of at the
315
+ * end of the body, so a later `WHERE` can no longer talk the `update-without-
316
+ * where` rule out of an earlier unrestricted UPDATE.
293
317
  */
294
318
  function proceduralCandidates(body) {
295
- const withoutComments = body.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
296
319
  const out = [];
297
- const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
298
- let m = re.exec(withoutComments);
299
- while (m !== null) {
300
- out.push(withoutComments.slice(m.index));
301
- m = re.exec(withoutComments);
320
+ for (const statement of tokenizeSql(body)) {
321
+ // `code` is the statement with comments gone and everything else verbatim.
322
+ // Literals have to survive: they are where dynamic SQL keeps its payload,
323
+ // and `stripped` would have emptied exactly them.
324
+ const text = statement.code;
325
+ const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
326
+ let m = re.exec(text);
327
+ while (m !== null) {
328
+ out.push(text.slice(m.index));
329
+ m = re.exec(text);
330
+ }
302
331
  }
303
332
  return out;
304
333
  }
@@ -308,43 +337,122 @@ function proceduralCandidates(body) {
308
337
  */
309
338
  export function scanDestructiveSql(sql) {
310
339
  const found = [];
311
- const cleaned = stripCommentsAndStrings(sql);
312
- let offset = 0;
313
- for (const rawStmt of cleaned.text.split(';')) {
314
- const start = offset;
315
- const end = offset + rawStmt.length;
316
- offset = end + 1; // the ';' consumed by split
317
- const stmt = rawStmt.trim();
340
+ for (const statement of tokenizeSql(sql)) {
341
+ if (statement.commentOnly)
342
+ continue;
343
+ const stmt = statement.stripped;
318
344
  if (!stmt)
319
345
  continue;
346
+ // `EXPLAIN ANALYZE` is a wrapper that RUNS its argument, so the rules are
347
+ // matched against what it wraps. `display` keeps the EXPLAIN, so the
348
+ // inventory shows the operator the statement they actually wrote.
349
+ const body = stripExecutingExplain(stmt);
320
350
  // Top level, then data-modifying CTEs, then any procedural body this
321
351
  // statement blanked. First match per statement wins, as before.
322
352
  const display = stmt.replace(/\s+/g, ' ');
323
353
  const candidates = [
324
- stripLeadingCtes(stmt),
325
- ...cteSubstatements(stmt),
354
+ stripLeadingCtes(body),
355
+ ...cteSubstatements(body),
326
356
  ].map((text) => ({
327
357
  text,
328
358
  display,
329
359
  }));
330
360
  // Only a DO block / routine body is procedural SQL. A dollar-quoted literal
331
361
  // used as DATA (`INSERT ... VALUES ($$DELETE FROM x$$)`) stays a literal.
362
+ // The tokenizer hands back each statement's OWN blocks, so which body
363
+ // belongs to which statement is no longer an offset calculation that can
364
+ // disagree with the statement split.
332
365
  const procedural = PROCEDURAL_STATEMENT.test(stmt);
333
- for (const block of procedural ? cleaned.blocks : []) {
334
- if (block.at < start || block.at >= end)
335
- continue;
336
- for (const text of proceduralCandidates(block.body)) {
366
+ const proceduralTexts = [];
367
+ for (const block of procedural ? statement.blocks : []) {
368
+ for (const text of proceduralCandidates(block)) {
337
369
  // The body was blanked in `display`, so name the fragment that matched.
338
370
  candidates.push({ text, display: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]` });
371
+ proceduralTexts.push(text);
339
372
  }
340
373
  }
374
+ let matched = false;
341
375
  for (const candidate of candidates) {
342
376
  const hit = matchRules(candidate.text);
343
377
  if (!hit)
344
378
  continue;
345
379
  found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
380
+ matched = true;
381
+ break;
382
+ }
383
+ if (matched)
384
+ continue;
385
+ // Nothing matched a rule. Inside a PROCEDURAL body that is not the end of
386
+ // the question, because the rules all need a parseable object name and
387
+ // dynamic SQL does not have one until it runs. All three of these execute a
388
+ // real `DROP TABLE` on PostgreSQL 16 and reported NOTHING:
389
+ //
390
+ // DO $$ BEGIN EXECUTE format('DROP TABLE %I', 'users'); END $$;
391
+ // DO $$ BEGIN EXECUTE 'DROP ' || 'TABLE users'; END $$;
392
+ // DO $$ BEGIN EXECUTE 'DROP TABLE ' || quote_ident('users'); END $$;
393
+ //
394
+ // A clean inventory for a file that drops a table is the exact failure this
395
+ // module exists to prevent: the operator confirms what they were shown, and
396
+ // the unlisted statement runs under that confirmation. So report it with an
397
+ // explicit unknown target and let them decide.
398
+ //
399
+ // Gated on evidence of RUNTIME ASSEMBLY rather than on the verb alone, and
400
+ // that gate is complete rather than heuristic: a destructive statement whose
401
+ // object name is written out literally already matches a rule above, so the
402
+ // only way to be dynamic is to concatenate or format. Without the gate,
403
+ // `RAISE NOTICE 'DROP the mic'` would prompt, and a guard that fires on
404
+ // prose teaches operators to confirm without reading, which costs more than
405
+ // it saves.
406
+ for (const text of proceduralTexts) {
407
+ const kind = dynamicDestructiveKind(text);
408
+ if (!kind)
409
+ continue;
410
+ found.push({
411
+ statement: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]`,
412
+ kind,
413
+ target: DYNAMIC_TARGET,
414
+ });
346
415
  break;
347
416
  }
348
417
  }
349
418
  return found;
350
419
  }
420
+ /** Shown in place of an object name that does not exist until the block runs. */
421
+ export const DYNAMIC_TARGET = '<name assembled at run time>';
422
+ /** `||`, `format(...)`, or a `quote_*` helper: the ways a body builds SQL. */
423
+ const DYNAMIC_ASSEMBLY = /\|\||\bformat\s*\(|\bquote_(?:ident|literal|nullable)\s*\(|%[IsL]/;
424
+ /**
425
+ * The kind a runtime-assembled procedural fragment should be reported as, or
426
+ * `null` when it is not dynamic (so a rule already had its chance) or its verb
427
+ * is not one that destroys data on its own.
428
+ *
429
+ * `ALTER` and `UPDATE` are deliberately absent even though
430
+ * {@link proceduralCandidates} collects them: their destructive forms are
431
+ * narrow (`ALTER COLUMN ... TYPE`, an `UPDATE` with no `WHERE`) and neither is
432
+ * decidable from a fragment whose tail is a runtime expression, so including
433
+ * them would flag every dynamic `UPDATE ... WHERE` in the file.
434
+ */
435
+ function dynamicDestructiveKind(text) {
436
+ if (!DYNAMIC_ASSEMBLY.test(text))
437
+ return null;
438
+ if (/^DROP\s+TABLE\b/i.test(text))
439
+ return 'drop-table';
440
+ if (/^DROP\s+SCHEMA\b/i.test(text))
441
+ return 'drop-schema';
442
+ if (/^DROP\s+DATABASE\b/i.test(text))
443
+ return 'drop-database';
444
+ if (/^DROP\s+MATERIALIZED\s+VIEW\b/i.test(text))
445
+ return 'drop-matview';
446
+ // A bare `DROP` whose object keyword is itself part of the runtime expression
447
+ // (`'DROP ' || 'TABLE users'`). Deliberately NOT reported as `drop-cascade`:
448
+ // that label claims dependent objects go too, which would be a factual claim
449
+ // about the operator's migration that we cannot support. Being alarming is
450
+ // fine here; being wrong is not.
451
+ if (/^DROP\b/i.test(text))
452
+ return 'dynamic-destructive';
453
+ if (/^TRUNCATE\b/i.test(text))
454
+ return 'truncate';
455
+ if (/^DELETE\b/i.test(text))
456
+ return 'delete';
457
+ return null;
458
+ }
@@ -393,10 +393,16 @@ export declare function initEnvNotice(input: {
393
393
  * database (unless `--no-db`), and emit (a) a Markdown resolution report and
394
394
  * (b) a typed `prisma-map.ts` name map next to the generated client.
395
395
  *
396
- * NOTE: within THIS command `--schema` names the Prisma schema FILE (not the
397
- * Postgres namespace, which the rest of the CLI's `--schema` means). The
398
- * Postgres namespace is `public` here; multi-schema (`@@schema`) is unsupported
399
- * in v1 and listed as a parser note in the report.
396
+ * NOTE: within THIS command `--schema` names the Prisma schema FILE, or the
397
+ * DIRECTORY of a multi-file schema (Prisma >= 5.15), not the Postgres namespace
398
+ * that the rest of the CLI's `--schema` means. The Postgres namespace is
399
+ * `public` here.
400
+ *
401
+ * Multi-schema (`@@schema`) is unsupported: the parser emits a warning naming
402
+ * the attribute, which lands in the report's "Parser notes" section, and two
403
+ * models that resolve to the same bare table name are both marked UNRESOLVED
404
+ * rather than silently collapsed (see flagDuplicateTables in prisma-resolve.ts).
405
+ * This JSDoc used to claim the note existed before either half was written.
400
406
  */
401
407
  /** Outcome of {@link resolveMigrateFromPrismaUrl}. */
402
408
  export interface MigrateFromPrismaUrl {
@@ -420,6 +426,17 @@ export interface MigrateFromPrismaUrl {
420
426
  * pass on every run.
421
427
  */
422
428
  export declare function resolveMigrateFromPrismaUrl(configUrl: string | undefined, ast: Pick<PrismaSchemaAst, 'datasources'>, env: Record<string, string | undefined>): MigrateFromPrismaUrl;
429
+ /**
430
+ * Read a Prisma schema from a FILE or from a multi-file schema DIRECTORY
431
+ * (Prisma >= 5.15, GA). A directory used to reach `readFileSync` unchanged and
432
+ * die with a raw `EISDIR`, which is a stack trace rather than an answer.
433
+ *
434
+ * Prisma treats every `.prisma` file under the directory as one logical schema,
435
+ * so they are concatenated in sorted order (stable across machines) and parsed
436
+ * as a single source. Line numbers in any parse error then refer to that
437
+ * concatenation, so each file is introduced by a comment naming it.
438
+ */
439
+ export declare function readPrismaSchemaSource(path: string): string;
423
440
  export declare function buildMigrateDeployOptions(args: CliArgs): {
424
441
  allowDrift: boolean;
425
442
  allowDestructive: true;