// Projecting an edge — a like, a repost, a reply.
//
// ⛔ THE SIGNED SET IS FROZEN (interaction.md §5.4). These are the three whose subject is a RECORD;
// follow and block name an ACTOR, and on atproto an actor is a DID, which a favela feed key is not.
//
// ⛔ EVERY REFUSAL IS NAMED. §5.5 says "every verb states its loss" and calls out `projectLike`
// returning null as a silence the design forbids — so a withheld edge carries a reason a person can
// read, not an absence they have to infer.
import test from 'brittle'
import { planEdge, LIKE_COLLECTION, REPOST_COLLECTION, POST_COLLECTION } from '../lib/plan-edge.js'
import { tidFor } from '../lib/tid.js'
const OURS = 'a'.repeat(64)
const THEIRS = 'b'.repeat(64)
const SUBJECT = { uri: 'at://did:plc:me/app.bsky.feed.post/3mv', cid: 'bafy123' }
const ctx = (extra = {}) => ({ ownFeed: OURS, subject: () => SUBJECT, ...extra })
const edge = (collection, extra = {}) => ({
name: '1788822724600',
collection,
target: { feed: OURS, collection: 'post', name: '1788800000000' },
createdAt: '2026-09-07T23:12:04.600Z',
...extra
})
test('★★ a like becomes app.bsky.feed.like, under a TID rkey', (t) => {
const p = planEdge(edge('reaction', { value: 'like' }), ctx())
t.ok(p.project)
t.is(p.collection, LIKE_COLLECTION)
t.alike(p.record.subject, SUBJECT, 'subject carries uri AND cid — the lexicon requires both')
t.is(p.record.createdAt, '2026-09-07T23:12:04.600Z', 'the edge\'s own time, not now')
t.is(p.rkey, tidFor('1788822724600'), 'derived from the edge name, so a re-run reproduces it')
})
test('★★★ a non-like reaction is withheld, never sent as a like', (t) => {
// ⛔ bsky has exactly one reaction. Sending a 🎉 as a like would publish a different act under
// somebody's name.
const p = planEdge(edge('reaction', { value: '🎉' }), ctx())
t.absent(p.project)
t.ok(p.losses.some((l) => /no reaction other than like/.test(l)), p.losses.join('; '))
})
test('★★ a repost becomes app.bsky.feed.repost', (t) => {
const p = planEdge(edge('repost'), ctx())
t.ok(p.project)
t.is(p.collection, REPOST_COLLECTION)
t.alike(p.record.subject, SUBJECT)
})
test('★★★ a reply is a post carrying root and parent', (t) => {
const p = planEdge(edge('reply', { body: 'well said' }), ctx())
t.ok(p.project)
t.is(p.collection, POST_COLLECTION)
t.is(p.record.text, 'well said')
t.alike(p.record.reply.parent, SUBJECT)
// ⚠ With no deeper chain the root IS the parent — a top-level reply, which is the common case and
// not an error.
t.alike(p.record.reply.root, SUBJECT, 'root falls back to the parent')
})
test('★★★ a reply to a reply carries the thread ROOT, not the parent twice', (t) => {
const ROOT = { uri: 'at://did:plc:me/app.bsky.feed.post/3root', cid: 'bafyroot' }
const p = planEdge(edge('reply', { body: 'and another thing' }), ctx({ root: () => ROOT }))
t.alike(p.record.reply.root, ROOT, 'the walked-up root')
t.alike(p.record.reply.parent, SUBJECT, 'and the immediate parent')
})
test('★★★ a subject on someone else\'s feed is withheld, and says why', (t) => {
// ⛔ Naming a foreign subject means choosing how it is stored, permanently (interaction.md §3).
// Nothing here forces that decision, and a person should see that their like did not travel.
const p = planEdge(edge('reaction', { value: 'like', target: { feed: THEIRS, collection: 'post', name: 'x' } }), ctx())
t.absent(p.project)
t.ok(p.losses.some((l) => /another feed/.test(l)), p.losses.join('; '))
})
test('★★★ an unresolvable subject is an ordinary wait, not a failure', (t) => {
// An edge projected before the post it points at — the next pass finds it.
const p = planEdge(edge('reaction', { value: 'like' }), ctx({ subject: () => null }))
t.absent(p.project)
t.ok(p.losses.some((l) => /not in the atproto repo yet/.test(l)), p.losses.join('; '))
})
test('★★★ a reply in a room that reaches nothing is withheld by the lattice', (t) => {
// ⛔ The reach lattice answers for the VERB. A members room reaches nothing but favela, and an
// interaction written there must not escape because the identity has bsky switched on.
const p = planEdge(edge('reply', { body: 'secret' }), ctx({ roomType: 'members' }))
t.absent(p.project)
t.ok(p.losses.length > 0, 'and says so: ' + p.losses.join('; '))
})
test('★★ a follow is not projected here — an actor is not a record', (t) => {
const p = planEdge(edge('follow'), ctx())
t.absent(p.project)
})