OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base / test / indexer-topology.test.mjs

89 lines · javascript ref:
// INDEXER TOPOLOGY (keet-aligned start-indexing). An ADMIN co-owner self-promotes to indexer
// (@keet/start-indexing handler I), growing N=1→N=2; a NON-admin is denied. The base keeps
// finalizing. (This is keet's self-promote-if-canAdmin — NOT StateBaseV3's cross-member
// move-indexer, which was the misread.)

import test from 'brittle'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import crypto from 'hypercore-crypto'
import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
import { readCar } from 'atproto-repo/lib/car.js'
import { verifyRepo } from 'atproto-repo'
import { AtprotoBase } from '../lib/base.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)
const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] })
const hex = (b) => b.toString('hex')
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
function mk (opts) { const dir = mkdtempSync(join(tmpdir(), 'idx-')); 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 }) } } }
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 {} } }
async function settle (...ws) { for (let i = 0; i < 20; i++) { for (const w of ws) { try { await w.base.base.update() } catch {} } await sleep(60) } }
const indexers = (bs) => { try { return bs.base.base.linearizer.indexers.length } catch { return -1 } }
async function recordCount (car) { return (await verifyRepo(await readCar(car))).records }

test('start-indexing: an ADMIN co-owner self-promotes to indexer (N=1→N=2); base keeps finalizing', async (t) => {
  const cId = crypto.keyPair(); const aId = crypto.keyPair()
  const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(aId.publicKey), role: 1 }] // both admins

  const C = mk({ identity: cId, members: roster }); await C.base.ready()
  const A = mk({ bootstrap: C.base.base.key, identity: aId }); await A.base.ready()
  const unpipe = pipe(C, A)
  t.teardown(async () => { unpipe(); await C.close(); await A.close() })

  await C.base.projectMembers(roster); await settle(C, A)
  t.is(indexers(C), 1, 'starts at N=1 (creator sole indexer)')

  const ok = await A.base.requestIndexer({ timeoutMs: 8000 })
  await settle(C, A)
  t.ok(ok && A.base._isLocalIndexer(), 'admin co-owner became an indexer (self-promote, canAdmin)')
  t.is(indexers(C), 2, 'indexer set grew to N=2')

  await A.base.putRecord('app.bsky.feed.post', '3a', post('from A'))
  await settle(C, A); await C.base.settled()
  t.is(await recordCount((await C.base.head()).carBytes), 1, 'base still finalizes with 2 indexers online')
})

test('N=1→N=3 quorum: after growing to 3 indexers, the base survives the creator going OFFLINE', async (t) => {
  const cId = crypto.keyPair(); const aId = crypto.keyPair(); const bId = crypto.keyPair()
  const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(aId.publicKey), role: 1 }, { key: hex(bId.publicKey), role: 1 }] // 3 admins

  const C = mk({ identity: cId, members: roster }); await C.base.ready()
  const A = mk({ bootstrap: C.base.base.key, identity: aId }); await A.base.ready()
  const B = mk({ bootstrap: C.base.base.key, identity: bId }); await B.base.ready()
  // full mesh
  let pCA = pipe(C, A); let pCB = pipe(C, B); const pAB = pipe(A, B)
  t.teardown(async () => { try { pCA() } catch {} ; try { pCB() } catch {} ; try { pAB() } catch {} ; await C.close(); await A.close(); await B.close() })

  await C.base.projectMembers(roster); await settle(C, A, B)
  await A.base.requestIndexer({ timeoutMs: 8000 }); await settle(C, A, B) // N=2
  await B.base.requestIndexer({ timeoutMs: 8000 }); await settle(C, A, B) // N=3
  t.is(indexers(A), 3, 'grew N=1→N=3 (three admin indexers)')

  // Creator goes OFFLINE. A + B (quorum 2 of 3) must still finalize.
  pCA(); pCB()
  await A.base.putRecord('app.bsky.feed.post', '3survive', post('written with creator offline'))
  await settle(A, B); await A.base.settled()
  t.is(await recordCount((await A.base.head()).carBytes), 1, 'A+B (2 of 3 quorum) finalized a write with the creator OFFLINE — the durability N=1 cannot provide')
})

test('start-indexing: a NON-admin is denied (stays a plain writer)', async (t) => {
  const cId = crypto.keyPair(); const nId = crypto.keyPair()
  const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(nId.publicKey), role: 3 }] // n = non-admin

  const C = mk({ identity: cId, members: roster }); await C.base.ready()
  const N = mk({ bootstrap: C.base.base.key, identity: nId }); await N.base.ready()
  const unpipe = pipe(C, N)
  t.teardown(async () => { unpipe(); await C.close(); await N.close() })

  await C.base.projectMembers(roster); await settle(C, N)
  const ok = await N.base.requestIndexer({ timeoutMs: 3000 })
  await settle(C, N)
  t.absent(ok, 'requestIndexer did not succeed for a non-admin')
  t.absent(N.base._isLocalIndexer(), 'non-admin never became an indexer')
  t.is(indexers(C), 1, 'indexer set stayed at N=1')
})