Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
db21e8e
checkpoint: initial spec
shrugs Apr 21, 2026
a7e70bb
enssdk: add v1/v2 registry id brands and reshape ENSv1DomainId
shrugs Apr 21, 2026
10e0307
ensdb-sdk, ensnode-sdk: unified polymorphic domain + registry schema
shrugs Apr 21, 2026
0af2647
ensindexer: migrate handlers to unified domain + polymorphic registry
shrugs Apr 21, 2026
5418244
ensapi: migrate omnigraph to unified domain + polymorphic registry
shrugs Apr 22, 2026
707f33f
chore: add breaking changeset for unified polymorphic domain/registry
shrugs Apr 22, 2026
8a7bca8
Merge branch 'main' into refactor/ensv1-domain-model
shrugs Apr 22, 2026
e57f87c
fix: openapi-schema
shrugs Apr 22, 2026
5534db0
fix: address pr notes
shrugs Apr 22, 2026
2b3e296
Merge branch 'main' into refactor/ensv1-domain-model
shrugs Apr 23, 2026
d29a5fb
fix: inline ownerId materialization to save a db op
shrugs Apr 23, 2026
c38284f
feat(ensapi): Query.root non-null, prefer v2; add ENSv1 coverage tests
shrugs Apr 23, 2026
a2434ec
fix: regenerate gql schema
shrugs Apr 23, 2026
e13da69
style: fix typos + clarify Domain.path description
shrugs Apr 23, 2026
ef9f65a
fix: schema and agents.md
shrugs Apr 23, 2026
9a3f33c
refactor(ensnode-sdk): getRootRegistryId helper; clarify canonical-re…
shrugs Apr 23, 2026
5c05677
perf(ensapi): short-circuit null subregistry_id in canonical-registri…
shrugs Apr 23, 2026
3ae0993
refactor(ensapi,enssdk,ensdb-sdk): consolidate dev query, add makeCon…
shrugs Apr 23, 2026
0f34ce1
chore(enssdk): regenerate GraphQL schema — ENSv1Registry description …
shrugs Apr 23, 2026
c37a9a4
fix(ensapi,ensnode-sdk,enskit): multi-root traversal + type-guard + g…
shrugs Apr 23, 2026
9d59225
fix(ensapi): multi-root canonical-path + DISTINCT canonical-registrie…
shrugs Apr 23, 2026
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/query-root-nonnull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

`Query.root` is now non-null and returns the namespace's Root Registry — preferring the ENSv2 Root Registry when defined, falling back to the ENSv1 Root Registry.
24 changes: 24 additions & 0 deletions .changeset/unified-domain-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"enssdk": minor
"@ensnode/ensdb-sdk": minor
"@ensnode/ensnode-sdk": minor
"ensindexer": minor
"ensapi": minor
---

Unify `v1Domain` + `v2Domain` into a single polymorphic `domain` table discriminated by a `type` enum (`"ENSv1Domain"` | `"ENSv2Domain"`), and make Registry polymorphic across concrete ENSv1 (mainnet Registry, Basenames Registry, Lineanames Registry), ENSv1 Virtual (per-parent-domain virtual Registry managed by each ENSv1 domain that has children), and ENSv2 Registries.

### Breaking schema + id format changes

- `ENSv1DomainId` is now CAIP-shaped: `${ENSv1RegistryId}/${node}` (was `Node`). Every ENSv1 Domain is addressable through a concrete Registry, so bare `node` values no longer identify a Domain by themselves.
- `RegistryId` is a union of `ENSv1RegistryId`, `ENSv1VirtualRegistryId`, and `ENSv2RegistryId`. New id constructors: `makeENSv1RegistryId`, `makeENSv2RegistryId`, `makeENSv1VirtualRegistryId`. `makeENSv1DomainId` now takes `(AccountId, Node)`. `makeRegistryId` is retained as a union-returning helper for callsites that can't narrow.
Comment thread
shrugs marked this conversation as resolved.
- `domains` table: replaces `v1_domains` + `v2_domains`. Adds `type`, nullable `tokenId` (non-null iff ENSv2), nullable `node` (non-null iff ENSv1), nullable `rootRegistryOwnerId` (v1 only). `parentId` removed; parent relationships flow through `registryCanonicalDomain` for both v1 and v2.
- `registries` table: adds `type` enum column and nullable `node` (non-null iff `ENSv1VirtualRegistry`). Unique `(chainId, address)` index becomes a plain index so virtual Registries can share their concrete parent's `(chainId, address)`.
- `registryCanonicalDomain.domainId` is typed as the unified `DomainId`.

