Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4b125d0
Introduce `IndexingMetadataContext` data model to ENSNode SDK
tk-o Apr 24, 2026
dcad533
Create `init*` functions for running event handlers preconditions
tk-o Apr 24, 2026
a5a8bd6
Execute `migrateEnsNodeSchema` from inside of `initIndexingSetup` fun…
tk-o Apr 24, 2026
c57c2ac
Use `EnsIndexerStackInfo` for `stackInfo` field in `IndexingMetadataC…
tk-o Apr 24, 2026
a28b0de
Add `getIndexingMetadataContext` method to `EnsDbReader` class
tk-o Apr 24, 2026
e91bd13
Add `upsertIndexingMetadataContext` method to `EnsDbWriter` class
tk-o Apr 24, 2026
8892dd5
Update `EnsDbWriterWorker` class to serve a new limited role
tk-o Apr 24, 2026
ee1190e
Fix ponder build issue
tk-o Apr 24, 2026
4954e21
Fix typos
tk-o Apr 24, 2026
2f8532e
Implement full tasks sequence for `initIndexingOnchainEvents`
tk-o Apr 25, 2026
361e99d
Merge `initIndexingSetup` function into `initIndexingOnchainEvents`
tk-o Apr 25, 2026
7595a41
Update unit tests
tk-o Apr 25, 2026
1902bfb
Use `getIndexingMetadataContext` for all ENSNode Metadata reads from …
tk-o Apr 25, 2026
6bda3d6
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 25, 2026
e3ddda0
Simplify `EnsDbReader` class and `EnsDbWriter` class
tk-o Apr 25, 2026
4684fb4
Fix tests
tk-o Apr 25, 2026
1ff960e
Apply AI PR feedback
tk-o Apr 26, 2026
41060d0
Simplify `initIndexingOnchainEvents` logic
tk-o Apr 26, 2026
e1d6d04
Update unit tests
tk-o Apr 26, 2026
98e6c45
Simplify `initIndexingOnchainEvents` function
tk-o Apr 26, 2026
eebe386
Simplify logic in ENSIndexer HTTP endpoints
tk-o Apr 26, 2026
11711ca
Improve naming
tk-o Apr 26, 2026
89c974a
Make indexing status cache and stack info cache for ENSApi to use the…
tk-o Apr 27, 2026
292ed35
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 27, 2026
159c4ff
Improve code docs
tk-o Apr 27, 2026
b36418b
Create a mock file for config.schema.ts
tk-o Apr 27, 2026
f3355ef
Integrate ENSDb health check and readiness check into `initIndexingOn…
tk-o Apr 27, 2026
40961af
Update code docs
tk-o Apr 27, 2026
6279dea
Apply AI PR feedback
tk-o Apr 27, 2026
43ef45b
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 27, 2026
ad1564f
Apply AI PR feedback
tk-o Apr 28, 2026
110927d
Add changeset
tk-o Apr 28, 2026
bcd81fe
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 28, 2026
698191f
Apply AI PR feedback
tk-o Apr 28, 2026
725901f
Fix re-exports
tk-o Apr 29, 2026
55ae265
Merge remote-tracking branch 'origin/main' into feat/indexing-metadat…
tk-o Apr 29, 2026
84b52e1
Create ENSRainbow readiness logic inline in ENSIndexer
tk-o Apr 29, 2026
5cc44ea
Fix typo
tk-o Apr 29, 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
26 changes: 16 additions & 10 deletions apps/ensapi/src/cache/indexing-status.cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk";
import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk";
import {
type CrossChainIndexingStatusSnapshot,
IndexingMetadataContextStatusCodes,
SWRCache,
} from "@ensnode/ensnode-sdk";

