Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
eae25f6
add records resolution
sevenzing Apr 21, 2026
bbf391c
Merge branch 'main' into ll/omnigraph-resolution-api
sevenzing May 19, 2026
e8653a4
small docker fixes
sevenzing May 20, 2026
6ff2930
add graphql styled records selection
sevenzing May 20, 2026
713f1ee
add primary names field in omnigraph
sevenzing May 20, 2026
319a99d
forgot introspection
sevenzing May 20, 2026
32f53c8
remove default chain id and add disableAcceleration
sevenzing May 20, 2026
09b1435
fix docs
sevenzing May 20, 2026
7668d50
fix tests a little bit
sevenzing May 20, 2026
e1e5680
refactor
sevenzing May 20, 2026
2e00efe
Merge branch 'main' into ll/omnigraph-resolution-api
sevenzing May 20, 2026
c88dde2
fix tests
sevenzing May 20, 2026
380dadf
forgot changeset
sevenzing May 20, 2026
4fef571
Merge branch 'main' into ll/omnigraph-resolution-api
sevenzing May 20, 2026
cf7cf01
Merge branch 'main' into ll/omnigraph-resolution-api
sevenzing May 20, 2026
b1232ab
fix PR suggestions
sevenzing May 21, 2026
bf275b9
fix the cast problem
sevenzing May 21, 2026
cbdca11
remove seed-cli
sevenzing May 21, 2026
8acc0b2
Merge branch 'main' into ll/omnigraph-resolution-api
sevenzing May 21, 2026
60d2793
Merge branch 'main' into ll/omnigraph-resolution-api
sevenzing May 22, 2026
0550de5
Merge branch 'main' into ll/omnigraph-resolution-api
sevenzing May 26, 2026
7c80a61
update resolution api
sevenzing May 27, 2026
caa8f76
fix PR comments
sevenzing May 27, 2026
ab2b849
lint + generate
sevenzing May 27, 2026
5b0b1d2
default chain id
sevenzing May 27, 2026
4e94c06
add resolve { } object and trace to resolve { }
sevenzing May 28, 2026
f5bacdd
self review
sevenzing May 28, 2026
c5330f0
fix for greptile review
sevenzing May 28, 2026
6f50a9d
add EMBEDDED_DATA for AccelerationStatus
sevenzing May 28, 2026
a0008ea
self review again
sevenzing May 28, 2026
c5d7818
fix no selection bug
sevenzing May 28, 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/omnigraph-resolution-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": patch
---

**Omnigraph**: add live ENS resolution fields — `Domain.records` for forward resolution (texts, addresses, contenthash, pubkey, ABI, interfaces, and related record types) and `Account.primaryNames` for ENSIP-19 multichain primary names. Record types to resolve are selected via the GraphQL field selection on `records`; both fields accept an optional `disableAcceleration` argument.
Comment thread
sevenzing marked this conversation as resolved.
Outdated
14 changes: 13 additions & 1 deletion apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import type { Duration } from "enssdk";

import {
hasOmnigraphApiConfigSupport,
hasOmnigraphApiIndexingStatusSupport,
} from "@ensnode/ensnode-sdk";

import di from "@/di";
import { createApp } from "@/lib/hono-factory";
import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware";
import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware";
import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware";

const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 60;
Comment thread
sevenzing marked this conversation as resolved.
Outdated
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

maybe use the same constant from resolution in ensapi? not sure since dont have a lot of context for perfect realtime distance

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@sevenzing Hey there's a lot of background context about this. Please discuss this all in detail with me later. For now, here's the quick answer: Increase this value from 1-minute to 10-minutes (600 seconds). There's a better solution we will implement in another future PR but the action I've suggested here is the correct answer for now.


