Skip to content

fix(plugin-drizzle): unique connection orderBy, and ordering by a selected expression - #1651

Merged
hayes merged 8 commits into
mainfrom
fix/drizzle-connection-tie-breaker
Aug 21, 2026
Merged

fix(plugin-drizzle): unique connection orderBy, and ordering by a selected expression#1651
hayes merged 8 commits into
mainfrom
fix/drizzle-connection-tie-breaker

Conversation

@hayes

@hayes hayes commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Two changes to how connection cursors are built, in two commits.

A cursor names a row's position in an ordering. That works only if the ordering can't put two rows in the same position, and if the values the cursor carries still mean the same thing when they come back. Each commit fixes one of those.

1. The ordering has to be unique

The primary key is only used when a connection supplies no orderBy at all. If it supplies its own, that is the whole keyset:

orderBy: { createdAt: 'desc' }   // no tie breaker

produces created_at < $1 for after, which drops every row sharing that timestamp. No microseconds needed — one insertMany does it.

postsByAuthor in the fixtures orders 151 posts by authorId (10 authors, 15 posts each). Walking the whole connection before the change:

expected [ '1', '2', '3', … ] to have a length of 151 but got 70
expected [ Array(70) ] to strictly equal [ Array(115) ]

Page size 7 returns 70 rows, page size 23 returns 115. 81 rows silently vanish.

Now the primary key is appended unless the ordering already covers a unique, non-nullable set of columns — a primary key, a composite primary key, or a unique constraint. Key columns the user placed themselves stay where they are, and the appended direction follows the last ordering column so a composite index stays scannable.

Nullable columns never count toward uniqueness: rows sharing a null tie with each other, and a cursor compared against null matches nothing. posts.slug is text('slug').unique() but nullable, so it still gets a tie breaker.

Unique indexes are deliberately excluded — they are only reachable through drizzle's internal .config, and the failure directions aren't symmetric: missing one costs a redundant order column, wrongly reporting a set as unique breaks pagination.

Existing cursors keep working. A cursor issued before a column joined the ordering holds values for a prefix of the current columns, so the keyset is built from the prefix it covers. That reproduces how the cursor paged when issued, and every cursor the resulting page returns carries the full set — pagination repairs itself after one request. The proof is that the fixtures didn't change: the four hardcoded cursors in connection-helpers.test.ts are still in their original DC:N:3 form and those tests pass untouched.

2. Cursor values have to round trip

This is #1650. A timestamp({ mode: 'date' }) column arrives as a Date, which holds milliseconds, while a Postgres timestamptz stores microseconds. The keyset compares the truncated value against the stored one.

Measured against Postgres 13 — twelve rows inside one millisecond (.086000.086077), ordered { createdAt: 'desc', id: 'desc' }, paged with first: 3:

ordering rows visited
createdAt (mode date) 4 of 12[12, 11, 10, 1]
an extra carrying the full value 12 of 12, in order

Eight rows gone. The report's symptom — the named node coming back on last/before — is the mild half; forward paging loses most of the page with no error and no gap in the response.

Pothos can't fix this by changing the encoding, because the microseconds are gone inside drizzle's mapper before Pothos sees the value. Rather than teach the plugin about timestamps, orderBy now accepts the name of any extra the same query selects:

