Files
HealthLog/src/lib/db.ts
T

121 lines
4.6 KiB
TypeScript

import { Prisma, PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const DEFAULT_CONNECTION_BUDGET = 20;
const DEFAULT_POOL_TIMEOUT_SECONDS = 20;
const PG_BOSS_CONNECTIONS = 2;
function positiveInteger(raw: string | undefined): number | null {
if (!raw) return null;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function databaseUrlParameter(name: string): string | undefined {
const rawUrl = process.env.DATABASE_URL;
if (!rawUrl) return undefined;
try {
return new URL(rawUrl).searchParams.get(name) ?? undefined;
} catch {
return undefined;
}
}
/**
* Total PostgreSQL connections this process may own. Compose already exposes
* this as DB_CONNECTION_LIMIT through DATABASE_URL's `connection_limit`.
* DATABASE_POOL_MAX remains a backward-compatible explicit override.
*/
export function getConnectionBudget(): number {
const configured =
positiveInteger(process.env.DB_CONNECTION_LIMIT) ??
positiveInteger(process.env.DATABASE_POOL_MAX) ??
positiveInteger(databaseUrlParameter("connection_limit")) ??
DEFAULT_CONNECTION_BUDGET;
return Math.max(2, configured);
}
export function getPgBossPoolMax(): number {
return Math.min(PG_BOSS_CONNECTIONS, getConnectionBudget() - 1);
}
export function getPrismaPoolMax(): number {
return getConnectionBudget() - getPgBossPoolMax();
}
export function getPoolConnectionTimeoutMs(): number {
const seconds =
positiveInteger(process.env.DB_POOL_TIMEOUT) ??
positiveInteger(databaseUrlParameter("pool_timeout")) ??
DEFAULT_POOL_TIMEOUT_SECONDS;
return seconds * 1_000;
}
/**
* Per-session DB statement timeout in milliseconds.
*
* Without this a single pathological query or lock wait holds one of the
* (default 20) pool slots indefinitely; 20 such queries permanently exhaust
* the pool and every DB-backed route stops serving — the single highest-impact
* availability gap (A-1). `statement_timeout` caps how long the server runs one
* statement; `idle_in_transaction_session_timeout` reaps a connection wedged
* mid-transaction (a client that opened a tx and stalled). Both are applied at
* connection-establishment via the libpq `options` startup parameter, so every
* pooled session inherits them.
*
* Env-overridable via `DATABASE_STATEMENT_TIMEOUT_MS`. Set to `0` to disable
* (legacy unbounded behaviour). Default 60 s — bounds a genuinely runaway query
* (the A-1 goal: never hold a pool slot forever) while leaving ample headroom
* for the heaviest legitimate read, the live-aggregate analytics fallback on a
* coverage miss over a large account, which can run well past 15 s. Heavy admin
* one-shots (drain/backfill) can still dial their own timeout if needed.
*/
export function getStatementTimeoutMs(): number {
const raw = process.env.DATABASE_STATEMENT_TIMEOUT_MS;
if (raw !== undefined && raw !== "") {
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
}
return 60_000;
}
/**
* Build the libpq `options` startup string applying the session timeouts, or
* `undefined` when disabled (timeout 0). Passed straight through `PrismaPg` to
* the underlying `pg.Pool`, which forwards it as the connection's `options`
* startup parameter so every session is timeout-bounded from the first query.
*/
export function buildSessionOptions(): string | undefined {
const timeoutMs = getStatementTimeoutMs();
if (timeoutMs <= 0) return undefined;
return `-c statement_timeout=${timeoutMs} -c idle_in_transaction_session_timeout=${timeoutMs}`;
}
function createPrismaClient() {
const sessionOptions = buildSessionOptions();
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
max: getPrismaPoolMax(),
connectionTimeoutMillis: getPoolConnectionTimeoutMs(),
...(sessionOptions ? { options: sessionOptions } : {}),
});
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// Prisma's `InputJsonValue` requires an explicit index signature that
// typed application shapes (Zod-validated, hand-written interfaces)
// don't carry. Every JSON-column write would otherwise repeat the same
// `value as unknown as Prisma.InputJsonValue` escape hatch — this one
// helper centralises the cast so the WHY stays in a single place.
export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;