Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
930a910
checkpoint: initial draft from claude
shrugs Feb 1, 2026
0a03e2a
checkpoint: DRY find-domains a bit more
shrugs Feb 1, 2026
cdfa7cc
Merge branch 'main' into feat/domain-ordering
shrugs Feb 2, 2026
523a98c
fix: address PR review feedback for domain ordering
shrugs Feb 2, 2026
3ab13bf
docs(changeset): ENSv2 GraphQL API: Introduces order criteria for Dom…
shrugs Feb 2, 2026
992a7fe
fix: address PR bot review feedback for domain ordering
shrugs Feb 3, 2026
3ebdb64
docs: update findDomains algorithm comment to match implementation
shrugs Feb 3, 2026
55c09e6
refactor: simplify cursorFilter to accept DomainCursor object
shrugs Feb 3, 2026
5652b14
docs: add note about Drizzle tuple comparison limitation
shrugs Feb 4, 2026
ee9498c
fix: refactor resolver into shared helper
shrugs Feb 4, 2026
7132c0f
style: wrap long comment line
shrugs Feb 6, 2026
fefb300
feat: add debug logging of generated SQL in find-domains-resolver
shrugs Feb 6, 2026
e458e5d
refactor: locations and fix bigint cursor encoding
shrugs Feb 6, 2026
19670c8
fix: cursor pagination bugs with NULL values and direction mismatch
shrugs Feb 6, 2026
932fbc0
fix: domain-cursor encoding using superjson
shrugs Feb 10, 2026
eeb2b22
tell all agents to shut the fuck up
shrugs Feb 10, 2026
9885464
Merge branch 'main' into feat/domain-ordering
shrugs Feb 10, 2026
25d6f65
Merge branch 'main' into feat/domain-ordering
shrugs Feb 10, 2026
f4d5625
fix bot nits
shrugs Feb 10, 2026
d941511
fix: use head label for NAME ordering instead of leaf label
shrugs Feb 10, 2026
eb05617
fix: explicit casts in cursor tuple comparison and catch malformed cu…
shrugs Feb 10, 2026
151b542
fit bot nits
shrugs Feb 10, 2026
b584e51
fix: cast bigint correctly
shrugs Feb 10, 2026
551cd5e
fix: headLabel is never null
shrugs Feb 10, 2026
a3ab5af
tests: add some unit tests
shrugs Feb 10, 2026
618d7a8
feat: make top-level domain search a non-testing method
shrugs Feb 10, 2026
1ccb1d7
Merge branch 'main' into feat/domain-ordering
shrugs Feb 17, 2026
78ce1fc
final pr notes
shrugs Feb 19, 2026
799d553
Merge branch 'main' into feat/domain-ordering
shrugs Feb 19, 2026
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
119 changes: 107 additions & 12 deletions apps/ensapi/src/graphql-api/lib/find-domains.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, eq, like, Param, sql } from "drizzle-orm";
import { and, asc, desc, eq, like, Param, type SQL, sql } from "drizzle-orm";
import { alias, unionAll } from "drizzle-orm/pg-core";
import type { Address } from "viem";

Expand All @@ -13,6 +13,8 @@ import {
parsePartialInterpretedName,
} from "@ensnode/ensnode-sdk";

import type { DomainsOrderBy } from "@/graphql-api/schema/domain";
import type { OrderDirection } from "@/graphql-api/schema/order-direction";
import { db } from "@/lib/db";
import { makeLogger } from "@/lib/logger";

Expand Down Expand Up @@ -98,9 +100,15 @@ export function findDomains({ name, owner }: DomainFilter) {
const v1HeadDomain = alias(schema.v1Domain, "v1HeadDomain");
const v2HeadDomain = alias(schema.v2Domain, "v2HeadDomain");

// join on leafId (the autocomplete result), filter by owner and partial
const v1Domains = db
.select({ id: sql<DomainId>`${schema.v1Domain.id}`.as("id") })
// Base subqueries: extract unified structure from v1 and v2 domains
// Returns {id, ownerId, leafLabelHash, headLabelHash} for each matching domain
const v1DomainsBase = db
.select({
id: sql<DomainId>`${schema.v1Domain.id}`.as("id"),
ownerId: schema.v1Domain.ownerId,
leafLabelHash: schema.v1Domain.labelHash,
headLabelHash: v1HeadDomain.labelHash,
})
.from(schema.v1Domain)
.innerJoin(
v1DomainsByLabelHashPathQuery,
Expand All @@ -118,28 +126,78 @@ export function findDomains({ name, owner }: DomainFilter) {
),
);

// join on leafId (the autocomplete result), filter by owner and partial
const v2Domains = db
.select({ id: sql<DomainId>`${schema.v2Domain.id}`.as("id") })
const v2DomainsBase = db
.select({
id: sql<DomainId>`${schema.v2Domain.id}`.as("id"),
ownerId: schema.v2Domain.ownerId,
leafLabelHash: schema.v2Domain.labelHash,
headLabelHash: v2HeadDomain.labelHash,
})
.from(schema.v2Domain)
.innerJoin(
v2DomainsByLabelHashPathQuery,
eq(schema.v2Domain.id, v2DomainsByLabelHashPathQuery.leafId),
)
.innerJoin(v2HeadDomain, eq(v2HeadDomain.id, v2DomainsByLabelHashPathQuery.headId))
.leftJoin(schema.label, eq(schema.label.labelHash, v2HeadDomain.labelHash))
.innerJoin(v2HeadDomain, eq(v2HeadDomain.id, v2DomainsByLabelHashPathQuery.headId));

// Union v1 and v2 base queries into a single CTE
const domainsBase = db.$with("domainsBase").as(unionAll(v1DomainsBase, v2DomainsBase));

// alias for head label (for partial matching) and leaf label (for NAME ordering)
const headLabel = alias(schema.label, "headLabel");
const leafLabel = alias(schema.label, "leafLabel");

// 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 latestRegistration = db
.select({
domainId: schema.registration.domainId,
start: schema.registration.start,
expiry: schema.registration.expiry,
})
.from(schema.registration)
.where(
eq(
schema.registration.index,
db
.select({ maxIndex: sql<number>`MAX(${schema.registration.index})` })
.from(schema.registration)
.where(eq(schema.registration.domainId, schema.registration.domainId)),
Comment thread
shrugs marked this conversation as resolved.
Outdated
Comment thread
shrugs marked this conversation as resolved.
Outdated
),
)
.as("latestRegistration");

// Apply shared joins and filters on the unified domain base
const domains = db
.with(domainsBase)
.select({
id: domainsBase.id,
// for NAME ordering
leafLabelValue: sql<string | null>`${leafLabel.interpreted}`.as("leafLabelValue"),
// for REGISTRATION_TIMESTAMP ordering
registrationStart: sql<bigint | null>`${latestRegistration.start}`.as("registrationStart"),
// for REGISTRATION_EXPIRY ordering
registrationExpiry: sql<bigint | null>`${latestRegistration.expiry}`.as("registrationExpiry"),
})
.from(domainsBase)
// join head label for partial matching
.leftJoin(headLabel, eq(headLabel.labelHash, domainsBase.headLabelHash))
// join leaf label for NAME ordering
.leftJoin(leafLabel, eq(leafLabel.labelHash, domainsBase.leafLabelHash))
// join latest registration for timestamp/expiry ordering
.leftJoin(latestRegistration, eq(latestRegistration.domainId, domainsBase.id))
.where(
and(
owner ? eq(schema.v2Domain.ownerId, owner) : undefined,
owner ? eq(domainsBase.ownerId, owner) : undefined,
// TODO: determine if it's necessary to additionally escape user input for LIKE operator
// Note: if label is NULL (unlabeled domain), LIKE returns NULL and filters out the row.
// This is intentional - we can't match partial text against unknown labels.
Comment thread
shrugs marked this conversation as resolved.
Outdated
partial ? like(schema.label.interpreted, `${partial}%`) : undefined,
),
);

// union the two subqueries and return
return db.$with("domains").as(unionAll(v1Domains, v2Domains));
return db.$with("domains").as(domains);
}

