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

drizzle-cursor

v0.7.2

Published

Utils for Drizzle ORM cursor based pagination

Readme


| :zap: Supports any number of cursors. | |------------------------------------------|

Check example at: test/example.ts

Installation

npm install drizzle-cursor

Compatibility

  • Drizzle 0.x query-builder: guaranteed
  • Drizzle 0.x relational query (RQB v1): db.query with SQL-compatible args
  • Drizzle 1.0.0-beta.x query-builder: guaranteed
  • Drizzle 1.0.0-beta.x RQB v1 (db._query) and RQB v2 (db.query): supported

Notes:

  • Canonical cross-version path: query-builder (db.select().from(...).where(cursor.where(...)).orderBy(...cursor.orderBy)).
  • cursor.relations is only for RQB v2 (db.query) and uses cursor.key names as relational keys.
  • Nullable cursor columns remain discouraged due database and driver consistency differences.

[!NOTE]

Check types at TSDocs

Usage

First create a cursor with generateCursor listing the Primary-key and the n-other cursors.

[!WARNING]

The order of the cursors matters because it's the way they are going to take in account on the generated SQL-query

Is not recommended to use nullable-columns for your cursors, it depends on your RDBMS how it handles them.

const cursorConfig: CursorConfig = {
  cursors: [
    { order: "ASC", key: "lastName", schema: schema.users.lastName },
    { order: "ASC", key: "firstName", schema: schema.users.firstName },
    { order: "ASC", key: "middleName", schema: schema.users.middleName },
  ],
  primaryCursor: { order: "ASC", key: "id", schema: schema.users.id },
};

const cursor = generateCursor(cursorConfig);

Pass ...cursor.orderBy to .orderBy and cursor.where() to .where on query-builder calls.

[!IMPORTANT]

for the first batch of results cursor.where() is empty,

Querying

Query Builder

const page1 = await db
  .select({
    lastName: schema.users.lastName,
    firstName: schema.users.firstName,
    middleName: schema.users.middleName,
    id: schema.users.id,
  })
  .from(schema.users)
  .orderBy(...cursor.orderBy) // Always include the order
  .where(cursor.where()) // .where() is called empty the first time, meaning "there's not previous records"
  .limit(page_size);

For the subsequent queries you can send the last previous record on cursor.where

const page2 = await db
  .select() // .select() can vary while it includes the needed data to create the cursor
  .from(schema.users)
  .orderBy(...cursor.orderBy)
  .where(cursor.where(page1.at(-1))) // last record of previous query (or any record "before: the one you want to start with)
  .limit(page_size);

or a token from the last item (useful to send to FE)

With token

const token = cursor.serialize(page2.at(-1)); // Send this string to FE
const pageFromToken = await db
  .select({
    lastName: schema.users.lastName,
    firstName: schema.users.firstName,
    middleName: schema.users.middleName,
    id: schema.users.id,
  })
  .from(schema.users)
  .orderBy(...cursor.orderBy)
  .where(cursor.where(token)) // parse() is already handled internally by cursor.where
  .limit(page_size);

Query V1

Drizzle v0 (db.query)

const page1V0 = await db.query.users.findMany({
  columns: {
    lastName: true,
    firstName: true,
    middleName: true,
    id: true,
  },
  orderBy: cursor.orderBy,
  where: cursor.where(),
  limit: page_size,
});

const page2V0 = await db.query.users.findMany({
  columns: {
    lastName: true,
    firstName: true,
    middleName: true,
    id: true,
  },
  orderBy: cursor.orderBy,
  where: cursor.where(cursor.serialize(page1V0.at(-1))),
  limit: page_size,
});

Drizzle v1 (db._query) legacy

const page1V1 = await db._query.users.findMany({
  columns: {
    lastName: true,
    firstName: true,
    middleName: true,
    id: true,
  },
  orderBy: cursor.orderBy,
  where: cursor.where(),
  limit: page_size,
});

const page2V1 = await db._query.users.findMany({
  columns: {
    lastName: true,
    firstName: true,
    middleName: true,
    id: true,
  },
  orderBy: cursor.orderBy,
  where: cursor.where(cursor.serialize(page1V1.at(-1))),
  limit: page_size,
});

Query V2

Drizzle v1 (db.query)

const page1V2 = await db.query.users.findMany({
  columns: {
    lastName: true,
    firstName: true,
    middleName: true,
    id: true,
  },
  orderBy: cursor.relations.orderBy,
  where: cursor.relations.where(),
  limit: page_size,
});

const page2V2 = await db.query.users.findMany({
  columns: {
    lastName: true,
    firstName: true,
    middleName: true,
    id: true,
  },
  orderBy: cursor.relations.orderBy,
  where: cursor.relations.where(cursor.serialize(page1V2.at(-1))),
  limit: page_size,
});

where

generateCursor uses cursor.where() exactly for this rule:

  • cursor.where() with no args means first page → returns undefined.
  • cursor.where(...) with a previous record (object) or a cursor string (token) means next page.

Important: only keys in your cursor definition (primaryCursor + cursors) matter.
Extra keys in rows are ignored.

const firstPage = await db
  .select({
    lastName: schema.users.lastName,
    firstName: schema.users.firstName,
    middleName: schema.users.middleName,
    id: schema.users.id,
  })
  .from(schema.users)
  .orderBy(...cursor.orderBy)
  .where(cursor.where()) // No args => first page.
  .limit(page_size);

const page2FromObject = await db
  .select({ id: schema.users.id })
  .from(schema.users)
  .orderBy(...cursor.orderBy)
  .where(cursor.where(firstPage.at(-1))) // Previous row object.
  .limit(page_size);

const token = cursor.serialize(firstPage.at(-1));

