~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
249 lines · 7.2 KB Raw History
/**
 * The historical half of the appview: a first-boot walk of the records the
 * live tail will never replay on its own.
 *
 * The relay's `com.atproto.sync.listReposByCollection` names every account
 * holding a watched collection. Each account's PDS is read from its DID
 * document, and each collection is listed with the PDS's own repo API, one
 * page at a time, into the store. A DID that does not answer costs its
 * records, not the walk.
 *
 * The pass resumes: the pagination cursor of each collection is stored in
 * the same table the live cursor uses, under its own source name.
 */

/** How many accounts one collection listing answers per page. */
const LIST_PAGE = 1000;

/** How many records one account's collection answers per page. */
const RECORDS_PAGE = 100;

/** The most records read from one account for one collection. */
const MAX_RECORDS_PER_DID = 1000;

/** How many accounts read at once. */
const CONCURRENCY = 8;

/**
 * What carries a request; the global fetch unless one is injected.
 * @typedef {(input: string|URL, init?: RequestInit) => Promise<Response>} FetchLike
 */

/**
 * @typedef {Object} BackfillOptions
 * @property {import('./store.js').AppViewStore} store
 * @property {string[]} collections
 * @property {string} [listService] - the relay serving listReposByCollection
 * @property {string} [plcUrl] - the directory did:plc documents resolve from
 * @property {FetchLike} [fetch]
 * @property {AbortSignal} [signal]
 * @property {(message: string) => void} [onNotice]
 */

/**
 * @param {BackfillOptions} options
 * @returns {Promise<void>} resolves when every collection has been walked
 */
export async function backfill(options) {
  const { store, collections, signal } = options;
  const send =
    options.fetch ??
    /** @type {FetchLike} */ ((url, init) => globalThis.fetch(url, init));
  const onNotice = options.onNotice ?? (() => {});
  const listService = (options.listService ?? 'https://bsky.network').replace(
    /\/+$/,
    '',
  );
  const plcUrl = (options.plcUrl ?? 'https://plc.directory').replace(
    /\/+$/,
    '',
  );

  for (const collection of collections) {
    if (signal?.aborted) return;
    const source = `backfill:${collection}`;
    let cursor = store.textCursor(source) ?? '';
    let stopped = false;
    while (!stopped) {
      if (signal?.aborted) return;
      const params = new URLSearchParams({
        collection,
        limit: String(LIST_PAGE),
      });
      if (cursor) params.set('cursor', cursor);
      let body;
      try {
        const res = await send(
          `${listService}/xrpc/com.atproto.sync.listReposByCollection?${params}`,
          { signal },
        );
        if (!res.ok) {
          onNotice(`backfill ${collection}: list answered ${res.status}`);
          return;
        }
        body = await res.json();
      } catch (err) {
        if (signal?.aborted) return;
        onNotice(
          `backfill ${collection}: ${err instanceof Error ? err.message : err}`,
        );
        return;
      }
      const repos = /** @type {{repos?: Array<{did: string}>}} */ (body);
      const dids = (repos.repos ?? [])
        .map((row) => row?.did)
        .filter((did) => typeof did === 'string');
      await pool(dids, CONCURRENCY, async (did) => {
        await indexAccount({
          store,
          did,
          collection,
          plcUrl,
          fetch: send,
          signal,
          onNotice,
        });
      });
      const next =
        typeof (/** @type {{cursor?: unknown}} */ (body).cursor) === 'string'
          ? /** @type {{cursor: string}} */ (body).cursor
          : '';
      cursor = next;
      if (next) store.setTextCursor(source, next);
      if (!next) stopped = true;
    }
    onNotice(`backfill ${collection}: done`);
  }
}

/**
 * Every record of one collection one account holds, into the store. The
 * account's own handle rides along from its DID document, which the PDS
 * resolution already fetched.
 * @param {Object} ctx
 * @param {import('./store.js').AppViewStore} ctx.store
 * @param {string} ctx.did
 * @param {string} ctx.collection
 * @param {string} ctx.plcUrl
 * @param {FetchLike} ctx.fetch
 * @param {AbortSignal|undefined} ctx.signal
 * @param {(message: string) => void} ctx.onNotice
 */
async function indexAccount(ctx) {
  const { store, did, collection, fetch, signal } = ctx;
  const doc = await readDidDocument(did, ctx.plcUrl, fetch, signal);
  if (!doc) return;
  const pds = pdsOf(doc);
  const handle = handleOf(doc);
  if (handle) store.setIdentity(did, handle);

  let cursor = '';
  for (let page = 0; page * RECORDS_PAGE < MAX_RECORDS_PER_DID; page++) {
    if (signal?.aborted) return;
    const params = new URLSearchParams({
      repo: did,
      collection,
      limit: String(RECORDS_PAGE),
    });
    if (cursor) params.set('cursor', cursor);
    let body;
    try {
      const res = await fetch(
        `${pds}/xrpc/com.atproto.repo.listRecords?${params}`,
        { signal },
      );
      if (!res.ok) return;
      body = await res.json();
    } catch {
      return;
    }
    const rows =
      /** @type {{records?: Array<{uri: string, cid: string, value: Record<string, *>}>, cursor?: string}} */ (
        body
      );
    for (const row of rows.records ?? []) {
      if (
        typeof row?.uri !== 'string' ||
        typeof row?.cid !== 'string' ||
        !row?.value ||
        typeof row.value !== 'object'
      ) {
        continue;
      }
      const rkey = row.uri.split('/').pop() ?? '';
      store.putRecord({
        did,
        collection,
        rkey,
        cid: row.cid,
        uri: row.uri,
        seq: 0,
        value: row.value,
      });
    }
    cursor = typeof rows.cursor === 'string' ? rows.cursor : '';
    if (!cursor) return;
  }
}

/**
 * @param {string} did
 * @param {string} plcUrl
 * @param {FetchLike} fetch
 * @param {AbortSignal|undefined} signal
 * @returns {Promise<any|null>} the DID document, or null when unreadable
 */
async function readDidDocument(did, plcUrl, fetch, signal) {
  /** @type {string} */
  let url;
  if (did.startsWith('did:plc:')) {
    url = `${plcUrl}/${did}/data`;
  } else if (did.startsWith('did:web:')) {
    const host = did.slice('did:web:'.length).replaceAll(':', '/');
    url = `https://${host}/.well-known/did.json`;
  } else {
    return null;
  }
  try {
    const res = await fetch(url, { signal });
    if (!res.ok) return null;
    return await res.json();
  } catch {
    return null;
  }
}

/**
 * @param {any} doc
 * @returns {string|null}
 */
function pdsOf(doc) {
  const service = (doc?.service ?? []).find(
    (/** @type {{id: string}} */ entry) => entry.id.endsWith('#atproto_pds'),
  );
  return service?.serviceEndpoint ? String(service.serviceEndpoint) : null;
}

/**
 * @param {any} doc
 * @returns {string|null}
 */
function handleOf(doc) {
  const aka = (doc?.alsoKnownAs ?? []).find((/** @type {string} */ name) =>
    name.startsWith('at://'),
  );
  return aka ? aka.slice('at://'.length) : null;
}

/**
 * @template T
 * @param {T[]} items
 * @param {number} size
 * @param {(item: T) => Promise<void>} work
 * @returns {Promise<void>}
 */
async function pool(items, size, work) {
  for (let start = 0; start < items.length; start += size) {
    await Promise.all(items.slice(start, start + size).map(work));
  }
}