paginate.ts
Language TypeScript8.8 KB252 lines
/*** Pagination and response-shape normalization.** This file exists because Ninety's API is not internally consistent. Per a* third-party SDK's docs (verified live only where marked):** Issues, KPIs -> { items: [], totalCount: n }* To-Dos, Teams -> bare JSON array* Rocks -> object keyed by teamId** and pagination splits three ways:** Rocks, Issues -> pageIndex (0-based), sortField/sortDirection* To-Dos -> page (1-based, verified), sort/order* Company To-Dos -> opaque cursor (verified, see fromCursor below)** Everything above this layer speaks one dialect: 0-based `page`, and `Page<T>`.* Cursor endpoints cannot be expressed that way (you cannot seek to page 4 of a* cursor stream), so they get `CursorPage<T>` and `iterateCursor` instead of a* fake page number. Do not paper over the difference; a synthetic pageIndex on a* cursor endpoint is a lie that eventually gets indexed into.** If a shape turns out wrong, this file changes and nothing else does. That is* the point of isolating it.*/import { NinetySchemaError, type NinetyErrorContext } from './errors.js'/** The one paginated shape the rest of the codebase knows about. */export interface Page<T> {items: T[]/** null when Ninety does not report a total (bare-array endpoints). */total: number | null/** Always 0-based, regardless of what the endpoint wanted. */pageIndex: numberpageSize: numberhasMore: boolean}/*** Cursor-paginated shape. No page index, no total; those do not exist here.* `nextToken` is opaque: pass it back verbatim, never parse or construct one.*/export interface CursorPage<T> {items: T[]/** null means the set is exhausted. Verified: a complete set returns null. */nextToken: string | null}/** The one request shape callers use. Translated per-resource on the way out. */export interface PageRequest {/** 0-based. Translated to 1-based for endpoints that need it. */page?: numberpageSize?: numbersortBy?: stringsortDir?: 'asc' | 'desc'}// --- shape adapters ---------------------------------------------------------/** `{ items, totalCount }` -> Page. Issues, KPIs. */export function fromEnvelope<T>(raw: unknown,req: Required<Pick<PageRequest, 'page' | 'pageSize'>>,ctx: NinetyErrorContext,): Page<T> {if (typeof raw !== 'object' || raw === null || !('items' in raw)) {throw new NinetySchemaError(ctx, `expected {items, totalCount}, got ${describe(raw)}`)}const items = (raw as { items: unknown }).itemsif (!Array.isArray(items)) {throw new NinetySchemaError(ctx, `expected .items to be an array, got ${describe(items)}`)}const totalRaw = (raw as { totalCount?: unknown }).totalCountconst total = typeof totalRaw === 'number' ? totalRaw : nullreturn {items: items as T[],total,pageIndex: req.page,pageSize: req.pageSize,hasMore:total === null ? items.length === req.pageSize : (req.page + 1) * req.pageSize < total,}}/*** Bare array -> Page. To-Dos, Teams.** No total is reported, so `hasMore` is inferred from a full page. That means* one wasted request when the item count is an exact multiple of pageSize.* Correctness over cleverness; the alternative is silently truncating.*/export function fromArray<T>(raw: unknown,req: Required<Pick<PageRequest, 'page' | 'pageSize'>>,ctx: NinetyErrorContext,): Page<T> {if (!Array.isArray(raw)) {throw new NinetySchemaError(ctx, `expected a bare array, got ${describe(raw)}`)}return {items: raw as T[],total: null,pageIndex: req.page,pageSize: req.pageSize,hasMore: raw.length >= req.pageSize,}}/*** `{ [teamId]: T[] }` -> Page. Rocks.** Flattens and stamps each item with the teamId it was filed under, since that* key is otherwise the only place the association exists.*/export function fromTeamKeyed<T extends object>(raw: unknown,req: Required<Pick<PageRequest, 'page' | 'pageSize'>>,ctx: NinetyErrorContext,): Page<T & { teamId: string }> {if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {throw new NinetySchemaError(ctx, `expected an object keyed by teamId, got ${describe(raw)}`)}const items: (T & { teamId: string })[] = []for (const [teamId, group] of Object.entries(raw as Record<string, unknown>)) {if (!Array.isArray(group)) {throw new NinetySchemaError(ctx, `expected an array under teamId "${teamId}", got ${describe(group)}`)}for (const item of group) {items.push({ ...(item as T), teamId })}}return {items,total: null,pageIndex: req.page,pageSize: req.pageSize,hasMore: items.length >= req.pageSize,}}/*** `{ data: T[], pagination: { nextToken } }` -> CursorPage. Company To-Dos.** Verified against GET /todos/company: top-level keys are exactly* `['data', 'pagination']`. A complete result set* returns `pagination.nextToken: null`, so null is "done", not "unknown".** Note this is a different envelope from `{items, totalCount}`: `data` not* `items`, and no total is reported at all. Do not try to compute a percentage* complete from it; there is no denominator.*/export function fromCursor<T>(raw: unknown, ctx: NinetyErrorContext): CursorPage<T> {if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {throw new NinetySchemaError(ctx, `expected {data, pagination}, got ${describe(raw)}`)}const data = (raw as { data?: unknown }).dataif (!Array.isArray(data)) {throw new NinetySchemaError(ctx, `expected .data to be an array, got ${describe(data)}`)}const pagination = (raw as { pagination?: unknown }).paginationlet nextToken: string | null = nullif (pagination !== undefined && pagination !== null) {if (typeof pagination !== 'object' || Array.isArray(pagination)) {throw new NinetySchemaError(ctx, `expected .pagination to be an object, got ${describe(pagination)}`)}const token = (pagination as { nextToken?: unknown }).nextTokenif (typeof token === 'string' && token.length > 0) nextToken = token}return { items: data as T[], nextToken }}/*** Walks a cursor stream to exhaustion.** The safety cap matters more here than on numbered pages: a server that keeps* echoing the same nextToken produces an infinite loop that looks like healthy* traffic. We also detect a repeated token explicitly, because that is the* failure mode most likely to actually happen.*/export async function* iterateCursor<T>(fetchPage: (nextToken: string | undefined) => Promise<CursorPage<T>>,opts: IterateOptions = {},): AsyncGenerator<T, void, undefined> {const maxPages = opts.maxPages ?? 500let token: string | undefinedconst seen = new Set<string>()for (let n = 0; n < maxPages; n++) {const page = await fetchPage(token)for (const item of page.items) yield itemif (!page.nextToken) returnif (seen.has(page.nextToken)) {throw new Error(`iterateCursor received the same nextToken twice ("${page.nextToken.slice(0, 12)}..."). ` +`Ninety is not advancing the cursor; stopping rather than looping forever.`,)}seen.add(page.nextToken)token = page.nextToken}throw new Error(`iterateCursor stopped at the ${maxPages}-page safety cap. Either raise maxPages or ` +`check that nextToken is being threaded back correctly.`,)}// --- iteration --------------------------------------------------------------export interface IterateOptions {/*** Hard stop. Without it a pagination bug on Ninety's side becomes an infinite* loop against a rate-limited API.*/maxPages?: number}/*** Walks every page. Callers should almost always use this rather than paging by* hand, so the 1-based/0-based translation stays in one place.*/export async function* iterateAll<T>(fetchPage: (req: Required<Pick<PageRequest, 'page' | 'pageSize'>>) => Promise<Page<T>>,req: Required<Pick<PageRequest, 'page' | 'pageSize'>>,opts: IterateOptions = {},): AsyncGenerator<T, void, undefined> {const maxPages = opts.maxPages ?? 500let page = req.pagefor (let n = 0; n < maxPages; n++) {const result = await fetchPage({ page, pageSize: req.pageSize })for (const item of result.items) yield itemif (!result.hasMore || result.items.length === 0) returnpage++}throw new Error(`iterateAll stopped at the ${maxPages}-page safety cap. Either raise maxPages or ` +`check that hasMore is being computed correctly for this endpoint.`,)}/** Convenience: drain an iterator into an array. */export async function collect<T>(it: AsyncGenerator<T, void, undefined>): Promise<T[]> {const out: T[] = []for await (const item of it) out.push(item)return out}function describe(v: unknown): string {if (v === null) return 'null'if (Array.isArray(v)) return `array(${v.length})`return typeof v}