From f3475d3f68ba331e9aa40ba17929144662656fd8 Mon Sep 17 00:00:00 2001 From: Maximiliano Duthey Date: Thu, 7 May 2026 11:54:46 -0300 Subject: [PATCH 01/29] docs: add M3 dashboard design spec and implementation plan (v2) Spec at docs/superpowers/specs/2026-05-07-m3-dashboard-design.md. Plan at docs/superpowers/plans/2026-05-07-m3-dashboard-implementation.md. v2 architecture: tx3-lift tracker runs as an external sidecar writing SQLite; the dashboard is a TanStack Start app whose Nitro server functions read that SQLite directly via Kysely + better-sqlite3. No Rust backend on the dashboard side. MVP scope: matches list + match detail with parties. --- .../2026-05-07-m3-dashboard-implementation.md | 495 ++++++++++++++++++ .../specs/2026-05-07-m3-dashboard-design.md | 336 ++++++++++++ 2 files changed, 831 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-07-m3-dashboard-implementation.md create mode 100644 docs/superpowers/specs/2026-05-07-m3-dashboard-design.md diff --git a/docs/superpowers/plans/2026-05-07-m3-dashboard-implementation.md b/docs/superpowers/plans/2026-05-07-m3-dashboard-implementation.md new file mode 100644 index 0000000..a4e2bab --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-m3-dashboard-implementation.md @@ -0,0 +1,495 @@ +# M3 Dashboard Implementation Plan (v2) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to execute this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Style:** declarative — each task specifies the public contract, the tests that must pass, and acceptance criteria; the implementer chooses the implementation. Code is shown literally only where it IS the artifact (SQL schema, TOML config, shell commands). + +**Goal:** Replace the M1 backend scaffold with a TanStack Start frontend that reads SQLite produced by the externally-run `tx3-lift` tracker. Ship two views (matches list + match detail) plus the documentation needed for an operator to run the system end-to-end. + +**Architecture:** Two-process topology — the tracker runs as a sidecar (built from `tx3-lang/tx3-lift`) and writes `tracker.db` (SQLite, WAL). The dashboard is a TanStack Start app whose Nitro server functions read that SQLite with Kysely + better-sqlite3. + +**Tech stack:** TypeScript, TanStack Start (Nitro SSR), TanStack Router, Tailwind 4, shadcn, Kysely, better-sqlite3, vitest, Biome, pnpm. + +--- + +## Spec reference + +Implements `docs/superpowers/specs/2026-05-07-m3-dashboard-design.md`. Two access patterns (AP-1 list, AP-2 detail), no Rust on the dashboard side, MVP scope. + +## Pre-conditions + +- Node 24 + pnpm 10 (matches frontend CI). +- `tx3-lang/tx3-lift` cloned at `../tx3-lift/` relative to this repo (used to run the tracker; not a build dependency of the dashboard). +- Rust stable (only needed to run the tracker). + +--- + +## File structure + +### Created + +``` +protocols/buidler-fest/ticketing-2026.tii committed fixture +tracker.toml tracker sidecar config +docs/architecture.md +docs/access-patterns.md +docs/running.md +frontend/src/lib/db.ts +frontend/src/lib/lifted.ts +frontend/src/lib/queries.ts +frontend/src/lib/__tests__/db.test.ts +frontend/src/lib/__tests__/lifted.test.ts +frontend/src/lib/__tests__/queries.test.ts +frontend/src/components/PartyChip.tsx +frontend/src/components/TxNamePill.tsx +``` + +### Modified + +- `README.md` — refreshed with C4 Context diagram + quick start. +- `.gitignore` — `tracker.db*` artifacts. +- `frontend/package.json` — adds Kysely + better-sqlite3. +- `frontend/src/components/Header.tsx` — slimmed. +- `frontend/src/routes/index.tsx` — rewritten as matches list. +- `frontend/src/routes/txs/$hash.tsx` — rewritten as match detail. +- `frontend/README.md` — slimmed. + +### Deleted + +- `backend/` (entire directory). +- `.github/workflows/backend-ci.yml`. +- `frontend/src/routes/txs/index.tsx`. + +--- + +## Tasks + +### Task 1: Remove the M1 backend scaffold + +**Goal:** delete the obsolete Rust backend and its CI; keep tracker artifacts out of git. + +**Files** +- Delete: `backend/`, `.github/workflows/backend-ci.yml` +- Modify: `.gitignore` — append `tracker.db`, `tracker.db-wal`, `tracker.db-shm` + +**Acceptance** +- `git ls-files backend/` and `git ls-files .github/workflows/backend-ci.yml` are empty. +- `(cd frontend && pnpm typecheck)` succeeds. +- A grep for `backend` in the surviving files turns up only documentation references that you've cleaned up (or none). + +**Commit:** `chore: remove M1 backend scaffold and its CI; v2 reads tracker SQLite directly` + +--- + +### Task 2: Vendor the demo TII and ship `tracker.toml` + +**Goal:** commit the buidler-fest TII fixture and the tracker config so an operator runs end-to-end with one extra clone. + +**Files** +- Create: `protocols/buidler-fest/ticketing-2026.tii` +- Create: `tracker.toml` (repo root) + +**TII fetch** (one-time vendor step): + +```bash +mkdir -p protocols/buidler-fest +curl -s -X POST https://api.tx3.land/graphql \ + -H 'content-type: application/json' \ + -d '{"query":"{ protocol(scope:\"buidler-fest\", name:\"ticketing-2026\") { tii } }"}' \ + | jq -r '.data.protocol.tii' \ + > protocols/buidler-fest/ticketing-2026.tii +``` + +If the registry returns the `tii` field as an inline JSON object (not a string), drop the `-r` and use `jq '.data.protocol.tii'`. Confirm: + +```bash +jq '.protocol.name, (.transactions | keys), (.profiles | keys)' protocols/buidler-fest/ticketing-2026.tii +``` +must print `"ticketing-2026"`, `["buy_ticket"]`, and a profile list including `"preview"`. + +**`tracker.toml` content** (literal): + +```toml +[upstream] +endpoint = "https://preview.utxorpc-v0.demeter.run" +intersect = "tip" +# api_key set via DMTR_API_KEY env + +[upstream.filter] +mints_policy_id = "1d9c0b541adc300c19ddc6b9fb63c0bfe32b1508305ba65b8762dc7b" + +[storage] +database_path = "./tracker.db" + +[[sources]] +name = "ticketing-2026-preview" +tii_path = "./protocols/buidler-fest/ticketing-2026.tii" +profile = "preview" +``` + +**Acceptance** +- `cd ../tx3-lift && cargo run -p tracker -- ../dashboard/tracker.toml` reaches the log line `subscribing to WatchTx endpoint=…` and creates `tracker.db` inside the dashboard repo. (Stop after one minute; full smoke is Task 12.) + +**Commit:** `feat: vendor ticketing-2026 TII and tracker.toml for the M3 demo` + +--- + +### Task 3: Frontend dependencies + +**Goal:** add Kysely + better-sqlite3 with the native build allowance pnpm requires. + +**Files**: `frontend/package.json`, `frontend/pnpm-lock.yaml`. + +**Steps** + +```bash +cd frontend +pnpm add kysely better-sqlite3 +pnpm add -D @types/better-sqlite3 +``` + +In `frontend/package.json`, append `"better-sqlite3"` to the existing `pnpm.onlyBuiltDependencies` array. + +**Acceptance** +- `pnpm install` finishes without `package … is not allowed to run scripts` warnings. +- `pnpm typecheck` succeeds. + +**Commit:** `feat(frontend): add kysely + better-sqlite3 for SSR data access` + +--- + +### Task 4: Database connection module + +**Goal:** typed read-only Kysely instance over `tracker.db`. Abstracts the file-vs-existing-instance choice for tests. + +**Files**: `frontend/src/lib/db.ts`, `frontend/src/lib/__tests__/db.test.ts`. + +**Public contract** + +```ts +interface MatchesRow { + id: number; + tx_hash: Buffer; + block_slot: number; + block_hash: Buffer; + source_name: string; + protocol_name: string; + tx_name: string; + profile_name: string; + lifted: string; + matched_at: number; +} +interface CursorRow { id: number; slot: number; block_hash: Buffer } +interface SchemaVersionRow { name: string; applied_at: number } +interface DashboardDatabase { + matches: MatchesRow; + cursor: CursorRow; + _schema_versions: SchemaVersionRow; +} + +createDb(opts?: { path?: string; existing?: BetterSqlite3.Database }): Kysely +``` + +**Behavior** +- `existing` provided → wrap it; do not open or pragma. Used by tests with `:memory:`. +- `path` provided → open `readonly: true`, `fileMustExist: true`. Set `journal_mode = WAL`. +- Neither → resolve path from `process.env.TRACKER_DB_PATH ?? './tracker.db'`, then open as above. + +**Tests** (`db.test.ts`) +- Build a `:memory:` connection seeded with the matches/cursor schema; pass it via `existing`; run a typed `selectFrom('matches').select('id').execute()`; expect `[]`. + +**Acceptance**: test passes; `pnpm typecheck` clean. + +**Commit:** `feat(frontend): Kysely-backed read-only connection to tracker.db` + +--- + +### Task 5: Lifted JSON types and helpers + +**Goal:** type the subset of `lifted` JSON the MVP renders and re-encode the byte-array fields the lifter writes (e.g. `address: [0x61, ...]`) into hex strings. + +**Files**: `frontend/src/lib/lifted.ts`, `frontend/src/lib/__tests__/lifted.test.ts`. + +**Public contract** + +```ts +interface LiftedParty { address: string; role: string } +interface Lifted { txName: string; parties: Record; raw: string } + +bytesToHex(bytes: number[]): string +truncateHex(hex: string, edge?: number): string // default edge = 6 +parseLifted(json: string): Lifted +``` + +**Behavior** +- `bytesToHex`: lowercase hex without `0x`. `[]` → `''`. Throws on non-integers or values outside `[0, 255]`. Error message contains the word "byte". +- `truncateHex(hex, edge)`: returns `hex` unchanged when `hex.length <= edge * 2`; otherwise `${hex.slice(0, edge)}…${hex.slice(-edge)}`. +- `parseLifted`: + - `JSON.parse` first (re-throws on invalid JSON). + - Reads `tx_name` (defaults to `''`). + - Iterates `parties` if present, skipping entries without an array `address`. Re-encodes `address` via `bytesToHex`. `role` is read as string (default `''`). + - Returns `{ txName, parties, raw: }`. + +**Tests** (`lifted.test.ts`) +- `bytesToHex([0xab, 0x01, 0xff])` → `'ab01ff'`. +- `bytesToHex([])` → `''`. +- `bytesToHex([300])` throws (`/byte/i`). +- `truncateHex('0123456789abcdef0123456789abcdef', 6)` → `'012345…abcdef'`. +- `truncateHex('abcd', 6)` → `'abcd'`. +- `parseLifted` with `tx_name: 'buy_ticket'` and parties `{ buyer: { address: [0x61, 0x12, 0x34], role: 'Input' }, treasury: { address: [0x61, 0xab], role: 'Output' } }` → `{ txName: 'buy_ticket', parties: { buyer: { address: '611234', role: 'Input' }, treasury: { address: '61ab', role: 'Output' } } }`. +- `parseLifted` without parties → `parties === {}`. +- `parseLifted('not json')` throws. + +**Acceptance**: 8 tests pass. + +**Commit:** `feat(frontend): lifted JSON parser + bytes-to-hex helpers` + +--- + +### Task 6: Queries — listMatches and getMatch + +**Goal:** implement AP-1 and AP-2 as typed Kysely queries returning a consumer-friendly shape. + +**Files**: `frontend/src/lib/queries.ts`, `frontend/src/lib/__tests__/queries.test.ts`. + +**Public contract** + +```ts +interface MatchRow { + id: number; + hash: string; // hex + txName: string; + protocolName: string; + profileName: string; + blockSlot: number; + matchedAt: Date; + parties: Record; + rawLifted: string; +} + +listMatches(db: Kysely, limit?: number): Promise +getMatch(db: Kysely, txHashHex: string): Promise +``` + +**Behavior** +- `listMatches`: `SELECT … FROM matches ORDER BY id DESC LIMIT ?`. Default `limit = 50`, clamped to `[1, 200]`. +- `getMatch`: `SELECT … FROM matches WHERE tx_hash = ? LIMIT 1`. Hex input must match `/^[0-9a-fA-F]+$/` and have even length — throw otherwise. Decode to `Buffer` for the bind. +- Both use `parseLifted` (Task 5) to populate `parties` and `rawLifted`. +- Both convert `matched_at` (unix seconds) to `Date`. + +**Tests** (`queries.test.ts`) +- Seed an in-memory SQLite with the schema from Task 4. Insert two rows: tx_hash `[0x01, 0x01]` (slot 100, matched_at 1700000000, lifted with parties `buyer→[0x61,0x01]`, `treasury→[0x61,0x02]`) and tx_hash `[0x02, 0x02]` (slot 110, matched_at 1700000050, parties `buyer→[0x61,0x02]`, `treasury→[0x61,0x03]`). +- `listMatches(db, 10)` → 2 rows newest-first; first row's `hash === '0202'`, `txName === 'buy_ticket'`, `parties.buyer.address === '6102'`, `matchedAt` equals `new Date(1700000050 * 1000)`. +- `listMatches(db, 1)` → 1 row. +- `getMatch(db, '0101')` → match where `parties.treasury.address === '6102'`. +- `getMatch(db, 'deadbeef')` → `null`. + +**Acceptance**: 4 tests pass. + +**Commit:** `feat(frontend): listMatches + getMatch queries` + +--- + +### Task 7: Reusable display components + +**Goal:** two small chip components used by both pages. + +**Files**: `frontend/src/components/PartyChip.tsx`, `frontend/src/components/TxNamePill.tsx`. + +**Public contract** + +```tsx +TxNamePill({ name: string }) +PartyChip({ name: string; address: string; role?: string }) +``` + +**Visual contract** +- `TxNamePill`: rounded pill, `bg-primary/10 text-primary`, small text size (xs), font-semibold. +- `PartyChip`: rounded pill with a 1.5×1.5 primary-color dot leading the row, party name (font-semibold), then address rendered through `truncateHex` in `font-mono text-muted-foreground`, then optional role suffix in tiny uppercase muted text. + +Use the existing palette tokens (`primary`, `muted-foreground`, `border`, `background`) — do not introduce new colors. + +**Acceptance**: `pnpm typecheck` clean. Visual eyeball happens via Tasks 8–9. + +**Commit:** `feat(frontend): PartyChip + TxNamePill display components` + +--- + +### Task 8: Matches list view at `/` + +**Goal:** replace the M1 placeholder with the AP-1 list. + +**Files**: `frontend/src/routes/index.tsx` (replace). + +**Behavior** +- Server function (`createServerFn({ method: 'GET' }).handler(...)`) opens a fresh `createDb()` per call, runs `listMatches(db, 50)`, calls `db.destroy()` in a `finally`. Returns `MatchRow[]` (must be JSON-serializable — `Date` is fine via TanStack Start's serialization). +- `Route.loader` invokes the server fn; component reads via `Route.useLoaderData()`. +- Render header: `

