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/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 **
|
|
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
|
|
319
|
-
| **Studio** | Read-only by default | Full CRUD, cloud-hosted |
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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`. */
|
|
@@ -109,9 +109,47 @@ exports.COLUMN_NAMING_ARG_KEYS = [
|
|
|
109
109
|
'_min',
|
|
110
110
|
'_max',
|
|
111
111
|
];
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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,
|
package/dist/cjs/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/cjs/cli/config.js
CHANGED
|
@@ -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
|
|
339
|
+
const proceduralParts = [];
|
|
371
340
|
for (const block of procedural ? statement.blocks : []) {
|
|
372
|
-
for (const
|
|
341
|
+
for (const part of proceduralCandidates(block)) {
|
|
373
342
|
// The body was blanked in `display`, so name the fragment that matched.
|
|
374
|
-
candidates.push({
|
|
375
|
-
|
|
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
|
|
411
|
-
const kind = dynamicDestructiveKind(
|
|
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
|
-
/**
|
|
427
|
-
|
|
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
|
-
|
|
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';
|
package/dist/cjs/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
|