import fs from 'node:fs'; import { config, type DbConfig } from './config.ts'; import type { Db } from './db-driver.ts'; import { PostgresDb } from './db-postgres.ts'; import { SCHEMA, SCHEMA_UPGRADES } from './db-schema.ts'; import { SqliteDb } from './db-sqlite.ts'; export type { Db, Dialect, Param, RunResult, Sql } from './db-driver.ts'; /** * Open a connection and make sure the schema is there. * * Both backends create their tables on first use, so pointing the API at an empty * Postgres database is the whole setup: no separate migration step to forget. */ export async function openDb(target: DbConfig = config.db): Promise { const db: Db = target.dialect === 'postgres' ? new PostgresDb(target.url, target.poolMax) : new SqliteDb(target.file); try { await db.exec(SCHEMA); await applySchemaUpgrades(db); } catch (err) { await db.close().catch(() => undefined); throw err; } return db; } /** * Bring an existing database up to the current schema. * * `CREATE TABLE IF NOT EXISTS` is a no-op on a table that already exists, so a * deployment seeded before federations were indexed would otherwise boot against a * `mints` table with no `type` column and fail on the first query. Running these here * means an upgrade is a restart, with no separate step to forget. * * An already-present column is the expected outcome on every boot after the first, so * that error is swallowed and anything else is re-thrown. Both backends are matched on * the message rather than on a code: SQLite raises `duplicate column name: type` and * Postgres `column "type" of relation "mints" already exists`, and neither exposes * anything more structured through the drivers here. */ async function applySchemaUpgrades(db: Db): Promise { for (const statement of SCHEMA_UPGRADES) { try { await db.exec([statement]); } catch (err) { /* * Postgres localizes its message text (lc_messages), so a German server says * neither phrase below and every boot after the first would re-throw. Its error * codes are locale-proof: 42701 duplicate_column, 42P07 duplicate_table. SQLite * has no code for this, so its English message is still matched. */ const code = (err as { code?: unknown }).code; const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); const alreadyThere = code === '42701' || code === '42P07' || message.includes('duplicate column') || message.includes('already exists'); if (!alreadyThere) throw err; } } } let opening: Promise | null = null; /** * The process-wide connection, opened on first use. * * Async because `pg` is: there is no synchronous way to reach a Postgres server. The * promise is memoized, so concurrent callers during startup share one connection * rather than racing to open several. */ export function getDb(): Promise { if (!opening) { fs.mkdirSync(config.iconDir, { recursive: true }); opening = openDb().catch((err: unknown) => { // A failed open must not be cached, or every later call replays the same error // against a connection that was never established. opening = null; throw err; }); } return opening; } export async function closeDb(): Promise { const pending = opening; opening = null; if (pending) await pending.then((db) => db.close()).catch(() => undefined); } export async function getState(key: string): Promise { const db = await getDb(); const row = await db.get<{ value: string }>('SELECT value FROM state WHERE key = ?', key); return row?.value ?? null; } export async function setState(key: string, value: string): Promise { const db = await getDb(); await db.run( 'INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value', key, value, ); } export async function getStateNumber(key: string): Promise { const raw = await getState(key); if (raw === null) return null; const n = Number.parseInt(raw, 10); return Number.isFinite(n) ? n : null; } /** Delete probe rows older than 90 days. Called once a day. */ export async function pruneProbes(): Promise { const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60; const db = await getDb(); return (await db.run('DELETE FROM probes WHERE ts < ?', cutoff)).changes; }