import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from './schema'; const connectionString = process.env.DATABASE_URL!; // Connection pool for queries. // // `statement_timeout` and `idle_in_transaction_session_timeout` are set // per-connection via `connection.options` (postgres.js exposes the // startup-parameter list there). Without these, a slow query or a // transaction left open by a crashed handler holds a connection slot // indefinitely and eventually exhausts the pool (max=20). The 30s // statement cap is well above expected query latency; tune up if a // legitimate report needs longer. // // `max_lifetime` recycles connections every 30 minutes so any // per-connection state drift (prepared statements, GUCs) doesn't // accumulate forever. // Larger pool in development because Next dev fans out (HMR refetches, // multi-widget dashboards, React Query refetch-on-focus) and a single // admin clicking around can saturate 20 slots. Production stays at the // conservative 20 so we don't hammer postgres in a multi-replica deploy. // // 60 was too aggressive locally — postgres + the drizzle logger creates // massive log volume that backed up node's stderr, blocking the event // loop on otherwise-cheap requests. 30 is a middle ground that holds // during clients-page fanout without log-storm. const POOL_MAX = process.env.NODE_ENV === 'development' ? 30 : 20; // Pool reliability hardening (post-audit F8): // During the audit the dev server twice entered a stuck state where every // query 500'd with `write CONNECT_TIMEOUT` while the DB was healthy // (1 of 100 connections used, queryable from psql immediately). // The Docker bridge can silently drop TCP sockets and postgres-js's pool // holds onto the stale handles until max_lifetime expires. // - connect_timeout: 5s so failures surface fast instead of stalling // requests for 10s before erroring. // - max_lifetime: 10min so connections recycle before stale sockets // accumulate. Was 30min — too long for the Docker socket-drop pattern. // - onnotice: surfaces postgres NOTICE/WARNING messages that we'd // otherwise miss (extension warnings, deprecation hints). const queryClient = postgres(connectionString, { max: POOL_MAX, idle_timeout: 20, connect_timeout: 5, max_lifetime: 60 * 10, onnotice: (notice) => { // postgres-js types `notice` as `unknown`; the runtime shape is // { severity, code, message, ... }. Only surface WARNING+. const n = notice as { severity?: string; message?: string }; if (n.severity && n.severity !== 'NOTICE') { console.warn(`[postgres ${n.severity}] ${n.message ?? ''}`); } }, connection: { // ms values per postgres.js types; these become Postgres GUC settings // applied at session start. statement_timeout: 30_000, idle_in_transaction_session_timeout: 10_000, }, }); // Drizzle query logging is opt-in via DRIZZLE_LOG=1 in development. // Was on-by-default in dev but the volume (per-query SQL + parameters // for 60+ queries per page load) saturated process.stderr and blocked // the Node event loop, causing apparent dev-server "hangs" after // repeated navigation. The HTTP request logs from Next still show // query routing, which is enough for normal debugging. export const db = drizzle(queryClient, { schema, logger: process.env.DRIZZLE_LOG === '1', }); /** Close the underlying connection pool. Used by the vitest teardown so * the parent process can exit cleanly. */ export async function closeDb(): Promise { await queryClient.end({ timeout: 5 }); } export type Database = typeof db;