turbine-orm 0.60.1 → 0.62.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 (84) hide show
  1. package/README.md +71 -27
  2. package/dist/cjs/cli/config.d.ts +40 -0
  3. package/dist/cjs/cli/config.js +74 -2
  4. package/dist/cjs/cli/index.d.ts +85 -1
  5. package/dist/cjs/cli/index.js +374 -24
  6. package/dist/cjs/cli/mcp.d.ts +8 -0
  7. package/dist/cjs/cli/mcp.js +448 -29
  8. package/dist/cjs/cli/pii-tags.d.ts +64 -9
  9. package/dist/cjs/cli/pii-tags.js +218 -39
  10. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  11. package/dist/cjs/cli/studio.d.ts +23 -0
  12. package/dist/cjs/cli/studio.js +126 -53
  13. package/dist/cjs/cli/ui.d.ts +15 -1
  14. package/dist/cjs/cli/ui.js +19 -5
  15. package/dist/cjs/client.js +248 -11
  16. package/dist/cjs/errors.d.ts +38 -1
  17. package/dist/cjs/errors.js +235 -24
  18. package/dist/cjs/index.d.ts +2 -2
  19. package/dist/cjs/index.js +7 -2
  20. package/dist/cjs/pipeline-submittable.js +26 -3
  21. package/dist/cjs/pipeline.js +15 -2
  22. package/dist/cjs/powql.d.ts +12 -0
  23. package/dist/cjs/powql.js +46 -21
  24. package/dist/cjs/prisma-compat.d.ts +15 -5
  25. package/dist/cjs/prisma-compat.js +273 -78
  26. package/dist/cjs/query/aggregates.d.ts +1 -1
  27. package/dist/cjs/query/aggregates.js +24 -10
  28. package/dist/cjs/query/batched-loader.d.ts +9 -4
  29. package/dist/cjs/query/batched-loader.js +4 -1
  30. package/dist/cjs/query/builder.d.ts +47 -0
  31. package/dist/cjs/query/builder.js +149 -21
  32. package/dist/cjs/query/index.d.ts +3 -1
  33. package/dist/cjs/query/index.js +7 -1
  34. package/dist/cjs/query/option-surface.d.ts +11 -0
  35. package/dist/cjs/query/option-surface.js +13 -0
  36. package/dist/cjs/query/relations.d.ts +8 -0
  37. package/dist/cjs/query/relations.js +21 -1
  38. package/dist/cjs/query/types.d.ts +152 -18
  39. package/dist/cjs/query/types.js +212 -1
  40. package/dist/cjs/query/where.d.ts +3 -3
  41. package/dist/cjs/query/where.js +8 -2
  42. package/dist/cjs/query/writes.js +10 -9
  43. package/dist/cli/config.d.ts +40 -0
  44. package/dist/cli/config.js +73 -2
  45. package/dist/cli/index.d.ts +85 -1
  46. package/dist/cli/index.js +373 -27
  47. package/dist/cli/mcp.d.ts +8 -0
  48. package/dist/cli/mcp.js +448 -29
  49. package/dist/cli/pii-tags.d.ts +64 -9
  50. package/dist/cli/pii-tags.js +217 -39
  51. package/dist/cli/studio-ui.generated.js +1 -1
  52. package/dist/cli/studio.d.ts +23 -0
  53. package/dist/cli/studio.js +125 -53
  54. package/dist/cli/ui.d.ts +15 -1
  55. package/dist/cli/ui.js +18 -4
  56. package/dist/client.js +250 -13
  57. package/dist/errors.d.ts +38 -1
  58. package/dist/errors.js +234 -23
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +5 -2
  61. package/dist/pipeline-submittable.js +26 -3
  62. package/dist/pipeline.js +15 -2
  63. package/dist/powql.d.ts +12 -0
  64. package/dist/powql.js +46 -21
  65. package/dist/prisma-compat.d.ts +15 -5
  66. package/dist/prisma-compat.js +274 -79
  67. package/dist/query/aggregates.d.ts +1 -1
  68. package/dist/query/aggregates.js +24 -10
  69. package/dist/query/batched-loader.d.ts +9 -4
  70. package/dist/query/batched-loader.js +4 -1
  71. package/dist/query/builder.d.ts +47 -0
  72. package/dist/query/builder.js +148 -21
  73. package/dist/query/index.d.ts +3 -1
  74. package/dist/query/index.js +2 -0
  75. package/dist/query/option-surface.d.ts +11 -0
  76. package/dist/query/option-surface.js +13 -0
  77. package/dist/query/relations.d.ts +8 -0
  78. package/dist/query/relations.js +21 -1
  79. package/dist/query/types.d.ts +152 -18
  80. package/dist/query/types.js +207 -2
  81. package/dist/query/where.d.ts +3 -3
  82. package/dist/query/where.js +8 -2
  83. package/dist/query/writes.js +10 -9
  84. package/package.json +13 -3
package/README.md CHANGED
@@ -8,33 +8,37 @@ Most query layers are designed for the shape of a laptop database: empty, dispos
8
8
  npm install turbine-orm
