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.
- package/README.md +164 -1041
- package/dist/cjs/cli/compile-query.d.ts +198 -0
- package/dist/cjs/cli/compile-query.js +529 -0
- package/dist/cjs/cli/index.d.ts +25 -1
- package/dist/cjs/cli/index.js +49 -1
- package/dist/cjs/cli/mcp.js +198 -16
- package/dist/cjs/client.d.ts +45 -10
- package/dist/cjs/client.js +21 -3
- package/dist/cjs/connection-url.d.ts +160 -0
- package/dist/cjs/connection-url.js +296 -0
- package/dist/cjs/index-stats.d.ts +4 -1
- package/dist/cjs/index-stats.js +27 -11
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/plan-flip-probe.js +17 -1
- package/dist/cjs/powql.d.ts +1 -0
- package/dist/cjs/powql.js +9 -0
- package/dist/cjs/query/builder.d.ts +133 -2
- package/dist/cjs/query/builder.js +288 -64
- package/dist/cjs/query/deferred.d.ts +12 -6
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/option-surface.js +6 -0
- package/dist/cjs/query/types.d.ts +47 -0
- package/dist/cjs/query/where.d.ts +11 -2
- package/dist/cli/compile-query.d.ts +198 -0
- package/dist/cli/compile-query.js +522 -0
- package/dist/cli/index.d.ts +25 -1
- package/dist/cli/index.js +48 -1
- package/dist/cli/mcp.js +198 -16
- package/dist/client.d.ts +45 -10
- package/dist/client.js +19 -1
- package/dist/connection-url.d.ts +160 -0
- package/dist/connection-url.js +289 -0
- package/dist/index-stats.d.ts +4 -1
- package/dist/index-stats.js +27 -11
- package/dist/index.d.ts +1 -1
- package/dist/plan-flip-probe.js +17 -1
- package/dist/powql.d.ts +1 -0
- package/dist/powql.js +9 -0
- package/dist/query/builder.d.ts +133 -2
- package/dist/query/builder.js +288 -64
- package/dist/query/deferred.d.ts +12 -6
- package/dist/query/index.d.ts +1 -1
- package/dist/query/option-surface.js +6 -0
- package/dist/query/types.d.ts +47 -0
- package/dist/query/where.d.ts +11 -2
- package/package.json +8 -6
package/README.md
CHANGED
|
@@ -1,123 +1,73 @@
|
|
|
1
1
|
# turbine-orm
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**A Postgres ORM written from scratch. One dependency.**
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
-
|
|
17
|
+
Six reasons, each with the mechanism that makes it true:
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
53
|
-
| findMany, 50 users + posts (L2) *(contested
|
|
54
|
-
| findMany, 10 users → posts → comments (L3) | 1.
|
|
55
|
-
| findUnique, single user by PK | **0.
|
|
56
|
-
| findUnique, user + posts + comments (L3) | **0.
|
|
57
|
-
| count, all users | **0.
|
|
58
|
-
| stream, iterate 50K rows (batch 1000) |
|
|
59
|
-
| atomic increment, `view_count + 1` *(contested
|
|
60
|
-
| pipeline, 5-query batch | **0.
|
|
61
|
-
| hot findUnique, 500x same shape | **0.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
- **Turbine
|
|
66
|
-
-
|
|
67
|
-
- **Drizzle wins streaming
|
|
68
|
-
- **
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
### Writes, including atomic operators
|
|
163
109
|
|
|
164
110
|
```typescript
|
|
165
|
-
|
|
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
|
-
|
|
113
|
+
await db.users.createMany({ data: [/* ... */] }); // one INSERT via UNNEST, not N inserts
|
|
177
114
|
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
138
|
+
### Pipelining, at the protocol level
|
|
323
139
|
|
|
324
140
|
```typescript
|
|
325
|
-
const [user, postCount,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 },
|
|
169
|
+
with: { posts: true },
|
|
386
170
|
})) {
|
|
387
171
|
process.stdout.write(`${user.email}\n`);
|
|
388
172
|
}
|
|
389
173
|
```
|
|
390
174
|
|
|
391
|
-
|
|
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
|
-
|
|
177
|
+
### Global filters
|
|
524
178
|
|
|
525
179
|
```typescript
|
|
526
180
|
const db = turbine({
|
|
527
181
|
connectionString: process.env.DATABASE_URL,
|
|
528
182
|
globalFilters: {
|
|
529
|
-
//
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
191
|
+
### The UNSAFE symbol
|
|
548
192
|
|
|
549
|
-
Three
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
+
## Built for agents
|
|
737
218
|
|
|
738
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
257
|
+
Going deep on one database means the parts other ORMs push to raw SQL are typed surface here:
|
|
881
258
|
|
|
882
|
-
- **
|
|
883
|
-
- **
|
|
884
|
-
- **
|
|
885
|
-
- **
|
|
886
|
-
- **
|
|
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
|
-
##
|
|
265
|
+
## Serverless and edge
|
|
889
266
|
|
|
890
|
-
|
|
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
|
-
|
|
992
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1017
|
-
npm install turbine-orm
|
|
1018
|
-
|
|
1019
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1190
|
-
| **
|
|
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
|
-
|
|
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
|
-
|
|
332
|
+
Stated so you do not find out three weeks in:
|
|
1204
333
|
|
|
1205
|
-
- **Postgres-first.**
|
|
1206
|
-
- **
|
|
1207
|
-
- **Large nested result sets
|
|
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
|
-
##
|
|
339
|
+
## Type mapping
|
|
1210
340
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
##
|
|
353
|
+
## Examples
|
|
1226
354
|
|
|
1227
|
-
- **[
|
|
1228
|
-
- **[
|
|
1229
|
-
- **[
|
|
1230
|
-
-
|
|
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
|
|
1240
|
-
- PostgreSQL
|
|
1241
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
|