Matches

` + caption "N most recent" (right-aligned, `text-sm text-muted-foreground`). +- When `matches.length === 0`: empty-state card with the message *"No matches yet — confirm the tracker is running. See `docs/running.md`."* in a dashed-border box. +- Otherwise: a table with columns Tx (TxNamePill) · Hash (clickable `Link to /txs/$hash` with truncated hex) · Slot (`toLocaleString()`, monospace muted) · Parties (row of `PartyChip`s) · When (`matchedAt.toISOString().replace('T', ' ').slice(0, 19)`). + +**Acceptance** +- `pnpm dev` boots; `/` renders. +- Empty `tracker.db` → empty state. +- Populated `tracker.db` → rows display; clicking a hash navigates to `/txs/$hash`. + +**Commit:** `feat(frontend): matches list view at /` + +--- + +### Task 9: Match detail view at `/txs/$hash` + +**Goal:** replace the M1 placeholder with the AP-2 detail; remove the obsolete `/txs` index. + +**Files** +- Modify: `frontend/src/routes/txs/$hash.tsx` +- Delete: `frontend/src/routes/txs/index.tsx` + +**Behavior** +- Server function declared with `.validator((hash: string) => hash)` and `.handler(async ({ data: hash }) => {...})`. Body opens `createDb()`, runs `getMatch(db, hash)`, destroys in `finally`. Returns `MatchRow | null`. +- `Route.loader` calls the server fn passing `params.hash`; if result is `null`, throw TanStack Router's `notFound()`. +- Component renders: + - Header: TxNamePill + caption `${protocol_name} · ${profile_name} · slot ${block_slot.toLocaleString()}`, then the full hash in `font-mono text-sm break-all`, then `matched_at` (ISO, sliced to seconds) and a "back to list" `Link to "/"`. + - Parties section: heading `Parties (${count})`, then either a paragraph "No parties annotated for this match." (when empty) or a wrapped row of ``s for each entry of `parties`, passing `role`. + - Collapsible `
` titled "Raw lifted JSON (debug)" containing `JSON.stringify(JSON.parse(rawLifted), null, 2)` in a `
`.
+
+**Acceptance**
+- Following a row from `/` lands on the detail with parties filled.
+- Hitting an unknown hash returns 404 (TanStack Router handles `notFound()`).
+- The "Raw lifted JSON" accordion expands to valid pretty JSON.
+
+**Commit:** `feat(frontend): match detail view; remove /txs index`
+
+---
+
+### Task 10: Slim the Header
+
+**Goal:** drop the now-irrelevant Transactions nav link.
+
+**Files**: `frontend/src/components/Header.tsx`.
+
+**Final shape**: brand `Link to "/"`, a small "Matches view" caption (`text-xs text-muted-foreground`), `` pushed right via `ml-auto`. No `