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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
182 lines · 6.4 KB Raw History
/**
 * The live half of the appview: jetstream events folded into the store.
 *
 * Every write is idempotent, because the stream is at-least-once: a record
 * upserts by uri, a delete removes, and replaying an event already indexed
 * changes nothing. The cursor advances on a timer rather than per event, so
 * a quiet network still moves the checkpoint forward.
 *
 * A `replay` option hands the history to the archive: on a first boot the
 * archive is replayed from the configured floor and the tail resumes at the
 * sealed tip; on a restart whose stored cursor the server refuses, the gap
 * is replayed before the tail tries again. Archive and live share one
 * cursor, the last folded sequence.
 */

import { replayArchive } from './archive.js';
import { subscribeJetstream } from './jetstream.js';

/** How often the checkpoint is written while events flow. */
const CHECKPOINT_MS = 5_000;

/**
 * @typedef {Object} WatchOptions
 * @property {import('./store.js').AppViewStore} store
 * @property {string} service - jetstream base URL
 * @property {string[]} collections - the collections to index
 * @property {string[]} [dids] - only these accounts
 * @property {number} [cursor] - resume position; the store's is used when absent
 * @property {AbortSignal} [signal]
 * @property {(message: string) => void} [onNotice]
 * @property {(event: import('./store.js').RecordWrite|import('./store.js').RecordDelete|{kind: 'identity', did: string, handle: string, seq: number}|{kind: 'account', did: string, status: string|undefined, seq: number}) => void} [onEvent]
 *   what the fold did, for metrics and tests
 * @property {(seq: number) => void} [onSeq] - every frame, for tests and
 *   callers that decide for themselves when they have seen enough
 * @property {ReplayOptions} [replay] - the archive to catch up from, when
 *   the caller wants history the live lookback window cannot serve
 */

/**
 * @typedef {Object} ReplayOptions
 * @property {string} [service] - the archive's Jetstream base URL; the
 *   live service is used when absent
 * @property {string} [apiKey] - the archive's bearer token, when metered
 * @property {number} [afterSeq] - the first-boot floor; 0 replays everything
 * @property {(bytes: Uint8Array) => Promise<Uint8Array>} decompress - what
 *   unpacks the archive's zstd block frames
 */

/**
 * Fold one event into the store: a write applies, a delete removes, an
 * identity updates the handle, a deleted account takes its records with it.
 * @param {import('./store.js').AppViewStore} store
 * @param {import('./store.js').RecordWrite|import('./store.js').RecordDelete|{kind: 'identity', did: string, handle: string, seq: number}|{kind: 'account', did: string, status: string|undefined, seq: number}} event
 */
export function foldEvent(store, event) {
  if ('deleted' in event) {
    store.deleteRecord(event.uri);
  } else if ('kind' in event) {
    if (event.kind === 'identity') {
      store.setIdentity(event.did, event.handle);
    } else if (event.status === 'deleted') {
      store.clearAccount(event.did);
    }
  } else {
    store.putRecord(event);
  }
}

/**
 * Watch the network until the signal aborts, folding every record write,
 * handle change and account deletion into the store. Resolves on abort.
 * @param {WatchOptions} options
 * @returns {Promise<void>}
 */
export async function watch(options) {
  const { store, signal, onNotice = () => {} } = options;
  const fold = (/** @type {Parameters<typeof foldEvent>[1]} */ event) => {
    foldEvent(store, event);
    options.onEvent?.(event);
  };

  /** The last folded sequence, persisted on a timer. @type {number|undefined} */
  let written;
  /** The highest sequence seen since the last write. @type {number|undefined} */
  let pending;
  /** @param {number} seq */
  const checkpoint = (seq) => {
    pending = seq;
    options.onSeq?.(seq);
  };
  const timer = setInterval(() => {
    if (pending !== undefined && pending !== written) {
      store.setCursor('jetstream', pending);
      written = pending;
      pending = undefined;
    }
  }, CHECKPOINT_MS);
  if (typeof timer === 'object' && timer && 'unref' in timer) {
    /** @type {{unref: () => void}} */ (timer).unref();
  }

  try {
    if (options.replay) {
      await watchWithReplay(options, fold, checkpoint);
    } else {
      await subscribeJetstream({
        service: options.service,
        collections: options.collections,
        dids: options.dids,
        cursor: options.cursor ?? store.cursor('jetstream'),
        signal,
        onNotice,
        onEvent: fold,
        onSeq: checkpoint,
      });
    }
  } finally {
    clearInterval(timer);
    if (pending !== undefined) store.setCursor('jetstream', pending);
  }
}

/**
 * Catch up from the archive, then tail live. The archive is the history
 * the live lookback window cannot serve; a cursor the server refuses sends
 * the tail back to it.
 * @param {WatchOptions} options
 * @param {(event: any) => void} fold
 * @param {(seq: number) => void} checkpoint
 * @returns {Promise<void>}
 */
async function watchWithReplay(options, fold, checkpoint) {
  const { store, signal, onNotice = () => {} } = options;
  const replay = /** @type {ReplayOptions} */ (options.replay);
  const service = replay.service ?? options.service;

  /**
   * Replay the archive from one sequence and fold the gap. The sealed tip
   * returned is the resume point the tail starts at.
   * @param {number} from
   * @returns {Promise<number>}
   */
  const gap = async (from) => {
    onNotice(`replaying the archive from ${from}`);
    return replayArchive({
      service,
      collections: options.collections,
      dids: options.dids,
      apiKey: replay.apiKey,
      decompress: replay.decompress,
      afterSeq: from,
      signal,
      onNotice,
      onEvent: fold,
      onSeq: checkpoint,
    });
  };

  let cursor = options.cursor ?? store.cursor('jetstream');
  if (cursor === undefined) {
    cursor = await gap(replay.afterSeq ?? 0);
    store.setCursor('jetstream', cursor);
  }

  await subscribeJetstream({
    service: options.service,
    collections: options.collections,
    dids: options.dids,
    cursor,
    signal,
    onNotice,
    onEvent: fold,
    onSeq: checkpoint,
    // A refused cursor replays the gap from the refused sequence, then
    // hands the tail the new tip to resume from.
    onCursorTooOld: async (refused) => {
      const tip = await gap(refused);
      store.setCursor('jetstream', tip);
      return tip;
    },
  });
}