OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / release-event-log.test.js

267 lines · javascript ref:
// 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')
})