Skip to content
BataDB

DocsGuides

Turbine ORM

Use the Turbine ORM with BataDB over TCP or edge HTTP, with query telemetry.

Turbine ORM is a typed Postgres ORM for TypeScript. BataDB is Postgres, so Turbine works against it with no adapter. You can connect two ways:

  • Over TCP, from a server, worker or job. Turbine owns the connection pool.
  • Over HTTP, from an edge or serverless function, through @batadata/serverless.

Your query code is the same on both. Only the factory and the connection differ.

1. Install

Terminal
npm install turbine-orm            # the ORM (one runtime dependency: pg)
npm install @batadata/serverless   # only for the HTTP transport or telemetry

2. Generate the typed client

Point Turbine's generator at your branch's direct connection string. It reads your schema over the Postgres wire and writes a typed client to ./generated/turbine/.

Terminal
# Copy the direct string from `bata db url` or the console's Connect panel.
export DATABASE_URL="postgresql://<user>:<password>@<endpoint-id>.<region>.db.batadata.com:5432/<database>?sslmode=require&options=endpoint%3D<endpoint-id>"

bata generate          # runs `turbine generate`; or run `npx turbine generate` yourself

Use the connection string exactly as bata db url prints it. It logs in as your project's owner role.

This writes three files:

  • generated/turbine/types.ts: entity types, plus the *Create and *Update input types
  • generated/turbine/metadata.ts: the runtime SCHEMA object you pass to a factory
  • generated/turbine/index.ts: a typed turbine(config?) factory

bata generate needs turbine-orm installed in the project (or a global turbine on your PATH). It connects over TCP, so an idle branch wakes on the first connection. Re-run it whenever your schema changes.

Inspect or diff a schema without a connection

bata schema dump returns a branch's live schema (tables, columns, types, indexes and constraints) as JSON over the API. An idle branch wakes for you. It is a quick way to check what a branch has before you regenerate, and to diff two branches in CI:

Terminal
bata schema dump --branch main --json            # the full schema
bata schema diff main feature-x --json           # tables, columns, indexes and constraints that differ

While the branch's compute wakes, both exit 6 (COMPUTE_STARTING). Retry in a few seconds.

To fail a build on a breaking migration, use bata migrate check <file.sql> --project <id>. It scores the change against live query traffic and exits 2 when the change is breaking or cannot be assessed. See bata migrate check.

3. Connect from a server (TCP)

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

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

const authors = await db.table('authors').findMany({
  where: { active: true },
  with: { posts: { orderBy: { views: 'desc' }, limit: 5 } }, // one query, no N+1
  limit: 20,
});

Over TCP everything works: nested reads, transactions, streaming cursors, $listen, and any extension you enable (vector, for example). $listen and other session features need the direct endpoint. For ordinary app traffic, the pooled endpoint is fine (see section 6).

4. Connect from the edge (HTTP)

@batadata/serverless implements Turbine's PgCompatPool interface, so turbineHttp(pool, SCHEMA) binds it with no adapter code. Use it in Vercel, Cloudflare or Deno edge functions.

The Pool retries while an idle branch wakes. When the API answers 503 COMPUTE_STARTING, it backs off and tries again for up to 30 seconds by default (set wakeRetry to change that). Your first query against an idle branch returns a result instead of an error.

Edge builds need turbine-orm 0.79.1 or later. Earlier versions import pg from the serverless entry, so a Next.js edge route fails to build.

TypeScript
import { turbineHttp } from 'turbine-orm/serverless';
import { Pool } from '@batadata/serverless';
import type { TurbineClient } from './generated/turbine/index.js';
import { SCHEMA } from './generated/turbine/metadata.js';

// An app key is bound to one project and branch, so it needs nothing else.
const pool = new Pool({ apiKey: process.env.BATA_API_KEY });

const db = turbineHttp<TurbineClient>(pool, SCHEMA);

// The same query code, nested read included, runs over HTTP:
const authors = await db.authors.findMany({
  where: { name: 'Ada Lovelace' },
  with: { posts: true },
});
// authors[0].posts[0].title is fully typed

