turbine-orm 0.75.0 → 0.76.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 (78) hide show
  1. package/README.md +48 -7
  2. package/dist/cjs/cli/compile-query.d.ts +22 -2
  3. package/dist/cjs/cli/compile-query.js +49 -5
  4. package/dist/cjs/cli/config.d.ts +2 -0
  5. package/dist/cjs/cli/config.js +1 -1
  6. package/dist/cjs/cli/destructive.js +78 -43
  7. package/dist/cjs/cli/index.d.ts +95 -1
  8. package/dist/cjs/cli/index.js +609 -145
  9. package/dist/cjs/cli/mcp.js +30 -1
  10. package/dist/cjs/cli/pii-predicate-guard.d.ts +25 -0
  11. package/dist/cjs/cli/pii-predicate-guard.js +72 -12
  12. package/dist/cjs/cli/rate-limit.js +38 -1
  13. package/dist/cjs/cli/studio.js +26 -5
  14. package/dist/cjs/cli/ui.d.ts +33 -0
  15. package/dist/cjs/cli/ui.js +53 -7
  16. package/dist/cjs/client.d.ts +13 -1
  17. package/dist/cjs/client.js +1 -1
  18. package/dist/cjs/errors.d.ts +12 -1
  19. package/dist/cjs/errors.js +11 -2
  20. package/dist/cjs/generate.d.ts +26 -0
  21. package/dist/cjs/generate.js +174 -27
  22. package/dist/cjs/index.d.ts +1 -1
  23. package/dist/cjs/index.js +1 -1
  24. package/dist/cjs/introspect.d.ts +17 -0
  25. package/dist/cjs/introspect.js +100 -1
  26. package/dist/cjs/mssql.d.ts +18 -0
  27. package/dist/cjs/mssql.js +20 -1
  28. package/dist/cjs/pipeline.js +44 -6
  29. package/dist/cjs/powql.js +51 -17
  30. package/dist/cjs/query/batched-loader.js +3 -3
  31. package/dist/cjs/query/builder.js +1 -1
  32. package/dist/cjs/query/relations.d.ts +5 -0
  33. package/dist/cjs/query/relations.js +141 -69
  34. package/dist/cjs/query/utils.d.ts +13 -0
  35. package/dist/cjs/query/utils.js +16 -0
  36. package/dist/cjs/serverless.d.ts +1 -1
  37. package/dist/cjs/serverless.js +1 -1
  38. package/dist/cjs/sqlite.d.ts +33 -1
  39. package/dist/cjs/sqlite.js +84 -3
  40. package/dist/cli/compile-query.d.ts +22 -2
  41. package/dist/cli/compile-query.js +50 -6
  42. package/dist/cli/config.d.ts +2 -0
  43. package/dist/cli/config.js +1 -1
  44. package/dist/cli/destructive.js +78 -43
  45. package/dist/cli/index.d.ts +95 -1
  46. package/dist/cli/index.js +604 -147
  47. package/dist/cli/mcp.js +30 -1
  48. package/dist/cli/pii-predicate-guard.d.ts +25 -0
  49. package/dist/cli/pii-predicate-guard.js +73 -13
  50. package/dist/cli/rate-limit.js +38 -1
  51. package/dist/cli/studio.js +27 -6
  52. package/dist/cli/ui.d.ts +33 -0
  53. package/dist/cli/ui.js +51 -7
  54. package/dist/client.d.ts +13 -1
  55. package/dist/client.js +1 -1
  56. package/dist/errors.d.ts +12 -1
  57. package/dist/errors.js +11 -2
  58. package/dist/generate.d.ts +26 -0
  59. package/dist/generate.js +172 -27
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js +1 -1
  62. package/dist/introspect.d.ts +17 -0
  63. package/dist/introspect.js +98 -1
  64. package/dist/mssql.d.ts +18 -0
  65. package/dist/mssql.js +20 -1
  66. package/dist/pipeline.js +44 -6
  67. package/dist/powql.js +53 -19
  68. package/dist/query/batched-loader.js +4 -4
  69. package/dist/query/builder.js +2 -2
  70. package/dist/query/relations.d.ts +5 -0
  71. package/dist/query/relations.js +141 -70
  72. package/dist/query/utils.d.ts +13 -0
  73. package/dist/query/utils.js +15 -0
  74. package/dist/serverless.d.ts +1 -1
  75. package/dist/serverless.js +1 -1
  76. package/dist/sqlite.d.ts +33 -1
  77. package/dist/sqlite.js +85 -4
  78. package/package.json +2 -2
