From 5cbb08026212b39ba9a0fa803f69bed6b20e2dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 15:29:01 +0200 Subject: [PATCH] fix(db): share one Prisma client across the server bundles A production build loads src/lib/db.ts once per server bundle, and the client was only parked on globalThis outside production. The running process held three clients: about 28 MB of query-compiler state each on a heap capped near 520 MB, and three pg pools each sized to the full per-process connection budget. Park the client on globalThis in every environment so each copy of the module resolves the same one. --- src/lib/__tests__/db.test.ts | 31 ++++++++++++++++++++++++++++++- src/lib/db.ts | 13 +++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index baf6e00f0..8ed282b60 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -2,7 +2,7 @@ * Pool configuration stays unit-tested here so connection ceilings and wait * timeouts cannot silently fall back to separate library defaults. */ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildSessionOptions, @@ -129,3 +129,32 @@ describe("buildSessionOptions", () => { expect(buildSessionOptions()).toBeUndefined(); }); }); + +describe("process-wide Prisma client", () => { + const globalSlot = globalThis as unknown as { prisma?: unknown }; + + afterEach(() => { + vi.unstubAllEnvs(); + delete globalSlot.prisma; + vi.resetModules(); + }); + + it("shares one client across separate copies of the module in production", async () => { + // A production build loads this module once per server bundle; each copy + // must resolve the same client rather than constructing its own. + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv( + "DATABASE_URL", + "postgresql://healthlog:test@db:5432/healthlog?connection_limit=20", + ); + delete globalSlot.prisma; + + vi.resetModules(); + const first = (await import("../db")).prisma; + vi.resetModules(); + const second = (await import("../db")).prisma; + + expect(second).toBe(first); + expect(globalSlot.prisma).toBe(first); + }); +}); diff --git a/src/lib/db.ts b/src/lib/db.ts index 7b46e69ad..86b45c035 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -106,11 +106,16 @@ function createPrismaClient() { return new PrismaClient({ adapter }); } +// One client per process, in production too. A production build gives every +// server bundle its own copy of this module (the route handlers, the RSC/SSR +// renderer, and the instrumentation bundle that boots the workers), so a +// module-scoped client alone meant three of them: three query-compiler states +// of roughly 28 MB each on a heap that V8 caps near 520 MB in a 1 GiB +// container, and three pg pools each sized to the whole per-process +// connection budget above. Parking the client on `globalThis` makes every +// copy of the module resolve the same one. export const prisma = globalForPrisma.prisma ?? createPrismaClient(); - -if (process.env.NODE_ENV !== "production") { - globalForPrisma.prisma = prisma; -} +globalForPrisma.prisma = prisma; // Prisma's `InputJsonValue` requires an explicit index signature that // typed application shapes (Zod-validated, hand-written interfaces)