Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,17 @@
"formatter": {
"quoteStyle": "double"
}
}
},
"overrides": [
{
"includes": ["**/*.css"],
"linter": {
"rules": {
"suspicious": {
"noUnknownAtRules": "off"
}
}
}
}
]
}
199 changes: 198 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"check": "biome check .",
"format": "biome format --write .",
"typecheck": "bun --filter='*' run typecheck",
"test": "bun --filter='*' run test"
"test": "bun --filter='*' run test",
"dev": "bun --filter='@bitcoinmints/app' run dev",
"build": "bun --filter='@bitcoinmints/app' run build"
},
"devDependencies": {
"@biomejs/biome": "2.3.12",
Expand Down
21 changes: 21 additions & 0 deletions packages/app/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
12 changes: 12 additions & 0 deletions packages/app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bitcoinmints — data dump (PR #6)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
22 changes: 22 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,29 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bitcoinmints/core": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dexie-react-hooks": "^4.4.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.19",
"vite": "^8.0.8"
}
}
6 changes: 6 additions & 0 deletions packages/app/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
162 changes: 162 additions & 0 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {
BitcoinmintsDB,
createMintInfoFetcher,
createPool,
createScheduler,
getSubscribedKinds,
type Scheduler,
type SchedulerStats,
SEED_RELAYS,
} from "@bitcoinmints/core";
import { type JSX, useEffect, useState } from "react";
import { MintList } from "./components/MintList";

/**
* Human labels for each subscribed kind, aligned with the validation-paths
* table below. The "firehose — no authors" annotation on kinds 0 and 10002
* matches PR #30's review callout: we subscribe without an `authors`
* restriction, so those filters are fundamentally unbounded. Visible in the
* X-ray so the demo doesn't need a footnote.
*/
const KIND_LABELS: Record<number, string> = {
38172: "cashu announcements",
38173: "fedimint announcements",
38000: "reviews",
0: "profiles (firehose — no authors)",
10002: "relay lists (firehose — no authors)",
};

/**
* The one and only route. No router: a single `/` view dumping everything
* Dexie has.
*
* Boot discipline:
* - db + pool + fetcher + scheduler are created exactly once at module
* load (outside the component) so React 19 StrictMode's double-invoke
* of effects in dev can't produce two schedulers fighting over the
* same Dexie. The effect body then does `scheduler.start()` on every
* mount and `scheduler.stop()` on every cleanup — the scheduler is
* idempotent across those calls (start resets `stopped`, stop drains
* in-flight Layer B work), so StrictMode's double-invoke produces
* start → stop → start exactly as intended rather than leaving us
* stuck after the first cleanup.
*
* Stats refresh:
* - getStats() returns a plain snapshot; we poll it on a 500ms ticker so
* the `<pre>` at the top advances even when Dexie writes are quiet (the
* Dexie-triggered `useLiveQuery` in MintList would otherwise be the
* only re-render driver, and it only fires on row changes — not when
* `eventsReceived` increments without a write).
*/
const db = new BitcoinmintsDB();
const pool = createPool({ relays: [...SEED_RELAYS] });
const fetcher = createMintInfoFetcher({ concurrency: 4 });
/**
* Debug logging is a demo/X-ray aid, toggled via `?debug` on the URL (any
* presence wins; no value parsing). When enabled, the scheduler logs its
* filters/relays on start(), a per-event path line, and a per-Layer-B
* verdict line — all through `console.log`/`console.warn` with the
* `[scheduler]` prefix. Deliberately URL-toggled (not env-baked) so an
* alchemist can flip it on during a live demo without rebuilding.
*/
const DEBUG_SCHEDULER =
typeof window !== "undefined" && new URLSearchParams(window.location.search).has("debug");
const scheduler: Scheduler = createScheduler({
db,
pool,
fetcher,
relays: SEED_RELAYS,
debug: DEBUG_SCHEDULER,
});

const STATS_POLL_MS = 500;

export function App(): JSX.Element {
const [stats, setStats] = useState<SchedulerStats>(scheduler.getStats());

useEffect(() => {
void scheduler.start();
const handle = window.setInterval(() => {
setStats(scheduler.getStats());
}, STATS_POLL_MS);
return () => {
window.clearInterval(handle);
// Fire-and-forget stop(): app unmount means page navigation away or
// dev-HMR, either way we want the subscription closed. We don't await
// because React's effect-cleanup contract is synchronous.
void scheduler.stop();
};
}, []);

// Static derivations for the X-ray — computed once per render, but the
// inputs are module-constants so React's reconciler is effectively a
// no-op on these blocks.
const kinds = getSubscribedKinds();
// The widest kind label is 5 chars (e.g. "38172"). Pad to align.
const filtersBlock = kinds
.map((k) => {
const label = KIND_LABELS[k] ?? "";
const padded = `{ kinds: [${String(k).padEnd(5, " ")}] }`;
return ` ${padded} ${label}`;
})
.join("\n");
const relaysBlock = SEED_RELAYS.map((r) => ` ${r}`).join("\n");

return (
<div className="font-mono text-sm p-4 max-w-full">
<div>scheduler stats</div>
<pre>{JSON.stringify(stats, null, 2)}</pre>
{/*
Counter note: all scheduler stats are monotonically increasing
EXCEPT `layerBPending`, which is transient — it goes up on
enqueue and back down when Layer B completes. The alchemist
observed it "going up then down" during the prior demo; this
comment exists so the next viewer doesn't flag it as a bug.
*/}
<div>counters: monotonic, except layerBPending (transient: enqueue↑ / complete↓)</div>

<hr />
<div>filters in use</div>
<pre>{filtersBlock}</pre>
<div>relays</div>
<pre>{relaysBlock}</pre>

<hr />
<div>validation paths</div>
<pre>{VALIDATION_PATHS_TABLE}</pre>

<hr />
<MintList db={db} />
</div>
);
}

