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
@@ -35,7 +35,7 @@
35
35
  * - **Case-insensitive matching** uses `COLLATE NOCASE`, which is **ASCII-only**
36
36
  * (no Unicode case folding).
37
37
  *
38
- * ## Example, `:memory:` database
38
+ * ## Example, `:memory:` database, from a generated schema
39
39
  *
40
40
  * ```ts
41
41
  * import { turbineSqlite } from 'turbine-orm/sqlite';
@@ -45,6 +45,38 @@
45
45
  * const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
46
46
  * await db.disconnect();
47
47
  * ```
48
+ *
49
+ * ## Example, SQLite with no Postgres anywhere
50
+ *
51
+ * The snippet above needs `turbine generate`, which reads a live **Postgres**
52
+ * catalog, so it is the wrong starting point if SQLite is your only database.
53
+ * Describe the schema in code instead: `schemaToSQL` emits the DDL and
54
+ * `schemaDefToMetadata` derives the runtime metadata (relations included, from
55
+ * the same `references:`), both pure functions with no database involved.
56
+ *
57
+ * ```ts
58
+ * import { defineSchema, schemaDefToMetadata, schemaToSQL } from 'turbine-orm';
59
+ * import { sqliteDialect, turbineSqlite } from 'turbine-orm/sqlite';
60
+ *
61
+ * const schema = defineSchema({
62
+ * users: { id: { type: 'serial', primaryKey: true }, email: { type: 'text', notNull: true } },
63
+ * posts: {
64
+ * id: { type: 'serial', primaryKey: true },
65
+ * userId: { type: 'integer', notNull: true, references: 'users.id' },
66
+ * title: { type: 'text', notNull: true },
67
+ * },
68
+ * });
69
+ *
70
+ * const db = turbineSqlite(':memory:', schemaDefToMetadata(schema));
71
+ * for (const stmt of schemaToSQL(schema, { dialect: sqliteDialect })) {
72
+ * await db.raw([stmt] as never);
73
+ * }
74
+ *
75
+ * // `db.table(...)`, not `db.users`: the typed property accessors are emitted
76
+ * // by `turbine generate`, and this path skips it.
77
+ * const users = await db.table('users').findMany({ with: { posts: true }, orderBy: { id: 'asc' } });
78
+ * await db.disconnect();
79
+ * ```
48
80
  */
49
81
  Object.defineProperty(exports, "__esModule", { value: true });
50
82
  exports.sqliteDialect = exports.SqlitePool = void 0;
@@ -248,8 +280,24 @@ function statementReturnsRows(sql) {
248
280
  * recognizable SQLite error.
249
281
  *
250
282
  * `wrapPgError` is invoked downstream (in the query executor and the
251
- * transaction proxy), so we only annotate here, we never throw a `new`
252
- * Turbine error from the driver itself.
283
+ * transaction proxy), so annotation is the preferred half of the job: every
284
+ * failure whose meaning Postgres already has a SQLSTATE for is expressed as
285
+ * that SQLSTATE and typed by the one classifier.
286
+ *
287
+ * ONE family cannot be handled that way, and it was the family a new user hits
288
+ * first. `SQLITE_ERROR` (primary code 1, "SQL logic error") covers
289
+ * `no such table: users`, `no such column: emial`, and a plain syntax error;
290
+ * Postgres reports the same three as 42P01 / 42703 / 42601, and `wrapPgError`
291
+ * classifies none of them, on either engine. So on SQLite they reached the
292
+ * caller as a bare `Error` with `code: 'ERR_SQLITE_ERROR'`: no `TURBINE_E0NN`,
293
+ * no `.docsUrl`, not a `TurbineError` at all, in a library whose typed-error
294
+ * table is a headline feature. {@link sqliteLogicError} builds the
295
+ * `ValidationError` for them here, the way `wrapPowdbError` builds its errors
296
+ * for a driver with no SQLSTATEs to annotate.
297
+ *
298
+ * Everything else stays annotation, so the two engines cannot drift: fixing a
299
+ * constraint classification in `wrapPgError` fixes it for SQLite in the same
300
+ * commit.
253
301
  */
