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 found

Composite 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

OperatorDescriptionExample
literalImplicit equalitywhere: { email: 'a@b.com' }
equalsExplicit equalitywhere: { email: { equals: 'a@b.com' } }
notInequality (or not: null for IS NOT NULL)where: { role: { not: 'admin' } }

Sets

OperatorDescriptionExample
inMatch any value in the arraywhere: { id: { in: [1, 2, 3] } }
notInMatch none of the valueswhere: { role: { notIn: ['banned', 'spam'] } }

Comparison

OperatorDescriptionExample
gtGreater thanwhere: { score: { gt: 100 } }
gteGreater than or equalwhere: { score: { gte: 100 } }
ltLess thanwhere: { score: { lt: 100 } }
lteLess than or equalwhere: { score: { lte: 100 } }

String

OperatorDescriptionExample
containsSubstring match (LIKE %v%)where: { title: { contains: 'sql' } }
startsWithPrefix match (LIKE v%)where: { email: { startsWith: 'admin@' } }
endsWithSuffix match (LIKE %v)where: { email: { endsWith: '@acme.com' } }
mode: 'insensitive'Switch any string operator to ILIKEwhere: { title: { contains: 'SQL', mode: 'insensitive' } }

LIKE wildcards in user input (%, _, \) are escaped automatically.

Array columns

OperatorDescriptionExample
hasArray contains elementwhere: { tags: { has: 'sql' } }
hasEveryArray contains every elementwhere: { tags: { hasEvery: ['sql', 'pg'] } }
hasSomeArray contains at least one elementwhere: { tags: { hasSome: ['sql', 'mysql'] } }
OperatorDescriptionExample
searchto_tsvector(col) @@ to_tsquery(query)where: { body: { search: 'postgres & orm' } }
configText 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.

OperatorDescriptionExample
someAt least one related row matcheswhere: { posts: { some: { published: true } } }
everyEvery related row matcheswhere: { posts: { every: { published: true } } }
noneNo related row matcheswhere: { 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.args is 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