Skip to content

zvndev/turbine-orm

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

127 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

turbine-orm

One dependency. No WASM engine. The Postgres ORM that ships light and locks tight.

npm install turbine-orm

Full docs: turbineorm.devQuick Start · API Reference · Relations · Transactions & Pipelines · Serverless & Edge · Typed Errors · Benchmarks

Why Turbine?

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:

  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.
  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.
  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.
  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.
  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.
  6. Pipeline batching via wire protocol. Real Parse/Bind/Execute pipeline — not queries wrapped in a transaction. N independent queries in one round-trip.

Every ORM claims single-query nested loads now (Prisma 7 and Drizzle v2 both use json_agg). Turbine does too — see 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.

Benchmarks

Tested against Prisma 7.6 (adapter-pg, relationJoins preview on) and Drizzle 0.45 (relational queries) on a Neon PostgreSQL database (pooled endpoint, US-East, PostgreSQL 17.8). 100 iterations, 20 warmup, Node v22. Same schema, same data (1K users, 10K posts, 50K comments), same connection pool config. Measured April 2026 on turbine-orm 0.7.1; the core read path these scenarios exercise is unchanged through 0.17.0 — see benchmarks/RESULTS.md to reproduce.

Scenario Turbine Prisma 7 Drizzle v2
findMany — 100 users (flat) 51.97 ms 52.90 ms 53.51 ms
findMany — 50 users + posts (L2) 55.84 ms 56.10 ms 88.80 ms
findMany — 10 users → posts → comments (L3) 52.77 ms 59.35 ms 52.38 ms
findUnique — single user by PK 47.66 ms 52.15 ms 47.78 ms
findUnique — user + posts + comments (L3) 51.71 ms 54.42 ms 52.47 ms
count — all users 44.57 ms 47.54 ms 46.75 ms
stream — iterate 50K rows (batch 1000) 3,207 ms 3,099 ms 4,620 ms
atomic increment — view_count + 1 49.76 ms 49.09 ms 46.25 ms
pipeline — 5-query batch 318 ms 327 ms 316 ms

Against a real pooled database, most single-query scenarios are within noise — network round-trip to Neon is ~33–40 ms, which swamps per-query CPU overhead. But a few results stand out:

  • L2 nested reads. Turbine and Prisma are neck-and-neck (~56 ms), while Drizzle is 1.59× slower (89 ms) on the 50-user + posts scenario. Turbine's json_agg approach and SQL template caching pay off here.
  • Streaming 50K rows. Turbine's optimized streaming (speculative first fetch + batch size 1000) matches Prisma at ~3.1–3.2 s. Drizzle's keyset pagination is 1.49× slower at 4.6 s. Turbine's cursor still gives you correctness on any orderBy and clean early-break semantics.
  • Pipeline batching puts 5 independent queries through a single round-trip using the Postgres extended-query pipeline protocol — all three ORMs are tied here since each runs 5 queries sequentially in a transaction.

