// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/tinyrecord
/**
* @template Input
* @template {Input} Output
* @typedef {{ "~standard": StandardSchemaProps }} StandardSchema
*/
/**
* @template Input
* @template Output
* @typedef {Object} StandardSchemaProps
* @property {1} version
* @property {string} vendor
* @property {(value: unknown) => StandardSchemaResult | Promise>} validate
* @property {{ input: Input, output: Output } | undefined} [types]
*/
/**
* @template Output
* @typedef {{ value: Output, issues?: undefined } | { issues: ReadonlyArray<{ message: string }> }} StandardSchemaResult
*/
/**
* @template Output
* @param {StandardSchema} schema
* @param {unknown} value
* @returns {Promise}
*/
async function validate(schema, value) {
const result = await schema["~standard"].validate(value);
if (result.issues) {
const messages = result.issues
.map(/** @param {{ message: string }} i */ (i) => i.message)
.join(", ");
throw Object.assign(new Error(`Validation failed: ${messages}`), {
issues: result.issues,
});
}
return result.value;
}
/**
* @typedef {Object} AtUri
* @property {string} did
* @property {string} collection
* @property {string} rkey
* @property {string} href
*/
/**
* @param {string} uri
* @returns {AtUri}
*/
export function parseAtUri(uri) {
if (!uri.startsWith("at://")) throw new Error(`Invalid AT URI: ${uri}`);
const [did, collection, rkey] = uri.slice("at://".length).split("/");
if (!did || !collection || !rkey) throw new Error(`Invalid AT URI: ${uri}`);
return { did, collection, rkey, href: uri };
}
/**
* @param {string} did
* @param {string} collection
* @param {string} rkey
* @returns {AtUri}
*/
export function atUri(did, collection, rkey) {
return { did, collection, rkey, href: `at://${did}/${collection}/${rkey}` };
}
// --- Resolution ---
/** @type {Map>} */
const resolutionCache = new Map();
/**
* @param {string} handle
* @param {typeof fetch} fetchFn
* @returns {Promise}
*/
async function resolveHandle(handle, fetchFn) {
const url = `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
const res = await fetchFn(url);
if (!res.ok) throw new Error(`Failed to resolve handle: ${handle}`);
const { did } = await res.json();
return did;
}
/**
* @param {string} did
* @param {typeof fetch} fetchFn
* @returns {Promise}
*/
async function resolvePds(did, fetchFn) {
/** @type {string} */
let docUrl;
if (did.startsWith("did:plc:")) {
docUrl = `https://plc.directory/${did}`;
} else if (did.startsWith("did:web:")) {
const host = did.slice("did:web:".length);
docUrl = `https://${host}/.well-known/did.json`;
} else {
throw new Error(`Unsupported DID method: ${did}`);
}
const res = await fetchFn(docUrl);
if (!res.ok) throw new Error(`Failed to fetch DID doc for ${did}`);
const doc = await res.json();
const service = doc.service?.find(/** @param {any} s */ (s) => s.id === "#atproto_pds");
if (!service) throw new Error(`No atproto_pds service in DID doc for ${did}`);
return service.serviceEndpoint;
}
/**
* @param {string} actor
* @param {typeof fetch} fetchFn
* @returns {Promise<{ did: string, pds: string }>}
*/
async function resolveActor(actor, fetchFn) {
const did = actor.startsWith("did:") ? actor : await resolveHandle(actor, fetchFn);
const pds = await resolvePds(did, fetchFn);
return { did, pds };
}
// --- FocusedCollection ---
/**
* @template T
* @typedef {Object} RepoRecord
* @property {AtUri} uri
* @property {string} cid
* @property {T} value
*/
/**
* @template T
* @typedef {Object} ListPage
* @property {RepoRecord[]} records
* @property {string | undefined} cursor
*/
/**
* @typedef {Object} ListOptions
* @property {number} [limit]
* @property {string} [cursor]
*/
/**
* @template T
*/
class FocusedCollection {
/**
* @param {string} did
* @param {string} pds
* @param {string} nsid
* @param {typeof fetch} fetchFn
* @param {StandardSchema | null} schema
*/
constructor(did, pds, nsid, fetchFn, schema) {
this.did = did;
this.pds = pds;
this.nsid = nsid;
this.fetchFn = fetchFn;
this.schema = schema;
}
/**
* @param {string} path
* @param {RequestInit} [init]
*/
async #rpc(path, init) {
const res = await this.fetchFn(`${this.pds}/xrpc/${path}`, init);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw Object.assign(new Error(err.message ?? res.statusText), {
status: res.status,
error: err.error,
});
}
return res.json();
}
/**
* @param {unknown} value
* @returns {Promise}
*/
async #validate(value) {
return this.schema ? validate(this.schema, value) : /** @type {T} */ (value);
}
/**
* @param {{ uri: string, cid: string, value: unknown }} raw
* @returns {Promise>}
*/
async #coerce(raw) {
const value = await this.#validate(raw.value);
return { uri: parseAtUri(raw.uri), cid: raw.cid, value };
}
/**
* @param {T} record
* @returns {Promise}
*/
async #prepare(record) {
return this.#validate(record);
}
/**
* @param {ListOptions} [opts]
* @returns {Promise>}
*/
async list({ limit, cursor } = {}) {
const params = new URLSearchParams({ repo: this.did, collection: this.nsid });
if (limit != null) params.set("limit", String(limit));
if (cursor != null) params.set("cursor", cursor);
const page = await this.#rpc(`com.atproto.repo.listRecords?${params}`);
const records = await Promise.all(
page.records.map(
/** @param {{ uri: string, cid: string, value: unknown }} r */ (r) => this.#coerce(r),
),
);
return { records, cursor: page.cursor };
}
/** @returns {AsyncGenerator>} */
async *listAll() {
let cursor;
do {
/** @type {ListPage} */
const page = await this.list({ limit: 100, cursor });
yield* page.records;
cursor = page.cursor;
} while (cursor);
}
/**
* @param {string | AtUri} rkeyOrUri
* @returns {Promise>}
*/
async get(rkeyOrUri) {
const rkey =
typeof rkeyOrUri === "string" && !rkeyOrUri.startsWith("at://")
? rkeyOrUri
: parseAtUri(typeof rkeyOrUri === "string" ? rkeyOrUri : rkeyOrUri.href).rkey;
const params = new URLSearchParams({ repo: this.did, collection: this.nsid, rkey });
const raw = await this.#rpc(`com.atproto.repo.getRecord?${params}`);
return this.#coerce(raw);
}
/**
* @param {T} record
* @returns {Promise<{ uri: AtUri, cid: string }>}
*/
async create(record) {
const prepared = await this.#prepare(record);
const raw = await this.#rpc("com.atproto.repo.createRecord", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repo: this.did, collection: this.nsid, record: prepared }),
});
return { uri: parseAtUri(raw.uri), cid: raw.cid };
}
/**
* @param {string} rkey
* @param {T} record
* @param {string} [swapCid]
* @returns {Promise<{ uri: AtUri, cid: string }>}
*/
async put(rkey, record, swapCid) {
const prepared = await this.#prepare(record);
const raw = await this.#rpc("com.atproto.repo.putRecord", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repo: this.did,
collection: this.nsid,
rkey,
record: prepared,
...(swapCid && { swapCid }),
}),
});
return { uri: parseAtUri(raw.uri), cid: raw.cid };
}
/**
* @param {string} rkey
* @param {string} [swapCid]
* @returns {Promise}
*/
async delete(rkey, swapCid) {
await this.#rpc("com.atproto.repo.deleteRecord", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repo: this.did,
collection: this.nsid,
rkey,
...(swapCid && { swapCid }),
}),
});
}
}
// --- Collection ---
/**
* @template T
*/
class Collection {
/**
* @param {string} nsid
* @param {string} defaultActor
* @param {typeof fetch} fetchFn
* @param {StandardSchema | null} schema
*/
constructor(nsid, defaultActor, fetchFn, schema) {
this.nsid = nsid;
this.defaultActor = defaultActor;
this.fetchFn = fetchFn;
this.schema = schema;
}
/**
* @param {string} actor
* @returns {Promise>}
*/
async of(actor) {
let pending = resolutionCache.get(actor);
if (!pending) {
pending = resolveActor(actor, this.fetchFn);
resolutionCache.set(actor, pending);
}
const { did, pds } = await pending;
return new FocusedCollection(did, pds, this.nsid, this.fetchFn, this.schema);
}
/** @returns {Promise>} */
#self() {
return this.of(this.defaultActor);
}
/** @param {T} record */
async create(record) {
return (await this.#self()).create(record);
}
/** @param {string} rkey @param {T} record @param {string} [swapCid] */
async put(rkey, record, swapCid) {
return (await this.#self()).put(rkey, record, swapCid);
}
/** @param {string} rkey @param {string} [swapCid] */
async delete(rkey, swapCid) {
return (await this.#self()).delete(rkey, swapCid);
}
/** @param {string | AtUri} rkeyOrUri */
async get(rkeyOrUri) {
return (await this.#self()).get(rkeyOrUri);
}
/** @param {ListOptions} [opts] */
async list(opts) {
return (await this.#self()).list(opts);
}
async *listAll() {
yield* (await this.#self()).listAll();
}
}
// --- Repo ---
/**
* @typedef {Object} RepoOptions
* @property {string} actor - your DID or handle
* @property {typeof fetch} [fetchFn]
*/
export class Repo {
/**
* @param {RepoOptions} options
*/
constructor({ actor, fetchFn = globalThis.fetch.bind(globalThis) }) {
this.actor = actor;
this.fetchFn = fetchFn;
if (!resolutionCache.has(actor)) {
resolutionCache.set(actor, resolveActor(actor, fetchFn));
}
}
/**
* @template {StandardSchema} S
* @param {string} nsid
* @param {S} [schema]
* @returns {Collection ? O : unknown>}
*/
collection(nsid, schema) {
return new Collection(nsid, this.actor, this.fetchFn, schema ?? null);
}
}