diff --git a/.changeset/domains-name-filter-oneof.md b/.changeset/domains-name-filter-oneof.md new file mode 100644 index 0000000000..cbdea2dfa4 --- /dev/null +++ b/.changeset/domains-name-filter-oneof.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +**Omnigraph (breaking)**: `where: { name }` on `Query.domains`, `Account.domains`, `Registry.domains`, and `Domain.subdomains` now takes a `DomainsNameFilter` `@oneOf` input with three modes: `starts_with` (prefix autocomplete, the previous behavior), `eq` (exact InterpretedName match — sugar for `in: [eq]`), and `in` (exact match against a set of up to 100 InterpretedNames). The old shape `where: { name: "examp" }` becomes `where: { name: { starts_with: "examp" } }`; for exact lookups use `where: { name: { eq: "vitalik.eth" } }` or `where: { name: { in: ["alice.eth", "bob.eth"] } }`. Combine with `version` to disambiguate across ENS protocol versions (e.g. `{ name: { eq: "eth" }, version: ENSv1 }` returns a single Domain). diff --git a/.changeset/domains-orderby-depth.md b/.changeset/domains-orderby-depth.md new file mode 100644 index 0000000000..38bd15766e --- /dev/null +++ b/.changeset/domains-orderby-depth.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +**Omnigraph**: add `DEPTH` to the `DomainsOrderBy` enum, ordering by the materialized `Domain.canonicalDepth` (number of labels in the Canonical Name). Applies to `Query.domains`, `Account.domains`, `Registry.domains`, and `Domain.subdomains` via `order: { by: DEPTH }`. Also wired in as the default ordering for `where: { name: { starts_with } }` (typeahead). diff --git a/.changeset/materialize-canonical-path.md b/.changeset/materialize-canonical-path.md new file mode 100644 index 0000000000..27d43fdef9 --- /dev/null +++ b/.changeset/materialize-canonical-path.md @@ -0,0 +1,6 @@ +--- +"ensindexer": minor +"@ensnode/ensdb-sdk": minor +--- + +**Materialize `Domain.canonicalPath` and `canonicalDepth`** on every Canonical Domain, alongside the existing `canonicalName` / `canonicalLabelHashPath` / `canonicalNode`. `canonicalPath` is the head-first array of ancestor DomainIds (parallel to `canonicalLabelHashPath`); `canonicalDepth` is the label count. Adds a `byCanonicalDepth` btree index for `ORDER BY canonical_depth` (typeahead, depth-ordered browse). diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index 9364271cfa..310595c81e 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -46,6 +46,7 @@ "@pothos/plugin-dataloader": "^4.4.3", "@pothos/plugin-relay": "^4.6.2", "@pothos/plugin-tracing": "^1.1.2", + "@pothos/plugin-zod": "^4.3.0", "@pothos/tracing-opentelemetry": "^1.1.3", "@standard-schema/utils": "^0.3.0", "dataloader": "^2.2.3", diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index eafbae8696..8f50658849 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -3,6 +3,7 @@ import SchemaBuilder, { type MaybePromise } from "@pothos/core"; import DataloaderPlugin from "@pothos/plugin-dataloader"; import RelayPlugin from "@pothos/plugin-relay"; import TracingPlugin, { isRootField } from "@pothos/plugin-tracing"; +import ZodPlugin from "@pothos/plugin-zod"; import { AttributeNames, createOpenTelemetryWrapper } from "@pothos/tracing-opentelemetry"; import type { ChainId, @@ -88,7 +89,7 @@ export const builder = new SchemaBuilder<{ DefaultEdgesNullability: false; DefaultNodeNullability: false; }>({ - plugins: [TracingPlugin, DataloaderPlugin, RelayPlugin], + plugins: [TracingPlugin, DataloaderPlugin, RelayPlugin, ZodPlugin], tracing: { default: (config) => { // NOTE: if you need all the tracing possible in order to debug something, you can return true diff --git a/apps/ensapi/src/omnigraph-api/context.ts b/apps/ensapi/src/omnigraph-api/context.ts index 2c851477a8..f6ac40df64 100644 --- a/apps/ensapi/src/omnigraph-api/context.ts +++ b/apps/ensapi/src/omnigraph-api/context.ts @@ -1,23 +1,10 @@ import DataLoader from "dataloader"; import { getUnixTime } from "date-fns"; import { inArray } from "drizzle-orm"; -import type { CanonicalPath, DomainId, RegistryId } from "enssdk"; +import type { DomainId, RegistryId } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; -import { getCanonicalPath } from "./lib/get-canonical-path"; - -/** - * A Promise.catch handler that provides the thrown error as a resolved value, useful for Dataloaders. - */ -const errorAsValue = (error: unknown) => - error instanceof Error ? error : new Error(String(error)); - -const createCanonicalPathLoader = () => - new DataLoader(async (domainIds) => - Promise.all(domainIds.map((id) => getCanonicalPath(id).catch(errorAsValue))), - ); - const createRegistryParentDomainLoader = () => new DataLoader(async (registryIds) => { const rows = await ensDb @@ -39,7 +26,6 @@ const createRegistryParentDomainLoader = () => export const context = () => ({ now: BigInt(getUnixTime(new Date())), loaders: { - canonicalPath: createCanonicalPathLoader(), registryParentDomain: createRegistryParentDomainLoader(), }, }); diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/domain-cursor.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/domain-cursor.ts index fb9a34be59..e8d14b52dc 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/domain-cursor.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/domain-cursor.ts @@ -19,7 +19,8 @@ export interface DomainCursor { id: DomainId; /** - * The criteria by which the set is ordered. One of NAME, REGISTRATION_TIMESTAMP, or REGISTRATION_EXPIRY. + * The criteria by which the set is ordered. One of NAME, DEPTH, REGISTRATION_TIMESTAMP, or + * REGISTRATION_EXPIRY. */ by: typeof DomainsOrderBy.$inferType; diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.ts index 410a9ff7d9..2da03fce07 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.ts @@ -13,7 +13,8 @@ function getOrderColumn( orderBy: typeof DomainsOrderBy.$inferType, ) { return { - NAME: domains.sortableLabel, + NAME: domains.canonicalName, + DEPTH: domains.canonicalDepth, REGISTRATION_TIMESTAMP: domains.registrationTimestamp, REGISTRATION_EXPIRY: domains.registrationExpiry, }[orderBy]; @@ -83,8 +84,17 @@ 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}::numeric(78,0)`; + const value = (() => { + switch (cursor.by) { + case "NAME": + return sql`${cursor.value}::text`; + case "DEPTH": + return sql`${cursor.value}::int`; + case "REGISTRATION_TIMESTAMP": + case "REGISTRATION_EXPIRY": + return sql`${cursor.value}::numeric(78,0)`; + } + })(); return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${value}, ${cursor.id})`; } diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts index 4f411e4c75..7ea15ef8f0 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts @@ -21,23 +21,13 @@ import { DOMAINS_DEFAULT_ORDER_BY, DOMAINS_DEFAULT_ORDER_DIR, type DomainsOrderBy, + type DomainsOrderInput, } from "@/omnigraph-api/schema/domain-inputs"; -import type { OrderDirection } from "@/omnigraph-api/schema/order-direction"; import { DomainCursors } from "./domain-cursor"; import { cursorFilter, orderFindDomains } from "./find-domains-resolver-helpers"; import type { DomainOrderValue } from "./types"; -/** - * Describes the ordering of the set of Domains. - * - * @dev derived from the GraphQL Input Types for 1:1 convenience - */ -interface FindDomainsOrderArg { - by?: typeof DomainsOrderBy.$inferType | null; - dir?: typeof OrderDirection.$inferType | null; -} - /** * Domain with order value injected. * @@ -57,7 +47,9 @@ function getOrderValueFromResult( ): DomainOrderValue { switch (orderBy) { case "NAME": - return result.sortableLabel; + return result.canonicalName; + case "DEPTH": + return result.canonicalDepth; case "REGISTRATION_TIMESTAMP": return result.registrationTimestamp; case "REGISTRATION_EXPIRY": @@ -80,12 +72,24 @@ export function resolveFindDomains( { domains, order, + defaultOrder, ...connectionArgs }: { - /** Pre-built domains CTE from `withOrderingMetadata` */ + /** + * Pre-built domains CTE from `withOrderingMetadata` + */ domains: DomainsWithOrderingMetadata; - /** Optional ordering; defaults to NAME ASC */ - order?: FindDomainsOrderArg | undefined | null; + + /** + * Optional ordering. Each unset field falls back to `defaultOrder` then the + * `DOMAINS_DEFAULT_ORDER_*` constants. + */ + order?: Partial | null; + + /** + * Filter-supplied default `(by, dir)` when the caller doesn't pass `order`. + */ + defaultOrder?: Partial; // relay connection args from t.connection first?: number | null; @@ -94,8 +98,8 @@ export function resolveFindDomains( after?: string | null; }, ) { - const orderBy = order?.by ?? DOMAINS_DEFAULT_ORDER_BY; - const orderDir = order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; + const orderBy = order?.by ?? defaultOrder?.by ?? DOMAINS_DEFAULT_ORDER_BY; + const orderDir = order?.dir ?? defaultOrder?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; return lazyConnection({ totalCount: () => diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts index 3d53aee3b4..76725a1035 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts @@ -1,6 +1,6 @@ import { and, eq, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; -import type { DomainId, NormalizedAddress, RegistryId } from "enssdk"; +import type { DomainId, InterpretedName, NormalizedAddress, RegistryId } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; @@ -14,13 +14,13 @@ export type DomainType = (typeof ensIndexerSchema.domainType.enumValues)[number] /** * Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata. * - * Returns `{ domainId, type, ownerId, registryId, parentId, canonical, labelHash, sortableLabel }`. - * - parentId is the canonical parent Domain, derived inline by joining to the parent Registry of - * this Domain (`registry.id = domain.registryId`) and then to the parent Domain named by - * `registry.canonicalDomainId`, requiring that parent Domain's `subregistryId` agree back to - * the same Registry. This is the bidirectional canonical-edge agreement that enforces a tree. - * - sortableLabel is the Domain's own InterpretedLabel, used for NAME ordering - * - all other values are directly sourced from Domain + * `parentId` is the canonical parent Domain, derived inline by joining to the parent Registry of + * this Domain (`registry.id = domain.registryId`) and then to the parent Domain named by + * `registry.canonicalDomainId`, requiring that parent Domain's `subregistryId` agree back to + * the same Registry. This is the bidirectional canonical-edge agreement that enforces a tree. + * + * `canonicalName` and `canonicalDepth` are sourced directly from Domain's materialized columns + * and drive NAME / DEPTH ordering downstream. All other values are directly sourced from Domain. * * All downstream filters (owner, parent, registry, name, canonical) operate on this shape. */ @@ -39,8 +39,11 @@ export function domainsBase() { parentId: sql`${parentDomain.id}`.as("parentId"), canonical: sql`${ensIndexerSchema.domain.canonical}`.as("canonical"), labelHash: sql`${ensIndexerSchema.domain.labelHash}`.as("labelHash"), - sortableLabel: sql`${ensIndexerSchema.label.interpreted}`.as( - "sortableLabel", + canonicalName: sql`${ensIndexerSchema.domain.canonicalName}`.as( + "canonicalName", + ), + canonicalDepth: sql`${ensIndexerSchema.domain.canonicalDepth}`.as( + "canonicalDepth", ), }) .from(ensIndexerSchema.domain) @@ -55,11 +58,6 @@ export function domainsBase() { eq(parentDomain.subregistryId, parentRegistry.id), ), ) - // join label for labelHash/sortableLabel - .leftJoin( - ensIndexerSchema.label, - eq(ensIndexerSchema.label.labelHash, ensIndexerSchema.domain.labelHash), - ) .as("baseDomains") ); } @@ -77,6 +75,7 @@ export function selectBase(base: BaseDomainSet) { parentId: base.parentId, canonical: base.canonical, labelHash: base.labelHash, - sortableLabel: base.sortableLabel, + canonicalName: base.canonicalName, + canonicalDepth: base.canonicalDepth, }; } diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-in.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-in.ts new file mode 100644 index 0000000000..cb2440dab3 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-in.ts @@ -0,0 +1,30 @@ +import { inArray, sql } from "drizzle-orm"; +import type { InterpretedName } from "enssdk"; + +import { ensDb } from "@/lib/ensdb/singleton"; + +import { type BaseDomainSet, selectBase } from "./base-domain-set"; + +/** + * Filter a base domain set to Domains whose materialized `canonicalName` exactly matches one of + * `names`. Validation (max-length, etc.) is enforced at the GraphQL input layer. + * + * Non-canonical rows have `canonicalName = NULL`, so they cannot match by construction — no + * separate root-anchoring guard is required. + * + * @param base - A base domain set subquery + * @param names - Exact InterpretedNames to match against + */ +export function filterByNameIn(base: BaseDomainSet, names: InterpretedName[]) { + // Drizzle footgun: `inArray(col, [])` generates `col in ()`, a Postgres syntax error. + // Short-circuit to an explicit empty result. + if (names.length === 0) { + return ensDb.select(selectBase(base)).from(base).where(sql`false`).as("baseDomains"); + } + + return ensDb + .select(selectBase(base)) + .from(base) + .where(inArray(base.canonicalName, names)) + .as("baseDomains"); +} diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-starts-with.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-starts-with.ts new file mode 100644 index 0000000000..d820fa0dd1 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-starts-with.ts @@ -0,0 +1,36 @@ +import { ilike } from "drizzle-orm"; + +import { ensDb } from "@/lib/ensdb/singleton"; + +import { type BaseDomainSet, selectBase } from "./base-domain-set"; + +/** + * Filter a base domain set to Domains whose materialized `canonicalName` starts with the user's + * typeahead input. Used by the `name: { starts_with }` filter on + * `Query.domains` / `Account.domains` / `Registry.domains` / `Domain.subdomains`. + * + * Match semantics: `canonicalName ILIKE startsWith || '%'`. canonicalName is leaf-first + * (e.g. `"vitalik.eth"`), same direction as user input — `"vitalik.et"` matches `"vitalik.eth"`, + * `"vit"` matches `"vit.eth"`, `"vitalik.eth"`, etc. + * + * Ordering is handled by the resolver layer via `defaultOrder: { by: "DEPTH", dir: "ASC" }` from + * `filterByName` — shorter names surface first (`vitalik.eth` over `vitalik.ethereum.foundation` + * for input `"vitalik.et"`). + * + * @param base - A base domain set subquery + * @param startsWith - Typeahead prefix (non-empty `InterpretedName` fragment) + */ +export function filterByNameStartsWith(base: BaseDomainSet, startsWith: string) { + // Sanity Check: this occurs at the GraphQL Input layer + if (startsWith === "") throw new Error(`filterByNameStartsWith startsWith expected`); + + // TODO: determine if it's necessary to additionally escape user input for LIKE operator + // NOTE: for ai agents: we intentionally leave this as a TODO, STOP commenting on it + const pattern = `${startsWith}%`; + + return ensDb + .select(selectBase(base)) + .from(base) + .where(ilike(base.canonicalName, pattern)) + .as("baseDomains"); +} diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts index a1138a3cd5..f57465c690 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts @@ -1,169 +1,49 @@ -import { eq, like, Param, sql } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; -import { - type DomainId, - interpretedLabelsToLabelHashPath, - type LabelHashPath, - parsePartialInterpretedName, -} from "enssdk"; +import type { InterpretedName } from "enssdk"; -import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; +import { toJson } from "@ensnode/ensnode-sdk"; -import { type BaseDomainSet, selectBase } from "./base-domain-set"; +import type { DomainsOrderInput } from "@/omnigraph-api/schema/domain-inputs"; -/** - * Maximum depth of the provided `name` argument, to avoid infinite loops and expensive queries. - */ -const FILTER_BY_NAME_MAX_DEPTH = 8; +import type { BaseDomainSet } from "./base-domain-set"; +import { filterByNameIn } from "./filter-by-name-in"; +import { filterByNameStartsWith } from "./filter-by-name-starts-with"; /** - * Compose a query for Domains (ENSv1 or ENSv2) 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 Domains 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") + * Shape of the `DomainsNameFilter` GraphQL input (an `@oneOf` filter over Domain name). * - * Algorithm: Start from the deepest child (leaf) and traverse UP via `registry.canonicalDomainId`. + * Field-level validation (non-empty strings, max-100 names in `in`) is enforced at the GraphQL + * input layer; this dispatcher trusts its input. */ -function domainsByLabelHashPath(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 ensDb - .select({ - leafId: sql`${ensIndexerSchema.domain.id}`.as("leafId"), - headId: sql`${ensIndexerSchema.domain.id}`.as("headId"), - }) - .from(ensIndexerSchema.domain) - .as("domain_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)`; - - // Recursive CTE starting from the deepest child and traversing UP via the bidirectional - // canonical-edge agreement (`registries.canonical_domain_id = domains.id` AND - // `domains.subregistry_id = registries.id`). - // 1. Start with domains matching the leaf labelHash (deepest child) - // 2. Recursively join parents via the agreement check, verifying each ancestor's labelHash - // 3. Return both the leaf (for result/ownership) and head (for partial match) - // - // NOTE: JOIN (not LEFT JOIN) is intentional — we only match domains with a complete - // canonical path to the searched `labelHashPath`. - return ensDb - .select({ - // https://github.com/drizzle-team/drizzle-orm/issues/1242 - leafId: sql`domain_path_check.leaf_id`.as("leafId"), - headId: sql`domain_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 walk one step - -- up via the agreement check (parent_registry.canonical_domain_id = parent.id AND - -- parent.subregistry_id = parent_registry.id). - SELECT - d.id AS leaf_id, - parent.id AS current_id, - 1 AS depth - FROM ${ensIndexerSchema.domain} d - JOIN ${ensIndexerSchema.registry} parent_registry - ON parent_registry.id = d.registry_id - JOIN ${ensIndexerSchema.domain} parent - ON parent.id = parent_registry.canonical_domain_id - AND parent.subregistry_id = parent_registry.id - WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] - - UNION ALL - - -- Recursive step: traverse UP via the agreement check, verifying each ancestor's - -- labelHash. - SELECT - upward_check.leaf_id, - np.id AS current_id, - upward_check.depth + 1 - FROM upward_check - JOIN ${ensIndexerSchema.domain} pd - ON pd.id = upward_check.current_id - JOIN ${ensIndexerSchema.registry} pdr - ON pdr.id = pd.registry_id - JOIN ${ensIndexerSchema.domain} np - ON np.id = pdr.canonical_domain_id - AND np.subregistry_id = pdr.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 domain_path_check`, - ) - .as("domain_path"); +export interface DomainsNameFilterValue { + starts_with?: string | null; + eq?: InterpretedName | null; + in?: InterpretedName[] | null; } /** - * Filter a base domain set by name. Parses the name into a concrete labelHash path and a partial - * label prefix. Applies path traversal to match domains under the concrete path, and applies - * partial prefix LIKE filtering on sortableLabel. - * - * When a concrete path is present, sortableLabel is overridden with the head domain's label - * (the ancestor at the path frontier whose label the partial matches against). + * Apply a `DomainsNameFilter` to a base domain set. Dispatches to the appropriate filter layer + * based on which `@oneOf` field is set. Returns `{ named: base }` unchanged when `filter` is + * nullish. * - * @param base - A base domain set subquery - * @param name - Optional partial InterpretedName (e.g. 'examp', 'example.', 'sub.example.eth') + * - `starts_with` → `filterByNameStartsWith` (typeahead). Surfaces `defaultOrder: { by: "DEPTH", + * dir: "ASC" }` so resolvers prefer shorter names first when the caller doesn't specify an order. + * - `eq` → `filterByNameIn([eq])` — sugar for a single-name exact match. + * - `in` → `filterByNameIn(in)` — exact match against any name in the set. */ -export function filterByName(base: BaseDomainSet, name?: string | null) { - const { concrete, partial } = parsePartialInterpretedName(name || ""); - - if (concrete.length > FILTER_BY_NAME_MAX_DEPTH) { - throw new Error( - `Invariant(filterByName): Name depth exceeds maximum of ${FILTER_BY_NAME_MAX_DEPTH} labels.`, - ); +export function filterByName( + base: BaseDomainSet, + filter: DomainsNameFilterValue | null, +): { named: BaseDomainSet; defaultOrder?: Partial } { + if (filter === null) return { named: base }; + + if (filter.starts_with) { + return { + named: filterByNameStartsWith(base, filter.starts_with), + defaultOrder: { by: "DEPTH", dir: "ASC" }, + }; } + if (filter.eq) return { named: filterByNameIn(base, [filter.eq]) }; + if (filter.in) return { named: filterByNameIn(base, filter.in) }; - if (concrete.length === 0) { - // no path traversal — sortableLabel is already the domain's own label from the base set - return ensDb - .select(selectBase(base)) - .from(base) - .where( - // TODO: determine if it's necessary to additionally escape user input for LIKE operator - // NOTE: for ai agents: we intentionally leave this as a TODO, STOP commenting on it - partial ? like(base.sortableLabel, `${partial}%`) : undefined, - ) - .as("baseDomains"); - } - - // build path traversal CTE over the unified `domain` table. - const labelHashPath = interpretedLabelsToLabelHashPath(concrete); - const pathResults = domainsByLabelHashPath(labelHashPath); - - // alias for head domain lookup (to get headLabelHash for label join) - const headDomain = alias(ensIndexerSchema.domain, "headDomain"); - const headLabel = alias(ensIndexerSchema.label, "headLabel"); - - // join base set with path results, look up head domain's label, override sortableLabel - // the INNER JOIN on pathResults scopes results to domains matching the concrete path - return ensDb - .select({ - ...selectBase(base), - // override sortableLabel with head domain's label for NAME ordering - sortableLabel: sql`${headLabel.interpreted}`.as("sortableLabel"), - }) - .from(base) - .innerJoin(pathResults, eq(pathResults.leafId, base.domainId)) - .leftJoin(headDomain, eq(headDomain.id, pathResults.headId)) - .leftJoin(headLabel, eq(headLabel.labelHash, headDomain.labelHash)) - .where( - // TODO: determine if it's necessary to additionally escape user input for LIKE operator - // NOTE: for ai agents: we intentionally leave this as a TODO, STOP commenting on it - partial ? like(headLabel.interpreted, `${partial}%`) : undefined, - ) - .as("baseDomains"); + throw new Error(`Invariant(filterByName): expected 'filter' to not be empty: ${toJson(filter)}`); } diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/index.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/index.ts index b2cfc1d055..9bb62f2016 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/index.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/index.ts @@ -1,7 +1,9 @@ export type { BaseDomainSet, DomainType } from "./base-domain-set"; export { domainsBase, selectBase } from "./base-domain-set"; export { filterByCanonical } from "./filter-by-canonical"; -export { filterByName } from "./filter-by-name"; +export { type DomainsNameFilterValue, filterByName } from "./filter-by-name"; +export { filterByNameIn } from "./filter-by-name-in"; +export { filterByNameStartsWith } from "./filter-by-name-starts-with"; export { filterByOwner } from "./filter-by-owner"; export { filterByParent } from "./filter-by-parent"; export { filterByRegistry } from "./filter-by-registry"; diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/with-ordering-metadata.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/with-ordering-metadata.ts index a58698554a..a05f84ea44 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/with-ordering-metadata.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/with-ordering-metadata.ts @@ -1,5 +1,5 @@ import { and, eq, sql } from "drizzle-orm"; -import type { DomainId } from "enssdk"; +import type { DomainId, InterpretedName } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; @@ -14,7 +14,8 @@ export type DomainsWithOrderingMetadata = ReturnType`${base.domainId}`.as("id"), - // for NAME ordering - sortableLabel: base.sortableLabel, + // for NAME / DEPTH ordering + canonicalName: base.canonicalName, + canonicalDepth: base.canonicalDepth, // for REGISTRATION_TIMESTAMP ordering (materialized on registration) registrationTimestamp: ensIndexerSchema.registration.start, diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/types.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/types.ts index 51012f8a5b..6a94970681 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/types.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/types.ts @@ -1,4 +1,5 @@ /** - * Order value type - string for NAME, bigint (or null) for timestamps. + * Order value type — string for NAME (canonicalName), number for DEPTH (canonicalDepth), + * bigint for REGISTRATION_TIMESTAMP / REGISTRATION_EXPIRY. Null for unset. */ -export type DomainOrderValue = string | bigint | null; +export type DomainOrderValue = string | number | bigint | null; diff --git a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts deleted file mode 100644 index 04e99febae..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { sql } from "drizzle-orm"; -import type { CanonicalPath, DomainId, RegistryId } from "enssdk"; - -import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; -import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; - -/** - * Provide the canonical parents for a Domain via reverse traversal of the namegraph. - * - * Walks `domain → registry → registry.canonicalDomainId` upward until the registry has no canonical - * parent (root). Returns `null` when the input Domain is not itself canonical. - */ -export async function getCanonicalPath(domainId: DomainId): Promise { - // Short-circuit for non-canonical Domains - const domain = await ensDb.query.domain.findFirst({ - where: (t, { eq }) => eq(t.id, domainId), - columns: { canonical: true }, - }); - if (!domain) throw new Error(`Invariant(getCanonicalPath): DomainId '${domainId}' expected.`); - - // if the Domain is not Canonical, there's no path, so we can short-circuit with null - if (!domain.canonical) return null; - - const result = await ensDb.execute(sql` - WITH RECURSIVE upward AS ( - -- Base case: start from the target domain - SELECT - d.id AS domain_id, - d.registry_id, - 1 AS depth - FROM ${ensIndexerSchema.domain} d - WHERE d.id = ${domainId} - - UNION ALL - - -- Step upward: domain → current registry's canonical parent domain via the bidirectional - -- canonical-edge agreement (registries.canonical_domain_id = domains.id AND - -- domains.subregistry_id = registries.id). - -- We allow recursion to one row beyond MAX_DEPTH so we can detect (and throw on) a - -- legitimate path that exceeds the cap, rather than silently truncating it. - SELECT - pd.id AS domain_id, - pd.registry_id, - upward.depth + 1 - FROM upward - JOIN ${ensIndexerSchema.registry} ur - ON ur.id = upward.registry_id - JOIN ${ensIndexerSchema.domain} pd - ON pd.id = ur.canonical_domain_id - AND pd.subregistry_id = ur.id - WHERE upward.depth <= ${MAX_SUPPORTED_NAME_DEPTH} - ) - SELECT * - FROM upward - ORDER BY depth; - `); - - const rows = result.rows as { domain_id: DomainId; registry_id: RegistryId }[]; - - // not necessary due to above Domain.canonical check but safety first - if (rows.length === 0) { - throw new Error( - `Invariant(getCanonicalPath): DomainId '${domainId}' is canonical but produced no upward path.`, - ); - } - - // depth check - if (rows.length > MAX_SUPPORTED_NAME_DEPTH) { - throw new Error( - `Invariant(getCanonicalPath): DomainId '${domainId}' produced a canonical path deeper than ${MAX_SUPPORTED_NAME_DEPTH}.`, - ); - } - - return rows.map((row) => row.domain_id); -} diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index d641b5e729..9016c2c40f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -77,11 +77,11 @@ AccountRef.implement({ resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = domainsBase(); const owned = filterByOwner(base, parent.id); - const named = filterByName(owned, where?.name); + const { named, defaultOrder } = filterByName(owned, where?.name ?? null); const canonical = where?.canonical === true ? filterByCanonical(named) : named; const versioned = where?.version ? filterByVersion(canonical, where.version) : canonical; const domains = withOrderingMetadata(versioned); - return resolveFindDomains(context, { domains, order, ...connectionArgs }); + return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts index 67b5850321..9883328f91 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-canonical.ts @@ -7,7 +7,8 @@ import { type Domain, DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; export const DomainCanonicalRef = builder.objectRef("DomainCanonical"); DomainCanonicalRef.implement({ - description: "Canonicality metadata for a Domain, including its name, node (namehash), and path.", + description: + "Canonicality metadata for a Domain, including its name, depth, path, and node (namehash).", fields: (t) => ({ name: t.field({ description: "The Canonical Name for this Domain.", @@ -23,24 +24,32 @@ DomainCanonicalRef.implement({ return domain.canonicalName; }, }), + depth: t.field({ + description: + "The depth of this Domain, i.e. the number of labels in this Domain's Canonical Name (e.g. 2 for `vitalik.eth`).", + type: "Int", + nullable: false, + resolve: (domain) => { + if (domain.canonicalDepth == null) { + throw new Error( + `Invariant(DomainCanonical.depth): canonical Domain '${domain.id}' is missing canonicalDepth.`, + ); + } + return domain.canonicalDepth; + }, + }), path: t.field({ description: - "The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain.", + "The Canonical Path from this Domain to the ENS Root, root→leaf inclusive of this Domain.", type: [DomainInterfaceRef], nullable: false, - // TODO: derive `path` from the materialized `canonicalLabelHashPath` column instead of - // walking the canonicalPath dataloader. Each ancestor's DomainId can be reconstructed from - // the path prefix and the parent Registry chain, then batched through `DomainInterfaceRef`. - resolve: async (domain, args, context) => { - const canonicalPath = await context.loaders.canonicalPath.load(domain.id); - if (canonicalPath instanceof Error) throw canonicalPath; - if (canonicalPath === null) { + resolve: (domain) => { + if (!domain.canonicalPath) { throw new Error( - `Invariant(DomainCanonical.path): canonical Domain '${domain.id}' produced null canonical path.`, + `Invariant(DomainCanonical.path): canonical Domain '${domain.id}' is missing canonicalPath.`, ); } - - return canonicalPath; + return domain.canonicalPath; }, }), node: t.field({ diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts index dd92d31a9d..e8fee80d71 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts @@ -22,13 +22,50 @@ export const DomainIdInput = builder.inputType("DomainIdInput", { }), }); +/** + * Max number of names accepted by `DomainsNameFilter.in`. + */ +export const DOMAINS_NAME_FILTER_IN_MAX = 100; + +/** + * @oneOf filter for Domain names. Exactly one of `starts_with`, `eq`, or `in` must be provided. + * + * - `starts_with`: prefix-match on Interpreted Name for typeahead. Case-insensitive. + * - `eq`: exact InterpretedName match. Sugar for `in: [eq]`. Combine with `version` to disambiguate + * across ENS protocol versions. + * - `in`: exact InterpretedName match against any name in the set. Max 100 items. + */ +export const DomainsNameFilter = builder.inputType("DomainsNameFilter", { + description: + "Filter Domains by name. Exactly one of `starts_with`, `eq`, or `in` must be provided.", + isOneOf: true, + fields: (t) => ({ + starts_with: t.string({ + description: + "Prefix-match on Interpreted Name for typeahead. ex: 'vit', 'vitalik.et'. Case-insensitive (InterpretedName labels are normalized).", + validate: { minLength: 1 }, + }), + eq: t.field({ + type: "InterpretedName", + description: + "Exact InterpretedName match. Sugar for `in: [eq]`. Combine with `version` to disambiguate across ENS protocol versions.", + validate: { minLength: 1 }, + }), + in: t.field({ + type: ["InterpretedName"], + description: `Exact InterpretedName match against any name in the set. Max ${DOMAINS_NAME_FILTER_IN_MAX} items.`, + validate: { items: { minLength: 1 }, maxLength: DOMAINS_NAME_FILTER_IN_MAX }, + }), + }), +}); + export const DomainsWhereInput = builder.inputType("DomainsWhereInput", { description: "Filter for the top-level domains query.", fields: (t) => ({ - name: t.string({ + name: t.field({ + type: DomainsNameFilter, required: true, - description: - "A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'.", + description: "Filter the set of Domains by name.", }), version: t.field({ type: ENSProtocolVersion, @@ -41,9 +78,9 @@ export const DomainsWhereInput = builder.inputType("DomainsWhereInput", { export const AccountDomainsWhereInput = builder.inputType("AccountDomainsWhereInput", { description: "Filter for Account.domains query.", fields: (t) => ({ - name: t.string({ - description: - "A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'.", + name: t.field({ + type: DomainsNameFilter, + description: "If set, filters the set of Domains by name.", }), canonical: t.boolean({ description: @@ -61,8 +98,9 @@ export const AccountDomainsWhereInput = builder.inputType("AccountDomainsWhereIn export const RegistryDomainsWhereInput = builder.inputType("RegistryDomainsWhereInput", { description: "Filter for Registry.domains query.", fields: (t) => ({ - name: t.string({ - description: "A partial Interpreted Name by which to filter Domains in this Registry.", + name: t.field({ + type: DomainsNameFilter, + description: "If set, filters the set of Domains in this Registry by name.", }), }), }); @@ -70,8 +108,9 @@ export const RegistryDomainsWhereInput = builder.inputType("RegistryDomainsWhere export const SubdomainsWhereInput = builder.inputType("SubdomainsWhereInput", { description: "Filter for Domain.subdomains query.", fields: (t) => ({ - name: t.string({ - description: "A partial Interpreted Name by which to filter subdomains.", + name: t.field({ + type: DomainsNameFilter, + description: "If set, filters the set of subdomains by name.", }), }), }); @@ -82,7 +121,7 @@ export const SubdomainsWhereInput = builder.inputType("SubdomainsWhereInput", { export const DomainsOrderBy = builder.enumType("DomainsOrderBy", { description: "Fields by which domains can be ordered", - values: ["NAME", "REGISTRATION_TIMESTAMP", "REGISTRATION_EXPIRY"] as const, + values: ["NAME", "DEPTH", "REGISTRATION_TIMESTAMP", "REGISTRATION_EXPIRY"] as const, }); export type DomainsOrderByValue = typeof DomainsOrderBy.$inferType; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index 91f335b72f..89c548c62a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -73,6 +73,7 @@ describe("Domain.canonical", () => { id: DomainId; canonical: { name: InterpretedName; + depth: number; node: string; path: { id: DomainId }[]; } | null; @@ -81,24 +82,25 @@ describe("Domain.canonical", () => { const DomainCanonicalByName = gql` query DomainCanonicalByName($name: InterpretedName!) { - domain(by: { name: $name }) { id canonical { name node path { id } } } + domain(by: { name: $name }) { id canonical { name depth node path { id } } } } `; const DomainCanonicalById = gql` query DomainCanonicalById($id: DomainId!) { - domain(by: { id: $id }) { id canonical { name node path { id } } } + domain(by: { id: $id }) { id canonical { name depth node path { id } } } } `; it.each(DEVNET_NAMES)( - "materializes canonical.{name, path, node} for '$name'", + "materializes canonical.{name, depth, path, node} for '$name'", async ({ name, canonical }) => { const result = await request(DomainCanonicalByName, { name }); + const labelCount = canonical.split(".").length; expect(result).toMatchObject({ - domain: { canonical: { name: canonical } }, + domain: { canonical: { name: canonical, depth: labelCount } }, }); - expect(result.domain!.canonical!.path.length).toBe(canonical.split(".").length); + expect(result.domain!.canonical!.path.length).toBe(labelCount); }, ); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index a4bab1a337..52f2c73387 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -232,10 +232,10 @@ DomainInterfaceRef.implement({ }, resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = filterByParent(domainsBase(), parent.id); - const named = filterByName(base, where?.name); + const { named, defaultOrder } = filterByName(base, where?.name ?? null); const domains = withOrderingMetadata(named); - return resolveFindDomains(context, { domains, order, ...connectionArgs }); + return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts index bea4d70187..f361e554de 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -91,7 +91,11 @@ describe("Query.domains", () => { }; const QueryDomains = gql` - query QueryDomains($name: String!, $version: ENSProtocolVersion, $order: DomainsOrderInput) { + query QueryDomains( + $name: DomainsNameFilter! + $version: ENSProtocolVersion + $order: DomainsOrderInput + ) { domains(where: { name: $name, version: $version }, order: $order) { edges { node { @@ -122,7 +126,9 @@ describe("Query.domains", () => { }); it("sees .eth domain", async () => { - const result = await request(QueryDomains, { name: "eth" }); + const result = await request(QueryDomains, { + name: { eq: "eth" }, + }); const domains = flattenConnection(result.domains); @@ -148,7 +154,9 @@ describe("Query.domains", () => { }); it("returns only canonical domains", async () => { - const result = await request(QueryDomains, { name: "parent" }); + const result = await request(QueryDomains, { + name: { starts_with: "parent" }, + }); const domains = flattenConnection(result.domains); // parent.eth is canonical (registered under the v2 ETH Registry which descends from the v2 Root) @@ -167,7 +175,7 @@ describe("Query.domains", () => { describe("version?: ENSProtocolVersion", () => { it("returns any version when unspecified", async () => { const result = await request(QueryDomains, { - name: "reverse", + name: { eq: "reverse" }, version: undefined, }); const domains = flattenConnection(result.domains); @@ -177,7 +185,7 @@ describe("Query.domains", () => { it("returns only ENSv1Domains when version: ENSv1", async () => { const result = await request(QueryDomains, { - name: "reverse", + name: { eq: "reverse" }, version: "ENSv1", }); const domains = flattenConnection(result.domains); @@ -187,7 +195,7 @@ describe("Query.domains", () => { it("returns only ENSv2Domains when version: ENSv2", async () => { const result = await request(QueryDomains, { - name: "reverse", + name: { eq: "reverse" }, version: "ENSv2", }); const domains = flattenConnection(result.domains); @@ -195,6 +203,67 @@ describe("Query.domains", () => { expect(domains.find((d) => d.__typename === "ENSv2Domain")).toBeDefined(); }); }); + + describe("name: { eq | in }", () => { + it("eq returns exact matches across versions", async () => { + const result = await request(QueryDomains, { + name: { eq: "eth" }, + }); + const domains = flattenConnection(result.domains); + + // v1 and v2 'eth' both exist; both should be returned (no version filter applied) + expect( + domains.find((d) => d.__typename === "ENSv1Domain" && d.id === V1_ETH_DOMAIN_ID), + ).toBeDefined(); + expect( + domains.find((d) => d.__typename === "ENSv2Domain" && d.id === V2_ETH_DOMAIN_ID), + ).toBeDefined(); + + // no prefix-matched names like "ethereum" should leak in + for (const d of domains) expect(d.canonical?.name).toBe("eth"); + }); + + it("eq + version: ENSv1 returns a single domain", async () => { + const result = await request(QueryDomains, { + name: { eq: "eth" }, + version: "ENSv1", + }); + const domains = flattenConnection(result.domains); + expect(domains).toHaveLength(1); + expect(domains[0]).toMatchObject({ + __typename: "ENSv1Domain", + id: V1_ETH_DOMAIN_ID, + canonical: { name: "eth" }, + }); + }); + + it("in returns the union of exact matches", async () => { + const result = await request(QueryDomains, { + name: { in: ["eth", "parent.eth"] }, + }); + const domains = flattenConnection(result.domains); + const names = new Set(domains.map((d) => d.canonical?.name)); + expect(names.has("eth")).toBe(true); + expect(names.has("parent.eth")).toBe(true); + for (const d of domains) expect(["eth", "parent.eth"]).toContain(d.canonical?.name); + }); + + it("in returns empty for an empty set", async () => { + const result = await request(QueryDomains, { + name: { in: [] }, + }); + const domains = flattenConnection(result.domains); + expect(domains).toHaveLength(0); + }); + + it("rejects when more than one oneOf field is provided", async () => { + await expect( + request(QueryDomains, { + name: { starts_with: "eth", eq: "eth" }, + }), + ).rejects.toThrow(); + }); + }); }); describe("Query.domain", () => { diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index d76082e179..21ad410a2f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -119,12 +119,12 @@ builder.queryType({ }, resolve: (_, { where, order, ...connectionArgs }, context) => { const base = domainsBase(); - const named = filterByName(base, where.name); + const { named, defaultOrder } = filterByName(base, where.name); const canonical = filterByCanonical(named); const versioned = where.version ? filterByVersion(canonical, where.version) : canonical; const domains = withOrderingMetadata(versioned); - return resolveFindDomains(context, { domains, order, ...connectionArgs }); + return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index 098ad99eac..d1e33abe6f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -132,9 +132,9 @@ RegistryInterfaceRef.implement({ }, resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = filterByRegistry(domainsBase(), parent.id); - const named = filterByName(base, where?.name); + const { named, defaultOrder } = filterByName(base, where?.name ?? null); const domains = withOrderingMetadata(named); - return resolveFindDomains(context, { domains, order, ...connectionArgs }); + return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); }, }), diff --git a/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts b/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts index 3b12e36908..9b81a98c63 100644 --- a/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts +++ b/apps/ensapi/src/test/integration/find-domains/domain-pagination-queries.ts @@ -1,4 +1,4 @@ -import type { DomainId, InterpretedLabel } from "enssdk"; +import type { DomainId, InterpretedName } from "enssdk"; import { gql } from "@/test/integration/omnigraph-api-client"; @@ -14,7 +14,7 @@ const PageInfoFragment = gql` const PaginatedDomainFragment = gql` fragment PaginatedDomainFragment on Domain { id - label { interpreted } + canonical { name depth } registration { expiry start @@ -24,7 +24,7 @@ const PaginatedDomainFragment = gql` export type PaginatedDomainResult = { id: DomainId; - label: { interpreted: InterpretedLabel }; + canonical: { name: InterpretedName; depth: number } | null; registration: { expiry: string | null; start: string; @@ -40,7 +40,7 @@ export const QueryDomainsPaginated = gql` $before: String ) { domains( - where: { name: "e" } + where: { name: { starts_with: "e" } } order: $order first: $first after: $after diff --git a/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts b/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts index fbcce9a407..755a59062e 100644 --- a/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts +++ b/apps/ensapi/src/test/integration/find-domains/test-domain-pagination.ts @@ -27,16 +27,23 @@ type FetchPage = ( const ORDER_PERMUTATIONS: Array<{ by: DomainsOrderByValue; dir: OrderDirectionValue }> = [ { by: "NAME", dir: "ASC" }, { by: "NAME", dir: "DESC" }, + { by: "DEPTH", dir: "ASC" }, + { by: "DEPTH", dir: "DESC" }, { by: "REGISTRATION_TIMESTAMP", dir: "ASC" }, { by: "REGISTRATION_TIMESTAMP", dir: "DESC" }, { by: "REGISTRATION_EXPIRY", dir: "ASC" }, { by: "REGISTRATION_EXPIRY", dir: "DESC" }, ]; -function getSortValue(domain: PaginatedDomainResult, by: DomainsOrderByValue): string | null { +function getSortValue( + domain: PaginatedDomainResult, + by: DomainsOrderByValue, +): string | number | null { switch (by) { case "NAME": - return domain.label.interpreted; + return domain.canonical?.name ?? null; + case "DEPTH": + return domain.canonical?.depth ?? null; case "REGISTRATION_TIMESTAMP": return domain.registration?.start ?? null; case "REGISTRATION_EXPIRY": @@ -70,15 +77,31 @@ function assertOrdering( } if (by === "NAME") { + const av = a as string; + const bv = b as string; + if (dir === "ASC") { + expect(av <= bv, `expected "${av}" <= "${bv}" at indices ${i},${i + 1} (NAME ASC)`).toBe( + true, + ); + } else { + expect(av >= bv, `expected "${av}" >= "${bv}" at indices ${i},${i + 1} (NAME DESC)`).toBe( + true, + ); + } + } else if (by === "DEPTH") { + const av = a as number; + const bv = b as number; if (dir === "ASC") { - expect(a <= b, `expected "${a}" <= "${b}" at indices ${i},${i + 1} (NAME ASC)`).toBe(true); + expect(av <= bv, `expected ${av} <= ${bv} at indices ${i},${i + 1} (DEPTH ASC)`).toBe(true); } else { - expect(a >= b, `expected "${a}" >= "${b}" at indices ${i},${i + 1} (NAME DESC)`).toBe(true); + expect(av >= bv, `expected ${av} >= ${bv} at indices ${i},${i + 1} (DEPTH DESC)`).toBe( + true, + ); } } else { // bigint string comparison - const av = BigInt(a); - const bv = BigInt(b); + const av = BigInt(a as string); + const bv = BigInt(b as string); if (dir === "ASC") { expect(av <= bv, `expected ${av} <= ${bv} at indices ${i},${i + 1} (${by} ASC)`).toBe(true); } else { diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 7cf2713965..3f1283f586 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -48,8 +48,8 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * `reconcileRegistryCanonicality` cascades through the canonical nametree beneath the Registry * in two situations: (a) the Registry's `canonical` flag flipped, or (b) the Registry's canonical * parent Domain identity changed while the flag stays canonical, which leaves descendants' - * materialized canonical-tree fields (`canonicalName`, `canonicalLabelHashPath`, `canonicalNode`) - * rooted at the previous parent's path and therefore stale. Situation (b) only arises when + * materialized canonical-tree fields (`canonicalName`, `canonicalLabelHashPath`, `canonicalPath`, + * `canonicalDepth`, `canonicalNode`) rooted at the previous parent's path and therefore stale. Situation (b) only arises when * `Registry.canonicalDomainId` itself was updated (handled via `handleRegistryCanonicalDomainUpdated`); * `handleSubregistryUpdated` cannot change which Domain is the canonical parent of a given Registry, * only whether the existing pointer agrees. Two cascade paths: @@ -135,6 +135,9 @@ export async function ensureDomainInRegistry( labelHash, ]; + // construct the Canonical Path of DomainIds (head-first, parallel to canonicalLabelHashPath) + const canonicalPath: DomainId[] = [...(parentDomain?.canonicalPath ?? []), domainId]; + // construct the Canonical Name const canonicalName = ( parentDomain?.canonicalName @@ -149,6 +152,8 @@ export async function ensureDomainInRegistry( canonical: true, canonicalName, canonicalLabelHashPath, + canonicalPath, + canonicalDepth: canonicalLabelHashPath.length, canonicalNode, }); } @@ -434,9 +439,12 @@ export async function cascadeLabelHeal( * The Registry UPDATE's `IS DISTINCT FROM` filter skips rows already at the target value (the * start registry's flag is set in the same statement, and any descendants that happen to already * be consistent are no-op'd). The Domain UPDATE's WHERE filter touches a row when either its - * flag flipped OR (when staying canonical) its `canonicalLabelHashPath` differs from the - * freshly-computed path — this second clause handles the parent-identity-changed case where the - * flag stays canonical but materialized paths are stale. + * flag flipped OR (when staying canonical) its `canonicalLabelHashPath` or `canonicalPath` + * differs from the freshly-computed path — this second clause handles the parent-identity-changed + * case where the flag stays canonical but materialized paths are stale. Both arrays are checked + * because two distinct canonical Domains can share a `canonicalLabelHashPath` across protocol + * roots (e.g. v1 `linea.eth` and v2 `linea.eth`), so re-parenting a Registry between such Domains + * leaves `canonicalLabelHashPath` equal while `canonicalPath` (DomainIds) drifts. * * Because a canonicalization update may affect an unbounded number of objects in the tree, we * batch the subsequent updates to at least buffer the severity of this operation. @@ -449,16 +457,17 @@ async function cascadeCanonicality( nextCanonical: boolean, ): Promise { const changed = await context.ensDb.sql.execute(sql` - WITH RECURSIVE walk(registry_id, parent_path, parent_name) AS ( - -- base: seed parent_path / parent_name from the start registry's canonical parent Domain - -- (if any). The start Registry may be a root, in which case no parent Domain exists and - -- seeds are () / NULL. + WITH RECURSIVE walk(registry_id, parent_path, parent_path_ids, parent_name) AS ( + -- base: seed parent_path / parent_path_ids / parent_name from the start registry's canonical + -- parent Domain (if any). The start Registry may be a root, in which case no parent Domain + -- exists and seeds are () / () / NULL. SELECT ${registryId}::text, COALESCE(seed.canonical_label_hash_path, ARRAY[]::text[]), + COALESCE(seed.canonical_path, ARRAY[]::text[]), seed.canonical_name FROM ( - SELECT pd.canonical_label_hash_path, pd.canonical_name + SELECT pd.canonical_label_hash_path, pd.canonical_path, pd.canonical_name FROM ${ensIndexerSchema.registry} r LEFT JOIN ${ensIndexerSchema.domain} pd ON pd.id = r.canonical_domain_id WHERE r.id = ${registryId} @@ -466,13 +475,14 @@ async function cascadeCanonicality( UNION - -- step downward via the canonical-edge agreement, extending parent_path / parent_name by - -- the linking Domain's labelHash / interpreted label. The path is head-first - -- (root → leaf), so we APPEND the labelHash; the name is the standard leaf-first ENS - -- string ("vitalik.eth"), so we PREPEND the interpreted label. + -- step downward via the canonical-edge agreement, extending parent_path / parent_path_ids / + -- parent_name by the linking Domain's labelHash / id / interpreted label. The path is + -- head-first (root → leaf), so we APPEND; the name is the standard leaf-first ENS string + -- ("vitalik.eth"), so we PREPEND. SELECT child_reg.id, w.parent_path || ARRAY[d.label_hash], + w.parent_path_ids || ARRAY[d.id], COALESCE(l.interpreted || '.' || w.parent_name, l.interpreted) FROM walk w JOIN ${ensIndexerSchema.domain} d @@ -486,7 +496,8 @@ async function cascadeCanonicality( domain_targets AS ( -- for each Registry in the walk, enumerate ALL of its child Domains (regardless of whether -- they themselves have a canonical-agreeing subregistry) and project the materialized - -- path / name. Head-first path → APPEND labelHash; leaf-first name → PREPEND interpreted label. + -- path / path_ids / name. Head-first → APPEND labelHash and id; leaf-first name → PREPEND + -- interpreted label. -- -- The agreement filter is intentionally omitted here. Membership in the canonical nametree -- is determined per-Domain via Domain.canonical, and every Domain that belongs to a canonical @@ -498,6 +509,7 @@ async function cascadeCanonicality( SELECT d.id AS domain_id, w.parent_path || ARRAY[d.label_hash] AS new_path, + w.parent_path_ids || ARRAY[d.id] AS new_path_ids, COALESCE(l.interpreted || '.' || w.parent_name, l.interpreted) AS new_name FROM walk w JOIN ${ensIndexerSchema.domain} d @@ -516,12 +528,20 @@ async function cascadeCanonicality( SET canonical = ${nextCanonical}, canonical_name = CASE WHEN ${nextCanonical} THEN dt.new_name ELSE NULL END, canonical_label_hash_path = CASE WHEN ${nextCanonical} THEN dt.new_path ELSE NULL END, + canonical_path = CASE WHEN ${nextCanonical} THEN dt.new_path_ids ELSE NULL END, + canonical_depth = CASE WHEN ${nextCanonical} THEN array_length(dt.new_path, 1) ELSE NULL END, canonical_node = NULL FROM domain_targets dt WHERE d.id = dt.domain_id AND ( d.canonical IS DISTINCT FROM ${nextCanonical} - OR (${nextCanonical} AND d.canonical_label_hash_path IS DISTINCT FROM dt.new_path) + OR ( + ${nextCanonical} + AND ( + d.canonical_label_hash_path IS DISTINCT FROM dt.new_path + OR d.canonical_path IS DISTINCT FROM dt.new_path_ids + ) + ) ) RETURNING d.id, d.canonical_label_hash_path; `); diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensdb.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensdb.mdx index 9777db278a..eaccedb5e1 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensdb.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensdb.mdx @@ -19,7 +19,11 @@ For special use cases that go beyond what the ENS Omnigraph exposes — you can -### Example: fetch ENSv2 domains with the ENSDb SDK +### Example: fetch a Domain by canonical name + +Canonical fields (`canonical_name`, `canonical_path`, `canonical_node`, `canonical_depth`) are +populated on every Domain reachable from the canonical root, across both ENSv1 and ENSv2 — query +them uniformly without branching by `type`. ```typescript import { EnsDbReader } from "@ensnode/ensdb-sdk"; @@ -28,18 +32,33 @@ import { eq } from "drizzle-orm"; const ensDbReader = new EnsDbReader(ensDbConnectionString, ensIndexerSchemaName); const { ensDb, ensIndexerSchema } = ensDbReader; -const v2Domains = await ensDb +const [vitalik] = await ensDb .select() .from(ensIndexerSchema.domain) - .where(eq(ensIndexerSchema.domain.type, "ENSv2Domain")); + .where(eq(ensIndexerSchema.domain.canonicalName, "vitalik.eth")); ``` -### Example: fetch ENSv2 domains with raw SQL - ```sql SELECT * FROM ensindexer_0.domains -WHERE type = 'ENSv2Domain' -LIMIT 10; +WHERE canonical_name = 'vitalik.eth'; +``` + +### Example: count an address's Domains by type (ENSv1 vs ENSv2) + +```typescript +import { count, eq } from "drizzle-orm"; + +const counts = await ensDb + .select({ type: ensIndexerSchema.domain.type, count: count() }) + .from(ensIndexerSchema.domain) + .where(eq(ensIndexerSchema.domain.ownerId, "0xd8da6bf26964af9d7eed9e03e53415d37aa96045")) + .groupBy(ensIndexerSchema.domain.type); +``` + +```sql +SELECT type, count(*) FROM ensindexer_0.domains +WHERE owner_id = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' +GROUP BY type; ``` ### Learn more diff --git a/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx b/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx index bb0572e838..1b8ec0fb55 100644 --- a/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx +++ b/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx @@ -83,10 +83,12 @@ Registrations are polymorphic between the defined RegistrationTypes, depending o guarantees (for example, ENSv1 BaseRegistrar Registrations may have a gracePeriod, but ENSv2 Registry Registrations do not). -Instead of materializing a Domain's name at any point, we maintain an internal rainbow table of -labelHash -> InterpretedLabel (the Label entity). This ensures that regardless of how or when a -new label is encountered onchain, all Domains that use that label are automatically healed at -resolution-time. +The `Label` entity (`labelHash` → `InterpretedLabel`) remains the source of truth for label values. +Canonical-tree fields on `domains` (`canonical_name`, `canonical_label_hash_path`, `canonical_path`, +`canonical_depth`, `canonical_node`) are materialized inline by the handlers in +`canonicality-db-helpers.ts`. Label heals propagate to `canonical_name` via a GIN-indexed bulk +UPDATE outside Ponder's cache; cascade round-trips are bounded to events that already pay a +flush (canonicality flip, heal of an unknown label). ENSv1 and ENSv2 both fit the `Registry` → `Domain` → (`Sub`)`Registry` → `Domain` → ... `namegraph` model. For ENSv1, each domain that has children implicitly owns a "virtual" `Registry` (a row of type @@ -99,6 +101,9 @@ exception of the canonical subgraph, which is reflected via `registries.canonica `domains.canonical` boolean flags on the rows themselves. The bidirectional canonical edge is NOT materialized in a parallel table; it is derived on demand by checking that the two unidirectional pointers agree (`registries.canonical_domain_id = domains.id` ↔ `domains.subregistry_id = registries.id`). +Cascading canonicality flips through the subgraph run as either an in-memory PK update (when +`registries.has_children = false`, the dominant case for fresh ENSv1 virtual registries on first +wire-up) or a single recursive-CTE batch UPDATE otherwise (see `canonicality-db-helpers.ts`). Note also that the Protocol Acceleration plugin is a hard requirement for the ENSv2 plugin. This allows us to rely on the shared logic for indexing: @@ -248,7 +253,7 @@ For ENSv1, each domain that has children implicitly owns a "virtual" Registry (` The `domains.owner_id` for ENSv1 Domains is the materialized effective owner. ENSv1 includes a diverse number of ways to 'own' a domain, including the ENSv1 Registry, various Registrars, and the NameWrapper. The ENSv1 indexing logic materializes the effective owner to simplify this aspect of ENS and enable efficient queries against `domains.owner_id`. :::note -Domain-Resolver relations are tracked via the Protocol Acceleration plugin, not stored on the domain row. The parent domain is derived via `registry_canonical_domains`, not stored on the domain row. +Domain-Resolver relations are tracked via the Protocol Acceleration plugin, not stored on the domain row. Parent-domain traversal of the canonical nametree is supported directly via the materialized `canonical_path` / `canonical_label_hash_path` arrays; non-canonical traversal walks the `registries.canonical_domain_id` ↔ `domains.subregistry_id` pointers at query-time. ::: | Column | Type | Nullable | Description | @@ -263,8 +268,13 @@ Domain-Resolver relations are tracked via the Protocol Acceleration plugin, not | `owner_id` | `text` | yes | If `ENSv1Domain`, the materialized effective owner address. If `ENSv2Domain`, the on-chain owner address (the HCA account address if used). | | `root_registry_owner_id` | `text` | yes | ENSv1 only: the owner recorded in the root ENSv1 registry. `null` for ENSv2 domains. | | `canonical` | `boolean` | no | Whether this Domain is part of the canonical nametree. This encodes bi-directional agreement between `domains.subregistry_id` and `registries.canonical_domain_id`, so traversal of the canonical nametree filtered to domains/registries where `canonical=true` is safe and doesn't require edge-authenticating oneself (i.e. don't need to compare `domains.subregistry_id` and `registries.canonical_domain_id` in the query, can just `WHERE canonical = true`). Mirrors the parent Registry's flag. Default `false`. | +| `canonical_name` | `text` | yes | Materialized Canonical Name, `NULL` iff `canonical = false`. Maintained by `canonicality-db-helpers.ts`. Example: `"vitalik.eth"`. | +| `canonical_label_hash_path` | `text[]` | yes | Materialized Canonical LabelHashPath, `NULL` iff `canonical = false`. Head-first (root → leaf), i.e. `[labelhash("eth"), labelhash("vitalik")]` for `"vitalik.eth"`. Maintained by `canonicality-db-helpers.ts`. | +| `canonical_path` | `text[]` | yes | Materialized Canonical Domain Path, `NULL` iff `canonical = false`. Head-first (root → leaf), i.e. `["eth"`'s `DomainId`, `"vitalik"`'s `DomainId]` for `"vitalik.eth"`. Maintained by `canonicality-db-helpers.ts`. | +| `canonical_depth` | `integer` | yes | Materialized Canonical Depth, `NULL` iff `canonical = false`. The depth of this Domain in the Canonical Nametree, i.e. the number of Labels in its Canonical Name (e.g. `"eth"` depth 1, `"vitalik.eth"` depth 2). Maintained by `canonicality-db-helpers.ts`. | +| `canonical_node` | `text` | yes | Materialized Canonical Node, `NULL` iff `canonical = false`. The computed Node (via `namehash`) of this Domain's Canonical Name. Maintained by `canonicality-db-helpers.ts`. | -**Indexes:** `type`, `registry_id`, `subregistry_id` (partial: non-null only), `owner_id`, `label_hash`. +**Indexes:** `type`, `registry_id`, `subregistry_id` (partial: non-null only), `owner_id`, `label_hash`, `canonical_name` (hash, exact match — avoids the btree 8191-byte row-size hazard for spam names), `canonical_name` (GIN trigram for substring / similarity queries), `canonical_label_hash_path` (GIN containment for `cascadeLabelHeal`'s `canonical_label_hash_path @> ARRAY[lh]` lookup), `canonical_node` (hash, for resolver-record → canonical-domain joins), `canonical_depth` (btree, for `ORDER BY canonical_depth` — typeahead and depth-ordered browse). **Relations:** belongs to one `registries` record, belongs to one `registries` record (as subregistry), has one `accounts` record (owner), has one `accounts` record (rootRegistryOwner), has one `labels` record, has many `registrations` records. diff --git a/docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sdk.mdx b/docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sdk.mdx index fedee9255f..5a86711837 100644 --- a/docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sdk.mdx +++ b/docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sdk.mdx @@ -23,27 +23,31 @@ yarn add @ensnode/ensdb-sdk ### Example Usage +Canonical fields (`canonicalName`, `canonicalPath`, `canonicalNode`, `canonicalDepth`) are +populated on every Domain reachable from the canonical root, across both ENSv1 and ENSv2 — query +them uniformly without branching by `type`. + ```typescript import { EnsDbReader, IndexingMetadataContextStatusCodes } from '@ensnode/ensdb-sdk'; -import { eq } from 'drizzle-orm'; +import { count, eq } from 'drizzle-orm'; // Connect to a specific ENSDb instance by providing its connection string and // the ENSIndexer Schema Name you want to query -const ensDbReader = new EnsDbReader(ensDbConnectionString, ensIndexerSchemaName); +const ensDbReader = new EnsDbReader(ensDbConnectionString, ensIndexerSchemaName); const { ensDb, ensIndexerSchema } = ensDbReader; -// Get ENS V1 domains from the ENSIndexer Schema -const v1Domains = await ensDb +// Fetch a Domain by its canonical name +const [vitalik] = await ensDb .select() .from(ensIndexerSchema.domain) - .where(eq(ensIndexerSchema.domain.type, "ENSv1Domain")); + .where(eq(ensIndexerSchema.domain.canonicalName, "vitalik.eth")); -// Get ENS V2 domains from the ENSIndexer Schema -const v2Domains = await ensDb - .select() +// Count an address's Domains, grouped by Domain type (ENSv1 vs ENSv2) +const counts = await ensDb + .select({ type: ensIndexerSchema.domain.type, count: count() }) .from(ensIndexerSchema.domain) - .where(eq(ensIndexerSchema.domain.type, "ENSv2Domain")); - + .where(eq(ensIndexerSchema.domain.ownerId, "0xd8da6bf26964af9d7eed9e03e53415d37aa96045")) + .groupBy(ensIndexerSchema.domain.type); // Get indexing status snapshot const indexingMetadataContext = await ensDbReader.getIndexingMetadataContext(); diff --git a/docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sql.mdx b/docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sql.mdx index 63afc4d627..c185d1c444 100644 --- a/docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sql.mdx +++ b/docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sql.mdx @@ -36,18 +36,19 @@ FROM ensnode.metadata; ### Query Data -Query data from your ENSDb instance: +Canonical fields (`canonical_name`, `canonical_path`, `canonical_node`, `canonical_depth`) are +populated on every Domain reachable from the canonical root, across both ENSv1 and ENSv2 — query +them uniformly without branching by `type`. ```sql --- Get ENSv1 domains from the ENSIndexer Schema with the `ensindexer_0` ENSIndexer Schema Name +-- Fetch a Domain by its canonical name SELECT * FROM ensindexer_0.domains -WHERE type = 'ENSv1Domain' -LIMIT 10; +WHERE canonical_name = 'vitalik.eth'; --- Get ENSv2 domains from the ENSIndexer Schema with the `ensindexer_0` ENSIndexer Schema Name -SELECT * FROM ensindexer_0.domains -WHERE type = 'ENSv2Domain' -LIMIT 10; +-- Count an address's Domains, grouped by Domain type (ENSv1 vs ENSv2) +SELECT type, count(*) FROM ensindexer_0.domains +WHERE owner_id = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' +GROUP BY type; -- Get indexing status snapshot for the ENSNode Schema with the `ensindexer_0` ENSIndexer Schema Name SELECT value -> 'indexingStatus' FROM "ensnode"."metadata" diff --git a/examples/enskit-react-example/src/SearchView.tsx b/examples/enskit-react-example/src/SearchView.tsx index 4128237af4..d286f724a2 100644 --- a/examples/enskit-react-example/src/SearchView.tsx +++ b/examples/enskit-react-example/src/SearchView.tsx @@ -5,7 +5,7 @@ import { Link, useSearchParams } from "react-router"; const DomainsByNameQuery = graphql(` query DomainsByName($name: String!, $first: Int!, $after: String) { - domains(where: { name: $name }, first: $first, after: $after) { + domains(where: { name: { starts_with: $name } }, first: $first, after: $after) { edges { node { __typename id canonical {name} } } @@ -67,9 +67,9 @@ export function SearchView() {

Domain Search

- Showcases live querying via Query.domains(where: {"{ name }"}). Only{" "} - Canonical Domains are rendered. Input is debounced by {DEBOUNCE_MS}ms and synced to - the URL as ?query=. + Showcases live querying via Query.domains(where: {"{ name: { starts_with } }"}) + . Only Canonical Domains are rendered. Input is debounced by {DEBOUNCE_MS}ms and + synced to the URL as ?query=.

(), + + /** + * Materialized Canonical LabelHashPath, NULL iff `canonical = false`. + * Maintained by `canonicality-db-helpers.ts`. + * + * @dev Note that LabelHashPaths are in traversal-order (i.e. [labelhash("eth"), labelhash("vitalik")]). + */ canonicalLabelHashPath: t.hex().array().$type(), + + /** + * Materialized Canonical Domain Path, NULL iff `canonical = false`. + * Maintained by `canonicality-db-helpers.ts`. + * + * @dev Note that canonicalPath is in traversal-order (i.e. ["eth"'s DomainId, "vitalik"'s DomainId]). + */ + canonicalPath: t.text().array().$type(), + + /** + * Materialized Canonical Depth, NULL iff `canonical = false`. + * Maintained by `canonicality-db-helpers.ts`. + * + * @dev The depth of this Domain in the Canonical Nametree i.e. the number of Labels in its Canonical Name. + * @example "eth" depth 1, "vitalik.eth" depth 2, etc + */ + canonicalDepth: t.integer(), + + /** + * Materialized Canonical Node, NULL iff `canonical = false`. + * Maintained by `canonicality-db-helpers.ts`. + * + * @dev the computed Node (via `namehash`) of this Domain's Canonical Name. + */ canonicalNode: t.hex().$type(), // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin @@ -319,6 +352,8 @@ export const domain = onchainTable( byCanonicalLabelHashPath: index().using("gin", t.canonicalLabelHashPath), // hash index for resolver-record → canonical-domain joins byCanonicalNode: index().using("hash", t.canonicalNode), + // btree for ORDER BY canonical_depth (typeahead and DEPTH-ordered browse) + byCanonicalDepth: index().on(t.canonicalDepth), }), ); diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index f9241e13d4..aea9aa5cbf 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -67,7 +67,7 @@ query HelloWorld { { query: ` query FindDomains( - $name: String! + $name: DomainsNameFilter! $order: DomainsOrderInput ) { domains( @@ -88,9 +88,15 @@ query FindDomains( } }`, variables: { - default: { name: "vitalik", order: { by: "NAME", dir: "DESC" } }, - [ENSNamespaceIds.EnsTestEnv]: { name: "c", order: { by: "NAME", dir: "DESC" } }, - [ENSNamespaceIds.SepoliaV2]: { name: "sfmonic", order: { by: "NAME", dir: "DESC" } }, + default: { name: { starts_with: "vitalik" }, order: { by: "NAME", dir: "DESC" } }, + [ENSNamespaceIds.EnsTestEnv]: { + name: { starts_with: "c" }, + order: { by: "NAME", dir: "DESC" }, + }, + [ENSNamespaceIds.SepoliaV2]: { + name: { starts_with: "sfmonic" }, + order: { by: "NAME", dir: "DESC" }, + }, }, }, diff --git a/packages/enssdk/src/lib/interpreted-names-and-labels.test.ts b/packages/enssdk/src/lib/interpreted-names-and-labels.test.ts index 0de6d9670e..62f9929f12 100644 --- a/packages/enssdk/src/lib/interpreted-names-and-labels.test.ts +++ b/packages/enssdk/src/lib/interpreted-names-and-labels.test.ts @@ -10,7 +10,6 @@ import { literalLabelsToInterpretedName, literalLabelToInterpretedLabel, literalNameToInterpretedName, - parsePartialInterpretedName, } from "./interpreted-names-and-labels"; import { encodeLabelHash, labelhashLiteralLabel } from "./labelhash"; import type { InterpretedLabel, InterpretedName, LiteralLabel, Name } from "./types"; @@ -119,65 +118,6 @@ describe("interpretation", () => { }); }); - describe("parsePartialInterpretedName", () => { - it.each([ - // empty input - ["", [], ""], - // partial only (no concrete labels) - ["t", [], "t"], - ["test", [], "test"], - ["exam", [], "exam"], - ["🔥", [], "🔥"], - // concrete TLD with empty partial - ["eth.", ["eth"], ""], - ["base.", ["base"], ""], - // concrete TLD with partial SLD - ["test.eth", ["test"], "eth"], - ["example.eth", ["example"], "eth"], - ["demo.eth", ["demo"], "eth"], - ["parent.eth", ["parent"], "eth"], - ["bridge.eth", ["bridge"], "eth"], - ["examp.eth", ["examp"], "eth"], - // concrete SLD with empty partial - ["sub.parent.eth.", ["sub", "parent", "eth"], ""], - // concrete SLD with partial 3LD - ["sub2.parent.eth", ["sub2", "parent"], "eth"], - ["linked.parent.eth", ["linked", "parent"], "eth"], - // deeper nesting - ["sub1.sub2.parent.eth", ["sub1", "sub2", "parent"], "eth"], - ["wallet.sub1.sub2.parent.eth", ["wallet", "sub1", "sub2", "parent"], "eth"], - ["wallet.linked.parent.eth", ["wallet", "linked", "parent"], "eth"], - // partial at various depths - ["wal.sub1.sub2.parent.eth", ["wal", "sub1", "sub2", "parent"], "eth"], - ["w.sub1.sub2.parent.eth", ["w", "sub1", "sub2", "parent"], "eth"], - // with encoded labelhashes in concrete - [`${EXAMPLE_ENCODED_LABEL_HASH}.eth`, [EXAMPLE_ENCODED_LABEL_HASH], "eth"], - // with encoded labelhash in partial - [ - `example.${EXAMPLE_ENCODED_LABEL_HASH.slice(0, 20)}`, - ["example"], - EXAMPLE_ENCODED_LABEL_HASH.slice(0, 20), - ], - ] as [Name, string[], string][])( - "parsePartialInterpretedName(%j) → { concrete: %j, partial: %j }", - (input, expectedConcrete, expectedPartial) => { - expect(parsePartialInterpretedName(input)).toEqual({ - concrete: expectedConcrete, - partial: expectedPartial, - }); - }, - ); - - it.each([ - "Test.eth", // uppercase in concrete - "EXAMPLE.eth", // uppercase in concrete - "test\0.eth", // null in concrete - "sub.Parent.eth", // uppercase in middle - ] as Name[])("throws for invalid concrete label: %j", (input) => { - expect(() => parsePartialInterpretedName(input)).toThrow(); - }); - }); - describe("constructSubInterpretedName", () => { it.each([ // label only (no parent) diff --git a/packages/enssdk/src/lib/interpreted-names-and-labels.ts b/packages/enssdk/src/lib/interpreted-names-and-labels.ts index 39a1a55666..d77f0e4bbe 100644 --- a/packages/enssdk/src/lib/interpreted-names-and-labels.ts +++ b/packages/enssdk/src/lib/interpreted-names-and-labels.ts @@ -263,37 +263,6 @@ export function ensureInterpretedLabel( return label ?? (encodeLabelHash(labelHash) as InterpretedLabel); } -/** - * Parses a Partial InterpretedName into concrete InterpretedLabels and the partial Label. - * - * @example - * ```ts - * const result = parsePartialInterpretedName("example.et") - * // { concrete: ["example"], partial: "et" } - * ``` - * - * @throws if the provided `partialInterpretedName` is not composed of concrete InterpretedLabels. - */ -export function parsePartialInterpretedName(partialInterpretedName: Name): { - concrete: InterpretedLabel[]; - partial: string; -} { - if (partialInterpretedName === "") return { concrete: [], partial: "" }; - - const concrete = partialInterpretedName.split("."); - // note that the concrete.pop mutates `concrete` to exclude the last element - // biome-ignore lint/style/noNonNullAssertion: there's always at least one element after a .split - const partial = concrete.pop()!; - - if (!concrete.every(isInterpretedLabel)) { - throw new Error( - `Invariant(parsePartialInterpretedName): Concrete portion of Partial InterpretedName contains segments that are not InterpretedLabels.\n${JSON.stringify(concrete)}`, - ); - } - - return { concrete, partial }; -} - /** * Casts a string to a {@link LiteralName}. * diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index e6948b9c45..c314b73369 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -395,8 +395,8 @@ const introspection = { { "name": "name", "type": { - "kind": "SCALAR", - "name": "String" + "kind": "INPUT_OBJECT", + "name": "DomainsNameFilter" } }, { @@ -1291,6 +1291,18 @@ const introspection = { "kind": "OBJECT", "name": "DomainCanonical", "fields": [ + { + "name": "depth", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "name", "type": { @@ -1615,10 +1627,48 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "INPUT_OBJECT", + "name": "DomainsNameFilter", + "inputFields": [ + { + "name": "eq", + "type": { + "kind": "SCALAR", + "name": "InterpretedName" + } + }, + { + "name": "in", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "InterpretedName" + } + } + } + }, + { + "name": "starts_with", + "type": { + "kind": "SCALAR", + "name": "String" + } + } + ], + "isOneOf": true + }, { "kind": "ENUM", "name": "DomainsOrderBy", "enumValues": [ + { + "name": "DEPTH", + "isDeprecated": false + }, { "name": "NAME", "isDeprecated": false @@ -1667,8 +1717,8 @@ const introspection = { "type": { "kind": "NON_NULL", "ofType": { - "kind": "SCALAR", - "name": "String" + "kind": "INPUT_OBJECT", + "name": "DomainsNameFilter" } } }, @@ -5426,8 +5476,8 @@ const introspection = { { "name": "name", "type": { - "kind": "SCALAR", - "name": "String" + "kind": "INPUT_OBJECT", + "name": "DomainsNameFilter" } } ], @@ -6174,8 +6224,8 @@ const introspection = { { "name": "name", "type": { - "kind": "SCALAR", - "name": "String" + "kind": "INPUT_OBJECT", + "name": "DomainsNameFilter" } } ], diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 62c50d1879..9cd29d8055 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -50,10 +50,8 @@ input AccountDomainsWhereInput { """ canonical: Boolean = false - """ - A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'. - """ - name: String + """If set, filters the set of Domains by name.""" + name: DomainsNameFilter """ If set, filters the set of Domains to only those of the specified ENS protocol version. @@ -264,9 +262,14 @@ interface Domain { } """ -Canonicality metadata for a Domain, including its name, node (namehash), and path. +Canonicality metadata for a Domain, including its name, depth, path, and node (namehash). """ type DomainCanonical { + """ + The depth of this Domain, i.e. the number of labels in this Domain's Canonical Name (e.g. 2 for `vitalik.eth`). + """ + depth: Int! + """The Canonical Name for this Domain.""" name: InterpretedName! @@ -276,7 +279,7 @@ type DomainCanonical { node: Node! """ - The Canonical Path from this Domain to the ENS Root, leaf→root inclusive of this Domain. + The Canonical Path from this Domain to the ENS Root, root→leaf inclusive of this Domain. """ path: [Domain!]! } @@ -328,8 +331,29 @@ type DomainSubdomainsConnectionEdge { node: Domain! } +""" +Filter Domains by name. Exactly one of `starts_with`, `eq`, or `in` must be provided. +""" +input DomainsNameFilter @oneOf { + """ + Exact InterpretedName match. Sugar for `in: [eq]`. Combine with `version` to disambiguate across ENS protocol versions. + """ + eq: InterpretedName + + """ + Exact InterpretedName match against any name in the set. Max 100 items. + """ + in: [InterpretedName!] + + """ + Prefix-match on Interpreted Name for typeahead. ex: 'vit', 'vitalik.et'. Case-insensitive (InterpretedName labels are normalized). + """ + starts_with: String +} + """Fields by which domains can be ordered""" enum DomainsOrderBy { + DEPTH NAME REGISTRATION_EXPIRY REGISTRATION_TIMESTAMP @@ -345,10 +369,8 @@ input DomainsOrderInput { """Filter for the top-level domains query.""" input DomainsWhereInput { - """ - A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'. - """ - name: String! + """Filter the set of Domains by name.""" + name: DomainsNameFilter! """ If set, filters the set of Domains to only those of the specified ENS protocol version. @@ -1127,10 +1149,8 @@ type RegistryDomainsConnectionEdge { """Filter for Registry.domains query.""" input RegistryDomainsWhereInput { - """ - A partial Interpreted Name by which to filter Domains in this Registry. - """ - name: String + """If set, filters the set of Domains in this Registry by name.""" + name: DomainsNameFilter } """RegistryId represents a enssdk#RegistryId.""" @@ -1295,8 +1315,8 @@ scalar ResolverRecordsId """Filter for Domain.subdomains query.""" input SubdomainsWhereInput { - """A partial Interpreted Name by which to filter subdomains.""" - name: String + """If set, filters the set of subdomains by name.""" + name: DomainsNameFilter } """ThreeDNSRegistration represents a Registration within ThreeDNSToken.""" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbb0e64be1..2da326abda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -413,6 +413,9 @@ importers: '@pothos/plugin-tracing': specifier: ^1.1.2 version: 1.1.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0) + '@pothos/plugin-zod': + specifier: ^4.3.0 + version: 4.3.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)(zod@4.3.6) '@pothos/tracing-opentelemetry': specifier: ^1.1.3 version: 1.1.3(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@opentelemetry/semantic-conventions@1.37.0)(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(graphql@16.11.0) @@ -3577,6 +3580,13 @@ packages: '@pothos/core': '*' graphql: ^16.10.0 + '@pothos/plugin-zod@4.3.0': + resolution: {integrity: sha512-g6fZ1Mp654sVSbXJ4yqgcdHzCI6iZdmtWJwaXutcTwZxMSz5nfOk72kXSHzxWji6zrOs+hzkJXvsLszUFcb63A==} + peerDependencies: + '@pothos/core': '*' + graphql: ^16.10.0 + zod: ^4.* + '@pothos/tracing-opentelemetry@1.1.3': resolution: {integrity: sha512-AjcF/hqXejuTw2rQrQweSLXLM8g63gzLPy1IW2hrCjJT1reqCVzcFoYfJeNm2O2QbrA+Ww44RA6BZE6HJCbHjQ==} peerDependencies: @@ -12900,6 +12910,12 @@ snapshots: '@pothos/core': 4.10.0(graphql@16.11.0) graphql: 16.11.0 + '@pothos/plugin-zod@4.3.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)(zod@4.3.6)': + dependencies: + '@pothos/core': 4.10.0(graphql@16.11.0) + graphql: 16.11.0 + zod: 4.3.6 + '@pothos/tracing-opentelemetry@1.1.3(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@opentelemetry/semantic-conventions@1.37.0)(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(graphql@16.11.0)': dependencies: '@opentelemetry/api': 1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a)