turbine-orm 0.19.2 → 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.
Files changed (46) hide show
  1. package/README.md +82 -18
  2. package/dist/adapters/cockroachdb.js +4 -9
  3. package/dist/cjs/adapters/cockroachdb.js +4 -9
  4. package/dist/cjs/cli/index.js +6 -3
  5. package/dist/cjs/cli/observe-ui.js +12 -2
  6. package/dist/cjs/cli/observe.js +6 -7
  7. package/dist/cjs/cli/studio.js +6 -7
  8. package/dist/cjs/client.js +33 -20
  9. package/dist/cjs/dialect.js +116 -0
  10. package/dist/cjs/errors.js +19 -1
  11. package/dist/cjs/generate.js +4 -0
  12. package/dist/cjs/index.js +3 -2
  13. package/dist/cjs/introspect.js +20 -0
  14. package/dist/cjs/mssql.js +1338 -0
  15. package/dist/cjs/mysql.js +1052 -0
  16. package/dist/cjs/query/builder.js +293 -73
  17. package/dist/cjs/sqlite.js +849 -0
  18. package/dist/cjs/typed-sql.js +9 -7
  19. package/dist/cli/index.js +6 -3
  20. package/dist/cli/observe-ui.d.ts +1 -1
  21. package/dist/cli/observe-ui.js +12 -2
  22. package/dist/cli/observe.js +7 -8
  23. package/dist/cli/studio.js +7 -8
  24. package/dist/client.d.ts +23 -1
  25. package/dist/client.js +34 -21
  26. package/dist/dialect.d.ts +258 -1
  27. package/dist/dialect.js +83 -0
  28. package/dist/errors.d.ts +12 -0
  29. package/dist/errors.js +17 -0
  30. package/dist/generate.js +4 -0
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.js +1 -1
  33. package/dist/introspect.d.ts +18 -0
  34. package/dist/introspect.js +19 -0
  35. package/dist/mssql.d.ts +233 -0
  36. package/dist/mssql.js +1298 -0
  37. package/dist/mysql.d.ts +174 -0
  38. package/dist/mysql.js +1012 -0
  39. package/dist/query/builder.d.ts +97 -0
  40. package/dist/query/builder.js +294 -74
  41. package/dist/query/index.d.ts +1 -1
  42. package/dist/sqlite.d.ts +144 -0
  43. package/dist/sqlite.js +842 -0
  44. package/dist/typed-sql.d.ts +7 -5
  45. package/dist/typed-sql.js +9 -7
  46. package/package.json +30 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # turbine-orm
2
2
 
3
- One dependency. No WASM engine. The Postgres ORM that ships light and locks tight.
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
- Prisma ships a 1.6 MB WASM query engine. Drizzle ships zero runtime but no Studio, no typed errors, no migration checksums. Turbine ships **one dependency (`pg`) and no engine binary**, and bundles six things no other TS ORM has together:
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. **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.
16
- 2. **Built-in read-only Studio.** `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. DBA-approvable.
17
- 3. **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.
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 constraintnever 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, schema)` — same API on Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase. No WASM bundle, no adapter package, no separate serverless build.
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
- Every ORM claims single-query nested loads now (Prisma 7 and Drizzle v2 both use json_agg). Turbine does too — see [How It Works](#how-it-works). The differentiator isn't the query strategy; it's the one-dependency, no-WASM footprint, the read-only Studio, and the error messages that never leak user data.
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 { schema } from '@/generated/turbine/metadata';
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, schema);
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 { schema } from './generated/turbine/metadata.js';
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, schema);
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 { schema } from './generated/turbine/metadata.js';
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
- }, schema);
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 { schema } from './generated/turbine/metadata';
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, schema);
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,70 @@ 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. 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
+
772
836
  ## Configuration
773
837
 
774
838
  Create `turbine.config.ts` in your project root (or run `npx turbine init`):
@@ -829,7 +893,7 @@ Turbine maps Postgres types to TypeScript:
829
893
  | **Many-to-many** | Auto-detected from junctions | Implicit/explicit | Explicit `relations()` | Manual joins |
830
894
  | **Vector search** | Built-in `distance` / KNN | Preview / raw | Extension API | Manual |
831
895
  | **LISTEN/NOTIFY** | `$listen` / `$notify` | None | None | None |
832
- | **Multi-DB** | PostgreSQL only | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
896
+ | **Multi-DB** | Postgres-first (+ SQLite/MySQL/MSSQL engines) | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
833
897
 
834
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.
835
899
 
@@ -837,7 +901,7 @@ All three ORMs now do single-query nested loads — that's table stakes. Turbine
837
901
 
838
902
  Turbine is focused and opinionated. Here's what it doesn't do:
