// pear-git #3 — forceResync clean re-join + admission = restored participation.
//
// A standalone peer's per-repo tracker can get stuck (poisoned local writer
// apply context after a group manifest migration) WITHOUT emitting a base
// 'error', so the auto self-heal (_resyncRepo, error-triggered) never fires and
// the tracker is left frozen/read-only. `forceResync` is the canonical on-demand
// trigger: bump the resync generation → FRESH corestore namespace → FRESH local
// writer (no poisoned apply context) → reopen as a clean read-only replica of the
// group's healthy state.
//
// Read-only is the autobase golden rule: the fresh writer is not yet in the
// tracker's writer set. This test proves the OTHER half the recovery test stopped
// short of — that a current indexer ADMITTING the fresh key (addWriter, exactly
// `repo-add-writer` / keet's addDevice) flips it WRITABLE and restores full write
// participation (a new issue converges back to the origin).
import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import b4a from 'b4a'
import Hypercore from 'hypercore'
import { HyperIssues } from 'hyper-issues'
import { signWriterVouch } from 'hyper-rooms'
import { IssueRegistry } from '../lib/issue-registry.js'
import { replicateAndSync } from 'autobase-test-helpers'
function tmp () { return mkdtempSync(join(tmpdir(), 'pgv3-forceresync-')) }
async function settle (bases, rounds = 40) {
for (let i = 0; i < rounds; i++) {
for (const b of bases) { try { await b.ack() } catch {} }
await replicateAndSync(bases)
}
}
// Healthy origin: P1 (genesis) + P2 promoted to owner (a manifest migration that
// changes the entropy-derived view key), plus an issue.
async function buildOrigin (dir) {
const s1 = new Corestore(join(dir, 'P1')); await s1.ready()
const P1 = new HyperIssues(s1, { namespace: 'issues:repo:ollama' }); await P1.ready()
const key = P1.keyHex
const s2 = new Corestore(join(dir, 'P2')); await s2.ready()
const P2 = new HyperIssues(s2, { namespace: 'issues:repo:ollama', bootstrapKey: key }); await P2.ready()
await replicateAndSync([P1.base, P2.base])
await P1.addWriter('P2', { writerKey: P2.localKeyHex, role: 'owner' })
await settle([P1.base, P2.base])
await P1.create({ title: 'origin-issue', body: 'seed', author: 'P1' })
await settle([P1.base, P2.base])
return { key, peers: [P1, P2], stores: [s1, s2] }
}
test('forceResync: fresh-corestore re-join is read-only; admission restores write participation', async (t) => {
const dir = tmp()
const origin = await buildOrigin(dir)
// A peer joins the tracker in its OWN corestore, canonical namespace.
const peerStore = new Corestore(join(dir, 'PEER')); await peerStore.ready()
const reg = new IssueRegistry(peerStore, {
requireBootstrap: true,
indexPath: join(dir, 'peer-index.json'),
sideStoreDir: join(dir, 'tracker-resync')
})
reg.bindRepoBootstrapKey('ollama', origin.key)
const before = await reg.getForRepo('ollama')
const beforeKey = before.localKeyHex
// --- forceResync: clean re-join into a FRESH per-tracker corestore ---
const after = await reg.forceResync('ollama')
const afterKey = after.localKeyHex
t.is(reg._index.resyncGen.ollama, 1, 'resyncGen bumped to 1')
t.ok(after._sideStore, 're-joined into a dedicated side corestore')
t.not(afterKey, beforeKey, 'FRESH corestore derives a NEW local writer key (sheds the poisoned apply context — a fresh namespace would NOT)')
// Replicate the fresh replica from the origin; converge read-only.
await settle([origin.peers[0].base, origin.peers[1].base, after.base])
const readIssues = await after.list({ status: 'all' })
t.ok(readIssues.length >= 1, `fresh re-join replicated the origin issues, read access (${readIssues.length})`)
t.absent(after.base.writable, 'fresh re-join is READ-ONLY until admitted (autobase golden rule)')
// --- Admission: an origin indexer addWriters the fresh local key ---
await origin.peers[0].addWriter('rejoined-peer', { writerKey: afterKey, role: 'writer' })
await settle([origin.peers[0].base, origin.peers[1].base, after.base])
t.ok(after.base.writable, 'after admission the re-joined tracker is WRITABLE (participation restored)')
// --- Participate: create an issue, origin sees it ---
await after.create({ title: 'from-rejoined-peer', body: 'participating', author: 'rejoined-peer' })
await settle([origin.peers[0].base, origin.peers[1].base, after.base])
const originList = await origin.peers[0].list({ status: 'all' })
t.ok(
originList.some((i) => i.title === 'from-rejoined-peer'),
'origin sees the re-joined peer\'s new issue — full write participation'
)
t.teardown(async () => {
try { await reg.close(); await peerStore.close() } catch {}
for (let i = 0; i < origin.peers.length; i++) {
try { await origin.peers[i].close() } catch {}
try { await origin.stores[i].close() } catch {}
}
})
})
test('doorbell: forceResync captures the MAIN-corestore voucher; ringing it auto-admits (no manual addWriter)', async (t) => {
const dir = tmp()
const origin = await buildOrigin(dir)
const peerStore = new Corestore(join(dir, 'PEER')); await peerStore.ready()
const reg = new IssueRegistry(peerStore, {
requireBootstrap: true,
indexPath: join(dir, 'peer-index.json'),
sideStoreDir: join(dir, 'tracker-resync')
})
reg.bindRepoBootstrapKey('ollama', origin.key)
const before = await reg.getForRepo('ollama')
const beforeKey = before.localKeyHex
// The peer's ORIGINAL (main-corestore) key is an admitted writer — like
// ashatron's demoted-genesis af062d8b.
await origin.peers[0].addWriter('peer-orig', { writerKey: beforeKey, role: 'writer' })
await settle([origin.peers[0].base, origin.peers[1].base, before.base])
// Re-join into a fresh corestore.
const after = await reg.forceResync('ollama')
const afterKey = after.localKeyHex
t.ok(after._voucherKeyPair, 'forceResync captured a voucher keyPair')
// The voucher must be the ORIGINAL main-corestore writer, NOT the side-store
// key — recompute its manifest key and check it equals beforeKey.
const voucherManifest = b4a.toString(Hypercore.key({ signers: [{ publicKey: after._voucherKeyPair.publicKey }] }), 'hex')
t.is(voucherManifest, beforeKey, 'voucher = the original (admitted) main-corestore writer, not the side-store key')
// Replicate the re-join, then RING the doorbell (what repo-resync does live).
await settle([origin.peers[0].base, origin.peers[1].base, after.base])
const voucherSig = signWriterVouch(afterKey, after._voucherKeyPair.secretKey)
const voucherSignerKey = b4a.toString(after._voucherKeyPair.publicKey, 'hex')
await after.room.requestWriter({ newWriterKey: afterKey, voucherSignerKey, voucherSig, userId: 'peer-rejoin' })
await settle([origin.peers[0].base, origin.peers[1].base, after.base])
t.ok(
(await origin.peers[0].listWriters()).some((w) => w.writerKey === afterKey),
'the doorbell auto-admitted the re-joined key (NO manual addWriter)'
)
t.ok(after.base.writable, 'peer is writable via the doorbell — auto-admission over the shared autobase')
t.teardown(async () => {
try { await reg.close(); await peerStore.close() } catch {}
for (let i = 0; i < origin.peers.length; i++) {
try { await origin.peers[i].close() } catch {}
try { await origin.stores[i].close() } catch {}
}
})
})
test('forceResync heal SURVIVES a restart: a fresh registry reopens from the side store', async (t) => {
const dir = tmp()
const origin = await buildOrigin(dir)
const indexPath = join(dir, 'peer-index.json')
const sideStoreDir = join(dir, 'tracker-resync')
// First registry: open canonical, then forceResync into a side store.
const s1 = new Corestore(join(dir, 'PEER')); await s1.ready()
const reg1 = new IssueRegistry(s1, { requireBootstrap: true, indexPath, sideStoreDir })
reg1.bindRepoBootstrapKey('ollama', origin.key)
await reg1.getForRepo('ollama')
const healed = await reg1.forceResync('ollama')
const healedKey = healed.localKeyHex
await reg1.close()
await s1.close()
// "Restart": a brand-new registry reading the SAME persisted index + corestore.
const s2 = new Corestore(join(dir, 'PEER')); await s2.ready()
const reg2 = new IssueRegistry(s2, { requireBootstrap: true, indexPath, sideStoreDir })
reg2.bindRepoBootstrapKey('ollama', origin.key)
const reopened = await reg2.getForRepo('ollama')
t.ok(reopened._sideStore, 'after restart, getForRepo reopened from the side store (not the poisoned main corestore)')
t.is(reopened.localKeyHex, healedKey, 'same clean local writer key as before the restart — heal persisted')
t.teardown(async () => {
try { await reg2.close(); await s2.close() } catch {}
for (let i = 0; i < origin.peers.length; i++) {
try { await origin.peers[i].close() } catch {}
try { await origin.stores[i].close() } catch {}
}
})
})