/**
Expand Down Expand Up @@ -299,3 +357,40 @@ function v2DomainsByLabelHashPath(labelHashPath: LabelHashPath) {
)
.as("v2_path");
}

/**
* Build ORDER BY clauses for a findDomains result.
*
* @param domains - The findDomains CTE result
* @param orderBy - The field to order by (defaults to NAME)
* @param orderDir - The direction to order ("ASC" or "DESC", defaults to "ASC")
* @param inverted - Whether the relay pagination is inverted (backward pagination)
* @returns Array of SQL order expressions
*/
export function orderFindDomains(
domains: ReturnType<typeof findDomains>,
orderBy: typeof DomainsOrderBy.$inferType | undefined | null,
orderDir: typeof OrderDirection.$inferType | undefined | null,
inverted: boolean,
): SQL[] {
// Combine user's orderDir with relay's inverted (XOR logic)
// inverted flips the sort for backward pagination, so we flip it back
const effectiveDesc = (orderDir === "DESC") !== inverted;

const orderColumn = {
NAME: domains.leafLabelValue,
REGISTRATION_TIMESTAMP: domains.registrationStart,
REGISTRATION_EXPIRY: domains.registrationExpiry,
}[orderBy ?? "NAME"];

// Use NULLS LAST for ascending, NULLS FIRST for descending
// This keeps unregistered domains at the end when sorting by registration fields
Comment thread
shrugs marked this conversation as resolved.
Outdated
const primaryOrder = effectiveDesc
? sql`${orderColumn} DESC NULLS FIRST`
Comment thread
vercel[bot] marked this conversation as resolved.
Outdated
Comment thread
shrugs marked this conversation as resolved.
Outdated
: sql`${orderColumn} ASC NULLS LAST`;

// Always include id as tiebreaker for stable ordering
const tiebreaker = effectiveDesc ? desc(domains.id) : asc(domains.id);

return [primaryOrder, tiebreaker];
}
19 changes: 16 additions & 3 deletions apps/ensapi/src/graphql-api/schema/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ import * as schema from "@ensnode/ensnode-schema";
import type { DomainId, PermissionsUserId } from "@ensnode/ensnode-sdk";

import { builder } from "@/graphql-api/builder";
import { findDomains } from "@/graphql-api/lib/find-domains";
import { findDomains, orderFindDomains } from "@/graphql-api/lib/find-domains";
import { getModelId } from "@/graphql-api/lib/get-model-id";
import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors";
import { AccountIdInput } from "@/graphql-api/schema/account-id";
import { AccountRegistryPermissionsRef } from "@/graphql-api/schema/account-registries-permissions";
import { AccountResolverPermissionsRef } from "@/graphql-api/schema/account-resolver-permissions";
import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants";
import { cursors } from "@/graphql-api/schema/cursors";
Comment thread
shrugs marked this conversation as resolved.
import { AccountDomainsWhereInput, DomainInterfaceRef } from "@/graphql-api/schema/domain";
import {
AccountDomainsWhereInput,
DomainInterfaceRef,
DomainsOrderInput,
} from "@/graphql-api/schema/domain";
import { PermissionsUserRef } from "@/graphql-api/schema/permissions";
import { db } from "@/lib/db";

