Database Engines

Turbine is Postgres-first. import { TurbineClient } from 'turbine-orm' targets PostgreSQL, and pgvector, LISTEN/NOTIFY, RLS, the Studio, and SQL-first migrations are built around it. The same typed findMany / with / where / create API also runs on SQLite, MySQL 8, and SQL Server through subpath exports, and on PowDB, a single-node database with its own query language (PowQL).

npm install turbine-orm pulls exactly one runtime dependency (pg). SQLite needs nothing extra (it uses the node:sqlite builtin). MySQL, SQL Server, and PowDB use optional peer dependencies you install only if you use them. SQLite and PowDB's embedded mode run in-process, with no server to start; PowDB also has a networked mode over the same data.

The turbine CLI drives PostgreSQL only. turbine generate, turbine push, and turbine migrate are Postgres-only today. Every other engine is code-first: define your schema with defineSchema, derive runtime metadata with schemaDefToMetadata, and construct the client through the engine's factory.

What is actually Postgres-only#

The typed query API (findMany / with / where / create / aggregate and the types they return) runs on every engine. Two smaller sets do not, and they fail in different ways.

Query features with no portable equivalent. The non-Postgres engines throw a typed UnsupportedFeatureError (TURBINE_E017) the moment you reach for one, rather than silently degrading:

  • pgvector distance ops / KNN ordering
  • LISTEN/NOTIFY realtime ($listen / $notify)
  • RLS sessionContext (the transaction-local GUCs $withSession sets)
  • DISTINCT ON (groupBy({ distinctOn }))
  • Full-text search in a where clause (use contains elsewhere)
  • Array-column filters (has / hasEvery / hasSome / isEmpty), which need a native array column type

Schema and diagnostic tooling that talks to pg directly. These are not query features and do not throw a capability error; they simply target PostgreSQL:

  • The whole turbine CLI: generate / pull, push, migrate, seed, status, studio, mcp, observe, doctor, migrate-from-prisma
  • The programmatic schema-management functions schemaPush and schemaDiff (schemaToSQL only builds DDL strings, has no connection, and works anywhere you can execute the statements yourself)

If pgvector, realtime, or RLS is core to your app, stay on Postgres. The engines below are for tests, edge demos, and existing MySQL / SQL Server deployments, with the same nested-relation query model and typed errors.

Capability matrix#

✗ E017 means calling the feature throws UnsupportedFeatureError. The matrix covers the four SQL engines; PowDB's capability list lives in its own section below.

FeaturePostgreSQLSQLiteMySQL 8SQL Server
Single-query nested relations (with)✓ json_agg✓ json_group_array✓ JSON_ARRAYAGG✓ FOR JSON PATH
Atomic update operators (increment, …)✓✓✓✓
Transactions + nested savepoints✓✓ ¹✓✓
Cursor / streaming (findManyStream)✓ true cursor⚠ ⁵⚠ ⁵⚠ ⁵
Optimistic locking✓✓✓ ⁴✓
Schema introspection✓✓ ²✓ ²✓ ²
Migrations (turbine migrate CLI)✓CLI ³CLI ³CLI ³
pgvector distance / KNN✓✗ E017✗ E017✗ E017
LISTEN/NOTIFY realtime✓✗ E017✗ E017✗ E017
RLS sessionContext✓✗ E017✗ E017✗ E017
Full-text search filter✓✗ E017✗ E017✗ E017
Array-column filters (has, hasEvery, …)✓✗ E017✗ E017✗ E017
groupBy({ distinctOn })✓✗ E017✗ E017✗ E017

¹ SQLite is single-writer: one write transaction at a time, and concurrent writers get SQLITE_BUSY (treated as retryable). WAL mode is enabled for file databases so readers do not block.

² Each engine ships a DialectIntrospector, so introspect({ dialect }) reads the engine's own catalog (PRAGMA for SQLite, information_schema / sys.* for MySQL and SQL Server); the turbine generate CLI itself is Postgres-only, so point an engine factory at a programmatically introspected or hand-written SCHEMA.

