7.4 KiB
Capability Spec Delta: pagination (pagination-streaming)
Package: @suaveplan/pagination
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 13.)Adds PG-14 and PG-15 to the canonical capability spec at
openspec/specs/pagination/spec.md.
ADDED Requirements
Requirement: Streaming pagination iterable over PaginationAdapter
The package SHALL export streamPaginated<T>(adapter: PaginationAdapter<T>, options?: StreamPaginationOptions): AsyncIterable<T>. The returned iterable SHALL drain adapter.fetchPage(offset, pageSize, sort?, filter?) lazily — fetching the next page only when the current page is exhausted — so memory use is bounded by pageSize. StreamPaginationOptions SHALL be { pageSize?: number; filter?: FilterConfig; sort?: SortConfig; signal?: AbortSignal }. When pageSize is omitted, the iterable SHALL use DEFAULT_STREAM_PAGE_SIZE (50). When signal is supplied and aborted, next() SHALL throw PaginationError with code PAGINATION.ABORTED without calling adapter.fetchPage.
Scenario: drains items from MemoryPaginationAdapter
- WHEN
streamPaginated(adapter, { pageSize: 10 })is iterated andadapterholds 100 items - THEN the consumer receives all 100 items in order AND
adapter.fetchPageis called exactly 11 times (10 full pages + one empty/partial page to detect end)
Scenario: defaults pageSize to 50 when omitted
- WHEN
streamPaginated(adapter)is iterated with no options - THEN every call to
adapter.fetchPageis made withpageSize: 50
Scenario: holds at most one page in memory
- WHEN the iterable is mid-drain and instrumentation observes the internal
currentPagearray length - THEN the length is always less than or equal to
pageSize
Scenario: lazy — no fetch before first next()
- WHEN
streamPaginated(adapter)is constructed butSymbol.asyncIteratoris not invoked - THEN
adapter.fetchPagehas been called zero times
Scenario: stops when partial page returned
- WHEN the adapter returns a page whose length is less than
pageSize - THEN the iterable yields the remaining items and then completes (no further
fetchPagecalls)
Scenario: forwards filter and sort to adapter.fetchPage
- WHEN
streamPaginated(adapter, { pageSize: 10, filter: { status: "active" }, sort: { field: "id", direction: "asc" } })is iterated - THEN every
adapter.fetchPagecall is made with the samefilterandsortarguments byte-for-byte
Scenario: signal already aborted before first next()
- WHEN
signal.aborted === trueat iterator construction andnext()is called - THEN the call throws
PaginationErrorwith codePAGINATION.ABORTEDANDadapter.fetchPageis NEVER called
Scenario: signal aborted between yields
- WHEN the consumer aborts the signal between two
next()calls mid-page - THEN the next
next()call throwsPaginationErrorwith codePAGINATION.ABORTED
Verified by: packages/data/pagination/src/streaming/stream-pagination.test.ts::"streams items from MemoryPaginationAdapter" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"defaults pageSize to 50 when omitted" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"holds at most one page in memory" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"is lazy — no fetch before first next()" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"stops when partial page returned" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"forwards filter and sort to adapter.fetchPage" AND packages/data/pagination/src/streaming/stream-pagination.spec-conformance.test.ts::"throws PAGINATION.ABORTED before first fetchPage when signal already aborted" AND packages/data/pagination/src/streaming/stream-pagination.spec-conformance.test.ts::"throws PAGINATION.ABORTED on next() after signal abort between yields"
Requirement: Streaming operator helpers — collect, count, take, filter, map
The package SHALL export five helper functions over AsyncIterable<T> produced by streamPaginated:
collectStream<T>(iterable: AsyncIterable<T>): Promise<T[]>— drains the entire iterable into an array.countStream<T>(iterable: AsyncIterable<T>): Promise<number>— counts items without retaining them.takeFromStream<T>(iterable: AsyncIterable<T>, count: number): Promise<T[]>— collects the firstcountitems, short-circuiting once enough are gathered.filterStream<T>(iterable: AsyncIterable<T>, predicate: (item: T) => boolean): AsyncIterable<T>— produces a new lazy iterable yielding only items matchingpredicate.mapStream<T, U>(iterable: AsyncIterable<T>, mapper: (item: T) => U): AsyncIterable<U>— produces a new lazy iterable yieldingmapper(item)for each input item.
takeFromStream SHALL stop pulling items as soon as count items have been collected — the underlying iterable's pagination SHALL NOT continue beyond what was needed. filterStream and mapStream SHALL be lazy — they SHALL NOT pre-fetch or pre-compute beyond what consumers next() request. AbortSignal cancellation propagates naturally through the for await chain — these helpers do not introduce a separate signal parameter.
Scenario: collectStream drains all items
- WHEN
collectStream(streamPaginated(adapter))is awaited against a 100-item adapter - THEN the result is an array of 100 items in order
Scenario: countStream returns total without retention
- WHEN
countStream(streamPaginated(adapter))is awaited against a 100-item adapter - THEN the result is
100AND no array of size 100 is held in memory at any point
Scenario: takeFromStream short-circuits at count
- WHEN
takeFromStream(streamPaginated(adapter, { pageSize: 10 }), 5)is awaited against a 100-item adapter - THEN the result is the first 5 items AND
adapter.fetchPageis called exactly once
Scenario: takeFromStream returns empty for count <= 0
- WHEN
takeFromStream(iter, 0)is awaited - THEN the result is
[]ANDiteris not advanced at all
Scenario: filterStream is lazy
- WHEN
filterStream(streamPaginated(adapter), (x) => x.id % 2 === 0)is iterated and only the first matching item is consumed - THEN the underlying adapter is queried at most enough times to find that one item — not the entire dataset
Scenario: mapStream is lazy
- WHEN
mapStream(streamPaginated(adapter), (x) => x.id)is iterated for two items - THEN the mapper is called exactly twice — not once per item in the underlying adapter
Verified by: packages/data/pagination/src/streaming/stream-pagination.test.ts::"collectStream drains all items" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"countStream returns total without retention" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"takeFromStream short-circuits at count" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"takeFromStream returns empty for count <= 0" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"filterStream is lazy and passthrough" AND packages/data/pagination/src/streaming/stream-pagination.test.ts::"mapStream is lazy and passthrough"