OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-did-peer commits / 6a2fc87

0.2.5: getRecord/listRecords/collections must also work without a repo core
REGRESSION I SHIPPED. 0.2.3 gave getRepo a derive-over-RPC fallback for a stateless peer but left the other three readers gating on `if (!this.repo) return null/[]`. That was harmless while noRepo (favela, which reads records from its own shared store) was the only repo-less mode — but the moment a peer went STATELESS it silently broke com.atproto.repo.getRecord. Observed in production: RecordNotFound for wj62's profile record that getRepo was serving correctly at the same instant. They now derive from the CAR getRepo already fetches and VERIFIES, cached against the head rev so N reads at one head cost ONE derive and the cache invalidates on the next commit. listRecords(car) already yields the record CID (the MST value), so nothing is recomputed and no dag-cbor dependency is added. Also fixes 0.2.4's one-way repo drop: the router drops a replica when a peer announces repoKey:null but never took it back when the peer resumed projecting, so rolling projectRepoCore back to true did NOT heal the cached front — it stayed repo-less for the life of the process. adoptRepoKey() restores it. TWO SELF-INFLICTED BUGS CAUGHT BY THE SUITE, both in co-owned freshest-by-rev: 1. adoptRepoKey raced the INITIAL _openCores (this.repo is null while the open is in flight), opening a competing repo core for the same front. Guarded on _opening/seq. 2. Routing the readers through the derive path made them THROW for a front with no repo AND no rpc, where they previously returned []/null — so a poller reading a not-yet-open front started erroring instead of reporting "nothing yet". _derivedRecords now returns [] when it cannot derive. Neither would have been caught by inspection; the co-owned test failed deterministically both times and passing/failing was bisected against the committed 0.2.4 rather than assumed. Suite green: commitops + router + co-owned + no-repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6a2fc87ca6a24297ed61f1e5bc6b79403633648d
by plankatron <fleeky@gmail.com> · 2026-07-27T00:39:04.000Z

Changed files (2) +66 -10

