~james/core

core/web/src/lib/api/pagination.ts -rw-r--r-- 3.6 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import type { XRPCQueries } from "@atcute/lexicons/ambient";
import type {
	InferInput,
	InferOutput,
	ObjectSchema,
	XRPCLexBodyParam
} from "@atcute/lexicons/validations";
import { ok, type BobbinContext, type XrpcRequestInit } from "#lib/api/client.ts";

export type QueryOutput<TName extends keyof XRPCQueries> =
	XRPCQueries[TName]["output"] extends XRPCLexBodyParam
		? InferOutput<XRPCQueries[TName]["output"]["schema"]>
		: never;

export type QueryParams<TName extends keyof XRPCQueries> =
	XRPCQueries[TName]["params"] extends ObjectSchema
		? InferInput<XRPCQueries[TName]["params"]>
		: Record<string, never>;

type CursorPage = { items: readonly unknown[]; cursor?: string };

// registered queries whose output is an { items, cursor? } page.
export type PaginatedQuery = {
	[K in keyof XRPCQueries]: QueryOutput<K> extends CursorPage ? K : never;
}[keyof XRPCQueries];

export type PageItem<TName extends PaginatedQuery> =
	QueryOutput<TName> extends { items: readonly (infer I)[] } ? I : never;

export type PageParams<TName extends PaginatedQuery> = Omit<QueryParams<TName>, "cursor">;

export interface PaginateOptions extends XrpcRequestInit {
	maxPages?: number;
}

const requestPage = <TName extends PaginatedQuery>(
	ctx: BobbinContext,
	name: TName,
	params: PageParams<TName> & { cursor?: string },
	init?: XrpcRequestInit
): Promise<QueryOutput<TName>> =>
	ok(
		ctx.xrpc.get(name, {
			params,
			signal: init?.signal,
			headers: init?.headers
		} as never)
	) as Promise<QueryOutput<TName>>;
export const fetchPage = <TName extends PaginatedQuery>(
	ctx: BobbinContext,
	name: TName,
	params: PageParams<TName> & { cursor?: string },
	init?: XrpcRequestInit
): Promise<QueryOutput<TName>> => requestPage(ctx, name, params, init);

export async function* pages<TName extends PaginatedQuery>(
	ctx: BobbinContext,
	name: TName,
	params: PageParams<TName>,
	options: PaginateOptions = {}
): AsyncGenerator<QueryOutput<TName>, void, unknown> {
	let cursor: string | undefined;
	let seen = 0;
	do {
		const data = await requestPage(
			ctx,
			name,
			{ ...params, cursor } as PageParams<TName> & { cursor?: string },
			options
		);
		yield data;
		const next = (data as CursorPage).cursor;
		if (next !== undefined && next === cursor) break;
		cursor = next;
		if (options.maxPages && ++seen >= options.maxPages) break;
	} while (cursor);
}

export async function* items<TName extends PaginatedQuery>(
	ctx: BobbinContext,
	name: TName,
	params: PageParams<TName>,
	options: PaginateOptions = {}
): AsyncGenerator<PageItem<TName>, void, unknown> {
	for await (const data of pages(ctx, name, params, options)) {
		for (const item of (data as CursorPage).items) yield item as PageItem<TName>;
	}
}

export interface CollectOptions extends PaginateOptions {
	max?: number;
}

export const collect = async <TName extends PaginatedQuery>(
	ctx: BobbinContext,
	name: TName,
	params: PageParams<TName>,
	options: CollectOptions = {}
): Promise<PageItem<TName>[]> => {
	const out: PageItem<TName>[] = [];
	for await (const item of items(ctx, name, params, options)) {
		out.push(item);
		if (options.max && out.length >= options.max) break;
	}
	return out;
};

export async function* paginateBy<T>(
	load: (cursor: string | undefined) => Promise<{ items: readonly T[]; cursor?: string | null }>,
	options: { maxPages?: number } = {}
): AsyncGenerator<T, void, unknown> {
	let cursor: string | undefined;
	let seen = 0;
	do {
		const page = await load(cursor);
		for (const item of page.items) yield item;
		const next = page.cursor ?? undefined;
		if (next !== undefined && next === cursor) break;
		cursor = next;
		if (options.maxPages && ++seen >= options.maxPages) break;
	} while (cursor);
}