diff --git a/.changeset/whole-ways-grin.md b/.changeset/whole-ways-grin.md new file mode 100644 index 000000000..7c371beae --- /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 })`. 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/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.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/domain-cursor.ts b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts new file mode 100644 index 000000000..aa2ff724d --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains/domain-cursor.ts @@ -0,0 +1,56 @@ +import superjson from "superjson"; + +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"; + +/** + * 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 tiebreaks. + */ + id: DomainId; + + /** + * 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, either ASC or DESC. + */ + dir: typeof OrderDirection.$inferType; + + /** + * The value of the sort column for this Domain in the set. + */ + value: DomainOrderValue; +} + +/** + * Encoding/Decoding helper for Composite DomainCursors. + * + * @dev it's base64'd (super)json + */ +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): 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.ts b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts similarity index 50% rename from apps/ensapi/src/graphql-api/lib/find-domains.ts rename to apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts index 70302744c..4db68e95f 100644 --- a/apps/ensapi/src/graphql-api/lib/find-domains.ts +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-by-labelhash-path.ts @@ -1,146 +1,9 @@ -import { and, eq, like, Param, sql } from "drizzle-orm"; -import { alias, unionAll } from "drizzle-orm/pg-core"; -import type { Address } from "viem"; +import { Param, sql } from "drizzle-orm"; import * as schema from "@ensnode/ensnode-schema"; -import { - type DomainId, - type ENSv1DomainId, - type ENSv2DomainId, - interpretedLabelsToLabelHashPath, - type LabelHashPath, - type Name, - parsePartialInterpretedName, -} from "@ensnode/ensnode-sdk"; +import type { ENSv1DomainId, ENSv2DomainId, LabelHashPath } from "@ensnode/ensnode-sdk"; import { db } from "@/lib/db"; -import { makeLogger } from "@/lib/logger"; - -const logger = makeLogger("find-domains"); - -const MAX_DEPTH = 16; - -interface DomainFilter { - name?: Name | undefined | null; - owner?: Address | undefined | null; -} - -/** - * Find Domains by Canonical Name. - * - * @throws if neither `name` or `owner` are provided - * @throws if `name` is provided but is not a valid Partial InterpretedName - * - * ## Terminology: - * - * - a 'Canonical Domain' is a Domain connected to either the ENSv1 Root or the ENSv2 Root. All ENSv1 - * Domains are Canonical Domains, but an ENSv2 Domain may not be Canonical, for example if it exists - * in a disjoint nametree or its Registry does not declare a Canonical Domain. - * - a 'Partial InterpretedName' is a partial InterpretedName (ex: 'examp', 'example.', 'sub1.sub2.paren') - * - * ## 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 - * 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. - * - * The overall user story we're trying to support is 'autocomplete' or 'search (my) domains'. More - * specifically, given a partial InterpretedName as input (ex: 'examp', 'example.', 'sub1.sub2.paren'), - * produce a set of Domains addressable by the provided partial InterpretedName. - * - * While complicated to do so, it is more correct to perform this calculation at query-time rather - * than at index-time, given the constraints above. - * - * ## 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 - */ -export function findDomains({ name, owner }: DomainFilter) { - // 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 || ""); - - // 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.`); - } - - logger.debug({ input: { name, owner, concrete, partial } }); - - // a name input is valid if it was parsed to something other than just empty string - const validName = concrete.length > 0 || partial !== ""; - const validOwner = !!owner; - - // Invariant: one of name or owner must be provided - // TODO: maybe this should be zod... - if (!validName && !validOwner) { - throw new Error(`Invariant(findDomains): One of 'name' or 'owner' must be provided.`); - } - - const labelHashPath = interpretedLabelsToLabelHashPath(concrete); - - // compose subquery by concrete LabelHashPath - const v1DomainsByLabelHashPathQuery = v1DomainsByLabelHashPath(labelHashPath); - const v2DomainsByLabelHashPathQuery = v2DomainsByLabelHashPath(labelHashPath); - - // alias for the head domains (to get its labelHash for partial matching) - const v1HeadDomain = alias(schema.v1Domain, "v1HeadDomain"); - const v2HeadDomain = alias(schema.v2Domain, "v2HeadDomain"); - - // join on leafId (the autocomplete result), filter by owner and partial - const v1Domains = db - .select({ id: sql`${schema.v1Domain.id}`.as("id") }) - .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)) - .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, - ), - ); - - // join on leafId (the autocomplete result), filter by owner and partial - const v2Domains = db - .select({ id: sql`${schema.v2Domain.id}`.as("id") }) - .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)) - .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.interpreted, `${partial}%`) : undefined, - ), - ); - - // union the two subqueries and return - return db.$with("domains").as(unionAll(v1Domains, v2Domains)); -} /** * Compose a query for v1Domains that have the specified children path. @@ -157,7 +20,7 @@ export function findDomains({ name, owner }: DomainFilter) { * 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) { +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) { @@ -232,7 +95,7 @@ function v1DomainsByLabelHashPath(labelHashPath: LabelHashPath) { * 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) { +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) { 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..5fba95062 --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains-resolver.ts @@ -0,0 +1,144 @@ +import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; +import { and } from "drizzle-orm"; + +import type { context as createContext } from "@/graphql-api/context"; +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 { DomainCursor } from "./domain-cursor"; +import { cursorFilter, findDomains, isEffectiveDesc, orderFindDomains } from "./find-domains"; +import type { + DomainOrderValue, + DomainWithOrderValue, + FindDomainsOrderArg, + FindDomainsResult, + FindDomainsWhereArg, +} from "./types"; + +const logger = makeLogger("find-domains-resolver"); + +/** + * 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.headLabel; + case "REGISTRATION_TIMESTAMP": + return result.registrationTimestamp; + 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 + }: { + // `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 are 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, + dir: orderDir, + 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 + const query = db + .with(domains) + .select() + .from(domains) + .where( + and( + beforeCursor + ? cursorFilter(domains, beforeCursor, orderBy, orderDir, "before", effectiveDesc) + : undefined, + afterCursor + ? cursorFilter(domains, afterCursor, orderBy, orderDir, "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 }; + }); + }, + ); +} 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); + }); +}); 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 new file mode 100644 index 000000000..50a17e8cf --- /dev/null +++ b/apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts @@ -0,0 +1,310 @@ +import { and, asc, desc, eq, like, type SQL, sql } from "drizzle-orm"; +import { alias, unionAll } from "drizzle-orm/pg-core"; + +import * as schema from "@ensnode/ensnode-schema"; +import { + type DomainId, + interpretedLabelsToLabelHashPath, + parsePartialInterpretedName, +} from "@ensnode/ensnode-sdk"; + +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"); + +/** + * Maximum depth of the provided `name` argument, to avoid infinite loops and expensive queries. + */ +const FIND_DOMAINS_MAX_DEPTH = 8; + +/** + * Find Domains by Canonical Name. + * + * @throws if neither `name` or `owner` are provided + * @throws if `name` is provided but is not a valid Partial InterpretedName + * + * ## Terminology: + * + * - a 'Canonical Domain' is a Domain connected to either the ENSv1 Root or the ENSv2 Root. All ENSv1 + * Domains are Canonical Domains, but an ENSv2 Domain may not be Canonical, for example if it exists + * in a disjoint nametree or its Registry does not declare a Canonical Domain. + * - a 'Partial InterpretedName' is a partial InterpretedName (ex: 'examp', 'example.', 'sub1.sub2.paren') + * + * ## 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 + * 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. + * + * The overall user story we're trying to support is 'autocomplete' or 'search (my) domains'. More + * specifically, given a partial InterpretedName as input (ex: 'examp', 'example.', 'sub1.sub2.paren'), + * produce a set of Domains addressable by the provided partial InterpretedName. + * + * While complicated to do so, it is more correct to perform this calculation at query-time rather + * than at index-time, given the constraints above. + * + * ## Algorithm + * + * 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, headLabelHash} + * 4. Union v1 and v2 results into domainsBase CTE + * 5. Join domainsBase with: + * - 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, 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 + // NOTE: throws if name is not a Partial InterpretedName + const { concrete, partial } = parsePartialInterpretedName(name || ""); + + // validate depth to prevent arbitrary recursion in CTEs + 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 } }); + + // a name input is valid if it was parsed to something other than just empty string + const validName = concrete.length > 0 || partial !== ""; + const validOwner = !!owner; + + // Invariant: one of name or owner must be provided + // TODO: maybe this should be zod... + if (!validName && !validOwner) { + throw new Error(`Invariant(findDomains): One of 'name' or 'owner' must be provided.`); + } + + const labelHashPath = interpretedLabelsToLabelHashPath(concrete); + + // compose subquery by concrete LabelHashPath + const v1DomainsByLabelHashPathQuery = v1DomainsByLabelHashPath(labelHashPath); + const v2DomainsByLabelHashPathQuery = v2DomainsByLabelHashPath(labelHashPath); + + // alias for the head domains (to get its labelHash for partial matching) + 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, 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, + headLabelHash: sql`${v1HeadDomain.labelHash}`.as("headLabelHash"), + }) + .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, + headLabelHash: sql`${v2HeadDomain.labelHash}`.as("headLabelHash"), + }) + .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 NAME ordering) + const headLabel = alias(schema.label, "headLabel"); + + // 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({ + domainId: registrationOuter.domainId, + start: registrationOuter.start, + expiry: registrationOuter.expiry, + }) + .from(registrationOuter) + .where( + eq( + registrationOuter.index, + db + .select({ maxIndex: sql`MAX(${schema.registration.index})` }) + .from(schema.registration) + .where(eq(schema.registration.domainId, registrationOuter.domainId)), + ), + ) + .as("latestRegistration"); + + // Apply shared joins and filters on the unified domain base + const domains = db + .with(domainsBase) + .select({ + id: domainsBase.id, + // for NAME ordering + headLabel: sql`${headLabel.interpreted}`.as("headLabel"), + // for REGISTRATION_TIMESTAMP ordering + registrationTimestamp: sql`${latestRegistration.start}`.as( + "registrationTimestamp", + ), + // for REGISTRATION_EXPIRY ordering + registrationExpiry: sql`${latestRegistration.expiry}`.as("registrationExpiry"), + }) + .from(domainsBase) + // join head label for partial matching and NAME ordering + .leftJoin(headLabel, eq(headLabel.labelHash, domainsBase.headLabelHash)) + // join latest registration for timestamp/expiry ordering + .leftJoin(latestRegistration, eq(latestRegistration.domainId, domainsBase.id)) + .where( + 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, + ), + ); + + return db.$with("domains").as(domains); +} + +/** + * Get the order column for a given DomainsOrderBy value. + */ +function getOrderColumn( + domains: ReturnType, + orderBy: typeof DomainsOrderBy.$inferType, +) { + return { + NAME: domains.headLabel, + REGISTRATION_TIMESTAMP: domains.registrationTimestamp, + REGISTRATION_EXPIRY: domains.registrationExpiry, + }[orderBy]; +} + +/** + * Build a cursor filter for keyset pagination on findDomains results. + * + * 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 { + // Validate cursor was created with the same ordering as the current query + if (cursor.by !== queryOrderBy) { + throw new Error( + `Invalid cursor: cursor was created with orderBy=${cursor.by} but query uses orderBy=${queryOrderBy}`, + ); + } + + 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: + // - "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; + + // 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 + // 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}::numeric(78,0)`; + return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${value}, ${cursor.id})`; +} + +/** + * 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, + orderDir: typeof OrderDirection.$inferType, + inverted: boolean, +): SQL[] { + 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 + const primaryOrder = effectiveDesc + ? sql`${orderColumn} DESC NULLS LAST` + : 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/lib/find-domains/types.ts b/apps/ensapi/src/graphql-api/lib/find-domains/types.ts new file mode 100644 index 000000000..55d823a4b --- /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; + headLabel: string | null; + registrationTimestamp: bigint | null; + registrationExpiry: bigint | null; +}; diff --git a/apps/ensapi/src/graphql-api/schema/account.ts b/apps/ensapi/src/graphql-api/schema/account.ts index c79011cdc..225c0334e 100644 --- a/apps/ensapi/src/graphql-api/schema/account.ts +++ b/apps/ensapi/src/graphql-api/schema/account.ts @@ -3,18 +3,21 @@ 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 } from "@/graphql-api/lib/find-domains"; +import { resolveFindDomains } from "@/graphql-api/lib/find-domains/find-domains-resolver"; import { getModelId } from "@/graphql-api/lib/get-model-id"; -import { rejectAnyErrors } from "@/graphql-api/lib/reject-any-errors"; import { AccountIdInput } from "@/graphql-api/schema/account-id"; import { AccountRegistryPermissionsRef } from "@/graphql-api/schema/account-registries-permissions"; import { AccountResolverPermissionsRef } from "@/graphql-api/schema/account-resolver-permissions"; import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; import { cursors } from "@/graphql-api/schema/cursors"; -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,36 +67,16 @@ AccountRef.implement({ type: DomainInterfaceRef, args: { where: t.arg({ type: AccountDomainsWhereInput, required: false }), + order: t.arg({ type: DomainsOrderInput }), }, resolve: (parent, args, context) => - resolveCursorConnection( - { ...DEFAULT_CONNECTION_ARGS, args }, - async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { - // construct query for relevant domains - const domains = findDomains({ ...args.where, owner: parent.id }); - - // execute with pagination constraints - 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, - ), - ) - .orderBy(inverted ? desc(domains.id) : asc(domains.id)) - .limit(limit); - - // provide full Domain entities via dataloader - return rejectAnyErrors( - DomainInterfaceRef.getDataloader(context).loadMany( - results.map((result) => result.id), - ), - ); + resolveFindDomains(context, { + ...args, + where: { + ...args.where, + owner: parent.id, }, - ), + }), }), /////////////////////// diff --git a/apps/ensapi/src/graphql-api/schema/domain.ts b/apps/ensapi/src/graphql-api/schema/domain.ts index 7e93aeb23..43e141380 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,25 @@ 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. If no order is provided, the default isASC.", + fields: (t) => ({ + by: t.field({ type: DomainsOrderBy, required: true }), + 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"; 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/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; diff --git a/apps/ensapi/src/graphql-api/schema/query.ts b/apps/ensapi/src/graphql-api/schema/query.ts index 16a6c093b..996667812 100644 --- a/apps/ensapi/src/graphql-api/schema/query.ts +++ b/apps/ensapi/src/graphql-api/schema/query.ts @@ -1,10 +1,8 @@ import config from "@/config"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { and, asc, desc, gt, lt } from "drizzle-orm"; import { - type DomainId, type ENSv1DomainId, type ENSv2DomainId, getENSv2RootRegistryId, @@ -16,9 +14,8 @@ import { } from "@ensnode/ensnode-sdk"; import { builder } from "@/graphql-api/builder"; -import { findDomains } from "@/graphql-api/lib/find-domains"; +import { resolveFindDomains } from "@/graphql-api/lib/find-domains/find-domains-resolver"; 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"; import { AccountIdInput } from "@/graphql-api/schema/account-id"; import { DEFAULT_CONNECTION_ARGS } from "@/graphql-api/schema/constants"; @@ -26,6 +23,7 @@ import { cursors } from "@/graphql-api/schema/cursors"; import { DomainIdInput, DomainInterfaceRef, + DomainsOrderInput, DomainsWhereInput, ENSv1DomainRef, ENSv2DomainRef, @@ -42,46 +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 }), - }, - resolve: (parent, args, context) => - resolveCursorConnection( - { ...DEFAULT_CONNECTION_ARGS, args }, - async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { - // construct query for relevant domains - const domains = findDomains(args.where); - - // execute with pagination constraints - 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, - ), - ) - .orderBy(inverted ? desc(domains.id) : asc(domains.id)) - .limit(limit); - - // provide full Domain entities via dataloader - return rejectAnyErrors( - DomainInterfaceRef.getDataloader(context).loadMany( - results.map((result) => result.id), - ), - ); - }, - ), - }), - ///////////////////////////// // Query.v1Domains (Testing) ///////////////////////////// @@ -173,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 ////////////////////////////////// diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dd605a4b..957a6f08d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,6 +414,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) @@ -7977,6 +7980,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'} @@ -11322,7 +11329,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: @@ -17294,6 +17301,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