OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / constellation.js

173 lines · javascript ref:
// constellation.js — the HISTORICAL half of DESIGN §7.6's inbound arm.
//
// WHY BOTH. Spacedust is a live subscription with NO cursor: `/subscribe` replays nothing, so every
// reconnect leaves a gap of unknown size. Constellation is the backlink INDEX over the same data — it
// answers "what links to this subject?" for any point in the past. §7.6 pairs them deliberately: the
// subscription is the edge, the index is the floor. Neither is sufficient alone, which is why treating
// either as the whole answer is the mistake to avoid.
//
// SAME POINTER SHAPE, DIFFERENT SOURCE. Constellation returns `{did, collection, rkey}` — a locator,
// exactly like spacedust, and for the same reason: it indexes links, not content. This module maps its
// rows into the IDENTICAL pointer shape `link-drain.js` already consumes, so the drain neither knows nor
// cares which source a pointer came from. That is the unification — one pipeline, several pointer
// sources — rather than a second parallel path.
//
// TIER C IS ANSWERED HERE, NOT RETAINED. §7.6 says likes and reposts are countable, not content. That
// is only affordable because `getBacklinksCount` exists: a post with 50k likes costs ONE query rather
// than 50k retained records. `countBacklinks` is that call, and it is why the tiering works at all.
//
// FAILS SOFT. Every path returns null / an empty page rather than throwing. Backfill is best-effort by
// construction: a gap we fail to close is a gap that stays open, not a crashed drain.
//
// PURE + INJECTED. `doFetch` is a parameter, so pagination, the cursor walk and failure handling are
// testable with no network.

import { CONTENT_BEARING } from './spacedust.js'
import { pointerKey } from './link-drain.js'

export const DEFAULT_ENDPOINT = 'https://constellation.microcosm.blue'

// Their docs ask consumers to identify themselves in the user-agent.
export const USER_AGENT = 'favela-web (+https://github.com/favela)'

const MAX_LIMIT = 100 // lexicon-enforced ceiling on getBacklinks

// A Constellation row is `{did, collection, rkey}`. Everything else in a pointer is context we already
// hold from the query itself, so it is filled in rather than parsed.
//
// `rev` is deliberately null: Constellation does not report the revision that produced a link, so the
// drain's skip-test cannot short-circuit on it. shouldSkipRev() treats a null incoming rev as NEW, which
// is the safe direction — we re-fetch and let verification decide, rather than skipping on absent data.
export function rowToPointer (row, { subject, source }) {
  if (!row || typeof row.did !== 'string' || typeof row.collection !== 'string' || typeof row.rkey !== 'string') return null
  if (!row.did || !row.collection || !row.rkey) return null
  return {
    operation: 'create', // the index only holds live links; deletions are absences, not rows
    source,
    subject,
    did: row.did,
    collection: row.collection,
    rkey: row.rkey,
    rev: null,
    contentBearing: CONTENT_BEARING.has(source),
    origin: 'backfill'
  }
}

