turbine-orm 0.19.1 → 0.21.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 +156 -24
- package/dist/adapters/cockroachdb.js +4 -9
- package/dist/cjs/adapters/cockroachdb.js +4 -9
- package/dist/cjs/cli/index.js +23 -12
- package/dist/cjs/cli/observe-ui.js +12 -2
- package/dist/cjs/cli/observe.js +6 -7
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +6 -37
- package/dist/cjs/client.js +45 -33
- package/dist/cjs/dialect.js +116 -0
- package/dist/cjs/errors.js +19 -1
- package/dist/cjs/generate.js +4 -0
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/introspect.js +20 -0
- package/dist/cjs/mssql.js +1338 -0
- package/dist/cjs/mysql.js +1052 -0
- package/dist/cjs/query/builder.js +408 -122
- package/dist/cjs/query/utils.js +1 -0
- package/dist/cjs/sqlite.js +849 -0
- package/dist/cjs/typed-sql.js +9 -7
- package/dist/cli/index.js +23 -12
- package/dist/cli/migrate.d.ts +2 -2
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +12 -2
- package/dist/cli/observe.js +7 -8
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +0 -10
- package/dist/cli/studio.js +7 -37
- package/dist/client.d.ts +35 -14
- package/dist/client.js +46 -34
- package/dist/dialect.d.ts +258 -1
- package/dist/dialect.js +83 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +17 -0
- package/dist/generate.js +4 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +18 -0
- package/dist/introspect.js +19 -0
- package/dist/mssql.d.ts +233 -0
- package/dist/mssql.js +1298 -0
- package/dist/mysql.d.ts +174 -0
- package/dist/mysql.js +1012 -0
- package/dist/query/builder.d.ts +105 -6
- package/dist/query/builder.js +409 -123
- package/dist/query/index.d.ts +2 -2
- package/dist/query/types.d.ts +62 -12
- package/dist/query/utils.js +1 -0
- package/dist/sqlite.d.ts +144 -0
- package/dist/sqlite.js +842 -0
- package/dist/typed-sql.d.ts +7 -5
- package/dist/typed-sql.js +9 -7
- package/package.json +32 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# turbine-orm
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Postgres ORM your DBA will sign off on. A read-only Studio that can't touch prod, errors that never leak PII, one dependency, and checksummed migrations.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
npm install turbine-orm
|
|
@@ -10,16 +10,16 @@ npm install turbine-orm
|
|
|
10
10
|
|
|
11
11
|
## Why Turbine?
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Every TS ORM now resolves nested relations in a single `json_agg` query — Prisma 7 and Drizzle v2 both ship it, and so does Turbine. That part is table stakes. The reason to reach for Turbine is the **safety bundle**: the boxes a DBA ticks before a query layer goes anywhere near production. It's the only TypeScript ORM that ships all six of these together:
|
|
14
14
|
|
|
15
|
-
1. **
|
|
16
|
-
2. **
|
|
17
|
-
3. **
|
|
15
|
+
1. **Read-only Studio your DBA will approve.** `npx turbine studio` spins up a loopback-bound web UI with 192-bit auth tokens, `BEGIN READ ONLY` transactions, and — since v0.19 — no raw-SQL surface at all: queries are composed in the ORM's own validated builder. The only TS ORM Studio that physically cannot mutate your database.
|
|
16
|
+
2. **PII-safe error messages.** Turbine errors show WHERE keys, not values. A `UniqueConstraintError` says which column violated the constraint — never the actual user data. Safe to log, safe to surface to monitoring, no scrubbing needed.
|
|
17
|
+
3. **One runtime dependency (`pg`).** No engine binary, no WASM adapter, no adapter packages to keep in lockstep. The main entry bundles to ~31 kB brotli (~109 KB minified); the edge entry to ~22 kB brotli. Prisma's WASM query engine alone is 1.6 MB.
|
|
18
18
|
4. **SQL-first migrations with drift detection.** Write real SQL. SHA-256 checksums catch modified migration files. `pg_try_advisory_lock()` prevents concurrent runs. Each migration in its own transaction. No shadow database, no magic DSL.
|
|
19
|
-
5. **Edge-native — one import swap.** `turbineHttp(pool,
|
|
19
|
+
5. **Edge-native — one import swap.** `turbineHttp(pool, SCHEMA)` — same API on Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase. No WASM bundle, no adapter package, no separate serverless build.
|
|
20
20
|
6. **Pipeline batching via wire protocol.** Real Parse/Bind/Execute pipeline — not queries wrapped in a transaction. N independent queries in one round-trip.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
See [How It Works](#how-it-works) for the `json_agg` query strategy itself — but the query strategy isn't why you'd pick Turbine. The safety bundle above is: a Studio that can't mutate prod, errors that never leak PII, one dependency, and checksummed migrations.
|
|
23
23
|
|
|
24
24
|
## Benchmarks
|
|
25
25
|
|
|
@@ -343,6 +343,8 @@ const db = turbine({
|
|
|
343
343
|
|
|
344
344
|
### Middleware
|
|
345
345
|
|
|
346
|
+
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.
|
|
347
|
+
|
|
346
348
|
```typescript
|
|
347
349
|
// Query timing
|
|
348
350
|
db.$use(async (params, next) => {
|
|
@@ -352,15 +354,33 @@ db.$use(async (params, next) => {
|
|
|
352
354
|
return result;
|
|
353
355
|
});
|
|
354
356
|
|
|
355
|
-
//
|
|
357
|
+
// Result transformation — redact a field on the way out
|
|
356
358
|
db.$use(async (params, next) => {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
+
const result = await next(params);
|
|
360
|
+
if (params.model === 'users' && Array.isArray(result)) {
|
|
361
|
+
for (const row of result as { email?: string }[]) row.email = '[redacted]';
|
|
359
362
|
}
|
|
360
|
-
return
|
|
363
|
+
return result;
|
|
361
364
|
});
|
|
362
365
|
```
|
|
363
366
|
|
|
367
|
+
> **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.
|
|
368
|
+
|
|
369
|
+
Because middleware can't rewrite queries, cross-cutting filters like **soft deletes** belong in the query itself — either explicitly or via a small scoped helper:
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
import type { WhereClause } from 'turbine-orm';
|
|
373
|
+
|
|
374
|
+
// Explicit filter
|
|
375
|
+
const users = await db.users.findMany({ where: { deletedAt: null } });
|
|
376
|
+
|
|
377
|
+
// Scoped helper that always applies the filter
|
|
378
|
+
const activeUsers = (where: WhereClause<User> = {}) =>
|
|
379
|
+
db.users.findMany({ where: { ...where, deletedAt: null } });
|
|
380
|
+
|
|
381
|
+
const rows = await activeUsers({ orgId: 1 });
|
|
382
|
+
```
|
|
383
|
+
|
|
364
384
|
### Error handling
|
|
365
385
|
|
|
366
386
|
Turbine throws typed errors you can catch programmatically:
|
|
@@ -384,7 +404,7 @@ try {
|
|
|
384
404
|
}
|
|
385
405
|
```
|
|
386
406
|
|
|
387
|
-
Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation), `TURBINE_E008` (UniqueConstraint), `TURBINE_E009` (ForeignKey), `TURBINE_E010` (NotNullViolation), `TURBINE_E011` (CheckConstraint), `TURBINE_E012` (Deadlock), `TURBINE_E013` (SerializationFailure), `TURBINE_E014` (Pipeline), `TURBINE_E015` (OptimisticLock), `TURBINE_E016` (ExclusionConstraint).
|
|
407
|
+
Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation), `TURBINE_E008` (UniqueConstraint), `TURBINE_E009` (ForeignKey), `TURBINE_E010` (NotNullViolation), `TURBINE_E011` (CheckConstraint), `TURBINE_E012` (Deadlock), `TURBINE_E013` (SerializationFailure), `TURBINE_E014` (Pipeline), `TURBINE_E015` (OptimisticLock), `TURBINE_E016` (ExclusionConstraint), `TURBINE_E017` (UnsupportedFeature — a Postgres-only feature invoked on another engine).
|
|
388
408
|
|
|
389
409
|
Full reference with `wrapPgError()` translation, retry patterns for `DeadlockError` / `SerializationFailureError`, and safe vs verbose message modes: **[turbineorm.dev/errors](https://turbineorm.dev/errors)**.
|
|
390
410
|
|
|
@@ -557,6 +577,7 @@ Commands:
|
|
|
557
577
|
seed Run seed file
|
|
558
578
|
status Show database schema summary
|
|
559
579
|
studio Launch local read-only Studio web UI
|
|
580
|
+
observe Launch local metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
560
581
|
|
|
561
582
|
Options:
|
|
562
583
|
--url, -u <url> Postgres connection string
|
|
@@ -638,6 +659,42 @@ npx turbine studio --port 5173 --host 127.0.0.1 --no-open
|
|
|
638
659
|
- **Every query runs inside `BEGIN READ ONLY`** with a 30s transaction-local statement timeout (parameterized `set_config`). Writes are physically impossible at the transaction level.
|
|
639
660
|
- **Security headers on every response** — CSP, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` — plus per-session rate limiting and cross-origin refusal.
|
|
640
661
|
|
|
662
|
+
## Observability
|
|
663
|
+
|
|
664
|
+
Built-in query metrics with zero new dependencies. `$observe` buffers per-query timings in memory and flushes **per-minute aggregates** — count, avg, p50, p95, p99, and error count per `model:action` — to a `_turbine_metrics` table in a **separate database**, over its own 1-connection pool so metrics writes never contend with your application pool.
|
|
665
|
+
|
|
666
|
+
```typescript
|
|
667
|
+
const handle = await db.$observe({
|
|
668
|
+
connectionString: process.env.TURBINE_OBSERVE_URL!, // metrics DB (not your app DB)
|
|
669
|
+
flushIntervalMs: 60_000, // default: 60s
|
|
670
|
+
retentionDays: 30, // default: 30 — older buckets are pruned on flush
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// Later, to flush remaining metrics and close the metrics pool
|
|
674
|
+
await handle.stop();
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
`$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.
|
|
678
|
+
|
|
679
|
+
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):
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
db.$on('query', (e) => {
|
|
683
|
+
if (e.duration > 200) {
|
|
684
|
+
console.warn(`slow query: ${e.model}.${e.action} (${e.duration.toFixed(1)}ms, ${e.rows} rows)`);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
View the collected metrics in a local dashboard:
|
|
690
|
+
|
|
691
|
+
```bash
|
|
692
|
+
TURBINE_OBSERVE_URL=postgres://... npx turbine observe
|
|
693
|
+
# Flags: --port (default 4984), --host (default 127.0.0.1), --no-open
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
Same security model as Studio: loopback binding by default, per-process random auth token in an `HttpOnly` cookie, CSP headers, and read-only access to the metrics table.
|
|
697
|
+
|
|
641
698
|
## Serverless / Edge
|
|
642
699
|
|
|
643
700
|
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.
|
|
@@ -648,12 +705,12 @@ Turbine's core is driver-agnostic: pass any pg-compatible pool to `TurbineConfig
|
|
|
648
705
|
// app/api/users/route.ts
|
|
649
706
|
import { Pool } from '@neondatabase/serverless';
|
|
650
707
|
import { turbineHttp } from 'turbine-orm/serverless';
|
|
651
|
-
import {
|
|
708
|
+
import { SCHEMA } from '@/generated/turbine/metadata';
|
|
652
709
|
|
|
653
710
|
export const runtime = 'edge';
|
|
654
711
|
|
|
655
712
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
656
|
-
const db = turbineHttp(pool,
|
|
713
|
+
const db = turbineHttp(pool, SCHEMA);
|
|
657
714
|
|
|
658
715
|
export async function GET() {
|
|
659
716
|
const users = await db.table('users').findMany({
|
|
@@ -669,22 +726,22 @@ export async function GET() {
|
|
|
669
726
|
```ts
|
|
670
727
|
import { createPool } from '@vercel/postgres';
|
|
671
728
|
import { turbineHttp } from 'turbine-orm/serverless';
|
|
672
|
-
import {
|
|
729
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
673
730
|
|
|
674
731
|
const pool = createPool({ connectionString: process.env.POSTGRES_URL });
|
|
675
|
-
const db = turbineHttp(pool,
|
|
732
|
+
const db = turbineHttp(pool, SCHEMA);
|
|
676
733
|
```
|
|
677
734
|
|
|
678
735
|
### Supabase (direct Postgres — no HTTP proxy needed)
|
|
679
736
|
|
|
680
737
|
```ts
|
|
681
738
|
import { TurbineClient } from 'turbine-orm';
|
|
682
|
-
import {
|
|
739
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
683
740
|
|
|
684
741
|
const db = new TurbineClient({
|
|
685
742
|
connectionString: process.env.SUPABASE_DB_URL,
|
|
686
743
|
ssl: { rejectUnauthorized: false },
|
|
687
|
-
},
|
|
744
|
+
}, SCHEMA);
|
|
688
745
|
```
|
|
689
746
|
|
|
690
747
|
### Cloudflare Workers
|
|
@@ -692,12 +749,12 @@ const db = new TurbineClient({
|
|
|
692
749
|
```ts
|
|
693
750
|
import { Pool } from '@neondatabase/serverless';
|
|
694
751
|
import { turbineHttp } from 'turbine-orm/serverless';
|
|
695
|
-
import {
|
|
752
|
+
import { SCHEMA } from './generated/turbine/metadata';
|
|
696
753
|
|
|
697
754
|
export default {
|
|
698
755
|
async fetch(req: Request, env: Env) {
|
|
699
756
|
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
|
700
|
-
const db = turbineHttp(pool,
|
|
757
|
+
const db = turbineHttp(pool, SCHEMA);
|
|
701
758
|
const users = await db.table('users').findMany({ limit: 10 });
|
|
702
759
|
return Response.json(users);
|
|
703
760
|
},
|
|
@@ -712,6 +769,70 @@ export default {
|
|
|
712
769
|
|
|
713
770
|
When Turbine receives an external pool, `db.disconnect()` is a no-op: the caller owns the pool's lifecycle.
|
|
714
771
|
|
|
772
|
+
## Database engines
|
|
773
|
+
|
|
774
|
+
Turbine is **Postgres-first** — `import { TurbineClient } from 'turbine-orm'` targets PostgreSQL, and the safety bundle above is built around it. When you need another database, the same typed API runs on **SQLite**, **MySQL 8**, and **SQL Server** through subpath exports. Multi-engine is *additive*, not a pivot: pick the engine that fits, keep the same `findMany` / `with` / `where` API.
|
|
775
|
+
|
|
776
|
+
The root install stays one dependency (`pg`). Each engine's driver is its own concern: SQLite needs nothing (Node's built-in `node:sqlite`), MySQL and SQL Server use **optional peer dependencies** you install only if you use them.
|
|
777
|
+
|
|
778
|
+
```bash
|
|
779
|
+
# SQLite — zero extra deps (Node >= 22.5, built-in node:sqlite)
|
|
780
|
+
npm install turbine-orm
|
|
781
|
+
|
|
782
|
+
# MySQL 8 — optional peer
|
|
783
|
+
npm install turbine-orm mysql2
|
|
784
|
+
|
|
785
|
+
# SQL Server 2016+ — optional peer
|
|
786
|
+
npm install turbine-orm mssql
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
Each engine ships a factory that returns the same `TurbineClient`:
|
|
790
|
+
|
|
791
|
+
```ts
|
|
792
|
+
// SQLite — synchronous; pass a file path, ':memory:', or an open DatabaseSync
|
|
793
|
+
import { turbineSqlite } from 'turbine-orm/sqlite';
|
|
794
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
795
|
+
|
|
796
|
+
const db = turbineSqlite(':memory:', SCHEMA);
|
|
797
|
+
const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
// MySQL 8 — async; connection string, mysql2 config, or an existing mysql2 pool
|
|
802
|
+
import { turbineMysql } from 'turbine-orm/mysql';
|
|
803
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
804
|
+
|
|
805
|
+
const db = await turbineMysql('mysql://user:pass@localhost:3306/app', SCHEMA);
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
```ts
|
|
809
|
+
// SQL Server 2016+ — async; connection string, mssql config, or an existing pool
|
|
810
|
+
import { turbineMssql } from 'turbine-orm/mssql';
|
|
811
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
812
|
+
|
|
813
|
+
const db = await turbineMssql('mssql://sa:Passw0rd!@localhost:1433/app', SCHEMA);
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
### Capability matrix
|
|
817
|
+
|
|
818
|
+
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.
|
|
819
|
+
|
|
820
|
+
| Feature | PostgreSQL | SQLite | MySQL 8 | SQL Server |
|
|
821
|
+
|---|:---:|:---:|:---:|:---:|
|
|
822
|
+
| Single-query nested `with` | ✓ `json_agg` | ✓ `json_group_array` | ✓ `JSON_ARRAYAGG` | ✓ `FOR JSON PATH` |
|
|
823
|
+
| Transactions + savepoints | ✓ | ✓ (single-writer) | ✓ | ✓ |
|
|
824
|
+
| Streaming (`findManyStream`) | ✓ | ✓ | ✓ | ✓ |
|
|
825
|
+
| Migrations (`turbine migrate` CLI) | ✓ | PG-only (CLI) | PG-only (CLI) | PG-only (CLI) |
|
|
826
|
+
| pgvector distance / KNN | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
|
|
827
|
+
| LISTEN/NOTIFY realtime | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
|
|
828
|
+
| RLS `sessionContext` | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
|
|
829
|
+
|
|
830
|
+
✗ E017 = throws `UnsupportedFeatureError`. The full matrix (atomic updates, introspection, optimistic locking, per-cell mechanics) is on [turbineorm.dev/engines](https://turbineorm.dev/engines).
|
|
831
|
+
|
|
832
|
+
**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`.
|
|
833
|
+
|
|
834
|
+
Full setup, signatures, and the complete support matrix: **[turbineorm.dev/engines](https://turbineorm.dev/engines)**.
|
|
835
|
+
|
|
715
836
|
## Configuration
|
|
716
837
|
|
|
717
838
|
Create `turbine.config.ts` in your project root (or run `npx turbine init`):
|
|
@@ -761,18 +882,18 @@ Turbine maps Postgres types to TypeScript:
|
|
|
761
882
|
|---|---|---|---|---|
|
|
762
883
|
| **Engine / runtime** | No engine binary (`pg` only) | Client + 1.6 MB WASM engine | No engine | No engine |
|
|
763
884
|
| **Runtime deps** | 1 (`pg`) | `@prisma/client` + adapter | 0 | 0 |
|
|
764
|
-
| **Main bundle (
|
|
885
|
+
| **Main bundle (brotli)** | ~31 kB | dominated by 1.6 MB WASM | ~7 KB core | small |
|
|
765
886
|
| **Studio** | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Paid tier | None |
|
|
766
887
|
| **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
|
|
767
888
|
| **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
|
|
768
|
-
| **Edge runtime** | One import swap, ~
|
|
889
|
+
| **Edge runtime** | One import swap, ~22 kB brotli | 1.6 MB WASM adapter | Native | Native |
|
|
769
890
|
| **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
|
|
770
891
|
| **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
|
|
771
892
|
| **Nested relations** | 1 query, deep type inference | 1 query, shallow inference | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
|
|
772
893
|
| **Many-to-many** | Auto-detected from junctions | Implicit/explicit | Explicit `relations()` | Manual joins |
|
|
773
894
|
| **Vector search** | Built-in `distance` / KNN | Preview / raw | Extension API | Manual |
|
|
774
895
|
| **LISTEN/NOTIFY** | `$listen` / `$notify` | None | None | None |
|
|
775
|
-
| **Multi-DB** |
|
|
896
|
+
| **Multi-DB** | Postgres-first (+ SQLite/MySQL/MSSQL engines) | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
|
|
776
897
|
|
|
777
898
|
All three ORMs now do single-query nested loads — that's table stakes. Turbine's real differentiators: no engine binary or WASM — just one dependency (`pg`), vs Prisma's 1.6 MB WASM query engine; the only read-only Studio in the ecosystem; error messages that never leak PII; and SQL-first migrations with SHA-256 drift detection. See [Benchmarks](#benchmarks) for performance numbers — most scenarios are within noise over a real pooled database.
|
|
778
899
|
|
|
@@ -780,7 +901,7 @@ All three ORMs now do single-query nested loads — that's table stakes. Turbine
|
|
|
780
901
|
|
|
781
902
|
Turbine is focused and opinionated. Here's what it doesn't do:
|
|
782
903
|
|
|
783
|
-
- **
|
|
904
|
+
- **Postgres-first.** PostgreSQL is the default and primary target — going deep on one database is what enables the safety bundle and the edge-runtime story. SQLite, MySQL 8, and SQL Server engines are available as additive subpath exports (see [Database engines](#database-engines)), but several flagship features (pgvector, LISTEN/NOTIFY, RLS `sessionContext`) are Postgres-only and throw `UnsupportedFeatureError` elsewhere.
|
|
784
905
|
- **Full-text search** is available via a `search` filter — `where: { title: { search: 'hello & world', config: 'english' } }` compiles to a parameterized `to_tsvector(...) @@ to_tsquery(...)`. For advanced ranking (`ts_rank`, weighted vectors) use `db.raw`.
|
|
785
906
|
- **Large nested result sets.** Nested results are materialized server-side in PostgreSQL memory. For relations with 10K+ rows, always use `limit` in your `with` clause — or stream the parents with `findManyStream` and resolve children per-row.
|
|
786
907
|
|
|
@@ -818,6 +939,17 @@ Turbine is focused and opinionated. Here's what it doesn't do:
|
|
|
818
939
|
- PostgreSQL >= 14
|
|
819
940
|
- Works with both ESM (`import`) and CommonJS (`require`)
|
|
820
941
|
|
|
942
|
+
## Contributing
|
|
943
|
+
|
|
944
|
+
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the test strategy, and the PR checklist. The unit suite runs without a database:
|
|
945
|
+
|
|
946
|
+
```bash
|
|
947
|
+
npm install
|
|
948
|
+
npm run test:unit
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
Integration tests need a PostgreSQL instance via `DATABASE_URL` (see CONTRIBUTING.md for a one-command seeded setup).
|
|
952
|
+
|
|
821
953
|
## License
|
|
822
954
|
|
|
823
955
|
MIT
|
|
@@ -54,15 +54,10 @@ const CREATE_LOCK_TABLE_SQL = `
|
|
|
54
54
|
// Introspection query overrides
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
/**
|
|
57
|
-
* CockroachDB index introspection via
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* CockroachDB's pg_indexes compatibility view but with a fallback query
|
|
62
|
-
* that produces the same columns.
|
|
63
|
-
*
|
|
64
|
-
* CockroachDB's pg_indexes is compatible since v22.1 — we keep this
|
|
65
|
-
* override for older versions or when indexdef is incomplete.
|
|
57
|
+
* CockroachDB index introspection via the pg_indexes compatibility view.
|
|
58
|
+
* CockroachDB exposes pg_indexes (compatible since v22.1) with the same
|
|
59
|
+
* tablename/indexname/indexdef columns as PostgreSQL, so the standard
|
|
60
|
+
* introspection flow consumes this drop-in SQL string unchanged.
|
|
66
61
|
*/
|
|
67
62
|
const SQL_INDEXES_CRDB = `
|
|
68
63
|
SELECT
|
|
@@ -57,15 +57,10 @@ const CREATE_LOCK_TABLE_SQL = `
|
|
|
57
57
|
// Introspection query overrides
|
|
58
58
|
// ---------------------------------------------------------------------------
|
|
59
59
|
/**
|
|
60
|
-
* CockroachDB index introspection via
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* CockroachDB's pg_indexes compatibility view but with a fallback query
|
|
65
|
-
* that produces the same columns.
|
|
66
|
-
*
|
|
67
|
-
* CockroachDB's pg_indexes is compatible since v22.1 — we keep this
|
|
68
|
-
* override for older versions or when indexdef is incomplete.
|
|
60
|
+
* CockroachDB index introspection via the pg_indexes compatibility view.
|
|
61
|
+
* CockroachDB exposes pg_indexes (compatible since v22.1) with the same
|
|
62
|
+
* tablename/indexname/indexdef columns as PostgreSQL, so the standard
|
|
63
|
+
* introspection flow consumes this drop-in SQL string unchanged.
|
|
69
64
|
*/
|
|
70
65
|
const SQL_INDEXES_CRDB = `
|
|
71
66
|
SELECT
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -964,9 +964,10 @@ async function cmdStudio(args, config) {
|
|
|
964
964
|
console.log((0, ui_js_1.red)(`✗ invalid port: ${args.port}`));
|
|
965
965
|
process.exit(1);
|
|
966
966
|
}
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
// session token, so exposing it on a LAN
|
|
967
|
+
// Warn loudly when an explicit non-loopback --host is used — the user is
|
|
968
|
+
// opting in, so we proceed rather than refuse. Studio has no real
|
|
969
|
+
// authentication beyond a random session token, so exposing it on a LAN
|
|
970
|
+
// interface is foot-gun territory.
|
|
970
971
|
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
971
972
|
console.log((0, ui_js_1.warn)(`Studio is binding to ${(0, ui_js_1.yellow)(host)} — this is NOT loopback. ` +
|
|
972
973
|
`Anyone on your network who can reach this port + guess the session token can read your database.`));
|
|
@@ -1039,6 +1040,8 @@ async function cmdObserve(args) {
|
|
|
1039
1040
|
console.log((0, ui_js_1.red)(`✗ invalid port: ${args.port}`));
|
|
1040
1041
|
process.exit(1);
|
|
1041
1042
|
}
|
|
1043
|
+
// Warn loudly when an explicit non-loopback --host is used — the user is
|
|
1044
|
+
// opting in, so we proceed rather than refuse.
|
|
1042
1045
|
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
1043
1046
|
console.log((0, ui_js_1.warn)(`Observe is binding to ${(0, ui_js_1.yellow)(host)} — this is NOT loopback. ` +
|
|
1044
1047
|
`Anyone on your network who can reach this port + guess the session token can read your metrics.`));
|
|
@@ -1164,13 +1167,15 @@ function showMigrateHelp() {
|
|
|
1164
1167
|
(0, ui_js_1.newline)();
|
|
1165
1168
|
console.log(` ${(0, ui_js_1.bold)('Options:')}`);
|
|
1166
1169
|
console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
|
|
1170
|
+
console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff ${(0, ui_js_1.dim)('(create only)')}`);
|
|
1167
1171
|
console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
|
|
1168
|
-
console.log(` ${(0, ui_js_1.cyan)('--dry-run')}
|
|
1169
|
-
console.log(` ${(0, ui_js_1.cyan)('--allow-drift')}
|
|
1170
|
-
console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')}
|
|
1172
|
+
console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
|
|
1173
|
+
console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation ${(0, ui_js_1.dim)('(migrate up only — advanced)')}`);
|
|
1174
|
+
console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
|
|
1171
1175
|
(0, ui_js_1.newline)();
|
|
1172
1176
|
console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
|
|
1173
1177
|
console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_users_table`);
|
|
1178
|
+
console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_email_index --auto`);
|
|
1174
1179
|
console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate up`);
|
|
1175
1180
|
console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate down --step 2`);
|
|
1176
1181
|
console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate status`);
|
|
@@ -1215,16 +1220,17 @@ function showHelp() {
|
|
|
1215
1220
|
(0, ui_js_1.newline)();
|
|
1216
1221
|
console.log(` ${(0, ui_js_1.bold)('Commands:')}`);
|
|
1217
1222
|
console.log(` ${(0, ui_js_1.cyan)('init')} Initialize a Turbine project`);
|
|
1218
|
-
console.log(` ${(0, ui_js_1.cyan)('generate')} ${(0, ui_js_1.dim)('| pull')}
|
|
1223
|
+
console.log(` ${(0, ui_js_1.cyan)('generate')} ${(0, ui_js_1.dim)('| pull')} Introspect database ${ui_js_1.symbols.arrow} generate types`);
|
|
1219
1224
|
console.log(` ${(0, ui_js_1.cyan)('push')} Apply schema definitions to database`);
|
|
1220
|
-
console.log(` ${(0, ui_js_1.cyan)('migrate')} ${(0, ui_js_1.dim)('<sub>')}
|
|
1221
|
-
console.log(` ${(0, ui_js_1.dim)('create <name>')}
|
|
1225
|
+
console.log(` ${(0, ui_js_1.cyan)('migrate')} ${(0, ui_js_1.dim)('<sub>')} SQL migration management`);
|
|
1226
|
+
console.log(` ${(0, ui_js_1.dim)('create <name>')} Create a new migration file`);
|
|
1222
1227
|
console.log(` ${(0, ui_js_1.dim)('up')} Apply pending migrations`);
|
|
1223
1228
|
console.log(` ${(0, ui_js_1.dim)('down')} Rollback last migration`);
|
|
1224
1229
|
console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
|
|
1225
1230
|
console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
|
|
1226
|
-
console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')}
|
|
1231
|
+
console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
|
|
1227
1232
|
console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI`);
|
|
1233
|
+
console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);
|
|
1228
1234
|
(0, ui_js_1.newline)();
|
|
1229
1235
|
console.log(` ${(0, ui_js_1.bold)('Options:')}`);
|
|
1230
1236
|
console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
|
|
@@ -1236,8 +1242,13 @@ function showHelp() {
|
|
|
1236
1242
|
console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
|
|
1237
1243
|
console.log(` ${(0, ui_js_1.cyan)('--force, -f')} Overwrite existing files`);
|
|
1238
1244
|
(0, ui_js_1.newline)();
|
|
1239
|
-
console.log(` ${(0, ui_js_1.bold)('
|
|
1240
|
-
console.log(` ${(0, ui_js_1.cyan)('--
|
|
1245
|
+
console.log(` ${(0, ui_js_1.bold)('Migrate options:')}`);
|
|
1246
|
+
console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff ${(0, ui_js_1.dim)('(create)')}`);
|
|
1247
|
+
console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
|
|
1248
|
+
console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation on ${(0, ui_js_1.cyan)('migrate up')} ${(0, ui_js_1.dim)('(advanced)')}`);
|
|
1249
|
+
(0, ui_js_1.newline)();
|
|
1250
|
+
console.log(` ${(0, ui_js_1.bold)('Studio / observe options:')}`);
|
|
1251
|
+
console.log(` ${(0, ui_js_1.cyan)('--port')} ${(0, ui_js_1.dim)('<n>')} HTTP port ${(0, ui_js_1.dim)('(default: 4983 studio, 4984 observe)')}`);
|
|
1241
1252
|
console.log(` ${(0, ui_js_1.cyan)('--host')} ${(0, ui_js_1.dim)('<addr>')} Bind address ${(0, ui_js_1.dim)('(default: 127.0.0.1)')}`);
|
|
1242
1253
|
console.log(` ${(0, ui_js_1.cyan)('--no-open')} Don't auto-open the browser`);
|
|
1243
1254
|
(0, ui_js_1.newline)();
|
|
@@ -132,12 +132,22 @@ exports.OBSERVE_HTML = `<!doctype html>
|
|
|
132
132
|
+ '</svg>';
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
function escapeHtml(s) {
|
|
136
|
+
if (s == null) return '';
|
|
137
|
+
return String(s)
|
|
138
|
+
.replace(/&/g, '&')
|
|
139
|
+
.replace(/</g, '<')
|
|
140
|
+
.replace(/>/g, '>')
|
|
141
|
+
.replace(/"/g, '"')
|
|
142
|
+
.replace(/'/g, ''');
|
|
143
|
+
}
|
|
144
|
+
|
|
135
145
|
function renderModels(data) {
|
|
136
146
|
const el = document.getElementById('models-table');
|
|
137
147
|
if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
|
|
138
148
|
let html = '<table><thead><tr><th>Model</th><th>Action</th><th class="num">Count</th><th class="num">Avg (ms)</th><th class="num">P95 (ms)</th><th class="num">P99 (ms)</th></tr></thead><tbody>';
|
|
139
149
|
for (const row of data) {
|
|
140
|
-
html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
|
|
150
|
+
html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'
|
|
141
151
|
+ '<td class="num">' + row.count + '</td>'
|
|
142
152
|
+ '<td class="num">' + row.avg_ms.toFixed(1) + '</td>'
|
|
143
153
|
+ '<td class="num">' + row.p95_ms.toFixed(1) + '</td>'
|
|
@@ -154,7 +164,7 @@ exports.OBSERVE_HTML = `<!doctype html>
|
|
|
154
164
|
for (const row of data) {
|
|
155
165
|
const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';
|
|
156
166
|
const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';
|
|
157
|
-
html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
|
|
167
|
+
html += '<tr><td>' + escapeHtml(row.model) + '</td><td>' + escapeHtml(row.action) + '</td>'
|
|
158
168
|
+ '<td class="num">' + row.count + '</td>'
|
|
159
169
|
+ '<td class="num">' + row.error_count + '</td>'
|
|
160
170
|
+ '<td class="num ' + cls + '">' + rate + '%</td></tr>';
|
package/dist/cjs/cli/observe.js
CHANGED
|
@@ -116,13 +116,12 @@ function isAuthorized(req, expectedToken) {
|
|
|
116
116
|
return false;
|
|
117
117
|
}
|
|
118
118
|
function constantTimeEqual(a, b) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return result === 0;
|
|
119
|
+
// Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
|
|
120
|
+
// This makes the comparison constant-length (timingSafeEqual never throws on a
|
|
121
|
+
// length mismatch) and leaks neither length nor content via timing.
|
|
122
|
+
const ah = (0, node_crypto_1.createHash)('sha256').update(a).digest();
|
|
123
|
+
const bh = (0, node_crypto_1.createHash)('sha256').update(b).digest();
|
|
124
|
+
return (0, node_crypto_1.timingSafeEqual)(ah, bh);
|
|
126
125
|
}
|
|
127
126
|
// ---------------------------------------------------------------------------
|
|
128
127
|
// API handlers
|