API Reference
Every query method, operator, and API surface Turbine exposes. Generated clients attach db.<table> accessors with the full API below — the examples use db.users and db.posts but any introspected table works identically.
findMany
Returns an array of rows matching the query. Supports where, with, orderBy, limit / take, offset, cursor, distinct, select, and omit.
const users = await db.users.findMany({
where: { role: 'admin', orgId: 1 },
orderBy: { createdAt: 'desc' },
limit: 20,
offset: 0,
});With nested relations:
const users = await db.users.findMany({
where: { orgId: 1 },
with: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
limit: 5,
with: { comments: true },
},
},
});The entire object graph resolves in one SQL statement. users[0].posts[0].comments[0] is fully typed.
select / omit
Pick or drop columns at the query level. Either one — not both.
// Only return id and email
const users = await db.users.findMany({
select: { id: true, email: true },
});
// Return everything except the password_hash column
const users = await db.users.findMany({
omit: { passwordHash: true },
});Pagination — limit / take, offset, cursor
Offset pagination uses limit + offset. take is a Prisma-compatible alias for limit — when both are passed, take wins (take ?? limit):
const page2 = await db.posts.findMany({
orderBy: { id: 'asc' },
limit: 20, // or take: 20 — same thing
offset: 20,
});For deep pagination, cursor is the keyset alternative: pass the column values of the last row you saw, and Turbine adds a comparison per cursor field — column > value for ascending order, column < value for descending (taken from orderBy; defaults to ascending). The cursor row itself is excluded, so the next page starts immediately after it:
const firstPage = await db.posts.findMany({
orderBy: { id: 'asc' },
take: 20,
});
const nextPage = await db.posts.findMany({
orderBy: { id: 'asc' },
cursor: { id: firstPage.at(-1)!.id }, // rows with id > this value
take: 20,
});Multiple cursor fields combine with AND. Use a unique (or unique-in-combination) column in cursor + orderBy so the ordering is total — that's what makes keyset pagination skip-free and index-friendly at any depth, where OFFSET 100000 still scans the skipped rows.
distinct
distinct de-duplicates rows by the listed fields using PostgreSQL's DISTINCT ON:
// One post per author
const sample = await db.posts.findMany({
distinct: ['userId'],
orderBy: { userId: 'asc' },
});Per DISTINCT ON semantics, which row survives per group is governed by orderBy — sort by the distinct fields first, then by whatever should decide the winner within each group.
findUnique / findUniqueOrThrow
Look up a row by a unique column (primary key or any unique constraint).
const user = await db.users.findUnique({
where: { id: 42 },
with: { posts: true },
});
// user is User | null
const user2 = await db.users.findUniqueOrThrow({
where: { email: 'alice@example.com' },
});
// Throws NotFoundError (TURBINE_E001) if not foundComposite primary keys are passed as an object matching the keys:
const row = await db.memberships.findUnique({
where: { userId: 1, orgId: 2 },
});findFirst / findFirstOrThrow
First matching row by the given where + orderBy. Non-unique lookups.
const post = await db.posts.findFirst({
where: { authorId: 42, published: true },
orderBy: { createdAt: 'desc' },
});create
Insert a single row. Returns the full row (including generated columns).
const newUser = await db.users.create({
data: {
email: 'alice@example.com',
name: 'Alice',
orgId: 1,
},
});createMany
Batch insert with a single INSERT ... UNNEST(...) — not N separate inserts.
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 },
],
});update / updateMany
Update a single row by unique key, or every row matching a where.
await db.users.update({
where: { id: 42 },
data: { name: 'Alice Updated' },
});
await db.users.updateMany({
where: { role: 'guest' },
data: { role: 'member' },
});Atomic update operators
For race-safe counter updates, pass an operator object instead of a literal. Turbine generates col = col + $n style SQL so concurrent updates can't lose writes.
await db.posts.update({
where: { id: 1 },
data: {
viewCount: { increment: 1 },
likesCount: { decrement: 1 },
score: { multiply: 2 },
rank: { divide: 2 },
title: { set: 'New title' }, // explicit set — same as a literal
},
});Supported operators on numeric columns: set, increment, decrement, multiply, divide.
delete / deleteMany
await db.users.delete({ where: { id: 42 } });
await db.users.deleteMany({
where: { createdAt: { lt: thirtyDaysAgo } },
});Empty where (or where: {}) throws ValidationError — Turbine blocks accidental mass deletes. Pass { allowFullTableScan: true } if you really mean it.
upsert
Insert a row or update it if a row matching the where clause already exists. Uses PostgreSQL's INSERT ... ON CONFLICT ... DO UPDATE under the hood — atomic, no race conditions.
The where keys determine the conflict target (must be unique or primary key columns). If no matching row exists, create is inserted. If a row matches, update is applied.
const user = await db.users.upsert({
where: { email: 'alice@example.com' },
create: { email: 'alice@example.com', name: 'Alice', orgId: 1 },
update: { name: 'Alice Updated' },
});Returns the full row (via RETURNING *) whether it was inserted or updated.
// Upsert with a composite key
const membership = await db.memberships.upsert({
where: { userId: 1, orgId: 2 },
create: { userId: 1, orgId: 2, role: 'member' },
update: { role: 'admin' },
});count
const total = await db.users.count();
const admins = await db.users.count({ where: { role: 'admin' } });aggregate
const stats = await db.posts.aggregate({
where: { orgId: 1 },
_sum: { viewCount: true },
_avg: { score: true },
_max: { createdAt: true },
_count: true,
});groupBy
Group rows by one or more columns and compute aggregations per group. Similar to SQL GROUP BY with aggregate functions.
const postsByUser = await db.posts.groupBy({
by: ['userId'],
_count: true,
_sum: { viewCount: true },
_avg: { score: true },
});
// [{ userId: 1, _count: 12, _sum: { viewCount: 4800 }, _avg: { score: 4.2 } }, ...]Filtering groups
Pass a where clause to filter rows before grouping:
const activePostsByOrg = await db.posts.groupBy({
by: ['orgId'],
where: { published: true },
_count: true,
_max: { createdAt: true },
orderBy: { _count: 'desc' },
});Multiple group-by columns
const breakdown = await db.posts.groupBy({
by: ['orgId', 'published'],
_count: true,
_min: { createdAt: true },
_max: { createdAt: true },
});Supported aggregate functions: _count, _sum, _avg, _min, _max. When _count is true (or omitted), each group includes a _count field with the number of rows in that group.
Filtering groups with HAVING
A where clause filters rows before grouping. A having clause filters the resulting groups after — 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 row count with _count, or on a column aggregate with { column: { _sum | _avg | _min | _max: { ... } } }. The available operators are gt, gte, lt, lte, in, and notIn — a bare number is shorthand for equality. Multiple having predicates combine with AND:
// Groups with > 1 row AND a summed view count <= 500
const niche = await db.posts.groupBy({
by: ['userId'],
_count: true,
_sum: { viewCount: true },
having: { _count: { gt: 1 }, viewCount: { _sum: { lte: 500 } } },
});HAVING is emitted after GROUP BY and before ORDER BY, and its parameters continue the same numbering as any where params.
findManyStream
Stream rows using a PostgreSQL server-side cursor. Constant memory, works on any number of rows, and supports nested with clauses inside the stream.
for await (const user of db.users.findManyStream({
where: { orgId: 1 },
orderBy: { id: 'asc' },
batchSize: 1000, // internal FETCH batch size (default: 1000)
with: { posts: true }, // nested relations work inside the stream
})) {
process.stdout.write(`${user.email}\n`);
}Under the hood Turbine issues a speculative LIMIT batchSize + 1 first fetch — if the result fits in one batch, rows are yielded directly without DECLARE CURSOR overhead. Larger result sets escalate to a cursor. Safe to break early; the cursor and connection are cleaned up deterministically.
WHERE operators
Every operator composes freely with AND, OR, NOT, and relation filters.
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 | 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.
Array columns
| Operator | Description | Example |
|---|---|---|
has | Array contains element | where: { tags: { has: 'sql' } } |
hasEvery | Array contains every element | where: { tags: { hasEvery: ['sql', 'pg'] } } |
hasSome | Array contains at least one element | where: { tags: { hasSome: ['sql', 'mysql'] } } |
Full-text search
| Operator | Description | Example |
|---|---|---|
search | to_tsvector(col) @@ to_tsquery(query) | where: { body: { search: 'postgres & orm' } } |
config | Text search configuration (default 'english') | where: { body: { search: 'orm', config: 'simple' } } |
The query string is bound as a parameter and uses to_tsquery syntax (&, |, !, <->). The config name is validated (alphanumeric + underscore only) before it reaches the SQL.
Relation filters
Filter parent rows by predicates on their child rows.
| 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 } } } |
Combinators
where: {
AND: [{ orgId: 1 }, { role: 'admin' }],
OR: [{ role: 'owner' }, { role: 'admin' }],
NOT: { deletedAt: { not: null } },
}Nested with
Relations in a with clause accept their own where, orderBy, limit, select / omit, and nested with.
const users = await db.users.findMany({
with: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
limit: 10,
select: { id: true, title: true, createdAt: true },
with: {
comments: {
where: { deletedAt: null },
orderBy: { createdAt: 'asc' },
limit: 50,
},
},
},
},
});Nesting depth is capped at 10 — beyond that, Turbine throws CircularRelationError (TURBINE_E007) with the full relation path.
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: 'Hello', content: '...' },
});
});tx has the same typed table accessors as db. Nested $transaction calls create SAVEPOINTs automatically.
Isolation levels + timeout
await db.$transaction(
async (tx) => {
// ...
},
{
isolationLevel: 'Serializable',
timeout: 5000, // ms — destroys the connection on expiry
},
);Supported isolation levels: 'ReadCommitted', 'RepeatableRead', 'Serializable'.
Pipeline
Run N independent queries in a single database round-trip using the PostgreSQL extended-query pipeline protocol.
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 }),
]);The build* methods return DeferredQuery objects — generated SQL + params + a transform — without executing. pipeline() sends every statement in one TCP flush (parse/bind/execute/sync) and runs each transform on the result.
Non-transactional pipelines
By default, pipelines run inside a transaction — any failure rolls back the batch. Opt out for error-isolated execution:
try {
await db.pipeline(queries, { transactional: false });
} catch (err) {
if (err instanceof PipelineError) {
// err.results is a per-query [{status:'ok', value} | {status:'error', error}] array
// err.failedIndex / err.failedTag identify the first failure
}
}HTTP / serverless pools that don't support the pipeline protocol fall back to sequential execution automatically. Probe at runtime with db.pipelineSupported().
Middleware
db.$use(fn) registers a middleware that 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.argsis 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 });Raw SQL
When you need something the query builder doesn't expose (window functions, WITH RECURSIVE, lateral joins, etc.):
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
`;Parameters from ${} interpolations become $1, $2, ... — never string-interpolated into the SQL text.
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. Like db.raw, every ${value} is bound as a $N parameter — injection is impossible even with hostile input. Unlike db.raw, you choose the result shape and get the convenience helpers.
// 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 the first row or null:
const user = await db.sql<{ id: number; name: string }>`
SELECT id, name FROM users WHERE id = ${42}
`.one();
// user is { id: number; name: string } | null.scalar() returns the first column of the first row, or null. Pass a type argument to override the inferred value type:
const total = await db.sql<{ count: number }>`
SELECT COUNT(*)::int AS count FROM users
`.scalar();
// total is number | null
const name = await db.sql<{ name: string }>`
SELECT name FROM users LIMIT 1
`.scalar<string>();Reach for db.sql<T> when you want a hand-written query with a known return type and the .one() / .scalar() helpers; use db.raw when you don't need either.
See also
- Typed Errors — every error code and the retry patterns.
- Schema & Migrations — code-first schemas and SQL migrations.
- Benchmarks — the numbers behind the query planner.