Client
Learn how services/db.ts builds the Prisma client on the @prisma/adapter-pg driver adapter, why the connection pool is tuned for a remote pooler, and how the singleton survives dev hot reload.
services/db.ts is the only place the application constructs a Prisma client.
It does four things in one module: builds a PrismaPg driver adapter over DATABASE_URL, tunes the node-postgres pool for a remote serverless Postgres, extends the client with the soft-delete extension, and caches the instance on globalThis outside production.
Every server-side module imports the same db from ~/services/db. There is no second construction site, so there is no second pool and no client that quietly lacks the extension.
The client
import { PrismaPg } from "@prisma/adapter-pg"
import { PrismaClient } from "@prisma/client"
import { env } from "~/env"
import { createSoftDeleteExtension } from "~/lib/soft-delete-extension"
const adapter = new PrismaPg({
connectionString: env.DATABASE_URL,
keepAlive: true,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 30_000,
max: 25,
})
function createPrismaClient() {
return new PrismaClient({
adapter,
transactionOptions: {
maxWait: 15_000,
timeout: 30_000,
},
}).$extends(createSoftDeleteExtension())
}
const globalForPrisma = global as unknown as { db: ReturnType<typeof createPrismaClient> }
export const db = globalForPrisma.db || createPrismaClient()
if (process.env.NODE_ENV !== "production") globalForPrisma.db = dbThe real file carries a comment block above each pool setting explaining the measurement behind it; the code above is trimmed of those comments.
Litestore runs Prisma 7 with a driver adapter rather than the Rust query engine binary.
prisma/schema.prisma declares:
generator client { provider = "prisma-client-js" }There is no output, so the generated client lands in the default node_modules location.
Its datasource db block declares only provider = "postgresql" — no url. The connection string reaches the runtime through env.DATABASE_URL, which env.ts validates with z.string().url(), and reaches the Prisma CLI through prisma.config.ts.
Pool settings
Each of the four pool settings overrides a node-postgres default that is wrong for a remote pooler, and each was set against a measurement rather than picked as a round number.
keepAlive: true
pg does not enable TCP keepalive by default.
Against a remote pooler, NAT silently drops idle sockets, and every dropped socket becomes a hung render surfacing as a random ETIMEDOUT page crash.
The failure has this shape:
idle socket
↓
NAT drops it silently
↓
pool still believes the connection is good
↓
next query hangs
↓
ETIMEDOUT, on an unrelated pageThe page that crashes is whichever one happened to draw the dead connection, which is why the symptom looks random.
idleTimeoutMillis: 30_000
Recycle idle connections before the pooler kills them.
connectionTimeoutMillis: 30_000
pg's default is to wait forever.
On the machine this was tuned on, a bare SELECT 1 takes 2–8 seconds round-trip, so the connect timeout is deliberately generous. Slow-but-rendering beats a crash.
max: 25
node-postgres defaults to 10.
The admin task computation alone fans out around 90 queries in one Promise.all, and Inngest handlers, webhooks and deferred after() activity writes draw from the same pool in the same process.
25 keeps that fan-out to roughly four waves.
max: 25 is per process, not per deployment
The pool budget multiplies by the number of concurrent instances. On a serverless
platform that scales to many instances, 25 connections each can exceed a Postgres
pooler's total limit. Lower max or raise the pooler budget before scaling out.
Transaction defaults
transactionOptions sets:
maxWait: 15_000
timeout: 30_000So an interactive $transaction waits up to 15 seconds for a connection and may run for 30 seconds before Prisma aborts it.
These are the defaults for every db.$transaction call in the codebase. Individual calls can still override them.
The extension
createPrismaClient returns:
new PrismaClient(...).$extends(createSoftDeleteExtension())So db is an extended client, not a bare PrismaClient. The extension injects deletedAt: null into top-level reads for every model that has a deletedAt field. No other extension is applied.
Because the extended type is what modules consume, the transaction client type is derived from db rather than from Prisma.TransactionClient:
export type DbTransaction = Omit<
typeof db,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
>Two files build their own client and therefore run without the soft-delete extension:
prisma/seed.tsscripts/check-db-guarantees.ts
Both run as standalone processes outside the app, and both want to see rows exactly as they exist in the table. A guarantee audit that could not see archived rows would be auditing a filtered view of the database rather than the database.
The singleton
globalForPrisma.db is assigned only when NODE_ENV !== "production".
In development, Next.js hot reload re-evaluates modules on every edit. Without the global cache, each reload would construct another PrismaClient and another pool, and the database would run out of connections within a few saves.
In production the module is evaluated once per process, so the global assignment is skipped and db is a plain module-level constant.
The pool itself is created at module scope, outside createPrismaClient, so the adapter and its pool are shared even if the client were rebuilt.
Generating the client
bun run db:generateRuns prisma generate.
For schema location, the Prisma CLI reads prisma.config.ts, not the prisma block in package.json:
export default defineConfig({
schema: path.join("prisma", "schema.prisma"),
migrations: {
path: path.join("prisma", "migrations"),
seed: "bun prisma/seed.ts",
},
datasource: {
url: process.env.DATABASE_URL,
},
})The file calls dotenv's config() at the top before reading process.env.DATABASE_URL, so CLI commands pick up .env without a wrapper.
When you need to run generate
bun run db:migrate regenerates the client as part of prisma migrate dev, so running your own schema change never needs a separate generate step.
The case that does need it is a git pull that changed the schema without you running a migration: the checked-out schema has moved, and the generated client in node_modules has not.
The useful question when Prisma types disagree with the schema you are reading is:
Did this schema change come from my migration, or from someone else's?
If it came from someone else's, run db:generate.
Related
Schema
Learn the core Prisma models in schema.prisma, the conventions every model follows, and how to change the schema safely.
Migrations
Learn what the two committed migrations under prisma/migrations install, how db:migrate, db:deploy and db:reset differ, what prisma/seed.ts creates, and where README.md contradicts the scripts.
Deletion
Learn which eight read operations the global Prisma extension filters, how deletedAt: undefined opts a read back in, and how check-nested-soft-delete.ts enforces explicit filters on nested to-many reads.
Schema
Learn the core Prisma models in schema.prisma, the conventions every model follows, and how to change the schema safely.
Migrations
Learn what the two committed migrations under prisma/migrations install, how db:migrate, db:deploy and db:reset differ, what prisma/seed.ts creates, and where README.md contradicts the scripts.