// repair-strongrefs — rewriting records whose strongRef lost its cid.
// Run: node test/repair-strongrefs.test.mjs
//
// The defect is silent by construction: a like with `subject:{uri}` and no cid indexes perfectly here
// and fails lexicon validation at the relay, so it federates as NOTHING while the UI shows it working.
// The assertions that matter are the refusals — never invent a cid, never partially repair, never
// mint a new rkey (which would double-count the subject).
import { brokenRefs, repairRecord, repairStrongRefs, STRONGREF_FIELDS } from '../lib/repair-strongrefs.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const URI = 'at://did:plc:someone00000000000000000/app.bsky.feed.post/3kabc'
const URI2 = 'at://did:plc:other0000000000000000000/app.bsky.feed.post/3kdef'
const like = (subject) => ({ $type: 'app.bsky.feed.like', subject, createdAt: '2026-07-29T23:39:11Z' })
const reply = (parent, root) => ({ $type: 'app.bsky.feed.post', text: 'hi', createdAt: 'x', reply: { parent, root } })
// A store shim: resolves any uri whose did is in `known`.
const storeFor = (known) => async (did) => (known.includes(did)
? { getRaw: async () => ({ bytes: new Uint8Array([1, 2, 3]), record: {} }) }
: null)
// ── detection ────────────────────────────────────────────────────────────────────────────────────
ok(brokenRefs('app.bsky.feed.like', like({ uri: URI })).length === 1, 'a subject with no cid is broken')
ok(brokenRefs('app.bsky.feed.like', like({ uri: URI, cid: 'bafy' })).length === 0, 'a subject WITH a cid is fine')
ok(brokenRefs('app.bsky.feed.post', reply({ uri: URI }, { uri: URI2 })).length === 2,
'a reply carries TWO strongRefs (parent AND root) and both count')
ok(brokenRefs('app.bsky.feed.post', { $type: 'app.bsky.feed.post', text: 'top-level' }).length === 0,
'a non-reply post has no strongRef to break')
ok(brokenRefs('app.bsky.actor.profile', { displayName: 'x' }).length === 0, 'collections without strongRefs are ignored')
ok(brokenRefs('app.bsky.feed.like', like({ uri: 'not-an-at-uri' })).length === 0,
'a malformed uri is not treated as a repairable ref — we could never resolve it')
ok(Object.keys(STRONGREF_FIELDS).length === 3, 'like, repost and post are the strongRef-carrying collections')
// ── repair: content-preserving ───────────────────────────────────────────────────────────────────
{
const rec = like({ uri: URI })
const fixed = await repairRecord('app.bsky.feed.like', rec, storeFor(['did:plc:someone00000000000000000']))
ok(fixed.subject.uri === URI, 'the subject URI is UNCHANGED — only the missing cid is added')
ok(!!fixed.subject.cid, 'and the cid is now present')
ok(rec.subject.cid === undefined, 'the original record is not mutated in place')
ok(fixed.createdAt === rec.createdAt, 'every other field is preserved')
}
// ── ⛔ the refusals ──────────────────────────────────────────────────────────────────────────────
{
const fixed = await repairRecord('app.bsky.feed.like', like({ uri: URI }), storeFor([]))
ok(fixed === null, 'an unresolvable subject is REFUSED — never rewrite with a guessed or placeholder cid')
}
{
// parent resolvable, root not. A half-repaired reply is STILL invalid, so it must be left alone.
const fixed = await repairRecord('app.bsky.feed.post', reply({ uri: URI }, { uri: URI2 }), storeFor(['did:plc:someone00000000000000000']))
ok(fixed === null, 'a PARTIAL repair is refused — one fixed ref and one broken is still an invalid record')
}
// ── the sweep ────────────────────────────────────────────────────────────────────────────────────
const sweepFixture = () => ({
'app.bsky.feed.like': [
{ rkey: 'good', record: like({ uri: URI, cid: 'bafyalready' }) },
{ rkey: 'bad1', record: like({ uri: URI }) },
{ rkey: 'bad2', record: like({ uri: URI2 }) } // URI2's did is NOT known -> unresolvable
],
'app.bsky.feed.repost': [],
'app.bsky.feed.post': []
})
{
const fx = sweepFixture()
const writes = []
const s = await repairStrongRefs({
listRecords: async (c) => fx[c],
applyWrite: async (op) => { writes.push(op) },
getRecordStore: storeFor(['did:plc:someone00000000000000000']),
write: true
})
ok(s.checked === 3 && s.broken === 2, 'counts what it checked and what was broken')
ok(s.repaired === 1 && s.unresolvable === 1, 'repairs what it can, reports what it cannot')
ok(writes.length === 1 && writes[0].rkey === 'bad1', 'only the resolvable one is written')
ok(writes[0].action === 'update' && writes[0].rkey === 'bad1',
'written as an UPDATE with the SAME rkey — a new rkey would be a second like and double-count the subject')
ok(!!writes[0].record.subject.cid, 'and the written record carries the cid')
}
{
const fx = sweepFixture()
const writes = []
const s = await repairStrongRefs({
listRecords: async (c) => fx[c],
applyWrite: async (op) => { writes.push(op) },
getRecordStore: storeFor(['did:plc:someone00000000000000000'])
}) // no write
ok(writes.length === 0, 'DRY RUN by default — nothing is written')
ok(s.entries.some((e) => e.state === 'would-repair'), 'but it reports what it would do')
}
{
// A collection that fails to list must not abort the sweep.
const s = await repairStrongRefs({
listRecords: async (c) => { if (c === 'app.bsky.feed.like') throw new Error('boom'); return [] },
applyWrite: async () => {},
getRecordStore: storeFor([])
})
ok(s.checked === 0, 'a failed collection is skipped, not fatal')
}
console.log(`\nREPAIR-STRONGREFS OK: ${pass} assertions`)