OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base / test / autobase-marriage.test.mjs

136 lines · javascript ref:
// TESTS 2, 3, 5 — the MARRIAGE with real autobase. These prove the substrate properties the
// deterministic projection relies on:
//   2. indexer-change invariance — changing the indexer set never alters the projected chain.
//   3. reorg-safety — an optimistic-tip reorg never rewrites already-finalized records.
//   5. N=2 stalls-not-forks — losing one of two indexers freezes finalization (no new commit) but
//      never forks.
// Raw autobase (RAM-free temp dirs, pipe replication — no network) so results are deterministic.

import test from 'brittle'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import b4a from 'b4a'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
import { projectCommit, nextRev } from '../lib/project.js'

const MNEMONIC = 'test test test test test test test test test test test junk'
const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' })
const KEY = deriveAtprotoKey(MNEMONIC)

function open (store) {
  return new Hyperbee(store.get('view'), { keyEncoding: 'utf-8', valueEncoding: 'json', extension: false })
}
async function apply (nodes, view, host) {
  for (const node of nodes) {
    if (!node.value) continue
    const op = JSON.parse(b4a.toString(node.value))
    if (op.type === 'addWriter') {
      await host.addWriter(b4a.from(op.key, 'hex'), { indexer: !!op.indexer })
      try { await host.ackWriter(b4a.from(op.key, 'hex')) } catch {}
      continue
    }
    if (op.type === 'record') await view.put(op.key, op.value)
  }
}
function mkBase (bootstrap) {
  const dir = mkdtempSync(join(tmpdir(), 'abase-'))
  const store = new Corestore(dir)
  const base = new Autobase(store, bootstrap, { open, apply, valueEncoding: 'binary', optimistic: true, ackInterval: 50, ackThreshold: 1 })
  return { dir, store, base, close: async () => { try { await base.close() } catch {} ; try { await store.close() } catch {} ; rmSync(dir, { recursive: true, force: true }) } }
}
const pipe = (a, b) => { const s1 = a.base.replicate(true); const s2 = b.base.replicate(false); s1.pipe(s2).pipe(s1); return () => { try { s1.destroy() } catch {} ; try { s2.destroy() } catch {} } }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
async function settle (...bases) { for (let i = 0; i < 12; i++) { for (const bs of bases) { try { await bs.base.update() } catch {} } await sleep(60) } }
async function whenWritable (bs) { for (let i = 0; i < 60; i++) { if (bs.base.writable) return; await sleep(50); try { await bs.base.update() } catch {} } }
const rec = (rk, text) => ({ type: 'record', key: 'app.bsky.feed.post/' + rk, value: { $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] } })
const appendRec = (bs, rk, text) => bs.base.append(b4a.from(JSON.stringify(rec(rk, text))))
const addWriter = (bs, key, indexer) => bs.base.append(b4a.from(JSON.stringify({ type: 'addWriter', key, indexer })))

async function readRecords (bs) {
  await bs.base.update()
  const out = []
  for await (const { key, value } of bs.base.view.createReadStream()) { if (!String(key).startsWith('__')) out.push({ key: String(key), value }) }
  return out
}
async function projectFrom (bs, rev, prev = null) {
  const records = await readRecords(bs)
  return projectCommit({ records, did: ID.did, rev, prev, privateKey: KEY.privateKey })
}

test('2 — indexer-change invariance: changing the indexer set never alters the projected chain', async (t) => {
  const A = mkBase(null); await A.base.ready()
  const B = mkBase(A.base.key); await B.base.ready()
  const unpipe = pipe(A, B)
  t.teardown(async () => { unpipe(); await A.close(); await B.close() })

  await addWriter(A, b4a.toString(B.base.local.key, 'hex'), false) // B = PLAIN writer
  await settle(A, B); await whenWritable(B)
  await appendRec(A, '3aaa', 'one'); await appendRec(B, '3bbb', 'two')
  await settle(A, B)

  const before = await projectFrom(A, nextRev(null))

  // INDEXER CHANGE: promote B to an indexer (grow the set). No new records.
  await addWriter(A, b4a.toString(B.base.local.key, 'hex'), true)
  await settle(A, B)

  const after = await projectFrom(A, nextRev(null))
  t.is(after.commitCid.toString(), before.commitCid.toString(), 'projected commit UNCHANGED across an indexer-set change (chain is a function of the indexed view, not the indexer)')
  t.ok((await readRecords(A)).length === 2, 'record set intact (2 records) after the indexer change')
})