### GraphQL

- `Registry` becomes a GraphQL interface with `ENSv1Registry`, `ENSv1VirtualRegistry`, and `ENSv2Registry` implementations. `ENSv1VirtualRegistry` exposes `node: Node!`.
- `Domain` interface gains `parent: Domain` (resolved via the canonical-path dataloader); `ENSv1Domain` exposes `node: Node!` and `rootRegistryOwner`; `ENSv2Domain` exposes `tokenId`, `registry`, `subregistry`, `permissions`.
- `Query.registry(by: { contract })` now DB-looks up the concrete Registry by `(chainId, address, type IN (ENSv1Registry, ENSv2Registry))`. Virtual Registries are not addressable via `AccountId` alone.

4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ Runnable commands for validating changes; lint and format with Biome.
- Use `describe`/`it` blocks with `expect` assertions.
- Use `vi.mock()` for module mocking and `vi.fn()` for function stubs.
- Each app and package has its own `vitest.config.ts`.
- Prefer the `await expect(...).resolves.*` format over await-then-expect.
- Prefer `await expect(...).resolves.toMatchObject({})` over expecting individual properties, if it is more concise.

## Documentation & DRY

Expand Down Expand Up @@ -81,3 +83,5 @@ Fail fast and loudly on invalid inputs.
1. `pnpm -F <affected-project> typecheck`
2. `pnpm lint`
3. `pnpm test --project <affected-project> [--project <other-affected-project>]`
4. If OpenAPI Specs were affected, run `pnpm generate:openapi`
Comment thread
shrugs marked this conversation as resolved.
5. If the Omnigraph GraphQL Schema was affected, run `pnpm generate:gqlschema`
Comment thread
shrugs marked this conversation as resolved.
18 changes: 6 additions & 12 deletions apps/ensapi/src/omnigraph-api/context.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import DataLoader from "dataloader";
import { getUnixTime } from "date-fns";
import type { CanonicalPath, ENSv1DomainId, ENSv2DomainId } from "enssdk";
import type { CanonicalPath, DomainId } from "enssdk";

import { getV1CanonicalPath, getV2CanonicalPath } from "./lib/get-canonical-path";
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 createV1CanonicalPathLoader = () =>
new DataLoader<ENSv1DomainId, CanonicalPath | null>(async (domainIds) =>
Promise.all(domainIds.map((id) => getV1CanonicalPath(id).catch(errorAsValue))),
);

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

/**
Expand All @@ -28,7 +23,6 @@ const createV2CanonicalPathLoader = () =>
export const context = () => ({
now: BigInt(getUnixTime(new Date())),
loaders: {
v1CanonicalPath: createV1CanonicalPathLoader(),
v2CanonicalPath: createV2CanonicalPathLoader(),
canonicalPath: createCanonicalPathLoader(),
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,46 @@ import config from "@/config";

import { sql } from "drizzle-orm";

import { maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk";
import { getRootRegistryIds } from "@ensnode/ensnode-sdk";

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

/**
* The maximum depth to traverse the ENSv2 namegraph in order to construct the set of Canonical
* Registries.
* The maximum depth to traverse the namegraph in order to construct the set of Canonical Registries.
*
* Note that the set of Canonical Registries in the ENSv2 Namegraph is a _tree_, enforced by the
* requirement that each Registry maintain a reverse-pointer to its Canonical Domain, a form of
* 'edge authentication': if the reverse-pointer doesn't agree with the forward-pointer, the edge
* is not traversed, making cycles within the direced graph impossible.
* The CTE walks `domain.subregistryId` forward from every Root Registry. `subregistryId` is the
* source-of-truth forward pointer, so no separate edge-authentication is needed — a Registry is
* canonical iff it is reachable via a chain of live forward pointers from a Root.
*
* So while technically not necessary, including the depth constraint avoids the possibility of an
* infinite runaway query in the event that the indexed namegraph is somehow corrupted or otherwise
* introduces a canonical cycle.
* The reachable set is a DAG, not a tree: aliased subregistries let multiple parent Domains
* declare the same child Registry, so the same row can appear at multiple depths during recursion.
* The outer projection dedupes via `SELECT DISTINCT`; `MAX_DEPTH` bounds runaway recursion if the
* graph is corrupted.
*/
const CANONICAL_REGISTRIES_MAX_DEPTH = 16;

// lazy() defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
const getENSV2RootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace));

