OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-store commits / f04bbc0

PointerSubstrate: symmetric record-set convergence via {oid,size} pointers (0.0.7)
Step 2a. PointerSubstrate is RepoBase's KV: set() writes bytes to the overlay (dedup) + a tiny {$oid,size} pointer to the StateBase autobase (so the record SET converges symmetrically across attested devices, like the default path) while bytes stay off the append-only log as deduped overlay files. get/list resolve pointers -> record bytes (store.ensureLocal: local file, or fetch-by-oid from a peer). OverlayRepoStore.put now returns {oid,size}; added ensureLocal for the follower byte-fetch. Retires the directional RepoMirror. Byte-exact (same MST root as a full-record device — proven in hpp). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f04bbc082e91d3865f4c2bfefbe2df7dec4ca57f
by plankatron <fleeky@gmail.com> · 2026-07-20T03:31:19.000Z

Changed files (3) +109 -2

lib/overlay-repo-store.js +21 -1
……@@ -51,6 +51,8 @@ export class OverlayRepoStore {
5151 return join(this._recordsDir, collection, rkey + EXT)
5252 }
5353
54 // Returns { oid, size }: oid = the overlay content hash (blake2b) that a PEER fetches the bytes by
55 // (PointerSubstrate stores {oid,size} as the symmetric autobase pointer). null oid when no overlay.
5456 async put (collection, rkey, record) {
5557 const bytes = b4a.from(dagCbor.encode(record))
5658 const p = this._pathFor(collection, rkey)
……@@ -59,7 +61,25 @@ export class OverlayRepoStore {
5961 // Stamp a monotonic per-write version (TID) so cross-device conflict resolution is atproto-native
6062 // (rev-wins: the later write wins). Records carry no rev at put time — the commit rev comes at sign.
6163 // Only affects the overlay change feed, never the canonical record file → determinism is preserved.
62 if (this._overlay) { try { await this._overlay.registerFile(this._overlayPath(collection, rkey), p, { size: bytes.length, rev: tidNow() }) } catch {} }
64 let oid = null
65 if (this._overlay) { try { const r = await this._overlay.registerFile(this._overlayPath(collection, rkey), p, { size: bytes.length, rev: tidNow() }); oid = r ? r.contentHash : null } catch {} }
66 return { oid, size: bytes.length }
67 }
68
69 // Ensure the record BYTES are present locally, fetching from a connected peer by overlay oid if this
70 // node holds only the pointer (a follower resolving the converged autobase set). Returns the decoded
71 // record or null. Local-hit is the common path; the fetch is the cross-node byte-resolution.
72 async ensureLocal (collection, rkey, oid) {
73 const local = await this.get(collection, rkey)
74 if (local != null) return local
75 if (!this._overlay || !oid) return null
76 const disk = this._pathFor(collection, rkey)
77 try {
78 fs.mkdirSync(join(this._recordsDir, collection), { recursive: true })
79 const r = await this._overlay.fetchFile(oid, { destPath: disk })
80 if (!r) return null
81 } catch { return null }
82 return this.get(collection, rkey)
6383 }
6484
6585 async get (collection, rkey) {
lib/pointer-substrate.js +87 -0
……@@ -0,0 +1,87 @@
1// PointerSubstrate — the RepoBase KV that restores SYMMETRIC multi-writer convergence to the record
2// set while keeping the bytes off the append-only log. A tiny { $oid, size } POINTER per record lives
3// in the shared StateBase autobase (so every attested device converges on WHICH records exist, exactly
4// like the default statebase path), and the record BYTES live as deduped files in the overlay,
5// content-addressed + served by `oid` (blake2b). This is the "pointers in the autobase + bytes in the
6// overlay" design: the autobase carries ~50 bytes/record instead of the full record (+ the firehose's
7// inline copy), and cross-device convergence is the autobase — no bespoke overlay-to-overlay sync
8// (RepoMirror is retired).
9//
10// RepoBase sees the SAME interface + shape as any substrate: set(key,record) / del(key) /
11// get(key)->record|null / list(prefix)->{key:record} / listNow. The pointer indirection is internal:
12// list/get RESOLVE each pointer to its record bytes via the overlay store (a local file hit, or a
13// fetch-by-oid from a connected peer when this node — e.g. a just-promoted signer — holds only the
14// converged pointer). Reserved "__" keys (firehose/admission) never reach here (swarm-device writes
15// those to the StateBase directly); we defensively skip any that appear in a raw list.
16
17const SEP = '/'
18
19function split (key) {
20 const k = String(key)
21 const i = k.indexOf(SEP)
22 if (i < 0) throw new Error('PointerSubstrate: expected a "collection/rkey" record key, got ' + JSON.stringify(key))
23 return { collection: k.slice(0, i), rkey: k.slice(i + 1) }
24}
25
26// Like RepoBase.splitKey: null for a reserved/non-record key (single-segment, or a "__"-prefixed
27// collection such as __commit__/<rev>), so a raw statebase.list('') never resolves control data.
28function recSplit (key) {
29 const i = key.indexOf(SEP)
30 if (i < 0) return null
31 const collection = key.slice(0, i)
32 if (collection.startsWith('__')) return null
33 return { collection, rkey: key.slice(i + 1) }
34}
35
36export class PointerSubstrate {
37 constructor (statebase, store) {
38 if (!statebase || typeof statebase.set !== 'function' || typeof statebase.list !== 'function') {
39 throw new Error('PointerSubstrate: a StateBase (set/get/del/list) is required for the pointer set')
40 }
41 if (!store || typeof store.put !== 'function' || typeof store.ensureLocal !== 'function') {
42 throw new Error('PointerSubstrate: an OverlayRepoStore (put/ensureLocal) is required for the bytes')
43 }
44 this.statebase = statebase
45 this.store = store
46 }
47
48 // Write the bytes to the overlay (dedup + servable by oid), then the tiny pointer to the autobase.
49 async set (key, record) {
50 const { collection, rkey } = split(key)
51 const { oid, size } = await this.store.put(collection, rkey, record)
52 await this.statebase.set(key, { $oid: oid, size })
53 }
54
55 async del (key) {
56 const { collection, rkey } = split(key)
57 await this.statebase.del(key) // pointer tombstone converges symmetrically
58 try { await this.store.del(collection, rkey) } catch {}
59 }
60
61 // Resolve the pointer to the record bytes (local file, or fetch-by-oid from a peer).
62 async get (key) {
63 const ptr = await this.statebase.get(key)
64 if (!ptr) return null
65 const { collection, rkey } = split(key)
66 return this.store.ensureLocal(collection, rkey, ptr && ptr.$oid)
67 }
68
69 // The converged pointer set (authoritative) resolved to { key: record }. Skips reserved keys.
70 async list (prefix = '') { return this._resolve(await this.statebase.list(prefix)) }
71 async listNow (prefix = '') {
72 const raw = (typeof this.statebase.listNow === 'function') ? await this.statebase.listNow(prefix) : await this.statebase.list(prefix)
73 return this._resolve(raw)
74 }
75
76 async _resolve (ptrMap) {
77 const out = {}
78 for (const key of Object.keys(ptrMap)) {
79 const s = recSplit(key)
80 if (!s) continue // reserved/control key — not a record
81 const ptr = ptrMap[key]
82 const rec = await this.store.ensureLocal(s.collection, s.rkey, ptr && ptr.$oid)
83 if (rec != null) out[key] = rec
84 }
85 return out
86 }
87}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-store",
3 "version": "0.0.6",
3 "version": "0.0.7",
44 "description": "Holepunch-native atproto repo store \u2014 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",