atproto record helpers
11 kB
406 lines
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/tinyrecord
6
7/**
8 * @template Input
9 * @template {Input} Output
10 * @typedef {{ "~standard": StandardSchemaProps<Input, Output> }} StandardSchema
11 */
12
13/**
14 * @template Input
15 * @template Output
16 * @typedef {Object} StandardSchemaProps
17 * @property {1} version
18 * @property {string} vendor
19 * @property {(value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>} validate
20 * @property {{ input: Input, output: Output } | undefined} [types]
21 */
22
23/**
24 * @template Output
25 * @typedef {{ value: Output, issues?: undefined } | { issues: ReadonlyArray<{ message: string }> }} StandardSchemaResult
26 */
27
28/**
29 * @template Output
30 * @param {StandardSchema<unknown, Output>} schema
31 * @param {unknown} value
32 * @returns {Promise<Output>}
33 */
34async function validate(schema, value) {
35 const result = await schema["~standard"].validate(value);
36 if (result.issues) {
37 const messages = result.issues
38 .map(/** @param {{ message: string }} i */ (i) => i.message)
39 .join(", ");
40 throw Object.assign(new Error(`Validation failed: ${messages}`), {
41 issues: result.issues,
42 });
43 }
44 return result.value;
45}
46
47/**
48 * @typedef {Object} AtUri
49 * @property {string} did
50 * @property {string} collection
51 * @property {string} rkey
52 * @property {string} href
53 */
54
55/**
56 * @param {string} uri
57 * @returns {AtUri}
58 */
59export function parseAtUri(uri) {
60 if (!uri.startsWith("at://")) throw new Error(`Invalid AT URI: ${uri}`);
61 const [did, collection, rkey] = uri.slice("at://".length).split("/");
62 if (!did || !collection || !rkey) throw new Error(`Invalid AT URI: ${uri}`);
63 return { did, collection, rkey, href: uri };
64}
65
66/**
67 * @param {string} did
68 * @param {string} collection
69 * @param {string} rkey
70 * @returns {AtUri}
71 */
72export function atUri(did, collection, rkey) {
73 return { did, collection, rkey, href: `at://${did}/${collection}/${rkey}` };
74}
75
76// --- Resolution ---
77
78/** @type {Map<string, Promise<{ did: string, pds: string }>>} */
79const resolutionCache = new Map();
80
81/**
82 * @param {string} handle
83 * @param {typeof fetch} fetchFn
84 * @returns {Promise<string>}
85 */
86async function resolveHandle(handle, fetchFn) {
87 const url = `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
88 const res = await fetchFn(url);
89 if (!res.ok) throw new Error(`Failed to resolve handle: ${handle}`);
90 const { did } = await res.json();
91 return did;
92}
93
94/**
95 * @param {string} did
96 * @param {typeof fetch} fetchFn
97 * @returns {Promise<string>}
98 */
99async function resolvePds(did, fetchFn) {
100 /** @type {string} */
101 let docUrl;
102 if (did.startsWith("did:plc:")) {
103 docUrl = `https://plc.directory/${did}`;
104 } else if (did.startsWith("did:web:")) {
105 const host = did.slice("did:web:".length);
106 docUrl = `https://${host}/.well-known/did.json`;
107 } else {
108 throw new Error(`Unsupported DID method: ${did}`);
109 }
110
111 const res = await fetchFn(docUrl);
112 if (!res.ok) throw new Error(`Failed to fetch DID doc for ${did}`);
113 const doc = await res.json();
114
115 const service = doc.service?.find(/** @param {any} s */ (s) => s.id === "#atproto_pds");
116 if (!service) throw new Error(`No atproto_pds service in DID doc for ${did}`);
117 return service.serviceEndpoint;
118}
119
120/**
121 * @param {string} actor
122 * @param {typeof fetch} fetchFn
123 * @returns {Promise<{ did: string, pds: string }>}
124 */
125async function resolveActor(actor, fetchFn) {
126 const did = actor.startsWith("did:") ? actor : await resolveHandle(actor, fetchFn);
127 const pds = await resolvePds(did, fetchFn);
128 return { did, pds };
129}
130
131// --- FocusedCollection ---
132
133/**
134 * @template T
135 * @typedef {Object} RepoRecord
136 * @property {AtUri} uri
137 * @property {string} cid
138 * @property {T} value
139 */
140
141/**
142 * @template T
143 * @typedef {Object} ListPage
144 * @property {RepoRecord<T>[]} records
145 * @property {string | undefined} cursor
146 */
147
148/**
149 * @typedef {Object} ListOptions
150 * @property {number} [limit]
151 * @property {string} [cursor]
152 */
153
154/**
155 * @template T
156 */
157class FocusedCollection {
158 /**
159 * @param {string} did
160 * @param {string} pds
161 * @param {string} nsid
162 * @param {typeof fetch} fetchFn
163 * @param {StandardSchema<unknown, T> | null} schema
164 */
165 constructor(did, pds, nsid, fetchFn, schema) {
166 this.did = did;
167 this.pds = pds;
168 this.nsid = nsid;
169 this.fetchFn = fetchFn;
170 this.schema = schema;
171 }
172
173 /**
174 * @param {string} path
175 * @param {RequestInit} [init]
176 */
177 async #rpc(path, init) {
178 const res = await this.fetchFn(`${this.pds}/xrpc/${path}`, init);
179 if (!res.ok) {
180 const err = await res.json().catch(() => ({}));
181 throw Object.assign(new Error(err.message ?? res.statusText), {
182 status: res.status,
183 error: err.error,
184 });
185 }
186 return res.json();
187 }
188
189 /**
190 * @param {unknown} value
191 * @returns {Promise<T>}
192 */
193 async #validate(value) {
194 return this.schema ? validate(this.schema, value) : /** @type {T} */ (value);
195 }
196
197 /**
198 * @param {{ uri: string, cid: string, value: unknown }} raw
199 * @returns {Promise<RepoRecord<T>>}
200 */
201 async #coerce(raw) {
202 const value = await this.#validate(raw.value);
203 return { uri: parseAtUri(raw.uri), cid: raw.cid, value };
204 }
205
206 /**
207 * @param {T} record
208 * @returns {Promise<T>}
209 */
210 async #prepare(record) {
211 return this.#validate(record);
212 }
213
214 /**
215 * @param {ListOptions} [opts]
216 * @returns {Promise<ListPage<T>>}
217 */
218 async list({ limit, cursor } = {}) {
219 const params = new URLSearchParams({ repo: this.did, collection: this.nsid });
220 if (limit != null) params.set("limit", String(limit));
221 if (cursor != null) params.set("cursor", cursor);
222 const page = await this.#rpc(`com.atproto.repo.listRecords?${params}`);
223 const records = await Promise.all(
224 page.records.map(
225 /** @param {{ uri: string, cid: string, value: unknown }} r */ (r) => this.#coerce(r),
226 ),
227 );
228 return { records, cursor: page.cursor };
229 }
230
231 /** @returns {AsyncGenerator<RepoRecord<T>>} */
232 async *listAll() {
233 let cursor;
234 do {
235 /** @type {ListPage<T>} */
236 const page = await this.list({ limit: 100, cursor });
237 yield* page.records;
238 cursor = page.cursor;
239 } while (cursor);
240 }
241
242 /**
243 * @param {string | AtUri} rkeyOrUri
244 * @returns {Promise<RepoRecord<T>>}
245 */
246 async get(rkeyOrUri) {
247 const rkey =
248 typeof rkeyOrUri === "string" && !rkeyOrUri.startsWith("at://")
249 ? rkeyOrUri
250 : parseAtUri(typeof rkeyOrUri === "string" ? rkeyOrUri : rkeyOrUri.href).rkey;
251 const params = new URLSearchParams({ repo: this.did, collection: this.nsid, rkey });
252 const raw = await this.#rpc(`com.atproto.repo.getRecord?${params}`);
253 return this.#coerce(raw);
254 }
255
256 /**
257 * @param {T} record
258 * @returns {Promise<{ uri: AtUri, cid: string }>}
259 */
260 async create(record) {
261 const prepared = await this.#prepare(record);
262 const raw = await this.#rpc("com.atproto.repo.createRecord", {
263 method: "POST",
264 headers: { "Content-Type": "application/json" },
265 body: JSON.stringify({ repo: this.did, collection: this.nsid, record: prepared }),
266 });
267 return { uri: parseAtUri(raw.uri), cid: raw.cid };
268 }
269
270 /**
271 * @param {string} rkey
272 * @param {T} record
273 * @param {string} [swapCid]
274 * @returns {Promise<{ uri: AtUri, cid: string }>}
275 */
276 async put(rkey, record, swapCid) {
277 const prepared = await this.#prepare(record);
278 const raw = await this.#rpc("com.atproto.repo.putRecord", {
279 method: "POST",
280 headers: { "Content-Type": "application/json" },
281 body: JSON.stringify({
282 repo: this.did,
283 collection: this.nsid,
284 rkey,
285 record: prepared,
286 ...(swapCid && { swapCid }),
287 }),
288 });
289 return { uri: parseAtUri(raw.uri), cid: raw.cid };
290 }
291
292 /**
293 * @param {string} rkey
294 * @param {string} [swapCid]
295 * @returns {Promise<void>}
296 */
297 async delete(rkey, swapCid) {
298 await this.#rpc("com.atproto.repo.deleteRecord", {
299 method: "POST",
300 headers: { "Content-Type": "application/json" },
301 body: JSON.stringify({
302 repo: this.did,
303 collection: this.nsid,
304 rkey,
305 ...(swapCid && { swapCid }),
306 }),
307 });
308 }
309}
310
311// --- Collection ---
312
313/**
314 * @template T
315 */
316class Collection {
317 /**
318 * @param {string} nsid
319 * @param {string} defaultActor
320 * @param {typeof fetch} fetchFn
321 * @param {StandardSchema<unknown, T> | null} schema
322 */
323 constructor(nsid, defaultActor, fetchFn, schema) {
324 this.nsid = nsid;
325 this.defaultActor = defaultActor;
326 this.fetchFn = fetchFn;
327 this.schema = schema;
328 }
329
330 /**
331 * @param {string} actor
332 * @returns {Promise<FocusedCollection<T>>}
333 */
334 async of(actor) {
335 let pending = resolutionCache.get(actor);
336 if (!pending) {
337 pending = resolveActor(actor, this.fetchFn);
338 resolutionCache.set(actor, pending);
339 }
340 const { did, pds } = await pending;
341 return new FocusedCollection(did, pds, this.nsid, this.fetchFn, this.schema);
342 }
343
344 /** @returns {Promise<FocusedCollection<T>>} */
345 #self() {
346 return this.of(this.defaultActor);
347 }
348
349 /** @param {T} record */
350 async create(record) {
351 return (await this.#self()).create(record);
352 }
353
354 /** @param {string} rkey @param {T} record @param {string} [swapCid] */
355 async put(rkey, record, swapCid) {
356 return (await this.#self()).put(rkey, record, swapCid);
357 }
358
359 /** @param {string} rkey @param {string} [swapCid] */
360 async delete(rkey, swapCid) {
361 return (await this.#self()).delete(rkey, swapCid);
362 }
363
364 /** @param {string | AtUri} rkeyOrUri */
365 async get(rkeyOrUri) {
366 return (await this.#self()).get(rkeyOrUri);
367 }
368
369 /** @param {ListOptions} [opts] */
370 async list(opts) {
371 return (await this.#self()).list(opts);
372 }
373
374 async *listAll() {
375 yield* (await this.#self()).listAll();
376 }
377}
378
379// --- createRepo ---
380
381/**
382 * @typedef {Object} RepoOptions
383 * @property {string} actor - your DID or handle
384 * @property {typeof fetch} [fetchFn]
385 */
386
387/**
388 * @param {RepoOptions} options
389 */
390export function createRepo({ actor, fetchFn = globalThis.fetch.bind(globalThis) }) {
391 if (!resolutionCache.has(actor)) {
392 resolutionCache.set(actor, resolveActor(actor, fetchFn));
393 }
394
395 /**
396 * @template {StandardSchema<any, any>} S
397 * @param {string} nsid
398 * @param {S} [schema]
399 * @returns {Collection<S extends StandardSchema<any, infer O> ? O : unknown>}
400 */
401 function collection(nsid, schema) {
402 return new Collection(nsid, actor, fetchFn, schema ?? null);
403 }
404
405 return { collection };
406}