³ The turbine migrate runner is Postgres-only. Each dialect emits its own migration-tracking SQL, and MySQL / SQL Server expose advisory-lock primitives (GET_LOCK, sp_getapplock) for a future adapter.

⁴ Optimistic locking throws OptimisticLockError on all four engines. On RETURNING / OUTPUT engines the conflict is a missing returned row; on MySQL it is detected from the version-checked UPDATE's affected-row count.

⁵ findManyStream works on every engine: it yields rows in batchSize batches and you can break early. Only PostgreSQL streams with a true server-side cursor (DECLARE CURSOR, constant memory regardless of result size); SQLite, MySQL, and SQL Server materialize the full result first, then yield it in batches.

Value fidelity inside with#

A nested relation is assembled as JSON by the database, and a JSON number is an IEEE double, so a column read through a with relation could come back different from the same column read at top level. Turbine closes this per engine: divergent columns are carried through the JSON layer as text and decoded back through the engine's own rule, so all read paths return the same value.

EngineColumns that needed itSymptom before
PostgreSQLnumeric, int8, dateprecision loss on large ints and exact decimals
SQLiteINTEGER, BLOB64-bit ints rounded; BLOB failed the query ("JSON cannot hold BLOB values")
MySQL 8BIGINT, DECIMAL, VARBINARY / BLOBints rounded, decimals as floats, binary as the literal text base64:type15:…
SQL ServerBIGINT, VARBINARYints rounded, binary as base64 text

Postgres was fixed in 0.50 and the other three in 0.51; upgrading across those versions changes the values such reads return, to the correct ones.

Zone-less date and timestamp agree across engines#

A date is a calendar day with no zone, so the driver has to pick an instant for it. All five engines read it at UTC midnight. PostgreSQL's driver used to pick the process's local midnight (2026-07-21 read as 2026-07-20T22:00:00.000Z in Europe/Berlin); since v0.54 it reads UTC midnight under the same utcTimestamps flag that governs timestamp. The upgrade keeps the calendar day but moves the epoch value for any Postgres app not running in UTC; see Zone-less columns before upgrading.

How writes return rows#

How a write surfaces the row it affected differs per engine. Turbine's resultStrategy seam handles it, so create / update / delete / upsert return the full row everywhere:

EngineresultStrategyMechanism
PostgreSQLreturningtrailing RETURNING *
SQLite ≥ 3.35returningtrailing RETURNING *
MySQL 8reselectrun the write, then SELECT the affected row by primary key / where
SQL ServeroutputOUTPUT INSERTED.* / MERGE in the same statement
PowDBreturningtrailing returning keyword (upsert excepted, see below)

On MySQL, createMany returns an empty array ([]): the rows are inserted, but there is no safe key set to reselect them by, so re-query if you need them.

PowDB's returning keyword takes no column list, so it returns the whole row and Turbine applies any PII projection client-side. Its upsert reselects: PowQL's upsert statement rejects returning, so Turbine reads the row back by primary key (a composite-PK upsert does the lookup-or-write in one flat transaction).

SQLite#

The in-process engine for tests, edge demos, and trying Turbine without a server. It uses Node's built-in node:sqlite driver, so it adds zero new dependencies.

npm install turbine-orm   # nothing else, node:sqlite is built in (Node >= 22.5)
import { turbineSqlite } from 'turbine-orm/sqlite';
import { SCHEMA } from './generated/turbine/metadata.js';
 
// File path, ':memory:', or an already-open DatabaseSync handle
const db = turbineSqlite(':memory:', SCHEMA);
 
const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
await db.disconnect();

turbineSqlite(target, schema, options?) is synchronous and returns a TurbineClient. Pass a file path, ':memory:', or an open DatabaseSync (so you can seed and introspect it first, then reuse the same connection). Options:

  • wal, enable WAL journal mode for file databases (default true; ignored for ':memory:').
  • busyTimeoutMs, how long a writer waits on SQLITE_BUSY (default 5000).
  • foreignKeys, enable PRAGMA foreign_keys enforcement (default true).
  • plus the client options below.

