OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / repair-pointers-core.js

119 lines · javascript ref:
// repair-pointers (core) — OPERATOR REPAIR for a converged pointer whose BYTES exist nowhere.
//
// THE FAILURE THIS HEALS. The autobase converges pointers ({$oid,size} per record); the signed repo is
// built from the bytes those pointers resolve to. If the only node holding a record's bytes overwrites
// them, the pointer still converges everywhere and the bytes match nothing: every co-owner reports the
// record, none can resolve it, and `reconcileBytes` retries forever because there is no peer to fetch
// from. The record is neither present nor deleted — a state nothing else in the system can express.
//
// Observed 2026-07-31 on wj62: favela-web's inbound lens re-encoded two of hpp's own records without
// `langs`, so the files no longer hashed to their pointers and tabertron was the only holder. Diagnosing
// it took an hour of hand-hashing files with a throwaway script; repairing it took another, because the
// only way to tombstone a pointer was a hand-written .mjs importing hyper-svc by absolute path out of the
// install tree. `diag` exists because that same shape cost a working day on the READ side (see diag.js);
// this is the same trigger for the WRITE side.
//
// ⛔ THE DANGEROUS PART, AND WHY THIS IS NOT "TOMBSTONE EVERYTHING STALE".
// A stale or missing pointer is NOT automatically unrecoverable. If ANY peer holds the authoritative
// bytes, reconcileBytes fetches them and the record heals with its CONTENT INTACT. Tombstoning that
// record would destroy recoverable data — strictly worse than the bug being repaired. So this never
// deletes on the strength of a diagnostic alone: it demands a REAL fetch attempt first and only
// considers what still fails afterwards. That ordering is the whole safety argument.
//
// DRY RUN BY DEFAULT. Reports what it WOULD tombstone and exits. `--write` is required to mutate, and
// even then it re-verifies afterwards. Deletion story: a tombstone is a soft, converged delete — the
// record leaves the signed repo, nothing is erased from disk, and the pointer set stops lying about a
// record nobody can produce.
// PURE — deliberately NO bare-only imports (bare-fs / hyper-svc / config). The CLI half lives in
// repair-pointers.js; keeping the decision logic here is what makes the SAFETY property testable under
// plain node, and that property (never tombstone a record the fetch recovered) is the entire reason
// this tool may be pointed at a live store.

// Records whose bytes this node cannot resolve. `no-oid` is deliberately EXCLUDED: that is a legacy
// inline record, which `migrate-inline-pointers` converts — a different repair, and tombstoning one
// would delete a record whose content is present and fine.
const CANDIDATE_STATES = new Set(['stale', 'missing'])

const candidatesOf = (diag) => ((diag && diag.entries) || []).filter((e) => CANDIDATE_STATES.has(e.state))

// Drain reconcileBytes until it stops reporting more work (bounded, so a large backlog cannot spin).
async function pullBytes (client, maxPasses = 12) {
  let passes = 0; let fetched = 0; let failed = 0
  for (; passes < maxPasses; passes++) {
    let r
    try { r = await client.call('reconcile-bytes', { limit: 32 }) } catch (e) { return { passes, fetched, failed, error: e.message } }
    if (!r || r.skipped) return { passes, fetched, failed, skipped: r && r.skipped }
    fetched += r.fetched || 0
    failed += r.failed || 0
    if (!r.more) break
  }
  return { passes: passes + 1, fetched, failed }
}

// The repair itself, against an already-connected client. Separated so it is TESTABLE: the safety
// property (a record that heals via the fetch is never tombstoned) is the entire reason this tool is
// safe to point at a live store, and it cannot be proven against production without first causing the
// damage it repairs.
export async function repairWithClient (client, did, { write = false, limit = 0 } = {}) {
  {
    const before = await client.call('pointer-diag', { limit: 0 })
    if (before && before.skipped) return { did, skipped: before.skipped }

    const initial = candidatesOf(before)
    if (!initial.length) {
      console.log(JSON.stringify({ did, clean: true, checked: before.checked, ok: before.ok }))
      return { did, clean: true, tombstoned: 0 }
    }

    // STEP 1 — try to actually GET the bytes. Anything that heals here must never be tombstoned.
    const pull = await pullBytes(client)

    // STEP 2 — re-diagnose. Survivors are the records no peer can produce.
    const after = await client.call('pointer-diag', { limit: 0 })
    const survivors = candidatesOf(after)
    const recovered = initial.length - survivors.length

    console.log(JSON.stringify({
      did,
      candidates: initial.length,
      recoveredByFetch: recovered,
      unrecoverable: survivors.length,
      pull,
      mode: write ? 'write' : 'dry-run'
    }))
    for (const e of survivors.slice(0, limit || survivors.length)) {
      console.log(JSON.stringify({ did, key: e.key, state: e.state, pointerOid: e.pointerOid, localOid: e.localOid, pointerSize: e.pointerSize }))
    }

    if (!survivors.length) return { did, clean: true, tombstoned: 0, recovered }
    if (!write) {
      console.log(JSON.stringify({ did, wouldTombstone: survivors.length, hint: 're-run with --write to apply' }))
      return { did, wouldTombstone: survivors.length, recovered }
    }

    // STEP 3 — tombstone. A soft, converged delete; the signer then materialises it forward.
    let tombstoned = 0; let failed = 0
    for (const e of survivors) {
      const i = e.key.indexOf('/')
      const collection = e.key.slice(0, i)
      const rkey = e.key.slice(i + 1)
      try {
        await client.call('apply-write', { action: 'delete', collection, rkey })
        tombstoned++
      } catch (err) {
        console.error(JSON.stringify({ did, key: e.key, error: err.message }))
        failed++
      }
    }

    // STEP 4 — re-verify rather than assert. The whole point of this tool is that the previous repair
    // was done by hand and unverifiable.
    const final = await client.call('pointer-diag', { limit: 0 })
    console.log(JSON.stringify({
      did, tombstoned, failed, remaining: candidatesOf(final).length, checked: final.checked, ok: final.ok
    }))
    return { did, tombstoned, failed, remaining: candidatesOf(final).length }
  }
}