Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
# Pagination Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The helpers module provides higher-order utilities for consuming paginated APIs: async iterators for offset and cursor strategies, collection helpers that drain iterators into arrays, a mapping helper, an emptiness check, and a merge utility for combining pre-fetched pages. All iterators support `AbortSignal` so long-running collection passes can be cancelled between page fetches without leaving hanging promises.
These helpers work with any function that returns an `OffsetPaginatedResponse<T>` or `CursorPaginatedResponse<T>` — they are not tied to a specific adapter or data source.
## Features
- `createOffsetIterator` — `AsyncIterable<PaginatedIteratorResult<T>>` driven by an offset fetcher function.
- `createCursorIterator` — `AsyncIterable<PaginatedIteratorResult<T>>` driven by a cursor fetcher function.
- `collectAll` — drain an iterable into a flat `T[]`; abortable.
- `collectUpTo` — drain up to N items; stops early; abortable.
- `mapPaginated` — map items across all pages, returning a flat `U[]`.
- `isPaginatedResponseEmpty` — check whether a response has zero items.
- `mergeOffsetResponses` — combine sequential pages into a single `{ data, total }`.
## Usage: Basic Example
```typescript
import { createOffsetIterator, collectAll } from "@suaveplan/pagination/helpers";
import { paginateArray } from "@suaveplan/pagination/offset";
const items = Array.from({ length: 25 }, (_, i) => i + 1);
const iter = createOffsetIterator(
(page, limit) => Promise.resolve(paginateArray(items, page, limit)),
{ limit: 10 }
);
const all = await collectAll(iter);
// → [1, 2, ..., 25]
```
## Usage: Advanced Example
```typescript
import {
createCursorIterator,
collectUpTo,
mapPaginated,
} from "@suaveplan/pagination/helpers";
// Abort after 2 seconds
const controller = new AbortController();
setTimeout(() => controller.abort(), 2000);
const iter = createCursorIterator(
async (cursor, limit) => fetchUsersPage(cursor, limit),
{ limit: 50, signal: controller.signal }
);
// Collect at most 200 users
const users = await collectUpTo(iter, 200, controller.signal);
// Map without collecting all pages first
const names = await mapPaginated(
createCursorIterator(async (c, l) => fetchUsersPage(c, l), { limit: 100 }),
(u) => u.name
);
```
## API Reference
### `createOffsetIterator<T>(fetcher, opts?): AsyncIterable<PaginatedIteratorResult<T>>`
| Parameter | Type | Default |
|------------------|------------------------------------------------------|---------|
| `fetcher` | `(page, limit) => Promise<OffsetPaginatedResponse<T>>` | — |
| `opts.startPage` | `number` | `1` |
| `opts.limit` | `number` | `20` |
| `opts.signal` | `AbortSignal \| undefined` | — |
Throws `PaginationError(PAGINATION.ABORTED)` between pages if the signal fires.
### `createCursorIterator<T>(fetcher, opts?): AsyncIterable<PaginatedIteratorResult<T>>`
| Parameter | Type | Default |
|--------------|-------------------------------------------------------------|---------|
| `fetcher` | `(cursor, limit) => Promise<CursorPaginatedResponse<T>>` | — |
| `opts.limit` | `number` | `20` |
| `opts.signal` | `AbortSignal \| undefined` | — |
### `collectAll<T>(iterable, signal?): Promise<T[]>`
Drains the entire iterable. Checks `signal?.aborted` after each page.
### `collectUpTo<T>(iterable, maxItems, signal?): Promise<T[]>`
Returns at most `maxItems` items, stopping early without consuming subsequent pages.
### `mapPaginated<T, U>(iterable, mapper): Promise<U[]>`
Maps each item with `mapper` and returns a flat `U[]`.
### `isPaginatedResponseEmpty<T>(response): boolean`
Returns `response.data.length === 0`. Accepts both `OffsetPaginatedResponse<T>` and `CursorPaginatedResponse<T>`.
### `mergeOffsetResponses<T>(responses): { data: T[]; total: number }`
Concatenates `data` arrays; `total` is taken from the first response (assumed to be the authoritative count for all pages).
## Implementation Notes
The `createOffsetIterator` and `createCursorIterator` factories close over their state (current page / cursor, done flag) in the `[Symbol.asyncIterator]()` factory, so each `for await` loop gets a fresh independent cursor. The abort check happens both before and after the async fetch call to handle signals that fire during IO.

View File

