// pear-git #3 — recovery for a broken standalone tracker via RE-JOIN.
//
// A per-repo tracker whose manifest migrated out from under a standalone GENESIS
// peer crash-loops on boot (its WRITABLE genesis core advances stale state into a
// null view; see self-heal-separate-corestore.test.js for the crash-gate proof).
// There is no clean IN-PLACE heal: the poisoned writable genesis core lives in
// the peer's main corestore, found by key, and the bootstrap writer cannot be
// removed (removeWriter is a no-op on the genesis). The recovery is therefore to
// RE-JOIN the tracker into a FRESH corestore — exactly what a mirror re-pair does
// (`pgh device join --mirror` → origin-bundle → strict-bootstrap mirror mount).
//
// This test exercises the REAL recovery code path: a fresh IssueRegistry in
// strict-bootstrap (mirror) mode binds the tracker's bootstrapKey and mounts it
// into a clean store, then replicates it to full health from the origin peers.
// The recovered tracker is a READ-ONLY replica — it has no writable genesis core
// to advance, so it never hits the crash (heals like a mirror).
import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { HyperIssues } from 'hyper-issues'
import { IssueRegistry } from '../lib/issue-registry.js'
import { replicate, replicateAndSync } from 'autobase-test-helpers'
function tmp () { return mkdtempSync(join(tmpdir(), 'pgv3-rejoin-')) }
const viewKey = (t) => t.base.system.views.map(v => v.key.toString('hex')).join(',')
async function settle (bases, rounds = 30) {
for (let i = 0; i < rounds; i++) {
for (const b of bases) { try { await b.ack() } catch {} }
await replicateAndSync(bases)
}
}
// Healthy origin: peers P1 (genesis) + P2 with a MIGRATED tracker + content.
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
await P1.create({ title: 'issue-1', body: 'genesis', author: 'P1' })
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' }) // migration: entropy view key changes
await settle([P1.base, P2.base])
for (let i = 0; i < 40; i++) await P2.create({ title: 'issue-' + i, body: 'x'.repeat(40), author: 'P2' })
await settle([P1.base, P2.base])
return { key, migratedView: viewKey(P1), all: await P1.list({ status: 'all' }), peers: [P1, P2], stores: [s1, s2] }
}
test('re-join recovers a repo tracker into a fresh corestore (mirror re-pair path)', async (t) => {
const dir = tmp()
const origin = await buildOrigin(dir)
t.ok(origin.all.length >= 41, `origin tracker healthy + migrated (${origin.all.length} issues)`)
// --- Recovery: a FRESH corestore + strict-bootstrap IssueRegistry (mirror). ---
const recStore = new Corestore(join(dir, 'RECOVERY')); await recStore.ready()
const reg = new IssueRegistry(recStore, {
requireBootstrap: true, // mirror mode: never mint a sibling root, wait for the key
indexPath: join(dir, 'recovery-issue-index.json')
})
// Before the origin bundle delivers the key, a mirror refuses to mount (no
// sibling autobase) — the real strict-bootstrap behavior.
await t.exception(reg.getForRepo('ollama'), /bootstrap key not yet known/, 'strict-bootstrap refuses to mount before the key is bound')
// origin-bundle / RepoEventLog delivers the tracker key -> bind + mount fresh.
reg.bindRepoBootstrapKey('ollama', origin.key)
const recovered = await reg.getForRepo('ollama')
// Replicate from the origin peers and poll until the issue DATA is readable.
const done = replicate([recovered.base, origin.peers[0].base, origin.peers[1].base])
let issues = []
let converged = false
for (let i = 0; i < 100; i++) {
try { await recovered.base.update() } catch {}
await new Promise((r) => setTimeout(r, 100))
if (viewKey(recovered) === origin.migratedView) {
try { issues = await recovered.list({ status: 'all' }) } catch { issues = [] }
if (issues.length === origin.all.length) { converged = true; break }
}
}
const recoveredView = viewKey(recovered)
const writers = await recovered.listWriters()
await done()
t.ok(converged, 're-joined tracker converged on the migrated view + readable issue data')
t.is(recoveredView, origin.migratedView, 're-joined tracker reached the migrated view key')
t.is(issues.length, origin.all.length, 're-joined tracker materialized every issue (full recovery)')
t.ok(writers.length >= 1, 're-joined tracker sees the migrated writer set')
t.absent(recovered.base.writable, 're-joined tracker is a READ-ONLY replica — no writable genesis core to advance (heals like a mirror)')
t.teardown(async () => {
try { await reg.close(); await recStore.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 {}
}
})
})