839
903
 
840
- - **PostgreSQL only.** No MySQL, SQLite, or MSSQL. By design — going deep on one database enables the performance advantage and the edge-runtime story.
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.
841
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`.
842
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.
843
907
 
@@ -54,15 +54,10 @@ const CREATE_LOCK_TABLE_SQL = `
54
54
  // Introspection query overrides
55
55
  // ---------------------------------------------------------------------------
56
56
  /**
57
- * CockroachDB index introspection via SHOW INDEXES.
58
- * This returns a different shape than pg_indexes, so the consumer
59
- * needs to handle the transformation. However, since we're providing this
60
- * as a drop-in SQL string for the existing introspection flow, we use
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 SHOW INDEXES.
61
- * This returns a different shape than pg_indexes, so the consumer
62
- * needs to handle the transformation. However, since we're providing this
63
- * as a drop-in SQL string for the existing introspection flow, we use
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
@@ -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
- // Refuse to bind anything other than loopback unless explicitly overridden.
968
- // This is deliberate: Studio has no real authentication beyond a random
969
- // session token, so exposing it on a LAN interface is foot-gun territory.
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, '&amp;')
139
+ .replace(/</g, '&lt;')
140
+ .replace(/>/g, '&gt;')
141
+ .replace(/"/g, '&quot;')
142
+ .replace(/'/g, '&#39;');
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>';
@@ -116,13 +116,12 @@ function isAuthorized(req, expectedToken) {
116
116
  return false;
117
117
  }
118
118
  function constantTimeEqual(a, b) {
119
- if (a.length !== b.length)
120
- return false;
121
- let result = 0;
122
- for (let i = 0; i < a.length; i++) {
123
- result |= a.charCodeAt(i) ^ b.charCodeAt(i);
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
@@ -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
- if (a.length !== b.length)
241
- return false;
242
- let result = 0;
243
- for (let i = 0; i < a.length; i++) {
244
- result |= a.charCodeAt(i) ^ b.charCodeAt(i);
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
@@ -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());
@@ -127,14 +131,14 @@ class TransactionClient {
127
131
  */
128
132
  async $transaction(fn) {
129
133
  const savepointName = `sp_${++this.savepointCounter}`;
130
- await this.client.query(`SAVEPOINT ${savepointName}`);
134
+ await this.client.query(this.dialect.savepointStatement(savepointName));
131
135
  try {
132
136
  const result = await fn(this);
133
- await this.client.query(`RELEASE SAVEPOINT ${savepointName}`);
137
+ await this.client.query(this.dialect.releaseSavepointStatement(savepointName));
134
138
  return result;
135
139
  }
136
140
  catch (err) {
137
- await this.client.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
141
+ await this.client.query(this.dialect.rollbackToSavepointStatement(savepointName));
138
142
  throw err;
139
143
  }
140
144
  }
