/** * Pagination helpers shared by connectors (SPEC ยง1: page numbers, cursors, offsets, infinite scroll * backed by an XHR endpoint). They are deliberately tiny: the connector decides how to build a URL * for a page and how to detect the end; these helpers handle bounds, cursors and abort signals. */ export interface PageLoopOptions { /** first page index (1 for most sites, 0 for offset APIs) */ start?: number; /** hard cap of pages to visit */ maxPages: number; signal?: AbortSignal; } /** * Iterate numbered pages until `load` returns `done: true` or the cap is reached. * `load` receives the page index and returns the items plus a done flag. */ export async function* numberedPages(load: (page: number) => Promise<{ items: T[]; done?: boolean }>, opts: PageLoopOptions): AsyncGenerator<{ page: number; items: T[] }> { const start = opts.start ?? 1; for (let page = start; page < start + opts.maxPages; page++) { if (opts.signal?.aborted) return; const r = await load(page); yield { page, items: r.items }; if (r.done || r.items.length === 0) return; } } /** * Cursor pagination: `load(cursor)` returns items and the next cursor (null when finished). * Resumable: pass the persisted cursor as `startCursor`. */ export async function* cursorPages(load: (cursor: C | null) => Promise<{ items: T[]; next: C | null }>, opts: { startCursor?: C | null; maxPages: number; signal?: AbortSignal }): AsyncGenerator<{ cursor: C | null; next: C | null; items: T[] }> { let cursor: C | null = opts.startCursor ?? null; for (let i = 0; i < opts.maxPages; i++) { if (opts.signal?.aborted) return; const r = await load(cursor); yield { cursor, next: r.next, items: r.items }; if (r.next === null || r.items.length === 0) return; cursor = r.next; } } /** Offset pagination (limit/offset APIs behind infinite-scroll pages). */ export async function* offsetPages(load: (offset: number, limit: number) => Promise<{ items: T[]; total?: number | null }>, opts: { limit: number; startOffset?: number; maxPages: number; signal?: AbortSignal }): AsyncGenerator<{ offset: number; items: T[]; total: number | null }> { let offset = opts.startOffset ?? 0; for (let i = 0; i < opts.maxPages; i++) { if (opts.signal?.aborted) return; const r = await load(offset, opts.limit); const total = r.total ?? null; yield { offset, items: r.items, total }; offset += opts.limit; if (r.items.length < opts.limit || (total !== null && offset >= total)) return; } } /** Build a URL with query parameters merged onto a base (keeps existing params). */ export function withParams(base: string, params: Record): string { const u = new URL(base); for (const [k, v] of Object.entries(params)) { if (v === null || v === undefined) u.searchParams.delete(k); else u.searchParams.set(k, String(v)); } return u.toString(); }