instructions
Prisma Next — Queries (Postgres)
Load this guide when db.ts imports from @prisma next/postgres/runtime .
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/prisma/prisma-next itself, not from our server, so what you get is exactly what its author published. The ZIP holds the whole collection; take the folder skills/prisma-next-queries/postgres.md out of it for Prisma Next — Queries (Postgres).
Part of the collection prisma/prisma-next.
Copy the text of postgres.md into the instructions file your agent reads (CLAUDE.md, AGENTS.md, .cursor/rules, …). Written for AI coding agents in general (from where the project keeps it; we have not run it).
README
synced Oct 14, 2026Prisma Next — Queries (Postgres)
Load this guide when
db.tsimports from@prisma-next/postgres/runtime.
Shared concepts (result consumption, script teardown, cross-target pitfalls, capability gaps) live in SKILL.md.
Key Concepts
Postgres (postgres<Contract>(...) from @prisma-next/postgres/runtime):
db.orm.<Model>— ORM, PascalCase model name (db.orm.User). Fluent.where(...).select(...).orderBy(...).all(), fully typed againstContract. Default lane for CRUD with relations.db.sql.<table>— SQL builder, lowercase storage name (db.sql.user). Produces a plan executed viadb.runtime().execute(plan). Use when the ORM is too high-level — explicitJOIN, computed projections, set operations, window functions.
Reach for the ORM first; drop to db.sql when the ORM can't express the shape. Lane choice is local — one query function picks one lane, not the whole app.
Lane decision table:
| Need | Choose | Why |
|---|---|---|
| Standard CRUD with relations | ORM (db.orm.<Model>) | Highest ergonomics; fully typed; model-shaped. |
| Eager-load related records | ORM .include(...) | Composes with .where / .select / .orderBy / .take per branch. |
| Aggregate (count, sum, avg) | ORM .aggregate(...) | Typed result; works with grouping (.groupBy(...).aggregate(...)). |
INSERT ... RETURNING / UPDATE ... RETURNING typed result | ORM mutations (returns updated rows) or db.sql.<t>.insert(...).returning(...) | ORM returns inserted/updated rows; SQL builder exposes .returning(...) explicitly. |
Computed projection (e.g. ST_DistanceSphere(location, point) AS meters) alongside model fields | SQL builder (db.sql.<t>) | The ORM projects model fields; arbitrary expression projection is the SQL builder's seam. |
Complex JOIN, set operation, window function | SQL builder | The ORM doesn't express arbitrary joins. |
Postgres-specific feature (LATERAL, FILTER, custom aggregates) | SQL builder, falling back to extension operators when the extension provides them | DSL first; extensions can contribute operators (postgis, pgvector, cipherstash). |
Workflow — ORM reads
The concept: db.orm.<Model> returns a collection you compose method-by-method. Each call returns a new collection (immutable chaining); the terminal verb (.all() / .first() / .count() / .aggregate(...)) issues the query. Predicates are lambdas over a field proxy: u.field.<op>(value).
// src/queries/users.ts — one directory deep under src/, so the import is '../prisma/db'
import { db } from '../prisma/db';
// Find one record by primary key shorthand.
const user = await db.orm.User.first({ id: userId });
// Returns the full row or `null`.
// Find one matching a predicate.
const alice = await db.orm.User
.where((u) => u.email.eq('alice@example.com'))
.first();
// Find many with projection, sort, and limit.
const recentUsers = await db.orm.User
.select('id', 'email', 'createdAt')
.orderBy((u) => u.createdAt.desc())
.take(10)
.all();
Predicates (.where(...)) come in two forms:
// Lambda form — full expression power.
db.orm.User.where((u) => u.email.eq('alice@example.com'));
// Shorthand object form — equality on the named fields.
db.orm.User.where({ kind: 'admin' });
Operators on the field proxy include .eq, .neq, .lt, .lte, .gt, .gte, .like, .ilike, .in([...]), .isNull(), .isNotNull(). Extensions add target-specific operators on extension-typed columns (pgvector's .cosineDistance(...), postgis's .within(...) / .intersectsBbox(...) / .distanceSphere(...), cipherstash's .cipherstashEq(...) / .cipherstashGt(...) / …).
There is no .between(a, b) operator. Express ranges either as two chained .where(...) clauses (the idiomatic form — clauses AND-compose) or with the and(...) combinator inside one clause:
// Chained .where() — each clause AND-composes with the previous one.
await db.orm.Sale
.where((s) => s.day.gte(start))
.where((s) => s.day.lte(end))
.all();
// Equivalent with an explicit `and(...)` inside one clause.
import { and } from '@prisma-next/sql-orm-client'; // façade re-export pending — see *What PN doesn't do yet* in SKILL.md
await db.orm.Sale
.where((s) => and(s.day.gte(start), s.day.lte(end)))
.all();
The two forms emit the same SQL. Pick chained .where() when each clause adds a separate condition that reads as its own thought; pick and(...) when one logical predicate happens to have two parts and you want the visual grouping. Don't reach for a between helper — there isn't one.
Combinators (and, or, not) compose predicates, and relation predicates (.some(...), .none(...), .every(...)) recurse into a relation. These currently come from the internal @prisma-next/sql-orm-client package — see What Prisma Next doesn't do yet in SKILL.md:
import { and, or, not } from '@prisma-next/sql-orm-client';
await db.orm.User
.where((u) =>
and(
or(u.kind.eq('admin'), u.email.ilike('%@example.com')),
not(u.posts.none((p) => p.title.ilike('%draft%'))),
),
)
.all();
Sorting and pagination. .orderBy(...) accepts a single lambda or an array of lambdas (each calling .asc() / .desc() on a field). .take(n) limits; .skip(n) offsets.
await db.orm.Post
.where((p) => p.authorId.eq(userId))
.orderBy([(p) => p.createdAt.desc(), (p) => p.id.desc()])
.take(20)
.all();
Cursor pagination. Call .cursor({ field: lastValue }) after .orderBy(...) to resume from a known position. The cursor requires a prior orderBy — the type system enforces this. Direction (forward or backward) follows the sort: ascending order means "greater than the cursor value", descending means "less than".
const page1 = await db.orm.Post
.orderBy((p) => p.createdAt.desc())
.take(20)
.all();
const last = page1[page1.length - 1]!;
const page2 = await db.orm.Post
.orderBy((p) => p.createdAt.desc())
.cursor({ createdAt: last.createdAt })
.take(20)
.all();
Cursor keys must match fields in the active orderBy. For a composite orderBy, pass a value for each ordering column — a partial cursor seeks only on the columns you supply, which gives an incomplete keyset. An empty cursor object is a no-op: you get the unfiltered first page back.
.first() vs .first({ pk }) vs .all(). Use .first() for a single row (issues a LIMIT 1); use .first({ pk }) for primary-key lookups; reserve .all() for the genuine many case (no implicit LIMIT).
Workflow — Eager-loading relations (.include)
The concept: .include('<relation>', (branch) => branch.<chain>) adds a relation branch to the parent query. The branch is its own collection — compose .where / .select / .orderBy / .take on it just like the parent.
await db.orm.User
.select('id', 'email')
.include('posts', (post) =>
post
.select('id', 'title', 'createdAt')
.orderBy((p) => p.createdAt.desc())
.take(5),
)
.take(10)
.all();
// → Array<{ id, email, posts: Array<{ id, title, createdAt }> }>
Nested 1:N → 1:N includes (e.g. User → posts → comments) require the contract to advertise the lateral + jsonAgg capabilities for the active target. The Postgres adapter advertises both by default, so most apps get this for free; if the type system rejects a nested include with a missing capability error, route to prisma-next-contract to add the required capability declarations and use prisma-next-queries for query-shape guidance.
Workflow — ORM writes
// Create — returns the inserted row.
const user = await db.orm.User.create({ id, email, displayName, kind, createdAt });
// Create with selected return — narrows the return shape.
const summary = await db.orm.User
.select('id', 'email', 'kind')
.create({ id, email, displayName, kind, createdAt });
// Update by predicate.
await db.orm.User.where({ id }).update({ email: newEmail });
// Update with selected return.
await db.orm.User
.where({ id })
.select('id', 'email', 'kind')
.update({ email: newEmail });
// Delete by predicate.
await db.orm.User.where({ id }).delete();
// Upsert — typed by the create branch's shape.
await db.orm.User
.select('id', 'email', 'kind', 'createdAt')
.upsert({
create: { id, email, displayName, kind, createdAt: new Date() },
update: { email, displayName, kind },
});
The ORM returns inserted / updated rows by default. The .returning(...) selector lives on the SQL builder (next section), where you build a plan and execute it explicitly.
Workflow — Aggregates
const totals = await db.orm.User.aggregate((aggregate) => ({
totalUsers: aggregate.count(),
}));
const adminTotals = await db.orm.User
.where({ kind: 'admin' })
.aggregate((aggregate) => ({
adminUsers: aggregate.count(),
}));
// Group-by + aggregate.
const byKind = await db.orm.User
.groupBy('kind')
.having((having) => having.count().gte(minUsers))
.aggregate((aggregate) => ({
totalUsers: aggregate.count(),
}));
aggregate exposes .count(), .sum(field), .avg(field), .min(field), .max(field). Project the aggregates into named result keys; the result type narrows accordingly.
Aggregate nullability matches SQL semantics:
| Aggregate | Type | Empty result |
|---|---|---|
count() | number | 0 |
sum(field) | number | null | null (SQL SUM over zero rows is NULL) |
avg(field) | number | null | null |
min(field) | number | null | null |
max(field) | number | null | null |
This isn't a typing bug — it's faithful to what the database returns. Coalesce client-side when you want zero-fill:
const revenue = await db.orm.Sale
.where((s) => s.day.gte(start))
.aggregate((a) => ({ total: a.sum('amount') }));
// revenue.total: number | null
const safe = revenue.total ?? 0; // ← apply at the consumption site, not in the aggregate spec.
If ?? 0 is showing up on every aggregate, that's a signal you're calling sum (or peers) over potentially-empty filters — which is exactly when SQL returns NULL. The pattern is correct; the typing is honest.
Workflow — SQL builder (db.sql.<table>)
The concept: db.sql.<table> is a table-shaped builder that produces a plan. The plan is a serialisable description of the query (AST + parameters); you execute it through the runtime with db.runtime().execute(plan). The builder gives you the lanes the ORM doesn't express — explicit JOIN, arbitrary expression projection, target-specific operations through extension helpers — without dropping to raw SQL.
// src/queries/posts.ts — adjust the relative import to match file depth.
import { db } from '../prisma/db';
// Select with predicate and limit.
const plan = db.sql.post
.select('id', 'title', 'userId', 'createdAt')
.where((f, fns) => fns.eq(f.userId, userId))
.limit(limit)
.build();
const rows = await db.runtime().execute(plan);
The .where(...) callback receives (fields, fns) — fields is the field proxy (column references), fns is the operator namespace (fns.eq, fns.ne, fns.gt, …). Extensions inject extension-shaped helpers into the same fns namespace (fns.distanceSphere, fns.cosineDistance, etc.).
INSERT / UPDATE / DELETE with RETURNING
// Insert and return selected columns.
const plan = db.sql.user
.insert({ email })
.returning('id', 'email')
.build();
const [row] = await db.runtime().execute(plan);
// Update with predicate and returning.
const updatePlan = db.sql.user
.update({ email: newEmail })
.where((f, fns) => fns.eq(f.id, userId))
.returning('id', 'email')
.build();
const rows = await db.runtime().execute(updatePlan);
// Delete with predicate.
const deletePlan = db.sql.user
.delete()
.where((f, fns) => fns.eq(f.id, userId))
.build();
await db.runtime().execute(deletePlan);
.returning(...) requires the target adapter to advertise the returning capability. The Postgres adapter advertises it by default.
Computed projections and joins
// Project a computed expression alongside model fields.
const plan = db.sql.cafe
.select('id', 'name')
.select('meters', (f, fns) => fns.distanceSphere(f.location, point))
.orderBy((f, fns) => fns.distanceSphere(f.location, point), { direction: 'asc' })
.orderBy((f) => f.id, { direction: 'asc' })
.limit(limit)
.build();
const rows = await db.runtime().execute(plan);
// Self-join with an alias.
db.sql.post
.innerJoin(db.sql.post.as('p2'), (f, fns) => fns.ne(f.p1.userId, f.p2.userId))
// ...
.build();
Workflow — Transactions
The concept: db.transaction(fn) opens a transaction and passes a tx context to the callback. tx.orm and tx.sql mirror db.orm / db.sql but ride the same transaction; tx.execute(plan) executes a SQL-builder plan within it. The transaction commits on the callback's successful return and rolls back on any thrown error.
await db.transaction(async (tx) => {
const user = await tx.orm.User.create({ id, email });
await tx.orm.Post.create({ userId: user.id, title: 'hello' });
// SQL-builder plan inside the transaction.
const plan = tx.sql.post.update({ status: 'archived' })
.where((f, fns) => fns.lt(f.createdAt, cutoff))
.build();
await tx.execute(plan);
// If anything throws, all three operations roll back.
});
The callback's return value passes through db.transaction(...). Capture inserted ids out of the callback and use them downstream after commit.
Namespace-aware accessors
When the contract declares multiple namespaces, both db.sql and db.orm expose a namespace coordinate alongside the flat bare-name surface:
// db.sql.<namespace>.<table>
const plan = db.sql.public.users.select('id', 'email').build();
const authPlan = db.sql.auth.users.select('id', 'token').build();
await db.runtime().execute(plan);
// db.orm.<namespace>.<Model>
const user = await db.orm.public.User.create({ id: 1, email: 'a@x.io' });
const authUser = await db.orm.auth.User.create({ id: 2, token: 'tok' });
The flat db.sql.users / db.orm.User form still works when bare names are unique across all namespaces. When the same bare name appears in more than one namespace, use the coordinate form — both the type system and the runtime require it to resolve to the right table.
Cross-namespace relations (e.g. public.Profile → auth.User) follow the same .include() syntax; the ORM resolves the correct schema-qualified join automatically.
Common Pitfalls (Postgres)
- Reaching for the lower-level lane when the ORM would have done. The ORM covers most CRUD shapes; drop to
db.sqlonly for shapes the ORM can't express. Default to the ORM. - Using
.all()when you wanted one row..all()issues no implicit limit. Use.first()or.first({ pk }). - Coalescing
count()with?? 0"just in case".count()isnumber, notnumber | null— the runtime already substitutes0for the empty case. The?? 0belongs onsum/avg/min/max. - Reaching for
.between(a, b)on a field proxy. It doesn't exist. Either chain.where((m) => m.field.gte(a)).where((m) => m.field.lte(b))or useand(m.field.gte(a), m.field.lte(b))inside one.where()clause. - Importing
and/or/notfrom a Postgres façade subpath. The combinators currently live in@prisma-next/sql-orm-client— an internal package. See What Prisma Next doesn't do yet inSKILL.md. - Trying to
db.sql.from(tables.user). That surface does not exist. The builder is table-shaped:db.sql.<tableName>.select(...). There is nodb.schema.tableseither. - Trying to
db.execute(plan)directly. Plans execute through the runtime:db.runtime().execute(plan). Inside a transaction, usetx.execute(plan). - Setting
capabilities: { lateral: true }inprisma-next.config.ts.defineConfigdoes not takecapabilities. Capabilities are declared by the active adapter and become part of the emitted contract; the Postgres adapter advertiseslateral,jsonAgg, andreturningout of the box. Enable extension capabilities throughextensions: [...]in the config (seeprisma-next-contract). - Confabulating a
db.sql.raw(...), TypedSQL, or.stream()surface. None of those exist today. See What Prisma Next doesn't do yet inSKILL.md. - Mixing the ORM mutation return with
runtime.execute(plan). ORM terminals issue the query themselves and return rows.runtime.executeis for SQL-builder plans. - Top-N grouped queries written as
groupBy(...).aggregate(...).sort().slice()in JS. That's a fallback because the grouped collection doesn't expose.orderBy(...)/.take(...). Fine at small cardinalities; for large grouped result sets, drop todb.sql.<table>.
Reference Files
- Example queries under
examples/prisma-next-demo/src/orm-client/andexamples/prisma-next-demo/src/queries/— canonical ORM and SQL-builder shapes. - ORM client source under
packages/3-extensions/sql-orm-client/src/. - SQL builder source under
packages/2-sql/4-lanes/sql-builder/src/.
Checklist
- Chose the right lane (ORM by default;
db.sqlfor shapes the ORM doesn't express). - Used
.first()/.first({ pk })for single-row reads — not.all(). - Coalesced
sum/avg/min/maxresults with?? 0at the consumption site when zero-fill is desired — did NOT coalescecount(), which isnumber. - Expressed ranges as chained
.where(...)clauses or a singleand(...)clause — did NOT reach for a non-existent.between(...)operator. - For cursor pagination, used
.orderBy(...).cursor({ field: lastValue }).take(n).all()— did NOT hand-write a.where(p => p.field.lt(cursor))workaround when the.cursor()API serves the same purpose. - For ORM combinators, imported
and/or/notfrom the (currently internal)@prisma-next/sql-orm-clientand noted the façade gap to the user. - Executed SQL-builder plans via
db.runtime().execute(plan)(ortx.execute(plan)inside a transaction). - Wrapped multi-statement work in
db.transaction(async (tx) => { ... })where atomicity matters. - For top-N grouped aggregates at meaningful scale, dropped to
db.sql.<table>rather than JS-side sort + slice overgroupBy(...).aggregate(...). - Did NOT confabulate
db.sql.raw, TypedSQL,.stream(),db.batch,.between(...), acapabilitiesfield ondefineConfig, or adb.sql.from(tables.user)API — routed to What Prisma Next doesn't do yet /prisma-next-feedbackinstead.