/**
* Tombstone propagation to a MIRROR (issue #135 follow-up).
*
* retired-repos.js calls the set "DERIVED, NOT STORED ... rebuilt identically
* on every boot". That is true on the ORIGIN, which folds its own writable
* RepoEventLog from seq 0 (pear-git.js: `foldRetired(repoEventLog)`).
*
* It was NOT true on a mirror. A mirror's own log carries no tombstones — the
* origin wrote them to ITS log — so the mirror's only source is the fold
* inside RepoPuller._enqueue, and that rides on a stream opened at
* `cursor + 1`. The cursor exists to avoid ~80 minutes of redundant git
* fetches per restart, so it is not going away; the consequence was that a
* tombstone appended BELOW the cursor was folded once, in memory, and then
* forgotten on the next restart. The mirror resumed listing and announcing a
* repo the origin had retired, for ever.
*
* Observed in production 2026-09-02: tabertron (origin) held 15 tombstones,
* stortron (mirror, and the load-balancer's ACTIVE backend) reported none,
* because it had restarted 2026-08-29 with its cursor already past the
* 2026-07-30 retire events.
*
* These tests use the same real-testnet harness as repo-puller-cursor.test.js
* so the log-streaming path is exercised end-to-end rather than stubbed.
*/
import test from 'brittle'
import { mkdtempSync, readFileSync, existsSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import b4a from 'b4a'
import Corestore from 'corestore'
import createTestnet from '@hyperswarm/testnet'
import Hyperswarm from 'hyperswarm'
import { RepoEventLog } from '../lib/services/repo-event-log.js'
import { RepoPuller } from '../lib/services/repo-mirror.js'
import { RetiredRepos, OP_CREATED, OP_UPDATED, OP_RETIRED } from '../lib/services/retired-repos.js'
let n = 0
function tmpDir () {
return mkdtempSync(join(tmpdir(), 'pgv3-retired-mirror-' + (n++) + '-'))
}
class StubSync {
constructor () {
this.calls = []
this.remotes = new Map()
}
addRemote (name, key) { this.remotes.set(name, key) }
async pull (remote, repoName) { this.calls.push({ remote, repoName }) }
}
async function setupOriginAndMirror (testnet, t) {
const topic = b4a.alloc(32, 'pgv3-retired-' + (n++))
const originStore = new Corestore(join(tmpDir(), 'origin'))
await originStore.ready()
const originLog = new RepoEventLog({ corestore: originStore })
await originLog.ready()
const originSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
originSwarm.on('connection', (conn) => originStore.replicate(conn))
originSwarm.join(topic, { server: true, client: false })
await originSwarm.flush()
const mirrorStore = new Corestore(join(tmpDir(), 'mirror'))
await mirrorStore.ready()
const mirrorLog = new RepoEventLog({ corestore: mirrorStore, key: originLog.keyHex })
await mirrorLog.ready()
const mirrorSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
mirrorSwarm.on('connection', (conn) => mirrorStore.replicate(conn))
mirrorSwarm.join(topic, { server: false, client: true })
await mirrorSwarm.flush()
t.teardown(async () => {
try { await originSwarm.destroy() } catch {}
try { await mirrorSwarm.destroy() } catch {}
try { await originLog.close() } catch {}
try { await mirrorLog.close() } catch {}
try { await originStore.close() } catch {}
try { await mirrorStore.close() } catch {}
})
return { originLog, mirrorLog, mirrorStore }
}
async function waitFor (predicate, timeoutMs = 8000) {
const t0 = Date.now()
while (Date.now() - t0 < timeoutMs) {
if (await predicate()) return true
await new Promise((r) => setTimeout(r, 50))
}
return false
}
function readCursor (cursorPath, serverKey) {
if (!existsSync(cursorPath)) return null
try { return JSON.parse(readFileSync(cursorPath, 'utf8'))[serverKey] } catch { return null }
}
test('mirror: a tombstone below the cursor survives a restart', async (t) => {
t.timeout(60000)
const testnet = await createTestnet(3, t.teardown)
const { originLog, mirrorLog, mirrorStore } = await setupOriginAndMirror(testnet, t)
const serverKey = 'a'.repeat(64)
const cursorPath = join(tmpDir(), 'repo-puller-cursor.json')
// Phase 1 — origin serves two repos, retires one, then keeps working.
// That last entry matters: the cursor advances only on a successful pull
// (a retire returns early), so without it the cursor would stop AT the
// tombstone and the restart would replay it, hiding the bug.
await originLog.append({ op: OP_CREATED, name: 'alpha' }) // seq 0
await originLog.append({ op: OP_CREATED, name: 'beta' }) // seq 1
await originLog.append({ op: OP_RETIRED, name: 'alpha' }) // seq 2 <- tombstone
await originLog.append({ op: OP_CREATED, name: 'gamma' }) // seq 3 <- pushes cursor past it
const retired1 = new RetiredRepos()
const sync1 = new StubSync()
const puller1 = new RepoPuller({
sync: sync1, log: mirrorLog, primaryServerKey: serverKey, cursorPath, retired: retired1
})
await puller1.start()
t.ok(await waitFor(() => retired1.has('alpha')), 'phase 1: the live fold sees the tombstone')
t.ok(await waitFor(() => readCursor(cursorPath, serverKey) === 3),
'phase 1: cursor advanced PAST the tombstone (seq 3)')
await puller1.close()
// Phase 2 — restart. RetiredRepos is derived state, and on a mirror it is
// folded from the mirror's OWN log, which carries no tombstones. So a real
// boot starts with an empty set, exactly as modelled here.
// A real restart re-opens the core; puller1.close() closed the old session.
const mirrorLog2 = new RepoEventLog({ corestore: mirrorStore, key: mirrorLog.keyHex })
await mirrorLog2.ready()
const retired2 = new RetiredRepos()
const sync2 = new StubSync()
const puller2 = new RepoPuller({
sync: sync2, log: mirrorLog2, primaryServerKey: serverKey, cursorPath, retired: retired2
})
t.teardown(async () => { try { await puller2.close() } catch {} })
await puller2.start()
// A live entry for an unrelated repo proves the read loop is running, so a
// failure below is a missing fold and not a stalled stream.
await originLog.append({ op: OP_UPDATED, name: 'beta' }) // seq 4
t.ok(await waitFor(() => sync2.calls.some((c) => c.repoName === 'beta')),
'phase 2: the read loop is live')
t.ok(retired2.has('alpha'),
'the tombstone is still known after a restart with a warm cursor')
t.absent(sync2.calls.some((c) => c.repoName === 'alpha'),
'and the retired repo is not pulled again')
})
test('mirror: re-folding must not clobber an un-retire that came later', async (t) => {
// The fix re-reads the log from seq 0 at boot. That must not resurrect a
// tombstone the origin has since reversed — retired-repos.js promises "an
// op 0 after an op 2 un-retires the repo and a mistake is reversible", and
// a naive re-fold ordered after the live stream would break exactly that.
t.timeout(60000)
const testnet = await createTestnet(3, t.teardown)
const { originLog, mirrorLog, mirrorStore } = await setupOriginAndMirror(testnet, t)
const serverKey = 'c'.repeat(64)
const cursorPath = join(tmpDir(), 'repo-puller-cursor.json')
await originLog.append({ op: OP_CREATED, name: 'alpha' }) // seq 0
await originLog.append({ op: OP_RETIRED, name: 'alpha' }) // seq 1
await originLog.append({ op: OP_CREATED, name: 'beta' }) // seq 2 <- cursor lands here
const puller1 = new RepoPuller({
sync: new StubSync(), log: mirrorLog, primaryServerKey: serverKey, cursorPath, retired: new RetiredRepos()
})
await puller1.start()
t.ok(await waitFor(() => readCursor(cursorPath, serverKey) === 2), 'cursor past the tombstone')
await puller1.close()
// A real restart re-opens the core; puller1.close() closed the old session.
const mirrorLog2 = new RepoEventLog({ corestore: mirrorStore, key: mirrorLog.keyHex })
await mirrorLog2.ready()
const retired2 = new RetiredRepos()
const sync2 = new StubSync()
const puller2 = new RepoPuller({
sync: sync2, log: mirrorLog2, primaryServerKey: serverKey, cursorPath, retired: retired2
})
t.teardown(async () => { try { await puller2.close() } catch {} })
await puller2.start()
// The operator changed their mind on the origin.
await originLog.append({ op: OP_CREATED, name: 'alpha' }) // seq 3 <- un-retire
t.ok(await waitFor(() => sync2.calls.some((c) => c.repoName === 'alpha')),
'the un-retired repo is pulled again')
t.absent(retired2.has('alpha'), 'and the tombstone is gone')
})