diff --git a/docs/superpowers/plans/2026-07-30-rfd-core-library.md b/docs/superpowers/plans/2026-07-30-rfd-core-library.md new file mode 100644 index 0000000..2f575ce --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-rfd-core-library.md @@ -0,0 +1,1448 @@ +# @rfd/core Library Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `@rfd/core`, a headless, transport-agnostic TypeScript library that reads a single Tangled RFD repo from a failover pool of bobbin appview instances and exposes plain-data methods for the web app (and a future CLI) to consume. + +**Architecture:** A new `packages/core` workspace package. A resilient bobbin XRPC client (`BobbinPool`) with sticky-primary failover and health gating sits under typed read methods. Identity + repo resolution turns an owner handle/DID into the repo's `repoDid` (the scoping key). Pure domain helpers (proposal status derivation, slug parsing, diff parsing) are isolated and table-tested. A `createRfd()` factory wires it together. Proposal *file assembly* (git-proxy) is deliberately out of scope here — it lands in the `www` plan once the git-proxy repo-param form is confirmed. + +**Tech Stack:** TypeScript (ESM), Vitest, `@atcute/identity-resolver` (handle/DID resolution), pnpm workspaces. No Astro, Hono, DOM, or Cloudflare dependencies. + +**Spec:** `docs/superpowers/specs/2026-07-30-rfd-bobbin-thin-client-design.md` + +--- + +## File Structure + +``` +packages/core/ + package.json # @rfd/core, ESM, vitest + tsconfig.json # extends root + src/ + index.ts # createRfd() factory + public re-exports + config.ts # RfdConfig + parseConfig(env) + types.ts # hand-authored record + envelope types + at-uri.ts # parseAtUri (moved from www index-event.ts) + patch.ts # pure diff parsing (moved verbatim from www) + bobbin.ts # BobbinPool: resilient XRPC GET client + resolve.ts # owner handle/DID -> repoDid context + proposal.ts # pure status/slug helpers (file assembly deferred to Plan 2) + test/ + fixtures/ + getRepo.json + listPulls.json + listComments.json + getCoverage.json + config.test.ts + at-uri.test.ts + patch.test.ts + bobbin.test.ts + resolve.test.ts + proposal.test.ts +``` + +Each `src` file has one responsibility. `bobbin.ts` is transport, `resolve.ts` is identity, `proposal.ts` is pure domain logic, `types.ts` is shape declarations only. + +--- + +## Task 1: Scaffold the `@rfd/core` package + +**Files:** +- Create: `packages/core/package.json` +- Create: `packages/core/tsconfig.json` +- Create: `packages/core/src/index.ts` +- Create: `packages/core/test/smoke.test.ts` + +- [ ] **Step 1: Create `packages/core/package.json`** + +```json +{ + "name": "@rfd/core", + "type": "module", + "version": "0.0.1", + "devEngines": { + "node": ">=24.15.0" + }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@atcute/identity-resolver": "^2.0.0" + }, + "devDependencies": { + "vitest": "^4.0.0" + } +} +``` + +- [ ] **Step 2: Create `packages/core/tsconfig.json`** + +`allowImportingTsExtensions` + `noEmit` are required because every import uses an explicit +`.ts` extension (matching the existing `lexicon`/`www` convention). If the extended base +already sets them, they are harmless to repeat. + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src", "test"] +} +``` + +- [ ] **Step 3: Create `packages/core/src/index.ts` placeholder** + +```ts +export const RFD_CORE_VERSION = '0.0.1'; +``` + +- [ ] **Step 4: Create `packages/core/test/smoke.test.ts`** + +```ts +import { expect, test } from 'vitest'; +import { RFD_CORE_VERSION } from '../src/index.ts'; + +test('package is importable', () => { + expect(RFD_CORE_VERSION).toBe('0.0.1'); +}); +``` + +- [ ] **Step 5: Install and run the smoke test** + +Run: `pnpm install && pnpm --filter @rfd/core test` +Expected: 1 passing test. If pnpm reports the package isn't found, confirm `pnpm-workspace.yaml` globs `packages/**` (it does) and that `pnpm install` linked the new package. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core pnpm-lock.yaml +git commit -m "feat(core): scaffold @rfd/core package" +``` + +--- + +## Task 2: `parseAtUri` helper + +**Files:** +- Create: `packages/core/src/at-uri.ts` +- Create: `packages/core/test/at-uri.test.ts` + +- [ ] **Step 1: Write the failing test** — `packages/core/test/at-uri.test.ts` + +```ts +import { expect, test } from 'vitest'; +import { parseAtUri } from '../src/at-uri.ts'; + +test('parses a well-formed at-uri', () => { + expect(parseAtUri('at://did:plc:abc/sh.tangled.repo.pull/3lz')).toEqual({ + did: 'did:plc:abc', + collection: 'sh.tangled.repo.pull', + rkey: '3lz', + }); +}); + +test('supports multi-segment rkeys', () => { + expect(parseAtUri('at://did:plc:abc/coll/a/b')?.rkey).toBe('a/b'); +}); + +test('returns null for non-at-uris and short uris', () => { + expect(parseAtUri('https://example.com')).toBeNull(); + expect(parseAtUri('at://did:plc:abc')).toBeNull(); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @rfd/core test at-uri` +Expected: FAIL — cannot find module `../src/at-uri.ts`. + +- [ ] **Step 3: Implement `packages/core/src/at-uri.ts`** + +```ts +export interface ParsedAtUri { + did: string; + collection: string; + rkey: string; +} + +export function parseAtUri(uri: string): ParsedAtUri | null { + if (!uri.startsWith('at://')) return null; + const rest = uri.slice('at://'.length); + const parts = rest.split('/'); + if (parts.length < 3) return null; + const [did, collection, ...rkeyParts] = parts; + if (!did || !collection || rkeyParts.length === 0) return null; + return { did, collection, rkey: rkeyParts.join('/') }; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @rfd/core test at-uri` +Expected: 3 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/at-uri.ts packages/core/test/at-uri.test.ts +git commit -m "feat(core): add parseAtUri" +``` + +--- + +## Task 3: Move pure diff parsing into core + +**Files:** +- Create: `packages/core/src/patch.ts` +- Create: `packages/core/test/patch.test.ts` + +The source is `packages/www/src/lib/patch.ts` (pure; no changes needed). We copy it into core now; the www copy is deleted in the distribution plan. + +- [ ] **Step 1: Copy the failing test** — copy `packages/www/test/patch.test.ts` to `packages/core/test/patch.test.ts` and fix the import path so the first line reads: + +```ts +import { + extractMarkdownFileFromDiff, + extractMarkdownFromDiff, + listMarkdownFilesInDiff, +} from '../src/patch.ts'; +``` + +(Keep the rest of the test body identical to the www version.) + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @rfd/core test patch` +Expected: FAIL — cannot find module `../src/patch.ts`. + +- [ ] **Step 3: Copy the implementation** — copy `packages/www/src/lib/patch.ts` verbatim to `packages/core/src/patch.ts`. No edits (it uses only `Response`, `DecompressionStream`, `TextDecoder`, all available on Node ≥24). + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @rfd/core test patch` +Expected: all patch tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/patch.ts packages/core/test/patch.test.ts +git commit -m "feat(core): move pure diff parsing into core" +``` + +--- + +## Task 4: Record + envelope types + +**Files:** +- Create: `packages/core/src/types.ts` +- Create: `packages/core/test/types.test.ts` + +Types are compile-checked; the test asserts a couple of shapes to keep them exercised. + +- [ ] **Step 1: Write the failing test** — `packages/core/test/types.test.ts` + +```ts +import { expectTypeOf, test } from 'vitest'; +import type { + CommentRecord, + PullListItem, + PullState, + RecordEnvelope, + RepoRecord, +} from '../src/types.ts'; + +test('PullListItem carries aggregated state + commentCount', () => { + expectTypeOf().toMatchObjectType<{ + uri: string; + state: PullState; + commentCount: number; + }>(); +}); + +test('RepoRecord exposes repoDid and knot', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +}); + +test('a comment envelope wraps a CommentRecord', () => { + expectTypeOf['value']['body']['original']>() + .toEqualTypeOf(); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @rfd/core test types` +Expected: FAIL — cannot find module `../src/types.ts`. + +- [ ] **Step 3: Implement `packages/core/src/types.ts`** + +Shapes are from the live spike against `api.tangled.org` (2026-07-30). + +```ts +/** Envelope bobbin returns for a single record. */ +export interface RecordEnvelope { + uri: string; + cid: string; + value: T; +} + +/** Paginated list response shape used across list* methods. */ +export interface ListResponse { + items: T[]; + cursor: string | null; +} + +export type PullState = 'open' | 'closed' | 'merged'; + +/** sh.tangled.repo */ +export interface RepoRecord { + $type: 'sh.tangled.repo'; + name: string; + knot: string; + description?: string; + createdAt: string; + /** Knot-assigned DID for the repo; the scoping key for issues/pulls. */ + repoDid?: string; + labels?: string[]; +} + +/** One patch round on a pull. */ +export interface PullRound { + createdAt: string; + patchBlob?: { ref?: { $link?: string } }; +} + +/** sh.tangled.repo.pull */ +export interface PullRecord { + $type: 'sh.tangled.repo.pull'; + title: string; + body?: string; + target: { repo: string; branch: string }; + source?: { repo?: string; branch?: string }; + rounds?: PullRound[]; + createdAt: string; +} + +/** sh.tangled.repo.issue */ +export interface IssueRecord { + $type: 'sh.tangled.repo.issue'; + title: string; + body?: string; + repo: string; + createdAt: string; +} + +/** sh.tangled.feed.comment — the current unified comment model. */ +export interface CommentRecord { + $type: 'sh.tangled.feed.comment'; + subject: string; + body: { original: string }; + createdAt: string; +} + +/** Item shape from sh.tangled.repo.listPulls (aggregated fields alongside the record). */ +export interface PullListItem extends RecordEnvelope { + state: PullState; + commentCount: number; +} + +export type IssueListItem = RecordEnvelope; +export type CommentListItem = RecordEnvelope; +export type RepoListItem = RecordEnvelope; + +export interface Coverage { + ready: boolean; + eventsProcessed: number; + lastCursor: number; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @rfd/core test types` +Expected: 3 passing type tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/types.ts packages/core/test/types.test.ts +git commit -m "feat(core): add record + envelope types" +``` + +--- + +## Task 5: Config parsing + +**Files:** +- Create: `packages/core/src/config.ts` +- Create: `packages/core/test/config.test.ts` + +- [ ] **Step 1: Write the failing test** — `packages/core/test/config.test.ts` + +```ts +import { expect, test } from 'vitest'; +import { parseConfig } from '../src/config.ts'; + +test('applies defaults with only RFD_OWNER set', () => { + const cfg = parseConfig({ RFD_OWNER: 'natemoo.re' }); + expect(cfg).toEqual({ + owner: 'natemoo.re', + repoName: 'rfd', + bobbinUrls: ['https://api.tangled.org'], + cacheTtlSeconds: 60, + }); +}); + +test('parses a comma-separated, preference-ordered bobbin pool', () => { + const cfg = parseConfig({ + RFD_OWNER: 'did:plc:abc', + BOBBIN_URL: 'https://a.example , https://b.example', + }); + expect(cfg.bobbinUrls).toEqual(['https://a.example', 'https://b.example']); +}); + +test('coerces RFD_CACHE_TTL and RFD_REPO_NAME', () => { + const cfg = parseConfig({ RFD_OWNER: 'x', RFD_REPO_NAME: 'rfc', RFD_CACHE_TTL: '15' }); + expect(cfg.repoName).toBe('rfc'); + expect(cfg.cacheTtlSeconds).toBe(15); +}); + +test('throws when RFD_OWNER is missing', () => { + expect(() => parseConfig({})).toThrow(/RFD_OWNER/); +}); + +test('throws on a non-numeric RFD_CACHE_TTL', () => { + expect(() => parseConfig({ RFD_OWNER: 'x', RFD_CACHE_TTL: 'soon' })).toThrow(/RFD_CACHE_TTL/); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @rfd/core test config` +Expected: FAIL — cannot find module `../src/config.ts`. + +- [ ] **Step 3: Implement `packages/core/src/config.ts`** + +```ts +export interface RfdConfig { + owner: string; + repoName: string; + bobbinUrls: string[]; + cacheTtlSeconds: number; +} + +export type Env = Record; + +const DEFAULT_BOBBIN = 'https://api.tangled.org'; + +export function parseConfig(env: Env): RfdConfig { + const owner = env.RFD_OWNER?.trim(); + if (!owner) { + throw new Error('RFD_OWNER is required (the handle or DID of the RFD repo owner)'); + } + + const bobbinUrls = (env.BOBBIN_URL ?? DEFAULT_BOBBIN) + .split(',') + .map((u) => u.trim()) + .filter((u) => u.length > 0); + if (bobbinUrls.length === 0) { + throw new Error('BOBBIN_URL must contain at least one URL'); + } + + const ttlRaw = env.RFD_CACHE_TTL ?? '60'; + const cacheTtlSeconds = Number(ttlRaw); + if (!Number.isFinite(cacheTtlSeconds) || cacheTtlSeconds < 0) { + throw new Error(`RFD_CACHE_TTL must be a non-negative number, got: ${ttlRaw}`); + } + + return { + owner, + repoName: env.RFD_REPO_NAME?.trim() || 'rfd', + bobbinUrls, + cacheTtlSeconds, + }; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @rfd/core test config` +Expected: 5 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/config.ts packages/core/test/config.test.ts +git commit -m "feat(core): add config parsing" +``` + +--- + +## Task 6: Capture bobbin fixtures + +**Files:** +- Create: `packages/core/test/fixtures/getRepo.json` +- Create: `packages/core/test/fixtures/listPulls.json` +- Create: `packages/core/test/fixtures/listComments.json` +- Create: `packages/core/test/fixtures/getCoverage.json` + +These are trimmed real responses used by later tests. No test of their own. + +- [ ] **Step 1: Create `getRepo.json`** (trimmed real shape) + +```json +{ + "cid": "bafyreidehocneckziff4uffajjqyhqgwxxze364qhcaxtfvv46t4avdqku", + "uri": "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.repo/core", + "value": { + "$type": "sh.tangled.repo", + "createdAt": "2025-02-23T18:43:18Z", + "description": "Monorepo for Tangled", + "knot": "knot1.tangled.sh", + "name": "core", + "repoDid": "did:plc:j5hmlfdrwkvtxm7cjmu7j2is" + } +} +``` + +- [ ] **Step 2: Create `listPulls.json`** (two items; note top-level `state` + `commentCount`) + +```json +{ + "items": [ + { + "uri": "at://did:plc:kic7mqihegzbj2ojesltkvho/sh.tangled.repo.pull/3mrn7dysfp522", + "cid": "bafyreici556u3cnkdqcim4wsydvjnrwvzavrdr4qt4achgtkgn5g3eim34", + "state": "open", + "commentCount": 2, + "value": { + "$type": "sh.tangled.repo.pull", + "title": "improve repo index performance", + "body": "exploratory work", + "createdAt": "2026-06-01T10:00:00Z", + "target": { "repo": "did:plc:j5hmlfdrwkvtxm7cjmu7j2is", "branch": "master" }, + "rounds": [ + { "createdAt": "2026-06-01T10:00:00Z", "patchBlob": { "ref": { "$link": "bafypatch1" } } } + ] + } + }, + { + "uri": "at://did:plc:kic7mqihegzbj2ojesltkvho/sh.tangled.repo.pull/3mrn7dysfp600", + "cid": "bafyreicid600", + "state": "merged", + "commentCount": 0, + "value": { + "$type": "sh.tangled.repo.pull", + "title": "0002 add governance doc", + "createdAt": "2026-05-01T10:00:00Z", + "target": { "repo": "did:plc:j5hmlfdrwkvtxm7cjmu7j2is", "branch": "master" }, + "rounds": [ + { "createdAt": "2026-05-01T10:00:00Z", "patchBlob": { "ref": { "$link": "bafypatch2" } } } + ] + } + } + ], + "cursor": null +} +``` + +- [ ] **Step 3: Create `listComments.json`** + +```json +{ + "items": [ + { + "uri": "at://did:plc:qfpnj4og54vl56wngdriaxug/sh.tangled.feed.comment/3mrp4r3uhpa22", + "cid": "bafyreiabuj5zezrksmkt5vhhrntpzy6crgwiggtnni2wchn6zyw6ovueju", + "value": { + "$type": "sh.tangled.feed.comment", + "subject": "at://did:plc:kic7mqihegzbj2ojesltkvho/sh.tangled.repo.pull/3mrn7dysfp522", + "body": { "original": "yes that is correct!" }, + "createdAt": "2026-06-01T11:00:00Z" + } + } + ], + "cursor": null +} +``` + +- [ ] **Step 4: Create `getCoverage.json`** + +```json +{ "ready": true, "eventsProcessed": 135389, "lastCursor": 158872 } +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/test/fixtures +git commit -m "test(core): add captured bobbin fixtures" +``` + +--- + +## Task 7: `BobbinPool` — resilient XRPC client with failover + +**Files:** +- Create: `packages/core/src/bobbin.ts` +- Create: `packages/core/test/bobbin.test.ts` + +The pool exposes `get(method, params)`. It tries the sticky instance first, fails over on network error / timeout / 5xx, health-gates *new* instances via `getCoverage`, and makes the last successful instance sticky. `fetchImpl` and `nowMs` are injected for testing. + +- [ ] **Step 1: Write the failing test** — `packages/core/test/bobbin.test.ts` + +```ts +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from 'vitest'; +import { createBobbinPool } from '../src/bobbin.ts'; + +const coverage = JSON.parse( + readFileSync(fileURLToPath(new URL('./fixtures/getCoverage.json', import.meta.url)), 'utf8'), +); +const repo = JSON.parse( + readFileSync(fileURLToPath(new URL('./fixtures/getRepo.json', import.meta.url)), 'utf8'), +); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +/** Build a fetch stub keyed by "|". */ +function stubFetch(routes: Record Promise | Response>) { + const calls: string[] = []; + const fetchImpl = async (input: string | URL): Promise => { + const url = new URL(String(input)); + const nsid = url.pathname.replace('/xrpc/', ''); + const key = `${url.origin}|${nsid}`; + calls.push(key); + const handler = routes[key]; + if (!handler) throw new TypeError(`no route for ${key}`); + return handler(); + }; + return { fetchImpl, calls }; +} + +test('returns data from the primary without health-checking it', async () => { + const { fetchImpl, calls } = stubFetch({ + 'https://a.test|sh.tangled.repo.getRepo': () => jsonResponse(repo), + }); + const pool = createBobbinPool({ urls: ['https://a.test'], fetchImpl }); + const out = await pool.get('sh.tangled.repo.getRepo', { repo: 'at://x' }); + expect(out).toEqual(repo); + // Primary is used directly — no coverage probe. + expect(calls).toEqual(['https://a.test|sh.tangled.repo.getRepo']); +}); + +test('fails over to a healthy secondary when the primary errors', async () => { + const { fetchImpl, calls } = stubFetch({ + 'https://a.test|sh.tangled.repo.getRepo': () => jsonResponse({ error: 'boom' }, 500), + 'https://b.test|sh.tangled.bobbin.getCoverage': () => jsonResponse(coverage), + 'https://b.test|sh.tangled.repo.getRepo': () => jsonResponse(repo), + }); + const pool = createBobbinPool({ urls: ['https://a.test', 'https://b.test'], fetchImpl }); + const out = await pool.get('sh.tangled.repo.getRepo', { repo: 'at://x' }); + expect(out).toEqual(repo); + // Secondary was health-gated before use. + expect(calls).toContain('https://b.test|sh.tangled.bobbin.getCoverage'); +}); + +test('skips a secondary that reports not-ready', async () => { + const { fetchImpl } = stubFetch({ + 'https://a.test|sh.tangled.repo.getRepo': () => jsonResponse({ error: 'boom' }, 500), + 'https://b.test|sh.tangled.bobbin.getCoverage': () => jsonResponse({ ready: false }), + 'https://c.test|sh.tangled.bobbin.getCoverage': () => jsonResponse(coverage), + 'https://c.test|sh.tangled.repo.getRepo': () => jsonResponse(repo), + }); + const pool = createBobbinPool({ + urls: ['https://a.test', 'https://b.test', 'https://c.test'], + fetchImpl, + }); + expect(await pool.get('sh.tangled.repo.getRepo', { repo: 'at://x' })).toEqual(repo); +}); + +test('throws when every instance is exhausted', async () => { + const { fetchImpl } = stubFetch({ + 'https://a.test|sh.tangled.repo.getRepo': () => jsonResponse({ error: 'boom' }, 500), + 'https://b.test|sh.tangled.bobbin.getCoverage': () => jsonResponse({ ready: false }), + }); + const pool = createBobbinPool({ urls: ['https://a.test', 'https://b.test'], fetchImpl }); + await expect(pool.get('sh.tangled.repo.getRepo', { repo: 'at://x' })).rejects.toThrow( + /all bobbin instances failed/i, + ); +}); + +test('makes the last successful instance sticky', async () => { + let aFails = true; + const { fetchImpl, calls } = stubFetch({ + 'https://a.test|sh.tangled.repo.getRepo': () => + aFails ? jsonResponse({ error: 'boom' }, 500) : jsonResponse(repo), + 'https://b.test|sh.tangled.bobbin.getCoverage': () => jsonResponse(coverage), + 'https://b.test|sh.tangled.repo.getRepo': () => jsonResponse(repo), + }); + const pool = createBobbinPool({ urls: ['https://a.test', 'https://b.test'], fetchImpl }); + await pool.get('sh.tangled.repo.getRepo', {}); // fails over to b, b becomes sticky + calls.length = 0; + await pool.get('sh.tangled.repo.getRepo', {}); // should start at b now + expect(calls[0]).toBe('https://b.test|sh.tangled.repo.getRepo'); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @rfd/core test bobbin` +Expected: FAIL — cannot find module `../src/bobbin.ts`. + +- [ ] **Step 3: Implement `packages/core/src/bobbin.ts`** + +```ts +import type { Coverage } from './types.ts'; + +export type FetchImpl = (input: string | URL, init?: RequestInit) => Promise; + +export interface BobbinPoolOptions { + urls: string[]; + fetchImpl?: FetchImpl; + /** Per-request timeout in ms. */ + timeoutMs?: number; + /** How long a health-check result is trusted, in ms. */ + healthTtlMs?: number; + /** Injectable clock for tests. */ + nowMs?: () => number; +} + +export interface BobbinPool { + get(method: string, params?: Record): Promise; +} + +interface HealthEntry { + ready: boolean; + checkedAtMs: number; +} + +function buildUrl(base: string, method: string, params: Record): string { + const url = new URL(`/xrpc/${method}`, base); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + return url.toString(); +} + +export function createBobbinPool(options: BobbinPoolOptions): BobbinPool { + const { + urls, + fetchImpl = fetch, + timeoutMs = 10_000, + healthTtlMs = 30_000, + nowMs = () => Date.now(), + } = options; + + if (urls.length === 0) throw new Error('createBobbinPool requires at least one URL'); + + let stickyIndex = 0; + const health = new Map(); + + async function rawGet(base: string, method: string, params: Record): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(buildUrl(base, method, params), { signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + + async function isReady(base: string): Promise { + const cached = health.get(base); + if (cached && nowMs() - cached.checkedAtMs < healthTtlMs) return cached.ready; + let ready = false; + try { + const res = await rawGet(base, 'sh.tangled.bobbin.getCoverage', {}); + if (res.ok) ready = ((await res.json()) as Coverage).ready === true; + } catch { + ready = false; + } + health.set(base, { ready, checkedAtMs: nowMs() }); + return ready; + } + + async function get(method: string, params: Record = {}): Promise { + // Ordered candidate list beginning at the sticky instance. + const order = urls.map((_, i) => (stickyIndex + i) % urls.length); + let lastError: unknown; + + for (let pos = 0; pos < order.length; pos++) { + const idx = order[pos]!; + const base = urls[idx]!; + // The sticky primary (pos 0) is used directly; failover targets are health-gated. + if (pos > 0 && !(await isReady(base))) continue; + try { + const res = await rawGet(base, method, params); + if (res.status >= 500) { + lastError = new Error(`${base} ${method} -> ${res.status}`); + continue; + } + if (!res.ok) { + // 4xx is a request-level error, not an instance failure — surface it. + throw new Error(`${base} ${method} -> ${res.status} ${await res.text()}`); + } + stickyIndex = idx; + return (await res.json()) as T; + } catch (err) { + if (err instanceof Error && err.message.includes('-> 4')) throw err; + lastError = err; + } + } + + throw new Error(`all bobbin instances failed for ${method}: ${String(lastError)}`); + } + + return { get }; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @rfd/core test bobbin` +Expected: 5 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/bobbin.ts packages/core/test/bobbin.test.ts +git commit -m "feat(core): add resilient BobbinPool with failover + health gating" +``` + +--- + +## Task 8: Typed bobbin read methods + +**Files:** +- Create: `packages/core/src/reads.ts` +- Create: `packages/core/test/reads.test.ts` + +Thin typed wrappers over `pool.get` for the repo-scoped read surface. + +- [ ] **Step 1: Write the failing test** — `packages/core/test/reads.test.ts` + +```ts +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { expect, test, vi } from 'vitest'; +import { createReads } from '../src/reads.ts'; + +const listPulls = JSON.parse( + readFileSync(fileURLToPath(new URL('./fixtures/listPulls.json', import.meta.url)), 'utf8'), +); +const listComments = JSON.parse( + readFileSync(fileURLToPath(new URL('./fixtures/listComments.json', import.meta.url)), 'utf8'), +); + +test('listPulls passes repoDid as subject and returns typed items', async () => { + const get = vi.fn().mockResolvedValue(listPulls); + const reads = createReads({ get }); + const out = await reads.listPulls('did:plc:repo'); + expect(get).toHaveBeenCalledWith('sh.tangled.repo.listPulls', { + subject: 'did:plc:repo', + limit: 100, + cursor: undefined, + }); + expect(out.items[0].state).toBe('open'); + expect(out.items[0].commentCount).toBe(2); +}); + +test('getDiscussion lists feed comments by subject uri', async () => { + const get = vi.fn().mockResolvedValue(listComments); + const reads = createReads({ get }); + const out = await reads.getDiscussion('at://did:plc:x/sh.tangled.repo.pull/1'); + expect(get).toHaveBeenCalledWith('sh.tangled.feed.listComments', { + subject: 'at://did:plc:x/sh.tangled.repo.pull/1', + limit: 100, + cursor: undefined, + }); + expect(out.items[0].value.body.original).toBe('yes that is correct!'); +}); + +test('getPull fetches a single record by uri', async () => { + const get = vi.fn().mockResolvedValue(listPulls.items[0]); + const reads = createReads({ get }); + await reads.getPull('at://did:plc:x/sh.tangled.repo.pull/1'); + expect(get).toHaveBeenCalledWith('sh.tangled.repo.getPull', { + pull: 'at://did:plc:x/sh.tangled.repo.pull/1', + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @rfd/core test reads` +Expected: FAIL — cannot find module `../src/reads.ts`. + +- [ ] **Step 3: Implement `packages/core/src/reads.ts`** + +```ts +import type { + CommentListItem, + IssueListItem, + IssueRecord, + ListResponse, + PullListItem, + PullRecord, + RecordEnvelope, + RepoListItem, + RepoRecord, +} from './types.ts'; + +export interface Getter { + get(method: string, params?: Record): Promise; +} + +const PAGE = 100; + +export function createReads(pool: Getter) { + return { + listRepos(ownerDid: string, cursor?: string) { + return pool.get>('sh.tangled.repo.listRepos', { + subject: ownerDid, + limit: PAGE, + cursor, + }); + }, + getRepo(repoUri: string) { + return pool.get>('sh.tangled.repo.getRepo', { repo: repoUri }); + }, + listPulls(repoDid: string, cursor?: string) { + return pool.get>('sh.tangled.repo.listPulls', { + subject: repoDid, + limit: PAGE, + cursor, + }); + }, + listIssues(repoDid: string, cursor?: string) { + return pool.get>('sh.tangled.repo.listIssues', { + subject: repoDid, + limit: PAGE, + cursor, + }); + }, + getPull(pullUri: string) { + return pool.get>('sh.tangled.repo.getPull', { pull: pullUri }); + }, + getIssue(issueUri: string) { + return pool.get>('sh.tangled.repo.getIssue', { issue: issueUri }); + }, + getDiscussion(subjectUri: string, cursor?: string) { + return pool.get>('sh.tangled.feed.listComments', { + subject: subjectUri, + limit: PAGE, + cursor, + }); + }, + }; +} + +export type Reads = ReturnType; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @rfd/core test reads` +Expected: 3 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/reads.ts packages/core/test/reads.test.ts +git commit -m "feat(core): add typed bobbin read methods" +``` + +--- + +## Task 9: Owner + repo resolution + +**Files:** +- Create: `packages/core/src/resolve.ts` +- Create: `packages/core/test/resolve.test.ts` + +Turn an owner handle/DID + repo name into a `RepoContext` (ownerDid, repoUri, repoDid, knot). Identity resolution is injected so the test needs no network. + +- [ ] **Step 1: Write the failing test** — `packages/core/test/resolve.test.ts` + +```ts +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { expect, test, vi } from 'vitest'; +import { NoRfdRepoError, resolveRepoContext } from '../src/resolve.ts'; + +const repo = JSON.parse( + readFileSync(fileURLToPath(new URL('./fixtures/getRepo.json', import.meta.url)), 'utf8'), +); + +function readsStub(repos: unknown[]) { + return { + listRepos: vi.fn().mockResolvedValue({ items: repos, cursor: null }), + } as any; +} + +test('resolves a handle to did, then finds the named repo', async () => { + const identity = { resolveDid: vi.fn().mockResolvedValue('did:plc:owner') }; + const reads = readsStub([ + { uri: 'at://did:plc:owner/sh.tangled.repo/other', value: { name: 'other', knot: 'k', repoDid: 'did:plc:x' } }, + { uri: 'at://did:plc:owner/sh.tangled.repo/rfd', value: { name: 'rfd', knot: 'knot1', repoDid: 'did:plc:repo' } }, + ]); + const ctx = await resolveRepoContext({ reads, identity, owner: 'natemoo.re', repoName: 'rfd' }); + expect(identity.resolveDid).toHaveBeenCalledWith('natemoo.re'); + expect(reads.listRepos).toHaveBeenCalledWith('did:plc:owner', undefined); + expect(ctx).toEqual({ + ownerDid: 'did:plc:owner', + repoUri: 'at://did:plc:owner/sh.tangled.repo/rfd', + repoDid: 'did:plc:repo', + knot: 'knot1', + name: 'rfd', + }); +}); + +test('accepts a DID owner without identity resolution', async () => { + const identity = { resolveDid: vi.fn() }; + const reads = readsStub([repo]); // fixture name is "core" + const ctx = await resolveRepoContext({ reads, identity, owner: 'did:plc:wshs7t2adsemcrrd4snkeqli', repoName: 'core' }); + expect(identity.resolveDid).not.toHaveBeenCalled(); + expect(ctx.repoDid).toBe('did:plc:j5hmlfdrwkvtxm7cjmu7j2is'); +}); + +test('throws NoRfdRepoError when no repo matches the name', async () => { + const identity = { resolveDid: vi.fn().mockResolvedValue('did:plc:owner') }; + const reads = readsStub([ + { uri: 'at://x', value: { name: 'notrfd', knot: 'k', repoDid: 'did:plc:x' } }, + ]); + await expect( + resolveRepoContext({ reads, identity, owner: 'x', repoName: 'rfd' }), + ).rejects.toBeInstanceOf(NoRfdRepoError); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @rfd/core test resolve` +Expected: FAIL — cannot find module `../src/resolve.ts`. + +- [ ] **Step 3: Implement `packages/core/src/resolve.ts`** + +```ts +import type { Reads } from './reads.ts'; + +export interface Identity { + /** Resolve a handle to a DID. */ + resolveDid(handle: string): Promise; +} + +export interface RepoContext { + ownerDid: string; + repoUri: string; + repoDid: string; + knot: string; + name: string; +} + +export class NoRfdRepoError extends Error { + override name = 'NoRfdRepoError'; + constructor(owner: string, repoName: string) { + super(`no repo named "${repoName}" found for ${owner}`); + } +} + +export interface ResolveArgs { + reads: Pick; + identity: Identity; + owner: string; + repoName: string; +} + +function isDid(value: string): boolean { + return value.startsWith('did:'); +} + +export async function resolveRepoContext(args: ResolveArgs): Promise { + const { reads, identity, owner, repoName } = args; + const ownerDid = isDid(owner) ? owner : await identity.resolveDid(owner); + + let cursor: string | undefined; + do { + const page = await reads.listRepos(ownerDid, cursor); + for (const item of page.items) { + if (item.value.name === repoName) { + const repoDid = item.value.repoDid; + if (!repoDid) { + throw new Error(`repo "${repoName}" has no repoDid; cannot scope reads`); + } + return { + ownerDid, + repoUri: item.uri, + repoDid, + knot: item.value.knot, + name: item.value.name, + }; + } + } + cursor = page.cursor ?? undefined; + } while (cursor); + + throw new NoRfdRepoError(owner, repoName); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @rfd/core test resolve` +Expected: 3 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/resolve.ts packages/core/test/resolve.test.ts +git commit -m "feat(core): resolve owner + repo name to a repoDid context" +``` + +--- + +## Task 10: Pure proposal helpers (status + slug) + +**Files:** +- Create: `packages/core/src/proposal.ts` +- Create: `packages/core/test/proposal.test.ts` + +Pure functions only. File assembly (git-proxy tree/blob + patch reads) is Plan 2. The status logic is ported verbatim from `packages/www/src/lib/proposal.ts`. + +- [ ] **Step 1: Write the failing test** — `packages/core/test/proposal.test.ts` + +```ts +import { expect, test } from 'vitest'; +import { deriveStatus, fileNameToSlug, isProposalFile } from '../src/proposal.ts'; + +test('recognises numbered proposal filenames', () => { + expect(isProposalFile('0001.md')).toBe(true); + expect(isProposalFile('0002-governance.md')).toBe(true); + expect(isProposalFile('README.md')).toBe(false); + expect(isProposalFile('0001.txt')).toBe(false); +}); + +test('derives slug from filename', () => { + expect(fileNameToSlug('0002-governance.md')).toBe('0002-governance'); + expect(fileNameToSlug('notes.txt')).toBeNull(); +}); + +test('an open pull means discussion', () => { + expect(deriveStatus({ onDefaultBranch: false, pulls: [{ state: 'open' }] })).toBe('discussion'); +}); + +test('default branch + a merged pull means published', () => { + expect(deriveStatus({ onDefaultBranch: true, pulls: [{ state: 'merged' }] })).toBe('published'); +}); + +test('default branch with no relevant merged pull means committed', () => { + expect(deriveStatus({ onDefaultBranch: true, pulls: [] })).toBe('committed'); +}); + +test('all pulls closed off the default branch means abandoned', () => { + expect(deriveStatus({ onDefaultBranch: false, pulls: [{ state: 'closed' }] })).toBe('abandoned'); +}); + +test('nothing known means unknown', () => { + expect(deriveStatus({ onDefaultBranch: false, pulls: [] })).toBe('unknown'); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @rfd/core test proposal` +Expected: FAIL — cannot find module `../src/proposal.ts`. + +- [ ] **Step 3: Implement `packages/core/src/proposal.ts`** + +```ts +import type { PullState } from './types.ts'; + +export type ProposalStatus = + | 'discussion' + | 'abandoned' + | 'published' + | 'committed' + | 'unknown'; + +const PROPOSAL_FILE_RE = /^\d{4}(?:-[a-z0-9][a-z0-9-]*)?\.md$/; + +export function isProposalFile(name: string): boolean { + return PROPOSAL_FILE_RE.test(name); +} + +export function fileNameToSlug(name: string): string | null { + if (!isProposalFile(name)) return null; + return name.slice(0, -'.md'.length); +} + +export function deriveStatus(opts: { + onDefaultBranch: boolean; + pulls: { state: PullState }[]; +}): ProposalStatus { + const { onDefaultBranch, pulls } = opts; + const hasOpen = pulls.some((p) => p.state === 'open'); + const hasMerged = pulls.some((p) => p.state === 'merged'); + const allClosed = pulls.length > 0 && pulls.every((p) => p.state === 'closed'); + + if (hasOpen) return 'discussion'; + if (onDefaultBranch && hasMerged) return 'published'; + if (onDefaultBranch) return 'committed'; + if (allClosed) return 'abandoned'; + return 'unknown'; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @rfd/core test proposal` +Expected: 7 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/proposal.ts packages/core/test/proposal.test.ts +git commit -m "feat(core): add pure proposal status + slug helpers" +``` + +--- + +## Task 11: `createRfd()` factory + identity adapter + public exports + +**Files:** +- Create: `packages/core/src/identity.ts` +- Modify: `packages/core/src/index.ts` +- Create: `packages/core/test/index.test.ts` + +`createRfd()` resolves the repo context lazily (once) and exposes the read methods bound to `repoDid`. A real identity adapter wraps `@atcute/identity-resolver`. + +- [ ] **Step 1: Write the failing test** — `packages/core/test/index.test.ts` + +```ts +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { expect, test, vi } from 'vitest'; +import { createRfd } from '../src/index.ts'; + +const repos = { + items: [ + { uri: 'at://did:plc:owner/sh.tangled.repo/rfd', value: { name: 'rfd', knot: 'knot1', repoDid: 'did:plc:repo' } }, + ], + cursor: null, +}; +const listPulls = JSON.parse( + readFileSync(fileURLToPath(new URL('./fixtures/listPulls.json', import.meta.url)), 'utf8'), +); + +test('createRfd resolves once, then scopes listPulls by repoDid', async () => { + const get = vi.fn(async (method: string) => { + if (method === 'sh.tangled.repo.listRepos') return repos; + if (method === 'sh.tangled.repo.listPulls') return listPulls; + throw new Error(`unexpected ${method}`); + }); + const identity = { resolveDid: vi.fn().mockResolvedValue('did:plc:owner') }; + const rfd = createRfd({ + config: { owner: 'natemoo.re', repoName: 'rfd', bobbinUrls: ['https://a.test'], cacheTtlSeconds: 60 }, + pool: { get }, + identity, + }); + + const a = await rfd.listPulls(); + const b = await rfd.listPulls(); + expect(a.items[0].state).toBe('open'); + // Resolution happened exactly once across both calls. + expect(get.mock.calls.filter(([m]) => m === 'sh.tangled.repo.listRepos')).toHaveLength(1); + expect(get).toHaveBeenCalledWith('sh.tangled.repo.listPulls', { + subject: 'did:plc:repo', + limit: 100, + cursor: undefined, + }); + expect(b.items).toHaveLength(2); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @rfd/core test index` +Expected: FAIL — `createRfd` is not exported. + +- [ ] **Step 3: Implement `packages/core/src/identity.ts`** + +```ts +import { + CompositeDidDocumentResolver, + CompositeHandleResolver, + DohJsonHandleResolver, + LocalActorResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver, +} from '@atcute/identity-resolver'; +import type { Identity } from './resolve.ts'; + +/** Real identity adapter backed by @atcute/identity-resolver. */ +export function createIdentity(): Identity { + const handleResolver = new CompositeHandleResolver({ + strategy: 'race', + methods: { + dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), + http: new WellKnownHandleResolver(), + }, + }); + const didDocumentResolver = new CompositeDidDocumentResolver({ + methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() }, + }); + const resolver = new LocalActorResolver({ handleResolver, didDocumentResolver }); + return { + async resolveDid(handle: string): Promise { + const r = await resolver.resolve(handle as never); + return r.did; + }, + }; +} +``` + +- [ ] **Step 4: Implement `packages/core/src/index.ts`** + +```ts +import { createBobbinPool } from './bobbin.ts'; +import type { RfdConfig } from './config.ts'; +import { createIdentity } from './identity.ts'; +import { createReads } from './reads.ts'; +import type { Getter } from './reads.ts'; +import type { Identity, RepoContext } from './resolve.ts'; +import { resolveRepoContext } from './resolve.ts'; + +export { parseConfig } from './config.ts'; +export type { RfdConfig } from './config.ts'; +export { NoRfdRepoError } from './resolve.ts'; +export * from './types.ts'; +export { + deriveStatus, + fileNameToSlug, + isProposalFile, + type ProposalStatus, +} from './proposal.ts'; + +export interface CreateRfdArgs { + config: RfdConfig; + /** Override the transport (tests). Defaults to a BobbinPool over config.bobbinUrls. */ + pool?: Getter; + /** Override identity resolution (tests). Defaults to the @atcute adapter. */ + identity?: Identity; +} + +export function createRfd(args: CreateRfdArgs) { + const { config } = args; + const pool = args.pool ?? createBobbinPool({ urls: config.bobbinUrls }); + const identity = args.identity ?? createIdentity(); + const reads = createReads(pool); + + let contextPromise: Promise | null = null; + function context(): Promise { + contextPromise ??= resolveRepoContext({ + reads, + identity, + owner: config.owner, + repoName: config.repoName, + }); + return contextPromise; + } + + return { + getContext: context, + async getRepo() { + const ctx = await context(); + return reads.getRepo(ctx.repoUri); + }, + async listPulls(cursor?: string) { + const ctx = await context(); + return reads.listPulls(ctx.repoDid, cursor); + }, + async listIssues(cursor?: string) { + const ctx = await context(); + return reads.listIssues(ctx.repoDid, cursor); + }, + getPull(pullUri: string) { + return reads.getPull(pullUri); + }, + getIssue(issueUri: string) { + return reads.getIssue(issueUri); + }, + getDiscussion(subjectUri: string, cursor?: string) { + return reads.getDiscussion(subjectUri, cursor); + }, + }; +} + +export type Rfd = ReturnType; +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `pnpm --filter @rfd/core test index` +Expected: 1 passing test. + +- [ ] **Step 6: Run the whole core suite** + +Run: `pnpm --filter @rfd/core test` +Expected: every task's tests pass (at-uri, patch, types, config, bobbin, reads, resolve, proposal, index, smoke). + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/identity.ts packages/core/src/index.ts packages/core/test/index.test.ts +git commit -m "feat(core): add createRfd factory + identity adapter" +``` + +--- + +## Task 12: Type-check the package + +**Files:** +- Modify: `packages/core/package.json` (add a `check` script) + +- [ ] **Step 1: Add a type-check script** to `packages/core/package.json` scripts: + +```json + "check": "tsc --noEmit -p tsconfig.json" +``` + +- [ ] **Step 2: Run the type-check** + +Run: `pnpm --filter @rfd/core check` +Expected: no type errors. If `tsc` isn't resolvable, add `"typescript": "^5"` to `devDependencies`, `pnpm install`, and re-run. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/package.json pnpm-lock.yaml +git commit -m "chore(core): add type-check script" +``` + +--- + +## Self-Review Notes (for the implementer) + +- **Spec coverage (Plan 1 slice):** bobbin failover pool + sticky + health gate (Task 7); `repoDid` scoping (Tasks 8–9, 11); `feed.comment` reads (Task 8); config incl. `BOBBIN_URL` pool + `RFD_CACHE_TTL` (Task 5); pure status/slug/patch domain logic (Tasks 3, 10); types-only record shapes, no lexicon codegen (Task 4). Deferred to later plans: proposal **file assembly** via git-proxy, the SSR short-TTL cache, `www` rewrite, web components/tokens, Cloudflare/microcosm deletion, Dockerfile/CI/Railway. +- **Open items carried forward:** git-proxy repo-param form and the `feed.comment` *write* body shape are resolved in Plan 2 (they only affect file assembly and the comment form). +- **Consistency:** `Getter` is the single transport interface consumed by `createReads` and `createRfd`; `Reads` is `ReturnType`; `RepoContext.repoDid` is the value threaded into every `subject` param. +```