// Bridge READ-view — pure functions turning RepoStore (Hyperbee) state into atproto
// wire responses (com.atproto.sync.* / repo.*). No HTTP here (that's the thin face,
// later); no storage-specific atproto state in the store. rev/prev are INPUTS — commit
// head-state (monotonic rev, prev-linking) advances on writes, which is the next slice.
//
// Curve note: the commit block carries our secp256k1 sig (millipds uses P-256), so the
// commit CID differs from millipds — but the MST + record blocks are curve-independent
// and byte-identical to millipds output (verified against the golden CAR).
import * as dagCbor from '@ipld/dag-cbor'
import { buildMst, reachableBlocks } from 'atproto-repo/lib/mst.js'
import { unsignedCommit, signCommit, commitCid } from 'atproto-repo/lib/commit.js'
import { cidForBytesSync } from 'atproto-repo/lib/cid.js'
import { writeCar } from 'atproto-repo/lib/repo.js'
// Build every block of the repo from the store's exact bytes (no re-encode).
async function assembleRepo (store, { did, rev, prev = null, privateKey }) {
const recs = await store.records() // [{ key, bytes, record }]
const recordBlocks = []
const mstRecords = []
for (const r of recs) {
const cid = cidForBytesSync(r.bytes)
recordBlocks.push({ cid, bytes: r.bytes })
mstRecords.push({ key: r.key, value: cid })
}
const { root: dataCid, ns } = buildMst(mstRecords)
const mstBlocks = [...reachableBlocks(ns, dataCid).values()]
const signed = signCommit(unsignedCommit({ did, rev, data: dataCid, prev }), privateKey)
const cCid = commitCid(signed)
const commitBlock = { cid: cCid, bytes: dagCbor.encode(signed) }
return { commitCid: cCid, dataCid, signedCommit: signed, commitBlock, mstBlocks, recordBlocks }
}
// com.atproto.sync.getRepo -> full CAR (commit + MST + records).
export async function getRepo (store, meta) {
const r = await assembleRepo(store, meta)
const carBytes = await writeCar(r.commitCid, [r.commitBlock, ...r.mstBlocks, ...r.recordBlocks])
return { carBytes, commitCid: r.commitCid, dataCid: r.dataCid, signedCommit: r.signedCommit }
}
// KEYLESS getRepo (peer/bridge split): assemble the full CAR from the store's records + a
// PROVIDED signed commit — e.g. one a bridge read from a peer's replicated firehose. The
// MST/records are curve-independent so they rebuild without the key; the signature travels
// IN the commit, never re-derived. Integrity: the provided commit must commit to exactly
// these records (its data CID == the rebuilt MST root), else we refuse to serve a mismatched
// repo. Signature verification (vs the peer's did:key) is the caller's concern.
export async function getRepoFromCommit (store, signedCommit) {
const recs = await store.records()
const recordBlocks = []
const mstRecords = []
for (const r of recs) {
const cid = cidForBytesSync(r.bytes)
recordBlocks.push({ cid, bytes: r.bytes })
mstRecords.push({ key: r.key, value: cid })
}
const { root: dataCid, ns } = buildMst(mstRecords)
if (!dataCid.equals(signedCommit.data)) {
throw new Error('getRepoFromCommit: signed commit data CID != rebuilt MST root (repo/commit mismatch)')
}
const cCid = commitCid(signedCommit)
const commitBlock = { cid: cCid, bytes: dagCbor.encode(signedCommit) }
const mstBlocks = [...reachableBlocks(ns, dataCid).values()]
const carBytes = await writeCar(cCid, [commitBlock, ...mstBlocks, ...recordBlocks])
return { carBytes, commitCid: cCid, dataCid }
}
// com.atproto.sync.getLatestCommit -> { cid, rev }.
export async function getLatestCommit (store, meta) {
const r = await assembleRepo(store, meta)
return { cid: r.commitCid.toString(), rev: r.signedCommit.rev }
}
// com.atproto.repo.getRecord -> { uri, cid, value }.
export async function getRecord (store, collection, rkey, { did }) {
const raw = await store.getRaw(collection, rkey)
if (!raw) return null
return {
uri: `at://${did}/${collection}/${rkey}`,
cid: cidForBytesSync(raw.bytes).toString(),
value: raw.record
}
}
// com.atproto.repo.listRecords -> [{ uri, cid, value }] for a collection.
// com.atproto.repo.listRecords. `limit`, `reverse` and `cursor` were ACCEPTED AND IGNORED, so every
// caller silently got the whole collection in ascending order no matter what it asked for — a client
// paging our repo would loop forever on a cursor that never advanced, and one asking for the newest 10
// would get the oldest N thousand. Non-conformance that looks like success is the worst kind.
//
// The lexicon's contract, implemented here:
// limit 1..100, default 50
// reverse true = DESCENDING rkey order (newest first for TID rkeys)
// cursor opaque; here it is the last rkey returned, and paging resumes STRICTLY after it
// returns { records, cursor } — cursor ABSENT when the page is the last one, which is how a client
// knows to stop. Returning a cursor at the end is what makes a pager loop.
export async function listRecords (store, collection, { did, limit = 50, reverse = false, cursor = null } = {}) {
const n = Math.min(100, Math.max(1, Number(limit) || 50))
// store.list yields in ascending key (byte) order — the same order the MST uses.
const all = []
for await (const { rkey, record } of store.list(collection)) all.push({ rkey, record })
if (reverse) all.reverse()
let start = 0
if (cursor) {
// Resume strictly AFTER the cursor. `>` not `>=`, or the cursor row is returned twice and a pager
// that dedupes by uri silently stalls instead of advancing.
const i = all.findIndex((r) => r.rkey === cursor)
start = i >= 0 ? i + 1 : all.length
}
const page = all.slice(start, start + n)
const records = page.map(({ rkey, record }) => ({
uri: `at://${did}/${collection}/${rkey}`,
// NOTE: this CIDs a re-encode of the decoded record, not the stored bytes. A canonical DAG-CBOR
// round-trip is byte-identical so this is correct today, but getRaw would be strictly safer — the
// 2026-07-31 incidents were all re-encode-vs-canonical mismatches. Left as-is rather than widened
// here; worth revisiting with a raw-bytes list().
cid: cidForBytesSync(dagCbor.encode(record)).toString(),
value: record
}))
const last = page.length ? page[page.length - 1].rkey : null
// No cursor on the final page — its ABSENCE is the stop signal.
const more = start + page.length < all.length
return { records, cursor: more && last ? last : undefined }
}