// CONCEPT VALIDATION v2: promote a DATA-BEARING peer to indexer via WRITER ROTATION,
// with the data-visibility question ISOLATED. (v1 conflated a flaky in-place control that
// polluted the indexer set + left the c:score visibility ambiguous.)
//
// Changes vs v1:
// - NO in-place control step (it was a race that promoted C-old and muddied everything).
// - PROVE c:score is COMMITTED before rotating — assert it's readable on A (a DIFFERENT
// indexer), i.e. finalized in the shared view, not merely a local optimistic block.
// - QUIESCE C before rotating (flush in-flight writes) — the "drain before rotate" design point.
// - Checkpoint c:score at every stage to pinpoint exactly IF/WHERE data becomes invisible:
// P1 committed (on A) pre-rotation → P2 on C right after setLocal → P3 on C+A after C indexes.
// - Expect a CLEAN 2→3 indexer set (only C-new, no C-old pollution).
//
// PASS =
// 1 base indexer set {A,B} = 2; C is a data-bearing plain writer (not an indexer)
// 2 c:score is COMMITTED pre-rotation (readable on indexer A) — the durability baseline
// 3 C rotated to a FRESH length-0 writer; c:score STILL readable on C right after setLocal
// 4 after promoting the fresh writer, C is an indexer and the set is a clean 3
// 5 c:score STILL readable on C and A after rotation+promotion (committed data survives)
// 6 GENESIS OFFLINE: rotated C's NEW write finalizes+replicates AND c:score still reads on B
import { StateBaseV3 } from '../lib/state-base-v3.js'
import createTestnet from 'hyperdht/testnet'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
const ROOT = path.join(os.tmpdir(), 'sbv3-rotate2-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')
const kps = [crypto.keyPair(), crypto.keyPair(), crypto.keyPair()].sort((x, y) => hex(x.publicKey) < hex(y.publicKey) ? -1 : 1)
const [kpA, kpB, kpC] = kps
const members = [
{ key: hex(kpA.publicKey), role: 1 },
{ key: hex(kpB.publicKey), role: 1 },
{ key: hex(kpC.publicKey), role: 3 } // member: plays, then granted admin, then rotated
]
let pass = 0; let fail = 0
const ok = (c, m) => { if (c) { pass++; console.log(' ok ' + m) } else { fail++; console.log(' FAIL ' + m) } }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const idxCount = (sb) => { try { return sb.base.linearizer.indexers.length } catch { return '?' } }
const score = async (sb) => { try { const v = await sb.get('c:score'); return v && v.pts } catch { return 'ERR' } }
let A, B, C
const live = () => [A, B, C].filter(x => x && x.base && !x._closing)
let hb = 0
// churnC=false → C does NOT append (used to quiesce C before/through rotation).
const churnSettle = async (predicate, maxMs, churnC = true) => {
const deadline = Date.now() + maxMs
while (Date.now() < deadline) {
for (const x of live()) { if (x.base.writable && (x !== C || churnC)) { try { await x.set('__hb__:' + hex(x._selfKey).slice(0, 6), hb++) } catch {} } }
for (const x of live()) await x.base.update().catch(() => {})
await sleep(150)
if (await predicate()) return true
}
return await predicate()
}
let stage = 'init'
const watchdog = setTimeout(() => { console.log(' [WATCHDOG] stuck at stage=' + stage + ' — exiting to flush'); Bare.exit(3) }, 260000)
async function main () {
stage = 'testnet'
const testnet = await createTestnet(3)
const bootstrap = testnet.bootstrap
const roomSeed = crypto.randomBytes(32)
const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 9999999 })
stage = 'A'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA))
await A.ready(); await A.projectMembers(members)
stage = 'B'; B = new StateBaseV3(path.join(ROOT, 'B'), A._roomKey, opt(kpB)); await B.ready()
stage = 'C'; C = new StateBaseV3(path.join(ROOT, 'C'), A._roomKey, opt(kpC)); await C.ready()
await churnSettle(async () => (B.base.length || 0) > 0 && (C.base.length || 0) > 0, 45000)
stage = 'growB'; await B.requestWriter({ timeoutMs: 40000, startIndexing: true }); await churnSettle(async () => B._isLocalIndexer() === true, 40000)
const base2 = await churnSettle(async () => idxCount(A) === 2 && idxCount(B) === 2, 40000)
ok(base2 && C._isLocalIndexer() !== true, '1. base indexer set {A,B}=2; C not an indexer')
// C plays.
stage = 'C.play'; await C.set('c:score', { pts: 100 })
await churnSettle(async () => (await C.effectiveWritable()) === true, 40000)
// ── ISOLATION: prove c:score is COMMITTED (readable on indexer A), not just local-optimistic.
stage = 'commit'; const committedOnA = await churnSettle(async () => (await score(A)) === 100, 45000)
console.log(' [diag] pre-rotation c:score C=%s A=%s B=%s', await score(C), await score(A), await score(B))
ok(committedOnA, '2. c:score COMMITTED pre-rotation (readable on indexer A) — durability baseline')
// Grant C admin.
stage = 'grantC'; members[2].role = 1; await A.projectMembers(members)
await churnSettle(async () => (await C.getProjectedRole(hex(kpC.publicKey))) === 1, 30000)
// QUIESCE C (drain in-flight) before rotating — no C appends from here through the rotation.
stage = 'quiesce'; await churnSettle(async () => false, 5000, false)
// ── ROTATE C to a FRESH length-0 writer (keet pairing/keys.js:69,88) ──
stage = 'rotate'
const freshKp = crypto.keyPair()
const freshCore = C.store.get({ keyPair: freshKp }); await freshCore.ready()
const oldLocalKey = hex(C.base.local.key)
await C.base.setLocal(freshCore.key)
await C.base.update()
const afterSetLocalScore = await score(C)
console.log(' [diag] rotated C %s → %s (len=%d); c:score on C right after setLocal=%s', oldLocalKey.slice(0, 12), hex(C.base.local.key).slice(0, 12), C.base.local.length, afterSetLocalScore)
ok(hex(C.base.local.key) !== oldLocalKey && (C.base.local.length || 0) === 0, '3a. C rotated to FRESH length-0 writer')
ok(afterSetLocalScore === 100, '3b. c:score STILL readable on C immediately after setLocal (before promotion)')
// Promote the fresh writer.
stage = 'promote'; await C.requestWriter({ timeoutMs: 60000, startIndexing: true })
const cIndexed = await churnSettle(async () => C._isLocalIndexer() === true, 60000)
const clean3 = await churnSettle(async () => [A, B, C].every(x => idxCount(x) === 3), 40000)
console.log(' [diag] indexer counts A=%s B=%s C=%s', idxCount(A), idxCount(B), idxCount(C))
ok(cIndexed, '4a. after ROTATION, C is a real indexer')
ok(clean3, '4b. clean 3-indexer set {A,B,C-new} (no C-old pollution)')
stage = 'postdata'; const sC = await score(C); const sA = await score(A)
console.log(' [diag] post-rotation c:score C=%s A=%s B=%s', sC, sA, await score(B))
ok(sC === 100 && sA === 100, '5. c:score STILL readable on C and A after rotation+promotion (committed data survives)')
// Durability with the GENESIS offline.
stage = 'closeA'; try { A._closing = true; await A.close() } catch {}
const key = 'rotated:durable:' + Date.now()
const signedBefore = C.base.signedLength
stage = 'writeC'; await C.set(key, { by: 'rotated-C' })
const finalized = await churnSettle(async () => C.base.signedLength > signedBefore, 90000)
const gotOnB = await churnSettle(async () => { try { const v = await B.get(key); return v && v.by === 'rotated-C' } catch { return false } }, 30000)
const scoreOnB = await score(B)
console.log(' [diag] genesis offline: signedLength C %d→%d; new write on B=%s; c:score on B=%s', signedBefore, C.base.signedLength, gotOnB, scoreOnB)
ok(finalized && gotOnB && scoreOnB === 100, '6. GENESIS OFFLINE: rotated C finalizes new write + c:score survives on B (durable quorum)')
clearTimeout(watchdog)
console.log('\n RESULT: ' + pass + ' passed, ' + fail + ' failed')
Bare.exit(fail === 0 ? 0 : 1)
}
main().catch((e) => { console.log(' FATAL ' + (e && e.stack || e)); Bare.exit(2) })