Client
Every database query in Litestore goes through one Prisma client, and its setup matters when you debug connection errors or tune the pool.
Every database query in Litestore goes through one Prisma client. Read this page when you need to debug connection errors, change the pool size, or add a new place that talks to the database.
platform/db/client.ts
The application constructs a Prisma client in exactly one file, platform/db/client.ts. That module builds a PrismaPg driver adapter over DATABASE_URL and tunes the node-postgres pool for a remote serverless Postgres. It then 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 ~/platform/db/client. Since no other module constructs a client, the app only ever opens one pool, and every module gets a client with the extension applied.
import { PrismaPg } from "@prisma/adapter-pg"
import { PrismaClient } from "@prisma/client"
import { env } from "~/env"
import { createSoftDeleteExtension } from "~/platform/db/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. We picked the values based on measurements.
| Setting | What it does | pg default |
|---|---|---|
keepAlive: true | The pool keeps idle sockets alive so NAT doesn't drop them silently. | Off |
idleTimeoutMillis: 30_000 | The pool recycles idle connections before the pooler kills them. | — |
connectionTimeoutMillis: 30_000 | The pool gives up on a new connection after 30 seconds. | Waits forever |
max: 25 | The pool opens up to 25 connections per process. | 10 |
keepAlive
When there's a remote pooler between the app and the database, NAT can drop idle sockets without notifying either side. The next query on a dropped socket hangs, and the page eventually fails with an ETIMEDOUT error that looks random.
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 crash happens on whichever page happened to get the dead connection, which is why it seems to have no pattern.
Connection timeout
On the machine where we tuned the pool, a bare SELECT 1 takes 2–8 seconds for the round trip. We set a long timeout so that slow connections make a page render slowly instead of making it crash.
Connection pool size
The code comment sizes the pool for a fan-out of 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 maxWait: 15_000 and timeout: 30_000.
An interactive $transaction waits up to 15 seconds for a connection, and once it starts it can run for 30 seconds before Prisma aborts it. Every db.$transaction call in the codebase uses these defaults, and an individual call overrides them by passing its own values.
Soft-delete extension
createPrismaClient returns this:
new PrismaClient(...).$extends(createSoftDeleteExtension())This means db is an extended client rather than a plain 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"
>One other file builds its own client, so it runs without the soft-delete extension.
The seed runs as a standalone process outside the app and needs to read and write rows exactly as they're stored in the table, archived rows included.
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, so the module skips the global assignment and db is an ordinary module-level constant.
The pool is created at module scope, outside createPrismaClient, so if the client were ever rebuilt it would still share the same adapter and pool.
Client generation
bun run db:generateThis command runs prisma generate.
The Prisma CLI finds the schema through prisma.config.ts and 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, so CLI commands load .env without needing a wrapper script.
Related
Schema
The core Prisma models follow shared conventions, and the schema changes safely when you follow a few rules.
Migrations
Litestore creates, applies and resets migrations with Prisma, and the seed script fills a new database with demo rows.
Deletion
Litestore hides archived rows from reads, and a few explicit helpers read them back when needed.