Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/refactor-find-domains-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"ensapi": patch
---

add `Account.domains` and enhance `Domain.subdomains` and `Registry.domains` with filtering and ordering

**`Account.domains`** (new) — paginated connection of domains owned by this account.
- `where: { name?: String, canonical?: Boolean }` — optional partial Interpreted Name filter and canonical filter (defaults to false)
- `order: { by: NAME | REGISTRATION_TIMESTAMP | REGISTRATION_EXPIRY, dir: ASC | DESC }` — ordering

**`Domain.subdomains`** (enhanced) — paginated connection of subdomains of this domain, now with filtering and ordering.
- `where: { name?: String }` — optional partial Interpreted Name filter
- `order: { by: NAME | REGISTRATION_TIMESTAMP | REGISTRATION_EXPIRY, dir: ASC | DESC }` — ordering

**`Registry.domains`** (enhanced) — paginated connection of domains in this registry, now with filtering and ordering.
- `where: { name?: String }` — optional partial Interpreted Name filter
- `order: { by: NAME | REGISTRATION_TIMESTAMP | REGISTRATION_EXPIRY, dir: ASC | DESC }` — ordering

**`Query.domains`** (updated) — `where.name` is now required. Added optional `where.canonical` filter (defaults to false).
- `where: { name: String!, canonical?: Boolean }` — required partial Interpreted Name, optional canonical filter
- `order: { by: NAME | REGISTRATION_TIMESTAMP | REGISTRATION_EXPIRY, dir: ASC | DESC }` — ordering
Comment thread
shrugs marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ const CANONICAL_REGISTRIES_MAX_DEPTH = 16;
*/
export const getCanonicalRegistriesCTE = () =>
db
.select({ registryId: sql<string>`registry_id`.as("registryId") })
.select({
// NOTE: using `id` here to avoid clobbering `registryId` in consuming queries, which would
// result in '_ is ambiguous' error messages from postgres because drizzle isn't scoping the
// selection properly. a bit fragile but works for now.
id: sql<string>`registry_id`.as("id"),
})
.from(
sql`(
WITH RECURSIVE canonical_registries AS (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,12 @@ import { db } from "@/lib/db";
import { makeLogger } from "@/lib/logger";

import { DomainCursor } from "./domain-cursor";
import { cursorFilter, findDomains, isEffectiveDesc, orderFindDomains } from "./find-domains";
import { cursorFilter, type DomainsCTE, isEffectiveDesc, orderFindDomains } from "./find-domains";
import type {
DomainOrderValue,
DomainWithOrderValue,
FindDomainsOrderArg,
FindDomainsResult,
FindDomainsWhereArg,
} from "./types";

const logger = makeLogger("find-domains-resolver");
Expand All @@ -34,7 +33,7 @@ function getOrderValueFromResult(
): DomainOrderValue {
switch (orderBy) {
case "NAME":
return result.headLabel;
return result.sortableLabel;
case "REGISTRATION_TIMESTAMP":
return result.registrationTimestamp;
case "REGISTRATION_EXPIRY":
Expand All @@ -43,24 +42,28 @@ function getOrderValueFromResult(
}

/**
* GraphQL API resolver for domains connection queries, used by Query.domains.
* GraphQL API resolver for domain connection queries. Accepts a pre-built domains CTE
* (output of withOrderingMetadata) and handles cursor-based pagination, ordering, and
* dataloader loading.
*
* Used by Query.domains, Account.domains, Registry.domains, and Domain.subdomains.
*
* @param context - The GraphQL Context, required for Dataloader access
* @param args - The GraphQL Args object (via t.connection) + FindDomains-specific args (where, order)
* @param args - The domains CTE, optional ordering, and relay connection args
*/
export function resolveFindDomains(
context: ReturnType<typeof createContext>,
{
where,
domains,
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
/** Pre-built domains CTE from composing layers (withOrderingMetadata output) */
domains: DomainsCTE;
/** Optional ordering; defaults to NAME ASC */
order?: FindDomainsOrderArg | undefined | null;

// these resolver arguments are from t.connection
// relay connection args from t.connection
first?: number | null;
last?: number | null;
before?: string | null;
Expand All @@ -86,9 +89,6 @@ export function resolveFindDomains(
// 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);

Expand Down
213 changes: 9 additions & 204 deletions apps/ensapi/src/graphql-api/lib/find-domains/find-domains.ts
Original file line number Diff line number Diff line change
@@ -1,214 +1,19 @@
import { and, asc, desc, eq, like, type SQL, sql } from "drizzle-orm";
import { alias, unionAll } from "drizzle-orm/pg-core";
import { asc, desc, type SQL, sql } from "drizzle-orm";

import * as schema from "@ensnode/ensnode-schema";
import {
type DomainId,
interpretedLabelsToLabelHashPath,
parsePartialInterpretedName,
} from "@ensnode/ensnode-sdk";

import { getCanonicalRegistriesCTE } from "@/graphql-api/lib/canonical-registries-cte";
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 { withOrderingMetadata } from "@/graphql-api/lib/find-domains/layers/with-ordering-metadata";
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 Registry' is an ENSv2 Registry that is the Root Registry or the (sub)Registry of a
* Domain in a Canonical Registry.
* - 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, r egistrationExpiry
*
* ## Possible Future Improvements
*
* To support iterating over the full set of Canonical Domains, if desired, we could remove the
* input validation constraint, making both optional, and ensure that the downstream generated sql
* condenses into something manageable. This is left as a todo, though, as it's not yet clear
* whether the ability to iterate all Canonical Domains is a requirement.
*/
export function findDomains({ name, owner, canonical }: FindDomainsWhereArg) {
// coerce `canonical` input to boolean
const onlyCanonical = canonical === true;

// 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, onlyCanonical, 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
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({
domainId: sql<DomainId>`${schema.v1Domain.id}`.as("domainId"),
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 v2DomainsBaseQuery = db
.select({
domainId: sql<DomainId>`${schema.v2Domain.id}`.as("domainId"),
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));

// conditionally join against the set of Canonical Registries if filtering by `canonical`
const canonicalRegistries = getCanonicalRegistriesCTE();
const v2DomainsBase = onlyCanonical
? v2DomainsBaseQuery.innerJoin(
canonicalRegistries,
eq(schema.v2Domain.registryId, canonicalRegistries.registryId),
)
: v2DomainsBaseQuery;

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

// Apply shared joins and filters on the unified domain base
const domains = db
.select({
id: domainsBase.domainId,

// for NAME ordering
headLabel: schema.label.interpreted,

// for REGISTRATION_TIMESTAMP ordering
registrationTimestamp: schema.event.timestamp,

// for REGISTRATION_EXPIRY ordering
registrationExpiry: schema.registration.expiry,
})
.from(domainsBase)
// join head label
.leftJoin(schema.label, eq(schema.label.labelHash, domainsBase.headLabelHash))
// join latestRegistrationIndex
.leftJoin(
schema.latestRegistrationIndex,
eq(schema.latestRegistrationIndex.domainId, domainsBase.domainId),
)
// join (latest) Registration
.leftJoin(
schema.registration,
and(
eq(schema.registration.domainId, domainsBase.domainId),
eq(schema.registration.index, schema.latestRegistrationIndex.index),
),
)
// join (latest) Registration's Event
.leftJoin(schema.event, eq(schema.event.id, schema.registration.eventId))
.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(schema.label.interpreted, `${partial}%`) : undefined,
),
);

return db.$with("domains").as(domains);
}
/** Type of the domains CTE produced by withOrderingMetadata. */
export type DomainsCTE = ReturnType<typeof withOrderingMetadata>;

/**
* Get the order column for a given DomainsOrderBy value.
*/
function getOrderColumn(
domains: ReturnType<typeof findDomains>,
orderBy: typeof DomainsOrderBy.$inferType,
) {
function getOrderColumn(domains: DomainsCTE, orderBy: typeof DomainsOrderBy.$inferType) {
return {
NAME: domains.headLabel,
NAME: domains.sortableLabel,
REGISTRATION_TIMESTAMP: domains.registrationTimestamp,
REGISTRATION_EXPIRY: domains.registrationExpiry,
}[orderBy];
Expand All @@ -220,7 +25,7 @@ function getOrderColumn(
* 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 domains - The domains CTE
* @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)
Expand All @@ -231,7 +36,7 @@ function getOrderColumn(
* @returns SQL expression for the cursor filter
*/
export function cursorFilter(
domains: ReturnType<typeof findDomains>,
domains: DomainsCTE,
cursor: DomainCursor,
queryOrderBy: typeof DomainsOrderBy.$inferType,
queryOrderDir: typeof OrderDirection.$inferType,
Expand Down Expand Up @@ -297,7 +102,7 @@ export function isEffectiveDesc(
}

export function orderFindDomains(
domains: ReturnType<typeof findDomains>,
domains: DomainsCTE,
orderBy: typeof DomainsOrderBy.$inferType,
orderDir: typeof OrderDirection.$inferType,
inverted: boolean,
Expand Down
Loading