Files
CashuMints.space/api/src/queries.ts
T
michilisandClaude Opus 5 9ffa53094d Make a starved discovery cycle say so, in the log and on /api/health.
For about a year the production RELAYS list did not include the relay carrying
the kind 38000/38172 archive. Every backfill read about thirty events, wrote them
faithfully, reported ok=true, and the nightly build republished an index of eight
mints. Nothing measured the difference between "the cycle completed" and "the
cycle read anything", so nothing went red.

Three signals now do:

  - Per-relay attribution. queryRelays() replaces pool.querySync(), which merges
    every relay into one deduplicated array and throws away who sent what. It
    keeps one subscription per relay over the pool's existing sockets and shares
    a single alreadyHaveEvent across them, so an event five relays carry is still
    verified once; receivedEvent fires before that check, which is what makes the
    per-relay count mean "what this relay contributed". The deadline moved out of
    each Subscription's own EOSE timer so `eose` means a frame arrived rather than
    something timed out.

  - A WARN naming any relay that will not connect, on every cycle, and any relay
    that connected and sent nothing, on backfills only. An incremental cycle is
    supposed to come back empty.

  - BACKFILL_MIN_EVENTS, default 200. Under it, ERROR discovery starvation
    suspected and a flag health reports as discovery_starved, forcing 503. Sticky
    across incremental cycles so an hourly cycle finding four events cannot clear
    what a backfill diagnosed; stored in the database so a restart cannot either.

A fresh database is starved until its first backfill lands. That is intended: it
holds the build's health gate rather than publishing a site made from nothing.

Verified against the live relay set — 1528 events, five relays connected, EOSE on
all five, health 200 — and against an unreachable list, which produces the two
WARN lines, the ERROR, and 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:07:01 +02:00

435 lines
15 KiB
TypeScript

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<Map<string, AggRow>> {
const db = await getDb();
const where = mintUrl === undefined ? '' : 'WHERE mint_url = ?';
const params = mintUrl === undefined ? [] : [mintUrl];
const rows = await db.all<AggRow>(
`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<number> {
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<MintListItem[]> {
const now = Math.floor(Date.now() / 1000);
const db = await getDb();
const [agg, mean, rows] = await Promise.all([
aggregates(),
priorMean(),
type
? db.all<MintRow>('SELECT * FROM mints WHERE type = ?', type)
: db.all<MintRow>('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<RatingDistribution> {
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<number> {
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<number | null> {
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<ProbeSample[]> {
const cutoff = Math.floor(Date.now() / 1000) - 48 * 60 * 60;
const db = await getDb();
return db.all<ProbeSample>(
'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<MintDetail | null> {
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<Stats> {
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<LnurlFields>(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<Health> {
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,
};
}