// followers.js — who follows a DID, across the whole network. DESIGN §7.6 Tier C.
//
// WHY THIS IS NOT IN THE APPVIEW. `av.followers(did)` counts only follows authored by DIDs this node
// INDEXES — its roster. A bsky account following you is invisible to it, not because anything is broken
// but because we never replicate that account's repo. Asking the local index for a network-wide fact
// gets a window-local answer, which is exactly the mistake §7.6 exists to prevent.
//
// TIER C: REFERENCED, NEVER RETAINED. A follow is countable, not content — there is nothing to render
// from the record itself beyond "this DID follows that DID". So we answer from a backlink index on
// demand and store NOTHING. No fetch, no verify, no write. That matters twice over: it costs no disk,
// and (after 2026-07-31) a read-only path cannot corrupt hpp's canonical store.
//
// ⛔ NEVER BLEND THE COUNTS. bsky's follower number is network-wide; ours is window-local. A sum means
// nothing, and silently substituting one for the other is worse — it makes a partial view look total.
// The caller gets both, separately labelled, and the render layer must keep them apart. lens-import.js
// makes the same refusal for the same reason.
//
// HANDLES ARE VERIFIED, NOT CLAIMED. Each follower DID is resolved through the bidirectional handle
// resolver, so an impostor DID claiming someone's handle shows as handle.invalid rather than as them.
// A follower list is exactly the surface where impersonation would pay.
//
// PURE + INJECTED. constellation, handles and the clock are parameters — no network in tests.
const FOLLOW_SOURCE = 'app.bsky.graph.follow:subject'
const TTL_MS = 5 * 60 * 1000
const DEFAULT_RESOLVE = 24 // how many follower handles to resolve for display
const RESOLVE_CONCURRENCY = 8
const RESOLVE_DEADLINE_MS = 2500 // hard cap on the WHOLE resolution phase
export function createFollowerLens ({
constellation, // createConstellationClient(...)
handles = null, // createHandleResolver(...) — optional; without it, DIDs render unresolved
ttlMs = TTL_MS,
resolveLimit = DEFAULT_RESOLVE,
// Handle resolution is NETWORK work (PLC + a well-known probe + possibly DNS) and it sits on the
// render path. Serially, 24 followers with unreachable hosts took >90s and hung the profile page —
// measured, not hypothetical. Bounded parallelism plus a hard deadline: whatever has not resolved
// when the deadline fires renders as a DID, which is the same graceful degradation used elsewhere.
concurrency = RESOLVE_CONCURRENCY,
deadlineMs = RESOLVE_DEADLINE_MS,
now = () => Date.now()
} = {}) {
if (!constellation) throw new Error('createFollowerLens requires constellation')
const cache = new Map()
return {
// -> { count, followers: [{ did, handle, verified }], shown, truncated } | null
//
// `count` is the NETWORK total from the index; `followers` is the resolved prefix we display.
// `truncated` says plainly that the list is shorter than the count, so the UI never implies it is
// showing everyone.
async followersOf (did) {
if (typeof did !== 'string' || !did.startsWith('did:')) return null
const hit = cache.get(did)
if (hit && hit.expires > now()) return hit.value
let value = null
try {
const count = await constellation.countBacklinks(did, FOLLOW_SOURCE)
const page = await constellation.getBacklinks(did, FOLLOW_SOURCE, { limit: resolveLimit })
if (count !== null || page !== null) {
// A follow record's AUTHOR is the follower; the subject is who they followed.
const dids = page ? [...new Set(page.pointers.map((p) => p.did))] : []
const resolved = new Map()
if (handles && dids.length) {
// Race the whole phase against a deadline; each worker also stops once it fires, so a slow
// host delays one follower's NAME, never the page.
let expired = false
const timer = new Promise((r) => setTimeout(() => { expired = true; r() }, deadlineMs))
const queue = dids.slice()
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => (async () => {
while (queue.length && !expired) {
const d = queue.shift()
try { const h = await handles.resolve(d); if (h) resolved.set(d, h) } catch { /* renders as a DID */ }
}
})())
await Promise.race([Promise.all(workers), timer])
}
const followers = dids.map((d) => {
const h = resolved.get(d)
return { did: d, handle: h && h.verified ? h.handle : null, verified: !!(h && h.verified) }
})
const total = count !== null ? count : followers.length
value = { count: total, followers, shown: followers.length, truncated: followers.length < total }
}
} catch {
value = null // fail soft — a follower list must never break a profile page
}
cache.set(did, { value, expires: now() + (value === null ? 60_000 : ttlMs) })
return value
},
// Count only — cheap enough to call per profile render without the handle resolution.
async countOf (did) {
const r = await this.followersOf(did)
return r ? r.count : null
}
}
}