{"_id":"@azhulin/pagination-core","_rev":"3-c68bdb45a2b66ac68791b0f2f78cee35","name":"@azhulin/pagination-core","dist-tags":{"latest":"1.2.0"},"versions":{"1.0.0":{"name":"@azhulin/pagination-core","version":"1.0.0","keywords":["pagination","cursor","cursor-pagination","keyset","keyset-pagination","offset","offset-pagination","seek-pagination","relay","relay-connections","graphql-connections","query-plan","database-agnostic","typescript"],"author":{"name":"Alex Zhulin"},"license":"MIT","_id":"@azhulin/pagination-core@1.0.0","maintainers":[{"name":"azhulin","email":"zhulin.dem@gmail.com"}],"homepage":"https://github.com/azhulin/pagination#readme","bugs":{"url":"https://github.com/azhulin/pagination/issues"},"dist":{"shasum":"951a5e3bd96da5a768e3a7e11d4b48992c026aab","tarball":"https://registry.npmjs.org/@azhulin/pagination-core/-/pagination-core-1.0.0.tgz","fileCount":55,"integrity":"sha512-jnLK0G9QpkEAJ5vcjMpqd3AomHbPQyIP8EFDxhzhJOtcDUdf9EbfJCaT2w8f3kltAk18ben5pYQsRmSle2ieBA==","signatures":[{"sig":"MEUCIQCsBvi9CBj6w3HmFgk39j8Q+IlcknXO2tYk1s8AaXdAxQIgODl1lgtFN3bCfzCrk1xisFUBV6NQWNqOIAin9Pa40xE=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":51549},"main":"dist/index.js","_from":"file:azhulin-pagination-core-1.0.0.tgz","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"scripts":{},"_npmUser":{"name":"azhulin","email":"zhulin.dem@gmail.com"},"_resolved":"/private/var/folders/y0/gfj8tg7s6_56fbbh64kygfpc0000gp/T/9485b853b94913215ce9d8dffe096f80/azhulin-pagination-core-1.0.0.tgz","_integrity":"sha512-jnLK0G9QpkEAJ5vcjMpqd3AomHbPQyIP8EFDxhzhJOtcDUdf9EbfJCaT2w8f3kltAk18ben5pYQsRmSle2ieBA==","repository":{"url":"git+https://github.com/azhulin/pagination.git","type":"git","directory":"packages/core"},"_npmVersion":"11.6.2","description":"Framework-agnostic cursor + offset pagination engine that emits a database-agnostic query plan and builds Relay-style connections.","directories":{},"_nodeVersion":"22.19.0","_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/pagination-core_1.0.0_1784640881044_0.889427312015082","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@azhulin/pagination-core","version":"1.1.0","keywords":["pagination","cursor","cursor-pagination","keyset","keyset-pagination","offset","offset-pagination","seek-pagination","relay","relay-connections","graphql-connections","query-plan","database-agnostic","typescript"],"author":{"name":"Alex Zhulin"},"license":"MIT","_id":"@azhulin/pagination-core@1.1.0","maintainers":[{"name":"azhulin","email":"zhulin.dem@gmail.com"}],"homepage":"https://github.com/azhulin/pagination#readme","bugs":{"url":"https://github.com/azhulin/pagination/issues"},"dist":{"shasum":"dbac47f9ed5559e9b1755f4442762ae465d70f71","tarball":"https://registry.npmjs.org/@azhulin/pagination-core/-/pagination-core-1.1.0.tgz","fileCount":55,"integrity":"sha512-OXk0fjhFnxok8HocEsP/g26JbTUWB8bydMk3dB0DF3y29acrkYC9/A68LtfiMgMXlesTMKWnhVlvdeI8Z/+wIg==","signatures":[{"sig":"MEUCIFp3tSe3ly+G5w6MuNd2EftLmCWV7V4KTa1LHliGrAvRAiEAkg6o4rZiRVF2CiPAPrLHtJ1YsJwZRTFeDJpjix6suus=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":51549},"main":"dist/index.js","_from":"file:azhulin-pagination-core-1.1.0.tgz","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"scripts":{},"_npmUser":{"name":"azhulin","email":"zhulin.dem@gmail.com"},"_resolved":"/private/var/folders/y0/gfj8tg7s6_56fbbh64kygfpc0000gp/T/eb39ae13a4a6560f82ef2b6c29d58d2f/azhulin-pagination-core-1.1.0.tgz","_integrity":"sha512-OXk0fjhFnxok8HocEsP/g26JbTUWB8bydMk3dB0DF3y29acrkYC9/A68LtfiMgMXlesTMKWnhVlvdeI8Z/+wIg==","repository":{"url":"git+https://github.com/azhulin/pagination.git","type":"git","directory":"packages/core"},"_npmVersion":"11.6.2","description":"Framework-agnostic cursor + offset pagination engine that emits a database-agnostic query plan and builds Relay-style connections.","directories":{},"_nodeVersion":"22.19.0","_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/pagination-core_1.1.0_1784712849094_0.06247912981011128","host":"s3://npm-registry-packages-npm-production"}},"1.2.0":{"name":"@azhulin/pagination-core","version":"1.2.0","description":"Framework-agnostic cursor + offset pagination engine that emits a database-agnostic query plan and builds Relay-style connections.","main":"dist/index.js","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"keywords":["pagination","cursor","cursor-pagination","keyset","keyset-pagination","offset","offset-pagination","seek-pagination","relay","relay-connections","graphql-connections","query-plan","database-agnostic","typescript"],"author":{"name":"Alex Zhulin"},"license":"MIT","repository":{"type":"git","url":"git+https://github.com/azhulin/pagination.git","directory":"packages/core"},"scripts":{},"_id":"@azhulin/pagination-core@1.2.0","bugs":{"url":"https://github.com/azhulin/pagination/issues"},"homepage":"https://github.com/azhulin/pagination#readme","_integrity":"sha512-4IKPZvaKN9Dcxvjgrc8RG12HDkA9GEemfhHKomgFmjuPU3vMIwRjS/2D/hBYme8q/eiNf3tb2We+R9w7ga1uow==","_resolved":"/private/var/folders/y0/gfj8tg7s6_56fbbh64kygfpc0000gp/T/674d82818b258c9d45a43092e98550a4/azhulin-pagination-core-1.2.0.tgz","_from":"file:azhulin-pagination-core-1.2.0.tgz","_nodeVersion":"22.19.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-4IKPZvaKN9Dcxvjgrc8RG12HDkA9GEemfhHKomgFmjuPU3vMIwRjS/2D/hBYme8q/eiNf3tb2We+R9w7ga1uow==","shasum":"d0b1ad4575310c14c34da469c889a3a02d4500e9","tarball":"https://registry.npmjs.org/@azhulin/pagination-core/-/pagination-core-1.2.0.tgz","fileCount":57,"unpackedSize":55128,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCzC837bMuzxNgKtJcN6qXBreUUu1GLra8o90ql8cBiwgIgLCBiTm255Bpkb+dJXnvpSCWrIHa8PS7Jc3WdL+Uw5xI="}]},"_npmUser":{"name":"azhulin","email":"zhulin.dem@gmail.com"},"directories":{},"maintainers":[{"name":"azhulin","email":"zhulin.dem@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/pagination-core_1.2.0_1784726758189_0.2860939722202973"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-21T13:34:40.928Z","modified":"2026-07-22T13:25:58.765Z","1.0.0":"2026-07-21T13:34:41.186Z","1.1.0":"2026-07-22T09:34:09.237Z","1.2.0":"2026-07-22T13:25:58.314Z"},"bugs":{"url":"https://github.com/azhulin/pagination/issues"},"author":{"name":"Alex Zhulin"},"license":"MIT","homepage":"https://github.com/azhulin/pagination#readme","keywords":["pagination","cursor","cursor-pagination","keyset","keyset-pagination","offset","offset-pagination","seek-pagination","relay","relay-connections","graphql-connections","query-plan","database-agnostic","typescript"],"repository":{"type":"git","url":"git+https://github.com/azhulin/pagination.git","directory":"packages/core"},"description":"Framework-agnostic cursor + offset pagination engine that emits a database-agnostic query plan and builds Relay-style connections.","maintainers":[{"name":"azhulin","email":"zhulin.dem@gmail.com"}],"readme":"# Pagination Core\n\nFramework-agnostic **cursor (keyset)** and **offset** pagination engine. Zero runtime dependencies.\n\n```shell\nnpm i @azhulin/pagination-core\n```\n\nOne `Paginator` per entity, driven in two phases from a single instance:\n\n- **`plan()`** → a database-agnostic `QueryPlan`. Render it — yourself or with an adapter like\n  [`@azhulin/pagination-typeorm`](../typeorm) — then run your query.\n- **`connection({ rows })`** → a [Relay-style](https://relay.dev/graphql/connections.htm) `Connection` from those rows.\n\nCursor mode is true keyset — **no `COUNT`, no deep `OFFSET`**. Offset mode (`page`/`pageSize`) is the only mode that\nneeds a total count.\n\n## Quick start\n\nSubclass `Paginator` once per entity — in your data-access layer — and implement the hooks:\n\n```ts\nimport { Paginator } from '@azhulin/pagination-core'\nimport type { SortKey } from '@azhulin/pagination-core'\n\ninterface User {\n  id: number\n  email: string\n}\n\n// Cursor data must be JSON-serializable and align, in order, with getSortKeys().\ntype UserCursorData = { email: string; id: number }\n\nclass UserPaginator extends Paginator<User, UserCursorData> {\n  // Natural (forward) order — MUST be a total order (last key a unique tie-breaker). A bare string is shorthand for\n  // ascending; use an object for direction / null control (see \"Sort keys\").\n  protected getSortKeys(): SortKey[] {\n    return ['user.email', 'user.id']\n  }\n\n  // Encode: stamp every edge with a keyset cursor (required in both modes).\n  protected getCursorData(node: User): UserCursorData {\n    return { email: node.email, id: node.id }\n  }\n\n  // Decode: unpack a cursor into values aligned with getSortKeys() (cursor mode only).\n  protected extractCursorValues(cursor: UserCursorData): unknown[] {\n    return [cursor.email, cursor.id]\n  }\n}\n```\n\nFor untrusted cursors, also override `isValidCursorData` to reject malformed ones (see [Hooks](#hooks)).\n\nThen drive it — one instance for the whole request:\n\n```ts\nimport { PaginationMode } from '@azhulin/pagination-core'\n\nconst paginator = new UserPaginator(args, { maxLimit: 50, defaultLimit: 20 })\n\nconst plan = paginator.plan()\nconst rows = await runYourQuery(plan) // render the plan, run it, collect rows in plan.orderBy order\nconst totalCount = PaginationMode.Offset === plan.mode ? await runYourCount(plan) : undefined // offset mode only\n\nconst connection = await paginator.connection({ rows, totalCount })\n```\n\n`connection` is a framework-neutral `Connection<ConnectionEdge<User>>`, ready to serve as-is:\n\n```jsonc\n{\n  \"pageInfo\": {\n    \"mode\": \"Cursor\",\n    \"hasPreviousPage\": false,\n    \"hasNextPage\": true,\n    \"startCursor\": \"eyJlbWFpbCI6ImFAeC5jb20iLCJpZCI6MX0\",\n    \"endCursor\": \"eyJlbWFpbCI6ImVAeC5jb20iLCJpZCI6NX0\"\n  },\n  \"edges\": [{ \"cursor\": \"eyJlbWFpbCI6…\", \"node\": { \"id\": 1, \"email\": \"a@x.com\" } }]\n}\n```\n\n## Hooks\n\n| Hook                              | Required    | Purpose                                                                                  |\n|-----------------------------------|-------------|------------------------------------------------------------------------------------------|\n| `getSortKeys()`                   | always      | Ordering; both modes need a stable `ORDER BY`. Must be a total order.                    |\n| `isSortReversed()`                | optional    | Reverse the natural order for a descending request — flips each key's direction.         |\n| `getCursorData(node)`             | always      | Encode — every edge (offset pages too) gets a keyset cursor, usable as `after`/`before`. |\n| `extractCursorValues(cursor)`     | cursor mode | Decode — unpack a cursor into values aligned with `getSortKeys()`. The default throws.   |\n| `isValidCursorData(data)`         | optional    | Reject structurally-invalid decoded cursors (default accepts all).                       |\n| `getCursorDataError(name, value)` | optional    | Return an `Error` to throw on a bad cursor, or `null` to ignore it (the default).        |\n| `createConnectionEdge(edge)`      | optional    | Enrich each edge when widening the edge type.                                            |\n| `createConnection(connection)`    | optional    | Enrich the connection when widening the connection type.                                 |\n\nAn **offset-only** paginator implements just the two `always` hooks.\n\n## Sort keys\n\n`getSortKeys()` defines the **natural (ascending)** order. Each key is a field-name string — shorthand for ascending\nwith the SQL-default null placement — or an object for finer control:\n\n```ts\ntype SortKey = string | { field: string; direction: OrderDirection; nulls?: OrderNulls; nullsPinned?: boolean }\n```\n\n- **`nulls`** defaults to the SQL default: `Last` for `Asc`, `First` for `Desc`.\n- **`isSortReversed()`** — override to `true` for a descending request; it flips every key's direction, so\n  `getSortKeys()` stays direction-agnostic. An explicit `nulls` flips with it, unless **`nullsPinned: true`** pins it —\n  e.g. `{ field: 'dueDate', direction: OrderDirection.Asc, nulls: OrderNulls.Last, nullsPinned: true }` keeps NULLs\n  last ascending **and** descending.\n\n## Modes\n\nThe request arguments select the mode; mixing families throws `ModeConflictPaginationError`.\n\n| Arguments                      | Mode              | Behavior                                                                  |\n|--------------------------------|-------------------|---------------------------------------------------------------------------|\n| `first` / `after`              | cursor · forward  | First `first` edges after the cursor.                                     |\n| `last` / `before` (no `first`) | cursor · backward | Last `last` edges before the cursor.                                      |\n| `first` **and** `last`         | cursor · combined | Relay \"last of first\": fetch forward `first`, return the trailing `last`. |\n| `page` / `pageSize`            | offset            | `limit`/`offset` paging; requires `totalCount`.                           |\n\n<details>\n<summary><b>Combined mode</b>, step by step</summary>\n\nGiven the full ordered range and args `after: E, before: V, first: 8, last: 4`:\n\n```\nA 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\n\nafter: 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\nbefore: V  →  drop from V on                  F G H I J K L M N O P Q R S T U\nfirst: 8   →  keep first 8                    F G H I J K L M\nlast: 4    →  keep trailing 4                         J K L M   ← edges returned\n```\n\n`hasNextPage` is true (the range continued past `M`); `hasPreviousPage` is true (the `last` slice dropped `F G H I`).\n\n</details>\n\n## Options\n\n```ts\nnew UserPaginator(args, { maxLimit: 50, defaultLimit: 20 })\n```\n\n| Option         | Default    | Meaning                                                                                      |\n|----------------|------------|----------------------------------------------------------------------------------------------|\n| `maxLimit`     | `Infinity` | Hard cap on edges per request; larger requests are clamped down.                             |\n| `defaultLimit` | `maxLimit` | Size used when the request supplies none (`first`/`last`/`pageSize`). Clamped to `maxLimit`. |\n\nNon-finite or negative sizes are sanitized (`first: NaN` → default, `first: -3` → `0`); pages clamp to `≥ 1`.\n\n## Cursors\n\n`Cursor` encodes cursor data as JSON, base64url (URL-safe, unpadded) — opaque to clients:\n\n```ts\nimport { Cursor } from '@azhulin/pagination-core'\n\nCursor.toString({ email: 'a@x.com', id: 1 }) // → 'eyJlbWFpbCI6ImFAeC5jb20iLCJpZCI6MX0'\nCursor.toData('eyJlbWFpbCI6…') // → { email: 'a@x.com', id: 1 }  (null if invalid)\n```\n\nJS numbers lose precision above 2⁵³ — encode large integer ids as strings.\n\n## Errors\n\nBoth extend `PaginationError`, which carries a stable `code`, so you can branch on `instanceof` or `error.code`:\n\n| Error                              | `code`                | Thrown when                                                     |\n|------------------------------------|-----------------------|-----------------------------------------------------------------|\n| `ModeConflictPaginationError`      | `mode-conflict`       | Cursor and offset arguments are mixed.                          |\n| `MissingTotalCountPaginationError` | `missing-total-count` | `connection()` is called in offset mode without a `totalCount`. |\n\n<details>\n<summary><b>Rendering the plan yourself</b> (skip if you use an adapter)</summary>\n\n`plan()` returns a discriminated union on `mode` — every operator, direction, and null placement is already resolved,\nso render it verbatim:\n\n```ts\ninterface CursorQueryPlan {\n  mode: PaginationMode.Cursor\n  orderBy: PlanSortKey[] // { field, direction, nulls }\n  bounds: PlanBound[] // { side: 'after' | 'before', keys: PlanBoundKey[] }\n  limit: number // requested + 1 probe; Infinity = unbounded (no LIMIT)\n}\n\ninterface OffsetQueryPlan {\n  mode: PaginationMode.Offset\n  orderBy: PlanSortKey[]\n  limit: number // Infinity = unbounded\n  offset: number\n}\n```\n\nEach `PlanBoundKey` carries `{ field, operator: '<' | '>', value, nullsLast }` — a lexicographic tuple comparison over\nthe sort keys. **Security:** a sort key's `field` is interpolated into SQL **verbatim**, so it must be a\ntrusted/whitelisted identifier — never raw user input. Only cursor _values_ are parameterized.\n\n</details>\n\n## Adapters\n\n| Package                                                     | Adds                                                  |\n|-------------------------------------------------------------|-------------------------------------------------------|\n| [`@azhulin/pagination-typeorm`](../typeorm)                 | Runs a `QueryPlan` on a TypeORM `SelectQueryBuilder`. |\n| [`@azhulin/pagination-class-validator`](../class-validator) | Validates the connection arguments.                   |\n| [`@azhulin/pagination-nestjs-graphql`](../nestjs-graphql)   | NestJS code-first GraphQL connection types.           |\n\n## License\n\nMIT © 2022–2026 Alex Zhulin\n","readmeFilename":"README.md"}