/** * `postgres` driver — binds `Bun.SQL` (never `pg` / postgres.js). * * Protocol-named: Neon, Supabase, RDS, Timescale all speak postgres. */ import { affectedRows } from "./affected-rows.ts"; import type { SqlConnectOptions, SqlConnection, SqlDriver, SqlRole, SqlRow } from "./types.ts"; import { hostFromUrl } from "./external.ts"; /** Bun.SQL client checked out via {@link PostgresClientLike.reserve}. */ export interface PostgresReservedClient extends PostgresClientLike { /** Return the connection to the pool. */ release(): void; } /** * Bun.SQL `unsafe` result. DML is an array with `count` or `affectedRows`. * `changes` is the older fake shape. */ export type PostgresQueryResult = SqlRow[] & { readonly count?: number; readonly affectedRows?: number; readonly changes?: number; }; /** Minimal surface we use from Bun.SQL (and test fakes). */ export interface PostgresClientLike { unsafe(sql: string, values?: unknown[]): PromiseLike; /** * Pin one pooled TCP connection. Required in front of PgDog — `begin()` * then `unsafe()` on the parent client can checkout a second slot and * deadlock the pool (`checkout timeout`). */ reserve?(): Promise; /** * Reserve one pooled connection for a transaction (Bun.SQL). * * @param fn - Work on the reserved client */ begin?(fn: (tx: PostgresClientLike) => Promise | T): Promise; close?(options?: { timeout?: number }): Promise; } /** Cap shared Bun.SQL pools so one process cannot outrun PgDog (default 10). */ export const POSTGRES_POOL_MAX = 8; /** One shared pool entry — raw Bun.SQL plus a pause-aware facade. */ type SharedPostgresEntry = { readonly raw: PostgresClientLike; readonly guarded: PostgresClientLike; }; /** * Process-wide pool registry. * * `bun --hot` re-evaluates this module on each save. A module-local `Map` * would drop the only handle to the previous Bun.SQL pools and leave them * connected. PgDog then queues every checkout — Console login included — * until its server pool times out. */ const SHARED_POSTGRES_CLIENTS = Symbol.for("oke.sharedPostgresClients"); function sharedClientMap(): Map { const g = globalThis as typeof globalThis & { [SHARED_POSTGRES_CLIENTS]?: Map; }; let map = g[SHARED_POSTGRES_CLIENTS]; if (!map) { map = new Map(); g[SHARED_POSTGRES_CLIENTS] = map; } return map; } /** * When true, shared pools stay closed and every query fails fast — no Bun.SQL * reconnect into a torn-down Postgres (see {@link pauseSharedPostgresClients}). */ let sharedPostgresPaused = false; /** * Thrown when Console / host code touches SQL after `oke dev` paused Postgres * for compose stop. Fail soft — never open a new TCP connection. */ export class SharedPostgresPausedError extends Error { readonly code = "ERR_OKE_POSTGRES_PAUSED"; /** * @param message - Operator-facing reason */ constructor(message = "shared Postgres pools are paused (compose stopped)") { super(message); this.name = "SharedPostgresPausedError"; } } /** * Bun.SQL rejected with a value that is not an {@link Error}. * * {@link Error.cause} is the original rejection. `code`, `errno`, * `constraint`, `table`, and `column` are copied when the driver sent them * so SQLSTATE mapping can read a plain object the same way it reads an Error. */ export class PostgresDriverError extends Error { /** Driver `code` (`ERR_POSTGRES_*` or a five-character SQLSTATE). */ readonly code?: string; /** SQLSTATE string, or a numeric errno when the driver sent one. */ readonly errno?: string | number; /** Constraint name, when the driver sent one. */ readonly constraint?: string; /** Table name, when the driver sent one. */ readonly table?: string; /** Column name, when the driver sent one. */ readonly column?: string; /** * @param cause - Non-Error rejection from Bun.SQL */ constructor(cause: unknown) { super(postgresRejectionMessage(cause), { cause }); this.name = "PostgresDriverError"; if (cause === null || typeof cause !== "object") return; const row = cause as Record; const code = readDriverString(row, "code"); const constraint = readDriverString(row, "constraint"); const table = readDriverString(row, "table"); const column = readDriverString(row, "column"); const errno = readDriverErrno(row); if (code !== undefined) this.code = code; if (errno !== undefined) this.errno = errno; if (constraint !== undefined) this.constraint = constraint; if (table !== undefined) this.table = table; if (column !== undefined) this.column = column; } } function postgresRejectionMessage(cause: unknown): string { if (cause !== null && typeof cause === "object" && "message" in cause) { const message = (cause as { message: unknown }).message; if (typeof message === "string" && message.length > 0) return message; } return "Postgres query failed"; } function readDriverString(row: Record, key: string): string | undefined { const value = row[key]; return typeof value === "string" && value.length > 0 ? value : undefined; } function readDriverErrno(row: Record): string | number | undefined { const value = row.errno; if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.length > 0) return value; return undefined; } /** * Await a Bun.SQL call. Non-Error rejections become {@link PostgresDriverError}. * * @param query - Driver promise */ async function settlePostgres(query: PromiseLike): Promise { try { return await query; } catch (err) { if (err instanceof Error) throw err; throw new PostgresDriverError(err); } } /** * Resolve the Postgres URL (injected, `DATABASE_URL`, then localhost). * * @param url - Explicit URL */ export function resolvePostgresUrl(url?: string): string { return url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke"; } /** * True while {@link pauseSharedPostgresClients} has not been resumed. */ export function isSharedPostgresPaused(): boolean { return sharedPostgresPaused; } /** * Bun / driver connection failures that flood the TTY when Postgres dies * under an open pool (compose stop). * * @param reason - Rejection reason */ export function isPostgresConnectionNoise(reason: unknown): boolean { if (reason instanceof SharedPostgresPausedError) return true; if (!reason || typeof reason !== "object") return false; const code = (reason as { code?: unknown }).code; if (typeof code === "string") { if ( code === "ERR_POSTGRES_CONNECTION_CLOSED" || code === "ERR_POSTGRES_CONNECTION_REFUSED" || code === "ERR_POSTGRES_CONNECTION_TIMEOUT" || code === "ERR_OKE_POSTGRES_PAUSED" ) { return true; } } const message = (reason as { message?: unknown }).message; if (typeof message !== "string") return false; return ( message === "Connection closed" || /Failed to connect/i.test(message) || /ECONNREFUSED/i.test(message) ); } function assertSharedPostgresReady(): void { if (sharedPostgresPaused) throw new SharedPostgresPausedError(); } /** * Pause-aware facade over a Bun.SQL client so journal / clock / instances / * Console wrappers that call `.unsafe` directly cannot reconnect after pause. * * @param raw - Underlying Bun.SQL (or test fake) */ function guardSharedPostgresClient(raw: PostgresClientLike): PostgresClientLike { const guarded: PostgresClientLike = { unsafe(sql, values) { assertSharedPostgresReady(); return raw.unsafe(sql, values); }, async reserve() { assertSharedPostgresReady(); if (typeof raw.reserve !== "function") { throw new Error("postgres client needs reserve()"); } const reserved = await raw.reserve(); const guardedReserved = guardSharedPostgresClient(reserved) as PostgresReservedClient; return Object.assign(guardedReserved, { release(): void { reserved.release(); }, }); }, begin(fn) { assertSharedPostgresReady(); if (typeof raw.begin !== "function") { throw new Error("postgres client needs begin()"); } return raw.begin((tx) => fn(guardSharedPostgresClient(tx))); }, async close(): Promise { // Shared pools are process-owned ({@link closeSharedPostgresClients}). // Journal / clock / instances / store holders must not tear down Bun.SQL // or the map keeps a dead client — next checkout returns // ERR_POSTGRES_CONNECTION_CLOSED (e.g. after in-process `oke db seed` // stops the temporary app boot inside `oke dev`). }, }; return guarded; } /** * Bun.SQL pool size. Unset, non-finite, or below 1 keeps {@link POSTGRES_POOL_MAX}. * A configured max is not capped — callers that need a larger pool ask for it. * * @param poolMax - Requested `max` */ function postgresPoolMax(poolMax: number | undefined): number { if (poolMax === undefined || !Number.isFinite(poolMax) || poolMax < 1) return POSTGRES_POOL_MAX; return Math.floor(poolMax); } /** * One Bun.SQL pool per URL and `max` for store / journal / clock / vault / console. * * Returns a pause-aware client — holders (including fleet store wrappers) * fail soft after {@link pauseSharedPostgresClients} instead of reconnecting. * * @param url - Connection URL * @param poolMax - Bun.SQL `max`. Omitted uses {@link POSTGRES_POOL_MAX}. A * different max is a different pool — a store pin of 20 must not reuse a * pool another subsystem already opened at the default. */ export function sharedPostgresClient(url?: string, poolMax?: number): PostgresClientLike { assertSharedPostgresReady(); const resolved = resolvePostgresUrl(url); const max = postgresPoolMax(poolMax); const key = `${resolved}\0${String(max)}`; const clients = sharedClientMap(); const existing = clients.get(key); if (existing) return existing.guarded; const raw = new Bun.SQL(resolved, { max }) as unknown as PostgresClientLike; const guarded = guardSharedPostgresClient(raw); clients.set(key, { raw, guarded }); return guarded; } /** * Close and drop every shared Bun.SQL pool. * * Prefer {@link pauseSharedPostgresClients} from `oke dev` — close alone does * not stop Console from opening a fresh pool on the next poll. */ export async function closeSharedPostgresClients(): Promise { const clients = [...sharedClientMap().values()]; sharedClientMap().clear(); await Promise.all( clients.map(async (entry) => { try { await entry.raw.close?.({ timeout: 1 }); } catch { // Pool already closed / server already gone. } }), ); } /** * Pause shared Postgres: block new pools, fail soft on existing wrappers, and * close open clients. * * Used by `oke dev` **`x`** before `docker compose stop` so parent Console * polls do not keep reconnecting into a torn-down Postgres/PgDog. */ export async function pauseSharedPostgresClients(): Promise { sharedPostgresPaused = true; await closeSharedPostgresClients(); } /** * Allow shared pools again after compose is back ( `oke dev` **`u`** ). */ export function resumeSharedPostgresClients(): void { sharedPostgresPaused = false; } /** * Pin one pooled connection, `BEGIN`, run `fn`, `COMMIT`/`ROLLBACK`, release. * * Prefer `reserve()` — Bun.SQL `begin()` then `unsafe()` on the parent * client can checkout a second slot and deadlock PgDog. * * @param client - Pool or test fake * @param fn - Work on the pinned client */ export async function withPinnedPostgres( client: PostgresClientLike, fn: (tx: PostgresClientLike) => Promise, ): Promise { if (typeof client.reserve === "function") { const reserved = await client.reserve(); try { await settlePostgres(reserved.unsafe("BEGIN")); try { const result = await fn(reserved); await settlePostgres(reserved.unsafe("COMMIT")); return result; } catch (err) { try { await settlePostgres(reserved.unsafe("ROLLBACK")); } catch { // Already aborted. } throw err; } } finally { reserved.release(); } } if (typeof client.begin === "function") { return client.begin(fn); } throw new Error("postgres client needs reserve() or begin() to pin a transaction"); } /** * Convert `?` placeholders to Postgres `$1…$n`. * Leaves SQL unchanged when there are no bound values so operators * like `jsonb ? key` are not rewritten. * * @param sql - SQL with `?` placeholders * @param values - Bound values (empty skips conversion) */ export function toPostgresParams(sql: string, values: readonly unknown[] = []): string { if (values.length === 0) return sql; let i = 0; return sql.replace(/\?/g, () => `$${++i}`); } /** * Open a Postgres connection via Bun.SQL. * * @param options - URL / role / injected client */ export async function connectPostgres(options: SqlConnectOptions = {}): Promise { const role = options.role ?? "primary"; const injected = options.client as PostgresClientLike | undefined; // Dedicated single-connection client (pool.max === 1): workloads that hold // manual transaction state across many statements (e.g. vault rotateMaster) // must not share a pooled client — Bun raises ERR_POSTGRES_UNSAFE_TRANSACTION // when raw statements interleave with open transaction state on the shared // pool. Opt in explicitly; default behavior is unchanged. const requestedMax = options.pool?.max; const dedicated = !injected && requestedMax === 1; const client = injected ?? (dedicated ? dedicatedPostgresClient(resolvePostgresUrl(options.url)) : sharedPostgresClient(options.url, requestedMax)); return wrapPostgresClient(client, role, { shared: injected === undefined && !dedicated, ...(options.url !== undefined ? { url: options.url } : {}), }); } /** Standalone one-connection Bun.SQL client — closed with its SqlConnection. */ function dedicatedPostgresClient(url: string): PostgresClientLike { return new Bun.SQL(url, { max: 1 }) as unknown as PostgresClientLike; } /** * Bind a Bun.SQL-compatible client as {@link SqlConnection}. * * @param client - Pool or reserved transaction client * @param role - Primary vs replica * @param opts - Shared-pool / already-pinned flags */ function wrapPostgresClient( client: PostgresClientLike, role: SqlRole, opts: { readonly shared?: boolean; readonly pinned?: boolean; readonly url?: string; } = {}, ): SqlConnection { const host = opts.url ? hostFromUrl(opts.url) : undefined; const connection: SqlConnection = { driverId: "postgres", role, ...(host !== undefined ? { external: { host, provider: "postgres", kind: "infrastructure" as const } } : {}), async query(sql, params = []) { // Shared wrappers outlive pause — fail soft before Bun.SQL reconnects. if (opts.shared) assertSharedPostgresReady(); const pg = toPostgresParams(sql, params); const result = await settlePostgres(client.unsafe(pg, [...params])); if (Array.isArray(result)) return result as SqlRow[]; return Array.from(result as ArrayLike); }, async exec(sql, params = []) { if (opts.shared) assertSharedPostgresReady(); const pg = toPostgresParams(sql, params); const result = await settlePostgres(client.unsafe(pg, [...params])); return { changes: affectedRows(result) }; }, async transaction(fn) { if (opts.shared) assertSharedPostgresReady(); if (opts.pinned) return fn(connection); return withPinnedPostgres(client, (tx) => fn(wrapPostgresClient(tx, role, { pinned: true }))); }, async close() { if (!opts.shared) await client.close?.(); }, }; return connection; } /** * In-memory Postgres-protocol fake for conformance when no server is available. * Speaks the same {@link SqlConnection} contract as the real driver. */ export function createPostgresFakeClient(): PostgresClientLike & { readonly tables: Map; } { const tables = new Map(); function parseIdent(raw: string): string { return raw.replaceAll('"', "").trim(); } /** Bun 1.4.2 DML shape: an array plus `count`, never `changes`. */ function counted(rows: SqlRow[], count: number): SqlRow[] { return Object.assign(rows, { count }); } return { tables, async begin(fn) { return fn(this); }, async reserve() { return Object.assign(this, { release() { /* fake has no pool */ }, }); }, async unsafe(sql, values = []) { const text = sql.trim(); // Accept both ? (pre-conversion) and $n forms. const normalised = text.replace(/\$\d+/g, "?"); if ( /^(begin|commit|rollback|set\b|select set_config|select pg_advisory_(xact_)?lock\b|create\b|grant\b|do\b|alter\b)\b/i.test( normalised, ) ) { return []; } const regclass = /^SELECT\s+to_regclass\(\?\)\s+IS\s+NOT\s+NULL\s+AS\s+present\s*$/i.exec( normalised, ); if (regclass) { const name = String(values[0] ?? "").replaceAll('"', ""); return [{ present: tables.has(name) }]; } const create = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\((.+)\)\s*$/i.exec( normalised, ); if (create) { const name = parseIdent(create[1]!); if (!tables.has(name)) tables.set(name, []); return []; } const select = /^SELECT\s+\*\s+FROM\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*(?:WHERE\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*=\s*\?)?\s*$/i.exec( normalised, ); if (select) { const rows = tables.get(parseIdent(select[1]!)) ?? []; if (select[2]) { const col = parseIdent(select[2]!); return rows.filter((r) => r[col] === values[0]).map((r) => ({ ...r })); } return rows.map((r) => ({ ...r })); } const exists = /^SELECT\s+1\s+AS\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+FROM\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+WHERE\s+(.+?)\s+LIMIT\s+1\s*$/i.exec( normalised, ); if (exists) { const rows = tables.get(parseIdent(exists[2]!)) ?? []; const preds = parseEqualityWhere(exists[3]!); const hit = rows.some((r) => preds.every((p, i) => r[p] === values[i])); return hit ? [{ [parseIdent(exists[1]!)]: 1 }] : []; } const updateReturning = /^UPDATE\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+SET\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*=\s*("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\+\s*\?\s+WHERE\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*=\s*\?\s+RETURNING\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*$/i.exec( normalised, ); if (updateReturning) { const name = parseIdent(updateReturning[1]!); const setCol = parseIdent(updateReturning[2]!); const addCol = parseIdent(updateReturning[3]!); const whereCol = parseIdent(updateReturning[4]!); const retCol = parseIdent(updateReturning[5]!); if (setCol !== addCol || setCol !== retCol) { throw new Error(`postgres fake: unsupported SQL: ${sql}`); } const list = tables.get(name) ?? []; const row = list.find((r) => r[whereCol] === values[1]); if (!row) return counted([], 0); const next = Number(row[setCol] ?? 0) + Number(values[0]); row[setCol] = next; return counted([{ [retCol]: next }], 1); } // CTE-shaped atomic increment with jsonb image capture: // WITH __oke_old AS (SELECT "pk" FROM t WHERE "pk" = ? FOR UPDATE) // UPDATE t SET "col" = "col" + ? FROM __oke_old // WHERE t."pk" = __oke_old."pk" // RETURNING row_to_json(t) AS __oke_after_data, "col" const cteIncrement = /^WITH\s+__oke_old\s+AS\s+\(SELECT\s+("?[\w]+"?)\s+FROM\s+("?[\w]+"?)\s+WHERE\s+"?[\w]+"?\s*=\s*\?\s+FOR\s+UPDATE\)\s+UPDATE\s+("?[\w]+"?)\s+SET\s+("?[\w]+"?)\s*=\s*("?[\w]+"?)\s*\+\s*\?\s+FROM\s+__oke_old\s+WHERE\s+("?[\w]+")\."?[\w]+"?\s*=\s*__oke_old\."?[\w]+"?\s+RETURNING\s+row_to_json\(("?[\w]+")\)\s+AS\s+__oke_after_data,\s*("?[\w]+"?)\s*$/i.exec( normalised, ); if (cteIncrement) { const whereTable = parseIdent(cteIncrement[2]!); const updateTable = parseIdent(cteIncrement[3]!); const setCol = parseIdent(cteIncrement[4]!); const addCol = parseIdent(cteIncrement[5]!); const retCol = parseIdent(cteIncrement[8]!); if (whereTable !== updateTable || setCol !== addCol || setCol !== retCol) { throw new Error(`postgres fake: unsupported SQL: ${sql}`); } const list = tables.get(updateTable) ?? []; const row = list.find((r) => r[parseIdent(cteIncrement[1]!)] === values[0]); if (!row) return counted([], 0); const next = Number(row[setCol] ?? 0) + Number(values[1]); row[setCol] = next; return counted([{ __oke_after_data: { ...row }, [retCol]: next }], 1); } const insertReturning = /^INSERT\s+INTO\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*RETURNING\s+\*\s*$/i.exec( normalised, ); if (insertReturning) { const name = parseIdent(insertReturning[1]!); const cols = insertReturning[2]!.split(",").map((c) => parseIdent(c)); const row: SqlRow = {}; cols.forEach((c, i) => { row[c] = values[i]; }); const list = tables.get(name) ?? []; list.push(row); tables.set(name, list); return counted([{ ...row }], 1); } const insert = /^INSERT\s+INTO\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*$/i.exec( normalised, ); if (insert) { const name = parseIdent(insert[1]!); const cols = insert[2]!.split(",").map((c) => parseIdent(c)); const row: SqlRow = {}; cols.forEach((c, i) => { row[c] = values[i]; }); const list = tables.get(name) ?? []; list.push(row); tables.set(name, list); return counted([], 1); } const del = /^DELETE\s+FROM\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s+WHERE\s+("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*=\s*\?\s*$/i.exec( normalised, ); if (del) { const name = parseIdent(del[1]!); const col = parseIdent(del[2]!); const list = tables.get(name) ?? []; const next = list.filter((r) => r[col] !== values[0]); const count = list.length - next.length; tables.set(name, next); return counted([], count); } throw new Error(`postgres fake: unsupported SQL: ${sql}`); }, async close() { tables.clear(); }, }; } /** Parse `col = ? AND col2 = ?` into ordered column names. */ function parseEqualityWhere(clause: string): string[] { return clause.split(/\s+AND\s+/i).map((part) => { const m = /^("?[a-zA-Z_][a-zA-Z0-9_]*"?)\s*=\s*\?$/.exec(part.trim()); if (!m) throw new Error(`postgres fake: unsupported WHERE: ${clause}`); return m[1]!.replaceAll('"', "").trim(); }); } /** Protocol-named postgres driver. */ export const postgresDriver: SqlDriver = { id: "postgres", facet: "sql", connect: connectPostgres, };