turbine-orm 0.70.0 → 0.71.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 (46) hide show
  1. package/README.md +164 -1041
  2. package/dist/cjs/cli/compile-query.d.ts +198 -0
  3. package/dist/cjs/cli/compile-query.js +529 -0
  4. package/dist/cjs/cli/index.d.ts +25 -1
  5. package/dist/cjs/cli/index.js +49 -1
  6. package/dist/cjs/cli/mcp.js +198 -16
  7. package/dist/cjs/client.d.ts +45 -10
  8. package/dist/cjs/client.js +21 -3
  9. package/dist/cjs/connection-url.d.ts +160 -0
  10. package/dist/cjs/connection-url.js +296 -0
  11. package/dist/cjs/index-stats.d.ts +4 -1
  12. package/dist/cjs/index-stats.js +27 -11
  13. package/dist/cjs/index.d.ts +1 -1
  14. package/dist/cjs/plan-flip-probe.js +17 -1
  15. package/dist/cjs/powql.d.ts +1 -0
  16. package/dist/cjs/powql.js +9 -0
  17. package/dist/cjs/query/builder.d.ts +133 -2
  18. package/dist/cjs/query/builder.js +288 -64
  19. package/dist/cjs/query/deferred.d.ts +12 -6
  20. package/dist/cjs/query/index.d.ts +1 -1
  21. package/dist/cjs/query/option-surface.js +6 -0
  22. package/dist/cjs/query/types.d.ts +47 -0
  23. package/dist/cjs/query/where.d.ts +11 -2
  24. package/dist/cli/compile-query.d.ts +198 -0
  25. package/dist/cli/compile-query.js +522 -0
  26. package/dist/cli/index.d.ts +25 -1
  27. package/dist/cli/index.js +48 -1
  28. package/dist/cli/mcp.js +198 -16
  29. package/dist/client.d.ts +45 -10
  30. package/dist/client.js +19 -1
  31. package/dist/connection-url.d.ts +160 -0
  32. package/dist/connection-url.js +289 -0
  33. package/dist/index-stats.d.ts +4 -1
  34. package/dist/index-stats.js +27 -11
  35. package/dist/index.d.ts +1 -1
  36. package/dist/plan-flip-probe.js +17 -1
  37. package/dist/powql.d.ts +1 -0
  38. package/dist/powql.js +9 -0
  39. package/dist/query/builder.d.ts +133 -2
  40. package/dist/query/builder.js +288 -64
  41. package/dist/query/deferred.d.ts +12 -6
  42. package/dist/query/index.d.ts +1 -1
  43. package/dist/query/option-surface.js +6 -0
  44. package/dist/query/types.d.ts +47 -0
  45. package/dist/query/where.d.ts +11 -2
  46. package/package.json +8 -6
package/README.md CHANGED
@@ -1,123 +1,73 @@
1
1
  # turbine-orm
2
2
 
3
- **The Postgres ORM that assumes your database has real data in it.**
3
+ **A Postgres ORM written from scratch. One dependency.**
4
4
 
5
- Most query layers are designed for the shape of a laptop database: empty, disposable, nobody's. Turbine is designed for the same schema six months later, when it is holding customer records. The database UI you point at it is read-only until you say otherwise. The columns you tagged as personal data stay out of query results, out of logs, and out of aggregates. The operations that can lose data make you say so out loud before they run.
5
+ Turbine compiles typed queries straight to SQL. There is no query engine, no WASM, and nothing between your code and Postgres except `pg`: `dependencies` is literally one line. Nested relations resolve in one statement. The client is small enough for a Worker or a Lambda cold start, sits next to hand-written `pg` in the benchmarks, ships an 11-tool read-only MCP server so coding agents can work your schema safely, and includes the operational tooling (index advice, migration guards, PII projection) that usually lives in a paid cloud tier. MIT, and the engine seam is documented if you want to fork it.
6
6
 
7
7
  ```
8
8
  npm install turbine-orm
9
9
  ```