254
302
  function augmentSqliteError(err) {
255
303
  if (!err || typeof err !== 'object')
@@ -297,10 +345,43 @@ function augmentSqliteError(err) {
297
345
  if (primary === 5 || primary === 6) {
298
346
  // Map to serialization_failure so withRetry()/$retry() retry it.
299
347
  target.code = '40001';
348
+ return err;
300
349
  }
350
+ // SQLITE_ERROR (1): the statement itself is wrong. See the note above.
351
+ if (primary === 1)
352
+ return sqliteLogicError(message, err);
301
353
  return err;
302
354
  }
303
355
  }
356
+ /**
357
+ * The `ValidationError` for a `SQLITE_ERROR`, with a hint for the two spellings
358
+ * that account for nearly all of them.
359
+ *
360
+ * The driver's own text IS the diagnosis (`no such table: users` names the
361
+ * table), so it is kept verbatim rather than replaced.
362
+ *
363
+ * The original error IS attached as `cause`, and safe mode is not a reason not
364
+ * to: `TurbineError` runs every cause it is given through `redactCauseForMode`,
365
+ * so attaching one goes THROUGH the redaction rather than around it. It also
366
+ * discloses nothing new here, because the driver's message is already quoted
367
+ * verbatim into the message above it. What it buys is the stack: without it a
368
+ * `no such table` surfaces with Turbine's frames and none of the driver's.
369
+ */
370
+ function sqliteLogicError(message, cause) {
371
+ const missingTable = /^no such table:\s*(\S+)/i.exec(message);
372
+ if (missingTable) {
373
+ return new errors_js_1.ValidationError(`[turbine] SQLite has no table "${missingTable[1]}": ${message}. ` +
374
+ 'Create it first (run your migrations, or execute the CREATE TABLE statements for this schema); ' +
375
+ 'an in-memory database starts empty on every connection.', { cause });
376
+ }
377
+ const missingColumn = /^no such column:\s*(\S+)/i.exec(message);
378
+ if (missingColumn) {
379
+ return new errors_js_1.ValidationError(`[turbine] SQLite has no column "${missingColumn[1]}": ${message}. ` +
380
+ 'Check the spelling against the table as it exists in this database, ' +
381
+ 'and re-run `turbine generate` if the schema has changed.', { cause });
382
+ }
383
+ return new errors_js_1.ValidationError(`[turbine] SQLite rejected the statement: ${message}`, { cause });
384
+ }
304
385
  function normalizeQueryArgs(arg, values) {
305
386
  if (typeof arg === 'string')
306
387
  return { text: arg, params: values ?? [] };
@@ -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`. */
@@ -36,7 +36,7 @@
36
36
  import { TurbineError } from '../errors.js';
37
37
  import { missingIndexForRelation } from '../index-advisor.js';
38
38
  import { QueryInterface } from '../query/index.js';
39
- import { ownLookup } from '../query/utils.js';
39
+ import { ownLookup, resolveRelation } from '../query/utils.js';
40
40
  import { explainErrorCode } from './error-catalog.js';
41
41
  import { PII_GUARD_MAX_DEPTH } from './pii-predicate-guard.js';
42
42
  /**
@@ -102,9 +102,47 @@ export const COLUMN_NAMING_ARG_KEYS = [
102
102
  '_min',
103
103
  '_max',
104
104
  ];
105
- /** Does this args object carry a key that names a column? */
106
- export function carriesColumnNamingArg(args) {
107
- return COLUMN_NAMING_ARG_KEYS.some((key) => args[key] !== undefined);
105
+ /**
106
+ * Does this args object carry a key that names a column, AT ANY DEPTH?
107
+ *
108
+ * The depth is the whole point and it was missing until 0.76.0. This is the
109
+ * fail-closed test for the case where the PII tag file could not be read, so
110
+ * "no column-naming arg" is a claim that the query cannot be filtering on a
111
+ * hidden column. A top-level-only check makes that claim falsely:
112
+ * `{ with: { posts: { where: { secretNote: { not: null } } } } }` has no
113
+ * column-naming key at the top level and filters on a column two levels down,
114
+ * and the tool reported it as safe to compile.
115
+ *
116
+ * `with` is not itself a column-naming key (its keys are RELATION names, and
117
+ * `select` / `omit` are excluded on the guard's own rule that they return
118
+ * values, which this tool never does), but the OPTIONS inside a `with` entry
119
+ * are the full findMany surface, so the walk descends through them.
120
+ *
121
+ * It errs toward TRUE: an unrecognized object value is descended into rather
122
+ * than skipped, and the depth cap answers true rather than false. This function
123
+ * only ever gates a refusal, so a false positive costs a caller one message and
124
+ * a false negative is the disclosure it exists to prevent.
125
+ */
126
+ export function carriesColumnNamingArg(args, depth = 0) {
127
+ if (depth > PII_GUARD_MAX_DEPTH)
128
+ return true;
129
+ for (const [key, value] of Object.entries(args)) {
130
+ if (COLUMN_NAMING_ARG_KEYS.includes(key) && value !== undefined)
131
+ return true;
132
+ // `select` / `omit` name columns but return them rather than filtering on
133
+ // them, and nothing is returned here; skipping them keeps this aligned with
134
+ // cli/pii-predicate-guard.ts, which makes the same call for the same reason.
135
+ if (key === 'select' || key === 'omit')
136
+ continue;
137
+ if (value && typeof value === 'object') {
138
+ for (const entry of Array.isArray(value) ? value : [value]) {
139
+ if (entry && typeof entry === 'object' && carriesColumnNamingArg(entry, depth + 1)) {
140
+ return true;
141
+ }
142
+ }
143
+ }
144
+ }
145
+ return false;
108
146
  }
109
147
  // ---------------------------------------------------------------------------
110
148
  // Compile
@@ -300,9 +338,15 @@ function walkWith(metadata, table, withClause, depth, prefix, out) {
300
338
  // statement and has no target table of its own.
301
339
  if (key === '_count')
302
340
  continue;
303
- const relation = ownLookup(table.relations, key);
304
- if (!relation)
341
+ // Resolve the caller's spelling the way the compiler does: a relation declared
342
+ // `blogPosts` is also reachable as `blog_posts` (resolveRelation, since 0.72). An
343
+ // exact-match lookup here did not fail loudly, it silently dropped the relation from
344
+ // the report, so compile_query under-counted statements and skipped the correlation
345
+ // probes for exactly the relation the caller asked about.
346
+ const resolved = resolveRelation(table.relations, key);
347
+ if (!resolved)
305
348
  continue;
349
+ const relation = resolved.def;
306
350
  const path = prefix ? `${prefix}.${relation.name}` : relation.name;
307
351
  out.push({
308
352
  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';
@@ -165,7 +165,7 @@ export function resolveConfig(fileConfig, overrides) {
165
165
  // `seedFile` is canonical (what the docs and `turbine init` use); `seed` is a
166
166
  // back-compat alias kept working for configs scaffolded before 0.50.
167
167
  seedFile: fileConfig.seedFile ?? fileConfig.seed,
168
- schemaFile: fileConfig.schemaFile ?? './turbine/schema.ts',
168
+ schemaFile: overrides.schemaFile ?? fileConfig.schemaFile ?? './turbine/schema.ts',
169
169
  importExtension: overrides.importExtension ?? fileConfig.importExtension ?? 'auto',
170
170
  keepColumnNames: overrides.keepColumnNames ?? fileConfig.keepColumnNames ?? false,
171
171
  legacyToManyUniques: overrides.legacyToManyUniques ?? fileConfig.legacyToManyUniques ?? false,
@@ -284,37 +284,6 @@ function stripExecutingExplain(stmt) {
284
284
  }
285
285
  /** Statements whose dollar-quoted body is executable SQL rather than data. */
286
286
  const PROCEDURAL_STATEMENT = /^(DO\b|CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b)/i;
287
- /**
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.
317
- */
318
287
  function proceduralCandidates(body) {
319
288
  const out = [];
320
289
  for (const statement of tokenizeSql(body)) {
@@ -325,7 +294,7 @@ function proceduralCandidates(body) {
325
294
  const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
326
295
  let m = re.exec(text);
327
296
  while (m !== null) {
328
- out.push(text.slice(m.index));
297
+ out.push({ text: text.slice(m.index), before: text.slice(0, m.index) });
329
298
  m = re.exec(text);
330
299
  }
331
300
  }
@@ -363,12 +332,15 @@ export function scanDestructiveSql(sql) {
363
332
  // belongs to which statement is no longer an offset calculation that can
364
333
  // disagree with the statement split.
365
334
  const procedural = PROCEDURAL_STATEMENT.test(stmt);
366
- const proceduralTexts = [];
335
+ const proceduralParts = [];
367
336
  for (const block of procedural ? statement.blocks : []) {
368
- for (const text of proceduralCandidates(block)) {
337
+ for (const part of proceduralCandidates(block)) {
369
338
  // The body was blanked in `display`, so name the fragment that matched.
370
- candidates.push({ text, display: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]` });
371
- proceduralTexts.push(text);
339
+ candidates.push({
340
+ text: part.text,
341
+ display: `${display} [in block: ${part.text.replace(/\s+/g, ' ').slice(0, 60)}]`,
342
+ });
343
+ proceduralParts.push(part);
372
344
  }
