fix(plugin-drizzle): unique connection orderBy, and ordering by a selected expression - #1651
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
@pothos/core
@pothos/plugin-add-graphql
@pothos/plugin-complexity
@pothos/plugin-dataloader
@pothos/plugin-directives
@pothos/plugin-drizzle
@pothos/plugin-errors
@pothos/plugin-example
@pothos/plugin-federation
@pothos/plugin-grafast
@pothos/plugin-mocks
@pothos/plugin-prisma
@pothos/plugin-prisma-utils
@pothos/plugin-relay
@pothos/plugin-scope-auth
@pothos/plugin-simple-objects
@pothos/plugin-smart-subscriptions
@pothos/plugin-sub-graph
@pothos/plugin-tracing
@pothos/plugin-validation
@pothos/plugin-with-input
@pothos/plugin-zod
@pothos/tracing-newrelic
@pothos/tracing-opentelemetry
@pothos/tracing-sentry
@pothos/tracing-xray
commit: |
…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>
52740af to
d5211ea
Compare
🦋 Changeset detectedLatest commit: a7de06d The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
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>
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>
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>
There was a problem hiding this comment.
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
orderByis not unique, while keeping older cursors functional. - Allow
orderByto reference queryextras, and build keyset paging filters against the expression (via DrizzleRAW) 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.
…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>
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
orderByat all. If it supplies its own, that is the whole keyset:produces
created_at < $1forafter, which drops every row sharing that timestamp. No microseconds needed — oneinsertManydoes it.postsByAuthorin the fixtures orders 151 posts byauthorId(10 authors, 15 posts each). Walking the whole connection before the change: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.slugistext('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.tsare still in their originalDC:N:3form and those tests pass untouched.2. Cursor values have to round trip
This is #1650. A
timestamp({ mode: 'date' })column arrives as aDate, which holds milliseconds, while a Postgrestimestamptzstores 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 withfirst: 3:createdAt(modedate)[12, 11, 10, 1]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,
orderBynow 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. 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.
d0), as does the user's extras callback.RAWescape hatch, so they compose with the column filters beside them inside the existingOR/ANDkeyset.parseOrderBybuilt three parallel arrays; it now builds one list and derives the column list, cursor fields, and drizzleorderByfrom it.afterandbeforekeyset blocks were verbatim identical and are now onekeysetFilter.findPrimaryKeyis the non-throwing form of the existinggetPrimaryKey, 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 tostring) 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
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).docker-composedatabase; it isn't committed, since no existing test touches that database and adding one would break CI without the container.🤖 Generated with Claude Code