first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import { normalizeMintUrl } from '@cashumints/shared';
import { getDb } from './db.ts';
import { log } from './log.ts';
/**
* URLs already reported as unusable. A rejected value (a Fedimint `fed11…` invite, an
* onion address) recurs in dozens of events per cycle, and logging each occurrence
* buries the lines that matter.
*/
const reportedSkips = new Set<string>();
export interface MintRow {
url: string;
host: string;
name: string | null;
description: string | null;
icon_url: string | null;
icon_file: string | null;
pubkey: string | null;
info_json: string | null;
nuts_json: string | null;
version: string | null;
status: string;
consecutive_fails: number;
last_online: number | null;
last_probe: number | null;
first_seen: number;
updated_at: number;
}
/**
* Insert a mint if it is new. Never touches an existing row: cached metadata is the
* whole point of the table, and discovery must not overwrite what a probe learned.
* Returns the normalized URL when a row was created, null otherwise.
*
* Deduped on the slug, not the URL. Real data has `https://mint.minibits.cash/Bitcoin`
* and `https://mint.minibits.cash/bitcoin` announced as separate mints; they are one
* mint written two ways, and keying on the URL would split its reviews across two pages.
* The URL keeps its original path case, because a mint's path is case sensitive over
* HTTP and the lowercase spelling 404s when probed.
*
* ponytail: whichever casing is seen first wins and is the one probed. The seed list
* pins the working spelling, so in practice the correct one is always inserted first.
* If that ever stops holding, the upgrade is to probe both casings once and keep the
* one that answers.
*/
export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 1000)): string | null {
const normalized = normalizeMintUrl(rawUrl);
if (!normalized) {
if (!reportedSkips.has(rawUrl)) {
reportedSkips.add(rawUrl);
log.warn('skipped mint url', { url: rawUrl.slice(0, 80) });
}
return null;
}
const result = getDb()
.prepare(
// OR IGNORE covers both unique keys, url and host, in one clause.
`INSERT OR IGNORE INTO mints (url, host, status, first_seen, updated_at)
VALUES (?, ?, 'unknown', ?, ?)`,
)
.run(normalized.url, normalized.host, now, now);
return result.changes > 0 ? normalized.url : null;
}
/** Canonical mint URL for a normalized slug, or null if no such mint is tracked. */
export function mintUrlByHost(host: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE host = ?').get(host) as
| { url: string }
| undefined;
return row?.url ?? null;
}
export function allMintRows(): MintRow[] {
return getDb().prepare('SELECT * FROM mints').all() as MintRow[];
}
export function mintByHost(host: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE host = ?').get(host) as MintRow | undefined;
}
export function mintByUrl(url: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE url = ?').get(url) as MintRow | undefined;
}
/** Resolve a mint by the pubkey it publishes in /v1/info, for reviews with only a `d` tag. */
export function mintUrlByPubkey(pubkey: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE pubkey = ? LIMIT 1').get(pubkey) as
| { url: string }
| undefined;
return row?.url ?? null;
}