← back to Dw Unbuyable Recovery Pilot
lib/db.mjs
59 lines
// Read-only DB access for the unbuyable-recovery pilot.
//
// SAFETY MODEL (proven, not asserted — see README "Safety" section):
// Every query is wrapped in `BEGIN READ ONLY; <sql>; ROLLBACK`. Postgres
// rejects any INSERT/UPDATE/DELETE/DDL against a persistent object inside a
// READ ONLY transaction with `cannot execute X in a read-only transaction`,
// BEFORE it touches a row. The trailing ROLLBACK guarantees nothing is
// committed even in the impossible case a write slipped through.
//
// NB: two traps we hit + fixed:
// (1) `SET default_transaction_read_only=on` in the same statement does NOT
// work — it only governs transactions that START after it. Use
// BEGIN READ ONLY, which applies to the current transaction.
// (2) `psql -c "stmt1; stmt2; stmt3"` uses PQexec, which returns ONLY the
// last statement's result — so BEGIN;SELECT;ROLLBACK silently drops the
// SELECT rows. We feed the statements via STDIN instead, where psql runs
// them sequentially and prints the SELECT while READ ONLY still persists.
//
// Connects over the local /tmp unix socket to the dw_unified MIRROR (the
// read/staging copy on this box), never Kamatera-canonical. No `pg` module
// dependency — shells to the system `psql`.
import { spawnSync } from 'node:child_process';
// ON_ERROR_STOP=1 so a bad SELECT aborts with a nonzero exit (surfaced as a
// thrown Error) instead of silently yielding an empty result set.
const PSQL_ARGS = ['-h', '/tmp', '-d', 'dw_unified', '-tA', '-v', 'ON_ERROR_STOP=1'];
const MAX_BUF = 64 * 1024 * 1024; // 64 MB — ample for any catalog-sized json_agg result
/**
* Run a SELECT and return an array of row objects.
* @param {string} selectSql a single SELECT statement (no trailing semicolon)
* @returns {object[]}
*/
export function queryRows(selectSql) {
const script = `BEGIN READ ONLY;\nSELECT json_agg(t) FROM (${selectSql}) t;\nROLLBACK;\n`;
const res = spawnSync('psql', PSQL_ARGS, { encoding: 'utf8', input: script, maxBuffer: MAX_BUF });
if (res.status !== 0) {
throw new Error(`psql failed (status ${res.status}):\n${res.stderr || res.stdout}`);
}
// stdout = BEGIN tag + the json_agg value (which may span multiple physical
// lines) + ROLLBACK tag. Strip the command tags, then parse the whole blob —
// never line-by-line, since a large JSON array wraps across lines.
const blob = res.stdout
.split('\n')
.filter(l => l.trim() !== 'BEGIN' && l.trim() !== 'ROLLBACK')
.join('\n')
.trim();
if (blob === '' || blob === 'null' || blob === '\\N') return [];
const parsed = JSON.parse(blob);
return parsed == null ? [] : parsed;
}
/** Convenience: run a SELECT expected to return exactly one row; return it. */
export function queryOne(selectSql) {
const rows = queryRows(selectSql);
return rows[0] ?? null;
}