373
345
  }
374
346
  let matched = false;
@@ -403,12 +375,12 @@ export function scanDestructiveSql(sql) {
403
375
  // `RAISE NOTICE 'DROP the mic'` would prompt, and a guard that fires on
404
376
  // prose teaches operators to confirm without reading, which costs more than
405
377
  // it saves.
406
- for (const text of proceduralTexts) {
407
- const kind = dynamicDestructiveKind(text);
378
+ for (const part of proceduralParts) {
379
+ const kind = dynamicDestructiveKind(part);
408
380
  if (!kind)
409
381
  continue;
410
382
  found.push({
411
- statement: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]`,
383
+ statement: `${display} [in block: ${part.text.replace(/\s+/g, ' ').slice(0, 60)}]`,
412
384
  kind,
413
385
  target: DYNAMIC_TARGET,
414
386
  });
@@ -419,8 +391,68 @@ export function scanDestructiveSql(sql) {
419
391
  }
420
392
  /** Shown in place of an object name that does not exist until the block runs. */
421
393
  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]/;
394
+ /**
395
+ * The concatenation operator, and `format()`'s placeholders. Case-sensitive on
396
+ * purpose: `%I`, `%s` and `%L` are the only specifiers `format()` accepts, and
397
+ * folding case here would also match `%i`, which is not one and does occur in
398
+ * prose.
399
+ */
400
+ const DYNAMIC_ASSEMBLY = /\|\||%[IsL]/;
401
+ /**
402
+ * Functions that BUILD a statement out of parts, looked for anywhere in the
403
+ * fragment (a proximity test, so the list stays tight).
404
+ *
405
+ * `concat` / `concat_ws` are the additions, and they are not a nicety: they are
406
+ * the function spelling of `||`, and the NULL-tolerant one, so they are exactly
407
+ * what an author reaches for when a name may be null. Their absence was
408
+ * fail-open in a safety guard, verified on PostgreSQL 16:
409
+ *
410
+ * DO $$ BEGIN EXECUTE 'DROP TABLE ' || 'users'; END $$; -> flagged
411
+ * DO $$ BEGIN EXECUTE concat('DROP TABLE ', 'users'); END $$; -> NOT flagged
412
+ *
413
+ * Both drop the table. The second reported a clean inventory, so `migrate up`
414
+ * never armed its data-loss prompt and applied the drop with no confirmation
415
+ * and no `--allow-destructive`.
416
+ */
417
+ const ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quote_nullable)\s*\(/i;
418
+ /**
419
+ * The same question asked of the text BEFORE the verb, and asked PRECISELY: an
420
+ * assembling call whose parenthesis is still OPEN where the verb appears, i.e.
421
+ * the verb is one of that call's arguments (`concat('DROP TABLE ', t)`). That
422
+ * is what `[^)]*$` says, and it is the whole reason a pre-verb test is safe to
423
+ * add at all: this is not "an assembly function is somewhere nearby", it is
424
+ * "the destructive verb is inside one".
425
+ *
426
+ * Being inside an assembling call is still not enough on its own, and the
427
+ * counter-example is not hypothetical, it appeared the first time this ran:
428
+ *
429
+ * DO $$ BEGIN UPDATE t SET a = regexp_replace(a, 'DROP .*', '') WHERE id = 1; END $$;
430
+ *
431
+ * Nothing there is assembled and nothing is destroyed, but the verb does sit
432
+ * inside a call. So the pre-verb test ALSO requires an `EXECUTE` in the same
433
+ * statement, which is the keyword that turns assembled text into a running
434
+ * statement, and the one thing a data-cleanup expression never has. The
435
+ * statement bound comes free: {@link proceduralCandidates} builds `before` from
436
+ * the candidate's OWN statement, so an `EXECUTE` three statements earlier in
437
+ * the body cannot vouch for this one.
438
+ *
439
+ * Precision is also what lets this list be wider than {@link ASSEMBLY_FN}'s.
440
+ * `array_to_string` joins a list of names into one statement, the shape a
441
+ * drop-many loop collapses to; `replace` / `regexp_replace` are template
442
+ * substitution (`EXECUTE replace('DROP TABLE $t', '$t', name)`). Neither may go
443
+ * in the proximity list: there they would arm the dynamic pass on any body that
444
+ * both mentions a destructive verb and tidies a string, and a guard that fires
445
+ * on innocent migrations teaches operators to confirm without reading, which is
446
+ * this module's other failure mode and costs more than it saves.
447
+ *
448
+ * Deliberately left out entirely: `string_agg` (it aggregates over ROWS, and the
449
+ * per-row half it aggregates is itself a `||` or a `concat` these already
450
+ * catch), `overlay`, and `substr`/`left`/`right` (they cut text down, they do
451
+ * not assemble a statement out of parts).
452
+ */
453
+ const WRAPPING_ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quote_nullable|array_to_string|regexp_replace|replace)\s*\([^)]*$/i;
454
+ /** Dynamic SQL only runs if something runs it. Scoped to the candidate's own statement. */
455
+ const RUNS_DYNAMIC_SQL = /\bEXECUTE\b/i;
424
456
  /**
425
457
  * The kind a runtime-assembled procedural fragment should be reported as, or
426
458
  * `null` when it is not dynamic (so a rule already had its chance) or its verb
@@ -432,8 +464,11 @@ const DYNAMIC_ASSEMBLY = /\|\||\bformat\s*\(|\bquote_(?:ident|literal|nullable)\
432
464
  * decidable from a fragment whose tail is a runtime expression, so including
433
465
  * them would flag every dynamic `UPDATE ... WHERE` in the file.
434
466
  */
435
- function dynamicDestructiveKind(text) {
436
- if (!DYNAMIC_ASSEMBLY.test(text))
467
+ function dynamicDestructiveKind({ text, before }) {
468
+ const assembled = DYNAMIC_ASSEMBLY.test(text) ||
469
+ ASSEMBLY_FN.test(text) ||
470
+ (RUNS_DYNAMIC_SQL.test(before) && WRAPPING_ASSEMBLY_FN.test(before));
471
+ if (!assembled)
437
472
  return null;
438
473
  if (/^DROP\s+TABLE\b/i.test(text))
439
474
  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