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/flat-flowers-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": minor
---

Added `validateEnsIndexerPublicConfig` and `validateEnsIndexerVersionInfo` functions.
5 changes: 5 additions & 0 deletions .changeset/violet-tires-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensindexer": minor
---

Refactored HTTP handlers to rely solely on ENSDb Client for data.
35 changes: 19 additions & 16 deletions apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import config from "@/config";

import { getUnixTime } from "date-fns";
import { Hono } from "hono";

import {
buildCrossChainIndexingStatusSnapshotOmnichain,
createRealtimeIndexingStatusProjection,
IndexingStatusResponseCodes,
type IndexingStatusResponseError,
Expand All @@ -13,31 +10,37 @@ import {
serializeIndexingStatusResponse,
} from "@ensnode/ensnode-sdk";

import { buildENSIndexerPublicConfig } from "@/config/public";
import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton";
import { ensDbClient } from "@/lib/ensdb-client/singleton";

const app = new Hono();

// include ENSIndexer Public Config endpoint
app.get("/config", async (c) => {
// prepare the public config object, including dependency info
const publicConfig = await buildENSIndexerPublicConfig(config);
const publicConfig = await ensDbClient.getEnsIndexerPublicConfig();

// Invariant: the public config is guaranteed to be available in ENSDb after
// application startup.
if (typeof publicConfig === "undefined") {
throw new Error("Unreachable: ENSIndexer Public Config is not available in ENSDb");
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The "Unreachable" branch is reachable: startEnsDbWriterWorker() is kicked off without awaiting .run(), so /config can be called before the worker has upserted the public config to ENSDb. Instead of throwing (which becomes a 500), return a controlled 503/500 response indicating config isn’t ready yet, or await worker initialization before serving routes.

Suggested change
throw new Error("Unreachable: ENSIndexer Public Config is not available in ENSDb");
// During startup there is a window where the public config may not yet
// be available in ENSDb. In that case, return a controlled error rather
// than throwing and causing an unstructured 500 response.
return c.json(
{
error: "ENSIndexer Public Config is not yet available in ENSDb",
},
503,
);

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In practice, it won't be rechable. ENSIndexer might not need any HTTP API whatsoever.

}
Comment on lines +21 to +25
Copy link
Contributor

@coderabbitai coderabbitai bot Mar 4, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

/config can hit a real startup race; this guard is reachable.

getEnsIndexerPublicConfig() legitimately returns undefined before metadata is written. Treating it as unreachable will produce transient 500s right after startup.

✅ Suggested handling
   if (typeof publicConfig === "undefined") {
-    throw new Error("Unreachable: ENSIndexer Public Config is not available in ENSDb");
+    return c.json(
+      { message: "ENSIndexer Public Config is not available yet." },
+      503,
+    );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Invariant: the public config is guaranteed to be available in ENSDb after
// application startup.
if (typeof publicConfig === "undefined") {
throw new Error("Unreachable: ENSIndexer Public Config is not available in ENSDb");
}
// Invariant: the public config is guaranteed to be available in ENSDb after
// application startup.
if (typeof publicConfig === "undefined") {
return c.json(
{ message: "ENSIndexer Public Config is not available yet." },
503,
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts` around lines 21 - 25,
The current guard in the /config handler (where getEnsIndexerPublicConfig()
result is stored in publicConfig) treats undefined as unreachable and throws,
causing startup race 500s; change this to handle the legitimate undefined case
by returning a transient response (e.g., 503 Service Unavailable or an
empty/fallback config) instead of throwing. Locate the check around publicConfig
in ensnode-api.ts (the code that calls getEnsIndexerPublicConfig()), remove the
throw, and implement a graceful early return that sets appropriate HTTP status
and message or a safe default payload so callers don’t get a hard 500 during
initial metadata writes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In practice, it won't be rechable. ENSIndexer might not need any HTTP API whatsoever.

Copy link
Contributor

Choose a reason for hiding this comment

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

@tk-o, understood — the EnsDbWriterWorker populates the public config at startup (fail-fast), so by the time the HTTP layer accepts requests, the config is guaranteed to be present in ENSDb. My concern about the startup race was incorrect. The throw pattern is consistent with the existing design philosophy.

I'll record this for future reviews.


✏️ Learnings added
Learnt from: tk-o
Repo: namehash/ensnode PR: 1715
File: apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts:21-25
Timestamp: 2026-03-04T11:40:29.081Z
Learning: In `apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts`, the guard `if (typeof publicConfig === "undefined") { throw new Error("Unreachable: ...") }` in the `/config` handler is intentionally unreachable. The `EnsDbWriterWorker` populates the ENSIndexer public config into ENSDb at startup (fail-fast), so the HTTP layer is guaranteed to only serve requests after the config is available. The `throw` is a defensive invariant, not a real error path.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: namehash/ensnode PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-02T05:28:59.644Z
Learning: Applies to apps/ensindexer/**/*.ts : Use Ponder as the indexer framework for blockchain indexing

Learnt from: tk-o
Repo: namehash/ensnode PR: 1639
File: packages/ensnode-sdk/src/ensapi/api/indexing-status/zod-schemas.ts:21-76
Timestamp: 2026-02-16T17:53:46.139Z
Learning: In the ENSNode SDK (`packages/ensnode-sdk`), schema builder functions exported from `zod-schemas.ts` files (e.g., `makeEnsApiIndexingStatusResponseSchema`) are considered internal API, not public API. These can have breaking changes without requiring deprecated aliases, even when exported via the `internal` entry point.

Learnt from: tk-o
Repo: namehash/ensnode PR: 1705
File: apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts:25-36
Timestamp: 2026-03-02T20:10:05.060Z
Learning: Ensure the ENS label set validation (validateSupportedLabelSetAndVersion) is performed at startup by the ENSDb Writer Worker during application startup for ENSIndexer. If validation fails, the worker should crash the process (fail-fast), so that runtime /config endpoints do not need to raise or return error responses. This enforces configuration correctness at deploy/startup time rather than at runtime for the file apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts.

Learnt from: tk-o
Repo: namehash/ensnode PR: 1705
File: apps/ensapi/src/config/validations.ts:37-43
Timestamp: 2026-03-02T19:03:50.730Z
Learning: In `apps/ensapi/src/config/validations.ts`, the ENSRainbow version equality invariant (ensIndexerPublicConfig.versionInfo.ensRainbowPublicConfig.version === packageJson.version) is intentionally kept. Even though ensRainbowPublicConfig represents a connected ENSRainbow service, strict version parity with ENSApi is enforced as a deployment requirement.

Learnt from: notrab
Repo: namehash/ensnode PR: 1631
File: apps/ensapi/src/handlers/ensnode-api.ts:23-27
Timestamp: 2026-02-18T16:11:09.421Z
Learning: In the ensapi application, dynamic `import("@/config")` inside request handlers is an acceptable pattern because Node.js caches modules after the first import, making subsequent calls resolve from cache with negligible overhead (just promise resolution).

Learnt from: tk-o
Repo: namehash/ensnode PR: 1614
File: apps/ensindexer/src/lib/ponder-api-client.ts:7-7
Timestamp: 2026-02-18T15:26:09.067Z
Learning: In apps/ensindexer/src/lib/ponder-api-client.ts, the localPonderClientPromise is intentionally left as a permanently rejected promise when LocalPonderClient.init fails after retries. This is expected behavior because process.exitCode = 1 signals the process should terminate, and if it continues running, all subsequent calls should fail immediately with the cached rejection rather than retrying initialization.

Learnt from: tk-o
Repo: namehash/ensnode PR: 1615
File: packages/ensnode-sdk/src/api/indexing-status/deserialize.ts:38-40
Timestamp: 2026-02-07T12:22:32.900Z
Learning: In `packages/ensnode-sdk/src/api/indexing-status/deserialize.ts`, the pattern of using `z.preprocess()` with `buildUnvalidatedIndexingStatusResponse` (which returns `unknown`) is intentional. This enforces a "parse serialized → preprocess to unvalidated → validate final schema" flow, where `z.preprocess` is semantically correct because it runs before final validation. Using `.transform()` would be incorrect here as it runs after parsing and receives a typed input.

Learnt from: CR
Repo: namehash/ensnode PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-02T05:28:59.644Z
Learning: Applies to apps/ensapi/src/**/*.ts : Use the shared `errorResponse` helper from `apps/ensapi/src/lib/handlers/error-response.ts` for all error responses in ENSApi

Learnt from: tk-o
Repo: namehash/ensnode PR: 1615
File: packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.ts:314-322
Timestamp: 2026-02-07T11:54:52.607Z
Learning: In the ENSNode SDK, `ChainIndexingStatusSnapshot[]` parameters in functions like `getTimestampForLowestOmnichainStartBlock` are guaranteed not to be empty arrays by design.

Learnt from: tk-o
Repo: namehash/ensnode PR: 1617
File: packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts:9-12
Timestamp: 2026-02-09T10:19:29.575Z
Learning: In ensnode-sdk validation functions (e.g., `validateChainIndexingStatusSnapshot` in `packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts`), the pattern of using `ChainIndexingStatusSnapshot | unknown` (even though it collapses to `unknown` in TypeScript) is intentionally kept for semantic clarity and documentation purposes.

Learnt from: Goader
Repo: namehash/ensnode PR: 1663
File: packages/ens-referrals/src/v1/award-models/rev-share-limit/metrics.ts:74-96
Timestamp: 2026-02-24T15:53:06.633Z
Learning: In TypeScript code reviews, prefer placing invariants on type aliases only when the invariant is context-independent or reused across multiple fields. If an invariant depends on surrounding rules or object semantics (e.g., field-specific metrics), keep the invariant as a field JSDoc instead. This guideline applies to TS files broadly (e.g., the repo's v1/award-models and similar modules).


// respond with the serialized public config object
return c.json(serializeENSIndexerPublicConfig(publicConfig));
});

app.get("/indexing-status", async (c) => {
// get system timestamp for the current request
const snapshotTime = getUnixTime(new Date());

try {
const omnichainSnapshot = await indexingStatusBuilder.getOmnichainIndexingStatusSnapshot();
const crossChainSnapshot = await ensDbClient.getIndexingStatusSnapshot();

const crossChainSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain(
omnichainSnapshot,
snapshotTime,
);
// 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") {
throw new Error("ENSDb does not contain an Indexing Status Snapshot");
}

const projectedAt = getUnixTime(new Date());
const realtimeProjection = createRealtimeIndexingStatusProjection(
Expand All @@ -53,7 +56,7 @@ app.get("/indexing-status", async (c) => {
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
console.error(`Omnichain snapshot is currently not available: ${errorMessage}`);
console.error(`Indexing Status Snapshot is currently not available: ${errorMessage}`);

return c.json(
serializeIndexingStatusResponse({
Expand Down
34 changes: 0 additions & 34 deletions apps/ensindexer/src/config/public.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { vi } from "vitest";

import {
type CrossChainIndexingStatusSnapshot,
CrossChainIndexingStrategyIds,
type EnsIndexerPublicConfig,
OmnichainIndexingStatusIds,
type OmnichainIndexingStatusSnapshot,
} from "@ensnode/ensnode-sdk";

import type { EnsDbClient } from "@/lib/ensdb-client/ensdb-client";
import * as ensDbClientMock from "@/lib/ensdb-client/ensdb-client.mock";
import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder";
import type { PublicConfigBuilder } from "@/lib/public-config-builder";

// Helper to create mock objects with consistent typing
export function createMockEnsDbClient(
overrides: Partial<ReturnType<typeof baseEnsDbClient>> = {},
): EnsDbClient {
return {
...baseEnsDbClient(),
...overrides,
} as unknown as EnsDbClient;
}

export function baseEnsDbClient() {
return {
getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined),
upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined),
upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined),
upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined),
};
}

export function createMockPublicConfigBuilder(
resolvedConfig: EnsIndexerPublicConfig = ensDbClientMock.publicConfig,
): PublicConfigBuilder {
return {
getPublicConfig: vi.fn().mockResolvedValue(resolvedConfig),
} as unknown as PublicConfigBuilder;
}

export function createMockIndexingStatusBuilder(
resolvedSnapshot: OmnichainIndexingStatusSnapshot = createMockOmnichainSnapshot(),
): IndexingStatusBuilder {
return {
getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(resolvedSnapshot),
} as unknown as IndexingStatusBuilder;
}

export function createMockOmnichainSnapshot(
overrides: Partial<OmnichainIndexingStatusSnapshot> = {},
): OmnichainIndexingStatusSnapshot {
return {
omnichainStatus: OmnichainIndexingStatusIds.Following,
omnichainIndexingCursor: 100,
chains: new Map(),
...overrides,
};
}

export function createMockCrossChainSnapshot(
overrides: Partial<CrossChainIndexingStatusSnapshot> = {},
): CrossChainIndexingStatusSnapshot {
return {
strategy: CrossChainIndexingStrategyIds.Omnichain,
slowestChainIndexingCursor: 100,
snapshotTime: 200,
omnichainSnapshot: createMockOmnichainSnapshot(),
...overrides,
};
}
Loading