diff --git a/.changeset/account-domains-canonical-semantics.md b/.changeset/account-domains-canonical-semantics.md new file mode 100644 index 0000000000..68af488bf4 --- /dev/null +++ b/.changeset/account-domains-canonical-semantics.md @@ -0,0 +1,5 @@ +--- +"ensapi": patch +--- + +**Omnigraph**: `AccountDomainsWhereInput.canonical` now filters on both `true` and `false` (previously `false` was a no-op). The `defaultValue: false` is dropped — clients omitting `canonical` will receive all Domains owned by the Account regardless of canonicality. Pass `canonical: true` for canonical-only or `canonical: false` for non-canonical-only. The underlying `DomainsWhere.canonical` in `resolveFindDomains` was generalized so `typeof === "boolean"` triggers the filter; `null`/`undefined` is "no filter". diff --git a/.changeset/find-domains-perf-indexes.md b/.changeset/find-domains-perf-indexes.md new file mode 100644 index 0000000000..8aaea63f4d --- /dev/null +++ b/.changeset/find-domains-perf-indexes.md @@ -0,0 +1,9 @@ +--- +"@ensnode/ensdb-sdk": patch +--- + +Add three btree indexes to the indexer schema to fix slow `Domain.subdomains`, `get-domain-by-interpreted-name`, and `Query.domains` paths: + +- `domain_resolver_relations(domain_id)` — secondary lookup off the PK so the namegraph-walk CTE can left-join by `domain_id` alone. +- `domains(registry_id, label_hash)` — composite (replaces the standalone `registry_id` index, which it subsumes via leading-column prefix). +- `domains(registry_id, left(canonical_name, 256), id)` — expression composite for registry-scoped `WHERE registry_id = X ORDER BY canonical_name LIMIT N` (the `Domain.subdomains` shape). The 256-char prefix bounds the index tuple under btree's per-tuple max; NAME-ordered queries must sort by the same `left(...)` expression for the planner to use this index for ordered scan. diff --git a/.changeset/sharp-towns-try.md b/.changeset/sharp-towns-try.md new file mode 100644 index 0000000000..a19b09196b --- /dev/null +++ b/.changeset/sharp-towns-try.md @@ -0,0 +1,5 @@ +--- +"ensindexer": patch +--- + +Basenames and Lineanames are now correctly canonicalized in the `unigraph` plugin. diff --git a/apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts index e23a9017ba..4609774095 100644 --- a/apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts +++ b/apps/ensapi/src/omnigraph-api/lib/connection-helpers.ts @@ -2,6 +2,7 @@ import { and, asc, desc, gt, lt } from "drizzle-orm"; import z from "zod/v4"; import { cursors } from "@/omnigraph-api/lib/cursors"; +import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; type Column = Parameters[0]; @@ -40,3 +41,19 @@ export const paginateByInt = ( */ export const orderPaginationBy = (column: Column, inverted: boolean) => inverted ? desc(column) : asc(column); + +/** + * An empty Relay Connection, used when short-circuiting connection resolvers. + */ +export const EMPTY_CONNECTION = lazyConnection({ + totalCount: async () => 0, + connection: async () => ({ + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }), +}); 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 2da03fce07..d0d927788c 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 @@ -1,23 +1,66 @@ import { asc, desc, type SQL, sql } from "drizzle-orm"; +import { ensIndexerSchema } from "@/lib/ensdb/singleton"; import type { DomainCursor } from "@/omnigraph-api/lib/find-domains/domain-cursor"; -import type { DomainsWithOrderingMetadata } from "@/omnigraph-api/lib/find-domains/layers/with-ordering-metadata"; import type { DomainsOrderBy } from "@/omnigraph-api/schema/domain-inputs"; import type { OrderDirection } from "@/omnigraph-api/schema/order-direction"; /** - * Get the order column for a given DomainsOrderBy value. + * Length cap (in characters) of the `canonical_name` prefix used by: + * 1. the `(registry_id, left(canonical_name, N), id)` composite btree on `domains`, + * 2. all NAME-ordered queries' ORDER BY expressions, and + * 3. the value stored in `DomainCursor.value` when ordering by NAME — pre-truncated at + * encode time via {@link truncateNameForCursor} so filter-time comparisons are simple + * tuple compares against the index expression with no per-row `left(...)` re-application. + * + * The btree per-tuple max is ~2712 bytes; with `registry_id` and `id` consuming ~240 bytes of + * that, ~2400 bytes remain for the prefix expression. 256 chars × max 4-byte UTF-8 codepoint = + * 1024 bytes, well under the limit and within the realm of reasonable name lengths (mainnet avg + * is ~126). Queries MUST sort by this same expression for the planner to use the index for + * ordered scan; raw `canonical_name` ORDER BY falls back to a full scan + sort. + * + * An alternative solution is to redefine InterpretedLabel to enforce a maximum byte length of 255 before + * being truncated into an Encoded LabelHash — this mirrors a name's resolvability (must be dns-encodable) + * and allows us to avoid storing spam names. Then we'd also have to produce a b-tree-indexed + * materializedCanonicalName field that's length-capped as well to fit the btree index. Then we could + * query against that column instead of the full InterpretedName. All of that would avoid this + * LEFT(...) expression index and the necessity for the query pattern to match the defined index + * (to avoid the full scan). */ -function getOrderColumn( - domains: DomainsWithOrderingMetadata, - orderBy: typeof DomainsOrderBy.$inferType, -) { - return { - NAME: domains.canonicalName, - DEPTH: domains.canonicalDepth, - REGISTRATION_TIMESTAMP: domains.registrationTimestamp, - REGISTRATION_EXPIRY: domains.registrationExpiry, - }[orderBy]; +export const CANONICAL_NAME_SORT_PREFIX = 256; + +/** + * Truncate a `canonicalName` to the cursor / index prefix length. Used when writing the cursor + * value for NAME orderings — callers slice once at encode time so the encoded cursor stays small + * (long names can hit thousands of characters) and `cursorFilter` can compare directly against + * the index expression without re-applying `left(...)` per row. + * + * Uses code-point iteration (`[...name]`) rather than `String.slice`, which counts UTF-16 code + * units and would split surrogate pairs. Postgres `left(text, N)` counts characters (code + * points), so this keeps the JS-side and DB-side prefixes byte-identical. + */ +export function truncateNameForCursor(name: string | null): string | null { + return name === null ? null : [...name].slice(0, CANONICAL_NAME_SORT_PREFIX).join(""); +} + +/** + * The order column / expression for each `DomainsOrderBy` value. + * + * Computed lazily using sql template so importing this module doesn't access the lazyProxy-backed + * `ensIndexerSchema` at module load time (test harnesses import it without env-driven DB + * config wired up). + */ +function getOrderColumn(orderBy: typeof DomainsOrderBy.$inferType): SQL { + switch (orderBy) { + case "NAME": + return sql`left(${ensIndexerSchema.domain.canonicalName}, ${sql.raw(String(CANONICAL_NAME_SORT_PREFIX))})`; + case "DEPTH": + return sql`${ensIndexerSchema.domain.canonicalDepth}`; + case "REGISTRATION_TIMESTAMP": + return sql`${ensIndexerSchema.registration.start}`; + case "REGISTRATION_EXPIRY": + return sql`${ensIndexerSchema.registration.expiry}`; + } } /** @@ -26,17 +69,14 @@ function getOrderColumn( * Uses tuple comparison for non-NULL cursor values, and explicit NULL handling * for NULL cursor values (since PostgreSQL tuple comparison with NULL yields NULL/unknown). * - * @param domains - The domains CTE * @param cursor - The decoded DomainCursor * @param queryOrderBy - The order field for the current query (must match cursor.by) * @param queryOrderDir - The order direction for the current query (must match cursor.dir) * @param direction - "after" for forward pagination, "before" for backward * @throws if cursor.by does not match queryOrderBy * @throws if cursor.dir does not match queryOrderDir - * @returns SQL expression for the cursor filter */ export function cursorFilter( - domains: DomainsWithOrderingMetadata, cursor: DomainCursor, queryOrderBy: typeof DomainsOrderBy.$inferType, queryOrderDir: typeof OrderDirection.$inferType, @@ -55,38 +95,28 @@ export function cursorFilter( ); } - const orderColumn = getOrderColumn(domains, cursor.by); + const orderColumn = getOrderColumn(cursor.by); - // Determine comparison direction: - // - "after" with ASC = greater than cursor - // - "after" with DESC = less than cursor - // - "before" with ASC = less than cursor - // - "before" with DESC = greater than cursor + // "after" with ASC and "before" with DESC both step forward in cursor order (greater-than). const useGreaterThan = (direction === "after") !== (queryOrderDir === "DESC"); + const op = sql.raw(useGreaterThan ? ">" : "<"); + const idCmp = sql`${ensIndexerSchema.domain.id} ${op} ${cursor.id}`; - // Handle NULL cursor values explicitly (PostgreSQL tuple comparison with NULL yields NULL/unknown) - // With NULLS LAST ordering: non-NULL values come before NULL values + // NULL cursor values need explicit handling because Postgres tuple comparison with NULL yields + // NULL/unknown. With NULLS LAST ordering, non-NULL values come before NULL values. if (cursor.value === null) { - if (direction === "after") { - // "after" a NULL = other NULLs with appropriate id comparison - return useGreaterThan - ? sql`(${orderColumn} IS NULL AND ${domains.id} > ${cursor.id})` - : sql`(${orderColumn} IS NULL AND ${domains.id} < ${cursor.id})`; - } else { - // "before" a NULL = all non-NULLs (they come before NULLs) + NULLs with appropriate id - return useGreaterThan - ? sql`(${orderColumn} IS NOT NULL OR (${orderColumn} IS NULL AND ${domains.id} > ${cursor.id}))` - : sql`(${orderColumn} IS NOT NULL OR (${orderColumn} IS NULL AND ${domains.id} < ${cursor.id}))`; - } + return direction === "after" + ? sql`(${orderColumn} IS NULL AND ${idCmp})` + : sql`(${orderColumn} IS NOT NULL OR (${orderColumn} IS NULL AND ${idCmp}))`; } - // Non-null cursor: use tuple comparison - // NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL - // NOTE: explicit cast required — Postgres can't infer parameter types in tuple comparisons - const op = useGreaterThan ? ">" : "<"; + // Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL. + // Explicit cast required — Postgres can't infer parameter types in tuple comparisons. const value = (() => { switch (cursor.by) { case "NAME": + // Already pre-truncated at encode time (see `truncateNameForCursor`), so this matches + // the index expression `left(canonical_name, CANONICAL_NAME_SORT_PREFIX)` directly. return sql`${cursor.value}::text`; case "DEPTH": return sql`${cursor.value}::int`; @@ -95,7 +125,8 @@ export function cursorFilter( return sql`${cursor.value}::numeric(78,0)`; } })(); - return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${value}, ${cursor.id})`; + + return sql`(${orderColumn}, ${ensIndexerSchema.domain.id}) ${op} (${value}, ${cursor.id})`; } /** @@ -110,13 +141,12 @@ export function isEffectiveDesc( } export function orderFindDomains( - domains: DomainsWithOrderingMetadata, orderBy: typeof DomainsOrderBy.$inferType, orderDir: typeof OrderDirection.$inferType, inverted: boolean, ): SQL[] { const effectiveDesc = isEffectiveDesc(orderDir, inverted); - const orderColumn = getOrderColumn(domains, orderBy); + const orderColumn = getOrderColumn(orderBy); // Always use NULLS LAST so unregistered domains (NULL registration fields) // appear at the end regardless of sort direction @@ -125,7 +155,9 @@ export function orderFindDomains( : sql`${orderColumn} ASC NULLS LAST`; // Always include id as tiebreaker for stable ordering - const tiebreaker = effectiveDesc ? desc(domains.id) : asc(domains.id); + const tiebreaker = effectiveDesc + ? desc(ensIndexerSchema.domain.id) + : asc(ensIndexerSchema.domain.id); return [primaryOrder, tiebreaker]; } 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 7ea15ef8f0..663c366f69 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 @@ -1,15 +1,19 @@ import { trace } from "@opentelemetry/api"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; -import { and, count } from "drizzle-orm"; +import { and, count, eq, ilike, inArray, type SQL, sql } from "drizzle-orm"; +import type { NormalizedAddress, RegistryId } from "enssdk"; -import { ensDb } from "@/lib/ensdb/singleton"; +import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; import { makeLogger } from "@/lib/logger"; import type { context as createContext } from "@/omnigraph-api/context"; -import type { - DomainsWithOrderingMetadata, - DomainsWithOrderingMetadataResult, -} from "@/omnigraph-api/lib/find-domains/layers/with-ordering-metadata"; +import { DomainCursors } from "@/omnigraph-api/lib/find-domains/domain-cursor"; +import { + cursorFilter, + orderFindDomains, + truncateNameForCursor, +} from "@/omnigraph-api/lib/find-domains/find-domains-resolver-helpers"; +import type { DomainOrderValue } from "@/omnigraph-api/lib/find-domains/types"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { rejectAnyErrors } from "@/omnigraph-api/lib/reject-any-errors"; import { @@ -17,94 +21,131 @@ import { PAGINATION_DEFAULT_PAGE_SIZE, } from "@/omnigraph-api/schema/constants"; import { type Domain, DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; -import { - DOMAINS_DEFAULT_ORDER_BY, - DOMAINS_DEFAULT_ORDER_DIR, - type DomainsOrderBy, - type DomainsOrderInput, +import type { + DomainsNameFilter, + DomainsOrderInput, + DomainsOrderValue, } from "@/omnigraph-api/schema/domain-inputs"; +import type { ENSProtocolVersion } from "@/omnigraph-api/schema/ens-protocol-version"; + +type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue }; + +const tracer = trace.getTracer("find-domains"); +const logger = makeLogger("find-domains"); -import { DomainCursors } from "./domain-cursor"; -import { cursorFilter, orderFindDomains } from "./find-domains-resolver-helpers"; -import type { DomainOrderValue } from "./types"; +const DOMAINS_DEFAULT_ORDER = { by: "NAME", dir: "ASC" } satisfies DomainsOrderValue; /** - * Domain with order value injected. + * Compound filter shape consumed by `resolveFindDomains`. Each property is optional; the resolver + * applies a flat compound WHERE over the `domains` table, opting in to the registration joins + * only when the corresponding order requires them. * - * @dev Relevant to composite DomainCursor encoding, see `domain-cursor.ts` + * @dev all of these are nullable to streamline usage with the inferred input types used in these + * resolvers. all null and undefined values are coerced to 'no filter'. */ -type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue }; +export interface DomainsWhere { + ownerId?: NormalizedAddress | null; + registryId?: RegistryId | null; + canonical?: boolean | null; + name?: typeof DomainsNameFilter.$inferInput | null; + version?: typeof ENSProtocolVersion.$inferType | null; +} -const tracer = trace.getTracer("find-domains"); -const logger = makeLogger("find-domains-resolver"); +const VERSION_TO_DOMAIN_TYPE: Record< + typeof ENSProtocolVersion.$inferType, + (typeof ensIndexerSchema.domainType.enumValues)[number] +> = { + ENSv1: "ENSv1Domain", + ENSv2: "ENSv2Domain", +}; /** - * Extract the order value from a findDomains result row based on the orderBy field. + * Build the SQL condition for `where.name`. */ -function getOrderValueFromResult( - result: DomainsWithOrderingMetadataResult, - orderBy: typeof DomainsOrderBy.$inferType, -): DomainOrderValue { - switch (orderBy) { - case "NAME": - return result.canonicalName; - case "DEPTH": - return result.canonicalDepth; - case "REGISTRATION_TIMESTAMP": - return result.registrationTimestamp; - case "REGISTRATION_EXPIRY": - return result.registrationExpiry; +function nameCondition(filter: typeof DomainsNameFilter.$inferInput): SQL { + if (filter.starts_with) { + return ilike(ensIndexerSchema.domain.canonicalName, `${filter.starts_with}%`); + } + + if (filter.eq) { + return eq(ensIndexerSchema.domain.canonicalName, filter.eq); + } + + if (filter.in) { + // NOTE: avoid inArray([]) runtime error by short-circuit to an explicit empty result + if (filter.in.length === 0) return sql`false`; + return inArray(ensIndexerSchema.domain.canonicalName, filter.in); } + + throw new Error( + "Invariant(nameCondition): empty filter provided, should not be possible with GraphQL @oneOf directive.", + ); } /** - * GraphQL API resolver for domain connection queries. Accepts a pre-built domains CTE - * ({@link DomainsWithOrderingMetadata}) and handles cursor-based pagination, ordering, and - * dataloader loading. - * - * Used by Query.domains, Account.domains, Registry.domains, and Domain.subdomains. + * Surface a default order when the name filter is a typeahead prefix — shorter names first so + * `vitalik.eth` outranks `vitalik.ethereum.foundation` for input `"vitalik.et"`. + */ +function getDefaultOrder(where: DomainsWhere | undefined | null): DomainsOrderValue { + if (where?.name?.starts_with) return { by: "DEPTH", dir: "ASC" }; + return DOMAINS_DEFAULT_ORDER; +} + +/** + * GraphQL API resolver for domain connection queries. Builds a single flat SELECT over + * `domains` with conditional joins (parent registry / registration) driven by the supplied + * `where` filters and ordering. Handles cursor-based pagination, ordering, and dataloader + * loading. Used by `Query.domains`, `Account.domains`, `Registry.domains`, and `Domain.subdomains`. * * @param context - The GraphQL Context, required for Dataloader access - * @param args - The domains CTE, optional ordering, and relay connection args + * @param args - Compound `where` filter, optional ordering, and relay connection args */ export function resolveFindDomains( context: ReturnType, { - domains, + where, order, - defaultOrder, ...connectionArgs }: { - /** - * Pre-built domains CTE from `withOrderingMetadata` - */ - domains: DomainsWithOrderingMetadata; - - /** - * Optional ordering. Each unset field falls back to `defaultOrder` then the - * `DOMAINS_DEFAULT_ORDER_*` constants. - */ - order?: Partial | null; - - /** - * Filter-supplied default `(by, dir)` when the caller doesn't pass `order`. - */ - defaultOrder?: Partial; - - // relay connection args from t.connection + where?: DomainsWhere | null; + order?: typeof DomainsOrderInput.$inferInput | null; first?: number | null; last?: number | null; before?: string | null; after?: string | null; }, ) { - const orderBy = order?.by ?? defaultOrder?.by ?? DOMAINS_DEFAULT_ORDER_BY; - const orderDir = order?.dir ?? defaultOrder?.dir ?? DOMAINS_DEFAULT_ORDER_DIR; + const defaultOrder = getDefaultOrder(where); + const orderBy = order?.by ?? defaultOrder.by; + const orderDir = order?.dir ?? defaultOrder.dir; + + const needsRegistrationJoin = + orderBy === "REGISTRATION_TIMESTAMP" || orderBy === "REGISTRATION_EXPIRY"; + + const filterConditions = and( + // by ownerId + where?.ownerId ? eq(ensIndexerSchema.domain.ownerId, where.ownerId) : undefined, + // by registryId + where?.registryId ? eq(ensIndexerSchema.domain.registryId, where.registryId) : undefined, + // by canonical + where?.canonical !== undefined && where?.canonical !== null + ? eq(ensIndexerSchema.domain.canonical, where.canonical) + : undefined, + // by name + where?.name ? nameCondition(where.name) : undefined, + // by version + where?.version + ? eq(ensIndexerSchema.domain.type, VERSION_TO_DOMAIN_TYPE[where.version]) + : undefined, + ); return lazyConnection({ totalCount: () => withActiveSpanAsync(tracer, "find-domains.totalCount", {}, async () => { - const rows = await ensDb.with(domains).select({ count: count() }).from(domains); + const rows = await ensDb + .select({ count: count() }) + .from(ensIndexerSchema.domain) + .where(filterConditions); return rows[0].count; }), @@ -123,43 +164,72 @@ export function resolveFindDomains( args: connectionArgs, }, async ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) => { - // build order clauses - const orderClauses = orderFindDomains(domains, orderBy, orderDir, inverted); + const orderClauses = orderFindDomains(orderBy, orderDir, inverted); - // decode cursors for keyset pagination const beforeCursor = before ? DomainCursors.decode(before) : undefined; const afterCursor = after ? DomainCursors.decode(after) : undefined; - // build query with pagination constraints - const query = ensDb - .with(domains) - .select() - .from(domains) + // SELECT only `id` plus the active order column when it requires a JOIN. NAME/DEPTH + // order values are read back from the dataloader-hydrated Domain — for those orderings + // the keyset query stays narrow enough for an index-only scan against the composite + // indexes on `domains`. + const registrationValueColumn = (() => { + switch (orderBy) { + case "REGISTRATION_TIMESTAMP": + return ensIndexerSchema.registration.start; + case "REGISTRATION_EXPIRY": + return ensIndexerSchema.registration.expiry; + default: + return sql`NULL`.as("registration_value"); + } + })(); + + let query = ensDb + .select({ + id: ensIndexerSchema.domain.id, + registrationValue: registrationValueColumn, + }) + .from(ensIndexerSchema.domain) + .$dynamic(); + + if (needsRegistrationJoin) { + query = query + .leftJoin( + ensIndexerSchema.latestRegistrationIndex, + eq(ensIndexerSchema.latestRegistrationIndex.domainId, ensIndexerSchema.domain.id), + ) + .leftJoin( + ensIndexerSchema.registration, + and( + eq(ensIndexerSchema.registration.domainId, ensIndexerSchema.domain.id), + eq( + ensIndexerSchema.registration.registrationIndex, + ensIndexerSchema.latestRegistrationIndex.registrationIndex, + ), + ), + ); + } + + const finalQuery = query .where( and( - beforeCursor - ? cursorFilter(domains, beforeCursor, orderBy, orderDir, "before") - : undefined, - afterCursor - ? cursorFilter(domains, afterCursor, orderBy, orderDir, "after") - : undefined, + filterConditions, + beforeCursor ? cursorFilter(beforeCursor, orderBy, orderDir, "before") : undefined, + afterCursor ? cursorFilter(afterCursor, orderBy, orderDir, "after") : undefined, ), ) .orderBy(...orderClauses) .limit(limit); - // log the generated SQL for debugging - logger.debug({ sql: query.toSQL() }); + logger.debug({ sql: finalQuery.toSQL() }); - // execute paginated query const results = await withActiveSpanAsync( tracer, "find-domains.connection", { orderBy, orderDir, limit }, - () => query.execute(), + () => finalQuery.execute(), ); - // load Domain entities via dataloader const loadedDomains = await withActiveSpanAsync( tracer, "find-domains.dataloader", @@ -172,18 +242,30 @@ export function resolveFindDomains( ), ); - // map results by id for faster order value lookup - const orderValueById = new Map( - results.map((r) => [r.id, getOrderValueFromResult(r, orderBy)]), - ); + const registrationValueById = needsRegistrationJoin + ? new Map(results.map((r) => [r.id, r.registrationValue ?? null])) + : null; - // inject order values into each result so that it can be encoded into the cursor - // (see DomainCursor for more information) return loadedDomains.map((domain): DomainWithOrderValue => { - const __orderValue = orderValueById.get(domain.id); - if (__orderValue === undefined) - throw new Error(`Never: guaranteed to be DomainOrderValue`); - + const __orderValue: DomainOrderValue = (() => { + switch (orderBy) { + case "NAME": + return truncateNameForCursor(domain.canonicalName); + case "DEPTH": + return domain.canonicalDepth; + case "REGISTRATION_TIMESTAMP": + case "REGISTRATION_EXPIRY": + // `registrationValueById` is populated iff `needsRegistrationJoin` is true, + // which is exactly the REGISTRATION_* arms here. `loadedDomains` is keyed by + // the same ids as `results`, so the lookup is guaranteed to hit. + if (registrationValueById === null) { + throw new Error( + `Invariant: registrationValueById should be populated when orderBy=${orderBy}`, + ); + } + return registrationValueById.get(domain.id) ?? null; + } + })(); return { ...domain, __orderValue }; }); }, 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 deleted file mode 100644 index 76725a1035..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; -import type { DomainId, InterpretedName, NormalizedAddress, RegistryId } from "enssdk"; - -import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; - -/** - * The type of the base domain set subquery. - */ -export type BaseDomainSet = ReturnType; - -export type DomainType = (typeof ensIndexerSchema.domainType.enumValues)[number]; - -/** - * Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata. - * - * `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. - */ -export function domainsBase() { - // alias for parent Registry / parent Domain joins so we can reference them distinctly from - // the base Domain's own `registryId` column. - const parentRegistry = alias(ensIndexerSchema.registry, "parentRegistry"); - const parentDomain = alias(ensIndexerSchema.domain, "parentDomain"); - return ( - ensDb - .select({ - domainId: sql`${ensIndexerSchema.domain.id}`.as("domainId"), - type: sql`${ensIndexerSchema.domain.type}`.as("type"), - ownerId: sql`${ensIndexerSchema.domain.ownerId}`.as("ownerId"), - registryId: sql`${ensIndexerSchema.domain.registryId}`.as("registryId"), - parentId: sql`${parentDomain.id}`.as("parentId"), - canonical: sql`${ensIndexerSchema.domain.canonical}`.as("canonical"), - labelHash: sql`${ensIndexerSchema.domain.labelHash}`.as("labelHash"), - canonicalName: sql`${ensIndexerSchema.domain.canonicalName}`.as( - "canonicalName", - ), - canonicalDepth: sql`${ensIndexerSchema.domain.canonicalDepth}`.as( - "canonicalDepth", - ), - }) - .from(ensIndexerSchema.domain) - // walk up to the parent Registry by this Domain's `registryId`, then to the parent Domain - // it points at, requiring `parentDomain.subregistryId` to agree back. The two joins + - // agreement predicate are the bidirectional canonical-edge check. - .leftJoin(parentRegistry, eq(parentRegistry.id, ensIndexerSchema.domain.registryId)) - .leftJoin( - parentDomain, - and( - eq(parentDomain.id, parentRegistry.canonicalDomainId), - eq(parentDomain.subregistryId, parentRegistry.id), - ), - ) - .as("baseDomains") - ); -} - -/** - * Select all columns from a base domain set subquery. Use this in filter layers - * to produce a select with the same shape as the base. - */ -export function selectBase(base: BaseDomainSet) { - return { - domainId: base.domainId, - type: base.type, - ownerId: base.ownerId, - registryId: base.registryId, - parentId: base.parentId, - canonical: base.canonical, - labelHash: base.labelHash, - canonicalName: base.canonicalName, - canonicalDepth: base.canonicalDepth, - }; -} diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-canonical.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-canonical.ts deleted file mode 100644 index 88f0300c2e..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-canonical.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { eq } from "drizzle-orm"; - -import { ensDb } from "@/lib/ensdb/singleton"; - -import { type BaseDomainSet, selectBase } from "./base-domain-set"; - -/** - * Filter a base domain set to only include Canonical Domains. - */ -export function filterByCanonical(base: BaseDomainSet) { - return ensDb - .select(selectBase(base)) - .from(base) - .where(eq(base.canonical, true)) - .as("baseDomains"); -} 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 deleted file mode 100644 index 5f5a648872..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-in.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { inArray, sql } from "drizzle-orm"; -import type { InterpretedName } from "enssdk"; - -import { ensDb } from "@/lib/ensdb/singleton"; - -import { type BaseDomainSet, selectBase } from "./base-domain-set"; - -/** - * Filter a base domain set to Domains whose materialized `canonicalName` exactly matches one of - * `names`. Validation (max-length, etc.) is enforced at the GraphQL input layer. - * - * Non-canonical rows have `canonicalName = NULL`, so they cannot match by construction — no - * separate root-anchoring guard is required. - * - * @param base - A base domain set subquery - * @param names - Exact InterpretedNames to match against - */ -export function filterByNameIn(base: BaseDomainSet, names: InterpretedName[]) { - // NOTE: avoid inArray([]) runtime error by short-circuit to an explicit empty result - if (names.length === 0) { - return ensDb.select(selectBase(base)).from(base).where(sql`false`).as("baseDomains"); - } - - return ensDb - .select(selectBase(base)) - .from(base) - .where(inArray(base.canonicalName, names)) - .as("baseDomains"); -} diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-starts-with.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-starts-with.ts deleted file mode 100644 index d820fa0dd1..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name-starts-with.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ilike } from "drizzle-orm"; - -import { ensDb } from "@/lib/ensdb/singleton"; - -import { type BaseDomainSet, selectBase } from "./base-domain-set"; - -/** - * Filter a base domain set to Domains whose materialized `canonicalName` starts with the user's - * typeahead input. Used by the `name: { starts_with }` filter on - * `Query.domains` / `Account.domains` / `Registry.domains` / `Domain.subdomains`. - * - * Match semantics: `canonicalName ILIKE startsWith || '%'`. canonicalName is leaf-first - * (e.g. `"vitalik.eth"`), same direction as user input — `"vitalik.et"` matches `"vitalik.eth"`, - * `"vit"` matches `"vit.eth"`, `"vitalik.eth"`, etc. - * - * Ordering is handled by the resolver layer via `defaultOrder: { by: "DEPTH", dir: "ASC" }` from - * `filterByName` — shorter names surface first (`vitalik.eth` over `vitalik.ethereum.foundation` - * for input `"vitalik.et"`). - * - * @param base - A base domain set subquery - * @param startsWith - Typeahead prefix (non-empty `InterpretedName` fragment) - */ -export function filterByNameStartsWith(base: BaseDomainSet, startsWith: string) { - // Sanity Check: this occurs at the GraphQL Input layer - if (startsWith === "") throw new Error(`filterByNameStartsWith startsWith expected`); - - // TODO: determine if it's necessary to additionally escape user input for LIKE operator - // NOTE: for ai agents: we intentionally leave this as a TODO, STOP commenting on it - const pattern = `${startsWith}%`; - - return ensDb - .select(selectBase(base)) - .from(base) - .where(ilike(base.canonicalName, pattern)) - .as("baseDomains"); -} diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts deleted file mode 100644 index f57465c690..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts +++ /dev/null @@ -1,49 +0,0 @@ -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"; -import { filterByNameIn } from "./filter-by-name-in"; -import { filterByNameStartsWith } from "./filter-by-name-starts-with"; - -/** - * 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 `{ named: base }` unchanged when `filter` is - * nullish. - * - * - `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, -): { named: BaseDomainSet; defaultOrder?: Partial } { - if (filter === null) return { named: base }; - - if (filter.starts_with) { - return { - named: filterByNameStartsWith(base, filter.starts_with), - defaultOrder: { by: "DEPTH", dir: "ASC" }, - }; - } - if (filter.eq) return { named: filterByNameIn(base, [filter.eq]) }; - if (filter.in) return { named: filterByNameIn(base, filter.in) }; - - throw new Error(`Invariant(filterByName): expected 'filter' to not be empty: ${toJson(filter)}`); -} diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-owner.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-owner.ts deleted file mode 100644 index 34476dfd27..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-owner.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { eq } from "drizzle-orm"; -import type { NormalizedAddress } from "enssdk"; - -import { ensDb } from "@/lib/ensdb/singleton"; - -import { type BaseDomainSet, selectBase } from "./base-domain-set"; - -/** - * Filter a base domain set by owner address. - */ -export function filterByOwner(base: BaseDomainSet, owner: NormalizedAddress) { - return ensDb // - .select(selectBase(base)) - .from(base) - .where(eq(base.ownerId, owner)) - .as("baseDomains"); -} diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-parent.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-parent.ts deleted file mode 100644 index 1e7f8210d5..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-parent.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { eq } from "drizzle-orm"; -import type { DomainId } from "enssdk"; - -import { ensDb } from "@/lib/ensdb/singleton"; - -import { type BaseDomainSet, selectBase } from "./base-domain-set"; - -/** - * Filter a base domain set to children of a specific parent domain. - */ -export function filterByParent(base: BaseDomainSet, parentId: DomainId) { - return ensDb - .select(selectBase(base)) - .from(base) - .where(eq(base.parentId, parentId)) - .as("baseDomains"); -} diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-registry.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-registry.ts deleted file mode 100644 index 4ee3394fef..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-registry.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { eq } from "drizzle-orm"; -import type { RegistryId } from "enssdk"; - -import { ensDb } from "@/lib/ensdb/singleton"; - -import { type BaseDomainSet, selectBase } from "./base-domain-set"; - -/** - * Filter a base domain set to domains belonging to a specific Registry. - */ -export function filterByRegistry(base: BaseDomainSet, registryId: RegistryId) { - return ensDb - .select(selectBase(base)) - .from(base) - .where(eq(base.registryId, registryId)) - .as("baseDomains"); -} diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-version.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-version.ts deleted file mode 100644 index 1629af85d8..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-version.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { eq } from "drizzle-orm"; - -import { ensDb } from "@/lib/ensdb/singleton"; -import type { ENSProtocolVersion } from "@/omnigraph-api/schema/ens-protocol-version"; - -import { type BaseDomainSet, type DomainType, selectBase } from "./base-domain-set"; - -const VERSION_TO_DOMAIN_TYPE: Record = { - ENSv1: "ENSv1Domain", - ENSv2: "ENSv2Domain", -}; - -/** - * Filter a base domain set by ENS protocol version (ENSv1 or ENSv2). - */ -export function filterByVersion( - base: BaseDomainSet, - version: typeof ENSProtocolVersion.$inferType, -) { - return ensDb - .select(selectBase(base)) - .from(base) - .where(eq(base.type, VERSION_TO_DOMAIN_TYPE[version])) - .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 deleted file mode 100644 index 9bb62f2016..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { BaseDomainSet, DomainType } from "./base-domain-set"; -export { domainsBase, selectBase } from "./base-domain-set"; -export { filterByCanonical } from "./filter-by-canonical"; -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"; -export { filterByVersion } from "./filter-by-version"; -export { withOrderingMetadata } from "./with-ordering-metadata"; 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 deleted file mode 100644 index a05f84ea44..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/with-ordering-metadata.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; -import type { DomainId, InterpretedName } from "enssdk"; - -import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; - -import type { BaseDomainSet } from "./base-domain-set"; - -export type DomainsWithOrderingMetadata = ReturnType; - -/** - * Type of row from `withOrderingMetadata` - * - * @dev should be able to derive this from drizzle, right?? - */ -export type DomainsWithOrderingMetadataResult = { - id: DomainId; - canonicalName: InterpretedName | null; - canonicalDepth: number | null; - registrationTimestamp: bigint | null; - registrationExpiry: bigint | null; -}; - -/** - * Enrich a base domain set with ordering metadata. - * - * Joins latestRegistrationIndex → registration for registration-based ordering. canonicalName / - * canonicalDepth pass through from the base set for NAME / DEPTH ordering. - * - * Returns a CTE suitable for cursor-based pagination. - * - * @param base - A base domain set (output of any filter layer) - */ -export function withOrderingMetadata(base: BaseDomainSet) { - const domains = ensDb - .select({ - id: sql`${base.domainId}`.as("id"), - - // for NAME / DEPTH ordering - canonicalName: base.canonicalName, - canonicalDepth: base.canonicalDepth, - - // for REGISTRATION_TIMESTAMP ordering (materialized on registration) - registrationTimestamp: ensIndexerSchema.registration.start, - - // for REGISTRATION_EXPIRY ordering - registrationExpiry: ensIndexerSchema.registration.expiry, - }) - .from(base) - // join latestRegistrationIndex - .leftJoin( - ensIndexerSchema.latestRegistrationIndex, - eq(ensIndexerSchema.latestRegistrationIndex.domainId, base.domainId), - ) - // join (latest) Registration - .leftJoin( - ensIndexerSchema.registration, - and( - eq(ensIndexerSchema.registration.domainId, base.domainId), - eq( - ensIndexerSchema.registration.registrationIndex, - ensIndexerSchema.latestRegistrationIndex.registrationIndex, - ), - ), - ); - - return ensDb.$with("domains").as(domains); -} diff --git a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts index f7d9f1275b..8092e31d09 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts @@ -25,7 +25,7 @@ import { import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; -import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; +import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; const tracer = trace.getTracer("get-domain-by-interpreted-name"); @@ -177,7 +177,8 @@ async function forwardWalkDisjointNamegraph(registryId: RegistryId, path: LabelH // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 const rawLabelHashPathArray = sql`${new Param(path)}::text[]`; - const result = await ensDb.execute(sql` + const result = await withSpanAsync(tracer, "forward-walk", { registryId, path }, () => + ensDb.execute(sql` WITH RECURSIVE path AS ( SELECT ${registryId}::text AS next_registry_id, @@ -209,7 +210,8 @@ async function forwardWalkDisjointNamegraph(registryId: RegistryId, path: LabelH ON drr.domain_id = path."domainId" WHERE path."domainId" IS NOT NULL ORDER BY path.depth DESC; - `); + `), + ); return result.rows as unknown as WalkResultRow[]; } diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 7953e60928..f61c9eade1 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -6,14 +6,6 @@ import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; -import { - domainsBase, - filterByCanonical, - filterByName, - filterByOwner, - filterByVersion, - withOrderingMetadata, -} from "@/omnigraph-api/lib/find-domains/layers"; import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-resolver"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; @@ -75,15 +67,12 @@ AccountRef.implement({ where: t.arg({ type: AccountDomainsWhereInput }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (parent, { where, order, ...connectionArgs }, context) => { - const base = domainsBase(); - const owned = filterByOwner(base, parent.id); - const { named, defaultOrder } = filterByName(owned, where?.name ?? null); - const canonical = where?.canonical === true ? filterByCanonical(named) : named; - const versioned = where?.version ? filterByVersion(canonical, where.version) : canonical; - const domains = withOrderingMetadata(versioned); - return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); - }, + resolve: (parent, { where, order, ...connectionArgs }, context) => + resolveFindDomains(context, { + where: { ...where, ownerId: parent.id }, + order, + ...connectionArgs, + }), }), ////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts index ba334b9048..3334b22450 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts @@ -1,6 +1,6 @@ import { builder } from "@/omnigraph-api/builder"; import { ENSProtocolVersion } from "@/omnigraph-api/schema/ens-protocol-version"; -import { OrderDirection } from "@/omnigraph-api/schema/order-direction"; +import { OrderDirection, type OrderDirectionValue } from "@/omnigraph-api/schema/order-direction"; ////////////////////// // Inputs @@ -111,8 +111,7 @@ export const AccountDomainsWhereInput = builder.inputType("AccountDomainsWhereIn }), canonical: t.boolean({ description: - "Optional, defaults to false. If true, filters the set of Domains by those that are Canonical (i.e. reachable by ENS Forward Resolution).", - defaultValue: false, + "If set, filters the set of Domains by canonicality (i.e. reachability by ENS Forward Resolution): `true` for Canonical only, `false` for non-Canonical only. If omitted, returns all Domains owned by the Account regardless of canonicality.", }), version: t.field({ type: ENSProtocolVersion, @@ -161,5 +160,4 @@ export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { }), }); -export const DOMAINS_DEFAULT_ORDER_BY: typeof DomainsOrderBy.$inferType = "NAME"; -export const DOMAINS_DEFAULT_ORDER_DIR: typeof OrderDirection.$inferType = "ASC"; +export type DomainsOrderValue = { by: DomainsOrderByValue; dir: OrderDirectionValue }; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index cd25c2c4f1..1645b66319 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -9,18 +9,13 @@ import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { withSpanAsync } from "@/lib/instrumentation/auto-span"; import { builder } from "@/omnigraph-api/builder"; import { + EMPTY_CONNECTION, orderPaginationBy, paginateBy, paginateByInt, } from "@/omnigraph-api/lib/connection-helpers"; import { cursors } from "@/omnigraph-api/lib/cursors"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; -import { - domainsBase, - filterByName, - filterByParent, - withOrderingMetadata, -} from "@/omnigraph-api/lib/find-domains/layers"; import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-resolver"; import { getLatestRegistration } from "@/omnigraph-api/lib/get-latest-registration"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; @@ -230,11 +225,13 @@ DomainInterfaceRef.implement({ order: t.arg({ type: DomainsOrderInput }), }, resolve: (parent, { where, order, ...connectionArgs }, context) => { - const base = filterByParent(domainsBase(), parent.id); - const { named, defaultOrder } = filterByName(base, where?.name ?? null); - const domains = withOrderingMetadata(named); + if (!parent.subregistryId) return EMPTY_CONNECTION; - return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); + return resolveFindDomains(context, { + where: { ...where, registryId: parent.subregistryId }, + order, + ...connectionArgs, + }); }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 21ad410a2f..9eca56bfeb 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -9,13 +9,6 @@ import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; -import { - domainsBase, - filterByCanonical, - filterByName, - filterByVersion, - withOrderingMetadata, -} from "@/omnigraph-api/lib/find-domains/layers"; import { getDomainIdByInterpretedName } from "@/omnigraph-api/lib/get-domain-by-interpreted-name"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { AccountByInput, AccountRef } from "@/omnigraph-api/schema/account"; @@ -117,15 +110,8 @@ builder.queryType({ where: t.arg({ type: DomainsWhereInput, required: true }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (_, { where, order, ...connectionArgs }, context) => { - const base = domainsBase(); - 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, defaultOrder, ...connectionArgs }); - }, + resolve: (_, { where, order, ...connectionArgs }, context) => + resolveFindDomains(context, { where, order, ...connectionArgs }), }), ////////////////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index d1e33abe6f..ce73aaad05 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -8,12 +8,6 @@ import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; import { resolveFindDomains } from "@/omnigraph-api/lib/find-domains/find-domains-resolver"; -import { - domainsBase, - filterByName, - filterByRegistry, - withOrderingMetadata, -} from "@/omnigraph-api/lib/find-domains/layers"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { AccountIdInput, AccountIdRef } from "@/omnigraph-api/schema/account-id"; @@ -130,12 +124,12 @@ RegistryInterfaceRef.implement({ where: t.arg({ type: RegistryDomainsWhereInput }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (parent, { where, order, ...connectionArgs }, context) => { - const base = filterByRegistry(domainsBase(), parent.id); - const { named, defaultOrder } = filterByName(base, where?.name ?? null); - const domains = withOrderingMetadata(named); - return resolveFindDomains(context, { domains, order, defaultOrder, ...connectionArgs }); - }, + resolve: (parent, { where, order, ...connectionArgs }, context) => + resolveFindDomains(context, { + where: { ...where, registryId: parent.id }, + order, + ...connectionArgs, + }), }), //////////////////////// diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/ENSv1Registry.ts index 2bf44863e3..b0647d1cbf 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/ENSv1Registry.ts @@ -20,6 +20,7 @@ import { interpretAddress, PluginName, } from "@ensnode/ensnode-sdk"; +import { isBridgedTargetRegistry, isBridgeOriginDomain } from "@ensnode/ensnode-sdk/internal"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { @@ -128,11 +129,27 @@ export default function () { }); const parentDomainId = makeENSv1DomainId(registry, parentNode); - // route through handleSubregistryUpdated so any prior subregistry edge (e.g. a bridged - // attachment) is properly reconciled instead of orphaned by a blind overwrite. - await handleSubregistryUpdated(context, parentDomainId, parentRegistryId); - await handleRegistryCanonicalDomainUpdated(context, parentRegistryId, parentDomainId); + // The bridged-resolver canonical edge owns both `Domain.subregistryId` on a bridge origin + // (L1, e.g. mainnet `linea.eth` → L2 Lineanames Registry) and `Registry.canonicalDomainId` + // on a bridged target (L2, e.g. Lineanames Registry → mainnet `linea.eth`). Chain-local + // subname events would otherwise clobber whichever pointer matches the current chain: + // - L1 NewOwner for a subname of `linea.eth` would reset `linea.eth.subregistryId` to + // the mainnet virtual registry. + // - L2 NewOwner for any Lineaname subname would reset the L2 bridged Registry's + // `canonicalDomainId` to the L2-side `linea.eth` Domain. + // Either clobber breaks the bidirectional agreement and de-canonicalizes the bridged + // subtree. Skip the corresponding write when the parent matches a known bridge endpoint. + + // only update subregistry if this is not the origin domain + if (!isBridgeOriginDomain(config.namespace, parentDomainId)) { + await handleSubregistryUpdated(context, parentDomainId, parentRegistryId); + } + + // only update canonical domain if this is not a target registry + if (!isBridgedTargetRegistry(config.namespace, parentRegistryId)) { + await handleRegistryCanonicalDomainUpdated(context, parentRegistryId, parentDomainId); + } } const ownerId = interpretAddress(owner); 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 2f6758c0ba..f4a266ee4e 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 @@ -274,7 +274,7 @@ Domain-Resolver relations are tracked via the Protocol Acceleration plugin, not | `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`, `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). +**Indexes:** `type`, `subregistry_id` (partial: non-null only), `owner_id`, `label_hash`, `(registry_id, label_hash)` (composite; leading-column prefix also serves `WHERE registry_id = X` lookups, so no separate `registry_id` index is needed), `(registry_id, left(canonical_name, 256), id)` (composite expression index for registry-scoped `WHERE registry_id = X ORDER BY canonical_name LIMIT N` — the `Domain.subdomains` shape; the 256-char prefix bounds the index tuple under btree's per-tuple max, and NAME-ordered queries must sort by the same `left(...)` expression for the planner to use this index for ordered scan), `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. @@ -453,6 +453,8 @@ It is keyed by `(chain_id, address, domain_id)` to match the on-chain data model **Primary key:** `(chain_id, address, domain_id)`. +**Indexes:** `domain_id` — secondary lookup off the PK. The namegraph-walk recursive CTE in `get-domain-by-interpreted-name` left-joins on `domain_id` alone, which the PK (leading-column `chain_id, address`) cannot serve. + **Relations:** has one `resolver` via `(chain_id, resolver)`. #### `resolvers` diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index cb15944536..02db93746e 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -338,10 +338,26 @@ export const domain = onchainTable( }), (t) => ({ byType: index().on(t.type), - byRegistry: index().on(t.registryId), bySubregistry: index().on(t.subregistryId).where(sql`${t.subregistryId} IS NOT NULL`), byOwner: index().on(t.ownerId), byLabelHash: index().on(t.labelHash), + // Composite for `(registry_id, label_hash)` lookups (namegraph walk in + // get-domain-by-interpreted-name.ts). The leading `registry_id` column also serves + // `WHERE registry_id = X` lookups via prefix scan. + byRegistryAndLabelHash: index().on(t.registryId, t.labelHash), + + // composite for `WHERE registry_id = X ORDER BY canonical_name LIMIT N` (Domain.subdomains + // and other find-domains queries when ordering by NAME). Uses `left(canonical_name, 256)` + // to bound the index tuple under btree's per-tuple max (~2712 bytes): 256 chars × max 4-byte + // UTF-8 = 1024 bytes, leaving ample room for the registry_id and id columns. Names beyond + // 256 chars (currently <0.0001% of mainnet) collide on the truncated prefix and tie-break by + // id; this is acceptable since such names are invariably spam. Callers MUST sort by the same + // expression for the planner to use this index for ordered scan. + byRegistryAndCanonicalNameLeft: index().on( + t.registryId, + sql`left(${t.canonicalName}, 256)`, + t.id, + ), // hash index avoids the btree 8191-byte row-size hazard for spam names byCanonicalNameExact: index().using("hash", t.canonicalName), diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts index b958853cb5..5bfe038158 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts @@ -12,7 +12,7 @@ import type { ResolverId, ResolverRecordsId, } from "enssdk"; -import { onchainTable, primaryKey, relations, uniqueIndex } from "ponder"; +import { index, onchainTable, primaryKey, relations, uniqueIndex } from "ponder"; /** * Tracks an Account's ENSIP-19 Reverse Name Records by CoinType. @@ -69,6 +69,7 @@ export const domainResolverRelation = onchainTable( }), (t) => ({ pk: primaryKey({ columns: [t.chainId, t.address, t.domainId] }), + byDomain: index().on(t.domainId), }), ); diff --git a/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts index 70892a28a1..2fa2e69c48 100644 --- a/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts +++ b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts @@ -130,3 +130,32 @@ export function isBridgedResolver( ) ?? null ); } + +/** + * Returns true iff `domainId` is the origin Domain of a known Bridged Resolver. + * + * `Domain.subregistryId` on a bridge origin (e.g. mainnet `base.eth`, `linea.eth`) is owned by + * `handleBridgedResolverChange` — it must point at the bridged target Registry on the L2 chain + * so the canonical edge to that target Registry can agree. Chain-local subname events on the + * origin Domain must not overwrite that pointer. + */ +export function isBridgeOriginDomain(namespace: ENSNamespaceId, domainId: DomainId): boolean { + return getBridgedResolverConfigs(namespace).some((config) => config.originDomainId === domainId); +} + +/** + * Returns true iff `registryId` is the target Registry of a known Bridged Resolver. + * + * `Registry.canonicalDomainId` on a bridged target (e.g. the Basenames/Lineanames L2 Registries) + * is owned by `handleBridgedResolverChange` — it must point at the mainnet origin Domain so the + * canonical edge can agree. Chain-local subname events on the target Registry must not overwrite + * that pointer with the L2-side Domain ID. + */ +export function isBridgedTargetRegistry( + namespace: ENSNamespaceId, + registryId: RegistryId, +): boolean { + return getBridgedResolverConfigs(namespace).some( + (config) => config.targetRegistryId === registryId, + ); +} diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index e73acc6fdb..0b8e345252 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -389,8 +389,7 @@ const introspection = { "type": { "kind": "SCALAR", "name": "Boolean" - }, - "defaultValue": "false" + } }, { "name": "name", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index e6cb769f69..4efba1e910 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -46,9 +46,9 @@ type AccountDomainsConnectionEdge { """Filter for Account.domains query.""" input AccountDomainsWhereInput { """ - Optional, defaults to false. If true, filters the set of Domains by those that are Canonical (i.e. reachable by ENS Forward Resolution). + If set, filters the set of Domains by canonicality (i.e. reachability by ENS Forward Resolution): `true` for Canonical only, `false` for non-Canonical only. If omitted, returns all Domains owned by the Account regardless of canonicality. """ - canonical: Boolean = false + canonical: Boolean """If set, filters the set of Domains by name.""" name: DomainsNameFilter