Skip to content

@studnicky/paginator

Cursor/page-list state tracker for paginated data sources.

Install

bash
pnpm add @studnicky/paginator

Usage

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:

ts
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

Loading example…

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.

HookWhen it firesArgs
onTransition(from, to, event)After a successful state transition, before the new state is returnedfrom 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 exhaustedstate: 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>.

Source on GitHub

Entities

@studnicky/paginator/entities exports every schema namespace in src/entities.

typescript
import { PaginatorIdleStateEntity } from '@studnicky/paginator/entities';

Interfaces

@studnicky/paginator/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.

typescript
import type { PaginatorAvailableCursorInterface } from '@studnicky/paginator/interfaces';

Exports

SymbolPurposeImport path
PaginatorProvides paginator functionality.@studnicky/paginator