2026-08-12 08:20:26 +05:30
|
|
|
// PostgreSQL backend for the async DB adapter — the ONLY backend (SQLite retired 2026-08-12). Implements
|
|
|
|
|
// prepare(sql).{get,all,run}, exec(sql), tx(fn), init() so repos/app code stay engine-agnostic (the facade
|
|
|
|
|
// in dbx.js keeps the door open for future backends). Connection string from DATABASE_URL.
|
2026-07-24 22:28:04 +05:30
|
|
|
const { Pool, types } = require('pg');
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
|
|
|
|
|
// BIGINT (int8, OID 20) defaults to STRING in node-postgres to avoid precision loss. Every BIGINT here is
|
|
|
|
|
// an epoch-ms timestamp or a byte size — all far below Number.MAX_SAFE_INTEGER — so parse them as numbers
|
|
|
|
|
// to match the SQLite backend. Otherwise `expires_at < Date.now()` would compare a string to a number.
|
|
|
|
|
types.setTypeParser(20, (v) => (v === null ? null : parseInt(v, 10)));
|
|
|
|
|
|
2026-11-03 21:00:18 +05:30
|
|
|
// Pool sizing + SAFETY TIMEOUTS. The old `max:10` with no connectionTimeoutMillis meant that once all 10
|
|
|
|
|
// connections were in use (a leaked/stuck client, a long query, or just a login burst) EVERY new query waited
|
|
|
|
|
// FOREVER for a free client — the "login spinner that never ends" outage. Now:
|
|
|
|
|
// • connectionTimeoutMillis — a saturated pool makes callers ERROR fast instead of hanging indefinitely.
|
|
|
|
|
// • statement_timeout — a runaway query is killed, so it can't pin a connection permanently.
|
|
|
|
|
// • idle_in_transaction_session_timeout — reclaims a client left "idle in transaction" (a hung tx()),
|
|
|
|
|
// which is the classic way a pool slowly leaks to zero.
|
|
|
|
|
// • max raised to 20 for more headroom.
|
|
|
|
|
// All tunable via env so they can be adjusted without a code change.
|
|
|
|
|
const pool = new Pool({
|
|
|
|
|
connectionString: process.env.DATABASE_URL,
|
|
|
|
|
max: Number(process.env.PG_POOL_MAX || 20),
|
|
|
|
|
connectionTimeoutMillis: Number(process.env.PG_CONN_TIMEOUT_MS || 10000),
|
|
|
|
|
idleTimeoutMillis: Number(process.env.PG_IDLE_TIMEOUT_MS || 30000),
|
|
|
|
|
statement_timeout: Number(process.env.PG_STATEMENT_TIMEOUT_MS || 60000),
|
|
|
|
|
idle_in_transaction_session_timeout: Number(process.env.PG_IDLE_TX_TIMEOUT_MS || 60000),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Don't let an unexpected idle-client error crash the process (pg emits 'error' on broken idle connections);
|
|
|
|
|
// the pool evicts the bad client and the next query gets a fresh one.
|
|
|
|
|
pool.on('error', (e) => { try { console.warn('[pg] idle client error:', e && e.message); } catch (_) {} });
|
2026-07-24 22:28:04 +05:30
|
|
|
|
|
|
|
|
// Repos use '?' placeholders (SQLite style); Postgres wants $1,$2,… — replace positionally. Safe because
|
|
|
|
|
// no literal '?' appears inside any SQL string literal in this codebase.
|
|
|
|
|
function toPg(sql) { let i = 0; return sql.replace(/\?/g, () => '$' + (++i)); }
|
|
|
|
|
|
|
|
|
|
function prepare(sql) {
|
|
|
|
|
const q = toPg(sql);
|
|
|
|
|
return {
|
|
|
|
|
get: (...p) => pool.query(q, p).then((r) => r.rows[0]),
|
|
|
|
|
all: (...p) => pool.query(q, p).then((r) => r.rows),
|
|
|
|
|
run: (...p) => pool.query(q, p).then((r) => ({ changes: r.rowCount, lastInsertRowid: undefined })),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exec(sql) { return pool.query(sql).then(() => {}); }
|
|
|
|
|
|
|
|
|
|
// Transaction on ONE pooled client (a pool would scatter BEGIN/COMMIT across connections). Same runner
|
|
|
|
|
// shape the sqlite backend's tx() exposes, so repos.mergeInto is identical on both engines.
|
|
|
|
|
async function tx(fn) {
|
|
|
|
|
const client = await pool.connect();
|
|
|
|
|
try {
|
|
|
|
|
await client.query('BEGIN');
|
|
|
|
|
const t = {
|
|
|
|
|
run: (sql, ...p) => client.query(toPg(sql), p).then((r) => ({ changes: r.rowCount })),
|
|
|
|
|
get: (sql, ...p) => client.query(toPg(sql), p).then((r) => r.rows[0]),
|
|
|
|
|
all: (sql, ...p) => client.query(toPg(sql), p).then((r) => r.rows),
|
|
|
|
|
};
|
|
|
|
|
const out = await fn(t);
|
|
|
|
|
await client.query('COMMIT');
|
|
|
|
|
return out;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
try { await client.query('ROLLBACK'); } catch (_) {}
|
|
|
|
|
throw e;
|
|
|
|
|
} finally {
|
|
|
|
|
client.release();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Apply the schema (all CREATE ... IF NOT EXISTS — idempotent). Multi-statement, no params, so it runs via
|
|
|
|
|
// the simple-query protocol in one call. MUST be awaited before serving (server.js boot).
|
|
|
|
|
async function init() {
|
|
|
|
|
const sql = fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8');
|
|
|
|
|
await pool.query(sql);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { prepare, exec, tx, init, name: 'pg', _pool: pool };
|