From 212e91cee90e48ac1eaa6dd211f7d6815118a48e Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 4 Jun 2026 14:47:07 -0500 Subject: [PATCH 1/6] perf(ensapi): index-accelerate registration-ordered domain queries Materialize the latest registration's start/expiry onto Domain as NOT NULL, sentinel-backed sort columns with (registry_id, col, id) composites, turning REGISTRATION_TIMESTAMP / REGISTRATION_EXPIRY ordering from a join + full-sort (~55s on .eth subdomains) into an index-ordered scan. Hydrate find-domains results directly via a relational query (skip the dataloader round-trip), and add a "most recently registered subdomains" Omnigraph example. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../recently-registered-subdomains-index.md | 7 + .../find-domains-resolver-helpers.ts | 42 +- .../lib/find-domains/find-domains-resolver.ts | 140 ++--- .../src/omnigraph-api/schema/account.ts | 12 +- .../src/omnigraph-api/schema/domain-inputs.ts | 27 +- .../ensapi/src/omnigraph-api/schema/domain.ts | 7 +- apps/ensapi/src/omnigraph-api/schema/query.ts | 7 +- .../src/omnigraph-api/schema/registry.ts | 12 +- .../find-domains/test-domain-pagination.ts | 37 +- .../src/lib/ensv2/registration-db-helpers.ts | 35 ++ .../unigraph/handlers/ensv1/BaseRegistrar.ts | 9 +- .../unigraph/handlers/ensv1/NameWrapper.ts | 14 +- .../unigraph/handlers/ensv2/ENSv2Registry.ts | 20 +- .../src/data/omnigraph-examples/examples.json | 28 + .../src/data/omnigraph-examples/meta.ts | 5 + .../data/omnigraph-examples/responses.json | 76 +++ .../data/omnigraph-examples/schema.graphql | 593 +++++++++++++++++- .../src/data/omnigraph-examples/snapshot.json | 4 +- .../ensindexer-abstract/unigraph.schema.ts | 54 ++ .../src/omnigraph-api/example-queries.ts | 21 + .../src/omnigraph/generated/schema.graphql | 45 +- packages/ensskills/skills/omnigraph/SKILL.md | 35 ++ 22 files changed, 1052 insertions(+), 178 deletions(-) create mode 100644 .changeset/recently-registered-subdomains-index.md diff --git a/.changeset/recently-registered-subdomains-index.md b/.changeset/recently-registered-subdomains-index.md new file mode 100644 index 0000000000..6e9b7fbf85 --- /dev/null +++ b/.changeset/recently-registered-subdomains-index.md @@ -0,0 +1,7 @@ +--- +"@ensnode/ensdb-sdk": patch +"ensindexer": patch +"ensapi": patch +--- + +Index-accelerate `REGISTRATION_TIMESTAMP` / `REGISTRATION_EXPIRY`-ordered domain queries (e.g. `Domain.subdomains(order: { by: REGISTRATION_TIMESTAMP, dir: DESC })`). Previously these joined `domains → latest_registration_indexes → registrations` and sorted the full registry partition — ~55s for `.eth`'s subdomains. The latest registration's start/expiry is now mirrored onto the Domain row (`__latestRegistrationStart` / `__latestRegistrationExpiry`) with composite indexes `(registry_id, , id)`, turning the query into an index-ordered scan. The sort columns are NOT NULL — an absent value (no registration, or a never-expiring registration) is materialized as a `+∞` sentinel — so a single plain composite per column serves both directions with a plain keyset tuple, and the sentinel sorts last for ASC and first for DESC. 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 513950f549..09856e6ecf 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 @@ -58,12 +58,25 @@ function getOrderColumn(orderBy: typeof DomainsOrderBy.$inferType): SQL { case "DEPTH": return sql`${ensIndexerSchema.domain.canonicalDepth}`; case "REGISTRATION_TIMESTAMP": - return sql`${ensIndexerSchema.registration.start}`; + return sql`${ensIndexerSchema.domain.__latestRegistrationStart}`; case "REGISTRATION_EXPIRY": - return sql`${ensIndexerSchema.registration.expiry}`; + return sql`${ensIndexerSchema.domain.__latestRegistrationExpiry}`; } } +/** + * Whether this is a registration ordering, whose sort columns (`Domain.__latestRegistration*`) are + * sentinel-backed and NOT NULL (see `REGISTRATION_SORT_SENTINEL`). + * + * Because those columns never hold NULL, the ORDER BY omits any NULLS clause so a single plain + * `(registry_id, , id)` composite serves both directions (ASC forward / DESC backward) with a + * plain keyset tuple. The sentinel sorts last for ASC ("oldest" / "expiring soonest") and first for + * DESC. NAME / DEPTH columns are nullable and keep their NULLS-LAST behavior. + */ +function isRegistrationOrdering(orderBy: typeof DomainsOrderBy.$inferType): boolean { + return orderBy === "REGISTRATION_TIMESTAMP" || orderBy === "REGISTRATION_EXPIRY"; +} + /** * Build a cursor filter for keyset pagination on findDomains results. * @@ -105,7 +118,9 @@ export function cursorFilter( const idCmp = sql`${ensIndexerSchema.domain.id} ${op} ${cursor.id}`; // 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. + // NULL/unknown. Reached only for NAME/DEPTH (whose columns are nullable, NULLS LAST); registration + // sort columns are sentinel-backed NOT NULL, so their cursor value is never null. With NULLS LAST, + // non-NULL values come before NULL values. if (cursor.value === null) { return direction === "after" ? sql`(${orderColumn} IS NULL AND ${idCmp})` @@ -124,6 +139,11 @@ export function cursorFilter( return sql`${cursor.value}::int`; case "REGISTRATION_TIMESTAMP": case "REGISTRATION_EXPIRY": + // Ponder's `t.bigint()` columns are `numeric(78,0)` (they hold EVM uint256 values, e.g. the + // uint64-max "never expires" expiry sentinel), so the materialized `__latestRegistration*` + // columns are numeric too. Cast the cursor value to the same type: it matches the column + // exactly (no `col::…` coercion) so the keyset tuple compare stays an Index Cond, and it + // avoids the `::bigint` overflow on values beyond int8 range. return sql`${cursor.value}::numeric(78,0)`; } })(); @@ -150,11 +170,17 @@ export function orderFindDomains( const effectiveDesc = isEffectiveDesc(orderDir, inverted); const orderColumn = getOrderColumn(orderBy); - // Always use NULLS LAST so unregistered domains (NULL registration fields) - // appear at the end regardless of sort direction - const primaryOrder = effectiveDesc - ? sql`${orderColumn} DESC NULLS LAST` - : sql`${orderColumn} ASC NULLS LAST`; + // Registration sort columns are sentinel-backed NOT NULL, so the ORDER BY omits any NULLS clause — + // that lets the plain `(registry_id, , id)` composite serve both directions (ASC forward / + // DESC backward); the sentinel (+∞) sorts last for ASC and first for DESC. NAME / DEPTH columns + // are nullable and keep NULLS LAST in both directions. + const primaryOrder = isRegistrationOrdering(orderBy) + ? effectiveDesc + ? sql`${orderColumn} DESC` + : sql`${orderColumn} ASC` + : effectiveDesc + ? sql`${orderColumn} DESC NULLS LAST` + : sql`${orderColumn} ASC NULLS LAST`; const { ensIndexerSchema } = di.context; // Always include id as tiebreaker for stable ordering 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 0fdb08f2fd..0f87ff2792 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 @@ -6,7 +6,6 @@ import type { NormalizedAddress, RegistryId } from "enssdk"; import di from "@/di"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; import { makeLogger } from "@/lib/logger"; -import type { Context } from "@/omnigraph-api/context"; import { DomainCursors } from "@/omnigraph-api/lib/find-domains/domain-cursor"; import { cursorFilter, @@ -15,12 +14,11 @@ import { } 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 { PAGINATION_DEFAULT_MAX_SIZE, PAGINATION_DEFAULT_PAGE_SIZE, } from "@/omnigraph-api/schema/constants"; -import { type Domain, DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; +import type { Domain } from "@/omnigraph-api/schema/domain"; import type { DomainsNameFilter, DomainsOrderInput, @@ -93,36 +91,29 @@ function getDefaultOrder(where: DomainsWhere | undefined | null): DomainsOrderVa } /** - * 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`. + * GraphQL API resolver for domain connection queries. Builds a single flat SELECT over `domains` + * (filters and ordering both resolve against `domains` columns) and hydrates fully-formed Domain + * rows in keyset order. Handles cursor-based pagination and ordering. Used by `Query.domains`, + * `Account.domains`, `Registry.domains`, and `Domain.subdomains`. * - * @param context - The GraphQL Context, required for Dataloader access * @param args - Compound `where` filter, optional ordering, and relay connection args */ -export function resolveFindDomains( - context: Context, - { - where, - order, - ...connectionArgs - }: { - where?: DomainsWhere | null; - order?: typeof DomainsOrderInput.$inferInput | null; - first?: number | null; - last?: number | null; - before?: string | null; - after?: string | null; - }, -) { +export function resolveFindDomains({ + where, + order, + ...connectionArgs +}: { + where?: DomainsWhere | null; + order?: typeof DomainsOrderInput.$inferInput | null; + first?: number | null; + last?: number | null; + before?: string | null; + after?: string | null; +}) { 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 { ensIndexerSchema } = di.context; const filterConditions = and( @@ -173,84 +164,32 @@ export function resolveFindDomains( const beforeCursor = before ? DomainCursors.decode(before) : undefined; const afterCursor = after ? DomainCursors.decode(after) : undefined; - // 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"); - } - })(); - + // Hydrate Domains directly: every order value lives on `domains` and the only eagerly + // loaded relation is `label`, so a single relational query (mirroring the Domain + // dataloader's `with: { label: true }`) returns fully-formed Domain rows in keyset order — + // no second round-trip through the dataloader. The keyset/order scan still rides the + // `domains` composite indexes; `label` is joined only for the `LIMIT`ed rows. const { ensDb } = di.context; - 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( - filterConditions, - beforeCursor ? cursorFilter(beforeCursor, orderBy, orderDir, "before") : undefined, - afterCursor ? cursorFilter(afterCursor, orderBy, orderDir, "after") : undefined, - ), - ) - .orderBy(...orderClauses) - .limit(limit); + const finalQuery = ensDb.query.domain.findMany({ + where: and( + filterConditions, + beforeCursor ? cursorFilter(beforeCursor, orderBy, orderDir, "before") : undefined, + afterCursor ? cursorFilter(afterCursor, orderBy, orderDir, "after") : undefined, + ), + orderBy: orderClauses, + limit, + with: { label: true }, + }); logger.debug({ sql: finalQuery.toSQL() }); - const results = await withActiveSpanAsync( + const loadedDomains = await withActiveSpanAsync( tracer, "find-domains.connection", { orderBy, orderDir, limit }, - () => finalQuery.execute(), + () => finalQuery, ); - const loadedDomains = await withActiveSpanAsync( - tracer, - "find-domains.dataloader", - { count: results.length }, - () => - rejectAnyErrors( - DomainInterfaceRef.getDataloader(context).loadMany( - results.map((result) => result.id), - ), - ), - ); - - const registrationValueById = needsRegistrationJoin - ? new Map(results.map((r) => [r.id, r.registrationValue ?? null])) - : null; - return loadedDomains.map((domain): DomainWithOrderValue => { const __orderValue: DomainOrderValue = (() => { switch (orderBy) { @@ -259,16 +198,9 @@ export function resolveFindDomains( case "DEPTH": return domain.canonicalDepth; case "REGISTRATION_TIMESTAMP": + return domain.__latestRegistrationStart; 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.__latestRegistrationExpiry; } })(); return { ...domain, __orderValue }; diff --git a/apps/ensapi/src/omnigraph-api/schema/account.ts b/apps/ensapi/src/omnigraph-api/schema/account.ts index 783d832b3f..e6020042bd 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.ts @@ -17,7 +17,11 @@ import { RESOLVE_ACCELERATE_ARG, } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; -import { AccountDomainsWhereInput, DomainsOrderInput } from "@/omnigraph-api/schema/domain-inputs"; +import { + AccountDomainsWhereInput, + DOMAINS_ORDERING_DESCRIPTION, + DomainsOrderInput, +} from "@/omnigraph-api/schema/domain-inputs"; import { EventRef } from "@/omnigraph-api/schema/event"; import { AccountEventsWhereInput } from "@/omnigraph-api/schema/event-inputs"; import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions"; @@ -115,14 +119,14 @@ AccountRef.implement({ // Account.domains //////////////////// domains: t.connection({ - description: "The Domains that are owned by the Account.", + description: `The Domains that are owned by the Account. ${DOMAINS_ORDERING_DESCRIPTION}`, type: DomainInterfaceRef, args: { where: t.arg({ type: AccountDomainsWhereInput }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (parent, { where, order, ...connectionArgs }, context) => - resolveFindDomains(context, { + resolve: (parent, { where, order, ...connectionArgs }) => + resolveFindDomains({ 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 3334b22450..d742126b33 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts @@ -146,12 +146,35 @@ export const SubdomainsWhereInput = builder.inputType("SubdomainsWhereInput", { ////////////////////// export const DomainsOrderBy = builder.enumType("DomainsOrderBy", { - description: "Fields by which domains can be ordered", - values: ["NAME", "DEPTH", "REGISTRATION_TIMESTAMP", "REGISTRATION_EXPIRY"] as const, + description: "Fields by which domains can be ordered.", + values: { + NAME: { description: "Order by the Domain's Canonical Name, alphabetically." }, + DEPTH: { + description: "Order by Canonical Name depth (number of labels); e.g. `eth` < `vitalik.eth`.", + }, + REGISTRATION_TIMESTAMP: { + description: + "Order by the start time of the Domain's latest Registration. A Domain with no Registration has no timestamp and sorts last when `dir: ASC` (“oldest registered first”) and first when `dir: DESC` (“most recently registered first”).", + }, + REGISTRATION_EXPIRY: { + description: + "Order by the expiry of the Domain's latest Registration. A Domain that never expires (or has no Registration) is treated as +∞: it sorts last when `dir: ASC` (“expiring soonest first”) and first when `dir: DESC` (“expiring latest first”).", + }, + }, }); export type DomainsOrderByValue = typeof DomainsOrderBy.$inferType; +/** + * Shared description fragment documenting ordering / NULL-placement behavior, appended to every + * find-domains-based connection field (Query.domains, Account.domains, Registry.domains, + * Domain.subdomains). Kept in one place so the semantics are documented exactly once. + */ +export const DOMAINS_ORDERING_DESCRIPTION = + "Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or " + + "REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration " + + "treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`."; + export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { description: "Ordering options for domains query. If no order is provided, the default is ASC.", fields: (t) => ({ diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 8c56dec249..beec7d262b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -37,6 +37,7 @@ import { } from "@/omnigraph-api/schema/constants"; import { DomainCanonicalRef } from "@/omnigraph-api/schema/domain-canonical"; import { + DOMAINS_ORDERING_DESCRIPTION, DomainPermissionsWhereInput, DomainsOrderInput, SubdomainsWhereInput, @@ -279,16 +280,16 @@ DomainInterfaceRef.implement({ // Domain.subdomains ///////////////////// subdomains: t.connection({ - description: "All Domains that are direct descendants of this Domain in the namegraph.", + description: `All Domains that are direct descendants of this Domain in the namegraph. ${DOMAINS_ORDERING_DESCRIPTION}`, type: DomainInterfaceRef, args: { where: t.arg({ type: SubdomainsWhereInput }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (parent, { where, order, ...connectionArgs }, context) => { + resolve: (parent, { where, order, ...connectionArgs }) => { if (!parent.subregistryId) return EMPTY_CONNECTION; - return resolveFindDomains(context, { + return resolveFindDomains({ 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 324d1d9dd2..445bbed274 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -14,6 +14,7 @@ import { AccountByInput, AccountRef } from "@/omnigraph-api/schema/account"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; import { + DOMAINS_ORDERING_DESCRIPTION, DomainIdInput, DomainsOrderInput, DomainsWhereInput, @@ -106,14 +107,14 @@ builder.queryType({ // Find Domains //////////////// domains: t.connection({ - description: "Find Canonical Domains by Name.", + description: `Find Canonical Domains by Name. ${DOMAINS_ORDERING_DESCRIPTION}`, type: DomainInterfaceRef, args: { where: t.arg({ type: DomainsWhereInput, required: true }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (_, { where, order, ...connectionArgs }, context) => - resolveFindDomains(context, { where, order, ...connectionArgs }), + resolve: (_, { where, order, ...connectionArgs }) => + resolveFindDomains({ where, order, ...connectionArgs }), }), ////////////////////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index d38d641e46..2c1155ae80 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -13,7 +13,11 @@ import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; import { AccountIdInput, AccountIdRef } from "@/omnigraph-api/schema/account-id"; import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain"; -import { DomainsOrderInput, RegistryDomainsWhereInput } from "@/omnigraph-api/schema/domain-inputs"; +import { + DOMAINS_ORDERING_DESCRIPTION, + DomainsOrderInput, + RegistryDomainsWhereInput, +} from "@/omnigraph-api/schema/domain-inputs"; import { PermissionsRef } from "@/omnigraph-api/schema/permissions"; /////////////////////////////////// @@ -121,14 +125,14 @@ RegistryInterfaceRef.implement({ // Registry.domains ////////////////////// domains: t.connection({ - description: "The Domains managed by this Registry.", + description: `The Domains managed by this Registry. ${DOMAINS_ORDERING_DESCRIPTION}`, type: DomainInterfaceRef, args: { where: t.arg({ type: RegistryDomainsWhereInput }), order: t.arg({ type: DomainsOrderInput }), }, - resolve: (parent, { where, order, ...connectionArgs }, context) => - resolveFindDomains(context, { + resolve: (parent, { where, order, ...connectionArgs }) => + resolveFindDomains({ where: { ...where, registryId: parent.id }, order, ...connectionArgs, 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 96c4fe4d3a..65f189a70e 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 @@ -58,22 +58,37 @@ function assertOrdering( ) { const values = domains.map((n) => getSortValue(n, by)); + // Registration orderings use SQL-default NULL placement (ASC → last, DESC → first); NAME/DEPTH + // keep NULLS LAST in both directions. See find-domains-resolver-helpers.ts. + const nullsFirst = + (by === "REGISTRATION_TIMESTAMP" || by === "REGISTRATION_EXPIRY") && dir === "DESC"; + for (let i = 0; i < values.length - 1; i++) { const a = values[i]; const b = values[i + 1]; - // nulls sort last regardless of direction - if (a === null) { - // a is null => b must also be null (everything after should be null) - expect( - b, - `expected null at index ${i + 1} because index ${i} was null (nulls last)`, - ).toBeNull(); - continue; - } - if (b === null) { + if (nullsFirst) { + // nulls first: a non-null must not be followed by a null + if (b === null) { + expect( + a, + `expected null at index ${i} because index ${i + 1} was null (nulls first)`, + ).toBeNull(); + continue; + } + // a is null, b is non-null => fine (null sorts first) + if (a === null) continue; + } else { + // nulls last: a null must be followed only by nulls + if (a === null) { + expect( + b, + `expected null at index ${i + 1} because index ${i} was null (nulls last)`, + ).toBeNull(); + continue; + } // a is non-null, b is null => fine (null sorts last) - continue; + if (b === null) continue; } if (by === "NAME") { diff --git a/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts index 779c7f0602..3463300302 100644 --- a/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts @@ -1,5 +1,7 @@ import { type DomainId, makeRegistrationId, makeRenewalId } from "enssdk"; +import { REGISTRATION_SORT_SENTINEL } from "@ensnode/ensdb-sdk/ensindexer-abstract"; + import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; /** @@ -55,6 +57,39 @@ export async function insertLatestRegistration( .insert(ensIndexerSchema.latestRegistrationIndex) .values({ domainId, registrationIndex }) .onConflictDoUpdate({ registrationIndex }); + + // materialize Domain.__latestRegistration* (absent expiry → sentinel; columns are NOT NULL) + await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set({ + __latestRegistrationStart: values.start, + __latestRegistrationExpiry: values.expiry ?? REGISTRATION_SORT_SENTINEL, + }); +} + +/** + * Updates the expiry of a Domain's latest Registration. + * + * @dev materializes Domain.__latestRegistrationExpiry + * @dev callers MUST pass the Domain's _latest_ Registration; we don't validate that the provided + * `registrationId` is actually the latest + */ +export async function updateLatestRegistrationExpiry( + context: IndexingEngineContext, + { + domainId, + registrationId, + expiry, + }: { + domainId: DomainId; + registrationId: ReturnType; + expiry: bigint | null; + }, +) { + await context.ensDb.update(ensIndexerSchema.registration, { id: registrationId }).set({ expiry }); + + // mirror onto the Domain sort column (absent expiry → sentinel; column is NOT NULL) + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ __latestRegistrationExpiry: expiry ?? REGISTRATION_SORT_SENTINEL }); } /** diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/BaseRegistrar.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/BaseRegistrar.ts index f6f359eeb6..a4dd89d171 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/BaseRegistrar.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/BaseRegistrar.ts @@ -23,6 +23,7 @@ import { getLatestRegistration, insertLatestRegistration, insertLatestRenewal, + updateLatestRegistrationExpiry, } from "@/lib/ensv2/registration-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; import { @@ -237,9 +238,11 @@ export default function () { const duration = expiry - registration.expiry; // update the registration - await context.ensDb - .update(ensIndexerSchema.registration, { id: registration.id }) - .set({ expiry }); + await updateLatestRegistrationExpiry(context, { + domainId, + registrationId: registration.id, + expiry, + }); // insert Renewal const eventId = await ensureEvent(context, event); diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/NameWrapper.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/NameWrapper.ts index beedc194b7..2c3068a046 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/NameWrapper.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/NameWrapper.ts @@ -32,6 +32,7 @@ import { getLatestRegistration, insertLatestRegistration, insertLatestRenewal, + updateLatestRegistrationExpiry, } from "@/lib/ensv2/registration-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; import { @@ -299,7 +300,9 @@ export default function () { }); } else { // otherwise, deactivate the latest registration by setting its expiry to this block - await context.ensDb.update(ensIndexerSchema.registration, { id: registration.id }).set({ + await updateLatestRegistrationExpiry(context, { + domainId, + registrationId: registration.id, expiry: event.block.timestamp, }); } @@ -375,9 +378,12 @@ export default function () { ); } - await context.ensDb - .update(ensIndexerSchema.registration, { id: registration.id }) - .set({ expiry }); + // update Registration expiry + await updateLatestRegistrationExpiry(context, { + domainId, + registrationId: registration.id, + expiry, + }); // push event to domain history const eventId = await ensureEvent(context, event); diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts index e9f9059ab7..99c2d19898 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts @@ -31,6 +31,7 @@ import { ensureLabel } from "@/lib/ensv2/label-db-helpers"; import { getLatestRegistration, insertLatestRegistration, + updateLatestRegistrationExpiry, } from "@/lib/ensv2/registration-db-helpers"; import { ensureRegistry } from "@/lib/ensv2/registry-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; @@ -196,10 +197,15 @@ export default function () { // unregistering a label just immediately sets its expiration to event.block.timestamp, which // effectively removes it from resolution (which interprets expired names as non-existent) const unregistrantId = await ensureAccount(context, unregistrant); - await context.ensDb.update(ensIndexerSchema.registration, { id: registration.id }).set({ + // set expiry to now (+ materialize Domain.__latestRegistrationExpiry) + await updateLatestRegistrationExpiry(context, { + domainId, + registrationId: registration.id, expiry: event.block.timestamp, - unregistrantId, }); + await context.ensDb + .update(ensIndexerSchema.registration, { id: registration.id }) + .set({ unregistrantId }); // NOTE(shrugs): PermissionedRegistry also increments eacVersionId and tokenVersionId if there was a // previous owner, but i'm not sure if we need to handle that detail here @@ -243,10 +249,12 @@ export default function () { ); } - // update Registration - await context.ensDb - .update(ensIndexerSchema.registration, { id: registration.id }) - .set({ expiry }); + // update Registration (and its materialized mirror on the Domain) + await updateLatestRegistrationExpiry(context, { + domainId, + registrationId: registration.id, + expiry, + }); // push event to domain history const senderId = await ensureAccount(context, sender); diff --git a/docs/ensnode.io/src/data/omnigraph-examples/examples.json b/docs/ensnode.io/src/data/omnigraph-examples/examples.json index 99ac34e43f..822c5542a4 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/examples.json +++ b/docs/ensnode.io/src/data/omnigraph-examples/examples.json @@ -31,6 +31,13 @@ "name": "roppp.eth" } }, + { + "id": "domain-records", + "query": "query DomainRecords(\n $name: InterpretedName!\n) {\n domain(by: { name: $name }) {\n canonical { name { interpreted } }\n resolve {\n records {\n addresses(coinTypes: [60]) { coinType address }\n texts(keys: [\"description\"]) { key value }\n }\n }\n }\n}", + "variables": { + "name": "roppp.eth" + } + }, { "id": "domain-subdomains", "query": "query DomainSubdomains($name: InterpretedName!) {\n domain(by: {name: $name}) {\n canonical { name { interpreted beautified } }\n subdomains(first: 10) {\n edges {\n node {\n canonical { name { interpreted beautified } }\n }\n }\n }\n }\n}", @@ -38,6 +45,13 @@ "name": "eth" } }, + { + "id": "domain-subdomains-recently-registered", + "query": "query RecentlyRegisteredSubdomains($name: InterpretedName!) {\n domain(by: {name: $name}) {\n canonical { name { interpreted beautified } }\n subdomains(first: 10, order: {by: REGISTRATION_TIMESTAMP, dir: DESC}) {\n edges {\n node {\n canonical { name { interpreted beautified } }\n }\n }\n }\n }\n}", + "variables": { + "name": "eth" + } + }, { "id": "subdomains-pagination", "query": "query SubdomainsPagination($first: Int!, $after: String) {\n domain(by: { name: \"eth\" }) {\n canonical { name { interpreted } }\n\n # paginate child names: pass pageInfo.endCursor back as $after for the next page\n subdomains(first: $first, after: $after) {\n totalCount\n pageInfo { hasNextPage endCursor }\n edges {\n cursor\n node {\n canonical { name { interpreted } }\n }\n }\n }\n }\n}", @@ -60,6 +74,13 @@ "address": "0x801d2e48d378f161dba7ad7ad002ad557714c191" } }, + { + "id": "account-primary-names", + "query": "query AccountPrimaryNames($address: Address!) {\n account(by: { address: $address }) {\n address\n resolve {\n primaryNames(where: { chainNames: [ETHEREUM, BASE] }) {\n coinType\n chainName\n name { interpreted beautified }\n resolve {\n records {\n addresses(coinTypes: [60]) {\n coinType\n address\n }\n }\n }\n }\n }\n }\n}", + "variables": { + "address": "0x801d2e48d378f161dba7ad7ad002ad557714c191" + } + }, { "id": "account-events", "query": "query AccountEvents(\n $address: Address!\n) {\n account(by: { address: $address }) {\n events { totalCount edges { node { topics data timestamp } } }\n }\n}", @@ -134,5 +155,12 @@ "id": "eth-by-version", "query": "query GetEthDomains {\n domains(where: { name: { eq: \"eth\" } }) {\n edges {\n node {\n __typename\n id\n }\n }\n }\n}", "variables": {} + }, + { + "id": "domain-profile", + "query": "query DomainProfile($name: InterpretedName!) {\n domain(by: { name: $name }) {\n resolve {\n profile {\n description\n avatar { httpUrl }\n addresses { ethereum }\n socials { github { handle httpUrl } }\n website { httpUrl }\n email\n }\n }\n }\n}", + "variables": { + "name": "vitalik.eth" + } } ] diff --git a/docs/ensnode.io/src/data/omnigraph-examples/meta.ts b/docs/ensnode.io/src/data/omnigraph-examples/meta.ts index 92ca459228..86ecfa4652 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/meta.ts +++ b/docs/ensnode.io/src/data/omnigraph-examples/meta.ts @@ -23,6 +23,11 @@ export const OMNIGRAPH_EXAMPLES_META: Record< description: "Paginate direct child names under a parent domain.", category: "Resolution", }, + "domain-subdomains-recently-registered": { + name: "Recently Registered Subdomains", + description: "List a parent domain's subdomains ordered by most recent registration first.", + category: "Resolution", + }, "domain-events": { name: "Domain Events", description: "Raw contract events associated with a domain’s registry records.", diff --git a/docs/ensnode.io/src/data/omnigraph-examples/responses.json b/docs/ensnode.io/src/data/omnigraph-examples/responses.json index 76741bb403..bbe71c56f2 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/responses.json +++ b/docs/ensnode.io/src/data/omnigraph-examples/responses.json @@ -2536,5 +2536,81 @@ } } } + }, + "domain-subdomains-recently-registered": { + "data": { + "domain": { + "canonical": { + "name": { + "interpreted": "eth", + "beautified": "eth" + } + }, + "subdomains": { + "edges": [ + { + "node": { + "canonical": { + "name": { + "interpreted": "wrapnation.eth", + "beautified": "wrapnation.eth" + } + } + } + }, + { + "node": { + "canonical": { + "name": { + "interpreted": "katrenpadu.eth", + "beautified": "katrenpadu.eth" + } + } + } + }, + { + "node": { + "canonical": { + "name": { + "interpreted": "roppp.eth", + "beautified": "roppp.eth" + } + } + } + }, + { + "node": { + "canonical": { + "name": { + "interpreted": "sfmpfvtoicv2ok.eth", + "beautified": "sfmpfvtoicv2ok.eth" + } + } + } + }, + { + "node": { + "canonical": { + "name": { + "interpreted": "sfmpfv44d0mig.eth", + "beautified": "sfmpfv44d0mig.eth" + } + } + } + }, + { + "node": { + "canonical": { + "name": { + "interpreted": "sfmpfv44d0res.eth", + "beautified": "sfmpfv44d0res.eth" + } + } + } + } + ] + } + } + } } } diff --git a/docs/ensnode.io/src/data/omnigraph-examples/schema.graphql b/docs/ensnode.io/src/data/omnigraph-examples/schema.graphql index 0c9e37a06b..5007fd6372 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/schema.graphql +++ b/docs/ensnode.io/src/data/omnigraph-examples/schema.graphql @@ -1,3 +1,18 @@ +""" +Execution status metadata for a resolver strategy. +""" +type AccelerationStatus { + """ + Whether protocol acceleration was attempted at runtime. + """ + attempted: Boolean! + + """ + Whether protocol acceleration was requested by the caller. + """ + requested: Boolean! +} + """ Represents an individual Account, keyed by its Address. """ @@ -8,7 +23,7 @@ type Account { address: Address! """ - The Domains that are owned by the Account. + The Domains that are owned by the Account. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ domains( after: String @@ -56,6 +71,17 @@ type Account { last: Int ): AccountRegistryPermissionsConnection + """ + Resolve primary names for this Account. + """ + resolve( + """ + When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): ReverseResolve! + """ The Permissions on Resolvers granted to this Account. """ @@ -270,6 +296,11 @@ type BaseRegistrarRegistration implements Registration { """ start: BigInt! + """ + The TokenId for this Domain in the BaseRegistrar. This is the bigint encoding of the Domain's LabelHash. + """ + tokenId: BigInt! + """ The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used). """ @@ -296,6 +327,21 @@ BigInt represents non-fractional signed whole numeric values. """ scalar BigInt +""" +BinanceAddress represents a Bech32-encoded Binance Chain (BNB) address (coin type 714). +""" +scalar BinanceAddress + +""" +BitcoinAddress represents a Base58Check-encoded Bitcoin address (coin type 0). +""" +scalar BitcoinAddress + +""" +BitcoinCashAddress represents a CashAddr-encoded Bitcoin Cash address (coin type 145). +""" +scalar BitcoinCashAddress + """ A Canonical Name, exposed in each representation we support. """ @@ -316,11 +362,28 @@ ChainId represents an enssdk#ChainId. """ scalar ChainId +""" +The names of chains that the Omnigraph API supports identifying by name as a syntactic convenience. The Omnigraph API supports identification of additional chains beyond this list, but those chains must be identified through other mechanisms such as `coinType` or `chainId`. +""" +enum ChainName { + ARBITRUM_ONE + BASE + ETHEREUM + LINEA + OPTIMISM + SCROLL +} + """ CoinType represents an enssdk#CoinType. """ scalar CoinType +""" +DogecoinAddress represents a Base58Check-encoded Dogecoin address (coin type 3). +""" +scalar DogecoinAddress + """ Represents a Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. """ @@ -352,7 +415,7 @@ interface Domain { label: Label! """ - If this is an ENSv1Domain, this is the effective owner of the Domain. If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). + If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). """ owner: Account @@ -376,13 +439,24 @@ interface Domain { """ registry: Registry! + """ + Resolve protocol-level data for this Domain. + """ + resolve( + """ + When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): ForwardResolve! + """ Resolver relationship metadata for this Domain. """ resolver: DomainResolver! """ - All Domains that are direct descendents of this Domain in the namegraph. + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ subdomains( after: String @@ -473,6 +547,46 @@ input DomainPermissionsWhereInput { user: DomainPermissionsUserFilter } +""" +The interpreted profile of an ENS name. +""" +type DomainProfile { + """ + The interpreted addresses on the profile of an ENS name. + """ + addresses: ProfileAddresses + + """ + Interpreted avatar metadata. Returns null when the raw avatar record is unset or empty. + """ + avatar: ProfileAvatar + + """ + The interpreted description on the profile of an ENS name, or null when unset. + """ + description: String + + """ + The interpreted email address on the profile of an ENS name, or null when unset or invalid. + """ + email: Email + + """ + Interpreted header metadata. Returns null when the raw header record is unset or empty. + """ + header: ProfileHeader + + """ + The interpreted social accounts on the profile of an ENS name. + """ + socials: ProfileSocials + + """ + The interpreted website on the profile of an ENS name. + """ + website: ProfileWebsite +} + type DomainRegistrationsConnection { edges: [DomainRegistrationsConnectionEdge!]! pageInfo: PageInfo! @@ -526,12 +640,27 @@ input DomainsNameFilter @oneOf { } """ -Fields by which domains can be ordered +Fields by which domains can be ordered. """ enum DomainsOrderBy { + """ + Order by Canonical Name depth (number of labels); e.g. `eth` < `vitalik.eth`. + """ DEPTH + + """ + Order by the Domain's Canonical Name, alphabetically. + """ NAME + + """ + Order by the expiry of the Domain's latest Registration. A Domain that never expires (or has no Registration) is treated as +∞: it sorts last when `dir: ASC` (“expiring soonest first”) and first when `dir: DESC` (“expiring latest first”). + """ REGISTRATION_EXPIRY + + """ + Order by the start time of the Domain's latest Registration. A Domain with no Registration has no timestamp and sorts last when `dir: ASC` (“oldest registered first”) and first when `dir: DESC` (“most recently registered first”). + """ REGISTRATION_TIMESTAMP } @@ -602,7 +731,7 @@ type ENSv1Domain implements Domain { node: Node! """ - If this is an ENSv1Domain, this is the effective owner of the Domain. If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). + If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). """ owner: Account @@ -626,6 +755,17 @@ type ENSv1Domain implements Domain { """ registry: Registry! + """ + Resolve protocol-level data for this Domain. + """ + resolve( + """ + When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): ForwardResolve! + """ Resolver relationship metadata for this Domain. """ @@ -637,7 +777,7 @@ type ENSv1Domain implements Domain { rootRegistryOwner: Account """ - All Domains that are direct descendents of this Domain in the namegraph. + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ subdomains( after: String @@ -669,7 +809,7 @@ type ENSv1Registry implements Registry { contract: AccountId! """ - The Domains managed by this Registry. + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ domains( after: String @@ -711,7 +851,7 @@ type ENSv1VirtualRegistry implements Registry { contract: AccountId! """ - The Domains managed by this Registry. + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ domains( after: String @@ -774,7 +914,7 @@ type ENSv2Domain implements Domain { label: Label! """ - If this is an ENSv1Domain, this is the effective owner of the Domain. If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). + If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used). """ owner: Account @@ -809,13 +949,24 @@ type ENSv2Domain implements Domain { """ registry: Registry! + """ + Resolve protocol-level data for this Domain. + """ + resolve( + """ + When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled. + @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration + """ + accelerate: Boolean = true + ): ForwardResolve! + """ Resolver relationship metadata for this Domain. """ resolver: DomainResolver! """ - All Domains that are direct descendents of this Domain in the namegraph. + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ subdomains( after: String @@ -863,7 +1014,7 @@ type ENSv2Registry implements Registry { contract: AccountId! """ - The Domains managed by this Registry. + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ domains( after: String @@ -1010,6 +1161,11 @@ type ENSv2RegistryReservation implements Registration { unregistrant: Account } +""" +Email represents a validated contact email address. +""" +scalar Email + """ An Event represents a discrete Log Event that was emitted on an EVM chain, including associated metadata. """ @@ -1180,11 +1336,41 @@ input EventsWhereInput { timestamp: EventsTimestampFilter } +""" +Nested domain resolution container exposing resolved data for the domain. +""" +type ForwardResolve { + """ + Whether protocol acceleration was requested and attempted for this resolution. + """ + acceleration: AccelerationStatus! + + """ + The interpreted profile of an ENS name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected). + """ + profile: DomainProfile + + """ + Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected). + """ + records: ResolvedRecords + + """ + Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. This data model should be expected to experience breaking changes. + """ + trace: JSON +} + """ Hex represents viem#Hex. """ scalar Hex +""" +InterfaceId represents an ERC-165 interface id (4-byte hex selector). +""" +scalar InterfaceId + """ InterpretedLabel represents an enssdk#InterpretedLabel. """ @@ -1195,6 +1381,11 @@ InterpretedName represents an enssdk#InterpretedName. """ scalar InterpretedName +""" +JSON represents arbitrary JSON-serializable data. +""" +scalar JSON + """ Represents a Label within ENS, providing its hash and interpreted representation. """ @@ -1218,6 +1409,16 @@ type Label { interpreted: InterpretedLabel! } +""" +LitecoinAddress represents a Base58Check-encoded Litecoin address (coin type 2). +""" +scalar LitecoinAddress + +""" +MonacoinAddress represents a Base58Check-encoded Monacoin address (coin type 22). +""" +scalar MonacoinAddress + """ Constructs a reference to a specific Node via one of `name` or `node`. """ @@ -1485,6 +1686,196 @@ PermissionsUserId represents an enssdk#PermissionsUserId. """ scalar PermissionsUserId +""" +Select a primary name lookup target. Exactly one of `coinType` or `chainName` must be provided. +""" +input PrimaryNameByInput @oneOf { + """ + A `ChainName` to resolve the primary name for. + """ + chainName: ChainName + + """ + The ENSIP-9 coin type to resolve the primary name for. + """ + coinType: CoinType +} + +""" +An ENSIP-19 primary name for an Account on a specific coin type. +""" +type PrimaryNameRecord { + """ + The chain corresponding to `coinType`, or null when `coinType` is not represented in `ChainName`. + """ + chainName: ChainName + + """ + The canonical ENSIP-9 coin type for this primary name lookup. + """ + coinType: CoinType! + + """ + The validated primary name for this Account on this coin type, or null if none is set. + """ + name: CanonicalName + + """ + Forward resolve data for this primary name. + """ + resolve: ForwardResolve! +} + +""" +Filter primary name lookups. Exactly one of `coinTypes` or `chainNames` must be provided. +""" +input PrimaryNamesWhereInput @oneOf { + """ + `ChainName` values to resolve primary names for. + """ + chainNames: [ChainName!] + + """ + Coin types to resolve primary names for. + """ + coinTypes: [CoinType!] +} + +""" +The interpreted addresses on the profile of an ENS name. +""" +type ProfileAddresses { + """ + The interpreted Base address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + base: Address + + """ + The interpreted Binance Chain (BNB) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + binance: BinanceAddress + + """ + The interpreted Bitcoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + bitcoin: BitcoinAddress + + """ + The interpreted Bitcoin Cash address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + bitcoincash: BitcoinCashAddress + + """ + The interpreted Dogecoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + dogecoin: DogecoinAddress + + """ + The interpreted Ethereum address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + ethereum: Address + + """ + The interpreted Litecoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + litecoin: LitecoinAddress + + """ + The interpreted Monacoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + monacoin: MonacoinAddress + + """ + The interpreted Ripple (XRP) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + ripple: RippleAddress + + """ + The interpreted Rootstock (RBTC) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + rootstock: RootstockAddress + + """ + The interpreted Solana address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ + solana: SolanaAddress +} + +""" +The interpreted avatar image on the profile of an ENS name. +""" +type ProfileAvatar { + """ + HTTP-compatible URL for fetching the avatar image in web browsers. Abstraction over the raw avatar record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12. + """ + httpUrl: String +} + +""" +The interpreted header image on the profile of an ENS name. +""" +type ProfileHeader { + """ + HTTP-compatible URL for fetching the header image in web browsers. Abstraction over the raw header record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12. + """ + httpUrl: String +} + +""" +An interpreted social account on the profile of an ENS name. +""" +type ProfileSocialAccount { + """ + The handle of the social account. + """ + handle: String! + + """ + The HTTP-compatible url to the social account. + """ + httpUrl: String! +} + +""" +The interpreted social accounts on the profile of an ENS name. +""" +type ProfileSocials { + """ + The interpreted GitHub account. Returns null when the raw record is unset, empty, or cannot be parsed as a GitHub handle or profile URL. + """ + github: ProfileSocialAccount + + """ + The interpreted Keybase account. Returns null when the raw record is unset, empty, or cannot be parsed as a Keybase handle or profile URL. + """ + keybase: ProfileSocialAccount + + """ + The interpreted LinkedIn account. Returns null when the raw record is unset, empty, or cannot be parsed as a LinkedIn handle or profile URL. + """ + linkedin: ProfileSocialAccount + + """ + The interpreted Telegram account. Returns null when the raw record is unset, empty, or cannot be parsed as a Telegram handle or profile URL. + """ + telegram: ProfileSocialAccount + + """ + The interpreted X (Twitter) account. Returns null when the raw record is unset, empty, or cannot be parsed as a X (Twitter) handle or profile URL. + """ + twitter: ProfileSocialAccount +} + +""" +The interpreted website on the profile of an ENS name. +""" +type ProfileWebsite { + """ + The HTTP-compatible website URL. Returns null when the raw url record is unset, empty, not an http(s) URL, or cannot be parsed as a valid URL. + """ + httpUrl: String +} + type Query { """ Identify an Account by ID or Address. @@ -1497,7 +1888,7 @@ type Query { domain(by: DomainIdInput!): Domain """ - Find Canonical Domains by Name. + Find Canonical Domains by Name. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ domains( after: String @@ -1631,7 +2022,7 @@ interface Registry { contract: AccountId! """ - The Domains managed by this Registry. + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ domains( after: String @@ -1770,6 +2161,130 @@ RenewalId represents an enssdk#RenewalId. """ scalar RenewalId +""" +A resolved ABI record for an ENS name. +""" +type ResolvedAbiRecord { + contentType: BigInt! + data: Hex! +} + +""" +A resolved address record for an ENS name. +""" +type ResolvedAddressRecord { + """ + The "raw" resolved address record as hex, or null if not set, empty ("0x"), or zeroAddress. Decode with ENSIP-9 (https://docs.ens.domains/ensip/9) and address-encoder (https://github.com/ensdomains/address-encoder) for the associated coin type. Guaranteed to be at least one byte of hex data. There is no guarantee that an EVM CoinType returns an address value of any particular byte length. + """ + address: Hex + + """ + The coin type for this address record. + """ + coinType: CoinType! +} + +""" +A resolved ERC-165 interface implementer record for an ENS name. +""" +type ResolvedInterfaceRecord { + implementer: Address + interfaceId: InterfaceId! +} + +""" +A resolved PubkeyResolver (x, y) pair for an ENS name. +""" +type ResolvedPubkeyRecord { + x: Hex! + y: Hex! +} + +""" +A resolved 'raw' text record for an ENS name. Value is any possible string and may require additional validation or preprocessing before use. +""" +type ResolvedRawTextRecord { + """ + The text record key. + """ + key: String! + + """ + The 'raw' text record value, or null if not set. Value is any possible string and may require additional validation or preprocessing before use. + """ + value: String +} + +""" +Records resolved for a specific ENS name via the ENS protocol. +""" +type ResolvedRecords { + """ + The first stored ABI matching the requested content-type bitmask, or null if not set. + """ + abi( + """ + Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first). + """ + contentTypeMask: BigInt! + ): ResolvedAbiRecord + + """ + Resolved address records for the requested coin types. + """ + addresses( + """ + Coin types to resolve (e.g. `60` for ETH). + """ + coinTypes: [CoinType!]! + ): [ResolvedAddressRecord!]! + + """ + The ENSIP-7 contenthash record raw bytes, or null if not set. + """ + contenthash: Hex + + """ + The IDNSZoneResolver zonehash raw bytes, or null if not set. + """ + dnszonehash: Hex + + """ + Resolved ERC-165 interface implementer records for the requested ids. + """ + interfaces( + """ + ERC-165 interface ids to resolve (4-byte hex selectors). + """ + ids: [InterfaceId!]! + ): [ResolvedInterfaceRecord!]! + + """ + The PubkeyResolver (x, y) pair, or null if not set. + """ + pubkey: ResolvedPubkeyRecord + + """ + The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain. + """ + reverseName: String + + """ + Resolved text records for the requested keys. + """ + texts( + """ + Text record keys to resolve (e.g. `avatar`, `description`). + """ + keys: [String!]! + ): [ResolvedRawTextRecord!]! + + """ + The IVersionableResolver version, or null if not set or unavailable. + """ + version: BigInt +} + """ A Resolver represents a Resolver contract on-chain. """ @@ -1913,6 +2428,56 @@ ResolverRecordsId represents an enssdk#ResolverRecordsId. """ scalar ResolverRecordsId +""" +Nested account resolution container exposing primary name resolution. +""" +type ReverseResolve { + """ + Whether protocol acceleration was requested and attempted for this reverse resolution. + """ + acceleration: AccelerationStatus! + + """ + The primary name for this Account on a specific coin type or chain name. + """ + primaryName( + """ + Select a coin type or chain name to resolve a primary name for. + """ + by: PrimaryNameByInput! + ): PrimaryNameRecord! + + """ + Primary names for this Account on the requested coin types or chain names. + """ + primaryNames( + """ + Select coin types or chain names to resolve primary names for. + """ + where: PrimaryNamesWhereInput! + ): [PrimaryNameRecord!]! + + """ + Protocol trace tree emitted by reverse resolution, represented as JSON for schema stability. This data model should be expected to experience breaking changes. + """ + trace: JSON! +} + +""" +RippleAddress represents a Base58Check-encoded Ripple (XRP) address (coin type 144). +""" +scalar RippleAddress + +""" +RootstockAddress represents an EIP-55 checksummed Rootstock (RBTC) address (coin type 137). +""" +scalar RootstockAddress + +""" +SolanaAddress represents a Base58-encoded Solana address (coin type 501). +""" +scalar SolanaAddress + """ Filter for Domain.subdomains query. """ @@ -1993,7 +2558,7 @@ type WrappedBaseRegistrarRegistration { fuses: Int! """ - The TokenID for this Domain in the NameWrapper. + The TokenId for this Domain in the NameWrapper. This is the bigint encoding of the Domain's Node. """ tokenId: BigInt! } diff --git a/docs/ensnode.io/src/data/omnigraph-examples/snapshot.json b/docs/ensnode.io/src/data/omnigraph-examples/snapshot.json index d30f06ad8a..5cf00d34d4 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/snapshot.json +++ b/docs/ensnode.io/src/data/omnigraph-examples/snapshot.json @@ -1,8 +1,8 @@ { "version": "v1.15.1", - "commit": "c9ceadd18", + "commit": "100dbced1", "sdkVersion": "1.15.1", "schemaTag": "v1.15.1", "endpoint": "https://api.v2-sepolia.ensnode.io", - "snapshottedAt": "2026-05-25" + "snapshottedAt": "2026-06-04" } diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/unigraph.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/unigraph.schema.ts index 4a939c5521..f182c8ca42 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/unigraph.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/unigraph.schema.ts @@ -258,6 +258,18 @@ export const relations_registry = relations(registry, ({ one, many }) => ({ // Domains /////////// +/** + * Sentinel "+∞" sort value materialized into `Domain.__latestRegistrationStart` / + * `__latestRegistrationExpiry` when the value is absent — a Domain with no Registration, or a + * Registration that never expires. Holding those sort columns NOT NULL (absent → this sentinel) + * lets REGISTRATION_*-ordered find-domains queries use a plain `(registry_id, col, id)` composite + * index in both directions with a plain keyset tuple, with no NULL-placement special casing. + * + * @dev uint256 max — larger than every real timestamp/expiry, including the uint64-max ENSv2 + * "never expires" expiry. It sorts last for ASC ("oldest"/"expiring soonest") and first for DESC. + */ +export const REGISTRATION_SORT_SENTINEL = 2n ** 256n - 1n; + export const domainType = onchainEnum("DomainType", ["ENSv1Domain", "ENSv2Domain"]); export const domain = onchainTable( @@ -335,6 +347,30 @@ export const domain = onchainTable( */ canonicalNode: t.hex().$type(), + /** + * Materialized `start` of this Domain's latest Registration, or {@link REGISTRATION_SORT_SENTINEL} + * when the Domain has no Registration. Mirror of the latest `registration.start` (see + * `latestRegistrationIndex`), maintained inline by `registration-db-helpers.ts`. + * + * @dev Exists purely so REGISTRATION_TIMESTAMP-ordered find-domains queries can use the + * `(registry_id, __latest_registration_start, id)` composite index instead of joining through + * `latest_registration_indexes` → `registrations` and sorting. Held NOT NULL (absent → sentinel) + * so the keyset stays a plain tuple compare with no NULL-placement special casing; see + * find-domains-resolver-helpers.ts. Double-underscore prefix marks it as an internal + * materialized mirror, not part of the on-chain protocol surface; the canonical (possibly null) + * value lives on the Registration entity. + */ + __latestRegistrationStart: t.bigint().notNull().default(REGISTRATION_SORT_SENTINEL), + + /** + * Materialized `expiry` of this Domain's latest Registration, or {@link REGISTRATION_SORT_SENTINEL} + * when the Domain has no Registration or its latest Registration never expires (effectively +∞). + * Mirror of the latest `registration.expiry`, maintained inline by `registration-db-helpers.ts`. + * + * @dev See `__latestRegistrationStart`. Backs REGISTRATION_EXPIRY-ordered queries. + */ + __latestRegistrationExpiry: t.bigint().notNull().default(REGISTRATION_SORT_SENTINEL), + // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin }), (t) => ({ @@ -371,6 +407,24 @@ export const domain = onchainTable( byCanonicalNode: index().using("hash", t.canonicalNode), // btree for ORDER BY canonical_depth (typeahead and DEPTH-ordered browse) byCanonicalDepth: index().on(t.canonicalDepth), + + // Composites for `WHERE registry_id = X ORDER BY LIMIT N` + // (REGISTRATION_TIMESTAMP / REGISTRATION_EXPIRY ordering in find-domains queries). The latest + // registration's start/expiry is mirrored onto the Domain row (see `__latestRegistration*`) so + // the order is an index-ordered scan, not a join through `latest_registration_indexes` → + // `registrations` followed by a sort. The columns are NOT NULL (absent → `REGISTRATION_SORT_SENTINEL`), + // so a single plain composite per column serves both ASC and DESC (forward / backward scan) with + // a plain keyset tuple — see find-domains-resolver-helpers.ts. + byRegistryAndLatestRegistrationStart: index().on( + t.registryId, + t.__latestRegistrationStart, + t.id, + ), + byRegistryAndLatestRegistrationExpiry: index().on( + t.registryId, + t.__latestRegistrationExpiry, + t.id, + ), }), ); diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 7e4dbfe747..5518f38f32 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -249,6 +249,27 @@ query DomainSubdomains($name: InterpretedName!) { variables: { default: { name: "eth" } }, }, + //////////////////////////////////// + // Most Recently Registered Subdomains + //////////////////////////////////// + { + id: "domain-subdomains-recently-registered", + query: ` +query RecentlyRegisteredSubdomains($name: InterpretedName!) { + domain(by: {name: $name}) { + canonical { name { interpreted beautified } } + subdomains(first: 10, order: {by: REGISTRATION_TIMESTAMP, dir: DESC}) { + edges { + node { + canonical { name { interpreted beautified } } + } + } + } + } +}`, + variables: { default: { name: "eth" } }, + }, + //////////////////////// // Subdomains Pagination //////////////////////// diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 81bc4ece1e..d6b4876c07 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -12,7 +12,9 @@ type Account { """An EVM Address that uniquely identifies this Account on-chain.""" address: Address! - """The Domains that are owned by the Account.""" + """ + The Domains that are owned by the Account. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: AccountDomainsWhereInput): AccountDomainsConnection """ @@ -343,7 +345,7 @@ interface Domain { resolver: DomainResolver! """ - All Domains that are direct descendants of this Domain in the namegraph. + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection @@ -495,11 +497,24 @@ input DomainsNameFilter @oneOf { starts_with: String } -"""Fields by which domains can be ordered""" +"""Fields by which domains can be ordered.""" enum DomainsOrderBy { + """ + Order by Canonical Name depth (number of labels); e.g. `eth` < `vitalik.eth`. + """ DEPTH + + """Order by the Domain's Canonical Name, alphabetically.""" NAME + + """ + Order by the expiry of the Domain's latest Registration. A Domain that never expires (or has no Registration) is treated as +∞: it sorts last when `dir: ASC` (“expiring soonest first”) and first when `dir: DESC` (“expiring latest first”). + """ REGISTRATION_EXPIRY + + """ + Order by the start time of the Domain's latest Registration. A Domain with no Registration has no timestamp and sorts last when `dir: ASC` (“oldest registered first”) and first when `dir: DESC` (“most recently registered first”). + """ REGISTRATION_TIMESTAMP } @@ -584,7 +599,7 @@ type ENSv1Domain implements Domain { rootRegistryOwner: Account """ - All Domains that are direct descendants of this Domain in the namegraph. + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection @@ -604,7 +619,9 @@ type ENSv1Registry implements Registry { """ contract: AccountId! - """The Domains managed by this Registry.""" + """ + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection """A unique reference to this Registry.""" @@ -629,7 +646,9 @@ type ENSv1VirtualRegistry implements Registry { """ contract: AccountId! - """The Domains managed by this Registry.""" + """ + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection """A unique reference to this Registry.""" @@ -700,7 +719,7 @@ type ENSv2Domain implements Domain { resolver: DomainResolver! """ - All Domains that are direct descendants of this Domain in the namegraph. + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. """ subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection @@ -732,7 +751,9 @@ type ENSv2Registry implements Registry { """ contract: AccountId! - """The Domains managed by this Registry.""" + """ + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection """A unique reference to this Registry.""" @@ -1399,7 +1420,9 @@ type Query { """Identify a Domain by Name or DomainId""" domain(by: DomainIdInput!): Domain - """Find Canonical Domains by Name.""" + """ + Find Canonical Domains by Name. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection """Identify Permissions by ID or AccountId.""" @@ -1502,7 +1525,9 @@ interface Registry { """ contract: AccountId! - """The Domains managed by this Registry.""" + """ + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection """A unique reference to this Registry.""" diff --git a/packages/ensskills/skills/omnigraph/SKILL.md b/packages/ensskills/skills/omnigraph/SKILL.md index b7dc798f6d..603e032f66 100644 --- a/packages/ensskills/skills/omnigraph/SKILL.md +++ b/packages/ensskills/skills/omnigraph/SKILL.md @@ -444,6 +444,41 @@ Variables: } ``` +### domain-subdomains-recently-registered + +```graphql +query RecentlyRegisteredSubdomains($name: InterpretedName!) { + domain(by: { name: $name }) { + canonical { + name { + interpreted + beautified + } + } + subdomains(first: 10, order: { by: REGISTRATION_TIMESTAMP, dir: DESC }) { + edges { + node { + canonical { + name { + interpreted + beautified + } + } + } + } + } + } +} +``` + +Variables: + +```json +{ + "name": "eth" +} +``` + ### subdomains-pagination ```graphql From f436ea073c6ae5c80616bbc1143280c89df51e48 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 4 Jun 2026 15:09:00 -0500 Subject: [PATCH 2/6] fix: address PR review (bot + reviewer notes) - rename isRegistrationOrdering -> shouldUseNullsLast (inverted), shared with the pagination integration test - drop the eager toSQL() debug log on the find-domains hot path - use query.execute() in the connection span callback (matches codebase convention) - trim resolver/helper comments and rename finalQuery/loadedDomains -> query/domains - drop ensapi from the changeset Co-Authored-By: Claude Opus 4.8 (1M context) --- .../recently-registered-subdomains-index.md | 1 - .../find-domains-resolver-helpers.ts | 35 +++++++------------ .../lib/find-domains/find-domains-resolver.ts | 23 ++++-------- .../find-domains/test-domain-pagination.ts | 4 +-- .../src/lib/ensv2/registration-db-helpers.ts | 4 +-- .../unigraph/handlers/ensv2/ENSv2Registry.ts | 2 +- 6 files changed, 24 insertions(+), 45 deletions(-) diff --git a/.changeset/recently-registered-subdomains-index.md b/.changeset/recently-registered-subdomains-index.md index 6e9b7fbf85..7a8644f18b 100644 --- a/.changeset/recently-registered-subdomains-index.md +++ b/.changeset/recently-registered-subdomains-index.md @@ -1,7 +1,6 @@ --- "@ensnode/ensdb-sdk": patch "ensindexer": patch -"ensapi": patch --- Index-accelerate `REGISTRATION_TIMESTAMP` / `REGISTRATION_EXPIRY`-ordered domain queries (e.g. `Domain.subdomains(order: { by: REGISTRATION_TIMESTAMP, dir: DESC })`). Previously these joined `domains → latest_registration_indexes → registrations` and sorted the full registry partition — ~55s for `.eth`'s subdomains. The latest registration's start/expiry is now mirrored onto the Domain row (`__latestRegistrationStart` / `__latestRegistrationExpiry`) with composite indexes `(registry_id, , id)`, turning the query into an index-ordered scan. The sort columns are NOT NULL — an absent value (no registration, or a never-expiring registration) is materialized as a `+∞` sentinel — so a single plain composite per column serves both directions with a plain keyset tuple, and the sentinel sorts last for ASC and first for DESC. 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 09856e6ecf..61f0165687 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 @@ -65,16 +65,15 @@ function getOrderColumn(orderBy: typeof DomainsOrderBy.$inferType): SQL { } /** - * Whether this is a registration ordering, whose sort columns (`Domain.__latestRegistration*`) are - * sentinel-backed and NOT NULL (see `REGISTRATION_SORT_SENTINEL`). + * Whether the ORDER BY for this column needs an explicit NULLS LAST clause. * - * Because those columns never hold NULL, the ORDER BY omits any NULLS clause so a single plain - * `(registry_id, , id)` composite serves both directions (ASC forward / DESC backward) with a - * plain keyset tuple. The sentinel sorts last for ASC ("oldest" / "expiring soonest") and first for - * DESC. NAME / DEPTH columns are nullable and keep their NULLS-LAST behavior. + * The registration sort columns (`Domain.__latestRegistration*`) materialize an infinity sentinel + * (see `REGISTRATION_SORT_SENTINEL`) in place of an absent value, so they're NOT NULL — there are no + * NULLs to sort last, and a plain `(registry_id, , id)` composite serves both directions. + * NAME / DEPTH columns are nullable and keep NULLS LAST. */ -function isRegistrationOrdering(orderBy: typeof DomainsOrderBy.$inferType): boolean { - return orderBy === "REGISTRATION_TIMESTAMP" || orderBy === "REGISTRATION_EXPIRY"; +export function shouldUseNullsLast(orderBy: typeof DomainsOrderBy.$inferType): boolean { + return orderBy === "NAME" || orderBy === "DEPTH"; } /** @@ -139,11 +138,7 @@ export function cursorFilter( return sql`${cursor.value}::int`; case "REGISTRATION_TIMESTAMP": case "REGISTRATION_EXPIRY": - // Ponder's `t.bigint()` columns are `numeric(78,0)` (they hold EVM uint256 values, e.g. the - // uint64-max "never expires" expiry sentinel), so the materialized `__latestRegistration*` - // columns are numeric too. Cast the cursor value to the same type: it matches the column - // exactly (no `col::…` coercion) so the keyset tuple compare stays an Index Cond, and it - // avoids the `::bigint` overflow on values beyond int8 range. + // ponder bigints are numeric(78,0) return sql`${cursor.value}::numeric(78,0)`; } })(); @@ -170,17 +165,13 @@ export function orderFindDomains( const effectiveDesc = isEffectiveDesc(orderDir, inverted); const orderColumn = getOrderColumn(orderBy); - // Registration sort columns are sentinel-backed NOT NULL, so the ORDER BY omits any NULLS clause — - // that lets the plain `(registry_id, , id)` composite serve both directions (ASC forward / - // DESC backward); the sentinel (+∞) sorts last for ASC and first for DESC. NAME / DEPTH columns - // are nullable and keep NULLS LAST in both directions. - const primaryOrder = isRegistrationOrdering(orderBy) + const primaryOrder = shouldUseNullsLast(orderBy) ? effectiveDesc - ? sql`${orderColumn} DESC` - : sql`${orderColumn} ASC` - : effectiveDesc ? sql`${orderColumn} DESC NULLS LAST` - : sql`${orderColumn} ASC NULLS LAST`; + : sql`${orderColumn} ASC NULLS LAST` + : effectiveDesc + ? sql`${orderColumn} DESC` + : sql`${orderColumn} ASC`; const { ensIndexerSchema } = di.context; // Always include id as tiebreaker for stable ordering 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 0f87ff2792..1ed311e275 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 @@ -5,7 +5,6 @@ import type { NormalizedAddress, RegistryId } from "enssdk"; import di from "@/di"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; -import { makeLogger } from "@/lib/logger"; import { DomainCursors } from "@/omnigraph-api/lib/find-domains/domain-cursor"; import { cursorFilter, @@ -29,7 +28,6 @@ import type { ENSProtocolVersion } from "@/omnigraph-api/schema/ens-protocol-ver type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue }; const tracer = trace.getTracer("find-domains"); -const logger = makeLogger("find-domains"); const DOMAINS_DEFAULT_ORDER = { by: "NAME", dir: "ASC" } satisfies DomainsOrderValue; @@ -91,10 +89,8 @@ function getDefaultOrder(where: DomainsWhere | undefined | null): DomainsOrderVa } /** - * GraphQL API resolver for domain connection queries. Builds a single flat SELECT over `domains` - * (filters and ordering both resolve against `domains` columns) and hydrates fully-formed Domain - * rows in keyset order. Handles cursor-based pagination and ordering. Used by `Query.domains`, - * `Account.domains`, `Registry.domains`, and `Domain.subdomains`. + * GraphQL API resolver for domain connection queries. Handles cursor-based pagination and ordering. + * Used by `Query.domains`, `Account.domains`, `Registry.domains`, and `Domain.subdomains`. * * @param args - Compound `where` filter, optional ordering, and relay connection args */ @@ -164,13 +160,8 @@ export function resolveFindDomains({ const beforeCursor = before ? DomainCursors.decode(before) : undefined; const afterCursor = after ? DomainCursors.decode(after) : undefined; - // Hydrate Domains directly: every order value lives on `domains` and the only eagerly - // loaded relation is `label`, so a single relational query (mirroring the Domain - // dataloader's `with: { label: true }`) returns fully-formed Domain rows in keyset order — - // no second round-trip through the dataloader. The keyset/order scan still rides the - // `domains` composite indexes; `label` is joined only for the `LIMIT`ed rows. const { ensDb } = di.context; - const finalQuery = ensDb.query.domain.findMany({ + const query = ensDb.query.domain.findMany({ where: and( filterConditions, beforeCursor ? cursorFilter(beforeCursor, orderBy, orderDir, "before") : undefined, @@ -181,16 +172,14 @@ export function resolveFindDomains({ with: { label: true }, }); - logger.debug({ sql: finalQuery.toSQL() }); - - const loadedDomains = await withActiveSpanAsync( + const domains = await withActiveSpanAsync( tracer, "find-domains.connection", { orderBy, orderDir, limit }, - () => finalQuery, + () => query.execute(), ); - return loadedDomains.map((domain): DomainWithOrderValue => { + return domains.map((domain): DomainWithOrderValue => { const __orderValue: DomainOrderValue = (() => { switch (orderBy) { case "NAME": 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 65f189a70e..6d82de80bd 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 @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, it } from "vitest"; +import { shouldUseNullsLast } from "@/omnigraph-api/lib/find-domains/find-domains-resolver-helpers"; import type { DomainsOrderByValue, DomainsOrderInput } from "@/omnigraph-api/schema/domain-inputs"; import type { OrderDirectionValue } from "@/omnigraph-api/schema/order-direction"; import type { PaginatedDomainResult } from "@/test/integration/find-domains/domain-pagination-queries"; @@ -60,8 +61,7 @@ function assertOrdering( // Registration orderings use SQL-default NULL placement (ASC → last, DESC → first); NAME/DEPTH // keep NULLS LAST in both directions. See find-domains-resolver-helpers.ts. - const nullsFirst = - (by === "REGISTRATION_TIMESTAMP" || by === "REGISTRATION_EXPIRY") && dir === "DESC"; + const nullsFirst = !shouldUseNullsLast(by) && dir === "DESC"; for (let i = 0; i < values.length - 1; i++) { const a = values[i]; diff --git a/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts index 3463300302..d70b68dbc1 100644 --- a/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts @@ -58,7 +58,7 @@ export async function insertLatestRegistration( .values({ domainId, registrationIndex }) .onConflictDoUpdate({ registrationIndex }); - // materialize Domain.__latestRegistration* (absent expiry → sentinel; columns are NOT NULL) + // materialize Domain.__latestRegistration* await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set({ __latestRegistrationStart: values.start, __latestRegistrationExpiry: values.expiry ?? REGISTRATION_SORT_SENTINEL, @@ -86,7 +86,7 @@ export async function updateLatestRegistrationExpiry( ) { await context.ensDb.update(ensIndexerSchema.registration, { id: registrationId }).set({ expiry }); - // mirror onto the Domain sort column (absent expiry → sentinel; column is NOT NULL) + // materialize Domain.__latestRegistrationExpiry await context.ensDb .update(ensIndexerSchema.domain, { id: domainId }) .set({ __latestRegistrationExpiry: expiry ?? REGISTRATION_SORT_SENTINEL }); diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts index 99c2d19898..2aefcd4f87 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv2/ENSv2Registry.ts @@ -197,7 +197,7 @@ export default function () { // unregistering a label just immediately sets its expiration to event.block.timestamp, which // effectively removes it from resolution (which interprets expired names as non-existent) const unregistrantId = await ensureAccount(context, unregistrant); - // set expiry to now (+ materialize Domain.__latestRegistrationExpiry) + // update registration expiry to now await updateLatestRegistrationExpiry(context, { domainId, registrationId: registration.id, From b120ef05db82f1f058b85f1bb2dc9598bf3091b1 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 4 Jun 2026 17:15:19 -0500 Subject: [PATCH 3/6] fix(ensindexer): don't crash materializing __latestRegistration* for preminted names A preminted name (BaseRegistrar registerOnly on Basenames/Lineanames) has a Registration before its Domain row exists, so the unconditional Domain update in insertLatestRegistration/updateLatestRegistrationExpiry would throw. Guard the update behind a find, and materialize the sort keys from the latest Registration when the preminted name's Domain is created (ENSv1Registry NewOwner). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/ensv2/registration-db-helpers.ts | 34 +++++++++++++++---- .../unigraph/handlers/ensv1/ENSv1Registry.ts | 12 +++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts index d70b68dbc1..1e83b03f67 100644 --- a/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/registration-db-helpers.ts @@ -20,6 +20,28 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * each type of entity. */ +/** + * Materializes the latest Registration's sort keys onto the Domain row, if it exists. + * + * Preminted names (BaseRegistrar `registerOnly` on Basenames/Lineanames) have a Registration before + * their Domain row exists, and Ponder's `update` throws on an absent row. Such a Domain materializes + * these columns from its latest Registration when it's created (see ENSv1Registry NewOwner). + */ +async function materializeDomainLatestRegistration( + context: IndexingEngineContext, + domainId: DomainId, + values: Partial< + Pick< + typeof ensIndexerSchema.domain.$inferInsert, + "__latestRegistrationStart" | "__latestRegistrationExpiry" + > + >, +) { + const domain = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); + if (!domain) return; + await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set(values); +} + /** * Gets the latest Registration for the provided `domainId`. */ @@ -58,8 +80,8 @@ export async function insertLatestRegistration( .values({ domainId, registrationIndex }) .onConflictDoUpdate({ registrationIndex }); - // materialize Domain.__latestRegistration* - await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set({ + // conditionally materialize Domain.__latestRegistration* + await materializeDomainLatestRegistration(context, domainId, { __latestRegistrationStart: values.start, __latestRegistrationExpiry: values.expiry ?? REGISTRATION_SORT_SENTINEL, }); @@ -86,10 +108,10 @@ export async function updateLatestRegistrationExpiry( ) { await context.ensDb.update(ensIndexerSchema.registration, { id: registrationId }).set({ expiry }); - // materialize Domain.__latestRegistrationExpiry - await context.ensDb - .update(ensIndexerSchema.domain, { id: domainId }) - .set({ __latestRegistrationExpiry: expiry ?? REGISTRATION_SORT_SENTINEL }); + // conditionally materialize Domain.__latestRegistrationExpiry + await materializeDomainLatestRegistration(context, domainId, { + __latestRegistrationExpiry: expiry ?? REGISTRATION_SORT_SENTINEL, + }); } /** diff --git a/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/ENSv1Registry.ts index 773533e9e4..2c2a6d1047 100644 --- a/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/unigraph/handlers/ensv1/ENSv1Registry.ts @@ -14,6 +14,7 @@ import { } from "enssdk"; import { isAddressEqual, zeroAddress } from "viem"; +import { REGISTRATION_SORT_SENTINEL } from "@ensnode/ensdb-sdk/ensindexer-abstract"; import { ENSNamespaceIds, getENSRootChainId, @@ -31,6 +32,7 @@ import { } from "@/lib/ensv2/canonicality-db-helpers"; import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel, ensureUnknownLabel, labelExists } from "@/lib/ensv2/label-db-helpers"; +import { getLatestRegistration } from "@/lib/ensv2/registration-db-helpers"; import { ensureRegistry } from "@/lib/ensv2/registry-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; import { healAddrReverseSubnameLabel } from "@/lib/heal-addr-reverse-subname-label"; @@ -178,6 +180,10 @@ export default function () { // (BaseRegistrar, NameWrapper) because (a) the root Registry is the source of truth even when // no Registrar is in use, and (b) Registrar events fire _after_ Registry events, so they // re-materialize over the value we set here. + // a preminted name (BaseRegistrar `registerOnly`) has a Registration before its Domain exists, so + // materialize its sort keys here on creation; a normal name has none yet (its Registrar + // NameRegistered materializes them afterwards). Set on insert only so owner-changes don't clobber. + const registration = await getLatestRegistration(context, domainId); await context.ensDb .insert(ensIndexerSchema.domain) .values({ @@ -188,6 +194,12 @@ export default function () { labelHash, ownerId, rootRegistryOwnerId: ownerId, + ...(registration + ? { + __latestRegistrationStart: registration.start, + __latestRegistrationExpiry: registration.expiry ?? REGISTRATION_SORT_SENTINEL, + } + : {}), }) .onConflictDoUpdate({ ownerId, rootRegistryOwnerId: ownerId }); From f42c4cd13120ddf1af321146936973e8ba43b1ce Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 10:24:34 -0500 Subject: [PATCH 4/6] fix: correct fields --- .../docs/docs/integrate/unigraph/schema-reference.mdx | 4 ++++ .../src/ensindexer-abstract/unigraph.schema.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/unigraph/schema-reference.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/unigraph/schema-reference.mdx index bd2f2e852f..cd53dfdf88 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/unigraph/schema-reference.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/unigraph/schema-reference.mdx @@ -236,6 +236,8 @@ Domain-Resolver relations are tracked via the Protocol Acceleration plugin, not | `canonical_path` | `text[]` | yes | Materialized Canonical Domain Path, `NULL` iff `canonical = false`. Head-first (root → leaf), i.e. `[DomainId("eth"), DomainId("vitalik")]` 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`. | +| `__latest_registration_start` | `numeric(78)` | no | Materialized `start` of this Domain's latest Registration, or the `REGISTRATION_SORT_SENTINEL` (`2^256 − 1`) when the Domain has no Registration. Mirror of the latest `registration.start`, maintained by `registration-db-helpers.ts`. Backs `REGISTRATION_TIMESTAMP`-ordered find-domains queries via the `(registry_id, __latest_registration_start, id)` composite index, avoiding a join through `latest_registration_indexes` → `registrations` and a sort. Held `NOT NULL` (absent → sentinel) so the keyset stays a plain tuple compare. The `__` prefix marks it an internal materialized mirror — the canonical (possibly null) value lives on the Registration entity. | +| `__latest_registration_expiry` | `numeric(78)` | no | Materialized `expiry` of this Domain's latest Registration, or the `REGISTRATION_SORT_SENTINEL` (`2^256 − 1`) when the Domain has no Registration or its latest Registration never expires (effectively +∞). Mirror of the latest `registration.expiry`, maintained by `registration-db-helpers.ts`. Backs `REGISTRATION_EXPIRY`-ordered queries; see `__latest_registration_start`. | **Indexes:** @@ -250,6 +252,8 @@ Domain-Resolver relations are tracked via the Protocol Acceleration plugin, not - `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) +- `(registry_id, __latest_registration_start, id)` (composite for registry-scoped `WHERE registry_id = X ORDER BY __latest_registration_start LIMIT N` — `REGISTRATION_TIMESTAMP` ordering; the `NOT NULL` columns let one plain composite serve both ASC and DESC keyset scans) +- `(registry_id, __latest_registration_expiry, id)` (composite, as above for `REGISTRATION_EXPIRY` ordering) **Relations:** belongs to one `registries` record, belongs to one `registries` record (as subregistry), has one `accounts` record (owner), has one `accounts` record (rootRegistryOwner), has one `labels` record, has many `registrations` records. diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/unigraph.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/unigraph.schema.ts index d6cddd1627..f0b2e60f83 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/unigraph.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/unigraph.schema.ts @@ -400,7 +400,10 @@ export const domain = onchainTable( * materialized mirror, not part of the on-chain protocol surface; the canonical (possibly null) * value lives on the Registration entity. */ - __latestRegistrationStart: t.bigint().notNull().default(REGISTRATION_SORT_SENTINEL), + __latestRegistrationStart: t + .bigint("__latest_registration_start") + .notNull() + .default(REGISTRATION_SORT_SENTINEL), /** * Materialized `expiry` of this Domain's latest Registration, or {@link REGISTRATION_SORT_SENTINEL} @@ -409,7 +412,10 @@ export const domain = onchainTable( * * @dev See `__latestRegistrationStart`. Backs REGISTRATION_EXPIRY-ordered queries. */ - __latestRegistrationExpiry: t.bigint().notNull().default(REGISTRATION_SORT_SENTINEL), + __latestRegistrationExpiry: t + .bigint("__latest_registration_expiry") + .notNull() + .default(REGISTRATION_SORT_SENTINEL), // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin }), From e04f2424dd5445563d30123700bd14b9600b59b3 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 10:26:36 -0500 Subject: [PATCH 5/6] fix: lint --- .../integrate/unigraph/schema-reference.mdx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/unigraph/schema-reference.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/unigraph/schema-reference.mdx index cd53dfdf88..e83c38ff4e 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/unigraph/schema-reference.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/unigraph/schema-reference.mdx @@ -218,26 +218,26 @@ The `domains.owner_id` for ENSv1 Domains is the materialized effective owner. EN 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 | -| --------------------------- | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `text` | no | ENSv1DomainId: `{ENSv1RegistryId}/{node}`. ENSv2DomainId: CAIP-19 asset identifier. Primary key. | -| `type` | `DomainType` | no | `ENSv1Domain` or `ENSv2Domain`. | -| `registry_id` | `text` | no | The registry this domain belongs to. | -| `subregistry_id` | `text` | yes | The registry that manages subdomains of this domain, if any. | -| `token_id` | `numeric(78)` | yes | ENSv2 only: the TokenId within the ENSv2Registry. `null` for ENSv1 domains. | -| `node` | `text` | yes | ENSv1 only: the domain's namehash. `null` for ENSv2 domains. | -| `label_hash` | `text` | no | Represents a labelHash. References `labels.label_hash`. | -| `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`. Use for exact matches (`canonical_name = 'vitalik.eth'`) and display. Example: `"vitalik.eth"`. | -| `__canonical_name_prefix` | `text` | yes | Materialized prefix of `canonical_name` (first 64 code points), `NULL` iff `canonical = false`. Maintained by `canonicality-db-helpers.ts`. Use for left-anchored / substring search (`__canonical_name_prefix ILIKE 'vit%'`, case-insensitive to match the Omnigraph `starts_with` filter) and NAME ordering without `canonical_name`'s full-length btree size hazard. The `__` prefix marks it an internal implementation detail — query `canonical_name` for exact matches and display. | -| `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. `[DomainId("eth"), DomainId("vitalik")]` 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`. | +| Column | Type | Nullable | Description | +| ------------------------------ | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `text` | no | ENSv1DomainId: `{ENSv1RegistryId}/{node}`. ENSv2DomainId: CAIP-19 asset identifier. Primary key. | +| `type` | `DomainType` | no | `ENSv1Domain` or `ENSv2Domain`. | +| `registry_id` | `text` | no | The registry this domain belongs to. | +| `subregistry_id` | `text` | yes | The registry that manages subdomains of this domain, if any. | +| `token_id` | `numeric(78)` | yes | ENSv2 only: the TokenId within the ENSv2Registry. `null` for ENSv1 domains. | +| `node` | `text` | yes | ENSv1 only: the domain's namehash. `null` for ENSv2 domains. | +| `label_hash` | `text` | no | Represents a labelHash. References `labels.label_hash`. | +| `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`. Use for exact matches (`canonical_name = 'vitalik.eth'`) and display. Example: `"vitalik.eth"`. | +| `__canonical_name_prefix` | `text` | yes | Materialized prefix of `canonical_name` (first 64 code points), `NULL` iff `canonical = false`. Maintained by `canonicality-db-helpers.ts`. Use for left-anchored / substring search (`__canonical_name_prefix ILIKE 'vit%'`, case-insensitive to match the Omnigraph `starts_with` filter) and NAME ordering without `canonical_name`'s full-length btree size hazard. The `__` prefix marks it an internal implementation detail — query `canonical_name` for exact matches and display. | +| `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. `[DomainId("eth"), DomainId("vitalik")]` 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`. | | `__latest_registration_start` | `numeric(78)` | no | Materialized `start` of this Domain's latest Registration, or the `REGISTRATION_SORT_SENTINEL` (`2^256 − 1`) when the Domain has no Registration. Mirror of the latest `registration.start`, maintained by `registration-db-helpers.ts`. Backs `REGISTRATION_TIMESTAMP`-ordered find-domains queries via the `(registry_id, __latest_registration_start, id)` composite index, avoiding a join through `latest_registration_indexes` → `registrations` and a sort. Held `NOT NULL` (absent → sentinel) so the keyset stays a plain tuple compare. The `__` prefix marks it an internal materialized mirror — the canonical (possibly null) value lives on the Registration entity. | -| `__latest_registration_expiry` | `numeric(78)` | no | Materialized `expiry` of this Domain's latest Registration, or the `REGISTRATION_SORT_SENTINEL` (`2^256 − 1`) when the Domain has no Registration or its latest Registration never expires (effectively +∞). Mirror of the latest `registration.expiry`, maintained by `registration-db-helpers.ts`. Backs `REGISTRATION_EXPIRY`-ordered queries; see `__latest_registration_start`. | +| `__latest_registration_expiry` | `numeric(78)` | no | Materialized `expiry` of this Domain's latest Registration, or the `REGISTRATION_SORT_SENTINEL` (`2^256 − 1`) when the Domain has no Registration or its latest Registration never expires (effectively +∞). Mirror of the latest `registration.expiry`, maintained by `registration-db-helpers.ts`. Backs `REGISTRATION_EXPIRY`-ordered queries; see `__latest_registration_start`. | **Indexes:** From aae4cd9d4d1b142eb2d62a7040ae40d9948f8d27 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 10:35:02 -0500 Subject: [PATCH 6/6] docs(ensapi): clarify REGISTRATION_TIMESTAMP vs EXPIRY null-value semantics in ordering description --- .../src/omnigraph-api/schema/domain-inputs.ts | 5 +++-- .../src/omnigraph/generated/schema.graphql | 18 +++++++++--------- packages/ensskills/skills/omnigraph/SKILL.md | 8 ++++---- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts index 865384796e..0d2ae274cb 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts @@ -172,8 +172,9 @@ export type DomainsOrderByValue = typeof DomainsOrderBy.$inferType; */ export const DOMAINS_ORDERING_DESCRIPTION = "Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or " + - "REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration " + - "treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`."; + "REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no " + + "Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when " + + "`dir: ASC` and first when `dir: DESC`."; export const DomainsOrderInput = builder.inputType("DomainsOrderInput", { description: "Ordering options for domains query. If no order is provided, the default is ASC.", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index bb6480e54e..58f5bb9fc1 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -13,7 +13,7 @@ type Account { address: Address! """ - The Domains that are owned by the Account. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + The Domains that are owned by the Account. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: AccountDomainsWhereInput): AccountDomainsConnection @@ -345,7 +345,7 @@ interface Domain { resolver: DomainResolver! """ - All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. """ subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection @@ -599,7 +599,7 @@ type ENSv1Domain implements Domain { rootRegistryOwner: Account """ - All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. """ subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection @@ -620,7 +620,7 @@ type ENSv1Registry implements Registry { contract: AccountId! """ - The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection @@ -647,7 +647,7 @@ type ENSv1VirtualRegistry implements Registry { contract: AccountId! """ - The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection @@ -719,7 +719,7 @@ type ENSv2Domain implements Domain { resolver: DomainResolver! """ - All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. """ subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection @@ -752,7 +752,7 @@ type ENSv2Registry implements Registry { contract: AccountId! """ - The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection @@ -1421,7 +1421,7 @@ type Query { domain(by: DomainIdInput!): Domain """ - Find Canonical Domains by Name. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + Find Canonical Domains by Name. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection @@ -1526,7 +1526,7 @@ interface Registry { contract: AccountId! """ - The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`. + The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection diff --git a/packages/ensskills/skills/omnigraph/SKILL.md b/packages/ensskills/skills/omnigraph/SKILL.md index 603e032f66..3fe5b0ad72 100644 --- a/packages/ensskills/skills/omnigraph/SKILL.md +++ b/packages/ensskills/skills/omnigraph/SKILL.md @@ -74,7 +74,7 @@ If a question genuinely isn't expressible in the Omnigraph schema, the underlyin - account(by: AccountByInput!): Account — Identify an Account by ID or Address. - domain(by: DomainIdInput!): Domain — Identify a Domain by Name or DomainId -- domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection — Find Canonical Domains by Name. +- domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection — Find Canonical Domains by Name. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. - permissions(by: PermissionsIdInput!): Permissions — Identify Permissions by ID or AccountId. - registry(by: RegistryIdInput!): Registry — Identify a Registry by ID or AccountId. If querying by `contract`, only concrete Registries will be returned. - resolver(by: ResolverIdInput!): Resolver — Identify a Resolver by ID or AccountId. @@ -97,7 +97,7 @@ _Represents a Domain, i.e. an individual Label within the ENS namegraph. It may - registry: Registry! — The Registry under which this Domain exists. - resolve(accelerate: Boolean): ForwardResolve! — Resolve protocol-level data for this Domain. - resolver: DomainResolver! — Resolver relationship metadata for this Domain. -- subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection — All Domains that are direct descendants of this Domain in the namegraph. +- subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection — All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. - subregistry: Registry — The Registry this Domain declares as its Subregistry, if exists. #### DomainCanonical @@ -114,7 +114,7 @@ _Canonicality metadata for a Domain, including its name, depth, path, and node ( _Represents an individual Account, keyed by its Address._ - address: Address! — An EVM Address that uniquely identifies this Account on-chain. -- domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: AccountDomainsWhereInput): AccountDomainsConnection — The Domains that are owned by the Account. +- domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: AccountDomainsWhereInput): AccountDomainsConnection — The Domains that are owned by the Account. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. - events(after: String, before: String, first: Int, last: Int, where: AccountEventsWhereInput): AccountEventsConnection — All Events for which this Account is the HCA-aware `sender` (i.e. `Event.sender`). - id: Address! — A unique reference to this Account. - permissions(after: String, before: String, first: Int, last: Int, where: AccountPermissionsWhereInput): AccountPermissionsConnection — The Permissions granted to this Account, optionally filtered to Permissions in a specific contract. @@ -146,7 +146,7 @@ _A Registry represents a Registry contract in the ENS namegraph. It may be an EN - canonical: Boolean! — Whether the Registry is Canonical. - contract: AccountId! — Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists. -- domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection — The Domains managed by this Registry. +- domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection — The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`. - id: RegistryId! — A unique reference to this Registry. - parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection — The Domains for which this Registry is a Subregistry. - permissions: Permissions — The Permissions managed by this Registry.