Files
CashuMints.space/api/src/config.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

172 lines
7.1 KiB
TypeScript

import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { DEFAULT_RELAYS } from '@cashumints/shared';
import type { Dialect } from './db-driver.ts';
import { redact } from './db-postgres.ts';
const here = path.dirname(fileURLToPath(import.meta.url));
const apiRoot = path.resolve(here, '..');
function int(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export interface DbConfig {
dialect: Dialect;
/** SQLite file. Empty when the dialect is postgres. */
file: string;
/** libpq connection string. Empty when the dialect is sqlite. */
url: string;
/** Postgres connections held open. Ignored by SQLite, which has one. */
poolMax: number;
/** Safe to log: a Postgres password is replaced with `***`. */
label: string;
}
export const DEFAULT_DB_FILE = path.join(apiRoot, 'data', 'cashumints.db');
/**
* Read one connection target.
*
* Accepts what a person is likely to type or paste:
*
* postgres://user:pw@host:5432/cashumints postgres
* postgresql://… postgres
* sqlite:/var/lib/cashumints/cashumints.db sqlite, absolute
* sqlite://./data/cashumints.db sqlite, relative to the working directory
* file:./data/cashumints.db sqlite
* /var/lib/cashumints/cashumints.db sqlite, a bare path
* :memory: sqlite, throwaway
*
* Throws on anything else rather than guessing, because the wrong guess here is a
* second empty database that looks like data loss.
*/
export function parseDbTarget(raw: string, poolMax = 10): DbConfig {
const value = raw.trim();
if (!value) throw new Error('Empty database target.');
const sqlite = (file: string): DbConfig => {
if (!file) throw new Error(`Database target has no path after the scheme: ${value}`);
const resolved = file === ':memory:' ? file : path.resolve(file);
return { dialect: 'sqlite', file: resolved, url: '', poolMax, label: `sqlite:${resolved}` };
};
// A scheme is checked before anything else, so an unsupported one is an error rather
// than a filename. Left to a "does it look like a path?" heuristic, `mysql://db/x`
// reads as a relative path and the API starts on a brand new empty SQLite file — data
// loss that announces itself as a successful boot.
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
switch (scheme) {
case undefined:
return sqlite(value); // A bare path, absolute or relative.
case 'postgres':
case 'postgresql':
return { dialect: 'postgres', file: '', url: value, poolMax, label: redact(value) };
case 'sqlite':
case 'sqlite3':
case 'file':
return sqlite(value.replace(/^[a-z0-9+.-]+:(?:\/\/)?/i, ''));
default:
// `:memory:` has no scheme by this reading — the regex needs a letter first.
if (value === ':memory:') return sqlite(value);
throw new Error(
`Cannot tell what database "${value}" means. Use a postgres:// URL, ` +
'a sqlite: path, or a filesystem path.',
);
}
}
/**
* Where this process keeps its data.
*
* `DATABASE_URL` decides the backend. Without it the API stays on SQLite at `DB_PATH`,
* which is what every existing deployment already has, so adding Postgres support
* changed nothing for anyone who does not ask for it.
*/
export function resolveDbConfig(env: NodeJS.ProcessEnv = process.env): DbConfig {
const poolMax = int('DB_POOL_MAX', 10);
const url = env['DATABASE_URL']?.trim();
if (url) return parseDbTarget(url, poolMax);
const file = path.resolve(env['DB_PATH'] ?? DEFAULT_DB_FILE);
return { dialect: 'sqlite', file, url: '', poolMax, label: `sqlite:${file}` };
}
let dbConfig: DbConfig | null = null;
export const config = {
port: int('PORT', 8787),
/**
* Resolved on first use rather than at import, so a process can still redirect itself
* — `test:offline` points at a throwaway copy by setting DATABASE_URL before anything
* opens a connection, and would otherwise rug the live database instead.
*/
get db(): DbConfig {
dbConfig ??= resolveDbConfig();
return dbConfig;
},
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
[...DEFAULT_RELAYS]) as string[],
/**
* The floor a backfill cycle has to clear before it counts as a real read.
*
* For about a year this deployment's RELAYS list did not include the relay carrying
* the kind 38000/38172 archive. Every backfill returned about thirty events, wrote
* them, reported ok=true, and the index sat at eight mints while every health signal
* stayed green. A backfill asks five relays for the whole history of four kinds; on a
* working relay set it comes back with thousands. Anything under this is not a quiet
* network, it is a misconfigured one, and it says so in the log and on /api/health.
*
* Raise it on a deployment that genuinely has more history, lower it for a local
* test relay. It is deliberately not zero-able: set it to 1 if you mean "off".
*/
backfillMinEvents: int('BACKFILL_MIN_EVENTS', 200),
probeIntervalMin: int('PROBE_INTERVAL_MIN', 10),
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
probeConcurrency: int('PROBE_CONCURRENCY', 8),
probeTimeoutMs: int('PROBE_TIMEOUT_MS', 5000),
userAgent: 'cashumints.space-indexer/1.0',
} as const;
/**
* Publishing `kind:38174` announcements, which is **off unless two switches are set**.
*
* One switch would be enough to make it work and is not enough to make it safe. This
* writes signed events to public relays, permanently and under this site's name, so
* turning it on has to be a thing somebody did on purpose rather than a thing that
* happened because a key was left in an env file from a test run:
*
* ANNOUNCE_LNURL=true the intent
* ANNOUNCE_KEY=nsec1… the identity it will be signed with
* ANNOUNCE_RELAYS=wss://… where, explicitly — there is deliberately no default
*
* The missing default on ANNOUNCE_RELAYS is the important one. Falling back to the
* site's read pool would mean the difference between a CI run against a local test relay
* and a permanent write to five public ones was a single unset variable.
*
* Returns null — never throws — when any of the three is missing, because a
* misconfigured publisher must not stop an indexer that still has a directory to serve.
* See the README section "Publishing LNURL mint announcements".
*/
export function announceConfig(
env: NodeJS.ProcessEnv = process.env,
): { secretKey: string; relays: string[] } | null {
if (env['ANNOUNCE_LNURL']?.trim().toLowerCase() !== 'true') return null;
const secretKey = env['ANNOUNCE_KEY']?.trim() ?? '';
const relays = (env['ANNOUNCE_RELAYS'] ?? '')
.split(',')
.map((relay) => relay.trim())
.filter((relay) => relay.startsWith('ws://') || relay.startsWith('wss://'));
if (!secretKey || relays.length === 0) return null;
return { secretKey, relays };
}
export const startedAt = Math.floor(Date.now() / 1000);