query({
  extras: {
    createdAtExact: (table) =>
      sql`to_char(${table.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
  },
  orderBy: { createdAtExact: 'desc' },
})

Pothos orders by the expression, builds the cursor from the value it returns, and compares the expression when paging. The application supplies the expression, so no database-specific SQL enters the plugin. It generalizes past dates — length(title), a computed rank, anything the query can select.

Implementation

An ordering entry is now a column or an expression, keyed by where its value lands on the row — which is what the cursor is built from either way.

  • Column orderings still emit the object form drizzle already rendered, so their SQL is unchanged. An expression switches to the callback form, which resolves against the alias drizzle queries under (d0), as does the user's extras callback.
  • Expression comparisons go through the relational filter's RAW escape hatch, so they compose with the column filters beside them inside the existing OR/AND keyset.
  • parseOrderBy built three parallel arrays; it now builds one list and derives the column list, cursor fields, and drizzle orderBy from it.
  • The after and before keyset blocks were verbatim identical and are now one keysetFilter.
  • findPrimaryKey is the non-throwing form of the existing getPrimaryKey, which is now defined in terms of it. A table with no key keeps working rather than newly throwing.

Docs

New "Ordering and cursors" section in the drizzle plugin docs: why an ordering has to be unique, why cursor values have to round trip, and the two ways to page a high-precision timestamp — mode: 'string' (no migration, keeps the column indexed, changes the field's type to string) or ordering by an extra. Includes the two things to get right with an expression: it has to sort the way its values compare, and it can't use an index on the underlying column.

Notes

  • New fixtures: postsByAuthor (non-unique order), usersByUsername (unique and not null — asserts no tie breaker is added), postsByTitleLength (ordered by an expression), postsMissingOrderByExtra (asserts the error when a key names neither).
  • Every new assertion was verified to fail without the corresponding change.
  • The Postgres measurements come from a throwaway harness against the docker-compose database; it isn't committed, since no existing test touches that database and adding one would break CI without the container.
  • 105 tests pass; typecheck and biome clean.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 20, 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 20, 2026 7:38pm

@pkg-pr-new

pkg-pr-new Bot commented Aug 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@pothos/core

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

@pothos/plugin-add-graphql

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

@pothos/plugin-complexity

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

@pothos/plugin-dataloader

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

@pothos/plugin-directives

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

@pothos/plugin-drizzle

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

@pothos/plugin-errors

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

@pothos/plugin-example

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

@pothos/plugin-federation

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

@pothos/plugin-grafast

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

@pothos/plugin-mocks

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

@pothos/plugin-prisma

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

@pothos/plugin-prisma-utils

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

@pothos/plugin-relay

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

@pothos/plugin-scope-auth

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

@pothos/plugin-simple-objects

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

@pothos/plugin-smart-subscriptions

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

@pothos/plugin-sub-graph

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

@pothos/plugin-tracing

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

@pothos/plugin-validation

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

@pothos/plugin-with-input

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

@pothos/plugin-zod

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

@pothos/tracing-newrelic

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

@pothos/tracing-opentelemetry

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

@pothos/tracing-sentry

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

@pothos/tracing-xray

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

commit: a7de06d

…orderBy

A cursor names a row's position in an ordering, which only works if no
two rows can share a position. `orderBy: { createdAt: 'desc' }` or
`orderBy: { status: 'asc' }` leaves ties to be broken arbitrarily, so
the keyset predicate cannot express "everything after this row" and
paging returns rows twice or skips them.

Append the primary key unless the ordering already covers a unique set
of non-nullable columns -- a primary key, a composite primary key, or a
unique constraint. Nullable columns never count toward uniqueness: rows
sharing a null tie with each other, and a cursor compared against null
matches nothing.

Cursors issued before a column joined the ordering hold values for a
prefix of the current columns. Rather than rejecting them, the keyset is
built from the prefix they cover, which reproduces how they paged when
issued; cursors returned by that page carry every column, so pagination
repairs itself after one request.

parseOrderBy now builds one list of entries and derives the column list
and drizzle orderBy from it, and the two identical after/before keyset
blocks are a single function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hayes
hayes force-pushed the fix/drizzle-connection-tie-breaker branch from 52740af to d5211ea Compare August 20, 2026 18:21
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a7de06d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@pothos/plugin-drizzle Minor

Not sure what this means? Click here to learn what changesets are.

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

@hayes
hayes changed the base branch from fix/drizzle-omit-undefined-query-keys to main August 20, 2026 18:21
Connection orderBy could only name a column, so the cursor could only
carry what the column's JavaScript mapping preserved. A
`timestamp({ mode: 'date' })` column arrives as a Date, which holds
milliseconds, and a cursor built from it cannot address a row stored at
microsecond precision -- the keyset compares a truncated value against
the stored one and drops every other row in that millisecond.

orderBy now accepts the name of any extra the same query selects. Pothos
orders by the expression, builds the cursor from the value it returns,
and compares the expression when paging, so the application supplies
whatever expression preserves its values:

  query({
    extras: { createdAtExact: (t) => sql`to_char(${t.createdAt}, ...)` },
    orderBy: { createdAtExact: 'desc' },
  })

An ordering entry is now a column or an expression, keyed by where its
value lands on the row -- which is what the cursor is built from either
way. Column orderings still emit the object form drizzle already
rendered, so their SQL is unchanged; an expression switches to the
callback form, which resolves against the alias drizzle queries under.
Expression comparisons go through the relational filter's RAW escape
hatch, so they compose with the column filters beside them.

Also documents cursor construction: why an ordering has to be unique,
why cursor values have to round trip, and both ways to page a
high-precision timestamp (string mode, or ordering by an extra).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hayes hayes changed the title fix(plugin-drizzle): append the primary key to non-unique connection orderBy fix(plugin-drizzle): unique connection orderBy, and ordering by a selected expression Aug 20, 2026
Match the surrounding docs voice: plain declarative sentences, no em
dashes, no bold lead-ins, sentence case headings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Expression ordering was only exercised through t.drizzleConnection. The
extras are threaded at three separate call sites, so cover the other two:
t.relatedConnection (asserting the nested `d1` alias, which is where an
unaliased expression would break) and drizzleConnectionHelpers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…de it

Review of the tie breaker turned up three defects it made reachable.

Compound cursors were serialized with plain JSON.stringify, with no type
tags, while single values carried them. Appending the primary key turns
most single-column orderings into compound ones, so `orderBy: { createdAt:
'desc' }` -- the example in the changeset and the docs -- started emitting
a Date as a string and failing on the next page in the column's
mapToDriverValue. A bigint key threw while formatting the edge cursor.
Each value in a compound cursor now carries the same tag it would carry
alone, under a `T:` prefix; `J:` still parses, so cursors already issued
keep working.

An ordering value of null produced drizzle's shorthand equality, which
takes `typeof null === 'object'` for a nested filter and throws
`Cannot convert undefined or null to object`. Null now compares with
IS NULL. Paging past a null still returns nothing, because null does not
compare after anything in SQL; that is a documented limitation rather
than an error.

The postgres dialect builds primaryKeys and uniqueConstraints from
ExtraConfigColumns rather than the table's own columns, so columnToTsName
did not recognise them and every composite-key connection with an
explicit orderBy threw. Key columns are now looked up by name against the
table's columns. Table configs are memoized while we are here, since
drizzle rebuilds them from the table's config callback on every call.

Docs gain what the review found overstated: unique indexes are not
detected, nothing is appended when no dependable key exists, ordering by
a nullable column cannot be paged, and helpers must declare an ordering
extra in `query`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…of notNull

SQL makes primary key columns non-nullable whether or not the drizzle
schema repeats it, so a composite key declared with
`primaryKey({ columns: [...] })` alone was being skipped and those
connections kept the non-unique ordering the tie breaker exists to fix.

The nullability check belongs to the fallback instead: a column that is
only unique has to say notNull() itself, because a cursor compared
against null matches nothing and rows sharing a null tie anyway. That
rule now lives in one place, findTieBreaker, rather than being split
between config and appendTieBreaker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hayes
hayes marked this pull request as ready for review August 20, 2026 19:28
@hayes
hayes requested a lite review from Copilot August 20, 2026 19:28
The compound cursor format changes, which is visible to anyone holding a
cursor across the upgrade even though old ones still parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates @pothos/plugin-drizzle cursor-connection pagination to (1) ensure connection orderings are unique by appending an appropriate tie-breaker when needed, and (2) support ordering (and cursor construction/comparison) by a selected “extra” expression so cursor values round-trip correctly for cases like high-precision timestamps.

Changes:

  • Append a reliable tie-breaker (preferably primary key / composite PK; otherwise safe unique columns) when user-provided orderBy is not unique, while keeping older cursors functional.
  • Allow orderBy to reference query extras, and build keyset paging filters against the expression (via Drizzle RAW) when applicable.
  • Update/expand documentation and test coverage (new fixtures + new test suites + snapshot updates) for the new ordering/cursor behaviors.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
website/content/docs/plugins/drizzle.mdx Adds “Ordering and cursors” documentation explaining uniqueness, nulls, and expression-based ordering.
packages/plugin-drizzle/src/utils/cursors.ts Implements tie-breaker appending, expression orderBy support, tagged compound cursor encoding, and unified keyset filtering.
packages/plugin-drizzle/src/utils/config.ts Adds schema config helpers for primary key / tie-breaker discovery and unique constraint detection with caching.
packages/plugin-drizzle/src/types.ts Extends connection orderBy typing to permit extra names and adds uniqueConstraints to table config typing.
packages/plugin-drizzle/src/drizzle-field-builder.ts Threads extras through cursor-connection query building and switches to cursorFields.
packages/plugin-drizzle/src/connection-helpers.ts Threads extras through helper queries and switches to cursorFields.
packages/plugin-drizzle/tests/cursor-values.test.ts New tests for tagged compound cursor round-tripping (Date/bigint/null) + legacy cursor compatibility + composite PK behavior.
packages/plugin-drizzle/tests/connection-order.test.ts New end-to-end tests for non-unique ordering, unique ordering, legacy cursors, expression ordering, and nullable-ordering behavior.
packages/plugin-drizzle/tests/example/schema/post.ts Adds new connection fields covering non-unique, expression, missing-extra, and nullable-unique scenarios.
packages/plugin-drizzle/tests/example/schema/user.ts Adds expression-ordering examples for related connections and drizzleConnectionHelpers, plus unique username ordering fixture.
packages/plugin-drizzle/tests/example/schema.graphql Updates generated example schema to include new connection fields.
packages/plugin-drizzle/tests/snapshots/index.test.ts.snap Updates schema snapshot to reflect new fields.
packages/plugin-drizzle/tests/variants.test.ts Updates SQL snapshot expectations to include appended tie-breaker ordering.
packages/plugin-drizzle/tests/drizzle-connections.test.ts Updates cursor snapshot expectations for new tagged compound cursor format.
packages/plugin-drizzle/tests/related-connection.test.ts Updates cursor snapshot expectations for new tagged compound cursor format.
packages/plugin-drizzle/tests/connection-helpers.test.ts Updates snapshots for appended ordering tie-breaker and new cursor encoding format.
.changeset/drizzle-order-by-extras.md Changeset: allow orderBy to reference extras for expression ordering and cursor correctness.
.changeset/drizzle-cursor-value-tags.md Changeset: tag compound cursor values for correct Date/bigint round-tripping + related crash fixes.
.changeset/drizzle-connection-tie-breaker.md Changeset: append primary key tie-breaker when ordering is not already unique.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/plugin-drizzle/src/utils/config.ts Outdated
Comment thread packages/plugin-drizzle/src/utils/config.ts Outdated
…he tie breaker

findTieBreaker considered single unique columns but not multi-column
unique constraints, so a table with no primary key and a non-null
composite unique constraint got no tie breaker even though the database
guarantees that keyset is unique. getUniqueConstraints already counted
those, so the two disagreed about what unique means.

Also moves buildTableConfig above the map that names its return type. The
forward reference type-checks, but reads as though it should not.

Both raised in review on #1651.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants