Initial Commit
This commit is contained in:
110
packages/foundation/data/pagination/src/helpers/helpers.md
Normal file
110
packages/foundation/data/pagination/src/helpers/helpers.md
Normal 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.
|
||||
424
packages/foundation/data/pagination/src/helpers/helpers.test.ts
Normal file
424
packages/foundation/data/pagination/src/helpers/helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
237
packages/foundation/data/pagination/src/helpers/helpers.ts
Normal file
237
packages/foundation/data/pagination/src/helpers/helpers.ts
Normal 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 };
|
||||
}
|
||||
9
packages/foundation/data/pagination/src/helpers/index.ts
Normal file
9
packages/foundation/data/pagination/src/helpers/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
collectAll,
|
||||
collectUpTo,
|
||||
createCursorIterator,
|
||||
createOffsetIterator,
|
||||
isPaginatedResponseEmpty,
|
||||
mapPaginated,
|
||||
mergeOffsetResponses,
|
||||
} from "./helpers";
|
||||
Reference in New Issue
Block a user