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<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,
};
}
}
};