Driver: node:sqlite DatabaseSync is the primary driver (Node ≥ 22.5; it emits a harmless ExperimentalWarning). For older Node, wrap a better-sqlite3 handle in the same pool shape (documented fallback, not bundled).

Caveats: SQLite has no native BOOLEAN or DATE, so Turbine binds booleans as 1/0 and Date values as ISO-8601 text, and coerces TIMESTAMP / DATETIME / DATE columns back to Date. Integers wider than Number.MAX_SAFE_INTEGER come back as strings, mirroring the Postgres int8 policy. Case-insensitive matching uses COLLATE NOCASE, which is ASCII-only (no Unicode case folding), and mode: 'insensitive' on in / notIn is refused with TURBINE_E017 (PostgreSQL only among the SQL engines, see Queries).

MySQL 8#

MySQL 8.0+ via the optional peer dependency mysql2: install it only if you use this subpath.

npm install turbine-orm mysql2
import { turbineMysql } from 'turbine-orm/mysql';
import { SCHEMA } from './generated/turbine/metadata.js';
 
const db = await turbineMysql('mysql://user:pass@localhost:3306/app', SCHEMA);

turbineMysql(target, schema, options?) is async and resolves to a TurbineClient. target may be a connection string, a mysql2 config object, or an existing mysql2 pool (injection: you own its lifecycle and disconnect() becomes a no-op). When Turbine builds the pool it pins the correct mysql2 flags (named placeholders, safe bignum, UTC dates, JSON-as-string) and probes SELECT VERSION() to reject MariaDB and any MySQL older than 8.0 (5.7 lacks JSON_ARRAYAGG). The only MySQL-specific option is connectionLimit (default 10), on top of the forwarded client config described above.

Caveats: writes use the reselect strategy (no RETURNING), so createMany returns [], see above. Nested relations use JSON_OBJECT / JSON_ARRAYAGG; since JSON_ARRAYAGG has no inline ORDER BY, every ordered to-many relation goes through the inner-subquery rewrite. Case-insensitive matching uses LOWER(col) LIKE LOWER(ref), which can defeat an index unless a functional/generated index exists, and mode: 'insensitive' on in / notIn is refused with TURBINE_E017 (PostgreSQL only among the SQL engines, see Queries).

SQL Server#

Microsoft SQL Server 2016+ via the optional peer dependency mssql (which wraps tedious).

npm install turbine-orm mssql
import { turbineMssql } from 'turbine-orm/mssql';
import { SCHEMA } from './generated/turbine/metadata.js';
 
const db = await turbineMssql('mssql://sa:Passw0rd!@localhost:1433/app', SCHEMA);

turbineMssql(target, schema, options?) is async and resolves to a TurbineClient. target may be a connection string, an mssql config object, or an existing pool (injection: disconnect() is a no-op). When Turbine builds the pool it probes SERVERPROPERTY('ProductMajorVersion') to reject any SQL Server older than 2016. The schema option sets the introspection / DDL schema (default dbo), on top of the forwarded client config described above.

Caveats: writes return rows via OUTPUT INSERTED.* / MERGE … OUTPUT (see above); nested relations use a dedicated FOR JSON PATH correlated-subquery generator (no json_agg), wrapping to-many results in ISNULL(…, '[]') and embedding nested objects with JSON_QUERY. Paging is ORDER BY … OFFSET … FETCH NEXT (a stable order is injected when a query has none). createMany is capped at 1000 rows / 2100 parameters per statement (exceeding either throws a ValidationError; chunk it yourself). MERGE is not a substitute for a unique constraint, so keep the upsert conflict target backed by a real UNIQUE / PK index; the loser of a race surfaces as a typed UniqueConstraintError. Case-insensitive matching follows the column's collation, and mode: 'insensitive' on in / notIn is refused with TURBINE_E017 (PostgreSQL only among the SQL engines, see Queries).

PowDB#

PowDB is a single-node embedded database with its own query language, PowQL. It is not SQL. Turbine talks to it through a parallel PowQL generator with the same findMany / with / where / create / aggregate surface, so application code is unchanged; only the import and the connection target differ.

