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