From ac6cce98d5bb1720650ca6bb1f07b6ce41c56bad Mon Sep 17 00:00:00 2001 From: Mark Bennett Date: Wed, 11 Feb 2026 18:01:57 -0700 Subject: [PATCH] Add constellation.ts client for cross-PDS record indexing Introduces getBacklinks() which queries constellation.microcosm.blue to find AT Protocol records that reference a given URI, across all PDSs. This is the foundation for fixing multi-collaborator issue queries. Co-Authored-By: Claude Sonnet 4.5 --- src/lib/constellation.ts | 58 ++++++++++++++++ tests/lib/constellation.test.ts | 117 ++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/lib/constellation.ts create mode 100644 tests/lib/constellation.test.ts diff --git a/src/lib/constellation.ts b/src/lib/constellation.ts new file mode 100644 index 0000000..571dc02 --- /dev/null +++ b/src/lib/constellation.ts @@ -0,0 +1,58 @@ +const CONSTELLATION_BASE = 'https://constellation.microcosm.blue'; + +export interface ConstellationRecord { + did: string; + collection: string; + rkey: string; +} + +export interface GetBacklinksResult { + total: number; + records: ConstellationRecord[]; + cursor: string | null; +} + +/** + * Query the constellation indexer for records that link to a given AT-URI. + * Constellation indexes records across all PDSs, enabling multi-collaborator queries. + * + * @param targetUri - The AT-URI being referenced by the records + * @param collection - Filter results to this collection (e.g. 'sh.tangled.repo.issue') + * @param path - The field path in each record that holds the target URI (e.g. '.repo') + * @param limit - Max records to return (default 100) + * @param cursor - Pagination cursor from a previous call + */ +export async function getBacklinks( + targetUri: string, + collection: string, + path: string, + limit = 100, + cursor?: string +): Promise { + const params = new URLSearchParams({ + target: targetUri, + collection, + path, + limit: String(limit), + }); + if (cursor) { + params.set('cursor', cursor); + } + + const response = await fetch(`${CONSTELLATION_BASE}/links?${params}`); + if (!response.ok) { + throw new Error(`Constellation API error: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { + total: number; + linking_records: ConstellationRecord[]; + cursor: string | null; + }; + + return { + total: data.total, + records: data.linking_records, + cursor: data.cursor, + }; +} diff --git a/tests/lib/constellation.test.ts b/tests/lib/constellation.test.ts new file mode 100644 index 0000000..9ceb597 --- /dev/null +++ b/tests/lib/constellation.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getBacklinks } from '../../src/lib/constellation.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockClear(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('getBacklinks', () => { + it('should return records from constellation', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + total: 2, + linking_records: [ + { did: 'did:plc:abc', collection: 'sh.tangled.repo.issue', rkey: 'rkey1' }, + { did: 'did:plc:def', collection: 'sh.tangled.repo.issue', rkey: 'rkey2' }, + ], + cursor: null, + }), + }); + + const result = await getBacklinks( + 'at://did:plc:owner/sh.tangled.repo/my-repo', + 'sh.tangled.repo.issue', + '.repo' + ); + + expect(result.total).toBe(2); + expect(result.records).toHaveLength(2); + expect(result.records[0]).toEqual({ + did: 'did:plc:abc', + collection: 'sh.tangled.repo.issue', + rkey: 'rkey1', + }); + expect(result.cursor).toBeNull(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://constellation.microcosm.blue/links?target=at%3A%2F%2Fdid%3Aplc%3Aowner%2Fsh.tangled.repo%2Fmy-repo&collection=sh.tangled.repo.issue&path=.repo&limit=100' + ); + }); + + it('should pass cursor and limit params', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ total: 0, linking_records: [], cursor: null }), + }); + + await getBacklinks( + 'at://did:plc:owner/sh.tangled.repo/my-repo', + 'sh.tangled.repo.issue', + '.repo', + 50, + 'abc123' + ); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('limit=50'); + expect(calledUrl).toContain('cursor=abc123'); + }); + + it('should return cursor for pagination', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + total: 200, + linking_records: [ + { did: 'did:plc:abc', collection: 'sh.tangled.repo.issue', rkey: 'rkey1' }, + ], + cursor: 'nextpage', + }), + }); + + const result = await getBacklinks( + 'at://did:plc:owner/sh.tangled.repo/my-repo', + 'sh.tangled.repo.issue', + '.repo', + 1 + ); + + expect(result.cursor).toBe('nextpage'); + }); + + it('should throw on non-OK response', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + }); + + await expect( + getBacklinks('at://did:plc:owner/sh.tangled.repo/my-repo', 'sh.tangled.repo.issue', '.repo') + ).rejects.toThrow('Constellation API error: 503 Service Unavailable'); + }); + + it('should return empty records when none found', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ total: 0, linking_records: [], cursor: null }), + }); + + const result = await getBacklinks( + 'at://did:plc:owner/sh.tangled.repo/my-repo', + 'sh.tangled.repo.issue', + '.repo' + ); + + expect(result.total).toBe(0); + expect(result.records).toEqual([]); + }); +}); -- 2.51.2