/**
* RetiredRepos — the replicated "stop touching this repo" set.
*
* ─────────────────────────────────────────────────────────────────────────
* THIS MODULE NEVER DELETES ANYTHING, AND MUST NEVER LEARN HOW.
*
* Removing a bare repo's bytes is a MANUAL operator action, on each peer, by
* hand. pear-git-v3 has no capability to delete a repo and must never gain
* one — a code bug that could mass-delete repos is precisely the outcome the
* never-delete rule exists to prevent. A tombstone is metadata: it changes
* what the daemon LISTS, SERVES, PULLS and RE-CREATES. It touches no bytes.
* ─────────────────────────────────────────────────────────────────────────
*
* WHY THIS EXISTS: deleting a bare repo by hand did not stick. Four paths
* resurrect or retain it (all verified in source, 2026-07-28):
*
* 1. cross-peer re-pull repo-mirror.js -> hyper-sync `git init --bare`
* 2. boot rescan ref-publish-reconciler.js readdirSync(reposDir)
* 3. directory watcher services/sync.js watch() -> scan() + serve()
* 4. residual references issue-registry-index.json, tracker autobases
*
* Peer A deletes; peer B still announces; A re-clones it. The bare dir is the
* serve source-of-truth, so a local delete survives only until the next boot.
* The fix is a tombstone that propagates BEFORE anyone removes bytes.
*
* DERIVED, NOT STORED. The RepoEventLog is append-only, idempotent, and
* replayed from seq 0 with no persisted cursor, so the retired-set is a pure
* fold over it — nothing to migrate, nothing to keep in sync, and it is
* rebuilt identically on every boot. Later ops win by sequence number, so an
* `op: 0` after an `op: 2` un-retires the repo and a mistake is reversible.
*
* WIRE COMPATIBILITY: `op: 2` was already reserved by the codec
* (repo-event-log-messages.js) and already reached a branch in repo-mirror —
* as a deliberate no-op. No wire change, no codec version bump.
*
* ⚠ ROLLOUT GATE: a peer running older code does not crash on `op: 2`, it
* IGNORES it and keeps announcing the repo — which resurrects it via path 1.
* Every peer must run this code BEFORE any operator removes bytes.
*/
/** RepoEventLog op codes. Use these instead of bare 0/1/2. */
export const OP_CREATED = 0
export const OP_UPDATED = 1
export const OP_RETIRED = 2
export class RetiredRepos {
constructor () {
// name -> { retired: boolean, seq: number }
this._state = new Map()
}
/**
* Fold one RepoEventLog entry. Later sequence numbers win, so replaying the
* log from 0 converges regardless of arrival order.
*
* @param {object} entry - decoded RepoEventLog entry
* @param {number} [seq] - the entry's log sequence number
* @returns {boolean} whether the repo is retired AFTER applying this entry
*/
applyEntry (entry, seq) {
if (!entry || !entry.name) return false
const prev = this._state.get(entry.name)
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.retired
const retired = entry.op === OP_RETIRED
this._state.set(entry.name, { retired, seq: at })
return retired
}
/** @returns {boolean} true if this repo is currently tombstoned */
has (name) {
const e = this._state.get(name)
return !!(e && e.retired)
}
/** @returns {string[]} currently-retired names, sorted */
names () {
const out = []
for (const [name, e] of this._state) if (e.retired) out.push(name)
return out.sort()
}
get size () {
return this.names().length
}
/**
* A predicate suitable for passing to the gate sites. Bound so it can be
* handed off as a bare function.
*/
get isRetired () {
return (name) => this.has(name)
}
/** Drop all state. Used by tests and by a full re-fold. */
clear () {
this._state.clear()
}
}
/**
* Fold an entire RepoEventLog into a RetiredRepos set.
*
* Reads to the current end of the log and returns; pass `{ live: true }` in
* opts to keep folding (the caller owns the loop lifetime in that case).
*
* `opts` is forwarded to the read stream, minus `start` — seq numbering here
* counts from 0 and a non-zero start would silently misnumber every entry,
* breaking applyEntry's newest-wins ordering. Callers reading a REPLICATED
* log should pass a `timeout`: on a sparse core an undownloaded block waits
* on the network forever, and a caller that awaits this fold would hang with
* it. With a timeout the stream throws instead, leaving the set folded up to
* the last block that did arrive — partial, but strictly better than none.
*
* @param {object} log - a RepoEventLog (needs .entries())
* @param {RetiredRepos} [set]
* @param {object} [opts] - read-stream options (e.g. { timeout })
* @returns {Promise<RetiredRepos>}
*/
export async function foldRetired (log, set = new RetiredRepos(), opts = {}) {
if (!log || typeof log.entries !== 'function') return set
const { start: _ignored, ...rest } = opts
let seq = 0
for await (const entry of log.entries({ live: false, ...rest, start: 0 })) {
set.applyEntry(entry, seq)
seq++
}
return set
}