OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-store / test / read-view.test.js

138 lines · javascript ref:
// 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')
})