OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-store commits / 2732ae1

0.0.12: BlobPointerStore — share the blob cid->oid mapping so co-owners can serve blobs
Records already solve cross-node byte availability: PointerSubstrate puts a tiny {$oid,size} pointer in the SHARED autobase, so every co-owner converges on which records exist and the oid to fetch each by. Blobs had no equivalent. OverlayBlobStore's metadata bee ({mimeType,size,oid}) is node-local — in the daemon it is `overlay-blobs:<did>` on membraneStore, which never replicates — and get() reads that bee FIRST, returning null before it ever looks at the file or the overlay. So an upload on node A leaves the FILE fetchable by oid from anywhere while the cid->oid mapping stays on A forever. Node B cannot derive it either: the atproto blob CID (raw codec) and the overlay oid (blake2b) are different hashes. Net effect in production: a blob serves or 404s depending on which co-owner the bridge happens to front. Same failure class as the serve-map bug that cost 75 hours on wj62 — bytes reachable, blocked by a node-local index. BlobPointerStore applies the PointerSubstrate pattern to blobs: - put() -> store locally, then publish `__blob__/<cid>` = {$oid,size,mimeType} into the shared statebase. Reserved "__" namespace, so RepoBase. splitKey and PointerSubstrate both reject it and it can never be materialized into a signed commit. - get() -> local first; on a miss, adopt the shared pointer's metadata and re-enter the ordinary get() path, so there stays exactly ONE byte-resolution path (fetch-by-oid). - has() -> reports swarm-wide availability, not just local. A failed pointer write does NOT fail the upload — the blob is already durably stored and locally servable, so throwing would be strictly worse than today — but it is logged loudly instead of leaving the silent local-only state. Enablers: OverlayBlobStore.put now also returns `oid`, and adoptMeta() records metadata learned from a replicated source (never clobbering local metadata). Tests: 5 new, 19 asserts — including a NEGATIVE CONTROL proving node B cannot serve the blob without the pointer and can with it, and a test that blob pointers stay invisible to the record set. This is also the first test coverage OverlayBlobStore has ever had. Suite: 43/43, 99/99 (was 38/38, 80/80). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2732ae1ec771bb9aa451477705d4956886d33985
by plankatron <fleeky@gmail.com> · 2026-07-26T13:24:49.000Z

Changed files (4) +266 -2