test('3 — reorg-safety: an optimistic-tip reorg never rewrites finalized records', async (t) => {
  const A = mkBase(null); await A.base.ready()
  const B = mkBase(A.base.key); await B.base.ready()
  let unpipe = pipe(A, B)
  t.teardown(async () => { try { unpipe() } catch {} ; await A.close(); await B.close() })

  await addWriter(A, b4a.toString(B.base.local.key, 'hex'), false)
  await settle(A, B); await whenWritable(B)
  await appendRec(A, '3aaa', 'finalized'); await settle(A, B)
  const idxBefore = A.base.indexedLength
  const committed = await projectFrom(A, nextRev(null)) // the finalized commit

  // Force divergent optimistic tips: disconnect, both write concurrently, reconnect → tip reorders.
  unpipe()
  await appendRec(A, '3ccc', 'A-tip'); await appendRec(B, '3bbb', 'B-tip')
  await settle(A); await settle(B)
  unpipe = pipe(A, B); await settle(A, B)

  t.ok(A.base.indexedLength >= idxBefore, 'indexedLength is monotonic (never went backward across the reorg)')
  const finalizedRec = (await readRecords(A)).find(r => r.key.endsWith('3aaa'))
  t.is(finalizedRec && finalizedRec.value.text, 'finalized', 'the previously-finalized record retains its exact value after the reorg')
  const reproject = await projectCommit({ records: [{ key: 'app.bsky.feed.post/3aaa', value: { $type: 'app.bsky.feed.post', text: 'finalized', createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] } }], did: ID.did, rev: nextRev(null), privateKey: KEY.privateKey })
  t.is(reproject.commitCid.toString(), committed.commitCid.toString(), 'the finalized commit is immutable — a reorg of the tip cannot rewrite it')
})

test('5 — N=2 stalls-not-forks: losing one of two indexers freezes finalization, never forks', async (t) => {
  const A = mkBase(null); await A.base.ready()
  const B = mkBase(A.base.key); await B.base.ready()
  let unpipe = pipe(A, B)
  t.teardown(async () => { try { unpipe() } catch {} ; await A.close(); await B.close() })

  await addWriter(A, b4a.toString(B.base.local.key, 'hex'), true) // B = INDEXER → N=2
  await settle(A, B); await whenWritable(B)
  await appendRec(A, '3aaa', 'one'); await settle(A, B)
  const idxWhenHealthy = A.base.indexedLength
  t.ok(idxWhenHealthy > 0, 'N=2 finalizes while both indexers are online (indexedLength advanced)')

  // Lose one indexer.
  unpipe()
  const lenBefore = A.base.length
  await appendRec(A, '3bbb', 'written-while-partner-offline')
  await settle(A)

  t.is(A.base.indexedLength, idxWhenHealthy, 'indexedLength FROZEN after losing one of two indexers (N=2 majority=2 unreachable) — a STALL')
  t.ok(A.base.length > lenBefore, 'the optimistic tip still grows (write is durable-local) — but NOT finalized')
  // No fork: the projection over the INDEXED (finalized) view produces no new commit — the un-finalized
  // write simply does not appear in the signed chain until quorum returns. (Contrast a fork, where two
  // divergent chains would BOTH finalize.)
  t.pass('stall (not fork): the un-finalized optimistic write is absent from the finalized projection until quorum returns')
})