In a deployment, BATA_API_KEY should be an app key granted exactly the tables the app uses. It never expires, rotates without downtime, and Postgres runs its SQL as a role that holds only those grants. A plain sql key runs SQL as the project owner, so never deploy one. See App keys.

Terminal
bata keys create --app --name blog --project <project-id> \
  --grant select:authors --grant select:posts --grant insert:posts

With a key that is not an app key, pass projectId and branchId to the Pool too. The API refuses a query that names no branch.

You only need @batadata/serverless on an edge runtime. On a Node server, use the TCP client from section 3. It needs no API key.

5. What works over HTTP

SQL over HTTP is stateless: each query is its own request. Anything that needs a held session fails with a clear error (BATA_HTTP_NO_SESSION) instead of running SQL that is not atomic.

CapabilityHTTP (turbineHttp + @batadata/serverless)TCP (turbine({ connectionString }))
findMany, findUnique, nested with readsYesYes
Single-statement create, update, delete, upsertYesYes
createMany, JSON path filters, aggregate, groupBy, explain()YesYes
$notifyYesYes
$on('query') events and telemetryYesYes
$transaction, db.transaction(), nested writesNo: clear errorYes
An upsert whose where does not carry create's key valuesNo: it runs in a transactionYes
Streaming cursors (findManyStream)No: clear errorYes
$listenNo: clear errorYes

Run transactional work on a server or worker over TCP. Serve reads and single-statement writes from the edge over HTTP.

6. Pooled vs. direct, TLS

  • Direct (<endpoint-id>.<region>.db.batadata.com): one Postgres connection per client. Use it for migrations, bata generate, $listen and anything that holds session state.
  • Pooled (<endpoint-id>-pooler.<region>.db.batadata.com): connections are shared. Use it for app traffic and for serverless functions that connect over TCP.
  • Always use TLS (sslmode=require). Recent pg versions warn that require will mean verify-full in a future major. Pin sslmode=verify-full with a CA for the strongest check, or add uselibpqcompat=true to keep libpq's meaning of require.

See Connecting for the full picture.

7. Send query telemetry

batadbTelemetry(db) from @batadata/serverless sends your Turbine client's query telemetry to your BataDB project. It works with both clients above, because both emit Turbine's $on('query') events. It needs @batadata/serverless 0.8.0 or later.

TypeScript
import { batadbTelemetry } from '@batadata/serverless';

// `db` is either Turbine client from section 3 or 4.
const telemetry = batadbTelemetry(db);
// Reads BATA_API_KEY and BATA_PROJECT_ID, and optionally BATA_BRANCH_ID and
// BATA_API_URL. Or pass { apiKey, projectId, branchId } explicitly.

// Long-running server: send what is left on shutdown.
process.on('SIGTERM', () => telemetry.stop());
// Serverless function: send before the invocation ends.
// await telemetry.flush();
SettingWhere it comes from
BATA_API_KEYA telemetry-only app key for this project: bata keys create --app --scope telemetry:write --project <project-id>, or "Create telemetry key" on the Insights page's Performance tab. It can only post telemetry for that project, and it never expires.
BATA_PROJECT_IDbata projects list --json, or the project URL in the console
BATA_BRANCH_ID (optional)bata db branches --json. Attributes the data to one branch. Unset means project-wide.
BATA_API_URL (optional)Defaults to https://api.batadata.com
BATA_TELEMETRY=offTurns it off without a code change

Check that data is arriving with bata telemetry status.

Which key to use

The key sits in your app's environment, so treat it as something that can leak. A telemetry-only app key can post query shapes and latency for one project and nothing else. It cannot read data, run SQL, change settings or reach another project. A leaked one costs you noise in your charts. An admin key in the same place is an owner credential for every project on the team.

Make it an app key (--app). Ordinary keys expire (90 days by default), and an expired telemetry key means telemetry that silently stops. App keys never expire, and they rotate with an overlap (bata keys rotate <key-id> --overlap 24h), so the old secret works until the new one is deployed.

