← back to Directory Core
src/db.ts
103 lines
// directory-core / db
//
// Shared PostgreSQL primitives for directory verticals. Each consuming project
// owns its OWN database (lawyer_professional_directory / doctor_professional_directory /
// animals_directory / restaurant_professional_directory) — Steve's hard rule that
// verticals never share PG. This module just gives them a consistent pool +
// query helper + transaction wrapper.
//
// Source: extracted from `lawyer-directory-builder/src/db/pool.ts` 2026-05-04
// (the cleanest implementation across the 4 verticals at extraction time).
// 2026-05-04 update: accepts EITHER DATABASE_URL (lawyer-directory style) OR
// individual PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD vars (restaurant-directory
// style). Either is fine — pg.Pool resolves both natively. Throws only if
// neither is set.
//
// Audit 2026-05-04: side-effect-at-import — the throw lives in `getPool()` not
// at module load, so this lib is import-safe in test environments where no DB
// is configured. Pool is instantiated lazily on first use.
import 'dotenv/config';
import pg from 'pg';
const { Pool } = pg;
let _pool: pg.Pool | null = null;
function envHasPgConfig(): boolean {
return Boolean(
process.env.DATABASE_URL ||
process.env.PGHOST ||
process.env.PGDATABASE ||
process.env.PGUSER
);
}
function buildPool(): pg.Pool {
if (!envHasPgConfig()) {
throw new Error(
'directory-core/db: no PG config in env — set DATABASE_URL, OR PGHOST/PGDATABASE/PGUSER (and optionally PGPORT/PGPASSWORD).'
);
}
const max = parseInt(process.env.PG_POOL_MAX || '10', 10);
// pg.Pool reads connectionString OR the discrete PG* env vars. We pass
// connectionString explicitly only if DATABASE_URL is set; otherwise pg
// picks up PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD on its own.
if (process.env.DATABASE_URL) {
return new Pool({ connectionString: process.env.DATABASE_URL, max });
}
return new Pool({ max });
}
// Lazy single-step initializer. Note (audit P1-a 2026-05-04): the assignment is
// a single synchronous JS statement — no await between the null-check and the
// store — so two concurrent first-touches both observe `null`, then both run
// `buildPool()`, but whichever wins the JS turn sets `_pool` first; the second
// runner overwrites with its own pool and the first one is orphaned (sockets
// leak). The synchronous fast path avoids that by collapsing check+assign to
// one statement and letting v8's reorder guarantee single-publication.
function ensurePool(): pg.Pool {
return _pool ?? (_pool = buildPool());
}
/**
* Lazily-initialized PG pool. Reads env on first access; subsequent calls
* return the same pool. Use this when you need direct pool access (e.g.
* pool.connect() for advanced patterns); for normal queries prefer query()
* or withTx() below.
*/
export const pool: pg.Pool = new Proxy({} as pg.Pool, {
get(_target, prop, _receiver) {
const p = ensurePool();
const v = (p as unknown as Record<string | symbol, unknown>)[prop as string];
return typeof v === 'function' ? (v as Function).bind(p) : v;
},
});
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params: unknown[] = []
): Promise<pg.QueryResult<T>> {
return pool.query<T>(text, params as never[]);
}
export async function withTx<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const out = await fn(client);
await client.query('COMMIT');
return out;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// Optional: graceful shutdown helper. Call from your server's SIGTERM handler.
export async function closePool() {
await pool.end();
}