Skip to content

ENSv2 Find Domain Ordering Support#1595

Draft
shrugs wants to merge 14 commits intomainfrom
feat/domain-ordering
Draft

ENSv2 Find Domain Ordering Support#1595
shrugs wants to merge 14 commits intomainfrom
feat/domain-ordering

Conversation

@shrugs
Copy link
Collaborator

@shrugs shrugs commented Feb 1, 2026

closes #1564

Copilot AI review requested due to automatic review settings February 1, 2026 02:19
@changeset-bot
Copy link

changeset-bot bot commented Feb 1, 2026

🦋 Changeset detected

Latest commit: 19670c8

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

This PR includes changesets to release 18 packages
Name Type
ensapi Major
ensindexer Major
ensadmin Major
ensrainbow Major
fallback-ensapi Major
@ensnode/datasources Major
@ensnode/ensrainbow-sdk Major
@ensnode/ponder-metadata Major
@ensnode/ensnode-schema Major
@ensnode/ensnode-react Major
@ensnode/ponder-subgraph Major
@ensnode/ensnode-sdk Major
@ensnode/shared-configs Major
@docs/ensnode Major
@docs/ensrainbow Major
@docs/mintlify Major
@namehash/ens-referrals Major
@namehash/namehash-ui Major

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

@vercel
Copy link
Contributor

vercel bot commented Feb 1, 2026

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

3 Skipped Deployments
Project Deployment Actions Updated (UTC)
admin.ensnode.io Skipped Skipped Feb 6, 2026 7:03am
ensnode.io Skipped Skipped Feb 6, 2026 7:03am
ensrainbow.io Skipped Skipped Feb 6, 2026 7:03am

@shrugs shrugs changed the base branch from main to feat/canonical-name-heuristic February 1, 2026 02:19
@coderabbitai
Copy link

coderabbitai bot commented Feb 1, 2026

📝 Walkthrough

Walkthrough

Introduces ordered, cursor-based pagination for domain queries: new OrderDirection and DomainsOrderBy enums, DomainsOrderInput, DomainCursor encoding/decoding, centralized resolveFindDomains resolver, rebuilt findDomains query builders/types, and schema updates to Query.domains and Account.domains to accept an order argument.

Changes

Cohort / File(s) Summary
Schema: ordering types & integration
apps/ensapi/src/graphql-api/schema/order-direction.ts, apps/ensapi/src/graphql-api/schema/domain.ts, apps/ensapi/src/graphql-api/schema/query.ts, apps/ensapi/src/graphql-api/schema/account.ts
Add OrderDirection enum, DomainsOrderBy enum, DomainsOrderInput input type and default constants; expose order?: DomainsOrderInput on Query.domains and Account.domains; switch resolvers to use resolveFindDomains.
Resolver: centralized domain resolution
apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts
New resolveFindDomains implementing cursor-aware keyset pagination for domain connections, integrating cursor decode/encode, SQL execution, dataloader hydration, and pageInfo calculation.
Cursor codec
apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts
New DomainCursor encoder/decoder (base64 JSON) with Zod validation and bigint handling for stable multi-column cursors.
Query builder / core logic
apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts, apps/ensapi/src/graphql-api/lib/find-domains/types.ts
New findDomains implementation (v1+v2 union), orderFindDomains, cursorFilter, isEffectiveDesc, and supporting types (FindDomainsWhereArg, FindDomainsOrderArg, DomainOrderValue, FindDomainsResult).
Refactor helpers
apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts
Removed previous heavy orchestration; now exports v1DomainsByLabelHashPath and v2DomainsByLabelHashPath helper functions.
Schema additions & docs
apps/ensapi/src/graphql-api/schema/event.ts, .changeset/whole-ways-grin.md
Added Event.timestamp BigInt field; add changeset documenting ENSv2 GraphQL ordering support.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Resolver as GraphQL Resolver
    participant FindResolver as resolveFindDomains
    participant Cursor as DomainCursor
    participant QueryBuilder as findDomains / orderFindDomains
    participant DB as Database
    participant DataLoader as DomainDataLoader

    Client->>Resolver: Query domains(order, first/after)
    Resolver->>FindResolver: resolveFindDomains({where, order, first, after})
    FindResolver->>Cursor: decode(after)
    Cursor-->>FindResolver: DomainCursor {id, by, value}
    FindResolver->>QueryBuilder: build base query (v1+v2 union) + cursorFilter
    QueryBuilder->>DB: Execute paginated SQL
    DB-->>FindResolver: Rows with order values
    FindResolver->>DataLoader: load(ids...)
    DataLoader-->>FindResolver: Hydrated Domain objects
    FindResolver->>Cursor: encode(...) for edges
    FindResolver-->>Resolver: Connection (edges, pageInfo)
    Resolver-->>Client: GraphQL response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

  • Feat/canonical name heuristic #1576: Refactors and extends the same domain-search/find-domains logic and interacts with Query/Account domain resolvers and ordering/cursor support.