PowDB runs both ways from the same data:

  • Embedded, in-process via the native addon @zvndev/powdb-embedded (no server, no socket).
  • Networked, over a Unix socket or TCP via @zvndev/powdb-client, talking to a powdb-server.

Both are optional peer dependencies; install only the transport you use.

# Embedded (in-process), native addon, no server
npm install turbine-orm @zvndev/powdb-embedded
 
# Networked, client for a running powdb-server
npm install turbine-orm @zvndev/powdb-client

Schemas are code-first: define them with defineSchema and derive runtime metadata with schemaDefToMetadata. A programmatic introspector also exists: introspectPowdbDatabase (exported from turbine-orm/powdb) reads a live catalog via PowDB 0.10+'s schema / describe statements, and on engine ≥ 0.19.1 also reads declared entity links into relations (see the module reference); defineSchema remains the relation-complete path.

import { turbinePowDB } from 'turbine-orm/powdb';
import { schemaDefToMetadata } from 'turbine-orm';
import { schema } from './schema.js'; // defineSchema({...})
 
const SCHEMA = schemaDefToMetadata(schema);
 
// Embedded: in-process, opens a data directory
const db = await turbinePowDB({ embedded: './data', syncMode: 'normal' }, SCHEMA);
 
// Networked: same API, talks to a running powdb-server
const remote = await turbinePowDB('powdb://127.0.0.1:7070', SCHEMA);
 
const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
await db.disconnect();

