/**
* DeletedReleases — the replicated "stop pulling this version" set.
*
* ─────────────────────────────────────────────────────────────────────────
* THIS MODULE NEVER DELETES ANYTHING, AND MUST NEVER LEARN HOW.
*
* A tombstone is metadata: it changes what a peer PULLS, never what is on
* disk. Removing release bytes is a manual operator action on each peer.
* Same rule, same reasoning as services/retired-repos.js — read that first.
* ─────────────────────────────────────────────────────────────────────────
*
* WHY THIS EXISTS (prod incident, 2026-08-29): `pgh deploy gc` removed 55
* favela-android versions on tabertron. The bytes were the only copy, so
* stortron's ReleasePuller could never satisfy its fetch and logged, forever:
*
* [release-pull] error: no holder for
* favela-android/versions/master-8d988ca/favela-android.apk
* → fetched 0, replaced 0, skipped 213, errors 1 — PARTIAL, will retry
*
* The active public backend crashed (exit 1) and both daemons flapped.
*
* The event already existed — ReleaseManager.deleteVersion appends
* `op: OP_DELETED` to the ReleaseEventLog — but NOTHING consumed it.
* repo-mirror.js honours the equivalent repo tombstone (OP_RETIRED) via
* RetiredRepos; the release path had the emitter and no reader. This is the
* missing reader.
*
* DERIVED, NOT STORED. The ReleaseEventLog is append-only and replayed from
* seq 0 with no persisted cursor, so this set is a pure fold over it —
* nothing to migrate, rebuilt identically on every boot. Later ops win by
* sequence number, so a republish (OP_PUBLISHED) after a delete revives the
* version and a mistake is reversible.
*
* ⚠ ROLLOUT GATE: a peer running older code IGNORES the tombstone and keeps
* requesting the version. Every peer must run this code BEFORE any operator
* removes release bytes — exactly the ordering that was violated to cause the
* incident above.
*/
/** ReleaseEventLog op codes — mirrors services/release-event-log.js. */
export const OP_PUBLISHED = 0
export const OP_PROMOTED = 1
export const OP_UNPINNED = 2
export const OP_DELETED = 3
/** Composite key. A version is only meaningful within its repo. */
function key (repo, version) {
return repo + '/' + version
}
export class DeletedReleases {
constructor () {
// "repo\0version" -> { deleted: boolean, seq: number }
this._state = new Map()
}
/**
* Fold one ReleaseEventLog entry. Later sequence numbers win, so replaying
* the log from 0 converges regardless of arrival order.
*
* Channel-only ops (OP_PROMOTED / OP_UNPINNED) carry an empty version and
* are ignored — they say nothing about whether bytes exist.
*
* @param {object} entry - decoded ReleaseEventLog entry
* @param {number} [seq] - the entry's log sequence number
* @returns {boolean} whether the version is deleted AFTER applying this entry
*/
applyEntry (entry, seq) {
if (!entry || !entry.repo || !entry.version) return false
if (entry.op !== OP_DELETED && entry.op !== OP_PUBLISHED) {
return this.has(entry.repo, entry.version)
}
const k = key(entry.repo, entry.version)
const prev = this._state.get(k)
const at = typeof seq === 'number' ? seq : (prev ? prev.seq : 0)
// An out-of-order replay of an OLDER entry must not clobber newer state.
if (prev && typeof seq === 'number' && seq < prev.seq) return prev.deleted
const deleted = entry.op === OP_DELETED
this._state.set(k, { deleted, seq: at })
return deleted
}
/** @returns {boolean} true if this version is currently tombstoned */
has (repo, version) {
const e = this._state.get(key(repo, version))
return !!(e && e.deleted)
}
/** @returns {Array<{repo,version}>} currently-tombstoned versions, sorted */
list () {
const out = []
for (const [k, e] of this._state) {
if (!e.deleted) continue
const i = k.indexOf('/')
out.push({ repo: k.slice(0, i), version: k.slice(i + 1) })
}
return out.sort((a, b) => (a.repo + a.version).localeCompare(b.repo + b.version))
}
get size () {
return this.list().length
}
/**
* A predicate suitable for handing to planPull(). Bound so it can be passed
* as a bare function.
*/
get isDeleted () {
return (repo, version) => this.has(repo, version)
}
/** Drop all state. Used by tests and by a full re-fold. */
clear () {
this._state.clear()
}
}
/**
* Fold an entire ReleaseEventLog into a DeletedReleases set.
*
* Reads to the current end of the log and returns.
*
* @param {object} log - a ReleaseEventLog (needs .entries())
* @param {DeletedReleases} [set]
* @returns {Promise<DeletedReleases>}
*/
export async function foldDeleted (log, set = new DeletedReleases()) {
if (!log || typeof log.entries !== 'function') return set
let seq = 0
for await (const entry of log.entries({ live: false, start: 0 })) {
set.applyEntry(entry, seq)
seq++
}
return set
}