Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
371 lines
12 KiB
TypeScript
371 lines
12 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 Stats,
|
|
} from '@cashumints/shared';
|
|
import { config, startedAt } from './config.ts';
|
|
import { getDb, getStateNumber, getState } from './db.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, reviews, fedimintReviews] = 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<{ 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 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,
|
|
};
|
|
|
|
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. */
|
|
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;
|
|
|
|
return {
|
|
status: probeStale || !discoveryOk ? 'degraded' : 'ok',
|
|
uptime_s: now - startedAt,
|
|
last_probe_at: lastProbe,
|
|
last_discovery_at: lastDiscovery,
|
|
mints_tracked: tracked?.n ?? 0,
|
|
updated_at: now,
|
|
};
|
|
}
|