OverviewFilesCommitsIssuesDocsReleasesPeers

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

85 lines · javascript ref:
// repair-pointers — 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.
import fs from 'bare-fs'
import { Client } from 'hyper-svc'
import { loadConfig } from './config.js'
import { sockFor } from './control-socket.js' // shared with diag/reindex-overlay — one definition, no drift
import { repairWithClient } from './repair-pointers-core.js'

export { repairWithClient }

async function repairOne (sock, did, opts) {
  const client = await Client.connectUds(sock)
  try {
    return await repairWithClient(client, did, opts)
  } finally { try { await client.close() } catch {} }
}

// args = CLI args AFTER the subcommand: ['did:plc:...'|'--all', '--write', '--limit', 'N', '--storage', '<p>']
export async function repairPointersCommand (args = []) {
  const cfg = loadConfig()
  const sIdx = args.indexOf('--storage')
  const storage = sIdx >= 0 ? args[sIdx + 1] : cfg.storage
  const lIdx = args.indexOf('--limit')
  const limit = lIdx >= 0 ? Number(args[lIdx + 1]) || 0 : 0
  const write = args.includes('--write')
  const all = args.includes('--all')
  const one = args.find((a) => a.startsWith('did:'))
  const hosted = (cfg.dids || []).map((d) => d.did)

  if (!one && !all) {
    console.error('usage: hyper-proto-peer repair-pointers <did>|--all [--write] [--limit N] [--storage <path>]')
    console.error('  Finds converged pointers whose BYTES cannot be resolved, ATTEMPTS a real fetch first,')
    console.error('  and tombstones only what still cannot be produced by any peer.')
    console.error('  DRY RUN unless --write is passed. A record that heals via the fetch is never touched.')
    console.error('  hosted DIDs:', JSON.stringify(hosted))
    return 1
  }

  const targets = all ? hosted : [one]
  if (!targets.length) { console.error('repair-pointers: no hosted DIDs in config'); return 1 }

  let failed = 0
  for (const did of targets) {
    const sock = sockFor(cfg, storage, did)
    if (!fs.existsSync(sock)) {
      console.error('repair-pointers: control socket not found at ' + sock)
      console.error('  Is the daemon running and hosting ' + did + '? (this repairs the LIVE store.)')
      failed++
      continue
    }
    try {
      const r = await repairOne(sock, did, { write, limit })
      if (r && r.failed) failed++
    } catch (e) {
      console.error('repair-pointers: FAILED for ' + did + ': ' + e.message)
      failed++
    }
  }
  return failed ? 1 : 0
}