| … | … | @@ -45,6 +45,7 @@ export class AtprotoBase { |
| 45 | 45 | this._encryptionKey = opts.encryptionKey || null |
| 46 | 46 | this._identity = opts.identity ? { publicKey: toBuf(opts.identity.publicKey), secretKey: toBuf(opts.identity.secretKey) } : null |
| 47 | 47 | this._members = opts.members || null |
| 48 | this._ackInterval = opts.ackInterval || 1000 | |
| 48 | 49 | this._admitted = new Set() |
| 49 | 50 | this.store = null |
| 50 | 51 | this.base = null |
| … | … | @@ -61,7 +62,7 @@ export class AtprotoBase { |
| 61 | 62 | close: async (view) => { if (view) await view.close() }, |
| 62 | 63 | valueEncoding: 'binary', |
| 63 | 64 | optimistic: true, |
| 64 | ackInterval: 1000, | |
| 65 | ackInterval: this._ackInterval, | |
| 65 | 66 | ackThreshold: 4 |
| 66 | 67 | } |
| 67 | 68 | if (this._keyPair) baseOpts.keyPair = this._keyPair |
| … | … | @@ -200,18 +201,41 @@ export class AtprotoBase { |
| 200 | 201 | await this.base.update() |
| 201 | 202 | } |
| 202 | 203 | |
| 203 | // The INDEXED (finalized) atproto record set — the input to the projection. Single-writer creator: | |
| 204 | // full view == indexed. Multi-writer indexed-view checkout (read at base.indexedLength) is the next slice. | |
| 204 | // The FINALIZED (durably multisig-signed) atproto record set — the input to the projection. Reads the | |
| 205 | // view Hyperbee CHECKED OUT at the VIEW CORE's signedLength, so an un-finalized optimistic tip (a | |
| 206 | // lagging co-owner's writes, or writes during an N=2 stall) NEVER leaks into the signed atproto chain | |
| 207 | // (engine-level reorg-safety; see DESIGN §hard-requirements). NB: base.view.core.signedLength — NOT | |
| 208 | // base.signedLength, which is the SYSTEM/oplog core, a different core (verified against autobase 7.28.1). | |
| 205 | 209 | async indexedRecords () { |
| 206 | 210 | await this.base.update() |
| 211 | const signed = (this.base.view && this.base.view.core) ? this.base.view.core.signedLength : 0 | |
| 212 | if (!signed) return [] // nothing finalized yet (also avoids Hyperbee checkout(0) → full-view footgun) | |
| 213 | const co = this.base.view.checkout(signed) | |
| 207 | 214 | const out = [] |
| 208 | for await (const { key, value } of this.base.view.createReadStream()) { | |
| 209 | if (String(key).startsWith('__')) continue // reserved governance keys | |
| 210 | out.push({ key: String(key), value: value.record }) | |
| 211 | } | |
| 215 | try { | |
| 216 | for await (const { key, value } of co.createReadStream()) { | |
| 217 | if (String(key).startsWith('__')) continue // reserved governance keys | |
| 218 | out.push({ key: String(key), value: value.record }) | |
| 219 | } | |
| 220 | } finally { try { await co.close() } catch {} } | |
| 212 | 221 | return out |
| 213 | 222 | } |
| 214 | 223 | |
| 224 | // Drive finalization until the view core's signedLength stabilizes — so a caller can await the point | |
| 225 | // where recent writes are durably finalized (and thus reflected in indexedRecords()/head()). The | |
| 226 | // atproto chain intentionally advances on FINALIZATION, not on write (durability), so this lag is | |
| 227 | // by-design. Bounded; returns once signedLength holds steady (or the timeout hits, e.g. an N=2 stall). | |
| 228 | async settled ({ timeoutMs = 5000 } = {}) { | |
| 229 | const deadline = Date.now() + timeoutMs | |
| 230 | let last = -1; let stable = 0 | |
| 231 | while (Date.now() < deadline) { | |
| 232 | try { await this.base.update() } catch {} | |
| 233 | const s = (this.base.view && this.base.view.core) ? this.base.view.core.signedLength : 0 | |
| 234 | if (s === last) { if (++stable >= 3) return } else { stable = 0; last = s } | |
| 235 | await new Promise((r) => setTimeout(r, 40)) | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 215 | 239 | async head ({ prevRev = null, prev = null } = {}) { |
| 216 | 240 | const records = await this.indexedRecords() |
| 217 | 241 | const rev = nextRev(prevRev) |
| … | … | @@ -18,9 +18,10 @@ const KEY = deriveAtprotoKey(MNEMONIC) |
| 18 | 18 | |
| 19 | 19 | function mkBase () { |
| 20 | 20 | const dir = mkdtempSync(join(tmpdir(), 'atbase-')) |
| 21 | const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey }) | |
| 21 | const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ackInterval: 100 }) | |
| 22 | 22 | return { base, dir, close: async () => { await base.close(); rmSync(dir, { recursive: true, force: true }) } } |
| 23 | 23 | } |
| 24 | const head = async (bs) => { await bs.base.settled(); return bs.base.head() } // finalized head | |
| 24 | 25 | const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] }) |
| 25 | 26 | |
| 26 | 27 | test('AtprotoBase: typed record writes → relay-valid, deterministic, advancing atproto chain', async (t) => { |
| … | … | @@ -31,7 +32,7 @@ test('AtprotoBase: typed record writes → relay-valid, deterministic, advancing |
| 31 | 32 | await A.base.putRecord('app.bsky.feed.post', '3aaa', post('one')) |
| 32 | 33 | await A.base.putRecord('app.bsky.feed.post', '3bbb', post('two')) |
| 33 | 34 | |
| 34 | const h1 = await A.base.head() | |
| 35 | const h1 = await head(A) | |
| 35 | 36 | const v = await verifyRepo(await readCar(h1.carBytes)) |
| 36 | 37 | t.ok(v.ok, `head CAR is relay-valid (verifyRepo), records=${v.records}`) |
| 37 | 38 | t.ok(verifyCommit(h1.signedCommit, KEY.publicKey), 'head commit verifies against #atproto key') |
| … | … | @@ -39,13 +40,13 @@ test('AtprotoBase: typed record writes → relay-valid, deterministic, advancing |
| 39 | 40 | |
| 40 | 41 | // Advances on a new write. |
| 41 | 42 | await A.base.putRecord('app.bsky.feed.post', '3ccc', post('three')) |
| 42 | const h2 = await A.base.head() | |
| 43 | const h2 = await head(A) | |
| 43 | 44 | t.not(h1.commitCid.toString(), h2.commitCid.toString(), 'head advances when a record is added') |
| 44 | 45 | t.is((await verifyRepo(await readCar(h2.carBytes))).records, 3, 'now 3 records') |
| 45 | 46 | |
| 46 | 47 | // Delete is reflected. |
| 47 | 48 | await A.base.delRecord('app.bsky.feed.post', '3aaa') |
| 48 | const h3 = await A.base.head() | |
| 49 | const h3 = await head(A) | |
| 49 | 50 | t.is((await verifyRepo(await readCar(h3.carBytes))).records, 2, 'record deleted → 2 records in projection') |
| 50 | 51 | }) |
| 51 | 52 | |
| … | … | @@ -60,8 +61,8 @@ test('AtprotoBase: two independent bases with the same records project the IDENT |
| 60 | 61 | await B.base.putRecord('app.bsky.feed.post', '3bbb', post('two')) |
| 61 | 62 | await B.base.putRecord('app.bsky.feed.post', '3aaa', post('one')) |
| 62 | 63 | |
| 63 | const ha = await A.base.head() | |
| 64 | const hb = await B.base.head() | |
| 64 | const ha = await head(A) | |
| 65 | const hb = await head(B) | |
| 65 | 66 | t.is(ha.commitCid.toString(), hb.commitCid.toString(), 'identical head commit CID across co-owners') |
| 66 | 67 | t.is(Buffer.compare(Buffer.from(ha.signedCommit.sig), Buffer.from(hb.signedCommit.sig)), 0, 'identical signature (no signer election needed)') |
| 67 | 68 | }) |
| … | … | @@ -0,0 +1,59 @@ |
| 1 | // ENGINE-LEVEL REORG-SAFETY — head()/indexedRecords() project ONLY the FINALIZED view (checked out | |
| 2 | // at the view core's signedLength), so an un-finalized optimistic write NEVER leaks into the signed | |
| 3 | // atproto chain. Proof: a plain writer, disconnected from the sole indexer, writes a record — it lands | |
| 4 | // in the writer's optimistic view but is ABSENT from its finalized head, until an indexer finalizes it. | |
| 5 | // (This is the head-level counterpart of proof #3/#5; it guarantees the projection can't sign a tip | |
| 6 | // that later reorgs, and that an N=2 stall simply omits the un-finalized write rather than forking.) | |
| 7 | ||
| 8 | import test from 'brittle' | |
| 9 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 10 | import { tmpdir } from 'node:os' | |
| 11 | import { join } from 'node:path' | |
| 12 | import crypto from 'hypercore-crypto' | |
| 13 | import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity' | |
| 14 | import { readCar } from 'atproto-repo/lib/car.js' | |
| 15 | import { verifyRepo } from 'atproto-repo' | |
| 16 | import { AtprotoBase } from '../lib/base.js' | |
| 17 | ||
| 18 | const MNEMONIC = 'test test test test test test test test test test test junk' | |
| 19 | const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }) | |
| 20 | const KEY = deriveAtprotoKey(MNEMONIC) | |
| 21 | const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] }) | |
| 22 | const hex = (b) => b.toString('hex') | |
| 23 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) | |
| 24 | function mk (opts) { const dir = mkdtempSync(join(tmpdir(), 'fv-')); const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ackInterval: 100, ...opts }); return { base, dir, close: async () => { await base.close(); rmSync(dir, { recursive: true, force: true }) } } } | |
| 25 | const pipe = (a, b) => { const s1 = a.base.base.replicate(true); const s2 = b.base.base.replicate(false); s1.pipe(s2).pipe(s1); return () => { try { s1.destroy() } catch {} ; try { s2.destroy() } catch {} } } | |
| 26 | async function settle (...ws) { for (let i = 0; i < 15; i++) { for (const w of ws) { try { await w.base.base.update() } catch {} } await sleep(60) } } | |
| 27 | async function fullViewCount (bs) { let n = 0; for await (const { key } of bs.base.base.view.createReadStream()) { if (!String(key).startsWith('__')) n++ } return n } | |
| 28 | async function recordCount (car) { return (await verifyRepo(await readCar(car))).records } | |
| 29 | ||
| 30 | test('finalized-view: an un-finalized write is in the optimistic view but NEVER in the signed head', async (t) => { | |
| 31 | const creatorId = crypto.keyPair() | |
| 32 | const writerId = crypto.keyPair() | |
| 33 | const roster = [{ key: hex(creatorId.publicKey), role: 1 }, { key: hex(writerId.publicKey), role: 3 }] | |
| 34 | ||
| 35 | const C = mk({ identity: creatorId, members: roster }) // creator = sole indexer | |
| 36 | await C.base.ready() | |
| 37 | const P = mk({ bootstrap: C.base.base.key, identity: writerId }) // P = plain writer | |
| 38 | await P.base.ready() | |
| 39 | let unpipe = pipe(C, P) | |
| 40 | t.teardown(async () => { try { unpipe() } catch {} ; await C.close(); await P.close() }) | |
| 41 | ||
| 42 | await C.base.projectMembers(roster); await settle(C, P) | |
| 43 | await P.base.putRecord('app.bsky.feed.post', '3one', post('finalized')) // admitted + written | |
| 44 | await settle(C, P); await P.base.settled() | |
| 45 | t.is(await recordCount((await P.base.head()).carBytes), 1, 'first write is finalized and in the signed head') | |
| 46 | ||
| 47 | // Disconnect P from the sole indexer; P writes again — this write CANNOT finalize. | |
| 48 | unpipe() | |
| 49 | await P.base.putRecord('app.bsky.feed.post', '3two', post('un-finalized')) | |
| 50 | await P.base.settled({ timeoutMs: 1500 }) // will not advance — nothing to finalize it | |
| 51 | ||
| 52 | t.is(await fullViewCount(P), 2, "the un-finalized write IS in P's optimistic view (durable-local)") | |
| 53 | t.is(await recordCount((await P.base.head()).carBytes), 1, 'but the un-finalized write is ABSENT from the finalized signed head (no tip leaks into the chain)') | |
| 54 | ||
| 55 | // Reconnect → the sole indexer finalizes it → it now appears in the head (no fork, just delayed). | |
| 56 | unpipe = pipe(C, P) | |
| 57 | await settle(C, P); await P.base.settled() | |
| 58 | t.is(await recordCount((await P.base.head()).carBytes), 2, 'after reconnect the indexer finalizes it → now in the signed head (stall resolved, never forked)') | |
| 59 | }) |
| … | … | @@ -20,7 +20,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) |
| 20 | 20 | |
| 21 | 21 | function mk (opts) { |
| 22 | 22 | const dir = mkdtempSync(join(tmpdir(), 'gov-')) |
| 23 | const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ...opts }) | |
| 23 | const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ackInterval: 100, ...opts }) | |
| 24 | 24 | return { base, dir, close: async () => { await base.close(); rmSync(dir, { recursive: true, force: true }) } } |
| 25 | 25 | } |
| 26 | 26 | const pipe = (a, b) => { const s1 = a.base.base.replicate(true); const s2 = b.base.base.replicate(false); s1.pipe(s2).pipe(s1); return () => { try { s1.destroy() } catch {} ; try { s2.destroy() } catch {} } } |
| … | … | @@ -46,9 +46,11 @@ test('governance: a member joiner is admitted (block-until-admitted) → writes |
| 46 | 46 | t.ok(J.base.writable, 'joiner was admitted as a writer (block-until-admitted)') |
| 47 | 47 | |
| 48 | 48 | await settle(C, J) |
| 49 | await C.base.settled() | |
| 49 | 50 | const recsC = await C.base.indexedRecords() |
| 50 | 51 | t.ok(recsC.find((r) => r.key.endsWith('3joiner')), "joiner's record replicated into the creator's shared view") |
| 51 | 52 | |
| 53 | await C.base.settled(); await J.base.settled() | |
| 52 | 54 | const hC = await C.base.head() |
| 53 | 55 | const hJ = await J.base.head() |
| 54 | 56 | t.is(hC.commitCid.toString(), hJ.commitCid.toString(), 'identical projected head across co-owners after admission (deterministic, no signer election)') |