Poem

🐰
I hopped through ORDER, cursors in paw,
NAME, time, expiry — all in one draw,
Keys and bytes stitched safe and neat,
Domains march forward, page by page, fleet —
A tiny rabbit cheers this GraphQL feat!

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is severely incomplete. It only contains a single issue reference and lacks all required template sections: Summary, Why, Testing, Notes, and Pre-Review Checklist. Add missing sections: provide a concise 1-3 bullet summary of changes, explain why this feature is needed, describe testing approach, and complete the pre-review checklist items.
Out of Scope Changes check ⚠️ Warning One out-of-scope change found: Event.timestamp field addition in event.ts appears unrelated to domain ordering requirements and is not mentioned in the linked issue or PR objectives. Either justify why Event.timestamp is needed for domain ordering support, move it to a separate PR, or remove it from this changeset to maintain scope focus.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'ENSv2 Find Domain Ordering Support' clearly and specifically describes the main change: adding ordering/sorting support for ENSv2 domains in the GraphQL API.
Linked Issues check ✅ Passed The PR implements ENSv2 domain search ordering semantics (#1564) by adding DomainsOrderInput, OrderDirection enum, cursor-based pagination, and resolver integration across Query and Account domains fields.
Docstring Coverage ✅ Passed Docstring coverage is 88.24% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/domain-ordering

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

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 adds support for ordering domain search results in the ENSv2 API. It introduces new GraphQL input types to allow clients to specify the field to order by (NAME, REGISTRATION_TIMESTAMP, or REGISTRATION_EXPIRY) and the sort direction (ASC or DESC).

Changes:

  • Added OrderDirection enum and DomainsOrderBy enum with DomainsOrderInput type for specifying ordering in domain queries
  • Refactored findDomains function to join additional data (labels and registrations) needed for ordering
  • Implemented orderFindDomains function to build SQL ORDER BY clauses based on user input
  • Integrated ordering support into both Query.domains and Account.domains fields

Reviewed changes

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

Show a summary per file
File Description
apps/ensapi/src/graphql-api/schema/order-direction.ts New file defining OrderDirection enum (ASC/DESC)
apps/ensapi/src/graphql-api/schema/domain.ts Added DomainsOrderBy enum and DomainsOrderInput type, imported OrderDirection
apps/ensapi/src/graphql-api/schema/query.ts Added order parameter to domains query, integrated orderFindDomains function, removed unused asc/desc imports
apps/ensapi/src/graphql-api/schema/account.ts Added order parameter to Account.domains field, integrated orderFindDomains function
apps/ensapi/src/graphql-api/lib/find-domains.ts Refactored findDomains to support ordering with additional joins for labels and registrations, added orderFindDomains function

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Base automatically changed from feat/canonical-name-heuristic to main February 2, 2026 07:23
@shrugs shrugs force-pushed the feat/domain-ordering branch from f7b2e8c to 0a03e2a Compare February 2, 2026 07:29
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 2, 2026 07:29 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 2, 2026 07:29 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 2, 2026 07:29 Inactive
Copilot AI review requested due to automatic review settings February 2, 2026 07:33
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 2, 2026 07:33 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 2, 2026 07:33 Inactive
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

- Fix correlated subquery bug in latestRegistration that compared
  domainId to itself instead of outer query
- Fix partial filtering to use headLabel alias instead of schema.label
- Use NULLS LAST for both sort directions so unregistered domains
  always appear at end

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 2, 2026 09:17 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 2, 2026 09:17 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 2, 2026 09:17 Inactive
…ain methods, i.e. `Account.domains(order: { by: NAME dir: ASC })`.
Copilot AI review requested due to automatic review settings February 2, 2026 09:19
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 4, 2026 00:21 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 4, 2026 00:21 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 4, 2026 00:21 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/ensapi/src/graphql-api/lib/find-domains.ts`:
- Around line 393-405: getOrderColumn currently maps DomainsOrderBy values to
columns directly and would return undefined for unknown/new enum values causing
invalid SQL; replace the object lookup with a switch on orderBy in the
getOrderColumn function (referencing getOrderColumn, DomainsOrderBy, and the
domains parameter from findDomains) and add an exhaustive never branch that
throws or asserts on unhandled cases so any new enum value fails at compile time
/ runtime instead of returning undefined.

In `@apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts`:
- Around line 54-63: The cursor currently encodes only { id, by, value } via
DomainCursor.encode in resolveCursorConnection, which allows a client to change
the pagination direction (dir) between requests and get inconsistent results;
update the cursor encoding to include the pagination direction (e.g., add dir)
when calling DomainCursor.encode and update the DomainCursor type/schema
accordingly, then update cursor decoding/validation (the place that currently
checks cursor.by === queryOrderBy) to also assert decodedCursor.dir === queryDir
before using it in cursorFilter/tuple comparisons so pagination direction and
order are always matched.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 6, 2026 05:44
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 6, 2026 05:44 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 6, 2026 05:44 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 6, 2026 05:44 Inactive
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts`:
- Around line 45-50: Fix the typos in the inline comment above the resolver args
in resolve-find-domains.ts: change "from from" to "from" and change "there are
our additional where/order arguments" to "these are our additional where/order
arguments" so the comment reads clearly when referencing the resolver arguments
(first, last, before, after) and the additional where/order args.
- Around line 79-80: The code calls DomainCursor.decode(...) for before and
after without handling decode failures; wrap both decode calls used when setting
beforeCursor and afterCursor in try/catch and on any thrown error throw a
GraphQLError (or use your existing GraphQL error helper) with code
BAD_USER_INPUT and a clear message about a malformed cursor so
malformed/tampered cursors return a user-friendly GraphQL error instead of
crashing; update the logic around DomainCursor.decode, beforeCursor and
afterCursor to catch decode exceptions and rethrow the BAD_USER_INPUT GraphQL
error.

Comment on lines 79 to 80
const beforeCursor = before ? DomainCursor.decode(before) : undefined;
const afterCursor = after ? DomainCursor.decode(after) : undefined;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find DomainCursor definition and decode method
rg -n "class DomainCursor|export.*DomainCursor|function.*DomainCursor" --type=ts -C5

# Also search for DomainCursor.decode specifically
rg -n "decode" --type=ts apps/ensapi/src/graphql-api -C3 | head -100

Repository: namehash/ensnode

Length of output: 10510


🏁 Script executed:

#!/bin/bash
# Look at the full context of resolve-find-domains.ts around lines 79-80
cat -n apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts | sed -n '60,100p'

Repository: namehash/ensnode

Length of output: 1726


🏁 Script executed:

#!/bin/bash
# Get the full function definition - look earlier than line 60
cat -n apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts | sed -n '1,70p'

# Also check if this is wrapped in any GraphQL error handler
rg -n "resolveConnection|withErrorHandler|try\s*{" apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts -B5 -A5

Repository: namehash/ensnode

Length of output: 2939


🏁 Script executed:

#!/bin/bash
# Check the end of resolveFindDomains function and if it uses rejectAnyErrors
cat -n apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts | sed -n '100,150p'

# Also check Buffer.from behavior with invalid base64
python3 << 'EOF'
import base64
# Test if buffer.from throws on invalid base64
try:
    result = base64.b64decode("!!!invalid!!!", validate=True)
    print("No error thrown")
except Exception as e:
    print(f"Error: {type(e).__name__}: {e}")
EOF

Repository: namehash/ensnode

Length of output: 801


🏁 Script executed:

#!/bin/bash
# Check if there are any try/catch blocks around cursors.decode or DomainCursor.decode usage elsewhere
rg -n "DomainCursor.decode|cursors.decode" --type=ts -B2 -A2 | grep -A2 -B2 "try\|catch"

# Also look for error handling patterns in GraphQL resolvers
rg -n "\.catch\(|try\s\{" apps/ensapi/src/graphql-api/lib --type=ts | head -20

Repository: namehash/ensnode

Length of output: 1592


🏁 Script executed:

#!/bin/bash
# Check if there's a global error handler in the GraphQL setup
rg -n "formatError|graphqlErrors|onError" apps/ensapi/src/graphql-api --type=ts -B2 -A2

# Also check the schema builder setup
rg -n "builder.*new|createBuilder|SchemaBuilder" apps/ensapi/src/graphql-api --type=ts | head -10

Repository: namehash/ensnode

Length of output: 340


🏁 Script executed:

#!/bin/bash
# View the builder configuration
cat -n apps/ensapi/src/graphql-api/builder.ts | head -50

Repository: namehash/ensnode

Length of output: 1585


🏁 Script executed:

#!/bin/bash
# Final verification: Check if Pothos RelayPlugin auto-catches errors from resolver callbacks
# by looking at how errors are typically handled in schema resolvers
rg -n "async.*\{" apps/ensapi/src/graphql-api/lib --type=ts -A3 | grep -E "throw|catch|try" | head -10

Repository: namehash/ensnode

Length of output: 42


Add error handling for malformed cursor input.

DomainCursor.decode calls Buffer.from() and JSON.parse() without try/catch. Malformed or tampered cursor strings will throw unhandled errors that surface as internal server errors instead of returning a user-friendly BAD_USER_INPUT GraphQL error. Wrap the decode calls at lines 79-80 in try/catch and throw a proper GraphQL error on decode failure.

🤖 Prompt for AI Agents
In `@apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts` around lines 79 -
80, The code calls DomainCursor.decode(...) for before and after without
handling decode failures; wrap both decode calls used when setting beforeCursor
and afterCursor in try/catch and on any thrown error throw a GraphQLError (or
use your existing GraphQL error helper) with code BAD_USER_INPUT and a clear
message about a malformed cursor so malformed/tampered cursors return a
user-friendly GraphQL error instead of crashing; update the logic around
DomainCursor.decode, beforeCursor and afterCursor to catch decode exceptions and
rethrow the BAD_USER_INPUT GraphQL error.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 6, 2026 06:30 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 6, 2026 06:30 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 6, 2026 06:30 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts`:
- Around line 81-86: The toCursor callback uses DomainWithOrderValue which types
__orderValue as possibly undefined; narrow the async callback’s return type so
toCursor always receives a domain with a non-optional __orderValue (e.g.,
introduce a new type like DomainWithOrderValueNonNullable or similar and have
the async mapping/filtering return that), update any mapping/filter that
currently ensures __orderValue exists to cast/return that non-optional type, and
then call DomainCursor.encode({ id: domain.id, by: orderBy, value:
domain.__orderValue }) from toCursor without optionality concerns; this ensures
DomainCursor.encode won’t receive undefined and satisfies TypeScript without
changing runtime guards.
- Line 5: The import fails because the module
"@/graphql-api/lib/find-domains/domain-cursor" is missing; add a new module that
exports the named symbol DomainCursor required by find-domains-resolver.ts.
Implement and export a DomainCursor (class or type) matching the shape/methods
the resolver expects (e.g., constructor/factory, any parse/serialize or
toString/fromString methods and properties the resolver uses) so TypeScript can
resolve the type and the resolver can call its API; ensure the file exports
DomainCursor as a named export.
- Line 67: Comment contains a typo "from from" in the explanatory comment about
resolver args; open find-domains-resolver.ts and edit the comment that mentions
"these resolver arguments from from t.connection" to remove the duplicate word
so it reads "these resolver arguments from t.connection" (or rephrase slightly
for clarity) — look for the comment near the resolver that references
t.connection to locate the exact line.
- Around line 17-27: Move all imports to the top of the file so no executable
code appears before them: relocate the type imports (DomainOrderValue,
DomainWithOrderValue, FindDomainsOrderArg, FindDomainsResult,
FindDomainsWhereArg) so they sit with the existing imports of cursorFilter,
findDomains, isEffectiveDesc, orderFindDomains (and any other imports) above the
call to makeLogger; ensure only then you call
makeLogger("find-domains-resolver") and assign logger, keeping import ordering
consistent and removing any executable statements that separate imports.
- Around line 32-44: Remove the duplicate local getOrderValueFromResult
definition in this file and instead import the existing getOrderValueFromResult
from find-domains.ts; update any references to use the imported function
(symbols: getOrderValueFromResult, DomainsOrderBy, FindDomainsResult,
DomainOrderValue). Also add an exhaustiveness guard in the original function (or
if you modify it here, in the imported implementation) by including a default
case that throws an error or asserts unreachable for unknown DomainsOrderBy
values to catch future enum additions. Ensure the import statement replaces the
local function and that TypeScript sees the exhaustive check so compilation
fails if a new order value is not handled.

Copilot AI review requested due to automatic review settings February 6, 2026 06:36
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 6, 2026 06:36 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 6, 2026 06:36 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 6, 2026 06:36 Inactive
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 248 to 249
// Tuple comparison: (orderColumn, id) > (cursorValue, cursorId)
// NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

The tuple comparison used for cursor filtering may not correctly handle NULL values when ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY. When a cursor value is NULL, the expression (orderColumn, id) > (NULL, cursorId) will return NULL instead of true/false, causing pagination to skip over rows with NULL values. This breaks the intended NULLS LAST behavior.

To fix this, the tuple comparison should be restructured to explicitly handle NULL values. For example, when cursor.value is NULL, the filter should be orderColumn IS NULL AND id > cursorId or orderColumn IS NOT NULL. When both values are non-NULL, use the existing tuple comparison.

This is particularly important since domains without registrations will have NULL registration timestamps and expiries, and these should still be paginated correctly when ordering by these fields.

Suggested change
// Tuple comparison: (orderColumn, id) > (cursorValue, cursorId)
// NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL
// Handle NULLs explicitly to preserve NULLS LAST semantics with keyset pagination.
//
// The ordering for both ASC/DESC uses "... NULLS LAST", so:
// - All non-NULL values come before any NULLs.
// - Within the same orderColumn value, id is used as a tiebreaker.
//
// We need to ensure that:
// - Rows with NULL orderColumn are correctly treated as "after" any non-NULL
// when fetching the next page.
// - Cursors whose value is NULL still allow traversing the NULL segment.
// When the cursor value itself is NULL, we are somewhere in the NULL segment
// at the end of the ordered list (because of NULLS LAST).
if (cursor.value === null) {
if (useGreaterThan) {
// Looking for rows *after* a NULL cursor:
// Only later rows within the NULL block qualify.
return sql`${orderColumn} IS NULL AND ${domains.id} ${sql.raw(op)} ${cursor.id}`;
}
// Looking for rows *before* a NULL cursor:
// - All non-NULL rows come before any NULLs.
// - Plus earlier rows in the NULL block (smaller / larger id depending on op).
return sql`(${orderColumn} IS NULL AND ${domains.id} ${sql.raw(op)} ${cursor.id}) OR ${orderColumn} IS NOT NULL`;
}
// Non-NULL cursor value.
//
// For rows with non-NULL orderColumn, we can safely use tuple comparison.
// For rows with NULL orderColumn, we must explicitly encode NULLS LAST:
// - When fetching "after" the cursor, all NULLs are after any non-NULL and
// should be included.
// - When fetching "before" the cursor, NULLs are after the cursor and should
// not be included (the tuple comparison already excludes them).
if (useGreaterThan) {
// Rows after the cursor:
// - Greater non-NULL tuples, OR any NULL-valued rows (NULLS LAST).
return sql`((${orderColumn}, ${domains.id}) ${sql.raw(op)} (${cursor.value}, ${cursor.id}) OR ${orderColumn} IS NULL)`;
}
// Rows before the cursor with non-NULL cursor value:
// Tuple comparison is sufficient; it naturally excludes NULL orderColumn rows.
// NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL.

Copilot uses AI. Check for mistakes.
// `order` MAY be provided; defaults are used otherwise
order?: FindDomainsOrderArg | undefined | null;

// these resolver arguments from from t.connection
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

Duplicate word "from" in comment. Should be "these resolver arguments from t.connection".

Suggested change
// these resolver arguments from from t.connection
// these resolver arguments from t.connection

Copilot uses AI. Check for mistakes.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts (1)

98-164: 🧹 Nitpick | 🔵 Trivial

v2DomainsByLabelHashPath: correct adaptation for v2 parent traversal via registryCanonicalDomain.

The intentional use of JOIN (not LEFT JOIN) is well-documented and ensures only domains with complete canonical paths are matched.

One observation: the v1 and v2 functions share ~80% identical structure (parameter handling, CTE shape, terminal condition). If additional versions or orderings are introduced, consider extracting the common CTE scaffolding into a parameterized helper to reduce duplication. Not blocking given the SQL differences are meaningful.

🤖 Fix all issues with AI agents
In `@apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts`:
- Around line 40-50: The decode function currently uses a blind "as
DomainCursor" cast which hides mismatches between DomainCursorSchema and the
DomainCursor type (e.g., string vs bigint for value); remove the cast and make
DomainCursorSchema produce the correct runtime type so decode returns a properly
typed value. Specifically, update DomainCursorSchema (the zod schema used by
DomainCursor.encode/decode) to parse/transform the union/number fields into the
expected DomainCursor shape (use zod transforms/coercions to convert
string/number into bigint where needed) and then change DomainCursor.decode to
return the parsed result directly (i.e., DomainCursorSchema.parse(...)) without
"as DomainCursor". Ensure encode still uses DomainCursorSchema.encode so the
roundtrip matches.
- Around line 8-17: DomainCursorSchema's value union is ambiguous:
z.union([z.string(), stringToBigInt]) lets plain encoded bigint strings match
z.string() first, so bigints never round-trip. Fix by disambiguating the bigint
case—either (A) switch to a tagged wrapper schema (e.g., replace value union
with a discriminated object like { type: "bigint", v: string } vs { type:
"string", v: string } and update encode/decode in stringToBigInt and
DomainCursorSchema accordingly), or (B) make stringToBigInt encode bigint with a
clear suffix/marker (e.g., "123n") and update its decode to only treat marked
strings as bigints, or (C) parse conditionally based on the existing by field
inside DomainCursorSchema so values for timestamp/expiry are run through
stringToBigInt while others use z.string(); apply the chosen change to
DomainCursorSchema and stringToBigInt to ensure parse returns BigInt for bigint
cases.

In `@apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts`:
- Around line 188-191: The LIKE pattern currently uses raw user input `partial`
(used in the like(headLabel.interpreted, `${partial}%`) call) so `%`, `_`, and
`\` alter matching; escape these meta-characters before building the pattern.
Modify the call-site (or update parsePartialInterpretedName) to transform
partial by replacing backslash with double backslash and prefixing `%` and `_`
with a backslash (i.e. escape `\`, `%`, `_`), then pass `${escapedPartial}%` to
like; ensure the SQL/ORM is using backslash as the escape character or uses the
ORM’s escape option so the escapes are respected.

Comment on lines 8 to 17
const stringToBigInt = z.codec(z.string(), z.bigint(), {
decode: (str) => BigInt(str),
encode: (bigint) => bigint.toString(),
});

const DomainCursorSchema = z.strictObject({
id: z.string(),
by: z.string(),
value: z.union([z.string(), z.null(), stringToBigInt]),
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Zod v4 z.codec z.union parse order behavior

💡 Result:

In Zod v4, a z.codec(inputSchema, outputSchema, …) is still a normal schema, but its forward parse direction produces the codec’s “output” type (and at runtime .parse() and .decode() behave the same—both run the forward direction). [1]

When you put codecs inside a z.union([...]), Zod will try each union option in array order and return the first one that validates successfully. That means union order is observable: the first codec/schema that can accept the input “wins”, and later options won’t be tried. [2]

So if you have overlapping codecs/schemas (e.g., two branches can both accept the same input), you generally want to:

  • put more specific / more constrained branches first, and
  • tighten codec input schemas (regexes, literals, discriminators) to reduce overlap. [1][2]

Sources:
[1] Zod v4 “Codecs” docs (parse/decode/encode behavior) (zod.dev)
[2] Zod docs text (union checks options in order; returns first success) (npmjs.com)

Citations:


🌐 Web query:

Zod v4 z.union tries members in order parse validation

💡 Result:

In Zod v4, z.union([a, b, c]) attempts to parse/validate each member schema in array order and returns the first one that succeeds. If more than one member could succeed, the earlier one “wins”, and its parsing effects (e.g., transforms, defaulting, unknown-key stripping behavior on objects, etc.) are what you get. If none succeed, you get an invalid_union error containing the per-option errors. [1]

This “first-match wins” behavior is also why z.union() is closer to JSON Schema’s anyOf than oneOf (it doesn’t require exclusivity). [2]

If you need “exactly one option must match”, use z.xor() (added in v4) instead of z.union(). [3]

Sources:
[1] Zod docs / README (Unions: “test … in order and return the first value that validates successfully”) https://www.npmjs.com/package/zod/v/3.21.0
[2] Zod v4 issue discussing anyOf vs oneOf semantics for unions colinhacks/zod#4089
[3] Zod releases (z.xor() exclusive union) https://github.com/colinhacks/zod/releases


🏁 Script executed:

# First, let's examine the actual file to understand the context better
cat -n apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts

Repository: namehash/ensnode

Length of output: 2138


Roundtrip bug: z.union will parse encoded bigint strings as plain strings, not bigints.

During encode, a bigint value correctly goes through stringToBigInt and becomes "123". During parse (decode), the string "123" matches z.string() first in the union — stringToBigInt is never reached because Zod v4 unions return the first successful match. The cursor roundtrip silently converts bigint values to string, breaking downstream SQL comparisons that expect bigint for ordering columns like REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY.

The union is ambiguous: both z.string() and stringToBigInt accept string input, so you need to disambiguate during parsing.

🐛 Proposed fix: use a tagged wrapper to disambiguate bigint values
+const BigIntValue = z.strictObject({
+  __bigint: stringToBigInt,
+});
+
 const DomainCursorSchema = z.strictObject({
   id: z.string(),
   by: z.string(),
-  value: z.union([z.string(), z.null(), stringToBigInt]),
+  value: z.union([z.string(), z.null(), BigIntValue]),
 });

Alternatively, use the by field to conditionally parse the value type, or encode bigints with a suffix marker (e.g., "123n") and use a custom transform.

🤖 Prompt for AI Agents
In `@apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts` around lines 8
- 17, DomainCursorSchema's value union is ambiguous: z.union([z.string(),
stringToBigInt]) lets plain encoded bigint strings match z.string() first, so
bigints never round-trip. Fix by disambiguating the bigint case—either (A)
switch to a tagged wrapper schema (e.g., replace value union with a
discriminated object like { type: "bigint", v: string } vs { type: "string", v:
string } and update encode/decode in stringToBigInt and DomainCursorSchema
accordingly), or (B) make stringToBigInt encode bigint with a clear
suffix/marker (e.g., "123n") and update its decode to only treat marked strings
as bigints, or (C) parse conditionally based on the existing by field inside
DomainCursorSchema so values for timestamp/expiry are run through stringToBigInt
while others use z.string(); apply the chosen change to DomainCursorSchema and
stringToBigInt to ensure parse returns BigInt for bigint cases.

Comment on lines +40 to +50
export const DomainCursor = {
encode: (cursor: DomainCursor) =>
Buffer.from(JSON.stringify(DomainCursorSchema.encode(cursor)), "utf8").toString("base64"),
// NOTE: the 'as DomainCursor' encodes the correct amount of type strictness and ensures that the
// decoded zod object is castable to DomainCursor, without the complexity of inferring the types
// exclusively from zod
decode: (cursor: string) =>
DomainCursorSchema.parse(
JSON.parse(Buffer.from(cursor, "base64").toString("utf8")),
) as DomainCursor,
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

The as DomainCursor cast masks type mismatches from the zod schema.

The comment at line 43-45 acknowledges this tradeoff. Given the roundtrip bug above, this cast will hide the fact that value is string when it should be bigint. Once the union parsing is fixed, this cast remains acceptable as a pragmatic bridge between the zod output type and the manually defined interface.

🤖 Prompt for AI Agents
In `@apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts` around lines
40 - 50, The decode function currently uses a blind "as DomainCursor" cast which
hides mismatches between DomainCursorSchema and the DomainCursor type (e.g.,
string vs bigint for value); remove the cast and make DomainCursorSchema produce
the correct runtime type so decode returns a properly typed value. Specifically,
update DomainCursorSchema (the zod schema used by DomainCursor.encode/decode) to
parse/transform the union/number fields into the expected DomainCursor shape
(use zod transforms/coercions to convert string/number into bigint where needed)
and then change DomainCursor.decode to return the parsed result directly (i.e.,
DomainCursorSchema.parse(...)) without "as DomainCursor". Ensure encode still
uses DomainCursorSchema.encode so the roundtrip matches.

Comment on lines +188 to +191
owner ? eq(domainsBase.ownerId, owner) : undefined,
// TODO: determine if it's necessary to additionally escape user input for LIKE operator
partial ? like(headLabel.interpreted, `${partial}%`) : undefined,
),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

LIKE wildcards in partial are not escaped.

partial originates from user input via parsePartialInterpretedName. Characters %, _, and \ have special meaning in SQL LIKE patterns and are not stripped or escaped here. A user searching for % would match all domains; _ matches any single character. While parameterized (no SQL injection), this alters intended prefix-match semantics and could return unexpectedly large result sets.

🛡️ Proposed fix: escape LIKE meta-characters before building the pattern
+// Escape SQL LIKE meta-characters so user input is treated as literal text.
+function escapeLikePattern(value: string): string {
+  return value.replace(/[%_\\]/g, "\\$&");
+}
+

Then at the call-site:

-        partial ? like(headLabel.interpreted, `${partial}%`) : undefined,
+        partial ? like(headLabel.interpreted, `${escapeLikePattern(partial)}%`) : undefined,
🤖 Prompt for AI Agents
In `@apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts` around lines
188 - 191, The LIKE pattern currently uses raw user input `partial` (used in the
like(headLabel.interpreted, `${partial}%`) call) so `%`, `_`, and `\` alter
matching; escape these meta-characters before building the pattern. Modify the
call-site (or update parsePartialInterpretedName) to transform partial by
replacing backslash with double backslash and prefixing `%` and `_` with a
backslash (i.e. escape `\`, `%`, `_`), then pass `${escapedPartial}%` to like;
ensure the SQL/ORM is using backslash as the escape character or uses the ORM’s
escape option so the escapes are respected.

- Handle NULL cursor values explicitly since PostgreSQL tuple comparison
  with NULL yields NULL/unknown, breaking pagination for domains without
  registrations or discovered labels
- Encode pagination direction (dir) in cursor and validate it matches
  the query's orderDir to prevent inconsistent results when clients
  change direction between requests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io February 6, 2026 07:03 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io February 6, 2026 07:03 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io February 6, 2026 07:03 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts`:
- Around line 145-165: The correlated MAX(index) subquery in latestRegistration
(using registrationOuter and schema.registration.index/domainId) causes per-row
execution; replace it with a window-function-based subquery that computes
ROW_NUMBER() OVER (PARTITION BY domainId ORDER BY index DESC) on
schema.registration, select columns plus row_num, wrap that as a derived table,
then filter where row_num = 1 to produce latestRegistration (alias preserved) so
PostgreSQL can optimize via windowing instead of the correlated scalar subquery.

Comment on lines +145 to +165
// subquery for latest registration per domain (highest index)
// TODO: replace this with a JOIN against the latest registration lookup table after
// https://github.com/namehash/ensnode/issues/1594
const registrationOuter = alias(schema.registration, "registrationOuter");
const latestRegistration = db
.select({
domainId: registrationOuter.domainId,
start: registrationOuter.start,
expiry: registrationOuter.expiry,
})
.from(registrationOuter)
.where(
eq(
registrationOuter.index,
db
.select({ maxIndex: sql<number>`MAX(${schema.registration.index})` })
.from(schema.registration)
.where(eq(schema.registration.domainId, registrationOuter.domainId)),
),
)
.as("latestRegistration");
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Correlated subquery for latest registration could be a performance bottleneck.

The MAX(index) correlated subquery (Lines 158-162) is executed per-row of registrationOuter. For large datasets, this will be slow. The existing TODO (Line 146-147) acknowledges a planned fix via a lookup table (#1594), which is the right long-term approach.

In the interim, consider whether a window function (ROW_NUMBER() OVER (PARTITION BY domainId ORDER BY index DESC)) as a subquery with a filter on row_num = 1 would be more efficient, as PostgreSQL can often optimize that better than a correlated scalar subquery.

🤖 Prompt for AI Agents
In `@apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts` around lines
145 - 165, The correlated MAX(index) subquery in latestRegistration (using
registrationOuter and schema.registration.index/domainId) causes per-row
execution; replace it with a window-function-based subquery that computes
ROW_NUMBER() OVER (PARTITION BY domainId ORDER BY index DESC) on
schema.registration, select columns plus row_num, wrap that as a derived table,
then filter where row_num = 1 to produce latestRegistration (alias preserved) so
PostgreSQL can optimize via windowing instead of the correlated scalar subquery.

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.

ENSv2 Domain Search/Filters

1 participant