| … | … | @@ -0,0 +1,88 @@ |
| 1 | // INDEXER TOPOLOGY (keet-aligned start-indexing). An ADMIN co-owner self-promotes to indexer |
| 2 | // (@keet/start-indexing handler I), growing N=1→N=2; a NON-admin is denied. The base keeps |
| 3 | // finalizing. (This is keet's self-promote-if-canAdmin — NOT StateBaseV3's cross-member |
| 4 | // move-indexer, which was the misread.) |
| 5 | |
| 6 | import test from 'brittle' |
| 7 | import { mkdtempSync, rmSync } from 'node:fs' |
| 8 | import { tmpdir } from 'node:os' |
| 9 | import { join } from 'node:path' |
| 10 | import crypto from 'hypercore-crypto' |
| 11 | import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity' |
| 12 | import { readCar } from 'atproto-repo/lib/car.js' |
| 13 | import { verifyRepo } from 'atproto-repo' |
| 14 | import { AtprotoBase } from '../lib/base.js' |
| 15 | |
| 16 | const MNEMONIC = 'test test test test test test test test test test test junk' |
| 17 | const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }) |
| 18 | const KEY = deriveAtprotoKey(MNEMONIC) |
| 19 | const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] }) |
| 20 | const hex = (b) => b.toString('hex') |
| 21 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) |
| 22 | 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 }) } } } |
| 23 | 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 {} } } |
| 24 | 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) } } |
| 25 | const indexers = (bs) => { try { return bs.base.base.linearizer.indexers.length } catch { return -1 } } |
| 26 | async function recordCount (car) { return (await verifyRepo(await readCar(car))).records } |
| 27 | |
| 28 | test('start-indexing: an ADMIN co-owner self-promotes to indexer (N=1→N=2); base keeps finalizing', async (t) => { |
| 29 | const cId = crypto.keyPair(); const aId = crypto.keyPair() |
| 30 | const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(aId.publicKey), role: 1 }] // both admins |
| 31 | |
| 32 | const C = mk({ identity: cId, members: roster }); await C.base.ready() |
| 33 | const A = mk({ bootstrap: C.base.base.key, identity: aId }); await A.base.ready() |
| 34 | const unpipe = pipe(C, A) |
| 35 | t.teardown(async () => { unpipe(); await C.close(); await A.close() }) |
| 36 | |
| 37 | await C.base.projectMembers(roster); await settle(C, A) |
| 38 | t.is(indexers(C), 1, 'starts at N=1 (creator sole indexer)') |
| 39 | |
| 40 | const ok = await A.base.requestIndexer({ timeoutMs: 8000 }) |
| 41 | await settle(C, A) |
| 42 | t.ok(ok && A.base._isLocalIndexer(), 'admin co-owner became an indexer (self-promote, canAdmin)') |
| 43 | t.is(indexers(C), 2, 'indexer set grew to N=2') |
| 44 | |
| 45 | await A.base.putRecord('app.bsky.feed.post', '3a', post('from A')) |
| 46 | await settle(C, A); await C.base.settled() |
| 47 | t.is(await recordCount((await C.base.head()).carBytes), 1, 'base still finalizes with 2 indexers online') |
| 48 | }) |
| 49 | |
| 50 | test('N=1→N=3 quorum: after growing to 3 indexers, the base survives the creator going OFFLINE', async (t) => { |
| 51 | const cId = crypto.keyPair(); const aId = crypto.keyPair(); const bId = crypto.keyPair() |
| 52 | const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(aId.publicKey), role: 1 }, { key: hex(bId.publicKey), role: 1 }] // 3 admins |
| 53 | |
| 54 | const C = mk({ identity: cId, members: roster }); await C.base.ready() |
| 55 | const A = mk({ bootstrap: C.base.base.key, identity: aId }); await A.base.ready() |
| 56 | const B = mk({ bootstrap: C.base.base.key, identity: bId }); await B.base.ready() |
| 57 | // full mesh |
| 58 | let pCA = pipe(C, A); let pCB = pipe(C, B); const pAB = pipe(A, B) |
| 59 | t.teardown(async () => { try { pCA() } catch {} ; try { pCB() } catch {} ; try { pAB() } catch {} ; await C.close(); await A.close(); await B.close() }) |
| 60 | |
| 61 | await C.base.projectMembers(roster); await settle(C, A, B) |
| 62 | await A.base.requestIndexer({ timeoutMs: 8000 }); await settle(C, A, B) // N=2 |
| 63 | await B.base.requestIndexer({ timeoutMs: 8000 }); await settle(C, A, B) // N=3 |
| 64 | t.is(indexers(A), 3, 'grew N=1→N=3 (three admin indexers)') |
| 65 | |
| 66 | // Creator goes OFFLINE. A + B (quorum 2 of 3) must still finalize. |
| 67 | pCA(); pCB() |
| 68 | await A.base.putRecord('app.bsky.feed.post', '3survive', post('written with creator offline')) |
| 69 | await settle(A, B); await A.base.settled() |
| 70 | 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') |
| 71 | }) |
| 72 | |
| 73 | test('start-indexing: a NON-admin is denied (stays a plain writer)', async (t) => { |
| 74 | const cId = crypto.keyPair(); const nId = crypto.keyPair() |
| 75 | const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(nId.publicKey), role: 3 }] // n = non-admin |
| 76 | |
| 77 | const C = mk({ identity: cId, members: roster }); await C.base.ready() |
| 78 | const N = mk({ bootstrap: C.base.base.key, identity: nId }); await N.base.ready() |
| 79 | const unpipe = pipe(C, N) |
| 80 | t.teardown(async () => { unpipe(); await C.close(); await N.close() }) |
| 81 | |
| 82 | await C.base.projectMembers(roster); await settle(C, N) |
| 83 | const ok = await N.base.requestIndexer({ timeoutMs: 3000 }) |
| 84 | await settle(C, N) |
| 85 | t.absent(ok, 'requestIndexer did not succeed for a non-admin') |
| 86 | t.absent(N.base._isLocalIndexer(), 'non-admin never became an indexer') |
| 87 | t.is(indexers(C), 1, 'indexer set stayed at N=1') |
| 88 | }) |