Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
+103
-79
@@ -1,101 +1,125 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from './config.ts';
|
||||
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';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS mints (
|
||||
url TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
icon_url TEXT,
|
||||
icon_file TEXT,
|
||||
pubkey TEXT,
|
||||
info_json TEXT,
|
||||
nuts_json TEXT,
|
||||
version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
consecutive_fails INTEGER NOT NULL DEFAULT 0,
|
||||
last_online INTEGER,
|
||||
last_probe INTEGER,
|
||||
first_seen INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host);
|
||||
CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey);
|
||||
export type { Db, Dialect, Param, RunResult, Sql } from './db-driver.ts';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
mint_url TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
rating INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at);
|
||||
/**
|
||||
* 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);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS probes (
|
||||
mint_url TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
ok INTEGER NOT NULL,
|
||||
latency_ms INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts);
|
||||
try {
|
||||
await db.exec(SCHEMA);
|
||||
await applySchemaUpgrades(db);
|
||||
} catch (err) {
|
||||
await db.close().catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export type DB = Database.Database;
|
||||
|
||||
let db: DB | null = null;
|
||||
|
||||
export function getDb(): DB {
|
||||
if (db) return db;
|
||||
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
fs.mkdirSync(config.iconDir, { recursive: true });
|
||||
|
||||
const handle = new Database(config.dbPath);
|
||||
handle.pragma('journal_mode = WAL');
|
||||
handle.pragma('synchronous = NORMAL');
|
||||
handle.pragma('busy_timeout = 5000');
|
||||
handle.exec(SCHEMA);
|
||||
|
||||
db = handle;
|
||||
return handle;
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
db?.close();
|
||||
db = null;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getState(key: string): string | null {
|
||||
const row = getDb().prepare('SELECT value FROM state WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
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 function setState(key: string, value: string): void {
|
||||
getDb()
|
||||
.prepare('INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.run(key, value);
|
||||
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 function getStateNumber(key: string): number | null {
|
||||
const raw = getState(key);
|
||||
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 function pruneProbes(): number {
|
||||
export async function pruneProbes(): Promise<number> {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
|
||||
return getDb().prepare('DELETE FROM probes WHERE ts < ?').run(cutoff).changes;
|
||||
const db = await getDb();
|
||||
return (await db.run('DELETE FROM probes WHERE ts < ?', cutoff)).changes;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user