@@ -0,0 +1,275 @@
|
||||
import {
|
||||
bayesianScore,
|
||||
compareMints,
|
||||
NEUTRAL_PRIOR_MEAN,
|
||||
parseNuts,
|
||||
type Health,
|
||||
type MintDetail,
|
||||
type MintInfo,
|
||||
type MintListItem,
|
||||
type MintStatus,
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
`;
|
||||
|
||||
interface AggRow {
|
||||
mint_url: string;
|
||||
review_count: number;
|
||||
rating_avg: number | null;
|
||||
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[];
|
||||
|
||||
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.
|
||||
*/
|
||||
function priorMean(): 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 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;
|
||||
}
|
||||
|
||||
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),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function listMints(limit?: number): MintListItem[] {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const agg = aggregates();
|
||||
const mean = priorMean();
|
||||
|
||||
const items = (getDb().prepare('SELECT * FROM mints').all() as MintRow[])
|
||||
.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 }[];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function reviews90d(mintUrl: string): 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;
|
||||
}
|
||||
|
||||
function uptime30d(mintUrl: string): 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 };
|
||||
|
||||
if (!row.n || row.up === null) return null;
|
||||
return Math.round(row.up * 1000) / 1000;
|
||||
}
|
||||
|
||||
function recentProbes(mintUrl: string): 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[];
|
||||
}
|
||||
|
||||
function parseInfo(json: string | null): MintInfo | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
return JSON.parse(json) as MintInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMintDetail(host: string): MintDetail | null {
|
||||
const row = 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);
|
||||
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);
|
||||
|
||||
return {
|
||||
...item,
|
||||
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: distribution(row.url),
|
||||
reviews_90d: reviews90d(row.url),
|
||||
uptime_30d: uptime30d(row.url),
|
||||
probes_recent: recentProbes(row.url),
|
||||
};
|
||||
}
|
||||
|
||||
let statsCache: { at: number; value: Stats } | null = null;
|
||||
const STATS_TTL_S = 60;
|
||||
|
||||
export function getStats(): 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(
|
||||
`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 };
|
||||
|
||||
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 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,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
const tracked = (getDb().prepare('SELECT COUNT(*) AS n FROM mints').get() as { n: number }).n;
|
||||
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,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user