Something went wrong. Try again.
csr tangled client in solid-js forked from ptr.pet/untangled
Something went wrong. Try again.
9.7 kB · 357 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358import 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<T, State extends string> = HydratedRecord<T> & { number: number; state: State;};
export type StatefulPageOptions<State extends string> = { 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 = <T extends { createdAt?: string }>( record: HydratedRecord<T>,): number => maxTimestamp( timestampFromDate(record.value.createdAt), timestampFromTid(record.rkey), );
export const sortStatefulRecordsByNewestRecord = <T extends { createdAt?: string }, State extends string>( items: Array<StatefulHydratedRecord<T, State>>,): Array<StatefulHydratedRecord<T, State>> => [...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 <T, State extends string>( items: Array<StatefulHydratedRecord<T, State>>, getUpdatedAt: (item: StatefulHydratedRecord<T, State>) => Promise<number>,): Promise<Array<StatefulHydratedRecord<T, State>>> => { 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 = <T, State extends string>( items: Array<StatefulHydratedRecord<T, State>>, options: StatefulPageOptions<State>,): PaginatedResult<StatefulHydratedRecord<T, State>> => { 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, AppviewParamValue> = {},): 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 <T extends { createdAt?: string }, State extends string>( nsid: string, subject: string, options: StatefulPageOptions<State>, normalizeState: (value?: string) => State, extraParams: Record<string, AppviewParamValue> = {}, getUpdatedAt?: ( record: StatefulHydratedRecord<T, State>, appviewRecord: AppviewStatefulRecordView<T>, ) => Promise<number>,): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { if (getUpdatedAt) { const records = await listAllAppviewRecords<T>(nsid, subject, extraParams) as Array<AppviewStatefulRecordView<T>>; 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<T, State> = { ...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<T>; 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<AppviewStatefulListResponse<T>>(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 <T, State extends string>( nsid: string, repo: { repoDid: Did }, options: StatefulPageOptions<State>, getState: (uri: ResourceUri) => Promise<State>,): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 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<StatefulHydratedRecord<T, State>> = []; let cursor: string | undefined; let matchingSeen = 0;
while (true) { const page = await appviewJson<AppviewSearchResponse<T>>('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, }; } }};