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
npm install turbine-orm # the ORM (one runtime dependency: pg)
npm install @batadata/serverless # only for the HTTP transport or telemetry2. 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/.
# 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` yourselfUse 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*Createand*Updateinput typesgenerated/turbine/metadata.ts: the runtimeSCHEMAobject you pass to a factorygenerated/turbine/index.ts: a typedturbine(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:
bata schema dump --branch main --json # the full schema
bata schema diff main feature-x --json # tables, columns, indexes and constraints that differWhile 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)
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.
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 typedIn 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.
bata keys create --app --name blog --project <project-id> \
--grant select:authors --grant select:posts --grant insert:postsWith 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.
| Capability | HTTP (turbineHttp + @batadata/serverless) | TCP (turbine({ connectionString })) |
|---|---|---|
findMany, findUnique, nested with reads | Yes | Yes |
Single-statement create, update, delete, upsert | Yes | Yes |
createMany, JSON path filters, aggregate, groupBy, explain() | Yes | Yes |
$notify | Yes | Yes |
$on('query') events and telemetry | Yes | Yes |
$transaction, db.transaction(), nested writes | No: clear error | Yes |
An upsert whose where does not carry create's key values | No: it runs in a transaction | Yes |
Streaming cursors (findManyStream) | No: clear error | Yes |
$listen | No: clear error | Yes |
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,$listenand 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). Recentpgversions warn thatrequirewill meanverify-fullin a future major. Pinsslmode=verify-fullwith a CA for the strongest check, or adduselibpqcompat=trueto keep libpq's meaning ofrequire.
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.
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();| Setting | Where it comes from |
|---|---|
BATA_API_KEY | A 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_ID | bata 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=off | Turns 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:
| Data | Shows up in |
|---|---|
| Query shapes: the parameterized SQL template, model, action, tag, calls, total/min/max time, rows and errors | The 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 errors | The 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. PassonErrorto 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:
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.
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:
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 nothingbata 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
| Package | Minimum for this guide |
|---|---|
turbine-orm | 0.79.1 (edge builds, raw SQL telemetry, $tag) |
@batadata/serverless | 0.8.0 (batadbTelemetry with unitOfWork) |
Turbine's changelog lists every release.
10. Migrating from Prisma or Drizzle
- Prisma: Migrating from Prisma covers the whole move: data
with
bata import, code withturbine migrate-from-prisma, then checks. - Drizzle: follow https://turbineorm.dev/migrate-from-drizzle. Your BataDB connection
string and
bata generateare the only BataDB-specific parts.