OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-store commits / b1d37f1

listBlobs support: enumerate the published blob pointer set
The referenced blob set can be derived by walking records. An ORPHAN — uploaded, referenced by no record — cannot; it is invisible to any such walk by construction, and that is the class that permanently lost the 2026-07-03 image. The pointer set is the authority's own inventory, so it is the only place the difference shows up. BlobPointerStore.list() pages the SHARED __blob__/<cid> keys (sorted, cursor-stable), so it lists what the DID published across all co-owners rather than what one node holds bytes for. `since` is deliberately NOT supported: pointers carry no rev, and answering a rev filter wrongly is worse than not offering it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b1d37f1133636a4e4f713529c1f8b6946e286cbf
by plankatron <fleeky@gmail.com> · 2026-08-07T01:59:02.000Z

Changed files (1) +32 -0

lib/blob-pointer-store.js +32 -0
……@@ -131,6 +131,38 @@ export class BlobPointerStore {
131131 // PULL-ONLY: it never writes a pointer, so it is safe on a NON-WRITABLE replica — exactly the peer
132132 // that needs to catch up. Presence is tested with hasLocalFile (a stat), not get(), so a converged
133133 // node does no reads at all. Bounded per pass with `more`, so a large backlog cannot stall the caller.
134 // Every blob CID this DID has PUBLISHED a pointer for — the inventory behind com.atproto.sync.listBlobs.
135 //
136 // ⛔ THIS IS THE ONLY WAY TO SEE AN ORPHAN. A blob referenced by some record can be found by walking
137 // records (favela-web/lib/blob-refs.js does exactly that, and needs no server support). A blob that is
138 // uploaded and referenced by NOTHING is invisible to any such walk, by construction — and that is the
139 // class that permanently lost the 2026-07-03 image. The pointer set is the authority's own inventory,
140 // so it is the only place the difference shows up.
141 //
142 // Reads the SHARED state, so it lists what the DID has published across all its co-owners, not merely
143 // what this node happens to hold bytes for. Sorted for a stable cursor; `since` is deliberately NOT
144 // supported (pointers carry no rev, so a rev-filter would silently return the wrong set — better to
145 // not offer it than to answer wrongly).
146 async list ({ limit = 500, cursor = null } = {}) {
147 let map
148 try {
149 map = await ((typeof this.sb.listNow === 'function') ? this.sb.listNow(BLOB_PREFIX) : this.sb.list(BLOB_PREFIX))
150 } catch (e) {
151 return { cids: [], cursor: null, error: e.message }
152 }
153 const all = Object.keys(map || {})
154 .filter((k) => k.startsWith(BLOB_PREFIX))
155 .map((k) => k.slice(BLOB_PREFIX.length))
156 .filter(Boolean)
157 .sort()
158 const start = cursor ? all.findIndex((c) => c > String(cursor)) : 0
159 const from = start < 0 ? all.length : start
160 const n = Math.max(1, Math.min(Number(limit) || 500, 1000))
161 const page = all.slice(from, from + n)
162 const next = (from + n) < all.length ? page[page.length - 1] : null
163 return { cids: page, cursor: next, total: all.length }
164 }
165
134166 async reconcile ({ limit = 8 } = {}) {
135167 if (typeof this.blobs.hasLocalFile !== 'function') return { skipped: 'not-supported' }
136168 let map