OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / repair-strongrefs.js

105 lines · javascript ref:
// repair-strongrefs — rewrite records whose strongRef is missing its `cid`.
//
// THE DEFECT. `app.bsky.feed.like.subject` (and repost, and a reply's parent/root) is a
// com.atproto.repo.strongRef: uri AND cid, both REQUIRED. A record written with `subject: { uri }`
// indexes perfectly in favela and FAILS LEXICON VALIDATION AT THE RELAY — it federates as nothing while
// every local surface reports success. favela-web 0.2.41 shipped exactly that; 0cce69a fixed the write
// path. But the fix is forward-only: records already written stay invalid forever, so the account keeps
// showing likes that no one else can see. Measured on wj62 2026-07-31: 4 of 6 likes invalid, all from a
// single 14-second burst on 2026-07-29.
//
// WHY A COMMAND. The same reason `hyper-proto-peer repair-pointers` exists: the fix was known and the
// only way to apply it was a hand-written script. A defect whose repair is un-runnable is not fixed.
//
// SAFETY. This is a CONTENT-PRESERVING rewrite, not a delete:
//   - The subject URI is UNCHANGED. Only the missing `cid` is added, resolved from the bytes we already
//     hold, asserted by hash (strongRef returns null rather than guessing).
//   - A record whose subject cannot be resolved is SKIPPED, never rewritten with a placeholder — an
//     invalid record is better than a confidently wrong one.
//   - Records that already carry a cid are untouched.
//   - DRY RUN by default; --write required.
//
// The rewrite reuses the SAME rkey, so it is an update of the existing record rather than a new like —
// no duplicate appears, and the subject's like count does not double-count.
import { strongRef, parseAtUri } from './strongref.js'

// Collections whose records carry a strongRef, and where that ref lives.
// A reply carries TWO (parent + root) and both must be valid.
export const STRONGREF_FIELDS = {
  'app.bsky.feed.like': [['subject']],
  'app.bsky.feed.repost': [['subject']],
  'app.bsky.feed.post': [['reply', 'parent'], ['reply', 'root']]
}

const at = (obj, path) => path.reduce((o, k) => (o == null ? o : o[k]), obj)

// Which refs in this record are missing a cid? Returns [{ path, uri }].
export function brokenRefs (collection, record) {
  const fields = STRONGREF_FIELDS[collection]
  if (!fields || !record) return []
  const out = []
  for (const path of fields) {
    const ref = at(record, path)
    if (!ref || typeof ref !== 'object') continue
    if (typeof ref.uri !== 'string' || !parseAtUri(ref.uri)) continue
    if (ref.cid) continue // already valid
    out.push({ path, uri: ref.uri })
  }
  return out
}

// Return a REPAIRED copy, or null when any broken ref could not be resolved. Never partially repairs:
// a record with one fixed ref and one still-missing is still invalid, so it is left alone and reported.
export async function repairRecord (collection, record, getRecordStore) {
  const broken = brokenRefs(collection, record)
  if (!broken.length) return null
  const next = JSON.parse(JSON.stringify(record))
  for (const { path, uri } of broken) {
    const ref = await strongRef(uri, getRecordStore)
    if (!ref || !ref.cid) return null // unresolvable — refuse rather than invent a cid
    const parent = path.slice(0, -1).reduce((o, k) => o[k], next)
    parent[path[path.length - 1]] = { uri: ref.uri, cid: ref.cid }
  }
  return next
}

// Scan a repo's records and repair every invalid strongRef.
//   listRecords(collection) -> [{ rkey, record }]
//   applyWrite({ action:'update'|'create', collection, rkey, record })
// Returns { checked, broken, repaired, unresolvable, entries[] }.
export async function repairStrongRefs ({ listRecords, applyWrite, getRecordStore, write = false, collections = Object.keys(STRONGREF_FIELDS), onEvent = null } = {}) {
  if (typeof listRecords !== 'function') throw new Error('repairStrongRefs requires listRecords')
  if (typeof getRecordStore !== 'function') throw new Error('repairStrongRefs requires getRecordStore')
  const emit = (e, d) => { if (onEvent) onEvent(e, d) }
  const stats = { checked: 0, broken: 0, repaired: 0, unresolvable: 0, entries: [] }

  for (const collection of collections) {
    let recs = []
    try { recs = (await listRecords(collection)) || [] } catch (err) { emit('list-failed', { collection, error: err.message }); continue }
    for (const { rkey, record } of recs) {
      stats.checked++
      const broken = brokenRefs(collection, record)
      if (!broken.length) continue
      stats.broken++
      const fixed = await repairRecord(collection, record, getRecordStore)
      if (!fixed) {
        stats.unresolvable++
        stats.entries.push({ collection, rkey, state: 'unresolvable', uris: broken.map((b) => b.uri) })
        emit('unresolvable', { collection, rkey, uris: broken.map((b) => b.uri) })
        continue
      }
      stats.entries.push({ collection, rkey, state: write ? 'repaired' : 'would-repair', uris: broken.map((b) => b.uri) })
      if (!write) continue
      try {
        // SAME rkey => an update, not a second like. A new rkey would double-count the subject.
        await applyWrite({ action: 'update', collection, rkey, record: fixed })
        stats.repaired++
        emit('repaired', { collection, rkey })
      } catch (err) {
        emit('write-failed', { collection, rkey, error: err.message })
      }
    }
  }
  return stats
}