// An edge, projected. Pure — no network, no store, so what gets published is testable on its own.
//
// ⛔ THE SIGNED SET IS FROZEN (interaction.md §5.4): like, repost, follow, block, reply. This handles
// the three whose subject is a RECORD. `follow` and `block` name an ACTOR, and on atproto an actor is a
// DID — a favela feed key is not one, so those need the cross-network identity edge (§3.3) and are out
// of scope until a foreign subject can be named.
//
// ⛔ OUR OWN RECORDS ONLY, for now. A subject we authored resolves to `at://<did>/app.bsky.feed.post/
// <tid>` by pure function — §3.1's "rkey pairing already makes all three names pure functions of each
// other" — and its CID is computable from the record's own bytes. A FOREIGN subject needs the naming
// decision interaction.md §3 calls "the one irreversible decision", and nothing here forces it.
//
// ⚠ EVERY REFUSAL IS NAMED. §5.5: "Every verb states its loss", and `projectLike` returning null is
// called out there as a silence the design forbids.
import { planRecord } from './plan.js'
import { tidFor } from './tid.js'
export const LIKE_COLLECTION = 'app.bsky.feed.like'
export const REPOST_COLLECTION = 'app.bsky.feed.repost'
export const POST_COLLECTION = 'app.bsky.feed.post'
const WITHHELD = (collection, ...losses) => ({ project: false, collection, rkey: null, record: null, losses })
/**
* @param {object} edge from peer `edges-by`: { name, collection, target, value, createdAt, body }
* @param {object} ctx
* @param {string} ctx.ownFeed this identity's feed key — a target on any other feed is foreign
* @param {function} ctx.subject (target) => { uri, cid } | null — resolved by the caller, which is the
* only part that needs the repo
* @param {string} [ctx.roomType]
*/
export function planEdge (edge, ctx = {}) {
const kind = edge && edge.collection
const target = (edge && edge.target) || null
const out = kind === 'repost' ? REPOST_COLLECTION : kind === 'reply' ? POST_COLLECTION : LIKE_COLLECTION
if (!target) return WITHHELD(out, 'the edge names no target')
// ⛔ THE SUBJECT MUST BE OURS. Naming somebody else's record means choosing how a foreign subject is
// stored, permanently — see the header. Said as a loss rather than skipped, so a person can see that
// their like of a stranger's post did not travel.
if (ctx.ownFeed && target.feed !== ctx.ownFeed) {
return WITHHELD(out, 'subject is on another feed — cross-network subjects are not named yet')
}
if (target.collection !== 'post') {
return WITHHELD(out, 'subject is a ' + target.collection + ', and only a post projects to bsky')
}
const subject = ctx.subject ? ctx.subject(target) : null
// ⛔ NOT AN ERROR, AND NOT A SILENCE. A subject that is not in the repo yet is the ordinary case when
// an edge is projected before the post it points at — the next pass finds it.
if (!subject || !subject.uri || !subject.cid) {
return WITHHELD(out, 'the subject is not in the atproto repo yet — it projects once the post does')
}
const createdAt = edge.createdAt || new Date().toISOString()
// ⛔ A REAL TID, like a post's. The AppView is keyed "tid" and silently drops records whose rkey is
// not one — measured on the live network for posts, and there is no reason to believe likes differ.
// Derived from the edge's own name, so a re-run reproduces it and a reversal can find it.
const rkey = tidFor(edge.name)
if (kind === 'reaction' || kind === 'like') {
// ⛔ bsky HAS ONE REACTION. A favela reaction carries a value, and anything but `like` has nowhere
// to go — reported, never silently sent as a like, which would publish a different act.
const value = edge.value || 'like'
if (value !== 'like') {
return WITHHELD(LIKE_COLLECTION, 'bsky has no reaction other than like, and this one is "' + value + '"')
}
return {
project: true,
collection: LIKE_COLLECTION,
rkey,
record: { $type: LIKE_COLLECTION, subject, createdAt },
losses: []
}
}
if (kind === 'repost') {
return {
project: true,
collection: REPOST_COLLECTION,
rkey,
record: { $type: REPOST_COLLECTION, subject, createdAt },
losses: []
}
}
if (kind === 'reply') {
// ⛔ THROUGH THE SAME PLANNER A POST USES, with verb 'reply' so the reach lattice answers for the
// right act. Writing a second text pipeline here is how the 300-grapheme rule, the facets and the
// strip would drift between a post and a reply.
const planned = planRecord({ name: edge.name, body: edge.body, createdAt }, { roomType: ctx.roomType || 'public', verb: 'reply' })
if (!planned.bsky.project) return WITHHELD(POST_COLLECTION, ...planned.bsky.losses)
// ⚠ root AND parent. atproto threads need both; the caller resolves the root by walking up, and
// falls back to the parent when the subject is itself a top-level post — which is the common case
// and not an error.
const root = (ctx.root && ctx.root(target)) || subject
return {
project: true,
collection: POST_COLLECTION,
rkey,
record: { ...planned.bsky.record, reply: { root, parent: subject } },
losses: planned.bsky.losses
}
}
return WITHHELD(out, 'no bsky projection for a ' + kind)
}
export default { planEdge, LIKE_COLLECTION, REPOST_COLLECTION, POST_COLLECTION }