Files
CashuMints.space/api/src/db.ts
T
michilis 6f17b572b1 Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
2026-08-21 02:10:48 +02:00

126 lines
4.4 KiB
TypeScript

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<Db> {
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<void> {
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<Db> | 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<Db> {
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<void> {
const pending = opening;
opening = null;
if (pending) await pending.then((db) => db.close()).catch(() => undefined);
}
export async function getState(key: string): Promise<string | null> {
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<void> {
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<number | null> {
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<number> {
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;
}