@studnicky/paginator
Cursor/page-list state tracker for paginated data sources.
Install
pnpm add @studnicky/paginatorUsage
Paginator does not fetch data — the caller supplies each fetched page via next(page, nextCursor), and the tracker reports whether more pages are expected and holds the pages received so far:
import type {
PaginatorIdleStateEntity,
PaginatorResetEventEntity
} from '../src/entities/index.js';
import type {
PaginatorExhaustedStateInterface,
PaginatorHasMoreStateInterface,
PaginatorPageReceivedEventInterface
} from '../src/interfaces/index.js';
import type { TransitionRecordEntity } from './entities/TransitionRecordEntity.js';
import { Paginator } from '../src/index.js';
class TelemetryPaginator<TPage, TCursor> extends Paginator<TPage, TCursor> {
readonly transitions: TransitionRecordEntity.Type[] = [];
protected override onTransition(
from: PaginatorIdleStateEntity.Type
| PaginatorHasMoreStateInterface<TPage, TCursor>
| PaginatorExhaustedStateInterface<TPage>,
to: PaginatorIdleStateEntity.Type
| PaginatorHasMoreStateInterface<TPage, TCursor>
| PaginatorExhaustedStateInterface<TPage>,
event: PaginatorResetEventEntity.Type | PaginatorPageReceivedEventInterface<TPage, TCursor>
): void {
console.log(`[paginator] ${from.variant} --${event.type}--> ${to.variant}`);
this.transitions.push({ 'event': event.type, 'from': from.variant, 'to': to.variant });
}
protected override onEnterState(
state: PaginatorIdleStateEntity.Type
| PaginatorHasMoreStateInterface<TPage, TCursor>
| PaginatorExhaustedStateInterface<TPage>
): void {
console.log(`[paginator] entered ${state.variant}`);
}
protected override onExitState(
state: PaginatorIdleStateEntity.Type
| PaginatorHasMoreStateInterface<TPage, TCursor>
| PaginatorExhaustedStateInterface<TPage>
): void {
console.log(`[paginator] exited ${state.variant}`);
}
}
const paginator = TelemetryPaginator.create();
// idle -> hasMore
paginator.next('page-1', { 'cursor': 2, 'exhausted': false });
// hasMore -> hasMore (no transition hooks fire — same variant)
paginator.next('page-2', { 'cursor': 3, 'exhausted': false });
// hasMore -> exhausted
paginator.next('page-3', { 'exhausted': true });
// exhausted -> idle
paginator.reset();
console.log('Pages:', paginator.pages);
console.log('Transitions:', paginator.transitions);nextCursor is PaginatorAvailableCursorInterface<TCursor> | PaginatorExhaustedCursorEntity.Type. Pass { exhausted: false, cursor } when another page is available and { exhausted: true } when the source is exhausted. The explicit discriminant keeps undefined available as a legitimate cursor value.
The pages getter returns a defensive snapshot in receipt order.
Try it
The output shows onTransition recording idle -> hasMore -> exhausted -> idle as pages arrive and the source resets, while a same-variant hasMore -> hasMore page fetch fires no transition hooks at all.
Observability hooks
Subclass Paginator and override any protected hook to inject trace logging, metrics, or side-effects at the exact stage where they are needed. Hooks should stay fast and non-blocking; observer-hook failures are contained so pagination state still wins.
| Hook | When it fires | Args |
|---|---|---|
onTransition(from, to, event) | After a successful state transition, before the new state is returned | from and to: PaginatorIdleStateEntity.Type | PaginatorHasMoreStateInterface<TPage, TCursor> | PaginatorExhaustedStateInterface<TPage>; event: PaginatorResetEventEntity.Type | PaginatorPageReceivedEventInterface<TPage, TCursor> |
onEnterState(state) | When entering a new state variant (not called when the variant is unchanged) | state: PaginatorIdleStateEntity.Type | PaginatorHasMoreStateInterface<TPage, TCursor> | PaginatorExhaustedStateInterface<TPage> |
onExitState(state) | When exiting a state variant (not called when the variant is unchanged) | state: PaginatorIdleStateEntity.Type | PaginatorHasMoreStateInterface<TPage, TCursor> | PaginatorExhaustedStateInterface<TPage> |
onTransitionRejected(state, event, reason) | When next() is called after the source is already exhausted | state: the explicit state union above; event: PaginatorResetEventEntity.Type | PaginatorPageReceivedEventInterface<TPage, TCursor>; reason: string |
The base class never calls any logger or metrics library. All hooks are no-ops by default.
Exported contracts
Pure discriminants and fixed pure-data variants are entity-derived: PaginatorAvailableCursorDiscriminantEntity.Type, PaginatorExhaustedCursorEntity.Type, PaginatorExhaustedStateDiscriminantEntity.Type, PaginatorHasMoreStateDiscriminantEntity.Type, PaginatorIdleStateEntity.Type, PaginatorPageReceivedEventDiscriminantEntity.Type, and PaginatorResetEventEntity.Type. Generic composite variants are PaginatorAvailableCursorInterface<TCursor>, PaginatorHasMoreStateInterface<TPage, TCursor>, PaginatorExhaustedStateInterface<TPage>, and PaginatorPageReceivedEventInterface<TPage, TCursor>.
Entities
@studnicky/paginator/entities exports every schema namespace in src/entities.
import { PaginatorIdleStateEntity } from '@studnicky/paginator/entities';Interfaces
@studnicky/paginator/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.
import type { PaginatorAvailableCursorInterface } from '@studnicky/paginator/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
Paginator | Provides paginator functionality. | @studnicky/paginator |