/** * @factory/database-client — TASK-0135 (Phase 15): the multi-client Postgres * routing helper (the spec's `prisma/clients.ts` `db.read()`/`db.write()`). * * Why a package: both `apps/api` and `apps/worker` compile with * `rootDir: "src"` (a bare tsc build), so a repo-root `prisma/clients.ts` * could not be imported by either app. This source-first workspace package * (the `@factory/*` pattern — main points at the TS source, no build step) * holds the shared routing logic instead; the layout note is recorded in * docs/development/perf-report.md. * * Contract (spec L161): * - writes → the PRIMARY client (`db.write()`); * - reads for search-indexing, tree loads, and listing endpoints → the * READER (`db.read()`): the replica client when `DATABASE_REPLICA_URL` * is configured, otherwise the primary (single-node dev/CI — there is * no replica there, so routing to it is a no-op); * - staleness: the replica LAGS the primary. Reader reads are acceptable * for tree/listing/search ONLY. They are NEVER acceptable for: * • permission decisions (a lagged membership/grant read is a * security bug — authorization must see the current state); * • optimistic-concurrency version checks — the `If-Match` path * (the block flush's page `version` CAS) reads the PRIMARY. * The helper enforces this by construction: `db.read()` is a read-side * convenience, not a general substitute — the hot-path call sites * (tree load, listing, search indexing) are the ONLY places wired to * it (see the write-then-read-your-write test in * tests/integration/replica-routing.integration.test.ts). * - transactions: `db.write().$transaction(...)` (or a `tx` object) is * ALWAYS the primary — a replica cannot serve a transaction's * snapshot. */ import { PrismaClient } from "@prisma/client"; export interface ReplicaEnv { /** Primary connection string (PgBouncer in production — transaction mode). */ databaseUrl: string; /** * Optional read-replica connection string. Empty/undefined → the reader * IS the primary (no replica in the deployment). */ databaseReplicaUrl?: string | undefined; /** * NOTE (Prisma 5.22): pool sizing AND log level are intentionally NOT * client options here. Prisma 5.x has no `connectionLimit`/`poolTimeout` * in `PrismaClientOptions` (that landed in Prisma 6) — the pool is owned * by PgBouncer (transaction mode, per-app `pool_size` = `DATABASE_POOL_SIZE`, * documented in infrastructure/kubernetes/postgres/pgbouncer + docs/ * development/perf-report.md), and the `log` level is configured by each * app's own client setup. The helper passes only the URLs. */ } export interface DatabaseClients { /** The PRIMARY client — all writes, permission reads, version checks. */ readonly primary: PrismaClient; /** The READER client — replica when configured, else the primary. */ readonly reader: PrismaClient; /** True when a distinct replica is in use (metrics/observability). */ readonly hasReplica: boolean; /** * `db.read()` — the replica-routed reader. See the staleness contract in * the module docs: tree/listing/search reads only. */ readonly read: () => PrismaClient; /** `db.write()` — the primary (writes + transactions). */ readonly write: () => PrismaClient; /** Disconnect every distinct client. */ disconnect(): Promise; } /** * Build the primary + reader clients. The replica client is created * EAGERLY (a broken `DATABASE_REPLICA_URL` should fail at boot, not on the * first tree load) but never connects unless used — `PrismaClient` is * lazy by default. */ export function createDatabaseClients(env: ReplicaEnv): DatabaseClients { const primary = new PrismaClient({ datasources: { db: { url: env.databaseUrl } } }); const replicaUrl = env.databaseReplicaUrl?.trim(); const replica: PrismaClient | null = replicaUrl ? new PrismaClient({ datasources: { db: { url: replicaUrl } } }) : null; return { primary, reader: replica ?? primary, hasReplica: replica !== null, read: () => (replica ?? primary), write: () => primary, async disconnect() { await primary.$disconnect(); if (replica) await replica.$disconnect(); } }; }