Performance is at parity with Prisma and Drizzle — the real reasons to choose Turbine are elsewhere: one dependency and no WASM engine (vs Prisma's 1.6 MB WASM query engine), the only read-only Studio in the TS ORM ecosystem, PII-safe error messages that never leak user data, and SQL-first migrations with SHA-256 drift detection. Deep type inference through with clauses works end-to-end: write db.users.findMany({ with: { posts: { with: { comments: true } } } }) and users[0].posts[0].comments[0].body autocompletes — no manual assertion, no helper annotation.

Full analysis with p50/p95/p99 and methodology notes: benchmarks/RESULTS.md. Reproduce: cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts

Quick Start

# 1. Install
npm install turbine-orm

# 2. Initialize project
npx turbine init --url postgres://user:pass@localhost:5432/mydb

# 3. Generate typed client from your database
npx turbine generate

Works with both ESM and CommonJS:

// ESM
import { turbine } from './generated/turbine';

// CommonJS
const { turbine } = require('./generated/turbine');

This introspects your database and generates a fully-typed client at ./generated/turbine/.

import { turbine } from './generated/turbine';

const db = turbine({ connectionString: process.env.DATABASE_URL });

// Type-safe queries with autocompletion
const users = await db.users.findMany({
  where: { role: 'admin' },
  orderBy: { createdAt: 'desc' },
  limit: 10,
});

await db.disconnect();

Usage Examples

findMany with nested relations

// Single query -- returns users with their posts and each post's comments
const users = await db.users.findMany({
  where: { orgId: 1 },
  with: {
    posts: {
      with: { comments: true },
      orderBy: { createdAt: 'desc' },
      limit: 5,
    },
  },
});

// users[0].posts[0].comments -- fully typed, single round-trip

findUnique

const user = await db.users.findUnique({
  where: { id: 42 },
  with: { posts: true },
});
// user.posts is Post[] -- resolved in the same query

Many-to-many relations

Turbine auto-detects pure junction tables during generate — a table whose primary key is exactly two single-column foreign keys and which carries no other columns (e.g. posts_tags(post_id, tag_id)). Both endpoints gain a many-to-many relation you can load like any other:

const posts = await db.posts.findMany({
  with: { tags: true }, // each post comes back with its tags array
});

// Nested where / orderBy / limit work on the m2m target too
const post = await db.posts.findFirst({
  where: { id: 1 },
  with: { tags: { where: { name: 'sql' }, orderBy: { name: 'asc' }, limit: 5 } },
});

A junction table that carries extra columns (a "payload") is treated as a first-class entity, so it stays an ordinary hasMany — that's by design. For those, or for any junction you want to wire up by hand, declare the relation explicitly in your code-first schema:

import { defineSchema } from 'turbine-orm';

export default defineSchema({
  posts: {
    id:    { type: 'serial', primaryKey: true },
    title: { type: 'text', notNull: true },
    manyToMany: [
      { name: 'tags', target: 'tags', through: 'postsTags',
        sourceKey: 'postId', targetKey: 'tagId' },
    ],
  },
  // ...tags and postsTags table definitions
});

sourceKey/targetKey are the junction columns referencing each side's primary key; add references if the source side is keyed on something other than id.

Self-relations

A self-referencing foreign key (e.g. categories.parent_id → categories.id) introspects to a belongsTo and a hasMany on the same table, so parent and child queries just work — including nested trees:

// A category with its parent and its children
const category = await db.categories.findFirst({
  where: { id: 2 },
  with: { parent: true, children: true },
});

// Walk a level deeper
const tree = await db.categories.findFirst({
  where: { id: 1 },
  with: { children: { with: { children: true } } },
});

When a table has a single self-referencing FK, Turbine auto-names the relations after the table: the belongsTo is named for the singular (category) and the hasMany for the table (categories). Rename them in your code-first schema if you prefer parent/children.

create

const newUser = await db.users.create({
  data: {
    email: 'alice@example.com',
    name: 'Alice',
    orgId: 1,
  },
});
// Returns the full row with generated id, createdAt, etc.

createMany (batch insert with UNNEST)

const users = await db.users.createMany({
  data: [
    { email: 'a@b.com', name: 'A', orgId: 1 },
    { email: 'b@b.com', name: 'B', orgId: 1 },
    { email: 'c@b.com', name: 'C', orgId: 1 },
  ],
});
// Single INSERT with UNNEST -- not 3 separate inserts

update / delete

const updated = await db.users.update({
  where: { id: 42 },
  data: { name: 'Alice Updated' },
});

const deleted = await db.users.delete({
  where: { id: 42 },
});

Atomic update operators

For race-free counter updates, pass an operator object instead of a literal. Turbine generates col = col + $n style SQL so concurrent updates are safe.

// Atomic increment — no read-modify-write race
await db.posts.update({
  where: { id: 1 },
  data: { viewCount: { increment: 1 } },
});

// Other supported operators on numeric columns
await db.posts.update({
  where: { id: 1 },
  data: {
    viewCount:  { increment: 5 },
    likesCount: { decrement: 1 },
    score:      { multiply: 2 },
    rank:       { divide: 2 },
    title:      { set: 'New title' }, // explicit set, equivalent to a literal
  },
});

Transactions

await db.$transaction(async (tx) => {
  const user = await tx.users.create({
    data: { email: 'new@example.com', name: 'New', orgId: 1 },
  });
  await tx.posts.create({
    data: { userId: user.id, orgId: 1, title: 'First Post', content: '...' },
  });
});
// Fully typed -- tx.users and tx.posts have the same API as db.users and db.posts

Pipeline (batch queries in one round-trip)

const [user, postCount, recentPosts] = await db.pipeline(
  db.users.buildFindUnique({ where: { id: 1 } }),
  db.posts.buildCount({ where: { orgId: 1 } }),
  db.posts.buildFindMany({ where: { userId: 1 }, limit: 5 }),
);
// 3 queries, 1 database round-trip

Raw SQL (tagged template)

const stats = await db.raw<{ day: Date; count: number }>`
  SELECT DATE_TRUNC('day', created_at) AS day, COUNT(*)::int AS count
  FROM posts WHERE org_id = ${orgId}
  GROUP BY day ORDER BY day
`;

Typed raw SQL (db.sql<T>)

db.sql<T> is the typed escape hatch: you supply the row shape and get a thenable query with .one() and .scalar() helpers. Every ${value} is bound as a $N parameter — never interpolated — so injection isn't possible even with hostile input.

// Awaiting the query returns T[]
const users = await db.sql<{ id: number; name: string }>`
  SELECT id, name FROM users WHERE org_id = ${orgId}
`;

// .one() returns T | null
const user = await db.sql<{ id: number; name: string }>`
  SELECT id, name FROM users WHERE id = ${42}
`.one();

// .scalar() returns the first column of the first row, or null
const total = await db.sql<{ count: number }>`
  SELECT COUNT(*)::int AS count FROM users
`.scalar();

Reach for db.sql<T> when you want a hand-written query with a known return type; use db.raw when you don't need the typing or the helpers.

Case-insensitive search

const users = await db.users.findMany({
  where: {
    email: { contains: 'alice', mode: 'insensitive' },
  },
});
// Generates: WHERE email ILIKE '%alice%'

Streaming large result sets

// Stream rows using PostgreSQL cursors — constant memory, no matter how many rows
for await (const user of db.users.findManyStream({
  where: { orgId: 1 },
  batchSize: 500,       // internal FETCH batch size (default: 1000)
  orderBy: { id: 'asc' },
  with: { posts: true }, // nested relations work too
})) {
  process.stdout.write(`${user.email}\n`);
}

Uses DECLARE CURSOR under the hood — rows are fetched in batches on a dedicated connection, parsed individually, and yielded via AsyncGenerator. Safe to break early; the cursor and connection are cleaned up automatically.

Query timeout

const users = await db.users.findMany({
  where: { orgId: 1 },
  timeout: 5000, // 5 second timeout
});

Default limit

// Set a default limit for all queries on a model
const db = turbine({
  connectionString: process.env.DATABASE_URL,
  defaultLimit: 100,
});

Middleware

Middleware wraps every query. It runs after SQL generation, so it can observe what's about to execute (params.model, params.action, params.args), measure timing, and transform the result returned by next() — but it cannot change the query itself.

// Query timing
db.$use(async (params, next) => {
  const start = Date.now();
  const result = await next(params);
  console.log(`${params.model}.${params.action} took ${Date.now() - start}ms`);
  return result;
});

// Result transformation — redact a field on the way out
db.$use(async (params, next) => {
  const result = await next(params);
  if (params.model === 'users' && Array.isArray(result)) {
    for (const row of result as { email?: string }[]) row.email = '[redacted]';
  }
  return result;
});

Warning: params.args is a read-only snapshot — mutating it does not change the executed SQL. The query is fully built and parameterized before middleware runs.

Because middleware can't rewrite queries, cross-cutting filters like soft deletes belong in the query itself — either explicitly or via a small scoped helper:

import type { WhereClause } from 'turbine-orm';

// Explicit filter
const users = await db.users.findMany({ where: { deletedAt: null } });

// Scoped helper that always applies the filter
const activeUsers = (where: WhereClause<User> = {}) =>
  db.users.findMany({ where: { ...where, deletedAt: null } });

const rows = await activeUsers({ orgId: 1 });

Error handling

Turbine throws typed errors you can catch programmatically:

import { NotFoundError, ValidationError, TimeoutError } from 'turbine-orm';

try {
  const user = await db.users.findUniqueOrThrow({ where: { id: 999 } });
} catch (err) {
  if (err instanceof NotFoundError) {
    // err.code === 'TURBINE_E001'
    console.log('User not found');
  } else if (err instanceof TimeoutError) {
    // err.code === 'TURBINE_E002'
    console.log('Query timed out');
  } else if (err instanceof ValidationError) {
    // err.code === 'TURBINE_E003'
    console.log('Invalid query:', err.message);
  }
}

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).

