| … | … | @@ -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 | |
| 11 | import Corestore from 'corestore' |
| 12 | import Autobase from 'autobase' |
| 13 | import Hyperbee from 'hyperbee' |
| 14 | import b4a from 'b4a' |
| 15 | import { projectCommit, nextRev } from './project.js' |
| 16 | |
| 17 | // Typed op names (contract-router style, like keet — NOT one op overloaded by magic keys). |
| 18 | const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record' } |
| 19 | const RKEY = (collection, rkey) => `${collection}/${rkey}` // the atproto MST key |
| 20 | |
| 21 | export 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 | } |