const app = createApp({ middlewares: [indexingStatusMiddleware] });
const app = createApp({
middlewares: [
indexingStatusMiddleware,
makeIsRealtimeMiddleware("omnigraph-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE),
canAccelerateMiddleware,
],
});

app.use(async (c, next) => {
const configPrerequisite = hasOmnigraphApiConfigSupport(di.context.stackInfo.ensIndexer);
Expand Down
6 changes: 4 additions & 2 deletions apps/ensapi/src/omnigraph-api/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
CoinType,
DomainId,
Hex,
InterfaceId,
InterpretedLabel,
InterpretedName,
Node,
Expand All @@ -28,7 +29,7 @@ import type {
import { getNamedType } from "graphql";
import superjson from "superjson";

import type { context } from "@/omnigraph-api/context";
import type { Context } from "@/omnigraph-api/context";

const tracer = trace.getTracer("graphql");
const createSpan = createOpenTelemetryWrapper(tracer, {
Expand Down Expand Up @@ -65,6 +66,7 @@ export type BuilderScalars = {
Hex: { Input: Hex; Output: Hex };
ChainId: { Input: ChainId; Output: ChainId };
CoinType: { Input: CoinType; Output: CoinType };
InterfaceId: { Input: InterfaceId; Output: InterfaceId };
Node: { Input: Node; Output: Node };
InterpretedName: { Input: InterpretedName; Output: InterpretedName };
InterpretedLabel: { Input: InterpretedLabel; Output: InterpretedLabel };
Expand All @@ -82,7 +84,7 @@ export type BuilderScalars = {
};

export const builder = new SchemaBuilder<{
Context: ReturnType<typeof context>;
Context: Context;
Scalars: BuilderScalars;

// the following ensures via typechecker that every t.connection returns a totalCount field
Expand Down
9 changes: 8 additions & 1 deletion apps/ensapi/src/omnigraph-api/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { inArray } from "drizzle-orm";
import type { DomainId, RegistryId } from "enssdk";

import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
import type { CanAccelerateMiddlewareVariables } from "@/middleware/can-accelerate.middleware";

/** Server context passed from Hono into GraphQL Yoga via `yoga.fetch(request, serverContext)`. */
export type OmnigraphYogaServerContext = CanAccelerateMiddlewareVariables;

const createRegistryParentDomainLoader = () =>
new DataLoader<RegistryId, DomainId | null>(async (registryIds) => {
Expand All @@ -23,9 +27,12 @@ const createRegistryParentDomainLoader = () =>
*
* @dev make sure that anything that is per-request (like dataloaders) are newly created in this fn
*/
export const context = () => ({
export const createOmnigraphContext = (serverContext: OmnigraphYogaServerContext) => ({
now: BigInt(getUnixTime(new Date())),
loaders: {
registryParentDomain: createRegistryParentDomainLoader(),
},
canAccelerate: serverContext.canAccelerate,
});

export type Context = ReturnType<typeof createOmnigraphContext>;
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { NormalizedAddress, RegistryId } from "enssdk";
import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span";
import { makeLogger } from "@/lib/logger";
import type { context as createContext } from "@/omnigraph-api/context";
import type { Context } from "@/omnigraph-api/context";
import { DomainCursors } from "@/omnigraph-api/lib/find-domains/domain-cursor";
import {
cursorFilter,
Expand Down Expand Up @@ -101,7 +101,7 @@ function getDefaultOrder(where: DomainsWhere | undefined | null): DomainsOrderVa
* @param args - Compound `where` filter, optional ordering, and relay connection args
*/
export function resolveFindDomains(
context: ReturnType<typeof createContext>,
context: Context,
{
where,
order,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import {
type GraphQLFieldConfigMap,
GraphQLInt,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
type GraphQLResolveInfo,
GraphQLScalarType,
GraphQLString,
Kind,
parse,
} from "graphql";
import { describe, expect, it } from "vitest";

import {
buildRecordsSelectionFromResolveInfo,
EMPTY_RECORDS_SELECTION_MESSAGE,
} from "@/omnigraph-api/lib/resolution/build-records-selection";
import {
RECORDS_SELECTION_PARAMETRIC_FIELDS,
RECORDS_SELECTION_SIMPLE_FIELDS,
} from "@/omnigraph-api/lib/resolution/records-selection-config";

const stringListArg = new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)));
const intListArg = new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLInt)));

const mockBigIntArg = new GraphQLNonNull(
new GraphQLScalarType({
name: "BigInt",
serialize: String,
parseValue: (value) => BigInt(value as string | number | bigint),
parseLiteral(ast) {
if (ast.kind === Kind.STRING || ast.kind === Kind.INT) return BigInt(ast.value);
throw new Error("BigInt literal must be a string or integer");
},
}),
);

function buildMockResolvedRecordsType() {
const fields: Record<string, { type: typeof GraphQLString; args?: Record<string, unknown> }> = {};

for (const { graphqlField } of RECORDS_SELECTION_SIMPLE_FIELDS) {
fields[graphqlField] = { type: GraphQLString };
}

for (const { graphqlField, argName } of RECORDS_SELECTION_PARAMETRIC_FIELDS) {
const argType =
argName === "coinTypes"
? intListArg
: argName === "contentTypeMask"
? mockBigIntArg
: stringListArg;
fields[graphqlField] = { type: GraphQLString, args: { [argName]: { type: argType } } };
}

return new GraphQLObjectType({
name: "ResolvedRecords",
fields: fields as GraphQLFieldConfigMap<unknown, unknown>,
});
}

const ResolvedRecordsType = buildMockResolvedRecordsType();

function parseRecordsFieldNode(subselection: string) {
const document = parse(`{ records { ${subselection} } }`);
const operation = document.definitions[0];
if (operation.kind !== "OperationDefinition") throw new Error("expected operation");

const recordsField = operation.selectionSet.selections[0];
if (recordsField.kind !== "Field") throw new Error("expected field");

return recordsField;
}

function mockResolveInfo(
fieldNodes: ReturnType<typeof parseRecordsFieldNode>[],
variableValues: Record<string, unknown> = {},
): GraphQLResolveInfo {
return {
fieldNodes,
fragments: {},
returnType: ResolvedRecordsType,
variableValues,
} as unknown as GraphQLResolveInfo;
}

function resolveInfoForRecordsSubselection(subselection: string): GraphQLResolveInfo {
return mockResolveInfo([parseRecordsFieldNode(subselection)]);
}

/** Simulates GraphQL passing multiple AST field nodes for the same `records` resolver. */
function resolveInfoForMultipleRecordsFieldNodes(...subselections: string[]): GraphQLResolveInfo {
return mockResolveInfo(subselections.map(parseRecordsFieldNode));
}

describe("buildRecordsSelectionFromResolveInfo", () => {
it.each(RECORDS_SELECTION_SIMPLE_FIELDS)(
"selects $graphqlField as $recordsSelectionKey",
({ graphqlField, recordsSelectionKey }) => {
const info = resolveInfoForRecordsSubselection(graphqlField);
expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ [recordsSelectionKey]: true });
},
);

it.each([
{
subselection: 'texts(keys: ["description"])',
expected: { texts: ["description"] },
},
{
subselection: "addresses(coinTypes: [60])",
expected: { addresses: [60] },
},
{
subselection: 'abi(contentTypeMask: "1")',
expected: { abi: 1n },
},
{
subselection: 'interfaces(ids: ["0x01020304"])',
expected: { interfaces: ["0x01020304"] },
},
])("parses parametric field: $subselection", ({ subselection, expected }) => {
const info = resolveInfoForRecordsSubselection(subselection);
expect(buildRecordsSelectionFromResolveInfo(info)).toEqual(expected);
});

it("builds combined selection across simple and parametric fields", () => {
const info = resolveInfoForRecordsSubselection(`
reverseName
contenthash
texts(keys: ["avatar", "description"])
addresses(coinTypes: [60])
abi(contentTypeMask: "1")
`);

expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({
name: true,
contenthash: true,
texts: ["avatar", "description"],
addresses: [60],
abi: 1n,
});
});

it("ignores __typename", () => {
const info = resolveInfoForRecordsSubselection("__typename reverseName");
expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({ name: true });
});

it("merges selections from multiple field nodes", () => {
const info = resolveInfoForMultipleRecordsFieldNodes(
'texts(keys: ["description"])',
"addresses(coinTypes: [60])",
);

expect(buildRecordsSelectionFromResolveInfo(info)).toEqual({
texts: ["description"],
addresses: [60],
});
});

it("throws when selection is empty", () => {
const info = resolveInfoForRecordsSubselection("__typename");
expect(() => buildRecordsSelectionFromResolveInfo(info)).toThrow(
EMPTY_RECORDS_SELECTION_MESSAGE,
);
});

it("throws when only unknown fields are selected", () => {
const info = resolveInfoForRecordsSubselection("unknownField");

expect(() => buildRecordsSelectionFromResolveInfo(info)).toThrow(
EMPTY_RECORDS_SELECTION_MESSAGE,
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
type FieldNode,
GraphQLError,
type GraphQLResolveInfo,
getArgumentValues,
getNamedType,
isObjectType,
Kind,
type SelectionSetNode,
} from "graphql";

import { isSelectionEmpty, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk";

import {
getParametricRecordsSelectionField,
getSimpleRecordsSelectionField,
} from "@/omnigraph-api/lib/resolution/records-selection-config";

export const EMPTY_RECORDS_SELECTION_MESSAGE = "Records selection cannot be empty.";

/** Recursively flatten a GraphQL selection set into Field nodes (expanding fragments). */
function collectFieldNodes(
graphqlSelectionSet: SelectionSetNode,
info: GraphQLResolveInfo,
): FieldNode[] {
const fields: FieldNode[] = [];

for (const graphqlSelection of graphqlSelectionSet.selections) {
if (graphqlSelection.kind === "Field") {
if (graphqlSelection.name.value === "__typename") continue;
fields.push(graphqlSelection);
} else if (graphqlSelection.kind === "InlineFragment") {
fields.push(...collectFieldNodes(graphqlSelection.selectionSet, info));
} else if (graphqlSelection.kind === "FragmentSpread") {
const fragment = info.fragments[graphqlSelection.name.value];
if (fragment) fields.push(...collectFieldNodes(fragment.selectionSet, info));
}
}

return fields;
}

/**
* Builds a {@link ResolverRecordsSelection} from the GraphQL field selection on `Domain.records`.
*
* GraphQL clients express *what* to resolve via a field selection set (e.g. `records { texts(...) }`).
* The ENS resolution layer expects a flat {@link ResolverRecordsSelection} instead — this function
* translates between the two.
*/
export function buildRecordsSelectionFromResolveInfo(
info: GraphQLResolveInfo,
): ResolverRecordsSelection {
// GraphQL may pass multiple AST field nodes for the same resolver when the client splits
// `records { ... }` across inline fragments (common on the `Domain` interface). Merge their
// GraphQL selection lists so we don't drop subselections on fieldNodes[1], fieldNodes[2], etc.
const graphqlSelections = info.fieldNodes.flatMap((node) => node.selectionSet?.selections ?? []);

if (graphqlSelections.length === 0) {
throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE);
}

// collectFieldNodes expects a SelectionSetNode; wrap the merged GraphQL selections into one.
const mergedGraphqlSelectionSet: SelectionSetNode = {
kind: Kind.SELECTION_SET,
selections: graphqlSelections,
};

const returnType = getNamedType(info.returnType);
if (!isObjectType(returnType)) {
throw new GraphQLError("Return type must be an object type.");
}

// Output for resolveForward(), e.g. { texts: ["description"], addresses: [60] }.
const recordsSelection: ResolverRecordsSelection = {};

// Walk every GraphQL child field under `records` (skipping __typename, expanding fragments).
for (const childField of collectFieldNodes(mergedGraphqlSelectionSet, info)) {
const graphqlField = childField.name.value;

// Simple GraphQL fields (contenthash, pubkey, …) map 1:1 to a boolean in recordsSelection.
const simple = getSimpleRecordsSelectionField(graphqlField);
if (simple) {
recordsSelection[simple.recordsSelectionKey] = true;
continue;
}

// Parametric GraphQL fields (texts, addresses, …) carry args we copy into recordsSelection.
const parametric = getParametricRecordsSelectionField(graphqlField);
if (!parametric) continue;

const fieldDef = returnType.getFields()[graphqlField];
if (!fieldDef) continue;

const args = getArgumentValues(fieldDef, childField, info.variableValues);
parametric.applyToRecordsSelection(recordsSelection, args);
}

// GraphQL query selected only __typename or unknown fields — nothing to resolve.
if (isSelectionEmpty(recordsSelection)) {
throw new GraphQLError(EMPTY_RECORDS_SELECTION_MESSAGE);
}

return recordsSelection;
}
Loading
Loading