import { bayesianScore, compareMints, NEUTRAL_PRIOR_MEAN, parseNuts, type Health, type MintDetail, type MintInfo, type MintListItem, type MintStatus, type MintType, type ProbeSample, type RatingDistribution, type LnurlFields, type Stats, } from '@cashumints/shared'; import { config, startedAt } from './config.ts'; import { getDb, getStateNumber, getState } from './db.ts'; import { lastDiscoveryReport } from './discovery.ts'; import { mintByHost, parseEcosystem, type MintRow } from './mints.ts'; /** * One review per author per mint, newest wins. * * Kind 38000 is in the addressable range, so a later event from the same author * replaces the earlier one. The old site applied the same rule in memory * (aggregateReviews, keyed by pubkey); doing it in SQL keeps counts honest and stops * one npub from moving a mint's average by re-posting. * * The `AS ranked` alias is not decoration: Postgres rejects an unaliased subquery in * FROM, and SQLite does not care either way. */ export const LATEST_REVIEWS = ` SELECT mint_url, pubkey, rating, created_at FROM ( SELECT mint_url, pubkey, rating, created_at, ROW_NUMBER() OVER ( PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id ) AS rn FROM reviews ) AS ranked WHERE rn = 1 `; interface AggRow { mint_url: string; review_count: number; rating_avg: number | null; last_review_at: number | null; } /** * Review counts and averages, for one mint or for all of them. * * A detail page passes its own URL. Computing the whole table to read one row off it * is free on a local SQLite file and is not on a Postgres server across a socket. */ async function aggregates(mintUrl?: string): Promise> { const db = await getDb(); const where = mintUrl === undefined ? '' : 'WHERE mint_url = ?'; const params = mintUrl === undefined ? [] : [mintUrl]; const rows = await db.all( `WITH latest AS (${LATEST_REVIEWS}) SELECT mint_url, COUNT(*) AS review_count, AVG(rating) AS rating_avg, MAX(created_at) AS last_review_at FROM latest ${where} GROUP BY mint_url`, ...params, ); return new Map(rows.map((r) => [r.mint_url, r])); } /** * C in the Bayesian formula. * * Defaults to a neutral 3, not the observed global mean: see the long comment in * shared/src/score.ts for why the observed mean (about 4.7 here) makes the formula * fail its own stated purpose. `SCORE_PRIOR_MEAN=global` restores literal BACKEND.md * behaviour, or set any number to pin it. */ async function priorMean(): Promise { const override = process.env['SCORE_PRIOR_MEAN']; if (override && override !== 'global') { const n = Number.parseFloat(override); if (Number.isFinite(n) && n >= 1 && n <= 5) return n; } if (override === 'global') { const db = await getDb(); const row = await db.get<{ avg: number | null }>( `WITH latest AS (${LATEST_REVIEWS}) SELECT AVG(rating) AS avg FROM latest`, ); return row?.avg ?? NEUTRAL_PRIOR_MEAN; } return NEUTRAL_PRIOR_MEAN; } function iconPath(row: MintRow): string | null { return row.icon_file ? `/icons/${row.icon_file}` : null; } function round1(n: number | null): number | null { return n === null ? null : Math.round(n * 10) / 10; } function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem { const base = { review_count: agg?.review_count ?? 0, rating_avg: agg?.rating_avg ?? null, status: row.status, last_review_at: agg?.last_review_at ?? null, }; return { url: row.url, host: row.host, name: row.name, icon: iconPath(row), type: row.type as MintType, status: row.status as MintStatus, last_online: row.last_online, review_count: base.review_count, rating_avg: round1(base.rating_avg), score: bayesianScore(base, mean, now), last_review_at: base.last_review_at, version: row.version, }; } /** * The list, optionally narrowed to one ecosystem. * * `type` is filtered in SQL rather than after scoring, because the two list pages ask * for one ecosystem each and there is no reason to score fifty federations to render * /mints. Unfiltered still returns everything, so `/api/mints` on its own is the whole * index with a `type` on every item. */ export async function listMints(limit?: number, type?: string): Promise { const now = Math.floor(Date.now() / 1000); const db = await getDb(); const [agg, mean, rows] = await Promise.all([ aggregates(), priorMean(), type ? db.all('SELECT * FROM mints WHERE type = ?', type) : db.all('SELECT * FROM mints'), ]); const items = rows.map((row) => toListItem(row, agg.get(row.url), mean, now)).sort(compareMints); return limit && limit > 0 ? items.slice(0, limit) : items; } async function distribution(mintUrl: string): Promise { const db = await getDb(); const rows = await db.all<{ rating: number; n: number }>( `WITH latest AS (${LATEST_REVIEWS}) SELECT rating, COUNT(*) AS n FROM latest WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`, mintUrl, ); const dist: RatingDistribution = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 }; for (const r of rows) { const key = String(r.rating) as keyof RatingDistribution; if (key in dist) dist[key] = r.n; } return dist; } async function reviews90d(mintUrl: string): Promise { const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60; const db = await getDb(); const row = await db.get<{ n: number }>( `WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`, mintUrl, cutoff, ); return row?.n ?? 0; } async function uptime30d(mintUrl: string): Promise { const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60; const db = await getDb(); const row = await db.get<{ up: number | null; n: number }>( 'SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?', mintUrl, cutoff, ); if (!row?.n || row.up === null) return null; return Math.round(row.up * 1000) / 1000; } async function recentProbes(mintUrl: string): Promise { const cutoff = Math.floor(Date.now() / 1000) - 48 * 60 * 60; const db = await getDb(); return db.all( 'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC', mintUrl, cutoff, ); } function parseInfo(json: string | null): MintInfo | null { if (!json) return null; try { return JSON.parse(json) as MintInfo; } catch { return null; } } export async function getMintDetail(host: string): Promise { const row = await mintByHost(host); if (!row) return null; const now = Math.floor(Date.now() / 1000); // One round trip's worth of latency instead of six, which is the difference between // a local file and a Postgres server on another host. const [agg, mean, ratingDistribution, reviews, uptime, probes] = await Promise.all([ aggregates(row.url), priorMean(), distribution(row.url), reviews90d(row.url), uptime30d(row.url), recentProbes(row.url), ]); const item = toListItem(row, agg.get(row.url), mean, now); const info = parseInfo(row.info_json); let nuts: string[] = []; if (row.nuts_json) { try { nuts = JSON.parse(row.nuts_json) as string[]; } catch { nuts = []; } } if (nuts.length === 0 && info) nuts = parseNuts(info.nuts); /* * Type-specific columns are spread across the payload rather than nested under a key. * * That is what keeps a Cashu detail byte for byte what it was: `ecosystem_json` is * null for a mint, so nothing is added and no consumer sees a new empty object to * handle. A federation gets `federation_id`, `invite_codes`, `modules`, `network` and * the rest at the top level, where the page reads them beside `status` and `name` * without unwrapping anything. */ const ecosystem = parseEcosystem(row); return { ...item, ...(ecosystem ?? {}), description: row.description, pubkey: row.pubkey, info, nuts, first_seen: row.first_seen, last_probe: row.last_probe, updated_at: row.updated_at, rating_distribution: ratingDistribution, reviews_90d: reviews, uptime_30d: uptime, probes_recent: probes, }; } let statsCache: { at: number; value: Stats } | null = null; const STATS_TTL_S = 60; export async function getStats(): Promise { const now = Math.floor(Date.now() / 1000); if (statsCache && now - statsCache.at < STATS_TTL_S) return statsCache.value; const db = await getDb(); /* * The four `mints_*` fields are scoped to `type = 'cashu'`, which is what they always * counted and what every consumer of them still means: the pulse ticker's "mints * indexed", the /mints page description, the home page. Letting federations quietly * inflate a number three pages print in a sentence would be a worse kind of breakage * than a missing field, because nothing would fail — the sentences would just stop * being true. * * COUNT(*) FILTER, not SUM(status = 'online'): Postgres has no implicit cast from * boolean to integer, so the SQLite spelling is a type error there. */ const [counts, federations, lnurl, lnurlFunding, reviews, fedimintReviews, lnurlReviews] = await Promise.all([ db.get<{ total: number; online: number; offline: number; degraded: number }>( `SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status = 'online') AS online, COUNT(*) FILTER (WHERE status = 'offline') AS offline, COUNT(*) FILTER (WHERE status = 'degraded') AS degraded FROM mints WHERE type = 'cashu'`, ), db.get<{ total: number; online: number; offline: number; announced: number }>( `SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status = 'online') AS online, COUNT(*) FILTER (WHERE status = 'offline') AS offline, COUNT(*) FILTER (WHERE status = 'announced') AS announced FROM mints WHERE type = 'fedimint'`, ), db.get<{ total: number; online: number; offline: number }>( `SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status = 'online') AS online, COUNT(*) FILTER (WHERE status = 'offline') AS offline FROM mints WHERE type = 'lnurl'`, ), /* * The degraded-funding count is read in JavaScript rather than in SQL, because the * flag lives inside `ecosystem_json` and neither a `LIKE '%"funding_available":false%'` * (which depends on how the two drivers happen to serialise) nor a JSON operator * (which SQLite and Postgres spell differently) is portable. There are a handful of * these rows, the whole result is memoised for a minute, and a correct answer on * both backends is worth one small scan. */ db.all<{ ecosystem_json: string | null }>( `SELECT ecosystem_json FROM mints WHERE type = 'lnurl' AND status = 'online'`, ), db.get<{ n: number; last: number | null }>( `WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`, ), db.get<{ n: number }>( `WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n FROM latest WHERE mint_url IN (SELECT url FROM mints WHERE type = 'fedimint')`, ), db.get<{ n: number }>( `WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n FROM latest WHERE mint_url IN (SELECT url FROM mints WHERE type = 'lnurl')`, ), ]); let lnurlDegradedFunding = 0; for (const row of lnurlFunding) { const fields = parseEcosystem(row); if (fields?.funding_available === false) lnurlDegradedFunding++; } const cashuTotal = counts?.total ?? 0; const value: Stats = { mints_total: cashuTotal, mints_online: counts?.online ?? 0, mints_offline: counts?.offline ?? 0, mints_degraded: counts?.degraded ?? 0, reviews_total: reviews?.n ?? 0, last_review_at: reviews?.last ?? null, updated_at: now, cashu_total: cashuTotal, fedimint_total: federations?.total ?? 0, fedimint_online: federations?.online ?? 0, fedimint_offline: federations?.offline ?? 0, fedimint_announced: federations?.announced ?? 0, fedimint_reviews: fedimintReviews?.n ?? 0, lnurl_total: lnurl?.total ?? 0, lnurl_online: lnurl?.online ?? 0, lnurl_offline: lnurl?.offline ?? 0, lnurl_degraded_funding: lnurlDegradedFunding, lnurl_reviews: lnurlReviews?.n ?? 0, }; statsCache = { at: now, value }; return value; } /** Drop the in-process stats cache. For tests that mutate the database underneath it. */ export function resetStatsCache(): void { statsCache = null; } /** * Health bypasses the stats cache: it is the endpoint you page on. * * Three things can degrade it, and they are three different failures: * * probeStale nothing has checked a mint in three intervals * !discoveryOk the last discovery cycle threw * report.starved the last backfill read less than BACKFILL_MIN_EVENTS * * The third is the one added after the postmortem, and it is the only one that would * have caught a year of the index sitting at eight mints: the cycles were completing, * `ok` was true, the probes were fresh, and the relay list simply did not contain the * relay holding the archive. "Ran without throwing" is not the same claim as "read * anything", and only the second one is worth a green light. * * A deployment with an empty database reports degraded until its first backfill lands, * because until then nothing has confirmed the relay set reads anything at all. That is * intended: it holds `cashumints-web.service` at its health gate rather than letting it * publish a site built from nothing. */ export async function getHealth(): Promise { const now = Math.floor(Date.now() / 1000); const db = await getDb(); const [lastProbe, lastDiscovery, discoveryOkRaw, tracked, report] = await Promise.all([ getStateNumber('last_probe_at'), getStateNumber('last_discovery_at'), getState('last_discovery_ok'), db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'), lastDiscoveryReport(), ]); const discoveryOk = discoveryOkRaw !== '0'; const staleAfter = config.probeIntervalMin * 60 * 3; const probeStale = lastProbe === null || now - lastProbe > staleAfter; // No report at all is starvation by default: see the note above. const starved = report?.starved ?? true; return { status: probeStale || !discoveryOk || starved ? 'degraded' : 'ok', uptime_s: now - startedAt, last_probe_at: lastProbe, last_discovery_at: lastDiscovery, mints_tracked: tracked?.n ?? 0, updated_at: now, discovery_relays: report?.relays ?? [], last_discovery_events: report?.events ?? null, last_discovery_mode: report?.mode ?? null, discovery_starved: starved, backfill_min_events: config.backfillMinEvents, }; }