Full reference with wrapPgError() translation, retry patterns for DeadlockError / SerializationFailureError, and safe vs verbose message modes: turbineorm.dev/errors.

groupBy with HAVING

groupBy aggregates rows by one or more columns. Add a having clause to filter the resulting groups by their aggregates. Every comparison value is parameterized.

// Users with more than one post
const prolific = await db.posts.groupBy({
  by: ['userId'],
  _count: true,
  having: { _count: { gt: 1 } },
});

// Groups whose summed view count clears a threshold
const popular = await db.posts.groupBy({
  by: ['published'],
  _sum: { viewCount: true },
  having: { viewCount: { _sum: { gte: 100 } } },
});

Filter on the group count with _count, or on a column aggregate with { column: { _sum | _avg | _min | _max: { ... } } }. Operators are gt, gte, lt, lte, in, and notIn (a bare number is shorthand for equality). having predicates combine with AND, and where filters rows before grouping while having filters groups after.

Multi-tenant queries with RLS session context

Set transaction-local Postgres settings (GUCs) so PostgreSQL Row-Level Security policies that call current_setting() filter rows for you. Pass sessionContext to $transaction, or use the $withSession shorthand.

// Postgres policy: USING (tenant_id = current_setting('app.current_tenant')::int)
const rows = await db.$transaction(
  async (tx) => tx.documents.findMany(),
  { sessionContext: { 'app.current_tenant': tenantId } },
);

