import type { Did, ResourceUri } from '@atcute/lexicons/syntax'; import { appviewJson, listAllAppviewRecords, type AppviewParamValue, type AppviewSearchResponse, type AppviewStatefulListResponse, type AppviewStatefulRecordView, } from './appview'; import { getNearestAppviewCursorCheckpoint, rememberAppviewCursorCheckpoint, } from './appview-cache'; import { appviewRecordToHydrated } from './hydration'; import { type HydratedRecord, type PaginatedResult, maxTimestamp, timestampFromDate, timestampFromTid, toNumberMap, } from './records'; import { getTangledAppviewService } from '../settings'; import { normalizeServiceUrl } from './client'; export type StatefulHydratedRecord = HydratedRecord & { number: number; state: State; }; export type StatefulPageOptions = { offset: number; limit: number; state: State; author?: Did; query?: string; }; const APPVIEW_PAGE_BATCH_LIMIT = 100; const SEARCH_PAGE_BATCH_LIMIT = 100; export const getAppviewRecordSortTimestamp = ( record: HydratedRecord, ): number => maxTimestamp( timestampFromDate(record.value.createdAt), timestampFromTid(record.rkey), ); export const sortStatefulRecordsByNewestRecord = ( items: Array>, ): Array> => [...items].sort((left, right) => { const timestampDiff = getAppviewRecordSortTimestamp(right) - getAppviewRecordSortTimestamp(left); if (timestampDiff !== 0) { return timestampDiff; } if (left.number !== right.number) { return right.number - left.number; } return right.rkey.localeCompare(left.rkey); }); export const sortStatefulRecordsByUpdatedAt = async ( items: Array>, getUpdatedAt: (item: StatefulHydratedRecord) => Promise, ): Promise>> => { const decorated = await Promise.all( items.map(async (item) => ({ item, updatedAt: await getUpdatedAt(item), })), ); decorated.sort((left, right) => { const updatedDiff = right.updatedAt - left.updatedAt; if (updatedDiff !== 0) { return updatedDiff; } if (left.item.number !== right.item.number) { return right.item.number - left.item.number; } return right.item.rkey.localeCompare(left.item.rkey); }); return decorated.map(({ item }) => item); }; export const statefulRecordMatchesQuery = (value: unknown, query?: string): boolean => { const normalized = query?.trim().toLowerCase(); if (!normalized) { return true; } const record = value as { title?: unknown; body?: unknown }; return [record.title, record.body] .filter((part): part is string => typeof part === 'string') .some((part) => part.toLowerCase().includes(normalized)); }; export const paginateStatefulRecords = ( items: Array>, options: StatefulPageOptions, ): PaginatedResult> => { const offset = Math.max(options.offset, 0); const limit = Math.max(options.limit, 1); const matching = items.filter((item) => item.state === options.state && (!options.author || item.author.did === options.author) && statefulRecordMatchesQuery(item.value, options.query), ); return { items: matching.slice(offset, offset + limit), totalCount: matching.length, hasNext: offset + limit < matching.length, }; }; const appviewCursorCacheKey = ( nsid: string, subject: string, state: string, extraParams: Record = {}, ): string => { const extras = Object.entries(extraParams) .filter(([, value]) => value !== undefined && value !== null) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, value]) => `${key}=${String(value)}`) .join('&'); return [ normalizeServiceUrl(getTangledAppviewService()), nsid, subject, state, extras, ].join('|'); }; export const listAppviewStatefulRecordsPage = async ( nsid: string, subject: string, options: StatefulPageOptions, normalizeState: (value?: string) => State, extraParams: Record = {}, getUpdatedAt?: ( record: StatefulHydratedRecord, appviewRecord: AppviewStatefulRecordView, ) => Promise, ): Promise>> => { if (getUpdatedAt) { const records = await listAllAppviewRecords(nsid, subject, extraParams) as Array>; const hydrated = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); const numbers = toNumberMap(hydrated); const decorated = await Promise.all( hydrated.map(async (record, index) => { const item: StatefulHydratedRecord = { ...record, number: numbers.get(record.uri) ?? index + 1, state: normalizeState(records[index].state), }; return { item, updatedAt: await getUpdatedAt(item, records[index]), }; }), ); decorated.sort((left, right) => { const updatedDiff = right.updatedAt - left.updatedAt; if (updatedDiff !== 0) { return updatedDiff; } if (left.item.number !== right.item.number) { return right.item.number - left.item.number; } return right.item.rkey.localeCompare(left.item.rkey); }); return paginateStatefulRecords( decorated.map(({ item }) => item), options, ); } // Bobbin exposes cursor pagination, while the UI still uses offsets. Cache // page-boundary cursors so sequential pages can resume from the prior page. const offset = Math.max(options.offset, 0); const limit = Math.max(options.limit, 1); const requestedEnd = offset + limit; const cacheKey = appviewCursorCacheKey(nsid, subject, options.state, extraParams); const start = getNearestAppviewCursorCheckpoint(cacheKey, offset); const pendingItems: Array<{ record: AppviewStatefulRecordView; number: number; state: State; }> = []; if (start.exhausted) { return { items: [], totalCount: start.matchingSeen, hasNext: false, }; } let cursor = start.cursor; let matchingSeen = start.matchingSeen; let scannedSeen = start.scannedSeen; while (true) { const pageLimit = Math.min( APPVIEW_PAGE_BATCH_LIMIT, Math.max(matchingSeen < requestedEnd ? requestedEnd - matchingSeen : 1, 1), ); const page = await appviewJson>(nsid, { subject, limit: pageLimit, cursor, ...extraParams, }); for (const record of page.items) { const state = normalizeState(record.state); const number = scannedSeen + 1; scannedSeen += 1; if (state !== options.state) { continue; } if (!statefulRecordMatchesQuery(record.value, options.query)) { continue; } if (matchingSeen >= offset && matchingSeen < requestedEnd) { pendingItems.push({ record, number, state }); } matchingSeen += 1; } cursor = page.cursor ?? undefined; const exhausted = !cursor; rememberAppviewCursorCheckpoint(cacheKey, { matchingSeen, scannedSeen, cursor, exhausted, }); if (matchingSeen > requestedEnd) { const visibleItems = pendingItems.slice(0, limit); const hydrated = await Promise.all( visibleItems.map(({ record }) => appviewRecordToHydrated(record)), ); return { items: hydrated.map((item, index) => ({ ...item, number: visibleItems[index].number, state: visibleItems[index].state, })), hasNext: true, }; } if (exhausted || page.items.length === 0) { const hydrated = await Promise.all( pendingItems.map(({ record }) => appviewRecordToHydrated(record)), ); return { items: hydrated.map((item, index) => ({ ...item, number: pendingItems[index].number, state: pendingItems[index].state, })), totalCount: matchingSeen, hasNext: false, }; } } }; export const listSearchedStatefulRecordsPage = async ( nsid: string, repo: { repoDid: Did }, options: StatefulPageOptions, getState: (uri: ResourceUri) => Promise, ): Promise>> => { const query = options.query?.trim(); if (!query) { return { items: [], totalCount: 0, hasNext: false }; } const offset = Math.max(options.offset, 0); const limit = Math.max(options.limit, 1); const requestedEnd = offset + limit; const items: Array> = []; let cursor: string | undefined; let matchingSeen = 0; while (true) { const page = await appviewJson>('sh.tangled.search.query', { q: query, nsid, author: options.author, repo: repo.repoDid, cursor, limit: SEARCH_PAGE_BATCH_LIMIT, }); const hydrated = await Promise.all(page.hits.map((hit) => appviewRecordToHydrated(hit))); const states = await Promise.all(hydrated.map((record) => getState(record.uri))); for (const [index, record] of hydrated.entries()) { const state = states[index]; if (state !== options.state) { continue; } if (matchingSeen >= offset && matchingSeen < requestedEnd) { items.push({ ...record, number: 0, state, }); } matchingSeen += 1; if (matchingSeen > requestedEnd) { return { items: items.slice(0, limit), hasNext: true, }; } } cursor = page.cursor ?? undefined; if (!cursor || page.hits.length === 0) { return { items, totalCount: matchingSeen, hasNext: false, }; } } };