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
package/README.md CHANGED
@@ -8,6 +8,8 @@ Turbine compiles typed queries straight to SQL. There is no query engine, no WAS
8
8
  npm install turbine-orm
9
9
  ```
10
10
 
11
+ Turbine is **pre-1.0** (`0.x`). Which surfaces hold steady across minors, which are Experimental, and what has to be true before 1.0: [STABILITY.md](STABILITY.md).
12
+
11
13
  **Docs: [turbineorm.dev](https://turbineorm.dev)** · [Quick Start](https://turbineorm.dev/quickstart) · [Why Turbine](https://turbineorm.dev/why-turbine) · [API Reference](https://turbineorm.dev/queries) · [Relations](https://turbineorm.dev/relations) · [AI Agents](https://turbineorm.dev/ai-agents) · [Benchmarks](https://turbineorm.dev/benchmarks) · [Errors](https://turbineorm.dev/errors)
12
14
 
13
15
  **Contents:** [Why Turbine](#why-turbine) · [Benchmarks](#benchmarks) · [Quick Start](#quick-start) · [Queries](#queries) · [Built for agents](#built-for-agents) · [Safety tooling](#safety-tooling) · [Postgres features](#postgres-features) · [Serverless and edge](#serverless-and-edge) · [Database engines](#database-engines) · [From scratch, and forkable](#from-scratch-and-forkable) · [Comparison](#comparison) · [Limitations](#limitations) · [Requirements](#requirements) · [Contributing](#contributing)
@@ -16,7 +18,7 @@ npm install turbine-orm
16
18
 
17
19
  Six reasons, each with the mechanism that makes it true:
18
20
 
19
- 1. **One dependency.** `dependencies` is `{ "pg": "^8.13.1" }`. No engine binary, no WASM compiler, no adapter packages in lockstep. The optional engines (SQLite, MySQL, SQL Server, PowDB) are peer dependencies or Node builtins you install only if you use them.
21
+ 1. **One dependency.** `dependencies` is `{ "pg": "^8.13.1" }`. No engine binary, no WASM compiler, no adapter packages in lockstep. The optional engines (SQLite, MySQL, SQL Server, PowDB, all tiered Experimental in [STABILITY.md](STABILITY.md)) are peer dependencies or Node builtins you install only if you use them.
20
22
  2. **Written from scratch.** Turbine is not a layer over Knex or a query-builder library. Query compilation is plain string building with an FNV-1a shape fingerprint into a bounded LRU of SQL templates, so there is no plan cache to size and no compiler running on your event loop.
21
23
  3. **Nested relations in one statement.** A `with` clause compiles to correlated `json_agg` subqueries, so users with posts with comments is one round trip, typed end to end: `users[0].posts[0].comments[0].author.name` autocompletes with no annotation.
22
24
  4. **Close to raw SQL.** In the last published run, Turbine's overhead over a hand-written `pg` control was 1.08x by geometric mean. The table is below; the losses are stated with the wins.
@@ -58,6 +60,7 @@ Reproduce it: `cd benchmarks && npm install && npx prisma generate && DATABASE_U
58
60
  ```bash
59
61
  npm install turbine-orm
60
62
  npm install --save-dev tsx # the CLI loads .ts config/schema files via tsx
63
+ npm pkg set type=module # the snippet below uses top-level await: ESM, or save it as a .mts file
61
64
 
62
65
  npx turbine init --url postgres://user:pass@localhost:5432/mydb
63
66
  npx turbine generate # introspect the DB, emit a typed client
@@ -103,9 +106,9 @@ const users = await db.users.findMany({
103
106
  // users[0].posts[0].comments is typed, and this was one SQL statement
104
107
  ```
105
108
 
106
- Per-relation `where`, `orderBy`, `limit`, `select`, and `omit` work at every depth. Many-to-many junction tables are auto-detected during `generate`, and self-referencing FKs give you parent and children relations. Relation filters (`some` / `every` / `none`) filter parents by their children.
109
+ Per-relation `where`, `orderBy`, `limit`, `select`, and `omit` work at every depth. Many-to-many junction tables are auto-detected during `generate`, and a self-referencing FK (`users.manager_id` referencing `users.id`) gives you both directions, named `user` (to-one) and `users` (to-many) by default and renameable in `turbine.config.ts`. Relation filters (`some` / `every` / `none`) filter parents by their children.
107
110
 
