open source is social v-it.org
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-dazfcrq3-pds-improvements'

# Conflicts:
# src/cmd/learn.js
# src/cmd/remix.js
# src/cmd/ship.js
# src/cmd/skim.js
# src/cmd/vet.js
# src/cmd/vouch.js
# src/lib/pds.js
# test/pds.test.js

+332 -167
+9 -7
src/cmd/learn.js
··· 12 12 import { shouldBypassVet } from '../lib/trust-gate.js'; 13 13 import { isSkillRef, nameFromSkillRef, isValidSkillRef } from '../lib/skill-ref.js'; 14 14 import { mark, name } from '../lib/brand.js'; 15 - import { resolvePds, listRecordsFromPds, queryDidsInParallel } from '../lib/pds.js'; 15 + import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 16 16 17 17 export default function register(program) { 18 18 program ··· 104 104 const following = readFollowing(); 105 105 const dids = following.map(e => e.did); 106 106 dids.push(did); 107 - if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 108 107 109 108 // Fetch skills from each DID, find matching ref 110 - let match = null; 111 - await queryDidsInParallel(dids, async (repoDid) => { 109 + const allRecords = await batchQuery(dids, async (repoDid) => { 112 110 const pds = await resolvePds(repoDid); 113 111 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 114 - const res = await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION); 115 - for (const rec of res.records) { 112 + return (await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50)).records; 113 + }, { verbose }); 114 + 115 + let match = null; 116 + for (const records of allRecords) { 117 + for (const rec of records) { 116 118 const recName = rec.value.name; 117 119 if (recName === skillName) { 118 120 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { ··· 120 122 } 121 123 } 122 124 } 123 - }); 125 + } 124 126 125 127 if (!match) { 126 128 console.error(`no skill found with ref '${ref}' from followed accounts.`);
+9 -7
src/cmd/remix.js
··· 9 9 import { shouldBypassVet } from '../lib/trust-gate.js'; 10 10 import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js'; 11 11 import { brand, name } from '../lib/brand.js'; 12 - import { resolvePds, listRecordsFromPds, queryDidsInParallel } from '../lib/pds.js'; 12 + import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 13 13 14 14 export default function register(program) { 15 15 program ··· 80 80 const following = readFollowing(); 81 81 const dids = following.map(e => e.did); 82 82 dids.push(did); 83 - if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 84 83 85 - let match = null; 86 - await queryDidsInParallel(dids, async (repoDid) => { 84 + const allRecords = await batchQuery(dids, async (repoDid) => { 87 85 const pds = await resolvePds(repoDid); 88 86 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 89 - const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION); 90 - for (const rec of res.records) { 87 + return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 88 + }, { verbose }); 89 + 90 + let match = null; 91 + for (const records of allRecords) { 92 + for (const rec of records) { 91 93 if (rec.value.beacon !== beacon) continue; 92 94 const recRef = resolveRef(rec.value, rec.cid); 93 95 if (recRef === ref) { ··· 96 98 } 97 99 } 98 100 } 99 - }); 101 + } 100 102 101 103 if (!match) { 102 104 console.error(`no cap found with ref '${ref}' for this beacon.`);
+9 -7
src/cmd/ship.js
··· 12 12 import { REF_PATTERN, resolveRef } from '../lib/cap-ref.js'; 13 13 import { isValidSkillName, skillRefFromName } from '../lib/skill-ref.js'; 14 14 import { name } from '../lib/brand.js'; 15 - import { resolvePds, listRecordsFromPds, queryDidsInParallel } from '../lib/pds.js'; 15 + import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 16 16 17 17 function parseFrontmatter(text) { 18 18 const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); ··· 351 351 const following = readFollowing(); 352 352 const dids = following.map(e => e.did); 353 353 dids.push(did); 354 - if (verbose) console.log(`[verbose] recap: querying ${dids.length} accounts`); 355 354 356 - let match = null; 357 - await queryDidsInParallel(dids, async (repoDid) => { 355 + const allRecords = await batchQuery(dids, async (repoDid) => { 358 356 const pds = await resolvePds(repoDid); 359 357 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 360 - const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION); 361 - for (const rec of res.records) { 358 + return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 359 + }, { verbose }); 360 + 361 + let match = null; 362 + for (const records of allRecords) { 363 + for (const rec of records) { 362 364 const recRef = resolveRef(rec.value, rec.cid); 363 365 if (recRef === opts.recap) { 364 366 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { ··· 366 368 } 367 369 } 368 370 } 369 - }); 371 + } 370 372 371 373 if (match) { 372 374 recapUri = match.uri;
+16 -8
src/cmd/skim.js
··· 9 9 import { resolveRef } from '../lib/cap-ref.js'; 10 10 import { skillRefFromName } from '../lib/skill-ref.js'; 11 11 import { name } from '../lib/brand.js'; 12 - import { resolvePds, listRecordsFromPds, queryDidsInParallel } from '../lib/pds.js'; 12 + import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 13 13 14 14 export default function register(program) { 15 15 program ··· 70 70 for (const e of following) handleMap.set(e.did, e.handle); 71 71 dids = following.map(e => e.did); 72 72 dids.push(did); 73 - if (verbose) console.log(`[verbose] querying ${dids.length} accounts (${dids.length - 1} follows + self)`); 74 73 } 75 74 76 75 // resolve own handle if not already known ··· 86 85 // fetch from each DID 87 86 const allItems = []; 88 87 89 - await queryDidsInParallel(dids, async (repoDid) => { 88 + const batchResults = await batchQuery(dids, async (repoDid) => { 90 89 const pds = await resolvePds(repoDid); 91 90 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 91 + const items = []; 92 92 93 + // Fetch caps (filtered by beacon) 93 94 if (wantCaps && beacon) { 94 - const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION); 95 + const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50); 95 96 const caps = res.records.filter(r => r.value.beacon === beacon); 96 97 if (verbose) console.log(`[verbose] ${repoDid}: ${res.records.length} caps, ${caps.length} matching beacon`); 97 98 for (const cap of caps) { 98 99 cap._handle = handleMap.get(repoDid) || repoDid; 99 100 cap._type = 'cap'; 100 101 } 101 - allItems.push(...caps); 102 + items.push(...caps); 102 103 } 103 104 105 + // Fetch skills (unfiltered — skills are universal) 104 106 if (wantSkills) { 105 107 try { 106 - const res = await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION); 108 + const res = await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50); 107 109 if (verbose) console.log(`[verbose] ${repoDid}: ${res.records.length} skills`); 108 110 for (const skill of res.records) { 109 111 skill._handle = handleMap.get(repoDid) || repoDid; 110 112 skill._type = 'skill'; 111 113 } 112 - allItems.push(...res.records); 114 + items.push(...res.records); 113 115 } catch (err) { 114 116 if (verbose) console.log(`[verbose] ${repoDid}: error fetching skills: ${err.message}`); 115 117 } 116 118 } 117 - }); 119 + 120 + return items; 121 + }, { verbose }); 122 + 123 + for (const items of batchResults) { 124 + allItems.push(...items); 125 + } 118 126 119 127 // sort by createdAt descending 120 128 allItems.sort((a, b) => {
+17 -13
src/cmd/vet.js
··· 11 11 import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js'; 12 12 import { isSkillRef, isValidSkillRef, nameFromSkillRef } from '../lib/skill-ref.js'; 13 13 import { mark, brand, name } from '../lib/brand.js'; 14 - import { resolvePds, listRecordsFromPds, queryDidsInParallel } from '../lib/pds.js'; 14 + import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 15 15 16 16 function ensureGitignore() { 17 17 const gitignorePath = join(vitDir(), '.gitignore'); ··· 135 135 const following = readFollowing(); 136 136 const dids = following.map(e => e.did); 137 137 dids.push(did); 138 - if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 139 138 140 139 // fetch caps from each DID, find matching ref 141 - let match = null; 142 - await queryDidsInParallel(dids, async (repoDid) => { 140 + const allRecords = await batchQuery(dids, async (repoDid) => { 143 141 const pds = await resolvePds(repoDid); 144 142 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 145 - const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION); 146 - for (const rec of res.records) { 143 + return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 144 + }, { verbose }); 145 + 146 + let match = null; 147 + for (const records of allRecords) { 148 + for (const rec of records) { 147 149 if (rec.value.beacon !== beacon) continue; 148 150 const recRef = resolveRef(rec.value, rec.cid); 149 151 if (recRef === ref) { ··· 152 154 } 153 155 } 154 156 } 155 - }); 157 + } 156 158 157 159 if (!match) { 158 160 console.error(`no cap found with ref '${ref}' for this beacon.`); ··· 207 209 const following = readFollowing(); 208 210 const dids = following.map(e => e.did); 209 211 dids.push(did); 210 - if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 211 212 212 - let match = null; 213 - await queryDidsInParallel(dids, async (repoDid) => { 213 + const allRecords = await batchQuery(dids, async (repoDid) => { 214 214 const pds = await resolvePds(repoDid); 215 215 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 216 - const res = await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION); 217 - for (const rec of res.records) { 216 + return (await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50)).records; 217 + }, { verbose }); 218 + 219 + let match = null; 220 + for (const records of allRecords) { 221 + for (const rec of records) { 218 222 if (rec.value.name === skillName) { 219 223 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 220 224 match = rec; 221 225 } 222 226 } 223 227 } 224 - }); 228 + } 225 229 226 230 if (!match) { 227 231 console.error(`no skill found with ref '${ref}' from followed accounts.`);
+17 -13
src/cmd/vouch.js
··· 9 9 import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js'; 10 10 import { isSkillRef, isValidSkillRef, nameFromSkillRef } from '../lib/skill-ref.js'; 11 11 import { mark, name } from '../lib/brand.js'; 12 - import { resolvePds, listRecordsFromPds, queryDidsInParallel } from '../lib/pds.js'; 12 + import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 13 13 14 14 export default function register(program) { 15 15 program ··· 67 67 const following = readFollowing(); 68 68 const dids = following.map(e => e.did); 69 69 dids.push(did); 70 - if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 71 70 72 - let match = null; 73 - await queryDidsInParallel(dids, async (repoDid) => { 71 + const allRecords = await batchQuery(dids, async (repoDid) => { 74 72 const pds = await resolvePds(repoDid); 75 73 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 76 - const res = await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION); 77 - for (const rec of res.records) { 74 + return (await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50)).records; 75 + }, { verbose }); 76 + 77 + let match = null; 78 + for (const records of allRecords) { 79 + for (const rec of records) { 78 80 if (rec.value.name === skillName) { 79 81 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 80 82 match = rec; 81 83 } 82 84 } 83 85 } 84 - }); 86 + } 85 87 86 88 if (!match) { 87 89 console.error(`no skill found with ref '${ref}' from followed accounts.`); ··· 156 158 const following = readFollowing(); 157 159 const dids = following.map(e => e.did); 158 160 dids.push(did); 159 - if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 160 161 161 - let match = null; 162 - await queryDidsInParallel(dids, async (repoDid) => { 162 + const allRecords = await batchQuery(dids, async (repoDid) => { 163 163 const pds = await resolvePds(repoDid); 164 164 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 165 - const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION); 166 - for (const rec of res.records) { 165 + return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 166 + }, { verbose }); 167 + 168 + let match = null; 169 + for (const records of allRecords) { 170 + for (const rec of records) { 167 171 if (rec.value.beacon !== beacon) continue; 168 172 const recRef = resolveRef(rec.value, rec.cid); 169 173 if (recRef === ref) { ··· 172 176 } 173 177 } 174 178 } 175 - }); 179 + } 176 180 177 181 if (!match) { 178 182 console.error(`no cap found with ref '${ref}' for this beacon.`);
+38 -29
src/lib/pds.js
··· 4 4 const PLC_DIRECTORY = 'https://plc.directory'; 5 5 const pdsCache = new Map(); 6 6 7 - function didDocUrl(did) { 7 + async function fetchDidDocument(did) { 8 + let url; 8 9 if (did.startsWith('did:web:')) { 9 - const parts = did.slice('did:web:'.length).split(':'); 10 + const rest = did.slice('did:web:'.length); 11 + const parts = rest.split(':'); 10 12 const domain = decodeURIComponent(parts[0]); 11 - const path = parts.slice(1).map(decodeURIComponent).join('/'); 12 - return path 13 - ? `https://${domain}/${path}/did.json` 14 - : `https://${domain}/.well-known/did.json`; 13 + if (parts.length === 1) { 14 + url = `https://${domain}/.well-known/did.json`; 15 + } else { 16 + url = `https://${domain}/${parts.slice(1).map(decodeURIComponent).join('/')}/did.json`; 17 + } 18 + } else { 19 + url = `${PLC_DIRECTORY}/${did}`; 15 20 } 16 - return `${PLC_DIRECTORY}/${did}`; 21 + 22 + const res = await fetch(url); 23 + if (!res.ok) throw new Error(`failed to resolve DID document for ${did}: ${res.status} ${res.statusText}`); 24 + return res.json(); 17 25 } 18 26 19 27 export async function resolvePds(did) { 20 28 if (pdsCache.has(did)) return pdsCache.get(did); 21 - const res = await fetch(didDocUrl(did)); 22 - if (!res.ok) throw new Error(`failed to resolve PDS for ${did}: ${res.status} ${res.statusText}`); 23 - const doc = await res.json(); 29 + const doc = await fetchDidDocument(did); 24 30 const pds = doc.service?.find(s => s.type === 'AtprotoPersonalDataServer'); 25 31 if (!pds?.serviceEndpoint) throw new Error(`no PDS found in DID document for ${did}`); 26 32 pdsCache.set(did, pds.serviceEndpoint); 27 33 return pds.serviceEndpoint; 28 34 } 29 35 30 - export async function listRecordsFromPds(pdsUrl, repo, collection) { 31 - const all = []; 36 + export async function listRecordsFromPds(pdsUrl, repo, collection, limit) { 37 + const records = []; 32 38 let cursor; 33 39 do { 34 40 const url = new URL('/xrpc/com.atproto.repo.listRecords', pdsUrl); 35 41 url.searchParams.set('repo', repo); 36 42 url.searchParams.set('collection', collection); 37 - url.searchParams.set('limit', '100'); 43 + if (limit) url.searchParams.set('limit', String(limit)); 38 44 if (cursor) url.searchParams.set('cursor', cursor); 39 45 const res = await fetch(url); 40 46 if (!res.ok) throw new Error(`listRecords failed for ${repo}: ${res.status} ${res.statusText}`); 41 47 const data = await res.json(); 42 - all.push(...(data.records || [])); 48 + records.push(...data.records); 43 49 cursor = data.cursor; 44 50 } while (cursor); 45 - return { records: all }; 51 + return { records }; 46 52 } 47 53 48 54 export async function resolveHandleFromDid(did) { 49 - const res = await fetch(didDocUrl(did)); 50 - if (!res.ok) return did; 51 - const doc = await res.json(); 52 - const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://')); 53 - return aka ? aka.replace('at://', '') : did; 55 + try { 56 + const doc = await fetchDidDocument(did); 57 + const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://')); 58 + return aka ? aka.replace('at://', '') : did; 59 + } catch { 60 + return did; 61 + } 54 62 } 55 63 56 - export async function queryDidsInParallel(dids, queryFn, { concurrency = 10 } = {}) { 64 + export async function batchQuery(items, fn, { batchSize = 10, verbose = false } = {}) { 65 + if (verbose) console.log(`[verbose] querying ${items.length} accounts in batches of ${batchSize}`); 57 66 const results = []; 58 - for (let i = 0; i < dids.length; i += concurrency) { 59 - const batch = dids.slice(i, i + concurrency); 60 - const settled = await Promise.allSettled(batch.map(queryFn)); 61 - for (const result of settled) { 62 - if (result.status === 'fulfilled') { 63 - results.push(result.value); 64 - } else { 65 - console.error(`warning: query failed: ${result.reason?.message || result.reason}`); 67 + for (let i = 0; i < items.length; i += batchSize) { 68 + const chunk = items.slice(i, i + batchSize); 69 + const settled = await Promise.allSettled(chunk.map(fn)); 70 + for (let j = 0; j < settled.length; j++) { 71 + if (settled[j].status === 'fulfilled' && settled[j].value !== undefined) { 72 + results.push(settled[j].value); 73 + } else if (settled[j].status === 'rejected' && verbose) { 74 + console.log(`[verbose] ${chunk[j]}: error: ${settled[j].reason?.message || settled[j].reason}`); 66 75 } 67 76 } 68 77 }
+217 -83
test/pds.test.js
··· 1 1 // SPDX-License-Identifier: AGPL-3.0-only 2 2 // Copyright (c) 2026 sol pbc 3 3 4 - import { afterEach, describe, expect, test } from 'bun:test'; 5 - import { listRecordsFromPds, resolvePds, resolveHandleFromDid } from '../src/lib/pds.js'; 4 + import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; 5 + import { resolvePds, resolveHandleFromDid, listRecordsFromPds, batchQuery } from '../src/lib/pds.js'; 6 6 7 - const originalFetch = globalThis.fetch; 7 + function jsonResponse(data, { ok = true, status = 200, statusText = 'OK' } = {}) { 8 + return { 9 + ok, 10 + status, 11 + statusText, 12 + async json() { 13 + return data; 14 + }, 15 + }; 16 + } 8 17 9 - afterEach(() => { 10 - globalThis.fetch = originalFetch; 11 - }); 18 + describe('pds', () => { 19 + let originalFetch; 12 20 13 - describe('pds helpers', () => { 14 - test('listRecordsFromPds follows pagination cursors and returns all records', async () => { 15 - const calls = []; 16 - globalThis.fetch = async (url) => { 17 - calls.push(String(url)); 18 - const cursor = new URL(String(url)).searchParams.get('cursor'); 19 - const body = cursor === 'next-page' 20 - ? { records: [{ uri: 'at://did:plc:123/org.v-it.cap/2', value: { text: 'two' } }] } 21 - : { 22 - records: [{ uri: 'at://did:plc:123/org.v-it.cap/1', value: { text: 'one' } }], 23 - cursor: 'next-page', 24 - }; 25 - return new Response(JSON.stringify(body), { 26 - status: 200, 27 - headers: { 'content-type': 'application/json' }, 28 - }); 29 - }; 21 + beforeEach(() => { 22 + originalFetch = global.fetch; 23 + }); 30 24 31 - const res = await listRecordsFromPds('https://pds.example', 'did:plc:123', 'org.v-it.cap'); 32 - 33 - expect(res.records).toHaveLength(2); 34 - expect(calls).toHaveLength(2); 35 - expect(calls[0]).toContain('limit=100'); 36 - expect(calls[1]).toContain('cursor=next-page'); 25 + afterEach(() => { 26 + global.fetch = originalFetch; 37 27 }); 38 28 39 - test('resolvePds uses .well-known did document for did:web domains', async () => { 40 - const calls = []; 41 - globalThis.fetch = async (url) => { 42 - calls.push(String(url)); 43 - return new Response(JSON.stringify({ 44 - service: [{ type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example' }], 45 - }), { 46 - status: 200, 47 - headers: { 'content-type': 'application/json' }, 48 - }); 49 - }; 29 + describe('resolvePds', () => { 30 + test('fetches PLC DID documents from plc.directory', async () => { 31 + let fetchedUrl; 32 + global.fetch = async (input) => { 33 + fetchedUrl = String(input); 34 + return jsonResponse({ 35 + service: [{ type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }], 36 + }); 37 + }; 50 38 51 - const pds = await resolvePds('did:web:example.com'); 39 + await expect(resolvePds('did:plc:abc123-pds-test')).resolves.toBe('https://pds.example.com'); 40 + expect(fetchedUrl).toBe('https://plc.directory/did:plc:abc123-pds-test'); 41 + }); 52 42 53 - expect(pds).toBe('https://pds.example'); 54 - expect(calls[0]).toBe('https://example.com/.well-known/did.json'); 43 + test('fetches root did:web documents from .well-known', async () => { 44 + let fetchedUrl; 45 + global.fetch = async (input) => { 46 + fetchedUrl = String(input); 47 + return jsonResponse({ 48 + service: [{ type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }], 49 + }); 50 + }; 51 + 52 + await expect(resolvePds('did:web:example.com')).resolves.toBe('https://pds.example.com'); 53 + expect(fetchedUrl).toBe('https://example.com/.well-known/did.json'); 54 + }); 55 + 56 + test('fetches path-based did:web documents from /.../did.json', async () => { 57 + let fetchedUrl; 58 + global.fetch = async (input) => { 59 + fetchedUrl = String(input); 60 + return jsonResponse({ 61 + service: [{ type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }], 62 + }); 63 + }; 64 + 65 + await expect(resolvePds('did:web:example.com:user:alice')).resolves.toBe('https://pds.example.com'); 66 + expect(fetchedUrl).toBe('https://example.com/user/alice/did.json'); 67 + }); 68 + 69 + test('decodes did:web port encoding', async () => { 70 + let fetchedUrl; 71 + global.fetch = async (input) => { 72 + fetchedUrl = String(input); 73 + return jsonResponse({ 74 + service: [{ type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }], 75 + }); 76 + }; 77 + 78 + await expect(resolvePds('did:web:example.com%3A3000')).resolves.toBe('https://pds.example.com'); 79 + expect(fetchedUrl).toBe('https://example.com:3000/.well-known/did.json'); 80 + }); 81 + 82 + test('caches per DID', async () => { 83 + let fetchCount = 0; 84 + global.fetch = async () => { 85 + fetchCount++; 86 + return jsonResponse({ 87 + service: [{ type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://cached.example.com' }], 88 + }); 89 + }; 90 + 91 + await expect(resolvePds('did:plc:cache-test-1')).resolves.toBe('https://cached.example.com'); 92 + await expect(resolvePds('did:plc:cache-test-1')).resolves.toBe('https://cached.example.com'); 93 + expect(fetchCount).toBe(1); 94 + }); 95 + 96 + test('throws when no PDS service is present', async () => { 97 + global.fetch = async () => jsonResponse({ service: [] }); 98 + 99 + await expect(resolvePds('did:plc:no-service-test')).rejects.toThrow( 100 + 'no PDS found in DID document for did:plc:no-service-test', 101 + ); 102 + }); 55 103 }); 56 104 57 - test('resolvePds uses path did document for did:web with path segments', async () => { 58 - const calls = []; 59 - globalThis.fetch = async (url) => { 60 - calls.push(String(url)); 61 - return new Response(JSON.stringify({ 62 - service: [{ type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example/path' }], 63 - }), { 64 - status: 200, 65 - headers: { 'content-type': 'application/json' }, 105 + describe('resolveHandleFromDid', () => { 106 + test('returns handle from alsoKnownAs for did:plc', async () => { 107 + global.fetch = async () => jsonResponse({ 108 + alsoKnownAs: ['at://alice.test'], 66 109 }); 67 - }; 68 110 69 - const pds = await resolvePds('did:web:example.com:path:segment'); 111 + await expect(resolveHandleFromDid('did:plc:handle-test')).resolves.toBe('alice.test'); 112 + }); 70 113 71 - expect(pds).toBe('https://pds.example/path'); 72 - expect(calls[0]).toBe('https://example.com/path/segment/did.json'); 114 + test('returns handle from alsoKnownAs for did:web', async () => { 115 + global.fetch = async () => jsonResponse({ 116 + alsoKnownAs: ['at://bob.test'], 117 + }); 118 + 119 + await expect(resolveHandleFromDid('did:web:example.com:bob')).resolves.toBe('bob.test'); 120 + }); 121 + 122 + test('returns DID when fetch fails', async () => { 123 + global.fetch = async () => { 124 + throw new Error('network down'); 125 + }; 126 + 127 + await expect(resolveHandleFromDid('did:web:example.com:fallback')).resolves.toBe('did:web:example.com:fallback'); 128 + }); 73 129 }); 74 130 75 - test('resolvePds still uses plc.directory for did:plc identifiers', async () => { 76 - const calls = []; 77 - globalThis.fetch = async (url) => { 78 - calls.push(String(url)); 79 - return new Response(JSON.stringify({ 80 - service: [{ type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example/plc' }], 81 - }), { 82 - status: 200, 83 - headers: { 'content-type': 'application/json' }, 131 + describe('listRecordsFromPds', () => { 132 + test('returns a single page of records', async () => { 133 + const seenUrls = []; 134 + global.fetch = async (input) => { 135 + seenUrls.push(String(input)); 136 + return jsonResponse({ 137 + records: [{ uri: 'at://did:plc:one/app.test.record/1', cid: 'cid1', value: { foo: 'bar' } }], 138 + }); 139 + }; 140 + 141 + await expect(listRecordsFromPds('https://pds.example.com', 'did:plc:one', 'app.test.record', 50)).resolves.toEqual({ 142 + records: [{ uri: 'at://did:plc:one/app.test.record/1', cid: 'cid1', value: { foo: 'bar' } }], 84 143 }); 85 - }; 144 + expect(seenUrls).toEqual([ 145 + 'https://pds.example.com/xrpc/com.atproto.repo.listRecords?repo=did%3Aplc%3Aone&collection=app.test.record&limit=50', 146 + ]); 147 + }); 148 + 149 + test('accumulates records across cursor pages', async () => { 150 + const seenUrls = []; 151 + global.fetch = async (input) => { 152 + const url = String(input); 153 + seenUrls.push(url); 154 + if (!url.includes('cursor=')) { 155 + return jsonResponse({ 156 + records: [{ uri: 'at://did:plc:two/app.test.record/1', cid: 'cid1', value: { page: 1 } }], 157 + cursor: 'next-cursor', 158 + }); 159 + } 160 + return jsonResponse({ 161 + records: [{ uri: 'at://did:plc:two/app.test.record/2', cid: 'cid2', value: { page: 2 } }], 162 + }); 163 + }; 86 164 87 - const pds = await resolvePds('did:plc:abc123'); 165 + await expect(listRecordsFromPds('https://pds.example.com', 'did:plc:two', 'app.test.record', 50)).resolves.toEqual({ 166 + records: [ 167 + { uri: 'at://did:plc:two/app.test.record/1', cid: 'cid1', value: { page: 1 } }, 168 + { uri: 'at://did:plc:two/app.test.record/2', cid: 'cid2', value: { page: 2 } }, 169 + ], 170 + }); 171 + expect(seenUrls).toEqual([ 172 + 'https://pds.example.com/xrpc/com.atproto.repo.listRecords?repo=did%3Aplc%3Atwo&collection=app.test.record&limit=50', 173 + 'https://pds.example.com/xrpc/com.atproto.repo.listRecords?repo=did%3Aplc%3Atwo&collection=app.test.record&limit=50&cursor=next-cursor', 174 + ]); 175 + }); 88 176 89 - expect(pds).toBe('https://pds.example/plc'); 90 - expect(calls[0]).toBe('https://plc.directory/did:plc:abc123'); 177 + test('throws on non-ok response', async () => { 178 + global.fetch = async () => jsonResponse({}, { ok: false, status: 500, statusText: 'Server Error' }); 179 + 180 + await expect(listRecordsFromPds('https://pds.example.com', 'did:plc:three', 'app.test.record', 50)).rejects.toThrow( 181 + 'listRecords failed for did:plc:three: 500 Server Error', 182 + ); 183 + }); 91 184 }); 92 185 93 - test('resolveHandleFromDid uses did:web URL for did:web identifiers', async () => { 94 - const calls = []; 95 - globalThis.fetch = async (url) => { 96 - calls.push(String(url)); 97 - return new Response(JSON.stringify({ 98 - alsoKnownAs: ['at://alice.example.com'], 99 - }), { 100 - status: 200, 101 - headers: { 'content-type': 'application/json' }, 102 - }); 103 - }; 186 + describe('batchQuery', () => { 187 + test('processes items in batches and collects fulfilled results', async () => { 188 + const active = []; 189 + const concurrentBatches = []; 190 + const results = await batchQuery( 191 + ['a', 'b', 'c', 'd', 'e'], 192 + async (item) => { 193 + active.push(item); 194 + concurrentBatches.push([...active]); 195 + await Promise.resolve(); 196 + active.splice(active.indexOf(item), 1); 197 + return item.toUpperCase(); 198 + }, 199 + { batchSize: 2 }, 200 + ); 201 + 202 + expect(results).toEqual(['A', 'B', 'C', 'D', 'E']); 203 + expect(concurrentBatches).toContainEqual(['a', 'b']); 204 + expect(concurrentBatches).toContainEqual(['c', 'd']); 205 + }); 206 + 207 + test('ignores rejected promises and continues', async () => { 208 + const results = await batchQuery( 209 + ['good', 'bad', 'fine'], 210 + async (item) => { 211 + if (item === 'bad') throw new Error('boom'); 212 + return item; 213 + }, 214 + { batchSize: 2 }, 215 + ); 216 + 217 + expect(results).toEqual(['good', 'fine']); 218 + }); 219 + 220 + test('logs batch start and errors in verbose mode', async () => { 221 + const logs = []; 222 + const originalLog = console.log; 223 + console.log = (msg) => logs.push(msg); 104 224 105 - const handle = await resolveHandleFromDid('did:web:example.com'); 225 + try { 226 + await batchQuery( 227 + ['did:one', 'did:two'], 228 + async (item) => { 229 + if (item === 'did:two') throw new Error('failed item'); 230 + return item; 231 + }, 232 + { batchSize: 1, verbose: true }, 233 + ); 234 + } finally { 235 + console.log = originalLog; 236 + } 106 237 107 - expect(handle).toBe('alice.example.com'); 108 - expect(calls[0]).toBe('https://example.com/.well-known/did.json'); 238 + expect(logs).toEqual([ 239 + '[verbose] querying 2 accounts in batches of 1', 240 + '[verbose] did:two: error: failed item', 241 + ]); 242 + }); 109 243 }); 110 244 });