Expand Down Expand Up @@ -64,6 +68,7 @@ AccountRef.implement({
type: DomainInterfaceRef,
args: {
where: t.arg({ type: AccountDomainsWhereInput, required: false }),
order: t.arg({ type: DomainsOrderInput }),
},
resolve: (parent, args, context) =>
resolveCursorConnection(
Expand All @@ -72,6 +77,14 @@ AccountRef.implement({
// construct query for relevant domains
const domains = findDomains({ ...args.where, owner: parent.id });

// build order clauses
const orderClauses = orderFindDomains(
domains,
args.order?.by,
args.order?.dir,
inverted,
);

// execute with pagination constraints
const results = await db
.with(domains)
Expand All @@ -83,7 +96,7 @@ AccountRef.implement({
after ? gt(domains.id, cursors.decode<DomainId>(after)) : undefined,
),
)
.orderBy(inverted ? desc(domains.id) : asc(domains.id))
.orderBy(...orderClauses)
.limit(limit);

// provide full Domain entities via dataloader
Expand Down
20 changes: 20 additions & 0 deletions apps/ensapi/src/graphql-api/schema/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors";
import { AccountRef } from "@/graphql-api/schema/account";
import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants";
import { cursors } from "@/graphql-api/schema/cursors";
import { OrderDirection } from "@/graphql-api/schema/order-direction";
import { RegistrationInterfaceRef } from "@/graphql-api/schema/registration";
import { RegistryRef } from "@/graphql-api/schema/registry";
import { ResolverRef } from "@/graphql-api/schema/resolver";
Expand Down Expand Up @@ -331,3 +332,22 @@ export const AccountDomainsWhereInput = builder.inputType("AccountDomainsWhereIn
name: t.string({ required: true }),
}),
});

//////////////////////
// Ordering
//////////////////////

export const DomainsOrderBy = builder.enumType("DomainsOrderBy", {
description: "Fields by which domains can be ordered",
values: ["NAME", "REGISTRATION_TIMESTAMP", "REGISTRATION_EXPIRY"] as const,
});

export type DomainsOrderByValue = typeof DomainsOrderBy.$inferType;

export const DomainsOrderInput = builder.inputType("DomainsOrderInput", {
description: "Ordering options for domains query",
Comment thread
shrugs marked this conversation as resolved.
Outdated
fields: (t) => ({
by: t.field({ type: DomainsOrderBy, required: true }),
dir: t.field({ type: OrderDirection, defaultValue: "ASC" }),
}),
Comment thread
shrugs marked this conversation as resolved.
Comment on lines +347 to +352
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

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

DomainsOrderInput’s description is misleading: the default isn’t just ASC—the default order for the connection is NAME ASC when order is omitted, and only dir defaults to ASC when order is provided without dir. Update the description to reflect the actual defaults to avoid confusing API consumers.

Copilot uses AI. Check for mistakes.
});
8 changes: 8 additions & 0 deletions apps/ensapi/src/graphql-api/schema/order-direction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { builder } from "@/graphql-api/builder";
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

shared OrderDirection enum for future methods


export const OrderDirection = builder.enumType("OrderDirection", {
description: "Sort direction",
values: ["ASC", "DESC"] as const,
});

export type OrderDirectionValue = typeof OrderDirection.$inferType;
16 changes: 13 additions & 3 deletions apps/ensapi/src/graphql-api/schema/query.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import config from "@/config";

import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay";
import { and, asc, desc, gt, lt } from "drizzle-orm";
import { and, gt, lt } from "drizzle-orm";

import {
type DomainId,
Expand All @@ -16,7 +16,7 @@ import {
} from "@ensnode/ensnode-sdk";

import { builder } from "@/graphql-api/builder";
import { findDomains } from "@/graphql-api/lib/find-domains";
import { findDomains, orderFindDomains } from "@/graphql-api/lib/find-domains";
import { getDomainIdByInterpretedName } from "@/graphql-api/lib/get-domain-by-fqdn";
import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors";
import { AccountRef } from "@/graphql-api/schema/account";
Expand All @@ -26,6 +26,7 @@ import { cursors } from "@/graphql-api/schema/cursors";
import {
DomainIdInput,
DomainInterfaceRef,
DomainsOrderInput,
DomainsWhereInput,
ENSv1DomainRef,
ENSv2DomainRef,
Expand All @@ -50,6 +51,7 @@ builder.queryType({
type: DomainInterfaceRef,
args: {
where: t.arg({ type: DomainsWhereInput, required: true }),
order: t.arg({ type: DomainsOrderInput }),
},
resolve: (parent, args, context) =>
resolveCursorConnection(
Expand All @@ -58,6 +60,14 @@ builder.queryType({
// construct query for relevant domains
const domains = findDomains(args.where);

// build order clauses
const orderClauses = orderFindDomains(
domains,
args.order?.by,
args.order?.dir,
inverted,
);

// execute with pagination constraints
const results = await db
.with(domains)
Expand All @@ -69,7 +79,7 @@ builder.queryType({
after ? gt(domains.id, cursors.decode<DomainId>(after)) : undefined,
),
)
.orderBy(inverted ? desc(domains.id) : asc(domains.id))
.orderBy(...orderClauses)
.limit(limit);

// provide full Domain entities via dataloader
Expand Down
Loading