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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
242 lines · 7.7 KB Raw History
// @pdsjs/sites - materialized queries over the repo
//
// A dev.pdsjs.query.def record names a live view over one collection;
// GET /xrpc/dev.pdsjs.query.run serves its rows as JSON in one request. The
// engine keeps a decoded copy of each queried collection in memory: loaded
// from storage on first use, maintained from the core commit tap, rebuilt
// by replay after a restart. Writes that bypass createCommit (a CAR import)
// are picked up on the restart that follows them.
//
// Queries read the public repo only. Space records live in their own
// storage and never appear here.

import { cborDecode, toDisplayValue } from '@pdsjs/core/repo';
import { QUERY_COLLECTION } from './lexicons.js';

const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 2000;
const LOAD_PAGE = 100;

/**
 * Read a dot-path off a decoded record.
 * @param {unknown} value
 * @param {string} path
 * @returns {unknown}
 */
export function fieldAt(value, path) {
  let current = value;
  for (const segment of path.split('.')) {
    if (current === null || typeof current !== 'object') return undefined;
    current = /** @type {Record<string, unknown>} */ (current)[segment];
  }
  return current;
}

/**
 * @typedef {Object} QueryEngineContext
 * @property {import('@pdsjs/core/ports').ActorStoragePort} actorStorage
 * @property {() => Promise<string|null>} getDid
 */

/**
 * @typedef {Object} CachedRecord
 * @property {string} uri
 * @property {string} rkey
 * @property {string} cid
 * @property {unknown} value - decoded record, JSON-shaped
 */

/**
 * Build the query engine: a route table fragment for core's queryRoutes and
 * an onCommit subscriber that keeps the caches current.
 *
 * @param {QueryEngineContext} ctx
 * @returns {{
 *   routes: Record<string, {method?: string, handler: (request: Request, url: URL) => Promise<Response>}>,
 *   onCommit: import('@pdsjs/core/ports').CommitTapPort,
 * }}
 */
export function createQueryEngine(ctx) {
  const { actorStorage, getDid } = ctx;
  if (!actorStorage) throw new Error('createQueryEngine requires actorStorage');
  if (!getDid) throw new Error('createQueryEngine requires getDid');

  /** @type {Map<string, Map<string, CachedRecord>>} */
  const collections = new Map();
  /** Loads in flight, so concurrent first requests replay a collection once. */
  /** @type {Map<string, Promise<Map<string, CachedRecord>>>} */
  const loading = new Map();

  /**
   * @param {string} uri
   * @param {string} cid
   * @param {Uint8Array|ArrayBuffer} value
   * @returns {CachedRecord}
   */
  function decodeRow(uri, cid, value) {
    const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
    return {
      uri,
      rkey: uri.split('/').pop() || '',
      cid,
      value: toDisplayValue(cborDecode(bytes)),
    };
  }

  /**
   * The decoded rows of a collection, replayed from storage on first use.
   * @param {string} collection
   * @returns {Promise<Map<string, CachedRecord>>}
   */
  async function loadCollection(collection) {
    const cached = collections.get(collection);
    if (cached) return cached;
    const inFlight = loading.get(collection);
    if (inFlight) return inFlight;

    const load = (async () => {
      /** @type {Map<string, CachedRecord>} */
      const rows = new Map();
      let cursor = /** @type {string|null} */ (null);
      do {
        const page = await actorStorage.listRecords(
          collection,
          cursor,
          LOAD_PAGE,
        );
        for (const record of page.records) {
          rows.set(record.uri, decodeRow(record.uri, record.cid, record.value));
        }
        cursor = page.records.length ? page.cursor : null;
      } while (cursor);
      collections.set(collection, rows);
      return rows;
    })();
    loading.set(collection, load);
    try {
      return await load;
    } finally {
      loading.delete(collection);
    }
  }

  /** @type {import('@pdsjs/core/ports').CommitTapPort} */
  async function onCommit(evt) {
    for (const op of evt.ops) {
      const [collection] = op.uri.replace(`at://${evt.did}/`, '').split('/');
      // A collection nobody has queried yet has no cache to maintain; the
      // first query replays it from storage, this write included.
      const rows = collections.get(collection);
      if (!rows) continue;
      if (op.action === 'delete') {
        rows.delete(op.uri);
      } else {
        const stored = await actorStorage.getRecord(op.uri);
        if (stored) {
          rows.set(op.uri, decodeRow(op.uri, stored.cid, stored.value));
        }
      }
    }
  }

  /**
   * @param {string} error
   * @param {string} message
   * @param {number} status
   */
  const errorResponse = (error, message, status) =>
    Response.json({ error, message }, { status });

  /**
   * GET /xrpc/dev.pdsjs.query.run?query=<name>&limit&cursor
   * @param {Request} _request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleQueryRun(_request, url) {
    const name = url.searchParams.get('query');
    if (!name) {
      return errorResponse(
        'InvalidRequest',
        'Missing required parameter: query',
        400,
      );
    }

    const did = await getDid();
    if (!did) {
      return errorResponse('QueryNotFound', 'No such query.', 404);
    }

    const defs = await loadCollection(QUERY_COLLECTION);
    const def = defs.get(`at://${did}/${QUERY_COLLECTION}/${name}`);
    if (!def) {
      return errorResponse('QueryNotFound', 'No such query.', 404);
    }
    const definition =
      /** @type {{collection?: unknown, where?: unknown, sort?: {field?: unknown, direction?: unknown}}} */ (
        def.value
      );
    if (typeof definition.collection !== 'string') {
      return errorResponse(
        'InvalidQuery',
        'The query definition names no collection.',
        400,
      );
    }

    const limit = Math.min(
      MAX_LIMIT,
      Math.max(1, Number(url.searchParams.get('limit')) || DEFAULT_LIMIT),
    );
    const offset = Math.max(0, Number(url.searchParams.get('cursor')) || 0);

    let rows = [...(await loadCollection(definition.collection)).values()];

    if (definition.where && typeof definition.where === 'object') {
      const conditions = Object.entries(definition.where);
      rows = rows.filter((row) =>
        conditions.every(
          ([path, expected]) => fieldAt(row.value, path) === expected,
        ),
      );
    }

    const sortField =
      typeof definition.sort?.field === 'string' ? definition.sort.field : null;
    const descending = definition.sort?.direction !== 'asc';
    rows.sort((a, b) => {
      if (sortField) {
        const left = fieldAt(a.value, sortField);
        const right = fieldAt(b.value, sortField);
        // Missing values sort last in either direction
        if (left === undefined && right !== undefined) return 1;
        if (right === undefined && left !== undefined) return -1;
        if (left !== right) {
          const cmp =
            /** @type {any} */ (left) < /** @type {any} */ (right) ? -1 : 1;
          return descending ? -cmp : cmp;
        }
      }
      // Record keys are TIDs in every collection queries target, so the
      // tiebreak (and the default order) is creation order, newest first.
      const cmp = a.rkey < b.rkey ? -1 : a.rkey > b.rkey ? 1 : 0;
      return sortField && !descending ? cmp : -cmp;
    });

    const page = rows.slice(offset, offset + limit);
    /** @type {{records: Array<{uri: string, cid: string, value: unknown}>, cursor?: string}} */
    const body = {
      records: page.map(({ uri, cid, value }) => ({ uri, cid, value })),
    };
    if (offset + limit < rows.length) body.cursor = String(offset + limit);
    return Response.json(body);
  }

  return {
    routes: {
      '/xrpc/dev.pdsjs.query.run': { method: 'GET', handler: handleQueryRun },
    },
    onCommit,
  };
}