OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / retired-repos.js

134 lines · javascript ref:
/**
 * 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
}