// @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>} 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} */ 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; }, }; }