export function createConstellationClient ({ doFetch, endpoint = DEFAULT_ENDPOINT, timeoutMs = 10_000 } = {}) {
  if (typeof doFetch !== 'function') throw new Error('createConstellationClient requires doFetch')
  const base = String(endpoint).replace(/\/+$/, '')

  async function query (nsid, params) {
    const url = new URL(`${base}/xrpc/${nsid}`)
    for (const [k, v] of Object.entries(params)) {
      if (v === undefined || v === null) continue
      if (Array.isArray(v)) { for (const item of v) url.searchParams.append(k, item) } else url.searchParams.set(k, String(v))
    }
    try {
      const res = await doFetch(url.toString(), {
        method: 'GET',
        headers: { accept: 'application/json', 'User-Agent': USER_AGENT },
        timeout: timeoutMs
      })
      if (!res || res.ok === false) return null
      return await res.json()
    } catch {
      return null // network, timeout, malformed JSON — all "no answer", never a throw
    }
  }

  return {
    // One page of records linking to `subject` through `source` (a `<nsid>:<path>` spec).
    // Returns { total, pointers, cursor } — or null when the index gave no usable answer.
    async getBacklinks (subject, source, { did = null, limit = MAX_LIMIT, cursor = null } = {}) {
      if (!subject || !source) return null
      const body = await query('blue.microcosm.links.getBacklinks', {
        subject,
        source,
        did,
        limit: Math.min(Math.max(1, limit), MAX_LIMIT),
        cursor
      })
      if (!body || !Array.isArray(body.records)) return null
      const pointers = body.records.map((r) => rowToPointer(r, { subject, source })).filter((p) => p !== null)
      return {
        total: typeof body.total === 'number' ? body.total : pointers.length,
        pointers,
        cursor: typeof body.cursor === 'string' && body.cursor.length > 0 ? body.cursor : null
      }
    },

    // TIER C. The reason likes never need retaining — one query instead of N records.
    async countBacklinks (subject, source) {
      if (!subject || !source) return null
      const body = await query('blue.microcosm.links.getBacklinksCount', { subject, source })
      if (typeof body === 'number') return body
      if (body && typeof body.total === 'number') return body.total
      if (body && typeof body.count === 'number') return body.count
      return null
    },

    // Distinct DIDs linking to a subject — "which people you follow liked this", without the records.
    async getDistinctDids (subject, source, { limit = MAX_LIMIT, cursor = null } = {}) {
      if (!subject || !source) return null
      const body = await query('blue.microcosm.links.getDistinct', {
        subject,
        source,
        limit: Math.min(Math.max(1, limit), MAX_LIMIT),
        cursor
      })
      if (!body) return null
      const dids = Array.isArray(body.dids)
        ? body.dids
        : Array.isArray(body.records) ? body.records.map((r) => r && r.did).filter(Boolean) : null
      if (dids === null) return null
      return { dids, cursor: typeof body.cursor === 'string' && body.cursor.length > 0 ? body.cursor : null }
    },

    // Walk every page for one (subject, source), yielding pointers. `maxPages` bounds the walk so a
    // pathological subject — a viral post with a million backlinks — cannot turn a gap-fill into an
    // unbounded crawl. Hitting the bound is reported, never silent: §7.6's whole posture is that a
    // truncation you cannot see reads as completeness you do not have.
    async * walkBacklinks (subject, source, { maxPages = 10, limit = MAX_LIMIT, onTruncate = null } = {}) {
      let cursor = null
      for (let page = 0; page < maxPages; page++) {
        const res = await this.getBacklinks(subject, source, { limit, cursor })
        if (res === null) return
        for (const p of res.pointers) yield p
        if (res.cursor === null) return
        cursor = res.cursor
      }
      if (onTruncate) onTruncate({ subject, source, maxPages })
    },

    // Gap-fill after a reconnect: pull recent backlinks for our subjects across the content-bearing
    // sources and hand them to the same drain the live subscription feeds. Returns { offered, duplicates }
    // so a caller can log what a reconnect actually cost.
    //
    // ⚠ DEDUP IS REQUIRED HERE, not optional. One record legitimately links through SEVERAL sources — a
    // reply carries both reply.parent.uri and reply.root.uri — so a (subjects x sources) sweep offers the
    // same record once per matching source. Live pointers dedup themselves via the rev skip-test, but
    // backfilled ones carry rev:null (Constellation does not report it), so nothing downstream would
    // catch it: measured 3 fetches, 3 signature verifications and 3 writes for ONE record. Dedup by
    // record identity within the sweep.
    async backfillInto (enqueue, { subjects, sources, maxPages = 3, onTruncate = null }) {
      const seen = new Set()
      let offered = 0
      let duplicates = 0
      for (const subject of subjects || []) {
        for (const source of sources || []) {
          for await (const pointer of this.walkBacklinks(subject, source, { maxPages, onTruncate })) {
            const key = pointerKey(pointer)
            if (seen.has(key)) { duplicates++; continue }
            seen.add(key)
            enqueue(pointer)
            offered++
          }
        }
      }
      return { offered, duplicates }
    }
  }
}