Skip to content

fix(plugin-drizzle): keyset pagination drops the equality clause for Date columns - #1653

Merged
hayes merged 1 commit into
mainfrom
fix/drizzle-keyset-date-equality
Aug 21, 2026
Merged

fix(plugin-drizzle): keyset pagination drops the equality clause for Date columns#1653
hayes merged 1 commit into
mainfrom
fix/drizzle-keyset-date-equality

Conversation

@hayes

@hayes hayes commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Follow up to #1651, reported from a run against Postgres 17.11.

What breaks

Ordering a connection by a timestamp column returns the wrong rows. With 12 rows sharing a millisecond and { createdAt: 'desc', id: 'desc' }, first: 3 walks all 12 instead of 3.

The keyset filter should be:

created_at < $1 OR (created_at = $2 AND id < $3)

What actually reached Postgres:

created_at < $1 OR id < $2

Those are two unrelated conditions, not a keyset comparison. Rows outside the page come back, and a newer row with a smaller id is returned as though it were older.

Cause

The equality half was written with drizzle's { column: value } shorthand. relationsFieldFilterToSQL treats any object value as a nested filter rather than a comparison:

if (typeof filter !== "object" || is(filter, Placeholder)) return eq(column, filter);
const entries = Object.entries(filter);
if (!entries.length) return void 0;

Object.entries(new Date()) is [], so the clause returns undefined and disappears. No error. null hit the same branch and threw, which is why it was already special cased; Date fails silently instead.

This only became reachable when compound cursors started carrying tagged values. Before that, JSON serialization handed the filter a date string, and a string is not an object, so drizzle built the comparison correctly by accident. Single column orderings never emit an equality clause, and expression orderings build their SQL through RAW, so neither path was affected.

Fix

Name the operator instead of relying on the shorthand:

-if (operator === 'eq') {
-  return isNullish ? { [entry.key]: { isNull: true } } : { [entry.key]: value };
+if (operator === 'eq' && isNullish) {
+  return { [entry.key]: { isNull: true } };
 }
 return { [entry.key]: { [operator]: value } };

The shorthand is gone entirely rather than special cased for Date, so bytea, array, and json columns cannot reach it either. Arrays would have crashed on operators['0'] rather than compiling away.

Tests

Two tests in cursor-values.test.ts build a filter through drizzleCursorConnectionQuery and compile it with .toSQL(). Asserting the shape of the filter object would have passed on the broken code, since the object looked right and drizzle discarded it. Verified red before the fix:

- expected: /"created_at" < \$1.*"created_at" = \$2.*"id" < \$3/
- actual:   where (("d0"."created_at" < $1) or ("d0"."id" < $2))

The 16 unrelated test file failures in the drizzle suite are pre existing, from @pothos/plugin-errors resolving against an unbuilt workspace. Same count before and after this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YHUkipCNUgbk6gsm38dkAb

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pothos Ready Ready Preview Aug 21, 2026 1:50am

@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 6c8bae4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@pothos/core

npm i https://pkg.pr.new/@pothos/core@1653

@pothos/plugin-add-graphql

npm i https://pkg.pr.new/@pothos/plugin-add-graphql@1653

@pothos/plugin-complexity

npm i https://pkg.pr.new/@pothos/plugin-complexity@1653

@pothos/plugin-dataloader

npm i https://pkg.pr.new/@pothos/plugin-dataloader@1653

@pothos/plugin-directives

npm i https://pkg.pr.new/@pothos/plugin-directives@1653

@pothos/plugin-drizzle

npm i https://pkg.pr.new/@pothos/plugin-drizzle@1653

@pothos/plugin-errors

npm i https://pkg.pr.new/@pothos/plugin-errors@1653

@pothos/plugin-example

npm i https://pkg.pr.new/@pothos/plugin-example@1653

@pothos/plugin-federation

npm i https://pkg.pr.new/@pothos/plugin-federation@1653

@pothos/plugin-grafast

npm i https://pkg.pr.new/@pothos/plugin-grafast@1653

@pothos/plugin-mocks

npm i https://pkg.pr.new/@pothos/plugin-mocks@1653

@pothos/plugin-prisma

npm i https://pkg.pr.new/@pothos/plugin-prisma@1653

@pothos/plugin-prisma-utils

npm i https://pkg.pr.new/@pothos/plugin-prisma-utils@1653

@pothos/plugin-relay

npm i https://pkg.pr.new/@pothos/plugin-relay@1653

@pothos/plugin-scope-auth

npm i https://pkg.pr.new/@pothos/plugin-scope-auth@1653

@pothos/plugin-simple-objects

npm i https://pkg.pr.new/@pothos/plugin-simple-objects@1653

@pothos/plugin-smart-subscriptions

npm i https://pkg.pr.new/@pothos/plugin-smart-subscriptions@1653

@pothos/plugin-sub-graph

npm i https://pkg.pr.new/@pothos/plugin-sub-graph@1653

@pothos/plugin-tracing

npm i https://pkg.pr.new/@pothos/plugin-tracing@1653

@pothos/plugin-validation

npm i https://pkg.pr.new/@pothos/plugin-validation@1653

@pothos/plugin-with-input

npm i https://pkg.pr.new/@pothos/plugin-with-input@1653

@pothos/plugin-zod

npm i https://pkg.pr.new/@pothos/plugin-zod@1653

@pothos/tracing-newrelic

npm i https://pkg.pr.new/@pothos/tracing-newrelic@1653

@pothos/tracing-opentelemetry

npm i https://pkg.pr.new/@pothos/tracing-opentelemetry@1653

@pothos/tracing-sentry

npm i https://pkg.pr.new/@pothos/tracing-sentry@1653

@pothos/tracing-xray

npm i https://pkg.pr.new/@pothos/tracing-xray@1653

commit: 6c8bae4

…for equality

Paging past a cursor compares `created_at < $1 OR (created_at = $2 AND id
< $3)`. The equality half was written with drizzle's `{ column: value }`
shorthand, which drizzle reads as a nested filter whenever the value is an
object. A Date has no enumerable keys, so the clause compiled to nothing
and the filter collapsed to `created_at < $1 OR id < $2`: rows outside the
page come back, and rows inside it are skipped whenever two rows share a
timestamp.

Ordering by a timestamp reached this once compound cursors started
preserving Date values instead of stringifying them. Before that the
shorthand happened to receive a string, which is not an object, so drizzle
built the comparison correctly.

Naming the operator drops the shorthand entirely rather than special
casing Date, so bytea, array, and json columns cannot reach it either.
The tests compile the generated filter to SQL, since the shape of the
filter object is not what went wrong.

No changeset: the cursor tagging change that introduced this has not been
released, so there is nothing for users to read about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHUkipCNUgbk6gsm38dkAb
@hayes
hayes force-pushed the fix/drizzle-keyset-date-equality branch from 736e7d1 to 6c8bae4 Compare August 21, 2026 01:49
@hayes
hayes merged commit 153f05e into main Aug 21, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant