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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/domains-name-filter-oneof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

**Omnigraph (breaking)**: `where: { name }` on `Query.domains`, `Account.domains`, `Registry.domains`, and `Domain.subdomains` now takes a `DomainsNameFilter` `@oneOf` input with three modes: `starts_with` (prefix autocomplete, the previous behavior), `eq` (exact InterpretedName match — sugar for `in: [eq]`), and `in` (exact match against a set of up to 100 InterpretedNames). The old shape `where: { name: "examp" }` becomes `where: { name: { starts_with: "examp" } }`; for exact lookups use `where: { name: { eq: "vitalik.eth" } }` or `where: { name: { in: ["alice.eth", "bob.eth"] } }`. Combine with `version` to disambiguate across ENS protocol versions (e.g. `{ name: { eq: "eth" }, version: ENSv1 }` returns a single Domain).
Comment on lines +2 to +5
5 changes: 5 additions & 0 deletions .changeset/domains-orderby-depth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

**Omnigraph**: add `DEPTH` to the `DomainsOrderBy` enum, ordering by the materialized `Domain.canonicalDepth` (number of labels in the Canonical Name). Applies to `Query.domains`, `Account.domains`, `Registry.domains`, and `Domain.subdomains` via `order: { by: DEPTH }`. Also wired in as the default ordering for `where: { name: { starts_with } }` (typeahead).
6 changes: 6 additions & 0 deletions .changeset/materialize-canonical-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"ensindexer": minor
"@ensnode/ensdb-sdk": minor
---

**Materialize `Domain.canonicalPath` and `canonicalDepth`** on every Canonical Domain, alongside the existing `canonicalName` / `canonicalLabelHashPath` / `canonicalNode`. `canonicalPath` is the head-first array of ancestor DomainIds (parallel to `canonicalLabelHashPath`); `canonicalDepth` is the label count. Adds a `byCanonicalDepth` btree index for `ORDER BY canonical_depth` (typeahead, depth-ordered browse).
1 change: 1 addition & 0 deletions apps/ensapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@pothos/plugin-dataloader": "^4.4.3",
"@pothos/plugin-relay": "^4.6.2",
"@pothos/plugin-tracing": "^1.1.2",
"@pothos/plugin-zod": "^4.3.0",
"@pothos/tracing-opentelemetry": "^1.1.3",
"@standard-schema/utils": "^0.3.0",
"dataloader": "^2.2.3",
Expand Down
3 changes: 2 additions & 1 deletion apps/ensapi/src/omnigraph-api/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SchemaBuilder, { type MaybePromise } from "@pothos/core";
import DataloaderPlugin from "@pothos/plugin-dataloader";
import RelayPlugin from "@pothos/plugin-relay";
import TracingPlugin, { isRootField } from "@pothos/plugin-tracing";
import ZodPlugin from "@pothos/plugin-zod";
import { AttributeNames, createOpenTelemetryWrapper } from "@pothos/tracing-opentelemetry";
import type {
ChainId,
Expand Down Expand Up @@ -88,7 +89,7 @@ export const builder = new SchemaBuilder<{
DefaultEdgesNullability: false;
DefaultNodeNullability: false;
}>({
plugins: [TracingPlugin, DataloaderPlugin, RelayPlugin],
plugins: [TracingPlugin, DataloaderPlugin, RelayPlugin, ZodPlugin],
tracing: {
default: (config) => {
// NOTE: if you need all the tracing possible in order to debug something, you can return true
Expand Down
16 changes: 1 addition & 15 deletions apps/ensapi/src/omnigraph-api/context.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,10 @@
import DataLoader from "dataloader";
import { getUnixTime } from "date-fns";
import { inArray } from "drizzle-orm";
import type { CanonicalPath, DomainId, RegistryId } from "enssdk";
import type { DomainId, RegistryId } from "enssdk";

import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";

import { getCanonicalPath } from "./lib/get-canonical-path";

/**
* A Promise.catch handler that provides the thrown error as a resolved value, useful for Dataloaders.
*/
const errorAsValue = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));

const createCanonicalPathLoader = () =>
new DataLoader<DomainId, CanonicalPath | Error | null>(async (domainIds) =>
Promise.all(domainIds.map((id) => getCanonicalPath(id).catch(errorAsValue))),
);

const createRegistryParentDomainLoader = () =>
new DataLoader<RegistryId, DomainId | null>(async (registryIds) => {
const rows = await ensDb
Expand All @@ -39,7 +26,6 @@ const createRegistryParentDomainLoader = () =>
export const context = () => ({
now: BigInt(getUnixTime(new Date())),
loaders: {
canonicalPath: createCanonicalPathLoader(),
registryParentDomain: createRegistryParentDomainLoader(),
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export interface DomainCursor {
id: DomainId;

/**
* The criteria by which the set is ordered. One of NAME, REGISTRATION_TIMESTAMP, or REGISTRATION_EXPIRY.
* The criteria by which the set is ordered. One of NAME, DEPTH, REGISTRATION_TIMESTAMP, or
* REGISTRATION_EXPIRY.
*/
by: typeof DomainsOrderBy.$inferType;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ function getOrderColumn(
orderBy: typeof DomainsOrderBy.$inferType,
) {
return {
NAME: domains.sortableLabel,
NAME: domains.canonicalName,
DEPTH: domains.canonicalDepth,
REGISTRATION_TIMESTAMP: domains.registrationTimestamp,
REGISTRATION_EXPIRY: domains.registrationExpiry,
}[orderBy];
Expand Down Expand Up @@ -83,8 +84,17 @@ export function cursorFilter(
// NOTE: Drizzle 0.41 doesn't support gt/lt with tuple arrays, so we use raw SQL
// NOTE: explicit cast required — Postgres can't infer parameter types in tuple comparisons
const op = useGreaterThan ? ">" : "<";
const value =
cursor.by === "NAME" ? sql`${cursor.value}::text` : sql`${cursor.value}::numeric(78,0)`;
const value = (() => {
switch (cursor.by) {
case "NAME":
return sql`${cursor.value}::text`;
case "DEPTH":
return sql`${cursor.value}::int`;
case "REGISTRATION_TIMESTAMP":
case "REGISTRATION_EXPIRY":
return sql`${cursor.value}::numeric(78,0)`;
}
})();
return sql`(${orderColumn}, ${domains.id}) ${sql.raw(op)} (${value}, ${cursor.id})`;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,13 @@ import {
DOMAINS_DEFAULT_ORDER_BY,
DOMAINS_DEFAULT_ORDER_DIR,
type DomainsOrderBy,
type DomainsOrderInput,
} from "@/omnigraph-api/schema/domain-inputs";
import type { OrderDirection } from "@/omnigraph-api/schema/order-direction";

import { DomainCursors } from "./domain-cursor";
import { cursorFilter, orderFindDomains } from "./find-domains-resolver-helpers";
import type { DomainOrderValue } from "./types";

/**
* Describes the ordering of the set of Domains.
*
* @dev derived from the GraphQL Input Types for 1:1 convenience
*/
interface FindDomainsOrderArg {
by?: typeof DomainsOrderBy.$inferType | null;
dir?: typeof OrderDirection.$inferType | null;
}

/**
* Domain with order value injected.
*
Expand All @@ -57,7 +47,9 @@ function getOrderValueFromResult(
): DomainOrderValue {
switch (orderBy) {
case "NAME":
return result.sortableLabel;
return result.canonicalName;
case "DEPTH":
return result.canonicalDepth;
case "REGISTRATION_TIMESTAMP":
return result.registrationTimestamp;
case "REGISTRATION_EXPIRY":
Expand All @@ -80,12 +72,24 @@ export function resolveFindDomains(
{
domains,
order,
defaultOrder,
...connectionArgs
}: {
/** Pre-built domains CTE from `withOrderingMetadata` */
/**
* Pre-built domains CTE from `withOrderingMetadata`
*/
domains: DomainsWithOrderingMetadata;
/** Optional ordering; defaults to NAME ASC */
order?: FindDomainsOrderArg | undefined | null;

/**
* Optional ordering. Each unset field falls back to `defaultOrder` then the
* `DOMAINS_DEFAULT_ORDER_*` constants.
*/
order?: Partial<typeof DomainsOrderInput.$inferInput> | null;

/**
* Filter-supplied default `(by, dir)` when the caller doesn't pass `order`.
*/
defaultOrder?: Partial<typeof DomainsOrderInput.$inferInput>;

// relay connection args from t.connection
first?: number | null;
Expand All @@ -94,8 +98,8 @@ export function resolveFindDomains(
after?: string | null;
},
) {
const orderBy = order?.by ?? DOMAINS_DEFAULT_ORDER_BY;
const orderDir = order?.dir ?? DOMAINS_DEFAULT_ORDER_DIR;
const orderBy = order?.by ?? defaultOrder?.by ?? DOMAINS_DEFAULT_ORDER_BY;
const orderDir = order?.dir ?? defaultOrder?.dir ?? DOMAINS_DEFAULT_ORDER_DIR;

return lazyConnection({
totalCount: () =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { and, eq, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import type { DomainId, NormalizedAddress, RegistryId } from "enssdk";
import type { DomainId, InterpretedName, NormalizedAddress, RegistryId } from "enssdk";

import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";

Expand All @@ -14,13 +14,13 @@ export type DomainType = (typeof ensIndexerSchema.domainType.enumValues)[number]
/**
* Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata.
*
* Returns `{ domainId, type, ownerId, registryId, parentId, canonical, labelHash, sortableLabel }`.
* - parentId is the canonical parent Domain, derived inline by joining to the parent Registry of
* this Domain (`registry.id = domain.registryId`) and then to the parent Domain named by
* `registry.canonicalDomainId`, requiring that parent Domain's `subregistryId` agree back to
* the same Registry. This is the bidirectional canonical-edge agreement that enforces a tree.
* - sortableLabel is the Domain's own InterpretedLabel, used for NAME ordering
* - all other values are directly sourced from Domain
* `parentId` is the canonical parent Domain, derived inline by joining to the parent Registry of
* this Domain (`registry.id = domain.registryId`) and then to the parent Domain named by
* `registry.canonicalDomainId`, requiring that parent Domain's `subregistryId` agree back to
* the same Registry. This is the bidirectional canonical-edge agreement that enforces a tree.
*
* `canonicalName` and `canonicalDepth` are sourced directly from Domain's materialized columns
* and drive NAME / DEPTH ordering downstream. All other values are directly sourced from Domain.
*
* All downstream filters (owner, parent, registry, name, canonical) operate on this shape.
*/
Expand All @@ -39,8 +39,11 @@ export function domainsBase() {
parentId: sql<DomainId | null>`${parentDomain.id}`.as("parentId"),
canonical: sql<boolean>`${ensIndexerSchema.domain.canonical}`.as("canonical"),
labelHash: sql<string>`${ensIndexerSchema.domain.labelHash}`.as("labelHash"),
sortableLabel: sql<string | null>`${ensIndexerSchema.label.interpreted}`.as(
"sortableLabel",
canonicalName: sql<InterpretedName | null>`${ensIndexerSchema.domain.canonicalName}`.as(
"canonicalName",
),
canonicalDepth: sql<number | null>`${ensIndexerSchema.domain.canonicalDepth}`.as(
"canonicalDepth",
),
})
.from(ensIndexerSchema.domain)
Expand All @@ -55,11 +58,6 @@ export function domainsBase() {
eq(parentDomain.subregistryId, parentRegistry.id),
),
)
// join label for labelHash/sortableLabel
.leftJoin(
ensIndexerSchema.label,
eq(ensIndexerSchema.label.labelHash, ensIndexerSchema.domain.labelHash),
)
.as("baseDomains")
);
}
Expand All @@ -77,6 +75,7 @@ export function selectBase(base: BaseDomainSet) {
parentId: base.parentId,
canonical: base.canonical,
labelHash: base.labelHash,
sortableLabel: base.sortableLabel,
canonicalName: base.canonicalName,
canonicalDepth: base.canonicalDepth,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { inArray, sql } from "drizzle-orm";
import type { InterpretedName } from "enssdk";

import { ensDb } from "@/lib/ensdb/singleton";

import { type BaseDomainSet, selectBase } from "./base-domain-set";

/**
* Filter a base domain set to Domains whose materialized `canonicalName` exactly matches one of
* `names`. Validation (max-length, etc.) is enforced at the GraphQL input layer.
*
* Non-canonical rows have `canonicalName = NULL`, so they cannot match by construction — no
* separate root-anchoring guard is required.
*
* @param base - A base domain set subquery
* @param names - Exact InterpretedNames to match against
*/
export function filterByNameIn(base: BaseDomainSet, names: InterpretedName[]) {
// Drizzle footgun: `inArray(col, [])` generates `col in ()`, a Postgres syntax error.
// Short-circuit to an explicit empty result.
if (names.length === 0) {
return ensDb.select(selectBase(base)).from(base).where(sql`false`).as("baseDomains");
}

return ensDb
.select(selectBase(base))
.from(base)
.where(inArray(base.canonicalName, names))
.as("baseDomains");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ilike } from "drizzle-orm";

import { ensDb } from "@/lib/ensdb/singleton";

import { type BaseDomainSet, selectBase } from "./base-domain-set";

/**
* Filter a base domain set to Domains whose materialized `canonicalName` starts with the user's
* typeahead input. Used by the `name: { starts_with }` filter on
* `Query.domains` / `Account.domains` / `Registry.domains` / `Domain.subdomains`.
*
* Match semantics: `canonicalName ILIKE startsWith || '%'`. canonicalName is leaf-first
* (e.g. `"vitalik.eth"`), same direction as user input — `"vitalik.et"` matches `"vitalik.eth"`,
* `"vit"` matches `"vit.eth"`, `"vitalik.eth"`, etc.
*
* Ordering is handled by the resolver layer via `defaultOrder: { by: "DEPTH", dir: "ASC" }` from
* `filterByName` — shorter names surface first (`vitalik.eth` over `vitalik.ethereum.foundation`
* for input `"vitalik.et"`).
*
* @param base - A base domain set subquery
* @param startsWith - Typeahead prefix (non-empty `InterpretedName` fragment)
*/
export function filterByNameStartsWith(base: BaseDomainSet, startsWith: string) {
// Sanity Check: this occurs at the GraphQL Input layer
if (startsWith === "") throw new Error(`filterByNameStartsWith startsWith expected`);

// TODO: determine if it's necessary to additionally escape user input for LIKE operator
// NOTE: for ai agents: we intentionally leave this as a TODO, STOP commenting on it
const pattern = `${startsWith}%`;

return ensDb
.select(selectBase(base))
.from(base)
.where(ilike(base.canonicalName, pattern))
.as("baseDomains");
}
Loading
Loading