lib/did-peer.js +65 -9
……@@ -24,7 +24,7 @@ import { RepoStore } from 'atproto-store/lib/repo-store.js'
2424import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
2525import { decodeCommitFrame, syncFrame } from 'atproto-store/lib/firehose.js'
2626import { getRepoFromCommit, getRecord as rvGetRecord, listRecords as rvListRecords } from 'atproto-store/lib/read-view.js'
27import { readCar, loadCommit, decodeBlock } from 'atproto-repo/lib/repo.js'
27import { readCar, loadCommit, decodeBlock, listRecords as carListRecords } from 'atproto-repo/lib/repo.js'
2828import { verifyCommit, commitCid } from 'atproto-repo/lib/commit.js'
2929
3030const ANNOUNCE_PROTOCOL = 'atproto/did-announce/v1'
……@@ -77,6 +77,25 @@ export class DidPeer {
7777 // them so we can serve + announce. Idempotent.
7878 async provide ({ repoKey, firehoseKey } = {}) { return this._openCores(repoKey, firehoseKey) }
7979
80 // Re-open a repo core for a front that went back to PROJECTING after a stateless spell. The router
81 // drops the replica when a peer announces repoKey:null; without this the drop is ONE-WAY, so a peer
82 // that resumes projecting stays repo-less for the life of the process — permanently on the slower
83 // derive path. Observed: rolling projectRepoCore back to true did NOT heal the cached front.
84 async adoptRepoKey (repoKey) {
85 if (this._noRepo || this.repo || !repoKey) return false
86 // CRITICAL: during the INITIAL _openCores, this.repo is null while the open is in flight. Without
87 // this guard a second announce arriving mid-open races it and opens a COMPETING repo core for the
88 // same front — which deterministically broke the co-owned freshest-by-rev test (the ahead co-owner
89 // was never fronted). Only adopt for a front that is already established (seq open, nothing opening).
90 if (this._opening || !this.seq) return false
91 this._repoKey = toKey(repoKey)
92 this.repo = await new RepoStore(this.corestore, { key: this._repoKey }).ready()
93 try { this.repo.core.download({ start: 0, end: -1 }) } catch {}
94 this._derived = null // the derive cache is now redundant; the core is authoritative
95 this._derivedAt = null
96 return true
97 }
98
8099 async _openCores (repoKey, firehoseKey) {
81100 if (this.seq) return // firehose is opened in BOTH modes; the idempotency guard (repo may stay null under noRepo)
82101 if (this._opening) return this._opening
……@@ -231,16 +250,49 @@ export class DidPeer {
231250 return { carBytes, commitCid: commitCid(signed), dataCid: signed.data }
232251 }
233252
253 // The record set for a peer with NO repo core, derived from the CAR getRepo() already fetches and
254 // VERIFIES. Cached against the head rev, so N reads at one head cost ONE derive and the cache
255 // invalidates the moment a new commit lands — no staleness, no per-read CAR fetch.
256 //
257 // Without this, getRecord/listRecords/collections returned null/[] whenever this.repo was null. That
258 // was tolerable when the only repo-less mode was noRepo (favela, which reads records from the shared
259 // store instead), but it silently broke com.atproto.repo.getRecord the moment a peer went STATELESS —
260 // observed in production as RecordNotFound for a profile that was present and correctly served by
261 // getRepo. The trust story is unchanged: these read out of the same verified CAR.
262 async _derivedRecords () {
263 if (!this.head) return []
264 // Derive ONLY when it can actually work. A front with no repo core and no RPC (one still mid-open,
265 // or a plain seeder) previously returned an empty list from these readers; making it THROW instead
266 // changed observable behaviour for every such caller — it broke the co-owned freshest-by-rev test,
267 // where a poller reading a not-yet-open front started erroring instead of reporting "nothing yet".
268 if (!this.rpc) return []
269 if (this._derivedAt === this.head.rev && this._derived) return this._derived
270 const { carBytes } = await this.getRepo()
271 const car = await readCar(carBytes)
272 // listRecords already yields the record CID (the MST value), so nothing is recomputed and no
273 // dag-cbor dependency is pulled in here — the CAR is the single source for key, cid and value.
274 this._derived = carListRecords(car).map((r) => ({ key: r.key, value: r.value, cid: r.cid.toString() }))
275 this._derivedAt = this.head.rev
276 return this._derived
277 }
278
234279 // com.atproto.repo.getRecord
235 getRecord (collection, rkey) {
236 if (!this.repo) return null
237 return rvGetRecord(this.repo, collection, rkey, { did: this.did })
280 async getRecord (collection, rkey) {
281 if (this.repo) return rvGetRecord(this.repo, collection, rkey, { did: this.did })
282 if (this._noRepo) return null // records live in the consumer's own store, by design
283 const key = collection + '/' + rkey
284 const hit = (await this._derivedRecords()).find((r) => r.key === key)
285 return hit ? { uri: `at://${this.did}/${key}`, cid: hit.cid, value: hit.value } : null
238286 }
239287
240288 // com.atproto.repo.listRecords
241 listRecords (collection) {
242 if (!this.repo) return []
243 return rvListRecords(this.repo, collection, { did: this.did })
289 async listRecords (collection) {
290 if (this.repo) return rvListRecords(this.repo, collection, { did: this.did })
291 if (this._noRepo) return []
292 const prefix = collection + '/'
293 return (await this._derivedRecords())
294 .filter((r) => r.key.startsWith(prefix))
295 .map((r) => ({ uri: `at://${this.did}/${r.key}`, cid: r.cid, value: r.value }))
244296 }
245297
246298 // com.atproto.sync.getLatestCommit -> { cid, rev } from the verified head (keyless).
……@@ -315,9 +367,9 @@ export class DidPeer {
315367
316368 // Distinct collection NSIDs present in the replicated repo (keys are "<collection>/<rkey>").
317369 async collections () {
318 if (!this.repo) return []
319370 const set = new Set()
320 for (const r of await this.repo.records()) {
371 const rows = this.repo ? await this.repo.records() : (this._noRepo ? [] : await this._derivedRecords())
372 for (const r of rows) {
321373 const i = r.key.indexOf('/')
322374 if (i > 0) set.add(r.key.slice(0, i))
323375 }
……@@ -501,6 +553,10 @@ export class DidPeerRouter {
501553 existing.repo = null
502554 existing._repoKey = null
503555 try { await stale.close() } catch {}
556 } else if (a.repoKey && !existing.repo) {
557 // ...and the reverse: the peer resumed projecting, so take the core back. Without this the drop
558 // above is one-way and a rolled-back flip never heals.
559 existing.adoptRepoKey(a.repoKey).catch(() => {})
504560 }
505561 if (existing.seq) existing.seq.core.update().catch(() => {})
506562 this._wake(did)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-did-peer",
3 "version": "0.2.4",
3 "version": "0.2.5",
44 "description": "Keyless, direction-free P2P reader/seeder for an atproto DID's repo+firehose Hypercores: announce-push discovery + event-driven head reconciliation + a keyless read surface. The shared primitive beneath atproto-bridge, atproto-relay and favela (extracted from atproto-bridge did-peer.js).",
55 "type": "module",
66 "main": "index.js",