/**
* Reference doc rendered in the X-ray — the kind → parser → gate → counter
* path for every subscribed kind. Kept as a plain string (not a React
* table) so it stays font-mono and terse alongside the other `<pre>`
* blocks. Source of truth: scheduler/index.ts:onEvent switch.
*
* Hand-aligned fixed-width columns — edit with care. If a column grows
* past its width, widen the whole column rather than wrapping mid-row.
*/
const VALIDATION_PATHS_TABLE = `
kind parser Layer A gate Layer B counters
───── ────── ──────────── ─────── ────────
38172 cashu parseMintAnnouncement upsertAnnouncement d-tag + spam verifySignerBinding /v1/info × parse-null → drop
(needs d + ≥1 u) check pubkey match rejected → rejectedByLayerA
accepted → accepted + layerBPending

38173 fedimint parseMintAnnouncement upsertAnnouncement d-tag + spam none same as 38172 minus Layer B
(needs d + ≥1 u) check

38000 review parseReview upsertReviewWithAggregate d-tag none parse-null → rejectedByParse
check rejected → rejectedByLayerA
accepted → accepted

0 profile toProfileRow upsertProfile none null → drop
accepted → accepted

10002 relay toRelayListRow upsertRelayList none same as kind 0
list
`;
97 changes: 97 additions & 0 deletions packages/app/src/components/MintList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {
type AnnouncementRow,
type BitcoinmintsDB,
type MintAggregateRow,
type MintInfoRow,
rankMints,
} from "@bitcoinmints/core";
import { useLiveQuery } from "dexie-react-hooks";
import type { JSX } from "react";
import { MintRow } from "./MintRow";

/**
* The whole list surface for PR #6 — spec is raw field dump per mint,
* sorted by `bayesianScore` DESC via `rankMints(db, 50)`.
*
* Join strategy: one unified `useLiveQuery` at this level pre-joins
* aggregate → announcement → mintInfo and hands `<MintRow>` fully-resolved
* props. Previously we had a per-row `useLiveQuery` which resolved a
* microtask after the aggregate query, causing a ~1s "(no announcement)"
* placeholder flash on reload. Single query kills the flash.
*/
type Props = {
db: BitcoinmintsDB;
};

type JoinedRow = {
aggregate: MintAggregateRow;
announcement: AnnouncementRow | undefined;
info: MintInfoRow | undefined;
};

export function MintList({ db }: Props): JSX.Element {
// Order: rankMints() returns aggregates sorted by bayesianScore DESC.
// A mint with no reviews yet has no aggregate row, so this list can
// lag behind `announcements` — that's intentional. PR #7 will decide
// whether to render un-reviewed announcements as a tail section; for
// the X-ray we follow the ranked-aggregate-as-truth posture.
//
// Render-filter note: we drop non-Cashu announcements (kind !== 38172)
// below so the X-ray matches spec v1 (Cashu-only). The parse layer still
// stores k=38173 (Fedimint) rows and `rankMints` still ranks them — PR
// #7+ may surface those elsewhere. Keep this filter in the UI; do NOT
// push it into `rankMints` (don't mutate core for a UI-only concern).
const rows = useLiveQuery<JoinedRow[], JoinedRow[]>(
async () => {
const aggregates = await rankMints(db, 50);
const ds = aggregates.map((a) => a.d);
const announcements = await db.announcements.where("d").anyOf(ds).toArray();
const infos = await db.mintInfo.bulkGet(ds);
// A single `d` CAN map to multiple announcements (different pubkeys).
// Map.set keeps whichever appears LAST in `toArray()`; that matches
// the previous per-row `.first()` behavior only by luck-of-insert-order.
// PR #7 will resolve the ambiguity properly.
const annByD = new Map(announcements.map((a) => [a.d, a]));
// bulkGet returns an array in the same order as the keys; index align.
return aggregates.map((agg, i) => ({
aggregate: agg,
announcement: annByD.get(agg.d),
info: infos[i],
}));
},
[db],
[],
);

// Render-only Cashu filter (see note above). Orphan aggregates (no
// announcement — shouldn't happen in practice) are also dropped
// defensively so we never flash a "(no announcement)" row.
const visible = rows.filter((r) => r.announcement !== undefined && r.announcement.kind === 38172);

// Empty state per spec: stats block still renders (that's in App.tsx),
// the `mints` header always renders, and if there's nothing to show the
// single line `no mints yet` sits below it.
if (visible.length === 0) {
return (
<>
<div>mints</div>
<div>no mints yet</div>
</>
);
}

return (
<>
<div>mints</div>
{visible.map((row, i) => (
<MintRow
key={row.aggregate.d}
aggregate={row.aggregate}
announcement={row.announcement}
info={row.info}
isLast={i === visible.length - 1}
/>
))}
</>
);
}
Loading
Loading