// Shorthand for a single-purpose session
const rows2 = await db.$withSession(
  { 'app.current_tenant': tenantId },
  async (tx) => tx.documents.findMany(),
);

Each entry is applied as SELECT set_config(name, value, true) right after BEGIN, so the setting is scoped to the transaction and resets automatically on commit. Values may be strings, numbers, or booleans (coerced to strings). Invalid setting names throw ValidationError and roll the transaction back before any query runs.

Realtime with LISTEN/NOTIFY

Subscribe to a Postgres channel with $listen and publish to it with $notify. The handler receives the notification payload as a string.

const sub = await db.$listen('order_created', (payload) => {
  console.log('new order:', payload);
});

await db.$notify('order_created', JSON.stringify({ id: 1 }));

// Later, when you're done
await sub.unsubscribe();

$listen holds a dedicated connection open for the lifetime of the subscription, so it requires a real persistent pool — it is not available over serverless HTTP drivers. $notify is a single round-trip and works everywhere. Channel names are validated as plain identifiers; the payload is always bound as a parameter.

Vector search (pgvector)

Query a vector column for nearest neighbors. Requires the pgvector extension and a vector column on your table.

KNN ranking — order by distance to a query vector and take the closest rows:

const similar = await db.items.findMany({
  orderBy: { embedding: { distance: { to: queryVector, metric: 'cosine' } } },
  limit: 5,
});
// queryVector is a number[]; nearest-first by default (direction: 'desc' to invert)

Distance filter — keep only rows within a distance threshold:

const close = await db.items.findMany({
  where: { embedding: { distance: { to: queryVector, metric: 'l2', lt: 0.3 } } },
});

