// 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
}