npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

Pagination Core

Framework-agnostic cursor (keyset) and offset pagination engine. Zero runtime dependencies.

npm i @azhulin/pagination-core

One Paginator per entity, driven in two phases from a single instance:

  • plan() → a database-agnostic QueryPlan. Render it — yourself or with an adapter like @azhulin/pagination-typeorm — then run your query.
  • connection({ rows }) → a Relay-style Connection from 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 }
  • nulls defaults to the SQL default: Last for Asc, First for Desc.
  • isSortReversed() — override to true for a descending request; it flips every key's direction, so getSortKeys() stays direction-agnostic. An explicit nulls flips with it, unless nullsPinned: true pins 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 returned

hasNextPage 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: -30); 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