const page2FromToken = await db
  .select({ id: schema.users.id })
  .from(schema.users)
  .orderBy(...cursor.orderBy)
  .where(cursor.where(token)) // Token string from previous page - MOCK EXAMPLE you in reality would get from the API request.
  .limit(page_size);

Custom parser + serializer

If the default JSON.stringify/JSON.parse pipeline is not enough, pass parser and serializer to generateCursor:

  • parser: converts the decoded payload into a JavaScript object.
  • serializer: converts your payload object into a string before encode.

Parser/serializer options (payload layer):

Example (from the extended tests):

import { generateCursor } from "drizzle-cursor";
import { parse, stringify } from "superjson";

const cursor = generateCursor(
  {
    primaryCursor: { key: "id", schema: users.id, order: "ASC" },
    cursors: [{ key: "slug", schema: users.slug, order: "ASC" }],
  },
  {
    serializer: (value) => `cur_${stringify(value)}`,
    parser: (value) => parse(value.slice(4)),
  },
);

const token = cursor.serialize({ id: 1, slug: "slug-01" });
const parsed = cursor.parse(token);

Custom encoder + decoder

Use encoder and decoder when you need to post-process the full token string (prefix, URL-safe base encoding, encryption, obfuscation, compression, etc.).

  • encoder: transforms the serialized payload into the final token string.
  • decoder: restores the serialized payload for parser.

Encoder/decoder options (token layer):

  • base64url / base-x (bun.toBase64, Buffer, base-x alphabets).
  • base-x
  • AES (crypto.createCipheriv / createDecipheriv).
  • URL-safe wrappers and signatures (HMAC/JWT-like) to prevent tampering.
  • custom prefixing/suffixing and checksums.
  • base64-js
  • base64url
  • tweetnacl (for signing/encrypt-like workflows)
  • etc (encoder: (value: string) => string, decoder: (value: string) => string)

generateCursor second argument shape (options):

type CursorRecord = Record<string, unknown>;

type CursorOptions<T extends CursorRecord = CursorRecord> = {
  decoder?: (value: string) => string;
  encoder?: (value: string) => string;
  parser?: (value: string) => T;
  serializer?: (value: T) => string;
  parse?: (cursor: string | null) => T | null;
  serialize?: (data?: T | null) => string | null;
};

Notes:

  • parser, serializer, encoder, decoder are usually enough to customize the token pipeline.
  • parse and serialize are advanced overrides (rarely needed) that replace the full internals:
    • serialize: build the full cursor token.
    • parse: read and validate the full cursor token.
  • In 99% of cases, prefer parser/serializer (+ optional encoder/decoder) and keep parse/serialize as defaults.
import { generateCursor } from "drizzle-cursor";
import BaseX from "base-x";

const prefix = "cur_";
const baseX = BaseX("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz");

const encoder = (value: string) => `${prefix}${baseX.encode(Buffer.from(value, "utf-8"))}`;
const decoder = (value: string) => {
  if (!value.startsWith(prefix)) {
    throw new Error("Invalid cursor token");
  }
  return Buffer.from(baseX.decode(value.slice(prefix.length))).toString("utf-8");
};

const cursor = generateCursor(
  {
    primaryCursor: { key: "id", schema: users.id, order: "ASC" },
    cursors: [{ key: "slug", schema: users.slug, order: "ASC" }],
  },
  { encoder, decoder },
);

const token = cursor.serialize({ id: 1, slug: "slug-01" });
const parsed = cursor.parse(token);

SQL Expression Cursors

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

import { sql } from "drizzle-orm";
import { generateCursor } from "drizzle-cursor";

const rankUpperName = sql<string>`${users.rank}::text || '-' || upper(${users.firstName})`;

const cursor = generateCursor({
  primaryCursor: { key: "id", schema: users.id, order: "ASC" },
  cursors: [
    { key: "rankUpperName", sql: rankUpperName, order: "ASC" },
  ],
});

The 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:

const page1 = await db
  .select({
    id: users.id,
    firstName: users.firstName,
    rankUpperName, // same sql expression — makes it available in the row
  })
  .from(users)
  .orderBy(...cursor.orderBy)
  .where(cursor.where())
  .limit(page_size);

const page2 = await db
  .select({ id: users.id, firstName: users.firstName, rankUpperName })
  .from(users)
  .orderBy(...cursor.orderBy)
  .where(cursor.where(cursor.serialize(page1.at(-1))))
  .limit(page_size);

SQL cursors with RQB v2 (cursor.relations)

When any cursor uses sql, cursor.relations.orderBy becomes a () => SQL[] callback instead of a plain Record<string, "asc" | "desc"> — pass it directly to the RQB v2 orderBy option:

const page1 = await db.query.users.findMany({
  orderBy: cursor.relations.orderBy, // () => SQL[] when SQL cursors are present
  where: cursor.relations.where(),
  limit: page_size,
});

[!NOTE]

cursor.relations.where() with SQL expression cursors produces { RAW: ... } conditions that work correctly in RQB v2 when the SQL expression does not reference the table through Drizzle's aliased context. For full portability across all query modes, prefer the query-builder path (cursor.where()) or RQB v1 (db._query) when paginating by SQL expressions.

Named types

TableCursor and SQLCursor are exported for user-facing type annotations:

import type { CursorConfig, SQLCursor, TableCursor } from "drizzle-cursor";

const config: CursorConfig<SQLCursor> = {
  primaryCursor: { key: "id", sql: sql`${users.id}`, order: "ASC" },
};

Contributing

Submit an Issue with a minimal reproducible example.

PRs are welcome

Maintainers: release and prerelease workflow lives in CONTRIBUTING.md.

See Also

  • drizzle-audit — Configurable audit logging for Drizzle ORM (by the same author)

License

MIT / Do whatever you want.