lib/blob-pointer-store.js +105 -0
……@@ -0,0 +1,105 @@
1// BlobPointerStore — the blob half of the "pointers in the autobase, bytes in the overlay" design.
2// This is to blobs exactly what PointerSubstrate is to records, and it exists because the records
3// side got this right and the blobs side did not.
4//
5// THE GAP IT CLOSES. OverlayBlobStore keeps a per-blob metadata record { mimeType, size, oid } in a
6// Hyperbee, and that bee is NODE-LOCAL — in the live daemon it lives in the membrane corestore
7// (device-node.js: `overlay-blobs:<did>` on membraneStore), which does not replicate. Its get() reads
8// that bee FIRST and returns null when the entry is missing, BEFORE it ever looks at the file or the
9// overlay. So a blob uploaded on node A produces two artifacts with very different fates:
10//
11// the FILE + overlay registration -> content-addressed, fetchable by `oid` from any peer
12// the bee entry { mimeType,size,oid } -> stays on node A, forever
13//
14// Node B therefore cannot serve the blob even when the bytes are reachable, because the one thing it
15// is missing is the cid -> oid mapping needed to ask for them. Since the atproto blob CID (raw codec,
16// cidForBlobSync) and the overlay oid (blake2b) are DIFFERENT hashes, B cannot derive one from the
17// other — the mapping has to be replicated, not recomputed. Symptom in production: an avatar that
18// serves or 404s depending on which co-owner the bridge happens to front.
19//
20// THE FIX is the pattern PointerSubstrate already proves for records: put a tiny pointer in the SHARED
21// autobase so every co-owner converges on which blobs exist and the oid to fetch each one by, while the
22// bytes stay out of the append-only log. Pointer key is `__blob__/<cid>`, in the RESERVED "__"
23// namespace — RepoBase.splitKey and PointerSubstrate both return null for a collection starting with
24// "__", so these entries can never be mistaken for records or materialized into a signed commit.
25//
26// Drop-in: same public surface as BlobStore / OverlayBlobStore (ready / put / get / has / close), so it
27// composes under DualBlobStore unchanged.
28
29const BLOB_PREFIX = '__blob__/'
30
31export class BlobPointerStore {
32 // statebase: the shared StateBase (set/get). blobs: an OverlayBlobStore (the local bytes + bee).
33 constructor (statebase, blobs, { did = null } = {}) {
34 if (!statebase || typeof statebase.set !== 'function' || typeof statebase.get !== 'function') {
35 throw new Error('BlobPointerStore: a StateBase (set/get) is required for the shared pointer set')
36 }
37 if (!blobs || typeof blobs.put !== 'function' || typeof blobs.adoptMeta !== 'function') {
38 throw new Error('BlobPointerStore: an OverlayBlobStore (put/get/adoptMeta) is required for the bytes')
39 }
40 this.sb = statebase
41 this.blobs = blobs
42 this._did = did
43 }
44
45 static key (cidStr) { return BLOB_PREFIX + cidStr }
46
47 async ready () { return this } // the caller readies the underlying OverlayBlobStore
48
49 // Write the bytes locally (file + overlay registration + local bee), then publish the SHARED pointer
50 // so any co-owner can resolve this blob by oid.
51 //
52 // A failed pointer write does NOT fail the upload. The blob is already durably stored and servable
53 // from this node, so throwing here would turn a cross-node limitation into a total upload failure —
54 // strictly worse than today. But it is logged LOUDLY rather than swallowed, because the resulting
55 // state (blob local-only) is exactly the silent condition this module exists to end. The common
56 // cause is a non-writable device: sb.set appends to the autobase, so only an admitted writer can
57 // publish a pointer.
58 async put (bytes, mimeType) {
59 const r = await this.blobs.put(bytes, mimeType)
60 const cidStr = r.cid.toString()
61 if (r.oid) {
62 try {
63 await this.sb.set(BlobPointerStore.key(cidStr), { $oid: r.oid, size: r.size, mimeType: r.mimeType })
64 } catch (err) {
65 this._warn(cidStr, 'pointer write failed — blob is stored LOCAL-ONLY and will not serve from other co-owners', err && err.message)
66 }
67 } else {
68 this._warn(cidStr, 'no overlay oid — blob cannot be published as a shared pointer (is the overlay attached?)', null)
69 }
70 return r
71 }
72
73 // Local first (bee hit -> file, or fetch-by-oid). On a miss, consult the SHARED pointer: adopt its
74 // metadata into the local bee, then re-enter the ordinary get() path, which now finds the mapping and
75 // fetches the bytes by oid. Reusing get() rather than fetching here keeps ONE byte-resolution path.
76 async get (cidStr) {
77 const cid = String(cidStr)
78 const local = await this.blobs.get(cid)
79 if (local) return local
80 const ptr = await this._pointer(cid)
81 if (!ptr || !ptr.$oid) return null
82 await this.blobs.adoptMeta(cid, { mimeType: ptr.mimeType, size: ptr.size, oid: ptr.$oid })
83 return this.blobs.get(cid)
84 }
85
86 // Known here OR known to the swarm. A shared pointer means the bytes are fetchable even if this node
87 // has never seen them, so reporting only the local bee would understate availability.
88 async has (cidStr) {
89 const cid = String(cidStr)
90 if (await this.blobs.has(cid)) return true
91 return !!(await this._pointer(cid))
92 }
93
94 async _pointer (cidStr) {
95 try { return await this.sb.get(BlobPointerStore.key(cidStr)) } catch { return null }
96 }
97
98 _warn (cid, msg, error) {
99 try { console.error(JSON.stringify({ msg: 'blob pointer: ' + msg, did: this._did, cid, error })) } catch {}
100 }
101
102 async close () { try { await this.blobs.close() } catch {} }
103}
104
105export { BLOB_PREFIX }
lib/overlay-blob-store.js +15 -1
……@@ -59,7 +59,21 @@ export class OverlayBlobStore {
5959 oid = r ? r.contentHash : null
6060 }
6161 await this.bee.put(cidStr, b4a.from(JSON.stringify({ mimeType, size: buf.length, oid }), 'utf-8'))
62 return { cid, mimeType, size: buf.length }
62 // `oid` is returned so a caller can publish it as a SHARED pointer (see BlobPointerStore). The bee
63 // here is node-local and never replicates, so without propagating the oid a co-owner that receives
64 // the blob FILE still cannot serve it — get() below returns null on a missing bee entry.
65 return { cid, mimeType, size: buf.length, oid }
66 }
67
68 // Record metadata for a blob this node did NOT upload — learned from a replicated source (the shared
69 // pointer set). Writing the bee entry is what makes get() able to resolve the blob at all: get() reads
70 // the bee FIRST and returns null when it is absent, even if the bytes are already on disk. After this,
71 // the ordinary get() path applies unchanged (local file hit, else fetchFile by oid).
72 async adoptMeta (cidStr, { mimeType = 'application/octet-stream', size = 0, oid = null } = {}) {
73 const existing = await this.bee.get(cidStr)
74 if (existing) return false // never clobber locally-known metadata
75 await this.bee.put(cidStr, b4a.from(JSON.stringify({ mimeType, size, oid }), 'utf-8'))
76 return true
6377 }
6478
6579 // Fetch by CID string; returns { mimeType, size, bytes } or null. Serves the local file if present;
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-store",
3 "version": "0.0.11",
3 "version": "0.0.12",
44 "description": "Holepunch-native atproto repo store — records in a classic Hyperbee (collection/rkey -> DAG-CBOR). The P2P home; the atproto-repo module is the bridge serializer over it.",
55 "type": "module",
66 "main": "lib/repo-store.js",
test/blob-pointer-store.test.js +145 -0
……@@ -0,0 +1,145 @@
1// BlobPointerStore — proves the cross-node gap it exists to close, and proves the pointer stays out
2// of the signed repo.
3//
4// The gap: OverlayBlobStore's metadata bee ({mimeType,size,oid}) is node-local and never replicates,
5// and its get() returns null on a missing bee entry BEFORE looking at the file or the overlay. So a
6// co-owner that can reach the bytes still cannot serve them — it lacks the cid -> oid mapping, and
7// since the atproto blob CID (raw) and the overlay oid (blake2b) are different hashes it cannot
8// derive one from the other. Test 2 below is the load-bearing one: it runs node B twice, WITHOUT and
9// WITH the shared pointer, so the fix is measured against its own negative control.
10//
11// The fake overlay models exactly the property that matters — bytes are fetchable by oid from
12// anywhere, metadata is not — without needing two real daemons.
13
14import test from 'brittle'
15import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'
16import { join } from 'node:path'
17import { tmpdir } from 'node:os'
18import { createHash } from 'node:crypto'
19import { OverlayBlobStore } from '../lib/overlay-blob-store.js'
20import { BlobPointerStore, BLOB_PREFIX } from '../lib/blob-pointer-store.js'
21import { PointerSubstrate } from '../lib/pointer-substrate.js'
22import { OverlayRepoStore } from '../lib/overlay-repo-store.js'
23
24const BYTES = Buffer.from('\xff\xd8\xff\xe0 pretend this is an avatar', 'binary')
25const MIME = 'image/jpeg'
26
27function tmp (t) { const d = mkdtempSync(join(tmpdir(), 'bps-')); t.teardown(() => rmSync(d, { recursive: true, force: true })); return d }
28
29// Content-addressed byte transport shared by every "node" — the property a real overlay provides.
30// sha256 stands in for blake2b; only distinctness from the atproto CID matters here.
31function mkOverlay (net) {
32 return {
33 async registerFile (overlayPath, diskPath) {
34 const bytes = readFileSync(diskPath)
35 const oid = createHash('sha256').update(bytes).digest('hex')
36 net.set(oid, bytes)
37 return { contentHash: oid }
38 },
39 async fetchFile (oid, { destPath } = {}) {
40 const bytes = net.get(oid)
41 if (!bytes) return null
42 writeFileSync(destPath, bytes)
43 return { path: destPath }
44 }
45 }
46}
47
48// The replicated autobase view: every node reads the same map.
49function mkStatebase (shared) {
50 return {
51 async set (k, v) { shared.set(k, v) },
52 async get (k) { return shared.has(k) ? shared.get(k) : null },
53 async list (prefix = '') {
54 const out = {}
55 for (const [k, v] of shared) if (k.startsWith(prefix)) out[k] = v
56 return out
57 }
58 }
59}
60
61async function mkNode (t, net) {
62 return new OverlayBlobStore(tmp(t), { overlay: mkOverlay(net), indexStore: tmp(t), name: 'blobs' }).ready()
63}
64
65test('put publishes a shared {$oid,size,mimeType} pointer under the reserved __blob__ namespace', async (t) => {
66 const net = new Map(); const shared = new Map()
67 const store = new BlobPointerStore(mkStatebase(shared), await mkNode(t, net))
68
69 const { cid, size } = await store.put(BYTES, MIME)
70 const ptr = shared.get(BLOB_PREFIX + cid.toString())
71
72 t.ok(ptr, 'a pointer was published')
73 t.ok(ptr.$oid, 'pointer carries the overlay oid (what a peer fetches by)')
74 t.is(ptr.mimeType, MIME, 'pointer carries the mime type')
75 t.is(ptr.size, size, 'pointer carries the size')
76 t.not(ptr.$oid, cid.toString(), 'oid and atproto CID are DIFFERENT hashes — the mapping cannot be recomputed')
77
78 await store.close()
79})
80
81test('a co-owner that never uploaded the blob can serve it — and CANNOT without the pointer', async (t) => {
82 const net = new Map(); const shared = new Map()
83
84 // Node A uploads. Bytes enter the shared content-addressed transport; A's bee is A's alone.
85 const a = new BlobPointerStore(mkStatebase(shared), await mkNode(t, net))
86 const { cid } = await a.put(BYTES, MIME)
87 const cidStr = cid.toString()
88
89 // NEGATIVE CONTROL — node B's bare OverlayBlobStore, bytes reachable in `net`, no shared pointer.
90 const bare = await mkNode(t, net)
91 t.is(await bare.get(cidStr), null, 'CONTROL: bytes are reachable but B cannot serve them (no cid->oid mapping)')
92
93 // Same node B, now reading through the pointer store.
94 const b = new BlobPointerStore(mkStatebase(shared), await mkNode(t, net))
95 const got = await b.get(cidStr)
96
97 t.ok(got, 'B resolves the blob via the shared pointer')
98 t.ok(Buffer.from(got.bytes).equals(BYTES), 'bytes are byte-identical to what A uploaded')
99 t.is(got.mimeType, MIME, 'mime type survived the hop')
100 t.ok(await b.has(cidStr), 'has() reports swarm-wide availability')
101
102 // Self-healing: the metadata is now local, so the next read needs no pointer lookup.
103 t.ok(await b.blobs.get(cidStr), 'B adopted the metadata into its own bee')
104
105 await a.close(); await bare.close(); await b.close()
106})
107
108test('a failed pointer write does not fail the upload (blob stays locally servable)', async (t) => {
109 const net = new Map()
110 const readOnly = { async set () { throw new Error('not writable') }, async get () { return null } }
111 const store = new BlobPointerStore(readOnly, await mkNode(t, net))
112
113 const r = await store.put(BYTES, MIME) // a non-writable device must still be able to store a blob
114 t.ok(r.cid, 'upload succeeded despite the pointer write failing')
115 t.ok(await store.get(r.cid.toString()), 'and the blob still serves from this node')
116
117 await store.close()
118})
119
120test('adoptMeta never clobbers metadata this node already has', async (t) => {
121 const net = new Map()
122 const blobs = await mkNode(t, net)
123 const { cid } = await blobs.put(BYTES, MIME)
124
125 const adopted = await blobs.adoptMeta(cid.toString(), { mimeType: 'application/octet-stream', size: 1, oid: 'bogus' })
126 t.is(adopted, false, 'adoptMeta declined to overwrite')
127 t.is((await blobs.get(cid.toString())).mimeType, MIME, 'original metadata intact')
128
129 await blobs.close()
130})
131
132test('blob pointers are invisible to the record set (never enter a signed commit)', async (t) => {
133 const net = new Map(); const shared = new Map()
134 const sb = mkStatebase(shared)
135
136 const records = new PointerSubstrate(sb, await new OverlayRepoStore(tmp(t), { overlay: mkOverlay(net) }).ready())
137 await records.set('app.bsky.feed.post/3kaaa', { $type: 'app.bsky.feed.post', text: 'hello' })
138 await new BlobPointerStore(sb, await mkNode(t, net)).put(BYTES, MIME)
139
140 const listed = await records.list('')
141 t.is(Object.keys(listed).length, 1, 'exactly one record — the blob pointer is not one')
142 t.ok(listed['app.bsky.feed.post/3kaaa'], 'the real record is present')
143 t.ok([...shared.keys()].some((k) => k.startsWith(BLOB_PREFIX)), 'the blob pointer IS in the shared state')
144 t.ok(BLOB_PREFIX.startsWith('__'), 'and it lives in the reserved namespace that splitKey rejects')
145})