10
10
 
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)
11
+ **Docs: [turbineorm.dev](https://turbineorm.dev)** · [Quick Start](https://turbineorm.dev/quickstart) · [Why Turbine](https://turbineorm.dev/why-turbine) · [API Reference](https://turbineorm.dev/queries) · [Relations](https://turbineorm.dev/relations) · [AI Agents](https://turbineorm.dev/ai-agents) · [Benchmarks](https://turbineorm.dev/benchmarks) · [Errors](https://turbineorm.dev/errors)
12
12
 
13
- **Contents:** [Why Turbine?](#why-turbine) · [Benchmarks](#benchmarks) · [Quick Start](#quick-start) · [Usage Examples](#usage-examples) · [Vector search](#vector-search-pgvector) · [WHERE Operators](#where-operator-reference) · [CLI](#cli) · [Studio](#studio) · [Observability](#observability) · [Serverless / Edge](#serverless--edge) · [Database engines](#database-engines) · [Configuration](#configuration) · [How It Works](#how-it-works) · [Type Mapping](#type-mapping) · [Comparison](#comparison) · [Limitations](#limitations) · [Examples](#examples) · [Guides](#guides) · [Requirements](#requirements) · [Contributing](#contributing)
13
+ **Contents:** [Why Turbine](#why-turbine) · [Benchmarks](#benchmarks) · [Quick Start](#quick-start) · [Queries](#queries) · [Built for agents](#built-for-agents) · [Safety tooling](#safety-tooling) · [Postgres features](#postgres-features) · [Serverless and edge](#serverless-and-edge) · [Database engines](#database-engines) · [From scratch, and forkable](#from-scratch-and-forkable) · [Comparison](#comparison) · [Limitations](#limitations) · [Requirements](#requirements) · [Contributing](#contributing)
14
14
 
15
- ## Why Turbine?
15
+ ## Why Turbine
16
16
 
17
- *(The same argument, laid out with the comparisons and the caveats, is at [turbineorm.dev/why-turbine](https://turbineorm.dev/why-turbine).)*
17
+ Six reasons, each with the mechanism that makes it true:
18
18
 
19
- 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.
20
-
21
- 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.
22
-
23
- 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.
24
- 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.
25
- 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.
26
- 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.
27
- 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.
28
-
29
- **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.
30
-
31
- Two more things worth knowing, which are about cost rather than safety:
32
-
33
- - **One runtime dependency (`pg`).** No engine binary, no WASM, no adapter packages in lockstep. The main entry's **import graph** is held under **85 kB brotli** (edge under 68 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, and again in 0.66.0 when the budget was re-baselined. 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.
34
- - **Real pipelining at the protocol level.** `db.pipeline(...)` uses the Postgres extended-query protocol (Parse/Bind/Execute/Sync) to put N queries in one TCP flush, so the win is round-trips, not batching semantics. 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 a driver-specific implicit transaction rather than wire pipelining. The batch is atomic by default; `{ transactional: false }` makes the queries independent instead.
35
-
36
- **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).
37
-
38
- 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).
19
+ 1. **One dependency.** `dependencies` is `{ "pg": "^8.13.1" }`. No engine binary, no WASM compiler, no adapter packages in lockstep. The optional engines (SQLite, MySQL, SQL Server, PowDB) are peer dependencies or Node builtins you install only if you use them.
20
+ 2. **Written from scratch.** Turbine is not a layer over Knex or a query-builder library. Query compilation is plain string building with an FNV-1a shape fingerprint into a bounded LRU of SQL templates, so there is no plan cache to size and no compiler running on your event loop.
21
+ 3. **Nested relations in one statement.** A `with` clause compiles to correlated `json_agg` subqueries, so users with posts with comments is one round trip, typed end to end: `users[0].posts[0].comments[0].author.name` autocompletes with no annotation.
22
+ 4. **Close to raw SQL.** In the last published run, Turbine's overhead over a hand-written `pg` control was 1.08x by geometric mean. The table is below; the losses are stated with the wins.
23
+ 5. **Agents get typed tools, not a SQL prompt.** `npx turbine mcp` exposes eleven read-only MCP tools, including a relation graph and a join-path finder that returns the `with` clause to write. Every tool runs inside `BEGIN READ ONLY`, and PII-tagged columns are redacted before rows reach a model.
24
+ 6. **The dangerous operations ask first.** Destructive migration statements refuse to run without typed consent. `update`/`delete` with an empty `where` throws. Columns tagged `pii: true` are excluded from the emitted SQL's projections. `turbine doctor` reports missing FK indexes offline, no account, no telemetry.
39
25
 
40
26
  ## Benchmarks
41
27
 
42
- > **These are a dated snapshot, not a live claim.** Every figure below comes from **one measurement run on 2026-08-09 against turbine-orm 0.66.0**, the release you are installing. Nothing here has been adjusted to match anything, 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.
43
-
44
- Measured **2026-08-09 against turbine-orm 0.66.0**, 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.
28
+ Measured **2026-08-15 against turbine-orm 0.71.0**, versus **Prisma 7.9.1** (`@prisma/adapter-pg`, `relationJoins` on) and **Drizzle 0.45.2** (relational queries), on local **PostgreSQL 17.9** over a Unix socket, with a hand-written `pg` control arm. Node v24.18.0, Apple Silicon (M5 Max). Same schema, same data (1K users, 10K posts, 50K comments), same pool config. Each figure is the median over 200 rounds with arm order rotated per round, taken as the median of three full runs.
45
29
 
46
- 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.
47
-
48
- Prisma's nested scenarios run on its **`join`** load strategy, which is its favorable configuration and is chosen deliberately.
49
-
50
- | Scenario | Turbine 0.66 | Prisma 7.9 | Drizzle 0.45 | raw pg |
30
+ | Scenario | Turbine 0.71 | Prisma 7.9 | Drizzle 0.45 | raw pg |
51
31
  |---|---|---|---|---|
52
- | findMany, 100 users (flat) | **0.189 ms** | 0.254 ms | 0.248 ms | 0.164 ms |
53
- | findMany, 50 users + posts (L2) *(contested, see below)* | 2.379 ms | 4.289 ms | **1.804 ms** | 1.779 ms |
54
- | findMany, 10 users → posts → comments (L3) | 1.436 ms | 3.990 ms | **1.223 ms** | n/a |
55
- | findUnique, single user by PK | **0.038 ms** | 0.083 ms | 0.085 ms | 0.037 ms |
56
- | findUnique, user + posts + comments (L3) | **0.197 ms** | 0.397 ms | 0.303 ms | n/a |
57
- | count, all users | **0.043 ms** | 0.076 ms | 0.058 ms | 0.044 ms |
58
- | stream, iterate 50K rows (batch 1000) | 54.01 ms | 57.93 ms | **40.81 ms** | 42.77 ms |
59
- | atomic increment, `view_count + 1` *(contested, see below)* | **0.073 ms** | 0.114 ms | 0.079 ms | 0.060 ms |
60
- | pipeline, 5-query batch | **0.183 ms** | 0.386 ms | 0.366 ms | 0.194 ms |
61
- | hot findUnique, 500x same shape | **0.029 ms** | 0.064 ms | 0.073 ms | 0.033 ms |
62
-
63
- **The number worth quoting from that run: Turbine ran at 1.09x hand-written `pg`, where Drizzle ran at 1.49x and Prisma at 1.86x** (geometric mean over the eight scenarios with a raw control). Across all ten scenarios Turbine was **1.82x faster than Prisma 7.9** and **1.32x faster than Drizzle 0.45** by geometric mean, against those two competitor versions.
64
-
65
- - **Turbine takes seven scenarios**, Drizzle three, Prisma none. Prisma is behind Turbine on all ten.
66
- - **Two scenarios are contested and are not claimed by either side.** This run used two independent harnesses (one interleaves the arms and rotates their order every round, one measures each ORM in a contiguous block), and they disagree about **L2 nested reads** and **atomic increment**. The table above reports the interleaved harness, which is the more rigorous of the two because drift over the life of the process is shared across arms rather than landing on whichever arm held that slice of wall clock. Where the two disagree, the honest reading is that measurement design decides the winner, so the scenario is a tie.
67
- - **Drizzle wins streaming outright, by 24%** (40.81 ms vs Turbine's 54.01 ms), reproduced in every run. Drizzle sits on the raw `pg` keyset control, which is where a thin builder should sit; Turbine's cursor carries about 26% 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.
68
- - **Drizzle leads L3 nested reads**, and L2 on the primary harness. Turbine's `json_agg` nesting is close behind and **1.8x to 2.8x ahead of Prisma** on the same L2/L3 shapes.
69
- - **The nested-relation gap to Drizzle widened since the 0.50.0 run** (L2 published at 1.21x, now 1.32x on the primary harness), and we have not yet found why. It is **not** this release: a direct interleaved A/B of 0.65.0 against 0.66.0 on the same database found 0.66.0 equal or marginally faster on every shape. Turbine's absolute L2 number barely moved between the two runs; the raw control and Drizzle both got about 10% faster and Turbine did not. Treat L2 as an open investigation rather than a result in either direction.
70
- - **Pipeline batching is Turbine's clearest win**: one TCP flush for 5 queries runs the dashboard batch 2.11x faster than Prisma's and 2.00x faster than Drizzle's sequential transaction, level with the raw `pg` control.
71
-
72
- > **Read the drift floor before quoting a sub-millisecond figure.** A `SELECT 1` probe at the head, middle and tail of each suite measured 0.0266 / 0.0107 / 0.0105 ms, a **153.8% spread**, nearly all of it process warmup between the head probe and the rest. The multi-millisecond scenarios (L2, L3, stream) are stable and their orderings are trustworthy. The sub-0.15 ms scenarios carry roughly one third uncertainty in their absolute values; their orderings held across all three runs but their **margins** should not be quoted. Full method, the 0.65-vs-0.66 A/B, and the harness-disagreement table in [`benchmarks/RESULTS-0.66.0.md`](https://github.com/zvndev/turbine-orm/blob/main/benchmarks/RESULTS-0.66.0.md).
73
-
74
- Net, as of that run: Turbine is 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.
75
-
76
- > Full analysis, methodology and the drift floor: [`benchmarks/RESULTS-0.66.0.md`](https://github.com/zvndev/turbine-orm/blob/main/benchmarks/RESULTS-0.66.0.md). Previous run: [`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).
77
- > Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench-interleaved.ts`
32
+ | findMany, 100 users (flat) | **0.181 ms** | 0.241 ms | 0.242 ms | 0.164 ms |
33
+ | findMany, 50 users + posts (L2) *(contested)* | **1.670 ms** | 4.302 ms | 1.711 ms | 1.847 ms |
34
+ | findMany, 10 users → posts → comments (L3) *(contested)* | **1.082 ms** | 4.204 ms | 1.166 ms | n/a |
35
+ | findUnique, single user by PK | **0.035 ms** | 0.078 ms | 0.080 ms | 0.036 ms |
36
+ | findUnique, user + posts + comments (L3) | **0.146 ms** | 0.374 ms | 0.285 ms | n/a |
37
+ | count, all users | **0.038 ms** | 0.070 ms | 0.053 ms | 0.041 ms |
38
+ | stream, iterate 50K rows (batch 1000) | 50.39 ms | 54.81 ms | **39.49 ms** | 42.33 ms |
39
+ | atomic increment, `view_count + 1` *(contested)* | **0.067 ms** | 0.106 ms | 0.073 ms | 0.055 ms |
40
+ | pipeline, 5-query batch | **0.165 ms** | 0.338 ms | 0.341 ms | 0.177 ms |
41
+ | hot findUnique, 500x same shape | **0.026 ms** | 0.059 ms | 0.069 ms | 0.030 ms |
42
+
43
+ What that run says:
44
+
45
+ - **Turbine ran at 1.08x hand-written `pg`**, where Drizzle ran at 1.47x and Prisma at 1.81x (geometric mean over the eight scenarios with a raw control). That 1.08x is the conservative reading: the raw L2 control statement still uses the `json_build_object` encoding Turbine has moved off, which is worth 1.83x on its own, so the figure the harness records unadjusted is 1.00x. The adjusted one is published instead.
46
+ - Across all ten scenarios Turbine was **2.02x faster than Prisma 7.9** and **1.46x faster than Drizzle 0.45** by geometric mean. Turbine takes nine scenarios, Drizzle one, Prisma none.
47
+ - **Drizzle wins streaming, by 22%** (39.49 ms vs 50.39 ms). Turbine's cursor carries about 19% overhead over hand-written keyset pagination on a full-table drain; it buys arbitrary `orderBy`, early break, and nested `with` per batch, but on this shape it is a loss. `findManyStreamBatches`, which yields a batch at a time instead of a row, cuts that to 6% above the control and narrows the loss to 1.16x without closing it.
48
+ - **Pipelining is Turbine's clearest win**: one TCP flush for 5 queries runs 2.04x faster than Prisma and 2.07x faster than Drizzle, level with raw `pg`.
49
+ - Three scenarios (L2, L3, atomic increment) are marked *contested*: the contiguous cross-check harness disagrees with itself across runs of the same configuration there, so neither side claims them. Both nested reads were losses in the previous run and the improvement behind them reproduces on both harnesses; whether it is enough to pass Drizzle is what is unsettled.
50
+ - The sub-0.15 ms scenarios carry roughly one third uncertainty in their absolute values. Orderings held across runs; margins should not be quoted, and that includes L2's 2.5%.
51
+
52
+ Full method, the drift-floor measurement, and the harness-disagreement table: [`benchmarks/RESULTS-0.71.0.md`](https://github.com/zvndev/turbine-orm/blob/main/benchmarks/RESULTS-0.71.0.md).
53
+
54
+ Reproduce it: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench-interleaved.ts`
78
55
 
79
56
  ## Quick Start
80
57
 
81
58
  ```bash
82
- # 1. Install (the CLI also needs tsx to load .ts config/schema files)
83
59
  npm install turbine-orm
84
- npm install --save-dev tsx
60
+ npm install --save-dev tsx # the CLI loads .ts config/schema files via tsx
85
61
 
86
- # 2. Initialize project
87
62
  npx turbine init --url postgres://user:pass@localhost:5432/mydb
88
-
89
- # 3. Generate typed client from your database
90
- npx turbine generate
91
- ```
92
-
93
- > **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.
94
-
95
- The `turbine-orm` package ships real dual builds, so importing the package works from either module system:
96
-
97
- ```typescript
98
- // ESM
99
- import { TurbineClient, defineSchema } from 'turbine-orm';
100
-
101
- // CommonJS
102
- const { TurbineClient, defineSchema } = require('turbine-orm');
103
- ```
104
-
105
- > **`turbine` is not exported by the package.** The `turbine()` factory is emitted into the *generated* client, because it is typed against your schema. Import it from your output directory (`./generated/turbine`), never from `'turbine-orm'`. From the package itself you get the untyped-but-generic `TurbineClient`, which takes a `SchemaMetadata` as its second argument.
106
-
107
- The generated client (`./generated/turbine/`) is TypeScript source: it re-exports across files with ESM-style `./metadata.js` specifiers, so you consume it through your bundler, `tsx`, or `tsc` like the rest of your app:
108
-
109
- ```typescript
110
- import { turbine } from './generated/turbine';
63
+ npx turbine generate # introspect the DB, emit a typed client
111
64
  ```
112
65
 
113
- This introspects your database and generates a fully-typed client at `./generated/turbine/`.
114
-
115
66
  ```typescript
116
67
  import { turbine } from './generated/turbine';
117
68
 
118
69
  const db = turbine({ connectionString: process.env.DATABASE_URL });
119
70
 
120
- // Type-safe queries with autocompletion
121
71
  const users = await db.users.findMany({
122
72
  where: { role: 'admin' },
123
73
  orderBy: { createdAt: 'desc' },
@@ -127,12 +77,17 @@ const users = await db.users.findMany({
127
77
  await db.disconnect();
128
78
  ```
129
79
 
130
- ## Usage Examples
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.
131
81
 
132
- ### findMany with nested relations
82
+ Full walkthrough, including the code-first `defineSchema` path for an empty database: [turbineorm.dev/quickstart](https://turbineorm.dev/quickstart).
83
+
84
+ ## Queries
85
+
86
+ The API is Prisma-shaped: `findMany`, `findUnique`, `findFirst`, `create`, `update`, `delete`, `upsert`, plus `count`, `aggregate`, `groupBy`, and streaming. A tour of the parts worth knowing:
87
+
88
+ ### Nested relations, one statement
133
89
 
134
90
  ```typescript
135
- // Single query -- returns users with their posts and each post's comments
136
91
  const users = await db.users.findMany({
137
92
  where: { orgId: 1 },
138
93
  with: {
@@ -143,894 +98,197 @@ const users = await db.users.findMany({
143
98
  },
144
99
  },
145
100
  });
146
-
147
- // users[0].posts[0].comments -- fully typed, single round-trip
101
+ // users[0].posts[0].comments is typed, and this was one SQL statement
148
102
  ```
149
103
 
150
- ### findUnique
151
-
152
- ```typescript
153
- const user = await db.users.findUnique({
154
- where: { id: 42 },
155
- with: { posts: true },
156
- });
157
- // user.posts is Post[] -- resolved in the same query
158
- ```
104
+ Per-relation `where`, `orderBy`, `limit`, `select`, and `omit` work at every depth. Many-to-many junction tables are auto-detected during `generate`, and self-referencing FKs give you parent and children relations. Relation filters (`some` / `every` / `none`) filter parents by their children.
159
105
 
160
- ### Many-to-many relations
106
+ Four load strategies produce identical rows: `join` (one statement), `batched` (one flat follow-up per relation), `flatten` (LEFT JOIN for eligible to-one relations), and `auto` (the default: the join plan, falling back to batched per relation when the correlation column has no covering index). A differential fuzz suite holds the strategies to byte-identical output. Details: [turbineorm.dev/relations](https://turbineorm.dev/relations).
161
107
 
162
- Turbine auto-detects pure junction tables during `generate`, a table whose primary key is exactly two single-column foreign keys and which carries no other columns (e.g. `posts_tags(post_id, tag_id)`). Both endpoints gain a many-to-many relation you can load like any other:
108
+ ### Writes, including atomic operators
163
109
 
164
110
  ```typescript
165
- const posts = await db.posts.findMany({
166
- with: { tags: true }, // each post comes back with its tags array
167
- });
168
-
169
- // Nested where / orderBy / limit work on the m2m target too
170
- const post = await db.posts.findFirst({
171
- where: { id: 1 },
172
- with: { tags: { where: { name: 'sql' }, orderBy: { name: 'asc' }, limit: 5 } },
173
- });
174
- ```
111
+ await db.users.create({ data: { email: 'a@b.com', name: 'Alice', orgId: 1 } });
175
112
 
176
- A junction table that carries extra columns (a "payload") is treated as a first-class entity, so it stays an ordinary `hasMany`. That's by design. For those, or for any junction you want to wire up by hand, declare the relation explicitly in your code-first schema:
113
+ await db.users.createMany({ data: [/* ... */] }); // one INSERT via UNNEST, not N inserts
177
114
 
178
- ```typescript
179
- import { defineSchema } from 'turbine-orm';
180
-
181
- export default defineSchema({
182
- posts: {
183
- id: { type: 'serial', primaryKey: true },
184
- title: { type: 'text', notNull: true },
185
- manyToMany: [
186
- { name: 'tags', target: 'tags', through: 'postsTags',
187
- sourceKey: 'postId', targetKey: 'tagId' },
188
- ],
189
- },
190
- // ...tags and postsTags table definitions
191
- });
192
- ```
193
-
194
- `sourceKey`/`targetKey` are the junction columns referencing each side's primary key; add `references` if the source side is keyed on something other than `id`.
195
-
196
- Since 0.50, a many-to-many relation also takes **`connect`, `disconnect` and `set`** as nested writes, so link rows are written for you inside the write's transaction:
197
-
198
- ```typescript
199
- // link (idempotent: an existing link is left alone, never duplicated)
200
- await db.posts.update({ where: { id: 1 }, data: { tags: { connect: [{ id: 7 }, { id: 9 }] } } });
201
-
202
- // unlink exactly those two, and nothing else
203
- await db.posts.update({ where: { id: 1 }, data: { tags: { disconnect: [{ id: 7 }] } } });
204
-
205
- // replace the whole link set (`set: []` clears it)
206
- await db.posts.update({ where: { id: 1 }, data: { tags: { set: [{ id: 3 }] } } });
207
-
208
- // on create, `connect` links after the parent row exists
209
- await db.posts.create({ data: { title: 'hi', tags: { connect: [{ id: 3 }] } } });
210
- ```
211
-
212
- The other nested operations (`create`, `connectOrCreate`, `update`, `upsert`, `delete`) would have to write the target row too, and there is no safe default for a junction's own extra columns, so they throw `ValidationError` (`TURBINE_E003`) naming the supported set. For those, write the target row on its own table and link it with `connect`, or write junction rows directly through the junction accessor, inside the same `$transaction`. Composite junction keys are refused for the same reason (nothing partially-keyed is emitted).
213
-
214
- ### Self-relations
215
-
216
- A self-referencing foreign key (e.g. `categories.parent_id → categories.id`) introspects to a `belongsTo` *and* a `hasMany` on the same table, so parent and child queries just work, including nested trees:
217
-
218
- ```typescript
219
- // A category with its parent and its children
220
- const category = await db.categories.findFirst({
221
- where: { id: 2 },
222
- with: { parent: true, children: true },
223
- });
224
-
225
- // Walk a level deeper
226
- const tree = await db.categories.findFirst({
115
+ await db.posts.update({
227
116
  where: { id: 1 },
228
- with: { children: { with: { children: true } } },
229
- });
230
- ```
231
-
232
- When a table has a single self-referencing FK, Turbine auto-names the relations after the table: the `belongsTo` is named for the singular (`category`) and the `hasMany` for the table (`categories`). Rename them in your code-first schema if you prefer `parent`/`children`.
233
-
234
- ### create
235
-
236
- ```typescript
237
- const newUser = await db.users.create({
238
- data: {
239
- email: 'alice@example.com',
240
- name: 'Alice',
241
- orgId: 1,
242
- },
117
+ data: { viewCount: { increment: 1 } }, // col = col + $n, no read-modify-write race
243
118
  });
244
- // Returns the full row with generated id, createdAt, etc.
245
119
  ```
246
120
 
247
- ### createMany (batch insert with UNNEST)
121
+ Atomic operators: `increment`, `decrement`, `multiply`, `divide`, `set`. Nested writes (`create`, `connect`, `connectOrCreate`, `disconnect`, `set`, `delete`, `update`, `upsert` inside `data`) run in one transaction. `update` / `delete` with an empty `where` throws `ValidationError` rather than touching every row; see [privilege options](#the-unsafe-symbol) for the explicit opt-out.
248
122
 
249
- One exception to the UNNEST shape: rows of pure defaults (`data: [{}, {}]`) name no column to unnest, so Postgres emits `INSERT INTO t SELECT FROM generate_series(1, N)` instead. `create({ data: {} })` inserts a single defaults row on every engine; the multi-row form is Postgres/MySQL only (SQLite and SQL Server throw `UnsupportedFeatureError`, as does MySQL when `skipDuplicates` is combined with it).
123
+ ### WHERE operators
250
124
 
251
-
252
- ```typescript
253
- const users = await db.users.createMany({
254
- data: [
255
- { email: 'a@b.com', name: 'A', orgId: 1 },
256
- { email: 'b@b.com', name: 'B', orgId: 1 },
257
- { email: 'c@b.com', name: 'C', orgId: 1 },
258
- ],
259
- });
260
- // Single INSERT with UNNEST -- not 3 separate inserts
261
- ```
262
-
263
- Because it is one statement, the column list comes from the **first** row, so every row must name the same fields. A field the first row names and a later row omits would be bound as `NULL` over that column's default, and a field only a later row names would be dropped, so a ragged call throws `ValidationError` (`TURBINE_E003`) naming the row index and the differing columns instead. This holds on every engine including PowDB. A field set to `undefined` counts as omitted, exactly as in `create`; split the call into one `createMany` per field set.
264
-
265
- ### update / delete
266
-
267
- ```typescript
268
- const updated = await db.users.update({
269
- where: { id: 42 },
270
- data: { name: 'Alice Updated' },
271
- });
272
-
273
- const deleted = await db.users.delete({
274
- where: { id: 42 },
275
- });
276
- ```
277
-
278
- ### Atomic update operators
279
-
280
- For race-free counter updates, pass an operator object instead of a literal. Turbine generates `col = col + $n` style SQL so concurrent updates are safe.
281
-
282
- ```typescript
283
- // Atomic increment, no read-modify-write race
284
- await db.posts.update({
285
- where: { id: 1 },
286
- data: { viewCount: { increment: 1 } },
287
- });
288
-
289
- // Other supported operators on numeric columns
290
- await db.posts.update({
291
- where: { id: 1 },
292
- data: {
293
- viewCount: { increment: 5 },
294
- likesCount: { decrement: 1 },
295
- score: { multiply: 2 },
296
- rank: { divide: 2 },
297
- title: { set: 'New title' }, // explicit set, equivalent to a literal
298
- },
299
- });
300
- ```
125
+ Equality, `not`, `in` / `notIn`, `gt` / `gte` / `lt` / `lte`, `contains` / `startsWith` / `endsWith` (with `mode: 'insensitive'` for ILIKE), Postgres array operators (`has` / `hasEvery` / `hasSome`), JSON path filters, full-text `search`, pgvector `distance`, and `AND` / `OR` / `NOT` at any depth. LIKE wildcards in user input are escaped; every value is a bound parameter. The full table with examples: [turbineorm.dev/queries](https://turbineorm.dev/queries).
301
126
 
302
127
  ### Transactions
303
128
 
304
129
  ```typescript
305
130
  await db.$transaction(async (tx) => {
306
- const user = await tx.users.create({
307
- data: { email: 'new@example.com', name: 'New', orgId: 1 },
308
- });
309
- await tx.posts.create({
310
- data: { userId: user.id, orgId: 1, title: 'First Post', content: '...' },
311
- });
131
+ const user = await tx.users.create({ data: { email: 'new@example.com', name: 'New', orgId: 1 } });
132
+ await tx.posts.create({ data: { userId: user.id, orgId: 1, title: 'First' } });
312
133
  });
313
- // Fully typed -- tx.users and tx.posts have the same API as db.users and db.posts
314
134
  ```
315
135
 
316
- `tx` is a `TransactionClient`, which is deliberately a **smaller** object than `db`. Its application surface is table accessors (`tx.users`, or `tx.table('users')`), `tx.$transaction(fn)` for SAVEPOINT-nested blocks, `tx.raw` for tagged-template raw SQL, and `tx.schema`. It does **not** have `tx.sql`, `tx.pipeline`, `tx.$use`, `tx.$listen` / `tx.$notify`, `tx.$observe`, `tx.$on` / `tx.$off`, `tx.$retry`, `tx.$primary`, `tx.$withSession`, `tx.pipelineSupported()`, `tx.transaction()`, `tx.connect()`, `tx.pool`, `tx.stats`, `tx.disconnect()` or `tx.end()`. `TransactionClient` declares `table()`, `$transaction()`, `raw`, `schema` and one `@internal` member (see below), plus the generated per-table accessors, and has no index signature, so reaching for any of the others is a **compile error** (`TS2339`), caught in your editor rather than in production. Reach for `tx.raw`: the tagged template turns every `${value}` into a placeholder, so a value cannot be concatenated into the SQL text by accident.
317
-
318
- `tx.rawQuery(text, params)` is the one `@internal` member. It is the seam `turbine-orm/prisma-compat` detects by shape so it can run compat raw SQL on the transaction's own connection, and it takes the SQL as a plain string, which puts the escaping discipline back on the caller, exactly what `tx.raw` exists to remove. It still exists at runtime and still typechecks, so nothing calling it breaks, it is simply no longer part of the documented API.
319
-
320
- Note that transaction-scoped raw SQL (`tx.raw`, and `tx.rawQuery`) bypasses the instrumentation layer: it emits no `$on('query')` event, runs no middleware, and records no timing, so it is invisible to query listeners and to `$observe` metrics. Table-scoped queries inside the transaction (`tx.users.findMany(...)`) are instrumented as usual. See [Transactions & Pipelines](https://turbineorm.dev/transactions#transactionclient-reference) for the full surface and the reason each omission exists.
136
+ Nested `$transaction` calls become SAVEPOINTs. Isolation levels, timeouts, and `sessionContext` (transaction-local GUCs for Postgres RLS) are options. `tx` deliberately exposes a smaller surface than `db`; reaching for a client-only member is a compile error, not a production surprise. Reference: [turbineorm.dev/transactions](https://turbineorm.dev/transactions).
321
137
 
322
- ### Pipeline (batch queries in one round-trip)
138
+ ### Pipelining, at the protocol level
323
139
 
324
140
  ```typescript
325
- const [user, postCount, recentPosts] = await db.pipeline(
141
+ const [user, postCount, recent] = await db.pipeline(
326
142
  db.users.buildFindUnique({ where: { id: 1 } }),
327
143
  db.posts.buildCount({ where: { orgId: 1 } }),
328
144
  db.posts.buildFindMany({ where: { userId: 1 }, limit: 5 }),
329
145
  );
330
- // 3 queries, 1 database round-trip
331
- ```
332
-
333
- ### Raw SQL (tagged template)
334
-
335
- ```typescript
336
- const stats = await db.raw<{ day: Date; count: number }>`
337
- SELECT DATE_TRUNC('day', created_at) AS day, COUNT(*)::int AS count
338
- FROM posts WHERE org_id = ${orgId}
339
- GROUP BY day ORDER BY day
340
- `;
146
+ // 3 queries, 1 TCP flush
341
147
  ```
342
148
 
343
- ### Typed raw SQL (`db.sql<T>`)
149
+ `db.pipeline(...)` uses the Postgres extended-query protocol (Parse/Bind/Execute/Sync) to send N queries in one flush. That is wire pipelining, not a batch transaction. Every query method has a `build*` twin returning `{ sql, params, transform }`, and the write builders batch too: `db.$transaction([...])` takes an array of built queries and runs them atomically.
344
150
 
345
- `db.sql<T>` is the typed escape hatch: you supply the row shape and get a thenable query with `.one()` and `.scalar()` helpers. Every `${value}` is bound as a `$N` parameter, never interpolated, so injection isn't possible even with hostile input.
151
+ ### Raw SQL, still parameterized
346
152
 
347
153
  ```typescript
348
- // Awaiting the query returns T[]
349
154
  const users = await db.sql<{ id: number; name: string }>`
350
155
  SELECT id, name FROM users WHERE org_id = ${orgId}
351
- `;
352
-
353
- // .one() returns T | null
354
- const user = await db.sql<{ id: number; name: string }>`
355
- SELECT id, name FROM users WHERE id = ${42}
356
- `.one();
156
+ `; // every ${value} becomes $N, never concatenated
357
157
 
358
- // .scalar() returns the first column of the first row, or null
359
- const total = await db.sql<{ count: number }>`
360
- SELECT COUNT(*)::int AS count FROM users
361
- `.scalar();
158
+ const one = await db.sql<{ id: number }>`SELECT id FROM users WHERE id = ${42}`.one();
362
159
  ```
363
160
 
364
- Reach for `db.sql<T>` when you want a hand-written query with a known return type; use `db.raw` when you don't need the typing or the helpers.
365
-
366
- ### Case-insensitive search
367
-
368
- ```typescript
369
- const users = await db.users.findMany({
370
- where: {
371
- email: { contains: 'alice', mode: 'insensitive' },
372
- },
373
- });
374
- // Generates: WHERE email ILIKE '%alice%'
375
- ```
161
+ `db.sql<T>` is the typed escape hatch (thenable, with `.one()` and `.scalar()`); `db.raw` is the untyped one. Neither has a code path that interpolates a value into SQL text.
376
162
 
377
- ### Streaming large result sets
163
+ ### Streaming
378
164
 
379
165
  ```typescript
380
- // Stream rows using PostgreSQL cursors, constant memory, no matter how many rows
381
166
  for await (const user of db.users.findManyStream({
382
167
  where: { orgId: 1 },
383
- batchSize: 500, // internal FETCH batch size (default: 1000)
384
168
  orderBy: { id: 'asc' },
385
- with: { posts: true }, // nested relations work too
169
+ with: { posts: true },
386
170
  })) {
387
171
  process.stdout.write(`${user.email}\n`);
388
172
  }
389
173
  ```
390
174
 
391
- Uses `DECLARE CURSOR` under the hood, rows are fetched in batches on a dedicated connection, parsed individually, and yielded via `AsyncGenerator`. Safe to `break` early; the cursor and connection are cleaned up automatically.
392
-
393
- ### Query timeout
394
-
395
- ```typescript
396
- const users = await db.users.findMany({
397
- where: { orgId: 1 },
398
- timeout: 5000, // 5 second timeout
399
- });
400
- ```
401
-
402
- ### Default limit
403
-
404
- ```typescript
405
- // Set a default limit for all queries on a model
406
- const db = turbine({
407
- connectionString: process.env.DATABASE_URL,
408
- defaultLimit: 100,
409
- });
410
- ```
411
-
412
- ### Relation loading and wire encoding
413
-
414
- A few client options tune how `with` relations are loaded and encoded. All are optional and default to today's behavior.
415
-
416
- ```typescript
417
- const db = turbine({
418
- connectionString: process.env.DATABASE_URL,
419
- // How with-clause relations resolve: 'auto' (default since 0.41.0: the
420
- // single-statement join plan, with a per-relation batched fallback when the
421
- // correlation column has no covering index), 'join' (always one correlated-
422
- // subquery statement), or 'batched' (base query + one flat follow-up per
423
- // relation). Override per query on findMany/findFirst/findUnique.
424
- relationLoadStrategy: 'auto',
425
- // 'positional' (Postgres-only) drops repeated JSON keys from relation
426
- // subqueries, ~39% fewer wire bytes on wide relations, byte-identical output.
427
- // Default 'object'.
428
- jsonEncoding: 'object',
429
- // Treat zone-less `date` / `timestamp` columns as UTC, the Prisma/Rails/
430
- // Django convention, so results don't shift with the server's local zone.
431
- // Since v0.52 it reaches the WRITE side too (the `Date` values that
432
- // create/update/upsert and where clauses bind on Postgres); before v0.52 it
433
- // reached reads only, so a client that set `false` was still binding UTC.
434
- // PER PROCESS, NOT PER CLIENT: see the note below.
435
- // Default true; set false for the legacy local-time interpretation.
436
- utcTimestamps: true,
437
- });
438
- ```
439
-
440
- > **`utcTimestamps` is a process-wide decision, not a per-client one.** The two halves settle differently. The WRITE half is per client: a `Date` bound to a zone-less `date` / `timestamp` column is rewritten to a UTC literal unless that client opted out. The READ half is a set of pg type parsers on OIDs 1114 (`timestamp`), 1082 (`date`) and their array forms 1115 / 1182, and `pg.types.setTypeParser` installs one parser per OID for the whole pg module, shared by every pool, every raw query, and any other library on the same `pg`. It is also **retroactive**: there is one parser table and it is read per row at decode time, so registering changes pools **that already exist and are already querying**, not just pools created afterwards. The same `pg.Pool` running the same query returns different values before and after some unrelated module constructs a Turbine client, and with lazy route imports that ordering is not stable between requests. If the OID had already been customized by something else, Turbine emits a one-time dev warning rather than replacing it silently. The first client in the process settles it for all the rest. Constructing a second client with the **opposite** value therefore throws `ValidationError` (`TURBINE_E003`) at construction, rather than handing back a client that writes UTC and reads local (or the reverse) and so does not round-trip its own values. The message names both values and the two ways out: give every client in the process the same `utcTimestamps`, or run the odd one out in its own process. Clients built on an external pool (`pool: ...`, `turbineHttp()`) never REGISTER a parser and settle nothing on their own, so a process holding only those keeps whatever parser configuration the caller set up. They are not exempt from the check, though: registration is process-global, so once a Turbine-owned client has installed the parsers an external-pool client reads through them too, and a disagreeing one would write local `date` literals while reading UTC.
441
-
442
- > **Upgrading with `utcTimestamps: false`.** If you already set `false`, this release changes the **stored text** of your writes: the write path now honors the flag where it previously ignored it, so zone-less columns receive local-calendar literals instead of UTC ones. Rows written before the upgrade and rows written after carry two conventions in the same column until you backfill.
443
-
444
- Run `npx turbine doctor` to catch relations whose child-side FK lacks a covering index, the correlated-subquery strategy probes the child once per parent row, so a missing FK index costs a full scan per parent.
445
-
446
- ### Pool and statement configuration
447
-
448
- ```typescript
449
- const db = turbine({
450
- connectionString: process.env.DATABASE_URL,
451
- poolSize: 10, // max pooled connections (default 10; pg alias: max)
452
- idleTimeoutMs: 30_000, // close an idle connection after this (pg alias: idleTimeoutMillis)
453
- connectionTimeoutMs: 5_000, // give up acquiring a connection after this (pg alias: connectionTimeoutMillis)
454
- preparedStatements: true, // see the warning below
455
- sqlCache: true, // SQL template cache (default true)
456
- sqlCacheSize: 1000, // distinct query SHAPES retained per table (default 1000)
457
- // Postgres only, opt-in, unset by default (Turbine then sends nothing).
458
- // Pins how the backend picks between a custom and a generic plan.
459
- // planCacheMode: 'force_custom_plan',
460
- });
461
- ```
462
-
463
- Where a pg-style alias exists (`max`, `idleTimeoutMillis`, `connectionTimeoutMillis`), the explicit Turbine field wins when both are set.
464
-
465
- > **`planCacheMode` (Postgres only, opt-in).** PostgreSQL may promote a **named** prepared statement to a generic plan from its sixth execution onward, and a generic plan is costed blind to the bound values. On a predicate whose selectivity swings per value (a `tenant_id` equality on a shared table, where one value matches a handful of rows and another matches most of them), the statement can be locked onto a plan chosen for the average value, and it never reverts. `planCacheMode: 'auto' | 'force_custom_plan' | 'force_generic_plan'` pins the backend's choice; `'force_custom_plan'` re-plans every execution and removes the cliff. It is applied as a connection parameter (`options=-c plan_cache_mode=...`) when Turbine opens a connection, so it is in force for that connection's first statement and for every checkout, `$transaction`, stream and pipeline on it, and it cannot race your first query. Leave it unset (the default) and Turbine sends nothing at all. Reach for it when you have measured a statement getting slower after its fifth execution. **Correction to the 0.54 text, which said `findMany` / `findFirst` bind `LIMIT $n` and are "much less exposed":** that was false. PostgreSQL does not deny the planner a limit fraction for a bound limit, it substitutes a default of 10% of the child node's own row estimate (clamped at one row), and an unknown `OFFSET` triggers the same substitution even when the limit is a constant, which a paginated Turbine read always has. Two things also need saying about the sentence that opens this note. The sixth execution is a ceiling, not a trigger: `auto` promotes only when the generic plan's **estimated** cost is not worse than the average custom cost, so many statements are never promoted at all, and `pg_prepared_statements.generic_plans` is how you tell. And the shape that gets promoted unprompted is the one with **no limit**, not the limited one: measured on a skewed join predicate, an unlimited `count()`-shaped statement promoted under the default `auto` and ran a nested loop at 430x the buffers of the custom plan, while the same predicate under `LIMIT $n` was never promoted across eight executions (its substituted row count made the generic plan look more expensive). A limited `findMany` gives the planner two unknowns instead of one, which is not the same as more damage. `implicitPkOrdering` is **off by default in core**, so a default `findMany` emits no `ORDER BY`; switching it on adds an ordering a generic plan can walk the whole table in. Measure with `plan_cache_mode = force_generic_plan` against `force_custom_plan` rather than reasoning about which shapes ought to be safe; the fixtures and numbers are on the [relations page](https://turbineorm.dev/relations) and in the 0.55.0 changelog. **Two 0.56 corrections to the paragraph above.** First, "neither an ORDER BY nor a limit is required" is true, but it read as if ordering did not matter: in a table-by-table sweep of a multi-tenant schema, every divergent shape measured had an `ORDER BY` and every shape without one measured 1.00x, so it is not necessary in general and is still the strongest single predictor in practice. Second, a custom plan is not automatically the better one: on a reproducible fixture where one dense value's rows are packed at the end of the heap, `LIMIT 20` with no ordering reads 4,262 buffers custom against 71 generic (the default `auto` never promotes there, so it produces the 4,262-buffer plan too). Since 0.56 the per-query read arg **`forceCustomPlan: true`** covers the case a connection-wide setting cannot express, custom on one query and `auto` everywhere else, and `turbine doctor` detects the distribution that admits the flip. **0.57 correction:** that read arg reached the core client only. Through `turbine-orm/prisma-compat` it was accepted and silently dropped until 0.57.0, so a compat integration that followed this advice got a no-op; confirm at the wire with `pg_prepared_statements` rather than assuming. 0.57 also adds a third divergence mechanism to `doctor`: an **unindexed** filter column, where the good plan is a sequential scan the generic plan will not choose (measured 250 buffers against 20,074 on a 20,000-row / 247-page fixture). Three scope limits: it does nothing on an **external pool** (Turbine never opens those connections, so set the GUC in the driver's own setup; Turbine-owned string `replicas` on that same client DO get it); a Postgres wire-compatible engine without the setting (CockroachDB, YugabyteDB, pre-12 PostgreSQL) refuses the connection parameter itself; and a **connection pooler** may filter startup parameters (PgBouncer's `ignore_startup_parameters`), where `ALTER ROLE ... SET plan_cache_mode = ...` is the way in. Any value outside the three throws `ValidationError` at construction, and a non-Postgres engine throws `UnsupportedFeatureError` (`TURBINE_E017`).
466
-
467
- > **`preparedStatements` and connection poolers.** With prepared statements on, Turbine submits queries as `{ name, text, values }` so Postgres caches the parse and plan **per backend connection**. That is a real win against a database you connect to directly, and a hazard behind a transaction-pooling proxy (PgBouncer in `transaction` mode, Supabase's pooler port, some serverless poolers): the named statement is prepared on one backend and your next query may land on another, which fails with `prepared statement "..." does not exist`. Turbine defaults it to `true` only for pools it creates itself and `false` for external pools passed via `pool` / `turbineHttp()`, because serverless drivers are the common case there. If you are pointing a Turbine-owned pool at a transaction pooler, set `preparedStatements: false`. The environment variable `TURBINE_DISABLE_PREPARED=1` turns it off globally without a code change.
468
-
469
- ### Client escape hatches
470
-
471
- | Member | Type | What it is for |
472
- |---|---|---|
473
- | `db.table<T>(name)` | `QueryInterface<T>` | Query a table by string name. This is the escape hatch for tables that are absent from your generated types: a table created after the last `generate`, a table reached over `turbineHttp(pool, SCHEMA)` where there is no generated subclass, or a name that is not a valid identifier. Pass `T` yourself to get typing back. The name is still validated against the schema metadata. |
474
- | `db.pool` | `pg.Pool` | The underlying pool, for anything Turbine does not wrap. |
475
- | `db.schema` | `SchemaMetadata` | The metadata the client was built from. |
476
- | `db.stats` | `{ totalCount, idleCount, waitingCount }` | Pool gauges for a health endpoint. Returns zeros on drivers that do not expose counts (Neon HTTP). |
477
- | `db.transaction(fn)` | raw `pg.PoolClient` | The pre-typed transaction API. Prefer `$transaction`; this exists for hand-written SQL that needs the same connection. |
478
- | `db.end()` | `Promise<void>` | Alias for `disconnect()`. Both are a no-op for an external pool, because the caller owns its lifecycle. |
479
- | `QueryInterface.cacheStats()` | `{ size, hits, misses, hitRate }` | Per-table SQL-template cache counters, e.g. `db.users.cacheStats()`. Useful to confirm a hot path is actually reusing a cached template rather than re-fingerprinting a new shape every call. |
480
-
481
- ### Building queries without running them
482
-
483
- Every query method has a `build*` twin that returns a `DeferredQuery` (`{ sql, params, transform, tag }`) instead of executing. That is what `pipeline()` and the array form of `$transaction()` consume, and it is not read-only: the write builders are batchable too.
484
-
485
- ```typescript
486
- // Reads and writes in ONE atomic batch, one connection, one BEGIN/COMMIT
487
- const [order, _items, updated] = await db.$transaction([
488
- db.orders.buildCreate({ data: { userId: 1, total: 4200 } }),
489
- db.orderItems.buildCreateMany({ data: [{ orderId: 1, sku: 'A' }, { orderId: 1, sku: 'B' }] }),
490
- db.users.buildUpdate({ where: { id: 1 }, data: { orderCount: { increment: 1 } } }),
491
- ]);
492
- ```
493
-
494
- The full set: `buildFindMany`, `buildFindUnique`, `buildFindFirst`, `buildFindUniqueOrThrow`, `buildFindFirstOrThrow`, `buildCount`, `buildAggregate`, `buildGroupBy`, `buildCreate`, `buildCreateMany`, `buildUpdate`, `buildUpdateMany`, `buildUpsert`, `buildDelete`, `buildDeleteMany`.
495
-
496
- Use `db.pipeline(...)` when the queries are independent and you want them in one round-trip; use `db.$transaction([...])` when you also need interactive control or SAVEPOINT nesting. Both are atomic by default: a pipeline wraps the batch in one `BEGIN`/`COMMIT` unless you pass `{ transactional: false }`, and only that opt-out path can produce a `PipelineError` (TURBINE_E014) with per-slot results. Nested writes (relation operations inside `data`) open their own transaction, so those methods stay `async` and have no `build*` twin.
497
-
498
- ### Middleware
499
-
500
- Middleware wraps every query. It runs **after SQL generation**, so it can observe what's about to execute (`params.model`, `params.action`, `params.args`), measure timing, and transform the result returned by `next()`, but it cannot change the query itself.
501
-
502
- ```typescript
503
- // Query timing
504
- db.$use(async (params, next) => {
505
- const start = Date.now();
506
- const result = await next(params);
507
- console.log(`${params.model}.${params.action} took ${Date.now() - start}ms`);
508
- return result;
509
- });
510
-
511
- // Result transformation, redact a field on the way out
512
- db.$use(async (params, next) => {
513
- const result = await next(params);
514
- if (params.model === 'users' && Array.isArray(result)) {
515
- for (const row of result as { email?: string }[]) row.email = '[redacted]';
516
- }
517
- return result;
518
- });
519
- ```
520
-
521
- > **Warning:** `params.args` is a read-only snapshot, mutating it does not change the executed SQL. The query is fully built and parameterized before middleware runs.
175
+ Backed by `DECLARE CURSOR` on a dedicated connection: constant memory at any row count, safe to `break` early, nested `with` per batch.
522
176
 
523
- Middleware can't rewrite queries, so cross-cutting predicates like **soft deletes** and **multi-tenancy** belong to [global filters](https://turbineorm.dev/global-filters) instead. A global filter is a `WhereClause` that Turbine `AND`-merges into the compiled `WHERE` of every query on a table: reads, the relation subqueries that target it, and the predicate of `update` / `delete` / `upsert`. `create` and `createMany` are never filtered, since a new row has nothing to scope.
177
+ ### Global filters
524
178
 
525
179
  ```typescript
526
180
  const db = turbine({
527
181
  connectionString: process.env.DATABASE_URL,
528
182
  globalFilters: {
529
- // Soft delete (static)
530
- posts: { deletedAt: null },
531
- users: { deletedAt: null },
532
- // Multi-tenancy: a function is evaluated every time a query is built,
533
- // so a closure over per-request state gives you request-scoped isolation
534
- orders: () => ({ tenantId: currentTenant() }),
183
+ posts: { deletedAt: null }, // soft delete
184
+ orders: () => ({ tenantId: currentTenant() }), // per-request tenancy
535
185
  },
536
186
  });
537
-
538
- await db.posts.findMany();
539
- // SELECT ... FROM "posts" WHERE "deleted_at" IS NULL
540
-
541
- await db.users.findMany({ where: { role: 'admin' } });
542
- // SELECT ... FROM "users" WHERE "role" = $1 AND "deleted_at" IS NULL
543
187
  ```
544
188
 
545
- 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`.
189
+ A global filter is a `WhereClause` that is AND-merged into every query on a table: reads, relation subqueries targeting it, and the predicates of `update` / `delete` / `upsert`. Values are parameterized. Details: [turbineorm.dev/global-filters](https://turbineorm.dev/global-filters).
546
190
 
547
- ### Privilege options and the `UNSAFE` symbol
191
+ ### The UNSAFE symbol
548
192
 
549
- 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:
193
+ Three options remove a safety boundary: `skipGlobalFilters`, `includePii`, and `allowFullTableScan`. Each accepts exactly one value, a symbol exported from the package:
550
194
 
551
195
  ```typescript
552
196
  import { UNSAFE } from 'turbine-orm';
553
197
 
554
- await db.posts.findMany({ skipGlobalFilters: UNSAFE }); // skip every filter
555
- await db.users.findMany({ with: { posts: true }, skipGlobalFilters: [UNSAFE, 'posts'] });
556
- await db.users.findFirst({ where: { id: 1 }, includePii: UNSAFE });
557
- await db.sessions.deleteMany({ where: {}, allowFullTableScan: UNSAFE }); // every row, on purpose
558
- ```
559
-
560
- `where` stays required on the mutations. `allowFullTableScan` permits an **empty** `where`; it does not let you omit the key.
561
-
562
- **Why a symbol.** All three sit on the same options object as `where`, and the idiomatic handler spreads a request body:
563
-
564
- ```typescript
565
- app.get('/users', (req, res) => db.users.findMany({ ...req.body }));
198
+ await db.sessions.deleteMany({ where: {}, allowFullTableScan: UNSAFE });
566
199
  ```
567
200
 
568
- 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.)
201
+ `true` throws. The reason is mass assignment: these options sit next to `where` on the same object, and a handler that spreads `req.body` into query args must not let a JSON payload disable tenancy or unlock PII. `JSON.parse` cannot produce a symbol, so there is no untrusted-data path to the privilege. Full rationale: [turbineorm.dev/global-filters](https://turbineorm.dev/global-filters#privilege-options-and-the-unsafe-symbol).
569
202
 
570
- **Upgrading.** This is breaking on those three options, deliberately loudly.
571
-
572
- | Before | After |
573
- |---|---|
574
- | `skipGlobalFilters: true` | `skipGlobalFilters: UNSAFE` |
575
- | `skipGlobalFilters: ['posts']` | `skipGlobalFilters: [UNSAFE, 'posts']` |
576
- | `includePii: true` | `includePii: UNSAFE` |
577
- | `allowFullTableScan: true` | `allowFullTableScan: UNSAFE` |
578
-
579
- 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:
203
+ ### Typed errors
580
204
 
581
205
  ```typescript
582
- await db.sessions.deleteMany({ where: {}, ...(purgeEverything ? { allowFullTableScan: UNSAFE } : {}) });
583
- ```
584
-
585
- 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).
586
-
587
- ### Error handling
588
-
589
- Turbine throws typed errors you can catch programmatically:
590
-
591
- ```typescript
592
- import { NotFoundError, ValidationError, TimeoutError } from 'turbine-orm';
206
+ import { NotFoundError, UniqueConstraintError } from 'turbine-orm';
593
207
 
594
208
  try {
595
- const user = await db.users.findUniqueOrThrow({ where: { id: 999 } });
209
+ await db.users.findUniqueOrThrow({ where: { id: 999 } });
596
210
  } catch (err) {
597
- if (err instanceof NotFoundError) {
598
- // err.code === 'TURBINE_E001'
599
- console.log('User not found');
600
- } else if (err instanceof TimeoutError) {
601
- // err.code === 'TURBINE_E002'
602
- console.log('Query timed out');
603
- } else if (err instanceof ValidationError) {
604
- // err.code === 'TURBINE_E003'
605
- console.log('Invalid query:', err.message);
606
- }
211
+ if (err instanceof NotFoundError) { /* err.code === 'TURBINE_E001' */ }
607
212
  }
608
213
  ```
609
214
 
610
- Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation), `TURBINE_E008` (UniqueConstraint), `TURBINE_E009` (ForeignKey), `TURBINE_E010` (NotNullViolation), `TURBINE_E011` (CheckConstraint), `TURBINE_E012` (Deadlock), `TURBINE_E013` (SerializationFailure), `TURBINE_E014` (Pipeline), `TURBINE_E015` (OptimisticLock), `TURBINE_E016` (ExclusionConstraint), `TURBINE_E017` (UnsupportedFeature: a Postgres-only feature invoked on another engine), `TURBINE_E018` (ReadOnly: a write refused on a read-only database, reason `'snapshot'` | `'rbac'`).
611
-
612
- Full reference with `wrapPgError()` translation, retry patterns for `DeadlockError` / `SerializationFailureError`, and safe vs verbose message modes: **[turbineorm.dev/errors](https://turbineorm.dev/errors)**.
613
-
614
- ### groupBy with HAVING
615
-
616
- `groupBy` aggregates rows by one or more columns. Add a `having` clause to filter the resulting groups by their aggregates. Every comparison value is parameterized.
617
-
618
- ```typescript
619
- // Users with more than one post
620
- const prolific = await db.posts.groupBy({
621
- by: ['userId'],
622
- _count: true,
623
- having: { _count: { gt: 1 } },
624
- });
625
-
626
- // Groups whose summed view count clears a threshold
627
- const popular = await db.posts.groupBy({
628
- by: ['published'],
629
- _sum: { viewCount: true },
630
- having: { viewCount: { _sum: { gte: 100 } } },
631
- });
632
- ```
633
-
634
- Filter on the group count with `_count`, or on a column aggregate with `{ column: { _sum | _avg | _min | _max: { ... } } }`. Operators are `gt`, `gte`, `lt`, `lte`, `in`, and `notIn` (a bare number is shorthand for equality). `having` predicates combine with `AND`, and `where` filters rows *before* grouping while `having` filters groups *after*.
635
-
636
- ### Multi-tenant queries with RLS session context
637
-
638
- Set transaction-local Postgres settings (GUCs) so PostgreSQL Row-Level Security policies that call `current_setting()` filter rows for you. Pass `sessionContext` to `$transaction`, or use the `$withSession` shorthand.
639
-
640
- ```typescript
641
- // Postgres policy: USING (tenant_id = current_setting('app.current_tenant')::int)
642
- const rows = await db.$transaction(
643
- async (tx) => tx.documents.findMany(),
644
- { sessionContext: { 'app.current_tenant': tenantId } },
645
- );
646
-
647
- // Shorthand for a single-purpose session
648
- const rows2 = await db.$withSession(
649
- { 'app.current_tenant': tenantId },
650
- async (tx) => tx.documents.findMany(),
651
- );
652
- ```
653
-
654
- Each entry is applied as `SELECT set_config(name, value, true)` right after `BEGIN`, so the setting is scoped to the transaction and resets automatically on commit. Values may be strings, numbers, or booleans (coerced to strings). Invalid setting names throw `ValidationError` and roll the transaction back before any query runs.
655
-
656
- ### Realtime with LISTEN/NOTIFY
657
-
658
- Subscribe to a Postgres channel with `$listen` and publish to it with `$notify`. The handler receives the notification payload as a string.
659
-
660
- ```typescript
661
- const sub = await db.$listen('order_created', (payload) => {
662
- console.log('new order:', payload);
663
- });
664
-
665
- await db.$notify('order_created', JSON.stringify({ id: 1 }));
666
-
667
- // Later, when you're done
668
- await sub.unsubscribe();
669
- ```
670
-
671
- `$listen` holds a dedicated connection open for the lifetime of the subscription, so it requires a real persistent pool. It is not available over serverless HTTP drivers. `$notify` is a single round-trip and works everywhere. Channel names are validated as plain identifiers; the payload is always bound as a parameter.
672
-
673
- ## Vector search (pgvector)
674
-
675
- Query a `vector` column for nearest neighbors. Requires the [pgvector](https://github.com/pgvector/pgvector) extension and a `vector` column on your table.
676
-
677
- **KNN ranking**, order by distance to a query vector and take the closest rows:
678
-
679
- ```typescript
680
- const similar = await db.items.findMany({
681
- orderBy: { embedding: { distance: { to: queryVector, metric: 'cosine' } } },
682
- limit: 5,
683
- });
684
- // queryVector is a number[]; nearest-first by default (direction: 'desc' to invert)
685
- ```
686
-
687
- **Distance filter**, keep only rows within a distance threshold:
688
-
689
- ```typescript
690
- const close = await db.items.findMany({
691
- where: { embedding: { distance: { to: queryVector, metric: 'l2', lt: 0.3 } } },
692
- });
693
- ```
694
-
695
- `metric` selects the pgvector operator: `'l2'` → `<->` (Euclidean), `'cosine'` → `<=>` (cosine distance), `'ip'` → `<#>` (negative inner product). Distance filters accept `lt`, `lte`, `gt`, and `gte`. The query vector is always bound as `$n::vector`, never interpolated.
696
-
697
- > **Note:** pg has no built-in parser for the `vector` type, so a fetched `vector` column comes back as a string literal like `'[1,2,3]'` unless you register a parser (e.g. via pgvector's own client helpers). Querying by distance works regardless.
698
-
699
- ## WHERE Operator Reference
700
-
701
- Every operator supported by the `where` clause. Operators compose freely with `AND`, `OR`, `NOT`, and the relation filters `some` / `every` / `none`.
702
-
703
- ### Equality
704
-
705
- | Operator | Description | Example |
706
- |---|---|---|
707
- | literal | Implicit equality | `where: { email: 'a@b.com' }` |
708
- | `equals` | Explicit equality | `where: { email: { equals: 'a@b.com' } }` |
709
- | `not` | Inequality (or `not: null` for `IS NOT NULL`) | `where: { role: { not: 'admin' } }` |
710
-
711
- ### Sets
712
-
713
- | Operator | Description | Example |
714
- |---|---|---|
715
- | `in` | Match any value in the array | `where: { id: { in: [1, 2, 3] } }` |
716
- | `notIn` | Match none of the values in the array | `where: { role: { notIn: ['banned', 'spam'] } }` |
717
-
718
- ### Comparison
719
-
720
- | Operator | Description | Example |
721
- |---|---|---|
722
- | `gt` | Greater than | `where: { score: { gt: 100 } }` |
723
- | `gte` | Greater than or equal | `where: { score: { gte: 100 } }` |
724
- | `lt` | Less than | `where: { score: { lt: 100 } }` |
725
- | `lte` | Less than or equal | `where: { score: { lte: 100 } }` |
726
-
727
- ### String
728
-
729
- | Operator | Description | Example |
730
- |---|---|---|
731
- | `contains` | Substring match (`LIKE %v%`) | `where: { title: { contains: 'sql' } }` |
732
- | `startsWith` | Prefix match (`LIKE v%`) | `where: { email: { startsWith: 'admin@' } }` |
733
- | `endsWith` | Suffix match (`LIKE %v`) | `where: { email: { endsWith: '@acme.com' } }` |
734
- | `mode: 'insensitive'` | Switch any string operator to `ILIKE` | `where: { title: { contains: 'SQL', mode: 'insensitive' } }` |
215
+ Every error extends `TurbineError` with a stable code (`TURBINE_E001` through `E018`) and a `docsUrl`. Error messages carry keys, never values: a `NotFoundError` says `where: { id, email }` without printing the email, so errors are safe to forward to a tracker without a scrubbing rule. Retryable failures (`DeadlockError`, `SerializationFailureError`) expose `isRetryable: true` as a typed const. Full table: [turbineorm.dev/errors](https://turbineorm.dev/errors).
735
216
 
736
- LIKE wildcards in user input are escaped automatically, `%`, `_`, and `\` are treated as literals.
217
+ ## Built for agents
737
218
 
738
- ### Relation filters
739
-
740
- Filter parent rows by predicates against their related child rows. Available on `hasMany` and `hasOne` relations.
741
-
742
- | Operator | Description | Example |
743
- |---|---|---|
744
- | `some` | At least one related row matches | `where: { posts: { some: { published: true } } }` |
745
- | `every` | Every related row matches | `where: { posts: { every: { published: true } } }` |
746
- | `none` | No related row matches | `where: { posts: { none: { published: false } } }` |
747
-
748
- ### Array columns
749
-
750
- Operators for Postgres array columns (`text[]`, `int[]`, etc.).
751
-
752
- | Operator | Description | Example |
753
- |---|---|---|
754
- | `has` | Array contains the given element | `where: { tags: { has: 'sql' } }` |
755
- | `hasEvery` | Array contains every element in the list | `where: { tags: { hasEvery: ['sql', 'postgres'] } }` |
756
- | `hasSome` | Array contains at least one element from the list | `where: { tags: { hasSome: ['sql', 'mysql'] } }` |
757
-
758
- ### Combinators
759
-
760
- | Operator | Description | Example |
761
- |---|---|---|
762
- | `AND` | All sub-clauses must match | `where: { AND: [{ orgId: 1 }, { role: 'admin' }] }` |
763
- | `OR` | Any sub-clause matches | `where: { OR: [{ role: 'admin' }, { role: 'owner' }] }` |
764
- | `NOT` | Negate a sub-clause | `where: { NOT: { role: 'banned' } }` |
765
-
766
- ## CLI
767
-
768
- ```
769
- npx turbine <command> [options]
770
-
771
- Commands:
772
- init Initialize a Turbine project (creates config, dirs, templates)
773
- generate | pull Introspect database and generate TypeScript types + client
774
- push Apply schema-builder definitions to database
775
- migrate create <name> Create a new SQL migration file
776
- migrate create <name> --auto Auto-generate from schema diff
777
- migrate create <name> --from-diff Like --auto, but flag destructive statements
778
- migrate up Apply pending migrations
779
- migrate deploy Apply pending migrations without prompts
780
- migrate down Rollback last migration (--step N for last N)
781
- migrate status Show applied/pending migrations
782
- seed Run seed file
783
- status Show database schema summary
784
- doctor Check relations for missing FK indexes (--fix emits migration)
785
- studio Launch local Studio web UI (read-only; --write for writes, --demo for a sample DB)
786
- mcp Start read-only MCP server over JSON-RPC stdio
787
- observe Launch local metrics dashboard (requires TURBINE_OBSERVE_URL)
788
-
789
- Options:
790
- --url, -u <url> Postgres connection string
791
- --out, -o <dir> Output directory (default: ./generated/turbine)
792
- --schema, -s <name> Postgres schema (default: public)
793
- --auto Auto-generate migration from schema diff
794
- --from-diff Like --auto, but flag destructive statements (migrate create)
795
- --recipe <name> Scaffold a sanctioned pattern, e.g. backfill (migrate create)
796
- --step, -n <N> Apply/rollback only N migrations (migrate up / down)
797
- --allow-drift Bypass checksum validation (migrate up / deploy)
798
- --allow-destructive Run data-destroying statements without confirmation (up / down / push)
799
- --dry-run Show SQL without executing
800
- --verbose, -v Detailed output
801
- ```
802
-
803
- ### Schema-first workflow
804
-
805
- Define your schema in TypeScript and push it to the database:
806
-
807
- ```typescript
808
- // turbine/schema.ts
809
- import { defineSchema } from 'turbine-orm';
810
-
811
- export default defineSchema({
812
- users: {
813
- id: { type: 'serial', primaryKey: true },
814
- email: { type: 'text', unique: true, notNull: true },
815
- name: { type: 'text', notNull: true },
816
- orgId: { type: 'bigint', notNull: true, references: 'organizations.id' },
817
- createdAt: { type: 'timestamp', default: 'now()' },
818
- },
819
- });
820
- ```
219
+ An agent pointed at a database usually gets a connection string and guesses. Turbine gives it typed tools instead:
821
220
 
822
221
  ```bash
823
- npx turbine push --dry-run # Preview SQL
824
- npx turbine push # Apply to database
825
- npx turbine generate # Regenerate typed client
222
+ npx turbine mcp # read-only MCP server over JSON-RPC stdio, ships in the package
826
223
  ```
827
224
 
828
- ### Migration workflow
829
-
830
- ```bash
831
- # Create a blank migration (write SQL manually)
832
- npx turbine migrate create add_users_table
833
-
834
- # Auto-generate migration from schema diff (compares defineSchema() vs live DB)
835
- npx turbine migrate create add_email_index --auto
836
- # -> Generates UP (ALTER/CREATE) and DOWN (reverse) SQL automatically
225
+ Eleven tools, every one inside `BEGIN READ ONLY` with a statement timeout, so an agent cannot mutate anything through this server:
837
226
 
838
- # Same diff, but destructive statements flagged inline (still refused by
839
- # migrate up unless confirmed or --allow-destructive); cannot combine with --auto/--recipe
840
- npx turbine migrate create sync_schema --from-diff
841
-
842
- # Apply all pending migrations
843
- npx turbine migrate up
844
-
845
- # Rollback the last applied migration
846
- npx turbine migrate down
227
+ | Tool | What it answers |
228
+ |---|---|
229
+ | `schema_overview` | Tables, columns, relations, indexes, estimated row counts. |
230
+ | `table_detail` | One table, in full. |
231
+ | `relation_graph` | The whole relation graph, or one table's subtree, with cardinality, keys, and junction tables. |
232
+ | `find_join_path` | "How do I get from `comments` to `orgs`": the relation chain **and the `with` clause to write**. |
233
+ | `table_stats` | Planner row estimate, on-disk size, indexes. Reports `analyzed: false` instead of guessing `0`. |
234
+ | `explain_query` | `EXPLAIN` for a schema-validated `findMany` plan. No free-form SQL input exists. |
235
+ | `compile_query` | The exact SQL a read query would send, without running it: bound params, statement count, relation strategy, and whether it is bounded by a `LIMIT`. |
236
+ | `explain_error` | A Turbine error code, mapped to cause, fix, and docs link. |
237
+ | `sample_rows` | Up to 50 rows; PII-tagged columns are never fetched, and the reply lists what was hidden. |
238
+ | `migrate_status` | Applied vs pending migrations, without applying anything. |
239
+ | `doctor_report` | Missing relation indexes, from the same advisor `turbine doctor` uses. |
847
240
 
848
- # Check migration status (applied vs pending)
849
- npx turbine migrate status
850
- ```
241
+ The rest of the agent story is structural: query args are fully typed, so a wrong query is a compile error the agent can read; errors carry stable codes it can branch on; and [llms.txt](https://turbineorm.dev/llms.txt) / [llms-full.txt](https://turbineorm.dev/llms-full.txt) give it the docs in fetchable form. Setup for Claude Code and Cursor, plus a drop-in instructions snippet: [turbineorm.dev/ai-agents](https://turbineorm.dev/ai-agents).
851
242
 
852
- **Destructive migrations require explicit confirmation.** If a pending migration (or a DOWN
853
- section being rolled back) contains data-destroying SQL, `DROP TABLE`, `DROP COLUMN`,
854
- `TRUNCATE`, `DELETE FROM`, `UPDATE` without `WHERE`, `ALTER COLUMN … TYPE`, Turbine refuses
855
- to run it and prints an itemized report. Interactively you must type `destroy my data` and
856
- then `yes`; in CI you must pass `--allow-destructive`. A refused batch applies nothing.
243
+ ## Safety tooling
857
244
 
858
- ## Studio
245
+ **`turbine doctor`: index advice, offline.** Turbine loads relations as correlated subqueries, so an unindexed FK becomes a scan per parent row. `doctor` derives every column set the relation queries probe, reports the ones with no covering index with a cost tier, and `--fix` writes the migration. It also detects cached-plan divergence: columns whose value distribution makes a named prepared statement's generic plan unsafe, verified with a plan-only `EXPLAIN`. No cloud service, no account.
859
246
 
860
- `turbine studio` launches a local web UI for exploring your database. It is **read-only by default** (no mutations, no writes, every transaction `BEGIN READ ONLY`) and since v0.19 has **no raw-SQL surface at all**: every query is composed visually in the ORM and compiled by the same validated query builder your application uses. Since v0.36, `--write` opts a launch in to primary-key-addressed insert/update/delete through that same validated builder (single rows, or a capped multi-select batch run in one all-or-nothing transaction since v0.38); without the flag the write endpoints do not exist.
247
+ **Destructive migrations 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. Interactively you type `destroy my data`, then `yes`; in CI you pass `--allow-destructive`. A refused batch applies nothing. Migrations are SQL files, checksummed with SHA-256, applied under `pg_try_advisory_lock()`.
861
248
 
862
- ```bash
863
- DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio
864
- # With flags
865
- npx turbine studio --port 5173 --host 127.0.0.1 --no-open
866
- ```
249
+ **PII stays in the database.** Tag a column `pii: true` in the schema and it is excluded from every default projection at the SQL level: `RETURNING "id", "name"` instead of `RETURNING *`. It is also refused as a `groupBy` key and a `_min` / `_max` target. Reading it back takes `includePii: UNSAFE`, per query. A schema with no tagged column emits byte-identical SQL.
867
250
 
868
- **Try it without a database.** `npx turbine-orm@latest studio --demo` boots Studio against a seeded, in-memory sample database (users, posts, comments, orgs) with no `DATABASE_URL` and no extra dependency, backed by Turbine's own SQLite engine over `node:sqlite` (Node 22.5+). An in-UI switcher flips the three modes live (Read-only / Show PII / Write), so you can feel PII redaction and the write flow back to back. Writes genuinely apply to the in-memory store but nothing is ever saved: every launch starts fresh.
251
+ **Studio is read-only by default.** `npx turbine studio` binds loopback, authenticates with a per-process token, runs every read inside `BEGIN READ ONLY`, and has no raw-SQL surface: queries are composed in a builder validated identifier by identifier. Without `--write`, the write endpoints do not exist in the router. PII cells are redacted server-side. Try it with no database: `npx turbine-orm@latest studio --demo` boots a seeded in-memory sample. Docs: [turbineorm.dev/studio](https://turbineorm.dev/studio).
869
252
 
870
- **Features**
253
+ **Observability without an agent.** `db.$on('query')` taps every query with params redacted by default; `db.$observe()` flushes p50/p95/p99 aggregates per minute to a metrics table, and `npx turbine observe` is the dashboard. Docs: [turbineorm.dev/observability](https://turbineorm.dev/observability).
871
254
 
872
- - **Query / Data / Schema tabs.** Compose queries visually, browse rows, and inspect tables and relations.
873
- - **ORM-native query composer.** The Query tab builds a real `findMany`, drill into relations (`with`) to any depth, pick fields (`select`/`omit`), add filters (`where`), `orderBy`, and `limit` at every level, with a live TypeScript preview of the exact call to copy into your codebase.
874
- - **Saved queries.** Named builder queries persisted to `.turbine/studio-queries.json`, share them across runs without committing them.
875
- - **Cmd+K command palette.** Jump to any table, tab, or saved query in one keystroke.
876
- - **Full-text search across rows.** The Data tab supports substring search across every text column of the current table.
877
- - **PII redaction.** Columns tagged `pii: true` in the schema render as a redaction placeholder in every tab. `--show-pii` reveals them, with a loud startup warning. Tags are a code-first declaration (introspection never infers one), so Studio reads them from the generated metadata in your `out` directory; if it finds none it says so at startup rather than implying a protection it cannot apply.
878
- - **Opt-in write mode.** `--write` enables insert/update/delete from the Data tab (single rows, multi-select delete, and paste-to-insert batches), gated per row by the full primary key, compiled by the same validated builders, and flagged with a persistent WRITE MODE banner. Read-only stays the default on every launch.
255
+ ## Postgres features
879
256
 
880
- **Security posture (read-only by default)**
257
+ Going deep on one database means the parts other ORMs push to raw SQL are typed surface here:
881
258
 
882
- - **No SQL input surface.** There is nothing to inject into: builder requests are validated identifier-by-identifier against the introspected schema, and every value is bound as a `$N` parameter.
883
- - **Loopback by default** (`127.0.0.1`). Non-loopback `--host` is **refused** unless you pass `--allow-remote` (loud warning when you opt in).
884
- - **Per-process auth token**: 24 random bytes of hex, stored in a `SameSite=Strict` `HttpOnly` cookie.
885
- - **Every read query runs inside `BEGIN READ ONLY`** with a 30s transaction-local statement timeout (parameterized `set_config`). Without `--write`, the write endpoints do not exist (they 404) and writes are impossible at the transaction level; with it, each write runs in its own transaction with the same timeout and schema pinning, requires the row's full primary key, and rejects absent or mismatched `Origin` headers.
886
- - **Security headers on every response**: nonce-based CSP, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, plus per-session rate limiting and cross-origin refusal.
259
+ - **pgvector**: KNN ordering and distance filters, `orderBy: { embedding: { distance: { to, metric: 'cosine' } } }`, values bound as parameters. [Docs](https://turbineorm.dev/vector)
260
+ - **LISTEN/NOTIFY**: `db.$listen(channel, handler)` and `db.$notify(channel, payload)`. Your database is the message bus. [Docs](https://turbineorm.dev/realtime)
261
+ - **RLS session context**: `$transaction(fn, { sessionContext })` sets transaction-local GUCs so Row-Level Security policies filter for you. [Docs](https://turbineorm.dev/transactions)
262
+ - **Full-text search**: `where: { body: { search: 'postgres & orm' } }` compiles to `to_tsvector @@ to_tsquery`, parameterized. [Docs](https://turbineorm.dev/queries#full-text-search)
263
+ - **Read replicas** with a `$primary()` escape hatch ([docs](https://turbineorm.dev/read-replicas)), **views and generated columns** ([docs](https://turbineorm.dev/views)), **optimistic locking** ([docs](https://turbineorm.dev/optimistic-locking)), and **`explain()`** on every table accessor ([docs](https://turbineorm.dev/queries#explain))
887
264
 
888
- ## Observability
265
+ ## Serverless and edge
889
266
 
890
- Built-in query metrics with zero new dependencies. `$observe` buffers per-query timings in memory and flushes **per-minute aggregates**, count, avg, p50, p95, p99, and error count per `model:action`, to a `_turbine_metrics` table in a **separate database**, over its own 1-connection pool so metrics writes never contend with your application pool.
267
+ The core is driver-agnostic: hand any pg-compatible pool to `turbineHttp()` and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, or anywhere else without TCP. The main entry's import graph is held under **85 kB brotli** (edge entry under **68 kB**) with `pg` external, enforced by `size-limit` in CI; run `npm run size` for the current figure.
891
268
 
892
269
  ```typescript
893
- const handle = await db.$observe({
894
- connectionString: process.env.TURBINE_OBSERVE_URL!, // metrics DB (not your app DB)
895
- flushIntervalMs: 60_000, // default: 60s
896
- retentionDays: 30, // default: 30, older buckets are pruned on flush
897
- });
898
-
899
- // Later, to flush remaining metrics and close the metrics pool
900
- await handle.stop();
901
- ```
902
-
903
- `$observe` creates the `_turbine_metrics` table if it doesn't exist. Flushes are fire-and-forget (`INSERT ... ON CONFLICT` additive merge) and never throw into your application. If the `TURBINE_OBSERVE_URL` environment variable is set, the client starts observing automatically on construction, no code needed.
904
-
905
- For your own instrumentation, subscribe to query events with `$on('query')`, each event carries `sql`, `params`, `duration` (ms), `model`, `action`, `rows`, `timestamp`, and `error` (if the query failed):
906
-
907
- ```typescript
908
- db.$on('query', (e) => {
909
- if (e.duration > 200) {
910
- console.warn(`slow query: ${e.model}.${e.action} (${e.duration.toFixed(1)}ms, ${e.rows} rows)`);
911
- }
912
- });
913
- ```
914
-
915
- **`event.params` is redacted by default**: every value arrives as `'[REDACTED]'` so a query log can't carry user data into a log sink. Opt in with `logQueryParams: true` on the client (`errorMessages: 'verbose'` also reveals them, and keeps doing so). Nothing else about the event changes.
916
-
917
- ```typescript
918
- const db = turbine({
919
- connectionString: process.env.DATABASE_URL,
920
- logQueryParams: process.env.NODE_ENV !== 'production',
921
- });
922
- ```
923
-
924
- See [Observability](https://turbineorm.dev/observability#seeing-the-parameter-values-logqueryparams) for the full behavior.
925
-
926
- View the collected metrics in a local dashboard:
927
-
928
- ```bash
929
- TURBINE_OBSERVE_URL=postgres://... npx turbine observe
930
- # Flags: --port (default 4984), --host (default 127.0.0.1), --no-open
931
- ```
932
-
933
- Same security model as Studio: loopback by default, non-loopback refused without `--allow-remote`, per-process random auth token in an `HttpOnly` cookie, CSP headers, and read-only access to the metrics table.
934
-
935
- ## Serverless / Edge
936
-
937
- Turbine's core is driver-agnostic: pass any pg-compatible pool to `TurbineConfig.pool` (or use the `turbineHttp()` factory) and Turbine runs on **Vercel Edge**, **Cloudflare Workers**, **Deno Deploy**, **Netlify Edge**, or any other environment where a direct TCP connection is unavailable. No new dependencies, install whichever driver you already use.
938
-
939
- ### Neon Serverless (HTTP / WebSocket)
940
-
941
- ```ts
942
- // app/api/users/route.ts
943
- import { Pool } from '@neondatabase/serverless';
944
- import { turbineHttp } from 'turbine-orm/serverless';
945
- import { SCHEMA } from '@/generated/turbine/metadata';
946
-
947
- export const runtime = 'edge';
948
-
949
- const pool = new Pool({ connectionString: process.env.DATABASE_URL });
950
- const db = turbineHttp(pool, SCHEMA);
951
-
952
- export async function GET() {
953
- const users = await db.table('users').findMany({
954
- with: { posts: { with: { comments: true } } },
955
- limit: 10,
956
- });
957
- return Response.json(users);
958
- }
959
- ```
960
-
961
- ### Vercel Postgres
962
-
963
- ```ts
964
- import { createPool } from '@vercel/postgres';
965
- import { turbineHttp } from 'turbine-orm/serverless';
966
- import { SCHEMA } from './generated/turbine/metadata.js';
967
-
968
- const pool = createPool({ connectionString: process.env.POSTGRES_URL });
969
- const db = turbineHttp(pool, SCHEMA);
970
- ```
971
-
972
- ### Supabase (direct Postgres, no HTTP proxy needed)
973
-
974
- ```ts
975
- import { TurbineClient } from 'turbine-orm';
976
- import { SCHEMA } from './generated/turbine/metadata.js';
977
-
978
- const db = new TurbineClient({
979
- connectionString: process.env.SUPABASE_DB_URL,
980
- ssl: { rejectUnauthorized: false },
981
- }, SCHEMA);
982
- ```
983
-
984
- ### Cloudflare Workers
985
-
986
- ```ts
987
270
  import { Pool } from '@neondatabase/serverless';
988
271
  import { turbineHttp } from 'turbine-orm/serverless';
989
272
  import { SCHEMA } from './generated/turbine/metadata';
990
273
 
991
- export default {
992
- async fetch(req: Request, env: Env) {
993
- const pool = new Pool({ connectionString: env.DATABASE_URL });
994
- const db = turbineHttp(pool, SCHEMA);
995
- const users = await db.table('users').findMany({ limit: 10 });
996
- return Response.json(users);
997
- },
998
- };
274
+ const db = turbineHttp(new Pool({ connectionString: process.env.DATABASE_URL }), SCHEMA);
275
+ const users = await db.table('users').findMany({ with: { posts: true }, limit: 10 });
999
276
  ```
1000
277
 
1001
- ### Limitations on HTTP drivers
1002
-
1003
- - **Streaming cursors** (`findManyStream`) require `DECLARE CURSOR`, which most HTTP drivers don't support. Use `findMany` with `limit` + pagination instead.
1004
- - **LISTEN/NOTIFY** is not available over HTTP.
1005
- - Transactions work but hold an HTTP connection for their duration, keep them short.
1006
-
1007
- When Turbine receives an external pool, `db.disconnect()` is a no-op: the caller owns the pool's lifecycle.
278
+ HTTP drivers cannot hold a cursor or a LISTEN connection, so `findManyStream` and `$listen` are unavailable there; everything else works. Walkthroughs for Neon, Vercel Postgres, Supabase, and Hyperdrive: [turbineorm.dev/serverless](https://turbineorm.dev/serverless).
1008
279
 
1009
280
  ## Database engines
1010
281
 
1011
- Turbine is **Postgres-first**, `import { TurbineClient } from 'turbine-orm'` targets PostgreSQL, and the safety bundle above is built around it. When you need another database, the same typed API runs on **SQLite**, **MySQL 8**, and **SQL Server** through subpath exports, plus **PowDB**, a single-node embedded database with its own query language (PowQL). Multi-engine is *additive*, not a pivot: pick the engine that fits, keep the same `findMany` / `with` / `where` API.
1012
-
1013
- Two engines run **in-process** (no server): **SQLite** (always. There is no SQLite wire protocol) and **PowDB**, which uniquely runs *both* in-process (embedded) *and* over a network client against the same data. The root install stays one dependency (`pg`). Each engine's driver is its own concern: SQLite needs nothing (Node's built-in `node:sqlite`), while MySQL, SQL Server, and PowDB use **optional peer dependencies** you install only if you use them.
282
+ Postgres is the default and primary target. The same typed API also runs on **SQLite** (Node's built-in `node:sqlite`, zero extra installs, Node 22.5), **MySQL 8** (`mysql2`), **SQL Server 2016+** (`mssql`), and **PowDB** (embedded or networked), each behind a subpath export with its driver as an optional peer:
1014
283
 
1015
284
  ```bash
1016
- # SQLite, zero extra deps (Node >= 22.5, built-in node:sqlite)
1017
- npm install turbine-orm
1018
-
1019
- # MySQL 8, optional peer
1020
- npm install turbine-orm mysql2
1021
-
1022
- # SQL Server 2016+, optional peer
1023
- npm install turbine-orm mssql
1024
-
1025
- # PowDB, optional peer; embedded (in-process) or networked transport
1026
- npm install turbine-orm @zvndev/powdb-embedded # in-process
1027
- npm install turbine-orm @zvndev/powdb-client # networked
285
+ npm install turbine-orm # SQLite needs nothing else
286
+ npm install turbine-orm mysql2 # MySQL
287
+ npm install turbine-orm mssql # SQL Server
288
+ npm install turbine-orm @zvndev/powdb-embedded # PowDB, in-process
1028
289
  ```
1029
290
 
1030
- Each engine ships a factory that returns the same `TurbineClient`:
1031
-
1032
- ```ts
1033
- // SQLite, synchronous; pass a file path, ':memory:', or an open DatabaseSync
291
+ ```typescript
1034
292
  import { turbineSqlite } from 'turbine-orm/sqlite';
1035
293
  import { SCHEMA } from './generated/turbine/metadata.js';
1036
294
 
@@ -1038,140 +296,15 @@ const db = turbineSqlite(':memory:', SCHEMA);
1038
296
  const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
1039
297
  ```
1040
298
 
1041
- ```ts
1042
- // MySQL 8, async; connection string, mysql2 config, or an existing mysql2 pool
1043
- import { turbineMysql } from 'turbine-orm/mysql';
1044
- import { SCHEMA } from './generated/turbine/metadata.js';
1045
-
1046
- const db = await turbineMysql('mysql://user:pass@localhost:3306/app', SCHEMA);
1047
- ```
1048
-
1049
- ```ts
1050
- // SQL Server 2016+, async; connection string, mssql config, or an existing pool
1051
- import { turbineMssql } from 'turbine-orm/mssql';
1052
- import { SCHEMA } from './generated/turbine/metadata.js';
1053
-
1054
- const db = await turbineMssql('mssql://sa:Passw0rd!@localhost:1433/app', SCHEMA);
1055
- ```
1056
-
1057
- ```ts
1058
- // PowDB, async; embedded (in-process) or networked. Schema is code-defined.
1059
- import { turbinePowDB } from 'turbine-orm/powdb';
1060
- import { schemaDefToMetadata } from 'turbine-orm';
1061
- import { schema } from './schema.js'; // defineSchema({...})
299
+ 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).
1062
300
 
1063
- // PowDB has no introspection-driven `turbine generate`, so derive the runtime
1064
- // SchemaMetadata from the code-first definition.
1065
- const SCHEMA = schemaDefToMetadata(schema);
301
+ **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).
1066
302
 
1067
- // Embedded, in-process. syncMode 'normal' moves fsync off the commit path:
1068
- const db = await turbinePowDB({ embedded: './data', syncMode: 'normal' }, SCHEMA);
1069
- // …or networked against a running powdb-server:
1070
- // const db = await turbinePowDB('powdb://127.0.0.1:7070', SCHEMA);
1071
- ```
1072
-
1073
- Migrating from Prisma? `turbine migrate-from-prisma` emits a typed `PRISMA_MAP`, and the
1074
- `turbine-orm/prisma-compat` subpath wraps a `TurbineClient` in Prisma's `db.Model.*`
1075
- surface (no new dependencies):
1076
-
1077
- ```ts
1078
- import { TurbineClient } from 'turbine-orm';
1079
- import { createPrismaCompatClient } from 'turbine-orm/prisma-compat';
1080
- import { SCHEMA } from './generated/turbine/metadata.js';
1081
- import { PRISMA_MAP } from './generated/turbine/prisma-map.js';
1082
-
1083
- const db = new TurbineClient({ connectionString: process.env.DATABASE_URL }, SCHEMA);
1084
- const prisma = createPrismaCompatClient(db, PRISMA_MAP);
1085
-
1086
- const users = await prisma.User.findMany({ include: { posts: { take: 5 } } });
1087
- ```
1088
-
1089
- Because nothing re-runs the generator, the map records a fingerprint of the
1090
- `schema.prisma` it was built from, and `createPrismaCompatClient` warns once at startup
1091
- (development only, never in production, silent when the file is absent) if that file has
1092
- since changed. Put `turbine migrate-from-prisma --if-db` in `postinstall` next to
1093
- `prisma generate` so it does not depend on anyone remembering; `--if-db` exits 0 when no
1094
- database is reachable, so a build image with no `DATABASE_URL` still installs.
1095
-
1096
- Turbine-only query options (`forceCustomPlan`, `skipGlobalFilters`, `allowFullTableScan`,
1097
- `warnOnUnlimited`, `timeout`, `optimisticLock`, `distinctOn`, …) pass through the compat
1098
- delegates, and an unrecognized query-level key logs a one-time dev warning naming the
1099
- nearest real option instead of being dropped.
1100
-
1101
- > **Correction, 0.57.0.** Before that release the adapter copied a hand-written allowlist
1102
- > of keys, so those options were accepted by the type-checker and silently dropped. That
1103
- > includes `forceCustomPlan`, which 0.56.0 shipped and `turbine doctor` recommended: on
1104
- > prisma-compat it did nothing at all, while working exactly as documented on the core
1105
- > client. Two changes are visible on upgrade even if you change nothing: `skipGlobalFilters`
1106
- > now takes effect where it was inert, and `relationLoadStrategy: 'query'` now maps to
1107
- > Turbine's `'batched'` instead of silently using the join plan. The option surface is now
1108
- > compiler-checked against the core argument interfaces, so a newly added core option fails
1109
- > the build in the adapter rather than being stranded in silence.
1110
-
1111
- ### Capability matrix
1112
-
1113
- Everything is honest about what ports and what doesn't. Features marked **PG-only** throw a typed `UnsupportedFeatureError` (`TURBINE_E017`) on other engines rather than silently degrading.
1114
-
1115
- | Feature | PostgreSQL | SQLite | MySQL 8 | SQL Server |
1116
- |---|:---:|:---:|:---:|:---:|
1117
- | Single-query nested `with` | ✓ `json_agg` | ✓ `json_group_array` | ✓ `JSON_ARRAYAGG` | ✓ `FOR JSON PATH` |
1118
- | Transactions + savepoints | ✓ | ✓ (single-writer) | ✓ | ✓ |
1119
- | Streaming (`findManyStream`) | ✓ true cursor | ⚠ materializes | ⚠ materializes | ⚠ materializes |
1120
- | Migrations (`turbine migrate` CLI) | ✓ | PG-only (CLI) | PG-only (CLI) | PG-only (CLI) |
1121
- | pgvector distance / KNN | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
1122
- | LISTEN/NOTIFY realtime | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
1123
- | RLS `sessionContext` | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
1124
-
1125
- ✗ 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).
1126
-
1127
- **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`.
1128
-
1129
- **PowDB** speaks its own non-SQL query language (PowQL), so it sits outside the SQL matrix above. Writes use a trailing **`returning`** keyword (upsert reselects by PK). PKs are server-assigned `auto` ints **or** client UUIDs. Nested relations run as **one statement** on engine 0.18+ (PowQL nested projections, per-parent order/limit, childless parents kept, the same single-query shape as Postgres `json_agg`); older engines and ineligible shapes (many-to-many via the junction) load client-side with identical output. Nested writes cover hasMany/hasOne/belongsTo, and route through the same shared nested-write engine as the SQL engines, so the many-to-many `connect` / `disconnect` / `set` junction writes added in 0.50 apply here too (they are ordinary reads and writes on the junction table); the remaining many-to-many operations throw `ValidationError`. Transactions are single-writer: concurrent `$transaction` calls queue FIFO (bounded by `transactionQueueTimeoutMs`); nested/re-entrant transactions throw typed errors (no savepoints). Schema is code-first via `defineSchema`, `schemaDefToMetadata()` bridges it to any engine that needs runtime metadata, and a programmatic `describe`-based introspector exists since 0.34 (relations excluded). JSON documents are first-class on engine 0.12+: `JsonFilter` where-filters, JSON-path `orderBy`/`groupBy`, doc-field expression indexes, and a lossless native wire (0.13+) that keeps JSON `null`, missing fields, and the string `"null"` distinct. Embedded `syncMode: 'normal'` moves fsync off the commit path; the networked transport runs the same data over a socket. Cursor streaming and the Postgres-only trio (pgvector / LISTEN/NOTIFY / RLS session GUCs) throw `UnsupportedFeatureError`. Full details: **[turbineorm.dev/engines#powdb](https://turbineorm.dev/engines#powdb)**.
1130
-
1131
- Full setup, signatures, and the complete support matrix: **[turbineorm.dev/engines](https://turbineorm.dev/engines)**.
1132
-
1133
- ## Configuration
1134
-
1135
- Create `turbine.config.ts` in your project root (or run `npx turbine init`):
1136
-
1137
- ```typescript
1138
- import type { TurbineCliConfig } from 'turbine-orm/cli';
1139
-
1140
- const config: TurbineCliConfig = {
1141
- url: process.env.DATABASE_URL,
1142
- out: './generated/turbine',
1143
- schema: 'public',
1144
- migrationsDir: './turbine/migrations',
1145
- seedFile: './turbine/seed.ts',
1146
- schemaFile: './turbine/schema.ts',
1147
- };
1148
-
1149
- export default config;
1150
- ```
1151
-
1152
- Priority order: CLI flags > environment variables (`DATABASE_URL`) > config file > defaults.
1153
-
1154
- ## How It Works
303
+ ## From scratch, and forkable
1155
304
 
1156
- Turbine resolves nested relations the same way Prisma 7 and Drizzle do: correlated subqueries with `json_agg` + `json_build_object`, evaluated by PostgreSQL in a single round-trip. No N+1, no client-side stitching, no separate queries per relation. The `with` clause is fully type-inferred end-to-end, write `db.users.findMany({ with: { posts: { with: { comments: { with: { author: true } } } } } })` and `users[0].posts[0].comments[0].author.name` autocompletes with zero manual annotation.
305
+ Turbine is MIT and has no cloud tier, no paid gateway, and no telemetry. Everything named on this page is in the box.
1157
306
 
1158
- The query strategy is table stakes now. What isn't table stakes: the one-dependency, no-WASM footprint, the read-only Studio your DBA will approve, the error messages that never leak PII, and the SQL-first migrations with SHA-256 drift detection. See [Why Turbine?](#why-turbine) for the full breakdown.
1159
-
1160
- ## Type Mapping
1161
-
1162
- Turbine maps Postgres types to TypeScript:
1163
-
1164
- | Postgres | TypeScript | Notes |
1165
- |---|---|---|
1166
- | `int2`, `int4`, `float4`, `float8` | `number` | Standard numeric types |
1167
- | `int8` / `bigint` | `number` | Values > `Number.MAX_SAFE_INTEGER` (2^53 - 1) are returned as `string` at runtime to avoid precision loss. This affects < 0.01% of use cases (auto-increment IDs, counts, etc. are all safe). |
1168
- | `numeric`, `money` | `string` | Arbitrary precision, kept as string to avoid JS float issues |
1169
- | `text`, `varchar`, `uuid`, `citext` | `string` | |
1170
- | `timestamptz`, `timestamp`, `date` | `Date` | `timestamp` (without time zone) **and `date`** are parsed as UTC by default (Prisma/Rails/Django convention), so the same row yields the same instant in every region. Opt out with `utcTimestamps: false`. Since v0.52 the flag also reaches the WRITE side on Postgres: it governs the `Date` values that `create` / `update` / `upsert` and `where` clauses bind to zone-less `date` / `timestamp` columns. Before v0.52 it reached the read path only, so a client that had set `false` was still binding UTC, and those statements (and the text they store) change on upgrade. The two halves settle at different scopes: the write half is per client, the read half is a process-global pg type parser, so every client in one process must agree or construction throws `ValidationError`. Since v0.54 `date` reads at UTC midnight rather than the process's local midnight (its array form too), which moves the **epoch value** of a `date` by your process's UTC offset even though the calendar day it denotes is unchanged: format with `toISOString().slice(0, 10)`, not with local-component helpers like `toLocaleDateString()`. Since v0.55 a stored `infinity` / `-infinity` reads as the JS number `Infinity` / `-Infinity` on **every** read strategy (v0.54 gave the number on the top-level and batched paths and an `Invalid Date` through a `with` clause, because `json_build_object` renders the value as the string `"infinity"` that no driver parser sees). There is no JS `Date` for either value, so the reading costs something either way and the default is the one that cannot **lose** a value: the number binds straight back, so `update({ data: { ...row } })` stores `infinity` again, whereas reading it as `null` makes a stored infinity indistinguishable from a stored NULL and that same write silently stores SQL NULL. The price of the default is that the field is declared `Date` and hands back a number, so `.toISOString()` / `.getTime()` throw a `TypeError` on those rows and `JSON.stringify` still renders them `null`. Set `temporalInfinity: 'null'` to read `null` instead, accepting the data loss, the collapse of `groupBy` / `distinct` keys, and `_max` returning `null` on a table with rows. Either way the value stays writable as `'infinity'` / `'-infinity'`, a one-time warning names the field the first time one is read (this one is NOT silenced by `NODE_ENV=production`, and naming either reading silences it), and `where: { col: null }` still means `IS NULL` and does not match those rows. See [Relation loading and wire encoding](#relation-loading-and-wire-encoding). |
1171
- | `boolean` | `boolean` | |
1172
- | `json`, `jsonb` | `unknown` | |
1173
- | `bytea` | `Buffer` | |
1174
- | Array types | `T[]` | e.g. `_text` → `string[]` |
307
+ It is also built to be extended rather than wrapped. All SQL generation routes through a documented `Dialect` contract (identifier quoting, placeholders, result strategy, capability flags, JSON aggregation hooks); the SQLite, MySQL, and SQL Server engines are implementations of that seam, not forks of the core. If you need an engine Turbine does not ship, the seam is where you start: [turbineorm.dev/dialects](https://turbineorm.dev/dialects).
1175
308
 
1176
309
  ## Comparison
1177
310
 
@@ -1180,76 +313,66 @@ Turbine maps Postgres types to TypeScript:
1180
313
  | **Engine / runtime** | No engine binary (`pg` only) | Client + TS/WASM query compiler | No engine | No engine |
1181
314
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + required driver adapter | 0 | 0 |
1182
315
  | **Main bundle (brotli)** | under 85 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
1183
- | **Studio** | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Free; hosted Gateway paid | None |
316
+ | **Studio** | Read-only by default | Full CRUD, cloud-hosted | Free; hosted Gateway paid | None |
1184
317
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
1185
318
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
1186
319
  | **Edge runtime** | One import swap, under 68 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native |
1187
320
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
1188
321
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
1189
- | **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`) |
1190
- | **Many-to-many** | Auto-detected from junctions | Implicit/explicit | Explicit `relations()` | Manual joins |
322
+ | **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`) |
323
+ | **Index advice** | `turbine doctor`, offline, `--fix` | Optimize retired March 2026 (cloud Query Insights) | None | None |
324
+ | **MCP server for agents** | 11 read-only tools, PII-redacted | Official MCP server | `drizzle-kit mcp` | None |
1191
325
  | **Vector search** | Built-in `distance` / KNN | Preview / raw | Extension API | Manual |
1192
326
  | **LISTEN/NOTIFY** | `$listen` / `$notify` | None | None | None |
1193
- | **Multi-DB** | Postgres-first (+ SQLite/MySQL/MSSQL engines) | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
1194
327
 
1195
- Reading the table: no engine binary and no WASM, just one runtime dependency (`pg`), against Prisma 7's ~1.6 MB TypeScript/WASM query compiler plus a required driver adapter; a Studio that is read-only by default, which as of July 2026 no other TypeScript ORM ships; error messages that never leak PII; and SQL-first migrations with SHA-256 drift detection. See [Benchmarks](#benchmarks) for performance numbers: most scenarios are within noise over a real pooled database.
1196
-
1197
- *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.*
1198
-
1199
- **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.
328
+ Competitor columns last checked August 2026, against Prisma 7 and Drizzle 0.45. Features marked Preview may change; bundle sizes move release to release. The longer version of this argument, including what is not a reason to switch: [turbineorm.dev/why-turbine](https://turbineorm.dev/why-turbine).
1200
329
 
1201
330
  ## Limitations
1202
331
 
1203
- Turbine is focused and opinionated. Here's what it doesn't do:
332
+ Stated so you do not find out three weeks in:
1204
333
 
1205
- - **Postgres-first.** PostgreSQL is the default and primary target, going deep on one database is what enables the safety bundle and the edge-runtime story. SQLite, MySQL 8, and SQL Server engines are available as additive subpath exports (see [Database engines](#database-engines)), but several flagship features (pgvector, LISTEN/NOTIFY, RLS `sessionContext`, full-text `search`, array-column filters, `findMany({ distinct })` and `groupBy({ distinctOn })`) are Postgres-only and throw `UnsupportedFeatureError` elsewhere.
1206
- - **Full-text search** is available via a `search` filter, `where: { title: { search: 'hello & world', config: 'english' } }` compiles to a parameterized `to_tsvector(...) @@ to_tsquery(...)`. PostgreSQL only: the other engines throw `UnsupportedFeatureError` (`TURBINE_E017`) rather than degrade to a `LIKE`. For advanced ranking (`ts_rank`, weighted vectors) use `db.raw`.
1207
- - **Large nested result sets.** Nested results are materialized server-side in PostgreSQL memory. For relations with 10K+ rows, always use `limit` in your `with` clause, or stream the parents with `findManyStream` and resolve children per-row.
334
+ - **Postgres-first.** The other engines are real and tested, but pgvector, LISTEN/NOTIFY, RLS `sessionContext`, full-text `search`, array filters, and `distinct` are Postgres-only and throw `UnsupportedFeatureError` elsewhere. The CLI (`generate`, `migrate`) is Postgres-only.
335
+ - **Only Postgres streams with a true cursor.** The other engines' `findManyStream` materializes the result first, then yields in batches: same rows, not constant memory.
336
+ - **Large nested result sets** are materialized in PostgreSQL memory. For relations with 10K+ rows, put a `limit` in the `with` clause, or stream parents and resolve children per batch.
337
+ - **It is younger** than Prisma or Drizzle: fewer Stack Overflow answers, a smaller community. The migration paths in both directions are real, which is the honest mitigation.
1208
338
 
1209
- ## Examples
339
+ ## Type mapping
1210
340
 
1211
- **Feature demos**
1212
-
1213
- - **[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
1214
- - **[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`
1215
- - **[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
1216
-
1217
- **Runtime targets**
1218
-
1219
- - **[Next.js](https://github.com/zvndev/turbine-orm/tree/main/examples/nextjs/)**, Server-rendered app with nested relations, streaming, and live code demos
1220
- - **[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`
1221
- - **[Vercel Postgres](https://github.com/zvndev/turbine-orm/tree/main/examples/vercel-postgres/)**, Next.js app router route handler on `@vercel/postgres`
1222
- - **[Cloudflare Worker](https://github.com/zvndev/turbine-orm/tree/main/examples/cloudflare-worker/)**, Worker `fetch` handler with `pg` over Cloudflare Hyperdrive
1223
- - **[Supabase](https://github.com/zvndev/turbine-orm/tree/main/examples/supabase/)**, Standalone script over the standard `pg` driver against Supabase
341
+ | Postgres | TypeScript | Notes |
342
+ |---|---|---|
343
+ | `int2`, `int4`, `float4`, `float8` | `number` | |
344
+ | `int8` / `bigint` | `number` | Values above `Number.MAX_SAFE_INTEGER` come back as `string` to avoid precision loss |
345
+ | `numeric`, `money` | `string` | Arbitrary precision, kept exact |
346
+ | `text`, `varchar`, `uuid`, `citext` | `string` | |
347
+ | `timestamptz`, `timestamp`, `date` | `Date` | Zone-less columns read and write as UTC by default (`utcTimestamps`); the full temporal semantics, including `temporalInfinity`, are at [turbineorm.dev/schema](https://turbineorm.dev/schema#zone-less-columns-timestamp-and-date-read-as-utc) |
348
+ | `boolean` | `boolean` | |
349
+ | `json`, `jsonb` | `unknown` | |
350
+ | `bytea` | `Buffer` | |
351
+ | Array types | `T[]` | |
1224
352
 
1225
- ## Guides
353
+ ## Examples
1226
354
 
1227
- - **[Quick Start](https://turbineorm.dev/quickstart)**, zero-to-first-query in five minutes
1228
- - **[API Reference](https://turbineorm.dev/queries)**, every `findMany` / `findUnique` / `create` / `update` / `delete` option, the full operator table, and `pipeline()` semantics
1229
- - **[Relations](https://turbineorm.dev/relations)**, deep `with` clause, nested options, relation filters (`some` / `every` / `none`), payload-size guidance
1230
- - **[Transactions & Pipelines](https://turbineorm.dev/transactions)**, isolation levels, nested SAVEPOINTs, retry loops for `DeadlockError` and `SerializationFailureError`
1231
- - **[Schema & Migrations](https://turbineorm.dev/schema)**, `defineSchema()`, auto-diff migrations, checksum validation
1232
- - **[Serverless & Edge](https://turbineorm.dev/serverless)**, Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase walkthroughs
1233
- - **[CLI](https://turbineorm.dev/cli)**, every command, flag, and config option
1234
- - **[Typed Errors](https://turbineorm.dev/errors)**, error code reference, `wrapPgError()` translation, retry patterns
1235
- - **[Migrating from Prisma](https://turbineorm.dev/migrate-from-prisma)**, API mapping table, side-by-side `findMany`, and notes on the differences
355
+ - **[Thread Machine](https://github.com/zvndev/turbine-orm/tree/main/examples/thread-machine/)**: HN clone rendered from a single `findMany`, 4 levels deep, typed through the chain
356
+ - **[Streaming CSV](https://github.com/zvndev/turbine-orm/tree/main/examples/streaming-csv/)**: 100K orders to CSV with constant memory
357
+ - **[Clickstorm](https://github.com/zvndev/turbine-orm/tree/main/examples/clickstorm/)**: atomic increment vs read-modify-write under 10K concurrent clicks
358
+ - Runtime targets: [Next.js](https://github.com/zvndev/turbine-orm/tree/main/examples/nextjs/) · [Neon Edge](https://github.com/zvndev/turbine-orm/tree/main/examples/neon-edge/) · [Vercel Postgres](https://github.com/zvndev/turbine-orm/tree/main/examples/vercel-postgres/) · [Cloudflare Worker](https://github.com/zvndev/turbine-orm/tree/main/examples/cloudflare-worker/) · [Supabase](https://github.com/zvndev/turbine-orm/tree/main/examples/supabase/)
1236
359
 
1237
360
  ## Requirements
1238
361
 
1239
- - Node.js >= 20.0.0
1240
- - PostgreSQL >= 14 (tested). CI runs the integration suite against PostgreSQL 14, 15, 16 and 17 on every change; 14 is the oldest version anything is verified on. Nothing in Turbine's introspection or query generation is known to require a feature newer than PostgreSQL 12, so older servers may well work, but they are untested and unsupported.
1241
- - Works with both ESM (`import`) and CommonJS (`require`)
362
+ - Node.js 20 (the SQLite engine needs ≥ 22.5 for `node:sqlite`)
363
+ - PostgreSQL 14 tested; CI runs the integration suite against PostgreSQL 14, 15, 16, and 17
364
+ - ESM and CommonJS both supported
1242
365
 
1243
366
  ## Contributing
1244
367
 
1245
- 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:
368
+ Contributions are welcome. See [CONTRIBUTING.md](https://github.com/zvndev/turbine-orm/blob/main/CONTRIBUTING.md) for setup 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:
1246
369
 
1247
370
  ```bash
1248
371
  npm install
1249
372
  npm run test:unit
1250
373
  ```
1251
374
 
1252
- 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).
375
+ 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).
1253
376
 
1254
377
  ## License
1255
378