OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base commits / 0c286a9

atproto-base: lib/base.js slice 1 — autobase record engine wired to the projector
AtprotoBase: one autobase per DID, typed record ops (set-record/del-record, LWW by log-derived counter — NOT set-entry overloaded by magic keys), indexedRecords() + head() projection. Records written through the typed API produce a relay-valid, deterministic, advancing atproto chain; two independent co-owner bases with the same records project the IDENTICAL head commit + signature (no signer election). Governance (proof-gated admission, indexer topology, revocation) and multi-writer indexed-view checkout are the next slices, ported clean from atproto-room/StateBaseV3. test/base.test.mjs (2 tests / 9 asserts). Suite now 9 tests / 45 asserts, all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0c286a9f49f055e0ec8254a6084ca7587a96b957
by plankatron <fleeky@gmail.com> · 2026-07-23T16:34:16.000Z

Changed files (2) +188 -0

lib/base.js +121 -0
……@@ -0,0 +1,121 @@
1// AtprotoBase — one autobase per DID whose view holds the atproto record set. The atproto commit
2// chain is produced by the deterministic PROJECTION (lib/project.js) over the INDEXED view. There is
3// NO signer election: signing is a pure function of the indexed view + the shared #atproto key, so it
4// is indexer-agnostic (see DESIGN.md, proven by the offline suite).
5//
6// SLICE 1 (this file): single-writer creator + TYPED record ops (set-record/del-record, LWW by rev)
7// + indexedRecords() + head() projection. Governance (proof-gated admission, indexer topology,
8// revocation) and multi-writer are the next slices, ported CLEAN from atproto-room/StateBaseV3 —
9// as TYPED ops, not the `set-entry`-overloaded-by-magic-keys design.
10
11import Corestore from 'corestore'
12import Autobase from 'autobase'
13import Hyperbee from 'hyperbee'
14import b4a from 'b4a'
15import { projectCommit, nextRev } from './project.js'
16
17// Typed op names (contract-router style, like keet — NOT one op overloaded by magic keys).
18const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record' }
19const RKEY = (collection, rkey) => `${collection}/${rkey}` // the atproto MST key
20
21export class AtprotoBase {
22 // opts: { path | store, bootstrap?, keyPair?, encryptionKey?, did, privateKey }
23 // `did` + `privateKey` are the DID identity used by the projection (the shared #atproto key).
24 constructor (opts = {}) {
25 if (!opts.did) throw new Error('AtprotoBase requires opts.did')
26 if (!opts.privateKey) throw new Error('AtprotoBase requires opts.privateKey (the #atproto signing key)')
27 this._did = opts.did
28 this._privateKey = opts.privateKey
29 this._injectedStore = opts.store || null
30 this._path = opts.path || null
31 this._bootstrap = opts.bootstrap || null
32 this._keyPair = opts.keyPair || null
33 this._encryptionKey = opts.encryptionKey || null
34 this.store = null
35 this.base = null
36 this._ready = false
37 }
38
39 async ready () {
40 if (this._ready) return
41 this.store = this._injectedStore ? this._injectedStore.namespace(this._did) : new Corestore(this._path)
42 await this.store.ready()
43 const baseOpts = {
44 open: (s) => new Hyperbee(s.get('view'), { keyEncoding: 'utf-8', valueEncoding: 'json', extension: false }),
45 apply: this._apply.bind(this),
46 close: async (view) => { if (view) await view.close() },
47 valueEncoding: 'binary',
48 optimistic: true,
49 ackInterval: 1000,
50 ackThreshold: 4
51 }
52 if (this._keyPair) baseOpts.keyPair = this._keyPair
53 if (this._encryptionKey) { baseOpts.encryptionKey = this._encryptionKey; baseOpts.encrypted = true }
54 this.base = new Autobase(this.store, this._bootstrap, baseOpts)
55 await this.base.ready()
56 this._ready = true
57 }
58
59 // Deterministic apply — TYPED ops fold into the record view. LWW by rev (a monotonic per-record
60 // counter carried on the op), so concurrent writers converge deterministically. Pure: no wall-clock.
61 async _apply (nodes, view, host) {
62 const tx = view // hyperbee batch is implicit via put/del; keep simple for slice 1
63 for (const node of nodes) {
64 if (!node.value) continue
65 let op
66 try { op = JSON.parse(b4a.toString(node.value)) } catch { continue }
67 if (op.t === OP.SET_RECORD) {
68 const existing = await view.get(op.key)
69 if (existing && existing.value && typeof existing.value.w === 'number' && existing.value.w > op.w) continue // LWW
70 await view.put(op.key, { record: op.record, w: op.w })
71 } else if (op.t === OP.DEL_RECORD) {
72 const existing = await view.get(op.key)
73 if (existing && existing.value && typeof existing.value.w === 'number' && existing.value.w > op.w) continue
74 await view.del(op.key)
75 }
76 }
77 }
78
79 get writable () { return this.base ? this.base.writable : false }
80
81 // Write a record. `w` is a per-write monotonic counter for deterministic LWW (log-derived, not
82 // wall-clock). Slice 1 uses the writer's own local length as w; multi-writer LWW hardening is later.
83 async putRecord (collection, rkey, record) {
84 const w = this.base.local ? this.base.local.length : 0
85 await this.base.append(b4a.from(JSON.stringify({ t: OP.SET_RECORD, key: RKEY(collection, rkey), record, w })))
86 await this.base.update()
87 }
88
89 async delRecord (collection, rkey) {
90 const w = this.base.local ? this.base.local.length : 0
91 await this.base.append(b4a.from(JSON.stringify({ t: OP.DEL_RECORD, key: RKEY(collection, rkey), w })))
92 await this.base.update()
93 }
94
95 // The INDEXED (finalized) atproto record set — the input to the projection. For a single-writer
96 // creator (sole indexer), everything it writes finalizes, so the full view == indexed. Multi-writer
97 // will read at base.indexedLength via a view checkout (next slice — see DESIGN reorg-safety).
98 async indexedRecords () {
99 await this.base.update()
100 const out = []
101 for await (const { key, value } of this.base.view.createReadStream()) {
102 if (String(key).startsWith('__')) continue // reserved governance keys (slice 2)
103 out.push({ key: String(key), value: value.record })
104 }
105 return out
106 }
107
108 // Project the atproto commit for the current indexed view. `prevRev`/`prev` continue an existing
109 // chain (null = genesis). Deterministic + relay-valid (proven by the offline suite).
110 async head ({ prevRev = null, prev = null } = {}) {
111 const records = await this.indexedRecords()
112 const rev = nextRev(prevRev)
113 return projectCommit({ records, did: this._did, rev, prev, privateKey: this._privateKey })
114 }
115
116 async close () {
117 if (this.base) { try { await this.base.close() } catch {} this.base = null }
118 if (this.store && !this._injectedStore) { try { await this.store.close() } catch {} }
119 this._ready = false
120 }
121}
test/base.test.mjs +67 -0
……@@ -0,0 +1,67 @@
1// AtprotoBase slice 1 — the engine (autobase record view) wired to the deterministic projector.
2// Proves: records written through the typed op API produce a relay-valid, deterministic, advancing
3// atproto commit chain — and two independent bases with the same records project the IDENTICAL head.
4
5import test from 'brittle'
6import { mkdtempSync, rmSync } from 'node:fs'
7import { tmpdir } from 'node:os'
8import { join } from 'node:path'
9import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
10import { verifyCommit } from 'atproto-repo/lib/commit.js'
11import { readCar } from 'atproto-repo/lib/car.js'
12import { verifyRepo } from 'atproto-repo'
13import { AtprotoBase } from '../lib/base.js'
14
15const MNEMONIC = 'test test test test test test test test test test test junk'
16const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' })
17const KEY = deriveAtprotoKey(MNEMONIC)
18
19function mkBase () {
20 const dir = mkdtempSync(join(tmpdir(), 'atbase-'))
21 const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey })
22 return { base, dir, close: async () => { await base.close(); rmSync(dir, { recursive: true, force: true }) } }
23}
24const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] })
25
26test('AtprotoBase: typed record writes → relay-valid, deterministic, advancing atproto chain', async (t) => {
27 const A = mkBase(); await A.base.ready()
28 t.teardown(() => A.close())
29 t.ok(A.base.writable, 'creator base is writable')
30
31 await A.base.putRecord('app.bsky.feed.post', '3aaa', post('one'))
32 await A.base.putRecord('app.bsky.feed.post', '3bbb', post('two'))
33
34 const h1 = await A.base.head()
35 const v = await verifyRepo(await readCar(h1.carBytes))
36 t.ok(v.ok, `head CAR is relay-valid (verifyRepo), records=${v.records}`)
37 t.ok(verifyCommit(h1.signedCommit, KEY.publicKey), 'head commit verifies against #atproto key')
38 t.is(v.records, 2, 'both records present in the projected repo')
39
40 // Advances on a new write.
41 await A.base.putRecord('app.bsky.feed.post', '3ccc', post('three'))
42 const h2 = await A.base.head()
43 t.not(h1.commitCid.toString(), h2.commitCid.toString(), 'head advances when a record is added')
44 t.is((await verifyRepo(await readCar(h2.carBytes))).records, 3, 'now 3 records')
45
46 // Delete is reflected.
47 await A.base.delRecord('app.bsky.feed.post', '3aaa')
48 const h3 = await A.base.head()
49 t.is((await verifyRepo(await readCar(h3.carBytes))).records, 2, 'record deleted → 2 records in projection')
50})
51
52test('AtprotoBase: two independent bases with the same records project the IDENTICAL head (engine-level determinism)', async (t) => {
53 const A = mkBase(); const B = mkBase()
54 await A.base.ready(); await B.base.ready()
55 t.teardown(async () => { await A.close(); await B.close() })
56
57 // Same records, written in DIFFERENT order on two separate co-owner bases.
58 await A.base.putRecord('app.bsky.feed.post', '3aaa', post('one'))
59 await A.base.putRecord('app.bsky.feed.post', '3bbb', post('two'))
60 await B.base.putRecord('app.bsky.feed.post', '3bbb', post('two'))
61 await B.base.putRecord('app.bsky.feed.post', '3aaa', post('one'))
62
63 const ha = await A.base.head()
64 const hb = await B.base.head()
65 t.is(ha.commitCid.toString(), hb.commitCid.toString(), 'identical head commit CID across co-owners')
66 t.is(Buffer.compare(Buffer.from(ha.signedCommit.sig), Buffer.from(hb.signedCommit.sig)), 0, 'identical signature (no signer election needed)')
67})