1/** 2 * @factory/database-client — TASK-0135 (Phase 15): the multi-client Postgres 3 * routing helper (the spec's `prisma/clients.ts` `db.read()`/`db.write()`). 4 * 5 * Why a package: both `apps/api` and `apps/worker` compile with 6 * `rootDir: "src"` (a bare tsc build), so a repo-root `prisma/clients.ts` 7 * could not be imported by either app. This source-first workspace package 8 * (the `@factory/*` pattern — main points at the TS source, no build step) 9 * holds the shared routing logic instead; the layout note is recorded in 10 * docs/development/perf-report.md. 11 * 12 * Contract (spec L161): 13 * - writes → the PRIMARY client (`db.write()`); 14 * - reads for search-indexing, tree loads, and listing endpoints → the 15 * READER (`db.read()`): the replica client when `DATABASE_REPLICA_URL` 16 * is configured, otherwise the primary (single-node dev/CI — there is 17 * no replica there, so routing to it is a no-op); 18 * - staleness: the replica LAGS the primary. Reader reads are acceptable 19 * for tree/listing/search ONLY. They are NEVER acceptable for: 20 * • permission decisions (a lagged membership/grant read is a 21 * security bug — authorization must see the current state); 22 * • optimistic-concurrency version checks — the `If-Match` path 23 * (the block flush's page `version` CAS) reads the PRIMARY. 24 * The helper enforces this by construction: `db.read()` is a read-side 25 * convenience, not a general substitute — the hot-path call sites 26 * (tree load, listing, search indexing) are the ONLY places wired to 27 * it (see the write-then-read-your-write test in 28 * tests/integration/replica-routing.integration.test.ts). 29 * - transactions: `db.write().$transaction(...)` (or a `tx` object) is 30 * ALWAYS the primary — a replica cannot serve a transaction's 31 * snapshot. 32 */ 33import { PrismaClient } from "@prisma/client"; 34 35export interface ReplicaEnv { 36 /** Primary connection string (PgBouncer in production — transaction mode). */ 37 databaseUrl: string; 38 /** 39 * Optional read-replica connection string. Empty/undefined → the reader 40 * IS the primary (no replica in the deployment). 41 */ 42 databaseReplicaUrl?: string | undefined; 43 /** 44 * NOTE (Prisma 5.22): pool sizing AND log level are intentionally NOT 45 * client options here. Prisma 5.x has no `connectionLimit`/`poolTimeout` 46 * in `PrismaClientOptions` (that landed in Prisma 6) — the pool is owned 47 * by PgBouncer (transaction mode, per-app `pool_size` = `DATABASE_POOL_SIZE`, 48 * documented in infrastructure/kubernetes/postgres/pgbouncer + docs/ 49 * development/perf-report.md), and the `log` level is configured by each 50 * app's own client setup. The helper passes only the URLs. 51 */ 52} 53 54export interface DatabaseClients { 55 /** The PRIMARY client — all writes, permission reads, version checks. */ 56 readonly primary: PrismaClient; 57 /** The READER client — replica when configured, else the primary. */ 58 readonly reader: PrismaClient; 59 /** True when a distinct replica is in use (metrics/observability). */ 60 readonly hasReplica: boolean; 61 /** 62 * `db.read()` — the replica-routed reader. See the staleness contract in 63 * the module docs: tree/listing/search reads only. 64 */ 65 readonly read: () => PrismaClient; 66 /** `db.write()` — the primary (writes + transactions). */ 67 readonly write: () => PrismaClient; 68 /** Disconnect every distinct client. */ 69 disconnect(): Promise<void>; 70} 71 72/** 73 * Build the primary + reader clients. The replica client is created 74 * EAGERLY (a broken `DATABASE_REPLICA_URL` should fail at boot, not on the 75 * first tree load) but never connects unless used — `PrismaClient` is 76 * lazy by default. 77 */ 78export function createDatabaseClients(env: ReplicaEnv): DatabaseClients { 79 const primary = new PrismaClient({ 80 datasources: { db: { url: env.databaseUrl } } 81 }); 82 const replicaUrl = env.databaseReplicaUrl?.trim(); 83 const replica: PrismaClient | null = replicaUrl 84 ? new PrismaClient({ 85 datasources: { db: { url: replicaUrl } } 86 }) 87 : null; 88 return { 89 primary, 90 reader: replica ?? primary, 91 hasReplica: replica !== null, 92 read: () => (replica ?? primary), 93 write: () => primary, 94 async disconnect() { 95 await primary.$disconnect(); 96 if (replica) await replica.$disconnect(); 97 } 98 }; 99} 100