name: prisma-next-runtime
description: Wire the Prisma Next runtime — db.ts setup using postgres<Contract>(...) from @prisma-next/postgres/runtime, sqlite<Contract>(...) from @prisma-next/sqlite/runtime, or mongo<Contract>(...) from @prisma-next/mongo/runtime; middleware composition (telemetry from @prisma-next/middleware-telemetry; lints and budgets), DATABASE_URL config, per-environment branching, switching between Postgres, SQLite, and Mongo façades. Use for db.ts, postgres(), sqlite(), mongo(), middleware, telemetry, lints, budgets, DATABASE_URL, .env, connection pool, poolOptions, dev vs prod config, transactions, db.transaction, read replicas, multi-database, script won't exit, hangs, close connection, db.end, db.close, pool.end, [Symbol.asyncDispose], await using.
Prisma Next — Runtime (db.ts Wiring)
Edit your data contract. Prisma handles the rest.
This skill covers the runtime entry point — db.ts — and how to compose the database client with extensions, middleware, and environment configuration.
When to Use
- User is wiring up
db.tsfor the first time (post-init). - User wants to add middleware (telemetry, lints, budgets, custom).
- User wants per-environment config (dev vs prod, multi-region).
- User wants to switch between the Postgres, SQLite, and Mongo façades.
- User wants to wrap operations in
db.transaction(...)(Postgres and SQLite). - User is running a one-off script (
tsx my-script.ts, Node CLI, CI task) and the process won't exit after queries finish, or they need script teardown (db.close(),await using). - User mentions: db.ts, postgres(), mongo(), middleware, telemetry, lints, budgets, DATABASE_URL, .env, connection pool, poolOptions, dev vs prod, transactions, read replicas, multi-database, script won't exit, hangs, db.close, db.end, close connection, pool.end, await using.
When Not to Use
- User wants to write queries →
prisma-next-queries. - User wants to edit the contract →
prisma-next-contract. - User wants to wire Prisma Next into a build tool (Vite plugin, Next.js, …) →
prisma-next-build. - User wants to debug a connection / runtime error →
prisma-next-debug. - User wants to file a bug or feature request →
prisma-next-feedback.
Key Concepts
db.tsis the runtime entry point. Imports the runtime factory from the@prisma-next/<target>façade (@prisma-next/postgres/runtime,@prisma-next/sqlite/runtime, or@prisma-next/mongo/runtime), the contract artefacts (contract.json+ theContracttype fromcontract.d.ts), and any middleware. Exports adbvalue the rest of your app imports.- The façade's runtime factory is the only surface user-authored
db.tsimports from. Each factory is a default export. For Postgres:import postgres from '@prisma-next/postgres/runtime'; SQLite:import sqlite from '@prisma-next/sqlite/runtime'; Mongo:import mongo from '@prisma-next/mongo/runtime'. The factory signature is<Target><Contract>(options)— a single type parameter (theContracttype fromcontract.d.ts), and one options object. - Lazy connect. The factory does not connect to the database synchronously. Static query surfaces (
db.sql,db.orm) are available immediately; the driver / pool is instantiated on the first call that needs a runtime (or when you explicitly callawait db.connect({ url })). This is whydb.tscan be imported in modules that load before the env is ready. - Middleware composes in order. The first middleware in the
middleware: [...]array runs outermost — it sees the operation first on the way in and last on the way out. Telemetry first means budget / lint failures show up inside telemetry spans. prisma-next.config.tsvs.env. The config (defineConfig({ contract, db, extensions, migrations })) is for static project shape: contract path, installed extensions, migrations directory, default connection string..envis for per-environment values (DATABASE_URL, secrets). The config reads.envautomatically viadotenv/config. HardcodingDATABASE_URLin the config file leaks credentials and bypasses per-env overrides.- Build-system / dev-server integration is a separate skill.
vite devauto-emit lives inprisma-next-build. The runtime side (this skill) readscontract.json/contract.d.tsregardless of how they got onto disk, so the two skills compose cleanly.
Workflow — Basic db.ts
The concept: db.ts is the seam between the emitted contract artefacts (target-shaped) and the runtime that executes queries against them. Three imports are load-bearing — the runtime factory, the Contract type (so the static query surfaces are typed), and the JSON artefact (so the runtime validates the structure at construct time).
init scaffolds something like this (for --target postgres):
// src/prisma/db.ts
import postgres from '@prisma-next/postgres/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL'],
});
(init currently scaffolds at prisma/db.ts instead — see TML-2532 in prisma-next-quickstart. The canonical path is src/prisma/db.ts; the rest of src/ imports from ./prisma/db or ../prisma/db depending on depth.)
Three things to know:
<Contract>type parameter is load-bearing. Without it, the static surfaces collapse to a generic shape and you lose autocomplete on model names. Always importContractfrom the emitted./contract.d.ts.with { type: 'json' }is required. Node's ESM JSON-import-attribute spec. Without it, the import errors.urlis optional at construct time. IfDATABASE_URLis not set whendb.tsloads, the factory still returns a client; you can callawait db.connect({ url })later. The factory throws lazily — only when a runtime is actually needed.
The Mongo façade has the same construction shape — import mongo from '@prisma-next/mongo/runtime' — and the same db.connect(...) / db.close() lifecycle methods. The Mongo façade does not expose db.transaction(...). See What Prisma Next doesn't do yet for the workaround. The ORM surface differs in one place: keys. On Mongo, db.orm is keyed by the collection's storage name (from @@map(...), or the lowercased model name if no @@map is set), not by the PSL model name — so model User { … @@map("users") } is reached at db.orm.users, not db.orm.User. The SQL builder lane (db.sql.<table>) doesn't exist on Mongo at all (db.sql is undefined). See prisma-next-queries § MongoDB ORM addressing for the full rule and a rewrite recipe for SQL-target examples.
Workflow — Running as a script (teardown)
The concept: short scripts that connect, query, then expect the process to exit will hang on Postgres because the façade-owned pg.Pool keeps Node's event loop alive. The data round-trip succeeds; the script never exits. Call await db.close() before the script returns (or use await using at the top of a script module so teardown runs when the module exits — see the block-scope warning below for why this matters).
Plain shape — export db from db.ts, import it in the script, close at the end:
// src/scripts/hello.ts
import { db } from '../prisma/db';
const created = await db.orm.User.create({ email: 'alice@example.com', name: 'Alice' });
const read = await db.orm.User.first();
console.log({ created, read });
await db.close();
TS 5.2+ idiomatic shape — construct the client at the top of a script module and let [Symbol.asyncDispose] call close() when the module exits:
// src/scripts/hello.ts — top-level await in a script module
import postgres from '@prisma-next/postgres/runtime';
import type { Contract } from '../prisma/contract.d';
import contractJson from '../prisma/contract.json' with { type: 'json' };
await using db = postgres<Contract>({ contractJson, url: process.env.DATABASE_URL! });
const user = await db.orm.User.first();
console.log(user);
// db.close() runs automatically when the script module exits.
await using is block-scoped — do not put it inside a request handler
This is the most important rule in this section. await using db = postgres(...) disposes when the enclosing block exits. In a script module, that block is the module body and disposal fires at process exit — fine. In a request handler, the enclosing block is the handler function, so disposal fires after every request — a fresh pg.Pool per call, TCP-connect storm, hot loop tearing connections up and down.
// DO NOT do this — closes the pool after every request.
app.get('/users', async (req, res) => {
await using db = postgres<Contract>({ contractJson, url: process.env.DATABASE_URL! });
const users = await db.orm.User.all();
res.json(users);
});
The right server pattern is a module-level singleton in db.ts, imported by handlers, never closed during the process lifetime:
// src/prisma/db.ts — constructed once, lives for the process
export const db = postgres<Contract>({ contractJson, url: process.env.DATABASE_URL });
// src/routes/users.ts
import { db } from '../prisma/db';
app.get('/users', async (req, res) => {
const users = await db.orm.User.all();
res.json(users);
});
Servers (HTTP handlers, workers in a request loop) do not call db.close() at all in steady state. The pool stays open for the process lifetime. db.close() and await using are for short-lived scripts — tsx my-script.ts, Node CLI commands, CI tasks, one-off seed runs — not for code that runs inside a request loop.
Semantics:
close()is idempotent. Calling it twice is a no-op.close()is terminal. There is no reconnect on a closeddb— construct a new client if you need another connection. After close,db.runtime(),db.connect(...),db.transaction(...), anddb.prepare(...)reject withError('<target> client is closed')(e.g.'Postgres client is closed','SQLite client is closed','Mongo client is closed').close()does not abort in-flight queries.awaitoutstanding work before callingclose(). Async iterators fromdb.runtime().execute(plan)andPreparedStatementhandles held afterclose()fail on their next call.- Ownership.
close()releases only what the façade constructed (pg.Poolfrom{ url },MongoClientfrom{ url }/{ uri, dbName }, SQLite handle from{ path }). If you supplied your ownpg.Pool/pg.Client(Postgrespg:option),mongodb.MongoClient(MongomongoClient:option), or a pre-builtbinding,db.close()does not touch those — you own their lifecycle.
db.end() does not exist. The universal node-postgres name is pool.end() on a pg.Pool; the Prisma Next runtime client is not a pg.Pool. The right call is await db.close().
Workflow — Telemetry middleware
The concept: telemetry middleware sees every operation and emits a structured event for each (start, success, error). Pair the events with your observability stack's collector.
import postgres from '@prisma-next/postgres/runtime';
import { createTelemetryMiddleware } from '@prisma-next/middleware-telemetry';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL'],
middleware: [
createTelemetryMiddleware({
onEvent: (event) => {
// forward to your collector, log, etc.
},
}),
],
});
createTelemetryMiddleware is shipped as a separate user-installable package (@prisma-next/middleware-telemetry), not as a /middleware subpath of the postgres façade. Install it directly. Run pnpm ls @prisma-next/middleware-telemetry to confirm it's on the lockfile.
Workflow — Lints and budgets middleware
The concept: lints catch authoring mistakes that survive type-check (e.g. DELETE without a WHERE, SELECT without a LIMIT on a large table); budgets enforce row-count and latency ceilings at runtime. Both surface findings through the structured-error envelope so an agent can branch on the code.
These ship in the underlying SQL runtime package (@prisma-next/sql-runtime) and are not yet re-exported from the postgres façade — see What Prisma Next doesn't do yet. The example apps under examples/prisma-next-demo/src/prisma/db.ts show the canonical import.
import postgres from '@prisma-next/postgres/runtime';
import { budgets, lints } from '@prisma-next/sql-runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL'],
middleware: [
lints({
severities: {
selectStar: 'warn',
noLimit: 'error',
deleteWithoutWhere: 'error',
updateWithoutWhere: 'error',
readOnlyMutation: 'error',
unindexedPredicate: 'warn',
},
}),
budgets({
maxRows: 10_000,
defaultTableRows: 10_000,
tableRows: { user: 10_000, post: 50_000 },
maxLatencyMs: 1_000,
severities: { rowCount: 'error', latency: 'warn' },
}),
],
});
For the full option surface, read the source: packages/2-sql/5-runtime/src/middleware/lints.ts and .../budgets.ts. The severities keys (selectStar, noLimit, deleteWithoutWhere, updateWithoutWhere, readOnlyMutation, unindexedPredicate for lints; rowCount, latency for budgets) are the source of truth; do not extrapolate to a key that ripgrep can't find.
Workflow — Compose multiple middleware
middleware: [
createTelemetryMiddleware({ onEvent }), // outermost — sees all sub-failures as inner errors
lints({ severities: { noLimit: 'error' } }),
budgets({ maxLatencyMs: 5_000 }), // innermost — runs closest to the driver
],
Order matters: outermost wraps. Telemetry first means budget / lint failures are captured as spans (the agent can correlate the lint code with the operation in the same trace).
Workflow — Configure the connection
The concept: the runtime takes one of three binding shapes — url, pg (a pre-constructed pg.Pool or pg.Client), or binding (an explicit kind tag). They're mutually exclusive. The pg form is for projects that already manage their own pool (e.g. a Lambda layer); url is the default. Pool tuning is poolOptions.connectionTimeoutMillis / poolOptions.idleTimeoutMillis — not driverOptions.
// Default — URL string, factory constructs the pool.
postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL'],
poolOptions: {
connectionTimeoutMillis: 20_000,
idleTimeoutMillis: 30_000,
},
});
// BYO pool — pass a pg.Pool you already created.
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
postgres<Contract>({ contractJson, pg: pool });
The url and pg keys are mutually exclusive at the type level; passing both errors.
DATABASE_URL lives in .env. The CLI reads it for emit / verify / migration commands; the runtime reads it through process.env at db.ts load time.
Workflow — Per-environment config (dev vs prod)
The concept: one DATABASE_URL per environment; the rest of the db.ts shape is the same. For middleware divergence (e.g. strict lints in dev only), branch in db.ts on process.env['NODE_ENV'].
const isProd = process.env['NODE_ENV'] === 'production';
export const db = postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL'],
middleware: isProd
? [createTelemetryMiddleware({ onEvent })]
: [
createTelemetryMiddleware({ onEvent }),
lints({ severities: { noLimit: 'error', deleteWithoutWhere: 'error' } }),
],
});
.env for local; the deploy platform's secrets for prod. Never commit .env.
Workflow — Transactions
The concept applies to Postgres and SQLite. db.transaction(fn) opens a transaction, gives the callback a tx context with the same sql / orm surfaces as db, and commits on successful return / rolls back on any thrown error. Inside the callback, use tx.sql and tx.orm instead of db.sql / db.orm so the writes ride the transaction. The Mongo façade does not expose db.transaction(...).
await db.transaction(async (tx) => {
const user = await tx.orm.User.create({ email: 'alice@example.com' });
await tx.orm.Post.create({ userId: user.id, title: 'hello' });
// If either call throws, both inserts roll back.
});
The callback returns whatever you return from it — the transaction wrapper passes it through. The tx object exposes execute(plan) for SQL-builder plans inside the transaction.
Workflow — Switch between Postgres, SQLite, and Mongo
The concept: the façade selection is baked into db.ts (@prisma-next/postgres, @prisma-next/sqlite, or @prisma-next/mongo) and prisma-next.config.ts (which defineConfig you import from). To switch a project's target, re-run prisma-next init in the same directory and pick the other target — the init flow detects the existing scaffold and prompts to reinit (--force skips the prompt). PN re-scaffolds prisma-next.config.ts and db.ts for the new façade. The contract source needs to be re-authored for the new target's idioms (Mongo expresses nested documents; Postgres/SQLite express relations).
After the switch (Mongo):
// src/prisma/db.ts (Mongo)
import mongo from '@prisma-next/mongo/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = mongo<Contract>({ contractJson, url: process.env['DATABASE_URL'] });
SQLite:
// src/prisma/db.ts (SQLite)
import sqlite from '@prisma-next/sqlite/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = sqlite<Contract>({ contractJson, path: 'app.db' });
path is optional at construct time (you can call db.connect({ path }) later); omit it and the façade still returns a client. The SQLite façade exposes the same db.sql, db.orm, db.transaction(...), db.close(), and [Symbol.asyncDispose] surfaces as Postgres. The Mongo façade shares db.orm, db.close(), and [Symbol.asyncDispose] but has no db.sql and no db.transaction(...).
The db.sql / db.orm surfaces stay the same in name; the operators each surface exposes are target-shaped (Mongo has no JOIN).
Workflow — Build-system / dev-server integration
If you want contract artefacts to re-emit automatically while the dev server is running (instead of running prisma-next contract emit by hand each time the contract source changes), reach for the build-tool plugin from prisma-next-build:
- Vite: install
@prisma-next/vite-plugin-contract-emitand registerprismaVitePlugin('prisma-next.config.ts')invite.config.ts. - Next.js, Webpack, esbuild, Rollup, Turbopack: no first-party plugin yet — the workaround is a
prebuildscript that runsprisma-next contract emit. Seeprisma-next-buildfor the walkthrough.
The runtime side (this skill) is the same regardless: db.ts reads contract.json + contract.d.ts from disk. The build-system plugin's job is to keep those files current during development.
Common Pitfalls
- Hardcoding
DATABASE_URLinprisma-next.config.ts. Leaks credentials; bypasses per-environment overrides. Use.env. - Omitting the
<Contract>type parameter inpostgres<Contract>(...). Without it, static surfaces collapse to a generic shape and you lose autocomplete for models. There is no second type parameter — the older two-param signature (postgres<Contract, TypeMaps>) is gone. - Forgetting
with { type: 'json' }on the contract import. Required by Node's ESM JSON-import-attribute spec. - Middleware order matters. Outermost wraps. Put telemetry first if you want it to capture inner-middleware errors.
- Importing middleware from a non-existent façade subpath.
@prisma-next/postgres/middlewaredoes not exist. Telemetry comes from@prisma-next/middleware-telemetry; lints / budgets come from@prisma-next/sql-runtimetoday (see What Prisma Next doesn't do yet). - Confabulating lint / budget option names. Lints take
severities(with the six keys above), notrequireWhere/maxRowsWithoutLimit. Budgets usemaxLatencyMs(notmaxDurationMs) plusmaxRows/defaultTableRows/tableRows. When in doubt, read the source. - Switching targets without re-emitting. The contract artefacts are target-shaped; emit after the target change.
- Script hangs after queries finish on Postgres. The
pg.Poolkeeps Node's event loop alive. Solution:await db.close()before the script returns, orawait using db = postgres<Contract>(...)at the top of a script module. Do not putawait using db = postgres(...)inside a request handler — it's block-scoped and would close the pool after every request. The right server pattern is a module-level singleton indb.tsthat lives for the process lifetime.
What Prisma Next doesn't do yet
@prisma-next/postgres/middlewaresubpath. The postgres façade re-exports the runtime factory (./runtime), config (./config), contract-builder (./contract-builder), control (./control), family (./family), target (./target), and serverless (./serverless) — but not middleware. Today's workaround: importlintsandbudgetsfrom@prisma-next/sql-runtime, andcreateTelemetryMiddlewarefrom@prisma-next/middleware-telemetry. File additional gaps you hit viaprisma-next-feedback.- Multi-database routing / read replicas. Prisma Next doesn't ship a built-in primary/replica router or shard-aware client. Workaround: configure separate
db.tsinstances per data store and call the right one in your application code. If you need first-class multi-database routing, file a feature request via theprisma-next-feedbackskill. - Connection pooling as a first-class config field.
poolOptions.connectionTimeoutMillisandpoolOptions.idleTimeoutMillisare wired through, but the rest ofpg.Pool's tuning surface (max connections,allowExitOnIdle, ssl options, …) is not exposed by name. Workaround: construct thepg.Poolyourself and pass it viapg:. If you need more pool fields surfaced on the façade, file a feature request via theprisma-next-feedbackskill. - Query logger middleware as a built-in. Prisma Next doesn't ship a "log every query" middleware. Workaround: write a small custom middleware that wraps each operation and logs; or use
createTelemetryMiddlewareand log inside theonEventcallback. If you need a built-in query log, file a feature request via theprisma-next-feedbackskill.
Reference Files
This skill is intentionally body-only; prisma-next init --help, the defineConfig factory in packages/3-extensions/postgres/src/config/define-config.ts, the postgres() factory in packages/3-extensions/postgres/src/runtime/postgres.ts, and the middleware sources in packages/2-sql/5-runtime/src/middleware/{lints,budgets}.ts are the authoritative surfaces for option-level detail. When in doubt, read the source.
Checklist
-
db.tsimports the runtime factory from@prisma-next/<target>/runtime(postgres,sqlite, ormongo) and theContracttype from./contract.d. -
with { type: 'json' }on the contract JSON import. -
<Contract>is the single type parameter onpostgres<Contract>(...)(no second parameter). -
DATABASE_URLlives in.env, not inprisma-next.config.ts. - Middleware ordered intentionally (telemetry outermost typically).
-
lints/budgetsuse the verified option keys (severities,maxLatencyMs,maxRows,tableRows). - Per-env divergence (if any) gated by
NODE_ENVor similar. - Did NOT hardcode credentials in any committed file.
- Did NOT confabulate a
@prisma-next/postgres/middlewaresubpath, a@prisma-next/postgres-extension-auditpackage, or a second type parameter onpostgres<...>. - Did NOT claim
db.transaction(...)exists on the Mongo façade — only Postgres and SQLite expose it. - Did NOT confabulate read-replica / multi-DB / extra pool config — pointed at What Prisma Next doesn't do yet and routed to
prisma-next-feedback. - For build-system / dev-server prompts (Vite plugin, Next.js plugin, …) routed to
prisma-next-build.