If the same app also queries over HTTP with Pool, one app key can do both: bata keys create --app --scope telemetry:write --project <project-id> --grant select:posts .... Or give each its own key and pass the telemetry key explicitly: batadbTelemetry(db, { apiKey: process.env.BATA_TELEMETRY_KEY }).

What it sends

Every 10 seconds, on a background timer:

DataShows up in
Query shapes: the parameterized SQL template, model, action, tag, calls, total/min/max time, rows and errorsThe Insights page (top queries, the ORM Insights tab), recommendations, bata migrate check and index trials
Latency per minute, per model and action: count, average, p50, p95, p99 and errorsThe Insights page's Performance tab: latency percentiles, throughput and error rate over time

What it guarantees:

  • It never breaks your app. The query listener only updates in-memory counters. Sends run on a background timer, one at a time, with a 10-second timeout each. Nothing is thrown into your code.
  • Bounded memory. A batch the API rejects (a 4xx, such as a wrong key or project id) is dropped, not retried forever. A network error, timeout or 5xx is retried on the next flush.
  • No parameter values leave your app. Only the parameterized template (... where id = $1) is sent. Bound values are never read.
  • A missing key or project id is not an error. The handle does nothing, and one warning names the missing variable. The first few distinct problems (a 401 from a bad key, say) are logged with console.warn. Pass onError to send them elsewhere, or () => {} to silence them.

It does not replace Turbine's own db.$observe(). If you already run one, it keeps working.

Next.js: use turbine-orm 0.79.1 or later. In 0.79.0 and earlier, the minifier in next build drops the error from the query event of raw SQL and $transaction statements, so failed queries are counted as successes. On those versions, add serverExternalPackages: ['turbine-orm'] to next.config.

Tag queries by feature

model and action say what ran, not why. Wrap a code path in Turbine's db.$tag() (turbine-orm 0.79 or later) and its queries are stored under that tag:

TypeScript
await db.$tag('checkout', async () => {
  const cart = await db.carts.findUnique({ where: { id } });
  return db.orders.create({ data: { cartId: cart.id } });
});

Filter on a tag with bata usage --by-query --tag checkout, with GET /v1/insights/<project-id>/top-queries?tag=checkout, or with the tag filter on the top queries table in the console.

Turbine 0.79 and later also report raw SQL (db.raw, db.sql, tx.raw, and prisma-compat $queryRaw and $executeRaw, stored as model $raw), every statement of a pipeline(), and every statement of $transaction([...]). Older versions report model methods only. Server-side query insights still see every query either way.

Group queries by request

telemetry.unitOfWork(label, fn) runs fn as one unit of work, such as a request or a job. BataDB can then see the queries of one request together: independent reads that ran one after another, the same lookup repeated in a loop, and the round trips each request costs.

TypeScript
app.get('/posts/:id', (req, res) =>
  telemetry.unitOfWork('GET /posts/:id', async () => {
    res.json(await db.posts.findUnique({ where: { id: req.params.id }, with: { comments: true } }));
  }),
);

It returns what fn returns and rethrows what it throws. If tracking fails, fn still runs, untracked.

8. Upload index advice from turbine doctor

turbine doctor reads your schema and statistics and suggests missing indexes. To keep its findings with your project, upload the report:

Terminal
bata doctor upload --project <project-id>             # run doctor on the branch and upload
bata doctor upload --project <project-id> --unused    # also report unused and redundant indexes
bata doctor upload --project <project-id> --dry-run   # print the report, upload nothing

bata doctor upload runs turbine doctor --json against the branch's direct connection and posts the report to POST /v1/projects/<project-id>/index-advice/import. Uploading needs an admin key, so keep that key in CI secrets, not in your app.

Re-uploading refreshes the numbers. A recommendation you dismissed stays dismissed. An open one that a later report no longer lists is marked resolved. List them with bata index advice, or GET /v1/projects/<project-id>/index-advice.

9. Versions

PackageMinimum for this guide
turbine-orm0.79.1 (edge builds, raw SQL telemetry, $tag)
@batadata/serverless0.8.0 (batadbTelemetry with unitOfWork)

Turbine's changelog lists every release.

10. Migrating from Prisma or Drizzle