turbine-orm 0.19.2 → 0.22.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 +99 -18
- package/dist/adapters/cockroachdb.js +4 -9
- package/dist/cjs/adapters/cockroachdb.js +4 -9
- package/dist/cjs/cli/index.js +6 -3
- package/dist/cjs/cli/observe-ui.js +12 -2
- package/dist/cjs/cli/observe.js +6 -7
- package/dist/cjs/cli/studio.js +6 -7
- package/dist/cjs/client.js +44 -22
- 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/powdb.js +851 -0
- package/dist/cjs/powql.js +903 -0
- package/dist/cjs/query/builder.js +293 -73
- package/dist/cjs/sqlite.js +849 -0
- package/dist/cjs/typed-sql.js +9 -7
- package/dist/cli/index.js +6 -3
- 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.js +7 -8
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +23 -1
- package/dist/client.js +45 -23
- 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 +3 -3
- 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/powdb.d.ts +338 -0
- package/dist/powdb.js +802 -0
- package/dist/powql.d.ts +153 -0
- package/dist/powql.js +900 -0
- package/dist/query/builder.d.ts +105 -0
- package/dist/query/builder.js +294 -74
- package/dist/query/index.d.ts +1 -1
- 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 +45 -1
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
|
|
|
@@ -404,7 +404,7 @@ try {
|
|
|
404
404
|
}
|
|
405
405
|
```
|
|
406
406
|
|
|
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).
|
|
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).
|
|
408
408
|
|
|
409
409
|
Full reference with `wrapPgError()` translation, retry patterns for `DeadlockError` / `SerializationFailureError`, and safe vs verbose message modes: **[turbineorm.dev/errors](https://turbineorm.dev/errors)**.
|
|
410
410
|
|
|
@@ -705,12 +705,12 @@ Turbine's core is driver-agnostic: pass any pg-compatible pool to `TurbineConfig
|
|
|
705
705
|
// app/api/users/route.ts
|
|
706
706
|
import { Pool } from '@neondatabase/serverless';
|
|
707
707
|
import { turbineHttp } from 'turbine-orm/serverless';
|
|
708
|
-
import {
|
|
708
|
+
import { SCHEMA } from '@/generated/turbine/metadata';
|
|
709
709
|
|
|
710
710
|
export const runtime = 'edge';
|
|
711
711
|
|
|
712
712
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
713
|
-
const db = turbineHttp(pool,
|
|
713
|
+
const db = turbineHttp(pool, SCHEMA);
|
|
714
714
|
|
|
715
715
|
export async function GET() {
|
|
716
716
|
const users = await db.table('users').findMany({
|
|
@@ -726,22 +726,22 @@ export async function GET() {
|
|
|
726
726
|
```ts
|
|
727
727
|
import { createPool } from '@vercel/postgres';
|
|
728
728
|
import { turbineHttp } from 'turbine-orm/serverless';
|
|
729
|
-
import {
|
|
729
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
730
730
|
|
|
731
731
|
const pool = createPool({ connectionString: process.env.POSTGRES_URL });
|
|
732
|
-
const db = turbineHttp(pool,
|
|
732
|
+
const db = turbineHttp(pool, SCHEMA);
|
|
733
733
|
```
|
|
734
734
|
|
|
735
735
|
### Supabase (direct Postgres — no HTTP proxy needed)
|
|
736
736
|
|
|
737
737
|
```ts
|
|
738
738
|
import { TurbineClient } from 'turbine-orm';
|
|
739
|
-
import {
|
|
739
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
740
740
|
|
|
741
741
|
const db = new TurbineClient({
|
|
742
742
|
connectionString: process.env.SUPABASE_DB_URL,
|
|
743
743
|
ssl: { rejectUnauthorized: false },
|
|
744
|
-
},
|
|
744
|
+
}, SCHEMA);
|
|
745
745
|
```
|
|
746
746
|
|
|
747
747
|
### Cloudflare Workers
|
|
@@ -749,12 +749,12 @@ const db = new TurbineClient({
|
|
|
749
749
|
```ts
|
|
750
750
|
import { Pool } from '@neondatabase/serverless';
|
|
751
751
|
import { turbineHttp } from 'turbine-orm/serverless';
|
|
752
|
-
import {
|
|
752
|
+
import { SCHEMA } from './generated/turbine/metadata';
|
|
753
753
|
|
|
754
754
|
export default {
|
|
755
755
|
async fetch(req: Request, env: Env) {
|
|
756
756
|
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
|
757
|
-
const db = turbineHttp(pool,
|
|
757
|
+
const db = turbineHttp(pool, SCHEMA);
|
|
758
758
|
const users = await db.table('users').findMany({ limit: 10 });
|
|
759
759
|
return Response.json(users);
|
|
760
760
|
},
|
|
@@ -769,6 +769,87 @@ export default {
|
|
|
769
769
|
|
|
770
770
|
When Turbine receives an external pool, `db.disconnect()` is a no-op: the caller owns the pool's lifecycle.
|
|
771
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 — plus **PowDB**, a single-node embedded database with its own query language (PowQL). Multi-engine is *additive*, not a pivot: pick the engine that fits, keep the same `findMany` / `with` / `where` API.
|
|
775
|
+
|
|
776
|
+
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.
|
|
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
|
+
# PowDB — optional peer; embedded (in-process) or networked transport
|
|
789
|
+
npm install turbine-orm @zvndev/powdb-embedded # in-process
|
|
790
|
+
npm install turbine-orm @zvndev/powdb-client # networked
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
Each engine ships a factory that returns the same `TurbineClient`:
|
|
794
|
+
|
|
795
|
+
```ts
|
|
796
|
+
// SQLite — synchronous; pass a file path, ':memory:', or an open DatabaseSync
|
|
797
|
+
import { turbineSqlite } from 'turbine-orm/sqlite';
|
|
798
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
799
|
+
|
|
800
|
+
const db = turbineSqlite(':memory:', SCHEMA);
|
|
801
|
+
const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
```ts
|
|
805
|
+
// MySQL 8 — async; connection string, mysql2 config, or an existing mysql2 pool
|
|
806
|
+
import { turbineMysql } from 'turbine-orm/mysql';
|
|
807
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
808
|
+
|
|
809
|
+
const db = await turbineMysql('mysql://user:pass@localhost:3306/app', SCHEMA);
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
```ts
|
|
813
|
+
// SQL Server 2016+ — async; connection string, mssql config, or an existing pool
|
|
814
|
+
import { turbineMssql } from 'turbine-orm/mssql';
|
|
815
|
+
import { SCHEMA } from './generated/turbine/metadata.js';
|
|
816
|
+
|
|
817
|
+
const db = await turbineMssql('mssql://sa:Passw0rd!@localhost:1433/app', SCHEMA);
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
```ts
|
|
821
|
+
// PowDB — async; embedded (in-process) or networked. Schema is code-defined (no introspection).
|
|
822
|
+
import { turbinePowDB } from 'turbine-orm/powdb';
|
|
823
|
+
import { schema } from './schema.js'; // defineSchema({...})
|
|
824
|
+
|
|
825
|
+
// Embedded, in-process — syncMode 'normal' makes writes beat SQLite:
|
|
826
|
+
const db = await turbinePowDB({ embedded: './data', syncMode: 'normal' }, schema);
|
|
827
|
+
// …or networked against a running powdb-server:
|
|
828
|
+
// const db = await turbinePowDB('powdb://127.0.0.1:7070', schema);
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
### Capability matrix
|
|
832
|
+
|
|
833
|
+
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.
|
|
834
|
+
|
|
835
|
+
| Feature | PostgreSQL | SQLite | MySQL 8 | SQL Server |
|
|
836
|
+
|---|:---:|:---:|:---:|:---:|
|
|
837
|
+
| Single-query nested `with` | ✓ `json_agg` | ✓ `json_group_array` | ✓ `JSON_ARRAYAGG` | ✓ `FOR JSON PATH` |
|
|
838
|
+
| Transactions + savepoints | ✓ | ✓ (single-writer) | ✓ | ✓ |
|
|
839
|
+
| Streaming (`findManyStream`) | ✓ | ✓ | ✓ | ✓ |
|
|
840
|
+
| Migrations (`turbine migrate` CLI) | ✓ | PG-only (CLI) | PG-only (CLI) | PG-only (CLI) |
|
|
841
|
+
| pgvector distance / KNN | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
|
|
842
|
+
| LISTEN/NOTIFY realtime | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
|
|
843
|
+
| RLS `sessionContext` | ✓ | ✗ E017 | ✗ E017 | ✗ E017 |
|
|
844
|
+
|
|
845
|
+
✗ E017 = throws `UnsupportedFeatureError`. The full matrix (atomic updates, introspection, optimistic locking, per-cell mechanics) is on [turbineorm.dev/engines](https://turbineorm.dev/engines).
|
|
846
|
+
|
|
847
|
+
**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`.
|
|
848
|
+
|
|
849
|
+
**PowDB** speaks its own non-SQL query language (PowQL), so it sits outside the SQL matrix above. It uses `reselect` writes with client-assigned UUID PKs, N+1 relation loaders (no `json_agg`), single-writer transactions (no nesting), and `defineSchema` (no introspection). Embedded `syncMode: 'normal'` writes beat SQLite; the networked transport runs the same data over a socket. many-to-many, nested writes, composite keys, cursor streaming, and the Postgres-only trio throw `UnsupportedFeatureError`. Full details: **[turbineorm.dev/engines#powdb](https://turbineorm.dev/engines#powdb)**.
|
|
850
|
+
|
|
851
|
+
Full setup, signatures, and the complete support matrix: **[turbineorm.dev/engines](https://turbineorm.dev/engines)**.
|
|
852
|
+
|
|
772
853
|
## Configuration
|
|
773
854
|
|
|
774
855
|
Create `turbine.config.ts` in your project root (or run `npx turbine init`):
|
|
@@ -829,7 +910,7 @@ Turbine maps Postgres types to TypeScript:
|
|
|
829
910
|
| **Many-to-many** | Auto-detected from junctions | Implicit/explicit | Explicit `relations()` | Manual joins |
|
|
830
911
|
| **Vector search** | Built-in `distance` / KNN | Preview / raw | Extension API | Manual |
|
|
831
912
|
| **LISTEN/NOTIFY** | `$listen` / `$notify` | None | None | None |
|
|
832
|
-
| **Multi-DB** |
|
|
913
|
+
| **Multi-DB** | Postgres-first (+ SQLite/MySQL/MSSQL engines) | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
|
|
833
914
|
|
|
834
915
|
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.
|
|
835
916
|
|
|
@@ -837,7 +918,7 @@ All three ORMs now do single-query nested loads — that's table stakes. Turbine
|
|
|
837
918
|
|
|
838
919
|
Turbine is focused and opinionated. Here's what it doesn't do:
|
|
839
920
|
|
|
840
|
-
- **
|
|
921
|
+
- **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.
|
|
841
922
|
- **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`.
|
|
842
923
|
- **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.
|
|
843
924
|
|
|
@@ -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.`));
|
|
@@ -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
|
package/dist/cjs/cli/studio.js
CHANGED
|
@@ -237,13 +237,12 @@ function checkRateLimit(limiter, token) {
|
|
|
237
237
|
return { allowed: true, resetAt: entry.resetAt };
|
|
238
238
|
}
|
|
239
239
|
function constantTimeEqual(a, b) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return result === 0;
|
|
240
|
+
// Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
|
|
241
|
+
// This makes the comparison constant-length (timingSafeEqual never throws on a
|
|
242
|
+
// length mismatch) and leaks neither length nor content via timing.
|
|
243
|
+
const ah = (0, node_crypto_1.createHash)('sha256').update(a).digest();
|
|
244
|
+
const bh = (0, node_crypto_1.createHash)('sha256').update(b).digest();
|
|
245
|
+
return (0, node_crypto_1.timingSafeEqual)(ah, bh);
|
|
247
246
|
}
|
|
248
247
|
/**
|
|
249
248
|
* Build a helpful "unknown table" error that lists the available tables so the
|
package/dist/cjs/client.js
CHANGED
|
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
29
29
|
exports.TurbineClient = exports.TransactionClient = void 0;
|
|
30
30
|
exports.withRetry = withRetry;
|
|
31
31
|
const pg_1 = __importDefault(require("pg"));
|
|
32
|
+
const dialect_js_1 = require("./dialect.js");
|
|
32
33
|
const errors_js_1 = require("./errors.js");
|
|
33
34
|
const observe_js_1 = require("./observe.js");
|
|
34
35
|
const pipeline_js_1 = require("./pipeline.js");
|
|
@@ -89,11 +90,14 @@ class TransactionClient {
|
|
|
89
90
|
queryOptions;
|
|
90
91
|
tableCache = new Map();
|
|
91
92
|
savepointCounter = 0;
|
|
93
|
+
/** Active SQL dialect — owns savepoint keywords and raw-SQL placeholders. */
|
|
94
|
+
dialect;
|
|
92
95
|
constructor(client, schema, middlewares, queryOptions) {
|
|
93
96
|
this.client = client;
|
|
94
97
|
this.schema = schema;
|
|
95
98
|
this.middlewares = middlewares;
|
|
96
99
|
this.queryOptions = queryOptions;
|
|
100
|
+
this.dialect = queryOptions?.dialect ?? dialect_js_1.postgresDialect;
|
|
97
101
|
// Auto-create typed table accessors for all tables in the schema
|
|
98
102
|
for (const tableName of Object.keys(schema.tables)) {
|
|
99
103
|
const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
@@ -116,7 +120,9 @@ class TransactionClient {
|
|
|
116
120
|
// We use a proxy pool that routes queries through the transaction client
|
|
117
121
|
const txPool = this.createTxPool();
|
|
118
122
|
const txOpts = { ...this.queryOptions, _txScoped: true };
|
|
119
|
-
qi =
|
|
123
|
+
qi = txOpts.queryInterfaceFactory
|
|
124
|
+
? txOpts.queryInterfaceFactory(txPool, name, this.schema, this.middlewares, txOpts)
|
|
125
|
+
: new index_js_1.QueryInterface(txPool, name, this.schema, this.middlewares, txOpts);
|
|
120
126
|
this.tableCache.set(name, qi);
|
|
121
127
|
}
|
|
122
128
|
return qi;
|
|
@@ -127,14 +133,14 @@ class TransactionClient {
|
|
|
127
133
|
*/
|
|
128
134
|
async $transaction(fn) {
|
|
129
135
|
const savepointName = `sp_${++this.savepointCounter}`;
|
|
130
|
-
await this.client.query(
|
|
136
|
+
await this.client.query(this.dialect.savepointStatement(savepointName));
|
|
131
137
|
try {
|
|
132
138
|
const result = await fn(this);
|
|
133
|
-
await this.client.query(
|
|
139
|
+
await this.client.query(this.dialect.releaseSavepointStatement(savepointName));
|
|
134
140
|
return result;
|
|
135
141
|
}
|
|
136
142
|
catch (err) {
|
|
137
|
-
await this.client.query(
|
|
143
|
+
await this.client.query(this.dialect.rollbackToSavepointStatement(savepointName));
|
|
138
144
|
throw err;
|
|
139
145
|
}
|
|
140
146
|
}
|
|
@@ -146,7 +152,7 @@ class TransactionClient {
|
|
|
146
152
|
strings.forEach((str, i) => {
|
|
147
153
|
sql += str;
|
|
148
154
|
if (i < values.length) {
|
|
149
|
-
sql +=
|
|
155
|
+
sql += this.dialect.paramPlaceholder(i + 1);
|
|
150
156
|
}
|
|
151
157
|
});
|
|
152
158
|
try {
|
|
@@ -200,6 +206,8 @@ class TurbineClient {
|
|
|
200
206
|
schema;
|
|
201
207
|
static int8ParserRegistered = false;
|
|
202
208
|
logging;
|
|
209
|
+
/** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
|
|
210
|
+
dialect;
|
|
203
211
|
tableCache = new Map();
|
|
204
212
|
middlewares = [];
|
|
205
213
|
queryListeners = new Set();
|
|
@@ -245,6 +253,7 @@ class TurbineClient {
|
|
|
245
253
|
TurbineClient.int8ParserRegistered = true;
|
|
246
254
|
}
|
|
247
255
|
this.logging = config.logging ?? false;
|
|
256
|
+
this.dialect = config.dialect ?? dialect_js_1.postgresDialect;
|
|
248
257
|
this.schema = schema;
|
|
249
258
|
// Respect env var kill switch
|
|
250
259
|
const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
|
|
@@ -255,6 +264,11 @@ class TurbineClient {
|
|
|
255
264
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
256
265
|
sqlCache: config.sqlCache ?? true,
|
|
257
266
|
dialect: config.dialect,
|
|
267
|
+
// Non-SQL backends (PowDB) inject a factory that builds their own query
|
|
268
|
+
// interface (PowqlInterface) instead of the SQL QueryInterface. SQL engines
|
|
269
|
+
// never set this, so `table()` keeps constructing `new QueryInterface`.
|
|
270
|
+
queryInterfaceFactory: config
|
|
271
|
+
.queryInterfaceFactory,
|
|
258
272
|
_onQuery: (event) => {
|
|
259
273
|
if (this.queryListeners.size === 0)
|
|
260
274
|
return;
|
|
@@ -406,7 +420,9 @@ class TurbineClient {
|
|
|
406
420
|
table(name) {
|
|
407
421
|
let qi = this.tableCache.get(name);
|
|
408
422
|
if (!qi) {
|
|
409
|
-
qi =
|
|
423
|
+
qi = this.queryOptions?.queryInterfaceFactory
|
|
424
|
+
? this.queryOptions.queryInterfaceFactory(this.pool, name, this.schema, this.middlewares, this.queryOptions)
|
|
425
|
+
: new index_js_1.QueryInterface(this.pool, name, this.schema, this.middlewares, this.queryOptions);
|
|
410
426
|
this.tableCache.set(name, qi);
|
|
411
427
|
}
|
|
412
428
|
return qi;
|
|
@@ -473,7 +489,7 @@ class TurbineClient {
|
|
|
473
489
|
strings.forEach((str, i) => {
|
|
474
490
|
sql += str;
|
|
475
491
|
if (i < values.length) {
|
|
476
|
-
sql +=
|
|
492
|
+
sql += this.dialect.paramPlaceholder(i + 1);
|
|
477
493
|
}
|
|
478
494
|
});
|
|
479
495
|
if (this.logging) {
|
|
@@ -518,7 +534,7 @@ class TurbineClient {
|
|
|
518
534
|
* ```
|
|
519
535
|
*/
|
|
520
536
|
sql(strings, ...values) {
|
|
521
|
-
const { sql, params } = (0, typed_sql_js_1.buildTypedSql)(strings, values);
|
|
537
|
+
const { sql, params } = (0, typed_sql_js_1.buildTypedSql)(strings, values, this.dialect);
|
|
522
538
|
return new typed_sql_js_1.TypedSqlQuery(this.pool, sql, params, this.logging);
|
|
523
539
|
}
|
|
524
540
|
// -------------------------------------------------------------------------
|
|
@@ -538,13 +554,13 @@ class TurbineClient {
|
|
|
538
554
|
async transaction(fn) {
|
|
539
555
|
const client = await this.pool.connect();
|
|
540
556
|
try {
|
|
541
|
-
await client.query(
|
|
557
|
+
await client.query(this.dialect.beginStatement());
|
|
542
558
|
const result = await fn(client);
|
|
543
|
-
await client.query(
|
|
559
|
+
await client.query(this.dialect.commitStatement());
|
|
544
560
|
return result;
|
|
545
561
|
}
|
|
546
562
|
catch (err) {
|
|
547
|
-
await client.query(
|
|
563
|
+
await client.query(this.dialect.rollbackStatement());
|
|
548
564
|
throw err;
|
|
549
565
|
}
|
|
550
566
|
finally {
|
|
@@ -595,14 +611,10 @@ class TurbineClient {
|
|
|
595
611
|
};
|
|
596
612
|
let timedOut = false;
|
|
597
613
|
try {
|
|
598
|
-
// BEGIN with optional isolation level
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
if (level)
|
|
603
|
-
beginSQL += ` ISOLATION LEVEL ${level}`;
|
|
604
|
-
}
|
|
605
|
-
await client.query(beginSQL);
|
|
614
|
+
// BEGIN with optional isolation level — the dialect owns the keyword and
|
|
615
|
+
// BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
|
|
616
|
+
const isolationSql = options?.isolationLevel ? ISOLATION_LEVELS[options.isolationLevel] : undefined;
|
|
617
|
+
await client.query(this.dialect.beginStatement(isolationSql));
|
|
606
618
|
// Apply transaction-local session context (RLS / multi-tenant GUCs).
|
|
607
619
|
// Order matters: BEGIN -> isolation level (above) -> set_config loop ->
|
|
608
620
|
// user fn. Any error here propagates to the catch below and rolls back
|
|
@@ -610,12 +622,16 @@ class TurbineClient {
|
|
|
610
622
|
// is_local=true) — the parameterizable, transaction-scoped equivalent of
|
|
611
623
|
// SET LOCAL — so both name and value are BOUND params, never interpolated.
|
|
612
624
|
if (options?.sessionContext) {
|
|
625
|
+
if (!this.dialect.supportsRLS) {
|
|
626
|
+
throw new errors_js_1.UnsupportedFeatureError('sessionContext (RLS session GUCs)', this.dialect.name, 'set_config-based row-level-security context requires PostgreSQL.');
|
|
627
|
+
}
|
|
613
628
|
for (const [name, value] of Object.entries(options.sessionContext)) {
|
|
614
629
|
if (!GUC_NAME_REGEX.test(name)) {
|
|
615
630
|
throw new errors_js_1.ValidationError(`[turbine] Invalid session-context GUC name "${name}" — must match ` +
|
|
616
631
|
'/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
|
|
617
632
|
}
|
|
618
|
-
|
|
633
|
+
const cfg = this.dialect.buildSetSessionConfig(name, String(value));
|
|
634
|
+
await client.query(cfg.sql, cfg.params);
|
|
619
635
|
}
|
|
620
636
|
}
|
|
621
637
|
// Create the transaction client with typed table accessors
|
|
@@ -661,7 +677,7 @@ class TurbineClient {
|
|
|
661
677
|
else {
|
|
662
678
|
result = await fn(tx);
|
|
663
679
|
}
|
|
664
|
-
await client.query(
|
|
680
|
+
await client.query(this.dialect.commitStatement());
|
|
665
681
|
if (this.logging) {
|
|
666
682
|
console.log('[turbine] Transaction committed');
|
|
667
683
|
}
|
|
@@ -674,7 +690,7 @@ class TurbineClient {
|
|
|
674
690
|
// when its socket was closed).
|
|
675
691
|
if (!timedOut && !released) {
|
|
676
692
|
try {
|
|
677
|
-
await client.query(
|
|
693
|
+
await client.query(this.dialect.rollbackStatement());
|
|
678
694
|
}
|
|
679
695
|
catch {
|
|
680
696
|
// Best-effort rollback — the connection may have died mid-query.
|
|
@@ -741,6 +757,9 @@ class TurbineClient {
|
|
|
741
757
|
* ```
|
|
742
758
|
*/
|
|
743
759
|
async $listen(channel, handler) {
|
|
760
|
+
if (!this.dialect.supportsListenNotify) {
|
|
761
|
+
throw new errors_js_1.UnsupportedFeatureError('$listen (LISTEN/NOTIFY realtime)', this.dialect.name, 'Realtime pub/sub requires PostgreSQL.');
|
|
762
|
+
}
|
|
744
763
|
(0, realtime_js_1.validateChannel)(channel);
|
|
745
764
|
const quoted = (0, utils_js_1.quoteIdent)(channel);
|
|
746
765
|
if (this.logging) {
|
|
@@ -766,6 +785,9 @@ class TurbineClient {
|
|
|
766
785
|
* ```
|
|
767
786
|
*/
|
|
768
787
|
async $notify(channel, payload) {
|
|
788
|
+
if (!this.dialect.supportsListenNotify) {
|
|
789
|
+
throw new errors_js_1.UnsupportedFeatureError('$notify (LISTEN/NOTIFY realtime)', this.dialect.name, 'Realtime pub/sub requires PostgreSQL.');
|
|
790
|
+
}
|
|
769
791
|
(0, realtime_js_1.validateChannel)(channel);
|
|
770
792
|
if (this.logging) {
|
|
771
793
|
console.log(`[turbine] NOTIFY ${channel}`);
|