import { ensDbClient } from "@/lib/ensdb/singleton";
import { lazyProxy } from "@/lib/lazy";
Expand All @@ -16,31 +20,33 @@ export const indexingStatusCache = lazyProxy<SWRCache<CrossChainIndexingStatusSn
new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async (_cachedResult) =>
ensDbClient
.getIndexingStatusSnapshot() // get the latest indexing status snapshot
.then((snapshot) => {
if (snapshot === undefined) {
// An indexing status snapshot has not been found in ENSDb yet.
.getIndexingMetadataContext() // get the latest indexing status snapshot
.then((indexingMetadataContext) => {
if (
indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized
) {
// The Indexing Metadata Context has not been initialized in ENSDb yet.
// This might happen during application startup, i.e. when ENSDb
// has not yet been populated with the first snapshot.
// Therefore, throw an error to trigger the subsequent `.catch` handler.
throw new Error("Indexing Status snapshot not found in ENSDb yet.");
throw new Error("Indexing Metadata Context was uninitialized in ENSDb.");
}

// The indexing status snapshot has been fetched and successfully validated for caching.
// Therefore, return it so that this current invocation of `readCache` will:
// - Replace the currently cached value (if any) with this new value.
// - Return this non-null value.
return snapshot;
return indexingMetadataContext.indexingStatus;
})
.catch((error) => {
// Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
// Indexing Metadata Context was uninitialized in ENSDb.
// Therefore, throw an error so that this current invocation of `readCache` will:
// - Reject the newly fetched response (if any) such that it won't be cached.
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
logger.error(
error,
`Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` +
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` +
`Error occurred while loading Indexing Metadata Context record from ENSNode Metadata table in ENSDb. ` +
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.IndexingMetadataContext}"). ` +
`The cached indexing status snapshot (if any) will not be updated.`,
);
throw error;
Expand Down
172 changes: 126 additions & 46 deletions apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,111 @@ import packageJson from "@/../package.json" with { type: "json" };

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { type ENSIndexerPublicConfig, PluginName } from "@ensnode/ensnode-sdk";
import {
ChainIndexingStatusIds,
CrossChainIndexingStrategyIds,
deserializeIndexingMetadataContext,
type EnsRainbowPublicConfig,
type IndexingMetadataContextInitialized,
IndexingMetadataContextStatusCodes,
OmnichainIndexingStatusIds,
PluginName,
RangeTypeIds,
type SerializedCrossChainIndexingStatusSnapshot,
type SerializedEnsDbPublicConfig,
type SerializedEnsIndexerPublicConfig,
type SerializedEnsIndexerStackInfo,
type SerializedIndexingMetadataContextInitialized,
} from "@ensnode/ensnode-sdk";
import type { RpcConfig } from "@ensnode/ensnode-sdk/internal";

import { ensApiVersionInfo } from "@/lib/version-info";

const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234";

const ENSDB_PUBLIC_CONFIG = {
versionInfo: {
postgresql: "17.4",
},
} satisfies SerializedEnsDbPublicConfig;

const ENSINDEXER_PUBLIC_CONFIG = {
namespace: "mainnet",
ensIndexerSchemaName: "ensindexer_0",
ensRainbowPublicConfig: {
serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 },
versionInfo: {
ensRainbow: packageJson.version,
},
},
indexedChainIds: [1],
isSubgraphCompatible: false,
clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 },
plugins: [PluginName.Subgraph],
versionInfo: {
ensDb: packageJson.version,
ensIndexer: packageJson.version,
ensNormalize: ensApiVersionInfo.ensNormalize,
ponder: "0.8.0",
},
} satisfies SerializedEnsIndexerPublicConfig;

const ENSRAINBOW_PUBLIC_CONFIG = {
serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 },
versionInfo: {
ensRainbow: packageJson.version,
},
} satisfies EnsRainbowPublicConfig;

const INDEXING_STATUS = {
strategy: CrossChainIndexingStrategyIds.Omnichain,
slowestChainIndexingCursor: 1777147427,
snapshotTime: 1777147440,
omnichainSnapshot: {
omnichainStatus: OmnichainIndexingStatusIds.Following,
chains: {
"1": {
chainStatus: ChainIndexingStatusIds.Following,
config: {
rangeType: RangeTypeIds.LeftBounded,
startBlock: {
timestamp: 1489165544,
number: 3327417,
},
},
latestIndexedBlock: {
timestamp: 1777147427,
number: 24959286,
},
latestKnownBlock: {
timestamp: 1777147427,
number: 24959286,
},
},
},
omnichainIndexingCursor: 1777147427,
},
} satisfies SerializedCrossChainIndexingStatusSnapshot;

const ENSINDEXER_STACK_INFO = {
ensDb: ENSDB_PUBLIC_CONFIG,
ensIndexer: ENSINDEXER_PUBLIC_CONFIG,
ensRainbow: ENSRAINBOW_PUBLIC_CONFIG,
} satisfies SerializedEnsIndexerStackInfo;

const INDEXING_METADATA_CONTEXT = {
statusCode: IndexingMetadataContextStatusCodes.Initialized,
indexingStatus: INDEXING_STATUS,
stackInfo: ENSINDEXER_STACK_INFO,
} satisfies SerializedIndexingMetadataContextInitialized;

const indexingMetadataContextInitialized = deserializeIndexingMetadataContext(
INDEXING_METADATA_CONTEXT,
) as IndexingMetadataContextInitialized;
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.

🧹 Nitpick | 🔵 Trivial

Prefer a runtime assertion over the bare type cast.

deserializeIndexingMetadataContext(...) returns a union, and the as IndexingMetadataContextInitialized cast silently lies if the deserializer ever produces a non-initialized variant (e.g., due to a future shape change in the fixture). A small invariant check (e.g., if (deserialized.statusCode !== IndexingMetadataContextStatusCodes.Initialized) throw new Error(...)) makes the test fail loudly at the fixture rather than at an assertion deep inside buildConfigFromEnvironment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensapi/src/config/config.schema.test.ts` around lines 103 - 105, Replace
the bare cast of deserializeIndexingMetadataContext(INDEXING_METADATA_CONTEXT)
to IndexingMetadataContextInitialized with a runtime invariant check: call
deserializeIndexingMetadataContext(...) into a variable, verify its statusCode
equals IndexingMetadataContextStatusCodes.Initialized, and if not throw a clear
Error (mentioning INDEXING_METADATA_CONTEXT and the unexpected status) so the
test fails immediately; only after this check treat the value as an
IndexingMetadataContextInitialized before passing it to
buildConfigFromEnvironment or further assertions.


vi.mock("@/lib/ensdb/singleton", () => ({
ensDbClient: {
getEnsIndexerPublicConfig: vi.fn(async () => ENSINDEXER_PUBLIC_CONFIG),
getIndexingMetadataContext: vi.fn(async () => indexingMetadataContextInitialized),
},
}));

Expand All @@ -22,7 +121,6 @@ import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/co
import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import type { EnsApiEnvironment } from "@/config/environment";
import logger from "@/lib/logger";
import { ensApiVersionInfo } from "@/lib/version-info";

vi.mock("@/lib/logger", () => ({
default: {
Expand All @@ -31,44 +129,23 @@ vi.mock("@/lib/logger", () => ({
},
}));

const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234";

const BASE_ENV = {
ENSDB_URL: "postgresql://user:password@localhost:5432/mydb",
ENSINDEXER_SCHEMA_NAME: "ensindexer_0",
RPC_URL_1: VALID_RPC_URL,
} satisfies EnsApiEnvironment;

const ENSINDEXER_PUBLIC_CONFIG = {
namespace: "mainnet",
ensIndexerSchemaName: "ensindexer_0",
ensRainbowPublicConfig: {
serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 },
versionInfo: {
ensRainbow: packageJson.version,
},
},
indexedChainIds: new Set([1]),
isSubgraphCompatible: false,
clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 },
plugins: [PluginName.Subgraph],
versionInfo: {
ensDb: packageJson.version,
ensIndexer: packageJson.version,
ensNormalize: ensApiVersionInfo.ensNormalize,
ponder: "0.8.0",
},
} satisfies ENSIndexerPublicConfig;

describe("buildConfigFromEnvironment", () => {
it("returns a valid config object using environment variables", async () => {
const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo;
await expect(buildConfigFromEnvironment(BASE_ENV)).resolves.toStrictEqual({
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME,
theGraphApiKey: undefined,

ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs: new Map([
[
1,
Expand Down Expand Up @@ -153,12 +230,13 @@ describe("buildConfigFromEnvironment", () => {

describe("buildEnsApiPublicConfig", () => {
it("returns a valid ENSApi public config with correct structure", () => {
const mockConfig = {
const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo;
const ensApiConfig = {
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME,
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs: new Map([
[
1,
Expand All @@ -171,52 +249,54 @@ describe("buildEnsApiPublicConfig", () => {
referralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);
const result = buildEnsApiPublicConfig(ensApiConfig);

expect(result).toStrictEqual({
versionInfo: ensApiVersionInfo,
theGraphFallback: {
canFallback: false,
reason: "not-subgraph-compatible",
},
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
ensIndexerPublicConfig,
});
});

it("preserves the complete ENSIndexer public config structure", () => {
const mockConfig = {
const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo;
const ensApiConfig = {
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME,
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs: new Map(),
referralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);
const result = buildEnsApiPublicConfig(ensApiConfig);

// Verify that all ENSIndexer public config fields are preserved
expect(result.ensIndexerPublicConfig).toStrictEqual(ENSINDEXER_PUBLIC_CONFIG);
expect(result.ensIndexerPublicConfig).toStrictEqual(ensIndexerPublicConfig);
});

it("includes the theGraphFallback and redacts api key", () => {
const mockConfig = {
const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo;
const ensApiConfig = {
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME,
ensIndexerPublicConfig: {
...ENSINDEXER_PUBLIC_CONFIG,
...ensIndexerPublicConfig,
plugins: ["subgraph"],
isSubgraphCompatible: true,
},
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs: new Map(),
referralProgramEditionConfigSetUrl: undefined,
theGraphApiKey: "secret-api-key",
};

const result = buildEnsApiPublicConfig(mockConfig);
const result = buildEnsApiPublicConfig(ensApiConfig);

expect(result.theGraphFallback.canFallback).toBe(true);
// discriminate the type...
Expand Down
10 changes: 5 additions & 5 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pRetry from "p-retry";
import { prettifyError, ZodError, z } from "zod/v4";

import type { EnsApiPublicConfig } from "@ensnode/ensnode-sdk";
import { type EnsApiPublicConfig, IndexingMetadataContextStatusCodes } from "@ensnode/ensnode-sdk";
import {
buildRpcConfigsFromEnv,
canFallbackToTheGraph,
Expand Down Expand Up @@ -70,13 +70,13 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis
// https://github.com/namehash/ensnode/issues/1806
const ensIndexerPublicConfig = await pRetry(
async () => {
const config = await ensDbClient.getEnsIndexerPublicConfig();
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

if (!config) {
throw new Error("ENSIndexer Public Config not yet available in ENSDb.");
if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) {
throw new Error("Indexing metadata context is uninitialized in ENSDb.");
}

return config;
return indexingMetadataContext.stackInfo.ensIndexer;
},
{
retries: 13, // This allows for a total of over 1 hour of retries with the exponential backoff strategy
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.

Should we keep such a long retry period still? Appreciate your advice!

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.

We should be able to remove this whole async call once #1806 is resolved.

Expand Down
12 changes: 7 additions & 5 deletions apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
EnsIndexerIndexingStatusResponseCodes,
type EnsIndexerIndexingStatusResponseError,
type EnsIndexerIndexingStatusResponseOk,
IndexingMetadataContextStatusCodes,
serializeEnsIndexerIndexingStatusResponse,
serializeEnsIndexerPublicConfig,
} from "@ensnode/ensnode-sdk";
Expand All @@ -17,32 +18,33 @@ const app = new Hono();

// include ENSIndexer Public Config endpoint
app.get("/config", async (c) => {
const publicConfig = await ensDbClient.getEnsIndexerPublicConfig();
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

// Invariant: the public config is guaranteed to be available in ENSDb after
// application startup.
if (typeof publicConfig === "undefined") {
if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) {
throw new Error("Unreachable: ENSIndexer Public Config is not available in ENSDb");
}
Comment thread
tk-o marked this conversation as resolved.
Outdated

// respond with the serialized public config object
return c.json(serializeEnsIndexerPublicConfig(publicConfig));
return c.json(serializeEnsIndexerPublicConfig(indexingMetadataContext.stackInfo.ensIndexer));
});

app.get("/indexing-status", async (c) => {
try {
const crossChainSnapshot = await ensDbClient.getIndexingStatusSnapshot();
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

// Invariant: the Indexing Status Snapshot is expected to be available in
// ENSDb shortly after application startup. There is a possibility that
// the snapshot is not yet available at the time of the request,
// i.e. when ENSDb has not yet been populated with the first snapshot.
// In this case, we treat the snapshot as unavailable and respond with
// an error response.
if (typeof crossChainSnapshot === "undefined") {
if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) {
throw new Error("ENSDb does not contain an Indexing Status Snapshot");
}

const crossChainSnapshot = indexingMetadataContext.indexingStatus;
const projectedAt = getUnixTime(new Date());
const realtimeProjection = createRealtimeIndexingStatusProjection(
crossChainSnapshot,
Expand Down
Loading
Loading