// Slice 2: the bridge read-view over RepoStore. Oracle = the golden CAR (millipds
// output). The commit block differs (our secp256k1 sig vs millipds P-256), so we diff
// the CURVE-INDEPENDENT parts: MST + record blocks byte-for-byte, and commit.data.
import test from 'brittle'
import { readFileSync, mkdtempSync, rmSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { RepoStore } from '../lib/repo-store.js'
import { getRepo, getLatestCommit, getRecord, listRecords } from '../lib/read-view.js'
import { readCar, loadCommit } from 'atproto-repo/lib/repo.js'
const here = dirname(fileURLToPath(import.meta.url))
const goldenBytes = readFileSync(join(here, 'vectors', 'bridgetest-repo.car'))
const DID = 'did:plc:bh2hiuoeb34g4lif62zj2vx7'
const REV = '3mpmksfzaqm7k'
const POST_RKEY = '3mpmksfzaqm7k'
const PRIV = new Uint8Array(32).fill(7) // any valid secp256k1 scalar; commit sig isn't the oracle
const splitKey = (k) => { const i = k.indexOf('/'); return [k.slice(0, i), k.slice(i + 1)] }
async function storeWithGolden (t) {
const dir = mkdtempSync(join(tmpdir(), 'readview-'))
const s = await new RepoStore(dir).ready()
t.teardown(async () => { try { await s.close() } catch {} rmSync(dir, { recursive: true, force: true }) })
const car = await readCar(goldenBytes)
const { listRecords: list } = await import('atproto-repo/lib/repo.js')
for (const r of list(car)) { const [c, rk] = splitKey(r.key); await s.put(c, rk, r.value) }
return { s, car }
}
const golden = await readCar(goldenBytes)
const goldenCommit = loadCommit(golden)
const goldenRoot = golden.roots[0].toString()
// 1. getRepo: MST + record blocks byte-identical to millipds; commit.data matches.
test('getRepo: non-commit blocks byte-match golden + data==commit.data', async (t) => {
const { s } = await storeWithGolden(t)
const { carBytes, dataCid, commitCid } = await getRepo(s, { did: DID, rev: REV, privateKey: PRIV })
const ours = await readCar(carBytes)
const ok = (() => {
if (!dataCid.equals(goldenCommit.value.data)) return false
// golden non-commit blocks
const goldenBlocks = new Map()
for (const [k, v] of golden.blocks) if (k !== goldenRoot) goldenBlocks.set(k, v.bytes)
// our non-commit blocks (skip our commit cid)
let ourNonCommit = 0
for (const [k, v] of ours.blocks) {
if (k === commitCid.toString()) continue
ourNonCommit++
const g = goldenBlocks.get(k)
if (!g || !Buffer.from(g).equals(Buffer.from(v.bytes))) return false
}
return ourNonCommit === goldenBlocks.size && goldenBlocks.size === 2 // mst node + record
})()
t.ok(ok, 'getRepo: non-commit blocks byte-match golden + data==commit.data')
})
// 2. getLatestCommit: rev preserved, commit cid present.
test('getLatestCommit returns { cid, rev }', async (t) => {
const { s } = await storeWithGolden(t)
const c = await getLatestCommit(s, { did: DID, rev: REV, privateKey: PRIV })
t.ok(c.rev === REV && /^bafyrei[a-z2-7]+$/.test(c.cid), 'getLatestCommit returns { cid, rev }')
})
// 3. getRecord: CID matches the golden record CID (curve-independent), value intact.
test('getRecord CID == golden record CID + value intact', async (t) => {
const { s } = await storeWithGolden(t)
const rec = await getRecord(s, 'app.bsky.feed.post', POST_RKEY, { did: DID })
// golden record cid: the MST entry's value; grab from listRecords
const { listRecords: list } = await import('atproto-repo/lib/repo.js')
const goldenRec = list(golden).find(r => r.key === `app.bsky.feed.post/${POST_RKEY}`)
t.ok(rec.cid === goldenRec.cid.toString() &&
rec.uri === `at://${DID}/app.bsky.feed.post/${POST_RKEY}` &&
rec.value.text.includes('pds.7rnx.net'),
'getRecord CID == golden record CID + value intact')
})
// 4. listRecords: one post, correct uri/cid.
test('listRecords scopes + shapes correctly', async (t) => {
const { s } = await storeWithGolden(t)
// Shape is { records, cursor } per com.atproto.repo.listRecords — it used to return a bare array,
// which was one of the ways limit/reverse/cursor went unimplemented without anyone noticing.
const { records: recs, cursor } = await listRecords(s, 'app.bsky.feed.post', { did: DID })
t.ok(recs.length === 1 && recs[0].uri.endsWith(POST_RKEY) && recs[0].cid.startsWith('bafyrei'),
'listRecords scopes + shapes correctly')
t.absent(cursor, 'and no cursor on the last page — its ABSENCE is how a client knows to stop')
})
// 5. LIVE oracle (bonus, non-fatal): millipds getRecord CID must equal ours.
test('LIVE millipds getRecord CID matches (or skip if PDS down)', async (t) => {
let live
try {
const res = await fetch(`https://pds.7rnx.net/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${POST_RKEY}`, { signal: AbortSignal.timeout(8000) })
if (!res.ok) { t.comment('millipds not serving — skipped'); t.pass('LIVE millipds getRecord — skipped'); return }
live = await res.json()
} catch { t.comment('millipds unreachable — skipped'); t.pass('LIVE millipds getRecord — skipped'); return }
const { s } = await storeWithGolden(t)
const rec = await getRecord(s, 'app.bsky.feed.post', POST_RKEY, { did: DID })
t.comment(`live=${live.cid} ours=${rec.cid}`)
t.is(live.cid, rec.cid, 'LIVE millipds getRecord CID matches')
})
// 4b. PAGING. limit/reverse/cursor were accepted and IGNORED, so a client asking for the newest 10 got
// the oldest N thousand and a pager looped forever on a cursor that never advanced. Non-conformance that
// looks like success.
test('listRecords honours limit, reverse and cursor', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'readview-pager-'))
const store = await new RepoStore(dir).ready()
t.teardown(async () => { try { await store.close() } catch {} rmSync(dir, { recursive: true, force: true }) })
const DID2 = 'did:plc:pager'
// Ascending rkeys, so order is unambiguous.
for (const k of ['aaa', 'bbb', 'ccc', 'ddd', 'eee']) {
await store.put('app.bsky.feed.post', k, { $type: 'app.bsky.feed.post', text: k, createdAt: '2026-01-01T00:00:00.000Z' })
}
const p1 = await listRecords(store, 'app.bsky.feed.post', { did: DID2, limit: 2 })
t.is(p1.records.length, 2, 'limit is honoured')
t.ok(p1.records[0].uri.endsWith('aaa') && p1.records[1].uri.endsWith('bbb'), 'ascending by default')
t.is(p1.cursor, 'bbb', 'and a cursor is returned while more remain')
// Resume STRICTLY after the cursor — `>=` would return the cursor row twice and stall a pager that
// dedupes by uri.
const p2 = await listRecords(store, 'app.bsky.feed.post', { did: DID2, limit: 2, cursor: p1.cursor })
t.ok(p2.records[0].uri.endsWith('ccc'), 'paging resumes strictly AFTER the cursor')
const p3 = await listRecords(store, 'app.bsky.feed.post', { did: DID2, limit: 2, cursor: p2.cursor })
t.is(p3.records.length, 1, 'the final page is short')
t.absent(p3.cursor, 'and carries NO cursor — its absence is the stop signal, and returning one is what makes a pager loop')
const rev = await listRecords(store, 'app.bsky.feed.post', { did: DID2, limit: 2, reverse: true })
t.ok(rev.records[0].uri.endsWith('eee'), 'reverse gives DESCENDING order (newest first for TID rkeys)')
// The lexicon caps limit at 100; a caller asking for more must not walk an unbounded collection.
const capped = await listRecords(store, 'app.bsky.feed.post', { did: DID2, limit: 9999 })
t.is(capped.records.length, 5, 'an over-large limit is clamped, not obeyed')
})