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.
- package/README.md +48 -7
- package/dist/cjs/cli/compile-query.d.ts +22 -2
- package/dist/cjs/cli/compile-query.js +49 -5
- package/dist/cjs/cli/config.d.ts +2 -0
- package/dist/cjs/cli/config.js +1 -1
- package/dist/cjs/cli/destructive.js +78 -43
- package/dist/cjs/cli/index.d.ts +95 -1
- package/dist/cjs/cli/index.js +609 -145
- package/dist/cjs/cli/mcp.js +30 -1
- package/dist/cjs/cli/pii-predicate-guard.d.ts +25 -0
- package/dist/cjs/cli/pii-predicate-guard.js +72 -12
- package/dist/cjs/cli/rate-limit.js +38 -1
- package/dist/cjs/cli/studio.js +26 -5
- package/dist/cjs/cli/ui.d.ts +33 -0
- package/dist/cjs/cli/ui.js +53 -7
- package/dist/cjs/client.d.ts +13 -1
- package/dist/cjs/client.js +1 -1
- package/dist/cjs/errors.d.ts +12 -1
- package/dist/cjs/errors.js +11 -2
- package/dist/cjs/generate.d.ts +26 -0
- package/dist/cjs/generate.js +174 -27
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/introspect.d.ts +17 -0
- package/dist/cjs/introspect.js +100 -1
- package/dist/cjs/mssql.d.ts +18 -0
- package/dist/cjs/mssql.js +20 -1
- package/dist/cjs/pipeline.js +44 -6
- package/dist/cjs/powql.js +51 -17
- package/dist/cjs/query/batched-loader.js +3 -3
- package/dist/cjs/query/builder.js +1 -1
- package/dist/cjs/query/relations.d.ts +5 -0
- package/dist/cjs/query/relations.js +141 -69
- package/dist/cjs/query/utils.d.ts +13 -0
- package/dist/cjs/query/utils.js +16 -0
- package/dist/cjs/serverless.d.ts +1 -1
- package/dist/cjs/serverless.js +1 -1
- package/dist/cjs/sqlite.d.ts +33 -1
- package/dist/cjs/sqlite.js +84 -3
- package/dist/cli/compile-query.d.ts +22 -2
- package/dist/cli/compile-query.js +50 -6
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.js +1 -1
- package/dist/cli/destructive.js +78 -43
- package/dist/cli/index.d.ts +95 -1
- package/dist/cli/index.js +604 -147
- package/dist/cli/mcp.js +30 -1
- package/dist/cli/pii-predicate-guard.d.ts +25 -0
- package/dist/cli/pii-predicate-guard.js +73 -13
- package/dist/cli/rate-limit.js +38 -1
- package/dist/cli/studio.js +27 -6
- package/dist/cli/ui.d.ts +33 -0
- package/dist/cli/ui.js +51 -7
- package/dist/client.d.ts +13 -1
- package/dist/client.js +1 -1
- package/dist/errors.d.ts +12 -1
- package/dist/errors.js +11 -2
- package/dist/generate.d.ts +26 -0
- package/dist/generate.js +172 -27
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +17 -0
- package/dist/introspect.js +98 -1
- package/dist/mssql.d.ts +18 -0
- package/dist/mssql.js +20 -1
- package/dist/pipeline.js +44 -6
- package/dist/powql.js +53 -19
- package/dist/query/batched-loader.js +4 -4
- package/dist/query/builder.js +2 -2
- package/dist/query/relations.d.ts +5 -0
- package/dist/query/relations.js +141 -70
- package/dist/query/utils.d.ts +13 -0
- package/dist/query/utils.js +15 -0
- package/dist/serverless.d.ts +1 -1
- package/dist/serverless.js +1 -1
- package/dist/sqlite.d.ts +33 -1
- package/dist/sqlite.js +85 -4
- package/package.json +2 -2
package/dist/cjs/sqlite.js
CHANGED
|
@@ -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
|
|
252
|
-
*
|
|
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
|
-
/**
|
|
71
|
-
|
|
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
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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,
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -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';
|
package/dist/cli/config.js
CHANGED
|
@@ -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,
|
package/dist/cli/destructive.js
CHANGED
|
@@ -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
|
|
335
|
+
const proceduralParts = [];
|
|
367
336
|
for (const block of procedural ? statement.blocks : []) {
|
|
368
|
-
for (const
|
|
337
|
+
for (const part of proceduralCandidates(block)) {
|
|
369
338
|
// The body was blanked in `display`, so name the fragment that matched.
|
|
370
|
-
candidates.push({
|
|
371
|
-
|
|
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
|
|
407
|
-
const kind = dynamicDestructiveKind(
|
|
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
|
-
/**
|
|
423
|
-
|
|
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
|
-
|
|
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';
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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
|