OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / feed-reactor.js

54 lines · javascript ref:
// The favela-web FEED REACTOR (community-ux.md §120): reconcile the feeds favela-web SERVES to the durable
// P2P FeedRegistry — with no per-community env and no restart. A FeedRegistry watch fires with the registry's
// FULL current entry list on every advance; reconcile(regKey, entries) makes the served set match:
//   - add-feed  → feedgen.addFeed (serve getFeedSkeleton) + wireRoster (index the roster, scope viewers)
//   - remove-feed (an entry that vanished) → feedgen.removeFeed, but ONLY once NO registry still lists it
//     (a feed can be listed in more than one allow-set registry; it stays served while any of them lists it).
// Roster-first: entries with a roomKey (community feeds — curation lives in the room) are served; light
// feeds (no room — curation in the entry) are reported to onLight and skipped for now.
//
// Pure + injected (feedgen, wireRoster, log stubs) so the reconcile is unit-testable without a live server.
// The registry entry shape is feed-schema.js: { did, rkey, name, curator, params, roomKey, handle, ... }.
export function makeFeedReactor ({ feedgen, wireRoster = async () => {}, log = () => {}, onLight = null } = {}) {
  if (!feedgen) throw new Error('makeFeedReactor requires a feedgen')
  const key = (did, rkey) => `${did}\x00${rkey}`
  const servedBy = new Map() // `${did}\x00${rkey}` -> Set<regKey> (served while ANY registry lists it)
  const wiredRooms = new Set() // roomKeys we've already wired a roster watch for (wire once; a live watch is
  // permanent + harmless even after a feed is de-listed, so we never un-wire — re-adding won't double-watch)

  async function reconcile (regKey, entries) {
    const desired = new Set()
    for (const e of entries || []) {
      if (!e || !e.did || !e.rkey) continue
      if (!e.roomKey) { if (onLight) onLight(e); else log(`[favela] light feed (no room) not served yet — roster-first: ${e.did} ${e.rkey}`); continue }
      const k = key(e.did, e.rkey)
      desired.add(k)
      // idempotent (Map.set): a re-fire with an unchanged entry is a no-op; a changed name/room updates it.
      feedgen.addFeed({ communityDid: e.did, rkey: e.rkey, roomKey: e.roomKey, displayName: e.name || e.rkey, description: `favela community: ${e.name || e.rkey}` })
      if (!servedBy.has(k)) servedBy.set(k, new Set())
      const regs = servedBy.get(k)
      if (!regs.has(regKey)) { regs.add(regKey); log(`[favela] registry feed: at://${e.did}/app.bsky.feed.generator/${e.rkey} (${e.name || e.rkey})`) }
      if (!wiredRooms.has(e.roomKey)) { wiredRooms.add(e.roomKey); await wireRoster(e.name || e.rkey, e.roomKey) }
    }
    // drop THIS registry's claim on feeds it no longer lists; stop SERVING only when no registry lists them.
    for (const [k, regs] of servedBy) {
      if (regs.has(regKey) && !desired.has(k)) {
        regs.delete(regKey)
        if (regs.size === 0) {
          const i = k.indexOf('\x00')
          const did = k.slice(0, i); const rkey = k.slice(i + 1)
          feedgen.removeFeed(did, rkey)
          servedBy.delete(k)
          log(`[favela] registry feed removed: at://${did}/app.bsky.feed.generator/${rkey}`)
        }
      }
    }
  }

  // introspection (tests / status): which (did,rkey) are currently served, and by which registries.
  reconcile.served = () => [...servedBy.keys()].map((k) => { const i = k.indexOf('\x00'); return { did: k.slice(0, i), rkey: k.slice(i + 1), registries: [...servedBy.get(k)] } })
  return reconcile
}

export default makeFeedReactor