diff --git a/bun.lock b/bun.lock index e79ea8c..e3f0eb1 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.5.0", + "fake-indexeddb": "^6.2.5", "postcss": "^8.5.10", "tailwindcss": "^3.4.19", "vite": "^8.0.8", diff --git a/packages/app/package.json b/packages/app/package.json index bd35930..33cf5ec 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -25,6 +25,7 @@ "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.5.0", + "fake-indexeddb": "^6.2.5", "postcss": "^8.5.10", "tailwindcss": "^3.4.19", "vite": "^8.0.8" diff --git a/packages/app/src/components/MintList.tsx b/packages/app/src/components/MintList.tsx index 7d778c5..a2dd28d 100644 --- a/packages/app/src/components/MintList.tsx +++ b/packages/app/src/components/MintList.tsx @@ -1,77 +1,37 @@ -import { - type AnnouncementRow, - type BitcoinmintsDB, - type MintAggregateRow, - type MintInfoRow, - rankMints, -} from "@bitcoinmints/core"; +import type { BitcoinmintsDB } from "@bitcoinmints/core"; import { useLiveQuery } from "dexie-react-hooks"; import type { JSX } from "react"; import { MintRow } from "./MintRow"; +import { type MintListRow, queryMintList } from "./mintListQuery"; /** * The whole list surface for PR #6 — spec is raw field dump per mint, - * sorted by `bayesianScore` DESC via `rankMints(db, 50)`. + * with `verifiedBySignerBinding === false` rendered as an "unverified" + * badge (handled downstream in `MintRow`), NOT filtered out. * - * Join strategy: one unified `useLiveQuery` at this level pre-joins - * aggregate → announcement → mintInfo and hands `` 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. + * All the query + join logic lives in `mintListQuery.ts` so it can be + * unit-tested against fake-indexeddb without mounting React. This + * component is a thin render shell: live-query → map to ``. + * + * Prior behavior (for the historians): we called `rankMints(db, 50)` and + * iterated the aggregates. That approach is aggregate-required — any + * mint without a review at all had no row to join from, so it was never + * visible. sharegap.net (0 reviews, Layer B failed) fell into that gap. + * The capture of 36 announcements → 0 rendered was this bug. See + * `mintListQuery.ts` for the fix: announcements-driven, with synthesized + * zero-aggregates for un-reviewed mints. */ 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( - 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); + const rows = useLiveQuery(() => queryMintList(db), [db], []); // 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) { + if (rows.length === 0) { return ( <>
mints
@@ -83,13 +43,13 @@ export function MintList({ db }: Props): JSX.Element { return ( <>
mints
- {visible.map((row, i) => ( + {rows.map((row, i) => ( ))} diff --git a/packages/app/src/components/mintListQuery.test.ts b/packages/app/src/components/mintListQuery.test.ts new file mode 100644 index 0000000..48e38be --- /dev/null +++ b/packages/app/src/components/mintListQuery.test.ts @@ -0,0 +1,281 @@ +/** + * Regression tests for the mint-list query shape. + * + * Captures the design decision that Cashu announcements whose Layer B + * signer binding failed (`verifiedBySignerBinding: false`) AND Cashu + * announcements with zero reviews (no `mintAggregate` row) must still + * appear in the list. The prior aggregate-driven query hid both; the + * announcements-driven query surfaces them with a synthesized empty + * aggregate so the renderer can tag them "unverified" or show + * `reviewCount: 0` as appropriate. + */ +import { + type AnnouncementRow, + BitcoinmintsDB, + upsertAnnouncement, + upsertMintAggregate, + upsertReviewWithAggregate, +} from "@bitcoinmints/core"; +import { afterEach, describe, expect, it } from "vitest"; +import { queryMintList } from "./mintListQuery"; + +const freshName = () => `test-mint-list-${Math.random().toString(36).slice(2)}`; +const toDispose: BitcoinmintsDB[] = []; + +afterEach(async () => { + while (toDispose.length > 0) { + const db = toDispose.pop(); + if (!db) continue; + db.close(); + await BitcoinmintsDB.delete(db.name); + } +}); + +async function freshDB(): Promise { + const db = new BitcoinmintsDB(freshName()); + toDispose.push(db); + await db.open(); + return db; +} + +/** 64-char valid Cashu d-tag deterministically generated from a seed. */ +function dForSeed(seed: string): string { + // Pad/truncate a seed string into a 64-char lowercase hex shape. + let hex = ""; + for (const ch of seed) { + hex += ch.charCodeAt(0).toString(16).padStart(2, "0"); + } + return hex.slice(0, 64).padEnd(64, "0"); +} + +function makeCashuAnnouncement(over: Partial & { d: string }): AnnouncementRow { + return { + pubkey: `pk${over.d.slice(0, 62)}`, + kind: 38172, + eventId: `ev${over.d.slice(0, 62)}`, + createdAt: 1_700_000_000, + u: ["https://mint.example"], + content: "", + rawTags: [], + verifiedBySignerBinding: null, + ...over, + }; +} + +describe("queryMintList — Layer B failures render (sharegap regression)", () => { + it("renders a Cashu announcement whose verifiedBySignerBinding is false", async () => { + const db = await freshDB(); + const d = dForSeed("sharegap-like-1"); + await upsertAnnouncement(db, makeCashuAnnouncement({ d, verifiedBySignerBinding: false })); + const rows = await queryMintList(db); + expect(rows).toHaveLength(1); + expect(rows[0]?.announcement.d).toBe(d); + expect(rows[0]?.announcement.verifiedBySignerBinding).toBe(false); + }); + + it("also renders verifiedBySignerBinding === null (pending) rows", async () => { + const db = await freshDB(); + const d = dForSeed("pending-layer-b"); + await upsertAnnouncement(db, makeCashuAnnouncement({ d, verifiedBySignerBinding: null })); + const rows = await queryMintList(db); + expect(rows).toHaveLength(1); + expect(rows[0]?.announcement.verifiedBySignerBinding).toBeNull(); + }); +}); + +describe("queryMintList — mints with no reviews render with a synthesized aggregate", () => { + it("Cashu announcement with no matching mintAggregate row still appears", async () => { + const db = await freshDB(); + const d = dForSeed("no-reviews-yet"); + await upsertAnnouncement(db, makeCashuAnnouncement({ d })); + // Sanity: no aggregate was written. + expect(await db.mintAggregate.get(d)).toBeUndefined(); + + const rows = await queryMintList(db); + expect(rows).toHaveLength(1); + expect(rows[0]?.aggregate).toMatchObject({ + d, + reviewCount: 0, + ratedCount: 0, + avgRating: null, + bayesianScore: 0, + }); + }); + + it("uses the materialized aggregate when one exists (prefers real data)", async () => { + const db = await freshDB(); + const d = dForSeed("has-reviews"); + await upsertAnnouncement(db, makeCashuAnnouncement({ d })); + await upsertReviewWithAggregate(db, { + pubkey: "pk".padEnd(64, "0"), + kind: 38000, + d, + eventId: "ev".padEnd(64, "0"), + createdAt: 1_700_000_100, + k: 38172, + a: `38172:deadbeef:${d}`, + rating: 5, + content: "", + rawTags: [], + }); + + const rows = await queryMintList(db); + expect(rows).toHaveLength(1); + // Not the synthesized zero — real aggregate from the review. + expect(rows[0]?.aggregate.reviewCount).toBe(1); + expect(rows[0]?.aggregate.ratedCount).toBe(1); + expect(rows[0]?.aggregate.avgRating).toBe(5); + expect(rows[0]?.aggregate.bayesianScore).toBeGreaterThan(0); + }); +}); + +describe("queryMintList — Cashu-only filter (spec v1)", () => { + it("Fedimint announcements (kind 38173) are excluded", async () => { + const db = await freshDB(); + const cashuD = dForSeed("cashu-one"); + // Valid Fedimint d is 64-char lowercase hex, same shape as Cashu hex. + const fediD = dForSeed("fedi-one"); + await upsertAnnouncement(db, makeCashuAnnouncement({ d: cashuD })); + await upsertAnnouncement(db, { + pubkey: `pk${fediD.slice(0, 62)}`, + kind: 38173, + d: fediD, + eventId: `ev${fediD.slice(0, 62)}`, + createdAt: 1_700_000_000, + u: ["fed11invite"], + content: "", + rawTags: [], + verifiedBySignerBinding: null, + }); + + const rows = await queryMintList(db); + expect(rows).toHaveLength(1); + expect(rows[0]?.announcement.d).toBe(cashuD); + expect(rows[0]?.announcement.kind).toBe(38172); + }); +}); + +describe("queryMintList — sort order", () => { + it("positive-bayesianScore rows sort before zero-score rows", async () => { + const db = await freshDB(); + const dReviewed = dForSeed("reviewed-mint"); + const dUnreviewed = dForSeed("unreviewed-mint"); + await upsertAnnouncement(db, makeCashuAnnouncement({ d: dReviewed })); + await upsertAnnouncement(db, makeCashuAnnouncement({ d: dUnreviewed })); + // Materialize an aggregate for the first so it has bayesianScore > 0. + await upsertReviewWithAggregate(db, { + pubkey: "pk".padEnd(64, "0"), + kind: 38000, + d: dReviewed, + eventId: "ev".padEnd(64, "0"), + createdAt: 1_700_000_100, + k: 38172, + a: `38172:deadbeef:${dReviewed}`, + rating: 5, + content: "", + rawTags: [], + }); + + const rows = await queryMintList(db); + expect(rows.map((r) => r.announcement.d)).toEqual([dReviewed, dUnreviewed]); + }); + + it("zero-score rows are ordered by announcement.createdAt DESC", async () => { + const db = await freshDB(); + const dOld = dForSeed("old-mint"); + const dNew = dForSeed("new-mint"); + await upsertAnnouncement(db, makeCashuAnnouncement({ d: dOld, createdAt: 1_000 })); + await upsertAnnouncement(db, makeCashuAnnouncement({ d: dNew, createdAt: 2_000 })); + + const rows = await queryMintList(db); + expect(rows.map((r) => r.announcement.d)).toEqual([dNew, dOld]); + }); + + it("positive scores sort DESC among themselves", async () => { + const db = await freshDB(); + const dHigh = dForSeed("high-score-d"); + const dLow = dForSeed("low-score-d"); + await upsertAnnouncement(db, makeCashuAnnouncement({ d: dHigh })); + await upsertAnnouncement(db, makeCashuAnnouncement({ d: dLow })); + // High: synthesize an aggregate with a bigger score directly to + // bypass the review-upsert arithmetic and keep the test focused on + // the sort, not the Bayesian math. + await upsertMintAggregate(db, { + d: dHigh, + reviewCount: 10, + ratedCount: 10, + avgRating: 5, + bayesianScore: 5, + updatedAt: 1, + }); + await upsertMintAggregate(db, { + d: dLow, + reviewCount: 1, + ratedCount: 1, + avgRating: 5, + bayesianScore: 1, + updatedAt: 1, + }); + + const rows = await queryMintList(db); + expect(rows.map((r) => r.announcement.d)).toEqual([dHigh, dLow]); + }); +}); + +describe("queryMintList — empty state", () => { + it("empty DB returns an empty array", async () => { + const db = await freshDB(); + expect(await queryMintList(db)).toEqual([]); + }); + + it("DB with only Fedimint announcements returns an empty array", async () => { + const db = await freshDB(); + const fediD = dForSeed("fedi-only"); + await upsertAnnouncement(db, { + pubkey: `pk${fediD.slice(0, 62)}`, + kind: 38173, + d: fediD, + eventId: `ev${fediD.slice(0, 62)}`, + createdAt: 1_700_000_000, + u: ["fed11invite"], + content: "", + rawTags: [], + verifiedBySignerBinding: null, + }); + expect(await queryMintList(db)).toEqual([]); + }); +}); + +describe("queryMintList — duplicate-d dedup", () => { + it("multiple pubkeys claiming the same d collapse to one row", async () => { + const db = await freshDB(); + const d = dForSeed("shared-d-tag"); + await upsertAnnouncement(db, { + pubkey: "pka".padEnd(64, "a"), + kind: 38172, + d, + eventId: "eva".padEnd(64, "a"), + createdAt: 1_000, + u: ["https://old.example"], + content: "", + rawTags: [], + verifiedBySignerBinding: null, + }); + await upsertAnnouncement(db, { + pubkey: "pkb".padEnd(64, "b"), + kind: 38172, + d, + eventId: "evb".padEnd(64, "b"), + createdAt: 2_000, + u: ["https://new.example"], + content: "", + rawTags: [], + verifiedBySignerBinding: null, + }); + + const rows = await queryMintList(db); + expect(rows).toHaveLength(1); + // Winner: the higher createdAt row. + expect(rows[0]?.announcement.createdAt).toBe(2_000); + }); +}); diff --git a/packages/app/src/components/mintListQuery.ts b/packages/app/src/components/mintListQuery.ts new file mode 100644 index 0000000..9ea18b5 --- /dev/null +++ b/packages/app/src/components/mintListQuery.ts @@ -0,0 +1,126 @@ +/** + * Pure data-layer query for the mint list surface. + * + * Extracted from `MintList.tsx` so the join + synthesis logic can be + * unit-tested against a fake IndexedDB without mounting React. Keeps + * `MintList.tsx` a thin render shell. + * + * Query contract: announcements-driven, kind 38172 (Cashu) only, left- + * joined against `mintAggregate` (synthesized zero-aggregate when a + * matching row is absent) and `mintInfo` (left-join, genuinely optional). + * Sort: positive-bayesianScore rows DESC first, then zero-score rows by + * announcement `createdAt` DESC. + * + * Why announcements-driven: a prior iteration drove this list from + * `rankMints(db, 50)` (the `mintAggregate` table). That's aggregate- + * required — any mint with zero reviews has no aggregate row, so it + * never surfaced. Real-world capture showed 36 announcements, 0 rendered. + * Driving from `announcements` and synthesizing absent aggregates gives + * `verifiedBySignerBinding: false` rows (and zero-review rows) a render + * path with an "unverified" badge downstream, per the v1 spec. + */ +import type { + AnnouncementRow, + BitcoinmintsDB, + MintAggregateRow, + MintInfoRow, +} from "@bitcoinmints/core"; + +export type MintListRow = { + aggregate: MintAggregateRow; + announcement: AnnouncementRow; + info: MintInfoRow | undefined; +}; + +/** + * Build a zero-populated aggregate for a mint that has no reviews yet. + * Matches `recomputeAggregateInTx`'s zero-review output shape (core's + * reviews/aggregate.ts) so downstream render code can't tell a + * synthesized row from a materialized-but-empty one. `updatedAt: 0` + * flags "synthesized placeholder" — any real materialized row uses + * `Date.now()` which is always >> 0. + */ +export function emptyAggregate(d: string): MintAggregateRow { + return { + d, + reviewCount: 0, + ratedCount: 0, + avgRating: null, + bayesianScore: 0, + updatedAt: 0, + }; +} + +/** + * Dedupe announcements by `d`, keeping the NIP-01-winning row for each + * d-tag. Multiple pubkeys CAN claim the same `d`; we pick the highest + * `createdAt`, tiebreaking on lowest `eventId` per NIP-01 §7.3. The + * per-(pubkey, d) row remains in the DB — this dedupe is display-only. + */ +function dedupeByD(announcements: AnnouncementRow[]): Map { + const byD = new Map(); + for (const a of announcements) { + const existing = byD.get(a.d); + if (!existing) { + byD.set(a.d, a); + continue; + } + if (a.createdAt > existing.createdAt) { + byD.set(a.d, a); + } else if (a.createdAt === existing.createdAt && a.eventId < existing.eventId) { + byD.set(a.d, a); + } + } + return byD; +} + +/** + * Execute the mint-list query against Dexie and return joined rows in + * the render order the UI wants. Async. Safe to call inside Dexie + * `useLiveQuery`. + */ +export async function queryMintList(db: BitcoinmintsDB): Promise { + // Cashu-only per spec v1. Pushing the kind gate into the Dexie query + // (via the `kind` secondary index on announcements) keeps the join + // cheap and avoids pulling Fedimint rows into memory only to drop + // them. Fedimints will surface via a separate toggle in a later PR. + const announcements = await db.announcements.where("kind").equals(38172).toArray(); + + if (announcements.length === 0) return []; + + const byD = dedupeByD(announcements); + const ds = Array.from(byD.keys()); + const aggregates = await db.mintAggregate.bulkGet(ds); + const infos = await db.mintInfo.bulkGet(ds); + + const joined: MintListRow[] = ds.map((d, i) => { + const announcement = byD.get(d); + if (!announcement) { + // Unreachable — `ds` came from `byD.keys()`. TS doesn't know that. + throw new Error(`invariant: no announcement for d=${d}`); + } + return { + aggregate: aggregates[i] ?? emptyAggregate(d), + announcement, + info: infos[i], + }; + }); + + // Sort: positive bayesianScore first (DESC), then zero-score rows + // by announcement.createdAt DESC. Inside each partition the sort is + // stable, so ties preserve insertion order. + joined.sort((a, b) => { + const aPositive = a.aggregate.bayesianScore > 0; + const bPositive = b.aggregate.bayesianScore > 0; + if (aPositive && bPositive) { + return b.aggregate.bayesianScore - a.aggregate.bayesianScore; + } + if (aPositive !== bPositive) { + return aPositive ? -1 : 1; + } + // Both zero-score: newest announcement first. + return b.announcement.createdAt - a.announcement.createdAt; + }); + + return joined; +} diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 39159d4..755bc86 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: false, + setupFiles: ["./vitest.setup.ts"], }, }); diff --git a/packages/app/vitest.setup.ts b/packages/app/vitest.setup.ts new file mode 100644 index 0000000..ce56ec7 --- /dev/null +++ b/packages/app/vitest.setup.ts @@ -0,0 +1,5 @@ +// Polyfill IndexedDB for the mint-list query tests. Required because +// vitest runs in Node, which has no native IndexedDB. fake-indexeddb/auto +// assigns the in-memory implementations to globalThis.indexedDB / +// IDBKeyRange / etc. Mirrors packages/core/vitest.setup.ts. +import "fake-indexeddb/auto";