Turbine + BataDB

From zero to typed queries on BataDB — a Postgres-wire-compatible serverless platform with instant branching and scale-to-zero. Because BataDB speaks the Postgres wire, Turbine runs against it with zero glue, and the same typed query code works whether you connect over a long-lived TCP pool or over SQL-over-HTTP from the edge.

1. Install

npm install turbine-orm            # the ORM — one runtime dep (pg)
npm install @batadata/serverless   # only for the edge / HTTP transport

2. Generate your client

Point Turbine at your direct BataDB endpoint and generate a fully-typed client. Grab the connection string from bata create … --json, the dashboard's Connect panel, or bata db url:

DATABASE_URL="postgresql://cloud_admin:…@cp-XXXX.us-east-1.db.batadata.com:5432/postgres?sslmode=require" \
  npx turbine generate

This introspects your schema over the Postgres wire and writes typed accessors to ./generated/turbine/ (types.ts, metadata.ts, index.ts).

Generation reads the catalog over TCP, so the branch's compute must be running. bata generate wraps the same command; re-run it (or --watch) whenever your schema changes.

3. Connect (Node.js / long-running server)

For traditional Node.js servers, Express, Fastify, or any worker or job — Turbine owns the pool and you get the full feature set: nested json_agg reads, transactions, streaming cursors, LISTEN/NOTIFY, pgvector, RLS.

import { turbine } from './generated/turbine';
 
const db = turbine({
  connectionString: process.env.DATABASE_URL,
});
 
const authors = await db.authors.findMany({
  where: { active: true },
  with: { posts: { orderBy: { views: 'desc' }, limit: 5 } }, // one query, no N+1
  limit: 20,
});

The generated turbine() factory returns a typed TurbineClient subclass — every column, operator, and relation is autocompleted and inferred.

4. Connect (Serverless / Edge)

For Vercel Edge, Cloudflare Workers, Deno Deploy, or any serverless runtime, use SQL-over-HTTP via @batadata/serverless. Its Pool is a PgCompatPool (conformant since 0.3.0), so turbineHttp(pool, SCHEMA) binds it with no adapter code:

import { Pool } from '@batadata/serverless';
import { turbineHttp } from 'turbine-orm/serverless';
import { SCHEMA } from './generated/turbine/metadata';
 
const pool = new Pool({
  apiKey: process.env.BATA_API_KEY,      // bata_… — routes + authorizes the request
  projectId: process.env.BATA_PROJECT_ID,
  branchId: process.env.BATA_BRANCH_ID,  // optional — defaults to the primary branch
});
 
const db = turbineHttp(pool, SCHEMA);
 
// Same API — including the nested json_agg read — works unchanged over HTTP:
const authors = await db.authors.findMany({
  where: { name: 'Ada Lovelace' },
  with: { posts: true },
});
// authors[0].posts[0].title  ← fully typed

5. Migrations

SQL-first migrations, no shadow database and no proprietary schema DSL:

# Create a migration
npx turbine migrate create add_authors_table
 
# Apply migrations
DATABASE_URL="…" npx turbine migrate up
 
# Check status
npx turbine migrate status

Pooled vs. direct endpoint, SSL

  • …db.batadata.com — direct endpoint, one connection per client. Use it for turbine generate and for the Node.js / worker path above.
  • -pooler.db.batadata.com — pooled endpoint (PgBouncer). Use it for many short-lived connections or serverless that bypasses the HTTP driver.
  • Always use TLS. sslmode=require is the minimum; pin sslmode=verify-full (with a CA) for the strongest guarantee, or uselibpqcompat=true&sslmode=require for libpq parity. Recent pg versions warn that require will adopt verify-full semantics in a future major.

Caveats over HTTP

SQL-over-HTTP is stateless — one transaction per request — so anything that needs a held session is unavailable on the HTTP transport. Turbine fails loud with a typed error rather than running non-atomic SQL:

CapabilityEdge / HTTP (turbineHttp)Direct (turbine({ connectionString }))
findMany / findUnique / nested with (json_agg)
create / update / delete / upsert (single statement)
aggregate / groupBy
$transaction / db.transaction()✗ → typed error
Streaming cursors (findManyStream)✗ → typed error
$listen / $notify (LISTEN/NOTIFY)✗ → typed error

Run transactional work, streaming, and realtime on a server or worker over the direct connection; serve reads and single-statement writes from the edge over HTTP.

Why Turbine on BataDB?

  • No WASM engine — ~22 kB brotli on the edge, fits Cloudflare Workers' 1 MB limit (Prisma: 1.6 MB WASM)
  • Single-query nested reads — one SQL statement via json_agg, not N+1
  • Pipeline batching — N queries in 1 round-trip, which matters most on high-latency serverless
  • No adapter layerturbineHttp(pool, SCHEMA) with any pg-compatible driver, including @batadata/serverless
  • SQL-first migrations — no shadow database, no proprietary schema DSL

BataDB also offers scale-to-zero and instant branching. Turbine's pipelining and single-query nested reads cut the number of round trips a request makes — and fewer round trips pairs naturally with wake-on-connect, since a cold compute pays its wake cost once rather than once per query. (Branching is a BataDB feature; Turbine just runs the same typed code against whichever branch your connection string points at.)

Next steps