From 930a910196e9b20766a9c1ad0c481039fa291759 Mon Sep 17 00:00:00 2001 From: shrugs Date: Sun, 1 Feb 2026 11:15:47 +0900 Subject: [PATCH 01/23] checkpoint: initial draft from claude --- .../src/graphql-api/lib/find-domains.ts | 108 ++++++++++++++++-- apps/ensapi/src/graphql-api/schema/account.ts | 19 ++- apps/ensapi/src/graphql-api/schema/domain.ts | 20 ++++ apps/ensapi/src/graphql-api/schema/query.ts | 16 ++- 4 files changed, 146 insertions(+), 17 deletions(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains.ts index 814d8afcc..640dc7fe8 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains.ts @@ -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"; @@ -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"; @@ -98,43 +100,90 @@ export function findDomains({ name, owner }: DomainFilter) { const v1HeadDomain = alias(schema.v1Domain, "v1HeadDomain"); const v2HeadDomain = alias(schema.v2Domain, "v2HeadDomain"); + // 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`MAX(${schema.registration.index})` }) + .from(schema.registration) + .where(eq(schema.registration.domainId, schema.registration.domainId)), + ), + ) + .as("latestRegistration"); + // join on leafId (the autocomplete result), filter by owner and partial const v1Domains = db - .select({ id: sql`${schema.v1Domain.id}`.as("id") }) + .select({ + id: sql`${schema.v1Domain.id}`.as("id"), + // for NAME ordering + leafLabelValue: sql`${leafLabel.value}`.as("leafLabelValue"), + // for REGISTRATION_TIMESTAMP ordering + registrationStart: sql`${latestRegistration.start}`.as("registrationStart"), + // for REGISTRATION_EXPIRY ordering + registrationExpiry: sql`${latestRegistration.expiry}`.as("registrationExpiry"), + }) .from(schema.v1Domain) .innerJoin( v1DomainsByLabelHashPathQuery, eq(schema.v1Domain.id, v1DomainsByLabelHashPathQuery.leafId), ) .innerJoin(v1HeadDomain, eq(v1HeadDomain.id, v1DomainsByLabelHashPathQuery.headId)) - .leftJoin(schema.label, eq(schema.label.labelHash, v1HeadDomain.labelHash)) + // join head label for partial matching + .leftJoin(headLabel, eq(headLabel.labelHash, v1HeadDomain.labelHash)) + // join leaf label for NAME ordering + .leftJoin(leafLabel, eq(leafLabel.labelHash, schema.v1Domain.labelHash)) + // join latest registration for timestamp/expiry ordering + .leftJoin(latestRegistration, eq(latestRegistration.domainId, schema.v1Domain.id)) .where( and( owner ? eq(schema.v1Domain.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. - partial ? like(schema.label.value, `${partial}%`) : undefined, + partial ? like(headLabel.value, `${partial}%`) : undefined, ), ); // join on leafId (the autocomplete result), filter by owner and partial const v2Domains = db - .select({ id: sql`${schema.v2Domain.id}`.as("id") }) + .select({ + id: sql`${schema.v2Domain.id}`.as("id"), + // for NAME ordering + leafLabelValue: sql`${leafLabel.value}`.as("leafLabelValue"), + // for REGISTRATION_TIMESTAMP ordering + registrationStart: sql`${latestRegistration.start}`.as("registrationStart"), + // for REGISTRATION_EXPIRY ordering + registrationExpiry: sql`${latestRegistration.expiry}`.as("registrationExpiry"), + }) .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)) + // join head label for partial matching + .leftJoin(headLabel, eq(headLabel.labelHash, v2HeadDomain.labelHash)) + // join leaf label for NAME ordering + .leftJoin(leafLabel, eq(leafLabel.labelHash, schema.v2Domain.labelHash)) + // join latest registration for timestamp/expiry ordering + .leftJoin(latestRegistration, eq(latestRegistration.domainId, schema.v2Domain.id)) .where( and( owner ? eq(schema.v2Domain.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. - partial ? like(schema.label.value, `${partial}%`) : undefined, + partial ? like(headLabel.value, `${partial}%`) : undefined, ), ); @@ -299,3 +348,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, + 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 + const primaryOrder = effectiveDesc + ? sql`${orderColumn} DESC NULLS FIRST` + : 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]; +} diff --git a/apps/ensapi/src/graphql-api/schema/account.ts b/apps/ensapi/src/graphql-api/schema/account.ts index c79011cdc..8da77e915 100644 --- a/apps/ensapi/src/graphql-api/schema/account.ts +++ b/apps/ensapi/src/graphql-api/schema/account.ts @@ -6,7 +6,7 @@ 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"; @@ -14,7 +14,11 @@ import { AccountRegistryPermissionsRef } from "@/graphql-api/schema/account-regi 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"; -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"; @@ -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( @@ -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) @@ -83,7 +96,7 @@ AccountRef.implement({ after ? gt(domains.id, cursors.decode(after)) : undefined, ), ) - .orderBy(inverted ? desc(domains.id) : asc(domains.id)) + .orderBy(...orderClauses) .limit(limit); // provide full Domain entities via dataloader diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index d74aa5ad8..859fdecd6 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -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"; @@ -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", + fields: (t) => ({ + by: t.field({ type: DomainsOrderBy, required: true }), + dir: t.field({ type: OrderDirection, defaultValue: "ASC" }), + }), +}); diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts index 16a6c093b..c6247c457 100644 --- a/apps/ensapi/src/graphql-api/schema/query.ts +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -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, @@ -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"; @@ -26,6 +26,7 @@ import { cursors } from "@/graphql-api/schema/cursors"; import { DomainIdInput, DomainInterfaceRef, + DomainsOrderInput, DomainsWhereInput, ENSv1DomainRef, ENSv2DomainRef, @@ -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( @@ -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) @@ -69,7 +79,7 @@ builder.queryType({ after ? gt(domains.id, cursors.decode(after)) : undefined, ), ) - .orderBy(inverted ? desc(domains.id) : asc(domains.id)) + .orderBy(...orderClauses) .limit(limit); // provide full Domain entities via dataloader From 0a03e2a8420323ce4f728cfa2a71c7cc75eaca36 Mon Sep 17 00:00:00 2001 From: shrugs Date: Sun, 1 Feb 2026 11:18:16 +0900 Subject: [PATCH 02/23] checkpoint: DRY find-domains a bit more --- .../src/graphql-api/lib/find-domains.ts | 91 +++++++++---------- .../src/graphql-api/schema/order-direction.ts | 8 ++ 2 files changed, 53 insertions(+), 46 deletions(-) create mode 100644 apps/ensapi/src/graphql-api/schema/order-direction.ts diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains.ts index 640dc7fe8..53c089fc9 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains.ts @@ -100,6 +100,39 @@ export function findDomains({ name, owner }: DomainFilter) { const v1HeadDomain = alias(schema.v1Domain, "v1HeadDomain"); const v2HeadDomain = alias(schema.v2Domain, "v2HeadDomain"); + // 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`${schema.v1Domain.id}`.as("id"), + ownerId: schema.v1Domain.ownerId, + leafLabelHash: schema.v1Domain.labelHash, + headLabelHash: v1HeadDomain.labelHash, + }) + .from(schema.v1Domain) + .innerJoin( + v1DomainsByLabelHashPathQuery, + eq(schema.v1Domain.id, v1DomainsByLabelHashPathQuery.leafId), + ) + .innerJoin(v1HeadDomain, eq(v1HeadDomain.id, v1DomainsByLabelHashPathQuery.headId)); + + const v2DomainsBase = db + .select({ + id: sql`${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)); + + // 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"); @@ -125,10 +158,11 @@ export function findDomains({ name, owner }: DomainFilter) { ) .as("latestRegistration"); - // join on leafId (the autocomplete result), filter by owner and partial - const v1Domains = db + // Apply shared joins and filters on the unified domain base + const domains = db + .with(domainsBase) .select({ - id: sql`${schema.v1Domain.id}`.as("id"), + id: domainsBase.id, // for NAME ordering leafLabelValue: sql`${leafLabel.value}`.as("leafLabelValue"), // for REGISTRATION_TIMESTAMP ordering @@ -136,59 +170,24 @@ export function findDomains({ name, owner }: DomainFilter) { // for REGISTRATION_EXPIRY ordering registrationExpiry: sql`${latestRegistration.expiry}`.as("registrationExpiry"), }) - .from(schema.v1Domain) - .innerJoin( - v1DomainsByLabelHashPathQuery, - eq(schema.v1Domain.id, v1DomainsByLabelHashPathQuery.leafId), - ) - .innerJoin(v1HeadDomain, eq(v1HeadDomain.id, v1DomainsByLabelHashPathQuery.headId)) - // join head label for partial matching - .leftJoin(headLabel, eq(headLabel.labelHash, v1HeadDomain.labelHash)) - // join leaf label for NAME ordering - .leftJoin(leafLabel, eq(leafLabel.labelHash, schema.v1Domain.labelHash)) - // join latest registration for timestamp/expiry ordering - .leftJoin(latestRegistration, eq(latestRegistration.domainId, schema.v1Domain.id)) - .where( - and( - owner ? eq(schema.v1Domain.ownerId, owner) : undefined, - // TODO: determine if it's necessary to additionally escape user input for LIKE operator - partial ? like(headLabel.value, `${partial}%`) : undefined, - ), - ); - - // join on leafId (the autocomplete result), filter by owner and partial - const v2Domains = db - .select({ - id: sql`${schema.v2Domain.id}`.as("id"), - // for NAME ordering - leafLabelValue: sql`${leafLabel.value}`.as("leafLabelValue"), - // for REGISTRATION_TIMESTAMP ordering - registrationStart: sql`${latestRegistration.start}`.as("registrationStart"), - // for REGISTRATION_EXPIRY ordering - registrationExpiry: sql`${latestRegistration.expiry}`.as("registrationExpiry"), - }) - .from(schema.v2Domain) - .innerJoin( - v2DomainsByLabelHashPathQuery, - eq(schema.v2Domain.id, v2DomainsByLabelHashPathQuery.leafId), - ) - .innerJoin(v2HeadDomain, eq(v2HeadDomain.id, v2DomainsByLabelHashPathQuery.headId)) + .from(domainsBase) // join head label for partial matching - .leftJoin(headLabel, eq(headLabel.labelHash, v2HeadDomain.labelHash)) + .leftJoin(headLabel, eq(headLabel.labelHash, domainsBase.headLabelHash)) // join leaf label for NAME ordering - .leftJoin(leafLabel, eq(leafLabel.labelHash, schema.v2Domain.labelHash)) + .leftJoin(leafLabel, eq(leafLabel.labelHash, domainsBase.leafLabelHash)) // join latest registration for timestamp/expiry ordering - .leftJoin(latestRegistration, eq(latestRegistration.domainId, schema.v2Domain.id)) + .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. partial ? like(headLabel.value, `${partial}%`) : undefined, ), ); - // union the two subqueries and return - return db.$with("domains").as(unionAll(v1Domains, v2Domains)); + return db.$with("domains").as(domains); } /** diff --git a/apps/ensapi/src/graphql-api/schema/order-direction.ts b/apps/ensapi/src/graphql-api/schema/order-direction.ts new file mode 100644 index 000000000..3fcec85ee --- /dev/null +++ b/apps/ensapi/src/graphql-api/schema/order-direction.ts @@ -0,0 +1,8 @@ +import { builder } from "@/graphql-api/builder"; + +export const OrderDirection = builder.enumType("OrderDirection", { + description: "Sort direction", + values: ["ASC", "DESC"] as const, +}); + +export type OrderDirectionValue = typeof OrderDirection.$inferType; From 523a98caf380196edcab3ed47e899b50f631e14d Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 2 Feb 2026 18:17:27 +0900 Subject: [PATCH 03/23] fix: address PR review feedback for domain ordering - 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 --- .../src/graphql-api/lib/find-domains.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains.ts index 73dcb0418..c1a907710 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains.ts @@ -150,20 +150,21 @@ export function findDomains({ name, owner }: DomainFilter) { // 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: schema.registration.domainId, - start: schema.registration.start, - expiry: schema.registration.expiry, + domainId: registrationOuter.domainId, + start: registrationOuter.start, + expiry: registrationOuter.expiry, }) - .from(schema.registration) + .from(registrationOuter) .where( eq( - schema.registration.index, + registrationOuter.index, db .select({ maxIndex: sql`MAX(${schema.registration.index})` }) .from(schema.registration) - .where(eq(schema.registration.domainId, schema.registration.domainId)), + .where(eq(schema.registration.domainId, registrationOuter.domainId)), ), ) .as("latestRegistration"); @@ -193,7 +194,7 @@ export function findDomains({ name, owner }: DomainFilter) { // 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. - partial ? like(schema.label.interpreted, `${partial}%`) : undefined, + partial ? like(headLabel.interpreted, `${partial}%`) : undefined, ), ); @@ -383,10 +384,10 @@ export function orderFindDomains( 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 + // Always use NULLS LAST so unregistered domains (NULL registration fields) + // appear at the end regardless of sort direction const primaryOrder = effectiveDesc - ? sql`${orderColumn} DESC NULLS FIRST` + ? sql`${orderColumn} DESC NULLS LAST` : sql`${orderColumn} ASC NULLS LAST`; // Always include id as tiebreaker for stable ordering From 3ab13bfdd8901c9c9784331dd0bfc2dc677a0a09 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 2 Feb 2026 18:19:01 +0900 Subject: [PATCH 04/23] docs(changeset): ENSv2 GraphQL API: Introduces order criteria for Domain methods, i.e. `Account.domains(order: { by: NAME dir: ASC })`. --- .changeset/whole-ways-grin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/whole-ways-grin.md diff --git a/.changeset/whole-ways-grin.md b/.changeset/whole-ways-grin.md new file mode 100644 index 000000000..1d23503d9 --- /dev/null +++ b/.changeset/whole-ways-grin.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +ENSv2 GraphQL API: Introduces order criteria for Domain methods, i.e. `Account.domains(order: { by: NAME dir: ASC })`. From 992a7fef7955ecd056293f8b267030969a9e22e4 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 4 Feb 2026 08:46:41 +0900 Subject: [PATCH 05/23] fix: address PR bot review feedback for domain ordering - Fix correlated subquery bug in latestRegistration (use registrationOuter alias) - Fix partial filtering to use correct table alias (headLabel.interpreted) - Fix NULLS ordering to always use NULLS LAST regardless of sort direction - Remove duplicate v1 filtering from v1DomainsBase (filters applied in unified query) - Implement composite cursor pagination with order value for stable keyset pagination - Add cursor orderBy validation to catch cursor/query mismatch errors - DRY up code: add DomainWithOrderValue type, getOrderValueFromResult helper - Fix changeset syntax (add missing comma) Co-Authored-By: Claude Opus 4.5 --- .changeset/whole-ways-grin.md | 2 +- .../src/graphql-api/lib/find-domains.ts | 129 ++++++++++++++---- apps/ensapi/src/graphql-api/schema/account.ts | 89 +++++++++--- apps/ensapi/src/graphql-api/schema/domain.ts | 26 ++++ apps/ensapi/src/graphql-api/schema/query.ts | 90 +++++++++--- 5 files changed, 272 insertions(+), 64 deletions(-) diff --git a/.changeset/whole-ways-grin.md b/.changeset/whole-ways-grin.md index 1d23503d9..87b3b0d4d 100644 --- a/.changeset/whole-ways-grin.md +++ b/.changeset/whole-ways-grin.md @@ -2,4 +2,4 @@ "ensapi": minor --- -ENSv2 GraphQL API: Introduces order criteria for Domain methods, i.e. `Account.domains(order: { by: NAME dir: ASC })`. +ENSv2 GraphQL API: Introduces order criteria for Domain methods, i.e. `Account.domains(order: { by: NAME, dir: ASC })`. diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains.ts index c1a907710..a6ffa47a7 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains.ts @@ -13,7 +13,7 @@ import { parsePartialInterpretedName, } from "@ensnode/ensnode-sdk"; -import type { DomainsOrderBy } from "@/graphql-api/schema/domain"; +import type { Domain, DomainOrderValue, 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"; @@ -27,6 +27,34 @@ interface DomainFilter { owner?: Address | undefined | null; } +/** Domain with order value attached for cursor encoding */ +export type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue | undefined }; + +/** Result row from findDomains CTE */ +type FindDomainsResult = { + id: DomainId; + leafLabelValue: string | null; + registrationStart: bigint | null; + registrationExpiry: bigint | null; +}; + +/** + * Extract the order value from a findDomains result row based on the orderBy field. + */ +export function getOrderValueFromResult( + result: FindDomainsResult, + orderBy: typeof DomainsOrderBy.$inferType, +): DomainOrderValue { + switch (orderBy) { + case "NAME": + return result.leafLabelValue; + case "REGISTRATION_TIMESTAMP": + return result.registrationStart; + case "REGISTRATION_EXPIRY": + return result.registrationExpiry; + } +} + /** * Find Domains by Canonical Name. * @@ -102,6 +130,7 @@ export function findDomains({ name, owner }: DomainFilter) { // Base subqueries: extract unified structure from v1 and v2 domains // Returns {id, ownerId, leafLabelHash, headLabelHash} for each matching domain + // Note: owner/partial filtering happens in the unified query below, not here const v1DomainsBase = db .select({ id: sql`${schema.v1Domain.id}`.as("id"), @@ -114,17 +143,7 @@ export function findDomains({ name, owner }: DomainFilter) { v1DomainsByLabelHashPathQuery, eq(schema.v1Domain.id, v1DomainsByLabelHashPathQuery.leafId), ) - .innerJoin(v1HeadDomain, eq(v1HeadDomain.id, v1DomainsByLabelHashPathQuery.headId)) - .leftJoin(schema.label, eq(schema.label.labelHash, v1HeadDomain.labelHash)) - .where( - and( - owner ? eq(schema.v1Domain.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. - partial ? like(schema.label.interpreted, `${partial}%`) : undefined, - ), - ); + .innerJoin(v1HeadDomain, eq(v1HeadDomain.id, v1DomainsByLabelHashPathQuery.headId)); const v2DomainsBase = db .select({ @@ -360,29 +379,83 @@ function v2DomainsByLabelHashPath(labelHashPath: LabelHashPath) { } /** - * Build ORDER BY clauses for a findDomains result. + * Get the order column for a given DomainsOrderBy value. + */ +function getOrderColumn( + domains: ReturnType, + orderBy: typeof DomainsOrderBy.$inferType, +) { + return { + NAME: domains.leafLabelValue, + REGISTRATION_TIMESTAMP: domains.registrationStart, + REGISTRATION_EXPIRY: domains.registrationExpiry, + }[orderBy]; +} + +/** + * Build a cursor filter for keyset pagination on findDomains results. + * + * Uses tuple comparison: (orderColumn, id) > (cursorValue, cursorId) * * @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 + * @param cursorId - The domain ID from the decoded cursor + * @param cursorValue - The order column value from the decoded cursor + * @param cursorOrderBy - The order field from the decoded cursor + * @param queryOrderBy - The order field for the current query (must match cursorOrderBy) + * @param direction - "after" for forward pagination, "before" for backward + * @param effectiveDesc - Whether the effective sort direction is descending + * @throws if cursorOrderBy does not match queryOrderBy + * @returns SQL expression for the cursor filter + */ +export function cursorFilter( + domains: ReturnType, + cursorId: DomainId, + cursorValue: DomainOrderValue | undefined, + cursorOrderBy: typeof DomainsOrderBy.$inferType, + queryOrderBy: typeof DomainsOrderBy.$inferType, + direction: "after" | "before", + effectiveDesc: boolean, +): SQL { + // Validate cursor was created with the same ordering as the current query + if (cursorOrderBy !== queryOrderBy) { + throw new Error( + `Invalid cursor: cursor was created with orderBy=${cursorOrderBy} but query uses orderBy=${queryOrderBy}`, + ); + } + + const orderColumn = getOrderColumn(domains, cursorOrderBy); + + // Determine comparison direction: + // - "after" with ASC = greater than cursor + // - "after" with DESC = less than cursor + // - "before" with ASC = less than cursor + // - "before" with DESC = greater than cursor + const useGreaterThan = (direction === "after") !== effectiveDesc; + const op = useGreaterThan ? ">" : "<"; + + // Direct tuple comparison with cursor values (no subquery needed) + return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${cursorValue}, ${cursorId})`; +} + +/** + * Compute the effective sort direction, combining user's orderDir with relay's inverted flag. + * XOR logic: inverted flips the sort for backward pagination. */ +export function isEffectiveDesc( + orderDir: typeof OrderDirection.$inferType, + inverted: boolean, +): boolean { + return (orderDir === "DESC") !== inverted; +} + export function orderFindDomains( domains: ReturnType, - orderBy: typeof DomainsOrderBy.$inferType | undefined | null, - orderDir: typeof OrderDirection.$inferType | undefined | null, + orderBy: typeof DomainsOrderBy.$inferType, + orderDir: typeof OrderDirection.$inferType, 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"]; + const effectiveDesc = isEffectiveDesc(orderDir, inverted); + const orderColumn = getOrderColumn(domains, orderBy); // Always use NULLS LAST so unregistered domains (NULL registration fields) // appear at the end regardless of sort direction diff --git a/apps/ensapi/src/graphql-api/schema/account.ts b/apps/ensapi/src/graphql-api/schema/account.ts index 8da77e915..82a1dae46 100644 --- a/apps/ensapi/src/graphql-api/schema/account.ts +++ b/apps/ensapi/src/graphql-api/schema/account.ts @@ -3,10 +3,17 @@ import { and, asc, desc, eq, gt, lt } from "drizzle-orm"; import type { Address } from "viem"; import * as schema from "@ensnode/ensnode-schema"; -import type { DomainId, PermissionsUserId } from "@ensnode/ensnode-sdk"; +import type { PermissionsUserId } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; -import { findDomains, orderFindDomains } from "@/graphql-api/lib/find-domains"; +import { + cursorFilter, + type DomainWithOrderValue, + findDomains, + getOrderValueFromResult, + isEffectiveDesc, + 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"; @@ -16,6 +23,9 @@ import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; import { cursors } from "@/graphql-api/schema/cursors"; import { AccountDomainsWhereInput, + DOMAINS_DEFAULT_ORDER_BY, + DOMAINS_DEFAULT_ORDER_DIR, + DomainCursor, DomainInterfaceRef, DomainsOrderInput, } from "@/graphql-api/schema/domain"; @@ -70,43 +80,88 @@ AccountRef.implement({ where: t.arg({ type: AccountDomainsWhereInput, required: false }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (parent, args, context) => - resolveCursorConnection( - { ...DEFAULT_CONNECTION_ARGS, args }, + resolve: (parent, args, context) => { + const orderBy = args.order?.by ?? DOMAINS_DEFAULT_ORDER_BY; + const orderDir = args.order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; + + return resolveCursorConnection( + { + ...DEFAULT_CONNECTION_ARGS, + args, + toCursor: (domain: DomainWithOrderValue) => + DomainCursor.encode({ + id: domain.id, + by: orderBy, + value: domain.__orderValue, + }), + }, async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { + const effectiveDesc = isEffectiveDesc(orderDir, inverted); + // 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, - ); + const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted); - // execute with pagination constraints + // decode cursors for keyset pagination + const beforeCursor = before ? DomainCursor.decode(before) : undefined; + const afterCursor = after ? DomainCursor.decode(after) : undefined; + + // execute with pagination constraints using tuple comparison const results = await db .with(domains) .select() .from(domains) .where( and( - before ? lt(domains.id, cursors.decode(before)) : undefined, - after ? gt(domains.id, cursors.decode(after)) : undefined, + beforeCursor + ? cursorFilter( + domains, + beforeCursor.id, + beforeCursor.value, + beforeCursor.by, + orderBy, + "before", + effectiveDesc, + ) + : undefined, + afterCursor + ? cursorFilter( + domains, + afterCursor.id, + afterCursor.value, + afterCursor.by, + orderBy, + "after", + effectiveDesc, + ) + : undefined, ), ) .orderBy(...orderClauses) .limit(limit); - // provide full Domain entities via dataloader - return rejectAnyErrors( + // Map CTE results by id for order value lookup + const orderValueById = new Map( + results.map((r) => [r.id, getOrderValueFromResult(r, orderBy)]), + ); + + // Load full Domain entities via dataloader + const loadedDomains = await rejectAnyErrors( DomainInterfaceRef.getDataloader(context).loadMany( results.map((result) => result.id), ), ); + + // Attach order values for cursor encoding + return loadedDomains.map((domain) => ({ + ...domain, + __orderValue: orderValueById.get(domain.id), + })); }, - ), + ); + }, }), /////////////////////// diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index 79dd131fd..f5e2af525 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -351,3 +351,29 @@ export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { dir: t.field({ type: OrderDirection, defaultValue: "ASC" }), }), }); + +export const DOMAINS_DEFAULT_ORDER_BY: typeof DomainsOrderBy.$inferType = "NAME"; +export const DOMAINS_DEFAULT_ORDER_DIR: typeof OrderDirection.$inferType = "ASC"; + +////////////////////// +// Cursors +////////////////////// + +/** Order value type - string for NAME, bigint for timestamps */ +export type DomainOrderValue = string | bigint | null; + +/** + * Composite Domain cursor for keyset pagination. + * Includes the order column value to enable proper tuple comparison without subqueries. + */ +export interface DomainCursor { + id: DomainId; + by: typeof DomainsOrderBy.$inferType; + value: DomainOrderValue | undefined; +} + +export const DomainCursor = { + encode: (cursor: DomainCursor) => Buffer.from(JSON.stringify(cursor), "utf8").toString("base64"), + decode: (cursor: string) => + JSON.parse(Buffer.from(cursor, "base64").toString("utf8")) as DomainCursor, +}; diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts index c6247c457..b1326880c 100644 --- a/apps/ensapi/src/graphql-api/schema/query.ts +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -1,10 +1,9 @@ import config from "@/config"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { and, gt, lt } from "drizzle-orm"; +import { and } from "drizzle-orm"; import { - type DomainId, type ENSv1DomainId, type ENSv2DomainId, getENSv2RootRegistryId, @@ -16,7 +15,14 @@ import { } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; -import { findDomains, orderFindDomains } from "@/graphql-api/lib/find-domains"; +import { + cursorFilter, + type DomainWithOrderValue, + findDomains, + getOrderValueFromResult, + isEffectiveDesc, + 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"; @@ -24,6 +30,9 @@ import { AccountIdInput } from "@/graphql-api/schema/account-id"; import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; import { cursors } from "@/graphql-api/schema/cursors"; import { + DOMAINS_DEFAULT_ORDER_BY, + DOMAINS_DEFAULT_ORDER_DIR, + DomainCursor, DomainIdInput, DomainInterfaceRef, DomainsOrderInput, @@ -53,43 +62,88 @@ builder.queryType({ where: t.arg({ type: DomainsWhereInput, required: true }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (parent, args, context) => - resolveCursorConnection( - { ...DEFAULT_CONNECTION_ARGS, args }, + resolve: (parent, args, context) => { + const orderBy = args.order?.by ?? DOMAINS_DEFAULT_ORDER_BY; + const orderDir = args.order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; + + return resolveCursorConnection( + { + ...DEFAULT_CONNECTION_ARGS, + args, + toCursor: (domain: DomainWithOrderValue) => + DomainCursor.encode({ + id: domain.id, + by: orderBy, + value: domain.__orderValue, + }), + }, async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { + const effectiveDesc = isEffectiveDesc(orderDir, inverted); + // construct query for relevant domains const domains = findDomains(args.where); // build order clauses - const orderClauses = orderFindDomains( - domains, - args.order?.by, - args.order?.dir, - inverted, - ); + const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted); + + // decode cursors for keyset pagination + const beforeCursor = before ? DomainCursor.decode(before) : undefined; + const afterCursor = after ? DomainCursor.decode(after) : undefined; - // execute with pagination constraints + // execute with pagination constraints using tuple comparison const results = await db .with(domains) .select() .from(domains) .where( and( - before ? lt(domains.id, cursors.decode(before)) : undefined, - after ? gt(domains.id, cursors.decode(after)) : undefined, + beforeCursor + ? cursorFilter( + domains, + beforeCursor.id, + beforeCursor.value, + beforeCursor.by, + orderBy, + "before", + effectiveDesc, + ) + : undefined, + afterCursor + ? cursorFilter( + domains, + afterCursor.id, + afterCursor.value, + afterCursor.by, + orderBy, + "after", + effectiveDesc, + ) + : undefined, ), ) .orderBy(...orderClauses) .limit(limit); - // provide full Domain entities via dataloader - return rejectAnyErrors( + // Map CTE results by id for order value lookup + const orderValueById = new Map( + results.map((r) => [r.id, getOrderValueFromResult(r, orderBy)]), + ); + + // Load full Domain entities via dataloader + const loadedDomains = await rejectAnyErrors( DomainInterfaceRef.getDataloader(context).loadMany( results.map((result) => result.id), ), ); + + // Attach order values for cursor encoding + return loadedDomains.map((domain) => ({ + ...domain, + __orderValue: orderValueById.get(domain.id), + })); }, - ), + ); + }, }), ///////////////////////////// From 3ebdb648c62aa926e5b7976d423490d3f262497c Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 4 Feb 2026 08:50:34 +0900 Subject: [PATCH 06/23] docs: update findDomains algorithm comment to match implementation Co-Authored-By: Claude Opus 4.5 --- .../src/graphql-api/lib/find-domains.ts | 34 ++++++++++++------- apps/ensapi/src/graphql-api/schema/domain.ts | 5 +++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains.ts index a6ffa47a7..4528817cf 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains.ts @@ -22,15 +22,22 @@ const logger = makeLogger("find-domains"); const MAX_DEPTH = 16; +/** + * Defines the full set of possible filters for a Find Domains operation. + */ interface DomainFilter { name?: Name | undefined | null; owner?: Address | undefined | null; } -/** Domain with order value attached for cursor encoding */ +/** + * Domain with order value attached for cursor encoding + */ export type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue | undefined }; -/** Result row from findDomains CTE */ +/** + * Result row from findDomains CTE. Includes columns for all supported orderings. + */ type FindDomainsResult = { id: DomainId; leafLabelValue: string | null; @@ -85,16 +92,19 @@ export function getOrderValueFromResult( * * ## Algorithm * - * 1. parse Partial InterpretedName into concrete path and partial fragment - * i.e. for a `name` like "sub1.sub2.paren": - * - concrete = ["sub1", "sub2"] - * - partial = 'paren' - * 2. validate inputs - * 3. for both v1Domains and v2Domains - * a. construct a subquery that filters the set of Domains to those with the specific concrete path - * b. if provided, filter the head domains of that path by `partial` - * c. if provided, filter the leaf domains of that path by `owner` - * 4. construct a union of the two result sets and return + * 1. Parse Partial InterpretedName into concrete path and partial fragment + * e.g. for `name` = "sub1.sub2.paren": concrete = ["sub1", "sub2"], partial = "paren" + * 2. Validate inputs (at least one of name or owner required) + * 3. For both v1Domains and v2Domains: + * a. Build recursive CTE to find domains matching the concrete labelHash path + * b. Extract unified structure: {id, ownerId, leafLabelHash, headLabelHash} + * 4. Union v1 and v2 results into domainsBase CTE + * 5. Join domainsBase with: + * - headLabel: for partial name matching (LIKE prefix) + * - leafLabel: for NAME ordering + * - latestRegistration: correlated subquery for REGISTRATION_* ordering + * 6. Apply filters (owner, partial) in the unified query + * 7. Return CTE with columns: id, leafLabelValue, registrationStart, registrationExpiry */ export function findDomains({ name, owner }: DomainFilter) { // NOTE: if name is not provided, parse empty string to simplify control-flow, validity checked below diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index f5e2af525..242256714 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -372,6 +372,11 @@ export interface DomainCursor { value: DomainOrderValue | undefined; } +/** + * Encoding/Decoding helper for Composite DomainCursors. + * + * @dev it's base64'd JSON + */ export const DomainCursor = { encode: (cursor: DomainCursor) => Buffer.from(JSON.stringify(cursor), "utf8").toString("base64"), decode: (cursor: string) => From 55c09e6324c914dac1aafdd10f137896b48bbfcc Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 4 Feb 2026 08:57:52 +0900 Subject: [PATCH 07/23] refactor: simplify cursorFilter to accept DomainCursor object Co-Authored-By: Claude Opus 4.5 --- .../src/graphql-api/lib/find-domains.ts | 29 +++++++++---------- apps/ensapi/src/graphql-api/schema/account.ts | 20 ++----------- apps/ensapi/src/graphql-api/schema/query.ts | 20 ++----------- 3 files changed, 18 insertions(+), 51 deletions(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains.ts index 4528817cf..5bf7a305f 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains.ts @@ -13,7 +13,12 @@ import { parsePartialInterpretedName, } from "@ensnode/ensnode-sdk"; -import type { Domain, DomainOrderValue, DomainsOrderBy } from "@/graphql-api/schema/domain"; +import type { + Domain, + DomainCursor, + DomainOrderValue, + 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"; @@ -221,8 +226,6 @@ export function findDomains({ name, owner }: DomainFilter) { and( 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. partial ? like(headLabel.interpreted, `${partial}%`) : undefined, ), ); @@ -408,32 +411,28 @@ function getOrderColumn( * Uses tuple comparison: (orderColumn, id) > (cursorValue, cursorId) * * @param domains - The findDomains CTE result - * @param cursorId - The domain ID from the decoded cursor - * @param cursorValue - The order column value from the decoded cursor - * @param cursorOrderBy - The order field from the decoded cursor - * @param queryOrderBy - The order field for the current query (must match cursorOrderBy) + * @param cursor - The decoded DomainCursor + * @param queryOrderBy - The order field for the current query (must match cursor.by) * @param direction - "after" for forward pagination, "before" for backward * @param effectiveDesc - Whether the effective sort direction is descending - * @throws if cursorOrderBy does not match queryOrderBy + * @throws if cursor.by does not match queryOrderBy * @returns SQL expression for the cursor filter */ export function cursorFilter( domains: ReturnType, - cursorId: DomainId, - cursorValue: DomainOrderValue | undefined, - cursorOrderBy: typeof DomainsOrderBy.$inferType, + cursor: DomainCursor, queryOrderBy: typeof DomainsOrderBy.$inferType, direction: "after" | "before", effectiveDesc: boolean, ): SQL { // Validate cursor was created with the same ordering as the current query - if (cursorOrderBy !== queryOrderBy) { + if (cursor.by !== queryOrderBy) { throw new Error( - `Invalid cursor: cursor was created with orderBy=${cursorOrderBy} but query uses orderBy=${queryOrderBy}`, + `Invalid cursor: cursor was created with orderBy=${cursor.by} but query uses orderBy=${queryOrderBy}`, ); } - const orderColumn = getOrderColumn(domains, cursorOrderBy); + const orderColumn = getOrderColumn(domains, cursor.by); // Determine comparison direction: // - "after" with ASC = greater than cursor @@ -444,7 +443,7 @@ export function cursorFilter( const op = useGreaterThan ? ">" : "<"; // Direct tuple comparison with cursor values (no subquery needed) - return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${cursorValue}, ${cursorId})`; + return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${cursor.value}, ${cursor.id})`; } /** diff --git a/apps/ensapi/src/graphql-api/schema/account.ts b/apps/ensapi/src/graphql-api/schema/account.ts index 82a1dae46..ae46e7370 100644 --- a/apps/ensapi/src/graphql-api/schema/account.ts +++ b/apps/ensapi/src/graphql-api/schema/account.ts @@ -116,26 +116,10 @@ AccountRef.implement({ .where( and( beforeCursor - ? cursorFilter( - domains, - beforeCursor.id, - beforeCursor.value, - beforeCursor.by, - orderBy, - "before", - effectiveDesc, - ) + ? cursorFilter(domains, beforeCursor, orderBy, "before", effectiveDesc) : undefined, afterCursor - ? cursorFilter( - domains, - afterCursor.id, - afterCursor.value, - afterCursor.by, - orderBy, - "after", - effectiveDesc, - ) + ? cursorFilter(domains, afterCursor, orderBy, "after", effectiveDesc) : undefined, ), ) diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts index b1326880c..fda7bc4d4 100644 --- a/apps/ensapi/src/graphql-api/schema/query.ts +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -98,26 +98,10 @@ builder.queryType({ .where( and( beforeCursor - ? cursorFilter( - domains, - beforeCursor.id, - beforeCursor.value, - beforeCursor.by, - orderBy, - "before", - effectiveDesc, - ) + ? cursorFilter(domains, beforeCursor, orderBy, "before", effectiveDesc) : undefined, afterCursor - ? cursorFilter( - domains, - afterCursor.id, - afterCursor.value, - afterCursor.by, - orderBy, - "after", - effectiveDesc, - ) + ? cursorFilter(domains, afterCursor, orderBy, "after", effectiveDesc) : undefined, ), ) From 5652b14d38507eac1da65da5b1952010ae6628d1 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 4 Feb 2026 09:00:02 +0900 Subject: [PATCH 08/23] docs: add note about Drizzle tuple comparison limitation Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL for the cursor filter tuple comparison. Co-Authored-By: Claude Opus 4.5 --- apps/ensapi/src/graphql-api/lib/find-domains.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains.ts index 5bf7a305f..5a3419c5e 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains.ts @@ -442,7 +442,8 @@ export function cursorFilter( const useGreaterThan = (direction === "after") !== effectiveDesc; const op = useGreaterThan ? ">" : "<"; - // Direct tuple comparison with cursor values (no subquery needed) + // Tuple comparison: (orderColumn, id) > (cursorValue, cursorId) + // NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${cursor.value}, ${cursor.id})`; } From ee9498c7beb9fb9647981572eb9a550e1d1c27d1 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 4 Feb 2026 09:21:16 +0900 Subject: [PATCH 09/23] fix: refactor resolver into shared helper --- .../src/graphql-api/lib/find-domains.ts | 9 +- .../graphql-api/lib/resolve-find-domains.ts | 113 ++++++++++++++++++ apps/ensapi/src/graphql-api/schema/account.ts | 85 ++----------- apps/ensapi/src/graphql-api/schema/query.ts | 81 +------------ 4 files changed, 127 insertions(+), 161 deletions(-) create mode 100644 apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains.ts index 5a3419c5e..87e1a329d 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains.ts @@ -9,7 +9,6 @@ import { type ENSv2DomainId, interpretedLabelsToLabelHashPath, type LabelHashPath, - type Name, parsePartialInterpretedName, } from "@ensnode/ensnode-sdk"; @@ -30,9 +29,9 @@ const MAX_DEPTH = 16; /** * Defines the full set of possible filters for a Find Domains operation. */ -interface DomainFilter { - name?: Name | undefined | null; - owner?: Address | undefined | null; +export interface FindDomainsWhereArg { + name?: string | null; + owner?: Address | null; } /** @@ -111,7 +110,7 @@ export function getOrderValueFromResult( * 6. Apply filters (owner, partial) in the unified query * 7. Return CTE with columns: id, leafLabelValue, registrationStart, registrationExpiry */ -export function findDomains({ name, owner }: DomainFilter) { +export function findDomains({ name, owner }: FindDomainsWhereArg) { // NOTE: if name is not provided, parse empty string to simplify control-flow, validity checked below // NOTE: throws if name is not a Partial InterpretedName const { concrete, partial } = parsePartialInterpretedName(name || ""); diff --git a/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts b/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts new file mode 100644 index 000000000..cc428a588 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts @@ -0,0 +1,113 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; +import { and } from "drizzle-orm"; + +import type { context as createContext } from "@/graphql-api/context"; +import { + cursorFilter, + type DomainWithOrderValue, + type FindDomainsWhereArg, + findDomains, + getOrderValueFromResult, + isEffectiveDesc, + orderFindDomains, +} from "@/graphql-api/lib/find-domains"; +import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; +import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { + DOMAINS_DEFAULT_ORDER_BY, + DOMAINS_DEFAULT_ORDER_DIR, + DomainCursor, + DomainInterfaceRef, + type DomainsOrderBy, +} from "@/graphql-api/schema/domain"; +import type { OrderDirection } from "@/graphql-api/schema/order-direction"; +import { db } from "@/lib/db"; + +interface FindDomainsOrderArg { + by?: typeof DomainsOrderBy.$inferType | null; + dir?: typeof OrderDirection.$inferType | null; +} + +/** + * Shared GraphQL API resolver for domains connection queries, used by Query.domains and Account.domains. + */ +export function resolveFindDomains( + context: ReturnType, + { + where, + order, + ...args + }: { + // these resolver arguments from from t.connection + first?: number | null; + last?: number | null; + before?: string | null; + after?: string | null; + // there are our additional where/order arguments + where: FindDomainsWhereArg; + order?: FindDomainsOrderArg | undefined | null; + }, +) { + const orderBy = order?.by ?? DOMAINS_DEFAULT_ORDER_BY; + const orderDir = order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; + + return resolveCursorConnection( + { + ...DEFAULT_CONNECTION_ARGS, + args, + toCursor: (domain: DomainWithOrderValue) => + DomainCursor.encode({ + id: domain.id, + by: orderBy, + value: domain.__orderValue, + }), + }, + async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { + const effectiveDesc = isEffectiveDesc(orderDir, inverted); + + // construct query for relevant domains + const domains = findDomains(where); + + // build order clauses + const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted); + + // decode cursors for keyset pagination + const beforeCursor = before ? DomainCursor.decode(before) : undefined; + const afterCursor = after ? DomainCursor.decode(after) : undefined; + + // execute with pagination constraints using tuple comparison + const results = await db + .with(domains) + .select() + .from(domains) + .where( + and( + beforeCursor + ? cursorFilter(domains, beforeCursor, orderBy, "before", effectiveDesc) + : undefined, + afterCursor + ? cursorFilter(domains, afterCursor, orderBy, "after", effectiveDesc) + : undefined, + ), + ) + .orderBy(...orderClauses) + .limit(limit); + + // Map CTE results by id for order value lookup + const orderValueById = new Map( + results.map((r) => [r.id, getOrderValueFromResult(r, orderBy)]), + ); + + // Load full Domain entities via dataloader + const loadedDomains = await rejectAnyErrors( + DomainInterfaceRef.getDataloader(context).loadMany(results.map((result) => result.id)), + ); + + // Attach order values for cursor encoding + return loadedDomains.map((domain) => ({ + ...domain, + __orderValue: orderValueById.get(domain.id), + })); + }, + ); +} diff --git a/apps/ensapi/src/graphql-api/schema/account.ts b/apps/ensapi/src/graphql-api/schema/account.ts index ae46e7370..140432f01 100644 --- a/apps/ensapi/src/graphql-api/schema/account.ts +++ b/apps/ensapi/src/graphql-api/schema/account.ts @@ -6,16 +6,8 @@ import * as schema from "@ensnode/ensnode-schema"; import type { PermissionsUserId } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; -import { - cursorFilter, - type DomainWithOrderValue, - findDomains, - getOrderValueFromResult, - isEffectiveDesc, - 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 { resolveFindDomains } from "@/graphql-api/lib/resolve-find-domains"; 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"; @@ -23,9 +15,6 @@ import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; import { cursors } from "@/graphql-api/schema/cursors"; import { AccountDomainsWhereInput, - DOMAINS_DEFAULT_ORDER_BY, - DOMAINS_DEFAULT_ORDER_DIR, - DomainCursor, DomainInterfaceRef, DomainsOrderInput, } from "@/graphql-api/schema/domain"; @@ -80,72 +69,14 @@ AccountRef.implement({ where: t.arg({ type: AccountDomainsWhereInput, required: false }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (parent, args, context) => { - const orderBy = args.order?.by ?? DOMAINS_DEFAULT_ORDER_BY; - const orderDir = args.order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; - - return resolveCursorConnection( - { - ...DEFAULT_CONNECTION_ARGS, - args, - toCursor: (domain: DomainWithOrderValue) => - DomainCursor.encode({ - id: domain.id, - by: orderBy, - value: domain.__orderValue, - }), - }, - async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { - const effectiveDesc = isEffectiveDesc(orderDir, inverted); - - // construct query for relevant domains - const domains = findDomains({ ...args.where, owner: parent.id }); - - // build order clauses - const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted); - - // decode cursors for keyset pagination - const beforeCursor = before ? DomainCursor.decode(before) : undefined; - const afterCursor = after ? DomainCursor.decode(after) : undefined; - - // execute with pagination constraints using tuple comparison - const results = await db - .with(domains) - .select() - .from(domains) - .where( - and( - beforeCursor - ? cursorFilter(domains, beforeCursor, orderBy, "before", effectiveDesc) - : undefined, - afterCursor - ? cursorFilter(domains, afterCursor, orderBy, "after", effectiveDesc) - : undefined, - ), - ) - .orderBy(...orderClauses) - .limit(limit); - - // Map CTE results by id for order value lookup - const orderValueById = new Map( - results.map((r) => [r.id, getOrderValueFromResult(r, orderBy)]), - ); - - // Load full Domain entities via dataloader - const loadedDomains = await rejectAnyErrors( - DomainInterfaceRef.getDataloader(context).loadMany( - results.map((result) => result.id), - ), - ); - - // Attach order values for cursor encoding - return loadedDomains.map((domain) => ({ - ...domain, - __orderValue: orderValueById.get(domain.id), - })); + resolve: (parent, args, context) => + resolveFindDomains(context, { + ...args, + where: { + ...args.where, + owner: parent.id, }, - ); - }, + }), }), /////////////////////// diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts index fda7bc4d4..ac80d9158 100644 --- a/apps/ensapi/src/graphql-api/schema/query.ts +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -1,7 +1,6 @@ import config from "@/config"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { and } from "drizzle-orm"; import { type ENSv1DomainId, @@ -15,24 +14,13 @@ import { } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; -import { - cursorFilter, - type DomainWithOrderValue, - findDomains, - getOrderValueFromResult, - isEffectiveDesc, - 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 { resolveFindDomains } from "@/graphql-api/lib/resolve-find-domains"; import { AccountRef } from "@/graphql-api/schema/account"; import { AccountIdInput } from "@/graphql-api/schema/account-id"; import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; import { cursors } from "@/graphql-api/schema/cursors"; import { - DOMAINS_DEFAULT_ORDER_BY, - DOMAINS_DEFAULT_ORDER_DIR, - DomainCursor, DomainIdInput, DomainInterfaceRef, DomainsOrderInput, @@ -62,72 +50,7 @@ builder.queryType({ where: t.arg({ type: DomainsWhereInput, required: true }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (parent, args, context) => { - const orderBy = args.order?.by ?? DOMAINS_DEFAULT_ORDER_BY; - const orderDir = args.order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; - - return resolveCursorConnection( - { - ...DEFAULT_CONNECTION_ARGS, - args, - toCursor: (domain: DomainWithOrderValue) => - DomainCursor.encode({ - id: domain.id, - by: orderBy, - value: domain.__orderValue, - }), - }, - async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { - const effectiveDesc = isEffectiveDesc(orderDir, inverted); - - // construct query for relevant domains - const domains = findDomains(args.where); - - // build order clauses - const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted); - - // decode cursors for keyset pagination - const beforeCursor = before ? DomainCursor.decode(before) : undefined; - const afterCursor = after ? DomainCursor.decode(after) : undefined; - - // execute with pagination constraints using tuple comparison - const results = await db - .with(domains) - .select() - .from(domains) - .where( - and( - beforeCursor - ? cursorFilter(domains, beforeCursor, orderBy, "before", effectiveDesc) - : undefined, - afterCursor - ? cursorFilter(domains, afterCursor, orderBy, "after", effectiveDesc) - : undefined, - ), - ) - .orderBy(...orderClauses) - .limit(limit); - - // Map CTE results by id for order value lookup - const orderValueById = new Map( - results.map((r) => [r.id, getOrderValueFromResult(r, orderBy)]), - ); - - // Load full Domain entities via dataloader - const loadedDomains = await rejectAnyErrors( - DomainInterfaceRef.getDataloader(context).loadMany( - results.map((result) => result.id), - ), - ); - - // Attach order values for cursor encoding - return loadedDomains.map((domain) => ({ - ...domain, - __orderValue: orderValueById.get(domain.id), - })); - }, - ); - }, + resolve: (_, args, context) => resolveFindDomains(context, args), }), ///////////////////////////// From 7132c0f3577aed15146cbbe6ac0f808fbf5d46be Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 6 Feb 2026 14:44:47 +0900 Subject: [PATCH 10/23] style: wrap long comment line Co-Authored-By: Claude Opus 4.5 --- apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts b/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts index cc428a588..e66fedc2e 100644 --- a/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts @@ -29,7 +29,11 @@ interface FindDomainsOrderArg { } /** - * Shared GraphQL API resolver for domains connection queries, used by Query.domains and Account.domains. + * Shared GraphQL API resolver for domains connection queries, used by Query.domains and + * Account.domains. + * + * @param context - The GraphQL Context, required for Dataloader access + * @param args - The GraphQL Args object (via t.connection) + FindDomains-specific args (where, order) */ export function resolveFindDomains( context: ReturnType, From fefb30018d52995ca913e67075cbe412eeb6cf57 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 6 Feb 2026 15:30:07 +0900 Subject: [PATCH 11/23] feat: add debug logging of generated SQL in find-domains-resolver Co-Authored-By: Claude Opus 4.5 --- .../lib/find-domains/find-domains-resolver.ts | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts new file mode 100644 index 000000000..4c2528108 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts @@ -0,0 +1,146 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; +import { and } from "drizzle-orm"; + +import type { context as createContext } from "@/graphql-api/context"; +import { DomainCursor } from "@/graphql-api/lib/find-domains/domain-cursor"; +import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; +import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; +import { + DOMAINS_DEFAULT_ORDER_BY, + DOMAINS_DEFAULT_ORDER_DIR, + DomainInterfaceRef, + type DomainsOrderBy, +} from "@/graphql-api/schema/domain"; +import { db } from "@/lib/db"; +import { makeLogger } from "@/lib/logger"; + +import { cursorFilter, findDomains, isEffectiveDesc, orderFindDomains } from "./find-domains"; + +const logger = makeLogger("find-domains-resolver"); + +import type { + DomainOrderValue, + DomainWithOrderValue, + FindDomainsOrderArg, + FindDomainsResult, + FindDomainsWhereArg, +} from "./types"; + +/** + * Extract the order value from a findDomains result row based on the orderBy field. + */ +function getOrderValueFromResult( + result: FindDomainsResult, + orderBy: typeof DomainsOrderBy.$inferType, +): DomainOrderValue { + switch (orderBy) { + case "NAME": + return result.leafLabelValue; + case "REGISTRATION_TIMESTAMP": + return result.registrationStart; + case "REGISTRATION_EXPIRY": + return result.registrationExpiry; + } +} + +/** + * Shared GraphQL API resolver for domains connection queries, used by Query.domains and + * Account.domains. + * + * @param context - The GraphQL Context, required for Dataloader access + * @param args - The GraphQL Args object (via t.connection) + FindDomains-specific args (where, order) + */ +export function resolveFindDomains( + context: ReturnType, + { + where, + order, + ...connectionArgs + }: { + // there are our additional where/order arguments + + // `where` MUST be provided, we don't currently allow iterating over the full set of domains + where: FindDomainsWhereArg; + // `order` MAY be provided; defaults are used otherwise + order?: FindDomainsOrderArg | undefined | null; + + // these resolver arguments from from t.connection + first?: number | null; + last?: number | null; + before?: string | null; + after?: string | null; + }, +) { + const orderBy = order?.by ?? DOMAINS_DEFAULT_ORDER_BY; + const orderDir = order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; + + return resolveCursorConnection( + { + ...DEFAULT_CONNECTION_ARGS, + args: connectionArgs, + toCursor: (domain: DomainWithOrderValue) => + DomainCursor.encode({ + id: domain.id, + by: orderBy, + value: domain.__orderValue, + }), + }, + async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { + // identify whether the effective sort direction is descending + const effectiveDesc = isEffectiveDesc(orderDir, inverted); + + // construct query for relevant domains + const domains = findDomains(where); + + // build order clauses + const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted); + + // decode cursors for keyset pagination + const beforeCursor = before ? DomainCursor.decode(before) : undefined; + const afterCursor = after ? DomainCursor.decode(after) : undefined; + + // build query with pagination constraints using tuple comparison + const query = db + .with(domains) + .select() + .from(domains) + .where( + and( + beforeCursor + ? cursorFilter(domains, beforeCursor, orderBy, "before", effectiveDesc) + : undefined, + afterCursor + ? cursorFilter(domains, afterCursor, orderBy, "after", effectiveDesc) + : undefined, + ), + ) + .orderBy(...orderClauses) + .limit(limit); + + // log the generated SQL for debugging + logger.debug({ sql: query.toSQL() }); + + // execute query + const results = await query; + + // load Domain entities via dataloader + const loadedDomains = await rejectAnyErrors( + DomainInterfaceRef.getDataloader(context).loadMany(results.map((result) => result.id)), + ); + + // map results by id for faster order value lookup + const orderValueById = new Map( + results.map((r) => [r.id, getOrderValueFromResult(r, orderBy)]), + ); + + // inject order values into each result so that it can be encoded into the cursor + // (see DomainCursor for more information) + return loadedDomains.map((domain): DomainWithOrderValue => { + const __orderValue = orderValueById.get(domain.id); + if (__orderValue === undefined) throw new Error(`Never: guaranteed to be DomainOrderValue`); + + return { ...domain, __orderValue }; + }); + }, + ); +} From e458e5d5bd9c1dffc729c228ccbf56165661e935 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 6 Feb 2026 15:35:49 +0900 Subject: [PATCH 12/23] refactor: locations and fix bigint cursor encoding --- .../lib/find-domains/domain-cursor.ts | 50 ++++ .../find-domains-by-labelhash-path.ts | 164 +++++++++++++ .../lib/{ => find-domains}/find-domains.ts | 232 ++---------------- .../src/graphql-api/lib/find-domains/types.ts | 56 +++++ .../graphql-api/lib/resolve-find-domains.ts | 117 --------- apps/ensapi/src/graphql-api/schema/account.ts | 2 +- apps/ensapi/src/graphql-api/schema/domain.ts | 28 --- apps/ensapi/src/graphql-api/schema/event.ts | 10 + apps/ensapi/src/graphql-api/schema/query.ts | 2 +- 9 files changed, 300 insertions(+), 361 deletions(-) create mode 100644 apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts create mode 100644 apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts rename apps/ensapi/src/graphql-api/lib/{ => find-domains}/find-domains.ts (56%) create mode 100644 apps/ensapi/src/graphql-api/lib/find-domains/types.ts delete mode 100644 apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts new file mode 100644 index 000000000..5f729772b --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts @@ -0,0 +1,50 @@ +import { z } from "zod/v4"; + +import type { DomainId } from "@ensnode/ensnode-sdk"; + +import type { DomainOrderValue } from "@/graphql-api/lib/find-domains/types"; +import type { DomainsOrderBy } from "@/graphql-api/schema/domain"; + +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]), +}); + +/** + * Composite Domain cursor for keyset pagination. + * Includes the order column value to enable proper tuple comparison without subqueries. + * + * @dev A composite cursor is required to support stable pagination over the set, regardless of which + * column and which direction the set is ordered. + */ +export interface DomainCursor { + // stable identifier for stable tiebreaks + id: DomainId; + // the column by which the set is ordered + by: typeof DomainsOrderBy.$inferType; + // the value of said sort column for this domain + value: DomainOrderValue; +} + +/** + * Encoding/Decoding helper for Composite DomainCursors. + * + * @dev it's base64'd JSON with bigint encoding via zod + */ +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, +}; diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts new file mode 100644 index 000000000..4db68e95f --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts @@ -0,0 +1,164 @@ +import { Param, sql } from "drizzle-orm"; + +import * as schema from "@ensnode/ensnode-schema"; +import type { ENSv1DomainId, ENSv2DomainId, LabelHashPath } from "@ensnode/ensnode-sdk"; + +import { db } from "@/lib/db"; + +/** + * Compose a query for v1Domains that have the specified children path. + * + * For a search like "sub1.sub2.paren": + * - concrete = ["sub1", "sub2"] + * - partial = 'paren' + * - labelHashPath = [labelhash('sub2'), labelhash('sub1')] + * + * We find v1Domains matching the concrete path and return both: + * - leafId: the deepest child (label "sub1") - the autocomplete result, for ownership check + * - headId: the parent of the path (whose label should match partial "paren") + * + * Algorithm: Start from the deepest child (leaf) and traverse UP to find the head. + * This is more efficient than starting from all domains and traversing down. + */ +export function v1DomainsByLabelHashPath(labelHashPath: LabelHashPath) { + // If no concrete path, return all domains (leaf = head = self) + // Postgres will optimize this simple subquery when joined + if (labelHashPath.length === 0) { + return db + .select({ + leafId: sql`${schema.v1Domain.id}`.as("leafId"), + headId: sql`${schema.v1Domain.id}`.as("headId"), + }) + .from(schema.v1Domain) + .as("v1_path"); + } + + // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 + const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; + const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`; + + // Use a recursive CTE starting from the deepest child and traversing UP + // The query: + // 1. Starts with domains matching the leaf labelHash (deepest child) + // 2. Recursively joins parents, verifying each ancestor's labelHash + // 3. Returns both the leaf (for result/ownership) and head (for partial match) + return db + .select({ + // https://github.com/drizzle-team/drizzle-orm/issues/1242 + leafId: sql`v1_path_check.leaf_id`.as("leafId"), + headId: sql`v1_path_check.head_id`.as("headId"), + }) + .from( + sql`( + WITH RECURSIVE upward_check AS ( + -- Base case: find the deepest children (leaves of the concrete path) + SELECT + d.id AS leaf_id, + d.parent_id AS current_id, + 1 AS depth + FROM ${schema.v1Domain} d + WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] + + UNION ALL + + -- Recursive step: traverse UP, verifying each ancestor's labelHash + SELECT + upward_check.leaf_id, + pd.parent_id AS current_id, + upward_check.depth + 1 + FROM upward_check + JOIN ${schema.v1Domain} pd + ON pd.id = upward_check.current_id + WHERE upward_check.depth < ${pathLength} + AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] + ) + SELECT leaf_id, current_id AS head_id + FROM upward_check + WHERE depth = ${pathLength} + ) AS v1_path_check`, + ) + .as("v1_path"); +} + +/** + * Compose a query for v2Domains that have the specified children path. + * + * For a search like "sub1.sub2.paren": + * - concrete = ["sub1", "sub2"] + * - partial = 'paren' + * - labelHashPath = [labelhash('sub2'), labelhash('sub1')] + * + * We find v2Domains matching the concrete path and return both: + * - leafId: the deepest child (label "sub1") - the autocomplete result, for ownership check + * - headId: the parent of the path (whose label should match partial "paren") + * + * Algorithm: Start from the deepest child (leaf) and traverse UP via registryCanonicalDomain. + * For v2, parent relationship is: domain.registryId -> registryCanonicalDomain -> parent domainId + */ +export function v2DomainsByLabelHashPath(labelHashPath: LabelHashPath) { + // If no concrete path, return all domains (leaf = head = self) + // Postgres will optimize this simple subquery when joined + if (labelHashPath.length === 0) { + return db + .select({ + leafId: sql`${schema.v2Domain.id}`.as("leafId"), + headId: sql`${schema.v2Domain.id}`.as("headId"), + }) + .from(schema.v2Domain) + .as("v2_path"); + } + + // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 + const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; + const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`; + + // Use a recursive CTE starting from the deepest child and traversing UP + // The query: + // 1. Starts with domains matching the leaf labelHash (deepest child) + // 2. Recursively joins parents via registryCanonicalDomain, verifying each ancestor's labelHash + // 3. Returns both the leaf (for result/ownership) and head (for partial match) + return db + .select({ + // https://github.com/drizzle-team/drizzle-orm/issues/1242 + leafId: sql`v2_path_check.leaf_id`.as("leafId"), + headId: sql`v2_path_check.head_id`.as("headId"), + }) + .from( + sql`( + WITH RECURSIVE upward_check AS ( + -- Base case: find the deepest children (leaves of the concrete path) + -- and get their parent via registryCanonicalDomain + -- Note: JOIN (not LEFT JOIN) is intentional - we only match domains + -- with a complete canonical path to the searched FQDN + SELECT + d.id AS leaf_id, + rcd.domain_id AS current_id, + 1 AS depth + FROM ${schema.v2Domain} d + JOIN ${schema.registryCanonicalDomain} rcd + ON rcd.registry_id = d.registry_id + WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] + + UNION ALL + + -- Recursive step: traverse UP via registryCanonicalDomain + -- Note: JOIN (not LEFT JOIN) is intentional - see base case comment + SELECT + upward_check.leaf_id, + rcd.domain_id AS current_id, + upward_check.depth + 1 + FROM upward_check + JOIN ${schema.v2Domain} pd + ON pd.id = upward_check.current_id + JOIN ${schema.registryCanonicalDomain} rcd + ON rcd.registry_id = pd.registry_id + WHERE upward_check.depth < ${pathLength} + AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] + ) + SELECT leaf_id, current_id AS head_id + FROM upward_check + WHERE depth = ${pathLength} + ) AS v2_path_check`, + ) + .as("v2_path"); +} diff --git a/apps/ensapi/src/graphql-api/lib/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts similarity index 56% rename from apps/ensapi/src/graphql-api/lib/find-domains.ts rename to apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts index 87e1a329d..60b65f913 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -1,70 +1,30 @@ -import { and, asc, desc, eq, like, Param, type SQL, sql } from "drizzle-orm"; +import { and, asc, desc, eq, like, type SQL, sql } from "drizzle-orm"; import { alias, unionAll } from "drizzle-orm/pg-core"; -import type { Address } from "viem"; import * as schema from "@ensnode/ensnode-schema"; import { type DomainId, - type ENSv1DomainId, - type ENSv2DomainId, interpretedLabelsToLabelHashPath, - type LabelHashPath, parsePartialInterpretedName, } from "@ensnode/ensnode-sdk"; -import type { - Domain, - DomainCursor, - DomainOrderValue, - DomainsOrderBy, -} from "@/graphql-api/schema/domain"; +import type { DomainCursor } from "@/graphql-api/lib/find-domains/domain-cursor"; +import { + v1DomainsByLabelHashPath, + v2DomainsByLabelHashPath, +} from "@/graphql-api/lib/find-domains/find-domains-by-labelhash-path"; +import type { FindDomainsWhereArg } from "@/graphql-api/lib/find-domains/types"; +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"; const logger = makeLogger("find-domains"); -const MAX_DEPTH = 16; - -/** - * Defines the full set of possible filters for a Find Domains operation. - */ -export interface FindDomainsWhereArg { - name?: string | null; - owner?: Address | null; -} - -/** - * Domain with order value attached for cursor encoding - */ -export type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue | undefined }; - -/** - * Result row from findDomains CTE. Includes columns for all supported orderings. - */ -type FindDomainsResult = { - id: DomainId; - leafLabelValue: string | null; - registrationStart: bigint | null; - registrationExpiry: bigint | null; -}; - /** - * Extract the order value from a findDomains result row based on the orderBy field. + * Maximum depth of the provided `name` argument, to avoid infinite loops and expensive queries. */ -export function getOrderValueFromResult( - result: FindDomainsResult, - orderBy: typeof DomainsOrderBy.$inferType, -): DomainOrderValue { - switch (orderBy) { - case "NAME": - return result.leafLabelValue; - case "REGISTRATION_TIMESTAMP": - return result.registrationStart; - case "REGISTRATION_EXPIRY": - return result.registrationExpiry; - } -} +const FIND_DOMAINS_MAX_DEPTH = 8; /** * Find Domains by Canonical Name. @@ -116,8 +76,10 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { const { concrete, partial } = parsePartialInterpretedName(name || ""); // validate depth to prevent arbitrary recursion in CTEs - if (concrete.length > MAX_DEPTH) { - throw new Error(`Invariant(findDomains): Name depth exceeds maximum of ${MAX_DEPTH} labels.`); + if (concrete.length > FIND_DOMAINS_MAX_DEPTH) { + throw new Error( + `Invariant(findDomains): Name depth exceeds maximum of ${FIND_DOMAINS_MAX_DEPTH} labels.`, + ); } logger.debug({ input: { name, owner, concrete, partial } }); @@ -149,8 +111,8 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { .select({ id: sql`${schema.v1Domain.id}`.as("id"), ownerId: schema.v1Domain.ownerId, - leafLabelHash: schema.v1Domain.labelHash, - headLabelHash: v1HeadDomain.labelHash, + leafLabelHash: sql`${schema.v1Domain.labelHash}`.as("leafLabelHash"), + headLabelHash: sql`${v1HeadDomain.labelHash}`.as("headLabelHash"), }) .from(schema.v1Domain) .innerJoin( @@ -163,8 +125,8 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { .select({ id: sql`${schema.v2Domain.id}`.as("id"), ownerId: schema.v2Domain.ownerId, - leafLabelHash: schema.v2Domain.labelHash, - headLabelHash: v2HeadDomain.labelHash, + leafLabelHash: sql`${schema.v2Domain.labelHash}`.as("leafLabelHash"), + headLabelHash: sql`${v2HeadDomain.labelHash}`.as("headLabelHash"), }) .from(schema.v2Domain) .innerJoin( @@ -232,164 +194,6 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { return db.$with("domains").as(domains); } -/** - * Compose a query for v1Domains that have the specified children path. - * - * For a search like "sub1.sub2.paren": - * - concrete = ["sub1", "sub2"] - * - partial = 'paren' - * - labelHashPath = [labelhash('sub2'), labelhash('sub1')] - * - * We find v1Domains matching the concrete path and return both: - * - leafId: the deepest child (label "sub1") - the autocomplete result, for ownership check - * - headId: the parent of the path (whose label should match partial "paren") - * - * Algorithm: Start from the deepest child (leaf) and traverse UP to find the head. - * This is more efficient than starting from all domains and traversing down. - */ -function v1DomainsByLabelHashPath(labelHashPath: LabelHashPath) { - // If no concrete path, return all domains (leaf = head = self) - // Postgres will optimize this simple subquery when joined - if (labelHashPath.length === 0) { - return db - .select({ - leafId: sql`${schema.v1Domain.id}`.as("leafId"), - headId: sql`${schema.v1Domain.id}`.as("headId"), - }) - .from(schema.v1Domain) - .as("v1_path"); - } - - // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 - const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; - const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`; - - // Use a recursive CTE starting from the deepest child and traversing UP - // The query: - // 1. Starts with domains matching the leaf labelHash (deepest child) - // 2. Recursively joins parents, verifying each ancestor's labelHash - // 3. Returns both the leaf (for result/ownership) and head (for partial match) - return db - .select({ - // https://github.com/drizzle-team/drizzle-orm/issues/1242 - leafId: sql`v1_path_check.leaf_id`.as("leafId"), - headId: sql`v1_path_check.head_id`.as("headId"), - }) - .from( - sql`( - WITH RECURSIVE upward_check AS ( - -- Base case: find the deepest children (leaves of the concrete path) - SELECT - d.id AS leaf_id, - d.parent_id AS current_id, - 1 AS depth - FROM ${schema.v1Domain} d - WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] - - UNION ALL - - -- Recursive step: traverse UP, verifying each ancestor's labelHash - SELECT - upward_check.leaf_id, - pd.parent_id AS current_id, - upward_check.depth + 1 - FROM upward_check - JOIN ${schema.v1Domain} pd - ON pd.id = upward_check.current_id - WHERE upward_check.depth < ${pathLength} - AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] - ) - SELECT leaf_id, current_id AS head_id - FROM upward_check - WHERE depth = ${pathLength} - ) AS v1_path_check`, - ) - .as("v1_path"); -} - -/** - * Compose a query for v2Domains that have the specified children path. - * - * For a search like "sub1.sub2.paren": - * - concrete = ["sub1", "sub2"] - * - partial = 'paren' - * - labelHashPath = [labelhash('sub2'), labelhash('sub1')] - * - * We find v2Domains matching the concrete path and return both: - * - leafId: the deepest child (label "sub1") - the autocomplete result, for ownership check - * - headId: the parent of the path (whose label should match partial "paren") - * - * Algorithm: Start from the deepest child (leaf) and traverse UP via registryCanonicalDomain. - * For v2, parent relationship is: domain.registryId -> registryCanonicalDomain -> parent domainId - */ -function v2DomainsByLabelHashPath(labelHashPath: LabelHashPath) { - // If no concrete path, return all domains (leaf = head = self) - // Postgres will optimize this simple subquery when joined - if (labelHashPath.length === 0) { - return db - .select({ - leafId: sql`${schema.v2Domain.id}`.as("leafId"), - headId: sql`${schema.v2Domain.id}`.as("headId"), - }) - .from(schema.v2Domain) - .as("v2_path"); - } - - // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 - const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; - const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`; - - // Use a recursive CTE starting from the deepest child and traversing UP - // The query: - // 1. Starts with domains matching the leaf labelHash (deepest child) - // 2. Recursively joins parents via registryCanonicalDomain, verifying each ancestor's labelHash - // 3. Returns both the leaf (for result/ownership) and head (for partial match) - return db - .select({ - // https://github.com/drizzle-team/drizzle-orm/issues/1242 - leafId: sql`v2_path_check.leaf_id`.as("leafId"), - headId: sql`v2_path_check.head_id`.as("headId"), - }) - .from( - sql`( - WITH RECURSIVE upward_check AS ( - -- Base case: find the deepest children (leaves of the concrete path) - -- and get their parent via registryCanonicalDomain - -- Note: JOIN (not LEFT JOIN) is intentional - we only match domains - -- with a complete canonical path to the searched FQDN - SELECT - d.id AS leaf_id, - rcd.domain_id AS current_id, - 1 AS depth - FROM ${schema.v2Domain} d - JOIN ${schema.registryCanonicalDomain} rcd - ON rcd.registry_id = d.registry_id - WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] - - UNION ALL - - -- Recursive step: traverse UP via registryCanonicalDomain - -- Note: JOIN (not LEFT JOIN) is intentional - see base case comment - SELECT - upward_check.leaf_id, - rcd.domain_id AS current_id, - upward_check.depth + 1 - FROM upward_check - JOIN ${schema.v2Domain} pd - ON pd.id = upward_check.current_id - JOIN ${schema.registryCanonicalDomain} rcd - ON rcd.registry_id = pd.registry_id - WHERE upward_check.depth < ${pathLength} - AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] - ) - SELECT leaf_id, current_id AS head_id - FROM upward_check - WHERE depth = ${pathLength} - ) AS v2_path_check`, - ) - .as("v2_path"); -} - /** * Get the order column for a given DomainsOrderBy value. */ diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/types.ts b/apps/ensapi/src/graphql-api/lib/find-domains/types.ts new file mode 100644 index 000000000..302044836 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains/types.ts @@ -0,0 +1,56 @@ +import type { Address } from "viem"; + +import type { DomainId } from "@ensnode/ensnode-sdk"; + +import type { Domain, DomainsOrderBy } from "@/graphql-api/schema/domain"; +import type { OrderDirection } from "@/graphql-api/schema/order-direction"; + +/** + * Order value type - string for NAME, bigint (or null) for timestamps. + */ +export type DomainOrderValue = string | bigint | null; + +/** + * Describes the filters by which Domains can be filtered in `findDomains`. + */ +export interface FindDomainsWhereArg { + /** + * The `name` input may be a Partial InterpretedName by which the set of Domains is filtered. + */ + name?: string | null; + + /** + * The `owner` address may be specified, filtering the set of Domains by those that are effectively + * owned by the specified Address. + */ + owner?: Address | null; +} + +/** + * Describes the ordering of the set of Domains in `findDomains`. + * + * @dev derived from the GraphQL Input Types for 1:1 convenience + */ +export interface FindDomainsOrderArg { + by?: typeof DomainsOrderBy.$inferType | null; + dir?: typeof OrderDirection.$inferType | null; +} + +/** + * Domain with order value injected. + * + * @dev Relevant to composite DomainCursor encoding, see `domain-cursor.ts` + */ +export type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue }; + +/** + * Result row from findDomains CTE. Includes columns for all supported orderings. + * + * @dev see `findDomains` + */ +export type FindDomainsResult = { + id: DomainId; + leafLabelValue: string | null; + registrationStart: bigint | null; + registrationExpiry: bigint | null; +}; diff --git a/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts b/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts deleted file mode 100644 index e66fedc2e..000000000 --- a/apps/ensapi/src/graphql-api/lib/resolve-find-domains.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { and } from "drizzle-orm"; - -import type { context as createContext } from "@/graphql-api/context"; -import { - cursorFilter, - type DomainWithOrderValue, - type FindDomainsWhereArg, - findDomains, - getOrderValueFromResult, - isEffectiveDesc, - orderFindDomains, -} from "@/graphql-api/lib/find-domains"; -import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; -import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; -import { - DOMAINS_DEFAULT_ORDER_BY, - DOMAINS_DEFAULT_ORDER_DIR, - DomainCursor, - DomainInterfaceRef, - type DomainsOrderBy, -} from "@/graphql-api/schema/domain"; -import type { OrderDirection } from "@/graphql-api/schema/order-direction"; -import { db } from "@/lib/db"; - -interface FindDomainsOrderArg { - by?: typeof DomainsOrderBy.$inferType | null; - dir?: typeof OrderDirection.$inferType | null; -} - -/** - * Shared GraphQL API resolver for domains connection queries, used by Query.domains and - * Account.domains. - * - * @param context - The GraphQL Context, required for Dataloader access - * @param args - The GraphQL Args object (via t.connection) + FindDomains-specific args (where, order) - */ -export function resolveFindDomains( - context: ReturnType, - { - where, - order, - ...args - }: { - // these resolver arguments from from t.connection - first?: number | null; - last?: number | null; - before?: string | null; - after?: string | null; - // there are our additional where/order arguments - where: FindDomainsWhereArg; - order?: FindDomainsOrderArg | undefined | null; - }, -) { - const orderBy = order?.by ?? DOMAINS_DEFAULT_ORDER_BY; - const orderDir = order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; - - return resolveCursorConnection( - { - ...DEFAULT_CONNECTION_ARGS, - args, - toCursor: (domain: DomainWithOrderValue) => - DomainCursor.encode({ - id: domain.id, - by: orderBy, - value: domain.__orderValue, - }), - }, - async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { - const effectiveDesc = isEffectiveDesc(orderDir, inverted); - - // construct query for relevant domains - const domains = findDomains(where); - - // build order clauses - const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted); - - // decode cursors for keyset pagination - const beforeCursor = before ? DomainCursor.decode(before) : undefined; - const afterCursor = after ? DomainCursor.decode(after) : undefined; - - // execute with pagination constraints using tuple comparison - const results = await db - .with(domains) - .select() - .from(domains) - .where( - and( - beforeCursor - ? cursorFilter(domains, beforeCursor, orderBy, "before", effectiveDesc) - : undefined, - afterCursor - ? cursorFilter(domains, afterCursor, orderBy, "after", effectiveDesc) - : undefined, - ), - ) - .orderBy(...orderClauses) - .limit(limit); - - // Map CTE results by id for order value lookup - const orderValueById = new Map( - results.map((r) => [r.id, getOrderValueFromResult(r, orderBy)]), - ); - - // Load full Domain entities via dataloader - const loadedDomains = await rejectAnyErrors( - DomainInterfaceRef.getDataloader(context).loadMany(results.map((result) => result.id)), - ); - - // Attach order values for cursor encoding - return loadedDomains.map((domain) => ({ - ...domain, - __orderValue: orderValueById.get(domain.id), - })); - }, - ); -} diff --git a/apps/ensapi/src/graphql-api/schema/account.ts b/apps/ensapi/src/graphql-api/schema/account.ts index 140432f01..225c0334e 100644 --- a/apps/ensapi/src/graphql-api/schema/account.ts +++ b/apps/ensapi/src/graphql-api/schema/account.ts @@ -6,8 +6,8 @@ import * as schema from "@ensnode/ensnode-schema"; import type { PermissionsUserId } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; +import { resolveFindDomains } from "@/graphql-api/lib/find-domains/find-domains-resolver"; import { getModelId } from "@/graphql-api/lib/get-model-id"; -import { resolveFindDomains } from "@/graphql-api/lib/resolve-find-domains"; 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"; diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index 242256714..a1d1ed163 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -354,31 +354,3 @@ export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { export const DOMAINS_DEFAULT_ORDER_BY: typeof DomainsOrderBy.$inferType = "NAME"; export const DOMAINS_DEFAULT_ORDER_DIR: typeof OrderDirection.$inferType = "ASC"; - -////////////////////// -// Cursors -////////////////////// - -/** Order value type - string for NAME, bigint for timestamps */ -export type DomainOrderValue = string | bigint | null; - -/** - * Composite Domain cursor for keyset pagination. - * Includes the order column value to enable proper tuple comparison without subqueries. - */ -export interface DomainCursor { - id: DomainId; - by: typeof DomainsOrderBy.$inferType; - value: DomainOrderValue | undefined; -} - -/** - * Encoding/Decoding helper for Composite DomainCursors. - * - * @dev it's base64'd JSON - */ -export const DomainCursor = { - encode: (cursor: DomainCursor) => Buffer.from(JSON.stringify(cursor), "utf8").toString("base64"), - decode: (cursor: string) => - JSON.parse(Buffer.from(cursor, "base64").toString("utf8")) as DomainCursor, -}; diff --git a/apps/ensapi/src/graphql-api/schema/event.ts b/apps/ensapi/src/graphql-api/schema/event.ts index 976273f3a..12aeeda29 100644 --- a/apps/ensapi/src/graphql-api/schema/event.ts +++ b/apps/ensapi/src/graphql-api/schema/event.ts @@ -50,6 +50,16 @@ EventRef.implement({ resolve: (parent) => parent.address, }), + /////////////////// + // Event.timestamp + /////////////////// + timestamp: t.field({ + description: "TODO", + type: "BigInt", + nullable: false, + resolve: (parent) => parent.timestamp, + }), + /////////////////// // Event.blockHash /////////////////// diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts index ac80d9158..b7d457e8c 100644 --- a/apps/ensapi/src/graphql-api/schema/query.ts +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -14,8 +14,8 @@ import { } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; +import { resolveFindDomains } from "@/graphql-api/lib/find-domains/find-domains-resolver"; import { getDomainIdByInterpretedName } from "@/graphql-api/lib/get-domain-by-fqdn"; -import { resolveFindDomains } from "@/graphql-api/lib/resolve-find-domains"; import { AccountRef } from "@/graphql-api/schema/account"; import { AccountIdInput } from "@/graphql-api/schema/account-id"; import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; From 19670c897ec5a6dfc2f4fe3f1f4a421847475011 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 6 Feb 2026 16:02:55 +0900 Subject: [PATCH 13/23] fix: cursor pagination bugs with NULL values and direction mismatch - 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 --- .../lib/find-domains/domain-cursor.ts | 4 +++ .../lib/find-domains/find-domains-resolver.ts | 9 +++--- .../lib/find-domains/find-domains.ts | 32 +++++++++++++++++-- apps/ensapi/src/graphql-api/schema/domain.ts | 3 +- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts index 5f729772b..9b6143adc 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts @@ -4,6 +4,7 @@ import type { DomainId } from "@ensnode/ensnode-sdk"; import type { DomainOrderValue } from "@/graphql-api/lib/find-domains/types"; import type { DomainsOrderBy } from "@/graphql-api/schema/domain"; +import type { OrderDirection } from "@/graphql-api/schema/order-direction"; const stringToBigInt = z.codec(z.string(), z.bigint(), { decode: (str) => BigInt(str), @@ -13,6 +14,7 @@ const stringToBigInt = z.codec(z.string(), z.bigint(), { const DomainCursorSchema = z.strictObject({ id: z.string(), by: z.string(), + dir: z.string(), value: z.union([z.string(), z.null(), stringToBigInt]), }); @@ -28,6 +30,8 @@ export interface DomainCursor { id: DomainId; // the column by which the set is ordered by: typeof DomainsOrderBy.$inferType; + // the direction in which the set is ordered + dir: typeof OrderDirection.$inferType; // the value of said sort column for this domain value: DomainOrderValue; } diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts index 4c2528108..ce9a8a449 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts @@ -57,14 +57,12 @@ export function resolveFindDomains( order, ...connectionArgs }: { - // there are our additional where/order arguments - // `where` MUST be provided, we don't currently allow iterating over the full set of domains where: FindDomainsWhereArg; // `order` MAY be provided; defaults are used otherwise order?: FindDomainsOrderArg | undefined | null; - // these resolver arguments from from t.connection + // these resolver arguments from t.connection first?: number | null; last?: number | null; before?: string | null; @@ -82,6 +80,7 @@ export function resolveFindDomains( DomainCursor.encode({ id: domain.id, by: orderBy, + dir: orderDir, value: domain.__orderValue, }), }, @@ -107,10 +106,10 @@ export function resolveFindDomains( .where( and( beforeCursor - ? cursorFilter(domains, beforeCursor, orderBy, "before", effectiveDesc) + ? cursorFilter(domains, beforeCursor, orderBy, orderDir, "before", effectiveDesc) : undefined, afterCursor - ? cursorFilter(domains, afterCursor, orderBy, "after", effectiveDesc) + ? cursorFilter(domains, afterCursor, orderBy, orderDir, "after", effectiveDesc) : undefined, ), ) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts index 60b65f913..55ffec4d3 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -211,20 +211,24 @@ function getOrderColumn( /** * Build a cursor filter for keyset pagination on findDomains results. * - * Uses tuple comparison: (orderColumn, id) > (cursorValue, cursorId) + * Uses tuple comparison for non-NULL cursor values, and explicit NULL handling + * for NULL cursor values (since PostgreSQL tuple comparison with NULL yields NULL/unknown). * * @param domains - The findDomains CTE result * @param cursor - The decoded DomainCursor * @param queryOrderBy - The order field for the current query (must match cursor.by) + * @param queryOrderDir - The order direction for the current query (must match cursor.dir) * @param direction - "after" for forward pagination, "before" for backward * @param effectiveDesc - Whether the effective sort direction is descending * @throws if cursor.by does not match queryOrderBy + * @throws if cursor.dir does not match queryOrderDir * @returns SQL expression for the cursor filter */ export function cursorFilter( domains: ReturnType, cursor: DomainCursor, queryOrderBy: typeof DomainsOrderBy.$inferType, + queryOrderDir: typeof OrderDirection.$inferType, direction: "after" | "before", effectiveDesc: boolean, ): SQL { @@ -235,6 +239,12 @@ export function cursorFilter( ); } + if (cursor.dir !== queryOrderDir) { + throw new Error( + `Invalid cursor: cursor was created with orderDir=${cursor.dir} but query uses orderDir=${queryOrderDir}`, + ); + } + const orderColumn = getOrderColumn(domains, cursor.by); // Determine comparison direction: @@ -243,10 +253,26 @@ export function cursorFilter( // - "before" with ASC = less than cursor // - "before" with DESC = greater than cursor const useGreaterThan = (direction === "after") !== effectiveDesc; - const op = useGreaterThan ? ">" : "<"; - // Tuple comparison: (orderColumn, id) > (cursorValue, cursorId) + // Handle NULL cursor values explicitly (PostgreSQL tuple comparison with NULL yields NULL/unknown) + // With NULLS LAST ordering: non-NULL values come before NULL values + if (cursor.value === null) { + if (direction === "after") { + // "after" a NULL = other NULLs with appropriate id comparison + return useGreaterThan + ? sql`(${orderColumn} IS NULL AND ${domains.id} > ${cursor.id})` + : sql`(${orderColumn} IS NULL AND ${domains.id} < ${cursor.id})`; + } else { + // "before" a NULL = all non-NULLs (they come before NULLs) + NULLs with appropriate id + return useGreaterThan + ? sql`(${orderColumn} IS NOT NULL OR (${orderColumn} IS NULL AND ${domains.id} > ${cursor.id}))` + : sql`(${orderColumn} IS NOT NULL OR (${orderColumn} IS NULL AND ${domains.id} < ${cursor.id}))`; + } + } + + // Non-null cursor: use tuple comparison // NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL + const op = useGreaterThan ? ">" : "<"; return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${cursor.value}, ${cursor.id})`; } diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index a1d1ed163..67a82d4da 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -345,7 +345,8 @@ export const DomainsOrderBy = builder.enumType("DomainsOrderBy", { export type DomainsOrderByValue = typeof DomainsOrderBy.$inferType; export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { - description: "Ordering options for domains query", + description: + "Ordering options for domains query. When providing a custom order, both `by` and `dir` must be specified. If no order is provided, the default is NAME ASC.", fields: (t) => ({ by: t.field({ type: DomainsOrderBy, required: true }), dir: t.field({ type: OrderDirection, defaultValue: "ASC" }), From 932fbc0660be31543a4f74f2ed8bf8b33382884b Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 11:08:36 +0900 Subject: [PATCH 14/23] fix: domain-cursor encoding using superjson --- apps/ensapi/package.json | 1 + .../lib/find-domains/domain-cursor.ts | 24 ++++--------------- .../lib/find-domains/find-domains-resolver.ts | 4 ++-- pnpm-lock.yaml | 13 +++++++++- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index a7c3ef8f3..144738c3b 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -57,6 +57,7 @@ "pg-connection-string": "catalog:", "pino": "catalog:", "ponder-enrich-gql-docs-middleware": "^0.1.3", + "superjson": "^2.2.6", "viem": "catalog:", "zod": "catalog:" }, diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts index 9b6143adc..033274474 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import superjson from "superjson"; import type { DomainId } from "@ensnode/ensnode-sdk"; @@ -6,18 +6,6 @@ import type { DomainOrderValue } from "@/graphql-api/lib/find-domains/types"; import type { DomainsOrderBy } from "@/graphql-api/schema/domain"; import type { OrderDirection } from "@/graphql-api/schema/order-direction"; -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(), - dir: z.string(), - value: z.union([z.string(), z.null(), stringToBigInt]), -}); - /** * Composite Domain cursor for keyset pagination. * Includes the order column value to enable proper tuple comparison without subqueries. @@ -43,12 +31,8 @@ export interface DomainCursor { */ 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 + Buffer.from(superjson.stringify(cursor), "utf8").toString("base64"), + // TODO: in the future, validate the cursor format matches DomainCursor decode: (cursor: string) => - DomainCursorSchema.parse( - JSON.parse(Buffer.from(cursor, "base64").toString("utf8")), - ) as DomainCursor, + superjson.parse(Buffer.from(cursor, "base64").toString("utf8")), }; diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts index ce9a8a449..dfe64b819 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts @@ -2,7 +2,6 @@ import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@poth import { and } from "drizzle-orm"; import type { context as createContext } from "@/graphql-api/context"; -import { DomainCursor } from "@/graphql-api/lib/find-domains/domain-cursor"; import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; import { @@ -14,6 +13,7 @@ import { import { db } from "@/lib/db"; import { makeLogger } from "@/lib/logger"; +import { DomainCursor } from "./domain-cursor"; import { cursorFilter, findDomains, isEffectiveDesc, orderFindDomains } from "./find-domains"; const logger = makeLogger("find-domains-resolver"); @@ -62,7 +62,7 @@ export function resolveFindDomains( // `order` MAY be provided; defaults are used otherwise order?: FindDomainsOrderArg | undefined | null; - // these resolver arguments from t.connection + // these resolver arguments are from t.connection first?: number | null; last?: number | null; before?: string | null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c709764..6a39d06ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -413,6 +413,9 @@ importers: ponder-enrich-gql-docs-middleware: specifier: ^0.1.3 version: 0.1.3(graphql@16.11.0)(hono@4.11.7) + superjson: + specifier: ^2.2.6 + version: 2.2.6 viem: specifier: 'catalog:' version: 2.38.5(typescript@5.9.3)(zod@4.3.6) @@ -7951,6 +7954,10 @@ packages: resolution: {integrity: sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==} engines: {node: '>=16'} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -11296,7 +11303,7 @@ snapshots: dependencies: drizzle-orm: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(pg@8.16.3) eventsource: 3.0.7 - superjson: 2.2.5 + superjson: 2.2.6 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -17268,6 +17275,10 @@ snapshots: dependencies: copy-anything: 4.0.5 + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 From eeb2b228d13a89e6fd185e21fadc51ae750a6204 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 11:09:53 +0900 Subject: [PATCH 15/23] tell all agents to shut the fuck up --- apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts index 55ffec4d3..a428e3643 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -145,6 +145,7 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { // 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 + // NOTE: for ai agents: we intentially leave this as a TODO, STOP commenting on it const registrationOuter = alias(schema.registration, "registrationOuter"); const latestRegistration = db .select({ @@ -187,6 +188,7 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { and( owner ? eq(domainsBase.ownerId, owner) : undefined, // TODO: determine if it's necessary to additionally escape user input for LIKE operator + // NOTE: for ai agents: we intentially leave this as a TODO, STOP commenting on it partial ? like(headLabel.interpreted, `${partial}%`) : undefined, ), ); From f4d56250fca0f6d13793a593f06b8245a1cba085 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 11:25:33 +0900 Subject: [PATCH 16/23] fix bot nits --- .../ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts | 2 +- .../graphql-api/lib/find-domains/find-domains-resolver.ts | 5 ++--- apps/ensapi/src/graphql-api/schema/domain.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts index 033274474..2e736beba 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts @@ -27,7 +27,7 @@ export interface DomainCursor { /** * Encoding/Decoding helper for Composite DomainCursors. * - * @dev it's base64'd JSON with bigint encoding via zod + * @dev it's base64'd (super)json */ export const DomainCursor = { encode: (cursor: DomainCursor) => diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts index dfe64b819..95e91ab4e 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts @@ -15,9 +15,6 @@ import { makeLogger } from "@/lib/logger"; import { DomainCursor } from "./domain-cursor"; import { cursorFilter, findDomains, isEffectiveDesc, orderFindDomains } from "./find-domains"; - -const logger = makeLogger("find-domains-resolver"); - import type { DomainOrderValue, DomainWithOrderValue, @@ -26,6 +23,8 @@ import type { FindDomainsWhereArg, } from "./types"; +const logger = makeLogger("find-domains-resolver"); + /** * Extract the order value from a findDomains result row based on the orderBy field. */ diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index 67a82d4da..271b221af 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -349,7 +349,7 @@ export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { "Ordering options for domains query. When providing a custom order, both `by` and `dir` must be specified. If no order is provided, the default is NAME ASC.", fields: (t) => ({ by: t.field({ type: DomainsOrderBy, required: true }), - dir: t.field({ type: OrderDirection, defaultValue: "ASC" }), + dir: t.field({ type: OrderDirection, required: true }), }), }); From d9415110a298df32eaf73bf401957ac6f5548dc1 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 11:42:35 +0900 Subject: [PATCH 17/23] fix: use head label for NAME ordering instead of leaf label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit when a concrete path is specified (e.g. sub1.sub2.paren), all results share the same leaf label, making leaf-based NAME ordering meaningless. head label is the varying part and what users expect to sort by. also renames registrationStart→registrationTimestamp for consistency, updates changeset description, and improves DomainCursor docs. Co-Authored-By: Claude Opus 4.6 --- .changeset/whole-ways-grin.md | 2 +- .../lib/find-domains/domain-cursor.ts | 19 ++++++++--- .../lib/find-domains/find-domains-resolver.ts | 6 ++-- .../lib/find-domains/find-domains.ts | 32 ++++++++----------- .../src/graphql-api/lib/find-domains/types.ts | 4 +-- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/.changeset/whole-ways-grin.md b/.changeset/whole-ways-grin.md index 87b3b0d4d..7c371beae 100644 --- a/.changeset/whole-ways-grin.md +++ b/.changeset/whole-ways-grin.md @@ -2,4 +2,4 @@ "ensapi": minor --- -ENSv2 GraphQL API: Introduces order criteria for Domain methods, i.e. `Account.domains(order: { by: NAME, dir: ASC })`. +ENSv2 GraphQL API: Introduces order criteria for Domain methods, i.e. `Account.domains(order: { by: NAME, dir: ASC })`. The supported Order criteria are `NAME`, `REGISTRATION_TIMESTAMP`, and `REGISTRATION_EXPIRY` in either `ASC` or `DESC` orders, defaulting to `NAME` and `ASC`. diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts index 2e736beba..5be40d1de 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts @@ -14,13 +14,24 @@ import type { OrderDirection } from "@/graphql-api/schema/order-direction"; * column and which direction the set is ordered. */ export interface DomainCursor { - // stable identifier for stable tiebreaks + /** + * Stable identifier for tiebreaks. + */ id: DomainId; - // the column by which the set is ordered + + /** + * The criteria by which the set is ordered. One of NAME, REGISTRATION_TIMESTAMP, or REGISTRATION_EXPIRY. + */ by: typeof DomainsOrderBy.$inferType; - // the direction in which the set is ordered + + /** + * The direction in which the set is ordered, either ASC or DESC. + */ dir: typeof OrderDirection.$inferType; - // the value of said sort column for this domain + + /** + * The value of the sort column for this Domain in the set. + */ value: DomainOrderValue; } diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts index 95e91ab4e..5fba95062 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts @@ -34,9 +34,9 @@ function getOrderValueFromResult( ): DomainOrderValue { switch (orderBy) { case "NAME": - return result.leafLabelValue; + return result.headLabel; case "REGISTRATION_TIMESTAMP": - return result.registrationStart; + return result.registrationTimestamp; case "REGISTRATION_EXPIRY": return result.registrationExpiry; } @@ -97,7 +97,7 @@ export function resolveFindDomains( const beforeCursor = before ? DomainCursor.decode(before) : undefined; const afterCursor = after ? DomainCursor.decode(after) : undefined; - // build query with pagination constraints using tuple comparison + // build query with pagination constraints const query = db .with(domains) .select() diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts index a428e3643..2170ed283 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -42,7 +42,7 @@ const FIND_DOMAINS_MAX_DEPTH = 8; * ## Background: * * Materializing the set of Canonical Names in ENSv2 is non-trivial and more or less impossible - * within the confines of Ponder's cache semantics. Additionally retroactive label healing (due to + * within the confines of Ponder's cache semantics. Additionally, retroactive label healing (due to * new labels being discovered on-chain) is likely impossible within those constraints as well. If we * were to implement a naive cache-unfriendly version of canonical name materialization, indexing time * would increase dramatically. @@ -61,14 +61,13 @@ const FIND_DOMAINS_MAX_DEPTH = 8; * 2. Validate inputs (at least one of name or owner required) * 3. For both v1Domains and v2Domains: * a. Build recursive CTE to find domains matching the concrete labelHash path - * b. Extract unified structure: {id, ownerId, leafLabelHash, headLabelHash} + * b. Extract unified structure: {id, ownerId, headLabelHash} * 4. Union v1 and v2 results into domainsBase CTE * 5. Join domainsBase with: - * - headLabel: for partial name matching (LIKE prefix) - * - leafLabel: for NAME ordering + * - headLabel: for partial name matching (LIKE prefix) and NAME ordering * - latestRegistration: correlated subquery for REGISTRATION_* ordering * 6. Apply filters (owner, partial) in the unified query - * 7. Return CTE with columns: id, leafLabelValue, registrationStart, registrationExpiry + * 7. Return CTE with columns: id, headLabel, registrationTimestamp, registrationExpiry */ export function findDomains({ name, owner }: FindDomainsWhereArg) { // NOTE: if name is not provided, parse empty string to simplify control-flow, validity checked below @@ -105,13 +104,12 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { const v2HeadDomain = alias(schema.v2Domain, "v2HeadDomain"); // Base subqueries: extract unified structure from v1 and v2 domains - // Returns {id, ownerId, leafLabelHash, headLabelHash} for each matching domain + // Returns {id, ownerId, headLabelHash} for each matching domain // Note: owner/partial filtering happens in the unified query below, not here const v1DomainsBase = db .select({ id: sql`${schema.v1Domain.id}`.as("id"), ownerId: schema.v1Domain.ownerId, - leafLabelHash: sql`${schema.v1Domain.labelHash}`.as("leafLabelHash"), headLabelHash: sql`${v1HeadDomain.labelHash}`.as("headLabelHash"), }) .from(schema.v1Domain) @@ -125,7 +123,6 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { .select({ id: sql`${schema.v2Domain.id}`.as("id"), ownerId: schema.v2Domain.ownerId, - leafLabelHash: sql`${schema.v2Domain.labelHash}`.as("leafLabelHash"), headLabelHash: sql`${v2HeadDomain.labelHash}`.as("headLabelHash"), }) .from(schema.v2Domain) @@ -138,9 +135,8 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { // 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) + // alias for head label (for partial matching and 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 @@ -170,18 +166,18 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { .with(domainsBase) .select({ id: domainsBase.id, - // for NAME ordering - leafLabelValue: sql`${leafLabel.interpreted}`.as("leafLabelValue"), + // for NAME ordering (uses head label — the varying part when a concrete path is specified) + headLabel: sql`${headLabel.interpreted}`.as("headLabel"), // for REGISTRATION_TIMESTAMP ordering - registrationStart: sql`${latestRegistration.start}`.as("registrationStart"), + registrationTimestamp: sql`${latestRegistration.start}`.as( + "registrationTimestamp", + ), // for REGISTRATION_EXPIRY ordering registrationExpiry: sql`${latestRegistration.expiry}`.as("registrationExpiry"), }) .from(domainsBase) - // join head label for partial matching + // join head label for partial matching and NAME ordering .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( @@ -204,8 +200,8 @@ function getOrderColumn( orderBy: typeof DomainsOrderBy.$inferType, ) { return { - NAME: domains.leafLabelValue, - REGISTRATION_TIMESTAMP: domains.registrationStart, + NAME: domains.headLabel, + REGISTRATION_TIMESTAMP: domains.registrationTimestamp, REGISTRATION_EXPIRY: domains.registrationExpiry, }[orderBy]; } diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/types.ts b/apps/ensapi/src/graphql-api/lib/find-domains/types.ts index 302044836..55d823a4b 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/types.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/types.ts @@ -50,7 +50,7 @@ export type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue }; */ export type FindDomainsResult = { id: DomainId; - leafLabelValue: string | null; - registrationStart: bigint | null; + headLabel: string | null; + registrationTimestamp: bigint | null; registrationExpiry: bigint | null; }; From eb05617c6d8bd6ff8c4d5b7173441ccdcc55b09b Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 11:49:33 +0900 Subject: [PATCH 18/23] fix: explicit casts in cursor tuple comparison and catch malformed cursors cast cursor.value to ::text or ::bigint in tuple comparison to avoid Postgres "could not determine data type of parameter" errors. also wrap DomainCursor.decode in try/catch for clear error on malformed input. Co-Authored-By: Claude Opus 4.6 --- .../src/graphql-api/lib/find-domains/domain-cursor.ts | 11 +++++++++-- .../src/graphql-api/lib/find-domains/find-domains.ts | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts index 5be40d1de..aa2ff724d 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts @@ -44,6 +44,13 @@ export const DomainCursor = { encode: (cursor: DomainCursor) => Buffer.from(superjson.stringify(cursor), "utf8").toString("base64"), // TODO: in the future, validate the cursor format matches DomainCursor - decode: (cursor: string) => - superjson.parse(Buffer.from(cursor, "base64").toString("utf8")), + decode: (cursor: string): DomainCursor => { + try { + return superjson.parse(Buffer.from(cursor, "base64").toString("utf8")); + } catch { + throw new Error( + "Invalid cursor: failed to decode cursor. The cursor may be malformed or from an incompatible query.", + ); + } + }, }; diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts index 2170ed283..d62cfba42 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -270,8 +270,11 @@ export function cursorFilter( // Non-null cursor: use tuple comparison // NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL + // NOTE: explicit cast required — Postgres can't infer parameter types in tuple comparisons const op = useGreaterThan ? ">" : "<"; - return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${cursor.value}, ${cursor.id})`; + const castValue = + cursor.by === "NAME" ? sql`${cursor.value}::text` : sql`${cursor.value}::bigint`; + return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${castValue}, ${cursor.id})`; } /** From 151b5425577743b8843626439070466a7e8f3b01 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 11:51:08 +0900 Subject: [PATCH 19/23] fit bot nits --- apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts | 5 ++--- apps/ensapi/src/graphql-api/schema/domain.ts | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts index d62cfba42..dce80db3c 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -272,9 +272,8 @@ export function cursorFilter( // NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL // NOTE: explicit cast required — Postgres can't infer parameter types in tuple comparisons const op = useGreaterThan ? ">" : "<"; - const castValue = - cursor.by === "NAME" ? sql`${cursor.value}::text` : sql`${cursor.value}::bigint`; - return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${castValue}, ${cursor.id})`; + const value = cursor.by === "NAME" ? sql`${cursor.value}::text` : sql`${cursor.value}::bigint`; + return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${value}, ${cursor.id})`; } /** diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index 271b221af..43e141380 100644 --- a/apps/ensapi/src/graphql-api/schema/domain.ts +++ b/apps/ensapi/src/graphql-api/schema/domain.ts @@ -345,11 +345,10 @@ export const DomainsOrderBy = builder.enumType("DomainsOrderBy", { export type DomainsOrderByValue = typeof DomainsOrderBy.$inferType; export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { - description: - "Ordering options for domains query. When providing a custom order, both `by` and `dir` must be specified. If no order is provided, the default is NAME ASC.", + description: "Ordering options for domains query. If no order is provided, the default isASC.", fields: (t) => ({ by: t.field({ type: DomainsOrderBy, required: true }), - dir: t.field({ type: OrderDirection, required: true }), + dir: t.field({ type: OrderDirection, defaultValue: "ASC" }), }), }); From b584e516945ad8e7b9204703c8300e125cff8b37 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 12:07:22 +0900 Subject: [PATCH 20/23] fix: cast bigint correctly --- apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts index dce80db3c..4121a4626 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -272,7 +272,8 @@ export function cursorFilter( // NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL // NOTE: explicit cast required — Postgres can't infer parameter types in tuple comparisons const op = useGreaterThan ? ">" : "<"; - const value = cursor.by === "NAME" ? sql`${cursor.value}::text` : sql`${cursor.value}::bigint`; + const value = + cursor.by === "NAME" ? sql`${cursor.value}::text` : sql`${cursor.value}::numeric(78,0)`; return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${value}, ${cursor.id})`; } From 551cd5eeeb22dd606dfa1541e787805401162dfe Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 12:10:41 +0900 Subject: [PATCH 21/23] fix: headLabel is never null --- apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts index 4121a4626..50a17e8cf 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -166,8 +166,8 @@ export function findDomains({ name, owner }: FindDomainsWhereArg) { .with(domainsBase) .select({ id: domainsBase.id, - // for NAME ordering (uses head label — the varying part when a concrete path is specified) - headLabel: sql`${headLabel.interpreted}`.as("headLabel"), + // for NAME ordering + headLabel: sql`${headLabel.interpreted}`.as("headLabel"), // for REGISTRATION_TIMESTAMP ordering registrationTimestamp: sql`${latestRegistration.start}`.as( "registrationTimestamp", From a3ab5aff0ee424fa1272ae7b2a592b77fcd72710 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 12:28:07 +0900 Subject: [PATCH 22/23] tests: add some unit tests --- .../lib/find-domains/domain-cursor.test.ts | 64 +++++++++++++++++++ .../lib/find-domains/find-domains.test.ts | 24 +++++++ 2 files changed, 88 insertions(+) create mode 100644 apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.test.ts create mode 100644 apps/ensapi/src/graphql-api/lib/find-domains/find-domains.test.ts diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.test.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.test.ts new file mode 100644 index 000000000..38e2d7f85 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import type { DomainId } from "@ensnode/ensnode-sdk"; + +import { DomainCursor } from "./domain-cursor"; + +describe("DomainCursor", () => { + describe("roundtrip encode/decode", () => { + it("roundtrips with a string value (NAME ordering)", () => { + const cursor: DomainCursor = { + id: "0xabc" as DomainId, + by: "NAME", + dir: "ASC", + value: "example", + }; + expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor); + }); + + it("roundtrips with a bigint value (REGISTRATION_TIMESTAMP ordering)", () => { + const cursor: DomainCursor = { + id: "0xabc" as DomainId, + by: "REGISTRATION_TIMESTAMP", + dir: "DESC", + value: 1234567890n, + }; + expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor); + }); + + it("roundtrips with a bigint value (REGISTRATION_EXPIRY ordering)", () => { + const cursor: DomainCursor = { + id: "0xdef" as DomainId, + by: "REGISTRATION_EXPIRY", + dir: "ASC", + value: 9999999999n, + }; + expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor); + }); + + it("roundtrips with a null value", () => { + const cursor: DomainCursor = { + id: "0xabc" as DomainId, + by: "REGISTRATION_TIMESTAMP", + dir: "ASC", + value: null, + }; + expect(DomainCursor.decode(DomainCursor.encode(cursor))).toEqual(cursor); + }); + }); + + describe("decode error handling", () => { + it("throws on garbage input", () => { + expect(() => DomainCursor.decode("not-valid-base64!!!")).toThrow("Invalid cursor"); + }); + + it("throws on valid base64 but invalid json", () => { + const notJson = Buffer.from("not json", "utf8").toString("base64"); + expect(() => DomainCursor.decode(notJson)).toThrow("Invalid cursor"); + }); + + it("throws on empty string", () => { + expect(() => DomainCursor.decode("")).toThrow("Invalid cursor"); + }); + }); +}); diff --git a/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.test.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.test.ts new file mode 100644 index 000000000..23029890f --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ db: {} })); +vi.mock("@/graphql-api/lib/find-domains/find-domains-by-labelhash-path", () => ({})); + +import { isEffectiveDesc } from "./find-domains"; + +describe("isEffectiveDesc", () => { + it("ASC + not inverted = not desc", () => { + expect(isEffectiveDesc("ASC", false)).toBe(false); + }); + + it("ASC + inverted = desc", () => { + expect(isEffectiveDesc("ASC", true)).toBe(true); + }); + + it("DESC + not inverted = desc", () => { + expect(isEffectiveDesc("DESC", false)).toBe(true); + }); + + it("DESC + inverted = not desc", () => { + expect(isEffectiveDesc("DESC", true)).toBe(false); + }); +}); From 618d7a8d6f2d330fb136ca00c5f08bd545ee9241 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 10 Feb 2026 13:04:38 +0900 Subject: [PATCH 23/23] feat: make top-level domain search a non-testing method --- apps/ensapi/src/graphql-api/schema/query.ts | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts index b7d457e8c..996667812 100644 --- a/apps/ensapi/src/graphql-api/schema/query.ts +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -40,19 +40,6 @@ const INCLUDE_DEV_METHODS = process.env.NODE_ENV !== "production"; builder.queryType({ fields: (t) => ({ ...(INCLUDE_DEV_METHODS && { - ///////////////////////////// - // Query.domains (Testing) - ///////////////////////////// - domains: t.connection({ - description: "TODO", - type: DomainInterfaceRef, - args: { - where: t.arg({ type: DomainsWhereInput, required: true }), - order: t.arg({ type: DomainsOrderInput }), - }, - resolve: (_, args, context) => resolveFindDomains(context, args), - }), - ///////////////////////////// // Query.v1Domains (Testing) ///////////////////////////// @@ -144,6 +131,19 @@ builder.queryType({ }), }), + //////////////// + // Find Domains + //////////////// + domains: t.connection({ + description: "TODO", + type: DomainInterfaceRef, + args: { + where: t.arg({ type: DomainsWhereInput, required: true }), + order: t.arg({ type: DomainsOrderInput }), + }, + resolve: (_, args, context) => resolveFindDomains(context, args), + }), + ////////////////////////////////// // Get Domain by Name or DomainId //////////////////////////////////