metric selects the pgvector operator: 'l2'<-> (Euclidean), 'cosine'<=> (cosine distance), 'ip'<#> (negative inner product). Distance filters accept lt, lte, gt, and gte. The query vector is always bound as $n::vector — never interpolated.

Note: pg has no built-in parser for the vector type, so a fetched vector column comes back as a string literal like '[1,2,3]' unless you register a parser (e.g. via pgvector's own client helpers). Querying by distance works regardless.

WHERE Operator Reference

Every operator supported by the where clause. Operators compose freely with AND, OR, NOT, and the relation filters some / every / none.

Equality

Operator Description Example
literal Implicit equality where: { email: 'a@b.com' }
equals Explicit equality where: { email: { equals: 'a@b.com' } }
not Inequality (or not: null for IS NOT NULL) where: { role: { not: 'admin' } }

Sets

Operator Description Example
in Match any value in the array where: { id: { in: [1, 2, 3] } }
notIn Match none of the values in the array where: { role: { notIn: ['banned', 'spam'] } }

Comparison

Operator Description Example
gt Greater than where: { score: { gt: 100 } }
gte Greater than or equal where: { score: { gte: 100 } }
lt Less than where: { score: { lt: 100 } }
lte Less than or equal where: { score: { lte: 100 } }

String

Operator Description Example
contains Substring match (LIKE %v%) where: { title: { contains: 'sql' } }
startsWith Prefix match (LIKE v%) where: { email: { startsWith: 'admin@' } }
endsWith Suffix match (LIKE %v) where: { email: { endsWith: '@acme.com' } }
mode: 'insensitive' Switch any string operator to ILIKE where: { title: { contains: 'SQL', mode: 'insensitive' } }

LIKE wildcards in user input are escaped automatically — %, _, and \ are treated as literals.

Relation filters

Filter parent rows by predicates against their related child rows. Available on hasMany and hasOne relations.

Operator Description Example
some At least one related row matches where: { posts: { some: { published: true } } }
every Every related row matches where: { posts: { every: { published: true } } }
none No related row matches where: { posts: { none: { published: false } } }

Array columns

Operators for Postgres array columns (text[], int[], etc.).

Operator Description Example
has Array contains the given element where: { tags: { has: 'sql' } }
hasEvery Array contains every element in the list where: { tags: { hasEvery: ['sql', 'postgres'] } }
hasSome Array contains at least one element from the list where: { tags: { hasSome: ['sql', 'mysql'] } }

Combinators

Operator Description Example
AND All sub-clauses must match where: { AND: [{ orgId: 1 }, { role: 'admin' }] }
OR Any sub-clause matches where: { OR: [{ role: 'admin' }, { role: 'owner' }] }
NOT Negate a sub-clause where: { NOT: { role: 'banned' } }

CLI

npx turbine <command> [options]

Commands:
  init                  Initialize a Turbine project (creates config, dirs, templates)
  generate | pull       Introspect database and generate TypeScript types + client
  push                  Apply schema-builder definitions to database
  migrate create <name>        Create a new SQL migration file
  migrate create <name> --auto Auto-generate from schema diff
  migrate up                   Apply pending migrations
  migrate down                 Rollback last migration
  migrate status               Show applied/pending migrations
  seed                         Run seed file
  status                       Show database schema summary
  studio                       Launch local read-only Studio web UI
  observe                      Launch local metrics dashboard (requires TURBINE_OBSERVE_URL)

Options:
  --url, -u <url>       Postgres connection string
  --out, -o <dir>       Output directory (default: ./generated/turbine)
  --schema, -s <name>   Postgres schema (default: public)
  --auto                Auto-generate migration from schema diff
  --dry-run             Show SQL without executing
  --verbose, -v         Detailed output

Schema-first workflow

Define your schema in TypeScript and push it to the database:

// turbine/schema.ts
import { defineSchema } from 'turbine-orm';

export default defineSchema({
  users: {
    id:        { type: 'serial', primaryKey: true },
    email:     { type: 'text', unique: true, notNull: true },
    name:      { type: 'text', notNull: true },
    orgId:     { type: 'bigint', notNull: true, references: 'organizations.id' },
    createdAt: { type: 'timestamp', default: 'now()' },
  },
});
npx turbine push --dry-run   # Preview SQL
npx turbine push             # Apply to database
npx turbine generate         # Regenerate typed client

Migration workflow

# Create a blank migration (write SQL manually)
npx turbine migrate create add_users_table

# Auto-generate migration from schema diff (compares defineSchema() vs live DB)
npx turbine migrate create add_email_index --auto
# -> Generates UP (ALTER/CREATE) and DOWN (reverse) SQL automatically

# Apply all pending migrations
npx turbine migrate up

# Rollback the last applied migration
npx turbine migrate down

# Check migration status (applied vs pending)
npx turbine migrate status

Studio

The only Postgres ORM with a Studio your DBA will approve. turbine studio launches a local, read-only web UI for exploring your database — no mutations, no writes, and since v0.19 no raw-SQL surface at all: every query is composed visually in the ORM and compiled by the same validated query builder your application uses.

DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio
# With flags
npx turbine studio --port 5173 --host 127.0.0.1 --no-open

Features

  • Query / Data / Schema tabs. Compose queries visually, browse rows, and inspect tables and relations.
  • ORM-native query composer. The Query tab builds a real findMany — drill into relations (with) to any depth, pick fields (select/omit), add filters (where), orderBy, and limit at every level — with a live TypeScript preview of the exact call to copy into your codebase.
  • Saved queries. Named builder queries persisted to .turbine/studio-queries.json — share them across runs without committing them.
  • Cmd+K command palette. Jump to any table, tab, or saved query in one keystroke.
  • Full-text search across rows. The Data tab supports substring search across every text column of the current table.

Security posture (read-only by design)

  • No SQL input surface. There is nothing to inject into — builder requests are validated identifier-by-identifier against the introspected schema, and every value is bound as a $N parameter.
  • Loopback by default (127.0.0.1) with a loud warning if you bind to a non-loopback address.
  • Per-process auth token — 24 random bytes of hex, stored in a SameSite=Strict HttpOnly cookie.
  • Every query runs inside BEGIN READ ONLY with a 30s transaction-local statement timeout (parameterized set_config). Writes are physically impossible at the transaction level.
  • Security headers on every response — CSP, X-Content-Type-Options, X-Frame-Options: DENY, Referrer-Policy: no-referrer — plus per-session rate limiting and cross-origin refusal.

Observability

Built-in query metrics with zero new dependencies. $observe buffers per-query timings in memory and flushes per-minute aggregates — count, avg, p50, p95, p99, and error count per model:action — to a _turbine_metrics table in a separate database, over its own 1-connection pool so metrics writes never contend with your application pool.

const handle = await db.$observe({
  connectionString: process.env.TURBINE_OBSERVE_URL!, // metrics DB (not your app DB)
  flushIntervalMs: 60_000, // default: 60s
  retentionDays: 30,       // default: 30 — older buckets are pruned on flush
});

// Later, to flush remaining metrics and close the metrics pool
await handle.stop();

$observe creates the _turbine_metrics table if it doesn't exist. Flushes are fire-and-forget (INSERT ... ON CONFLICT additive merge) and never throw into your application. If the TURBINE_OBSERVE_URL environment variable is set, the client starts observing automatically on construction — no code needed.

For your own instrumentation, subscribe to query events with $on('query') — each event carries sql, params, duration (ms), model, action, rows, timestamp, and error (if the query failed):

db.$on('query', (e) => {
  if (e.duration > 200) {
    console.warn(`slow query: ${e.model}.${e.action} (${e.duration.toFixed(1)}ms, ${e.rows} rows)`);
  }
});

View the collected metrics in a local dashboard:

TURBINE_OBSERVE_URL=postgres://... npx turbine observe
# Flags: --port (default 4984), --host (default 127.0.0.1), --no-open

Same security model as Studio: loopback binding by default, per-process random auth token in an HttpOnly cookie, CSP headers, and read-only access to the metrics table.

Serverless / Edge

Turbine's core is driver-agnostic: pass any pg-compatible pool to TurbineConfig.pool (or use the turbineHttp() factory) and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, Netlify Edge, or any other environment where a direct TCP connection is unavailable. No new dependencies — install whichever driver you already use.

Neon Serverless (HTTP / WebSocket)

// app/api/users/route.ts
import { Pool } from '@neondatabase/serverless';
import { turbineHttp } from 'turbine-orm/serverless';
import { schema } from '@/generated/turbine/metadata';

export const runtime = 'edge';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = turbineHttp(pool, schema);

export async function GET() {
  const users = await db.table('users').findMany({
    with: { posts: { with: { comments: true } } },
    limit: 10,
  });
  return Response.json(users);
}

Vercel Postgres

import { createPool } from '@vercel/postgres';
import { turbineHttp } from 'turbine-orm/serverless';
import { schema } from './generated/turbine/metadata.js';

const pool = createPool({ connectionString: process.env.POSTGRES_URL });
const db = turbineHttp(pool, schema);

Supabase (direct Postgres — no HTTP proxy needed)

import { TurbineClient } from 'turbine-orm';
import { schema } from './generated/turbine/metadata.js';

const db = new TurbineClient({
  connectionString: process.env.SUPABASE_DB_URL,
  ssl: { rejectUnauthorized: false },
}, schema);

Cloudflare Workers

import { Pool } from '@neondatabase/serverless';
import { turbineHttp } from 'turbine-orm/serverless';
import { schema } from './generated/turbine/metadata';

export default {
  async fetch(req: Request, env: Env) {
    const pool = new Pool({ connectionString: env.DATABASE_URL });
    const db = turbineHttp(pool, schema);
    const users = await db.table('users').findMany({ limit: 10 });
    return Response.json(users);
  },
};

Limitations on HTTP drivers

  • Streaming cursors (findManyStream) require DECLARE CURSOR, which most HTTP drivers don't support. Use findMany with limit + pagination instead.
  • LISTEN/NOTIFY is not available over HTTP.
  • Transactions work but hold an HTTP connection for their duration — keep them short.

When Turbine receives an external pool, db.disconnect() is a no-op: the caller owns the pool's lifecycle.

Configuration

Create turbine.config.ts in your project root (or run npx turbine init):

import type { TurbineCliConfig } from 'turbine-orm/cli';

const config: TurbineCliConfig = {
  url: process.env.DATABASE_URL,
  out: './generated/turbine',
  schema: 'public',
  migrationsDir: './turbine/migrations',
  seedFile: './turbine/seed.ts',
  schemaFile: './turbine/schema.ts',
};

export default config;

Priority order: CLI flags > environment variables (DATABASE_URL) > config file > defaults.

How It Works

Turbine resolves nested relations the same way Prisma 7 and Drizzle v2 do: correlated subqueries with json_agg + json_build_object, evaluated by PostgreSQL in a single round-trip. No N+1, no client-side stitching, no separate queries per relation. The with clause is fully type-inferred end-to-end — write db.users.findMany({ with: { posts: { with: { comments: { with: { author: true } } } } } }) and users[0].posts[0].comments[0].author.name autocompletes with zero manual annotation.

The query strategy is table stakes now. What isn't table stakes: the one-dependency, no-WASM footprint, the read-only Studio your DBA will approve, the error messages that never leak PII, and the SQL-first migrations with SHA-256 drift detection. See Why Turbine? for the full breakdown.

Type Mapping

Turbine maps Postgres types to TypeScript:

Postgres TypeScript Notes
int2, int4, float4, float8 number Standard numeric types
int8 / bigint number Values > Number.MAX_SAFE_INTEGER (2^53 - 1) are returned as string at runtime to avoid precision loss. This affects < 0.01% of use cases (auto-increment IDs, counts, etc. are all safe).
numeric, money string Arbitrary precision — kept as string to avoid JS float issues
text, varchar, uuid, citext string
timestamptz, timestamp, date Date
boolean boolean
json, jsonb unknown
bytea Buffer
Array types T[] e.g. _textstring[]

Comparison

Turbine Prisma Drizzle Kysely
Engine / runtime No engine binary (pg only) Client + 1.6 MB WASM engine No engine No engine
Runtime deps 1 (pg) @prisma/client + adapter 0 0
Main bundle (brotli) ~31 kB dominated by 1.6 MB WASM ~7 KB core small
Studio Read-only, 192-bit auth Full CRUD, cloud-hosted Paid tier None
Error PII safety Keys only by default Values in messages Raw pg errors Raw pg errors
Migrations SQL-first, SHA-256 checksums DSL-generated, shadow DB SQL or Drizzle Kit None
Edge runtime One import swap, ~22 kB brotli 1.6 MB WASM adapter Native Native
Pipeline batching Parse/Bind/Execute protocol Sequential in txn Sequential Manual
Typed errors isRetryable discriminant Error codes only None None
Nested relations 1 query, deep type inference 1 query, shallow inference 1 query, relations() re-declaration Manual (jsonArrayFrom)
Many-to-many Auto-detected from junctions Implicit/explicit Explicit relations() Manual joins
Vector search Built-in distance / KNN Preview / raw Extension API Manual
LISTEN/NOTIFY $listen / $notify None None None
Multi-DB PostgreSQL only PG, MySQL, SQLite, MSSQL PG, MySQL, SQLite PG, MySQL, SQLite

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 for performance numbers — most scenarios are within noise over a real pooled database.

Limitations

Turbine is focused and opinionated. Here's what it doesn't do:

  • PostgreSQL only. No MySQL, SQLite, or MSSQL. By design — going deep on one database enables the performance advantage and the edge-runtime story.
  • 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.
  • 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.

Examples

Feature demos

  • Thread Machine — HN clone rendered from a single findMany. 4-level object graph (stories → comments → replies → author), every property autocompletes through the chain
  • Streaming CSV — Export 100K orders + line items to CSV with constant memory. PostgreSQL cursors, live heap meter, nested with inside findManyStream
  • Clickstorm — Side-by-side atomic-increment vs read-modify-write load test. 10K concurrent clicks. The atomic path wins every time

Runtime targets

  • Next.js — Server-rendered app with nested relations, streaming, and live code demos
  • Neon Edge — Vercel Edge route handler talking to Neon over HTTP via @neondatabase/serverless
  • Vercel Postgres — Next.js app router route handler on @vercel/postgres
  • Cloudflare Worker — Worker fetch handler with pg over Cloudflare Hyperdrive
  • Supabase — Standalone script over the standard pg driver against Supabase

Guides

  • Quick Start — zero-to-first-query in five minutes
  • API Reference — every findMany / findUnique / create / update / delete option, the full operator table, and pipeline() semantics
  • Relations — deep with clause, nested options, relation filters (some / every / none), payload-size guidance
  • Transactions & Pipelines — isolation levels, nested SAVEPOINTs, retry loops for DeadlockError and SerializationFailureError
  • Schema & MigrationsdefineSchema(), auto-diff migrations, checksum validation
  • Serverless & Edge — Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase walkthroughs
  • CLI — every command, flag, and config option
  • Typed Errors — error code reference, wrapPgError() translation, retry patterns
  • Migrating from Prisma — API mapping table, side-by-side findMany, and notes on the differences

Requirements

  • Node.js >= 18.0.0
  • PostgreSQL >= 14
  • Works with both ESM (import) and CommonJS (require)

Contributing

Contributions are welcome. See CONTRIBUTING.md for development setup, the test strategy, and the PR checklist. The unit suite runs without a database:

npm install
npm run test:unit

Integration tests need a PostgreSQL instance via DATABASE_URL (see CONTRIBUTING.md for a one-command seeded setup).

License

MIT

About

Postgres-native TypeScript ORM. Single-query nested relations via json_agg. Streaming cursors, typed errors, edge runtime support. One runtime dependency.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors