Pagination Reference
paginate and paginateAll over cursor-paginated Graph list endpoints, with AbortSignal support and a typed error union.
active · reviewed 2026-04-22
paginate and paginateAll iterate cursor-paginated Graph list endpoints (phone numbers, message templates, etc.) without hand-rolled cursor plumbing. Both work over any defineEndpoint callable whose response conforms to PaginatedPage<T>.
import {
GraphClient,
defineEndpoint,
paginate,
paginateAll,
PaginationError,
type PaginatedPage
} from "@wats/graph";
// 1. Define a list endpoint whose response conforms to PaginatedPage<T>.
interface PhoneNumberEntry {
readonly id: string;
readonly display_phone_number?: string;
}
const listPhoneNumbers = defineEndpoint<
{ wabaId: string; after?: string; limit?: string },
never,
PaginatedPage<PhoneNumberEntry>
>({
method: "GET",
pathTemplate: "/{wabaId}/phone_numbers",
params: {
wabaId: { in: "path", required: true },
after: { in: "query", required: false },
limit: { in: "query", required: false }
}
});
const client = new GraphClient({
accessToken: "...",
apiVersion: "v25.0"
});
// 2. Stream items one at a time (recommended for large result sets).
for await (const phone of paginate(
client,
listPhoneNumbers,
{ wabaId: "1234567890" },
{ maxPages: 50, pageSize: 100 }
)) {
console.log("saw phone number:", phone.id);
}
// 3. Or accumulate into a flat list via paginateAll.
const result = await paginateAll(
client,
listPhoneNumbers,
{ wabaId: "1234567890" },
{ maxPages: 10 }
);
console.log("total items:", result.items.length);
console.log("pages consumed:", result.pagesConsumed);
console.log("cap hit?", result.pageLimitReached);paginate is an async generator yielding items one at a time. paginateAll drives the generator to completion and returns a PaginatedResult<T> summary with the flat items list.
PaginationOptions
| Field | Type | Default | Notes |
|---|---|---|---|
maxPages | number | 1000 | Positive integer cap on pages consumed. NaN/Infinity/0.5 rejected. |
pageSize | number | — | Positive integer; merged into first request's query under limit. |
signal | AbortSignal | — | Duck-typed signal (aborted, addEventListener, removeEventListener). |
All three fields are validated at the first .next() tick. Invalid inputs throw PaginationError with a typed .code — the generator never silently swallows bad options.
PaginatedResult<T>
Every run returns a frozen summary regardless of how it ended:
items: readonly T[]— flat list of every yielded item (the same items thefor awaitloop observed).pagesConsumed: number— pages that actually hit the wire.pageLimitReached: boolean—truewhenmaxPagesstopped iteration;falseon natural exhaustion.aborted: boolean—truewhen anAbortSignalinterrupted iteration.
Cursor extraction from paging.next
Graph list responses carry a paging.next field — an absolute URL with an after query parameter. The primitive parses paging.next as a URL, reads after, and merges it into the next iteration's params as { ..., after: <cursor> }.
If paging.next is absent, empty, unparseable, or lacks after, iteration terminates cleanly — no fabricated cursor, no infinite loop.
Error taxonomy
PaginationError is a plain Error subclass (not a TypeError) with a stable .code union:
.code | When it fires |
|---|---|
invalid_endpoint | First arg is not an EndpointCallable (missing .definition brand). |
invalid_max_pages | maxPages is not a positive integer (0, negatives, NaN, Infinity, 0.5 etc.). |
invalid_page_size | pageSize is not a positive integer. |
invalid_signal | signal is not a duck-typed AbortSignal. |
aborted | Reserved for programmatic abort-path signalling (currently conveyed via the result flag). |
page_fetch_failed | Underlying endpoint threw mid-stream; original error attached as .cause. |
Endpoint errors always re-throw as PaginationError(page_fetch_failed, ..., { cause }). Narrow via instanceof PaginationError, branch on .code, or follow .cause back to the underlying GraphApiError / transport failure.
Streaming iteration
paginate yields each item as it arrives, then fetches the next page once the current one is exhausted. Page bodies never accumulate in memory; the only growing structure is the items array on PaginatedResult. For very large result sets, iterate with for await and skip paginateAll to avoid holding the flat list.
AbortSignal semantics
- Pre-aborted signal — iteration returns an empty result with
aborted: truewithout touching the transport (zero requests). - Abort between pages — the current page's items still flush to the caller; the next fetch is skipped; the final result carries
aborted: trueandpagesConsumedreflects pages actually fetched. - The signal is also forwarded to the underlying endpoint's
EndpointInvokeOptions.signal, so in-flight fetches cancel at the transport layer.
maxPages cap
Default DEFAULT_MAX_PAGES = 1000 — large enough for any realistic list endpoint, small enough to stop a runaway loop against a misbehaving cursor. On cap:
- Iteration stops after the Nth page's items have been yielded.
- The final result carries
pageLimitReached: trueandpagesConsumed === maxPages.
Set maxPages: 1 for first-page-only semantics; raise it for long exports. The default is a safety net, not a tight bound.
Message template cursor handling
listMessageTemplates(...) accepts both after and before cursor parameters and forwards them to Graph /{wabaId}/message_templates. Graph code 131059 maps to InvalidTemplateCursorError when Meta rejects a template cursor.
WATS performs no hidden retry. If your workflow can safely restart from the first page, catch InvalidTemplateCursorError and retry without before/after as an explicit opt-in:
try {
await waba.listMessageTemplates({ after: savedCursor });
} catch (error) {
if (error instanceof InvalidTemplateCursorError) {
// opt-in: discard stale cursor and retry without before/after
await waba.listMessageTemplates({ limit: "25" });
}
}Related: whatsapp_business_manager_messaging_limit and messaging_limit_tier appear on WABA/phone-number inventory responses. They are not pagination inputs, but they often shape how operators schedule template and campaign work.
Non-goals
Pagination does not:
- persist cursor state across process restarts (save
result.itemsor re-iterate); - implement cross-envelope pagination — the Graph shape is the only shape;
- provide bidirectional iteration (no
previouscursor walk); - retry or back off transient page-fetch failures (they surface as
PaginationError(page_fetch_failed); wrap with your own retry policy); - resume streams across process boundaries;
- cache: each run hits the transport.
Related docs
- Endpoints Reference —
defineEndpoint. - Scoped Clients Reference —
PhoneNumberClient/WABAClient.