Client
This page explains how Litestore builds its one Prisma client and why its connection pool is tuned the way it is.
Every database query in Litestore goes through one Prisma client. Read this page when you need to understand connection errors, change the pool size, or add a new place that talks to the database.
The application constructs a Prisma client in exactly one file, services/db.ts. That module does four things:
- It builds a
PrismaPgdriver adapter overDATABASE_URL. - It tunes the node-postgres pool for a remote serverless Postgres.
- It extends the client with the soft-delete extension.
- Outside production, it caches the instance on
globalThis.
Every server-side module imports the same db from ~/services/db. Because nothing else constructs a client, the app never opens a second pool, and no module ends up with a client that 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 = dbIn the real file, a comment above each pool setting explains the measurement behind it. The code above leaves those comments out.
Litestore runs Prisma 7 with a driver adapter instead of the Rust query engine binary.
prisma/schema.prisma declares the generator like this:
generator client { provider = "prisma-client-js" }The generator has no output, so Prisma writes the generated client to the default location in node_modules.
The datasource db block declares only provider = "postgresql" and has no url. At runtime, the app reads the connection string from env.DATABASE_URL, which env.ts validates with z.string().url(). The Prisma CLI reads it through prisma.config.ts.
Pool settings
Each of the four pool settings overrides a node-postgres default that doesn't suit a remote pooler. Each value was chosen from a measurement, not picked as a round number.
keepAlive: true
By default, pg doesn't turn on TCP keepalive.
With a remote pooler in between, NAT drops idle sockets without telling anyone. Each dropped socket then turns into a hung render, which shows up as a random ETIMEDOUT page crash.
The failure happens in this order:
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 get the dead connection. That is why the crashes look random.
idleTimeoutMillis: 30_000
This setting recycles idle connections before the pooler kills them.
connectionTimeoutMillis: 30_000
By default, pg waits forever for a connection.
On the machine where this was tuned, a bare SELECT 1 takes 2–8 seconds for the round trip, so the connect timeout is deliberately generous. A page that renders slowly is better than a page that crashes.
max: 25
node-postgres allows 10 connections by default.
The admin task computation alone sends around 90 queries in one Promise.all. Inngest handlers, webhooks and deferred after() activity writes also take connections from the same pool in the same process.
With 25 connections, that burst of 90 queries runs in roughly four waves.
max: 25 is per process, not per deployment
Each running instance opens its own pool, so the total number of connections grows with the number of instances. On a serverless platform that scales to many instances, 25 connections each can exceed your Postgres pooler's total limit. Lower max or raise the pooler's limit before you scale out.
Transaction defaults
transactionOptions sets these values:
maxWait: 15_000
timeout: 30_000An interactive $transaction waits up to 15 seconds for a connection. Once it starts, it may run for 30 seconds before Prisma aborts it.
Every db.$transaction call in the codebase uses these defaults. You can still override them on an individual call.
The extension
createPrismaClient returns this:
new PrismaClient(...).$extends(createSoftDeleteExtension())This means db is an extended client, not a bare PrismaClient. For every model that has a deletedAt field, the extension adds deletedAt: null to top-level reads. The client uses no other extension.
Modules work with the extended type, so the transaction client type comes from db instead of from Prisma.TransactionClient:
export type DbTransaction = Omit<
typeof db,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
>Two files build their own client, so they run without the soft-delete extension:
prisma/seed.tsscripts/check-db-guarantees.ts
Both run as standalone processes outside the app, and both need to see rows exactly as they are stored in the table. If the guarantee audit couldn't see archived rows, it would be checking a filtered view instead of the real database.
The singleton
The module assigns globalForPrisma.db only when NODE_ENV !== "production".
In development, Next.js hot reload runs your modules again after every edit. Without the global cache, each reload would create another PrismaClient with another pool, and the database would run out of connections after a few saves.
In production, each process runs the module only once. The global assignment is skipped there, and db is an ordinary module-level constant.
The module creates the pool at module scope, outside createPrismaClient. If the client were ever rebuilt, it would still share the same adapter and pool.
Generating the client
bun run db:generateThis command runs prisma generate.
To find the schema, the Prisma CLI reads prisma.config.ts. It ignores 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 it reads process.env.DATABASE_URL. That way, CLI commands load .env without a wrapper script.
When you need to run generate
bun run db:migrate runs prisma migrate dev, which regenerates the client for you. When you change the schema yourself, you never need a separate generate step.
You do need it after a git pull that changed the schema when you haven't run a migration. In that case, the schema file you checked out has changed, but the generated client in node_modules still matches the old one.
If Prisma's types disagree with the schema you are reading, ask yourself whether the change came from your own 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
See how Litestore creates, applies and resets database migrations, and what the seed script puts in a new database.
Deletion
See how Litestore hides deleted rows from reads and how you read them back when you need to.