9
9
  ```
10
10
 
11
- **Full docs: [turbineorm.dev](https://turbineorm.dev)**, [Quick Start](https://turbineorm.dev/quickstart) · [API Reference](https://turbineorm.dev/queries) · [Relations](https://turbineorm.dev/relations) · [Transactions & Pipelines](https://turbineorm.dev/transactions) · [Serverless & Edge](https://turbineorm.dev/serverless) · [Typed Errors](https://turbineorm.dev/errors) · [Benchmarks](https://turbineorm.dev/benchmarks)
11
+ **Full docs: [turbineorm.dev](https://turbineorm.dev)**, [Why Turbine](https://turbineorm.dev/why-turbine) · [Quick Start](https://turbineorm.dev/quickstart) · [API Reference](https://turbineorm.dev/queries) · [Relations](https://turbineorm.dev/relations) · [Transactions & Pipelines](https://turbineorm.dev/transactions) · [Serverless & Edge](https://turbineorm.dev/serverless) · [Typed Errors](https://turbineorm.dev/errors) · [Benchmarks](https://turbineorm.dev/benchmarks)
12
12
 
13
13
  ## Why Turbine?
14
14
 
15
+ *(The same argument, laid out with the comparisons and the caveats, is at [turbineorm.dev/why-turbine](https://turbineorm.dev/why-turbine).)*
16
+
15
17
  First, what is **not** a reason. Resolving a nested `with` clause in one statement is table stakes in 2026: Drizzle has compiled relational queries to `LEFT JOIN LATERAL` plus JSON aggregation since 0.28, Prisma does the same under its `relationJoins` preview flag, and Kysely ships `jsonArrayFrom` / `jsonObjectFrom` helpers for it. Turbine does it too, it does it well, and it is documented under [How It Works](#how-it-works) as a correctness detail rather than a headline.
16
18
 
17
19
  The reason to reach for Turbine is that every layer between you and a production database is built on one assumption: **the rows are real**. That plays out in five concrete places.
18
20
 
19
21
  1. **The database UI is read-only, and writes are a per-launch decision.** `npx turbine studio` binds loopback, authenticates with a 192-bit per-process token, and runs every read inside `BEGIN READ ONLY`. In the default mode the write endpoints do not exist in the router at all (they 404), so there is nothing to bypass. `--write` opts a single launch in to edits, each addressed by its full primary key rather than a predicate, compiled by the same validated builder your app uses. There is no raw-SQL surface at all since v0.19.
20
- 2. **PII is a schema contract, enforced in the SQL.** Tag a column `pii: true` and it is excluded from every default projection: top-level rows, `with` subqueries, batched loaders, write returns, and the Studio UI. On the SQL engines the exclusion is in the emitted statement, so the value never leaves the database. (PowDB is the one exception, and it is a weaker guarantee: its `returning` keyword takes no column list, so a write's PII is stripped in the client after crossing the wire. Its read projections are still column-explicit.) A tagged column is also **refused** as a `groupBy` key and as a `_min` / `_max` target, because both hand back a stored cell. `includePii: true` unlocks it explicitly per read. A schema with no tagged column emits byte-identical SQL.
22
+ 2. **PII is a schema contract, enforced in the SQL.** Tag a column `pii: true` and it is excluded from every default projection: top-level rows, `with` subqueries, batched loaders, write returns, and the Studio UI. On the SQL engines the exclusion is in the emitted statement, so the value never leaves the database. (PowDB is the one exception, and it is a weaker guarantee: its `returning` keyword takes no column list, so a write's PII is stripped in the client after crossing the wire. Its read projections are still column-explicit.) A tagged column is also **refused** as a `groupBy` key and as a `_min` / `_max` target, because both hand back a stored cell. `includePii: UNSAFE` unlocks it explicitly per read (see **Privilege options** below: a plain `true` throws). A schema with no tagged column emits byte-identical SQL.
21
23
  3. **Errors carry keys, never values.** A `NotFoundError` says `where: { id, email }`. A `UniqueConstraintError` names the column that conflicted. Neither prints the user's data, so the error is safe to send straight to Sentry with no scrubbing rule in front of it. The full `where` object stays available as `err.where` in code.
22
24
  4. **Data-destroying statements need consent.** `migrate up`, `migrate down` and `push` scan for `DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, unqualified `DELETE` / `UPDATE`, and `ALTER COLUMN … TYPE`, print an itemized report, and refuse to run. Interactively you type `destroy my data` and then `yes`; in CI you pass `--allow-destructive`. A refused batch applies nothing.
23
25
  5. **The review a DBA would have given you, offline.** `npx turbine doctor` derives every column set the ORM's relation subqueries probe and reports the ones with no covering index, with a cost tier per finding. `--fix` writes the migration. No cloud service, no telemetry, no account: it reads your introspected schema.
24
26
 
25
- **On "only".** Each of these is checkable, so here is the checkable version, current as of July 2026: no other TypeScript ORM ships a studio that is read-only by default or that redacts PII (Prisma Studio is proprietary and its read-only request has been open since February 2021; Drizzle Studio is not open source and self-hosting runs through the paid Drizzle Gateway; TypeORM, MikroORM, Kysely and Sequelize have no studio). No TypeScript ORM CLI offers missing-index advice: not the Prisma CLI, drizzle-kit, kysely-ctl, or the MikroORM / TypeORM / Sequelize CLIs, and Prisma Optimize was retired in March 2026 in favour of cloud-only Query Insights. Prior art exists outside TypeScript, notably Ruby's `active_record_doctor`, so the honest claim is "no TypeScript ORM", not "no ORM". Turbine is the only TypeScript ORM that ships all five of the above together.
27
+ **On "only".** Each of these is checkable, so here is the checkable version, current as of July 2026: no other TypeScript ORM ships a studio that is read-only by default or that redacts PII (Prisma Studio is open source, `@prisma/studio-core` is Apache-2.0, but it has no read-only mode and that request has been open since February 2021; Drizzle Studio is not open source and self-hosting runs through the paid Drizzle Gateway; TypeORM, MikroORM, Kysely and Sequelize have no studio). No TypeScript ORM CLI offers missing-index advice: not the Prisma CLI, drizzle-kit, kysely-ctl, or the MikroORM / TypeORM / Sequelize CLIs, and Prisma Optimize was retired in March 2026 in favour of cloud-only Query Insights. Prior art exists outside TypeScript, notably Ruby's `active_record_doctor`, so the honest claim is "no TypeScript ORM", not "no ORM". Turbine is the only TypeScript ORM that ships all five of the above together.
26
28
 
27
29
  Two more things worth knowing, which are about cost rather than safety:
28
30
 
