Turbine

Framework recipes

Create one client per process and call it from your handlers. These recipes show the wiring.

Next.js, singleton client + Route Handler#

Next.js hot reload re-evaluates modules in development, which would open a new pool on every save. Keep the client on globalThis:

// lib/db.ts
import { turbine } from '@/generated/turbine';
 
const globalForDb = globalThis as unknown as {
  db?: ReturnType<typeof turbine>;
};
 
export const db =
  globalForDb.db ?? turbine({ connectionString: process.env.DATABASE_URL });
 
if (process.env.NODE_ENV !== 'production') globalForDb.db = db;

Import that db in any Route Handler. Do not call db.disconnect() per request; the pool is shared.

// app/api/users/route.ts
import { db } from '@/lib/db';
 
export async function GET(req: Request) {
  const limit = Number(new URL(req.url).searchParams.get('limit') ?? 20);
 
  const users = await db.users.findMany({
    orderBy: { createdAt: 'desc' },
    limit,
    with: { posts: { limit: 5 } },
  });
 
  return Response.json(users);
}
 
export async function POST(req: Request) {
  const body = await req.json();
 
  const user = await db.users.create({
    data: { email: body.email, name: body.name },
  });
 
  return Response.json(user, { status: 201 });
}

On an edge runtime, bind an HTTP pool with turbineHttp instead. See Serverless & Edge.

Express, request handler#

Create the client at startup and disconnect on shutdown. Pass errors to next(err) so one error handler maps Turbine's typed errors to status codes.

// server.ts
import express from 'express';
import { turbine } from './generated/turbine/index.js';
import { NotFoundError, UniqueConstraintError } from 'turbine-orm';
 
const db = turbine({ connectionString: process.env.DATABASE_URL });
const app = express();
app.use(express.json());
 
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await db.users.findUniqueOrThrow({
      where: { id: Number(req.params.id) },
      with: { posts: { limit: 10 } },
    });
    res.json(user);
  } catch (err) {
    next(err); // a NotFoundError becomes a 404 below
  }
});
 
app.post('/users', async (req, res, next) => {
  try {
    const user = await db.users.create({ data: { email: req.body.email, name: req.body.name } });
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});
 
// Map typed Turbine errors to HTTP status codes.
const onError: express.ErrorRequestHandler = (err, _req, res, _next) => {
  if (err instanceof NotFoundError) {
    res.status(404).json({ error: err.message });
  } else if (err instanceof UniqueConstraintError) {
    res.status(409).json({ error: err.message });
  } else {
    console.error(err);
    res.status(500).json({ error: 'Internal error' });
  }
};
app.use(onError);
 
const server = app.listen(3000);
 
// Graceful shutdown: drain the pool.
process.on('SIGTERM', async () => {
  server.close();
  await db.disconnect();
});

Multi-tenant isolation with Row-Level Security#

Put the tenant boundary in PostgreSQL, so a forgotten WHERE tenant_id = ... cannot leak data. You write the policy once and the database applies it to every query.

1. Define the policy#

Enable RLS and add a policy that reads the tenant from a transaction-local setting. current_setting(name, true) returns NULL, rather than an error, when the setting is absent.

-- migration: isolate documents by tenant
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON documents
  USING (tenant_id = current_setting('app.current_tenant', true)::int);

Connect as a role that RLS applies to. Superusers always bypass policies, and the table owner does too unless you run ALTER TABLE documents FORCE ROW LEVEL SECURITY.

A policy with only USING also checks rows you insert or update, so a new row needs the current tenant's tenant_id. Give the column a default such as current_setting('app.current_tenant')::int, or set it in data.

2. Set the tenant per request#

$withSession(context, fn) opens a transaction, sets each entry with set_config(name, value, true), then runs your callback. Every query inside sees only the tenant's rows, and the settings reset at commit or rollback.

import { db } from '@/lib/db';
 
async function listDocuments(tenantId: number) {
  return db.$withSession(
    { 'app.current_tenant': tenantId },
    (tx) => tx.documents.findMany(), // policy filters to this tenant
  );
}

$withSession is shorthand for $transaction(fn, { sessionContext }). Use the long form to add other transaction options:

await db.$transaction(
  async (tx) => {
    await tx.documents.create({ data: { title: 'Q3 report' } }); // tenant_id enforced by policy
    return tx.documents.findMany();
  },
  { sessionContext: { 'app.current_tenant': tenantId } },
);

Values may be strings, numbers or booleans. Names need a dot, like app.current_tenant. An invalid name, or undefined, throws ValidationError (E003) before a connection is taken.

3. Wire it into a handler#

Read the tenant from your auth layer and run the queries through the session:

// app/api/documents/route.ts
import { db } from '@/lib/db';
import { getTenantId } from '@/lib/auth';
 
export async function GET(req: Request) {
  const tenantId = await getTenantId(req); // from your session / JWT
 
  const docs = await db.$withSession(
    { 'app.current_tenant': tenantId },
    (tx) => tx.documents.findMany(),
  );
 
  return Response.json(docs);
}

See also#