Turbine

Database Engines

Turbine is built for PostgreSQL. The same typed findMany / with / where / create API also runs on SQLite, MySQL 8, SQL Server and PowDB, each behind its own import path.

EngineImportDriver to installMinimum version
PostgreSQLturbine-ormnone (pg is included)PostgreSQL 14
SQLiteturbine-orm/sqlitenone (node:sqlite is built in)Node 22.5
MySQLturbine-orm/mysqlmysql2MySQL 8.0 (not MariaDB)
SQL Serverturbine-orm/mssqlmssqlSQL Server 2016
PowDBturbine-orm/powdb@zvndev/powdb-embedded or @zvndev/powdb-clientPowDB 0.7

The engine drivers are optional peer dependencies, so npm install turbine-orm installs only pg. The non-PostgreSQL engines are Experimental under the stability policy, so a minor release can change them.

Capability matrix#

✗ means the feature throws UnsupportedFeatureError (TURBINE_E017). n/a means the engine has no such option.

FeaturePostgreSQLSQLiteMySQL 8SQL ServerPowDB
Nested relations (with)✓✓✓✓✓ ¹
Atomic update operators (increment, ...)✓✓✓✓✓
Update expressions (expr)✓✓✓✓✗
Multi-row upsert (createMany onConflict)✓✓✓ 8.0.19+✓✗
Optimistic locking✓✓✓✓✓
$transaction✓✓ ²✓✓✓ ³
Nested tx.$transaction (savepoints)✓✓✓✓✗
Read-only transactions (readOnly)✓✗✓✗✗
Row locks (lock)all modes✗'update', 'share'✗✗
findManyStreamserver cursorin memory ⁴in memory ⁴in memory ⁴✗
Cursor pagination (cursor)✓✓✓✓✗
mode: 'insensitive' on in / notIn✓✗✗✗✓
nulls placement in orderBy✓✓✗✗'last' only
explain()✓✓✓✗✓
distinct / groupBy({ distinctOn })✓✗✗✗✗
Percentiles (_percentile)✓✗✗✗✗
Full-text search filter✓✗✗✗✗
Array-column filters (has, hasEvery, ...)✓✗✗✗✗
pgvector distance and KNN ordering✓✗✗✗✗
LISTEN/NOTIFY ($listen / $notify)✓✗✗✗✗
RLS sessionContext✓✗✗✗✗
Read replicas (replicas)✓n/an/an/asnapshots ⁵
turbine CLI✓n/an/an/an/a

¹ One PowQL statement on engine 0.18+. Older engines and many-to-many relations use batched lookups, with the same results. ² SQLite has one connection. An open $transaction holds it, and other callers wait up to busyTimeoutMs. ³ PowDB has a single writer. Concurrent $transaction calls queue and run one at a time. ⁴ The whole result loads into memory, then yields in batchSize batches. Only PostgreSQL streams in constant memory. ⁵ Read-only snapshots, routed by hand.

What is actually Postgres-only#

Every row above with ✗ on all other engines is a PostgreSQL feature. Using one elsewhere throws TURBINE_E017, never broken SQL.

Two pieces of tooling also target PostgreSQL only, with no capability error:

  • The turbine CLI: generate / pull, push, migrate, seed, status, studio, mcp, observe, doctor and migrate-from-prisma.
  • schemaPush and schemaDiff. schemaToSQL builds DDL for any engine's dialect and opens no connection.

If pgvector, realtime or RLS is core to your app, stay on PostgreSQL. The other engines suit tests, edge demos and existing MySQL or SQL Server deployments.

Setup#

Outside PostgreSQL you work code-first. Define the schema with defineSchema and turn it into runtime metadata:

// schema.ts
import { defineSchema, schemaDefToMetadata } from 'turbine-orm';
 
export const schema = defineSchema({
  users: {
    id: { type: 'serial', primaryKey: true },
    email: { type: 'text', unique: true, notNull: true },
  },
  posts: {
    id: { type: 'serial', primaryKey: true },
    userId: { type: 'bigint', notNull: true, references: 'users.id' },
    title: { type: 'text', notNull: true },
  },
});
 
export const SCHEMA = schemaDefToMetadata(schema);

Each factory takes (target, schema, options?) and returns a TurbineClient. options accepts every client option except the connection settings and replicas, plus the engine options listed below. disconnect() closes what the factory opened and leaves a pool you passed in open.

The factories return the base client type, so the examples reach tables with db.table('users').

SQLite#

npm install turbine-orm
import { schemaToSQL } from 'turbine-orm';
import { sqliteDialect, turbineSqlite } from 'turbine-orm/sqlite';
import { schema, SCHEMA } from './schema.js';
 
// A file path, ':memory:', or an open DatabaseSync handle
const db = turbineSqlite(':memory:', SCHEMA);
 
for (const statement of schemaToSQL(schema, { dialect: sqliteDialect })) {
  await db.rawUnsafe(statement);
}
 
const user = await db.table('users').findUnique({ where: { id: 1 }, with: { posts: true } });

