From 2758f87a0f85ee400f6adc7174643ba85d2c486b Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 11 May 2026 14:26:04 -0500 Subject: [PATCH 01/14] checkpoint: DomainsNameFilter @oneOf (starts_with | eq | in) Replace `where: { name: String }` on Query.domains, Account.domains, Registry.domains, and Domain.subdomains with a DomainsNameFilter @oneOf input supporting `starts_with` (prefix autocomplete, unchanged behavior), `eq` (exact match), and `in` (exact match against a set, max 100). Exact-match implementation (filterByNameIn) currently uses an upward recursive CTE that walks the canonical-edge agreement and verifies the topmost matched ancestor lives in a configured root registry. To be replaced with a single-column lookup on Domain.canonicalName once that is materialized. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/domains-name-filter-oneof.md | 5 + .../lib/find-domains/apply-name-filter.ts | 53 ++++++ .../find-domains/layers/filter-by-name-in.ts | 164 ++++++++++++++++++ .../lib/find-domains/layers/filter-by-name.ts | 12 +- .../lib/find-domains/layers/index.ts | 1 + .../src/omnigraph-api/schema/account.ts | 4 +- .../ensapi/src/omnigraph-api/schema/domain.ts | 57 ++++-- .../schema/query.integration.test.ts | 81 ++++++++- apps/ensapi/src/omnigraph-api/schema/query.ts | 4 +- .../src/omnigraph-api/schema/registry.ts | 4 +- .../find-domains/domain-pagination-queries.ts | 2 +- .../enskit-react-example/src/SearchView.tsx | 8 +- .../src/omnigraph-api/example-queries.ts | 14 +- .../src/omnigraph/generated/introspection.ts | 50 +++++- .../src/omnigraph/generated/schema.graphql | 42 +++-- 15 files changed, 440 insertions(+), 61 deletions(-) create mode 100644 .changeset/domains-name-filter-oneof.md create mode 100644 apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-in.ts 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/apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts new file mode 100644 index 0000000000..90121852cb --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts @@ -0,0 +1,53 @@ +import type { InterpretedName } from "enssdk"; + +import { + type BaseDomainSet, + FILTER_BY_NAME_IN_MAX_NAMES, + filterByName, + filterByNameIn, +} from "@/omnigraph-api/lib/find-domains/layers"; + +/** + * Shape of the `DomainsNameFilter` GraphQL input (an `@oneOf` filter over Domain name). + */ +export interface DomainsNameFilterValue { + starts_with?: string | null; + eq?: InterpretedName | null; + in?: InterpretedName[] | null; +} + +/** + * Apply a `DomainsNameFilter` to a base domain set. Dispatches to the appropriate filter layer + * based on which `@oneOf` field is set. Returns `base` unchanged when `filter` is nullish. + * + * - `starts_with` → `filterByName` (prefix match on last label, exact on ancestors). + * - `eq` → `filterByNameIn([eq])` — sugar for a single-name exact match. + * - `in` → `filterByNameIn(in)` — exact match against any name in the set. + * + * Enforces a maximum of {@link FILTER_BY_NAME_IN_MAX_NAMES} entries in the `in` filter. + */ +export function applyDomainsNameFilter( + base: BaseDomainSet, + filter: DomainsNameFilterValue | null | undefined, +): BaseDomainSet { + if (!filter) return base; + + if (filter.starts_with !== undefined && filter.starts_with !== null) { + return filterByName(base, filter.starts_with); + } + + if (filter.in !== undefined && filter.in !== null) { + if (filter.in.length > FILTER_BY_NAME_IN_MAX_NAMES) { + throw new Error( + `'name.in' accepts at most ${FILTER_BY_NAME_IN_MAX_NAMES} names; received ${filter.in.length}.`, + ); + } + return filterByNameIn(base, filter.in); + } + + if (filter.eq !== undefined && filter.eq !== null) { + return filterByNameIn(base, [filter.eq]); + } + + return base; +} 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..f8297720b3 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-in.ts @@ -0,0 +1,164 @@ +import config from "@/config"; + +import { eq, Param, sql } from "drizzle-orm"; +import { + type DomainId, + type InterpretedName, + interpretedLabelsToLabelHashPath, + interpretedNameToInterpretedLabels, + type LabelHashPath, + type RegistryId, +} from "enssdk"; + +import { getENSv1RootRegistryId, maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; + +import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; + +import { type BaseDomainSet, selectBase } from "./base-domain-set"; + +/** + * Maximum depth of each name in `filterByNameIn`, to avoid expensive queries. + */ +const FILTER_BY_NAME_IN_MAX_DEPTH = 8; + +/** + * Maximum number of names accepted by `filterByNameIn` in a single request. + */ +export const FILTER_BY_NAME_IN_MAX_NAMES = 100; + +/** + * The Root Registry IDs for the configured namespace (ENSv1 always, ENSv2 when defined). + * A Domain qualifies as an exact-name match for an N-label path only if its topmost matched + * ancestor lives in one of these root registries (i.e. the leaf is at depth exactly N from a + * canonical root). + */ +function getRootRegistryIds(): RegistryId[] { + const v1 = getENSv1RootRegistryId(config.namespace); + const v2 = maybeGetENSv2RootRegistryId(config.namespace); + return v2 ? [v1, v2] : [v1]; +} + +function namesToLabelHashPaths(names: InterpretedName[]): LabelHashPath[] { + return names.map((name) => { + const labels = interpretedNameToInterpretedLabels(name); + if (labels.length === 0) { + throw new Error( + `Invariant(filterByNameIn): the ENS Root Name ('') is not addressable in this filter.`, + ); + } + if (labels.length > FILTER_BY_NAME_IN_MAX_DEPTH) { + throw new Error( + `Invariant(filterByNameIn): Name '${name}' depth exceeds maximum of ${FILTER_BY_NAME_IN_MAX_DEPTH} labels.`, + ); + } + return interpretedLabelsToLabelHashPath(labels); + }); +} + +/** + * Build a subquery returning the leaf Domain IDs whose ancestry in the canonical tree exactly + * matches one of the input label hash paths. + * + * Uses a single multi-path recursive CTE: input paths are encoded as a `(path_id, position, + * label_hash)` relation; each path is walked up from its leaf via the bidirectional + * canonical-edge agreement check (`registries.canonical_domain_id = domains.id` AND + * `domains.subregistry_id = registries.id`), verifying each ancestor's labelHash matches the + * expected position in the path. A path matches when `depth = path length` AND the topmost + * matched ancestor lives in one of the namespace's root registries — the latter constraint + * enforces depth correctness so that a 1-label path like 'eth' does not match a same-labeled + * descendant deeper in some other canonical tree. + */ +function domainsByExactLabelHashPaths(labelHashPaths: LabelHashPath[]) { + // encode the input paths as a jsonb 2D array — each path may have a different length, so a + // flat text[] won't do. + const pathsJson = sql`${new Param(JSON.stringify(labelHashPaths))}::jsonb`; + const rootRegistryIds = sql`${new Param(getRootRegistryIds())}::text[]`; + + return ensDb + .select({ leafId: sql`exact_path_match.leaf_id`.as("leafId") }) + .from( + sql`( + WITH RECURSIVE + path_input AS ( + SELECT + (p.path_idx - 1)::int AS path_id, + l.label_idx::int AS position, + l.label_hash::text AS label_hash + FROM jsonb_array_elements(${pathsJson}) WITH ORDINALITY p(path, path_idx) + CROSS JOIN LATERAL jsonb_array_elements_text(p.path) + WITH ORDINALITY l(label_hash, label_idx) + ), + path_length AS ( + SELECT path_id, MAX(position) AS length + FROM path_input + GROUP BY path_id + ), + upward_check AS ( + -- Base case: leaf candidates matching the deepest label of each path + SELECT + pi.path_id, + d.id AS leaf_id, + d.id AS current_id, + 1 AS depth + FROM path_input pi + JOIN path_length pl + ON pl.path_id = pi.path_id AND pi.position = pl.length + JOIN ${ensIndexerSchema.domain} d + ON d.label_hash = pi.label_hash + + UNION ALL + + -- Recursive step: walk up via the agreement check, verifying each ancestor's labelHash + -- against the expected position in the path. + SELECT + uc.path_id, + uc.leaf_id, + np.id AS current_id, + uc.depth + 1 + FROM upward_check uc + JOIN path_length pl ON pl.path_id = uc.path_id + JOIN ${ensIndexerSchema.domain} cur ON cur.id = uc.current_id + JOIN ${ensIndexerSchema.registry} cur_reg ON cur_reg.id = cur.registry_id + JOIN ${ensIndexerSchema.domain} np + ON np.id = cur_reg.canonical_domain_id + AND np.subregistry_id = cur_reg.id + JOIN path_input pi + ON pi.path_id = uc.path_id + AND pi.position = pl.length - uc.depth + AND np.label_hash = pi.label_hash + WHERE uc.depth < pl.length + ) + SELECT DISTINCT uc.leaf_id + FROM upward_check uc + JOIN path_length pl ON pl.path_id = uc.path_id + JOIN ${ensIndexerSchema.domain} top ON top.id = uc.current_id + WHERE uc.depth = pl.length + AND top.registry_id = ANY(${rootRegistryIds}) + ) AS exact_path_match`, + ) + .as("exact_path_match"); +} + +/** + * Filter a base domain set to only Domains whose Interpreted Name exactly matches one of + * `names`, considering ancestry in the canonical tree. + * + * Returns an empty result set if `names` is empty. + * + * @param base - A base domain set subquery + * @param names - Exact InterpretedNames to match against + */ +export function filterByNameIn(base: BaseDomainSet, names: InterpretedName[]) { + if (names.length === 0) { + return ensDb.select(selectBase(base)).from(base).where(sql`false`).as("baseDomains"); + } + + const labelHashPaths = namesToLabelHashPaths(names); + const pathResults = domainsByExactLabelHashPaths(labelHashPaths); + + return ensDb + .select(selectBase(base)) + .from(base) + .innerJoin(pathResults, eq(pathResults.leafId, base.domainId)) + .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..e19c1f199a 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 @@ -108,18 +108,18 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { } /** - * 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. + * Filter a base domain set by name prefix. Parses the input 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). * * @param base - A base domain set subquery - * @param name - Optional partial InterpretedName (e.g. 'examp', 'example.', 'sub.example.eth') + * @param startsWith - Optional partial InterpretedName (e.g. 'examp', 'example.', 'sub.example.eth') */ -export function filterByName(base: BaseDomainSet, name?: string | null) { - const { concrete, partial } = parsePartialInterpretedName(name || ""); +export function filterByName(base: BaseDomainSet, startsWith?: string | null) { + const { concrete, partial } = parsePartialInterpretedName(startsWith || ""); if (concrete.length > FILTER_BY_NAME_MAX_DEPTH) { throw new Error( 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..3c735c7108 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 @@ -2,6 +2,7 @@ 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 { FILTER_BY_NAME_IN_MAX_NAMES, filterByNameIn } from "./filter-by-name-in"; 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/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 5d2e5f2d3c..ecb703d65b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -5,11 +5,11 @@ import type { Address } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; +import { applyDomainsNameFilter } from "@/omnigraph-api/lib/find-domains/apply-name-filter"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; import { domainsBase, filterByCanonical, - filterByName, filterByOwner, filterByVersion, withOrderingMetadata, @@ -80,7 +80,7 @@ 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 = applyDomainsNameFilter(owned, where?.name); const canonical = where?.canonical === true ? filterByCanonical(named) : named; const versioned = where?.version ? filterByVersion(canonical, where.version) : canonical; const domains = withOrderingMetadata(versioned); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index ce8535bf60..5e6f77009c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -14,10 +14,10 @@ import { paginateByInt, } from "@/omnigraph-api/lib/connection-helpers"; import { cursors } from "@/omnigraph-api/lib/cursors"; +import { applyDomainsNameFilter } from "@/omnigraph-api/lib/find-domains/apply-name-filter"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; import { domainsBase, - filterByName, filterByParent, withOrderingMetadata, } from "@/omnigraph-api/lib/find-domains/layers"; @@ -265,7 +265,7 @@ DomainInterfaceRef.implement({ }, resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = filterByParent(domainsBase(), parent.id); - const named = filterByName(base, where?.name); + const named = applyDomainsNameFilter(base, where?.name); const domains = withOrderingMetadata(named); return resolveFindDomains(context, { domains, order, ...connectionArgs }); @@ -432,13 +432,44 @@ export const DomainIdInput = builder.inputType("DomainIdInput", { }), }); +/** + * @oneOf filter for Domain names. Exactly one of `starts_with`, `eq`, or `in` must be provided. + * + * - `starts_with`: partial Interpreted Name for autocomplete; exact match on every label except + * the last, prefix match on the last label. ex: 'example', 'example.', 'example.et'. + * - `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: + "Partial Interpreted Name for autocomplete. Matches Domains whose Interpreted Name starts with the given value: exact match on every label except the last, prefix match on the last label. ex: 'example', 'example.', 'example.et'. Case-sensitive (InterpretedName labels are normalized).", + }), + eq: t.field({ + type: "InterpretedName", + description: + "Exact InterpretedName match. Sugar for `in: [eq]`. Combine with `version` to disambiguate across ENS protocol versions.", + }), + in: t.field({ + type: ["InterpretedName"], + description: + "Exact InterpretedName match against any name in the set. Max 100 items; requests above the limit return an error.", + }), + }), +}); + 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, @@ -451,9 +482,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: @@ -471,8 +502,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.", }), }), }); @@ -480,8 +512,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.", }), }), }); 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 4f78ec06cd..e25b6e1cce 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 { @@ -120,7 +124,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); @@ -146,7 +152,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) @@ -165,7 +173,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); @@ -175,7 +183,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); @@ -185,7 +193,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); @@ -193,6 +201,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.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, + 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.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.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 3969e54967..59b134fa2f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -8,11 +8,11 @@ import { getRootRegistryId } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; +import { applyDomainsNameFilter } from "@/omnigraph-api/lib/find-domains/apply-name-filter"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; import { domainsBase, filterByCanonical, - filterByName, filterByVersion, withOrderingMetadata, } from "@/omnigraph-api/lib/find-domains/layers"; @@ -119,7 +119,7 @@ builder.queryType({ }, resolve: (_, { where, order, ...connectionArgs }, context) => { const base = domainsBase(); - const named = filterByName(base, where.name); + const named = applyDomainsNameFilter(base, where.name); const canonical = filterByCanonical(named); const versioned = where.version ? filterByVersion(canonical, where.version) : canonical; const domains = withOrderingMetadata(versioned); diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index 278807d804..843083030c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -7,10 +7,10 @@ import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; +import { applyDomainsNameFilter } from "@/omnigraph-api/lib/find-domains/apply-name-filter"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; import { domainsBase, - filterByName, filterByRegistry, withOrderingMetadata, } from "@/omnigraph-api/lib/find-domains/layers"; @@ -135,7 +135,7 @@ RegistryInterfaceRef.implement({ }, resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = filterByRegistry(domainsBase(), parent.id); - const named = filterByName(base, where?.name); + const named = applyDomainsNameFilter(base, where?.name); const domains = withOrderingMetadata(named); return resolveFindDomains(context, { domains, order, ...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 4a251fa848..998992876c 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 @@ -42,7 +42,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/examples/enskit-react-example/src/SearchView.tsx b/examples/enskit-react-example/src/SearchView.tsx index 6d78dd9a89..2e9d404862 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, canonical: true }, first: $first, after: $after) { + domains(where: { name: { starts_with: $name } }, first: $first, after: $after) { edges { node { __typename id 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=.

Date: Fri, 15 May 2026 14:08:35 -0500 Subject: [PATCH 02/14] checkpoint: initial domain query refactor --- apps/ensapi/package.json | 1 + apps/ensapi/src/omnigraph-api/builder.ts | 3 +- apps/ensapi/src/omnigraph-api/context.ts | 16 +- .../lib/find-domains/apply-name-filter.ts | 29 ++- .../find-domains-resolver-helpers.ts | 16 +- .../lib/find-domains/find-domains-resolver.ts | 21 ++- .../find-domains/layers/base-domain-set.ts | 31 ++-- .../find-domains/layers/filter-by-name-in.ts | 152 +--------------- .../lib/find-domains/layers/filter-by-name.ts | 171 ++---------------- .../lib/find-domains/layers/index.ts | 2 +- .../layers/with-ordering-metadata.ts | 17 +- .../omnigraph-api/lib/find-domains/types.ts | 5 +- .../omnigraph-api/lib/get-canonical-path.ts | 75 -------- .../src/omnigraph-api/schema/account.ts | 4 +- .../omnigraph-api/schema/domain-canonical.ts | 33 ++-- .../src/omnigraph-api/schema/domain-inputs.ts | 18 +- .../ensapi/src/omnigraph-api/schema/domain.ts | 4 +- .../schema/query.integration.test.ts | 8 +- apps/ensapi/src/omnigraph-api/schema/query.ts | 4 +- .../src/omnigraph-api/schema/registry.ts | 4 +- .../find-domains/domain-pagination-queries.ts | 6 +- .../find-domains/test-domain-pagination.ts | 35 +++- .../src/lib/ensv2/canonicality-db-helpers.ts | 35 ++-- .../integrate/integration-options/ensdb.mdx | 33 +++- .../docs/docs/services/ensdb/usage/sdk.mdx | 24 ++- .../docs/docs/services/ensdb/usage/sql.mdx | 17 +- .../src/ensindexer-abstract/ensv2.schema.ts | 53 +++++- .../src/omnigraph/generated/introspection.ts | 16 ++ .../src/omnigraph/generated/schema.graphql | 14 +- pnpm-lock.yaml | 26 ++- 30 files changed, 348 insertions(+), 525 deletions(-) delete mode 100644 apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts 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/apply-name-filter.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts index 90121852cb..98ac7703c6 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts @@ -2,13 +2,16 @@ import type { InterpretedName } from "enssdk"; import { type BaseDomainSet, - FILTER_BY_NAME_IN_MAX_NAMES, filterByName, filterByNameIn, } from "@/omnigraph-api/lib/find-domains/layers"; +import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain-inputs"; /** * Shape of the `DomainsNameFilter` GraphQL input (an `@oneOf` filter over Domain name). + * + * Field-level validation (non-empty strings, max-100 names in `in`) is enforced at the GraphQL + * input layer; this dispatcher trusts its input. */ export interface DomainsNameFilterValue { starts_with?: string | null; @@ -18,36 +21,30 @@ export interface DomainsNameFilterValue { /** * Apply a `DomainsNameFilter` to a base domain set. Dispatches to the appropriate filter layer - * based on which `@oneOf` field is set. Returns `base` unchanged when `filter` is nullish. + * based on which `@oneOf` field is set. Returns `{ base }` unchanged when `filter` is nullish. * - * - `starts_with` → `filterByName` (prefix match on last label, exact on ancestors). + * - `starts_with` → `filterByName` (typeahead). Surfaces a `defaultOrderBy: "DEPTH"` so resolvers + * prefer shorter names 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. - * - * Enforces a maximum of {@link FILTER_BY_NAME_IN_MAX_NAMES} entries in the `in` filter. */ export function applyDomainsNameFilter( base: BaseDomainSet, filter: DomainsNameFilterValue | null | undefined, -): BaseDomainSet { - if (!filter) return base; +): { base: BaseDomainSet; defaultOrderBy?: typeof DomainsOrderBy.$inferType } { + if (!filter) return { base }; if (filter.starts_with !== undefined && filter.starts_with !== null) { - return filterByName(base, filter.starts_with); + return { base: filterByName(base, filter.starts_with), defaultOrderBy: "DEPTH" }; } if (filter.in !== undefined && filter.in !== null) { - if (filter.in.length > FILTER_BY_NAME_IN_MAX_NAMES) { - throw new Error( - `'name.in' accepts at most ${FILTER_BY_NAME_IN_MAX_NAMES} names; received ${filter.in.length}.`, - ); - } - return filterByNameIn(base, filter.in); + return { base: filterByNameIn(base, filter.in) }; } if (filter.eq !== undefined && filter.eq !== null) { - return filterByNameIn(base, [filter.eq]); + return { base: filterByNameIn(base, [filter.eq]) }; } - return base; + return { base }; } 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..1545692349 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 @@ -57,7 +57,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,13 +82,24 @@ export function resolveFindDomains( { domains, order, + defaultOrderBy, ...connectionArgs }: { - /** Pre-built domains CTE from `withOrderingMetadata` */ + /** + * Pre-built domains CTE from `withOrderingMetadata` + */ domains: DomainsWithOrderingMetadata; - /** Optional ordering; defaults to NAME ASC */ + + /** + * Optional ordering; falls back to `defaultOrderBy` or DOMAINS_DEFAULT_ORDER_BY + */ order?: FindDomainsOrderArg | undefined | null; + /** + * Filter-supplied default ordering when the caller doesn't pass `order.by`. + */ + defaultOrderBy?: typeof DomainsOrderBy.$inferType; + // relay connection args from t.connection first?: number | null; last?: number | null; @@ -94,7 +107,7 @@ export function resolveFindDomains( after?: string | null; }, ) { - const orderBy = order?.by ?? DOMAINS_DEFAULT_ORDER_BY; + const orderBy = order?.by ?? defaultOrderBy ?? DOMAINS_DEFAULT_ORDER_BY; const orderDir = order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; return lazyConnection({ 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 index f8297720b3..8b795667c7 100644 --- 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 @@ -1,164 +1,30 @@ -import config from "@/config"; - -import { eq, Param, sql } from "drizzle-orm"; -import { - type DomainId, - type InterpretedName, - interpretedLabelsToLabelHashPath, - interpretedNameToInterpretedLabels, - type LabelHashPath, - type RegistryId, -} from "enssdk"; - -import { getENSv1RootRegistryId, maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { eq, inArray, sql } from "drizzle-orm"; +import type { InterpretedName } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { type BaseDomainSet, selectBase } from "./base-domain-set"; /** - * Maximum depth of each name in `filterByNameIn`, to avoid expensive queries. - */ -const FILTER_BY_NAME_IN_MAX_DEPTH = 8; - -/** - * Maximum number of names accepted by `filterByNameIn` in a single request. - */ -export const FILTER_BY_NAME_IN_MAX_NAMES = 100; - -/** - * The Root Registry IDs for the configured namespace (ENSv1 always, ENSv2 when defined). - * A Domain qualifies as an exact-name match for an N-label path only if its topmost matched - * ancestor lives in one of these root registries (i.e. the leaf is at depth exactly N from a - * canonical root). - */ -function getRootRegistryIds(): RegistryId[] { - const v1 = getENSv1RootRegistryId(config.namespace); - const v2 = maybeGetENSv2RootRegistryId(config.namespace); - return v2 ? [v1, v2] : [v1]; -} - -function namesToLabelHashPaths(names: InterpretedName[]): LabelHashPath[] { - return names.map((name) => { - const labels = interpretedNameToInterpretedLabels(name); - if (labels.length === 0) { - throw new Error( - `Invariant(filterByNameIn): the ENS Root Name ('') is not addressable in this filter.`, - ); - } - if (labels.length > FILTER_BY_NAME_IN_MAX_DEPTH) { - throw new Error( - `Invariant(filterByNameIn): Name '${name}' depth exceeds maximum of ${FILTER_BY_NAME_IN_MAX_DEPTH} labels.`, - ); - } - return interpretedLabelsToLabelHashPath(labels); - }); -} - -/** - * Build a subquery returning the leaf Domain IDs whose ancestry in the canonical tree exactly - * matches one of the input label hash paths. - * - * Uses a single multi-path recursive CTE: input paths are encoded as a `(path_id, position, - * label_hash)` relation; each path is walked up from its leaf via the bidirectional - * canonical-edge agreement check (`registries.canonical_domain_id = domains.id` AND - * `domains.subregistry_id = registries.id`), verifying each ancestor's labelHash matches the - * expected position in the path. A path matches when `depth = path length` AND the topmost - * matched ancestor lives in one of the namespace's root registries — the latter constraint - * enforces depth correctness so that a 1-label path like 'eth' does not match a same-labeled - * descendant deeper in some other canonical tree. - */ -function domainsByExactLabelHashPaths(labelHashPaths: LabelHashPath[]) { - // encode the input paths as a jsonb 2D array — each path may have a different length, so a - // flat text[] won't do. - const pathsJson = sql`${new Param(JSON.stringify(labelHashPaths))}::jsonb`; - const rootRegistryIds = sql`${new Param(getRootRegistryIds())}::text[]`; - - return ensDb - .select({ leafId: sql`exact_path_match.leaf_id`.as("leafId") }) - .from( - sql`( - WITH RECURSIVE - path_input AS ( - SELECT - (p.path_idx - 1)::int AS path_id, - l.label_idx::int AS position, - l.label_hash::text AS label_hash - FROM jsonb_array_elements(${pathsJson}) WITH ORDINALITY p(path, path_idx) - CROSS JOIN LATERAL jsonb_array_elements_text(p.path) - WITH ORDINALITY l(label_hash, label_idx) - ), - path_length AS ( - SELECT path_id, MAX(position) AS length - FROM path_input - GROUP BY path_id - ), - upward_check AS ( - -- Base case: leaf candidates matching the deepest label of each path - SELECT - pi.path_id, - d.id AS leaf_id, - d.id AS current_id, - 1 AS depth - FROM path_input pi - JOIN path_length pl - ON pl.path_id = pi.path_id AND pi.position = pl.length - JOIN ${ensIndexerSchema.domain} d - ON d.label_hash = pi.label_hash - - UNION ALL - - -- Recursive step: walk up via the agreement check, verifying each ancestor's labelHash - -- against the expected position in the path. - SELECT - uc.path_id, - uc.leaf_id, - np.id AS current_id, - uc.depth + 1 - FROM upward_check uc - JOIN path_length pl ON pl.path_id = uc.path_id - JOIN ${ensIndexerSchema.domain} cur ON cur.id = uc.current_id - JOIN ${ensIndexerSchema.registry} cur_reg ON cur_reg.id = cur.registry_id - JOIN ${ensIndexerSchema.domain} np - ON np.id = cur_reg.canonical_domain_id - AND np.subregistry_id = cur_reg.id - JOIN path_input pi - ON pi.path_id = uc.path_id - AND pi.position = pl.length - uc.depth - AND np.label_hash = pi.label_hash - WHERE uc.depth < pl.length - ) - SELECT DISTINCT uc.leaf_id - FROM upward_check uc - JOIN path_length pl ON pl.path_id = uc.path_id - JOIN ${ensIndexerSchema.domain} top ON top.id = uc.current_id - WHERE uc.depth = pl.length - AND top.registry_id = ANY(${rootRegistryIds}) - ) AS exact_path_match`, - ) - .as("exact_path_match"); -} - -/** - * Filter a base domain set to only Domains whose Interpreted Name exactly matches one of - * `names`, considering ancestry in the canonical tree. + * Filter a base domain set to only canonical Domains whose materialized `canonicalName` exactly + * matches one of `names`. Validation (max-length, etc.) is enforced at the GraphQL input layer. * - * Returns an empty result set if `names` is empty. + * 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[]) { + // NOTE: empty array in drizzle is a runtime error, so check here if (names.length === 0) { return ensDb.select(selectBase(base)).from(base).where(sql`false`).as("baseDomains"); } - const labelHashPaths = namesToLabelHashPaths(names); - const pathResults = domainsByExactLabelHashPaths(labelHashPaths); - return ensDb .select(selectBase(base)) .from(base) - .innerJoin(pathResults, eq(pathResults.leafId, base.domainId)) + .innerJoin(ensIndexerSchema.domain, eq(ensIndexerSchema.domain.id, base.domainId)) + .where(inArray(ensIndexerSchema.domain.canonicalName, names)) .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 e19c1f199a..af34b60421 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,38 @@ -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 { eq, ilike } from "drizzle-orm"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { type BaseDomainSet, selectBase } from "./base-domain-set"; /** - * Maximum depth of the provided `name` argument, to avoid infinite loops and expensive queries. - */ -const FILTER_BY_NAME_MAX_DEPTH = 8; - -/** - * 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")] + * Filter a base domain set to canonical Domains whose materialized `canonicalName` starts with + * the user's typeahead input. Used by `Query.domains(where: { name: { starts_with } })` and the + * three sibling resolvers. * - * 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") + * 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. * - * Algorithm: Start from the deepest child (leaf) and traverse UP via `registry.canonicalDomainId`. - */ -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"); -} - -/** - * Filter a base domain set by name prefix. Parses the input 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. + * Empty `startsWith` is rejected upstream at the GraphQL input layer (see `DomainsNameFilter`). * - * 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). + * Ordering is handled by the resolver layer via `defaultOrderBy: "DEPTH"` from + * `applyDomainsNameFilter` — shorter names surface first (`vitalik.eth` over + * `vitalik.ethereum.foundation` for input `"vitalik.et"`). * * @param base - A base domain set subquery - * @param startsWith - Optional partial InterpretedName (e.g. 'examp', 'example.', 'sub.example.eth') + * @param startsWith - Typeahead prefix (non-empty `InterpretedName` fragment) */ -export function filterByName(base: BaseDomainSet, startsWith?: string | null) { - const { concrete, partial } = parsePartialInterpretedName(startsWith || ""); - - if (concrete.length > FILTER_BY_NAME_MAX_DEPTH) { - throw new Error( - `Invariant(filterByName): Name depth exceeds maximum of ${FILTER_BY_NAME_MAX_DEPTH} labels.`, - ); - } - - 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); +export function filterByName(base: BaseDomainSet, startsWith: string) { + if (startsWith === "") throw new Error(`Invariant(filterByName): startsWith expected.`); - // alias for head domain lookup (to get headLabelHash for label join) - const headDomain = alias(ensIndexerSchema.domain, "headDomain"); - const headLabel = alias(ensIndexerSchema.label, "headLabel"); + // 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}%`; - // 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"), - }) + .select(selectBase(base)) .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, - ) + .innerJoin(ensIndexerSchema.domain, eq(ensIndexerSchema.domain.id, base.domainId)) + .where(ilike(ensIndexerSchema.domain.canonicalName, pattern)) .as("baseDomains"); } 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 3c735c7108..e05d5205d8 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 @@ -2,7 +2,7 @@ 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 { FILTER_BY_NAME_IN_MAX_NAMES, filterByNameIn } from "./filter-by-name-in"; +export { filterByNameIn } from "./filter-by-name-in"; 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 8540083328..be6395d6d1 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 = applyDomainsNameFilter(owned, where?.name); + const { base: named, defaultOrderBy } = applyDomainsNameFilter(owned, where?.name); 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, defaultOrderBy, ...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 b9476ff206..e8fee80d71 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts @@ -22,11 +22,15 @@ 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`: partial Interpreted Name for autocomplete; exact match on every label except - * the last, prefix match on the last label. ex: 'example', 'example.', 'example.et'. + * - `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. @@ -38,17 +42,19 @@ export const DomainsNameFilter = builder.inputType("DomainsNameFilter", { fields: (t) => ({ starts_with: t.string({ description: - "Partial Interpreted Name for autocomplete. Matches Domains whose Interpreted Name starts with the given value: exact match on every label except the last, prefix match on the last label. ex: 'example', 'example.', 'example.et'. Case-sensitive (InterpretedName labels are normalized).", + "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 100 items; requests above the limit return an error.", + 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 }, }), }), }); @@ -115,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.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 59b52baffc..625f52fe15 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 = applyDomainsNameFilter(base, where?.name); + const { base: named, defaultOrderBy } = applyDomainsNameFilter(base, where?.name); const domains = withOrderingMetadata(named); - return resolveFindDomains(context, { domains, order, ...connectionArgs }); + return resolveFindDomains(context, { domains, order, defaultOrderBy, ...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 85675490d9..f361e554de 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -220,7 +220,7 @@ describe("Query.domains", () => { ).toBeDefined(); // no prefix-matched names like "ethereum" should leak in - for (const d of domains) expect(d.name).toBe("eth"); + for (const d of domains) expect(d.canonical?.name).toBe("eth"); }); it("eq + version: ENSv1 returns a single domain", async () => { @@ -233,7 +233,7 @@ describe("Query.domains", () => { expect(domains[0]).toMatchObject({ __typename: "ENSv1Domain", id: V1_ETH_DOMAIN_ID, - name: "eth", + canonical: { name: "eth" }, }); }); @@ -242,10 +242,10 @@ describe("Query.domains", () => { name: { in: ["eth", "parent.eth"] }, }); const domains = flattenConnection(result.domains); - const names = new Set(domains.map((d) => d.name)); + 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.name); + for (const d of domains) expect(["eth", "parent.eth"]).toContain(d.canonical?.name); }); it("in returns empty for an empty set", async () => { diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 0acdfb9d6b..d737e6d79c 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 = applyDomainsNameFilter(base, where.name); + const { base: named, defaultOrderBy } = applyDomainsNameFilter(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, defaultOrderBy, ...connectionArgs }); }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index afe63c89f1..da61af427c 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 = applyDomainsNameFilter(base, where?.name); + const { base: named, defaultOrderBy } = applyDomainsNameFilter(base, where?.name); const domains = withOrderingMetadata(named); - return resolveFindDomains(context, { domains, order, ...connectionArgs }); + return resolveFindDomains(context, { domains, order, defaultOrderBy, ...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 a399b29821..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; 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..6c60833a76 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, }); } @@ -449,16 +454,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 +472,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 +493,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 +506,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,6 +525,8 @@ 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 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..0782904406 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 + +```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/usage/sdk.mdx b/docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sdk.mdx index fedee9255f..dd370bc97d 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 +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..0da5fc3e4c 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 +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/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index a90f2e2e06..a4b9a08e58 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -54,10 +54,11 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * Registry Registrations do not). * * The `Label` entity (labelHash → InterpretedLabel) remains the source of truth for label values. - * Canonical-tree fields on `Domain` (`canonicalName`, `canonicalLabelHashPath`, `canonicalNode`) - * are materialized inline by the handlers in `canonicality-db-helpers.ts`. Label heals propagate - * to `canonicalName` 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). + * Canonical-tree fields on `Domain` (`canonicalName`, `canonicalLabelHashPath`, `canonicalPath`, + * `canonicalDepth`, `canonicalNode`) are materialized inline by the handlers in + * `canonicality-db-helpers.ts`. Label heals propagate to `canonicalName` 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 @@ -292,13 +293,45 @@ export const domain = onchainTable( // Whether this Domain is part of the canonical nametree. Mirrors the parent Registry's flag. canonical: t.boolean().notNull().default(false), - // Materialized canonical-tree fields. All three are set/cleared atomically with `canonical` - // (all NULL iff `canonical = false`). Maintained inline by `canonicality-db-helpers.ts`. - // `canonicalLabelHashPath` is head-first traversal order (root → leaf, per LabelHashPath); - // `canonicalName` is the standard leaf-first ENS string; `canonicalNode` is the namehash - // over the path. + /** + * Materialized Canonical Name, NULL iff `canonical = false`. + * Maintained by `canonicality-db-helpers.ts`. + * + * @example "vitalik.eth" + */ canonicalName: t.text().$type(), + + /** + * 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/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 4123186534..c314b73369 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -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": { @@ -1653,6 +1665,10 @@ const introspection = { "kind": "ENUM", "name": "DomainsOrderBy", "enumValues": [ + { + "name": "DEPTH", + "isDeprecated": false + }, { "name": "NAME", "isDeprecated": false diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 5e217f969d..9cd29d8055 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -262,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! @@ -274,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!]! } @@ -336,18 +341,19 @@ input DomainsNameFilter @oneOf { eq: InterpretedName """ - Exact InterpretedName match against any name in the set. Max 100 items; requests above the limit return an error. + Exact InterpretedName match against any name in the set. Max 100 items. """ in: [InterpretedName!] """ - Partial Interpreted Name for autocomplete. Matches Domains whose Interpreted Name starts with the given value: exact match on every label except the last, prefix match on the last label. ex: 'example', 'example.', 'example.et'. Case-sensitive (InterpretedName labels are normalized). + 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8d3269ba5..8f2a4a7911 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) @@ -3691,6 +3694,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: @@ -13092,6 +13102,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) @@ -14980,6 +14996,14 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.3) + '@vitest/mocker@4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.0.5 @@ -20499,7 +20523,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.12))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.3)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 From 2b8285d551e00688ff1fb7ca25430f10793c6ef4 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 14:17:08 -0500 Subject: [PATCH 03/14] fix: tests and such --- .../lib/find-domains/apply-name-filter.ts | 50 ------------- .../find-domains/layers/filter-by-name-in.ts | 14 ++-- .../layers/filter-by-name-starts-with.ts | 35 +++++++++ .../lib/find-domains/layers/filter-by-name.ts | 72 +++++++++++-------- .../lib/find-domains/layers/index.ts | 3 +- .../src/omnigraph-api/schema/account.ts | 4 +- .../schema/domain.integration.test.ts | 12 ++-- .../ensapi/src/omnigraph-api/schema/domain.ts | 4 +- apps/ensapi/src/omnigraph-api/schema/query.ts | 4 +- .../src/omnigraph-api/schema/registry.ts | 4 +- 10 files changed, 101 insertions(+), 101 deletions(-) delete mode 100644 apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-starts-with.ts diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts deleted file mode 100644 index 98ac7703c6..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/apply-name-filter.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { InterpretedName } from "enssdk"; - -import { - type BaseDomainSet, - filterByName, - filterByNameIn, -} from "@/omnigraph-api/lib/find-domains/layers"; -import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain-inputs"; - -/** - * Shape of the `DomainsNameFilter` GraphQL input (an `@oneOf` filter over Domain name). - * - * Field-level validation (non-empty strings, max-100 names in `in`) is enforced at the GraphQL - * input layer; this dispatcher trusts its input. - */ -export interface DomainsNameFilterValue { - starts_with?: string | null; - eq?: InterpretedName | null; - in?: InterpretedName[] | null; -} - -/** - * Apply a `DomainsNameFilter` to a base domain set. Dispatches to the appropriate filter layer - * based on which `@oneOf` field is set. Returns `{ base }` unchanged when `filter` is nullish. - * - * - `starts_with` → `filterByName` (typeahead). Surfaces a `defaultOrderBy: "DEPTH"` so resolvers - * prefer shorter names 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 applyDomainsNameFilter( - base: BaseDomainSet, - filter: DomainsNameFilterValue | null | undefined, -): { base: BaseDomainSet; defaultOrderBy?: typeof DomainsOrderBy.$inferType } { - if (!filter) return { base }; - - if (filter.starts_with !== undefined && filter.starts_with !== null) { - return { base: filterByName(base, filter.starts_with), defaultOrderBy: "DEPTH" }; - } - - if (filter.in !== undefined && filter.in !== null) { - return { base: filterByNameIn(base, filter.in) }; - } - - if (filter.eq !== undefined && filter.eq !== null) { - return { base: filterByNameIn(base, [filter.eq]) }; - } - - return { base }; -} 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 index 8b795667c7..cb2440dab3 100644 --- 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 @@ -1,13 +1,13 @@ -import { eq, inArray, sql } from "drizzle-orm"; +import { inArray, sql } from "drizzle-orm"; import type { InterpretedName } from "enssdk"; -import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; +import { ensDb } from "@/lib/ensdb/singleton"; import { type BaseDomainSet, selectBase } from "./base-domain-set"; /** - * Filter a base domain set to only canonical Domains whose materialized `canonicalName` exactly - * matches one of `names`. Validation (max-length, etc.) is enforced at the GraphQL input layer. + * 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. @@ -16,7 +16,8 @@ import { type BaseDomainSet, selectBase } from "./base-domain-set"; * @param names - Exact InterpretedNames to match against */ export function filterByNameIn(base: BaseDomainSet, names: InterpretedName[]) { - // NOTE: empty array in drizzle is a runtime error, so check here + // 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"); } @@ -24,7 +25,6 @@ export function filterByNameIn(base: BaseDomainSet, names: InterpretedName[]) { return ensDb .select(selectBase(base)) .from(base) - .innerJoin(ensIndexerSchema.domain, eq(ensIndexerSchema.domain.id, base.domainId)) - .where(inArray(ensIndexerSchema.domain.canonicalName, names)) + .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..fe44902937 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-starts-with.ts @@ -0,0 +1,35 @@ +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. + * + * Empty `startsWith` is rejected upstream by `DomainsNameFilter` input validation. + * + * Ordering is handled by the resolver layer via `defaultOrderBy: "DEPTH"` 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) { + // 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 af34b60421..c3dea0c35d 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,38 +1,50 @@ -import { eq, ilike } from "drizzle-orm"; +import type { InterpretedName } from "enssdk"; -import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; +import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain-inputs"; -import { type BaseDomainSet, selectBase } from "./base-domain-set"; +import type { BaseDomainSet } from "./base-domain-set"; +import { filterByNameIn } from "./filter-by-name-in"; +import { filterByNameStartsWith } from "./filter-by-name-starts-with"; /** - * Filter a base domain set to canonical Domains whose materialized `canonicalName` starts with - * the user's typeahead input. Used by `Query.domains(where: { name: { starts_with } })` and the - * three sibling resolvers. + * Shape of the `DomainsNameFilter` GraphQL input (an `@oneOf` filter over Domain name). * - * 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. - * - * Empty `startsWith` is rejected upstream at the GraphQL input layer (see `DomainsNameFilter`). - * - * Ordering is handled by the resolver layer via `defaultOrderBy: "DEPTH"` from - * `applyDomainsNameFilter` — shorter names surface first (`vitalik.eth` over - * `vitalik.ethereum.foundation` for input `"vitalik.et"`). + * Field-level validation (non-empty strings, max-100 names in `in`) is enforced at the GraphQL + * input layer; this dispatcher trusts its input. + */ +export interface DomainsNameFilterValue { + starts_with?: string | null; + eq?: InterpretedName | null; + in?: InterpretedName[] | null; +} + +/** + * 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 startsWith - Typeahead prefix (non-empty `InterpretedName` fragment) + * - `starts_with` → `filterByNameStartsWith` (typeahead). Surfaces `defaultOrderBy: "DEPTH"` so + * resolvers prefer shorter names 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, startsWith: string) { - if (startsWith === "") throw new Error(`Invariant(filterByName): 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) - .innerJoin(ensIndexerSchema.domain, eq(ensIndexerSchema.domain.id, base.domainId)) - .where(ilike(ensIndexerSchema.domain.canonicalName, pattern)) - .as("baseDomains"); +export function filterByName( + base: BaseDomainSet, + filter: DomainsNameFilterValue | null | undefined, +): { named: BaseDomainSet; defaultOrderBy?: typeof DomainsOrderBy.$inferType } { + if (!filter) return { named: base }; + + if (filter.starts_with !== undefined && filter.starts_with !== null) { + return { named: filterByNameStartsWith(base, filter.starts_with), defaultOrderBy: "DEPTH" }; + } + + if (filter.in !== undefined && filter.in !== null) { + return { named: filterByNameIn(base, filter.in) }; + } + + if (filter.eq !== undefined && filter.eq !== null) { + return { named: filterByNameIn(base, [filter.eq]) }; + } + + return { named: base }; } 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 e05d5205d8..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,8 +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/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index be6395d6d1..46a17f18d4 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -5,11 +5,11 @@ import type { Address } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; -import { applyDomainsNameFilter } from "@/omnigraph-api/lib/find-domains/apply-name-filter"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; import { domainsBase, filterByCanonical, + filterByName, filterByOwner, filterByVersion, withOrderingMetadata, @@ -77,7 +77,7 @@ AccountRef.implement({ resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = domainsBase(); const owned = filterByOwner(base, parent.id); - const { base: named, defaultOrderBy } = applyDomainsNameFilter(owned, where?.name); + const { named, defaultOrderBy } = filterByName(owned, where?.name); const canonical = where?.canonical === true ? filterByCanonical(named) : named; const versioned = where?.version ? filterByVersion(canonical, where.version) : canonical; const domains = withOrderingMetadata(versioned); 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 625f52fe15..ca623e1a8f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -14,10 +14,10 @@ import { paginateByInt, } from "@/omnigraph-api/lib/connection-helpers"; import { cursors } from "@/omnigraph-api/lib/cursors"; -import { applyDomainsNameFilter } from "@/omnigraph-api/lib/find-domains/apply-name-filter"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; import { domainsBase, + filterByName, filterByParent, withOrderingMetadata, } from "@/omnigraph-api/lib/find-domains/layers"; @@ -232,7 +232,7 @@ DomainInterfaceRef.implement({ }, resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = filterByParent(domainsBase(), parent.id); - const { base: named, defaultOrderBy } = applyDomainsNameFilter(base, where?.name); + const { named, defaultOrderBy } = filterByName(base, where?.name); const domains = withOrderingMetadata(named); return resolveFindDomains(context, { domains, order, defaultOrderBy, ...connectionArgs }); diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index d737e6d79c..f012fe07e0 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -8,11 +8,11 @@ import { getRootRegistryId } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; -import { applyDomainsNameFilter } from "@/omnigraph-api/lib/find-domains/apply-name-filter"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; import { domainsBase, filterByCanonical, + filterByName, filterByVersion, withOrderingMetadata, } from "@/omnigraph-api/lib/find-domains/layers"; @@ -119,7 +119,7 @@ builder.queryType({ }, resolve: (_, { where, order, ...connectionArgs }, context) => { const base = domainsBase(); - const { base: named, defaultOrderBy } = applyDomainsNameFilter(base, where.name); + const { named, defaultOrderBy } = filterByName(base, where.name); const canonical = filterByCanonical(named); const versioned = where.version ? filterByVersion(canonical, where.version) : canonical; const domains = withOrderingMetadata(versioned); diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index da61af427c..e77c21c169 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -7,10 +7,10 @@ import type { RequiredAndNotNull, RequiredAndNull } from "@ensnode/ensnode-sdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; -import { applyDomainsNameFilter } from "@/omnigraph-api/lib/find-domains/apply-name-filter"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; import { domainsBase, + filterByName, filterByRegistry, withOrderingMetadata, } from "@/omnigraph-api/lib/find-domains/layers"; @@ -132,7 +132,7 @@ RegistryInterfaceRef.implement({ }, resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = filterByRegistry(domainsBase(), parent.id); - const { base: named, defaultOrderBy } = applyDomainsNameFilter(base, where?.name); + const { named, defaultOrderBy } = filterByName(base, where?.name); const domains = withOrderingMetadata(named); return resolveFindDomains(context, { domains, order, defaultOrderBy, ...connectionArgs }); }, From 6cc5008ab5aa672d9f5307e5ebdfc6c1393a0f52 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 14:23:35 -0500 Subject: [PATCH 04/14] fix: changeset, docs updates --- .changeset/domains-orderby-depth.md | 5 +++++ .../layers/filter-by-name-starts-with.ts | 5 +++-- .../ensdb/concepts/database-schemas.mdx | 22 ++++++++++++++----- 3 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 .changeset/domains-orderby-depth.md 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/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 index fe44902937..d1814feedd 100644 --- 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 @@ -13,8 +13,6 @@ import { type BaseDomainSet, selectBase } from "./base-domain-set"; * (e.g. `"vitalik.eth"`), same direction as user input — `"vitalik.et"` matches `"vitalik.eth"`, * `"vit"` matches `"vit.eth"`, `"vitalik.eth"`, etc. * - * Empty `startsWith` is rejected upstream by `DomainsNameFilter` input validation. - * * Ordering is handled by the resolver layer via `defaultOrderBy: "DEPTH"` from `filterByName` — * shorter names surface first (`vitalik.eth` over `vitalik.ethereum.foundation` for input * `"vitalik.et"`). @@ -23,6 +21,9 @@ import { type BaseDomainSet, selectBase } from "./base-domain-set"; * @param startsWith - Typeahead prefix (non-empty `InterpretedName` fragment) */ export function filterByNameStartsWith(base: BaseDomainSet, startsWith: string) { + // Sanity Check: this occurs at the GrahpQL 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}%`; 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..b3cdd19d1c 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`. In traversal-order (i.e. `[labelhash("eth"), labelhash("vitalik")]`). Maintained by `canonicality-db-helpers.ts`. | +| `canonical_path` | `text[]` | yes | Materialized Canonical Domain Path, `NULL` iff `canonical = false`. In traversal-order (i.e. `["eth"`'s `DomainId`, `"vitalik"`'s `DomainId]`). 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. From 6750340159f8f15b95cad9b6866ef4a64f3f7052 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 14:37:54 -0500 Subject: [PATCH 05/14] =?UTF-8?q?refactor:=20defaultOrderBy=20=E2=86=92=20?= =?UTF-8?q?defaultOrder=20({=20by,=20dir=20})?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter-supplied default ordering now carries both `by` and `dir` so a filter can fully drive ordering when the caller doesn't pass `order`. `name: { starts_with }` surfaces `{ by: DEPTH, dir: ASC }` for typeahead. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/find-domains/find-domains-resolver.ts | 14 +++++++------ .../lib/find-domains/layers/filter-by-name.ts | 20 +++++++++++++++---- .../src/omnigraph-api/schema/account.ts | 4 ++-- .../ensapi/src/omnigraph-api/schema/domain.ts | 4 ++-- apps/ensapi/src/omnigraph-api/schema/query.ts | 4 ++-- .../src/omnigraph-api/schema/registry.ts | 4 ++-- 6 files changed, 32 insertions(+), 18 deletions(-) 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 1545692349..0b537865cf 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 @@ -26,6 +26,7 @@ import type { OrderDirection } from "@/omnigraph-api/schema/order-direction"; import { DomainCursors } from "./domain-cursor"; import { cursorFilter, orderFindDomains } from "./find-domains-resolver-helpers"; +import type { DomainsDefaultOrder } from "./layers/filter-by-name"; import type { DomainOrderValue } from "./types"; /** @@ -82,7 +83,7 @@ export function resolveFindDomains( { domains, order, - defaultOrderBy, + defaultOrder, ...connectionArgs }: { /** @@ -91,14 +92,15 @@ export function resolveFindDomains( domains: DomainsWithOrderingMetadata; /** - * Optional ordering; falls back to `defaultOrderBy` or DOMAINS_DEFAULT_ORDER_BY + * Optional ordering. Each unset field falls back to `defaultOrder` then the + * `DOMAINS_DEFAULT_ORDER_*` constants. */ order?: FindDomainsOrderArg | undefined | null; /** - * Filter-supplied default ordering when the caller doesn't pass `order.by`. + * Filter-supplied default `(by, dir)` when the caller doesn't pass `order`. */ - defaultOrderBy?: typeof DomainsOrderBy.$inferType; + defaultOrder?: DomainsDefaultOrder; // relay connection args from t.connection first?: number | null; @@ -107,8 +109,8 @@ export function resolveFindDomains( after?: string | null; }, ) { - const orderBy = order?.by ?? defaultOrderBy ?? 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/filter-by-name.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts index c3dea0c35d..b5286aed45 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,6 +1,7 @@ import type { InterpretedName } from "enssdk"; import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain-inputs"; +import type { OrderDirection } from "@/omnigraph-api/schema/order-direction"; import type { BaseDomainSet } from "./base-domain-set"; import { filterByNameIn } from "./filter-by-name-in"; @@ -18,24 +19,35 @@ export interface DomainsNameFilterValue { in?: InterpretedName[] | null; } +/** + * Filter-supplied default `(by, dir)` applied when the caller doesn't pass `order`. + */ +export interface DomainsDefaultOrder { + by: typeof DomainsOrderBy.$inferType; + dir: typeof OrderDirection.$inferType; +} + /** * 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. * - * - `starts_with` → `filterByNameStartsWith` (typeahead). Surfaces `defaultOrderBy: "DEPTH"` so - * resolvers prefer shorter names when the caller doesn't specify an order. + * - `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, filter: DomainsNameFilterValue | null | undefined, -): { named: BaseDomainSet; defaultOrderBy?: typeof DomainsOrderBy.$inferType } { +): { named: BaseDomainSet; defaultOrder?: DomainsDefaultOrder } { if (!filter) return { named: base }; if (filter.starts_with !== undefined && filter.starts_with !== null) { - return { named: filterByNameStartsWith(base, filter.starts_with), defaultOrderBy: "DEPTH" }; + return { + named: filterByNameStartsWith(base, filter.starts_with), + defaultOrder: { by: "DEPTH", dir: "ASC" }, + }; } if (filter.in !== undefined && filter.in !== null) { diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 46a17f18d4..4e351d5c7f 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, defaultOrderBy } = filterByName(owned, where?.name); + const { named, defaultOrder } = filterByName(owned, where?.name); 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, defaultOrderBy, ...connectionArgs }); + return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index ca623e1a8f..3d82aea9ed 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, defaultOrderBy } = filterByName(base, where?.name); + const { named, defaultOrder } = filterByName(base, where?.name); const domains = withOrderingMetadata(named); - return resolveFindDomains(context, { domains, order, defaultOrderBy, ...connectionArgs }); + return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index f012fe07e0..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, defaultOrderBy } = 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, defaultOrderBy, ...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 e77c21c169..40fbd229bf 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, defaultOrderBy } = filterByName(base, where?.name); + const { named, defaultOrder } = filterByName(base, where?.name); const domains = withOrderingMetadata(named); - return resolveFindDomains(context, { domains, order, defaultOrderBy, ...connectionArgs }); + return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); }, }), From a28f036307b29d5ed6067c6c7c8a841b23c744a4 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 14:41:04 -0500 Subject: [PATCH 06/14] refactor: drop DomainsDefaultOrder, infer from DomainsOrderInput.$inferInput Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/find-domains/find-domains-resolver.ts | 4 ++-- .../lib/find-domains/layers/filter-by-name.ts | 13 ++----------- 2 files changed, 4 insertions(+), 13 deletions(-) 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 0b537865cf..29f17db1e8 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,12 +21,12 @@ 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 { DomainsDefaultOrder } from "./layers/filter-by-name"; import type { DomainOrderValue } from "./types"; /** @@ -100,7 +100,7 @@ export function resolveFindDomains( /** * Filter-supplied default `(by, dir)` when the caller doesn't pass `order`. */ - defaultOrder?: DomainsDefaultOrder; + defaultOrder?: typeof DomainsOrderInput.$inferInput; // relay connection args from t.connection first?: number | null; 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 b5286aed45..c26d088c44 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,7 +1,6 @@ import type { InterpretedName } from "enssdk"; -import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain-inputs"; -import type { OrderDirection } from "@/omnigraph-api/schema/order-direction"; +import type { DomainsOrderInput } from "@/omnigraph-api/schema/domain-inputs"; import type { BaseDomainSet } from "./base-domain-set"; import { filterByNameIn } from "./filter-by-name-in"; @@ -19,14 +18,6 @@ export interface DomainsNameFilterValue { in?: InterpretedName[] | null; } -/** - * Filter-supplied default `(by, dir)` applied when the caller doesn't pass `order`. - */ -export interface DomainsDefaultOrder { - by: typeof DomainsOrderBy.$inferType; - dir: typeof OrderDirection.$inferType; -} - /** * 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 @@ -40,7 +31,7 @@ export interface DomainsDefaultOrder { export function filterByName( base: BaseDomainSet, filter: DomainsNameFilterValue | null | undefined, -): { named: BaseDomainSet; defaultOrder?: DomainsDefaultOrder } { +): { named: BaseDomainSet; defaultOrder?: typeof DomainsOrderInput.$inferInput } { if (!filter) return { named: base }; if (filter.starts_with !== undefined && filter.starts_with !== null) { From f0c3de1300483009e2dc60c15669b7413fdb413c Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 14:43:32 -0500 Subject: [PATCH 07/14] refactor: order/defaultOrder use Partial Drops hand-rolled FindDomainsOrderArg; both args share the GraphQL input shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/find-domains/find-domains-resolver.ts | 15 ++------------- .../lib/find-domains/layers/filter-by-name.ts | 2 +- 2 files changed, 3 insertions(+), 14 deletions(-) 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 29f17db1e8..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 @@ -23,22 +23,11 @@ import { 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. * @@ -95,12 +84,12 @@ export function resolveFindDomains( * Optional ordering. Each unset field falls back to `defaultOrder` then the * `DOMAINS_DEFAULT_ORDER_*` constants. */ - order?: FindDomainsOrderArg | undefined | null; + order?: Partial | null; /** * Filter-supplied default `(by, dir)` when the caller doesn't pass `order`. */ - defaultOrder?: typeof DomainsOrderInput.$inferInput; + defaultOrder?: Partial; // relay connection args from t.connection first?: number | null; 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 c26d088c44..709856356d 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 @@ -31,7 +31,7 @@ export interface DomainsNameFilterValue { export function filterByName( base: BaseDomainSet, filter: DomainsNameFilterValue | null | undefined, -): { named: BaseDomainSet; defaultOrder?: typeof DomainsOrderInput.$inferInput } { +): { named: BaseDomainSet; defaultOrder?: Partial } { if (!filter) return { named: base }; if (filter.starts_with !== undefined && filter.starts_with !== null) { From ed565a9a604b545e88f7ea03da50aafe7158156e Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 14:51:41 -0500 Subject: [PATCH 08/14] fix: bot notes (loop 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Copilot: GrahpQL → GraphQL typo - Copilot: clarify head-first (root → leaf) wording on canonical_label_hash_path / canonical_path docs rows - CodeRabbit: cascade WHERE also checks canonical_path drift for parent-identity changes where label-hash path coincides across protocol roots Co-Authored-By: Claude Opus 4.7 (1M context) --- .../layers/filter-by-name-starts-with.ts | 8 ++++---- .../src/lib/ensv2/canonicality-db-helpers.ts | 17 +++++++++++++---- .../ensdb/concepts/database-schemas.mdx | 4 ++-- 3 files changed, 19 insertions(+), 10 deletions(-) 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 index d1814feedd..d820fa0dd1 100644 --- 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 @@ -13,15 +13,15 @@ import { type BaseDomainSet, selectBase } from "./base-domain-set"; * (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 `defaultOrderBy: "DEPTH"` from `filterByName` — - * shorter names surface first (`vitalik.eth` over `vitalik.ethereum.foundation` for input - * `"vitalik.et"`). + * 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 GrahpQL Input layer + // 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 diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 6c60833a76..3f1283f586 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -439,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. @@ -532,7 +535,13 @@ async function cascadeCanonicality( 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/services/ensdb/concepts/database-schemas.mdx b/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx index b3cdd19d1c..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 @@ -269,8 +269,8 @@ Domain-Resolver relations are tracked via the Protocol Acceleration plugin, not | `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`. In traversal-order (i.e. `[labelhash("eth"), labelhash("vitalik")]`). Maintained by `canonicality-db-helpers.ts`. | -| `canonical_path` | `text[]` | yes | Materialized Canonical Domain Path, `NULL` iff `canonical = false`. In traversal-order (i.e. `["eth"`'s `DomainId`, `"vitalik"`'s `DomainId]`). Maintained by `canonicality-db-helpers.ts`. | +| `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`. | From 8772588cdeb9444f3636153b84ca0cf4aff6b243 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 15:12:23 -0500 Subject: [PATCH 09/14] fix: bot notes (loop 2) - changeset for @ensnode/ensdb-sdk + ensindexer (canonicalPath / canonicalDepth columns + byCanonicalDepth index) - filter-by-name dispatcher reordered to match JSDoc; throws on empty filter Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/materialize-canonical-path.md | 6 ++++++ .../lib/find-domains/layers/filter-by-name.ts | 20 ++++++++----------- .../src/omnigraph-api/schema/account.ts | 2 +- .../ensapi/src/omnigraph-api/schema/domain.ts | 2 +- .../src/omnigraph-api/schema/registry.ts | 2 +- 5 files changed, 17 insertions(+), 15 deletions(-) create mode 100644 .changeset/materialize-canonical-path.md 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/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 709856356d..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,5 +1,7 @@ import type { InterpretedName } from "enssdk"; +import { toJson } from "@ensnode/ensnode-sdk"; + import type { DomainsOrderInput } from "@/omnigraph-api/schema/domain-inputs"; import type { BaseDomainSet } from "./base-domain-set"; @@ -30,24 +32,18 @@ export interface DomainsNameFilterValue { */ export function filterByName( base: BaseDomainSet, - filter: DomainsNameFilterValue | null | undefined, + filter: DomainsNameFilterValue | null, ): { named: BaseDomainSet; defaultOrder?: Partial } { - if (!filter) return { named: base }; + if (filter === null) return { named: base }; - if (filter.starts_with !== undefined && filter.starts_with !== null) { + 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 (filter.in !== undefined && filter.in !== null) { - return { named: filterByNameIn(base, filter.in) }; - } - - if (filter.eq !== undefined && filter.eq !== null) { - return { named: filterByNameIn(base, [filter.eq]) }; - } - - return { named: base }; + throw new Error(`Invariant(filterByName): expected 'filter' to not be empty: ${toJson(filter)}`); } diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 4e351d5c7f..9016c2c40f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -77,7 +77,7 @@ AccountRef.implement({ resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = domainsBase(); const owned = filterByOwner(base, parent.id); - const { named, defaultOrder } = 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); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 3d82aea9ed..52f2c73387 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -232,7 +232,7 @@ DomainInterfaceRef.implement({ }, resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = filterByParent(domainsBase(), parent.id); - const { named, defaultOrder } = filterByName(base, where?.name); + const { named, defaultOrder } = filterByName(base, where?.name ?? null); const domains = withOrderingMetadata(named); 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 40fbd229bf..d1e33abe6f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -132,7 +132,7 @@ RegistryInterfaceRef.implement({ }, resolve: (parent, { where, order, ...connectionArgs }, context) => { const base = filterByRegistry(domainsBase(), parent.id); - const { named, defaultOrder } = filterByName(base, where?.name); + const { named, defaultOrder } = filterByName(base, where?.name ?? null); const domains = withOrderingMetadata(named); return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); }, From e96a99cadc9dcbc753ed84ff0c993ba7404d1885 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 15:26:13 -0500 Subject: [PATCH 10/14] chore: remove parsePartialInterpretedName Unused since find-domains' name filter moved off the partial-name parsing path onto materialized canonicalName lookups. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/interpreted-names-and-labels.test.ts | 60 ------------------- .../src/lib/interpreted-names-and-labels.ts | 31 ---------- 2 files changed, 91 deletions(-) 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}. * From f0f16f143be26f726f69a936858a273f9c382f5d Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 15 May 2026 15:34:10 -0500 Subject: [PATCH 11/14] fix: DomainCursor.by JSDoc lists DEPTH Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ensapi/src/omnigraph-api/lib/find-domains/domain-cursor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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; From abf0b15adc31eb78e4e7921249cf5e6ffaead8cb Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Mon, 18 May 2026 12:43:40 +0400 Subject: [PATCH 12/14] Update docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sdk.mdx --- .../src/content/docs/docs/services/ensdb/usage/sdk.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dd370bc97d..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 @@ -42,7 +42,7 @@ const [vitalik] = await ensDb .from(ensIndexerSchema.domain) .where(eq(ensIndexerSchema.domain.canonicalName, "vitalik.eth")); -// Count an address's Domains, grouped by Domain type +// 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) From aa15b74f6ad73a92f51d4ea84c294932ed3e31f7 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Mon, 18 May 2026 12:43:50 +0400 Subject: [PATCH 13/14] Update docs/ensnode.io/src/content/docs/docs/services/ensdb/usage/sql.mdx --- .../src/content/docs/docs/services/ensdb/usage/sql.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0da5fc3e4c..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 @@ -45,7 +45,7 @@ them uniformly without branching by `type`. SELECT * FROM ensindexer_0.domains WHERE canonical_name = 'vitalik.eth'; --- Count an address's Domains, grouped by Domain type +-- 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; From 6fc3989985553986b76fb94145926457682cef01 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Mon, 18 May 2026 12:43:59 +0400 Subject: [PATCH 14/14] Update docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensdb.mdx --- .../content/docs/docs/integrate/integration-options/ensdb.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0782904406..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 @@ -43,7 +43,7 @@ SELECT * FROM ensindexer_0.domains WHERE canonical_name = 'vitalik.eth'; ``` -### Example: count an address's Domains by type +### Example: count an address's Domains by type (ENSv1 vs ENSv2) ```typescript import { count, eq } from "drizzle-orm";