// ReleaseEventLog — the trigger that replaced the ReleasePuller's 15-minute
// polling interval, plus the ReleaseManager side that appends to it.
//
// Why this log exists rather than a new op code on repoEventLog: RepoPuller
// ._enqueue treats EVERY entry whose op is not OP_RETIRED as "git-pull this
// repo", so release ops appended there would make every already-deployed mirror
// spuriously git-fetch on each publish. The separation is pinned below.
import test from 'brittle'
import c from 'compact-encoding'
import { mkdtempSync, readFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import Corestore from 'corestore'
import b4a from 'b4a'
import { entry as entrySchema } from '../lib/services/release-event-log-messages.js'
import {
ReleaseEventLog, OP_PUBLISHED, OP_PROMOTED, OP_UNPINNED, OP_DELETED
} from '../lib/services/release-event-log.js'
import { ReleaseManager } from '../lib/release-manager.js'
function tmpStorage (p = 'pgv3-release-event-log-') {
return mkdtempSync(join(tmpdir(), p))
}
// ── codec ──────────────────────────────────────────────────────
test('codec round-trip', (t) => {
const decoded = c.decode(entrySchema, c.encode(entrySchema, {
op: OP_PUBLISHED, repo: 'hardops', version: 'main-27fbc2e', channel: 'production', ts: 1234567890
}))
t.is(decoded.op, OP_PUBLISHED)
t.is(decoded.repo, 'hardops')
t.is(decoded.version, 'main-27fbc2e')
t.is(decoded.channel, 'production')
t.is(decoded.ts, 1234567890)
})
test('codec tolerates absent version/channel', (t) => {
// An unpin names a channel but no version; a delete names no channel.
const decoded = c.decode(entrySchema, c.encode(entrySchema, { op: OP_UNPINNED, repo: 'demo' }))
t.is(decoded.repo, 'demo')
t.is(decoded.version, '', 'absent version decodes as empty, not undefined')
t.is(decoded.channel, '')
})
test('an unknown future op still decodes — forward compatibility', (t) => {
// A newer origin may append ops this reader has no name for. It must decode
// cleanly and be treated as a plain wake-up, never crash the stream.
const decoded = c.decode(entrySchema, c.encode(entrySchema, { op: 99, repo: 'demo', ts: 1 }))
t.is(decoded.op, 99)
t.is(decoded.repo, 'demo', 'the repo — the only field reconciliation needs — survives')
})
// ── log surface ────────────────────────────────────────────────
test('append then read back', async (t) => {
const store = new Corestore(tmpStorage())
const log = new ReleaseEventLog({ corestore: store })
await log.ready()
t.teardown(async () => { await log.close(); await store.close() })
t.ok(log.writable, 'the origin owns its log')
t.ok(log.keyHex, 'and has a key to hand out via dump-keys')
await log.append({ op: OP_PUBLISHED, repo: 'alpha', version: 'v1' })
await log.append({ op: OP_PROMOTED, repo: 'alpha', version: 'v1', channel: 'stable' })
t.is(log.length, 2)
const out = []
for await (const e of log.entries({ start: 0 })) out.push(e)
t.is(out.length, 2)
t.is(out[0].repo, 'alpha')
t.is(out[1].op, OP_PROMOTED)
t.is(out[1].channel, 'stable')
})
test('a replica opened by key is read-only', async (t) => {
const dirA = tmpStorage('pgv3-rel-log-origin-')
const storeA = new Corestore(dirA)
const origin = new ReleaseEventLog({ corestore: storeA })
await origin.ready()
const storeB = new Corestore(tmpStorage('pgv3-rel-log-mirror-'))
const replica = new ReleaseEventLog({ corestore: storeB, key: origin.keyHex })
await replica.ready()
t.teardown(async () => {
await origin.close(); await replica.close()
await storeA.close(); await storeB.close()
})
t.absent(replica.writable, 'a mirror cannot author release events')
await t.exception(() => replica.append({ op: OP_PUBLISHED, repo: 'x' }), /not writable/,
'and says so rather than silently dropping the append')
})
// ── ReleaseManager → log ───────────────────────────────────────
// A tiny stand-in so these assertions are about WHICH events are emitted, not
// about hypercore. Mirrors the real log's `writable` + `append` contract.
function recordingLog (writable = true) {
return { writable, events: [], async append (e) { this.events.push(e) } }
}
test('every index mutation emits exactly one event', async (t) => {
const dir = tmpStorage('pgv3-rel-rm-')
const eventLog = recordingLog()
const rm = new ReleaseManager({ releaseDir: dir, eventLog })
await rm.publish({ repo: 'demo', version: 'v1', artifacts: [{ path: 'a.zip', data: b4a.from('A') }] })
await rm.publish({ repo: 'demo', version: 'v2', artifacts: [{ path: 'a.zip', data: b4a.from('B') }] })
await rm.promote('demo', 'v1', 'stable')
await rm.unpinChannel('demo', 'stable')
await rm.deleteVersion('demo', 'v2')
t.alike(eventLog.events.map(e => e.op),
[OP_PUBLISHED, OP_PUBLISHED, OP_PROMOTED, OP_UNPINNED, OP_DELETED],
'publish/promote/unpin/delete each announce themselves')
t.alike(eventLog.events.map(e => e.repo), ['demo', 'demo', 'demo', 'demo', 'demo'],
'every event names its repo — that is what scopes the mirror reconcile')
t.is(eventLog.events[2].channel, 'stable', 'a promote carries the channel')
t.is(eventLog.events[4].version, 'v2', 'a delete carries the version')
})
test('the event is appended AFTER the index is written', async (t) => {
// A mirror can react within milliseconds, so an event that outran the index
// would send it to read the OLD state and conclude nothing changed.
const dir = tmpStorage('pgv3-rel-order-')
const seen = []
const eventLog = {
writable: true,
async append () {
const idx = JSON.parse(readFileSync(join(dir, '_index.json'), 'utf8'))
seen.push(idx.files.some(f => f.path === 'demo/versions/v1/a.zip'))
}
}
const rm = new ReleaseManager({ releaseDir: dir, eventLog })
await rm.publish({ repo: 'demo', version: 'v1', artifacts: [{ path: 'a.zip', data: b4a.from('A') }] })
t.alike(seen, [true], 'the index already describes the new version when the event fires')
})
test('a failing event log does not fail the publish', async (t) => {
// Notification is best-effort by design: a dropped entry costs latency (the
// next event re-reconciles), but a publish that rolls back because a peer
// could not be told would be a far worse trade.
const dir = tmpStorage('pgv3-rel-fail-')
const eventLog = { writable: true, async append () { throw new Error('core offline') } }
const rm = new ReleaseManager({ releaseDir: dir, eventLog })
const res = await rm.publish({
repo: 'demo', version: 'v1', artifacts: [{ path: 'a.zip', data: b4a.from('A') }]
})
t.is(res.version, 'v1', 'publish still succeeded')
const idx = JSON.parse(readFileSync(join(dir, '_index.json'), 'utf8'))
t.ok(idx.files.some(f => f.path === 'demo/versions/v1/a.zip'), 'and the index is intact')
})
test('a mirror (non-writable log) appends nothing', async (t) => {
// Both backends run the same code. A mirror must not author release events —
// that would make the log multi-writer and its cursor meaningless.
const dir = tmpStorage('pgv3-rel-mirror-')
const eventLog = recordingLog(false)
const rm = new ReleaseManager({ releaseDir: dir, eventLog })
await rm.publish({ repo: 'demo', version: 'v1', artifacts: [{ path: 'a.zip', data: b4a.from('A') }] })
t.is(eventLog.events.length, 0, 'no append attempted when the log is read-only')
})
test('no event log configured is not an error', async (t) => {
// Local/CLI use (`new ReleaseManager({ releaseDir })`) and every existing
// test construct it without one.
const dir = tmpStorage('pgv3-rel-nolog-')
const rm = new ReleaseManager({ releaseDir: dir })
const res = await rm.publish({
repo: 'demo', version: 'v1', artifacts: [{ path: 'a.zip', data: b4a.from('A') }]
})
t.is(res.version, 'v1')
})
// ── overlay serve-map seeding (the "no holder" bug) ────────────
test('seedOverlayServeMap re-points the overlay at release files after a restart', async (t) => {
// hyper-overlay's serve maps are in-memory and populated only by registerFile;
// FileIndex persists the contentHash but NOT a disk path. So after a restart a
// daemon serves nothing it did not publish since boot, and _writeIndex cannot
// repair it — it skips entries whose hash has not changed. Result measured
// 2026-08-08: "no holder" for a 136 MiB APK AND a 436-byte manifest sitting on
// the origin's own disk.
const dir = tmpStorage('pgv3-rel-seed-')
// Stand-in for hyper-overlay: separates what is PERSISTED (the index) from the
// in-memory serve maps, which is exactly the distinction that caused the bug.
const overlay = {
index: [],
servable: new Map(), // contentHash → diskPath (in-memory)
async registerFile (overlayPath, diskPath, meta) {
this.index.push({ path: overlayPath, contentHash: meta.contentHash })
this.servable.set(meta.contentHash, diskPath)
return meta
},
async seedFilePathsFromIndex (resolve) {
let seeded = 0
for (const f of this.index) {
const disk = resolve(f.path)
if (!disk) continue
this.servable.set(f.contentHash, disk)
seeded++
}
return seeded
}
}
const rm = new ReleaseManager({ releaseDir: dir, overlay })
await rm.publish({ repo: 'demo', version: 'v1', artifacts: [{ path: 'app.zip', data: b4a.from('PAYLOAD') }] })
const hashes = [...overlay.servable.keys()]
t.is(hashes.length, 2, 'publish registered the artifact and its manifest')
// Simulate the restart: the index persists, the serve maps do not.
overlay.servable.clear()
t.is(overlay.servable.size, 0, 'after restart nothing is servable — this is the bug')
// A fresh manager over the same dir, as a reboot would build.
const rebooted = new ReleaseManager({ releaseDir: dir, overlay })
const seeded = await rebooted.seedOverlayServeMap()
t.is(seeded, 2, 'seeding restored every artifact from the persisted index')
for (const h of hashes) {
t.ok(overlay.servable.has(h), 'hash ' + h.slice(0, 8) + ' is servable again')
t.ok(overlay.servable.get(h).startsWith(dir), 'and resolves to a real path under the release dir')
}
})
test('seeding ignores overlay paths outside the release namespace', async (t) => {
// The attachment spool shares this overlay. Claiming its oids as release files
// would point hash → a path that does not exist and break attachments.
const dir = tmpStorage('pgv3-rel-ns-')
const overlay = {
index: [{ path: 'attachments/abc123', contentHash: 'h-attach' },
{ path: 'release:demo/versions/v1/app.zip', contentHash: 'h-release' }],
servable: new Map(),
async seedFilePathsFromIndex (resolve) {
let seeded = 0
for (const f of this.index) {
const disk = resolve(f.path)
if (!disk) continue
this.servable.set(f.contentHash, disk)
seeded++
}
return seeded
}
}
const rm = new ReleaseManager({ releaseDir: dir, overlay })
t.is(await rm.seedOverlayServeMap(), 1, 'only the release-namespaced entry is claimed')
t.ok(overlay.servable.has('h-release'))
t.absent(overlay.servable.has('h-attach'), 'attachment oids left alone')
})
test('no overlay, or an overlay too old to seed, is not an error', async (t) => {
const dir = tmpStorage('pgv3-rel-noseed-')
t.is(await new ReleaseManager({ releaseDir: dir }).seedOverlayServeMap(), 0, 'no overlay → 0')
t.is(await new ReleaseManager({ releaseDir: dir, overlay: {} }).seedOverlayServeMap(), 0,
'overlay without seedFilePathsFromIndex → 0, not a crash')
})