turbineSqlite is synchronous and adds no dependency.

OptionDefaultWhat it does
waltrueWAL journal mode for file databases.
busyTimeoutMs5000How long a statement waits for a lock or for another caller's open transaction.
foreignKeystrueTurns on PRAGMA foreign_keys.
  • A caller still waiting after busyTimeoutMs gets a retryable SerializationFailureError (TURBINE_E013). Inside a $transaction callback use tx: calling db there throws TURBINE_E017.
  • Columns your schema declares boolean or json read back as booleans and parsed values on Node 22.16+ or 23.11+. Older Node returns 1 / 0 and JSON text.
  • numeric reads as a JS number. Integers past Number.MAX_SAFE_INTEGER read as strings.
  • mode: 'insensitive' uses COLLATE NOCASE, which folds ASCII only.
  • isolationLevel is ignored, since SQLite transactions are serializable.

MySQL 8#

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

target is a connection string, a mysql2 config object or a mysql2 pool. A pool you pass in must set namedPlaceholders: true. connectionLimit (default 10) sizes a pool the factory builds.

  • createMany returns [], because MySQL has no RETURNING. Query the rows again if you need them. With onConflict it returns the written rows.
  • create reads the new row back by a key it knows: a primary key in data, an AUTO_INCREMENT id, or a unique key in data. Any other server-generated key (a UUID default) throws TURBINE_E017 before the insert, so pass it in data.
  • The default collation, utf8mb4_0900_ai_ci, ignores case and accents in =, unique keys, groupBy and ORDER BY. Use a binary or _as_cs collation where case must count.
  • An unconstrained numeric is created as DECIMAL(65,30) and reads back padded to 30 decimal places.
  • NaN, Infinity and an Invalid Date throw ValidationError instead of storing 0.

SQL Server#

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

target is a connection string, an mssql config object or an mssql pool. The schema option sets the database schema for introspection and DDL (default dbo).

  • A createMany over 1000 rows or 2098 parameters is split into chunks inside one transaction.
  • numeric reads as a JS number, because the driver has no string mode for DECIMAL. A UNIQUEIDENTIFIER reads back in upper case.
  • A json / jsonb column is stored as NVARCHAR(MAX) and reads back parsed. Pathless JSON equals throws TURBINE_E017.
  • Back an upsert's conflict target with a real UNIQUE index or primary key. MERGE alone does not stop two concurrent inserts.

PowDB#

PowDB is a single-node database with its own query language, PowQL. It runs in-process (embedded) or as a server (networked), over the same data.

npm install turbine-orm @zvndev/powdb-embedded   # in-process, no server
npm install turbine-orm @zvndev/powdb-client     # a running powdb-server
import { powqlSchemaDDL, turbinePowDB } from 'turbine-orm/powdb';
import { SCHEMA } from './schema.js';
 
const db = await turbinePowDB({ embedded: './data' }, SCHEMA);
// or: await turbinePowDB('powdb://127.0.0.1:7070', SCHEMA);
 
for (const statement of powqlSchemaDDL(SCHEMA)) await db.rawUnsafe(statement);

PowDB has no savepoints, row locks, cursors or bulk upsert, so those features throw TURBINE_E017 (see the matrix). Setup options, version-gated features and the full list of limits are on the PowDB module page.

Read-only snapshots#

PowDB 0.14+ serves a copy of a data directory strictly read-only, to any number of readers:

const replica = await turbinePowDB({ embedded: './snapshot', readonly: true }, SCHEMA);
// or a powdb-server started with --readonly:
const remote = await turbinePowDB('powdb://replica-host:7070', SCHEMA, { readonly: true });

Reads work unchanged. A write throws ReadOnlyError (TURBINE_E018) with reason: 'snapshot', so route it to the primary. The data is as fresh as the last snapshot. See Read Replicas.

Differences you will hit#

Some results follow the engine, and no error marks the difference:

PostgreSQLSQLiteMySQL 8SQL Server
String =case-sensitivecase-sensitivefollows the collation (default ignores case and accents)follows the collation
contains / startsWith / endsWithcase-sensitiveASCII case-insensitivefollows the collationfollows the collation
NULL in ascending orderlastfirstfirstfirst
numeric / decimalexact stringJS numberexact stringJS number
Pathless JSON equalsdocument equalityTURBINE_E017document equalityTURBINE_E017

These agree on all four SQL engines:

  • A value read inside with equals the same column read at top level, including bigint, numeric and binary columns.
  • A zone-less date reads as UTC midnight. See Zone-less columns.
  • Integer divide truncates, and _avg over an integer column returns the full fraction.
  • create, update, delete and upsert return the full row. MySQL's createMany is the exception above.

Postgres-compatible databases#

Databases that speak the PostgreSQL wire protocol (Neon, Supabase, AlloyDB, TimescaleDB, YugabyteDB, CockroachDB) are not separate engines. They use the default turbine-orm import, some with a thin adapter. See Database Compatibility.

See also#