OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge / lib / plan-edge.js

113 lines · javascript ref:
// 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 }