👁️
at dev 180 lines 5.1 kB view raw
1/** 2 * API client for Constellation backlink indexer 3 * See .claude/CONSTELLATION.md for full API documentation 4 */ 5 6import type { Did } from "@atcute/lexicons"; 7import { getCollectionFromSchema, type Result } from "./atproto-client"; 8import { 9 ComDeckbelcherCollectionList, 10 ComDeckbelcherDeckList, 11 ComDeckbelcherSocialComment, 12 ComDeckbelcherSocialLike, 13 ComDeckbelcherSocialReply, 14} from "./lexicons/index"; 15 16const CONSTELLATION_BASE = "https://constellation.microcosm.blue"; 17export const MICROCOSM_USER_AGENT = "deckbelcher.com by @aviva.gay"; 18 19// NSIDs derived from schemas (single source of truth) 20export const COLLECTION_LIST_NSID = getCollectionFromSchema( 21 ComDeckbelcherCollectionList.mainSchema, 22); 23export const DECK_LIST_NSID = getCollectionFromSchema( 24 ComDeckbelcherDeckList.mainSchema, 25); 26export const LIKE_NSID = getCollectionFromSchema( 27 ComDeckbelcherSocialLike.mainSchema, 28); 29export const COMMENT_NSID = getCollectionFromSchema( 30 ComDeckbelcherSocialComment.mainSchema, 31); 32export const REPLY_NSID = getCollectionFromSchema( 33 ComDeckbelcherSocialReply.mainSchema, 34); 35 36// Path constants for DeckBelcher collections 37// Constellation includes $type in paths for union array elements 38// Use oracleUri for card aggregation (counts across printings) 39export const COLLECTION_LIST_CARD_PATH = `.items[${COLLECTION_LIST_NSID}#cardItem].ref.oracleUri`; 40export const COLLECTION_LIST_DECK_PATH = `.items[${COLLECTION_LIST_NSID}#deckItem].ref.uri`; 41// Future: cards in decks (also uses oracleUri for aggregation) 42export const DECK_LIST_CARD_PATH = ".cards[].ref.oracleUri"; 43 44// Like paths (subject is a union but NOT an array, so no [$type] notation) 45export const LIKE_CARD_PATH = ".subject.ref.oracleUri"; 46export const LIKE_RECORD_PATH = ".subject.ref.uri"; 47 48// Comment paths (top-level comments, subject is union) 49// Query comments on a card (global card discussion) 50export const COMMENT_CARD_PATH = ".subject.ref.oracleUri"; 51// Query comments on a record (deck, collection, etc) 52export const COMMENT_RECORD_PATH = ".subject.ref.uri"; 53 54// Reply paths (threaded replies) 55// Query by root to get all replies in a thread (for thread expansion) 56export const REPLY_ROOT_PATH = ".root.uri"; 57// Query by parent to get direct replies to a specific comment/reply 58export const REPLY_PARENT_PATH = ".parent.uri"; 59 60export interface BacklinkRecord { 61 did: Did; 62 collection: string; 63 rkey: string; 64} 65 66export interface BacklinksResponse { 67 total: number; 68 records: BacklinkRecord[]; 69 cursor?: string; 70} 71 72export interface CountResponse { 73 total: number; 74} 75 76export interface GetBacklinksParams { 77 subject: string; 78 source: string; 79 did?: string; 80 limit?: number; 81 cursor?: string; 82} 83 84export interface GetLinksCountParams { 85 target: string; 86 collection: string; 87 path: string; 88} 89 90/** 91 * Get records that link to a target 92 */ 93export async function getBacklinks( 94 params: GetBacklinksParams, 95): Promise<Result<BacklinksResponse>> { 96 try { 97 const url = new URL( 98 `${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinks`, 99 ); 100 url.searchParams.set("subject", params.subject); 101 url.searchParams.set("source", params.source); 102 if (params.did) { 103 url.searchParams.set("did", params.did); 104 } 105 if (params.limit !== undefined) { 106 url.searchParams.set("limit", String(params.limit)); 107 } 108 if (params.cursor) { 109 url.searchParams.set("cursor", params.cursor); 110 } 111 112 const response = await fetch(url.toString(), { 113 headers: { 114 Accept: "application/json", 115 "User-Agent": MICROCOSM_USER_AGENT, 116 }, 117 }); 118 119 if (!response.ok) { 120 return { 121 success: false, 122 error: new Error(`Constellation API error: ${response.statusText}`), 123 }; 124 } 125 126 const data = (await response.json()) as BacklinksResponse; 127 return { success: true, data }; 128 } catch (error) { 129 return { 130 success: false, 131 error: error instanceof Error ? error : new Error(String(error)), 132 }; 133 } 134} 135 136/** 137 * Get count of records linking to a target 138 */ 139export async function getLinksCount( 140 params: GetLinksCountParams, 141): Promise<Result<CountResponse>> { 142 try { 143 const url = new URL(`${CONSTELLATION_BASE}/links/count`); 144 url.searchParams.set("target", params.target); 145 url.searchParams.set("collection", params.collection); 146 url.searchParams.set("path", params.path); 147 148 const response = await fetch(url.toString(), { 149 headers: { 150 Accept: "application/json", 151 "User-Agent": MICROCOSM_USER_AGENT, 152 }, 153 }); 154 155 if (!response.ok) { 156 return { 157 success: false, 158 error: new Error(`Constellation API error: ${response.statusText}`), 159 }; 160 } 161 162 const data = (await response.json()) as CountResponse; 163 return { success: true, data }; 164 } catch (error) { 165 return { 166 success: false, 167 error: error instanceof Error ? error : new Error(String(error)), 168 }; 169 } 170} 171 172/** 173 * Build the source string for getBacklinks 174 * Format: collection:path (without leading dot) 175 * Note: getBacklinks expects path WITHOUT leading dot, but /links/count expects WITH leading dot 176 */ 177export function buildSource(collection: string, path: string): string { 178 const pathWithoutDot = path.startsWith(".") ? path.slice(1) : path; 179 return `${collection}:${pathWithoutDot}`; 180}