/** Granola list pages: `{ : [...], hasMore: boolean, cursor: string | null }`. */ export type Page = { items: Array; hasMore: boolean; cursor: string | null } export type PaginateResult = { items: Array; hasMore: boolean; cursor: string | null; pages: number } /** * Collect up to `limit` items. With `paginate: false` only one page is fetched. The API caps * page_size (30 for most lists, 100 for transcripts), so pages are requested at min(remaining, max). * Paging follows `hasMore` + `cursor` only (a page may hold fewer items and still not be the last). * Items beyond `limit` on the last page are dropped, and `hasMore` / `cursor` then point at the * page after the one that was cut (the API has no item offset), so `truncated` is reported. */ export async function paginate( fetchPage: (cursor: string | undefined, pageSize: number) => Promise>, opts: { limit: number; maxPageSize: number; cursor?: string; paginate: boolean }, ): Promise & { truncated: boolean }> { const items: Array = [] let cursor: string | undefined = opts.cursor let hasMore = false let nextCursor: string | null = null let pages = 0 let truncated = false for (;;) { const remaining = opts.limit - items.length if (remaining <= 0) break const page = await fetchPage(cursor, Math.min(remaining, opts.maxPageSize)) pages++ const take = page.items.slice(0, remaining) if (take.length < page.items.length) truncated = true items.push(...take) hasMore = page.hasMore nextCursor = page.cursor ?? null if (!opts.paginate || !page.hasMore || !page.cursor) break if (page.cursor === cursor) break // defensive: never loop on a stuck cursor cursor = page.cursor } return { items, hasMore, cursor: hasMore ? nextCursor : null, pages, truncated } }