👁️
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}