import pg from 'pg'; import type { Db, Param, RunResult, Sql } from './db-driver.ts'; const { Pool, types } = pg; /** * node-postgres hands back BIGINT and NUMERIC as strings, because either can hold a * value JavaScript's number cannot. Nothing here can: the counters are row counts, the * timestamps are unix seconds, and the averages are ratings between 1 and 5. Left * as-is the strings would flow straight into the JSON the API serves, turning * `review_count: 12` into `"12"` and quietly breaking every comparison on the way. */ types.setTypeParser(20, (v) => Number.parseInt(v, 10)); // int8 types.setTypeParser(1700, (v) => Number.parseFloat(v)); // numeric /** * Rewrite `?` placeholders to `$1..$n`. * * Quoted text is copied verbatim so a `?` inside a string literal is not renumbered. * Postgres escapes a quote by doubling it, which needs no special case: the closing * quote ends one literal and the next character opens another. */ export function toPgPlaceholders(sql: string): string { let out = ''; let quote: string | null = null; let n = 0; for (const ch of sql) { if (quote !== null) { if (ch === quote) quote = null; out += ch; } else if (ch === "'" || ch === '"') { quote = ch; out += ch; } else if (ch === '?') { out += `$${++n}`; } else { out += ch; } } return out; } const translated = new Map(); function translate(sql: string): string { let pgSql = translated.get(sql); if (pgSql === undefined) { pgSql = toPgPlaceholders(sql); translated.set(sql, pgSql); } return pgSql; } /** Bind a Sql surface to anything that can run a query: the pool, or one client. */ function surface(run: (text: string, values: Param[]) => Promise): Sql { return { async all(sql: string, ...params: Param[]): Promise { return (await run(translate(sql), params)).rows as T[]; }, async get(sql: string, ...params: Param[]): Promise { return (await run(translate(sql), params)).rows[0] as T | undefined; }, async run(sql: string, ...params: Param[]): Promise { return { changes: (await run(translate(sql), params)).rowCount ?? 0 }; }, }; } /** Strip the password so a connection string is safe to put in a log line. */ export function redact(url: string): string { try { const parsed = new URL(url); if (parsed.password) parsed.password = '***'; return parsed.toString(); } catch { return 'postgres'; } } export class PostgresDb implements Db { readonly dialect = 'postgres' as const; readonly label: string; #pool: pg.Pool; #sql: Sql; constructor(connectionString: string, poolMax: number) { this.#pool = new Pool({ connectionString, max: poolMax }); // A pool client can die between checkouts — a Postgres restart, a killed session, an // idle timeout on a proxy. Without a listener node-postgres emits that on the pool as // an unhandled 'error' and takes the process down with it. this.#pool.on('error', () => undefined); this.#sql = surface((text, values) => this.#pool.query(text, values)); this.label = redact(connectionString); } all(sql: string, ...params: Param[]): Promise { return this.#sql.all(sql, ...params); } get(sql: string, ...params: Param[]): Promise { return this.#sql.get(sql, ...params); } run(sql: string, ...params: Param[]): Promise { return this.#sql.run(sql, ...params); } async exec(statements: readonly string[]): Promise { // One connection for the lot: concurrent CREATE ... IF NOT EXISTS on the same // catalog rows deadlocks in Postgres, and this runs on every boot. const client = await this.#pool.connect(); try { for (const sql of statements) await client.query(sql); } finally { client.release(); } } async transaction(fn: (tx: Sql) => Promise): Promise { const client = await this.#pool.connect(); const tx = surface((text, values) => client.query(text, values)); try { await client.query('BEGIN'); const result = await fn(tx); await client.query('COMMIT'); return result; } catch (err) { try { await client.query('ROLLBACK'); } catch { // The connection is already unusable; release() below discards it. } throw err; } finally { client.release(); } } close(): Promise { return this.#pool.end(); } }