Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
+183 -88
View File
@@ -8,13 +8,14 @@ import {
type MintInfo,
type MintListItem,
type MintStatus,
type MintType,
type ProbeSample,
type RatingDistribution,
type Stats,
} from '@cashumints/shared';
import { config, startedAt } from './config.ts';
import { getDb, getStateNumber, getState } from './db.ts';
import { mintByHost, type MintRow } from './mints.ts';
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
/**
* One review per author per mint, newest wins.
@@ -23,15 +24,18 @@ import { mintByHost, type MintRow } from './mints.ts';
* 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.
*/
const LATEST_REVIEWS = `
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
) WHERE rn = 1
) AS ranked WHERE rn = 1
`;
interface AggRow {
@@ -41,17 +45,26 @@ interface AggRow {
last_review_at: number | null;
}
function aggregates(): Map<string, AggRow> {
const rows = getDb()
.prepare(
`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 GROUP BY mint_url`,
)
.all() as AggRow[];
/**
* 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]));
}
@@ -64,7 +77,7 @@ function aggregates(): Map<string, AggRow> {
* fail its own stated purpose. `SCORE_PRIOR_MEAN=global` restores literal BACKEND.md
* behaviour, or set any number to pin it.
*/
function priorMean(): number {
async function priorMean(): Promise<number> {
const override = process.env['SCORE_PRIOR_MEAN'];
if (override && override !== 'global') {
@@ -73,10 +86,11 @@ function priorMean(): number {
}
if (override === 'global') {
const row = getDb()
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT AVG(rating) AS avg FROM latest`)
.get() as { avg: number | null };
return row.avg ?? NEUTRAL_PRIOR_MEAN;
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;
@@ -103,6 +117,7 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
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,
@@ -113,26 +128,38 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
};
}
export function listMints(limit?: number): MintListItem[] {
/**
* 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 agg = aggregates();
const mean = priorMean();
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 = (getDb().prepare('SELECT * FROM mints').all() as MintRow[])
.map((row) => toListItem(row, agg.get(row.url), mean, now))
.sort(compareMints);
const items = rows.map((row) => toListItem(row, agg.get(row.url), mean, now)).sort(compareMints);
return limit && limit > 0 ? items.slice(0, limit) : items;
}
function distribution(mintUrl: string): RatingDistribution {
const rows = getDb()
.prepare(
`WITH latest AS (${LATEST_REVIEWS})
SELECT rating, COUNT(*) AS n FROM latest
WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`,
)
.all(mintUrl) as { rating: number; n: number }[];
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) {
@@ -142,34 +169,39 @@ function distribution(mintUrl: string): RatingDistribution {
return dist;
}
function reviews90d(mintUrl: string): number {
async function reviews90d(mintUrl: string): Promise<number> {
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
const row = getDb()
.prepare(
`WITH latest AS (${LATEST_REVIEWS})
SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`,
)
.get(mintUrl, cutoff) as { n: number };
return row.n;
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;
}
function uptime30d(mintUrl: string): number | null {
async function uptime30d(mintUrl: string): Promise<number | null> {
const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
const row = getDb()
.prepare('SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?')
.get(mintUrl, cutoff) as { up: number | null; n: number };
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;
if (!row?.n || row.up === null) return null;
return Math.round(row.up * 1000) / 1000;
}
function recentProbes(mintUrl: string): ProbeSample[] {
async function recentProbes(mintUrl: string): Promise<ProbeSample[]> {
const cutoff = Math.floor(Date.now() / 1000) - 48 * 60 * 60;
return getDb()
.prepare(
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
)
.all(mintUrl, cutoff) as ProbeSample[];
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 {
@@ -181,13 +213,23 @@ function parseInfo(json: string | null): MintInfo | null {
}
}
export function getMintDetail(host: string): MintDetail | null {
const row = mintByHost(host);
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);
const agg = aggregates().get(row.url);
const item = toListItem(row, agg, priorMean(), now);
// 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[] = [];
@@ -200,8 +242,20 @@ export function getMintDetail(host: string): MintDetail | null {
}
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,
@@ -209,58 +263,99 @@ export function getMintDetail(host: string): MintDetail | null {
first_seen: row.first_seen,
last_probe: row.last_probe,
updated_at: row.updated_at,
rating_distribution: distribution(row.url),
reviews_90d: reviews90d(row.url),
uptime_30d: uptime30d(row.url),
probes_recent: recentProbes(row.url),
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 function getStats(): Stats {
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 = getDb();
const counts = db
.prepare(
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, reviews, fedimintReviews] = await Promise.all([
db.get<{ total: number; online: number; offline: number; degraded: number }>(
`SELECT
COUNT(*) AS total,
SUM(status = 'online') AS online,
SUM(status = 'offline') AS offline,
SUM(status = 'degraded') AS degraded
FROM mints`,
)
.get() as { total: number; online: number | null; offline: number | null; degraded: number | null };
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<{ 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')`,
),
]);
const reviews = db
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`)
.get() as { n: number; last: number | null };
const cashuTotal = counts?.total ?? 0;
const value: Stats = {
mints_total: counts.total,
mints_online: counts.online ?? 0,
mints_offline: counts.offline ?? 0,
mints_degraded: counts.degraded ?? 0,
reviews_total: reviews.n,
last_review_at: reviews.last,
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,
};
statsCache = { at: now, value };
return value;
}
/** Health bypasses the stats cache: it is the endpoint you page on. */
export function getHealth(): Health {
const now = Math.floor(Date.now() / 1000);
const lastProbe = getStateNumber('last_probe_at');
const lastDiscovery = getStateNumber('last_discovery_at');
const discoveryOk = getState('last_discovery_ok') !== '0';
/** Drop the in-process stats cache. For tests that mutate the database underneath it. */
export function resetStatsCache(): void {
statsCache = null;
}
const tracked = (getDb().prepare('SELECT COUNT(*) AS n FROM mints').get() as { n: number }).n;
/** Health bypasses the stats cache: it is the endpoint you page on. */
export async function getHealth(): Promise<Health> {
const now = Math.floor(Date.now() / 1000);
const db = await getDb();
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked] = 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'),
]);
const discoveryOk = discoveryOkRaw !== '0';
const staleAfter = config.probeIntervalMin * 60 * 3;
const probeStale = lastProbe === null || now - lastProbe > staleAfter;
@@ -269,7 +364,7 @@ export function getHealth(): Health {
uptime_s: now - startedAt,
last_probe_at: lastProbe,
last_discovery_at: lastDiscovery,
mints_tracked: tracked,
mints_tracked: tracked?.n ?? 0,
updated_at: now,
};
}