-
Notifications
You must be signed in to change notification settings - Fork 16
ENSv2 Find Domain Ordering Support #1595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 20 commits
930a910
0a03e2a
cdfa7cc
523a98c
3ab13bf
992a7fe
3ebdb64
55c09e6
5652b14
ee9498c
7132c0f
fefb300
e458e5d
19670c8
932fbc0
eeb2b22
9885464
25d6f65
f4d5625
d941511
eb05617
151b542
b584e51
551cd5e
a3ab5af
618d7a8
1ccb1d7
78ce1fc
799d553
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| 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; | ||
|
|
||
|
shrugs marked this conversation as resolved.
|
||
| /** | ||
| * 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) => | ||
| superjson.parse<DomainCursor>(Buffer.from(cursor, "base64").toString("utf8")), | ||
|
shrugs marked this conversation as resolved.
Outdated
|
||
| }; | ||
|
shrugs marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. kept in |
||
|
|
||
| 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<DomainId>`${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<DomainId>`${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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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, | ||||||
|
||||||
| type DomainsOrderBy, | |
| DomainsOrderBy, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is the shared implementation for the GraphQL Resolver
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it's at this level that we build the specific query (using find-domains.ts) and apply the request's pagination constraints
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DomainsOrderByandOrderDirectionare imported withimport type, but this file usestypeof DomainsOrderBy.$inferType/typeof OrderDirection.$inferType, which requires the value-side symbol. This will fail TypeScript compilation; import the values (non-type import) or switch these annotations to use exported type aliases (e.g.DomainsOrderByValue/OrderDirectionValue).