/**
* Builds a recursive CTE that traverses from the ENSv2 Root Registry to construct a set of all
* Canonical Registries. A Canonical Registry is an ENSv2 Registry that is the Root Registry or the
* (sub)Registry of a Domain in a Canonical Registry.
* Builds a recursive CTE that traverses forward from every top-level Root Registry configured for
* the namespace (all concrete ENSv1Registries plus the ENSv2 Root when defined) to construct a
* set of all Canonical Registries.
*
* A Canonical Registry is one whose Domains are resolvable under the primary resolution pipeline.
* This includes both the ENSv2 subtree and every ENSv1 subtree: Universal Resolver v2 falls back
* to ENSv1 at resolution time for names not (yet) present in ENSv2, so ENSv1 Domains remain
* canonical from a resolution perspective.
*
* Both ENSv1 and ENSv2 Domains set `subregistryId` (ENSv1 Domains to their managed ENSv1
* VirtualRegistry, ENSv2 Domains to their declared Subregistry), so a single recursive step over
* `domain.subregistryId` covers both lineages.
*
* TODO: could this be optimized further, perhaps as a materialized view?
*/
export const getCanonicalRegistriesCTE = () => {
// if ENSv2 is not defined, return an empty set with identical structure to below
if (!getENSV2RootRegistryId()) {
return ensDb
.select({ id: sql<string>`registry_id`.as("id") })
.from(sql`(SELECT NULL::text AS registry_id WHERE FALSE) AS canonical_registries_cte`)
.as("canonical_registries");
}
const roots = getRootRegistryIds(config.namespace);

const rootsUnion = roots
.map((root) => sql`SELECT ${root}::text AS registry_id, 0 AS depth`)
.reduce((acc, part, i) => (i === 0 ? part : sql`${acc} UNION ALL ${part}`));
Comment thread
shrugs marked this conversation as resolved.

return ensDb
.select({
Expand All @@ -53,15 +54,17 @@ export const getCanonicalRegistriesCTE = () => {
sql`
(
WITH RECURSIVE canonical_registries AS (
SELECT ${getENSV2RootRegistryId()}::text AS registry_id, 0 AS depth
${rootsUnion}
UNION ALL
SELECT rcd.registry_id, cr.depth + 1
FROM ${ensIndexerSchema.registryCanonicalDomain} rcd
JOIN ${ensIndexerSchema.v2Domain} parent ON parent.id = rcd.domain_id AND parent.subregistry_id = rcd.registry_id
JOIN canonical_registries cr ON cr.registry_id = parent.registry_id
-- Filter nulls at the recursive step so terminal Domains (no subregistry declared) don't
-- emit null rows into the CTE and don't spawn dead-end recursion branches.
SELECT d.subregistry_id AS registry_id, cr.depth + 1
FROM canonical_registries cr
JOIN ${ensIndexerSchema.domain} d ON d.registry_id = cr.registry_id
WHERE cr.depth < ${CANONICAL_REGISTRIES_MAX_DEPTH}
Comment thread
shrugs marked this conversation as resolved.
AND d.subregistry_id IS NOT NULL
)
Comment thread
shrugs marked this conversation as resolved.
Comment on lines 56 to 66
SELECT registry_id FROM canonical_registries
SELECT DISTINCT registry_id FROM canonical_registries
) AS canonical_registries_cte`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
.as("canonical_registries");
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, unionAll } from "drizzle-orm/pg-core";
import type { DomainId, NormalizedAddress } from "enssdk";
import { alias } from "drizzle-orm/pg-core";
import type { DomainId, NormalizedAddress, RegistryId } from "enssdk";

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

Expand All @@ -10,73 +10,51 @@ import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
export type BaseDomainSet = ReturnType<typeof domainsBase>;

/**
* Universal base domain set: all v1 and v2 domains with consistent metadata.
* Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata.
*
* Returns {domainId, ownerId, registryId, parentId, labelHash, sortableLabel} where:
* - registryId is NULL for v1 domains (all v1 domains are canonical)
* - v1 parentId comes directly from the v1Domain.parentId column
* - v2 parentId is derived via canonical registry traversal: look up the canonical domain
* for this domain's registry (via registryCanonicalDomain), then verify the reverse pointer
* (parent.subregistryId = child.registryId). See getV2CanonicalPath for the recursive version.
* - sortableLabel is the domain's own interpreted label, used for NAME ordering, which can be
* overridden by future layers.
* Returns `{ domainId, ownerId, registryId, parentId, labelHash, sortableLabel }` where `parentId`
* is derived via the domain's registry → canonical domain link (`registryCanonicalDomain`)
* and `sortableLabel` is the domain's own interpreted label, used for NAME ordering, and can be
* overridden by later layers.
*
* All downstream filters (owner, parent, registry, name, canonical) operate on this shape.
*/
export function domainsBase() {
const v2ParentDomain = alias(ensIndexerSchema.v2Domain, "v2ParentDomain");
const parentDomain = alias(ensIndexerSchema.domain, "parentDomain");

return unionAll(
return (
ensDb
.select({
domainId: sql<DomainId>`${ensIndexerSchema.v1Domain.id}`.as("domainId"),
ownerId: sql<NormalizedAddress | null>`${ensIndexerSchema.v1Domain.ownerId}`.as("ownerId"),
registryId: sql<string | null>`NULL::text`.as("registryId"),
parentId: sql<DomainId | null>`${ensIndexerSchema.v1Domain.parentId}`.as("parentId"),
labelHash: sql<string>`${ensIndexerSchema.v1Domain.labelHash}`.as("labelHash"),
domainId: sql<DomainId>`${ensIndexerSchema.domain.id}`.as("domainId"),
ownerId: sql<NormalizedAddress | null>`${ensIndexerSchema.domain.ownerId}`.as("ownerId"),
registryId: sql<RegistryId>`${ensIndexerSchema.domain.registryId}`.as("registryId"),
parentId: sql<DomainId | null>`${parentDomain.id}`.as("parentId"),
labelHash: sql<string>`${ensIndexerSchema.domain.labelHash}`.as("labelHash"),
sortableLabel: sql<string | null>`${ensIndexerSchema.label.interpreted}`.as(
"sortableLabel",
),
})
.from(ensIndexerSchema.v1Domain)
.leftJoin(
ensIndexerSchema.label,
eq(ensIndexerSchema.label.labelHash, ensIndexerSchema.v1Domain.labelHash),
),
ensDb
.select({
domainId: sql<DomainId>`${ensIndexerSchema.v2Domain.id}`.as("domainId"),
ownerId: sql<NormalizedAddress | null>`${ensIndexerSchema.v2Domain.ownerId}`.as("ownerId"),
registryId: sql<string | null>`${ensIndexerSchema.v2Domain.registryId}`.as("registryId"),
parentId: sql<DomainId | null>`${v2ParentDomain.id}`.as("parentId"),
labelHash: sql<string>`${ensIndexerSchema.v2Domain.labelHash}`.as("labelHash"),
sortableLabel: sql<string | null>`${ensIndexerSchema.label.interpreted}`.as(
"sortableLabel",
),
})
.from(ensIndexerSchema.v2Domain)
// derive v2 parentId via canonical registry traversal:
// 1. find the canonical domain for this domain's registry
.from(ensIndexerSchema.domain)
Comment thread
shrugs marked this conversation as resolved.
// parentId derivation: domain.registryId → canonical parent domain via registryCanonicalDomain.
// The `parentDomain.subregistryId = domain.registryId` clause performs edge authentication.
.leftJoin(
ensIndexerSchema.registryCanonicalDomain,
eq(
ensIndexerSchema.registryCanonicalDomain.registryId,
ensIndexerSchema.v2Domain.registryId,
),
eq(ensIndexerSchema.registryCanonicalDomain.registryId, ensIndexerSchema.domain.registryId),
)
// 2. verify the reverse pointer: parent.id = rcd.domainId AND parent.subregistryId = child.registryId
.leftJoin(
v2ParentDomain,
parentDomain,
and(
eq(v2ParentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId),
eq(v2ParentDomain.subregistryId, ensIndexerSchema.v2Domain.registryId),
eq(parentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId),
eq(parentDomain.subregistryId, ensIndexerSchema.domain.registryId),
),
)
// join label for labelHash/sortableLabel
.leftJoin(
ensIndexerSchema.label,
eq(ensIndexerSchema.label.labelHash, ensIndexerSchema.v2Domain.labelHash),
),
).as("baseDomains");
eq(ensIndexerSchema.label.labelHash, ensIndexerSchema.domain.labelHash),
)
.as("baseDomains")
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, isNotNull, isNull, or } from "drizzle-orm";
import { eq } from "drizzle-orm";

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

Expand All @@ -7,25 +7,13 @@ import { type BaseDomainSet, selectBase } from "./base-domain-set";

/**
* Filter a base domain set to only include Canonical Domains.
*
* All v1Domains are Canonical (registryId IS NULL).
* v2Domains are Canonical iff their registryId is reachable from the ENSv2 Root Registry.
*
* Uses LEFT JOIN with canonical registries CTE: v1 domains pass through (registryId IS NULL),
* v2 domains must match a canonical registry.
*/
export function filterByCanonical(base: BaseDomainSet) {
const canonicalRegistries = getCanonicalRegistriesCTE();

return ensDb
.select(selectBase(base))
.from(base)
.leftJoin(canonicalRegistries, eq(canonicalRegistries.id, base.registryId))
.where(
or(
isNull(base.registryId), // v1 domains are always canonical
isNotNull(canonicalRegistries.id), // v2 domains must be in a canonical registry
),
)
.innerJoin(canonicalRegistries, eq(canonicalRegistries.id, base.registryId))
.as("baseDomains");
}
Comment on lines 14 to 19
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 ENSv1 domains with unset subregistryId silently fall out of canonical results

filterByCanonical switched from a LEFT JOIN … WHERE (registryId IS NULL OR canonical) pattern to a plain INNER JOIN. In the new model every domain — including ENSv1 — has a non-null registryId, so ENSv1 domains will only appear in the canonical set when their registryId is reachable from a root via canonical_registries. That reachability depends on domain.subregistryId being correctly set on every ancestor. If a child NewOwner fires before its parent exists, the UPDATE ... SET subregistryId is a no-op, and any descendant of that gap will have a registryId never emitted by the CTE, disappearing from canonical results. The old registryId IS NULL pass-through was a safe default; the new strict inner-join makes results sensitive to indexing event order.

Loading
Loading