Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions apps/web/content/docs/dev/database/pagination.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ VitNode uses cursor-based pagination for optimal performance with large datasets

```ts
import z from "zod";
import { getTableColumns } from "drizzle-orm";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the current Drizzle column helper

With the repository's pinned Drizzle v1 release, getTableColumns was renamed to getColumns; the live cron route already imports and calls getColumns in packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts. Anyone copying this newly updated basic example will therefore get a missing-export error before reaching the pagination code, so replace both getTableColumns references with getColumns.

Useful? React with 👍 / 👎.

import { buildRoute } from "@/api/lib/route";
import {
withPagination,
Expand Down Expand Up @@ -66,10 +67,10 @@ export const getCronsRoute = buildRoute({
},
c,
primaryCursor: core_cron.id,
query: async ({ limit, where, orderBy }) =>
query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
.select()
.select({ ...getTableColumns(core_cron), ...cursorSelection })
.from(core_cron)
.where(where)
.orderBy(orderBy)
Expand All @@ -93,11 +94,39 @@ The `withPagination` function accepts the following parameters:
| Parameter | Type | Description |
| --------------- | -------- | -------------------------------------------- |
| `params` | Object | Contains the query parameters for pagination |
| `primaryCursor` | Column | The primary key column used for pagination |
| `primaryCursor` | Column | Integer, bigint or UUID row identifier |
| `query` | Function | The database query function |
| `table` | Table | The database table being queried |
| `orderBy` | Object | The column and order to sort by |

### Integer, bigint and UUID identifiers

`primaryCursor` accepts integer, bigint and UUID columns. Bigints use decimal
strings inside the opaque cursor, so values above JavaScript's safe-integer
limit keep their precision. UUIDs stay strings and are validated before they
reach PostgreSQL.

The order column and identifier can use different types. For example, a bigint
order column can use a UUID primary key as its stable tiebreaker:

```ts
import { bigint, pgTable, uuid } from "drizzle-orm/pg-core";

const events = pgTable("events", {
id: uuid().defaultRandom().primaryKey(),
sequence: bigint({ mode: "bigint" }).notNull(),
});

