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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
125 lines · 4.7 KB Raw History
// @pdsjs/core/space-browser - Account-page view of permissioned-data spaces
//
// The counting a space storage does not do itself. Core stays free of any
// @pdsjs/spaces import: a platform package hands the storage in, and what
// comes back is the plain data the account page renders.

import { cborDecode, cborDecodeJson, tidToMs } from './repo.js';

/**
 * @typedef {Object} SpaceStorageLike
 * @property {(options: {type?: string|null, did?: string|null, cursor?: string|null, limit?: number}) => Promise<{spaces: import('./ports.js').SpaceRow[], cursor: string|null}>} listSpaces
 * @property {(space: string, cursor: string|null, limit: number) => Promise<{members: import('./ports.js').SpaceMemberRow[], cursor: string|null}>} listMembers
 * @property {(space: string) => Promise<Array<{collection: string, rkey: string, cid: string}>>} listAllSpaceRecords
 * @property {(space: string, opts: {collection?: string|null, cursor?: string|null, limit: number, reverse?: boolean, excludeValues?: boolean}) => Promise<{records: Array<{rkey: string, cid: string, value?: Uint8Array, indexedAt: string}>, cursor: string|null}>} listSpaceRecords
 */

// A personal server holds few spaces; this caps a runaway listing rather
// than paging an account page that would be unreadable long before it.
const SPACE_LIMIT = 100;
const MEMBER_LIMIT = 500;
// Paged rather than capped: the reap guard has to see every record, since a
// blob it misses is a blob that gets deleted.
const RECORD_PAGE = 500;

/**
 * Build the read-only space view the account page consumes.
 * @param {SpaceStorageLike} spaceStorage
 * @returns {import('./ports.js').SpaceBrowserPort}
 */
export function createSpaceBrowser(spaceStorage) {
  return {
    async listSpaces() {
      const { spaces } = await spaceStorage.listSpaces({ limit: SPACE_LIMIT });
      return spaces;
    },

    // The write time comes from the rkey's TID, the same way the repo's own
    // collections report one, so a record with a non-TID rkey counts toward
    // the total and carries no time.
    async countRecords(uri) {
      /** @type {Map<string, {count: number, lastRecordAt: number|null}>} */
      const counts = new Map();
      for (const record of await spaceStorage.listAllSpaceRecords(uri)) {
        const entry = counts.get(record.collection) || {
          count: 0,
          lastRecordAt: null,
        };
        entry.count += 1;
        const at = tidToMs(record.rkey);
        if (at !== null && at > (entry.lastRecordAt || 0)) {
          entry.lastRecordAt = at;
        }
        counts.set(record.collection, entry);
      }
      return [...counts]
        .map(([collection, entry]) => ({ collection, ...entry }))
        .sort((a, b) => a.collection.localeCompare(b.collection));
    },

    async listRecords(uri, collection, limit) {
      // One over the limit, so the page can say there are more without
      // paging through a collection it is not going to show in full.
      const { records } = await spaceStorage.listSpaceRecords(uri, {
        collection,
        limit: limit + 1,
        reverse: true,
      });
      return {
        records: records.slice(0, limit).map((record) => ({
          rkey: record.rkey,
          cid: record.cid,
          indexedAt: record.indexedAt,
          value: cborDecodeJson(/** @type {Uint8Array} */ (record.value)),
        })),
        truncated: records.length > limit,
      };
    },

    // Feeds the blob reap guard, which reads every record's content to find
    // out what it references. Values are decoded as stored, which
    // findBlobRefs reads directly.
    async listAllRecords() {
      const { spaces } = await spaceStorage.listSpaces({ limit: SPACE_LIMIT });
      /** @type {Array<{space: string, collection: string, rkey: string, value: unknown}>} */
      const all = [];
      for (const space of spaces) {
        let cursor = null;
        do {
          const page = await spaceStorage.listSpaceRecords(space.uri, {
            cursor,
            limit: RECORD_PAGE,
          });
          for (const record of page.records) {
            all.push({
              space: space.uri,
              collection: /** @type {any} */ (record).collection,
              rkey: record.rkey,
              value: cborDecode(/** @type {Uint8Array} */ (record.value)),
            });
          }
          cursor = page.cursor;
        } while (cursor);
      }
      return all;
    },

    async countMembers(uri) {
      const { members } = await spaceStorage.listMembers(
        uri,
        null,
        MEMBER_LIMIT,
      );
      return members.length;
    },

    async listMembers(uri) {
      const { members } = await spaceStorage.listMembers(
        uri,
        null,
        MEMBER_LIMIT,
      );
      return members;
    },
  };
}