@@ -0,0 +1,424 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cursorPaginateArray } from "../cursor/cursor.js";
import { PaginationError } from "../errors/pagination-error.js";
import { paginateArray } from "../offset/offset.js";
import {
collectAll,
collectUpTo,
createCursorIterator,
createOffsetIterator,
isPaginatedResponseEmpty,
mapPaginated,
mergeOffsetResponses,
} from "./helpers.js";
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
function makeOffsetFetcher(items: number[]) {
return (page: number, limit: number) => {
return Promise.resolve(paginateArray(items, page, limit));
};
}
function makeCursorFetcher(items: number[]) {
return (cursor: string | undefined, limit: number) => {
return Promise.resolve(
cursorPaginateArray(items, { cursor, limit }, (_, i) => String(i))
);
};
}
// ---------------------------------------------------------------------------
// createOffsetIterator
// ---------------------------------------------------------------------------
describe("createOffsetIterator", () => {
const items = Array.from({ length: 25 }, (_, i) => i + 1);
it("yields all pages", { timeout: 5000 }, async () => {
const iter = createOffsetIterator(makeOffsetFetcher(items), {
limit: 10,
});
const pages: number[][] = [];
for await (const p of iter) {
pages.push(p.items);
}
expect(pages).toHaveLength(3);
expect(pages.flat()).toEqual(items);
});
it("hasMore is false on last page", { timeout: 5000 }, async () => {
const iter = createOffsetIterator(makeOffsetFetcher(items), {
limit: 25,
});
const results: boolean[] = [];
for await (const p of iter) results.push(p.hasMore);
expect(results).toEqual([false]);
});
it("yields nextPage on non-last pages", { timeout: 5000 }, async () => {
const iter = createOffsetIterator(makeOffsetFetcher(items), {
limit: 10,
});
let first = true;
for await (const p of iter) {
if (first) {
expect(p.nextPage).toBe(2);
first = false;
}
}
});
it("throws ABORTED when signal pre-aborted", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
const iter = createOffsetIterator(makeOffsetFetcher(items), {
limit: 10,
signal: controller.signal,
});
await expect(
(async () => {
for await (const _ of iter) {
}
})()
).rejects.toBeInstanceOf(PaginationError);
});
it("done=true causes subsequent next() to terminate", {
timeout: 5000,
}, async () => {
const iter = createOffsetIterator(makeOffsetFetcher([1, 2, 3]), {
limit: 10,
});
const iterator = iter[Symbol.asyncIterator]();
const first = await iterator.next();
expect(first.done).toBe(false);
const second = await iterator.next();
expect(second.done).toBe(true);
});
it("respects startPage option", { timeout: 5000 }, async () => {
const iter = createOffsetIterator(makeOffsetFetcher(items), {
startPage: 2,
limit: 10,
});
const iterator = iter[Symbol.asyncIterator]();
const first = await iterator.next();
expect((first.value as { items: number[] }).items[0]).toBe(11);
});
it("uses default limit=20 and startPage=1 when no opts provided", {
timeout: 5000,
}, async () => {
const smallItems = Array.from({ length: 5 }, (_, i) => i + 1);
const iter = createOffsetIterator(makeOffsetFetcher(smallItems)); // no opts
const all: number[] = [];
for await (const p of iter) all.push(...p.items);
expect(all).toEqual(smallItems);
});
it("uses default limit=20 when opts provided without limit", {
timeout: 5000,
}, async () => {
const smallItems = Array.from({ length: 3 }, (_, i) => i + 1);
// Pass opts with only startPage (no limit) → triggers `opts.limit ?? 20` branch
const iter = createOffsetIterator(makeOffsetFetcher(smallItems), {
startPage: 1,
});
const all: number[] = [];
for await (const p of iter) all.push(...p.items);
expect(all).toEqual(smallItems);
});
});
// ---------------------------------------------------------------------------
// createCursorIterator
// ---------------------------------------------------------------------------
describe("createCursorIterator", () => {
it("yields all cursor pages", { timeout: 5000 }, async () => {
const items = Array.from({ length: 6 }, (_, i) => i + 1);
const fetcher = makeCursorFetcher(items);
const iter = createCursorIterator(fetcher, { limit: 2 });
const all: number[] = [];
for await (const p of iter) {
all.push(...p.items);
}
expect(all).toEqual(items);
});
it("throws ABORTED when signal pre-aborted", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
const fetcher = makeCursorFetcher([1, 2, 3]);
const iter = createCursorIterator(fetcher, {
limit: 10,
signal: controller.signal,
});
await expect(
(async () => {
for await (const _ of iter) {
}
})()
).rejects.toBeInstanceOf(PaginationError);
});
it("throws ABORTED when signal fires after fetch", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const fetcher = (cursor: string | undefined, limit: number) => {
controller.abort(); // abort during the fetch
return makeCursorFetcher([1, 2, 3])(cursor, limit);
};
const iter = createCursorIterator(fetcher, {
limit: 10,
signal: controller.signal,
});
await expect(
(async () => {
for await (const _ of iter) {
}
})()
).rejects.toBeInstanceOf(PaginationError);
});
it("done=true causes subsequent next() to terminate", {
timeout: 5000,
}, async () => {
const fetcher = makeCursorFetcher([1, 2]);
const iter = createCursorIterator(fetcher, { limit: 10 });
const iterator = iter[Symbol.asyncIterator]();
const first = await iterator.next();
expect(first.done).toBe(false);
const second = await iterator.next();
expect(second.done).toBe(true);
});
it("uses default limit=20 when no opts provided", {
timeout: 5000,
}, async () => {
const items = Array.from({ length: 5 }, (_, i) => i + 1);
const fetcher = makeCursorFetcher(items);
const iter = createCursorIterator(fetcher); // no opts → default limit 20
const all: number[] = [];
for await (const p of iter) all.push(...p.items);
expect(all).toEqual(items);
});
it("uses default limit=20 when opts provided without limit", {
timeout: 5000,
}, async () => {
const items = Array.from({ length: 3 }, (_, i) => i + 1);
const fetcher = makeCursorFetcher(items);
// Pass opts with only signal (no limit) → triggers `opts.limit ?? 20` branch
const iter = createCursorIterator(fetcher, { signal: undefined });
const all: number[] = [];
for await (const p of iter) all.push(...p.items);
expect(all).toEqual(items);
});
it("handles null endCursor (nextCursor becomes undefined)", {
timeout: 5000,
}, async () => {
// Create a fetcher that returns a response with null endCursor on last page
let called = false;
const fetcher = (_cursor: string | undefined, _limit: number) => {
if (called)
return Promise.resolve({
data: [],
meta: {
startCursor: null,
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
},
});
called = true;
return Promise.resolve({
data: [1, 2],
meta: {
startCursor: "s",
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
},
});
};
const iter = createCursorIterator(
fetcher as Parameters<typeof createCursorIterator>[0],
{ limit: 10 }
);
const all: number[] = [];
for await (const p of iter) all.push(...p.items);
expect(all).toEqual([1, 2]);
});
});
// ---------------------------------------------------------------------------
// createOffsetIterator post-fetch abort
// ---------------------------------------------------------------------------
describe("createOffsetIterator post-fetch abort", () => {
it("throws ABORTED when signal fires after fetch completes", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const items = [1, 2, 3];
const fetcher = (page: number, limit: number) => {
controller.abort(); // abort during the fetch
return Promise.resolve(paginateArray(items, page, limit));
};
const iter = createOffsetIterator(fetcher, {
limit: 10,
signal: controller.signal,
});
await expect(
(async () => {
for await (const _ of iter) {
}
})()
).rejects.toBeInstanceOf(PaginationError);
});
});
// ---------------------------------------------------------------------------
// collectAll
// ---------------------------------------------------------------------------
describe("collectAll", () => {
it("collects all items", { timeout: 5000 }, async () => {
const items = Array.from({ length: 15 }, (_, i) => i);
const iter = createOffsetIterator(makeOffsetFetcher(items), {
limit: 5,
});
const all = await collectAll(iter);
expect(all).toEqual(items);
});
it("throws ABORTED when signal aborted before first page", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const iter = createOffsetIterator(makeOffsetFetcher([1, 2]), {
limit: 5,
});
controller.abort();
await expect(
collectAll(iter, controller.signal)
).rejects.toBeInstanceOf(PaginationError);
});
});
// ---------------------------------------------------------------------------
// collectUpTo
// ---------------------------------------------------------------------------
describe("collectUpTo", () => {
it("returns at most maxItems", { timeout: 5000 }, async () => {
const items = Array.from({ length: 50 }, (_, i) => i);
const iter = createOffsetIterator(makeOffsetFetcher(items), {
limit: 10,
});
const collected = await collectUpTo(iter, 25);
expect(collected).toHaveLength(25);
});
it("returns all when maxItems > total", { timeout: 5000 }, async () => {
const items = [1, 2, 3];
const iter = createOffsetIterator(makeOffsetFetcher(items), {
limit: 10,
});
const collected = await collectUpTo(iter, 100);
expect(collected).toEqual(items);
});
it("throws ABORTED when signal aborted before first page", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
const iter = createOffsetIterator(makeOffsetFetcher([1, 2, 3]), {
limit: 10,
});
await expect(
collectUpTo(iter, 10, controller.signal)
).rejects.toBeInstanceOf(PaginationError);
});
});
// ---------------------------------------------------------------------------
// mapPaginated
// ---------------------------------------------------------------------------
describe("mapPaginated", () => {
it("maps all items", { timeout: 5000 }, async () => {
const items = [1, 2, 3, 4, 5];
const iter = createOffsetIterator(makeOffsetFetcher(items), {
limit: 2,
});
const doubled = await mapPaginated(iter, (x) => x * 2);
expect(doubled).toEqual([2, 4, 6, 8, 10]);
});
it("preserves page shape (returns flat array)", {
timeout: 5000,
}, async () => {
const items = ["a", "b", "c"];
const iter = createOffsetIterator(
(page, limit) => Promise.resolve(paginateArray(items, page, limit)),
{ limit: 2 }
);
const upper = await mapPaginated(iter, (s) => s.toUpperCase());
expect(upper).toEqual(["A", "B", "C"]);
});
});
// ---------------------------------------------------------------------------
// isPaginatedResponseEmpty
// ---------------------------------------------------------------------------
describe("isPaginatedResponseEmpty", () => {
it("returns true for empty offset response", { timeout: 5000 }, () => {
const res = paginateArray([], 1, 10);
expect(isPaginatedResponseEmpty(res)).toBe(true);
});
it("returns false for non-empty offset response", { timeout: 5000 }, () => {
const res = paginateArray([1, 2, 3], 1, 10);
expect(isPaginatedResponseEmpty(res)).toBe(false);
});
it("returns true for empty cursor response", { timeout: 5000 }, () => {
const res = {
data: [],
meta: {
startCursor: null,
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
},
};
expect(isPaginatedResponseEmpty(res)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// mergeOffsetResponses
// ---------------------------------------------------------------------------
describe("mergeOffsetResponses", () => {
it("merges data from multiple responses", { timeout: 5000 }, () => {
const r1 = paginateArray([1, 2, 3], 1, 3);
const r2 = paginateArray([1, 2, 3, 4, 5, 6], 2, 3);
const merged = mergeOffsetResponses([r1, r2]);
expect(merged.data).toEqual([1, 2, 3, 4, 5, 6]);
expect(merged.total).toBe(3); // from first response
});
it("returns zero total for empty responses array", {
timeout: 5000,
}, () => {
const merged = mergeOffsetResponses([]);
expect(merged.data).toEqual([]);
expect(merged.total).toBe(0);
});
});

View File

@@ -0,0 +1,237 @@
import { PAGINATION_CODES } from "../errors/codes.js";
import { PaginationError } from "../errors/pagination-error.js";
import type {
CursorPaginatedResponse,
OffsetPaginatedResponse,
PaginatedIteratorResult,
} from "../types/pagination.types.js";
// ---------------------------------------------------------------------------
// Offset iterator
// ---------------------------------------------------------------------------
/**
* Create an `AsyncIterable` that walks through an offset-paginated API.
*
* Each iteration step yields a `PaginatedIteratorResult<T>` containing
* the page items and whether more pages exist. Pass `opts.signal` to
* abort between page fetches; a `PaginationError(PAGINATION.ABORTED)`
* is thrown when the signal fires.
*/
export function createOffsetIterator<T>(
fetcher: (
page: number,
limit: number
) => Promise<OffsetPaginatedResponse<T>>,
opts?: { startPage?: number; limit?: number; signal?: AbortSignal }
): AsyncIterable<PaginatedIteratorResult<T>> {
const startPage = opts === undefined ? 1 : (opts.startPage ?? 1);
const limit = opts === undefined ? 20 : (opts.limit ?? 20);
const signal = opts?.signal;
return {
[Symbol.asyncIterator]() {
let currentPage = startPage;
let done = false;
return {
async next(): Promise<
IteratorResult<PaginatedIteratorResult<T>>
> {
if (done) return { done: true, value: undefined };
if (signal?.aborted) {
throw new PaginationError(
PAGINATION_CODES.ABORTED,
"pagination.aborted"
);
}
const response = await fetcher(currentPage, limit);
if (signal?.aborted) {
throw new PaginationError(
PAGINATION_CODES.ABORTED,
"pagination.aborted"
);
}
const hasMore = response.meta.hasNext;
if (!hasMore) done = true;
const result: PaginatedIteratorResult<T> = {
items: response.data,
hasMore,
};
if (hasMore) result.nextPage = currentPage + 1;
currentPage++;
return { done: false, value: result };
},
};
},
};
}
// ---------------------------------------------------------------------------
// Cursor iterator
// ---------------------------------------------------------------------------
/**
* Create an `AsyncIterable` that walks through a cursor-paginated API.
*
* Each step yields a `PaginatedIteratorResult<T>`. Supports `AbortSignal`
* via `opts.signal`.
*/
export function createCursorIterator<T>(
fetcher: (
cursor: string | undefined,
limit: number
) => Promise<CursorPaginatedResponse<T>>,
opts?: { limit?: number; signal?: AbortSignal }
): AsyncIterable<PaginatedIteratorResult<T>> {
const limit = opts === undefined ? 20 : (opts.limit ?? 20);
const signal = opts?.signal;
return {
[Symbol.asyncIterator]() {
let nextCursor: string | undefined;
let done = false;
return {
async next(): Promise<
IteratorResult<PaginatedIteratorResult<T>>
> {
if (done) return { done: true, value: undefined };
if (signal?.aborted) {
throw new PaginationError(
PAGINATION_CODES.ABORTED,
"pagination.aborted"
);
}
const response = await fetcher(nextCursor, limit);
if (signal?.aborted) {
throw new PaginationError(
PAGINATION_CODES.ABORTED,
"pagination.aborted"
);
}
const hasMore = response.meta.hasNextPage;
nextCursor =
response.meta.endCursor === null
? undefined
: response.meta.endCursor;
if (!hasMore) done = true;
const result: PaginatedIteratorResult<T> = {
items: response.data,
hasMore,
};
if (hasMore && nextCursor !== undefined)
result.nextCursor = nextCursor;
return { done: false, value: result };
},
};
},
};
}
// ---------------------------------------------------------------------------
// Collection helpers
// ---------------------------------------------------------------------------
/**
* Collect all items from a paginated async iterable into a single array.
*
* Accepts an optional `AbortSignal`; forwards it to each iterator step
* by checking the signal before each page fetch.
*/
export async function collectAll<T>(
iterable: AsyncIterable<PaginatedIteratorResult<T>>,
signal?: AbortSignal
): Promise<T[]> {
const all: T[] = [];
for await (const result of iterable) {
if (signal?.aborted) {
throw new PaginationError(
PAGINATION_CODES.ABORTED,
"pagination.aborted"
);
}
all.push(...result.items);
}
return all;
}
/**
* Collect up to `maxItems` items from a paginated async iterable.
*
* Stops early once `maxItems` is reached. Accepts an optional `AbortSignal`.
*/
export async function collectUpTo<T>(
iterable: AsyncIterable<PaginatedIteratorResult<T>>,
maxItems: number,
signal?: AbortSignal
): Promise<T[]> {
const all: T[] = [];
for await (const result of iterable) {
if (signal?.aborted) {
throw new PaginationError(
PAGINATION_CODES.ABORTED,
"pagination.aborted"
);
}
for (const item of result.items) {
all.push(item);
if (all.length >= maxItems) return all;
}
}
return all;
}
// ---------------------------------------------------------------------------
// Mapping and inspection
// ---------------------------------------------------------------------------
/**
* Map items across all pages of a paginated async iterable.
*
* Returns a flat array of mapped values (not a paginated response).
*/
export async function mapPaginated<T, U>(
iterable: AsyncIterable<PaginatedIteratorResult<T>>,
mapper: (item: T) => U
): Promise<U[]> {
const results: U[] = [];
for await (const result of iterable) {
results.push(...result.items.map(mapper));
}
return results;
}
/**
* Return `true` when the paginated response contains no data items.
*/
export function isPaginatedResponseEmpty<T>(
response: OffsetPaginatedResponse<T> | CursorPaginatedResponse<T>
): boolean {
return response.data.length === 0;
}
/**
* Merge an array of offset-paginated responses into a single combined result.
*
* The `total` is taken from the first response; assumes all responses share
* the same total count (i.e., they are sequential pages of the same query).
*/
export function mergeOffsetResponses<T>(
responses: OffsetPaginatedResponse<T>[]
): { data: T[]; total: number } {
const data = responses.flatMap((r) => r.data);
const total = responses.length > 0 ? responses[0]!.meta.total : 0;
return { data, total };
}

View File

@@ -0,0 +1,9 @@
export {
collectAll,
collectUpTo,
createCursorIterator,
createOffsetIterator,
isPaginatedResponseEmpty,
mapPaginated,
mergeOffsetResponses,
} from "./helpers";