await withPagination({
// ...
primaryCursor: events.id,
orderBy: { column: events.sequence, order: "asc" },
});
```

Keep the cursor opaque on the client and return it unchanged in the next
request.

### Zod Schemas

VitNode provides pre-defined Zod schemas for pagination:
Expand Down
111 changes: 105 additions & 6 deletions packages/vitnode/src/api/lib/pagination-cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ import {
text,
time,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
// @vitest-environment node
import { HTTPException } from "hono/http-exception";
import { describe, expect, it } from "vitest";

import { core_users } from "@/database/users";

import type { PaginationCursorColumn } from "./with-pagination";

import {
cursorIdentifierForColumn,
cursorIdentifierOf,
cursorValueForColumn,
cursorValueIsCanonicalText,
cursorValueOf,
decodePaginationCursor,
encodePaginationCursor,
isCursorSortableColumn,
isPaginationIdentifierColumn,
} from "./pagination-cursor";

const probes = camelCase.table("cursor_probes", {
Expand All @@ -28,8 +34,11 @@ const probes = camelCase.table("cursor_probes", {
day: date(),
moment: timestamp(),
tags: text().array("[]"),
uuid: uuid(),
});

const UUID_ID = "0198f6f7-d4a2-7ce1-a2ee-4f5f1f2f3a4b";

const statusOf = (error: unknown): number =>
error instanceof HTTPException ? error.status : 0;

Expand All @@ -44,7 +53,7 @@ describe("encoding", () => {
expect(
decodePaginationCursor(encodePaginationCursor(cursor), {
column: "updatedAt",
primaryKey: "id",
primary: core_users.id,
}),
).toEqual(cursor);
});
Expand All @@ -66,7 +75,7 @@ describe("encoding", () => {
expect(
decodePaginationCursor(encodePaginationCursor(cursor), {
column: "publishedAt",
primaryKey: "id",
primary: core_users.id,
}),
).toEqual(cursor);
});
Expand All @@ -81,15 +90,45 @@ describe("encoding", () => {
expect(
decodePaginationCursor(encodePaginationCursor(cursor), {
column: "name",
primaryKey: "id",
primary: core_users.id,
}).value,
).toEqual(value);
});

it("round-trips a bigint order value with a UUID tiebreaker", () => {
const cursor = {
column: "big",
id: UUID_ID,
value: "9007199254740993",
};

expect(
decodePaginationCursor(encodePaginationCursor(cursor), {
column: "big",
primary: probes.uuid,
}),
).toEqual(cursor);
});

it("round-trips a bigint identifier without losing precision", () => {
const cursor = {
column: "big",
id: "9007199254740993",
value: "9007199254740993",
};

expect(
decodePaginationCursor(encodePaginationCursor(cursor), {
column: "big",
primary: probes.big,
}),
).toEqual(cursor);
});
});

describe("decoding refuses what it cannot trust", () => {
const decode = (raw: string, column = "updatedAt") =>
decodePaginationCursor(raw, { column, primaryKey: "id" });
decodePaginationCursor(raw, { column, primary: core_users.id });

it.each([
["garbage", "not-a-cursor!!"],
Expand Down Expand Up @@ -149,17 +188,69 @@ describe("decoding refuses what it cannot trust", () => {
describe("legacy numeric cursors", () => {
it("still works when the list is ordered by its identifier", () => {
expect(
decodePaginationCursor("42", { column: "id", primaryKey: "id" }),
decodePaginationCursor("42", { column: "id", primary: core_users.id }),
).toEqual({ column: "id", id: 42, value: 42 });
});

it("is refused for any other ordering rather than guessed at", () => {
expect(() =>
decodePaginationCursor("42", { column: "updatedAt", primaryKey: "id" }),
decodePaginationCursor("42", {
column: "updatedAt",
primary: core_users.id,
}),
).toThrow(/cannot be used with the "updatedAt" ordering/);
});
});

describe("cursor identifiers", () => {
it("accepts integer, bigint and UUID columns", () => {
const columns: PaginationCursorColumn[] = [
core_users.id,
probes.big,
probes.uuid,
];

expect(columns.every(isPaginationIdentifierColumn)).toBe(true);
});

it("serializes and restores every supported identifier type", () => {
expect(cursorIdentifierOf(core_users.id, 42)).toBe(42);
expect(cursorIdentifierForColumn(core_users.id, 42)).toBe(42);

expect(cursorIdentifierOf(probes.big, 9007199254740993n)).toBe(
"9007199254740993",
);
expect(cursorIdentifierForColumn(probes.big, "9007199254740993")).toBe(
9007199254740993n,
);

expect(cursorIdentifierOf(probes.uuid, UUID_ID)).toBe(UUID_ID);
expect(cursorIdentifierForColumn(probes.uuid, UUID_ID)).toBe(UUID_ID);
});

it.each([
["a bigint sent as a number", probes.big, 42],
["a zero bigint", probes.big, "0"],
["a malformed bigint", probes.big, "42n"],
["a bigint outside PostgreSQL's range", probes.big, "9223372036854775808"],
["a UUID sent as a number", probes.uuid, 42],
["a malformed UUID", probes.uuid, "not-a-uuid"],
])("refuses %s", (_why, column, value) => {
expect(() => cursorIdentifierForColumn(column, value)).toThrow(
HTTPException,
);
});

it("does not treat a legacy numeric cursor as a UUID", () => {
expect(() =>
decodePaginationCursor("42", {
column: "uuid",
primary: probes.uuid,
}),
).toThrow(HTTPException);
});
});

describe("column values", () => {
it("keeps a timestamp as text on both sides", () => {
const flattened = cursorValueOf(
Expand All @@ -178,6 +269,13 @@ describe("column values", () => {
expect(cursorValueForColumn(core_users.name, "Ada")).toBe("Ada");
});

it("validates a UUID before it can reach Postgres", () => {
expect(cursorValueForColumn(probes.uuid, UUID_ID)).toBe(UUID_ID);
expect(() => cursorValueForColumn(probes.uuid, "not-a-uuid")).toThrow(
HTTPException,
);
});

it("keeps a number a number", () => {
expect(cursorValueOf(core_users.id, 7)).toBe(7);
expect(cursorValueForColumn(core_users.id, 7)).toBe(7);
Expand Down Expand Up @@ -273,6 +371,7 @@ describe("a tampered cursor value is refused, never coerced", () => {
["a number", 12],
["a boolean", false],
["whitespace", " 12 "],
["outside PostgreSQL's range", "9223372036854775808"],
])("refuses %s", (_why, value) => {
refuses(probes.big, value);
});
Expand Down
Loading
Loading