// User lists — app.bsky.graph.list + app.bsky.graph.listitem.
//
// TWO RECORDS, and the relationship between them is the whole thing. A `list` is just a name and a
// purpose; membership lives in separate `listitem` records, each carrying `subject` (the member's DID)
// and `list` (the list's at:// uri). So a list of 500 people is 501 records, and removing someone means
// deleting THEIR listitem — not editing the list.
//
// That shape is why this needs no index for our OWN lists: we know our DID, so both collections are
// directly readable from the record store. Someone ELSE's list still needs the peer path
// (lib/roster.js resolveRoster), which is what threadgate #listRule already uses.
//
// PURPOSE IS LOAD-BEARING, not a label. `#curatelist` is a normal user list; `#modlist` is a moderation
// list, and subscribing to one MUTES OR BLOCKS everyone on it. Rendering a modlist as if it were a
// curation list, or letting one be created by accident, hands someone a footgun — so the purpose is
// explicit at creation and shown wherever a list is displayed.
export const LIST_COLLECTION = 'app.bsky.graph.list'
export const LISTITEM_COLLECTION = 'app.bsky.graph.listitem'
export const CURATE = 'app.bsky.graph.defs#curatelist'
export const MODLIST = 'app.bsky.graph.defs#modlist'
export const PURPOSES = [CURATE, MODLIST]
export function listRecord ({ name, purpose = CURATE, description = '', createdAt = new Date().toISOString() }) {
const n = String(name || '').trim()
if (!n) return null
return {
$type: LIST_COLLECTION,
purpose: PURPOSES.includes(purpose) ? purpose : CURATE,
name: n.slice(0, 64),
...(description ? { description: String(description).slice(0, 300) } : {}),
createdAt
}
}
export function listitemRecord ({ subject, list, createdAt = new Date().toISOString() }) {
if (typeof subject !== 'string' || !subject.startsWith('did:')) return null
if (typeof list !== 'string' || !list.startsWith('at://')) return null
return { $type: LISTITEM_COLLECTION, subject, list, createdAt }
}
export function isModlist (record) {
return !!(record && record.purpose === MODLIST)
}
export function describePurpose (record) {
return isModlist(record)
? 'moderation list — subscribing to this mutes or blocks everyone on it'
: 'curation list'
}
export const listUri = (did, rkey) => `at://${did}/${LIST_COLLECTION}/${rkey}`
// Our own lists, read straight from the record store. Returns [{ rkey, uri, record }].
export async function ownLists (store, did) {
const out = []
if (!store || !did) return out
try {
for await (const { rkey, record } of store.list(LIST_COLLECTION)) {
if (record && record.$type === LIST_COLLECTION) out.push({ rkey, uri: listUri(did, rkey), record })
}
} catch { /* absent collection dir — no lists yet */ }
out.sort((a, b) => String((a.record && a.record.name) || '').localeCompare(String((b.record && b.record.name) || '')))
return out
}
// Members of one of OUR lists: every listitem whose `list` matches. Returns [{ rkey, subject }].
//
// This SCANS the listitem collection, because membership is not indexed by list — the same honest trade
// search makes. A few hundred listitems is nothing; a hundred thousand would want an index.
export async function membersOf (store, uri) {
const out = []
if (!store || !uri) return out
try {
for await (const { rkey, record } of store.list(LISTITEM_COLLECTION)) {
if (record && record.list === uri && typeof record.subject === 'string') out.push({ rkey, subject: record.subject })
}
} catch { /* no listitems yet */ }
return out
}
// The listitem rkey binding `subject` to `uri`, or null. Needed to REMOVE someone: the membership record
// is what gets deleted, and without its rkey you would have to scan for it every time.
export async function memberRkey (store, uri, subject) {
for (const m of await membersOf(store, uri)) if (m.subject === subject) return m.rkey
return null
}
// ── CONVERGING ONE IDENTITY'S SUBSCRIPTIONS ACROSS ITS DEVICES ───────────────────────────────────────
// Devices hosting the same DID are the same identity and should agree on what it subscribes to with
// nothing configured on any of them. Both helpers below exist so that decision is testable without a
// repo, a writer or a handset — the failure they prevent was only visible on a phone.
// The rkey binding a subject to the subscriptions list.
//
// ⛔ DETERMINISTIC, NOT A FRESH TID. Two devices contributing the same DID concurrently would otherwise
// write TWO records for one membership: reads dedup by subject so it looks fine, while the repo
// accumulates duplicates for ever. Same subject => same rkey => the second write is a no-op, which is
// what makes the merge idempotent rather than merely convergent-on-read.
//
// rkey charset is [A-Za-z0-9.:_~-], so hex — not base64url. Truncated: a collision across a handful of
// subscriptions is not a practical concern, and would only drop one add, which another device's merge
// re-contributes.
export function subscriptionRkey (subject, hash) {
if (typeof subject !== 'string' || !subject) throw new Error('subject required')
if (typeof hash !== 'function') throw new Error('hash function required')
return 'sub' + String(hash(subject)).slice(0, 24)
}
// What THIS node still owes the shared list: its own roster, minus itself, minus what the list already
// has, deduped and order-stable.
//
// ⛔ UNION, NEVER REPLACE. Writing the list wholesale makes "who booted first" decide the identity's
// roster for every device — a handset won that race against a server on 2026-08-25 and its derived set
// became authoritative. A union is order-independent: any interleaving of devices converges on the same
// set, and there is no winner to be.
//
// Self is excluded because a hosted DID is subscribed by virtue of being hosted; listing yourself says
// nothing and would round-trip into the roster as a duplicate.
export function missingMembers ({ local = [], listed = [], selfDid = null } = {}) {
const have = new Set(listed)
const out = []
const seen = new Set()
for (const did of local) {
if (typeof did !== 'string' || !did.startsWith('did:')) continue
if (did === selfDid || have.has(did) || seen.has(did)) continue
seen.add(did)
out.push(did)
}
return out
}