// probe-durability-no-indexer.js
//
// Load-bearing question for the game-state foundation:
// Does an ADMITTED non-indexer writer's UNSIGNED write survive a restart
// when NO indexer is online to sign it?
//
// If yes -> durability != online indexer (oplog is the durable substrate;
// signing is only eventual finality). Confirms keet's "no state
// loss" model and kills the "need a permanent indexer" premise.
// If no -> there is a real durability gap and the design must account for it.
//
// Pure node (autobase 7.28 loads under node). On-disk corestores so we can
// truly close + reopen. Manual replication pipes (no autobase-test-helpers).
import os from 'os'
import path from 'path'
import fs from 'fs'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'dur-probe-'))
const A_DIR = path.join(ROOT, 'A')
const B_DIR = path.join(ROOT, 'B')
let failures = 0
function check (label, cond) {
console.log((cond ? 'PASS ' : 'FAIL ') + label)
if (!cond) failures++
}
function baseOpts () {
return {
open: (store) => new Hyperbee(store.get('view'), { keyEncoding: 'utf-8', valueEncoding: 'json' }),
apply: async (nodes, view, host) => {
for (const node of nodes) {
const op = node.value
if (!op || typeof op !== 'object') continue
if (op.type === 'add-writer') {
await host.addWriter(b4a.from(op.key, 'hex'), { indexer: !!op.indexer })
continue
}
if (op.type === 'set') await view.put(op.k, op.v)
}
},
close: async (view) => { if (view) await view.close() },
valueEncoding: 'json',
optimistic: true,
ackInterval: 1000,
ackThreshold: 4
}
}
function pipe (storeA, storeB) {
const sa = storeA.replicate(true)
const sb = storeB.replicate(false)
sa.on('error', () => {})
sb.on('error', () => {})
sa.pipe(sb).pipe(sa)
return () => { sa.destroy(); sb.destroy() }
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
async function getView (base, key) {
await base.update()
const node = await base.view.get(key)
return node ? node.value : null
}
async function main () {
// ---- A = creator/genesis indexer (pins keyPair) ----
const kpA = crypto.keyPair()
const storeA = new Corestore(A_DIR)
await storeA.ready()
const A = new Autobase(storeA, null, { ...baseOpts(), keyPair: kpA })
await A.ready()
console.log('A.key', A.key.toString('hex').slice(0, 12), 'A.writable', A.writable, 'A.isIndexer', A.isIndexer)
// ---- B = joiner, no keyPair (auto-derives local writer) ----
const storeB = new Corestore(B_DIR)
await storeB.ready()
let B = new Autobase(storeB, A.key, baseOpts())
await B.ready()
console.log('B.local.key', B.local.key.toString('hex').slice(0, 12), 'B.writable(before admit)', B.writable)
// ---- connect, admit B as a NON-INDEXER writer (A is the only indexer, online) ----
let cut = pipe(storeA, storeB)
await sleep(300)
await A.append({ type: 'add-writer', key: B.local.key.toString('hex'), indexer: false })
await A.update()
// let B receive the addWriter + system update
for (let i = 0; i < 40 && !B.writable; i++) { await A.update(); await B.update(); await sleep(100) }
check('1. B becomes writable with ONE online indexer (transient, not permanent)', B.writable === true)
// ---- B writes 'a' while A (the indexer) is online ----
await B.append({ type: 'set', k: 'a', v: 1 })
await B.update()
for (let i = 0; i < 30; i++) { await A.update(); await B.update(); await sleep(80); if ((await getView(A, 'a')) === 1) break }
check('2. A sees B write while indexer online (a=1)', (await getView(A, 'a')) === 1)
const signedWithIndexer = B.signedLength
// ---- A GOES OFFLINE. B (a non-indexer) writes 'b' with NO indexer online ----
cut()
await sleep(200)
await B.append({ type: 'set', k: 'b', v: 2 })
await B.update()
check('3. B applies its own write to tentative view with NO indexer online (b=2)', (await getView(B, 'b')) === 2)
console.log(' B.signedLength before:', signedWithIndexer, 'after offline write:', B.signedLength, '(freeze expected — no quorum)')
// ---- RESTART B: close + reopen from disk (still no indexer online) ----
await B.close()
await storeB.close()
const storeB2 = new Corestore(B_DIR)
await storeB2.ready()
B = new Autobase(storeB2, A.key, baseOpts())
await B.ready()
await B.update()
const aAfter = await getView(B, 'a')
const bAfter = await getView(B, 'b')
console.log(' after restart: B.writable', B.writable, 'a=', aAfter, 'b=', bAfter)
check('4a. ADMITTED writer survives restart (still writable, no indexer online)', B.writable === true)
check('4b. SIGNED write survives restart (a=1)', aAfter === 1)
check('4c. ★ UNSIGNED write survives restart with NO indexer online (b=2)', bAfter === 2)
// ---- A returns: b should propagate + eventually sign (eventual finality) ----
cut = pipe(storeA, storeB2)
for (let i = 0; i < 40; i++) { await A.update(); await B.update(); await sleep(100); if ((await getView(A, 'b')) === 2) break }
check('5. After indexer returns, offline write propagates + finalizes (A sees b=2)', (await getView(A, 'b')) === 2)
console.log(' A.signedLength after rejoin:', A.signedLength)
cut()
await A.close(); await storeA.close()
await B.close(); await storeB2.close()
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
console.log('\n' + (failures === 0 ? 'ALL PASS' : failures + ' FAILURE(S)'))
process.exit(failures === 0 ? 0 : 1)
}
main().catch((e) => { console.error('THREW:', e); process.exit(2) })