{"id":49488252,"url":"https://github.com/xantiagoma/drizzle-cursor","last_synced_at":"2026-05-23T10:08:27.179Z","repository":{"id":193605059,"uuid":"689147506","full_name":"xantiagoma/drizzle-cursor","owner":"xantiagoma","description":"Drizzle ORM - Utils to generate cursor pagination","archived":false,"fork":false,"pushed_at":"2026-05-01T01:43:44.000Z","size":705,"stargazers_count":50,"open_issues_count":4,"forks_count":5,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-05-01T03:19:24.813Z","etag":null,"topics":["cursor","cursor-pagination","drizzle","drizzle-cursor","drizzle-orm","drizzle-pagination","drizzle-plugin","drizzleorm","pagination"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/drizzle-cursor","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/xantiagoma.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2023-09-08T23:38:23.000Z","updated_at":"2026-04-30T05:09:01.000Z","dependencies_parsed_at":null,"dependency_job_id":"be6a4e5b-67be-497b-853e-fd63350bad34","html_url":"https://github.com/xantiagoma/drizzle-cursor","commit_stats":null,"previous_names":["xantiagoma/drizzle-cursor"],"tags_count":21,"template":false,"template_full_name":null,"purl":"pkg:github/xantiagoma/drizzle-cursor","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xantiagoma%2Fdrizzle-cursor","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xantiagoma%2Fdrizzle-cursor/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xantiagoma%2Fdrizzle-cursor/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xantiagoma%2Fdrizzle-cursor/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/xantiagoma","download_url":"https://codeload.github.com/xantiagoma/drizzle-cursor/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xantiagoma%2Fdrizzle-cursor/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33391006,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-23T04:15:53.637Z","status":"ssl_error","status_checked_at":"2026-05-23T04:15:53.242Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cursor","cursor-pagination","drizzle","drizzle-cursor","drizzle-orm","drizzle-pagination","drizzle-plugin","drizzleorm","pagination"],"created_at":"2026-05-01T03:01:28.329Z","updated_at":"2026-05-23T10:08:27.172Z","avatar_url":"https://github.com/xantiagoma.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"./assets/logo.png\" alt=\"drizzle-cursor\" width=\"180\" /\u003e\n\u003c/p\u003e\n\n\u003ch1 align=\"center\"\u003edrizzle-cursor\u003c/h1\u003e\n\n\u003cp align=\"center\"\u003e\n  Cursor-based pagination for \u003ca href=\"https://github.com/drizzle-team/drizzle-orm\"\u003eDrizzle ORM\u003c/a\u003e.\u003cbr/\u003e\n  Supports any number of cursors, custom serialization, SQL expressions, and both query-builder and relational query APIs.\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://www.npmjs.com/package/drizzle-cursor\"\u003e\u003cimg src=\"https://img.shields.io/npm/v/drizzle-cursor?color=blue\" alt=\"npm version\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://www.npmjs.com/package/drizzle-cursor\"\u003e\u003cimg src=\"https://img.shields.io/npm/dm/drizzle-cursor\" alt=\"npm downloads\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://github.com/xantiagoma/drizzle-cursor/blob/main/LICENSE\"\u003e\u003cimg src=\"https://img.shields.io/npm/l/drizzle-cursor\" alt=\"license\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://github.com/xantiagoma/drizzle-cursor\"\u003e\u003cimg src=\"https://img.shields.io/github/stars/xantiagoma/drizzle-cursor?style=social\" alt=\"stars\" /\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n---\n\n| :zap: Supports *any* number of cursors.  |\n|------------------------------------------|\n\nCheck example at: [test/example.ts](./test/example.ts)\n\n## Installation\n\n```sh\nnpm install drizzle-cursor\n```\n\n## Compatibility\n\n- Drizzle `0.x` query-builder: guaranteed\n- Drizzle `0.x` relational query (RQB v1): `db.query` with SQL-compatible args\n- Drizzle `1.0.0-beta.x` query-builder: guaranteed\n- Drizzle `1.0.0-beta.x` RQB v1 (`db._query`) and RQB v2 (`db.query`): supported\n\nNotes:\n\n- Canonical cross-version path: query-builder\n  (`db.select().from(...).where(cursor.where(...)).orderBy(...cursor.orderBy)`).\n- `cursor.relations` is only for RQB v2 (`db.query`) and uses\n  `cursor.key` names as relational keys.\n- Nullable cursor columns remain discouraged due database and driver consistency differences.\n\n\u003e [!NOTE]\n\u003e\n\u003e Check types at [`TSDocs`](https://tsdocs.dev/docs/drizzle-cursor/latest/index.html)\n\u003e\n\n## Usage\n\nFirst create a cursor with `generateCursor` listing the Primary-key and the n-other cursors.\n\n\u003e [!WARNING]\n\u003e\n\u003e The order of the `cursors` matters because it's the way they are going to take in account on the generated SQL-query\n\u003e\n\u003e Is **not recommended** to use *nullable-columns* for your cursors, it depends on your RDBMS how it handles them.\n\u003e\n\n```ts\nconst cursorConfig: CursorConfig = {\n  cursors: [\n    { order: \"ASC\", key: \"lastName\", schema: schema.users.lastName },\n    { order: \"ASC\", key: \"firstName\", schema: schema.users.firstName },\n    { order: \"ASC\", key: \"middleName\", schema: schema.users.middleName },\n  ],\n  primaryCursor: { order: \"ASC\", key: \"id\", schema: schema.users.id },\n};\n\nconst cursor = generateCursor(cursorConfig);\n```\n\nPass `...cursor.orderBy` to `.orderBy` and `cursor.where()` to `.where` on query-builder calls.\n\n\u003e [!IMPORTANT]\n\u003e\n\u003e for the first batch of results `cursor.where()` is empty,\n\u003e\n\n## Querying\n\n### Query Builder\n\n```ts\nconst page1 = await db\n  .select({\n    lastName: schema.users.lastName,\n    firstName: schema.users.firstName,\n    middleName: schema.users.middleName,\n    id: schema.users.id,\n  })\n  .from(schema.users)\n  .orderBy(...cursor.orderBy) // Always include the order\n  .where(cursor.where()) // .where() is called empty the first time, meaning \"there's not previous records\"\n  .limit(page_size);\n```\n\nFor the subsequent queries you can send the last previous record on `cursor.where`\n\n```ts\nconst page2 = await db\n  .select() // .select() can vary while it includes the needed data to create the cursor\n  .from(schema.users)\n  .orderBy(...cursor.orderBy)\n  .where(cursor.where(page1.at(-1))) // last record of previous query (or any record \"before: the one you want to start with)\n  .limit(page_size);\n```\n\nor a token from the last item (useful to send to FE)\n\n#### With token\n\n```ts\nconst token = cursor.serialize(page2.at(-1)); // Send this string to FE\nconst pageFromToken = await db\n  .select({\n    lastName: schema.users.lastName,\n    firstName: schema.users.firstName,\n    middleName: schema.users.middleName,\n    id: schema.users.id,\n  })\n  .from(schema.users)\n  .orderBy(...cursor.orderBy)\n  .where(cursor.where(token)) // parse() is already handled internally by cursor.where\n  .limit(page_size);\n```\n\n### Query V1\n\n#### Drizzle v0 (`db.query`)\n\n```ts\nconst page1V0 = await db.query.users.findMany({\n  columns: {\n    lastName: true,\n    firstName: true,\n    middleName: true,\n    id: true,\n  },\n  orderBy: cursor.orderBy,\n  where: cursor.where(),\n  limit: page_size,\n});\n\nconst page2V0 = await db.query.users.findMany({\n  columns: {\n    lastName: true,\n    firstName: true,\n    middleName: true,\n    id: true,\n  },\n  orderBy: cursor.orderBy,\n  where: cursor.where(cursor.serialize(page1V0.at(-1))),\n  limit: page_size,\n});\n```\n\n#### Drizzle v1 (`db._query`) legacy\n\n```ts\nconst page1V1 = await db._query.users.findMany({\n  columns: {\n    lastName: true,\n    firstName: true,\n    middleName: true,\n    id: true,\n  },\n  orderBy: cursor.orderBy,\n  where: cursor.where(),\n  limit: page_size,\n});\n\nconst page2V1 = await db._query.users.findMany({\n  columns: {\n    lastName: true,\n    firstName: true,\n    middleName: true,\n    id: true,\n  },\n  orderBy: cursor.orderBy,\n  where: cursor.where(cursor.serialize(page1V1.at(-1))),\n  limit: page_size,\n});\n```\n\n### Query V2\n\n#### Drizzle v1 (`db.query`)\n\n```ts\nconst page1V2 = await db.query.users.findMany({\n  columns: {\n    lastName: true,\n    firstName: true,\n    middleName: true,\n    id: true,\n  },\n  orderBy: cursor.relations.orderBy,\n  where: cursor.relations.where(),\n  limit: page_size,\n});\n\nconst page2V2 = await db.query.users.findMany({\n  columns: {\n    lastName: true,\n    firstName: true,\n    middleName: true,\n    id: true,\n  },\n  orderBy: cursor.relations.orderBy,\n  where: cursor.relations.where(cursor.serialize(page1V2.at(-1))),\n  limit: page_size,\n});\n```\n\n## where\n\n`generateCursor` uses `cursor.where()` exactly for this rule:\n\n- `cursor.where()` with **no args** means first page → returns `undefined`.\n- `cursor.where(...)` with a **previous record** (`object`) or a **cursor string** (`token`) means next page.\n\nImportant: only keys in your cursor definition (`primaryCursor` + `cursors`) matter.  \nExtra keys in rows are ignored.\n\n```ts\nconst firstPage = await db\n  .select({\n    lastName: schema.users.lastName,\n    firstName: schema.users.firstName,\n    middleName: schema.users.middleName,\n    id: schema.users.id,\n  })\n  .from(schema.users)\n  .orderBy(...cursor.orderBy)\n  .where(cursor.where()) // No args =\u003e first page.\n  .limit(page_size);\n\nconst page2FromObject = await db\n  .select({ id: schema.users.id })\n  .from(schema.users)\n  .orderBy(...cursor.orderBy)\n  .where(cursor.where(firstPage.at(-1))) // Previous row object.\n  .limit(page_size);\n\nconst token = cursor.serialize(firstPage.at(-1));\n\nconst page2FromToken = await db\n  .select({ id: schema.users.id })\n  .from(schema.users)\n  .orderBy(...cursor.orderBy)\n  .where(cursor.where(token)) // Token string from previous page - MOCK EXAMPLE you in reality would get from the API request.\n  .limit(page_size);\n```\n\n## Custom parser + serializer\n\nIf the default `JSON.stringify/JSON.parse` pipeline is not enough, pass `parser` and `serializer` to `generateCursor`:\n\n- `parser`: converts the decoded payload into a JavaScript object.\n- `serializer`: converts your payload object into a string before encode.\n\nParser/serializer options (payload layer):\n\n- [superjson](https://github.com/blitz-js/superjson)\n- [devalue](https://github.com/sveltejs/devalue)\n- [msgpackr](https://github.com/kriszyp/msgpackr)\n- [flatted](https://github.com/WebReflection/flatted)\n- [yaml](https://github.com/eemeli/yaml) / [js-yaml](https://github.com/nodeca/js-yaml)\n- [toml](https://github.com/BinaryMuse/toml)\n- [JSON5](https://github.com/json5/json5)\n- [zipson](https://github.com/gregersrygg/zipson)\n- [ZON](https://github.com/ZON-Format/zon-TS)\n- [tron](https://tron-format.github.io/)\n- [toon](https://github.com/toon-format/toon)\n- [csv](https://github.com/adaltas/node-csv)\n- etc (`serializer: (value: T) =\u003e string`, `parser: (value: string) =\u003e T`)\n\nExample (from the extended tests):\n\n```ts\nimport { generateCursor } from \"drizzle-cursor\";\nimport { parse, stringify } from \"superjson\";\n\nconst cursor = generateCursor(\n  {\n    primaryCursor: { key: \"id\", schema: users.id, order: \"ASC\" },\n    cursors: [{ key: \"slug\", schema: users.slug, order: \"ASC\" }],\n  },\n  {\n    serializer: (value) =\u003e `cur_${stringify(value)}`,\n    parser: (value) =\u003e parse(value.slice(4)),\n  },\n);\n\nconst token = cursor.serialize({ id: 1, slug: \"slug-01\" });\nconst parsed = cursor.parse(token);\n```\n\n## Custom encoder + decoder\n\nUse `encoder` and `decoder` when you need to post-process the full token string\n(prefix, URL-safe base encoding, encryption, obfuscation, compression, etc.).\n\n- `encoder`: transforms the serialized payload into the final token string.\n- `decoder`: restores the serialized payload for `parser`.\n\nEncoder/decoder options (token layer):\n\n- `base64url` / `base-x` (`bun.toBase64`, `Buffer`, `base-x` alphabets).\n- [base-x](https://github.com/cryptocoinjs/base-x)\n- `AES` (`crypto.createCipheriv` / `createDecipheriv`).\n- URL-safe wrappers and signatures (HMAC/JWT-like) to prevent tampering.\n- custom prefixing/suffixing and checksums.\n- [base64-js](https://github.com/beatgammit/base64-js)\n- [base64url](https://github.com/joepie91/node-base64-url)\n- [tweetnacl](https://github.com/dchest/tweetnacl-js) (for signing/encrypt-like workflows)\n- etc (`encoder: (value: string) =\u003e string`, `decoder: (value: string) =\u003e string`)\n\n`generateCursor` second argument shape (`options`):\n\n```ts\ntype CursorRecord = Record\u003cstring, unknown\u003e;\n\ntype CursorOptions\u003cT extends CursorRecord = CursorRecord\u003e = {\n  decoder?: (value: string) =\u003e string;\n  encoder?: (value: string) =\u003e string;\n  parser?: (value: string) =\u003e T;\n  serializer?: (value: T) =\u003e string;\n  parse?: (cursor: string | null) =\u003e T | null;\n  serialize?: (data?: T | null) =\u003e string | null;\n};\n```\n\nNotes:\n\n- `parser`, `serializer`, `encoder`, `decoder` are usually enough to customize the token pipeline.\n- `parse` and `serialize` are advanced overrides (rarely needed) that replace the full internals:\n  - `serialize`: build the full cursor token.\n  - `parse`: read and validate the full cursor token.\n- In 99% of cases, prefer `parser`/`serializer` (+ optional `encoder`/`decoder`) and keep `parse`/`serialize` as defaults.\n\n```ts\nimport { generateCursor } from \"drizzle-cursor\";\nimport BaseX from \"base-x\";\n\nconst prefix = \"cur_\";\nconst baseX = BaseX(\"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\");\n\nconst encoder = (value: string) =\u003e `${prefix}${baseX.encode(Buffer.from(value, \"utf-8\"))}`;\nconst decoder = (value: string) =\u003e {\n  if (!value.startsWith(prefix)) {\n    throw new Error(\"Invalid cursor token\");\n  }\n  return Buffer.from(baseX.decode(value.slice(prefix.length))).toString(\"utf-8\");\n};\n\nconst cursor = generateCursor(\n  {\n    primaryCursor: { key: \"id\", schema: users.id, order: \"ASC\" },\n    cursors: [{ key: \"slug\", schema: users.slug, order: \"ASC\" }],\n  },\n  { encoder, decoder },\n);\n\nconst token = cursor.serialize({ id: 1, slug: \"slug-01\" });\nconst parsed = cursor.parse(token);\n```\n\n## SQL Expression Cursors\n\nInstead of a table column (`schema`), you can use a raw Drizzle `sql` expression as a cursor. This is useful for sorting by computed or virtual values like case-insensitive names, concatenated fields, or any expression your database can evaluate.\n\n```ts\nimport { sql } from \"drizzle-orm\";\nimport { generateCursor } from \"drizzle-cursor\";\n\nconst rankUpperName = sql\u003cstring\u003e`${users.rank}::text || '-' || upper(${users.firstName})`;\n\nconst cursor = generateCursor({\n  primaryCursor: { key: \"id\", schema: users.id, order: \"ASC\" },\n  cursors: [\n    { key: \"rankUpperName\", sql: rankUpperName, order: \"ASC\" },\n  ],\n});\n```\n\nThe `key` must match a field in the result row so the token pipeline can read its value for pagination. Include the expression in your `select` to make it available:\n\n```ts\nconst page1 = await db\n  .select({\n    id: users.id,\n    firstName: users.firstName,\n    rankUpperName, // same sql expression — makes it available in the row\n  })\n  .from(users)\n  .orderBy(...cursor.orderBy)\n  .where(cursor.where())\n  .limit(page_size);\n\nconst page2 = await db\n  .select({ id: users.id, firstName: users.firstName, rankUpperName })\n  .from(users)\n  .orderBy(...cursor.orderBy)\n  .where(cursor.where(cursor.serialize(page1.at(-1))))\n  .limit(page_size);\n```\n\n### SQL cursors with RQB v2 (`cursor.relations`)\n\nWhen any cursor uses `sql`, `cursor.relations.orderBy` becomes a `() =\u003e SQL[]` callback instead of a plain `Record\u003cstring, \"asc\" | \"desc\"\u003e` — pass it directly to the RQB v2 `orderBy` option:\n\n```ts\nconst page1 = await db.query.users.findMany({\n  orderBy: cursor.relations.orderBy, // () =\u003e SQL[] when SQL cursors are present\n  where: cursor.relations.where(),\n  limit: page_size,\n});\n```\n\n\u003e [!NOTE]\n\u003e\n\u003e `cursor.relations.where()` with SQL expression cursors produces `{ RAW: ... }` conditions\n\u003e that work correctly in RQB v2 when the SQL expression does **not** reference the table through\n\u003e Drizzle's aliased context. For full portability across all query modes, prefer the query-builder\n\u003e path (`cursor.where()`) or RQB v1 (`db._query`) when paginating by SQL expressions.\n\n### Named types\n\n`TableCursor` and `SQLCursor` are exported for user-facing type annotations:\n\n```ts\nimport type { CursorConfig, SQLCursor, TableCursor } from \"drizzle-cursor\";\n\nconst config: CursorConfig\u003cSQLCursor\u003e = {\n  primaryCursor: { key: \"id\", sql: sql`${users.id}`, order: \"ASC\" },\n};\n```\n\n## Contributing\n\nSubmit an Issue with a minimal reproducible example.\n\n\u003e PRs are welcome\n\nMaintainers: release and prerelease workflow lives in `CONTRIBUTING.md`.\n\n## See Also\n\n- [drizzle-audit](https://github.com/xantiagoma/drizzle-audit) — Configurable audit logging for Drizzle ORM (by the same author)\n\n## License\n\nMIT / Do whatever you want.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fxantiagoma%2Fdrizzle-cursor","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fxantiagoma%2Fdrizzle-cursor","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fxantiagoma%2Fdrizzle-cursor/lists"}