turbinePowDB(target, schema, options?) is async and resolves to a TurbineClient. target is either an embedded descriptor ({ embedded: dir, syncMode?, memoryLimit?, readonly? }) or a networked target (powdb://host:port string, a config object, or an existing PowDB pool or PowdbPool; injection, so disconnect() becomes a no-op).

The networked path probes the server version and requires PowDB ≥ 0.7.0 at runtime; the declared optional peer range in package.json is >=0.7.1 <1.0.0. The ESM-only @zvndev/powdb-client ≥ 0.9 loads correctly even from a CommonJS build.

Embedded durability, syncMode#

The embedded addon lets Turbine choose how aggressively PowDB fsyncs, via turbinePowDB({ embedded, syncMode }):

  • 'full' (default), fsync on every commit. Matches SQLite's default durability.
  • 'normal', fsync moved off the commit path. On OS crash / power loss you can lose a bounded window (≤ one fsync interval); a process crash loses nothing (WAL replay). This is the mode that makes embedded writes fast.
  • 'off', no fsync. For throwaway / test data only.

An optional memoryLimit (bytes) caps the embedded cache. syncMode / memoryLimit require the 0.7.1+ addon; on an older addon Turbine raises a ConnectionError rather than silently ignoring them.

Embedded PowDB vs SQLite: writes win, reads do not#

Embedded PowDB with syncMode: 'normal' beats SQLite on writes; SQLite beats it on reads. The numbers are the 2026-07-21 check-in of the cross-engine harness (benchmarks/cross-engine.ts) on @zvndev/powdb-embedded 0.17.0, Node v24.18.0, ENGINES=pg,sqlite,powdb_emb. Two full runs agreed on every rank shown:

op (p50, ms)SQLitePowDB embed (normal)
create0.0180.015PowDB faster
update (atomic increment)0.0160.011PowDB faster
createMany (100 rows)1.5160.421PowDB ~3.6x faster
findUnique by PK0.0070.010SQLite faster
findMany (filter+order+limit)0.0990.267SQLite faster
nested with0.4740.516SQLite faster

The bulk-write gap (createMany roughly 3x to 4x faster) has held across every run. PowDB's own benchmarks (July 2026) corroborate the point-lookup gap: through PowQL an indexed point lookup measures 7.9x slower than SQLite, dominated by fixed per-query front-end work (lex, parse, plan-cache lookup) that SQLite amortizes with a prepared statement; PowDB's prepared-plan path is not exposed to drivers. Full tables and caveats: benchmarks/CROSS-ENGINE-RESULTS.md and the upstream 2026-07-24-wide-bench-snapshot.md.

These are per-op latencies on a warm cache, a small dataset, and a single connection, not concurrent throughput. If your hot path is "fetch one row by id", SQLite is currently the better embedded choice; for write-heavy work, embedded PowDB is, and it keeps a real storage engine (indexes, WAL) plus a networked sibling serving the same data, which SQLite cannot offer.

What PowDB does differently#

  • Nested with runs as one statement on engine 0.18+ via nested projections (below), the same single-query shape json_agg gives Postgres. Older engines, and ineligible shapes such as many-to-many, use batched lookups (keys chunked at 1,000) or opt-in native joins. Output is identical on every path.
  • Primary keys can be server-assigned or client-assigned. An auto int PK (isGenerated) is assigned by the engine on create and read back automatically; a defaulted string PK gets a client-generated UUID.
  • Relation filters resolve to a literal list, not a subquery. where: { tags: { some } } runs the inner predicate first and filters with in (<keys>): one extra round-trip, sidestepping a PowDB quirk where a repeated in (<subquery>) of the same shape can return a stale cached result. Correct on every cardinality and nesting level.
  • Nested writes run as one flat transaction. Relation ops in create/update (create, connect, connectOrCreate, disconnect, set, delete, update, upsert on hasMany/hasOne/belongsTo) commit or roll back together. PowDB is single-writer with one global write lock: top-level $transaction calls queue FIFO, while a re-entrant or nested $transaction throws UnsupportedFeatureError (no savepoints; waiting on a held lock would deadlock). transactionQueueTimeoutMs (default 30000; 0 or Infinity waits forever) bounds the queue wait with TimeoutError (TURBINE_E002).
  • createMany requires uniform rows. Every row must name the same fields (undefined counts as omitted); a row that disagrees with the first throws ValidationError (TURBINE_E003) naming the row index and the differing columns, keeping the call portable with the SQL engines' one-statement builds. Split into one createMany per field set.
  • Type mapping is narrowed. PowDB stores str / int / float / bool / json (json needs engine ≥ 0.12). Turbine never emits uuid / datetime / bytes on the wire, and maps Date to integer microseconds. Reading and filtering a datetime column created by another tool is supported, with the version gate in the correctness round.
  • Reserved PowQL words are quoted automatically (type, order, schema, describe; PowDB ≥ 0.10).
  • Server-side timeouts surface as TimeoutError (TURBINE_E002), matching the client-side queue timeout above.
  • Connection failures surface as ConnectionError (TURBINE_E004) with the original error as .cause, including the "received unexpected frame" shape a stale idle socket produces. An opt-in retryStaleReads replays a first-statement read once on that exact signature (never a write, never inside a transaction).

JSON documents (PowDB ≥ 0.12 / 0.13)#

PowDB 0.12 added a native json document column type; 0.13 added path filters, path ordering, doc-field grouped aggregates, and doc-field expression indexes. Turbine maps its existing JSON API onto them, so the same queries you write on Postgres jsonb run on PowDB. Each feature is version-gated: an older engine throws a typed E017 with an upgrade hint, never a parse error.

  • JsonFilter where-filters: where: { data: { path: ['ns', 'value'], gte: 5 } } compiles to a PowQL path comparison, every segment and value bound as a typed parameter. A digit-only segment addresses an array index. equals: null matches a JSON null or a missing key on PowDB. contains and pathless equals stay E017 (PowQL has no containment operator).
  • JSON-path orderBy and groupBy: missing keys sort last in both directions; group keys and aggregate targets keep the SQL engines' alias and orderBy semantics.
  • Doc-field expression indexes: declare indexes: [{ docField: 'data', path: ['ns', 'value'] }] in defineSchema and powqlSchemaDDL emits alter T add index (.data->"ns"->"value") (engine ≥ 0.13). Unique doc-field violations map to UniqueConstraintError (E008).
  • Native typed wire on both transports: networked uses PowDB's lossless queryNativeRaw API on engine ≥ 0.13; embedded runs through queryWithParams (real positional binding plus typed result cells) on addon ≥ 0.14, where disconnect() also performs a real checkpoint-flush close. A JSON null, a missing field, and the string "null" are all distinguishable end-to-end.
  • Equality is type-strict on JSON leaves. PowQL's = never coerces across types: a document holding 7.0 (float) does not match a filter binding the integer 7. Values written through Turbine round-trip correctly (an integral JS number binds as int, a fractional one as float); when filtering documents written by other tools, bind the stored type. (Stored scalar columns are softer: an int literal against a float column widens losslessly.)

Nested projections: one-statement with (PowDB ≥ 0.18)#

On a ≥ 0.18 engine a PowQL projection field can be a whole correlated child query, so Turbine compiles eligible with clauses straight into the parent statement. This is the default relation path; nothing to configure:

// ONE PowQL statement: per-parent order/limit, childless users keep
// posts: [] and profile: null, arbitrary nesting depth.
const users = await db.users.findMany({
  with: {
    posts: { orderBy: { views: 'desc' }, limit: 3, with: { comments: true } },
    profile: true,
  },
});

Per-relation where / orderBy / limit apply per parent, select/omit/PII rules shape the child projection, and child values are re-coerced by column type (datetime microseconds come back as Date). explain() shows the engine's nested plan.

Ineligible shapes silently fall back to the batched loaders with identical output: many-to-many, a bigint-typed child column (JSON cannot carry int64 losslessly), a to-one relation with limit/offset, parent distinct, and a relation named like a projected parent column. relationLoadStrategy: 'batched' opts back out entirely; 'join' also prefers nesting on ≥ 0.18. Engines below 0.18 keep the loaders byte-for-byte.

Native relation joins (PowDB ≥ 0.13)#

Below 0.18 (or with nesting opted out), relation loading runs the batched loaders. relationLoadStrategy: 'join' opts eligible relations into native server-side joins instead: one hash-accelerated join statement per relation, no key lists, no 1,000-key chunking:

const users = await db.users.findMany({ with: { posts: true }, relationLoadStrategy: 'join' });
// or client-wide: await turbinePowDB(target, schema, { relationLoadStrategy: 'join' })

A relation is join-eligible when the parent query has no limit/offset, the relation is top-level (nested with levels keep the loaders), and the correlation key is the parent's primary key or a unique column. Ineligible relations silently fall back to the loaders, so results are always identical. Requesting 'join' per query against an engine older than 0.13 throws a typed UnsupportedFeatureError; a client-level default falls back silently.

One semantic note: the join re-evaluates the parent where in a second statement rather than pinning the fetched keys, so a row updated between the two statements can drop out of the relation set; the loaders pin fetched keys and have a narrower window. Neither runs in a transaction; for snapshot consistency across relation loads, wrap the read in $transaction.

PowDB 0.19 added entity links: declared relationships traversable directly in PowQL. Turbine keeps composing its own nested projections and batched loaders (the engine never caches link-bearing plans, so links would regress hot paths) and adopts links in three ways on engine ≥ 0.19.1 (0.19.0 had silent-wrong-result link bugs, so the floor is the patch release):

  • Scalar link paths, used automatically for one case. A belongsTo whose child projection includes a bigint/bytes column cannot ride a JSON nested block; when a matching link is declared, Turbine compiles it to scalar link paths on the parent statement (a single round-trip) instead of a per-relation loader. A missing or mismatched declaration falls back to the loader, byte-for-byte identical either way.
  • Link introspection populates relations. introspectPowdbDatabase reads schema links and fills SchemaMetadata.relations (a to-one link becomes a belongsTo on the owner plus a synthesized reverse hasMany; a to-many link the reverse). Many-to-many junctions cannot be inferred from links.
  • emitLinks (opt-in DDL). powqlSchemaDDL(schema, { emitLinks: true }) emits one link declaration per single-column relation; applyPowdbLinks(exec, schema) applies them existence-checked (link DDL is create-only, so already-declared links are skipped, and endpoint drift warns rather than replaces). Off by default because of the one-way door below.

The catalog v7 one-way door. The first link declaration in a data directory permanently upgrades its on-disk catalog from v6 to v7; a pre-0.19 binary or addon then fails to open it with unsupported catalog version: 7 (mapped to ConnectionError, TURBINE_E004). A database that never declares a link stays at v6, and Turbine only emits link DDL when you opt in via emitLinks / applyPowdbLinks; merely opening a database never triggers the upgrade. Upgrade your fleet before introducing links.

Null semantics: not / notIn and the NOT combinator#

The not and notIn where-operators match SQL null semantics on PowDB: { not: v } compiles to .col != $1 and { notIn: [a, b] } to (.col not in ($1, $2) and .col is not null), so a null row is excluded from both, exactly as on every SQL engine. notIn: [] keeps its match-everything semantics with no presence guard, also matching SQL.

One divergence is permanent by upstream design: the whole-clause NOT: { ... } combinator lowers to PowQL's not ( ... ), the plain two-valued complement, which matches when the inner predicate is false, including on a missing value. For strict null parity on PowDB, use the leaf not / notIn operators rather than a NOT wrapper.

Read-only snapshots and replicas (PowDB ≥ 0.14)#

PowDB 0.14 can serve a quiescent data directory strictly read-only (powdb-server --readonly, or embedded Database.openReadOnly) with any number of concurrent readers across processes:

// Embedded read-only snapshot (addon >= 0.14):
const replica = await turbinePowDB({ embedded: './snapshot', readonly: true }, schema);
 
// Networked against a powdb-server --readonly, failing writes fast locally:
const replica = await turbinePowDB('powdb://replica-host:7070', schema, { readonly: true });

Reads work unchanged. Any write is refused with a typed ReadOnlyError (TURBINE_E018) whose reason field distinguishes 'snapshot' (nothing can write here, route writes to the primary) from 'rbac' (this connection's role may not write). With the client-level readonly: true flag the refusal happens locally, before the wire. Freshness is the snapshot cadence: this is snapshot serving, not streaming replication. See Read Replicas for the routing pattern.

Two notes for snapshot fleets. PowDB 0.16 changed the on-disk index format: a directory upgrades on its first writable open, and a read-only open rebuilds the affected indexes in memory on every open until then, so run the snapshot through one writable open or take snapshots from a 0.16+ primary. PowDB 0.17 added a one-byte error class to server error frames; wrapPowdbError classifies by it before message matching, so a server-sanitized message still maps to the right typed error.

Correctness round: PowDB 0.20#

PowDB 0.20 fixed silent-wrong-answer engine bugs. Two changed results for queries Turbine emits, so Turbine gates them: on an older engine you get a typed UnsupportedFeatureError (TURBINE_E017) naming the column and the version floor, instead of the wrong rows that engine returns.

Comparisons on a PowDB datetime column. Before 0.20 the engine compared a datetime column against an integer by type tag: a > filter matched every non-null row, an equality matched none. Turbine binds a JS Date as integer microseconds, exactly the affected shape. Turbine's own DDL never creates a datetime column (a Date field is provisioned as PowQL int epoch micros), so only tables created outside Turbine with a real datetime column (introspectPowdbDatabase reports dialectType: 'datetime') are affected; their predicates require engine ≥ 0.20. Null checks, orderBy, groupBy and min/max were never affected and are never gated.

One gap remains open upstream: 0.20 fixed the binary comparison operators but not the list forms, where in still matches nothing and not in matches everything. Turbine no longer emits that form: in / notIn on a datetime column expands into an equality chain built from the fixed operators:

in     →  (.ts = $1 or .ts = $2 or …)
notIn  →  (.ts != $1 and .ts != $2 … and .ts is not null)

The chain sits behind the same engine ≥ 0.20 gate as = and >, so every relation strategy behaves identically. The cost is width: PowQL spends one level of its 64-level nesting budget per chain term, so a datetime in list is capped at 32 values (measured headroom: 63 terms parse at the top level, 61 one level deep). The batched loaders chunk key sets to the same 32 for a datetime correlation column. A hand-written list, or a relation filter matching more than 32 distinct key timestamps, raises TURBINE_E017 naming the three ways out: a gte/lte range, splitting the call, or storing the column as PowQL int epoch microseconds (what Turbine's DDL emits for a Date column).

Per-field _count. aggregate({ _count: { field: true } }) counts non-null values (SQL's COUNT(col)) only from 0.20 on; below it PowDB returned the row count. The two differ only on a nullable column, so the gate is per column: a per-field _count of a nullable column requires ≥ 0.20; _count of a NOT NULL column, and _count: true, are never refused.

Pagination. Turbine validates limit / offset client-side: a negative value is a ValidationError (TURBINE_E003) instead of reaching an engine that ignored it; limit: 0 answers locally as "no rows", matching SQL LIMIT 0.

New engine refusals, mapped. 0.20 turned three previously-silent classes into typed errors: an unknown column in a filter or projection (ValidationError, pointing at schema drift), a type-mismatched comparison (ValidationError naming the column), and a corrupt page, which now fails the table open rather than a later read (ConnectionError, TURBINE_E004; restore from backup, there is no salvage mode).

Operator-chain budget. A flat OR / AND chain costs one level per term against the same 64-level budget as nested parentheses: roughly 63 terms at the top level, fewer inside a nested with block. Turbine maps the engine's refusal to a ValidationError telling you to split the array or use an in list (the true remaining budget depends on where the predicate sits, so there is no client-side pre-check). A literal in (…) list is a single flat node and does not count against the budget, so the 1,000-key relation chunking is unaffected.

explain() and index guidance#

Every table accessor has explain(args): it runs the exact query findMany would compile through the engine's plan explainer and returns the plan lines (see Queries). PowDB's planner picks indexes greedily using per-index statistics (engine ≥ 0.15), so index the most selective column of your common conjunctions and verify with explain that the intended index drives the scan. Plan text is diagnostic output, not a stable API.

Unsupported features → TURBINE_E017#

Beyond the Postgres-only trio every non-Postgres engine throws on (pgvector, LISTEN/NOTIFY, RLS sessionContext), PowDB throws UnsupportedFeatureError for what PowQL cannot express:

  • Filters: array, full-text, and pgvector-distance filters; vector / distance ordering. (JSON path filters: see above.)
  • Composite keys via subqueries: composite-key relation filters, composite-key nested reads, and composite-junction many-to-many; PowQL has no tuple-in. (A single-row findUnique/upsert on a composite PK works.)
  • Writes: nested writes inside createMany / upsert (use create / update).
  • Reads: cursor pagination and findManyStream (no server-side cursor; page with findMany({ limit, offset })).
  • Version-gated correctness (engine < 0.20): comparisons on a PowDB-native datetime column, including in / notIn, and per-field _count of a nullable column. See the correctness round.

Everything else works: the findMany family, every write including composite-PK upsert, atomic update operators, count / aggregate / groupBy, where operators, orderBy / select / omit, with for every cardinality including many-to-many, relation filters (some / none / every), and nested writes in create / update. That includes mode: 'insensitive' on every operator it applies to, in and notIn among them, which the three non-Postgres SQL engines refuse: PowQL's list form takes an expression per element, so the same lower(...) covers the column and every operand.

Platform binaries (embedded)#

The embedded addon ships prebuilt binaries for darwin-arm64 and linux-glibc (x64 / arm64). On other platforms (musl/Alpine, Windows, Intel macOS) the addon builds from source at install. The networked transport has no such constraint: any platform can run @zvndev/powdb-client against a powdb-server.

Postgres-compatible databases#

Distributed and managed databases that speak the PostgreSQL wire protocol (AlloyDB, TimescaleDB, Neon, Supabase, YugabyteDB, CockroachDB) are not separate engines: they run on the default Postgres path, some with a thin adapter for migration locking or introspection quirks. See Database Compatibility.

See also#

  • Database Compatibility: PG-wire-compatible databases (CockroachDB, YugabyteDB, AlloyDB, …) and their adapters.
  • Writing a custom dialect: the exported Dialect contract, for an engine Turbine does not ship.
  • Typed Errors: UnsupportedFeatureError (TURBINE_E017) and the full error code reference.
  • Serverless & Edge: driver injection for Neon, Vercel Postgres, and Cloudflare on the Postgres path.