package/README.md CHANGED
@@ -64,7 +64,7 @@ npx turbine generate # introspect the DB, emit a typed client
64
64
  ```
65
65
 
66
66
  ```typescript
67
- import { turbine } from './generated/turbine';
67
+ import { turbine } from './generated/turbine/index.js';
68
68
 
69
69
  const db = turbine({ connectionString: process.env.DATABASE_URL });
70
70
 
@@ -79,6 +79,8 @@ await db.disconnect();
79
79
 
80
80
  `generate` writes three files to `./generated/turbine/`: entity types, runtime schema metadata, and a typed client with a `turbine()` factory. The factory is generated (it is typed against your schema), so import it from your output directory, not from `'turbine-orm'`. ESM and CommonJS both work.
81
81
 
82
+ The `/index.js` on that import is required under `moduleResolution: NodeNext` (a relative import needs an explicit extension, and there is no directory-index resolution). Under a bundler (`moduleResolution: bundler`, the Next.js and Vite default) `'./generated/turbine'` works too. `turbine generate` prints the spelling that matches your `tsconfig.json`.
83
+
82
84
  Full walkthrough, including the code-first `defineSchema` path for an empty database: [turbineorm.dev/quickstart](https://turbineorm.dev/quickstart).
83
85
 
84
86
  ## Queries
@@ -267,12 +269,12 @@ Going deep on one database means the parts other ORMs push to raw SQL are typed
267
269
 
268
270
  ## Serverless and edge
269
271
 
270
- 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 **85 kB brotli** (edge entry under **68 kB**) with `pg` external, enforced by `size-limit` in CI; run `npm run size` for the current figure.
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.
271
273
 
272
274
  ```typescript
273
275
  import { Pool } from '@neondatabase/serverless';
274
276
  import { turbineHttp } from 'turbine-orm/serverless';
275
- import { SCHEMA } from './generated/turbine/metadata';
277
+ import { SCHEMA } from './generated/turbine/metadata.js';
276
278
 
277
279
  const db = turbineHttp(new Pool({ connectionString: process.env.DATABASE_URL }), SCHEMA);
278
280
  const users = await db.table('users').findMany({ with: { posts: true }, limit: 10 });
@@ -299,6 +301,45 @@ const db = turbineSqlite(':memory:', SCHEMA);
299
301
  const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
300
302
  ```
301
303
 
304
+ That snippet assumes a Postgres database somewhere, because `SCHEMA` comes from
305
+ `turbine generate` and `generate` reads a live Postgres catalog. **If SQLite is
306
+ your only database, describe the schema in code instead.** `defineSchema` is the
307
+ same declaration `push` and `migrate` consume, and two pure functions turn it
308
+ into the DDL and the runtime metadata, with no database involved:
309
+
310
+ ```typescript
311
+ import { defineSchema, schemaDefToMetadata, schemaToSQL } from 'turbine-orm';
312
+ import { sqliteDialect, turbineSqlite } from 'turbine-orm/sqlite';
313
+
314
+ const schema = defineSchema({
315
+ users: {
316
+ id: { type: 'serial', primaryKey: true },
317
+ email: { type: 'text', notNull: true, unique: true },
318
+ },
319
+ posts: {
320
+ id: { type: 'serial', primaryKey: true },
321
+ // "table.column" — this is what makes `with: { posts: true }` work below.
322
+ userId: { type: 'integer', notNull: true, references: 'users.id' },
323
+ title: { type: 'text', notNull: true },
324
+ },
325
+ });
326
+
327
+ const db = turbineSqlite(':memory:', schemaDefToMetadata(schema));
328
+ for (const stmt of schemaToSQL(schema, { dialect: sqliteDialect })) {
329
+ await db.raw([stmt] as never);
330
+ }
331
+
332
+ const users = await db
333
+ .table('users')
334
+ .findMany({ with: { posts: true }, orderBy: { id: 'asc' }, limit: 10 });
335
+ ```
336
+
337
+ `schemaToSQL` emits SQLite DDL (including the foreign key and its index) and
338
+ `schemaDefToMetadata` derives the `posts` / `users` relations from the same
339
+ `references:`, so nested `with` works without a code-generation step. The
340
+ accessors are `db.table('users')` rather than `db.users`: the typed property
341
+ accessors are what `turbine generate` emits, and this path skips it.
342
+
302
343
  Single-statement nested `with` works on all four SQL engines (`json_agg`, `json_group_array`, `JSON_ARRAYAGG`, `FOR JSON PATH`). Postgres-only features (pgvector, LISTEN/NOTIFY, RLS `sessionContext`, true cursor streaming) throw a typed `UnsupportedFeatureError` (`TURBINE_E017`) elsewhere instead of degrading silently. The `turbine generate` / `migrate` CLI is Postgres-only. Full capability matrix and per-engine notes: [turbineorm.dev/engines](https://turbineorm.dev/engines).
303
344
 
304
345
  **Migrating from Prisma?** `turbine migrate-from-prisma` reads your `schema.prisma` and emits a typed mapping; `turbine-orm/prisma-compat` then wraps a TurbineClient in a `PrismaClient`-shaped surface, so `prisma.user.findMany({ include })` keeps working while you port. [Guide](https://turbineorm.dev/migrate-from-prisma). Coming from Drizzle: [the mapping](https://turbineorm.dev/migrate-from-drizzle).
@@ -315,11 +356,11 @@ It is also built to be extended rather than wrapped. All SQL generation routes t
315
356
  |---|---|---|---|---|
316
357
  | **Engine / runtime** | No engine binary (`pg` only) | Client + TS/WASM query compiler | No engine | No engine |
317
358
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + required driver adapter | 0 | 0 |
318
- | **Main bundle (brotli)** | under 85 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
319
- | **Studio** | Read-only by default | Full CRUD, cloud-hosted | Free; hosted Gateway paid | None |
359
+ | **Main bundle (brotli)** | under 87 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
360
+ | **Studio** | Read-only by default | Full CRUD, cloud-hosted | Full CRUD; [Gateway](https://gateway.drizzle.team/) self-hosted, free | None |
320
361
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
321
362
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
322
- | **Edge runtime** | One import swap, under 68 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native |
363
+ | **Edge runtime** | One import swap, under 69 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native |
323
364
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
324
365
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
325
366
  | **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`) |
@@ -328,7 +369,7 @@ It is also built to be extended rather than wrapped. All SQL generation routes t
328
369
  | **Vector search** | Built-in `distance` / KNN | Preview / raw | Extension API | Manual |
329
370
  | **LISTEN/NOTIFY** | `$listen` / `$notify` | None | None | None |
330
371
 
331
- Competitor columns last checked August 2026, against Prisma 7 and Drizzle 0.45. Features marked Preview may change; bundle sizes move release to release. The longer version of this argument, including what is not a reason to switch: [turbineorm.dev/why-turbine](https://turbineorm.dev/why-turbine).
372
+ Competitor columns last checked 2026-08-22, against Prisma 7 and Drizzle 0.45 (the current `latest`; 1.0 is at rc.4). Cells that make a claim about someone else's pricing or licensing carry a link to that vendor's own page, because those are the cells that go stale silently: this table said Drizzle's Gateway was paid, which was true of the old Drizzle Studio subscription and is not true of Gateway, and nothing in the repo would have caught it. Features marked Preview may change; bundle sizes move release to release. The longer version of this argument, including what is not a reason to switch: [turbineorm.dev/why-turbine](https://turbineorm.dev/why-turbine).
332
373
 
333
374
  ## Limitations
334
375
 
@@ -67,8 +67,28 @@ export declare const SEALED_POOL: PgCompatPool;
67
67
  * rule: they return values, and this tool returns no values at all.
68
68
  */
69
69
  export declare const COLUMN_NAMING_ARG_KEYS: readonly string[];
70
- /** Does this args object carry a key that names a column? */
71
- export declare function carriesColumnNamingArg(args: Record<string, unknown>): boolean;
70
+ /**
71
+ * Does this args object carry a key that names a column, AT ANY DEPTH?
72
+ *
73
+ * The depth is the whole point and it was missing until 0.76.0. This is the
74
+ * fail-closed test for the case where the PII tag file could not be read, so
75
+ * "no column-naming arg" is a claim that the query cannot be filtering on a
76
+ * hidden column. A top-level-only check makes that claim falsely:
77
+ * `{ with: { posts: { where: { secretNote: { not: null } } } } }` has no
78
+ * column-naming key at the top level and filters on a column two levels down,
79
+ * and the tool reported it as safe to compile.
80
+ *
81
+ * `with` is not itself a column-naming key (its keys are RELATION names, and
82
+ * `select` / `omit` are excluded on the guard's own rule that they return
83
+ * values, which this tool never does), but the OPTIONS inside a `with` entry
84
+ * are the full findMany surface, so the walk descends through them.
85
+ *
86
+ * It errs toward TRUE: an unrecognized object value is descended into rather
87
+ * than skipped, and the depth cap answers true rather than false. This function
88
+ * only ever gates a refusal, so a false positive costs a caller one message and
89
+ * a false negative is the disclosure it exists to prevent.
90
+ */
91
+ export declare function carriesColumnNamingArg(args: Record<string, unknown>, depth?: number): boolean;
72
92
  /** One relation reached by the query's `with` clause. */
73
93
  export interface CompiledRelation {
74
94
  /** Dotted path from the queried table, e.g. `author.org`. */
@@ -109,9 +109,47 @@ exports.COLUMN_NAMING_ARG_KEYS = [
109
109
  '_min',
110
110
  '_max',
111
111
  ];
112
- /** Does this args object carry a key that names a column? */
113
- function carriesColumnNamingArg(args) {
114
- return exports.COLUMN_NAMING_ARG_KEYS.some((key) => args[key] !== undefined);
112
+ /**
113
+ * Does this args object carry a key that names a column, AT ANY DEPTH?
114
+ *
115
+ * The depth is the whole point and it was missing until 0.76.0. This is the
116
+ * fail-closed test for the case where the PII tag file could not be read, so
117
+ * "no column-naming arg" is a claim that the query cannot be filtering on a
118
+ * hidden column. A top-level-only check makes that claim falsely:
119
+ * `{ with: { posts: { where: { secretNote: { not: null } } } } }` has no
120
+ * column-naming key at the top level and filters on a column two levels down,
121
+ * and the tool reported it as safe to compile.
122
+ *
123
+ * `with` is not itself a column-naming key (its keys are RELATION names, and
124
+ * `select` / `omit` are excluded on the guard's own rule that they return
125
+ * values, which this tool never does), but the OPTIONS inside a `with` entry
126
+ * are the full findMany surface, so the walk descends through them.
127
+ *
128
+ * It errs toward TRUE: an unrecognized object value is descended into rather
129
+ * than skipped, and the depth cap answers true rather than false. This function
130
+ * only ever gates a refusal, so a false positive costs a caller one message and
131
+ * a false negative is the disclosure it exists to prevent.
132
+ */
133
+ function carriesColumnNamingArg(args, depth = 0) {
134
+ if (depth > pii_predicate_guard_js_1.PII_GUARD_MAX_DEPTH)
135
+ return true;
136
+ for (const [key, value] of Object.entries(args)) {
137
+ if (exports.COLUMN_NAMING_ARG_KEYS.includes(key) && value !== undefined)
138
+ return true;
139
+ // `select` / `omit` name columns but return them rather than filtering on
140
+ // them, and nothing is returned here; skipping them keeps this aligned with
141
+ // cli/pii-predicate-guard.ts, which makes the same call for the same reason.
142
+ if (key === 'select' || key === 'omit')
143
+ continue;
144
+ if (value && typeof value === 'object') {
145
+ for (const entry of Array.isArray(value) ? value : [value]) {
146
+ if (entry && typeof entry === 'object' && carriesColumnNamingArg(entry, depth + 1)) {
147
+ return true;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return false;
115
153
  }
116
154
  // ---------------------------------------------------------------------------
117
155
  // Compile
@@ -307,9 +345,15 @@ function walkWith(metadata, table, withClause, depth, prefix, out) {
307
345
  // statement and has no target table of its own.
308
346
  if (key === '_count')
309
347
  continue;
310
- const relation = (0, utils_js_1.ownLookup)(table.relations, key);
311
- if (!relation)
348
+ // Resolve the caller's spelling the way the compiler does: a relation declared
349
+ // `blogPosts` is also reachable as `blog_posts` (resolveRelation, since 0.72). An
350
+ // exact-match lookup here did not fail loudly, it silently dropped the relation from
351
+ // the report, so compile_query under-counted statements and skipped the correlation
352
+ // probes for exactly the relation the caller asked about.
353
+ const resolved = (0, utils_js_1.resolveRelation)(table.relations, key);
354
+ if (!resolved)
312
355
  continue;
356
+ const relation = resolved.def;
313
357
  const path = prefix ? `${prefix}.${relation.name}` : relation.name;
314
358
  out.push({
315
359
  path,
@@ -172,6 +172,8 @@ export interface CliOverrides {
172
172
  url?: string;
173
173
  out?: string;
174
174
  schema?: string;
175
+ /** `--schema-file <path>`: the defineSchema() file, overriding config `schemaFile`. */
176
+ schemaFile?: string;
175
177
  include?: string[];
176
178
  exclude?: string[];
177
179
  importExtension?: 'js' | 'none' | 'auto';
@@ -211,7 +211,7 @@ function resolveConfig(fileConfig, overrides) {
211
211
  // `seedFile` is canonical (what the docs and `turbine init` use); `seed` is a
212
212
  // back-compat alias kept working for configs scaffolded before 0.50.
213
213
  seedFile: fileConfig.seedFile ?? fileConfig.seed,
214
- schemaFile: fileConfig.schemaFile ?? './turbine/schema.ts',
214
+ schemaFile: overrides.schemaFile ?? fileConfig.schemaFile ?? './turbine/schema.ts',
215
215
  importExtension: overrides.importExtension ?? fileConfig.importExtension ?? 'auto',
216
216
  keepColumnNames: overrides.keepColumnNames ?? fileConfig.keepColumnNames ?? false,
217
217
  legacyToManyUniques: overrides.legacyToManyUniques ?? fileConfig.legacyToManyUniques ?? false,
@@ -288,37 +288,6 @@ function stripExecutingExplain(stmt) {
288
288
  }
289
289
  /** Statements whose dollar-quoted body is executable SQL rather than data. */
290
290
  const PROCEDURAL_STATEMENT = /^(DO\b|CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b)/i;
291
- /**
292
- * Candidate fragments inside a procedural body (a `DO $$ ... $$` block, a
293
- * function source, or a PG14+ `BEGIN ATOMIC` body). The body's own string
294
- * literals are NOT stripped: the whole point is dynamic SQL, whose payload
295
- * lives in a literal (`EXECUTE 'DROP TABLE users'`). Rules are anchored, so
296
- * every keyword-leading position in the body is offered as its own candidate.
297
- * This deliberately over-reports (a body that merely mentions "drop table" in a
298
- * message string is flagged) in keeping with the module's
299
- * false-positives-only asymmetry.
300
- *
301
- * Comments come out via the SHARED tokenizer, never a regex. The pair that used
302
- * to do it here, `/\/\*[\s\S]*?\*\//g` and `/--[^\n]*\/g`, was the exact
303
- * hand-written lexer the tokenizer was written to delete, still in place one
304
- * level down and on the path that exists specifically to catch dynamic SQL.
305
- * Neither pattern nests and neither respects string literals, so ONE earlier
306
- * literal containing `--` or an unclosed `/*` blanked every destructive
307
- * statement after it. Both of these reported an empty inventory and dropped the
308
- * table on PostgreSQL 16.14:
309
- *
310
- * DO $$ DECLARE s text := 'x --'; BEGIN EXECUTE 'DROP TABLE users'; END $$;
311
- * DO $$ DECLARE s text := 'a /*'; BEGIN EXECUTE 'DROP TABLE users';
312
- * RAISE NOTICE '% b *\/', s; END $$;
313
- *
314
- * The `--` shape is the worse of the two because it is reachable by ACCIDENT:
315
- * any single-line body whose earlier literal holds a `--` (a date range, a
316
- * separator, a placeholder) hides everything that follows it.
317
- *
318
- * Tokenizing also bounds each candidate at its own statement instead of at the
319
- * end of the body, so a later `WHERE` can no longer talk the `update-without-
320
- * where` rule out of an earlier unrestricted UPDATE.
321
- */
322
291
  function proceduralCandidates(body) {
323
292
  const out = [];
324
293
  for (const statement of (0, sql_statements_js_1.tokenizeSql)(body)) {
@@ -329,7 +298,7 @@ function proceduralCandidates(body) {
329
298
  const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
330
299
  let m = re.exec(text);
331
300
  while (m !== null) {
332
- out.push(text.slice(m.index));
301
+ out.push({ text: text.slice(m.index), before: text.slice(0, m.index) });
333
302
  m = re.exec(text);
334
303
  }
335
304
  }
@@ -367,12 +336,15 @@ function scanDestructiveSql(sql) {
367
336
  // belongs to which statement is no longer an offset calculation that can
368
337
  // disagree with the statement split.
369
338
  const procedural = PROCEDURAL_STATEMENT.test(stmt);
370
- const proceduralTexts = [];
339
+ const proceduralParts = [];
371
340
  for (const block of procedural ? statement.blocks : []) {
372
- for (const text of proceduralCandidates(block)) {
341
+ for (const part of proceduralCandidates(block)) {
373
342
  // The body was blanked in `display`, so name the fragment that matched.
374
- candidates.push({ text, display: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]` });
375
- proceduralTexts.push(text);
343
+ candidates.push({
344
+ text: part.text,
345
+ display: `${display} [in block: ${part.text.replace(/\s+/g, ' ').slice(0, 60)}]`,
346
+ });
347
+ proceduralParts.push(part);
376
348
  }
377
349
  }
378
350
  let matched = false;
@@ -407,12 +379,12 @@ function scanDestructiveSql(sql) {
407
379
  // `RAISE NOTICE 'DROP the mic'` would prompt, and a guard that fires on
408
380
  // prose teaches operators to confirm without reading, which costs more than
409
381
  // it saves.
410
- for (const text of proceduralTexts) {
411
- const kind = dynamicDestructiveKind(text);
382
+ for (const part of proceduralParts) {
383
+ const kind = dynamicDestructiveKind(part);
412
384
  if (!kind)
413
385
  continue;
414
386
  found.push({
415
- statement: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]`,
387
+ statement: `${display} [in block: ${part.text.replace(/\s+/g, ' ').slice(0, 60)}]`,
416
388
  kind,
417
389
  target: exports.DYNAMIC_TARGET,
418
390
  });
@@ -423,8 +395,68 @@ function scanDestructiveSql(sql) {
423
395
  }
424
396
  /** Shown in place of an object name that does not exist until the block runs. */
425
397
  exports.DYNAMIC_TARGET = '<name assembled at run time>';
426
- /** `||`, `format(...)`, or a `quote_*` helper: the ways a body builds SQL. */
427
- const DYNAMIC_ASSEMBLY = /\|\||\bformat\s*\(|\bquote_(?:ident|literal|nullable)\s*\(|%[IsL]/;
398
+ /**
399
+ * The concatenation operator, and `format()`'s placeholders. Case-sensitive on
400
+ * purpose: `%I`, `%s` and `%L` are the only specifiers `format()` accepts, and
401
+ * folding case here would also match `%i`, which is not one and does occur in
402
+ * prose.
403
+ */
404
+ const DYNAMIC_ASSEMBLY = /\|\||%[IsL]/;
405
+ /**
406
+ * Functions that BUILD a statement out of parts, looked for anywhere in the
407
+ * fragment (a proximity test, so the list stays tight).
408
+ *
409
+ * `concat` / `concat_ws` are the additions, and they are not a nicety: they are
410
+ * the function spelling of `||`, and the NULL-tolerant one, so they are exactly
411
+ * what an author reaches for when a name may be null. Their absence was
412
+ * fail-open in a safety guard, verified on PostgreSQL 16:
413
+ *
414
+ * DO $$ BEGIN EXECUTE 'DROP TABLE ' || 'users'; END $$; -> flagged
415
+ * DO $$ BEGIN EXECUTE concat('DROP TABLE ', 'users'); END $$; -> NOT flagged
416
+ *
417
+ * Both drop the table. The second reported a clean inventory, so `migrate up`
418
+ * never armed its data-loss prompt and applied the drop with no confirmation
419
+ * and no `--allow-destructive`.
420
+ */
421
+ const ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quote_nullable)\s*\(/i;
422
+ /**
423
+ * The same question asked of the text BEFORE the verb, and asked PRECISELY: an
424
+ * assembling call whose parenthesis is still OPEN where the verb appears, i.e.
425
+ * the verb is one of that call's arguments (`concat('DROP TABLE ', t)`). That
426
+ * is what `[^)]*$` says, and it is the whole reason a pre-verb test is safe to
427
+ * add at all: this is not "an assembly function is somewhere nearby", it is
428
+ * "the destructive verb is inside one".
429
+ *
430
+ * Being inside an assembling call is still not enough on its own, and the
431
+ * counter-example is not hypothetical, it appeared the first time this ran:
432
+ *
433
+ * DO $$ BEGIN UPDATE t SET a = regexp_replace(a, 'DROP .*', '') WHERE id = 1; END $$;
434
+ *
435
+ * Nothing there is assembled and nothing is destroyed, but the verb does sit
436
+ * inside a call. So the pre-verb test ALSO requires an `EXECUTE` in the same
437
+ * statement, which is the keyword that turns assembled text into a running
438
+ * statement, and the one thing a data-cleanup expression never has. The
439
+ * statement bound comes free: {@link proceduralCandidates} builds `before` from
440
+ * the candidate's OWN statement, so an `EXECUTE` three statements earlier in
441
+ * the body cannot vouch for this one.
442
+ *
443
+ * Precision is also what lets this list be wider than {@link ASSEMBLY_FN}'s.
444
+ * `array_to_string` joins a list of names into one statement, the shape a
445
+ * drop-many loop collapses to; `replace` / `regexp_replace` are template
446
+ * substitution (`EXECUTE replace('DROP TABLE $t', '$t', name)`). Neither may go
447
+ * in the proximity list: there they would arm the dynamic pass on any body that
448
+ * both mentions a destructive verb and tidies a string, and a guard that fires
449
+ * on innocent migrations teaches operators to confirm without reading, which is
450
+ * this module's other failure mode and costs more than it saves.
451
+ *
452
+ * Deliberately left out entirely: `string_agg` (it aggregates over ROWS, and the
453
+ * per-row half it aggregates is itself a `||` or a `concat` these already
454
+ * catch), `overlay`, and `substr`/`left`/`right` (they cut text down, they do
455
+ * not assemble a statement out of parts).
456
+ */
457
+ const WRAPPING_ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quote_nullable|array_to_string|regexp_replace|replace)\s*\([^)]*$/i;
458
+ /** Dynamic SQL only runs if something runs it. Scoped to the candidate's own statement. */
459
+ const RUNS_DYNAMIC_SQL = /\bEXECUTE\b/i;
428
460
  /**
429
461
  * The kind a runtime-assembled procedural fragment should be reported as, or
430
462
  * `null` when it is not dynamic (so a rule already had its chance) or its verb
@@ -436,8 +468,11 @@ const DYNAMIC_ASSEMBLY = /\|\||\bformat\s*\(|\bquote_(?:ident|literal|nullable)\
436
468
  * decidable from a fragment whose tail is a runtime expression, so including
437
469
  * them would flag every dynamic `UPDATE ... WHERE` in the file.
438
470
  */
439
- function dynamicDestructiveKind(text) {
440
- if (!DYNAMIC_ASSEMBLY.test(text))
471
+ function dynamicDestructiveKind({ text, before }) {
472
+ const assembled = DYNAMIC_ASSEMBLY.test(text) ||
473
+ ASSEMBLY_FN.test(text) ||
474
+ (RUNS_DYNAMIC_SQL.test(before) && WRAPPING_ASSEMBLY_FN.test(before));
475
+ if (!assembled)
441
476
  return null;
442
477
  if (/^DROP\s+TABLE\b/i.test(text))
443
478
  return 'drop-table';
@@ -6,7 +6,7 @@
6
6
  * turbine init , Initialize a Turbine project
7
7
  * turbine generate | pull , Introspect database and generate TypeScript types
8
8
  * turbine migrate-from-prisma - Parse a schema.prisma and emit a Prisma->Turbine name map + report
9
- * turbine push - Apply schema-builder definitions to database (destructive ops gated)
9
+ * turbine push - Apply schema-builder definitions to database (--schema-file, destructive ops gated)
10
10
  * turbine migrate create <name> - Create a new SQL migration file (--auto | --from-diff | --recipe <name>)
11
11
  * turbine migrate up , Apply pending migrations
12
12
  * turbine migrate deploy , Apply pending migrations without prompts
@@ -34,6 +34,18 @@ export interface CliArgs {
34
34
  url?: string;
35
35
  out?: string;
36
36
  schema?: string;
37
+ /**
38
+ * `--schema-file <path>`: the defineSchema() FILE, the `schemaFile` config key
39
+ * as a flag.
40
+ *
41
+ * `--schema` is the Postgres NAMESPACE, and it has been mistaken for this one
42
+ * often enough to be its own class of bug: `turbine push --schema ./schema.ts`
43
+ * reported "Schema file not found: ./turbine/schema.ts", naming a path the
44
+ * user never typed while their schema file sat in the directory they ran it
45
+ * from. Labelling the mistake is not the whole fix; the mistake exists because
46
+ * one of the two ideas had a flag and the other did not.
47
+ */
48
+ schemaFile?: string;
37
49
  include?: string[];
38
50
  exclude?: string[];
39
51
  step?: number;
@@ -121,6 +133,41 @@ export interface CliArgs {
121
133
  /** `skill --dir <path>`: the skills root to install into (default `.claude/skills`). */
122
134
  dir?: string;
123
135
  }
136
+ /**
137
+ * The canonical name of `command`, or undefined when nothing dispatches it.
138
+ *
139
+ * @internal exported for tests.
140
+ */
141
+ export declare function canonicalCommand(command: string): string | undefined;
142
+ /** Every command name a user could reasonably have meant, canonical spellings only. */
143
+ export declare function knownCommands(): string[];
144
+ /**
145
+ * The long flags `command` accepts (its own, then the global ones), or
146
+ * undefined when the command itself is unrecognized.
147
+ *
148
+ * An unknown command returns undefined rather than an empty list on purpose:
149
+ * `turbine genrate --url ...` should be told the COMMAND is misspelled, not
150
+ * handed a flag error for a flag that is perfectly valid on the command it
151
+ * meant.
152
+ *
153
+ * @internal exported for tests.
154
+ */
155
+ export declare function flagsForCommand(command: string): {
156
+ own: readonly string[];
157
+ global: readonly string[];
158
+ } | undefined;
159
+ /**
160
+ * Every flag token any command accepts, long spellings and aliases alike.
161
+ *
162
+ * Exists for `src/test/cli-arg-safety.test.ts`, which cross-checks it against
163
+ * the `case` labels in {@link parseArgs} in both directions. A flag in the
164
+ * parser but in no command's list is unreachable (the validator rejects it
165
+ * before the case runs); a flag in a list with no case is accepted and then
166
+ * silently ignored, which is the bug this whole surface exists to end.
167
+ *
168
+ * @internal exported for tests.
169
+ */
170
+ export declare function allFlagTokens(): Set<string>;
124
171
  export declare function parseArgs(argv?: string[]): CliArgs;
125
172
  /**
126
173
  * Does this invocation need a `turbine.config.*` file?
@@ -312,6 +359,12 @@ export interface InitPlanFlags {
312
359
  * skipped when there is no URL or the database is unreachable.
313
360
  */
314
361
  export declare function planInitSteps(state: InitPlanState, flags: InitPlanFlags): InitPlanStep[];
362
+ /**
363
+ * Which starter schema to scaffold.
364
+ *
365
+ * @internal exported for tests.
366
+ */
367
+ export declare function initSchemaTemplate(dbHasTables: boolean): string;
315
368
  /** What the secret-handling scaffold decided to do with one file. */
316
369
  export type EnvScaffoldAction = 'created' | 'appended' | 'unchanged';
317
370
  /** Detected state of the three files the scaffold touches (all IO by the caller). */
@@ -369,6 +422,24 @@ export declare function planEnvScaffold(state: EnvScaffoldState): EnvScaffoldPla
369
422
  * @internal exported for tests.
370
423
  */
371
424
  export declare function scaffoldEnvForUrl(url: string): EnvScaffoldPlan;
425
+ /**
426
+ * The exact import line for the generated client, extension included.
427
+ *
428
+ * `import { turbine } from './generated/turbine'` is a hard TypeScript error
429
+ * (TS2834) under `moduleResolution: NodeNext`, which is what this package ships
430
+ * and what its own tsconfig uses: a relative import needs an explicit file
431
+ * extension, and NodeNext does no directory-index resolution either. The
432
+ * generator has always appended the extension to its OWN sibling imports; the
433
+ * line printed at the reader was the one place it never reached.
434
+ *
435
+ * The extension comes from {@link resolveImportExtension}, the same resolver the
436
+ * generator runs, so the printed line matches the files just written rather
437
+ * than a second guess about the consumer's tsconfig. Under bundler resolution
438
+ * it resolves to `''` and the directory form is correct as-is.
439
+ *
440
+ * @internal exported for tests.
441
+ */
442
+ export declare function generatedClientImport(config: Pick<ResolvedConfig, 'out' | 'importExtension'>): string;
372
443
  /**
373
444
  * The one-line connection heads-up `turbine init` opens with.
374
445
  *
@@ -396,6 +467,29 @@ export declare function initEnvNotice(input: {
396
467
  flagUrl: string | undefined;
397
468
  configUrl: string | undefined;
398
469
  }): InitEnvNotice;
470
+ /**
471
+ * Refuse a `schema` that is plainly a FILE PATH, on every command that reads it
472
+ * as a Postgres namespace.
473
+ *
474
+ * `--schema` / `-s` sets the namespace to introspect (default `public`). The
475
+ * defineSchema() file is a different idea entirely, and mistaking the two is
476
+ * silent on every command that used to accept it: `generate` introspects
477
+ * `WHERE table_schema = './turbine/schema.ts'` and matches nothing, and `push`
478
+ * reads `config.schemaFile` instead and reports
479
+ * "Schema file not found: ./turbine/schema.ts", naming a path the reader never
480
+ * typed while their schema file sits in the directory they ran it from.
481
+ *
482
+ * The check was written for `generate` and wired into `generate` alone, which is
483
+ * how `push`, the command whose flag name the mistake is actually about, kept
484
+ * the bad error. It is one function called from every `--schema` command now,
485
+ * for the same reason `resolveColumnName` is one function: two copies of a rule
486
+ * is how two commands come to disagree about whether an argument is valid.
487
+ *
488
+ * @internal exported for tests.
489
+ */
490
+ export declare function refuseSchemaFilePath(config: Pick<ResolvedConfig, 'schema' | 'schemaFile'>, options?: {
491
+ escapeHatch?: string;
492
+ }): void;
399
493
  /**
400
494
  * `turbine migrate-from-prisma --schema prisma/schema.prisma` parses a Prisma
401
495
  * schema, resolve its models/fields/relations/compound-uniques against the live