// 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 }
}
}
}