29
- - **One runtime dependency (`pg`).** No engine binary, no WASM, no adapter packages in lockstep. The main entry's **import graph** is ~65 kB brotli (edge ~50 kB) with `pg` external (measured 2026-07-25 on 0.50.0: 65.16 kB and 50.18 kB, budgeted in `.size-limit.js`). That is the client footprint your bundler sees, not the size of the dual ESM+CJS build on disk, which is larger. Prisma 7 dropped its Rust query engine but its client still ships a TypeScript/WASM query compiler, a ~1.6 MB bundle, down from the ~14 MB Rust-era client.
31
+ - **One runtime dependency (`pg`).** No engine binary, no WASM, no adapter packages in lockstep. The main entry's **import graph** is held under **77 kB brotli** (edge under 61 kB) with `pg` external. That is the ceiling `size-limit` enforces in CI from `.size-limit.js`, not a figure typed into this file: a measurement quoted in prose goes stale silently, and this one did, drifting ~12% low over ten releases before a review caught it. Run `npm run size` for the current number. That is the client footprint your bundler sees, not the size of the dual ESM+CJS build on disk, which is larger. Prisma 7 dropped its Rust query engine but its client still ships a TypeScript/WASM query compiler, a ~1.6 MB bundle, down from the ~14 MB Rust-era client.
30
32
  - **Real pipelining, not a batch transaction.** `db.pipeline(...)` uses the Postgres extended-query protocol (Parse/Bind/Execute/Sync) to put N independent queries in one TCP flush. node-postgres does not expose pipelining in its pure-JS core ([brianc/node-postgres#2646](https://github.com/brianc/node-postgres/issues/2646) was still open as of July 2026), and Drizzle's `db.batch()` is an implicit transaction on specific drivers rather than independent-query pipelining.
31
33
 
32
34
  **Beyond the safety bundle, what ships today:** [global filters](https://turbineorm.dev/global-filters) for soft-delete and multi-tenancy · [read replicas](https://turbineorm.dev/read-replicas) with a `$primary()` escape hatch · a read-only [MCP server](https://turbineorm.dev/mcp) for AI agents · [seed-as-code](https://turbineorm.dev/seeding) and a non-interactive `migrate deploy` for CI · [Zod generation](https://turbineorm.dev/zod) · read-only [views & generated columns](https://turbineorm.dev/views) · [optional SQLite / MySQL / SQL Server / PowDB engines](https://turbineorm.dev/engines) behind subpath exports · a [Prisma migration toolkit](https://turbineorm.dev/migrate-from-prisma) (schema mapper plus a runtime compat adapter) · a cost-aware index advisor in [`turbine doctor`](https://turbineorm.dev/cli#turbine-doctor).
33
35
 
34
- Per-release detail lives in the [CHANGELOG](./CHANGELOG.md) and at [turbineorm.dev/changelog](https://turbineorm.dev/changelog).
36
+ Per-release detail lives in the [CHANGELOG](https://github.com/zvndev/turbine-orm/blob/main/CHANGELOG.md) and at [turbineorm.dev/changelog](https://turbineorm.dev/changelog).
35
37
 
36
38
  ## Benchmarks
37
39
 
40
+ > **These are a dated snapshot, not a live claim.** Every figure below comes from **one measurement run on 2026-07-25 against turbine-orm 0.50.0**. It has not been re-run since, and the package you are installing is several minor versions ahead of it. Nothing here has been adjusted to match a later release, because inventing numbers is worse than quoting old ones. Read the table as *the shape of the result* (who leads which scenario, and by roughly how much) rather than as the latency your deployment will see, and reproduce it with the command at the end of this section if the absolute values matter to you. The same reasoning applies to the bundle-size figure above: a precise number typed into prose goes stale silently, so prefer the claim that cannot rot.
41
+
38
42
  Measured **2026-07-25 against turbine-orm 0.50.0** (commit `f8fec86`), tested against **Prisma 7.9.0** (`@prisma/adapter-pg`, `relationJoins` preview on) and **Drizzle 0.45.2** (relational queries) on a **local PostgreSQL 17.9** database over a Unix socket, with a **hand-written `pg` control arm**. Node v24.18.0, Apple Silicon MacBook Pro (M5 Max). Same schema, same data (1K users, 10K posts, 50K comments), same pool config.
39
43
 
40
44
  Every arm runs once per round, the arm order rotates every round, and each figure is the median over 200 rounds, taken as the median of three full runs. A local socket has no network round-trip, so these numbers are mostly sub-millisecond and isolate per-query overhead instead of hiding it behind network latency.
@@ -54,18 +58,18 @@ Prisma's nested scenarios run on its **`join`** load strategy, which is its favo
54
58
  | pipeline, 5-query batch | **0.206 ms** | 0.431 ms | 0.402 ms | 0.205 ms |
55
59
  | hot findUnique, 500x same shape | **0.029 ms** | 0.065 ms | 0.075 ms | 0.033 ms |
56
60
 
57
- **The number worth quoting: Turbine runs at 1.07x hand-written `pg`, where Drizzle runs at 1.47x and Prisma at 1.84x** (geometric mean over the eight scenarios with a raw control). Across all ten scenarios Turbine is **1.87x faster than Prisma 7.9** and **1.36x faster than Drizzle 0.45** by geometric mean.
61
+ **The number worth quoting from that run: Turbine ran at 1.07x hand-written `pg`, where Drizzle ran at 1.47x and Prisma at 1.84x** (geometric mean over the eight scenarios with a raw control). Across all ten scenarios Turbine was **1.87x faster than Prisma 7.9** and **1.36x faster than Drizzle 0.45** by geometric mean, against those two competitor versions.
58
62
 
59
63
  - **Turbine takes seven scenarios**, Drizzle three, Prisma none. Prisma is behind Turbine on all ten. Two of the ten (L3 nested, atomic increment) are genuine near-ties and are not leads for either side.
60
64
  - **Drizzle wins streaming outright, by 27%** (50.18 ms vs Turbine's 63.87 ms), reproduced in every run. Drizzle sits exactly on the raw `pg` keyset control, which is where a thin builder should sit; Turbine's cursor carries about 25% overhead above hand-written keyset pagination on a full-table drain. That overhead buys cursor semantics keyset cannot offer (any `orderBy`, deterministic early break, nested `with` per batch), but on this shape it is a loss and it is an open optimization target. A previous version of this table showed Turbine fastest here and called it a near-tie; that rested on a Drizzle figure we could not reproduce, and it was wrong.
61
65
  - **Drizzle also leads nested reads (L2).** Turbine's `json_agg` nesting is close behind and **1.9x to 3.0x ahead of Prisma** on the same L2/L3 shapes.
62
66
  - **Pipeline batching is Turbine's clearest win**: one TCP flush for 5 queries runs the dashboard batch 2.09x faster than Prisma's and 1.95x faster than Drizzle's sequential transaction, level with the raw `pg` control.
63
67
 
64
- > **Read the drift floor before quoting a sub-millisecond figure.** The identical raw control arm drifts 1% to 14% between runs on the multi-millisecond scenarios but **21% to 47%** on the sub-0.15 ms ones (findUnique by PK, count, atomic increment, hot findUnique, pipeline). Those orderings are stable across all five runs; their absolute values carry roughly one third uncertainty. Full per-run drift tables in [`benchmarks/RESULTS-0.50.0.md`](./benchmarks/RESULTS-0.50.0.md).
68
+ > **Read the drift floor before quoting a sub-millisecond figure.** The identical raw control arm drifts 1% to 14% between runs on the multi-millisecond scenarios but **21% to 47%** on the sub-0.15 ms ones (findUnique by PK, count, atomic increment, hot findUnique, pipeline). Those orderings are stable across all five runs; their absolute values carry roughly one third uncertainty. Full per-run drift tables in [`benchmarks/RESULTS-0.50.0.md`](https://github.com/zvndev/turbine-orm/blob/main/benchmarks/RESULTS-0.50.0.md).
65
69
 
66
- Net: Turbine is competitive-to-ahead across the board rather than a clean sweep, and the honest takeaway is unchanged: performance is close enough that the real reasons to choose Turbine are elsewhere. **One dependency and no WASM** (vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler), the **only read-only-by-default Studio** in the TS ORM ecosystem, **PII-safe error messages** that never leak user data, and **SQL-first migrations** with SHA-256 drift detection. Deep type inference through `with` clauses works end-to-end: write `db.users.findMany({ with: { posts: { with: { comments: true } } } })` and `users[0].posts[0].comments[0].body` autocompletes, with no manual assertion and no helper annotation.
70
+ Net, as of that run: Turbine was competitive-to-ahead across the board rather than a clean sweep, and the takeaway is the part that does not go stale: performance is close enough that the real reasons to choose Turbine are elsewhere. **One dependency and no WASM** (vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler), the **only read-only-by-default Studio** in the TS ORM ecosystem, **PII-safe error messages** that never leak user data, and **SQL-first migrations** with SHA-256 drift detection. Deep type inference through `with` clauses works end-to-end: write `db.users.findMany({ with: { posts: { with: { comments: true } } } })` and `users[0].posts[0].comments[0].body` autocompletes, with no manual assertion and no helper annotation.
67
71
 
68
- > Full analysis, methodology and the drift floor: [`benchmarks/RESULTS-0.50.0.md`](./benchmarks/RESULTS-0.50.0.md). Historical runs: [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md).
72
+ > Full analysis, methodology and the drift floor: [`benchmarks/RESULTS-0.50.0.md`](https://github.com/zvndev/turbine-orm/blob/main/benchmarks/RESULTS-0.50.0.md). Historical runs: [`benchmarks/RESULTS.md`](https://github.com/zvndev/turbine-orm/blob/main/benchmarks/RESULTS.md).
69
73
  > Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench-interleaved.ts`
70
74
 
71
75
  ## Quick Start
@@ -82,7 +86,7 @@ npx turbine init --url postgres://user:pass@localhost:5432/mydb
82
86
  npx turbine generate
83
87
  ```
84
88
 
85
- > **CLI prerequisites.** The `turbine` CLI loads your `turbine.config.ts` / `turbine/schema.ts` directly, so a fresh project needs `tsx` installed (otherwise `.ts` config loading fails with *"Loading .ts config / schema files requires tsx to be installed"*). Turbine ships both ESM and CommonJS builds, so the CLI loads your config and schema correctly in either an ESM (`"type": "module"`) or a CommonJS project; ESM is recommended but not required. See [USING-TURBINE-ORM.md §0](docs/USING-TURBINE-ORM.md) for details.
89
+ > **CLI prerequisites.** The `turbine` CLI loads your `turbine.config.ts` / `turbine/schema.ts` directly, so a fresh project needs `tsx` installed (otherwise `.ts` config loading fails with *"Loading .ts config / schema files requires tsx to be installed"*). Turbine ships both ESM and CommonJS builds, so the CLI loads your config and schema correctly in either an ESM (`"type": "module"`) or a CommonJS project; ESM is recommended but not required. See [USING-TURBINE-ORM.md §0](https://github.com/zvndev/turbine-orm/blob/main/docs/USING-TURBINE-ORM.md) for details.
86
90
 
87
91
  The `turbine-orm` package ships real dual builds, so importing the package works from either module system:
88
92
 
@@ -534,7 +538,47 @@ await db.users.findMany({ where: { role: 'admin' } });
534
538
  // SELECT ... FROM "users" WHERE "role" = $1 AND "deleted_at" IS NULL
535
539
  ```
536
540
 
537
- Values are always parameterized. Opt a single query out with `skipGlobalFilters: true` (or a table-name array). Note that a global filter does **not** satisfy the empty-`where` guard: an `update` or `delete` with no `where` of its own still throws unless you pass `allowFullTableScan: true`.
541
+ Values are always parameterized. Opt a single query out with `skipGlobalFilters: UNSAFE`, or skip named tables with `skipGlobalFilters: [UNSAFE, 'posts']`. Note that a global filter does **not** satisfy the empty-`where` guard: an `update` or `delete` with no `where` of its own still throws unless you pass `allowFullTableScan: UNSAFE`.
542
+
543
+ ### Privilege options and the `UNSAFE` symbol
544
+
545
+ Three query options remove a safety boundary rather than change a result: `skipGlobalFilters` (drops the tenant or soft-delete predicate), `includePii` (drops the PII projection), and `allowFullTableScan` (drops the empty-`where` guard on a mutation). Each is enabled by **one** value, a symbol exported from the package:
546
+
547
+ ```typescript
548
+ import { UNSAFE } from 'turbine-orm';
549
+
550
+ await db.posts.findMany({ skipGlobalFilters: UNSAFE }); // skip every filter
551
+ await db.users.findMany({ with: { posts: true }, skipGlobalFilters: [UNSAFE, 'posts'] });
552
+ await db.users.findFirst({ where: { id: 1 }, includePii: UNSAFE });
553
+ await db.sessions.deleteMany({ where: {}, allowFullTableScan: UNSAFE }); // every row, on purpose
554
+ ```
555
+
556
+ `where` stays required on the mutations. `allowFullTableScan` permits an **empty** `where`; it does not let you omit the key.
557
+
558
+ **Why a symbol.** All three sit on the same options object as `where`, and the idiomatic handler spreads a request body:
559
+
560
+ ```typescript
561
+ app.get('/users', (req, res) => db.users.findMany({ ...req.body }));
562
+ ```
563
+
564
+ A client posting `{"where":{"name":"x"},"skipGlobalFilters":true}` used to get the same statement minus the tenant predicate: the documented multi-tenancy mechanism, removed over the wire by the party it exists to contain. `includePii: true` reached the PII columns the same way, and `allowFullTableScan: true` disarmed the guard on an unqualified `UPDATE` or `DELETE`. Typing them `boolean` and writing "be careful" is not a fix, because this is a mass-assignment shape and mass assignment happens exactly when nobody enumerated the keys. `JSON.parse` cannot produce a symbol, and neither can a query string, a form body, or a `structuredClone` of parsed input, so there is no untrusted-data path that puts `UNSAFE` on an args object at all. The array form is policed just as hard, since `{"skipGlobalFilters":["users"]}` is the same breach with one extra step. (`UNSAFE` is `Symbol.for('turbine-orm.UNSAFE')`, not `Symbol()`, so the ESM and CJS copies of a dual-package install agree on one value.)
565
+
566
+ **Upgrading.** This is breaking on those three options, deliberately loudly.
567
+
568
+ | Before | After |
569
+ |---|---|
570
+ | `skipGlobalFilters: true` | `skipGlobalFilters: UNSAFE` |
571
+ | `skipGlobalFilters: ['posts']` | `skipGlobalFilters: [UNSAFE, 'posts']` |
572
+ | `includePii: true` | `includePii: UNSAFE` |
573
+ | `allowFullTableScan: true` | `allowFullTableScan: UNSAFE` |
574
+
575
+ The options are typed as the symbol's type, `Unsafe`, so **every** row of that table is a compile error before it is a runtime error, and so is `allowFullTableScan: false`. A conditional call site is written by adding the key or not, never by passing a boolean:
576
+
577
+ ```typescript
578
+ await db.sessions.deleteMany({ where: {}, ...(purgeEverything ? { allowFullTableScan: UNSAFE } : {}) });
579
+ ```
580
+
581
+ At runtime, `false`, `null` and `undefined` are still accepted and mean "not enabled", which keeps untyped call sites (plain JS, or args that arrive as `any`) from breaking on a value that never asked for the privilege. Everything else, `true` included, throws `ValidationError` (`TURBINE_E003`) naming the option and the import. Ignoring a stale `true` would trade an escalation bug for a silent-failure bug: an admin tool would quietly stop seeing soft-deleted rows, or quietly return objects with the PII columns missing. `turbine-orm/prisma-compat` forwards these three verbatim, so the same rule applies there and the adapter carries no second copy of it. Full rationale: [turbineorm.dev/global-filters](https://turbineorm.dev/global-filters#privilege-options-and-the-unsafe-symbol).
538
582
 
539
583
  ### Error handling
540
584
 
@@ -1068,13 +1112,13 @@ Everything is honest about what ports and what doesn't. Features marked **PG-onl
1068
1112
  |---|:---:|:---:|:---:|:---:|
1069
1113
  | Single-query nested `with` | ✓ `json_agg` | ✓ `json_group_array` | ✓ `JSON_ARRAYAGG` | ✓ `FOR JSON PATH` |
1070
1114
  | Transactions + savepoints | ✓ | ✓ (single-writer) | ✓ | ✓ |
1071
- | Streaming (`findManyStream`) | ✓ | | | |
1115
+ | Streaming (`findManyStream`) | ✓ true cursor | materializes | materializes | materializes |
1072
1116
  | Migrations (`turbine migrate` CLI) | ✓ | PG-only (CLI) | PG-only (CLI) | PG-only (CLI) |
1073
1117
  | pgvector distance / KNN | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
1074
1118
  | LISTEN/NOTIFY realtime | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
1075
1119
  | RLS `sessionContext` | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
1076
1120
 
1077
- ✗ E017 = throws `UnsupportedFeatureError`. The full matrix (atomic updates, introspection, optimistic locking, per-cell mechanics) is on [turbineorm.dev/engines](https://turbineorm.dev/engines).
1121
+ ✗ E017 = throws `UnsupportedFeatureError`. ⚠ materializes = the API works and returns the same rows, but the whole result set is held in memory first and then yielded in batches, so it does **not** give you constant memory (see Engine notes). The full matrix (atomic updates, introspection, optimistic locking, per-cell mechanics) is on [turbineorm.dev/engines](https://turbineorm.dev/engines).
1078
1122
 
1079
1123
  **Engine notes:** SQLite uses `RETURNING` (≥ 3.35) just like Postgres. MySQL has no `RETURNING`, so writes re-`SELECT` the affected row and **`createMany` returns `[]`** (the rows ARE inserted, re-query if you need them). SQL Server returns rows via `OUTPUT`/`MERGE`; `DISTINCT ON` is Postgres-only. Only Postgres streams via a true cursor (constant memory); the other engines' `findManyStream` materializes the result then yields it in batches. Optimistic locking throws `OptimisticLockError` on all engines (on MySQL the conflict is detected from the version-checked UPDATE's affected-row count). The `turbine` CLI (`generate`, `migrate`) is currently PostgreSQL-only, point the engine factories at a hand-written or programmatically introspected `SCHEMA`.
1080
1124
 
@@ -1131,14 +1175,14 @@ Turbine maps Postgres types to TypeScript:
1131
1175
  |---|---|---|---|---|
1132
1176
  | **Engine / runtime** | No engine binary (`pg` only) | Client + TS/WASM query compiler | No engine | No engine |
1133
1177
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + required driver adapter | 0 | 0 |
1134
- | **Main bundle (brotli)** | ~65 kB import graph, `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
1178
+ | **Main bundle (brotli)** | under 77 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
1135
1179
  | **Studio** | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Free; hosted Gateway paid | None |
1136
1180
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
1137
1181
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
1138
- | **Edge runtime** | One import swap, ~50 kB brotli | Driver adapter + WASM compiler | Native | Native |
1182
+ | **Edge runtime** | One import swap, under 61 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native |
1139
1183
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
1140
1184
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
1141
- | **Nested relations** | 1 query, deep type inference | 1 query, shallow inference | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
1185
+ | **Nested relations** | 1 query, deep type inference | 1 query per relation by default; single-query `relationJoins` is still Preview and whole-query only | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
1142
1186
  | **Many-to-many** | Auto-detected from junctions | Implicit/explicit | Explicit `relations()` | Manual joins |
1143
1187
  | **Vector search** | Built-in `distance` / KNN | Preview / raw | Extension API | Manual |
1144
1188
  | **LISTEN/NOTIFY** | `$listen` / `$notify` | None | None | None |
@@ -1148,7 +1192,7 @@ Reading the table: no engine binary and no WASM, just one runtime dependency (`p
1148
1192
 
1149
1193
  *Competitor columns are re-verified against competitor releases on a fixed schedule. Last checked July 2026, against Prisma 7 and Drizzle 0.45. Features marked Preview or beta may change, and bundle sizes move release to release.*
1150
1194
 
1151
- **A note on Kysely.** Kysely's [`jsonArrayFrom` / `jsonObjectFrom`](https://kysely.dev/docs/recipes/relations) relations recipe builds nested results with the same correlated-subquery-plus-JSON approach Turbine uses, good evidence the pattern is the right one. The gap is in what the driver can no longer see once rows are aggregated into JSON: nested fields lose their column types, so a `Date` inside a `jsonArrayFrom` result is typed `Date` but arrives as a **string** at runtime ([kysely-org/kysely#482](https://github.com/kysely-org/kysely/issues/482)), and the nesting isn't type-checked at depth. Turbine's `WithResult` inference types the whole tree, and `parseNestedRow` re-applies date coercion (and snake→camel mapping) to every nested row, so `users[0].posts[0].createdAt` is an actual `Date`, at any depth, with no plugin to wire up.
1195
+ **A note on Kysely.** Kysely's [`jsonArrayFrom` / `jsonObjectFrom`](https://kysely.dev/docs/recipes/relations) relations recipe builds nested results with the same correlated-subquery-plus-JSON approach Turbine uses, good evidence the pattern is the right one. The gap is in what the driver can no longer see once rows are aggregated into JSON: a `Date` nested inside a `jsonArrayFrom` result arrives as a **string** at runtime, because the aggregation erased the column type the driver would otherwise have parsed on. Kysely types that honestly and leaves the conversion to you. Turbine's `parseNestedRow` re-applies date coercion and snake→camel mapping to every nested row, so `users[0].posts[0].createdAt` is an actual `Date` at any depth with nothing to wire up. Stated from documented behaviour rather than from an issue-tracker link: a linked issue's state changes without notice, and a claim resting on one goes stale silently.
1152
1196
 
1153
1197
  ## Limitations
1154
1198
 
@@ -1162,17 +1206,17 @@ Turbine is focused and opinionated. Here's what it doesn't do:
1162
1206
 
1163
1207
  **Feature demos**
1164
1208
 
1165
- - **[Thread Machine](./examples/thread-machine/)**, HN clone rendered from a single `findMany`. 4-level object graph (stories → comments → replies → author), every property autocompletes through the chain
1166
- - **[Streaming CSV](./examples/streaming-csv/)**, Export 100K orders + line items to CSV with constant memory. PostgreSQL cursors, live heap meter, nested `with` inside `findManyStream`
1167
- - **[Clickstorm](./examples/clickstorm/)**, Side-by-side atomic-increment vs read-modify-write load test. 10K concurrent clicks. The atomic path wins every time
1209
+ - **[Thread Machine](https://github.com/zvndev/turbine-orm/tree/main/examples/thread-machine/)**, HN clone rendered from a single `findMany`. 4-level object graph (stories → comments → replies → author), every property autocompletes through the chain
1210
+ - **[Streaming CSV](https://github.com/zvndev/turbine-orm/tree/main/examples/streaming-csv/)**, Export 100K orders + line items to CSV with constant memory. PostgreSQL cursors, live heap meter, nested `with` inside `findManyStream`
1211
+ - **[Clickstorm](https://github.com/zvndev/turbine-orm/tree/main/examples/clickstorm/)**, Side-by-side atomic-increment vs read-modify-write load test. 10K concurrent clicks. The atomic path wins every time
1168
1212
 
1169
1213
  **Runtime targets**
1170
1214
 
1171
- - **[Next.js](./examples/nextjs/)**, Server-rendered app with nested relations, streaming, and live code demos
1172
- - **[Neon Edge](./examples/neon-edge/)**, Vercel Edge route handler talking to Neon over HTTP via `@neondatabase/serverless`
1173
- - **[Vercel Postgres](./examples/vercel-postgres/)**, Next.js app router route handler on `@vercel/postgres`
1174
- - **[Cloudflare Worker](./examples/cloudflare-worker/)**, Worker `fetch` handler with `pg` over Cloudflare Hyperdrive
1175
- - **[Supabase](./examples/supabase/)**, Standalone script over the standard `pg` driver against Supabase
1215
+ - **[Next.js](https://github.com/zvndev/turbine-orm/tree/main/examples/nextjs/)**, Server-rendered app with nested relations, streaming, and live code demos
1216
+ - **[Neon Edge](https://github.com/zvndev/turbine-orm/tree/main/examples/neon-edge/)**, Vercel Edge route handler talking to Neon over HTTP via `@neondatabase/serverless`
1217
+ - **[Vercel Postgres](https://github.com/zvndev/turbine-orm/tree/main/examples/vercel-postgres/)**, Next.js app router route handler on `@vercel/postgres`
1218
+ - **[Cloudflare Worker](https://github.com/zvndev/turbine-orm/tree/main/examples/cloudflare-worker/)**, Worker `fetch` handler with `pg` over Cloudflare Hyperdrive
1219
+ - **[Supabase](https://github.com/zvndev/turbine-orm/tree/main/examples/supabase/)**, Standalone script over the standard `pg` driver against Supabase
1176
1220
 
1177
1221
  ## Guides
1178
1222
 
@@ -1194,14 +1238,14 @@ Turbine is focused and opinionated. Here's what it doesn't do:
1194
1238
 
1195
1239
  ## Contributing
1196
1240
 
1197
- Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the test strategy, and the PR checklist. Participants agree to the [Code of Conduct](CODE_OF_CONDUCT.md). The unit suite runs without a database:
1241
+ Contributions are welcome. See [CONTRIBUTING.md](https://github.com/zvndev/turbine-orm/blob/main/CONTRIBUTING.md) for development setup, the test strategy, and the PR checklist. Participants agree to the [Code of Conduct](https://github.com/zvndev/turbine-orm/blob/main/CODE_OF_CONDUCT.md). The unit suite runs without a database:
1198
1242
 
1199
1243
  ```bash
1200
1244
  npm install
1201
1245
  npm run test:unit
1202
1246
  ```
1203
1247
 
1204
- Integration tests need a PostgreSQL instance via `DATABASE_URL` (see CONTRIBUTING.md for a one-command seeded setup).
1248
+ Integration tests need a PostgreSQL instance via `DATABASE_URL` (see [CONTRIBUTING.md](https://github.com/zvndev/turbine-orm/blob/main/CONTRIBUTING.md) for a one-command seeded setup).
1205
1249
 
1206
1250
  ## License
1207
1251
 
@@ -189,4 +189,44 @@ export declare function resolveConfig(fileConfig: TurbineCliConfig, overrides: C
189
189
  * order (root-level first, then the `turbine/` location `init` scaffolds).
190
190
  */
191
191
  export declare function resolveSeedFile(config: Pick<TurbineCliConfig, 'seed' | 'seedFile'>, cwd?: string): string | null;
192
+ /**
193
+ * Does this connection string carry a password?
194
+ *
195
+ * `turbine init --url` used to inline whatever it was given straight into
196
+ * `turbine.config.ts`, a file projects commit, so the documented one-liner
197
+ * (`turbine init --url postgres://user:PASSWORD@host/db`) committed a live
198
+ * database password. Everything that decides between "inline the string" and
199
+ * "read `process.env.DATABASE_URL`" asks this function, so there is exactly one
200
+ * definition of "this value is a secret".
201
+ *
202
+ * Three spellings carry a password, and libpq (and `pg-connection-string`,
203
+ * which is what `pg` actually parses with) accepts all three:
204
+ *
205
+ * 1. URL userinfo: `postgres://user:pass@host/db`.
206
+ * 2. URL query parameter: `postgres://user@host/db?password=pass`, and its
207
+ * siblings such as `?sslpassword=`. This one was missed, on the reasoning
208
+ * that a successful `new URL` ruled out the keyword form below, which is
209
+ * true and irrelevant: the secret was in the query string.
210
+ * 3. libpq keyword form: `host=... password=...` (also `sslpassword=`), which
211
+ * is not a URL at all.
212
+ *
213
+ * The query-parameter test reuses `PASSWORD_QUERY_PARAM_PATTERN`, the very
214
+ * regex `redactUrl` redacts with, so a spelling the terminal output hides can
215
+ * never be a spelling this function calls safe to commit. `pg-connection-string`
216
+ * itself would be the ideal oracle, but it is only a TRANSITIVE dependency (of
217
+ * `pg`), and importing an undeclared package would break on any strict,
218
+ * non-hoisting installer. Detection stays self-contained.
219
+ *
220
+ * @internal exported for tests.
221
+ */
222
+ export declare function connectionStringHasPassword(connectionString: string): boolean;
223
+ /**
224
+ * The `turbine.config.ts` scaffold.
225
+ *
226
+ * A password-bearing `connectionString` is NEVER inlined: the emitted config
227
+ * reads `process.env.DATABASE_URL` instead, and `turbine init` scaffolds the
228
+ * `.env` that holds the real value. The refusal lives here rather than at the
229
+ * call site so no future caller can reintroduce the leak by passing the raw
230
+ * `--url` through.
231
+ */
192
232
  export declare function configTemplate(connectionString?: string): string;
@@ -48,10 +48,12 @@ exports.loadConfig = loadConfig;
48
48
  exports.findConfigFile = findConfigFile;
49
49
  exports.resolveConfig = resolveConfig;
50
50
  exports.resolveSeedFile = resolveSeedFile;
51
+ exports.connectionStringHasPassword = connectionStringHasPassword;
51
52
  exports.configTemplate = configTemplate;
52
53
  const node_fs_1 = require("node:fs");
53
54
  const node_path_1 = require("node:path");
54
55
  const node_url_1 = require("node:url");
56
+ const ui_js_1 = require("./ui.js");
55
57
  /**
56
58
  * Heuristic: does a configured `schema` value actually look like a schema FILE
57
59
  * path rather than a Postgres schema name? `schema` is the Postgres namespace
@@ -235,9 +237,79 @@ function resolveSeedFile(config, cwd = process.cwd()) {
235
237
  // ---------------------------------------------------------------------------
236
238
  // Config file template (for `turbine init`)
237
239
  // ---------------------------------------------------------------------------
240
+ /**
241
+ * Does this connection string carry a password?
242
+ *
243
+ * `turbine init --url` used to inline whatever it was given straight into
244
+ * `turbine.config.ts`, a file projects commit, so the documented one-liner
245
+ * (`turbine init --url postgres://user:PASSWORD@host/db`) committed a live
246
+ * database password. Everything that decides between "inline the string" and
247
+ * "read `process.env.DATABASE_URL`" asks this function, so there is exactly one
248
+ * definition of "this value is a secret".
249
+ *
250
+ * Three spellings carry a password, and libpq (and `pg-connection-string`,
251
+ * which is what `pg` actually parses with) accepts all three:
252
+ *
253
+ * 1. URL userinfo: `postgres://user:pass@host/db`.
254
+ * 2. URL query parameter: `postgres://user@host/db?password=pass`, and its
255
+ * siblings such as `?sslpassword=`. This one was missed, on the reasoning
256
+ * that a successful `new URL` ruled out the keyword form below, which is
257
+ * true and irrelevant: the secret was in the query string.
258
+ * 3. libpq keyword form: `host=... password=...` (also `sslpassword=`), which
259
+ * is not a URL at all.
260
+ *
261
+ * The query-parameter test reuses `PASSWORD_QUERY_PARAM_PATTERN`, the very
262
+ * regex `redactUrl` redacts with, so a spelling the terminal output hides can
263
+ * never be a spelling this function calls safe to commit. `pg-connection-string`
264
+ * itself would be the ideal oracle, but it is only a TRANSITIVE dependency (of
265
+ * `pg`), and importing an undeclared package would break on any strict,
266
+ * non-hoisting installer. Detection stays self-contained.
267
+ *
268
+ * @internal exported for tests.
269
+ */
270
+ function connectionStringHasPassword(connectionString) {
271
+ const value = connectionString.trim();
272
+ if (!value)
273
+ return false;
274
+ try {
275
+ const parsed = new URL(value);
276
+ // An empty password ("postgres://user@host/db", "?password=") is not a
277
+ // secret. decodeURIComponent is deliberately NOT applied: presence is all
278
+ // we need, and a percent-encoded value is still a non-empty one.
279
+ if (parsed.password !== '')
280
+ return true;
281
+ for (const match of value.matchAll(new RegExp(ui_js_1.PASSWORD_QUERY_PARAM_PATTERN, 'gi'))) {
282
+ if (match[2] !== '')
283
+ return true;
284
+ }
285
+ // A string libpq parses as a URI is never re-parsed as the keyword form, so
286
+ // the check below genuinely cannot apply here.
287
+ return false;
288
+ }
289
+ catch {
290
+ // Not a URL: fall through to the libpq keyword form.
291
+ }
292
+ // A keyword whose name ends in `password` (`password`, `sslpassword`) with a
293
+ // non-empty value, at the start of the string or after whitespace.
294
+ return /(^|\s)[^\s=]*password\s*=\s*\S/i.test(value);
295
+ }
296
+ /**
297
+ * The `turbine.config.ts` scaffold.
298
+ *
299
+ * A password-bearing `connectionString` is NEVER inlined: the emitted config
300
+ * reads `process.env.DATABASE_URL` instead, and `turbine init` scaffolds the
301
+ * `.env` that holds the real value. The refusal lives here rather than at the
302
+ * call site so no future caller can reintroduce the leak by passing the raw
303
+ * `--url` through.
304
+ */
238
305
  function configTemplate(connectionString) {
239
- const _url = connectionString ?? 'process.env.DATABASE_URL';
240
- const urlLine = connectionString ? ` url: '${connectionString}',` : ` url: process.env.DATABASE_URL,`;
306
+ const inlineUrl = connectionString && !connectionStringHasPassword(connectionString) ? connectionString : undefined;
307
+ // Single quotes are the string delimiter in the emitted TS, so a connection
308
+ // string containing one would otherwise produce a config file that does not
309
+ // parse (a password-free URL can still carry a quote in a query parameter).
310
+ const urlLine = inlineUrl
311
+ ? ` url: '${inlineUrl.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}',`
312
+ : ` url: process.env.DATABASE_URL,`;
241
313
  return `import type { TurbineCliConfig } from 'turbine-orm/cli';
242
314
 
243
315
  /**
@@ -67,6 +67,12 @@ export interface CliArgs {
67
67
  yes?: boolean;
68
68
  /** `init --skip-schema`: don't scaffold the schema file. */
69
69
  skipSchema?: boolean;
70
+ /**
71
+ * `init --with-schema`: scaffold the starter schema file even when the
72
+ * database already has tables. The escape hatch for a code-first project
73
+ * bootstrapping against a populated database.
74
+ */
75
+ withSchema?: boolean;
70
76
  /** `init --skip-seed`: don't scaffold the seed file or offer to run it. */
71
77
  skipSeed?: boolean;
72
78
  /** `init --skip-push`: don't offer to push the schema to the database. */
@@ -241,7 +247,9 @@ export type InitStepId = 'config' | 'schema' | 'seed-file' | 'push' | 'generate'
241
247
  /** What the planner decided to do with a step. */
242
248
  export type InitStepAction = 'run' | 'prompt' | 'skip';
243
249
  /** Why a step was skipped (only set when `action` is `skip`). */
244
- export type InitStepSkipReason = 'exists' | 'flag' | 'no-url' | 'unreachable' | 'no-seed-file' | 'non-interactive' | 'default-no';
250
+ export type InitStepSkipReason = 'exists' | 'flag' | 'no-url' | 'unreachable' | 'no-seed-file' | 'non-interactive' | 'default-no'
251
+ /** The database already has tables, so an EMPTY code-first schema file is wrong. */
252
+ | 'db-has-tables';
245
253
  export interface InitPlanStep {
246
254
  id: InitStepId;
247
255
  action: InitStepAction;
@@ -256,6 +264,12 @@ export interface InitPlanState {
256
264
  seedFileExists: boolean;
257
265
  hasUrl: boolean;
258
266
  dbReachable: boolean;
267
+ /**
268
+ * The reachable database already contains user tables. Optional: absent means
269
+ * "not probed / unknown", which keeps the pre-0.62 plan for every caller that
270
+ * does not supply it.
271
+ */
272
+ dbHasTables?: boolean;
259
273
  }
260
274
  /** Effective flags for the planner. */
261
275
  export interface InitPlanFlags {
@@ -263,6 +277,11 @@ export interface InitPlanFlags {
263
277
  force: boolean;
264
278
  interactive: boolean;
265
279
  skipSchema: boolean;
280
+ /**
281
+ * `--with-schema`: scaffold the schema file even against a database that
282
+ * already has tables. Loses to `skipSchema`, which is the explicit "don't".
283
+ */
284
+ withSchema?: boolean;
266
285
  skipSeed: boolean;
267
286
  skipPush: boolean;
268
287
  skipGenerate: boolean;
@@ -284,6 +303,63 @@ export interface InitPlanFlags {
284
303
  * skipped when there is no URL or the database is unreachable.
285
304
  */
286
305
  export declare function planInitSteps(state: InitPlanState, flags: InitPlanFlags): InitPlanStep[];
306
+ /** What the secret-handling scaffold decided to do with one file. */
307
+ export type EnvScaffoldAction = 'created' | 'appended' | 'unchanged';
308
+ /** Detected state of the three files the scaffold touches (all IO by the caller). */
309
+ export interface EnvScaffoldState {
310
+ envExists: boolean;
311
+ /** `.env` already assigns DATABASE_URL (their value wins; we never rewrite it). */
312
+ envHasDatabaseUrl: boolean;
313
+ envExampleExists: boolean;
314
+ gitignoreExists: boolean;
315
+ gitignoreIgnoresEnv: boolean;
316
+ }
317
+ export interface EnvScaffoldPlan {
318
+ env: EnvScaffoldAction;
319
+ envExample: EnvScaffoldAction;
320
+ gitignore: EnvScaffoldAction;
321
+ }
322
+ /**
323
+ * Does this `.gitignore` text already ignore `.env`?
324
+ *
325
+ * Line-based rather than a substring search: a `.gitignore` mentioning
326
+ * `.env.example` (a very common line, since the example file is the one you DO
327
+ * commit) contains the text `.env` while ignoring nothing of the kind, and
328
+ * treating that as covered is how the password would stay committable.
329
+ * Negations (`!.env`) are honored as a later line overriding an earlier one,
330
+ * exactly as git resolves them.
331
+ *
332
+ * @internal exported for tests.
333
+ */
334
+ export declare function gitignoreIgnoresEnv(content: string): boolean;
335
+ /**
336
+ * Decide what the `--url`-carries-a-password scaffold writes. Pure, so the whole
337
+ * matrix (fresh project, existing `.env`, existing `.gitignore`, re-run) is
338
+ * testable without a filesystem.
339
+ *
340
+ * An existing `DATABASE_URL` in `.env` is NEVER rewritten: it is the value the
341
+ * project already runs against, and silently repointing it at the `--url` from
342
+ * one command line is a worse failure than printing a notice.
343
+ *
344
+ * @internal exported for tests.
345
+ */
346
+ export declare function planEnvScaffold(state: EnvScaffoldState): EnvScaffoldPlan;
347
+ /**
348
+ * Move a password-bearing `--url` out of `turbine.config.ts` and into `.env`.
349
+ *
350
+ * `turbine init --url postgres://user:PASSWORD@host/db` is the documented
351
+ * one-liner, and it used to inline that string verbatim into a file projects
352
+ * commit, with no `.gitignore` written at all. The config template now refuses
353
+ * to inline a secret (see `configTemplate`), so the real value has to land
354
+ * somewhere the config can read it from: this writes `.env`, scaffolds the
355
+ * committable `.env.example` next to it, and makes sure `.gitignore` covers
356
+ * `.env` BEFORE the secret is on disk long enough to be staged.
357
+ *
358
+ * Paths are cwd-relative, exactly like the rest of the init scaffold.
359
+ *
360
+ * @internal exported for tests.
361
+ */
362
+ export declare function scaffoldEnvForUrl(url: string): EnvScaffoldPlan;
287
363
  /**
288
364
  * The one-line connection heads-up `turbine init` opens with.
289
365
  *
@@ -366,3 +442,11 @@ export declare function getSeedExecutionPlan(seedFile: string): SeedExecutionPla
366
442
  * `--allow-remote`. Accepts IPv4, IPv6, and the common bracket form.
367
443
  */
368
444
  export declare function isLoopbackHost(host: string): boolean;
445
+ /**
446
+ * Print `<command> --help` for a command that has real help, returning whether
447
+ * one existed. Falling through to the GLOBAL help is the failure mode this map
448
+ * guards against, and it is silent, so the coverage is asserted in tests.
449
+ *
450
+ * @internal exported for tests.
451
+ */
452
+ export declare function showSubcommandHelp(command: string): boolean;