108
- Four load strategies produce identical rows: `join` (one statement), `batched` (one flat follow-up per relation), `flatten` (LEFT JOIN for eligible to-one relations), and `auto` (the default: the join plan, falling back to batched per relation when the correlation column has no covering index). A differential fuzz suite holds the strategies to byte-identical output. Details: [turbineorm.dev/relations](https://turbineorm.dev/relations).
111
+ Four load strategies produce identical rows: `join` (one statement), `batched` (one flat follow-up per relation), `flatten` (LEFT JOIN for eligible to-one relations), and `auto` (the default: the join plan, falling back to batched per relation when the correlation column has no covering index). A differential fuzz suite holds `join`, `batched` and `auto` to byte-identical output on every release and nightly; `flatten` is held to the same parity by its own deterministic suite (`src/test/flatten-parity.integration.test.ts`). Details: [turbineorm.dev/relations](https://turbineorm.dev/relations).
109
112
 
110
113
  ### Writes, including atomic operators
111
114
 
@@ -214,7 +217,7 @@ try {
214
217
  }
215
218
  ```
216
219
 
217
- Every error extends `TurbineError` with a stable code (`TURBINE_E001` through `E018`) and a `docsUrl`. Error messages carry keys, never values: a `NotFoundError` says `where: { id, email }` without printing the email, so errors are safe to forward to a tracker without a scrubbing rule. Retryable failures (`DeadlockError`, `SerializationFailureError`) expose `isRetryable: true` as a typed const. Full table: [turbineorm.dev/errors](https://turbineorm.dev/errors).
220
+ Every error Turbine raises extends `TurbineError` with a stable code (`TURBINE_E001` through `E018`) and a `docsUrl`; a driver error whose SQLSTATE Turbine does not map stays the driver's own error, carrying that SQLSTATE on `.code`. Error messages carry keys, never values: a `NotFoundError` says `where: { id, email }` without printing the email, so errors are safe to forward to a tracker without a scrubbing rule. Retryable failures (`DeadlockError`, `SerializationFailureError`) expose `isRetryable: true` as a typed const. `err.code` is the check that survives a mixed ESM/CJS module graph, where two copies of the package hold two copies of every class; since 0.78 `instanceof` works across copies too, through a cross-copy brand on every error. Full table: [turbineorm.dev/errors](https://turbineorm.dev/errors).
218
221
 
219
222
  ## Built for agents
220
223
 
@@ -269,7 +272,7 @@ Going deep on one database means the parts other ORMs push to raw SQL are typed
269
272
 
270
273
  ## Serverless and edge
271
274
 
272
- The core is driver-agnostic: hand any pg-compatible pool to `turbineHttp()` and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, or anywhere else without TCP. The main entry's import graph is held under **87 kB brotli** (edge entry under **69 kB**) with `pg` external, enforced by `size-limit` in CI at those exact numbers; run `npm run size` for the current figure.
275
+ The core is driver-agnostic: hand any pg-compatible pool to `turbineHttp()` and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, or anywhere else without TCP. The main entry's import graph is held under **91 kB brotli** (edge entry under **72 kB**) with `pg` external, enforced by `size-limit` in CI at those exact numbers; run `npm run size` for the current figure.
273
276
 
274
277
  ```typescript
275
278
  import { Pool } from '@neondatabase/serverless';
@@ -284,7 +287,7 @@ HTTP drivers cannot hold a cursor or a LISTEN connection, so `findManyStream` an
284
287
 
285
288
  ## Database engines
286
289
 
287
- Postgres is the default and primary target. The same typed API also runs on **SQLite** (Node's built-in `node:sqlite`, zero extra installs, Node ≥ 22.5), **MySQL 8** (`mysql2`), **SQL Server 2016+** (`mssql`), and **PowDB** (embedded or networked), each behind a subpath export with its driver as an optional peer:
290
+ Postgres is the default and primary target. The same typed API also runs on **SQLite** (Node's built-in `node:sqlite`, zero extra installs, Node ≥ 22.5), **MySQL 8** (`mysql2`), **SQL Server 2016+** (`mssql`), and **PowDB** (embedded or networked), each behind a subpath export with its driver as an optional peer. All four are **Experimental** in the [stability policy](STABILITY.md): the query API they expose is the shared one, but each engine's capability boundaries and factory options may still change in a minor release.
288
291
 
289
292
  ```bash
290
293
  npm install turbine-orm # SQLite needs nothing else
@@ -318,7 +321,7 @@ const schema = defineSchema({
318
321
  },
319
322
  posts: {
320
323
  id: { type: 'serial', primaryKey: true },
321
- // "table.column" this is what makes `with: { posts: true }` work below.
324
+ // "table.column": this is what makes `with: { posts: true }` work below.
322
325
  userId: { type: 'integer', notNull: true, references: 'users.id' },
323
326
  title: { type: 'text', notNull: true },
324
327
  },
@@ -356,11 +359,11 @@ It is also built to be extended rather than wrapped. All SQL generation routes t
356
359
  |---|---|---|---|---|
357
360
  | **Engine / runtime** | No engine binary (`pg` only) | Client + TS/WASM query compiler | No engine | No engine |
358
361
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + required driver adapter | 0 | 0 |
359
- | **Main bundle (brotli)** | under 87 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
362
+ | **Main bundle (brotli)** | under 91 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
360
363
  | **Studio** | Read-only by default | Full CRUD, cloud-hosted | Full CRUD; [Gateway](https://gateway.drizzle.team/) self-hosted, free | None |
361
364
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
362
365
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
363
- | **Edge runtime** | One import swap, under 69 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native |
366
+ | **Edge runtime** | One import swap, under 72 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native |
364
367
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
365
368
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
366
369
  | **Nested relations** | 1 query, deep type inference | 1 query per relation by default; single-query `relationJoins` is Preview | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
@@ -406,6 +409,7 @@ Stated so you do not find out three weeks in:
406
409
  - Node.js ≥ 20 (the SQLite engine needs ≥ 22.5 for `node:sqlite`)
407
410
  - PostgreSQL ≥ 14 tested; CI runs the integration suite against PostgreSQL 14, 15, 16, and 17
408
411
  - ESM and CommonJS both supported
412
+ - `@types/node` in `devDependencies` when you typecheck with `skipLibCheck: false`: the `turbine-orm/sqlite` engine and the `turbine-orm/cli` declarations reference Node's own types (`node:sqlite`, `NodeJS`)
409
413
 
410
414
  ## Contributing
411
415
 
@@ -230,5 +230,11 @@ export declare function connectionStringHasPassword(connectionString: string): b
230
230
  * `.env` that holds the real value. The refusal lives here rather than at the
231
231
  * call site so no future caller can reintroduce the leak by passing the raw
232
232
  * `--url` through.
233
+ *
234
+ * `schema` is the RESOLVED Postgres schema (`--schema` or the default), written
235
+ * as the config's `schema` value. `init --schema app` probes `app` and then
236
+ * used to write `schema: 'public'` regardless, so the next documented step,
237
+ * `push`, diffed the starter schema against the wrong namespace and proposed
238
+ * dropping every column it found there.
233
239
  */
234
- export declare function configTemplate(connectionString?: string): string;
240
+ export declare function configTemplate(connectionString?: string, schema?: string): string;
@@ -301,9 +301,18 @@ function connectionStringHasPassword(connectionString) {
301
301
  * `.env` that holds the real value. The refusal lives here rather than at the
302
302
  * call site so no future caller can reintroduce the leak by passing the raw
303
303
  * `--url` through.
304
+ *
305
+ * `schema` is the RESOLVED Postgres schema (`--schema` or the default), written
306
+ * as the config's `schema` value. `init --schema app` probes `app` and then
307
+ * used to write `schema: 'public'` regardless, so the next documented step,
308
+ * `push`, diffed the starter schema against the wrong namespace and proposed
309
+ * dropping every column it found there.
304
310
  */
305
- function configTemplate(connectionString) {
311
+ function configTemplate(connectionString, schema = 'public') {
306
312
  const inlineUrl = connectionString && !connectionStringHasPassword(connectionString) ? connectionString : undefined;
313
+ // Same delimiter rule as the URL below: a schema name is an arbitrary
314
+ // identifier, and a quote in it must not end the TS string early.
315
+ const schemaLiteral = schema.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
307
316
  // Single quotes are the string delimiter in the emitted TS, so a connection
308
317
  // string containing one would otherwise produce a config file that does not
309
318
  // parse (a password-free URL can still carry a quote in a query parameter).
@@ -324,7 +333,7 @@ ${urlLine}
324
333
  out: './generated/turbine',
325
334
 
326
335
  /** Postgres schema to introspect (default: public) */
327
- schema: 'public',
336
+ schema: '${schemaLiteral}',
328
337
 
329
338
  /** Tables to exclude from generation */
330
339
  // exclude: ['_migrations', '_sessions'],
@@ -28,7 +28,7 @@
28
28
  * migration runner. Two lexers is how this guard came to disagree with the
29
29
  * executor about what a file contained (see that module's header).
30
30
  */
31
- export type DestructiveKind = 'drop-table' | 'drop-schema' | 'drop-database' | 'drop-owned' | 'drop-matview' | 'drop-column' | 'drop-cascade' | 'detach-partition' | 'truncate' | 'delete' | 'update-without-where' | 'alter-column-type' | 'merge-delete' | 'rename' | 'dynamic-destructive';
31
+ export type DestructiveKind = 'drop-table' | 'drop-schema' | 'drop-database' | 'drop-owned' | 'drop-matview' | 'drop-column' | 'drop-cascade' | 'detach-partition' | 'truncate' | 'delete' | 'update-without-where' | 'alter-column-type' | 'merge-delete' | 'rename' | 'rewrite-rule' | 'dynamic-destructive' | 'dynamic-unclassified';
32
32
  export interface DestructiveStatement {
33
33
  /** The offending SQL statement (trimmed, possibly long, display truncated) */
34
34
  statement: string;
@@ -49,7 +49,9 @@ exports.DESTRUCTIVE_KIND_LABEL = {
49
49
  'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
50
50
  'merge-delete': 'deletes matched rows (MERGE ... THEN DELETE)',
51
51
  rename: 'renames a table or column, every query and view referencing the old name breaks',
52
+ 'rewrite-rule': 'installs a rewrite rule that runs a destructive statement every time a later ordinary statement matches it',
52
53
  'dynamic-destructive': 'runs destructive SQL assembled at run time, what it destroys cannot be known without running it',
54
+ 'dynamic-unclassified': 'runs SQL assembled at run time whose statement the scanner cannot read; confirm what it does yourself',
53
55
  };
54
56
  /** Unquote a "quoted" identifier for display. */
55
57
  const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
@@ -154,6 +156,25 @@ const RULES = [
154
156
  target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
155
157
  also: (stmt) => !hasTopLevelWhere(stmt),
156
158
  },
159
+ {
160
+ // `CREATE RULE r AS ON INSERT TO t DO INSTEAD DELETE FROM u`. The rule
161
+ // destroys nothing when it is created, which is why it passed: the DELETE
162
+ // runs later, on every ordinary INSERT into `t`, and the first one emptied
163
+ // the table. Deferred destruction is still destruction the migration
164
+ // installs. `DO ALSO` counts too (the action runs in addition), and the
165
+ // action may be a parenthesized list, whose FIRST statement decides.
166
+ kind: 'rewrite-rule',
167
+ 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'),
168
+ target: (m) => `${qualified(m, 4)} (${(m[7] ?? '').toUpperCase()} on every matching statement)`,
169
+ // An UPDATE action is judged the way a top-level UPDATE is: destructive
170
+ // without a WHERE, ordinary with one. `CREATE RULE v_upd AS ON UPDATE TO v
171
+ // DO INSTEAD UPDATE base SET n = 1 WHERE id = 2` is the standard updatable
172
+ // -view idiom and was being reported, which is a false positive on a shape
173
+ // that appears in ordinary migrations. DELETE, TRUNCATE and DROP stay
174
+ // unconditional: a DELETE inside a rule destroys on every matching
175
+ // statement whether or not it is narrowed, and the other two take no WHERE.
176
+ also: (stmt) => !/\bDO\s+(?:ALSO\s+|INSTEAD\s+)?\(?\s*UPDATE\b/i.test(stmt) || !hasTopLevelWhere(stmt),
177
+ },
157
178
  // Renames come LAST: they destroy no data, so any statement that is BOTH a
158
179
  // rename and a data-loss operation should report the data loss instead.
159
180
  {
@@ -226,6 +247,28 @@ function matchRules(candidate) {
226
247
  }
227
248
  return null;
228
249
  }
250
+ /**
251
+ * The DML inside `COPY (DELETE FROM t RETURNING *) TO ...`.
252
+ *
253
+ * The same trick as a data-modifying CTE, through a different wrapper: the
254
+ * statement reads as a COPY, which nothing here treats as destructive, and the
255
+ * DELETE inside it runs and empties the table. Verified live on PostgreSQL 17
256
+ * through node-postgres, `TO STDOUT` included, so it needs no server-side file
257
+ * permission and no `TO PROGRAM`. `cteSubstatements` does not reach it because
258
+ * that one only fires on a leading `WITH`.
259
+ */
260
+ function copySubstatements(stmt) {
261
+ if (!/^COPY\s*\(/i.test(stmt))
262
+ return [];
263
+ const open = stmt.indexOf('(');
264
+ if (open === -1)
265
+ return [];
266
+ const inner = stmt.slice(open + 1, closingParenIndex(stmt, open)).trim();
267
+ // A `COPY (SELECT ...) TO` is the ordinary export form and destroys nothing.
268
+ // A leading WITH is handed on so its own CTE bodies are read by the rule
269
+ // above rather than being judged as one blob here.
270
+ return /^(?:DELETE|UPDATE|INSERT|MERGE|WITH)\b/i.test(inner) ? [inner] : [];
271
+ }
229
272
  /**
230
273
  * Data-modifying CTE bodies: `WITH d AS (DELETE FROM t ...) SELECT ...` runs a
231
274
  * real DELETE even though the statement reads as a SELECT. Each candidate is cut
@@ -298,7 +341,7 @@ function proceduralCandidates(body) {
298
341
  const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
299
342
  let m = re.exec(text);
300
343
  while (m !== null) {
301
- out.push({ text: text.slice(m.index), before: text.slice(0, m.index) });
344
+ out.push({ text: text.slice(m.index), before: text.slice(0, m.index), statementCode: text });
302
345
  m = re.exec(text);
303
346
  }
304
347
  }
@@ -326,6 +369,7 @@ function scanDestructiveSql(sql) {
326
369
  const candidates = [
327
370
  stripLeadingCtes(body),
328
371
  ...cteSubstatements(body),
372
+ ...copySubstatements(body).flatMap((inner) => [inner, ...cteSubstatements(inner)]),
329
373
  ].map((text) => ({
330
374
  text,
331
375
  display,
@@ -343,10 +387,20 @@ function scanDestructiveSql(sql) {
343
387
  candidates.push({
344
388
  text: part.text,
345
389
  display: `${display} [in block: ${part.text.replace(/\s+/g, ' ').slice(0, 60)}]`,
390
+ statementCode: part.statementCode,
346
391
  });
347
392
  proceduralParts.push(part);
348
393
  }
349
394
  }
395
+ // Sub-statements of a procedural body that some pass has already named.
396
+ // ONE body holds several statements and each is judged on its own, so a
397
+ // pass that reports one must not silence the others: `DO $$ BEGIN EXECUTE
398
+ // 'ALTER TABLE t DROP COLUMN x'; EXECUTE 'D'||'ROP TABLE victim'; END $$`
399
+ // reported only the DROP COLUMN, and the DROP TABLE then ran under a
400
+ // confirmation the operator gave for something else. Equally, the
401
+ // unclassified backstop must not re-report a statement an earlier pass
402
+ // already named, which is what this set is for.
403
+ const spokenFor = new Set();
350
404
  let matched = false;
351
405
  for (const candidate of candidates) {
352
406
  const hit = matchRules(candidate.text);
@@ -354,9 +408,15 @@ function scanDestructiveSql(sql) {
354
408
  continue;
355
409
  found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
356
410
  matched = true;
357
- break;
411
+ if (candidate.statementCode !== undefined)
412
+ spokenFor.add(candidate.statementCode);
413
+ // A statement outside a procedural body has exactly one verdict, as
414
+ // before. Inside one, keep going: the remaining sub-statements have not
415
+ // been judged yet.
416
+ if (!procedural)
417
+ break;
358
418
  }
359
- if (matched)
419
+ if (matched && !procedural)
360
420
  continue;
361
421
  // Nothing matched a rule. Inside a PROCEDURAL body that is not the end of
362
422
  // the question, because the rules all need a parseable object name and
@@ -379,7 +439,10 @@ function scanDestructiveSql(sql) {
379
439
  // `RAISE NOTICE 'DROP the mic'` would prompt, and a guard that fires on
380
440
  // prose teaches operators to confirm without reading, which costs more than
381
441
  // it saves.
442
+ let dynamicHit = false;
382
443
  for (const part of proceduralParts) {
444
+ if (spokenFor.has(part.statementCode))
445
+ continue;
383
446
  const kind = dynamicDestructiveKind(part);
384
447
  if (!kind)
385
448
  continue;
@@ -388,11 +451,210 @@ function scanDestructiveSql(sql) {
388
451
  kind,
389
452
  target: exports.DYNAMIC_TARGET,
390
453
  });
391
- break;
454
+ dynamicHit = true;
455
+ spokenFor.add(part.statementCode);
456
+ }
457
+ // Still nothing, in a procedural body. Every pass above needs to SEE a verb,
458
+ // and an EXECUTE whose text is assembled so that no verb is visible
459
+ // (`'D' || 'ROP TABLE t'`, `chr(68) || ...`, `reverse(...)`, an escape-
460
+ // encoded literal, a variable built across statements) walked past all of
461
+ // them and dropped the table live. The scanner cannot classify such a
462
+ // statement, and "cannot classify" must not be reported as "clean": that is
463
+ // the consent gate deciding in the author's favour on no evidence. So it
464
+ // asks, with a kind whose label says exactly that.
465
+ for (const block of procedural ? statement.blocks : []) {
466
+ for (const unreadable of unclassifiableExecutes(block, matched || dynamicHit)) {
467
+ if (spokenFor.has(unreadable.code))
468
+ continue;
469
+ spokenFor.add(unreadable.code);
470
+ found.push({
471
+ statement: `${display} [in block: ${`EXECUTE ${unreadable.expr}`.replace(/\s+/g, ' ').slice(0, 60)}]`,
472
+ kind: 'dynamic-unclassified',
473
+ target: exports.DYNAMIC_TARGET,
474
+ });
475
+ }
392
476
  }
393
477
  }
394
478
  return found;
395
479
  }
480
+ /**
481
+ * Statement openers that destroy nothing, so an assembled `EXECUTE` whose text
482
+ * visibly begins with one of them is left alone. `UPDATE` is here because its
483
+ * destructive form is the ABSENCE of a `WHERE`, which the tail of an assembled
484
+ * fragment cannot answer; flagging every dynamic `UPDATE ... WHERE` is the
485
+ * false-positive cost this module refuses to pay.
486
+ */
487
+ 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;
488
+ /**
489
+ * Openers whose rules deliberately stay SILENT on a benign form, so silence
490
+ * from them is a decision rather than a failure to parse. `ALTER TABLE ... ADD
491
+ * COLUMN` loses nothing and is not reported on purpose; a `MERGE` without a
492
+ * `THEN DELETE` likewise; a `WITH` whose CTE bodies are all reads likewise.
493
+ * Reporting these again here would contradict that silence.
494
+ */
495
+ const VERB_SILENT_WHEN_BENIGN = /^(?:ALTER|MERGE|WITH)\b/i;
496
+ /**
497
+ * Openers whose rules ALWAYS report when they can read the statement. Silence
498
+ * from one of these is not a verdict, it is the rules failing to parse, so the
499
+ * skip is conditional on an earlier pass having actually reported.
500
+ *
501
+ * The shape that made the distinction necessary: two adjacent string literals
502
+ * are ONE string in PostgreSQL, so `EXECUTE 'DROP TABLE '\n'victims'` reads as
503
+ * `DROP` to the opener test while every earlier pass declines, the literal
504
+ * rules because a quote is not an identifier and the assembly test because
505
+ * continuation is lexical and carries no `||`, `concat` or `format`. The
506
+ * opener was trusted, the statement was skipped, and the table went.
507
+ */
508
+ const VERB_ALWAYS_REPORTS = /^(?:DROP|TRUNCATE|DELETE)\b/i;
509
+ /**
510
+ * `EXECUTE` in a position where plpgsql can begin a statement.
511
+ *
512
+ * Matched against a statement's `stripped` view, so the word can never be one
513
+ * that lived inside a string literal. `GRANT`/`REVOKE EXECUTE ON FUNCTION` are
514
+ * excluded by construction: their preceding token is a verb, not one of the
515
+ * introducers here.
516
+ */
517
+ const EXECUTE_AT_STATEMENT_START = /(?:^|;|\bTHEN\b|\bELSE\b|\bLOOP\b|\bBEGIN\b|\bDECLARE\b)\s*EXECUTE\s/i;
518
+ /**
519
+ * Functions whose FIRST literal argument is the statement text (or its
520
+ * template), so the opener can be read through the call.
521
+ */
522
+ const TEMPLATE_FN = /^(?:format|replace|regexp_replace|concat|concat_ws|array_to_string)\s*\(/i;
523
+ /** The leading string literal of `text`: its raw content and whether it can hide a verb. */
524
+ function leadingLiteral(text) {
525
+ const m = /^(U&'|[EeBbXx]'|'|\$([A-Za-z_][\w]*)?\$)/.exec(text);
526
+ if (!m)
527
+ return null;
528
+ const open = m[1];
529
+ if (open.startsWith('$')) {
530
+ const close = text.indexOf(open, open.length);
531
+ if (close === -1)
532
+ return null;
533
+ return { content: text.slice(open.length, close), rest: text.slice(close + open.length), escaped: false };
534
+ }
535
+ // Single-quoted: `''` is an escaped quote inside the literal.
536
+ let i = open.length;
537
+ let content = '';
538
+ while (i < text.length) {
539
+ const ch = text[i];
540
+ if (ch === "'") {
541
+ if (text[i + 1] === "'") {
542
+ content += "'";
543
+ i += 2;
544
+ continue;
545
+ }
546
+ break;
547
+ }
548
+ content += ch;
549
+ i++;
550
+ }
551
+ if (i >= text.length)
552
+ return null;
553
+ // `E'\x44ROP'` and `U&'\0044ROP'` spell a verb the scanner cannot see; an
554
+ // escape string with no backslash in it hides nothing.
555
+ const escaped = open !== "'" && content.includes('\\');
556
+ return { content, rest: text.slice(i + 1), escaped };
557
+ }
558
+ /**
559
+ * The first word of the statement an `EXECUTE` argument would run, read as far
560
+ * as the text allows, plus whether the argument is one PLAIN literal (in which
561
+ * case the literal passes already had their chance and this pass stays out).
562
+ * `null` opener = the scanner cannot see a verb at all.
563
+ */
564
+ function executeOpener(expr) {
565
+ const text = expr.trim();
566
+ const lit = leadingLiteral(text);
567
+ if (lit) {
568
+ const word = /^\s*([A-Za-z_][\w]*)/.exec(lit.content)?.[1] ?? null;
569
+ // Plain: the literal IS the whole argument (bar an INTO / USING clause),
570
+ // spelled without escapes.
571
+ const plain = !lit.escaped && /^\s*(?:;|INTO\b|USING\b|$)/i.test(lit.rest);
572
+ return { opener: lit.escaped ? null : word, plain };
573
+ }
574
+ if (TEMPLATE_FN.test(text)) {
575
+ // Read the opener from the first literal argument that carries a word: a
576
+ // `concat_ws(' ', ...)` separator or a `format('%s', ...)` placeholder is
577
+ // not a verb, and neither is a `concat('DR', 'OP', ...)` fragment.
578
+ const inner = text.slice(text.indexOf('(') + 1);
579
+ let cursor = inner;
580
+ for (let guard = 0; guard < 16; guard++) {
581
+ const lit2 = leadingLiteral(cursor.trimStart());
582
+ if (!lit2)
583
+ break;
584
+ const word = /^\s*([A-Za-z_][\w]*)/.exec(lit2.content)?.[1];
585
+ if (word)
586
+ return { opener: lit2.escaped ? null : word, plain: false };
587
+ const comma = lit2.rest.indexOf(',');
588
+ if (comma === -1)
589
+ break;
590
+ cursor = lit2.rest.slice(comma + 1);
591
+ }
592
+ return { opener: null, plain: false };
593
+ }
594
+ // Any other function call, a parenthesised expression, or a bare variable
595
+ // (`EXECUTE s`, built across earlier statements): nothing readable.
596
+ return { opener: null, plain: false };
597
+ }
598
+ /**
599
+ * The EXECUTE argument in `body`, when it is assembled or encoded such that no
600
+ * statement verb is visible to the passes above. Returns the offending
601
+ * fragment for display, or `null` when every EXECUTE in the body either is a
602
+ * plain literal (the literal rules own it) or visibly begins with a verb some
603
+ * other rule has already judged.
604
+ */
605
+ function unclassifiableExecutes(body, verbPassesReported) {
606
+ const out = [];
607
+ for (const statement of (0, sql_statements_js_1.tokenizeSql)(body)) {
608
+ // Decide PRESENCE against `stripped`, whose literals are emptied, and read
609
+ // the ARGUMENT out of `code`, which keeps them. Reading both out of `code`
610
+ // fired on the word rather than on the statement: `RAISE NOTICE 'EXECUTE
611
+ // the plan'` and `INSERT INTO log(msg) VALUES ('EXECUTE me later')` both
612
+ // prompted, and a guard that fires on prose teaches operators to confirm
613
+ // without reading, which is the cost this module's own doctrine refuses to
614
+ // pay. `stripped` cannot contain a word that only ever appeared inside a
615
+ // literal, so it settles the question the offsets cannot.
616
+ //
617
+ // The keyword must also sit where a plpgsql STATEMENT can start, not
618
+ // merely somewhere in the text: `GRANT EXECUTE ON FUNCTION f() TO app` is
619
+ // an ordinary permission grant and was being reported as unreadable
620
+ // dynamic SQL, which fails an ordinary migration under `migrate deploy`
621
+ // where there is no terminal to confirm at. The introducers below are the
622
+ // positions plpgsql actually allows one at, since `tokenizeSql` splits on
623
+ // top-level semicolons and a body's `IF ... THEN EXECUTE ...` therefore
624
+ // arrives as one statement.
625
+ if (!EXECUTE_AT_STATEMENT_START.test(statement.stripped))
626
+ continue;
627
+ const m = /\bEXECUTE\s+([\s\S]+)$/i.exec(statement.code);
628
+ if (!m)
629
+ continue;
630
+ const expr = m[1];
631
+ const { opener, plain } = executeOpener(expr);
632
+ if (plain)
633
+ continue;
634
+ // `EXECUTE s` where `s` is a bare variable: the statement it runs was
635
+ // assembled by some EARLIER sub-statement of the same block, so when a pass
636
+ // has already named one, the operator has been told what this body does and
637
+ // a second "cannot classify" entry for the same thing is noise that
638
+ // contradicts the first. When nothing was named, this is the whole finding
639
+ // and it stands: an assignment the rules could not read leaves
640
+ // `verbPassesReported` false.
641
+ if (opener === null && verbPassesReported && /^[A-Za-z_]\w*\s*;?$/.test(expr.trim()))
642
+ continue;
643
+ if (opener !== null) {
644
+ if (HARMLESS_OPENER.test(opener))
645
+ continue;
646
+ if (VERB_SILENT_WHEN_BENIGN.test(opener))
647
+ continue;
648
+ // The remaining skip is a claim that an earlier pass already reported
649
+ // this statement, so it holds only when one actually did. See
650
+ // VERB_ALWAYS_REPORTS for the shape where none does.
651
+ if (verbPassesReported && VERB_ALWAYS_REPORTS.test(opener))
652
+ continue;
653
+ }
654
+ out.push({ code: statement.code, expr });
655
+ }
656
+ return out;
657
+ }
396
658
  /** Shown in place of an object name that does not exist until the block runs. */
397
659
  exports.DYNAMIC_TARGET = '<name assembled at run time>';
398
660
  /**
@@ -457,16 +719,41 @@ const ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quot
457
719
  const WRAPPING_ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quote_nullable|array_to_string|regexp_replace|replace)\s*\([^)]*$/i;
458
720
  /** Dynamic SQL only runs if something runs it. Scoped to the candidate's own statement. */
459
721
  const RUNS_DYNAMIC_SQL = /\bEXECUTE\b/i;
722
+ /**
723
+ * `ALTER TABLE <assembled name> DROP [COLUMN] ...`: the sub-action that loses
724
+ * rows, with the same exclusions as the static `drop-column` rule (`DROP
725
+ * CONSTRAINT` / `DEFAULT` / `NOT NULL` / `IDENTITY` / `EXPRESSION` lose none).
726
+ * Matches when the keyword `COLUMN`, an `IF EXISTS`, or a literal column name
727
+ * follows the `DROP`; a `DROP` followed by nothing literal is handled below.
728
+ * {@link OUTSIDE_QUOTES} keeps `ADD COLUMN "drop me"` out, as it does statically.
729
+ */
730
+ 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');
731
+ /** `ALTER TABLE <assembled name> ALTER [COLUMN] <col> [SET DATA] TYPE ...`. */
732
+ 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');
733
+ /** The `DROP <thing>` sub-actions of ALTER TABLE that lose no rows. */
734
+ 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');
460
735
  /**
461
736
  * The kind a runtime-assembled procedural fragment should be reported as, or
462
737
  * `null` when it is not dynamic (so a rule already had its chance) or its verb
463
738
  * is not one that destroys data on its own.
464
739
  *
465
- * `ALTER` and `UPDATE` are deliberately absent even though
466
- * {@link proceduralCandidates} collects them: their destructive forms are
467
- * narrow (`ALTER COLUMN ... TYPE`, an `UPDATE` with no `WHERE`) and neither is
468
- * decidable from a fragment whose tail is a runtime expression, so including
469
- * them would flag every dynamic `UPDATE ... WHERE` in the file.
740
+ * `ALTER TABLE` IS decided here, by the sub-action that follows the assembled
741
+ * table name, because in the shape that matters that sub-action is literal
742
+ * text: a multi-tenant loop assembles the TABLE (`'ALTER TABLE ' ||
743
+ * quote_ident(t) || ' DROP COLUMN legacy_phone'`) and writes out what it does
744
+ * to it. That statement dropped the column from every tenant table while the
745
+ * scan reported a clean inventory, and its `DROP TABLE` twin was already being
746
+ * flagged, so an operator who had seen the guard fire once would assume this
747
+ * was covered. Only the forms that lose rows are reported (`DROP [COLUMN]`,
748
+ * `ALTER COLUMN ... TYPE`), with the same exclusions as their static rules; an
749
+ * assembled `ADD COLUMN` stays silent. A `DROP` whose object is itself in the
750
+ * runtime expression (`' DROP ' || what`) is `dynamic-destructive`, the bare
751
+ * `DROP` precedent below: alarming is fine, wrong is not.
752
+ *
753
+ * `UPDATE` is still deliberately absent even though {@link proceduralCandidates}
754
+ * collects it: its destructive form is the ABSENCE of a `WHERE`, and absence
755
+ * is not decidable from a fragment whose tail is a runtime expression, so
756
+ * including it would flag every dynamic `UPDATE ... WHERE` in the file.
470
757
  */
471
758
  function dynamicDestructiveKind({ text, before }) {
472
759
  const assembled = DYNAMIC_ASSEMBLY.test(text) ||
@@ -474,6 +761,17 @@ function dynamicDestructiveKind({ text, before }) {
474
761
  (RUNS_DYNAMIC_SQL.test(before) && WRAPPING_ASSEMBLY_FN.test(before));
475
762
  if (!assembled)
476
763
  return null;
764
+ if (/^ALTER\s+TABLE\b/i.test(text)) {
765
+ if (DYNAMIC_ALTER_DROP_COLUMN.test(text))
766
+ return 'drop-column';
767
+ if (DYNAMIC_ALTER_COLUMN_TYPE.test(text))
768
+ return 'alter-column-type';
769
+ if (DYNAMIC_ALTER_DROP_HARMLESS.test(text))
770
+ return null;
771
+ if (new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\b`, 'i').test(text))
772
+ return 'dynamic-destructive';
773
+ return null;
774
+ }
477
775
  if (/^DROP\s+TABLE\b/i.test(text))
478
776
  return 'drop-table';
479
777
  if (/^DROP\s+SCHEMA\b/i.test(text))