OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-store commits / 6feb7d7

OverlayRepoSubstrate: RepoBase KV adapter over OverlayRepoStore (record seam)
Presents an OverlayRepoStore as the { set, del, get, list, listNow } substrate RepoBase expects, so the multi-DID path's record ops read/write content-addressed FILES instead of the append-only StateBase autobase. Records-only: reserved "__" control keys never reach it (swarm-device writes those to the StateBase directly); throws loud on a non-record key. listNow == list (files always current). Proof (test, 3/3 · 10/10): the REAL RepoBase (dependency-free) drives put/get/ list/snapshot/delete over files; records written through RepoBase→adapter derive a byte-identical commit CID + CAR vs the Hyperbee RepoStore. Full suite green. Tracks hyper-social #8. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6feb7d70e79d2d0cafcac5fd6a073393b2b225a3
by plankatron <fleeky@gmail.com> · 2026-07-19T16:32:20.000Z

Changed files (2) +117 -0

lib/overlay-repo-substrate.js +44 -0
……@@ -0,0 +1,44 @@
1// OverlayRepoSubstrate — present an OverlayRepoStore as the KV substrate RepoBase expects
2// (`{ set, del, get, list, listNow }`), so RepoBase's record ops read/write content-addressed
3// FILES instead of an append-only autobase. This is the seam that moves the multi-DID path's
4// records off the StateBase (see hyper-proto-peer/docs/DESIGN-multidid-record-decoupling.md).
5//
6// RepoBase keys are "<collection>/<rkey>" (the FIRST "/" separates; atproto NSIDs + rkeys forbid
7// "/"). Reserved "__"-prefixed / single-segment control keys (__members__, __commit__/<rev>, …)
8// NEVER reach here — swarm-device writes those directly to the StateBase — so this substrate is
9// records-only and throws on a non-record key (fail-loud if that assumption is ever violated).
10//
11// listNow == list: file reads are always current, so there is no blocking base.update() to avoid
12// (RepoBase.listRecords({now:true}) uses listNow for its liveness-critical, non-blocking path).
13
14const SEP = '/'
15
16function split (key) {
17 const k = String(key)
18 const i = k.indexOf(SEP)
19 if (i < 0) throw new Error('OverlayRepoSubstrate: expected a "collection/rkey" record key, got ' + JSON.stringify(key))
20 return { collection: k.slice(0, i), rkey: k.slice(i + 1) }
21}
22
23export class OverlayRepoSubstrate {
24 constructor (store) {
25 if (!store || typeof store.put !== 'function' || typeof store.records !== 'function') {
26 throw new Error('OverlayRepoSubstrate: an OverlayRepoStore (put/get/del/records) is required')
27 }
28 this.store = store
29 }
30
31 async set (key, value) { const { collection, rkey } = split(key); await this.store.put(collection, rkey, value) }
32 async del (key) { const { collection, rkey } = split(key); await this.store.del(collection, rkey) }
33 async get (key) { const { collection, rkey } = split(key); return this.store.get(collection, rkey) }
34
35 // Return { key: record } for every record whose key starts with `prefix` ("" = all,
36 // "<collection>/" = one collection) — the shape RepoBase.listRecords folds over.
37 async list (prefix = '') {
38 const out = {}
39 for (const { key, record } of await this.store.records()) if (key.startsWith(prefix)) out[key] = record
40 return out
41 }
42
43 async listNow (prefix = '') { return this.list(prefix) } // files are always current — no base.update() to block on
44}
test/overlay-repo-substrate.test.js +73 -0
……@@ -0,0 +1,73 @@
1// Proof: the REAL RepoBase (atproto-room, dependency-free) drives an OverlayRepoStore through the
2// substrate adapter identically to a StateBase KV — and the records it writes derive the same repo
3// as the Hyperbee RepoStore. This is the record seam that moves the multi-DID path off the autobase.
4
5import test from 'brittle'
6import { mkdtempSync, rmSync } from 'node:fs'
7import { join } from 'node:path'
8import { tmpdir } from 'node:os'
9import b4a from 'b4a'
10import { OverlayRepoStore } from '../lib/overlay-repo-store.js'
11import { OverlayRepoSubstrate } from '../lib/overlay-repo-substrate.js'
12import { RepoStore } from '../lib/repo-store.js'
13import { getRepo } from '../lib/read-view.js'
14import { RepoBase } from '../../atproto-room/lib/repo-base.js' // dependency-free (pure KV convention)
15
16const DID = 'did:plc:test'
17const KEY = (() => { const k = b4a.alloc(32); k[31] = 7; return k })()
18const REV = '3ktestrev00000'
19const META = { did: DID, rev: REV, prev: null, privateKey: KEY }
20const tmp = (t) => { const d = mkdtempSync(join(tmpdir(), 'ors-sub-')); t.teardown(() => rmSync(d, { recursive: true, force: true })); return d }
21
22test('RepoBase over the adapter: put/get/list/snapshot/delete work over files', async (t) => {
23 const store = await new OverlayRepoStore(tmp(t)).ready()
24 const repo = new RepoBase(new OverlayRepoSubstrate(store), { did: DID })
25
26 const post = (text, at) => ({ $type: 'app.bsky.feed.post', text, createdAt: at })
27 await repo.putRecord('app.bsky.feed.post', '3kaaa', post('hello', '2026-01-01T00:00:00.000Z'))
28 await repo.putRecord('app.bsky.feed.post', '3kbbb', post('world', '2026-01-02T00:00:00.000Z'))
29 await repo.putRecord('app.bsky.actor.profile', 'self', { $type: 'app.bsky.actor.profile', displayName: 'T' })
30
31 t.alike(await repo.getRecord('app.bsky.feed.post', '3kaaa'), post('hello', '2026-01-01T00:00:00.000Z'), 'getRecord round-trips through files')
32 t.is(await repo.getRecord('app.bsky.feed.post', 'nope'), null, 'missing record → null')
33
34 const posts = await repo.listRecords('app.bsky.feed.post')
35 t.alike(posts.map((r) => r.rkey).sort(), ['3kaaa', '3kbbb'], 'listRecords scoped to a collection')
36 t.is((await repo.listRecords()).length, 3, 'listRecords all')
37
38 const snap = await repo.snapshot()
39 t.is(Object.keys(snap).length, 2, 'two collections in snapshot')
40 t.ok(snap['app.bsky.feed.post']['3kaaa'], 'snapshot nests collection/rkey/record')
41
42 await repo.deleteRecord('app.bsky.feed.post', '3kbbb')
43 t.is((await repo.listRecords('app.bsky.feed.post')).length, 1, 'delete removed the record file')
44
45 await store.close()
46})
47
48test('records written via RepoBase→adapter derive the same repo as a Hyperbee RepoStore', async (t) => {
49 const RECS = [
50 ['app.bsky.actor.profile', 'self', { $type: 'app.bsky.actor.profile', displayName: 'T' }],
51 ['app.bsky.feed.post', '3kaaa', { $type: 'app.bsky.feed.post', text: 'hello', createdAt: '2026-01-01T00:00:00.000Z' }],
52 ['app.bsky.feed.post', '3kbbb', { $type: 'app.bsky.feed.post', text: 'world', createdAt: '2026-01-02T00:00:00.000Z' }]
53 ]
54 const ov = await new OverlayRepoStore(tmp(t)).ready()
55 const rb = new RepoBase(new OverlayRepoSubstrate(ov), { did: DID })
56 for (const [c, r, rec] of RECS) await rb.putRecord(c, r, rec)
57
58 const hb = await new RepoStore(tmp(t)).ready()
59 for (const [c, r, rec] of RECS) await hb.put(c, r, rec)
60
61 const a = await getRepo(hb, META); const b = await getRepo(ov, META)
62 t.is(b.commitCid.toString(), a.commitCid.toString(), 'commit CID identical')
63 t.ok(b4a.equals(b.carBytes, a.carBytes), 'CAR byte-identical')
64
65 await ov.close(); await hb.close()
66})
67
68test('substrate is records-only — a reserved / no-separator key is rejected', async (t) => {
69 const store = await new OverlayRepoStore(tmp(t)).ready()
70 const sub = new OverlayRepoSubstrate(store)
71 await t.exception(() => sub.set('__lease__', { x: 1 }), /collection\/rkey/, 'reserved control key rejected')
72 await store.close()
73})