@azhulin/pagination-core
v1.2.0
Published
Framework-agnostic cursor + offset pagination engine that emits a database-agnostic query plan and builds Relay-style connections.
Maintainers
Readme
Pagination Core
Framework-agnostic cursor (keyset) and offset pagination engine. Zero runtime dependencies.
npm i @azhulin/pagination-coreOne Paginator per entity, driven in two phases from a single instance:
plan()→ a database-agnosticQueryPlan. Render it — yourself or with an adapter like@azhulin/pagination-typeorm— then run your query.connection({ rows })→ a Relay-styleConnectionfrom those rows.
Cursor mode is true keyset — no COUNT, no deep OFFSET. Offset mode (page/pageSize) is the only mode that
needs a total count.
Quick start
Subclass Paginator once per entity — in your data-access layer — and implement the hooks:
import { Paginator } from '@azhulin/pagination-core'
import type { SortKey } from '@azhulin/pagination-core'
interface User {
id: number
email: string
}
// Cursor data must be JSON-serializable and align, in order, with getSortKeys().
type UserCursorData = { email: string; id: number }
class UserPaginator extends Paginator<User, UserCursorData> {
// Natural (forward) order — MUST be a total order (last key a unique tie-breaker). A bare string is shorthand for
// ascending; use an object for direction / null control (see "Sort keys").
protected getSortKeys(): SortKey[] {
return ['user.email', 'user.id']
}
// Encode: stamp every edge with a keyset cursor (required in both modes).
protected getCursorData(node: User): UserCursorData {
return { email: node.email, id: node.id }
}
// Decode: unpack a cursor into values aligned with getSortKeys() (cursor mode only).
protected extractCursorValues(cursor: UserCursorData): unknown[] {
return [cursor.email, cursor.id]
}
}For untrusted cursors, also override isValidCursorData to reject malformed ones (see Hooks).
Then drive it — one instance for the whole request:
import { PaginationMode } from '@azhulin/pagination-core'
const paginator = new UserPaginator(args, { maxLimit: 50, defaultLimit: 20 })
const plan = paginator.plan()
const rows = await runYourQuery(plan) // render the plan, run it, collect rows in plan.orderBy order
const totalCount = PaginationMode.Offset === plan.mode ? await runYourCount(plan) : undefined // offset mode only
const connection = await paginator.connection({ rows, totalCount })connection is a framework-neutral Connection<ConnectionEdge<User>>, ready to serve as-is:
{
"pageInfo": {
"mode": "Cursor",
"hasPreviousPage": false,
"hasNextPage": true,
"startCursor": "eyJlbWFpbCI6ImFAeC5jb20iLCJpZCI6MX0",
"endCursor": "eyJlbWFpbCI6ImVAeC5jb20iLCJpZCI6NX0"
},
"edges": [{ "cursor": "eyJlbWFpbCI6…", "node": { "id": 1, "email": "[email protected]" } }]
}Hooks
| Hook | Required | Purpose |
|-----------------------------------|-------------|------------------------------------------------------------------------------------------|
| getSortKeys() | always | Ordering; both modes need a stable ORDER BY. Must be a total order. |
| isSortReversed() | optional | Reverse the natural order for a descending request — flips each key's direction. |
| getCursorData(node) | always | Encode — every edge (offset pages too) gets a keyset cursor, usable as after/before. |
| extractCursorValues(cursor) | cursor mode | Decode — unpack a cursor into values aligned with getSortKeys(). The default throws. |
| isValidCursorData(data) | optional | Reject structurally-invalid decoded cursors (default accepts all). |
| getCursorDataError(name, value) | optional | Return an Error to throw on a bad cursor, or null to ignore it (the default). |
| createConnectionEdge(edge) | optional | Enrich each edge when widening the edge type. |
| createConnection(connection) | optional | Enrich the connection when widening the connection type. |
An offset-only paginator implements just the two always hooks.
Sort keys
getSortKeys() defines the natural (ascending) order. Each key is a field-name string — shorthand for ascending
with the SQL-default null placement — or an object for finer control:
type SortKey = string | { field: string; direction: OrderDirection; nulls?: OrderNulls; nullsPinned?: boolean }nullsdefaults to the SQL default:LastforAsc,FirstforDesc.isSortReversed()— override totruefor a descending request; it flips every key's direction, sogetSortKeys()stays direction-agnostic. An explicitnullsflips with it, unlessnullsPinned: truepins it — e.g.{ field: 'dueDate', direction: OrderDirection.Asc, nulls: OrderNulls.Last, nullsPinned: true }keeps NULLs last ascending and descending.
Modes
The request arguments select the mode; mixing families throws ModeConflictPaginationError.
| Arguments | Mode | Behavior |
|--------------------------------|-------------------|---------------------------------------------------------------------------|
| first / after | cursor · forward | First first edges after the cursor. |
| last / before (no first) | cursor · backward | Last last edges before the cursor. |
| first and last | cursor · combined | Relay "last of first": fetch forward first, return the trailing last. |
| page / pageSize | offset | limit/offset paging; requires totalCount. |
Given the full ordered range and args after: E, before: V, first: 8, last: 4:
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
after: E → drop through E F G H I J K L M N O P Q R S T U V W X Y Z
before: V → drop from V on F G H I J K L M N O P Q R S T U
first: 8 → keep first 8 F G H I J K L M
last: 4 → keep trailing 4 J K L M ← edges returnedhasNextPage is true (the range continued past M); hasPreviousPage is true (the last slice dropped F G H I).
Options
new UserPaginator(args, { maxLimit: 50, defaultLimit: 20 })| Option | Default | Meaning |
|----------------|------------|----------------------------------------------------------------------------------------------|
| maxLimit | Infinity | Hard cap on edges per request; larger requests are clamped down. |
| defaultLimit | maxLimit | Size used when the request supplies none (first/last/pageSize). Clamped to maxLimit. |
Non-finite or negative sizes are sanitized (first: NaN → default, first: -3 → 0); pages clamp to ≥ 1.
Cursors
Cursor encodes cursor data as JSON, base64url (URL-safe, unpadded) — opaque to clients:
import { Cursor } from '@azhulin/pagination-core'
Cursor.toString({ email: '[email protected]', id: 1 }) // → 'eyJlbWFpbCI6ImFAeC5jb20iLCJpZCI6MX0'
Cursor.toData('eyJlbWFpbCI6…') // → { email: '[email protected]', id: 1 } (null if invalid)JS numbers lose precision above 2⁵³ — encode large integer ids as strings.
Errors
Both extend PaginationError, which carries a stable code, so you can branch on instanceof or error.code:
| Error | code | Thrown when |
|------------------------------------|-----------------------|-----------------------------------------------------------------|
| ModeConflictPaginationError | mode-conflict | Cursor and offset arguments are mixed. |
| MissingTotalCountPaginationError | missing-total-count | connection() is called in offset mode without a totalCount. |
plan() returns a discriminated union on mode — every operator, direction, and null placement is already resolved,
so render it verbatim:
interface CursorQueryPlan {
mode: PaginationMode.Cursor
orderBy: PlanSortKey[] // { field, direction, nulls }
bounds: PlanBound[] // { side: 'after' | 'before', keys: PlanBoundKey[] }
limit: number // requested + 1 probe; Infinity = unbounded (no LIMIT)
}
interface OffsetQueryPlan {
mode: PaginationMode.Offset
orderBy: PlanSortKey[]
limit: number // Infinity = unbounded
offset: number
}Each PlanBoundKey carries { field, operator: '<' | '>', value, nullsLast } — a lexicographic tuple comparison over
the sort keys. Security: a sort key's field is interpolated into SQL verbatim, so it must be a
trusted/whitelisted identifier — never raw user input. Only cursor values are parameterized.
Adapters
| Package | Adds |
|-------------------------------------------------------------|-------------------------------------------------------|
| @azhulin/pagination-typeorm | Runs a QueryPlan on a TypeORM SelectQueryBuilder. |
| @azhulin/pagination-class-validator | Validates the connection arguments. |
| @azhulin/pagination-nestjs-graphql | NestJS code-first GraphQL connection types. |
License
MIT © 2022–2026 Alex Zhulin
