WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)

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 <noreply@anthropic.com>

authored by markbennett.ca

Claude Sonnet 4.5 and committed by tangled.org ac6cce98 8aee8768

+175
+58
src/lib/constellation.ts
··· 1 + const CONSTELLATION_BASE = 'https://constellation.microcosm.blue'; 2 + 3 + export interface ConstellationRecord { 4 + did: string; 5 + collection: string; 6 + rkey: string; 7 + } 8 + 9 + export interface GetBacklinksResult { 10 + total: number; 11 + records: ConstellationRecord[]; 12 + cursor: string | null; 13 + } 14 + 15 + /** 16 + * Query the constellation indexer for records that link to a given AT-URI. 17 + * Constellation indexes records across all PDSs, enabling multi-collaborator queries. 18 + * 19 + * @param targetUri - The AT-URI being referenced by the records 20 + * @param collection - Filter results to this collection (e.g. 'sh.tangled.repo.issue') 21 + * @param path - The field path in each record that holds the target URI (e.g. '.repo') 22 + * @param limit - Max records to return (default 100) 23 + * @param cursor - Pagination cursor from a previous call 24 + */ 25 + export async function getBacklinks( 26 + targetUri: string, 27 + collection: string, 28 + path: string, 29 + limit = 100, 30 + cursor?: string 31 + ): Promise<GetBacklinksResult> { 32 + const params = new URLSearchParams({ 33 + target: targetUri, 34 + collection, 35 + path, 36 + limit: String(limit), 37 + }); 38 + if (cursor) { 39 + params.set('cursor', cursor); 40 + } 41 + 42 + const response = await fetch(`${CONSTELLATION_BASE}/links?${params}`); 43 + if (!response.ok) { 44 + throw new Error(`Constellation API error: ${response.status} ${response.statusText}`); 45 + } 46 + 47 + const data = (await response.json()) as { 48 + total: number; 49 + linking_records: ConstellationRecord[]; 50 + cursor: string | null; 51 + }; 52 + 53 + return { 54 + total: data.total, 55 + records: data.linking_records, 56 + cursor: data.cursor, 57 + }; 58 + }
+117
tests/lib/constellation.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import { getBacklinks } from '../../src/lib/constellation.js'; 3 + 4 + const mockFetch = vi.fn(); 5 + 6 + beforeEach(() => { 7 + mockFetch.mockClear(); 8 + vi.stubGlobal('fetch', mockFetch); 9 + }); 10 + 11 + afterEach(() => { 12 + vi.unstubAllGlobals(); 13 + }); 14 + 15 + describe('getBacklinks', () => { 16 + it('should return records from constellation', async () => { 17 + mockFetch.mockResolvedValue({ 18 + ok: true, 19 + json: async () => ({ 20 + total: 2, 21 + linking_records: [ 22 + { did: 'did:plc:abc', collection: 'sh.tangled.repo.issue', rkey: 'rkey1' }, 23 + { did: 'did:plc:def', collection: 'sh.tangled.repo.issue', rkey: 'rkey2' }, 24 + ], 25 + cursor: null, 26 + }), 27 + }); 28 + 29 + const result = await getBacklinks( 30 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 31 + 'sh.tangled.repo.issue', 32 + '.repo' 33 + ); 34 + 35 + expect(result.total).toBe(2); 36 + expect(result.records).toHaveLength(2); 37 + expect(result.records[0]).toEqual({ 38 + did: 'did:plc:abc', 39 + collection: 'sh.tangled.repo.issue', 40 + rkey: 'rkey1', 41 + }); 42 + expect(result.cursor).toBeNull(); 43 + 44 + expect(mockFetch).toHaveBeenCalledWith( 45 + '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' 46 + ); 47 + }); 48 + 49 + it('should pass cursor and limit params', async () => { 50 + mockFetch.mockResolvedValue({ 51 + ok: true, 52 + json: async () => ({ total: 0, linking_records: [], cursor: null }), 53 + }); 54 + 55 + await getBacklinks( 56 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 57 + 'sh.tangled.repo.issue', 58 + '.repo', 59 + 50, 60 + 'abc123' 61 + ); 62 + 63 + const calledUrl = mockFetch.mock.calls[0][0] as string; 64 + expect(calledUrl).toContain('limit=50'); 65 + expect(calledUrl).toContain('cursor=abc123'); 66 + }); 67 + 68 + it('should return cursor for pagination', async () => { 69 + mockFetch.mockResolvedValue({ 70 + ok: true, 71 + json: async () => ({ 72 + total: 200, 73 + linking_records: [ 74 + { did: 'did:plc:abc', collection: 'sh.tangled.repo.issue', rkey: 'rkey1' }, 75 + ], 76 + cursor: 'nextpage', 77 + }), 78 + }); 79 + 80 + const result = await getBacklinks( 81 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 82 + 'sh.tangled.repo.issue', 83 + '.repo', 84 + 1 85 + ); 86 + 87 + expect(result.cursor).toBe('nextpage'); 88 + }); 89 + 90 + it('should throw on non-OK response', async () => { 91 + mockFetch.mockResolvedValue({ 92 + ok: false, 93 + status: 503, 94 + statusText: 'Service Unavailable', 95 + }); 96 + 97 + await expect( 98 + getBacklinks('at://did:plc:owner/sh.tangled.repo/my-repo', 'sh.tangled.repo.issue', '.repo') 99 + ).rejects.toThrow('Constellation API error: 503 Service Unavailable'); 100 + }); 101 + 102 + it('should return empty records when none found', async () => { 103 + mockFetch.mockResolvedValue({ 104 + ok: true, 105 + json: async () => ({ total: 0, linking_records: [], cursor: null }), 106 + }); 107 + 108 + const result = await getBacklinks( 109 + 'at://did:plc:owner/sh.tangled.repo/my-repo', 110 + 'sh.tangled.repo.issue', 111 + '.repo' 112 + ); 113 + 114 + expect(result.total).toBe(0); 115 + expect(result.records).toEqual([]); 116 + }); 117 + });