@@ -146,7 +150,7 @@ class TransactionClient {
146
150
  strings.forEach((str, i) => {
147
151
  sql += str;
148
152
  if (i < values.length) {
149
- sql += `$${i + 1}`;
153
+ sql += this.dialect.paramPlaceholder(i + 1);
150
154
  }
151
155
  });
152
156
  try {
@@ -200,6 +204,8 @@ class TurbineClient {
200
204
  schema;
201
205
  static int8ParserRegistered = false;
202
206
  logging;
207
+ /** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
208
+ dialect;
203
209
  tableCache = new Map();
204
210
  middlewares = [];
205
211
  queryListeners = new Set();
@@ -245,6 +251,7 @@ class TurbineClient {
245
251
  TurbineClient.int8ParserRegistered = true;
246
252
  }
247
253
  this.logging = config.logging ?? false;
254
+ this.dialect = config.dialect ?? dialect_js_1.postgresDialect;
248
255
  this.schema = schema;
249
256
  // Respect env var kill switch
250
257
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
@@ -473,7 +480,7 @@ class TurbineClient {
473
480
  strings.forEach((str, i) => {
474
481
  sql += str;
475
482
  if (i < values.length) {
476
- sql += `$${i + 1}`;
483
+ sql += this.dialect.paramPlaceholder(i + 1);
477
484
  }
478
485
  });
479
486
  if (this.logging) {
@@ -518,7 +525,7 @@ class TurbineClient {
518
525
  * ```
519
526
  */
520
527
  sql(strings, ...values) {
521
- const { sql, params } = (0, typed_sql_js_1.buildTypedSql)(strings, values);
528
+ const { sql, params } = (0, typed_sql_js_1.buildTypedSql)(strings, values, this.dialect);
522
529
  return new typed_sql_js_1.TypedSqlQuery(this.pool, sql, params, this.logging);
523
530
  }
524
531
  // -------------------------------------------------------------------------
@@ -538,13 +545,13 @@ class TurbineClient {
538
545
  async transaction(fn) {
539
546
  const client = await this.pool.connect();
540
547
  try {
541
- await client.query('BEGIN');
548
+ await client.query(this.dialect.beginStatement());
542
549
  const result = await fn(client);
543
- await client.query('COMMIT');
550
+ await client.query(this.dialect.commitStatement());
544
551
  return result;
545
552
  }
546
553
  catch (err) {
547
- await client.query('ROLLBACK');
554
+ await client.query(this.dialect.rollbackStatement());
548
555
  throw err;
549
556
  }
550
557
  finally {
@@ -595,14 +602,10 @@ class TurbineClient {
595
602
  };
596
603
  let timedOut = false;
597
604
  try {
598
- // BEGIN with optional isolation level
599
- let beginSQL = 'BEGIN';
600
- if (options?.isolationLevel) {
601
- const level = ISOLATION_LEVELS[options.isolationLevel];
602
- if (level)
603
- beginSQL += ` ISOLATION LEVEL ${level}`;
604
- }
605
- await client.query(beginSQL);
605
+ // BEGIN with optional isolation level — the dialect owns the keyword and
606
+ // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
607
+ const isolationSql = options?.isolationLevel ? ISOLATION_LEVELS[options.isolationLevel] : undefined;
608
+ await client.query(this.dialect.beginStatement(isolationSql));
606
609
  // Apply transaction-local session context (RLS / multi-tenant GUCs).
607
610
  // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
608
611
  // user fn. Any error here propagates to the catch below and rolls back
@@ -610,12 +613,16 @@ class TurbineClient {
610
613
  // is_local=true) — the parameterizable, transaction-scoped equivalent of
611
614
  // SET LOCAL — so both name and value are BOUND params, never interpolated.
612
615
  if (options?.sessionContext) {
616
+ if (!this.dialect.supportsRLS) {
617
+ throw new errors_js_1.UnsupportedFeatureError('sessionContext (RLS session GUCs)', this.dialect.name, 'set_config-based row-level-security context requires PostgreSQL.');
618
+ }
613
619
  for (const [name, value] of Object.entries(options.sessionContext)) {
614
620
  if (!GUC_NAME_REGEX.test(name)) {
615
621
  throw new errors_js_1.ValidationError(`[turbine] Invalid session-context GUC name "${name}" — must match ` +
616
622
  '/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
617
623
  }
618
- await client.query('SELECT set_config($1, $2, true)', [name, String(value)]);
624
+ const cfg = this.dialect.buildSetSessionConfig(name, String(value));
625
+ await client.query(cfg.sql, cfg.params);
619
626
  }
620
627
  }
621
628
  // Create the transaction client with typed table accessors
@@ -661,7 +668,7 @@ class TurbineClient {
661
668
  else {
662
669
  result = await fn(tx);
663
670
  }
664
- await client.query('COMMIT');
671
+ await client.query(this.dialect.commitStatement());
665
672
  if (this.logging) {
666
673
  console.log('[turbine] Transaction committed');
667
674
  }
@@ -674,7 +681,7 @@ class TurbineClient {
674
681
  // when its socket was closed).
675
682
  if (!timedOut && !released) {
676
683
  try {
677
- await client.query('ROLLBACK');
684
+ await client.query(this.dialect.rollbackStatement());
678
685
  }
679
686
  catch {
680
687
  // Best-effort rollback — the connection may have died mid-query.
@@ -741,6 +748,9 @@ class TurbineClient {
741
748
  * ```
742
749
  */
743
750
  async $listen(channel, handler) {
751
+ if (!this.dialect.supportsListenNotify) {
752
+ throw new errors_js_1.UnsupportedFeatureError('$listen (LISTEN/NOTIFY realtime)', this.dialect.name, 'Realtime pub/sub requires PostgreSQL.');
753
+ }
744
754
  (0, realtime_js_1.validateChannel)(channel);
745
755
  const quoted = (0, utils_js_1.quoteIdent)(channel);
746
756
  if (this.logging) {
@@ -766,6 +776,9 @@ class TurbineClient {
766
776
  * ```
767
777
  */
768
778
  async $notify(channel, payload) {
779
+ if (!this.dialect.supportsListenNotify) {
780
+ throw new errors_js_1.UnsupportedFeatureError('$notify (LISTEN/NOTIFY realtime)', this.dialect.name, 'Realtime pub/sub requires PostgreSQL.');
781
+ }
769
782
  (0, realtime_js_1.validateChannel)(channel);
770
783
  if (this.logging) {
771
784
  console.log(`[turbine] NOTIFY ${channel}`);