From 4b125d0dba1fca1759ad60758f3c5aa0eaab93b5 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 16:58:40 +0200 Subject: [PATCH 01/33] Introduce `IndexingMetadataContext` data model to ENSNode SDK --- packages/ensnode-sdk/src/ensnode/index.ts | 1 + .../deserialize/indexing-metadata-context.ts | 74 +++++++++++++++++++ .../ensnode-sdk/src/ensnode/metadata/index.ts | 4 + .../metadata/indexing-metadata-context.ts | 72 ++++++++++++++++++ .../serialize/indexing-metadata-context.ts | 60 +++++++++++++++ .../validate/indexing-metadata-context.ts | 23 ++++++ .../zod-schemas/indexing-metadata-context.ts | 58 +++++++++++++++ 7 files changed, 292 insertions(+) create mode 100644 packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts create mode 100644 packages/ensnode-sdk/src/ensnode/metadata/index.ts create mode 100644 packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts create mode 100644 packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts create mode 100644 packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts create mode 100644 packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts diff --git a/packages/ensnode-sdk/src/ensnode/index.ts b/packages/ensnode-sdk/src/ensnode/index.ts index 227ed19cd..ece02b5ce 100644 --- a/packages/ensnode-sdk/src/ensnode/index.ts +++ b/packages/ensnode-sdk/src/ensnode/index.ts @@ -5,3 +5,4 @@ export { } from "./client"; export * from "./client-error"; export * from "./deployments"; +export * from "./metadata"; diff --git a/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts new file mode 100644 index 000000000..74c3aa463 --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts @@ -0,0 +1,74 @@ +import { prettifyError } from "zod/v4"; + +import { buildUnvalidatedCrossChainIndexingStatusSnapshot } from "../../../indexing-status"; +import type { Unvalidated } from "../../../shared/types"; +import { buildUnvalidatedEnsNodeStackInfo } from "../../../stack-info"; +import { + type IndexingMetadataContext, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, +} from "../indexing-metadata-context"; +import type { + SerializedIndexingMetadataContext, + SerializedIndexingMetadataContextInitialized, +} from "../serialize/indexing-metadata-context"; +import { + makeIndexingMetadataContextSchema, + makeSerializedIndexingMetadataContextSchema, +} from "../zod-schemas/indexing-metadata-context"; + +/** + * Builds an unvalidated {@link IndexingMetadataContextInitialized} object. + */ +function buildUnvalidatedIndexingMetadataContextInitializedSchema( + serializedIndexingMetadataContext: SerializedIndexingMetadataContextInitialized, +): Unvalidated { + return { + statusCode: serializedIndexingMetadataContext.statusCode, + indexingStatus: buildUnvalidatedCrossChainIndexingStatusSnapshot( + serializedIndexingMetadataContext.indexingStatus, + ), + stackInfo: buildUnvalidatedEnsNodeStackInfo(serializedIndexingMetadataContext.stackInfo), + }; +} + +/** + * Builds an unvalidated {@link IndexingMetadataContext} object to be + * validated with {@link makeIndexingMetadataContextSchema}. + * + * @param serializedIndexingMetadataContext - The serialized indexing metadata context to build from. + * @return An unvalidated {@link IndexingMetadataContextInitialized} object. + */ +function buildUnvalidatedIndexingMetadataContextSchema( + serializedIndexingMetadataContext: SerializedIndexingMetadataContext, +): Unvalidated { + switch (serializedIndexingMetadataContext.statusCode) { + case IndexingMetadataContextStatusCodes.Uninitialized: + return serializedIndexingMetadataContext; + + case IndexingMetadataContextStatusCodes.Initialized: + return buildUnvalidatedIndexingMetadataContextInitializedSchema( + serializedIndexingMetadataContext, + ); + } +} + +/** + * Deserialize a serialized {@link IndexingMetadataContext} object. + */ +export function deserializeIndexingMetadataContext( + serializedIndexingMetadataContext: Unvalidated, + valueLabel?: string, +): IndexingMetadataContext { + const label = valueLabel ?? "IndexingMetadataContext"; + + const parsed = makeSerializedIndexingMetadataContextSchema(label) + .transform(buildUnvalidatedIndexingMetadataContextSchema) + .pipe(makeIndexingMetadataContextSchema(label)) + .safeParse(serializedIndexingMetadataContext); + + if (parsed.error) { + throw new Error(`Cannot validate IndexingMetadataContext:\n${prettifyError(parsed.error)}\n`); + } + return parsed.data; +} diff --git a/packages/ensnode-sdk/src/ensnode/metadata/index.ts b/packages/ensnode-sdk/src/ensnode/metadata/index.ts new file mode 100644 index 000000000..483ae202d --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/index.ts @@ -0,0 +1,4 @@ +export * from "./deserialize/indexing-metadata-context"; +export * from "./indexing-metadata-context"; +export * from "./serialize/indexing-metadata-context"; +export * from "./validate/indexing-metadata-context"; diff --git a/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts new file mode 100644 index 000000000..ee2482362 --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts @@ -0,0 +1,72 @@ +import type { CrossChainIndexingStatusSnapshot } from "../../indexing-status"; +import type { EnsNodeStackInfo } from "../../stack-info"; +import { validateIndexingMetadataContextInitialized } from "./validate/indexing-metadata-context"; + +/** + * A status code for an indexing metadata context + */ +export const IndexingMetadataContextStatusCodes = { + /** + * Represents that the no indexing metadata context has been initialized + * for the ENSIndexer Schema Name in the ENSNode Metadata table in ENSDb. + */ + Uninitialized: "Uninitialized", + + /** + * Represents that the indexing metadata context has been initialized + * for the ENSIndexer Schema Name in the ENSNode Metadata table in ENSDb. + */ + Initialized: "Initialized", +} as const; + +/** + * The derived string union of possible {@link IndexingMetadataContextStatusCodes}. + */ +export type IndexingMetadataContextStatusCode = + (typeof IndexingMetadataContextStatusCodes)[keyof typeof IndexingMetadataContextStatusCodes]; + +export interface IndexingMetadataContextUninitialized { + statusCode: typeof IndexingMetadataContextStatusCodes.Uninitialized; +} + +export interface IndexingMetadataContextInitialized { + statusCode: typeof IndexingMetadataContextStatusCodes.Initialized; + indexingStatus: CrossChainIndexingStatusSnapshot; + stackInfo: EnsNodeStackInfo; +} + +/** + * Indexing Metadata Context + * + * Use the {@link IndexingMetadataContext.statusCode} field to determine + * the specific type interpretation at runtime. + */ +export type IndexingMetadataContext = + | IndexingMetadataContextUninitialized + | IndexingMetadataContextInitialized; + +/** + * Build an {@link IndexingMetadataContextUninitialized} object. + */ +export function buildIndexingMetadataContextUninitialized(): IndexingMetadataContextUninitialized { + return { + statusCode: IndexingMetadataContextStatusCodes.Uninitialized, + }; +} + +/** + * Build an {@link IndexingMetadataContextInitialized} object. + * + * @throws Error if the provided parameters do not satisfy the validation + * criteria for an {@link IndexingMetadataContextInitialized} object. + */ +export function buildIndexingMetadataContextInitialized( + indexingStatus: CrossChainIndexingStatusSnapshot, + stackInfo: EnsNodeStackInfo, +): IndexingMetadataContextInitialized { + return validateIndexingMetadataContextInitialized({ + statusCode: IndexingMetadataContextStatusCodes.Initialized, + indexingStatus, + stackInfo, + }); +} diff --git a/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts new file mode 100644 index 000000000..85a44b9dd --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts @@ -0,0 +1,60 @@ +import { + type SerializedCrossChainIndexingStatusSnapshot, + serializeCrossChainIndexingStatusSnapshot, +} from "../../../indexing-status/serialize/cross-chain-indexing-status-snapshot"; +import { + type SerializedEnsNodeStackInfo, + serializeEnsNodeStackInfo, +} from "../../../stack-info/serialize/ensnode-stack-info"; +import { + type IndexingMetadataContext, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + type IndexingMetadataContextUninitialized, +} from "../indexing-metadata-context"; + +/** + * Serialized representation of an {@link IndexingMetadataContextUninitialized}. + */ +export type SerializedIndexingMetadataContextUninitialized = IndexingMetadataContextUninitialized; + +/** + * Serialized representation of an {@link IndexingMetadataContextInitialized}. + */ +export interface SerializedIndexingMetadataContextInitialized + extends Omit { + indexingStatus: SerializedCrossChainIndexingStatusSnapshot; + stackInfo: SerializedEnsNodeStackInfo; +} + +/** + * Serialized representation of an {@link IndexingMetadataContext}. + * + * Use the {@link SerializedIndexingMetadataContext.statusCode} field to + * determine the specific type interpretation at runtime. + */ +export type SerializedIndexingMetadataContext = + | SerializedIndexingMetadataContextUninitialized + | SerializedIndexingMetadataContextInitialized; + +export function serializeIndexingMetadataContextInitialized( + context: IndexingMetadataContextInitialized, +): SerializedIndexingMetadataContextInitialized { + const { statusCode, indexingStatus, stackInfo } = context; + return { + statusCode, + indexingStatus: serializeCrossChainIndexingStatusSnapshot(indexingStatus), + stackInfo: serializeEnsNodeStackInfo(stackInfo), + }; +} + +export function serializeIndexingMetadataContext( + context: IndexingMetadataContext, +): SerializedIndexingMetadataContext { + switch (context.statusCode) { + case IndexingMetadataContextStatusCodes.Uninitialized: + return context; + case IndexingMetadataContextStatusCodes.Initialized: + return serializeIndexingMetadataContextInitialized(context); + } +} diff --git a/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts new file mode 100644 index 000000000..cf56bc126 --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts @@ -0,0 +1,23 @@ +import { prettifyError } from "zod/v4"; + +import type { Unvalidated } from "../../../shared/types"; +import type { IndexingMetadataContextInitialized } from "../indexing-metadata-context"; +import { makeIndexingMetadataContextInitializedSchema } from "../zod-schemas/indexing-metadata-context"; + +/** + * Validate a maybe {@link IndexingMetadataContextInitialized} object. + */ +export function validateIndexingMetadataContextInitialized( + maybeIndexingMetadataContext: Unvalidated, +): IndexingMetadataContextInitialized { + const result = makeIndexingMetadataContextInitializedSchema().safeParse( + maybeIndexingMetadataContext, + ); + + if (result.error) { + throw new Error( + `Cannot validate IndexingMetadataContextInitialized:\n${prettifyError(result.error)}\n`, + ); + } + return result.data; +} diff --git a/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts new file mode 100644 index 000000000..6405ca52c --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts @@ -0,0 +1,58 @@ +import { z } from "zod/v4"; + +import { + makeCrossChainIndexingStatusSnapshotSchema, + makeSerializedCrossChainIndexingStatusSnapshotSchema, +} from "../../../indexing-status/zod-schema/cross-chain-indexing-status-snapshot"; +import { + makeEnsNodeStackInfoSchema, + makeSerializedEnsNodeStackInfoSchema, +} from "../../../stack-info/zod-schemas/ensnode-stack-info"; +import { IndexingMetadataContextStatusCodes } from "../indexing-metadata-context"; + +const makeSerializedIndexingMetadataContextUninitializedSchema = (_valueLabel?: string) => { + return z.object({ + statusCode: z.literal(IndexingMetadataContextStatusCodes.Uninitialized), + }); +}; + +export const makeSerializedIndexingMetadataContextInitializedSchema = (valueLabel?: string) => { + const label = valueLabel ?? "SerializedIndexingMetadataContextInitialized"; + + return z.object({ + statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized), + indexingStatus: makeSerializedCrossChainIndexingStatusSnapshotSchema(`${label}.indexingStatus`), + stackInfo: makeSerializedEnsNodeStackInfoSchema(`${label}.stackInfo`), + }); +}; + +export const makeSerializedIndexingMetadataContextSchema = (valueLabel?: string) => { + const label = valueLabel ?? "SerializedIndexingMetadataContext"; + + return z.discriminatedUnion("statusCode", [ + makeSerializedIndexingMetadataContextUninitializedSchema(label), + makeSerializedIndexingMetadataContextInitializedSchema(label), + ]); +}; + +const makeIndexingMetadataContextUninitializedSchema = + makeSerializedIndexingMetadataContextUninitializedSchema; + +export const makeIndexingMetadataContextInitializedSchema = (valueLabel?: string) => { + const label = valueLabel ?? "IndexingMetadataContextInitialized"; + + return z.object({ + statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized), + indexingStatus: makeCrossChainIndexingStatusSnapshotSchema(`${label}.indexingStatus`), + stackInfo: makeEnsNodeStackInfoSchema(`${label}.stackInfo`), + }); +}; + +export const makeIndexingMetadataContextSchema = (valueLabel?: string) => { + const label = valueLabel ?? "IndexingMetadataContext"; + + return z.discriminatedUnion("statusCode", [ + makeIndexingMetadataContextUninitializedSchema(label), + makeIndexingMetadataContextInitializedSchema(label), + ]); +}; From dcad533e54895a91dc2e068841d2ce3f2a3206dd Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 20:07:33 +0200 Subject: [PATCH 02/33] Create `init*` functions for running event handlers preconditions --- .../init-indexing-onchain-events.ts | 29 ++++++ .../indexing-engines/init-indexing-setup.ts | 43 +++++++++ .../src/lib/indexing-engines/ponder.ts | 92 +++++++------------ 3 files changed, 104 insertions(+), 60 deletions(-) create mode 100644 apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts create mode 100644 apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts new file mode 100644 index 000000000..2d4181ac8 --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -0,0 +1,29 @@ +import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; + +/** + * Prepare for executing the "onchain" event handlers. + * + * During Ponder startup, the "onchain" event handlers are executed + * after all "setup" event handlers have completed. + * + * This function is useful to make sure any long-running preconditions for + * onchain event handlers are met, for example, waiting for + * the ENSRainbow instance to be ready before processing any onchain events + * that require data from ENSRainbow. + * + * @example A single blocking precondition + * ```ts + * await waitForEnsRainbowToBeReady(); + * ``` + * + * @example Multiple blocking preconditions + * ```ts + * await Promise.all([ + * waitForEnsRainbowToBeReady(), + * waitForAnotherPrecondition(), + * ]); + * ``` + */ +export async function initIndexingOnchainEvents(): Promise { + await waitForEnsRainbowToBeReady(); +} diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts new file mode 100644 index 000000000..2eea3675a --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts @@ -0,0 +1,43 @@ +/** + * This module defines the initialization logic for the setup handlers of + * the Ponder indexing engine executed in an ENSIndexer instance. + * + * Setup handlers are executed by Ponder once per ENSIndexer instance lifetime, + * at the start of the omnichain indexing process. + * + * ENSIndexer startup sequence executed by Ponder: + * 1. Connect to the database and initialize required database objects. + * 2. Start the omnichain indexing process. + * 3. Check whether Ponder Checkpoints are already initialized. + * 4. If not: + * a) Execute setup handlers. + * b) Initialize Ponder Checkpoints. + * 5. a) Make Ponder HTTP API usable. + * 5. b) Start executing "onchain" event handlers. + * + * Step 4 is skipped on ENSIndexer instance restart if Ponder Checkpoints were + * already initialized in a previous run. + */ + +import { logger } from "@/lib/logger"; + +/** + * Initialize indexing setup + * + * Runs once per ENSIndexer instance lifetime to initialize indexing setup. + * + * Since multiple ENSIndexer instances may run concurrently against the same + * ENSDb instance, this function MUST BE idempotent and race-condition-safe. + * + * Completion of this function unblocks the following sequence of events + * during ENSIndexer startup: + * 1. "setup" event handlers execute + * 2. Ponder Checkpoints initialize + * 3. IndexingStatusBuilder can build OmnichainIndexingStatusSnapshot + * via LocalPonderClient (which queries the Ponder HTTP API) + * + * @throws Error if any precondition is not satisfied. + */ +export async function initIndexingSetup(): Promise { + // Execute any necessary preconditions for the indexing setup here. +} diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 84996fdc6..7d1b0d14c 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -15,7 +15,8 @@ import { ponder, } from "ponder:registry"; -import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; +import { initIndexingOnchainEvents } from "./init-indexing-onchain-events"; +import { initIndexingSetup } from "./init-indexing-setup"; /** * Context passed to event handlers registered with @@ -113,7 +114,7 @@ const EventTypeIds = { * * Driven by an onchain event emitted by an indexed contract. */ - Onchain: "Onchain", + OnchainEvent: "OnchainEvent", } as const; /** @@ -125,59 +126,13 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { if (eventName.endsWith(":setup")) { return EventTypeIds.Setup; } else { - return EventTypeIds.Onchain; + return EventTypeIds.OnchainEvent; } } -/** - * Prepare for executing the "setup" event handlers. - * - * During Ponder startup, the "setup" event handlers are executed: - * - After Ponder completed database migrations for ENSIndexer Schema in ENSDb. - * - Before Ponder starts processing any onchain events for indexed chains. - * - * This function is useful to make sure ENSDb is ready for writes, for example, - * by ensuring all required Postgres extensions are installed, etc. - */ -async function initializeIndexingSetup(): Promise { - /** - * Setup event handlers should not have any *long-running* preconditions. This is because - * Ponder populates the indexing metrics for all indexed chains only after all setup handlers have run. - * ENSIndexer relies on these indexing metrics being immediately available on startup to build and - * store the current Indexing Status in ENSDb. - */ -} - -/** - * Prepare for executing the "onchain" event handlers. - * - * During Ponder startup, the "onchain" event handlers are executed - * after all "setup" event handlers have completed. - * - * This function is useful to make sure any long-running preconditions for - * onchain event handlers are met, for example, waiting for - * the ENSRainbow instance to be ready before processing any onchain events - * that require data from ENSRainbow. - * - * @example A single blocking precondition - * ```ts - * await waitForEnsRainbowToBeReady(); - * ``` - * - * @example Multiple blocking preconditions - * ```ts - * await Promise.all([ - * waitForEnsRainbowToBeReady(), - * waitForAnotherPrecondition(), - * ]); - * ``` - */ -async function initializeIndexingActivation(): Promise { - await waitForEnsRainbowToBeReady(); -} - +let eventHandlerPreconditionsFullyExecuted = false; let indexingSetupPromise: Promise | null = null; -let indexingActivationPromise: Promise | null = null; +let indexingOnchainEventsPromise: Promise | null = null; /** * Execute any necessary preconditions before running an event handler @@ -192,25 +147,42 @@ let indexingActivationPromise: Promise | null = null; * "onchain" event. */ async function eventHandlerPreconditions(eventType: EventTypeId): Promise { + if (eventHandlerPreconditionsFullyExecuted) { + // Preconditions have already been fully executed, so we can skip executing them again. + // We can also reset the promises for indexing setup and onchain events to free up memory, + // since they will never be used again after the preconditions have been fully executed. + indexingSetupPromise = null; + indexingOnchainEventsPromise = null; + return; + } + switch (eventType) { case EventTypeIds.Setup: { if (indexingSetupPromise === null) { - // Initialize the indexing setup just once. - indexingSetupPromise = initializeIndexingSetup(); + // Init the indexing setup just once. There will be multiple + // setup events executed during Ponder startup, but they will + // run sequentially, so we can just check if we have already + // initialized the indexing setup or not. + indexingSetupPromise = initIndexingSetup(); } return await indexingSetupPromise; } - case EventTypeIds.Onchain: { - if (indexingActivationPromise === null) { - // Initialize the indexing activation just once in order to - // optimize the "hot path" of indexing onchain events, since these are - // much more frequent than setup events. - indexingActivationPromise = initializeIndexingActivation(); + case EventTypeIds.OnchainEvent: { + if (indexingOnchainEventsPromise === null) { + // Init the indexing of "onchain" events just once in order to + // optimize the indexing "hot path", since these events are much + // more frequent than setup events. + indexingOnchainEventsPromise = initIndexingOnchainEvents().then(() => { + // Mark the preconditions as fully executed after the first time we execute + // the preconditions for onchain events, since that's the "hot path" and we want to + // minimize the overhead of this function in the long run. + eventHandlerPreconditionsFullyExecuted = true; + }); } - return await indexingActivationPromise; + return await indexingOnchainEventsPromise; } } } From a5a8bd6b4a51304b3e99b8cc11b536b0b388901f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 20:08:21 +0200 Subject: [PATCH 03/33] Execute `migrateEnsNodeSchema` from inside of `initIndexingSetup` function --- apps/ensindexer/ponder/src/api/index.ts | 13 ---------- .../indexing-engines/init-indexing-setup.ts | 12 +++++++++- .../src/lib/indexing-engines/ponder.test.ts | 24 +++++++++++++++++++ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/ensindexer/ponder/src/api/index.ts b/apps/ensindexer/ponder/src/api/index.ts index c00d16188..600aa41bc 100644 --- a/apps/ensindexer/ponder/src/api/index.ts +++ b/apps/ensindexer/ponder/src/api/index.ts @@ -5,24 +5,11 @@ import { cors } from "hono/cors"; import type { ErrorResponse } from "@ensnode/ensnode-sdk"; -import { migrateEnsNodeSchema } from "@/lib/ensdb/migrate-ensnode-schema"; import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; import { logger } from "@/lib/logger"; import ensNodeApi from "./handlers/ensnode-api"; -// Before starting the ENSDb Writer Worker, we need to ensure that -// the ENSNode Schema in ENSDb is up to date by running any pending migrations. -await migrateEnsNodeSchema().catch((error) => { - logger.error({ - msg: "Failed to initialize ENSNode metadata", - error, - module: "ponder-api", - }); - process.exitCode = 1; - throw error; -}); - // The entry point for the ENSDb Writer Worker. startEnsDbWriterWorker(); diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts index 2eea3675a..7793a4358 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts @@ -39,5 +39,15 @@ import { logger } from "@/lib/logger"; * @throws Error if any precondition is not satisfied. */ export async function initIndexingSetup(): Promise { - // Execute any necessary preconditions for the indexing setup here. + const { migrateEnsNodeSchema } = await import("@/lib/ensdb/migrate-ensnode-schema"); + // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. + await migrateEnsNodeSchema().catch((error) => { + logger.error({ + msg: "Failed to initialize ENSNode metadata", + error, + module: "ponder-api", + }); + process.exitCode = 1; + throw error; + }); } diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 28d13674f..5c01961cf 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -7,6 +7,25 @@ const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() })); const mockWaitForEnsRainbow = vi.hoisted(() => vi.fn()); +const mockMigrateEnsNodeSchema = vi.hoisted(() => vi.fn()); + +// Set up PONDER_COMMON global before any imports that depend on it +vi.hoisted(() => { + (globalThis as any).PONDER_COMMON = { + options: { + command: "start", + port: 42069, + }, + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; +}); + vi.mock("ponder:registry", () => ({ ponder: { on: (...args: unknown[]) => mockPonderOn(...args), @@ -21,10 +40,15 @@ vi.mock("@/lib/ensrainbow/singleton", () => ({ waitForEnsRainbowToBeReady: mockWaitForEnsRainbow, })); +vi.mock("@/lib/ensdb/migrate-ensnode-schema", () => ({ + migrateEnsNodeSchema: mockMigrateEnsNodeSchema, +})); + describe("addOnchainEventListener", () => { beforeEach(async () => { vi.clearAllMocks(); mockWaitForEnsRainbow.mockResolvedValue(undefined); + mockMigrateEnsNodeSchema.mockResolvedValue(undefined); // Reset module state to test idempotent behavior correctly vi.resetModules(); }); From c57c2ac4c7e84e6d24323420d8355808b037a807 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 20:53:22 +0200 Subject: [PATCH 04/33] Use `EnsIndexerStackInfo` for `stackInfo` field in `IndexingMetadataContext` data model --- .../deserialize/indexing-metadata-context.ts | 4 ++-- .../metadata/indexing-metadata-context.ts | 6 +++--- .../serialize/indexing-metadata-context.ts | 16 +++++++++++----- .../zod-schemas/indexing-metadata-context.ts | 10 +++++----- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts index 74c3aa463..6c4864bc9 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts @@ -2,7 +2,7 @@ import { prettifyError } from "zod/v4"; import { buildUnvalidatedCrossChainIndexingStatusSnapshot } from "../../../indexing-status"; import type { Unvalidated } from "../../../shared/types"; -import { buildUnvalidatedEnsNodeStackInfo } from "../../../stack-info"; +import { buildUnvalidatedEnsIndexerStackInfo } from "../../../stack-info"; import { type IndexingMetadataContext, type IndexingMetadataContextInitialized, @@ -28,7 +28,7 @@ function buildUnvalidatedIndexingMetadataContextInitializedSchema( indexingStatus: buildUnvalidatedCrossChainIndexingStatusSnapshot( serializedIndexingMetadataContext.indexingStatus, ), - stackInfo: buildUnvalidatedEnsNodeStackInfo(serializedIndexingMetadataContext.stackInfo), + stackInfo: buildUnvalidatedEnsIndexerStackInfo(serializedIndexingMetadataContext.stackInfo), }; } diff --git a/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts index ee2482362..0b3efd697 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts @@ -1,5 +1,5 @@ import type { CrossChainIndexingStatusSnapshot } from "../../indexing-status"; -import type { EnsNodeStackInfo } from "../../stack-info"; +import type { EnsIndexerStackInfo } from "../../stack-info"; import { validateIndexingMetadataContextInitialized } from "./validate/indexing-metadata-context"; /** @@ -32,7 +32,7 @@ export interface IndexingMetadataContextUninitialized { export interface IndexingMetadataContextInitialized { statusCode: typeof IndexingMetadataContextStatusCodes.Initialized; indexingStatus: CrossChainIndexingStatusSnapshot; - stackInfo: EnsNodeStackInfo; + stackInfo: EnsIndexerStackInfo; } /** @@ -62,7 +62,7 @@ export function buildIndexingMetadataContextUninitialized(): IndexingMetadataCon */ export function buildIndexingMetadataContextInitialized( indexingStatus: CrossChainIndexingStatusSnapshot, - stackInfo: EnsNodeStackInfo, + stackInfo: EnsIndexerStackInfo, ): IndexingMetadataContextInitialized { return validateIndexingMetadataContextInitialized({ statusCode: IndexingMetadataContextStatusCodes.Initialized, diff --git a/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts index 85a44b9dd..cde334e7a 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/serialize/indexing-metadata-context.ts @@ -3,9 +3,9 @@ import { serializeCrossChainIndexingStatusSnapshot, } from "../../../indexing-status/serialize/cross-chain-indexing-status-snapshot"; import { - type SerializedEnsNodeStackInfo, - serializeEnsNodeStackInfo, -} from "../../../stack-info/serialize/ensnode-stack-info"; + type SerializedEnsIndexerStackInfo, + serializeEnsIndexerStackInfo, +} from "../../../stack-info/serialize/ensindexer-stack-info"; import { type IndexingMetadataContext, type IndexingMetadataContextInitialized, @@ -24,7 +24,7 @@ export type SerializedIndexingMetadataContextUninitialized = IndexingMetadataCon export interface SerializedIndexingMetadataContextInitialized extends Omit { indexingStatus: SerializedCrossChainIndexingStatusSnapshot; - stackInfo: SerializedEnsNodeStackInfo; + stackInfo: SerializedEnsIndexerStackInfo; } /** @@ -44,10 +44,16 @@ export function serializeIndexingMetadataContextInitialized( return { statusCode, indexingStatus: serializeCrossChainIndexingStatusSnapshot(indexingStatus), - stackInfo: serializeEnsNodeStackInfo(stackInfo), + stackInfo: serializeEnsIndexerStackInfo(stackInfo), }; } +export function serializeIndexingMetadataContext( + context: IndexingMetadataContextUninitialized, +): SerializedIndexingMetadataContextUninitialized; +export function serializeIndexingMetadataContext( + context: IndexingMetadataContextInitialized, +): SerializedIndexingMetadataContextInitialized; export function serializeIndexingMetadataContext( context: IndexingMetadataContext, ): SerializedIndexingMetadataContext { diff --git a/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts index 6405ca52c..25bead23b 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts @@ -5,9 +5,9 @@ import { makeSerializedCrossChainIndexingStatusSnapshotSchema, } from "../../../indexing-status/zod-schema/cross-chain-indexing-status-snapshot"; import { - makeEnsNodeStackInfoSchema, - makeSerializedEnsNodeStackInfoSchema, -} from "../../../stack-info/zod-schemas/ensnode-stack-info"; + makeEnsIndexerStackInfoSchema, + makeSerializedEnsIndexerStackInfoSchema, +} from "../../../stack-info/zod-schemas/ensindexer-stack-info"; import { IndexingMetadataContextStatusCodes } from "../indexing-metadata-context"; const makeSerializedIndexingMetadataContextUninitializedSchema = (_valueLabel?: string) => { @@ -22,7 +22,7 @@ export const makeSerializedIndexingMetadataContextInitializedSchema = (valueLabe return z.object({ statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized), indexingStatus: makeSerializedCrossChainIndexingStatusSnapshotSchema(`${label}.indexingStatus`), - stackInfo: makeSerializedEnsNodeStackInfoSchema(`${label}.stackInfo`), + stackInfo: makeSerializedEnsIndexerStackInfoSchema(`${label}.stackInfo`), }); }; @@ -44,7 +44,7 @@ export const makeIndexingMetadataContextInitializedSchema = (valueLabel?: string return z.object({ statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized), indexingStatus: makeCrossChainIndexingStatusSnapshotSchema(`${label}.indexingStatus`), - stackInfo: makeEnsNodeStackInfoSchema(`${label}.stackInfo`), + stackInfo: makeEnsIndexerStackInfoSchema(`${label}.stackInfo`), }); }; From a28b0de7a455be8dcaa43631a6ea8a1ed43ddbe9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 20:54:15 +0200 Subject: [PATCH 05/33] Add `getIndexingMetadataContext` method to `EnsDbReader` class --- packages/ensdb-sdk/src/client/ensdb-reader.ts | 21 +++++++++++++++++++ .../ensdb-sdk/src/client/ensnode-metadata.ts | 10 ++++++++- .../src/client/serialize/ensnode-metadata.ts | 9 +++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.ts b/packages/ensdb-sdk/src/client/ensdb-reader.ts index 49d61d3ee..db4d5ace6 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.ts @@ -1,12 +1,15 @@ import { and, eq } from "drizzle-orm/sql"; import { + buildIndexingMetadataContextUninitialized, type CrossChainIndexingStatusSnapshot, deserializeCrossChainIndexingStatusSnapshot, deserializeEnsIndexerPublicConfig, + deserializeIndexingMetadataContext, type EnsDbPublicConfig, type EnsDbVersionInfo, type EnsIndexerPublicConfig, + type IndexingMetadataContext, } from "@ensnode/ensnode-sdk"; import { @@ -23,6 +26,7 @@ import type { SerializedEnsNodeMetadataEnsDbVersion, SerializedEnsNodeMetadataEnsIndexerIndexingStatus, SerializedEnsNodeMetadataEnsIndexerPublicConfig, + SerializedEnsNodeMetadataIndexingMetadataContext, } from "./serialize/ensnode-metadata"; /** @@ -189,6 +193,23 @@ export class EnsDbReader< return deserializeCrossChainIndexingStatusSnapshot(record); } + /** + * Get Indexing Metadata Context + * + * @returns the initialized record, or a default uninitialized one if no record exists in ENSDb. + */ + async getIndexingMetadataContext(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.IndexingMetadataContext, + }); + + if (!record) { + return buildIndexingMetadataContextUninitialized(); + } + + return deserializeIndexingMetadataContext(record); + } + /** * Get ENSNode Metadata record * diff --git a/packages/ensdb-sdk/src/client/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/ensnode-metadata.ts index bdb35c406..0713108f2 100644 --- a/packages/ensdb-sdk/src/client/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/ensnode-metadata.ts @@ -1,6 +1,7 @@ import type { CrossChainIndexingStatusSnapshot, EnsIndexerPublicConfig, + IndexingMetadataContextInitialized, } from "@ensnode/ensnode-sdk"; /** @@ -10,6 +11,7 @@ export const EnsNodeMetadataKeys = { EnsDbVersion: "ensdb_version", EnsIndexerPublicConfig: "ensindexer_public_config", EnsIndexerIndexingStatus: "ensindexer_indexing_status", + IndexingMetadataContext: "indexing_metadata_context", } as const; export type EnsNodeMetadataKey = (typeof EnsNodeMetadataKeys)[keyof typeof EnsNodeMetadataKeys]; @@ -29,6 +31,11 @@ export interface EnsNodeMetadataEnsIndexerIndexingStatus { value: CrossChainIndexingStatusSnapshot; } +export interface EnsNodeMetadataIndexingMetadataContext { + key: typeof EnsNodeMetadataKeys.IndexingMetadataContext; + value: IndexingMetadataContextInitialized; +} + /** * ENSNode Metadata * @@ -37,4 +44,5 @@ export interface EnsNodeMetadataEnsIndexerIndexingStatus { export type EnsNodeMetadata = | EnsNodeMetadataEnsDbVersion | EnsNodeMetadataEnsIndexerPublicConfig - | EnsNodeMetadataEnsIndexerIndexingStatus; + | EnsNodeMetadataEnsIndexerIndexingStatus + | EnsNodeMetadataIndexingMetadataContext; diff --git a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts index cae7fcdd3..8347f16eb 100644 --- a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts @@ -1,6 +1,7 @@ import type { SerializedCrossChainIndexingStatusSnapshot, SerializedEnsIndexerPublicConfig, + SerializedIndexingMetadataContextInitialized, } from "@ensnode/ensnode-sdk"; import type { @@ -32,10 +33,16 @@ export interface SerializedEnsNodeMetadataEnsIndexerIndexingStatus { value: SerializedCrossChainIndexingStatusSnapshot; } +export interface SerializedEnsNodeMetadataIndexingMetadataContext { + key: typeof EnsNodeMetadataKeys.IndexingMetadataContext; + value: SerializedIndexingMetadataContextInitialized; +} + /** * Serialized representation of {@link EnsNodeMetadata} */ export type SerializedEnsNodeMetadata = | SerializedEnsNodeMetadataEnsDbVersion | SerializedEnsNodeMetadataEnsIndexerPublicConfig - | SerializedEnsNodeMetadataEnsIndexerIndexingStatus; + | SerializedEnsNodeMetadataEnsIndexerIndexingStatus + | SerializedEnsNodeMetadataIndexingMetadataContext; From e91bd13af00dac2a613140fa9d33b4152f853f73 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 20:54:32 +0200 Subject: [PATCH 06/33] Add `upsertIndexingMetadataContext` method to `EnsDbWriter` class --- packages/ensdb-sdk/src/client/ensdb-writer.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.ts b/packages/ensdb-sdk/src/client/ensdb-writer.ts index 2ebbd9e08..c0c89d050 100644 --- a/packages/ensdb-sdk/src/client/ensdb-writer.ts +++ b/packages/ensdb-sdk/src/client/ensdb-writer.ts @@ -3,8 +3,10 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import { type CrossChainIndexingStatusSnapshot, type EnsIndexerPublicConfig, + type IndexingMetadataContextInitialized, serializeCrossChainIndexingStatusSnapshot, serializeEnsIndexerPublicConfig, + serializeIndexingMetadataContext, } from "@ensnode/ensnode-sdk"; import { EnsDbReader } from "./ensdb-reader"; @@ -73,6 +75,20 @@ export class EnsDbWriter extends EnsDbReader { }); } + /** + * Upsert Indexing Metadata Context Initialized + * + * @throws when upsert operation failed. + */ + async upsertIndexingMetadataContext( + indexingMetadataContext: IndexingMetadataContextInitialized, + ): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.IndexingMetadataContext, + value: serializeIndexingMetadataContext(indexingMetadataContext), + }); + } + /** * Upsert ENSNode metadata * From 8892dd50ea97057a2e5af501891666f015e0c642 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 20:55:50 +0200 Subject: [PATCH 07/33] Update `EnsDbWriterWorker` class to serve a new limited role It will just have a single recurring task to keep the stored `IndexingMetadataContext` up to date --- apps/ensindexer/ponder/src/api/index.ts | 4 - .../ensdb-writer-worker.ts | 174 ++---------------- .../init-indexing-onchain-events.ts | 69 ++++++- 3 files changed, 86 insertions(+), 161 deletions(-) diff --git a/apps/ensindexer/ponder/src/api/index.ts b/apps/ensindexer/ponder/src/api/index.ts index 600aa41bc..a714ce9e2 100644 --- a/apps/ensindexer/ponder/src/api/index.ts +++ b/apps/ensindexer/ponder/src/api/index.ts @@ -5,14 +5,10 @@ import { cors } from "hono/cors"; import type { ErrorResponse } from "@ensnode/ensnode-sdk"; -import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; import { logger } from "@/lib/logger"; import ensNodeApi from "./handlers/ensnode-api"; -// The entry point for the ENSDb Writer Worker. -startEnsDbWriterWorker(); - const app = new Hono(); // set the X-ENSIndexer-Version header to the current version diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 38aa6d12d..8416b6c50 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -5,8 +5,10 @@ import pRetry from "p-retry"; import type { EnsDbWriter } from "@ensnode/ensdb-sdk"; import { buildCrossChainIndexingStatusSnapshotOmnichain, + buildIndexingMetadataContextInitialized, type CrossChainIndexingStatusSnapshot, type EnsIndexerPublicConfig, + IndexingMetadataContextStatusCodes, OmnichainIndexingStatusIds, type OmnichainIndexingStatusSnapshot, validateEnsIndexerPublicConfigCompatibility, @@ -97,32 +99,9 @@ export class EnsDbWriterWorker { throw new Error("EnsDbWriterWorker is already running"); } - // Fetch data required for task 1 and task 2. - const inMemoryConfig = await this.getValidatedEnsIndexerPublicConfig(); - - // Task 1: upsert ENSDb version into ENSDb. - logger.debug({ msg: "Upserting ENSDb version", module: "EnsDbWriterWorker" }); - await this.ensDbClient.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb); - logger.info({ - msg: "Upserted ENSDb version", - ensDbVersion: inMemoryConfig.versionInfo.ensDb, - module: "EnsDbWriterWorker", - }); - - // Task 2: upsert of EnsIndexerPublicConfig into ENSDb. - logger.debug({ - msg: "Upserting ENSIndexer public config", - module: "EnsDbWriterWorker", - }); - await this.ensDbClient.upsertEnsIndexerPublicConfig(inMemoryConfig); - logger.info({ - msg: "Upserted ENSIndexer public config", - module: "EnsDbWriterWorker", - }); - - // Task 3: recurring upsert of Indexing Status Snapshot into ENSDb. + // Task 1: recurring upsert of Indexing Metadata Context into ENSDb. this.indexingStatusInterval = setInterval( - () => this.upsertIndexingStatusSnapshot(), + () => this.upsertIndexingMetadataContext(), secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL), ); } @@ -146,131 +125,36 @@ export class EnsDbWriterWorker { } } - /** - * Get validated ENSIndexer Public Config object for the ENSDb Writer Worker. - * - * The function retrieves the ENSIndexer Public Config object from both: - * - stored config in ENSDb, if available, and - * - in-memory config from ENSIndexer Client. - * - * If a stored config exists **and** the local Ponder app is **not** in dev - * mode, the in-memory config is validated for compatibility against the - * stored one. Validation is skipped if the local Ponder app is in dev mode, - * allowing to override the stored config in ENSDb with the current in-memory - * config, without having to keep them compatible. - * - * @returns The in-memory config when validation passes or no stored config - * exists. - * @throws Error if either fetch fails, or if the in-memory config is - * incompatible with the stored config. - */ - private async getValidatedEnsIndexerPublicConfig(): Promise { - /** - * Fetch the in-memory config with retries, to handle potential transient errors - * in the ENSIndexer Public Config Builder (e.g. due to network issues). - * If the fetch fails after the defined number of retries, the error - * will be thrown and the worker will not start, as the ENSIndexer Public Config - * is a critical dependency for the worker's tasks. - */ - const configFetchRetries = 3; - - logger.debug({ - msg: "Fetching ENSIndexer public config", - retries: configFetchRetries, - module: "EnsDbWriterWorker", - }); - - const inMemoryConfigPromise = pRetry(() => this.publicConfigBuilder.getPublicConfig(), { - retries: configFetchRetries, - onFailedAttempt: ({ attemptNumber, retriesLeft }) => { - logger.warn({ - msg: "Config fetch attempt failed", - attempt: attemptNumber, - retriesLeft, - module: "EnsDbWriterWorker", - }); - }, - }); - - let storedConfig: EnsIndexerPublicConfig | undefined; - let inMemoryConfig: EnsIndexerPublicConfig; - - try { - [storedConfig, inMemoryConfig] = await Promise.all([ - this.ensDbClient.getEnsIndexerPublicConfig(), - inMemoryConfigPromise, - ]); - logger.info({ - msg: "Fetched ENSIndexer public config", - module: "EnsDbWriterWorker", - config: inMemoryConfig, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - logger.error({ - msg: "Failed to fetch ENSIndexer public config", - error, - module: "EnsDbWriterWorker", - }); - - // Throw the error to terminate the ENSIndexer process due to failed fetch of critical dependency - throw new Error(errorMessage, { - cause: error, - }); - } - - // Validate in-memory config object compatibility with the stored one, - // if the stored one is available. - // The validation is skipped if the local Ponder app is running in dev mode. - // This is to improve the development experience during ENSIndexer - // development, by allowing to override the stored config in ENSDb with - // the current in-memory config, without having to keep them compatible. - if (storedConfig && !this.localPonderClient.isInDevMode) { - try { - validateEnsIndexerPublicConfigCompatibility(storedConfig, inMemoryConfig); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - logger.error({ - msg: "In-memory config incompatible with stored config", - error, - module: "EnsDbWriterWorker", - }); - - // Throw the error to terminate the ENSIndexer process due to - // found config incompatibility - throw new Error(errorMessage, { - cause: error, - }); - } - } - - return inMemoryConfig; - } - /** * Upsert the current Indexing Status Snapshot into ENSDb. * * This method is called by the scheduler at regular intervals. * Errors are logged but not thrown, to keep the worker running. */ - private async upsertIndexingStatusSnapshot(): Promise { + private async upsertIndexingMetadataContext(): Promise { try { // get system timestamp for the current iteration const snapshotTime = getUnixTime(new Date()); + const indexingMetadataContext = await this.ensDbClient.getIndexingMetadataContext(); - const omnichainSnapshot = await this.getValidatedIndexingStatusSnapshot(); + if (indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized) { + throw new Error( + `Cannot upsert Indexing Status Snapshot into ENSDb because Indexing Metadata Context should be be initialized first`, + ); + } + + const omnichainSnapshot = + await this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); - const crossChainSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain( - omnichainSnapshot, - snapshotTime, + const updatedIndexingMetadataContext = buildIndexingMetadataContextInitialized( + buildCrossChainIndexingStatusSnapshotOmnichain(omnichainSnapshot, snapshotTime), + indexingMetadataContext.stackInfo, ); - await this.ensDbClient.upsertIndexingStatusSnapshot(crossChainSnapshot); + await this.ensDbClient.upsertIndexingMetadataContext(updatedIndexingMetadataContext); } catch (error) { logger.error({ - msg: "Failed to upsert indexing status snapshot", + msg: "Failed to upsert indexing metadata context", error, module: "EnsDbWriterWorker", }); @@ -278,24 +162,4 @@ export class EnsDbWriterWorker { // should not cause the ENSDb Writer Worker to stop functioning. } } - - /** - * Get validated Omnichain Indexing Status Snapshot - * - * @returns Validated Omnichain Indexing Status Snapshot. - * @throws Error if the Omnichain Indexing Status is not in expected status yet. - */ - private async getValidatedIndexingStatusSnapshot(): Promise { - const omnichainSnapshot = await this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); - - // It only makes sense to write Indexing Status Snapshots into ENSDb once - // the indexing process has started, as before that there is no meaningful - // status to record. - // Invariant: the Omnichain Status must indicate that indexing has started already. - if (omnichainSnapshot.omnichainStatus === OmnichainIndexingStatusIds.Unstarted) { - throw new Error("Omnichain Status must not be 'Unstarted'."); - } - - return omnichainSnapshot; - } } diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts index 2d4181ac8..426106dbe 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -1,4 +1,18 @@ -import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; +import { getUnixTime } from "date-fns/fp/getUnixTime"; + +import { + buildCrossChainIndexingStatusSnapshotOmnichain, + buildEnsIndexerStackInfo, + buildIndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, +} from "@ensnode/ensnode-sdk"; + +import { ensDbClient } from "@/lib/ensdb/singleton"; +import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; +import { ensRainbowClient, waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; +import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; /** * Prepare for executing the "onchain" event handlers. @@ -25,5 +39,56 @@ import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; * ``` */ export async function initIndexingOnchainEvents(): Promise { - await waitForEnsRainbowToBeReady(); + try { + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); + console.log("Indexing Metadata Context:", indexingMetadataContext); + const indexingStatus = await indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); + const ensIndexerPublicConfig = await publicConfigBuilder.getPublicConfig(); + const ensDbPublicConfig = await ensDbClient.buildEnsDbPublicConfig(); + + if (indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized) { + // Invariant: indexing status must be "unstarted" + if (indexingStatus.omnichainStatus !== OmnichainIndexingStatusIds.Unstarted) { + throw new Error( + `Invariant violation: expected omnichain indexing status to be "unstarted" when initializing indexing of onchain events for the first time, but got "${indexingStatus.omnichainStatus}" instead`, + ); + } + } else { + // if (ensIndexerPublicConfig.ensIndexerBuildId !== indexingMetadataContext.stackInfo.ensIndexer.ensIndexerBuildId) { + // TODO: store the `ensIndexerPublicConfig` object in ENSDb so `indexingMetadataContext.stackInfo.ensIndexer` is updated + // } + } + + await waitForEnsRainbowToBeReady(); + + const ensRainbowPublicConfig = await ensRainbowClient.config(); + const now = getUnixTime(new Date()); + const updatedIndexingMetadataContext = buildIndexingMetadataContextInitialized( + buildCrossChainIndexingStatusSnapshotOmnichain(indexingStatus, now), + buildEnsIndexerStackInfo(ensDbPublicConfig, ensIndexerPublicConfig, ensRainbowPublicConfig), + ); + + // TODO: check ENSRainbow compatibility + if ( + ensRainbowPublicConfig.serverLabelSet.labelSetId < + ensIndexerPublicConfig.clientLabelSet.labelSetId + ) { + throw new Error( + `ENSRainbow instance is not compatible with the current ENSIndexer instance: ENSRainbow serverLabelSetId (${ensRainbowPublicConfig.serverLabelSet.labelSetId}) is less than ENSIndexer clientLabelSetId (${ensIndexerPublicConfig.clientLabelSet.labelSetId})`, + ); + } + + await ensDbClient.upsertIndexingMetadataContext(updatedIndexingMetadataContext); + + // TODO: start Indexing Status Sync worker + // It will be responsible for keeping the indexing status stored within Indexing Metadata Context record in ENSDb up to date + // await indexingStatusSyncWorker.start(); + startEnsDbWriterWorker(); + } catch (error) { + // If any error happens during the execution of the preconditions for onchain events, + // we want to log the error and exit the process with a non-zero exit code, + // since this is a critical failure that prevents the ENSIndexer instance from functioning properly. + console.error("Failed to execute preconditions for onchain events:", error); + process.exit(1); + } } From ee1190e7186fed09cb18eeec7603a7255d29a123 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 20:56:46 +0200 Subject: [PATCH 08/33] Fix ponder build issue This one was complaining about `ponder:api` imports being made from outside of `apps/ensindexer/ponder/src/api` dir. The dynamic imports solve that problem. --- apps/ensindexer/src/lib/indexing-engines/ponder.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 7d1b0d14c..82c62656a 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -15,9 +15,6 @@ import { ponder, } from "ponder:registry"; -import { initIndexingOnchainEvents } from "./init-indexing-onchain-events"; -import { initIndexingSetup } from "./init-indexing-setup"; - /** * Context passed to event handlers registered with * {@link addOnchainEventListener}. @@ -159,6 +156,7 @@ async function eventHandlerPreconditions(eventType: EventTypeId): Promise switch (eventType) { case EventTypeIds.Setup: { if (indexingSetupPromise === null) { + const { initIndexingSetup } = await import("./init-indexing-setup"); // Init the indexing setup just once. There will be multiple // setup events executed during Ponder startup, but they will // run sequentially, so we can just check if we have already @@ -171,6 +169,7 @@ async function eventHandlerPreconditions(eventType: EventTypeId): Promise case EventTypeIds.OnchainEvent: { if (indexingOnchainEventsPromise === null) { + const { initIndexingOnchainEvents } = await import("./init-indexing-onchain-events"); // Init the indexing of "onchain" events just once in order to // optimize the indexing "hot path", since these events are much // more frequent than setup events. From 4954e21dc255436f65d512d2c3d452f4a2128c1f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 21:02:16 +0200 Subject: [PATCH 09/33] Fix typos --- .../src/ensnode/metadata/indexing-metadata-context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts index 0b3efd697..845313b22 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts @@ -10,13 +10,13 @@ export const IndexingMetadataContextStatusCodes = { * Represents that the no indexing metadata context has been initialized * for the ENSIndexer Schema Name in the ENSNode Metadata table in ENSDb. */ - Uninitialized: "Uninitialized", + Uninitialized: "uninitialized", /** * Represents that the indexing metadata context has been initialized * for the ENSIndexer Schema Name in the ENSNode Metadata table in ENSDb. */ - Initialized: "Initialized", + Initialized: "initialized", } as const; /** From 2f8532e563e52992aae5cd24b01e9a51f8bed48a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 25 Apr 2026 08:21:16 +0200 Subject: [PATCH 10/33] Implement full tasks sequence for `initIndexingOnchainEvents` --- .../ensdb-writer-worker.ts | 36 +---- .../src/lib/ensdb-writer-worker/singleton.ts | 9 +- .../src/lib/ensrainbow/singleton.ts | 72 +++++++++- .../init-indexing-onchain-events.ts | 129 +++++++++++++----- .../indexing-engines/init-indexing-setup.ts | 19 ++- .../src/lib/indexing-engines/ponder.ts | 7 +- 6 files changed, 187 insertions(+), 85 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 8416b6c50..c1703726c 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -1,6 +1,5 @@ import { getUnixTime, secondsToMilliseconds } from "date-fns"; import type { Duration } from "enssdk"; -import pRetry from "p-retry"; import type { EnsDbWriter } from "@ensnode/ensdb-sdk"; import { @@ -8,16 +7,12 @@ import { buildIndexingMetadataContextInitialized, type CrossChainIndexingStatusSnapshot, type EnsIndexerPublicConfig, + type IndexingMetadataContext, IndexingMetadataContextStatusCodes, - OmnichainIndexingStatusIds, - type OmnichainIndexingStatusSnapshot, - validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; -import type { LocalPonderClient } from "@ensnode/ponder-sdk"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; import { logger } from "@/lib/logger"; -import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; /** * Interval in seconds between two consecutive attempts to upsert @@ -28,10 +23,8 @@ const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 1; /** * ENSDb Writer Worker * - * A worker responsible for writing ENSIndexer-related metadata into ENSDb, including: - * - ENSDb version - * - ENSIndexer Public Config - * - ENSIndexer Indexing Status Snapshots + * A worker responsible for writing the current {@link CrossChainIndexingStatusSnapshot} into + * the {@link IndexingMetadataContext} record in ENSDb. */ export class EnsDbWriterWorker { /** @@ -49,34 +42,13 @@ export class EnsDbWriterWorker { */ private indexingStatusBuilder: IndexingStatusBuilder; - /** - * ENSIndexer Public Config Builder instance used by the worker to read ENSIndexer Public Config. - */ - private publicConfigBuilder: PublicConfigBuilder; - - /** - * Local Ponder Client instance - * - * Used to get local Ponder app command. - */ - private localPonderClient: LocalPonderClient; - /** * @param ensDbClient ENSDb Writer instance used by the worker to interact with ENSDb. - * @param publicConfigBuilder ENSIndexer Public Config Builder instance used by the worker to read ENSIndexer Public Config. * @param indexingStatusBuilder Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. - * @param localPonderClient Local Ponder Client instance, used to get local Ponder app command. */ - constructor( - ensDbClient: EnsDbWriter, - publicConfigBuilder: PublicConfigBuilder, - indexingStatusBuilder: IndexingStatusBuilder, - localPonderClient: LocalPonderClient, - ) { + constructor(ensDbClient: EnsDbWriter, indexingStatusBuilder: IndexingStatusBuilder) { this.ensDbClient = ensDbClient; - this.publicConfigBuilder = publicConfigBuilder; this.indexingStatusBuilder = indexingStatusBuilder; - this.localPonderClient = localPonderClient; } /** diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index 22fd6a5e9..7375b5fdc 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,8 +1,6 @@ import { ensDbClient } from "@/lib/ensdb/singleton"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; -import { localPonderClient } from "@/lib/local-ponder-client"; import { logger } from "@/lib/logger"; -import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import { EnsDbWriterWorker } from "./ensdb-writer-worker"; @@ -22,12 +20,7 @@ export function startEnsDbWriterWorker() { throw new Error("EnsDbWriterWorker has already been initialized"); } - ensDbWriterWorker = new EnsDbWriterWorker( - ensDbClient, - publicConfigBuilder, - indexingStatusBuilder, - localPonderClient, - ); + ensDbWriterWorker = new EnsDbWriterWorker(ensDbClient, indexingStatusBuilder); ensDbWriterWorker .run() diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 331d62e6a..5c4ba97d4 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -24,6 +24,69 @@ export const ensRainbowClient = new EnsRainbowApiClient({ clientLabelSet, }); +/** + * Cached promise for waiting for ENSRainbow to be healthy. + * + * This ensures that multiple concurrent calls to + * {@link waitForEnsRainbowToBeHealthy} will share the same underlying promise + * in order to use the same retry sequence. + */ +let waitForEnsRainbowToBeHealthyPromise: Promise | undefined; + +/** + * Wait for ENSRainbow to be healthy + * + * Blocks execution until the ENSRainbow instance is healthy. That is, + * the ENSRainbow instance is responsive and able to serve basic requests successfully. + * + * We need to wait for ENSRainbow to be healthy before attempting to fetch + * the {@link EnsRainbowPublicConfig} from ENSRainbow. + * + * @throws When ENSRainbow fails to become healthy after all configured retry attempts. + * This error will trigger termination of the ENSIndexer process. + */ +export function waitForEnsRainbowToBeHealthy(): Promise { + if (waitForEnsRainbowToBeHealthyPromise) { + return waitForEnsRainbowToBeHealthyPromise; + } + + logger.info({ + msg: `Waiting for ENSRainbow instance to be healthy`, + ensRainbowInstance: ensRainbowUrl.href, + }); + + waitForEnsRainbowToBeHealthyPromise = pRetry(async () => ensRainbowClient.health(), { + retries: 3, + onFailedAttempt: ({ attemptNumber, retriesLeft }) => { + logger.warn({ + msg: `ENSRainbow health check failed`, + attempt: attemptNumber, + retriesLeft, + ensRainbowInstance: ensRainbowUrl.href, + advice: `This might be a transient issue after ENSNode deployment. If this persists, it might indicate an issue with the ENSRainbow instance or connectivity to it.`, + }); + }, + }) + .then(() => { + logger.info({ + msg: `ENSRainbow instance is healthy`, + ensRainbowInstance: ensRainbowUrl.href, + }); + }) + .catch((error) => { + logger.error({ + msg: `ENSRainbow health check failed after multiple attempts`, + error, + ensRainbowInstance: ensRainbowUrl.href, + }); + + // Throw the error to terminate the ENSIndexer process due to the failed health check of a critical dependency + throw error; + }); + + return waitForEnsRainbowToBeHealthyPromise; +} + /** * Cached promise for waiting for ENSRainbow to be ready. * @@ -60,12 +123,11 @@ export function waitForEnsRainbowToBeReady(): Promise { retries: 60, // This allows for a total of over 1 hour of retries with 1 minute between attempts. minTimeout: secondsToMilliseconds(60), maxTimeout: secondsToMilliseconds(60), - onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { + onFailedAttempt: ({ attemptNumber, retriesLeft }) => { logger.warn({ msg: `ENSRainbow health check failed`, attempt: attemptNumber, retriesLeft, - error: retriesLeft === 0 ? error : undefined, ensRainbowInstance: ensRainbowUrl.href, advice: `This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, }); @@ -78,8 +140,6 @@ export function waitForEnsRainbowToBeReady(): Promise { }); }) .catch((error) => { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - logger.error({ msg: `ENSRainbow health check failed after multiple attempts`, error, @@ -87,9 +147,7 @@ export function waitForEnsRainbowToBeReady(): Promise { }); // Throw the error to terminate the ENSIndexer process due to the failed health check of a critical dependency - throw new Error(errorMessage, { - cause: error instanceof Error ? error : undefined, - }); + throw error; }); return waitForEnsRainbowToBeReadyPromise; diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts index 426106dbe..1a09bae16 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -1,4 +1,4 @@ -import { getUnixTime } from "date-fns/fp/getUnixTime"; +import { getUnixTime } from "date-fns"; import { buildCrossChainIndexingStatusSnapshotOmnichain, @@ -6,12 +6,18 @@ import { buildIndexingMetadataContextInitialized, IndexingMetadataContextStatusCodes, OmnichainIndexingStatusIds, + validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; import { ensDbClient } from "@/lib/ensdb/singleton"; import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; -import { ensRainbowClient, waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; +import { + ensRainbowClient, + waitForEnsRainbowToBeHealthy, + waitForEnsRainbowToBeReady, +} from "@/lib/ensrainbow/singleton"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { logger } from "@/lib/logger"; import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; /** @@ -39,56 +45,117 @@ import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; * ``` */ export async function initIndexingOnchainEvents(): Promise { + // Before calling `ensRainbowClient.config()`, we want to make sure that + // the ENSRainbow instance is healthy and ready to serve requests. + // This is a quick check, as we expect the ENSRainbow instance to be healthy + // by the time ENSIndexer instance executes `initIndexingOnchainEvents`. + await waitForEnsRainbowToBeHealthy(); + try { - const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); - console.log("Indexing Metadata Context:", indexingMetadataContext); - const indexingStatus = await indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); - const ensIndexerPublicConfig = await publicConfigBuilder.getPublicConfig(); - const ensDbPublicConfig = await ensDbClient.buildEnsDbPublicConfig(); + const [ + inMemoryIndexingStatusSnapshot, + inMemoryEnsDbPublicConfig, + inMemoryEnsIndexerPublicConfig, + inMemoryEnsRainbowPublicConfig, + storedIndexingMetadataContext, + ] = await Promise.all([ + indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(), + ensDbClient.buildEnsDbPublicConfig(), + publicConfigBuilder.getPublicConfig(), + ensRainbowClient.config(), + ensDbClient.getIndexingMetadataContext(), + ]); + + if ( + storedIndexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized + ) { + logger.info({ + msg: `Indexing Metadata Context is "uninitialized"`, + }); - if (indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized) { - // Invariant: indexing status must be "unstarted" - if (indexingStatus.omnichainStatus !== OmnichainIndexingStatusIds.Unstarted) { + // Invariant: indexing status must be "unstarted" when the indexing metadata context is uninitialized, + // since we haven't started processing any onchain events yet + if (inMemoryIndexingStatusSnapshot.omnichainStatus !== OmnichainIndexingStatusIds.Unstarted) { throw new Error( - `Invariant violation: expected omnichain indexing status to be "unstarted" when initializing indexing of onchain events for the first time, but got "${indexingStatus.omnichainStatus}" instead`, + `Omnichain indexing status must be "unstarted" for "uninitialized" Indexing Metadata Context. Provided omnichain indexing status "${inMemoryIndexingStatusSnapshot.omnichainStatus}".`, ); } } else { - // if (ensIndexerPublicConfig.ensIndexerBuildId !== indexingMetadataContext.stackInfo.ensIndexer.ensIndexerBuildId) { - // TODO: store the `ensIndexerPublicConfig` object in ENSDb so `indexingMetadataContext.stackInfo.ensIndexer` is updated + logger.info({ + msg: `Indexing Metadata Context is "initialized"`, + }); + logger.debug({ + msg: `Indexing Metadata Context`, + indexingStatus: storedIndexingMetadataContext.indexingStatus, + stackInfo: storedIndexingMetadataContext.stackInfo, + }); + // if (ensIndexerPublicConfig.ensIndexerBuildId !== storedIndexingMetadataContext.stackInfo.ensIndexer.ensIndexerBuildId) { + // TODO: store the `ensIndexerPublicConfig` object in ENSDb so `storedIndexingMetadataContext.stackInfo.ensIndexer` is updated // } + const { ensIndexer: storedEnsIndexerPublicConfig } = storedIndexingMetadataContext.stackInfo; + validateEnsIndexerPublicConfigCompatibility( + inMemoryEnsIndexerPublicConfig, + storedEnsIndexerPublicConfig, + ); } - await waitForEnsRainbowToBeReady(); - - const ensRainbowPublicConfig = await ensRainbowClient.config(); + // Build the {@link CrossChainIndexingStatusSnapshot} with the current snapshot time. + // This is important to make sure the `snapshotTime` is always up to date in + // the indexing status snapshot stored in ENSDb. const now = getUnixTime(new Date()); - const updatedIndexingMetadataContext = buildIndexingMetadataContextInitialized( - buildCrossChainIndexingStatusSnapshotOmnichain(indexingStatus, now), - buildEnsIndexerStackInfo(ensDbPublicConfig, ensIndexerPublicConfig, ensRainbowPublicConfig), + const crossChainIndexingStatusSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain( + inMemoryIndexingStatusSnapshot, + now, ); - // TODO: check ENSRainbow compatibility - if ( - ensRainbowPublicConfig.serverLabelSet.labelSetId < - ensIndexerPublicConfig.clientLabelSet.labelSetId - ) { - throw new Error( - `ENSRainbow instance is not compatible with the current ENSIndexer instance: ENSRainbow serverLabelSetId (${ensRainbowPublicConfig.serverLabelSet.labelSetId}) is less than ENSIndexer clientLabelSetId (${ensIndexerPublicConfig.clientLabelSet.labelSetId})`, - ); - } + // Build EnsIndexerStackInfo based on the current state of in-memory public + // config objects. It's unlikely, but possible, that after the ENSIndexer + // instance restarts, some values in the public config objects have changed + // compared to the previous instance before the restart. For example, + // if the ENSIndexer instance is redeployed with a new version of the code that has different default values for some config parameters, or if there are changes in the environment variables used to build the public config objects. + const updatedStackInfo = buildEnsIndexerStackInfo( + inMemoryEnsDbPublicConfig, + inMemoryEnsIndexerPublicConfig, + inMemoryEnsRainbowPublicConfig, + ); + + const updatedIndexingMetadataContext = buildIndexingMetadataContextInitialized( + crossChainIndexingStatusSnapshot, + updatedStackInfo, + ); + logger.info({ + msg: `Upserting Indexing Metadata Context Initialized`, + }); + logger.debug({ + msg: `Indexing Metadata Context`, + indexingStatus: updatedIndexingMetadataContext.indexingStatus, + stackInfo: updatedIndexingMetadataContext.stackInfo, + }); await ensDbClient.upsertIndexingMetadataContext(updatedIndexingMetadataContext); + logger.info({ + msg: `Successfully upserted Indexing Metadata Context Initialized`, + }); + + // Before starting to process onchain events, we want to make sure that + // ENSRainbow is ready and ready to serve the "heal" requests. + await waitForEnsRainbowToBeReady(); // TODO: start Indexing Status Sync worker // It will be responsible for keeping the indexing status stored within Indexing Metadata Context record in ENSDb up to date // await indexingStatusSyncWorker.start(); startEnsDbWriterWorker(); } catch (error) { - // If any error happens during the execution of the preconditions for onchain events, + // If any error happens during the initialization of indexing of onchain events, // we want to log the error and exit the process with a non-zero exit code, // since this is a critical failure that prevents the ENSIndexer instance from functioning properly. - console.error("Failed to execute preconditions for onchain events:", error); - process.exit(1); + logger.error({ + msg: "Failed to initialize the onchain events indexing", + module: "init-indexing-onchain-events", + error, + }); + + process.exitCode = 1; + throw error; } } diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts index 7793a4358..bac6cbc04 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts @@ -39,15 +39,22 @@ import { logger } from "@/lib/logger"; * @throws Error if any precondition is not satisfied. */ export async function initIndexingSetup(): Promise { - const { migrateEnsNodeSchema } = await import("@/lib/ensdb/migrate-ensnode-schema"); - // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. - await migrateEnsNodeSchema().catch((error) => { + try { + // TODO: wait for ENSDb instance to be healthy + const { migrateEnsNodeSchema } = await import("@/lib/ensdb/migrate-ensnode-schema"); + // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. + await migrateEnsNodeSchema(); + } catch (error) { + // If any error happens during the initialization of indexing of onchain events, + // we want to log the error and exit the process with a non-zero exit code, + // since this is a critical failure that prevents the ENSIndexer instance from functioning properly. logger.error({ - msg: "Failed to initialize ENSNode metadata", + msg: "Failed to initialize the indexing setup", + module: "init-indexing-setup", error, - module: "ponder-api", }); + process.exitCode = 1; throw error; - }); + } } diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 82c62656a..30e2b9d4b 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -15,6 +15,8 @@ import { ponder, } from "ponder:registry"; +import { initIndexingSetup } from "@/lib/indexing-engines/init-indexing-setup"; + /** * Context passed to event handlers registered with * {@link addOnchainEventListener}. @@ -156,7 +158,6 @@ async function eventHandlerPreconditions(eventType: EventTypeId): Promise switch (eventType) { case EventTypeIds.Setup: { if (indexingSetupPromise === null) { - const { initIndexingSetup } = await import("./init-indexing-setup"); // Init the indexing setup just once. There will be multiple // setup events executed during Ponder startup, but they will // run sequentially, so we can just check if we have already @@ -169,6 +170,10 @@ async function eventHandlerPreconditions(eventType: EventTypeId): Promise case EventTypeIds.OnchainEvent: { if (indexingOnchainEventsPromise === null) { + // We need to work around the Ponder limitation for importing modules, + // since Ponder would not allow us to use static imports for modules + // that internally rely on `ponder:api`. Using dynamic imports solves + // this issue. const { initIndexingOnchainEvents } = await import("./init-indexing-onchain-events"); // Init the indexing of "onchain" events just once in order to // optimize the indexing "hot path", since these events are much From 361e99dfb107576af9d4cc77c0a71a1de49bb07d Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 25 Apr 2026 16:32:09 +0200 Subject: [PATCH 11/33] Merge `initIndexingSetup` function into `initIndexingOnchainEvents` --- .../init-indexing-onchain-events.ts | 33 ++++++++++ .../indexing-engines/init-indexing-setup.ts | 60 ------------------- .../src/lib/indexing-engines/ponder.ts | 43 +++++++------ 3 files changed, 53 insertions(+), 83 deletions(-) delete mode 100644 apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts index 1a09bae16..e98d54a74 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -1,3 +1,28 @@ +/** + * This module defines the initialization logic for the onchain event handlers of + * the Ponder indexing engine executed in an ENSIndexer instance. + * + * Onchain event handlers are executed by Ponder once per ENSIndexer instance lifetime, + * at the start of the omnichain indexing process. + * + * ENSIndexer startup sequence executed by Ponder: + * 1. Connect to the database and initialize required database objects. + * 2. Start the omnichain indexing process. + * 3. Check whether Ponder Checkpoints are already initialized. + * 4. If not: + * a) Execute setup handlers, if any were registered. + * b) Initialize Ponder Checkpoints. + * 5. a) Make Ponder HTTP API usable. + * 5. b) Start executing "onchain" event handlers. + * + * Step 4 is skipped on ENSIndexer instance restart if Ponder Checkpoints were + * already initialized in a previous run. Also, step 4 a) is skipped if + * no setup handlers were registered. Therefore, we don't implement any init + * logic for setup handlers. Instead, to guarantee that any necessary initialization logic + * is executed each time the ENSIndexer instance starts, we implement the init indexing onchain events logic + * in this module, which is executed in step 5 b) and is guaranteed to be executed on every ENSIndexer instance startup, + * regardless of the state of Ponder Checkpoints or whether any setup handlers were registered. + */ import { getUnixTime } from "date-fns"; import { @@ -9,6 +34,7 @@ import { validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; +import { migrateEnsNodeSchema } from "@/lib/ensdb/migrate-ensnode-schema"; import { ensDbClient } from "@/lib/ensdb/singleton"; import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; import { @@ -43,8 +69,15 @@ import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; * waitForAnotherPrecondition(), * ]); * ``` + * + * Goals of this function: + * 1. Make ENSDb instance "ready" for ENSDb clients to use. */ export async function initIndexingOnchainEvents(): Promise { + // TODO: wait for ENSDb instance to be healthy + // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. + await migrateEnsNodeSchema(); + // Before calling `ensRainbowClient.config()`, we want to make sure that // the ENSRainbow instance is healthy and ready to serve requests. // This is a quick check, as we expect the ENSRainbow instance to be healthy diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts deleted file mode 100644 index bac6cbc04..000000000 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-setup.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * This module defines the initialization logic for the setup handlers of - * the Ponder indexing engine executed in an ENSIndexer instance. - * - * Setup handlers are executed by Ponder once per ENSIndexer instance lifetime, - * at the start of the omnichain indexing process. - * - * ENSIndexer startup sequence executed by Ponder: - * 1. Connect to the database and initialize required database objects. - * 2. Start the omnichain indexing process. - * 3. Check whether Ponder Checkpoints are already initialized. - * 4. If not: - * a) Execute setup handlers. - * b) Initialize Ponder Checkpoints. - * 5. a) Make Ponder HTTP API usable. - * 5. b) Start executing "onchain" event handlers. - * - * Step 4 is skipped on ENSIndexer instance restart if Ponder Checkpoints were - * already initialized in a previous run. - */ - -import { logger } from "@/lib/logger"; - -/** - * Initialize indexing setup - * - * Runs once per ENSIndexer instance lifetime to initialize indexing setup. - * - * Since multiple ENSIndexer instances may run concurrently against the same - * ENSDb instance, this function MUST BE idempotent and race-condition-safe. - * - * Completion of this function unblocks the following sequence of events - * during ENSIndexer startup: - * 1. "setup" event handlers execute - * 2. Ponder Checkpoints initialize - * 3. IndexingStatusBuilder can build OmnichainIndexingStatusSnapshot - * via LocalPonderClient (which queries the Ponder HTTP API) - * - * @throws Error if any precondition is not satisfied. - */ -export async function initIndexingSetup(): Promise { - try { - // TODO: wait for ENSDb instance to be healthy - const { migrateEnsNodeSchema } = await import("@/lib/ensdb/migrate-ensnode-schema"); - // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. - await migrateEnsNodeSchema(); - } catch (error) { - // If any error happens during the initialization of indexing of onchain events, - // we want to log the error and exit the process with a non-zero exit code, - // since this is a critical failure that prevents the ENSIndexer instance from functioning properly. - logger.error({ - msg: "Failed to initialize the indexing setup", - module: "init-indexing-setup", - error, - }); - - process.exitCode = 1; - throw error; - } -} diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 30e2b9d4b..d76e9f41c 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -15,8 +15,6 @@ import { ponder, } from "ponder:registry"; -import { initIndexingSetup } from "@/lib/indexing-engines/init-indexing-setup"; - /** * Context passed to event handlers registered with * {@link addOnchainEventListener}. @@ -130,7 +128,6 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { } let eventHandlerPreconditionsFullyExecuted = false; -let indexingSetupPromise: Promise | null = null; let indexingOnchainEventsPromise: Promise | null = null; /** @@ -150,22 +147,19 @@ async function eventHandlerPreconditions(eventType: EventTypeId): Promise // Preconditions have already been fully executed, so we can skip executing them again. // We can also reset the promises for indexing setup and onchain events to free up memory, // since they will never be used again after the preconditions have been fully executed. - indexingSetupPromise = null; indexingOnchainEventsPromise = null; return; } switch (eventType) { case EventTypeIds.Setup: { - if (indexingSetupPromise === null) { - // Init the indexing setup just once. There will be multiple - // setup events executed during Ponder startup, but they will - // run sequentially, so we can just check if we have already - // initialized the indexing setup or not. - indexingSetupPromise = initIndexingSetup(); - } - - return await indexingSetupPromise; + // For some ENSIndexer instances, the setup handlers are not defined at all, + // for example, if the ENSIndexer instance has only the `ensv2` plugin activated. + // In this case, some important logic, such as running migrations for ENSNode Schema + // in ENSDb, would not be executed at all, which would cause the ENSIndexer instance + // to not work properly. Therefore, all logic required to be executed before + // indexing of onchain events should be executed in initIndexingOnchainEvents function. + return; } case EventTypeIds.OnchainEvent: { @@ -174,16 +168,19 @@ async function eventHandlerPreconditions(eventType: EventTypeId): Promise // since Ponder would not allow us to use static imports for modules // that internally rely on `ponder:api`. Using dynamic imports solves // this issue. - const { initIndexingOnchainEvents } = await import("./init-indexing-onchain-events"); - // Init the indexing of "onchain" events just once in order to - // optimize the indexing "hot path", since these events are much - // more frequent than setup events. - indexingOnchainEventsPromise = initIndexingOnchainEvents().then(() => { - // Mark the preconditions as fully executed after the first time we execute - // the preconditions for onchain events, since that's the "hot path" and we want to - // minimize the overhead of this function in the long run. - eventHandlerPreconditionsFullyExecuted = true; - }); + indexingOnchainEventsPromise = import("./init-indexing-onchain-events") + .then(({ initIndexingOnchainEvents }) => + // Init the indexing of "onchain" events just once in order to + // optimize the indexing "hot path", since these events are much + // more frequent than setup events. + initIndexingOnchainEvents(), + ) + .then(() => { + // Mark the preconditions as fully executed after the first time we execute + // the preconditions for onchain events, since that's the "hot path" and we want to + // minimize the overhead of this function in the long run. + eventHandlerPreconditionsFullyExecuted = true; + }); } return await indexingOnchainEventsPromise; From 7595a4118f63b531bf5c2fc91c24f58511967334 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 25 Apr 2026 16:32:15 +0200 Subject: [PATCH 12/33] Update unit tests --- .../ensdb-writer-worker.mock.ts | 100 ++---- .../ensdb-writer-worker.test.ts | 316 +++++++----------- .../src/lib/indexing-engines/ponder.test.ts | 65 ++-- 3 files changed, 185 insertions(+), 296 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts index 50da45a6f..1ab454fff 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts @@ -4,47 +4,26 @@ import type { EnsDbWriter } from "@ensnode/ensdb-sdk"; import { type CrossChainIndexingStatusSnapshot, CrossChainIndexingStrategyIds, - ENSNamespaceIds, - type EnsIndexerPublicConfig, - type EnsIndexerVersionInfo, - type EnsRainbowPublicConfig, + type IndexingMetadataContext, + IndexingMetadataContextStatusCodes, OmnichainIndexingStatusIds, type OmnichainIndexingStatusSnapshot, - PluginName, } from "@ensnode/ensnode-sdk"; -import type { LocalPonderClient } from "@ensnode/ponder-sdk"; import { EnsDbWriterWorker } from "@/lib/ensdb-writer-worker/ensdb-writer-worker"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder"; -import type { PublicConfigBuilder } from "@/lib/public-config-builder"; -// Test fixture for EnsRainbowPublicConfig -export const mockEnsRainbowPublicConfig: EnsRainbowPublicConfig = { - serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, - versionInfo: { - ensRainbow: "1.0.0", +// Test fixture for stack info - minimal valid structure for tests +export const mockStackInfo = { + ensDb: { version: "1.0.0" }, + ensIndexer: { + clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, }, -}; - -// Test fixture for EnsIndexerVersionInfo -export const mockVersionInfo: EnsIndexerVersionInfo = { - ponder: "0.9.0", - ensDb: "1.0.0", - ensIndexer: "1.0.0", - ensNormalize: "1.10.0", -}; - -// Test fixture for EnsIndexerPublicConfig -export const mockPublicConfig: EnsIndexerPublicConfig = { - ensIndexerSchemaName: "ensindexer_0", - clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, - ensRainbowPublicConfig: mockEnsRainbowPublicConfig, - indexedChainIds: new Set([1, 8453]), - isSubgraphCompatible: true, - namespace: ENSNamespaceIds.Mainnet, - plugins: [PluginName.Subgraph], - versionInfo: mockVersionInfo, -}; + ensRainbow: { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.0.0" }, + }, +} as any; // Helper to create mock objects with consistent typing export function createMockEnsDbWriter( @@ -58,21 +37,26 @@ export function createMockEnsDbWriter( export function baseEnsDbWriter() { return { - getEnsDbVersion: vi.fn().mockResolvedValue(undefined), - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - getIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), + getIndexingMetadataContext: vi.fn().mockResolvedValue(undefined), + upsertIndexingMetadataContext: vi.fn().mockResolvedValue(undefined), }; } -export function createMockPublicConfigBuilder( - resolvedConfig: EnsIndexerPublicConfig = mockPublicConfig, -): PublicConfigBuilder { +export function createMockIndexingMetadataContextInitialized( + overrides: Partial = {}, +): IndexingMetadataContext { return { - getPublicConfig: vi.fn().mockResolvedValue(resolvedConfig), - } as unknown as PublicConfigBuilder; + statusCode: IndexingMetadataContextStatusCodes.Initialized, + indexingStatus: createMockCrossChainSnapshot(), + stackInfo: mockStackInfo, + ...overrides, + }; +} + +export function createMockIndexingMetadataContextUninitialized(): IndexingMetadataContext { + return { + statusCode: IndexingMetadataContextStatusCodes.Uninitialized, + }; } export function createMockIndexingStatusBuilder( @@ -106,36 +90,12 @@ export function createMockCrossChainSnapshot( }; } -export function createMockLocalPonderClient( - overrides: { isInDevMode?: boolean } = {}, -): LocalPonderClient { - const isInDevMode = overrides.isInDevMode ?? false; - - return { - isInDevMode, - } as unknown as LocalPonderClient; -} - export function createMockEnsDbWriterWorker( - overrides: { - ensDbClient?: EnsDbWriter; - publicConfigBuilder?: PublicConfigBuilder; - indexingStatusBuilder?: IndexingStatusBuilder; - isInDevMode?: boolean; - } = {}, + overrides: { ensDbClient?: EnsDbWriter; indexingStatusBuilder?: IndexingStatusBuilder } = {}, ) { const ensDbClient = overrides.ensDbClient ?? createMockEnsDbWriter(); - const publicConfigBuilder = overrides.publicConfigBuilder ?? createMockPublicConfigBuilder(); const indexingStatusBuilder = overrides.indexingStatusBuilder ?? createMockIndexingStatusBuilder(); - const localPonderClient = createMockLocalPonderClient({ - isInDevMode: overrides.isInDevMode ?? false, - }); - return new EnsDbWriterWorker( - ensDbClient, - publicConfigBuilder, - indexingStatusBuilder, - localPonderClient, - ); + return new EnsDbWriterWorker(ensDbClient, indexingStatusBuilder); } diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index ba0f0bee5..2fe5d9714 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -2,23 +2,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildCrossChainIndexingStatusSnapshotOmnichain, - OmnichainIndexingStatusIds, - validateEnsIndexerPublicConfigCompatibility, + buildIndexingMetadataContextInitialized, + type IndexingMetadataContextInitialized, } from "@ensnode/ensnode-sdk"; import "@/lib/__test__/mockLogger"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; -import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; import { createMockCrossChainSnapshot, createMockEnsDbWriter, createMockEnsDbWriterWorker, + createMockIndexingMetadataContextInitialized, + createMockIndexingMetadataContextUninitialized, createMockIndexingStatusBuilder, createMockOmnichainSnapshot, - createMockPublicConfigBuilder, - mockPublicConfig, } from "./ensdb-writer-worker.mock"; vi.mock("@ensnode/ensnode-sdk", async () => { @@ -26,15 +25,11 @@ vi.mock("@ensnode/ensnode-sdk", async () => { return { ...actual, - validateEnsIndexerPublicConfigCompatibility: vi.fn(), buildCrossChainIndexingStatusSnapshotOmnichain: vi.fn(), + buildIndexingMetadataContextInitialized: vi.fn(), }; }); -vi.mock("p-retry", () => ({ - default: vi.fn((fn) => fn()), -})); - describe("EnsDbWriterWorker", () => { beforeEach(() => { vi.useFakeTimers(); @@ -46,13 +41,20 @@ describe("EnsDbWriterWorker", () => { }); describe("run() - worker initialization", () => { - it("upserts version, config, and starts interval for indexing status snapshots", async () => { + it("starts the interval for upserting indexing metadata context", async () => { // arrange const omnichainSnapshot = createMockOmnichainSnapshot(); - const snapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); + const crossChainSnapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); + const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); - const ensDbClient = createMockEnsDbWriter(); + vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); + vi.mocked(buildIndexingMetadataContextInitialized).mockReturnValue( + indexingMetadataContext as IndexingMetadataContextInitialized, + ); + + const ensDbClient = createMockEnsDbWriter({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), + }); const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshot), @@ -61,72 +63,22 @@ describe("EnsDbWriterWorker", () => { // act await worker.run(); - // assert - verify initial upserts happened - expect(ensDbClient.upsertEnsDbVersion).toHaveBeenCalledWith( - mockPublicConfig.versionInfo.ensDb, - ); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(mockPublicConfig); - // advance time to trigger interval await vi.advanceTimersByTimeAsync(1000); - // assert - snapshot should be upserted - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(snapshot); + // assert - snapshot should be upserted via upsertIndexingMetadataContext + expect(ensDbClient.getIndexingMetadataContext).toHaveBeenCalled(); expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( omnichainSnapshot, expect.any(Number), ); - - // cleanup - worker.stop(); - }); - - it("throws when stored config is incompatible", async () => { - // arrange - vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { - throw new Error("incompatible"); - }); - - const ensDbClient = createMockEnsDbWriter({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), - }); - const worker = createMockEnsDbWriterWorker({ - ensDbClient, - publicConfigBuilder: createMockPublicConfigBuilder(mockPublicConfig), - }); - - // act & assert - await expect(worker.run()).rejects.toThrow("incompatible"); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); - }); - - it("skips config validation when in dev mode", async () => { - // arrange - vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { - throw new Error("incompatible"); - }); - - const snapshot = createMockCrossChainSnapshot(); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - - const ensDbClient = createMockEnsDbWriter({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), - }); - const worker = createMockEnsDbWriterWorker({ - ensDbClient, - publicConfigBuilder: createMockPublicConfigBuilder(mockPublicConfig), - isInDevMode: true, - }); - - // act - should not throw even though configs are incompatible - await worker.run(); - - // assert - validation should not have been called - expect(validateEnsIndexerPublicConfigCompatibility).not.toHaveBeenCalled(); - expect(ensDbClient.upsertEnsDbVersion).toHaveBeenCalledWith( - mockPublicConfig.versionInfo.ensDb, + expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( + crossChainSnapshot, + (indexingMetadataContext as IndexingMetadataContextInitialized).stackInfo, + ); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith( + indexingMetadataContext, ); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(mockPublicConfig); // cleanup worker.stop(); @@ -145,90 +97,24 @@ describe("EnsDbWriterWorker", () => { // cleanup worker.stop(); }); - - it("throws error when config fetch fails", async () => { - // arrange - const publicConfigBuilder = { - getPublicConfig: vi.fn().mockRejectedValue(new Error("Network failure")), - } as unknown as PublicConfigBuilder; - const ensDbClient = createMockEnsDbWriter(); - const worker = createMockEnsDbWriterWorker({ ensDbClient, publicConfigBuilder }); - - // act & assert - await expect(worker.run()).rejects.toThrow("Network failure"); - expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); - }); - - it("throws error when stored config fetch fails", async () => { - // arrange - const ensDbClient = createMockEnsDbWriter({ - getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(new Error("Database connection lost")), - }); - const worker = createMockEnsDbWriterWorker({ ensDbClient }); - - // act & assert - await expect(worker.run()).rejects.toThrow("Database connection lost"); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); - }); - - it("fetches stored and in-memory configs concurrently", async () => { - // arrange - vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => {}); - - const ensDbClient = createMockEnsDbWriter({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), - }); - const publicConfigBuilder = createMockPublicConfigBuilder(mockPublicConfig); - const worker = createMockEnsDbWriterWorker({ - ensDbClient, - publicConfigBuilder, - }); - - // act - await worker.run(); - - // assert - both should have been called (concurrent execution via Promise.all) - expect(ensDbClient.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); - expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - - // cleanup - worker.stop(); - }); - - it("calls pRetry for config fetch with retry logic", async () => { - // arrange - pRetry is mocked to call fn directly - const snapshot = createMockCrossChainSnapshot(); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - - const ensDbClient = createMockEnsDbWriter(); - const publicConfigBuilder = createMockPublicConfigBuilder(); - const worker = createMockEnsDbWriterWorker({ ensDbClient, publicConfigBuilder }); - - // act - await worker.run(); - - // assert - config should be called once (pRetry is mocked) - expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(mockPublicConfig); - - // cleanup - worker.stop(); - }); }); describe("stop() - worker termination", () => { it("stops the interval when stop() is called", async () => { // arrange - const upsertIndexingStatusSnapshot = vi.fn().mockResolvedValue(undefined); - const ensDbClient = createMockEnsDbWriter({ upsertIndexingStatusSnapshot }); + const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); + const upsertIndexingMetadataContext = vi.fn().mockResolvedValue(undefined); + const ensDbClient = createMockEnsDbWriter({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), + upsertIndexingMetadataContext, + }); const worker = createMockEnsDbWriterWorker({ ensDbClient }); // act await worker.run(); await vi.advanceTimersByTimeAsync(1000); - const callCountBeforeStop = upsertIndexingStatusSnapshot.mock.calls.length; + const callCountBeforeStop = upsertIndexingMetadataContext.mock.calls.length; worker.stop(); @@ -236,7 +122,7 @@ describe("EnsDbWriterWorker", () => { await vi.advanceTimersByTimeAsync(2000); // assert - no more calls after stop - expect(upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(callCountBeforeStop); + expect(upsertIndexingMetadataContext).toHaveBeenCalledTimes(callCountBeforeStop); }); }); @@ -262,64 +148,43 @@ describe("EnsDbWriterWorker", () => { }); }); - describe("interval behavior - snapshot upserts", () => { - it("continues upserting after snapshot validation errors", async () => { + describe("interval behavior - upsertIndexingMetadataContext", () => { + it("throws when indexing metadata context is uninitialized", async () => { // arrange - const unstartedSnapshot = createMockOmnichainSnapshot({ - omnichainStatus: OmnichainIndexingStatusIds.Unstarted, - }); - const validSnapshot = createMockOmnichainSnapshot({ - omnichainIndexingCursor: 200, - }); - const crossChainSnapshot = createMockCrossChainSnapshot({ - slowestChainIndexingCursor: 200, - snapshotTime: 300, - omnichainSnapshot: validSnapshot, + const indexingMetadataContext = createMockIndexingMetadataContextUninitialized(); + const ensDbClient = createMockEnsDbWriter({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), }); + const worker = createMockEnsDbWriterWorker({ ensDbClient }); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); - - const ensDbClient = createMockEnsDbWriter(); - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi - .fn() - .mockResolvedValueOnce(unstartedSnapshot) - .mockResolvedValueOnce(validSnapshot), - } as unknown as IndexingStatusBuilder; - const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder }); - - // act - run returns immediately + // act await worker.run(); - // first interval tick - should error but not throw + // first interval tick - should error but not throw (error is caught and logged) await vi.advanceTimersByTimeAsync(1000); - // second interval tick - should succeed - await vi.advanceTimersByTimeAsync(1000); - - // assert - expect(indexingStatusBuilder.getOmnichainIndexingStatusSnapshot).toHaveBeenCalledTimes(2); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot); + // assert - get should be called but upsert should not (due to error) + expect(ensDbClient.getIndexingMetadataContext).toHaveBeenCalledTimes(1); + expect(ensDbClient.upsertIndexingMetadataContext).not.toHaveBeenCalled(); // cleanup worker.stop(); }); - it("recovers from errors and continues upserting snapshots", async () => { + it("recovers from errors and continues upserting", async () => { // arrange - const snapshot1 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 100 }); - const snapshot2 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 200 }); + const omnichainSnapshot1 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 100 }); + const omnichainSnapshot2 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 200 }); const crossChainSnapshot1 = createMockCrossChainSnapshot({ slowestChainIndexingCursor: 100, snapshotTime: 1000, - omnichainSnapshot: snapshot1, + omnichainSnapshot: omnichainSnapshot1, }); const crossChainSnapshot2 = createMockCrossChainSnapshot({ slowestChainIndexingCursor: 200, snapshotTime: 2000, - omnichainSnapshot: snapshot2, + omnichainSnapshot: omnichainSnapshot2, }); vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain) @@ -327,8 +192,15 @@ describe("EnsDbWriterWorker", () => { .mockReturnValueOnce(crossChainSnapshot2) .mockReturnValueOnce(crossChainSnapshot2); + const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); + vi.mocked(buildIndexingMetadataContextInitialized) + .mockReturnValueOnce(indexingMetadataContext as IndexingMetadataContextInitialized) + .mockReturnValueOnce(indexingMetadataContext as IndexingMetadataContextInitialized) + .mockReturnValueOnce(indexingMetadataContext as IndexingMetadataContextInitialized); + const ensDbClient = createMockEnsDbWriter({ - upsertIndexingStatusSnapshot: vi + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), + upsertIndexingMetadataContext: vi .fn() .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error("DB error")) @@ -337,9 +209,9 @@ describe("EnsDbWriterWorker", () => { const indexingStatusBuilder = { getOmnichainIndexingStatusSnapshot: vi .fn() - .mockResolvedValueOnce(snapshot1) - .mockResolvedValueOnce(snapshot2) - .mockResolvedValueOnce(snapshot2), + .mockResolvedValueOnce(omnichainSnapshot1) + .mockResolvedValueOnce(omnichainSnapshot2) + .mockResolvedValueOnce(omnichainSnapshot2), } as unknown as IndexingStatusBuilder; const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder }); @@ -348,17 +220,77 @@ describe("EnsDbWriterWorker", () => { // first tick - succeeds await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot1); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); // second tick - fails with DB error, but continues await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenLastCalledWith( - crossChainSnapshot2, - ); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(2); // third tick - succeeds again await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(3); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(3); + + // cleanup + worker.stop(); + }); + + it("builds cross-chain snapshot with correct parameters", async () => { + // arrange + const omnichainSnapshot = createMockOmnichainSnapshot({ + omnichainIndexingCursor: 500, + }); + const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); + const ensDbClient = createMockEnsDbWriter({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), + }); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshot); + const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder }); + + // act + await worker.run(); + await vi.advanceTimersByTimeAsync(1000); + + // assert + expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( + omnichainSnapshot, + expect.any(Number), + ); + + // cleanup + worker.stop(); + }); + + it("calls upsertIndexingMetadataContext with built context", async () => { + // arrange + const omnichainSnapshot = createMockOmnichainSnapshot(); + const crossChainSnapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); + const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); + + vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); + vi.mocked(buildIndexingMetadataContextInitialized).mockReturnValue( + indexingMetadataContext as IndexingMetadataContextInitialized, + ); + + const ensDbClient = createMockEnsDbWriter({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), + }); + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshot), + }); + + // act + await worker.run(); + await vi.advanceTimersByTimeAsync(1000); + + // assert + expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( + crossChainSnapshot, + (indexingMetadataContext as IndexingMetadataContextInitialized).stackInfo, + ); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith( + indexingMetadataContext, + ); // cleanup worker.stop(); diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 5c01961cf..6fa329867 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -5,9 +5,9 @@ import type { IndexingEngineContext, IndexingEngineEvent } from "./ponder"; const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() })); -const mockWaitForEnsRainbow = vi.hoisted(() => vi.fn()); - -const mockMigrateEnsNodeSchema = vi.hoisted(() => vi.fn()); +const { mockInitIndexingOnchainEvents } = vi.hoisted(() => ({ + mockInitIndexingOnchainEvents: vi.fn(), +})); // Set up PONDER_COMMON global before any imports that depend on it vi.hoisted(() => { @@ -36,19 +36,14 @@ vi.mock("ponder:schema", () => ({ ensIndexerSchema: {}, })); -vi.mock("@/lib/ensrainbow/singleton", () => ({ - waitForEnsRainbowToBeReady: mockWaitForEnsRainbow, -})); - -vi.mock("@/lib/ensdb/migrate-ensnode-schema", () => ({ - migrateEnsNodeSchema: mockMigrateEnsNodeSchema, +vi.mock("@/lib/indexing-engines/init-indexing-onchain-events", () => ({ + initIndexingOnchainEvents: mockInitIndexingOnchainEvents, })); describe("addOnchainEventListener", () => { beforeEach(async () => { vi.clearAllMocks(); - mockWaitForEnsRainbow.mockResolvedValue(undefined); - mockMigrateEnsNodeSchema.mockResolvedValue(undefined); + mockInitIndexingOnchainEvents.mockResolvedValue(undefined); // Reset module state to test idempotent behavior correctly vi.resetModules(); }); @@ -245,8 +240,8 @@ describe("addOnchainEventListener", () => { }); }); - describe("ENSRainbow preconditions (onchain events)", () => { - it("waits for ENSRainbow before executing the handler", async () => { + describe("onchain event preconditions", () => { + it("runs onchain event initialization before executing the handler", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler = vi.fn().mockResolvedValue(undefined); @@ -256,14 +251,16 @@ describe("addOnchainEventListener", () => { event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(mockInitIndexingOnchainEvents).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalled(); }); - it("prevents handler execution if ENSRainbow is not ready", async () => { + it("prevents handler execution if onchain event initialization fails", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler = vi.fn().mockResolvedValue(undefined); - mockWaitForEnsRainbow.mockRejectedValue(new Error("ENSRainbow not ready")); + mockInitIndexingOnchainEvents.mockRejectedValue( + new Error("Onchain event initialization failed"), + ); addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); @@ -272,12 +269,12 @@ describe("addOnchainEventListener", () => { context: { db: vi.fn() } as unknown as Context, event: {} as IndexingEngineEvent, }), - ).rejects.toThrow("ENSRainbow not ready"); + ).rejects.toThrow("Onchain event initialization failed"); expect(handler).not.toHaveBeenCalled(); }); - it("calls waitForEnsRainbowToBeReady only once across multiple onchain events (idempotent)", async () => { + it("calls initIndexingOnchainEvents only once across multiple onchain events (idempotent)", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler1 = vi.fn().mockResolvedValue(undefined); const handler2 = vi.fn().mockResolvedValue(undefined); @@ -291,7 +288,7 @@ describe("addOnchainEventListener", () => { context: { db: vi.fn() } as unknown as Context, event: { args: { a: "1" } } as unknown as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(mockInitIndexingOnchainEvents).toHaveBeenCalledTimes(1); // Trigger the second event handler await getRegisteredCallback(1)({ @@ -300,19 +297,19 @@ describe("addOnchainEventListener", () => { }); // Should still only have been called once (idempotent behavior) - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(mockInitIndexingOnchainEvents).toHaveBeenCalledTimes(1); expect(handler1).toHaveBeenCalledTimes(1); expect(handler2).toHaveBeenCalledTimes(1); }); - it("calls waitForEnsRainbowToBeReady only once when two onchain callbacks fire concurrently before the readiness promise resolves", async () => { + it("calls initIndexingOnchainEvents only once when two onchain callbacks fire concurrently before the initialization promise resolves", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler1 = vi.fn().mockResolvedValue(undefined); const handler2 = vi.fn().mockResolvedValue(undefined); let resolveReadiness: (() => void) | undefined; // Create a promise that won't resolve until we manually trigger it - mockWaitForEnsRainbow.mockImplementation(() => { + mockInitIndexingOnchainEvents.mockImplementation(() => { return new Promise((resolve) => { resolveReadiness = resolve; }); @@ -332,8 +329,8 @@ describe("addOnchainEventListener", () => { event: { args: { a: "2" } } as unknown as IndexingEngineEvent, }); - // Should only have been called once despite concurrent execution - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + // Allow the dynamic import to settle before asserting + await vi.waitFor(() => expect(mockInitIndexingOnchainEvents).toHaveBeenCalledTimes(1)); // Neither handler should have executed yet expect(handler1).not.toHaveBeenCalled(); @@ -350,12 +347,12 @@ describe("addOnchainEventListener", () => { expect(handler2).toHaveBeenCalledTimes(1); }); - it("resolves ENSRainbow before calling the handler", async () => { + it("resolves onchain event initialization before calling the handler", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler = vi.fn().mockResolvedValue(undefined); let preconditionResolved = false; - mockWaitForEnsRainbow.mockImplementation(async () => { + mockInitIndexingOnchainEvents.mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); preconditionResolved = true; }); @@ -372,7 +369,7 @@ describe("addOnchainEventListener", () => { }); describe("setup events (no preconditions)", () => { - it("skips ENSRainbow wait for :setup events", async () => { + it("skips onchain event initialization for :setup events", async () => { const { addOnchainEventListener } = await getPonderModule(); const handler = vi.fn().mockResolvedValue(undefined); @@ -382,7 +379,7 @@ describe("addOnchainEventListener", () => { event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(mockInitIndexingOnchainEvents).not.toHaveBeenCalled(); expect(handler).toHaveBeenCalled(); }); @@ -405,7 +402,7 @@ describe("addOnchainEventListener", () => { event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(mockInitIndexingOnchainEvents).not.toHaveBeenCalled(); expect(handler).toHaveBeenCalled(); } }); @@ -420,20 +417,20 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("PublicResolver:setup" as EventNames, setupHandler); addOnchainEventListener("PublicResolver:AddrChanged" as EventNames, onchainHandler); - // Setup event - no ENSRainbow wait + // Setup event - no onchain event initialization await getRegisteredCallback(0)({ context: { db: vi.fn() } as unknown as Context, event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(mockInitIndexingOnchainEvents).not.toHaveBeenCalled(); expect(setupHandler).toHaveBeenCalled(); - // Onchain event - ENSRainbow wait required + // Onchain event - initialization required await getRegisteredCallback(1)({ context: { db: vi.fn() } as unknown as Context, event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(mockInitIndexingOnchainEvents).toHaveBeenCalledTimes(1); expect(onchainHandler).toHaveBeenCalled(); }); @@ -458,7 +455,7 @@ describe("addOnchainEventListener", () => { event: {} as IndexingEngineEvent, }); - expect(mockWaitForEnsRainbow).toHaveBeenCalled(); + expect(mockInitIndexingOnchainEvents).toHaveBeenCalled(); expect(handler).toHaveBeenCalled(); } }); From 1902bfb1b32bcdff3c85dd407146afb426b757e7 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 25 Apr 2026 16:53:48 +0200 Subject: [PATCH 13/33] Use `getIndexingMetadataContext` for all ENSNode Metadata reads from ENSDb instnace --- apps/ensapi/src/cache/indexing-status.cache.ts | 16 +++++++++++----- apps/ensapi/src/config/config.schema.ts | 10 +++++----- .../ponder/src/api/handlers/ensnode-api.ts | 12 +++++++----- .../integration-test-env/src/orchestrator.ts | 12 ++++++++---- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts index 8ae9562f2..a7961e785 100644 --- a/apps/ensapi/src/cache/indexing-status.cache.ts +++ b/apps/ensapi/src/cache/indexing-status.cache.ts @@ -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"; @@ -16,9 +20,11 @@ export const indexingStatusCache = lazyProxy({ fn: async (_cachedResult) => ensDbClient - .getIndexingStatusSnapshot() // get the latest indexing status snapshot - .then((snapshot) => { - if (snapshot === undefined) { + .getIndexingMetadataContext() // get the latest indexing status snapshot + .then((indexingMetadataContext) => { + if ( + indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized + ) { // An indexing status snapshot has not been found in ENSDb yet. // This might happen during application startup, i.e. when ENSDb // has not yet been populated with the first snapshot. @@ -30,7 +36,7 @@ export const indexingStatusCache = lazyProxy { // Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet. diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 17576766f..a1ecb7c37 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -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, @@ -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 diff --git a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts index b4d3c9cb3..f65817e08 100644 --- a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts +++ b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts @@ -6,6 +6,7 @@ import { EnsIndexerIndexingStatusResponseCodes, type EnsIndexerIndexingStatusResponseError, type EnsIndexerIndexingStatusResponseOk, + IndexingMetadataContextStatusCodes, serializeEnsIndexerIndexingStatusResponse, serializeEnsIndexerPublicConfig, } from "@ensnode/ensnode-sdk"; @@ -17,21 +18,21 @@ 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"); } // 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 @@ -39,10 +40,11 @@ app.get("/indexing-status", async (c) => { // 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, diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index 99df9f5f4..33b63497f 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -39,7 +39,10 @@ import { } from "testcontainers"; import { ENSNamespaceIds } from "@ensnode/datasources"; -import { OmnichainIndexingStatusIds } from "@ensnode/ensnode-sdk"; +import { + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, +} from "@ensnode/ensnode-sdk"; const MONOREPO_ROOT = resolve(import.meta.dirname, "../../.."); const ENSRAINBOW_DIR = resolve(MONOREPO_ROOT, "apps/ensrainbow"); @@ -198,9 +201,10 @@ async function pollIndexingStatus( while (Date.now() - start < timeoutMs) { checkAborted(); try { - const snapshot = await ensDbClient.getIndexingStatusSnapshot(); - if (snapshot !== undefined) { - const omnichainStatus = snapshot.omnichainSnapshot.omnichainStatus; + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); + + if (indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Initialized) { + const { omnichainStatus } = indexingMetadataContext.indexingStatus.omnichainSnapshot; log(`Omnichain status: ${omnichainStatus}`); if ( omnichainStatus === OmnichainIndexingStatusIds.Following || From e3ddda0ef26688ac16b01bc4eba2394bd12e0288 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 25 Apr 2026 17:09:46 +0200 Subject: [PATCH 14/33] Simplify `EnsDbReader` class and `EnsDbWriter` class Drop all unused methods --- .../ensdb-sdk/src/client/ensdb-reader.test.ts | 136 +++++++++++++----- packages/ensdb-sdk/src/client/ensdb-reader.ts | 56 -------- .../ensdb-sdk/src/client/ensdb-writer.test.ts | 74 ++++------ packages/ensdb-sdk/src/client/ensdb-writer.ts | 44 ------ .../ensdb-sdk/src/client/ensnode-metadata.ts | 36 ++--- .../src/client/serialize/ensnode-metadata.ts | 41 +----- 6 files changed, 146 insertions(+), 241 deletions(-) diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts index 3a488c4a8..f60ad260b 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts @@ -1,17 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + buildEnsIndexerStackInfo, + buildIndexingMetadataContextInitialized, + buildIndexingMetadataContextUninitialized, deserializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, + deserializeIndexingMetadataContext, + type EnsDbPublicConfig, + serializeIndexingMetadataContext, } from "@ensnode/ensnode-sdk"; import * as ensDbClientMock from "./ensdb-client.mock"; import { EnsDbReader } from "./ensdb-reader"; +const executeMock = vi.fn(); const whereMock = vi.fn(async () => [] as Array<{ value: unknown }>); const fromMock = vi.fn(() => ({ where: whereMock })); const selectMock = vi.fn(() => ({ from: fromMock })); -const drizzleClientMock = { select: selectMock } as any; +const drizzleClientMock = { select: selectMock, execute: executeMock } as any; vi.mock("drizzle-orm/node-postgres", () => ({ drizzle: vi.fn(() => drizzleClientMock), @@ -29,59 +35,125 @@ describe("EnsDbReader", () => { whereMock.mockClear(); fromMock.mockClear(); selectMock.mockClear(); + executeMock.mockClear(); }); - describe("getEnsDbVersion", () => { - it("returns undefined when no record exists", async () => { - const ensDbClient = createEnsDbReader(); - const { ensNodeSchema } = ensDbClient; + describe("getters", () => { + it("returns the ensDb drizzle client", () => { + const ensDbReader = createEnsDbReader(); + expect(ensDbReader.ensDb).toBe(drizzleClientMock); + }); - await expect(ensDbClient.getEnsDbVersion()).resolves.toBeUndefined(); + it("returns the ensIndexerSchema", () => { + const ensDbReader = createEnsDbReader(); + expect(ensDbReader.ensIndexerSchema).toBeDefined(); + }); - expect(selectMock).toHaveBeenCalledTimes(1); - expect(fromMock).toHaveBeenCalledWith(ensNodeSchema.metadata); + it("returns the ensIndexerSchemaName", () => { + const ensDbReader = createEnsDbReader(); + expect(ensDbReader.ensIndexerSchemaName).toBe(ensDbClientMock.ensIndexerSchemaName); }); - it("returns value when one record exists", async () => { - selectResult.current = [{ value: "0.1.0" }]; + it("returns the ensNodeSchema", () => { + const ensDbReader = createEnsDbReader(); + expect(ensDbReader.ensNodeSchema).toBeDefined(); + }); + }); - await expect(createEnsDbReader().getEnsDbVersion()).resolves.toBe("0.1.0"); + describe("buildEnsDbPublicConfig", () => { + it("returns version info with the postgresql version", async () => { + executeMock.mockResolvedValueOnce({ + rows: [ + { + version: "PostgreSQL 17.4 (Ubuntu 17.4-0ubuntu0.22.04.1) on x86_64-pc-linux-gnu", + }, + ], + }); + + const result = await createEnsDbReader().buildEnsDbPublicConfig(); + + expect(result).toStrictEqual({ + versionInfo: { + postgresql: "17.4", + }, + } satisfies EnsDbPublicConfig); + expect(executeMock).toHaveBeenCalledWith("SELECT version();"); }); - // This scenario should be impossible due to the primary key constraint on - // the ('ensIndexerSchemaName', 'key') columns of the 'ensnode_metadata' table. - it("throws when multiple records exist", async () => { - selectResult.current = [{ value: "0.1.0" }, { value: "0.1.1" }]; + it("throws when execute returns no rows", async () => { + executeMock.mockResolvedValueOnce({ rows: [] }); - await expect(createEnsDbReader().getEnsDbVersion()).rejects.toThrowError(/ensdb_version/i); + await expect(createEnsDbReader().buildEnsDbPublicConfig()).rejects.toThrow( + /Failed to get PostgreSQL version/, + ); }); - }); - describe("getEnsIndexerPublicConfig", () => { - it("returns undefined when no record exists", async () => { - await expect(createEnsDbReader().getEnsIndexerPublicConfig()).resolves.toBeUndefined(); + it("throws when execute returns an invalid version string", async () => { + executeMock.mockResolvedValueOnce({ + rows: [{ version: "invalid version string" }], + }); + + await expect(createEnsDbReader().buildEnsDbPublicConfig()).rejects.toThrow( + /Failed to get PostgreSQL version/, + ); }); - it("deserializes the stored config", async () => { - const serializedConfig = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - selectResult.current = [{ value: serializedConfig }]; + it("propagates errors from execute", async () => { + executeMock.mockRejectedValueOnce(new Error("Connection refused")); - await expect(createEnsDbReader().getEnsIndexerPublicConfig()).resolves.toStrictEqual( - ensDbClientMock.publicConfig, + await expect(createEnsDbReader().buildEnsDbPublicConfig()).rejects.toThrow( + "Connection refused", ); }); }); - describe("getIndexingStatusSnapshot", () => { - it("deserializes the stored indexing status snapshot", async () => { - selectResult.current = [{ value: ensDbClientMock.serializedSnapshot }]; + describe("getIndexingMetadataContext", () => { + it("returns an uninitialized context when no record exists", async () => { + const ensDbReader = createEnsDbReader(); + const { ensNodeSchema } = ensDbReader; + + const result = await ensDbReader.getIndexingMetadataContext(); - const expected = deserializeCrossChainIndexingStatusSnapshot( + expect(result).toStrictEqual(buildIndexingMetadataContextUninitialized()); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(fromMock).toHaveBeenCalledWith(ensNodeSchema.metadata); + expect(whereMock).toHaveBeenCalled(); + }); + + it("returns the deserialized initialized context when one record exists", async () => { + const indexingStatus = deserializeCrossChainIndexingStatusSnapshot( ensDbClientMock.serializedSnapshot, ); + const ensDbPublicConfig: EnsDbPublicConfig = { + versionInfo: { postgresql: "17.4" }, + }; + const ensRainbowPublicConfig = { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.9.0" }, + }; + const stackInfo = buildEnsIndexerStackInfo( + ensDbPublicConfig, + ensDbClientMock.publicConfig, + ensRainbowPublicConfig, + ); + const context = buildIndexingMetadataContextInitialized(indexingStatus, stackInfo); + const serialized = serializeIndexingMetadataContext(context); + + selectResult.current = [{ value: serialized }]; + + const result = await createEnsDbReader().getIndexingMetadataContext(); + + const expected = deserializeIndexingMetadataContext(serialized); + expect(result).toStrictEqual(expected); + }); + + // This scenario should be impossible due to the primary key constraint on + // the ('ensIndexerSchemaName', 'key') columns of the 'ensnode_metadata' table. + it("throws when multiple records exist", async () => { + selectResult.current = [{ value: "value1" }, { value: "value2" }]; - await expect(createEnsDbReader().getIndexingStatusSnapshot()).resolves.toStrictEqual( - expected, + await expect(createEnsDbReader().getIndexingMetadataContext()).rejects.toThrow( + /There must be exactly one ENSNodeMetadata record/, ); }); }); diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.ts b/packages/ensdb-sdk/src/client/ensdb-reader.ts index db4d5ace6..eb03dc5f4 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.ts @@ -2,13 +2,9 @@ import { and, eq } from "drizzle-orm/sql"; import { buildIndexingMetadataContextUninitialized, - type CrossChainIndexingStatusSnapshot, - deserializeCrossChainIndexingStatusSnapshot, - deserializeEnsIndexerPublicConfig, deserializeIndexingMetadataContext, type EnsDbPublicConfig, type EnsDbVersionInfo, - type EnsIndexerPublicConfig, type IndexingMetadataContext, } from "@ensnode/ensnode-sdk"; @@ -23,9 +19,6 @@ import { parsePgVersionInfo } from "../lib/parse-pg-version-info"; import { EnsNodeMetadataKeys } from "./ensnode-metadata"; import type { SerializedEnsNodeMetadata, - SerializedEnsNodeMetadataEnsDbVersion, - SerializedEnsNodeMetadataEnsIndexerIndexingStatus, - SerializedEnsNodeMetadataEnsIndexerPublicConfig, SerializedEnsNodeMetadataIndexingMetadataContext, } from "./serialize/ensnode-metadata"; @@ -133,36 +126,6 @@ export class EnsDbReader< return this._ensNodeSchema; } - /** - * Get ENSDb Version - * - * @returns the existing record, or `undefined`. - */ - async getEnsDbVersion(): Promise { - const record = await this.getEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsDbVersion, - }); - - return record; - } - - /** - * Get ENSIndexer Public Config - * - * @returns the existing record, or `undefined`. - */ - async getEnsIndexerPublicConfig(): Promise { - const record = await this.getEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - }); - - if (!record) { - return undefined; - } - - return deserializeEnsIndexerPublicConfig(record); - } - /** * Build ENSDb Public Config */ @@ -174,25 +137,6 @@ export class EnsDbReader< }; } - /** - * Get Indexing Status Snapshot - * - * @returns the existing record, or `undefined`. - */ - async getIndexingStatusSnapshot(): Promise { - const record = await this.getEnsNodeMetadata( - { - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, - }, - ); - - if (!record) { - return undefined; - } - - return deserializeCrossChainIndexingStatusSnapshot(record); - } - /** * Get Indexing Metadata Context * diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.test.ts b/packages/ensdb-sdk/src/client/ensdb-writer.test.ts index 51e7ccf75..d3769d329 100644 --- a/packages/ensdb-sdk/src/client/ensdb-writer.test.ts +++ b/packages/ensdb-sdk/src/client/ensdb-writer.test.ts @@ -2,9 +2,10 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + buildEnsIndexerStackInfo, + buildIndexingMetadataContextInitialized, deserializeCrossChainIndexingStatusSnapshot, - serializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, + serializeIndexingMetadataContext, } from "@ensnode/ensnode-sdk"; import * as ensDbClientMock from "./ensdb-client.mock"; @@ -32,59 +33,46 @@ describe("EnsDbWriter", () => { vi.mocked(migrate).mockClear(); }); - describe("upsertEnsDbVersion", () => { - it("writes the database version metadata", async () => { - const ensDbClient = createEnsDbWriter(); - const { ensNodeSchema } = ensDbClient; + describe("upsertIndexingMetadataContext", () => { + it("serializes and writes the indexing metadata context", async () => { + const ensDbWriter = createEnsDbWriter(); + const { ensNodeSchema } = ensDbWriter; - await ensDbClient.upsertEnsDbVersion("0.2.0"); - - expect(insertMock).toHaveBeenCalledWith(ensNodeSchema.metadata); - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, - key: EnsNodeMetadataKeys.EnsDbVersion, - value: "0.2.0", - }); - expect(onConflictDoUpdateMock).toHaveBeenCalledWith({ - target: [ensNodeSchema.metadata.ensIndexerSchemaName, ensNodeSchema.metadata.key], - set: { value: "0.2.0" }, - }); - }); - }); - - describe("upsertEnsIndexerPublicConfig", () => { - it("serializes and writes the public config", async () => { - const expectedValue = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - await createEnsDbWriter().upsertEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - value: expectedValue, - }); - }); - }); - - describe("upsertIndexingStatusSnapshot", () => { - it("serializes and writes the indexing status snapshot", async () => { - const snapshot = deserializeCrossChainIndexingStatusSnapshot( + const indexingStatus = deserializeCrossChainIndexingStatusSnapshot( ensDbClientMock.serializedSnapshot, ); - const expectedValue = serializeCrossChainIndexingStatusSnapshot(snapshot); + const ensDbPublicConfig = { + versionInfo: { postgresql: "17.4" }, + }; + const ensRainbowPublicConfig = { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.9.0" }, + }; + const stackInfo = buildEnsIndexerStackInfo( + ensDbPublicConfig, + ensDbClientMock.publicConfig, + ensRainbowPublicConfig, + ); + const context = buildIndexingMetadataContextInitialized(indexingStatus, stackInfo); + const expectedValue = serializeIndexingMetadataContext(context); - await createEnsDbWriter().upsertIndexingStatusSnapshot(snapshot); + await ensDbWriter.upsertIndexingMetadataContext(context); + expect(insertMock).toHaveBeenCalledWith(ensNodeSchema.metadata); expect(valuesMock).toHaveBeenCalledWith({ ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, + key: EnsNodeMetadataKeys.IndexingMetadataContext, value: expectedValue, }); + expect(onConflictDoUpdateMock).toHaveBeenCalledWith({ + target: [ensNodeSchema.metadata.ensIndexerSchemaName, ensNodeSchema.metadata.key], + set: { value: expectedValue }, + }); }); }); describe("migrateEnsNodeSchema", () => { - it("calls drizzle-orm migrateEnsNodeSchema with the correct parameters", async () => { + it("calls drizzle-orm migrate with the correct parameters", async () => { const migrationsDirPath = "/path/to/migrations"; await createEnsDbWriter().migrateEnsNodeSchema(migrationsDirPath); @@ -95,7 +83,7 @@ describe("EnsDbWriter", () => { }); }); - it("propagates errors from the migrateEnsNodeSchema function", async () => { + it("propagates errors from the migrate function", async () => { const migrationsDirPath = "/path/to/migrations"; vi.mocked(migrate).mockRejectedValueOnce(new Error("Migration failed")); diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.ts b/packages/ensdb-sdk/src/client/ensdb-writer.ts index c0c89d050..92008792f 100644 --- a/packages/ensdb-sdk/src/client/ensdb-writer.ts +++ b/packages/ensdb-sdk/src/client/ensdb-writer.ts @@ -1,11 +1,7 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import { - type CrossChainIndexingStatusSnapshot, - type EnsIndexerPublicConfig, type IndexingMetadataContextInitialized, - serializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, serializeIndexingMetadataContext, } from "@ensnode/ensnode-sdk"; @@ -35,46 +31,6 @@ export class EnsDbWriter extends EnsDbReader { }); } - /** - * Upsert ENSDb Version - * - * @throws when upsert operation failed. - */ - async upsertEnsDbVersion(ensDbVersion: string): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsDbVersion, - value: ensDbVersion, - }); - } - - /** - * Upsert ENSIndexer Public Config - * - * @throws when upsert operation failed. - */ - async upsertEnsIndexerPublicConfig( - ensIndexerPublicConfig: EnsIndexerPublicConfig, - ): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - value: serializeEnsIndexerPublicConfig(ensIndexerPublicConfig), - }); - } - - /** - * Upsert Indexing Status Snapshot - * - * @throws when upsert operation failed. - */ - async upsertIndexingStatusSnapshot( - indexingStatus: CrossChainIndexingStatusSnapshot, - ): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, - value: serializeCrossChainIndexingStatusSnapshot(indexingStatus), - }); - } - /** * Upsert Indexing Metadata Context Initialized * diff --git a/packages/ensdb-sdk/src/client/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/ensnode-metadata.ts index 0713108f2..eb7565a0e 100644 --- a/packages/ensdb-sdk/src/client/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/ensnode-metadata.ts @@ -1,36 +1,20 @@ -import type { - CrossChainIndexingStatusSnapshot, - EnsIndexerPublicConfig, - IndexingMetadataContextInitialized, -} from "@ensnode/ensnode-sdk"; +import type { IndexingMetadataContextInitialized } from "@ensnode/ensnode-sdk"; /** * Keys used to distinguish records in `ensnode_metadata` table in the ENSDb. */ export const EnsNodeMetadataKeys = { - EnsDbVersion: "ensdb_version", - EnsIndexerPublicConfig: "ensindexer_public_config", - EnsIndexerIndexingStatus: "ensindexer_indexing_status", IndexingMetadataContext: "indexing_metadata_context", } as const; export type EnsNodeMetadataKey = (typeof EnsNodeMetadataKeys)[keyof typeof EnsNodeMetadataKeys]; -export interface EnsNodeMetadataEnsDbVersion { - key: typeof EnsNodeMetadataKeys.EnsDbVersion; - value: string; -} - -export interface EnsNodeMetadataEnsIndexerPublicConfig { - key: typeof EnsNodeMetadataKeys.EnsIndexerPublicConfig; - value: EnsIndexerPublicConfig; -} - -export interface EnsNodeMetadataEnsIndexerIndexingStatus { - key: typeof EnsNodeMetadataKeys.EnsIndexerIndexingStatus; - value: CrossChainIndexingStatusSnapshot; -} - +/** + * ENSNode Metadata record for Indexing Metadata Context + * + * This record is used to store the Indexing Metadata Context in + * ENSNode Metadata table for each ENSIndexer instance. + */ export interface EnsNodeMetadataIndexingMetadataContext { key: typeof EnsNodeMetadataKeys.IndexingMetadataContext; value: IndexingMetadataContextInitialized; @@ -41,8 +25,4 @@ export interface EnsNodeMetadataIndexingMetadataContext { * * Union type gathering all variants of ENSNode Metadata. */ -export type EnsNodeMetadata = - | EnsNodeMetadataEnsDbVersion - | EnsNodeMetadataEnsIndexerPublicConfig - | EnsNodeMetadataEnsIndexerIndexingStatus - | EnsNodeMetadataIndexingMetadataContext; +export type EnsNodeMetadata = EnsNodeMetadataIndexingMetadataContext; diff --git a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts index 8347f16eb..aeacf63b4 100644 --- a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts @@ -1,37 +1,6 @@ -import type { - SerializedCrossChainIndexingStatusSnapshot, - SerializedEnsIndexerPublicConfig, - SerializedIndexingMetadataContextInitialized, -} from "@ensnode/ensnode-sdk"; +import type { SerializedIndexingMetadataContextInitialized } from "@ensnode/ensnode-sdk"; -import type { - EnsNodeMetadata, - EnsNodeMetadataEnsDbVersion, - EnsNodeMetadataEnsIndexerIndexingStatus, - EnsNodeMetadataEnsIndexerPublicConfig, - EnsNodeMetadataKeys, -} from "../ensnode-metadata"; - -/** - * Serialized representation of {@link EnsNodeMetadataEnsDbVersion}. - */ -export type SerializedEnsNodeMetadataEnsDbVersion = EnsNodeMetadataEnsDbVersion; - -/** - * Serialized representation of {@link EnsNodeMetadataEnsIndexerPublicConfig}. - */ -export interface SerializedEnsNodeMetadataEnsIndexerPublicConfig { - key: typeof EnsNodeMetadataKeys.EnsIndexerPublicConfig; - value: SerializedEnsIndexerPublicConfig; -} - -/** - * Serialized representation of {@link EnsNodeMetadataEnsIndexerIndexingStatus}. - */ -export interface SerializedEnsNodeMetadataEnsIndexerIndexingStatus { - key: typeof EnsNodeMetadataKeys.EnsIndexerIndexingStatus; - value: SerializedCrossChainIndexingStatusSnapshot; -} +import type { EnsNodeMetadata, EnsNodeMetadataKeys } from "../ensnode-metadata"; export interface SerializedEnsNodeMetadataIndexingMetadataContext { key: typeof EnsNodeMetadataKeys.IndexingMetadataContext; @@ -41,8 +10,4 @@ export interface SerializedEnsNodeMetadataIndexingMetadataContext { /** * Serialized representation of {@link EnsNodeMetadata} */ -export type SerializedEnsNodeMetadata = - | SerializedEnsNodeMetadataEnsDbVersion - | SerializedEnsNodeMetadataEnsIndexerPublicConfig - | SerializedEnsNodeMetadataEnsIndexerIndexingStatus - | SerializedEnsNodeMetadataIndexingMetadataContext; +export type SerializedEnsNodeMetadata = SerializedEnsNodeMetadataIndexingMetadataContext; From 4684fb462e892681f190560bec5f024d53c7e232 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 25 Apr 2026 22:19:31 +0200 Subject: [PATCH 15/33] Fix tests --- apps/ensapi/src/config/config.schema.test.ts | 172 +++++++++++++----- .../src/lib/indexing-engines/ponder.test.ts | 2 +- 2 files changed, 127 insertions(+), 47 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 4f1e9493c..72fe56065 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -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; + vi.mock("@/lib/ensdb/singleton", () => ({ ensDbClient: { - getEnsIndexerPublicConfig: vi.fn(async () => ENSINDEXER_PUBLIC_CONFIG), + getIndexingMetadataContext: vi.fn(async () => indexingMetadataContextInitialized), }, })); @@ -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: { @@ -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, @@ -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, @@ -171,7 +249,7 @@ describe("buildEnsApiPublicConfig", () => { referralProgramEditionConfigSetUrl: undefined, }; - const result = buildEnsApiPublicConfig(mockConfig); + const result = buildEnsApiPublicConfig(ensApiConfig); expect(result).toStrictEqual({ versionInfo: ensApiVersionInfo, @@ -179,44 +257,46 @@ describe("buildEnsApiPublicConfig", () => { 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... diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 6fa329867..d24a9c975 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -36,7 +36,7 @@ vi.mock("ponder:schema", () => ({ ensIndexerSchema: {}, })); -vi.mock("@/lib/indexing-engines/init-indexing-onchain-events", () => ({ +vi.mock("./init-indexing-onchain-events", () => ({ initIndexingOnchainEvents: mockInitIndexingOnchainEvents, })); From 1ff960e466a0d55e67cd400fe3e85fe9f96ce493 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 26 Apr 2026 09:26:35 +0200 Subject: [PATCH 16/33] Apply AI PR feedback --- .../ensdb-writer-worker/ensdb-writer-worker.ts | 16 ++++------------ .../deserialize/indexing-metadata-context.ts | 11 ++++------- .../metadata/indexing-metadata-context.ts | 2 +- .../validate/indexing-metadata-context.ts | 3 ++- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index c1703726c..d12e4816d 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -5,8 +5,6 @@ import type { EnsDbWriter } from "@ensnode/ensdb-sdk"; import { buildCrossChainIndexingStatusSnapshotOmnichain, buildIndexingMetadataContextInitialized, - type CrossChainIndexingStatusSnapshot, - type EnsIndexerPublicConfig, type IndexingMetadataContext, IndexingMetadataContextStatusCodes, } from "@ensnode/ensnode-sdk"; @@ -54,16 +52,10 @@ export class EnsDbWriterWorker { /** * Run the ENSDb Writer Worker * - * The worker performs the following tasks: - * 1) A single attempt to upsert ENSDb version into ENSDb. - * 2) A single attempt to upsert serialized representation of - * {@link EnsIndexerPublicConfig} into ENSDb. - * 3) A recurring attempt to upsert serialized representation of - * {@link CrossChainIndexingStatusSnapshot} into ENSDb. + * The worker performs a recurring upsert of + * the {@link IndexingMetadataContext} record into ENSDb. * - * @throws Error if the worker is already running, or - * if the in-memory ENSIndexer Public Config could not be fetched, or - * if the in-memory ENSIndexer Public Config is incompatible with the stored config in ENSDb. + * @throws Error if the worker is already running. */ public async run(): Promise { // Do not allow multiple concurrent runs of the worker @@ -71,7 +63,7 @@ export class EnsDbWriterWorker { throw new Error("EnsDbWriterWorker is already running"); } - // Task 1: recurring upsert of Indexing Metadata Context into ENSDb. + // Recurring upsert of Indexing Metadata Context into ENSDb. this.indexingStatusInterval = setInterval( () => this.upsertIndexingMetadataContext(), secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL), diff --git a/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts index 6c4864bc9..c7b883a59 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts @@ -20,7 +20,7 @@ import { /** * Builds an unvalidated {@link IndexingMetadataContextInitialized} object. */ -function buildUnvalidatedIndexingMetadataContextInitializedSchema( +function buildUnvalidatedIndexingMetadataContextInitialized( serializedIndexingMetadataContext: SerializedIndexingMetadataContextInitialized, ): Unvalidated { return { @@ -37,9 +37,8 @@ function buildUnvalidatedIndexingMetadataContextInitializedSchema( * validated with {@link makeIndexingMetadataContextSchema}. * * @param serializedIndexingMetadataContext - The serialized indexing metadata context to build from. - * @return An unvalidated {@link IndexingMetadataContextInitialized} object. */ -function buildUnvalidatedIndexingMetadataContextSchema( +function buildUnvalidatedIndexingMetadataContext( serializedIndexingMetadataContext: SerializedIndexingMetadataContext, ): Unvalidated { switch (serializedIndexingMetadataContext.statusCode) { @@ -47,9 +46,7 @@ function buildUnvalidatedIndexingMetadataContextSchema( return serializedIndexingMetadataContext; case IndexingMetadataContextStatusCodes.Initialized: - return buildUnvalidatedIndexingMetadataContextInitializedSchema( - serializedIndexingMetadataContext, - ); + return buildUnvalidatedIndexingMetadataContextInitialized(serializedIndexingMetadataContext); } } @@ -63,7 +60,7 @@ export function deserializeIndexingMetadataContext( const label = valueLabel ?? "IndexingMetadataContext"; const parsed = makeSerializedIndexingMetadataContextSchema(label) - .transform(buildUnvalidatedIndexingMetadataContextSchema) + .transform(buildUnvalidatedIndexingMetadataContext) .pipe(makeIndexingMetadataContextSchema(label)) .safeParse(serializedIndexingMetadataContext); diff --git a/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts index 845313b22..457a9cd2c 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.ts @@ -7,7 +7,7 @@ import { validateIndexingMetadataContextInitialized } from "./validate/indexing- */ export const IndexingMetadataContextStatusCodes = { /** - * Represents that the no indexing metadata context has been initialized + * Represents that no indexing metadata context has been initialized * for the ENSIndexer Schema Name in the ENSNode Metadata table in ENSDb. */ Uninitialized: "uninitialized", diff --git a/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts index cf56bc126..691c97c8a 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts @@ -9,8 +9,9 @@ import { makeIndexingMetadataContextInitializedSchema } from "../zod-schemas/ind */ export function validateIndexingMetadataContextInitialized( maybeIndexingMetadataContext: Unvalidated, + valueLabel?: string, ): IndexingMetadataContextInitialized { - const result = makeIndexingMetadataContextInitializedSchema().safeParse( + const result = makeIndexingMetadataContextInitializedSchema(valueLabel).safeParse( maybeIndexingMetadataContext, ); From 41060d040efeed43917d5c3444b831ec12866fb2 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 26 Apr 2026 14:48:02 +0200 Subject: [PATCH 17/33] Simplify `initIndexingOnchainEvents` logic Refactor logic into `IndexingMetadataContextBuilder` class and `StackInfoBuilder` class --- .../ensdb-writer-worker.ts | 64 ++++------ .../src/lib/ensdb-writer-worker/singleton.ts | 4 +- .../init-indexing-onchain-events.ts | 110 +++--------------- .../src/lib/indexing-engines/ponder.ts | 21 +--- .../indexing-metadata-context-builder.ts | 105 +++++++++++++++++ .../singleton.ts | 13 +++ .../src/lib/stack-info-builder/singleton.ts | 13 +++ .../stack-info-builder/stack-info-builder.ts | 46 ++++++++ .../deserialize/indexing-metadata-context.ts | 4 +- 9 files changed, 225 insertions(+), 155 deletions(-) create mode 100644 apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts create mode 100644 apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts create mode 100644 apps/ensindexer/src/lib/stack-info-builder/singleton.ts create mode 100644 apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index d12e4816d..e042c1bfd 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -1,15 +1,9 @@ -import { getUnixTime, secondsToMilliseconds } from "date-fns"; +import { secondsToMilliseconds } from "date-fns"; import type { Duration } from "enssdk"; import type { EnsDbWriter } from "@ensnode/ensdb-sdk"; -import { - buildCrossChainIndexingStatusSnapshotOmnichain, - buildIndexingMetadataContextInitialized, - type IndexingMetadataContext, - IndexingMetadataContextStatusCodes, -} from "@ensnode/ensnode-sdk"; -import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; +import type { IndexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/indexing-metadata-context-builder"; import { logger } from "@/lib/logger"; /** @@ -36,17 +30,20 @@ export class EnsDbWriterWorker { private ensDbClient: EnsDbWriter; /** - * Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. + * Indexing Metadata Context Builder instance used by the worker to read {@link IndexingMetadataContext}. */ - private indexingStatusBuilder: IndexingStatusBuilder; + private indexingMetadataContextBuilder: IndexingMetadataContextBuilder; /** * @param ensDbClient ENSDb Writer instance used by the worker to interact with ENSDb. - * @param indexingStatusBuilder Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. + * @param indexingMetadataContextBuilder Indexing Metadata Context Builder instance used by the worker to read {@link IndexingMetadataContext}. */ - constructor(ensDbClient: EnsDbWriter, indexingStatusBuilder: IndexingStatusBuilder) { + constructor( + ensDbClient: EnsDbWriter, + indexingMetadataContextBuilder: IndexingMetadataContextBuilder, + ) { this.ensDbClient = ensDbClient; - this.indexingStatusBuilder = indexingStatusBuilder; + this.indexingMetadataContextBuilder = indexingMetadataContextBuilder; } /** @@ -63,9 +60,9 @@ export class EnsDbWriterWorker { throw new Error("EnsDbWriterWorker is already running"); } - // Recurring upsert of Indexing Metadata Context into ENSDb. + // Recurring update of the Indexing Metadata Context record in ENSDb. this.indexingStatusInterval = setInterval( - () => this.upsertIndexingMetadataContext(), + () => this.updateIndexingMetadataContext(), secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL), ); } @@ -90,40 +87,29 @@ export class EnsDbWriterWorker { } /** - * Upsert the current Indexing Status Snapshot into ENSDb. + * Update the current Indexing Status Snapshot into ENSDb. * * This method is called by the scheduler at regular intervals. * Errors are logged but not thrown, to keep the worker running. */ - private async upsertIndexingMetadataContext(): Promise { + private async updateIndexingMetadataContext(): Promise { try { - // get system timestamp for the current iteration - const snapshotTime = getUnixTime(new Date()); - const indexingMetadataContext = await this.ensDbClient.getIndexingMetadataContext(); + const indexingMetadataContext = + await this.indexingMetadataContextBuilder.getIndexingMetadataContext(); - if (indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized) { - throw new Error( - `Cannot upsert Indexing Status Snapshot into ENSDb because Indexing Metadata Context should be be initialized first`, - ); - } - - const omnichainSnapshot = - await this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); - - const updatedIndexingMetadataContext = buildIndexingMetadataContextInitialized( - buildCrossChainIndexingStatusSnapshotOmnichain(omnichainSnapshot, snapshotTime), - indexingMetadataContext.stackInfo, - ); - - await this.ensDbClient.upsertIndexingMetadataContext(updatedIndexingMetadataContext); + await this.ensDbClient.upsertIndexingMetadataContext(indexingMetadataContext); } catch (error) { + // If any error happens during the update of indexing metadata context record in ENSDb, + // we want to log the error and exit the process with a non-zero exit code, + // since this is a critical failure that prevents the ENSIndexer instance from functioning properly. logger.error({ - msg: "Failed to upsert indexing metadata context", - error, + msg: "Failed to update indexing metadata context record in ENSDb", module: "EnsDbWriterWorker", + error, }); - // Do not throw the error, as failure to retrieve the Indexing Status - // should not cause the ENSDb Writer Worker to stop functioning. + + process.exitCode = 1; + throw error; } } } diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index 7375b5fdc..66e10d903 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,5 +1,5 @@ import { ensDbClient } from "@/lib/ensdb/singleton"; -import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { indexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/singleton"; import { logger } from "@/lib/logger"; import { EnsDbWriterWorker } from "./ensdb-writer-worker"; @@ -20,7 +20,7 @@ export function startEnsDbWriterWorker() { throw new Error("EnsDbWriterWorker has already been initialized"); } - ensDbWriterWorker = new EnsDbWriterWorker(ensDbClient, indexingStatusBuilder); + ensDbWriterWorker = new EnsDbWriterWorker(ensDbClient, indexingMetadataContextBuilder); ensDbWriterWorker .run() diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts index e98d54a74..8081cb5e8 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -23,28 +23,16 @@ * in this module, which is executed in step 5 b) and is guaranteed to be executed on every ENSIndexer instance startup, * regardless of the state of Ponder Checkpoints or whether any setup handlers were registered. */ -import { getUnixTime } from "date-fns"; - -import { - buildCrossChainIndexingStatusSnapshotOmnichain, - buildEnsIndexerStackInfo, - buildIndexingMetadataContextInitialized, - IndexingMetadataContextStatusCodes, - OmnichainIndexingStatusIds, - validateEnsIndexerPublicConfigCompatibility, -} from "@ensnode/ensnode-sdk"; import { migrateEnsNodeSchema } from "@/lib/ensdb/migrate-ensnode-schema"; import { ensDbClient } from "@/lib/ensdb/singleton"; import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; import { - ensRainbowClient, waitForEnsRainbowToBeHealthy, waitForEnsRainbowToBeReady, } from "@/lib/ensrainbow/singleton"; -import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { indexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/singleton"; import { logger } from "@/lib/logger"; -import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; /** * Prepare for executing the "onchain" event handlers. @@ -74,104 +62,36 @@ import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; * 1. Make ENSDb instance "ready" for ENSDb clients to use. */ export async function initIndexingOnchainEvents(): Promise { - // TODO: wait for ENSDb instance to be healthy - // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. - await migrateEnsNodeSchema(); - - // Before calling `ensRainbowClient.config()`, we want to make sure that - // the ENSRainbow instance is healthy and ready to serve requests. - // This is a quick check, as we expect the ENSRainbow instance to be healthy - // by the time ENSIndexer instance executes `initIndexingOnchainEvents`. - await waitForEnsRainbowToBeHealthy(); - try { - const [ - inMemoryIndexingStatusSnapshot, - inMemoryEnsDbPublicConfig, - inMemoryEnsIndexerPublicConfig, - inMemoryEnsRainbowPublicConfig, - storedIndexingMetadataContext, - ] = await Promise.all([ - indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(), - ensDbClient.buildEnsDbPublicConfig(), - publicConfigBuilder.getPublicConfig(), - ensRainbowClient.config(), - ensDbClient.getIndexingMetadataContext(), - ]); - - if ( - storedIndexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized - ) { - logger.info({ - msg: `Indexing Metadata Context is "uninitialized"`, - }); - - // Invariant: indexing status must be "unstarted" when the indexing metadata context is uninitialized, - // since we haven't started processing any onchain events yet - if (inMemoryIndexingStatusSnapshot.omnichainStatus !== OmnichainIndexingStatusIds.Unstarted) { - throw new Error( - `Omnichain indexing status must be "unstarted" for "uninitialized" Indexing Metadata Context. Provided omnichain indexing status "${inMemoryIndexingStatusSnapshot.omnichainStatus}".`, - ); - } - } else { - logger.info({ - msg: `Indexing Metadata Context is "initialized"`, - }); - logger.debug({ - msg: `Indexing Metadata Context`, - indexingStatus: storedIndexingMetadataContext.indexingStatus, - stackInfo: storedIndexingMetadataContext.stackInfo, - }); - // if (ensIndexerPublicConfig.ensIndexerBuildId !== storedIndexingMetadataContext.stackInfo.ensIndexer.ensIndexerBuildId) { - // TODO: store the `ensIndexerPublicConfig` object in ENSDb so `storedIndexingMetadataContext.stackInfo.ensIndexer` is updated - // } - const { ensIndexer: storedEnsIndexerPublicConfig } = storedIndexingMetadataContext.stackInfo; - validateEnsIndexerPublicConfigCompatibility( - inMemoryEnsIndexerPublicConfig, - storedEnsIndexerPublicConfig, - ); - } + // TODO: wait for ENSDb instance to be healthy before running any queries against it. - // Build the {@link CrossChainIndexingStatusSnapshot} with the current snapshot time. - // This is important to make sure the `snapshotTime` is always up to date in - // the indexing status snapshot stored in ENSDb. - const now = getUnixTime(new Date()); - const crossChainIndexingStatusSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain( - inMemoryIndexingStatusSnapshot, - now, - ); + // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. + await migrateEnsNodeSchema(); - // Build EnsIndexerStackInfo based on the current state of in-memory public - // config objects. It's unlikely, but possible, that after the ENSIndexer - // instance restarts, some values in the public config objects have changed - // compared to the previous instance before the restart. For example, - // if the ENSIndexer instance is redeployed with a new version of the code that has different default values for some config parameters, or if there are changes in the environment variables used to build the public config objects. - const updatedStackInfo = buildEnsIndexerStackInfo( - inMemoryEnsDbPublicConfig, - inMemoryEnsIndexerPublicConfig, - inMemoryEnsRainbowPublicConfig, - ); + // Before calling `ensRainbowClient.config()`, we want to make sure that + // the ENSRainbow instance is healthy and ready to serve requests. + // This is a quick check, as we expect the ENSRainbow instance to be healthy + // by the time ENSIndexer instance executes `initIndexingOnchainEvents`. + await waitForEnsRainbowToBeHealthy(); - const updatedIndexingMetadataContext = buildIndexingMetadataContextInitialized( - crossChainIndexingStatusSnapshot, - updatedStackInfo, - ); + const indexingMetadataContext = + await indexingMetadataContextBuilder.getIndexingMetadataContext(); logger.info({ msg: `Upserting Indexing Metadata Context Initialized`, }); logger.debug({ msg: `Indexing Metadata Context`, - indexingStatus: updatedIndexingMetadataContext.indexingStatus, - stackInfo: updatedIndexingMetadataContext.stackInfo, + indexingStatus: indexingMetadataContext.indexingStatus, + stackInfo: indexingMetadataContext.stackInfo, }); - await ensDbClient.upsertIndexingMetadataContext(updatedIndexingMetadataContext); + await ensDbClient.upsertIndexingMetadataContext(indexingMetadataContext); logger.info({ msg: `Successfully upserted Indexing Metadata Context Initialized`, }); // Before starting to process onchain events, we want to make sure that - // ENSRainbow is ready and ready to serve the "heal" requests. + // ENSRainbow is ready to serve the "heal" requests. await waitForEnsRainbowToBeReady(); // TODO: start Indexing Status Sync worker diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index d76e9f41c..1726860d2 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -127,7 +127,6 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { } } -let eventHandlerPreconditionsFullyExecuted = false; let indexingOnchainEventsPromise: Promise | null = null; /** @@ -143,14 +142,6 @@ let indexingOnchainEventsPromise: Promise | null = null; * "onchain" event. */ async function eventHandlerPreconditions(eventType: EventTypeId): Promise { - if (eventHandlerPreconditionsFullyExecuted) { - // Preconditions have already been fully executed, so we can skip executing them again. - // We can also reset the promises for indexing setup and onchain events to free up memory, - // since they will never be used again after the preconditions have been fully executed. - indexingOnchainEventsPromise = null; - return; - } - switch (eventType) { case EventTypeIds.Setup: { // For some ENSIndexer instances, the setup handlers are not defined at all, @@ -168,19 +159,13 @@ async function eventHandlerPreconditions(eventType: EventTypeId): Promise // since Ponder would not allow us to use static imports for modules // that internally rely on `ponder:api`. Using dynamic imports solves // this issue. - indexingOnchainEventsPromise = import("./init-indexing-onchain-events") - .then(({ initIndexingOnchainEvents }) => + indexingOnchainEventsPromise = import("./init-indexing-onchain-events").then( + ({ initIndexingOnchainEvents }) => // Init the indexing of "onchain" events just once in order to // optimize the indexing "hot path", since these events are much // more frequent than setup events. initIndexingOnchainEvents(), - ) - .then(() => { - // Mark the preconditions as fully executed after the first time we execute - // the preconditions for onchain events, since that's the "hot path" and we want to - // minimize the overhead of this function in the long run. - eventHandlerPreconditionsFullyExecuted = true; - }); + ); } return await indexingOnchainEventsPromise; diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts new file mode 100644 index 000000000..22f098737 --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts @@ -0,0 +1,105 @@ +import { getUnixTime } from "date-fns"; + +import type { EnsDbReader } from "@ensnode/ensdb-sdk"; +import { + buildCrossChainIndexingStatusSnapshotOmnichain, + buildIndexingMetadataContextInitialized, + type EnsIndexerStackInfo, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, + validateEnsIndexerPublicConfigCompatibility, +} from "@ensnode/ensnode-sdk"; + +import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; +import { logger } from "@/lib/logger"; +import type { StackInfoBuilder } from "@/lib/stack-info-builder/stack-info-builder"; + +function invariant_indexingStatusIsUnstartedForIndexingMetadataContextUninitialized( + inMemoryIndexingStatusSnapshot: OmnichainIndexingStatusSnapshot, +): void { + // Invariant: indexing status must be "unstarted" when the indexing metadata context is uninitialized, + // since we haven't started processing any onchain events yet + if (inMemoryIndexingStatusSnapshot.omnichainStatus !== OmnichainIndexingStatusIds.Unstarted) { + throw new Error( + `Omnichain indexing status must be "unstarted" for "uninitialized" Indexing Metadata Context. Provided omnichain indexing status "${inMemoryIndexingStatusSnapshot.omnichainStatus}".`, + ); + } +} + +function invariant_ensIndexerPublicConfigIsCompatibleWithStackInfo( + storedEnsIndexerStackInfo: EnsIndexerStackInfo, + inMemoryEnsIndexerStackInfo: EnsIndexerStackInfo, +): void { + const { ensIndexer: storedEnsIndexerPublicConfig } = storedEnsIndexerStackInfo; + const { ensIndexer: inMemoryEnsIndexerPublicConfig } = inMemoryEnsIndexerStackInfo; + + validateEnsIndexerPublicConfigCompatibility( + storedEnsIndexerPublicConfig, + inMemoryEnsIndexerPublicConfig, + ); +} + +export class IndexingMetadataContextBuilder { + constructor( + private readonly ensDbClient: EnsDbReader, + private readonly indexingStatusBuilder: IndexingStatusBuilder, + private readonly stackInfoBuilder: StackInfoBuilder, + ) {} + + /** + * Get the current {@link IndexingMetadataContextInitialized} object. + * + * Expected to be called while writing an {@link IndexingMetadataContextInitialized} record into ENSDb + */ + async getIndexingMetadataContext(): Promise { + const [ + inMemoryIndexingStatusSnapshot, + inMemoryEnsIndexerStackInfo, + storedIndexingMetadataContext, + ] = await Promise.all([ + this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(), + this.stackInfoBuilder.getStackInfo(), + this.ensDbClient.getIndexingMetadataContext(), + ]); + + // Build the {@link CrossChainIndexingStatusSnapshot} with the current snapshot time. + // This is important to make sure the `snapshotTime` is always up to date in + // the indexing status snapshot stored within the Indexing Metadata Context record in ENSDb. + const now = getUnixTime(new Date()); + const crossChainIndexingStatusSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain( + inMemoryIndexingStatusSnapshot, + now, + ); + + const inMemoryIndexingMetadataContext = buildIndexingMetadataContextInitialized( + crossChainIndexingStatusSnapshot, + inMemoryEnsIndexerStackInfo, + ); + + if ( + storedIndexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized + ) { + logger.info({ msg: `Indexing Metadata Context is "uninitialized"` }); + + invariant_indexingStatusIsUnstartedForIndexingMetadataContextUninitialized( + inMemoryIndexingStatusSnapshot, + ); + } else { + logger.info({ msg: `Indexing Metadata Context is "initialized"` }); + logger.debug({ + msg: `Indexing Metadata Context`, + indexingStatus: storedIndexingMetadataContext.indexingStatus, + stackInfo: storedIndexingMetadataContext.stackInfo, + }); + + invariant_ensIndexerPublicConfigIsCompatibleWithStackInfo( + storedIndexingMetadataContext.stackInfo, + inMemoryEnsIndexerStackInfo, + ); + } + + return inMemoryIndexingMetadataContext; + } +} diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts new file mode 100644 index 000000000..634c94805 --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts @@ -0,0 +1,13 @@ +import { ensDbClient } from "@/lib/ensdb/singleton"; +import { IndexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/indexing-metadata-context-builder"; +import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { stackInfoBuilder } from "@/lib/stack-info-builder/singleton"; + +/** + * Singleton {@link IndexingMetadataContextBuilder} instance to use across ENSIndexer modules. + */ +export const indexingMetadataContextBuilder = new IndexingMetadataContextBuilder( + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, +); diff --git a/apps/ensindexer/src/lib/stack-info-builder/singleton.ts b/apps/ensindexer/src/lib/stack-info-builder/singleton.ts new file mode 100644 index 000000000..2b924c058 --- /dev/null +++ b/apps/ensindexer/src/lib/stack-info-builder/singleton.ts @@ -0,0 +1,13 @@ +import { ensDbClient } from "@/lib/ensdb/singleton"; +import { ensRainbowClient } from "@/lib/ensrainbow/singleton"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; +import { StackInfoBuilder } from "@/lib/stack-info-builder/stack-info-builder"; + +/** + * Singleton {@link StackInfoBuilder} instance to use across ENSIndexer modules. + */ +export const stackInfoBuilder = new StackInfoBuilder( + ensDbClient, + ensRainbowClient, + publicConfigBuilder, +); diff --git a/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts new file mode 100644 index 000000000..c70c7566d --- /dev/null +++ b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts @@ -0,0 +1,46 @@ +import type { EnsDbReader } from "@ensnode/ensdb-sdk"; +import { buildEnsIndexerStackInfo, type EnsIndexerStackInfo } from "@ensnode/ensnode-sdk"; +import type { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk/client"; + +import type { PublicConfigBuilder } from "@/lib/public-config-builder"; + +export class StackInfoBuilder { + /** + * Immutable {@link EnsIndexerStackInfo} + * + * The cached {@link EnsIndexerStackInfo} object, which is built and validated + * on the first call to `getStackInfo()`, and returned as-is on subsequent calls. + */ + private immutableStackInfo: EnsIndexerStackInfo | undefined; + + constructor( + private readonly ensDbClient: EnsDbReader, + private readonly ensRainbowClient: EnsRainbowApiClient, + private readonly publicConfigBuilder: PublicConfigBuilder, + ) {} + + /** + * Get ENSIndexer Stack Info + * + * Note: ENSIndexer Stack Info is cached after the first call, so + * subsequent calls will return the cached version without rebuilding it. + * + * @throws if the built ENSIndexer Stack Info does not conform to + * the expected schema + */ + async getStackInfo(): Promise { + if (typeof this.immutableStackInfo === "undefined") { + const ensDbPublicConfig = await this.ensDbClient.buildEnsDbPublicConfig(); + const ensIndexerPublicConfig = await this.publicConfigBuilder.getPublicConfig(); + const ensRainbowPublicConfig = await this.ensRainbowClient.config(); + + this.immutableStackInfo = buildEnsIndexerStackInfo( + ensDbPublicConfig, + ensIndexerPublicConfig, + ensRainbowPublicConfig, + ); + } + + return this.immutableStackInfo; + } +} diff --git a/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts index c7b883a59..2ada8c6be 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/deserialize/indexing-metadata-context.ts @@ -65,7 +65,9 @@ export function deserializeIndexingMetadataContext( .safeParse(serializedIndexingMetadataContext); if (parsed.error) { - throw new Error(`Cannot validate IndexingMetadataContext:\n${prettifyError(parsed.error)}\n`); + throw new Error( + `Cannot deserialize IndexingMetadataContext:\n${prettifyError(parsed.error)}\n`, + ); } return parsed.data; } From e1d6d046169d02b2b3d1f18341aeca5ee39b3c45 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 26 Apr 2026 14:48:09 +0200 Subject: [PATCH 18/33] Update unit tests --- .../ensdb-writer-worker.mock.ts | 110 ++++---- .../ensdb-writer-worker.test.ts | 238 +++++++----------- .../indexing-metadata-context-builder.test.ts | 232 +++++++++++++++++ .../stack-info-builder.test.ts | 188 ++++++++++++++ 4 files changed, 572 insertions(+), 196 deletions(-) create mode 100644 apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts create mode 100644 apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.test.ts diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts index 1ab454fff..30b9f4814 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts @@ -2,71 +2,65 @@ import { vi } from "vitest"; import type { EnsDbWriter } from "@ensnode/ensdb-sdk"; import { + ChainIndexingStatusIds, type CrossChainIndexingStatusSnapshot, CrossChainIndexingStrategyIds, - type IndexingMetadataContext, + type IndexingMetadataContextInitialized, IndexingMetadataContextStatusCodes, OmnichainIndexingStatusIds, type OmnichainIndexingStatusSnapshot, + RangeTypeIds, } from "@ensnode/ensnode-sdk"; import { EnsDbWriterWorker } from "@/lib/ensdb-writer-worker/ensdb-writer-worker"; -import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder"; +import type { IndexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/indexing-metadata-context-builder"; -// Test fixture for stack info - minimal valid structure for tests -export const mockStackInfo = { - ensDb: { version: "1.0.0" }, - ensIndexer: { - clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, - }, - ensRainbow: { - serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, - versionInfo: { ensRainbow: "1.0.0" }, - }, -} as any; +// Test fixtures for IndexingMetadataContext objects -// Helper to create mock objects with consistent typing -export function createMockEnsDbWriter( - overrides: Partial> = {}, -): EnsDbWriter { +export function createMockCrossChainSnapshot( + overrides: Partial = {}, +): CrossChainIndexingStatusSnapshot { return { - ...baseEnsDbWriter(), + strategy: CrossChainIndexingStrategyIds.Omnichain, + slowestChainIndexingCursor: 100, + snapshotTime: 200, + omnichainSnapshot: { + omnichainStatus: OmnichainIndexingStatusIds.Following, + omnichainIndexingCursor: 100, + chains: new Map([ + [ + 1, + { + chainStatus: ChainIndexingStatusIds.Following, + latestIndexedBlock: { timestamp: 100, number: 100 }, + latestKnownBlock: { timestamp: 200, number: 200 }, + config: { + rangeType: RangeTypeIds.LeftBounded, + startBlock: { timestamp: 0, number: 0 }, + }, + }, + ], + ]), + }, ...overrides, - } as unknown as EnsDbWriter; -} - -export function baseEnsDbWriter() { - return { - getIndexingMetadataContext: vi.fn().mockResolvedValue(undefined), - upsertIndexingMetadataContext: vi.fn().mockResolvedValue(undefined), }; } export function createMockIndexingMetadataContextInitialized( - overrides: Partial = {}, -): IndexingMetadataContext { + overrides: Partial = {}, +): IndexingMetadataContextInitialized { return { statusCode: IndexingMetadataContextStatusCodes.Initialized, indexingStatus: createMockCrossChainSnapshot(), - stackInfo: mockStackInfo, + stackInfo: { + ensDb: { versionInfo: { postgresql: "17.4" } }, + ensIndexer: {} as any, + ensRainbow: {} as any, + }, ...overrides, }; } -export function createMockIndexingMetadataContextUninitialized(): IndexingMetadataContext { - return { - statusCode: IndexingMetadataContextStatusCodes.Uninitialized, - }; -} - -export function createMockIndexingStatusBuilder( - resolvedSnapshot: OmnichainIndexingStatusSnapshot = createMockOmnichainSnapshot(), -): IndexingStatusBuilder { - return { - getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(resolvedSnapshot), - } as unknown as IndexingStatusBuilder; -} - export function createMockOmnichainSnapshot( overrides: Partial = {}, ): OmnichainIndexingStatusSnapshot { @@ -78,24 +72,32 @@ export function createMockOmnichainSnapshot( }; } -export function createMockCrossChainSnapshot( - overrides: Partial = {}, -): CrossChainIndexingStatusSnapshot { +export function createMockEnsDbWriter( + overrides: Partial> = {}, +): EnsDbWriter { return { - strategy: CrossChainIndexingStrategyIds.Omnichain, - slowestChainIndexingCursor: 100, - snapshotTime: 200, - omnichainSnapshot: createMockOmnichainSnapshot(), + upsertIndexingMetadataContext: vi.fn().mockResolvedValue(undefined), ...overrides, - }; + } as unknown as EnsDbWriter; +} + +export function createMockIndexingMetadataContextBuilder( + resolvedContext: IndexingMetadataContextInitialized = createMockIndexingMetadataContextInitialized(), +): IndexingMetadataContextBuilder { + return { + getIndexingMetadataContext: vi.fn().mockResolvedValue(resolvedContext), + } as unknown as IndexingMetadataContextBuilder; } export function createMockEnsDbWriterWorker( - overrides: { ensDbClient?: EnsDbWriter; indexingStatusBuilder?: IndexingStatusBuilder } = {}, + overrides: { + ensDbClient?: EnsDbWriter; + indexingMetadataContextBuilder?: IndexingMetadataContextBuilder; + } = {}, ) { const ensDbClient = overrides.ensDbClient ?? createMockEnsDbWriter(); - const indexingStatusBuilder = - overrides.indexingStatusBuilder ?? createMockIndexingStatusBuilder(); + const indexingMetadataContextBuilder = + overrides.indexingMetadataContextBuilder ?? createMockIndexingMetadataContextBuilder(); - return new EnsDbWriterWorker(ensDbClient, indexingStatusBuilder); + return new EnsDbWriterWorker(ensDbClient, indexingMetadataContextBuilder); } diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index 2fe5d9714..c7f6e2e98 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -1,35 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - buildCrossChainIndexingStatusSnapshotOmnichain, - buildIndexingMetadataContextInitialized, - type IndexingMetadataContextInitialized, -} from "@ensnode/ensnode-sdk"; - import "@/lib/__test__/mockLogger"; -import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; - import { - createMockCrossChainSnapshot, createMockEnsDbWriter, createMockEnsDbWriterWorker, + createMockIndexingMetadataContextBuilder, createMockIndexingMetadataContextInitialized, - createMockIndexingMetadataContextUninitialized, - createMockIndexingStatusBuilder, - createMockOmnichainSnapshot, } from "./ensdb-writer-worker.mock"; -vi.mock("@ensnode/ensnode-sdk", async () => { - const actual = await vi.importActual("@ensnode/ensnode-sdk"); - - return { - ...actual, - buildCrossChainIndexingStatusSnapshotOmnichain: vi.fn(), - buildIndexingMetadataContextInitialized: vi.fn(), - }; -}); - describe("EnsDbWriterWorker", () => { beforeEach(() => { vi.useFakeTimers(); @@ -41,23 +20,15 @@ describe("EnsDbWriterWorker", () => { }); describe("run() - worker initialization", () => { - it("starts the interval for upserting indexing metadata context", async () => { + it("starts the interval for updating indexing metadata context", async () => { // arrange - const omnichainSnapshot = createMockOmnichainSnapshot(); - const crossChainSnapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); - const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); - - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); - vi.mocked(buildIndexingMetadataContextInitialized).mockReturnValue( - indexingMetadataContext as IndexingMetadataContextInitialized, - ); + const context = createMockIndexingMetadataContextInitialized(); + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(context); - const ensDbClient = createMockEnsDbWriter({ - getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), - }); + const ensDbClient = createMockEnsDbWriter(); const worker = createMockEnsDbWriterWorker({ ensDbClient, - indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshot), + indexingMetadataContextBuilder, }); // act @@ -66,19 +37,9 @@ describe("EnsDbWriterWorker", () => { // advance time to trigger interval await vi.advanceTimersByTimeAsync(1000); - // assert - snapshot should be upserted via upsertIndexingMetadataContext - expect(ensDbClient.getIndexingMetadataContext).toHaveBeenCalled(); - expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( - omnichainSnapshot, - expect.any(Number), - ); - expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( - crossChainSnapshot, - (indexingMetadataContext as IndexingMetadataContextInitialized).stackInfo, - ); - expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith( - indexingMetadataContext, - ); + // assert - worker delegates to indexingMetadataContextBuilder + expect(indexingMetadataContextBuilder.getIndexingMetadataContext).toHaveBeenCalled(); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith(context); // cleanup worker.stop(); @@ -102,12 +63,8 @@ describe("EnsDbWriterWorker", () => { describe("stop() - worker termination", () => { it("stops the interval when stop() is called", async () => { // arrange - const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); const upsertIndexingMetadataContext = vi.fn().mockResolvedValue(undefined); - const ensDbClient = createMockEnsDbWriter({ - getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), - upsertIndexingMetadataContext, - }); + const ensDbClient = createMockEnsDbWriter({ upsertIndexingMetadataContext }); const worker = createMockEnsDbWriterWorker({ ensDbClient }); // act @@ -148,72 +105,97 @@ describe("EnsDbWriterWorker", () => { }); }); - describe("interval behavior - upsertIndexingMetadataContext", () => { - it("throws when indexing metadata context is uninitialized", async () => { + describe("interval behavior - updateIndexingMetadataContext", () => { + it("calls getIndexingMetadataContext and upserts on each tick", async () => { // arrange - const indexingMetadataContext = createMockIndexingMetadataContextUninitialized(); - const ensDbClient = createMockEnsDbWriter({ - getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), + const context1 = createMockIndexingMetadataContextInitialized(); + const context2 = createMockIndexingMetadataContextInitialized(); + + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(context1); + (indexingMetadataContextBuilder.getIndexingMetadataContext as any) + .mockResolvedValueOnce(context1) + .mockResolvedValueOnce(context2); + + const ensDbClient = createMockEnsDbWriter(); + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + indexingMetadataContextBuilder, }); - const worker = createMockEnsDbWriterWorker({ ensDbClient }); // act await worker.run(); - // first interval tick - should error but not throw (error is caught and logged) + // first tick await vi.advanceTimersByTimeAsync(1000); + expect(indexingMetadataContextBuilder.getIndexingMetadataContext).toHaveBeenCalledTimes(1); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith(context1); - // assert - get should be called but upsert should not (due to error) - expect(ensDbClient.getIndexingMetadataContext).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertIndexingMetadataContext).not.toHaveBeenCalled(); + // second tick + await vi.advanceTimersByTimeAsync(1000); + expect(indexingMetadataContextBuilder.getIndexingMetadataContext).toHaveBeenCalledTimes(2); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith(context2); // cleanup worker.stop(); }); - it("recovers from errors and continues upserting", async () => { + it("recovers from getIndexingMetadataContext errors between ticks", async () => { // arrange - const omnichainSnapshot1 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 100 }); - const omnichainSnapshot2 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 200 }); - - const crossChainSnapshot1 = createMockCrossChainSnapshot({ - slowestChainIndexingCursor: 100, - snapshotTime: 1000, - omnichainSnapshot: omnichainSnapshot1, - }); - const crossChainSnapshot2 = createMockCrossChainSnapshot({ - slowestChainIndexingCursor: 200, - snapshotTime: 2000, - omnichainSnapshot: omnichainSnapshot2, + const context = createMockIndexingMetadataContextInitialized(); + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(context); + (indexingMetadataContextBuilder.getIndexingMetadataContext as any) + .mockResolvedValueOnce(context) + .mockRejectedValueOnce(new Error("Builder error")) + .mockResolvedValueOnce(context); + + const ensDbClient = createMockEnsDbWriter(); + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + indexingMetadataContextBuilder, }); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain) - .mockReturnValueOnce(crossChainSnapshot1) - .mockReturnValueOnce(crossChainSnapshot2) - .mockReturnValueOnce(crossChainSnapshot2); + // act + await worker.run(); + + // first tick - succeeds + await vi.advanceTimersByTimeAsync(1000); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); + + // second tick - builder error, the catch block rethrows + // setInterval keeps running despite the unhandled rejection + const handler = vi.fn(); + process.on("unhandledRejection", handler); + await vi.advanceTimersByTimeAsync(1000); + process.removeListener("unhandledRejection", handler); + expect(handler).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); // no new upsert + + // third tick - succeeds again + await vi.advanceTimersByTimeAsync(1000); + expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(2); + + // cleanup + worker.stop(); + }); - const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); - vi.mocked(buildIndexingMetadataContextInitialized) - .mockReturnValueOnce(indexingMetadataContext as IndexingMetadataContextInitialized) - .mockReturnValueOnce(indexingMetadataContext as IndexingMetadataContextInitialized) - .mockReturnValueOnce(indexingMetadataContext as IndexingMetadataContextInitialized); + it("recovers from upsertIndexingMetadataContext errors between ticks", async () => { + // arrange + const context = createMockIndexingMetadataContextInitialized(); + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(context); const ensDbClient = createMockEnsDbWriter({ - getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), upsertIndexingMetadataContext: vi .fn() .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error("DB error")) .mockResolvedValueOnce(undefined), }); - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi - .fn() - .mockResolvedValueOnce(omnichainSnapshot1) - .mockResolvedValueOnce(omnichainSnapshot2) - .mockResolvedValueOnce(omnichainSnapshot2), - } as unknown as IndexingStatusBuilder; - const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder }); + + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + indexingMetadataContextBuilder, + }); // act await worker.run(); @@ -222,8 +204,13 @@ describe("EnsDbWriterWorker", () => { await vi.advanceTimersByTimeAsync(1000); expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); - // second tick - fails with DB error, but continues + // second tick - DB error, the catch block rethrows + const handler = vi.fn(); + process.on("unhandledRejection", handler); await vi.advanceTimersByTimeAsync(1000); + process.removeListener("unhandledRejection", handler); + expect(handler).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(2); // third tick - succeeds again @@ -234,63 +221,30 @@ describe("EnsDbWriterWorker", () => { worker.stop(); }); - it("builds cross-chain snapshot with correct parameters", async () => { + it("sets process.exitCode on error", async () => { // arrange - const omnichainSnapshot = createMockOmnichainSnapshot({ - omnichainIndexingCursor: 500, - }); - const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); - const ensDbClient = createMockEnsDbWriter({ - getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), - }); - const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshot); - const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder }); - - // act - await worker.run(); - await vi.advanceTimersByTimeAsync(1000); - - // assert - expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( - omnichainSnapshot, - expect.any(Number), + const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(); + (indexingMetadataContextBuilder.getIndexingMetadataContext as any).mockRejectedValue( + new Error("Fatal error"), ); - // cleanup - worker.stop(); - }); - - it("calls upsertIndexingMetadataContext with built context", async () => { - // arrange - const omnichainSnapshot = createMockOmnichainSnapshot(); - const crossChainSnapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); - const indexingMetadataContext = createMockIndexingMetadataContextInitialized(); + const worker = createMockEnsDbWriterWorker({ indexingMetadataContextBuilder }); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); - vi.mocked(buildIndexingMetadataContextInitialized).mockReturnValue( - indexingMetadataContext as IndexingMetadataContextInitialized, - ); + // reset exitCode before test + process.exitCode = undefined; - const ensDbClient = createMockEnsDbWriter({ - getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContext), - }); - const worker = createMockEnsDbWriterWorker({ - ensDbClient, - indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshot), - }); + // act - suppress unhandled rejection from the setInterval callback + const handler = vi.fn(); + process.on("unhandledRejection", handler); - // act await worker.run(); await vi.advanceTimersByTimeAsync(1000); + process.removeListener("unhandledRejection", handler); + // assert - expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( - crossChainSnapshot, - (indexingMetadataContext as IndexingMetadataContextInitialized).stackInfo, - ); - expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledWith( - indexingMetadataContext, - ); + expect(handler).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); // cleanup worker.stop(); diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts new file mode 100644 index 000000000..cf2cf6dce --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EnsDbReader } from "@ensnode/ensdb-sdk"; +import { + buildCrossChainIndexingStatusSnapshotOmnichain, + buildIndexingMetadataContextInitialized, + type CrossChainIndexingStatusSnapshot, + type EnsIndexerStackInfo, + type IndexingMetadataContext, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, + validateEnsIndexerPublicConfigCompatibility, +} from "@ensnode/ensnode-sdk"; + +import "@/lib/__test__/mockLogger"; + +import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; +import type { StackInfoBuilder } from "@/lib/stack-info-builder/stack-info-builder"; + +import { IndexingMetadataContextBuilder } from "./indexing-metadata-context-builder"; + +vi.mock("@ensnode/ensnode-sdk", async () => { + const actual = await vi.importActual("@ensnode/ensnode-sdk"); + + return { + ...actual, + buildCrossChainIndexingStatusSnapshotOmnichain: vi.fn(), + buildIndexingMetadataContextInitialized: vi.fn(), + validateEnsIndexerPublicConfigCompatibility: vi.fn(), + }; +}); + +const omnichainSnapshotUnstarted: OmnichainIndexingStatusSnapshot = { + omnichainStatus: OmnichainIndexingStatusIds.Unstarted, + omnichainIndexingCursor: 0, + chains: new Map(), +}; + +const omnichainSnapshotFollowing: OmnichainIndexingStatusSnapshot = { + omnichainStatus: OmnichainIndexingStatusIds.Following, + omnichainIndexingCursor: 100, + chains: new Map(), +}; + +const crossChainSnapshot: CrossChainIndexingStatusSnapshot = { + strategy: "omnichain" as any, + slowestChainIndexingCursor: 100, + snapshotTime: 200, + omnichainSnapshot: omnichainSnapshotFollowing, +}; + +const stackInfo: EnsIndexerStackInfo = { + ensDb: { versionInfo: { postgresql: "17.4" } }, + ensIndexer: {} as any, + ensRainbow: {} as any, +}; + +const indexingMetadataContextInitialized: IndexingMetadataContextInitialized = { + statusCode: IndexingMetadataContextStatusCodes.Initialized, + indexingStatus: crossChainSnapshot, + stackInfo, +}; + +const indexingMetadataContextUninitialized: IndexingMetadataContext = { + statusCode: IndexingMetadataContextStatusCodes.Uninitialized, +}; + +function createMockEnsDbReader( + overrides: Partial> = {}, +): EnsDbReader { + return { + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextUninitialized), + ...overrides, + } as unknown as EnsDbReader; +} + +function createMockIndexingStatusBuilder( + resolvedSnapshot: OmnichainIndexingStatusSnapshot = omnichainSnapshotUnstarted, +): IndexingStatusBuilder { + return { + getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(resolvedSnapshot), + } as unknown as IndexingStatusBuilder; +} + +function createMockStackInfoBuilder( + resolvedStackInfo: EnsIndexerStackInfo = stackInfo, +): StackInfoBuilder { + return { + getStackInfo: vi.fn().mockResolvedValue(resolvedStackInfo), + } as unknown as StackInfoBuilder; +} + +describe("IndexingMetadataContextBuilder", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); + vi.mocked(buildIndexingMetadataContextInitialized).mockReturnValue( + indexingMetadataContextInitialized as IndexingMetadataContextInitialized, + ); + }); + + describe("getIndexingMetadataContext()", () => { + describe("when stored context is Uninitialized", () => { + it("builds and returns initialized context with fresh snapshot time", async () => { + const ensDbClient = createMockEnsDbReader(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotUnstarted); + const stackInfoBuilder = createMockStackInfoBuilder(); + + const builder = new IndexingMetadataContextBuilder( + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, + ); + const result = await builder.getIndexingMetadataContext(); + + expect(ensDbClient.getIndexingMetadataContext).toHaveBeenCalledOnce(); + expect(indexingStatusBuilder.getOmnichainIndexingStatusSnapshot).toHaveBeenCalledOnce(); + expect(stackInfoBuilder.getStackInfo).toHaveBeenCalledOnce(); + expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( + omnichainSnapshotUnstarted, + expect.any(Number), + ); + expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( + crossChainSnapshot, + stackInfo, + ); + expect(result).toBe(indexingMetadataContextInitialized); + }); + + it("throws when indexing status is not unstarted", async () => { + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotFollowing); + + const builder = new IndexingMetadataContextBuilder( + createMockEnsDbReader(), + indexingStatusBuilder, + createMockStackInfoBuilder(), + ); + + await expect(builder.getIndexingMetadataContext()).rejects.toThrow( + /Omnichain indexing status must be "unstarted"/, + ); + }); + }); + + describe("when stored context is Initialized", () => { + it("builds and returns initialized context after validating compatibility", async () => { + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), + }); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotFollowing); + const stackInfoBuilder = createMockStackInfoBuilder(); + + const builder = new IndexingMetadataContextBuilder( + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, + ); + const result = await builder.getIndexingMetadataContext(); + + expect(validateEnsIndexerPublicConfigCompatibility).toHaveBeenCalledWith( + (indexingMetadataContextInitialized as IndexingMetadataContextInitialized).stackInfo + .ensIndexer, + stackInfo.ensIndexer, + ); + expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( + crossChainSnapshot, + stackInfo, + ); + expect(result).toBe(indexingMetadataContextInitialized); + }); + + it("throws when stored and in-memory configs are incompatible", async () => { + vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { + throw new Error("Incompatible ENSIndexer config"); + }); + + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), + }); + + const builder = new IndexingMetadataContextBuilder( + ensDbClient, + createMockIndexingStatusBuilder(omnichainSnapshotFollowing), + createMockStackInfoBuilder(), + ); + + await expect(builder.getIndexingMetadataContext()).rejects.toThrow( + "Incompatible ENSIndexer config", + ); + }); + }); + + it("fetches all three data sources in parallel", async () => { + const resolveOrder: string[] = []; + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 10)); + resolveOrder.push("ensDb"); + return indexingMetadataContextUninitialized; + }), + }); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotUnstarted); + (indexingStatusBuilder.getOmnichainIndexingStatusSnapshot as any) = vi + .fn() + .mockImplementation(async () => { + resolveOrder.push("indexingStatus"); + return omnichainSnapshotUnstarted; + }); + const stackInfoBuilder = createMockStackInfoBuilder(); + (stackInfoBuilder.getStackInfo as any) = vi.fn().mockImplementation(async () => { + resolveOrder.push("stackInfo"); + return stackInfo; + }); + + const builder = new IndexingMetadataContextBuilder( + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, + ); + await builder.getIndexingMetadataContext(); + + // All three should have been called (ordering is not deterministic for parallel) + expect(resolveOrder).toHaveLength(3); + expect(resolveOrder).toContain("ensDb"); + expect(resolveOrder).toContain("indexingStatus"); + expect(resolveOrder).toContain("stackInfo"); + }); + }); +}); diff --git a/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.test.ts b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.test.ts new file mode 100644 index 000000000..259233d19 --- /dev/null +++ b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EnsDbReader } from "@ensnode/ensdb-sdk"; +import { + buildEnsIndexerStackInfo, + type EnsIndexerPublicConfig, + type EnsIndexerStackInfo, +} from "@ensnode/ensnode-sdk"; +import type { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk/client"; + +import type { PublicConfigBuilder } from "@/lib/public-config-builder"; + +import { StackInfoBuilder } from "./stack-info-builder"; + +vi.mock("@ensnode/ensnode-sdk", async () => { + const actual = await vi.importActual("@ensnode/ensnode-sdk"); + + return { + ...actual, + buildEnsIndexerStackInfo: vi.fn(), + }; +}); + +const mockEnsDbPublicConfig = { + versionInfo: { postgresql: "17.4" }, +}; + +const mockEnsIndexerPublicConfig = { + ensIndexerSchemaName: "ensindexer_0", + ensRainbowPublicConfig: { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.9.0" }, + }, + clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, + indexedChainIds: new Set([1]), + isSubgraphCompatible: true, + namespace: "mainnet", + plugins: [], + versionInfo: { + ponder: "0.11.0", + ensDb: "1.0.0", + ensIndexer: "1.0.0", + ensNormalize: "1.0.0", + }, +} satisfies EnsIndexerPublicConfig; + +const mockEnsRainbowPublicConfig = { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.9.0" }, +}; + +const mockStackInfo = { + ensDb: mockEnsDbPublicConfig, + ensIndexer: mockEnsIndexerPublicConfig, + ensRainbow: mockEnsRainbowPublicConfig, +} satisfies EnsIndexerStackInfo; + +function createMockEnsDbReader( + overrides: Partial> = {}, +): EnsDbReader { + return { + buildEnsDbPublicConfig: vi.fn().mockResolvedValue(mockEnsDbPublicConfig), + ...overrides, + } as unknown as EnsDbReader; +} + +function createMockEnsRainbowClient( + overrides: Partial> = {}, +): EnsRainbowApiClient { + return { + config: vi.fn().mockResolvedValue(mockEnsRainbowPublicConfig), + ...overrides, + } as unknown as EnsRainbowApiClient; +} + +function createMockPublicConfigBuilder( + overrides: Partial> = {}, +): PublicConfigBuilder { + return { + getPublicConfig: vi.fn().mockResolvedValue(mockEnsIndexerPublicConfig), + ...overrides, + } as unknown as PublicConfigBuilder; +} + +describe("StackInfoBuilder", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getStackInfo()", () => { + it("builds stack info from ensDb, ensIndexer, and ensRainbow public configs", async () => { + vi.mocked(buildEnsIndexerStackInfo).mockReturnValue(mockStackInfo); + + const ensDbClient = createMockEnsDbReader(); + const ensRainbowClient = createMockEnsRainbowClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + + const builder = new StackInfoBuilder(ensDbClient, ensRainbowClient, publicConfigBuilder); + const result = await builder.getStackInfo(); + + expect(ensDbClient.buildEnsDbPublicConfig).toHaveBeenCalledOnce(); + expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledOnce(); + expect(ensRainbowClient.config).toHaveBeenCalledOnce(); + expect(buildEnsIndexerStackInfo).toHaveBeenCalledWith( + mockEnsDbPublicConfig, + mockEnsIndexerPublicConfig, + mockEnsRainbowPublicConfig, + ); + expect(result).toBe(mockStackInfo); + }); + + it("caches stack info and returns the same value on subsequent calls", async () => { + vi.mocked(buildEnsIndexerStackInfo).mockReturnValue(mockStackInfo); + + const ensDbClient = createMockEnsDbReader(); + const ensRainbowClient = createMockEnsRainbowClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + + const builder = new StackInfoBuilder(ensDbClient, ensRainbowClient, publicConfigBuilder); + + const result1 = await builder.getStackInfo(); + const result2 = await builder.getStackInfo(); + + expect(result1).toBe(result2); + // Underlying dependencies should only be called once due to caching + expect(ensDbClient.buildEnsDbPublicConfig).toHaveBeenCalledOnce(); + expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledOnce(); + expect(ensRainbowClient.config).toHaveBeenCalledOnce(); + expect(buildEnsIndexerStackInfo).toHaveBeenCalledOnce(); + }); + + it("throws when buildEnsIndexerStackInfo throws", async () => { + vi.mocked(buildEnsIndexerStackInfo).mockImplementation(() => { + throw new Error("Stack info validation failed"); + }); + + const builder = new StackInfoBuilder( + createMockEnsDbReader(), + createMockEnsRainbowClient(), + createMockPublicConfigBuilder(), + ); + + await expect(builder.getStackInfo()).rejects.toThrow("Stack info validation failed"); + }); + + it("propagates errors from ensDb client", async () => { + const ensDbClient = createMockEnsDbReader({ + buildEnsDbPublicConfig: vi.fn().mockRejectedValue(new Error("ENSDB connection failed")), + }); + + const builder = new StackInfoBuilder( + ensDbClient, + createMockEnsRainbowClient(), + createMockPublicConfigBuilder(), + ); + + await expect(builder.getStackInfo()).rejects.toThrow("ENSDB connection failed"); + }); + + it("propagates errors from ensRainbow client", async () => { + const ensRainbowClient = createMockEnsRainbowClient({ + config: vi.fn().mockRejectedValue(new Error("ENSRainbow not available")), + }); + + const builder = new StackInfoBuilder( + createMockEnsDbReader(), + ensRainbowClient, + createMockPublicConfigBuilder(), + ); + + await expect(builder.getStackInfo()).rejects.toThrow("ENSRainbow not available"); + }); + + it("propagates errors from public config builder", async () => { + const publicConfigBuilder = createMockPublicConfigBuilder({ + getPublicConfig: vi.fn().mockRejectedValue(new Error("Config retrieval failed")), + }); + + const builder = new StackInfoBuilder( + createMockEnsDbReader(), + createMockEnsRainbowClient(), + publicConfigBuilder, + ); + + await expect(builder.getStackInfo()).rejects.toThrow("Config retrieval failed"); + }); + }); +}); From 98e6c45fec63317a48551c51da7254b718aa0d29 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 26 Apr 2026 15:05:57 +0200 Subject: [PATCH 19/33] Simplify `initIndexingOnchainEvents` function --- .../init-indexing-onchain-events.ts | 36 ++++--- .../indexing-metadata-context-builder.test.ts | 98 ++++++++++++++++--- .../indexing-metadata-context-builder.ts | 18 +++- .../singleton.ts | 2 + .../ensdb-sdk/src/client/ensnode-metadata.ts | 5 +- 5 files changed, 123 insertions(+), 36 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts index 8081cb5e8..bfe6e2bfc 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -34,6 +34,25 @@ import { import { indexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/singleton"; import { logger } from "@/lib/logger"; +async function upsertIndexingMetadataContextRecord(): Promise { + const indexingMetadataContext = await indexingMetadataContextBuilder.getIndexingMetadataContext(); + + logger.info({ + msg: `Upserting Indexing Metadata Context Initialized`, + }); + logger.debug({ + msg: `Indexing Metadata Context`, + indexingStatus: indexingMetadataContext.indexingStatus, + stackInfo: indexingMetadataContext.stackInfo, + }); + + await ensDbClient.upsertIndexingMetadataContext(indexingMetadataContext); + + logger.info({ + msg: `Successfully upserted Indexing Metadata Context Initialized`, + }); +} + /** * Prepare for executing the "onchain" event handlers. * @@ -74,21 +93,8 @@ export async function initIndexingOnchainEvents(): Promise { // by the time ENSIndexer instance executes `initIndexingOnchainEvents`. await waitForEnsRainbowToBeHealthy(); - const indexingMetadataContext = - await indexingMetadataContextBuilder.getIndexingMetadataContext(); - - logger.info({ - msg: `Upserting Indexing Metadata Context Initialized`, - }); - logger.debug({ - msg: `Indexing Metadata Context`, - indexingStatus: indexingMetadataContext.indexingStatus, - stackInfo: indexingMetadataContext.stackInfo, - }); - await ensDbClient.upsertIndexingMetadataContext(indexingMetadataContext); - logger.info({ - msg: `Successfully upserted Indexing Metadata Context Initialized`, - }); + // Upsert the Indexing Metadata Context record into ENSDb + await upsertIndexingMetadataContextRecord(); // Before starting to process onchain events, we want to make sure that // ENSRainbow is ready to serve the "heal" requests. diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts index cf2cf6dce..d8bed80c7 100644 --- a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts @@ -13,6 +13,7 @@ import { type OmnichainIndexingStatusSnapshot, validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; +import type { LocalPonderClient } from "@ensnode/ponder-sdk"; import "@/lib/__test__/mockLogger"; @@ -92,6 +93,28 @@ function createMockStackInfoBuilder( } as unknown as StackInfoBuilder; } +function createMockLocalPonderClient(options: { isInDevMode?: boolean } = {}): LocalPonderClient { + return { + isInDevMode: options.isInDevMode ?? false, + } as unknown as LocalPonderClient; +} + +function createIndexingMetadataContextBuilder( + overrides: { + ensDbClient?: EnsDbReader; + indexingStatusBuilder?: IndexingStatusBuilder; + stackInfoBuilder?: StackInfoBuilder; + localPonderClient?: LocalPonderClient; + } = {}, +): IndexingMetadataContextBuilder { + return new IndexingMetadataContextBuilder( + overrides.ensDbClient ?? createMockEnsDbReader(), + overrides.indexingStatusBuilder ?? createMockIndexingStatusBuilder(), + overrides.stackInfoBuilder ?? createMockStackInfoBuilder(), + overrides.localPonderClient ?? createMockLocalPonderClient(), + ); +} + describe("IndexingMetadataContextBuilder", () => { beforeEach(() => { vi.clearAllMocks(); @@ -109,11 +132,11 @@ describe("IndexingMetadataContextBuilder", () => { const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotUnstarted); const stackInfoBuilder = createMockStackInfoBuilder(); - const builder = new IndexingMetadataContextBuilder( + const builder = createIndexingMetadataContextBuilder({ ensDbClient, indexingStatusBuilder, stackInfoBuilder, - ); + }); const result = await builder.getIndexingMetadataContext(); expect(ensDbClient.getIndexingMetadataContext).toHaveBeenCalledOnce(); @@ -133,11 +156,9 @@ describe("IndexingMetadataContextBuilder", () => { it("throws when indexing status is not unstarted", async () => { const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotFollowing); - const builder = new IndexingMetadataContextBuilder( - createMockEnsDbReader(), + const builder = createIndexingMetadataContextBuilder({ indexingStatusBuilder, - createMockStackInfoBuilder(), - ); + }); await expect(builder.getIndexingMetadataContext()).rejects.toThrow( /Omnichain indexing status must be "unstarted"/, @@ -146,18 +167,20 @@ describe("IndexingMetadataContextBuilder", () => { }); describe("when stored context is Initialized", () => { - it("builds and returns initialized context after validating compatibility", async () => { + it("validates compatibility when not in dev mode", async () => { const ensDbClient = createMockEnsDbReader({ getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), }); const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotFollowing); const stackInfoBuilder = createMockStackInfoBuilder(); + const localPonderClient = createMockLocalPonderClient({ isInDevMode: false }); - const builder = new IndexingMetadataContextBuilder( + const builder = createIndexingMetadataContextBuilder({ ensDbClient, indexingStatusBuilder, stackInfoBuilder, - ); + localPonderClient, + }); const result = await builder.getIndexingMetadataContext(); expect(validateEnsIndexerPublicConfigCompatibility).toHaveBeenCalledWith( @@ -172,7 +195,32 @@ describe("IndexingMetadataContextBuilder", () => { expect(result).toBe(indexingMetadataContextInitialized); }); - it("throws when stored and in-memory configs are incompatible", async () => { + it("skips compatibility validation when in dev mode", async () => { + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), + }); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshotFollowing); + const stackInfoBuilder = createMockStackInfoBuilder(); + const localPonderClient = createMockLocalPonderClient({ isInDevMode: true }); + + const builder = createIndexingMetadataContextBuilder({ + ensDbClient, + indexingStatusBuilder, + stackInfoBuilder, + localPonderClient, + }); + const result = await builder.getIndexingMetadataContext(); + + // Compatibility validation should NOT be called in dev mode + expect(validateEnsIndexerPublicConfigCompatibility).not.toHaveBeenCalled(); + expect(buildIndexingMetadataContextInitialized).toHaveBeenCalledWith( + crossChainSnapshot, + stackInfo, + ); + expect(result).toBe(indexingMetadataContextInitialized); + }); + + it("throws when stored and in-memory configs are incompatible (not in dev mode)", async () => { vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { throw new Error("Incompatible ENSIndexer config"); }); @@ -181,16 +229,34 @@ describe("IndexingMetadataContextBuilder", () => { getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), }); - const builder = new IndexingMetadataContextBuilder( + const builder = createIndexingMetadataContextBuilder({ ensDbClient, - createMockIndexingStatusBuilder(omnichainSnapshotFollowing), - createMockStackInfoBuilder(), - ); + indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshotFollowing), + localPonderClient: createMockLocalPonderClient({ isInDevMode: false }), + }); await expect(builder.getIndexingMetadataContext()).rejects.toThrow( "Incompatible ENSIndexer config", ); }); + + it("does not throw on incompatible configs when in dev mode", async () => { + vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { + throw new Error("Incompatible ENSIndexer config"); + }); + + const ensDbClient = createMockEnsDbReader({ + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextInitialized), + }); + + const builder = createIndexingMetadataContextBuilder({ + ensDbClient, + indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshotFollowing), + localPonderClient: createMockLocalPonderClient({ isInDevMode: true }), + }); + + await expect(builder.getIndexingMetadataContext()).resolves.toBeDefined(); + }); }); it("fetches all three data sources in parallel", async () => { @@ -215,11 +281,11 @@ describe("IndexingMetadataContextBuilder", () => { return stackInfo; }); - const builder = new IndexingMetadataContextBuilder( + const builder = createIndexingMetadataContextBuilder({ ensDbClient, indexingStatusBuilder, stackInfoBuilder, - ); + }); await builder.getIndexingMetadataContext(); // All three should have been called (ordering is not deterministic for parallel) diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts index 22f098737..1a4254f80 100644 --- a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts @@ -11,6 +11,7 @@ import { type OmnichainIndexingStatusSnapshot, validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; +import type { LocalPonderClient } from "@ensnode/ponder-sdk"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; import { logger } from "@/lib/logger"; @@ -46,6 +47,7 @@ export class IndexingMetadataContextBuilder { private readonly ensDbClient: EnsDbReader, private readonly indexingStatusBuilder: IndexingStatusBuilder, private readonly stackInfoBuilder: StackInfoBuilder, + private readonly localPonderClient: LocalPonderClient, ) {} /** @@ -94,10 +96,18 @@ export class IndexingMetadataContextBuilder { stackInfo: storedIndexingMetadataContext.stackInfo, }); - invariant_ensIndexerPublicConfigIsCompatibleWithStackInfo( - storedIndexingMetadataContext.stackInfo, - inMemoryEnsIndexerStackInfo, - ); + // Validate in-memory config object compatibility with the stored one, + // if the stored one is available. + // The validation is skipped if the local Ponder app is running in dev mode. + // This is to improve the development experience during ENSIndexer + // development, by allowing to override the stored config in ENSDb with + // the current in-memory config, without having to keep them compatible. + if (!this.localPonderClient.isInDevMode) { + invariant_ensIndexerPublicConfigIsCompatibleWithStackInfo( + storedIndexingMetadataContext.stackInfo, + inMemoryEnsIndexerStackInfo, + ); + } } return inMemoryIndexingMetadataContext; diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts index 634c94805..31299cd55 100644 --- a/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/singleton.ts @@ -1,6 +1,7 @@ import { ensDbClient } from "@/lib/ensdb/singleton"; import { IndexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/indexing-metadata-context-builder"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { localPonderClient } from "@/lib/local-ponder-client"; import { stackInfoBuilder } from "@/lib/stack-info-builder/singleton"; /** @@ -10,4 +11,5 @@ export const indexingMetadataContextBuilder = new IndexingMetadataContextBuilder ensDbClient, indexingStatusBuilder, stackInfoBuilder, + localPonderClient, ); diff --git a/packages/ensdb-sdk/src/client/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/ensnode-metadata.ts index eb7565a0e..9b0a1a2d5 100644 --- a/packages/ensdb-sdk/src/client/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/ensnode-metadata.ts @@ -23,6 +23,9 @@ export interface EnsNodeMetadataIndexingMetadataContext { /** * ENSNode Metadata * - * Union type gathering all variants of ENSNode Metadata. + * Type alias for ENSNode Metadata records, + * currently only includes the record for Indexing Metadata Context, + * but can be extended in the future to include more types of + * ENSNode Metadata records as needed. */ export type EnsNodeMetadata = EnsNodeMetadataIndexingMetadataContext; From eebe38629179a67c1ddd13260642642e9d7c173a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 26 Apr 2026 17:27:00 +0200 Subject: [PATCH 20/33] Simplify logic in ENSIndexer HTTP endpoints --- .../ponder/src/api/handlers/ensnode-api.ts | 39 ++++++------------- .../public-config-builder.ts | 8 ++-- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts index f65817e08..463deec10 100644 --- a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts +++ b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts @@ -2,55 +2,40 @@ import { getUnixTime } from "date-fns"; import { Hono } from "hono"; import { + buildCrossChainIndexingStatusSnapshotOmnichain, createRealtimeIndexingStatusProjection, EnsIndexerIndexingStatusResponseCodes, type EnsIndexerIndexingStatusResponseError, type EnsIndexerIndexingStatusResponseOk, - IndexingMetadataContextStatusCodes, serializeEnsIndexerIndexingStatusResponse, serializeEnsIndexerPublicConfig, } from "@ensnode/ensnode-sdk"; -import { ensDbClient } from "@/lib/ensdb/singleton"; +import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; import { logger } from "@/lib/logger"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; const app = new Hono(); -// include ENSIndexer Public Config endpoint app.get("/config", async (c) => { - const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); - - // Invariant: the public config is guaranteed to be available in ENSDb after - // application startup. - if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) { - throw new Error("Unreachable: ENSIndexer Public Config is not available in ENSDb"); - } + const ensIndexerPublicConfig = await publicConfigBuilder.getPublicConfig(); // respond with the serialized public config object - return c.json(serializeEnsIndexerPublicConfig(indexingMetadataContext.stackInfo.ensIndexer)); + return c.json(serializeEnsIndexerPublicConfig(ensIndexerPublicConfig)); }); app.get("/indexing-status", async (c) => { try { - const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); + const omnichainSnapshot = await indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); - // 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 (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, - projectedAt, + const now = getUnixTime(new Date()); + const crossChainSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain( + omnichainSnapshot, + now, ); + const realtimeProjection = createRealtimeIndexingStatusProjection(crossChainSnapshot, now); + return c.json( serializeEnsIndexerIndexingStatusResponse({ responseCode: EnsIndexerIndexingStatusResponseCodes.Ok, diff --git a/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts index 7229af036..c5dc7d184 100644 --- a/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts +++ b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts @@ -37,10 +37,12 @@ export class PublicConfigBuilder { /** * Get ENSIndexer Public Config * - * Note: ENSIndexer Public Config is cached after the first call, so - * subsequent calls will return the cached version without rebuilding it. + * Note: The {@link EnsIndexerPublicConfig} object is immutable for + * the whole ENSIndexer instance lifecycle. Therefore, the result of + * the first {@link getPublicConfig} call is cached and returned for + * subsequent calls. * - * @throws if the built ENSIndexer Public Config does not conform to + * @throws if the built {@link EnsIndexerPublicConfig} does not conform to * the expected schema */ async getPublicConfig(): Promise { From 11711ca00224de70efd38da6c529dd0138ff1611 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 26 Apr 2026 17:38:34 +0200 Subject: [PATCH 21/33] Improve naming --- apps/ensindexer/src/lib/indexing-engines/ponder.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 1726860d2..e9fb4b46d 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -127,7 +127,7 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { } } -let indexingOnchainEventsPromise: Promise | null = null; +let initIndexingOnchainEventsPromise: Promise | null = null; /** * Execute any necessary preconditions before running an event handler @@ -154,12 +154,12 @@ async function eventHandlerPreconditions(eventType: EventTypeId): Promise } case EventTypeIds.OnchainEvent: { - if (indexingOnchainEventsPromise === null) { + if (initIndexingOnchainEventsPromise === null) { // We need to work around the Ponder limitation for importing modules, // since Ponder would not allow us to use static imports for modules // that internally rely on `ponder:api`. Using dynamic imports solves // this issue. - indexingOnchainEventsPromise = import("./init-indexing-onchain-events").then( + initIndexingOnchainEventsPromise = import("./init-indexing-onchain-events").then( ({ initIndexingOnchainEvents }) => // Init the indexing of "onchain" events just once in order to // optimize the indexing "hot path", since these events are much @@ -168,7 +168,7 @@ async function eventHandlerPreconditions(eventType: EventTypeId): Promise ); } - return await indexingOnchainEventsPromise; + return await initIndexingOnchainEventsPromise; } } } From 89c974a197d6c957ea0bb4d9d8af6cb4ca0589a3 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 27 Apr 2026 11:15:28 +0200 Subject: [PATCH 22/33] Make indexing status cache and stack info cache for ENSApi to use the same strategy for fetching the indexing metadata context record --- .../ensapi/src/cache/indexing-status.cache.ts | 77 +++++++------ apps/ensapi/src/cache/stack-info.cache.ts | 102 +++++++++++------- .../indexing-metadata-context-builder.ts | 13 ++- 3 files changed, 113 insertions(+), 79 deletions(-) diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts index 907c88bdd..fc25640b5 100644 --- a/apps/ensapi/src/cache/indexing-status.cache.ts +++ b/apps/ensapi/src/cache/indexing-status.cache.ts @@ -11,46 +11,57 @@ import { makeLogger } from "@/lib/logger"; const logger = makeLogger("indexing-status.cache"); +export type IndexingStatusCache = SWRCache; + // lazyProxy defers construction until first use so that this module can be // imported without env vars being present (e.g. during OpenAPI generation). // SWRCache with proactivelyInitialize:true starts background polling immediately // on construction, which would trigger ensDbClient before env vars are available. -export const indexingStatusCache = lazyProxy>( +/** + * Cache for {@link CrossChainIndexingStatusSnapshot}, which is loaded + * from ENSDb on demand. The cached value is expected to be updated + * very frequently, following the update frequency of + * {@link IndexingMetadataContextInitialized.indexingStatus} in ENSDb. + * Therefore, the cache is configured with a very short TTL and + * proactive revalidation interval to ensure that the cached value is + * as fresh as possible. + */ +export const indexingStatusCache = lazyProxy( () => new SWRCache({ - fn: async (_cachedResult) => - ensDbClient - .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 Metadata Context was uninitialized in ENSDb."); - } + fn: async function loadIndexingStatusSnapshot() { + try { + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); + + if ( + indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized + ) { + // The IndexingMetadataContext has not been initialized in ENSDb yet. + // This might happen during application startup, i.e. when ENSDb + // has not yet been populated with the IndexingMetadataContext record. + // Therefore, throw an error to trigger the subsequent catch handler. + 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 indexingMetadataContext.indexingStatus; - }) - .catch((error) => { - // 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 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; - }), + // The CrossChainIndexingStatusSnapshot has been successfully built 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 indexingMetadataContext.indexingStatus; + } catch (error) { + // IndexingMetadataContext 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 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; + } + }, // We need to refresh the indexing status cache very frequently. // ENSDb won't have issues handling this frequency of queries. ttl: 1, // 1 second diff --git a/apps/ensapi/src/cache/stack-info.cache.ts b/apps/ensapi/src/cache/stack-info.cache.ts index 3a94a38b5..fb376933d 100644 --- a/apps/ensapi/src/cache/stack-info.cache.ts +++ b/apps/ensapi/src/cache/stack-info.cache.ts @@ -2,76 +2,96 @@ import config from "@/config"; import { minutesToSeconds } from "date-fns"; +import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk"; import { buildEnsNodeStackInfo, - type CachedResult, type EnsNodeStackInfo, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, SWRCache, } from "@ensnode/ensnode-sdk"; import { buildEnsApiPublicConfig } from "@/config/config.schema"; import { ensDbClient } from "@/lib/ensdb/singleton"; import { lazyProxy } from "@/lib/lazy"; +import logger from "@/lib/logger"; -/** - * Loads the ENSNode stack info, either from cache if available, - * or by building it from the public configs of ENSApi and ENSDb. - * - * The ENSNode Stack Info object is considered immutable for - * the lifecycle of an ENSApi process instance, so once it is successfully - * loaded, it will be cached indefinitely. - */ -async function loadEnsNodeStackInfo( - cachedResult?: CachedResult, -): Promise { - if (cachedResult && !(cachedResult.result instanceof Error)) { - return cachedResult.result; - } - - const ensApiPublicConfig = buildEnsApiPublicConfig(config); - const ensDbPublicConfig = await ensDbClient.buildEnsDbPublicConfig(); - const ensIndexerPublicConfig = ensApiPublicConfig.ensIndexerPublicConfig; - const ensRainbowPublicConfig = ensIndexerPublicConfig.ensRainbowPublicConfig; - - return buildEnsNodeStackInfo( - ensApiPublicConfig, - ensDbPublicConfig, - ensIndexerPublicConfig, - ensRainbowPublicConfig, - ); -} +export type EnsNodeStackInfoCache = SWRCache; // lazyProxy defers construction until first use so that this module can be // imported without env vars being present (e.g. during OpenAPI generation). // SWRCache with proactivelyInitialize:true starts background polling immediately // on construction, which would trigger ensDbClient before env vars are available. /** - * Cache for ENSNode stack info - * Once successfully loaded, the ENSNode Stack Info is cached indefinitely and - * never revalidated. This ensures the JSON is only fetched once during - * the application lifecycle. + * Cache for {@link EnsNodeStackInfo}, which is loaded from ENSDb on demand. + * Once successfully loaded, the {@link EnsNodeStackInfo} is cached and kept up-to-date + * by proactive revalidation, since the {@link EnsNodeStackInfo} might change during + * the lifecycle of the ENSApi instance, for example, when + * {@link IndexingMetadataContextInitialized.stackInfo} is updated in ENSDb. * * Configuration: - * - ttl: Infinity - Never expires once cached - * - errorTtl: 1 minute - If loading fails, retry on next access after 1 minute - * - proactiveRevalidationInterval: undefined - No proactive revalidation + * - ttl: 1 minute - Allow cached value to be fresh for up to 1 minute. + * - errorTtl: 1 minute - If loading fails, retry on next access after 1 minute. + * - proactiveRevalidationInterval: 5 minutes - Refresh the cached value every 5 minutes. * - proactivelyInitialize: true - Load immediately on startup */ -export const stackInfoCache = lazyProxy( +export const stackInfoCache = lazyProxy( () => /** * Cache for ENSNode stack info * * Once initialized successfully, this cache will always return - * the same stack info for the lifecycle of the ENSApi instance. + * the same {@link EnsNodeStackInfo} for the lifecycle of the ENSApi instance. * - * If initialization fails, it will keep retrying on access until it succeeds, which is desirable because the stack info is critical for the functioning of the application and we want to recover from transient initialization failures without requiring a restart. + * If initialization fails, it will keep retrying on access until it succeeds, + * which is desirable because the {@link EnsNodeStackInfo} is critical for the functioning of the application and we want to recover from transient initialization failures without requiring a restart. */ new SWRCache({ - fn: loadEnsNodeStackInfo, - ttl: Number.POSITIVE_INFINITY, + fn: async function loadEnsNodeStackInfo() { + try { + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); + + if ( + indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized + ) { + // The IndexingMetadataContext has not been initialized in ENSDb yet. + // This might happen during application startup, i.e. when ENSDb + // has not yet been populated with the IndexingMetadataContext record. + // Therefore, throw an error to trigger the subsequent catch handler. + throw new Error("Indexing Metadata Context was uninitialized in ENSDb."); + } + + const ensIndexerStackInfo = indexingMetadataContext.stackInfo; + const ensNodeStackInfo = buildEnsNodeStackInfo( + buildEnsApiPublicConfig(config), + ensIndexerStackInfo.ensDb, + ensIndexerStackInfo.ensIndexer, + ensIndexerStackInfo.ensRainbow, + ); + + // The EnsNodeStackInfo has been successfully built 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 ensNodeStackInfo; + } catch (error) { + // IndexingMetadataContext 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 Metadata Context record from ENSNode Metadata table in ENSDb. ` + + `Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.IndexingMetadataContext}"). ` + + `The cached EnsNodeStackInfo object (if any) will not be updated.`, + ); + + throw error; + } + }, + ttl: minutesToSeconds(1), errorTtl: minutesToSeconds(1), - proactiveRevalidationInterval: undefined, + proactiveRevalidationInterval: minutesToSeconds(5), proactivelyInitialize: true, }), ); diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts index 1a4254f80..3c3e3dadc 100644 --- a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts @@ -66,7 +66,7 @@ export class IndexingMetadataContextBuilder { this.ensDbClient.getIndexingMetadataContext(), ]); - // Build the {@link CrossChainIndexingStatusSnapshot} with the current snapshot time. + // Build the CrossChainIndexingStatusSnapshot with the current snapshot time. // This is important to make sure the `snapshotTime` is always up to date in // the indexing status snapshot stored within the Indexing Metadata Context record in ENSDb. const now = getUnixTime(new Date()); @@ -85,19 +85,22 @@ export class IndexingMetadataContextBuilder { ) { logger.info({ msg: `Indexing Metadata Context is "uninitialized"` }); + // If no IndexingMetadataContext has been initialized in ENSDb yet, then + // the "in-memory" CrossChainIndexingStatusSnapshot must be in + // "unstarted" status,since onchain events indexing has not started yet. invariant_indexingStatusIsUnstartedForIndexingMetadataContextUninitialized( inMemoryIndexingStatusSnapshot, ); } else { - logger.info({ msg: `Indexing Metadata Context is "initialized"` }); - logger.debug({ + logger.debug({ msg: `Indexing Metadata Context is "initialized"` }); + logger.trace({ msg: `Indexing Metadata Context`, indexingStatus: storedIndexingMetadataContext.indexingStatus, stackInfo: storedIndexingMetadataContext.stackInfo, }); - // Validate in-memory config object compatibility with the stored one, - // if the stored one is available. + // For EnsIndexerPublicConfig, validate in-memory config object + // compatibility with the stored one, if the stored one is available. // The validation is skipped if the local Ponder app is running in dev mode. // This is to improve the development experience during ENSIndexer // development, by allowing to override the stored config in ENSDb with From 159c4ff90191e94af0b311c8bd601c638c9f378b Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 27 Apr 2026 12:34:00 +0200 Subject: [PATCH 23/33] Improve code docs --- .../ensapi/src/cache/indexing-status.cache.ts | 2 +- apps/ensapi/src/cache/stack-info.cache.ts | 4 ++ .../ensdb-writer-worker.ts | 67 ++++++++++--------- .../src/lib/indexing-engines/ponder.ts | 49 ++++++++++++-- .../public-config-builder.ts | 1 + 5 files changed, 86 insertions(+), 37 deletions(-) diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts index fc25640b5..51453f153 100644 --- a/apps/ensapi/src/cache/indexing-status.cache.ts +++ b/apps/ensapi/src/cache/indexing-status.cache.ts @@ -43,7 +43,7 @@ export const indexingStatusCache = lazyProxy( throw new Error("Indexing Metadata Context was uninitialized in ENSDb."); } - // The CrossChainIndexingStatusSnapshot has been successfully built for caching. + // The CrossChainIndexingStatusSnapshot has been successfully loaded 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. diff --git a/apps/ensapi/src/cache/stack-info.cache.ts b/apps/ensapi/src/cache/stack-info.cache.ts index fb376933d..d8f3b8465 100644 --- a/apps/ensapi/src/cache/stack-info.cache.ts +++ b/apps/ensapi/src/cache/stack-info.cache.ts @@ -28,6 +28,10 @@ export type EnsNodeStackInfoCache = SWRCache; * by proactive revalidation, since the {@link EnsNodeStackInfo} might change during * the lifecycle of the ENSApi instance, for example, when * {@link IndexingMetadataContextInitialized.stackInfo} is updated in ENSDb. + * This is unlikely to happen at all, and if it does happen, it is likely to be + * very infrequent. However, proactive revalidation ensures that if such changes do happen, + * the cached value will be updated in a reasonable time frame without requiring + * a restart of the ENSApi application. * * Configuration: * - ttl: 1 minute - Allow cached value to be fresh for up to 1 minute. diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index e042c1bfd..ad0ec27e8 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -20,23 +20,24 @@ const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 1; */ export class EnsDbWriterWorker { /** - * Interval for recurring upserts of Indexing Status Snapshots into ENSDb. + * Interval for recurring updates of Indexing Status Snapshots into ENSDb. */ - private indexingStatusInterval: ReturnType | null = null; + private indexingStatusUpdateInterval: ReturnType | null = null; /** - * ENSDb Client instance used by the worker to interact with ENSDb. + * {@link EnsDbWriter} instance used by the worker to interact with ENSDb instance. */ private ensDbClient: EnsDbWriter; /** - * Indexing Metadata Context Builder instance used by the worker to read {@link IndexingMetadataContext}. + * {@link IndexingMetadataContextBuilder} instance used by the worker to read {@link IndexingMetadataContext}. */ private indexingMetadataContextBuilder: IndexingMetadataContextBuilder; /** * @param ensDbClient ENSDb Writer instance used by the worker to interact with ENSDb. - * @param indexingMetadataContextBuilder Indexing Metadata Context Builder instance used by the worker to read {@link IndexingMetadataContext}. + * @param indexingMetadataContextBuilder {@link IndexingMetadataContextBuilder} instance used by + * the worker to read {@link IndexingMetadataContext}. */ constructor( ensDbClient: EnsDbWriter, @@ -60,9 +61,26 @@ export class EnsDbWriterWorker { throw new Error("EnsDbWriterWorker is already running"); } - // Recurring update of the Indexing Metadata Context record in ENSDb. - this.indexingStatusInterval = setInterval( - () => this.updateIndexingMetadataContext(), + // Recurring update of the IndexingMetadataRecord record in ENSDb. + this.indexingStatusUpdateInterval = setInterval( + () => + this.updateIndexingMetadataContext().catch((error) => { + logger.error({ + msg: "Failed to update indexing metadata context record in ENSDb", + module: "EnsDbWriterWorker", + error, + }); + + // Updating the IndexingMetadataContext record in ENSDb is + // a critical operation for the ENSIndexer instance, + // therefore if any error happens during this operation, + // we want to stop the worker to prevent further errors, + // and exit the process with a non-zero exit code. + this.stop(); + + process.exitCode = 1; + throw error; + }), secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL), ); } @@ -71,7 +89,7 @@ export class EnsDbWriterWorker { * Indicates whether the ENSDb Writer Worker is currently running. */ get isRunning(): boolean { - return this.indexingStatusInterval !== null; + return this.indexingStatusUpdateInterval !== null; } /** @@ -80,36 +98,23 @@ export class EnsDbWriterWorker { * Stops all recurring tasks in the worker. */ public stop(): void { - if (this.indexingStatusInterval) { - clearInterval(this.indexingStatusInterval); - this.indexingStatusInterval = null; + if (this.indexingStatusUpdateInterval) { + clearInterval(this.indexingStatusUpdateInterval); + this.indexingStatusUpdateInterval = null; } } /** * Update the current Indexing Status Snapshot into ENSDb. * - * This method is called by the scheduler at regular intervals. - * Errors are logged but not thrown, to keep the worker running. + * This method is called by the scheduler at regular intervals from {@link run}. + * + * @throws Error if the update operation fails. */ private async updateIndexingMetadataContext(): Promise { - try { - const indexingMetadataContext = - await this.indexingMetadataContextBuilder.getIndexingMetadataContext(); + const indexingMetadataContext = + await this.indexingMetadataContextBuilder.getIndexingMetadataContext(); - await this.ensDbClient.upsertIndexingMetadataContext(indexingMetadataContext); - } catch (error) { - // If any error happens during the update of indexing metadata context record in ENSDb, - // we want to log the error and exit the process with a non-zero exit code, - // since this is a critical failure that prevents the ENSIndexer instance from functioning properly. - logger.error({ - msg: "Failed to update indexing metadata context record in ENSDb", - module: "EnsDbWriterWorker", - error, - }); - - process.exitCode = 1; - throw error; - } + await this.ensDbClient.upsertIndexingMetadataContext(indexingMetadataContext); } } diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index e9fb4b46d..fd6b0f00e 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -1,9 +1,48 @@ /** - * This module is an abstraction layer for the Indexing Engine of ENSIndexer. - * It decouples core indexing logic from Ponder-specific implementation details. - * Benefits of this decoupling include: - * - Building a custom context data model. - * - Implementing shared logic before or after event handlers, if needed. + * Ponder Indexing Engine + * + * This module provides an abstraction layer over the Ponder Indexing Engine + * to decouple the core indexing logic of the ENSIndexer from Ponder-specific + * implementation details. This allows us to build a custom context data model, + * and implement shared logic before or after event handlers, if needed, without + * affecting the "hot path" of indexing onchain events. + * + * Ponder Indexing Engine runs within an ENSIndexer instance, and is responsible + * for: + * - Managing the Ponder Schema and the ENSIndexer Schema in ENSDb instance. + * - Running HTTP server. + * - Executing the omnichain indexing strategy for sourcing and handling events. + * + * The startup sequence of the Ponder Indexing Engine is as follows: + * 1. Execute Ponder Config file (apps/ensindexer/ponder/ponder.config.ts), + * Ponder Schema file (apps/ensindexer/ponder/ponder.schema.ts), + * and all event handler files (as per nested imports in + * apps/ensindexer/ponder/src/register-handlers.ts). + * 2. Connect to the database and initialize required database objects. + * a) Execute database migrations for the ENSIndexer Schema in ENSDb. + * b) Execute database migrations for the Ponder Schema in ENSDb. + * 3. Execute Ponder HTTP API file (apps/ensindexer/ponder/src/api/index.ts) + * and start the HTTP server. + * 4. Execute the omnichain indexing strategy + * a) Start sourcing onchain events from the configured RPCs for + * the indexed contracts. + * b) Check if Ponder Checkpoints have been initialized in + * the ENSIndexer Schema in ENSDb. If not, execute + * the setup event handlers (if any), and initialize + * the Ponder Checkpoints in ENSDb. + * c) Once the Ponder Checkpoints are initialized, start executing + * the onchain event handlers for the sourced onchain events. + * + * The ENSIndexer instance has to be able to execute arbitrary logic + * before any onchain event handlers are executed, for example, to set up + * necessary state in the ENSNode Schema in ENSDb instance. To achieve this, + * we define the {@link addOnchainEventListener} function, which is + * a thin wrapper around {@link ponder.on} that allows us to execute additional + * logic before the onchain event handlers are executed, while keeping the + * "hot path" of indexing onchain events as efficient as possible. + * + * For more details on Ponder and its concepts, see the Ponder documentation. + * @see https://ponder.sh/docs/indexing/overview */ export * as ensIndexerSchema from "ponder:schema"; diff --git a/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts index c5dc7d184..9b132ddce 100644 --- a/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts +++ b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts @@ -49,6 +49,7 @@ export class PublicConfigBuilder { if (typeof this.immutablePublicConfig === "undefined") { const [versionInfo, ensRainbowPublicConfig] = await Promise.all([ this.getEnsIndexerVersionInfo(), + // TODO: remove dependency on ENSRainbow by dropping `ensRainbowPublicConfig` from `EnsIndexerPublicConfig`. this.ensRainbowClient.config(), ]); From b36418bd57458a0124c8004178d7c6b3c79f2140 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 27 Apr 2026 12:34:19 +0200 Subject: [PATCH 24/33] Create a mock file for config.schema.ts --- apps/ensapi/src/config/config.schema.mock.ts | 108 +++++++++++++++++ apps/ensapi/src/config/config.schema.test.ts | 111 +----------------- apps/ensapi/src/config/config.schema.ts | 14 ++- .../init-indexing-onchain-events.ts | 59 ++-------- 4 files changed, 134 insertions(+), 158 deletions(-) create mode 100644 apps/ensapi/src/config/config.schema.mock.ts diff --git a/apps/ensapi/src/config/config.schema.mock.ts b/apps/ensapi/src/config/config.schema.mock.ts new file mode 100644 index 000000000..bb878dabf --- /dev/null +++ b/apps/ensapi/src/config/config.schema.mock.ts @@ -0,0 +1,108 @@ +import packageJson from "@/../package.json" with { type: "json" }; + +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 { EnsApiEnvironment } from "@/config/environment"; + +export const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234"; + +export const ENSDB_PUBLIC_CONFIG = { + versionInfo: { + postgresql: "17.4", + }, +} satisfies SerializedEnsDbPublicConfig; + +export 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: "1.1.1", + ponder: "0.8.0", + }, +} satisfies SerializedEnsIndexerPublicConfig; + +const ENSRAINBOW_PUBLIC_CONFIG = { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { + ensRainbow: packageJson.version, + }, +} satisfies EnsRainbowPublicConfig; + +export 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; + +export const ENSINDEXER_STACK_INFO = { + ensDb: ENSDB_PUBLIC_CONFIG, + ensIndexer: ENSINDEXER_PUBLIC_CONFIG, + ensRainbow: ENSRAINBOW_PUBLIC_CONFIG, +} satisfies SerializedEnsIndexerStackInfo; + +export const INDEXING_METADATA_CONTEXT = { + statusCode: IndexingMetadataContextStatusCodes.Initialized, + indexingStatus: INDEXING_STATUS, + stackInfo: ENSINDEXER_STACK_INFO, +} satisfies SerializedIndexingMetadataContextInitialized; + +export const indexingMetadataContextInitialized = deserializeIndexingMetadataContext( + INDEXING_METADATA_CONTEXT, +) as IndexingMetadataContextInitialized; + +export const BASE_ENV = { + ENSDB_URL: "postgresql://user:password@localhost:5432/mydb", + ENSINDEXER_SCHEMA_NAME: "ensindexer_0", + RPC_URL_1: VALID_RPC_URL, +} satisfies EnsApiEnvironment; diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 72fe56065..fa79f75d4 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -1,109 +1,9 @@ -import packageJson from "@/../package.json" with { type: "json" }; - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -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; - vi.mock("@/lib/ensdb/singleton", () => ({ ensDbClient: { getIndexingMetadataContext: vi.fn(async () => indexingMetadataContextInitialized), @@ -118,6 +18,11 @@ vi.mock("@/config/ensdb-config", () => ({ })); import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/config.schema"; +import { + BASE_ENV, + indexingMetadataContextInitialized, + VALID_RPC_URL, +} from "@/config/config.schema.mock"; import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import type { EnsApiEnvironment } from "@/config/environment"; import logger from "@/lib/logger"; @@ -129,12 +34,6 @@ vi.mock("@/lib/logger", () => ({ }, })); -const BASE_ENV = { - ENSDB_URL: "postgresql://user:password@localhost:5432/mydb", - ENSINDEXER_SCHEMA_NAME: "ensindexer_0", - RPC_URL_1: VALID_RPC_URL, -} satisfies EnsApiEnvironment; - describe("buildConfigFromEnvironment", () => { it("returns a valid config object using environment variables", async () => { const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index a1ecb7c37..80f8ff1b4 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,7 +1,7 @@ import pRetry from "p-retry"; import { prettifyError, ZodError, z } from "zod/v4"; -import { type EnsApiPublicConfig, IndexingMetadataContextStatusCodes } from "@ensnode/ensnode-sdk"; +import type { EnsApiPublicConfig } from "@ensnode/ensnode-sdk"; import { buildRpcConfigsFromEnv, canFallbackToTheGraph, @@ -13,11 +13,11 @@ import { TheGraphApiKeySchema, } from "@ensnode/ensnode-sdk/internal"; +import { stackInfoCache } from "@/cache/stack-info.cache"; import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import ensDbConfig from "@/config/ensdb-config"; import type { EnsApiEnvironment } from "@/config/environment"; import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations"; -import { ensDbClient } from "@/lib/ensdb/singleton"; import logger from "@/lib/logger"; import { ensApiVersionInfo } from "@/lib/version-info"; @@ -70,13 +70,15 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis // https://github.com/namehash/ensnode/issues/1806 const ensIndexerPublicConfig = await pRetry( async () => { - const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); + const ensNodeStackInfo = await stackInfoCache.read(); - if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) { - throw new Error("Indexing metadata context is uninitialized in ENSDb."); + if (ensNodeStackInfo instanceof Error) { + throw new Error( + "EnsNodeStackInfo is not available yet for fetching the EnsIndexerPublicConfig.", + ); } - return indexingMetadataContext.stackInfo.ensIndexer; + return ensNodeStackInfo.ensIndexer; }, { retries: 13, // This allows for a total of over 1 hour of retries with the exponential backoff strategy diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts index bfe6e2bfc..8136633df 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -1,27 +1,7 @@ /** - * This module defines the initialization logic for the onchain event handlers of - * the Ponder indexing engine executed in an ENSIndexer instance. - * - * Onchain event handlers are executed by Ponder once per ENSIndexer instance lifetime, - * at the start of the omnichain indexing process. - * - * ENSIndexer startup sequence executed by Ponder: - * 1. Connect to the database and initialize required database objects. - * 2. Start the omnichain indexing process. - * 3. Check whether Ponder Checkpoints are already initialized. - * 4. If not: - * a) Execute setup handlers, if any were registered. - * b) Initialize Ponder Checkpoints. - * 5. a) Make Ponder HTTP API usable. - * 5. b) Start executing "onchain" event handlers. - * - * Step 4 is skipped on ENSIndexer instance restart if Ponder Checkpoints were - * already initialized in a previous run. Also, step 4 a) is skipped if - * no setup handlers were registered. Therefore, we don't implement any init - * logic for setup handlers. Instead, to guarantee that any necessary initialization logic - * is executed each time the ENSIndexer instance starts, we implement the init indexing onchain events logic - * in this module, which is executed in step 5 b) and is guaranteed to be executed on every ENSIndexer instance startup, - * regardless of the state of Ponder Checkpoints or whether any setup handlers were registered. + * This module defines the initialization logic to be executed by + * the ENSIndexer instance before it starts executing any "onchain" + * event handlers. */ import { migrateEnsNodeSchema } from "@/lib/ensdb/migrate-ensnode-schema"; @@ -54,31 +34,18 @@ async function upsertIndexingMetadataContextRecord(): Promise { } /** - * Prepare for executing the "onchain" event handlers. - * - * During Ponder startup, the "onchain" event handlers are executed - * after all "setup" event handlers have completed. - * - * This function is useful to make sure any long-running preconditions for - * onchain event handlers are met, for example, waiting for - * the ENSRainbow instance to be ready before processing any onchain events - * that require data from ENSRainbow. - * - * @example A single blocking precondition - * ```ts - * await waitForEnsRainbowToBeReady(); - * ``` + * Initialize indexing of "onchain" events * - * @example Multiple blocking preconditions - * ```ts - * await Promise.all([ - * waitForEnsRainbowToBeReady(), - * waitForAnotherPrecondition(), - * ]); - * ``` + * This function is guaranteed to be called exactly once by + * `eventHandlerPreconditions` before executing any "onchain" event handlers, + * and is used to initialize the ENSNode Schema. * - * Goals of this function: - * 1. Make ENSDb instance "ready" for ENSDb clients to use. + * Each time the ENSIndexer instance starts, the logic in this function will be + * executed. Therefore, all logic must be idempotent and concurrency-safe, + * to prevent any issues during the startup of the ENSIndexer instance. + * For example, multiple ENSIndexer instances might be started at the same time, + * and they might all execute the logic in this function concurrently, + * so we need to make sure that this does not cause any unexpected side effects. */ export async function initIndexingOnchainEvents(): Promise { try { From f3355ef76dd8e15632f09eede2196fd13ff5678a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 27 Apr 2026 15:32:43 +0200 Subject: [PATCH 25/33] Integrate ENSDb health check and readiness check into `initIndexingOnchainEvents` logic --- .../init-indexing-onchain-events.ts | 16 ++++- .../ensdb-sdk/src/client/ensdb-reader.test.ts | 64 +++++++++++++++++++ packages/ensdb-sdk/src/client/ensdb-reader.ts | 31 +++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts index 8136633df..ae5cd9c60 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -49,7 +49,8 @@ async function upsertIndexingMetadataContextRecord(): Promise { */ export async function initIndexingOnchainEvents(): Promise { try { - // TODO: wait for ENSDb instance to be healthy before running any queries against it. + // Ensure ENSDb instance is healthy before trying to run any queries against it. + await ensDbClient.isHealthy(); // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. await migrateEnsNodeSchema(); @@ -63,6 +64,19 @@ export async function initIndexingOnchainEvents(): Promise { // Upsert the Indexing Metadata Context record into ENSDb await upsertIndexingMetadataContextRecord(); + // Invariant: at this point, the ENSDb instance must be considered ready. + // This is a defensive check, highly unlikely to ever fail, since we just + // have successfully executed database migrations for the ENSNode Schema + // and upserted the IndexingMetadataContext record into ENSDb. However, + // if any database migration silently failed without throwing an error, + // or if the upsert operation for IndexingMetadataContext record was + // not completed as expected, the ENSDb instance might not be ready, and + // we want to catch this issue before we start processing onchain events. + const isEnsDbReady = await ensDbClient.isReady(); + if (!isEnsDbReady) { + throw new Error("ENSDb instance must be ready before onchain events can be indexed."); + } + // Before starting to process onchain events, we want to make sure that // ENSRainbow is ready to serve the "heal" requests. await waitForEnsRainbowToBeReady(); diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts index f60ad260b..0be99368c 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts @@ -107,6 +107,70 @@ describe("EnsDbReader", () => { }); }); + describe("isHealthy", () => { + it("returns true when execute succeeds", async () => { + executeMock.mockResolvedValueOnce({ rows: [] }); + + const result = await createEnsDbReader().isHealthy(); + + expect(result).toBe(true); + }); + + it("returns false when execute fails", async () => { + executeMock.mockRejectedValueOnce(new Error("Connection refused")); + + const result = await createEnsDbReader().isHealthy(); + + expect(result).toBe(false); + }); + }); + + describe("isReady", () => { + it("returns true when healthy and indexing metadata context is initialized", async () => { + executeMock.mockResolvedValueOnce({ rows: [] }); + + const indexingStatus = deserializeCrossChainIndexingStatusSnapshot( + ensDbClientMock.serializedSnapshot, + ); + const ensDbPublicConfig: EnsDbPublicConfig = { + versionInfo: { postgresql: "17.4" }, + }; + const ensRainbowPublicConfig = { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.9.0" }, + }; + const stackInfo = buildEnsIndexerStackInfo( + ensDbPublicConfig, + ensDbClientMock.publicConfig, + ensRainbowPublicConfig, + ); + const context = buildIndexingMetadataContextInitialized(indexingStatus, stackInfo); + const serialized = serializeIndexingMetadataContext(context); + selectResult.current = [{ value: serialized }]; + + const result = await createEnsDbReader().isReady(); + + expect(result).toBe(true); + }); + + it("returns false when healthy but indexing metadata context is uninitialized", async () => { + executeMock.mockResolvedValueOnce({ rows: [] }); + selectResult.current = []; + + const result = await createEnsDbReader().isReady(); + + expect(result).toBe(false); + }); + + it("returns false when not healthy", async () => { + executeMock.mockRejectedValueOnce(new Error("Connection refused")); + + const result = await createEnsDbReader().isReady(); + + expect(result).toBe(false); + }); + }); + describe("getIndexingMetadataContext", () => { it("returns an uninitialized context when no record exists", async () => { const ensDbReader = createEnsDbReader(); diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.ts b/packages/ensdb-sdk/src/client/ensdb-reader.ts index eb03dc5f4..72b9614c6 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.ts @@ -6,6 +6,7 @@ import { type EnsDbPublicConfig, type EnsDbVersionInfo, type IndexingMetadataContext, + IndexingMetadataContextStatusCodes, } from "@ensnode/ensnode-sdk"; import { @@ -126,6 +127,36 @@ export class EnsDbReader< return this._ensNodeSchema; } + /** + * Check if the ENSDb instance is healthy by running a simple query + * against it. + */ + async isHealthy(): Promise { + try { + await this.ensDb.execute("SELECT 1;"); + return true; + } catch { + return false; + } + } + + /** + * Check if the ENSDb instance is ready by verifying that it is + * healthy and the {@link IndexingMetadataContext} has been initialized for + * the ENSIndexer Schema used by this ENSDbReader instance. + */ + async isReady(): Promise { + const isHealthy = await this.isHealthy(); + + if (!isHealthy) { + return false; + } + + const indexingMetadataContext = await this.getIndexingMetadataContext(); + + return indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Initialized; + } + /** * Build ENSDb Public Config */ From 40961afacc1cf6d3eabd01e7eb533adbb15a92a7 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 27 Apr 2026 15:54:58 +0200 Subject: [PATCH 26/33] Update code docs --- .../indexing-metadata-context-builder.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts index 3c3e3dadc..2c6a1d44b 100644 --- a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts @@ -20,8 +20,6 @@ import type { StackInfoBuilder } from "@/lib/stack-info-builder/stack-info-build function invariant_indexingStatusIsUnstartedForIndexingMetadataContextUninitialized( inMemoryIndexingStatusSnapshot: OmnichainIndexingStatusSnapshot, ): void { - // Invariant: indexing status must be "unstarted" when the indexing metadata context is uninitialized, - // since we haven't started processing any onchain events yet if (inMemoryIndexingStatusSnapshot.omnichainStatus !== OmnichainIndexingStatusIds.Unstarted) { throw new Error( `Omnichain indexing status must be "unstarted" for "uninitialized" Indexing Metadata Context. Provided omnichain indexing status "${inMemoryIndexingStatusSnapshot.omnichainStatus}".`, @@ -44,9 +42,35 @@ function invariant_ensIndexerPublicConfigIsCompatibleWithStackInfo( export class IndexingMetadataContextBuilder { constructor( + /** + * ENSDb Client used to read the currently stored + * {@link IndexingMetadataContextInitialized} record from ENSDb, + * which the invariant validation logic in + * {@link getIndexingMetadataContext} depends on. + */ private readonly ensDbClient: EnsDbReader, + + /** + * IndexingStatusBuilder used to get + * the current in-memory {@link OmnichainIndexingStatusSnapshot} for building + * the "in-memory" {@link IndexingMetadataContextInitialized} object + * within {@link getIndexingMetadataContext}. + */ private readonly indexingStatusBuilder: IndexingStatusBuilder, + + /** + * StackInfoBuilder used to get + * the current in-memory {@link EnsIndexerStackInfo} for building + * the "in-memory" {@link IndexingMetadataContextInitialized} object + * within {@link getIndexingMetadataContext}. + */ private readonly stackInfoBuilder: StackInfoBuilder, + + /** + * Local Ponder Client used to determine if the local Ponder app + * is running in dev mode, which affects the validation logic applied + * in {@link getIndexingMetadataContext}. + */ private readonly localPonderClient: LocalPonderClient, ) {} From 6279dea2954c5bc98f76fc560239576bb7bd91ec Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 27 Apr 2026 22:11:46 +0200 Subject: [PATCH 27/33] Apply AI PR feedback --- apps/ensapi/src/cache/stack-info.cache.ts | 2 +- apps/ensapi/src/config/config.schema.mock.ts | 2 +- apps/ensapi/src/config/config.schema.test.ts | 139 +++++++++++++----- apps/ensapi/src/config/config.schema.ts | 31 ++-- .../ensdb-writer-worker.test.ts | 30 +--- .../ensdb-writer-worker.ts | 10 -- .../init-indexing-onchain-events.ts | 7 +- .../indexing-metadata-context-builder.ts | 2 +- 8 files changed, 139 insertions(+), 84 deletions(-) diff --git a/apps/ensapi/src/cache/stack-info.cache.ts b/apps/ensapi/src/cache/stack-info.cache.ts index d8f3b8465..0df28eb6f 100644 --- a/apps/ensapi/src/cache/stack-info.cache.ts +++ b/apps/ensapi/src/cache/stack-info.cache.ts @@ -67,7 +67,7 @@ export const stackInfoCache = lazyProxy( const ensIndexerStackInfo = indexingMetadataContext.stackInfo; const ensNodeStackInfo = buildEnsNodeStackInfo( - buildEnsApiPublicConfig(config), + buildEnsApiPublicConfig(config, ensIndexerStackInfo.ensIndexer), ensIndexerStackInfo.ensDb, ensIndexerStackInfo.ensIndexer, ensIndexerStackInfo.ensRainbow, diff --git a/apps/ensapi/src/config/config.schema.mock.ts b/apps/ensapi/src/config/config.schema.mock.ts index bb878dabf..7425c5ed6 100644 --- a/apps/ensapi/src/config/config.schema.mock.ts +++ b/apps/ensapi/src/config/config.schema.mock.ts @@ -43,7 +43,7 @@ export const ENSINDEXER_PUBLIC_CONFIG = { versionInfo: { ensDb: packageJson.version, ensIndexer: packageJson.version, - ensNormalize: "1.1.1", + ensNormalize: "1.11.1", ponder: "0.8.0", }, } satisfies SerializedEnsIndexerPublicConfig; diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index fa79f75d4..df899a7df 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -34,10 +34,22 @@ vi.mock("@/lib/logger", () => ({ }, })); +const mockProcessExit = () => + vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as never); + describe("buildConfigFromEnvironment", () => { it("returns a valid config object using environment variables", async () => { + const exitSpy = mockProcessExit(); + const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; - await expect(buildConfigFromEnvironment(BASE_ENV)).resolves.toStrictEqual({ + const config = await buildConfigFromEnvironment(BASE_ENV); + + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + + expect(config).toStrictEqual({ port: ENSApi_DEFAULT_PORT, ensDbUrl: BASE_ENV.ENSDB_URL, ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, @@ -59,6 +71,7 @@ describe("buildConfigFromEnvironment", () => { }); it("parses REFERRAL_PROGRAM_EDITIONS as a URL object", async () => { + const exitSpy = mockProcessExit(); const editionsUrl = "https://example.com/editions.json"; const config = await buildConfigFromEnvironment({ @@ -66,63 +79,90 @@ describe("buildConfigFromEnvironment", () => { REFERRAL_PROGRAM_EDITIONS: editionsUrl, }); + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + expect(config.referralProgramEditionConfigSetUrl).toEqual(new URL(editionsUrl)); }); + it("includes theGraphApiKey when provided", async () => { + const exitSpy = mockProcessExit(); + + const config = await buildConfigFromEnvironment({ + ...BASE_ENV, + THEGRAPH_API_KEY: "my-api-key", + }); + + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + + expect(config.theGraphApiKey).toBe("my-api-key"); + }); + describe("Useful error messages", () => { - // Mock process.exit to prevent actual exit - const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + let exitSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); + exitSpy = mockProcessExit(); }); afterEach(() => { - mockExit.mockClear(); + exitSpy.mockRestore(); }); - const TEST_ENV: EnsApiEnvironment = structuredClone(BASE_ENV); - it("logs error and exits when REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => { - await buildConfigFromEnvironment({ - ...TEST_ENV, - REFERRAL_PROGRAM_EDITIONS: "not-a-url", - }); + const testEnv = structuredClone(BASE_ENV); + + await expect( + buildConfigFromEnvironment({ + ...testEnv, + REFERRAL_PROGRAM_EDITIONS: "not-a-url", + }), + ).rejects.toThrow("process.exit"); - expect(logger.error).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledExactlyOnceWith( expect.stringContaining("REFERRAL_PROGRAM_EDITIONS is not a valid URL: not-a-url"), ); - expect(process.exit).toHaveBeenCalledWith(1); + expect(process.exit).toHaveBeenCalledExactlyOnceWith(1); }); it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", async () => { - await buildConfigFromEnvironment({ - ...TEST_ENV, - QUICKNODE_API_KEY: "my-api-key", - }); + const testEnv = structuredClone(BASE_ENV); - expect(logger.error).toHaveBeenCalledWith( + await expect( + buildConfigFromEnvironment({ + ...testEnv, + QUICKNODE_API_KEY: "my-api-key", + }), + ).rejects.toThrow("process.exit"); + + expect(logger.error).toHaveBeenCalledExactlyOnceWith( new Error( "Use of the QUICKNODE_API_KEY environment variable requires use of the QUICKNODE_ENDPOINT_NAME environment variable as well.", ), "Failed to build EnsApiConfig", ); - expect(process.exit).toHaveBeenCalledWith(1); + expect(process.exit).toHaveBeenCalledExactlyOnceWith(1); }); it("logs error message when QuickNode RPC config was partially configured (missing API key)", async () => { - await buildConfigFromEnvironment({ - ...TEST_ENV, - QUICKNODE_ENDPOINT_NAME: "my-endpoint-name", - }); + const testEnv = structuredClone(BASE_ENV); + + await expect( + buildConfigFromEnvironment({ + ...testEnv, + QUICKNODE_ENDPOINT_NAME: "my-endpoint-name", + }), + ).rejects.toThrow("process.exit"); - expect(logger.error).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledExactlyOnceWith( new Error( "Use of the QUICKNODE_ENDPOINT_NAME environment variable requires use of the QUICKNODE_API_KEY environment variable as well.", ), "Failed to build EnsApiConfig", ); - expect(process.exit).toHaveBeenCalledWith(1); + expect(process.exit).toHaveBeenCalledExactlyOnceWith(1); }); }); }); @@ -148,7 +188,7 @@ describe("buildEnsApiPublicConfig", () => { referralProgramEditionConfigSetUrl: undefined, }; - const result = buildEnsApiPublicConfig(ensApiConfig); + const result = buildEnsApiPublicConfig(ensApiConfig, ensIndexerPublicConfig); expect(result).toStrictEqual({ versionInfo: ensApiVersionInfo, @@ -172,36 +212,63 @@ describe("buildEnsApiPublicConfig", () => { referralProgramEditionConfigSetUrl: undefined, }; - const result = buildEnsApiPublicConfig(ensApiConfig); + const result = buildEnsApiPublicConfig(ensApiConfig, ensIndexerPublicConfig); - // Verify that all ENSIndexer public config fields are preserved expect(result.ensIndexerPublicConfig).toStrictEqual(ensIndexerPublicConfig); }); it("includes the theGraphFallback and redacts api key", () => { - const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; + const ensIndexerPublicConfig = { + ...indexingMetadataContextInitialized.stackInfo.ensIndexer, + plugins: ["subgraph"], + isSubgraphCompatible: true, + }; + const ensApiConfig = { port: ENSApi_DEFAULT_PORT, ensDbUrl: BASE_ENV.ENSDB_URL, ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, - ensIndexerPublicConfig: { - ...ensIndexerPublicConfig, - plugins: ["subgraph"], - isSubgraphCompatible: true, - }, + ensIndexerPublicConfig, namespace: ensIndexerPublicConfig.namespace, rpcConfigs: new Map(), referralProgramEditionConfigSetUrl: undefined, theGraphApiKey: "secret-api-key", }; - const result = buildEnsApiPublicConfig(ensApiConfig); + const result = buildEnsApiPublicConfig(ensApiConfig, ensIndexerPublicConfig); expect(result.theGraphFallback.canFallback).toBe(true); // discriminate the type... if (!result.theGraphFallback.canFallback) throw new Error("never"); - // shouldn't have the secret-api-key in the url - expect(result.theGraphFallback.url).not.toMatch(/secret-api-key/gi); + expect(result.theGraphFallback.url).toBe( + "https://gateway.thegraph.com/api//subgraphs/id/5XqPmWe6gjyrJtFn9cLy237i4cWw2j9HcUJEXsP5qGtH", + ); + }); + + it("returns canFallback=false when no theGraphApiKey is provided even if subgraph compatible", () => { + const ensIndexerPublicConfig = { + ...indexingMetadataContextInitialized.stackInfo.ensIndexer, + plugins: ["subgraph"], + isSubgraphCompatible: true, + }; + + const ensApiConfig = { + port: ENSApi_DEFAULT_PORT, + ensDbUrl: BASE_ENV.ENSDB_URL, + ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, + ensIndexerPublicConfig, + namespace: ensIndexerPublicConfig.namespace, + rpcConfigs: new Map(), + referralProgramEditionConfigSetUrl: undefined, + theGraphApiKey: undefined, + }; + + const result = buildEnsApiPublicConfig(ensApiConfig, ensIndexerPublicConfig); + + expect(result.theGraphFallback).toStrictEqual({ + canFallback: false, + reason: "no-api-key", + }); }); }); diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 80f8ff1b4..733bf0ec0 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,7 +1,11 @@ import pRetry from "p-retry"; import { prettifyError, ZodError, z } from "zod/v4"; -import type { EnsApiPublicConfig } from "@ensnode/ensnode-sdk"; +import { + type EnsApiPublicConfig, + type EnsIndexerPublicConfig, + IndexingMetadataContextStatusCodes, +} from "@ensnode/ensnode-sdk"; import { buildRpcConfigsFromEnv, canFallbackToTheGraph, @@ -13,11 +17,11 @@ import { TheGraphApiKeySchema, } from "@ensnode/ensnode-sdk/internal"; -import { stackInfoCache } from "@/cache/stack-info.cache"; import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import ensDbConfig from "@/config/ensdb-config"; import type { EnsApiEnvironment } from "@/config/environment"; import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations"; +import { ensDbClient } from "@/lib/ensdb/singleton"; import logger from "@/lib/logger"; import { ensApiVersionInfo } from "@/lib/version-info"; @@ -70,15 +74,17 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis // https://github.com/namehash/ensnode/issues/1806 const ensIndexerPublicConfig = await pRetry( async () => { - const ensNodeStackInfo = await stackInfoCache.read(); + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); - if (ensNodeStackInfo instanceof Error) { + if ( + indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized + ) { throw new Error( - "EnsNodeStackInfo is not available yet for fetching the EnsIndexerPublicConfig.", + "EnsIndexerPublicConfig could not be fetched, the IndexingMetadataContext record has not been initialized in ENSDb yet.", ); } - return ensNodeStackInfo.ensIndexer; + return indexingMetadataContext.stackInfo.ensIndexer; }, { retries: 13, // This allows for a total of over 1 hour of retries with the exponential backoff strategy @@ -123,17 +129,20 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis * @param config - The validated EnsApiConfig object * @returns A complete ENSApiPublicConfig object */ -export function buildEnsApiPublicConfig(config: EnsApiConfig): EnsApiPublicConfig { +export function buildEnsApiPublicConfig( + ensApiConfig: EnsApiConfig, + ensIndexerPublicConfig: EnsIndexerPublicConfig, +): EnsApiPublicConfig { return { versionInfo: ensApiVersionInfo, theGraphFallback: canFallbackToTheGraph({ - namespace: config.namespace, + namespace: ensIndexerPublicConfig.namespace, // NOTE: very important here that we replace the actual server-side api key with a placeholder // so that it's not sent to clients as part of the `theGraphFallback.url`. The placeholder must // pass validation, of course, but the only validation necessary is that it is a string. - theGraphApiKey: config.theGraphApiKey ? "" : undefined, - isSubgraphCompatible: config.ensIndexerPublicConfig.isSubgraphCompatible, + theGraphApiKey: ensApiConfig.theGraphApiKey ? "" : undefined, + isSubgraphCompatible: ensIndexerPublicConfig.isSubgraphCompatible, }), - ensIndexerPublicConfig: config.ensIndexerPublicConfig, + ensIndexerPublicConfig, }; } diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index c7f6e2e98..00c5f72f4 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -161,14 +161,8 @@ describe("EnsDbWriterWorker", () => { await vi.advanceTimersByTimeAsync(1000); expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); - // second tick - builder error, the catch block rethrows - // setInterval keeps running despite the unhandled rejection - const handler = vi.fn(); - process.on("unhandledRejection", handler); + // second tick - builder error, swallowed, no upsert await vi.advanceTimersByTimeAsync(1000); - process.removeListener("unhandledRejection", handler); - expect(handler).toHaveBeenCalled(); - expect(process.exitCode).toBe(1); expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); // no new upsert // third tick - succeeds again @@ -204,13 +198,8 @@ describe("EnsDbWriterWorker", () => { await vi.advanceTimersByTimeAsync(1000); expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(1); - // second tick - DB error, the catch block rethrows - const handler = vi.fn(); - process.on("unhandledRejection", handler); + // second tick - DB error, swallowed, upsert was called but rejected await vi.advanceTimersByTimeAsync(1000); - process.removeListener("unhandledRejection", handler); - expect(handler).toHaveBeenCalled(); - expect(process.exitCode).toBe(1); expect(ensDbClient.upsertIndexingMetadataContext).toHaveBeenCalledTimes(2); // third tick - succeeds again @@ -221,7 +210,7 @@ describe("EnsDbWriterWorker", () => { worker.stop(); }); - it("sets process.exitCode on error", async () => { + it("does not stop worker or set exitCode on error", async () => { // arrange const indexingMetadataContextBuilder = createMockIndexingMetadataContextBuilder(); (indexingMetadataContextBuilder.getIndexingMetadataContext as any).mockRejectedValue( @@ -233,18 +222,13 @@ describe("EnsDbWriterWorker", () => { // reset exitCode before test process.exitCode = undefined; - // act - suppress unhandled rejection from the setInterval callback - const handler = vi.fn(); - process.on("unhandledRejection", handler); - + // act await worker.run(); await vi.advanceTimersByTimeAsync(1000); - process.removeListener("unhandledRejection", handler); - - // assert - expect(handler).toHaveBeenCalled(); - expect(process.exitCode).toBe(1); + // assert - error is swallowed, worker keeps running, no exitCode set + expect(worker.isRunning).toBe(true); + expect(process.exitCode).toBeUndefined(); // cleanup worker.stop(); diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index ad0ec27e8..6e4bd8b16 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -70,16 +70,6 @@ export class EnsDbWriterWorker { module: "EnsDbWriterWorker", error, }); - - // Updating the IndexingMetadataContext record in ENSDb is - // a critical operation for the ENSIndexer instance, - // therefore if any error happens during this operation, - // we want to stop the worker to prevent further errors, - // and exit the process with a non-zero exit code. - this.stop(); - - process.exitCode = 1; - throw error; }), secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL), ); diff --git a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts index ae5cd9c60..cf8c22fe4 100644 --- a/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts +++ b/apps/ensindexer/src/lib/indexing-engines/init-indexing-onchain-events.ts @@ -50,7 +50,12 @@ async function upsertIndexingMetadataContextRecord(): Promise { export async function initIndexingOnchainEvents(): Promise { try { // Ensure ENSDb instance is healthy before trying to run any queries against it. - await ensDbClient.isHealthy(); + const isEnsDbHealthy = await ensDbClient.isHealthy(); + + // Invariant: ENSDb instance must be healthy by now. + if (!isEnsDbHealthy) { + throw new Error("ENSDb instance must be healthy"); + } // Ensure the ENSNode Schema in ENSDb is up to date by running any pending migrations. await migrateEnsNodeSchema(); diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts index 2c6a1d44b..4a9272752 100644 --- a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts @@ -111,7 +111,7 @@ export class IndexingMetadataContextBuilder { // If no IndexingMetadataContext has been initialized in ENSDb yet, then // the "in-memory" CrossChainIndexingStatusSnapshot must be in - // "unstarted" status,since onchain events indexing has not started yet. + // "unstarted" status, since onchain events indexing has not started yet. invariant_indexingStatusIsUnstartedForIndexingMetadataContextUninitialized( inMemoryIndexingStatusSnapshot, ); From ad1564f72ef3e242b032e4df48fa2ca3c2ac7a39 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 28 Apr 2026 08:35:15 +0200 Subject: [PATCH 28/33] Apply AI PR feedback --- apps/ensapi/src/config/config.schema.test.ts | 1 - .../ensdb-writer-worker.ts | 8 +- .../src/lib/indexing-engines/ponder.ts | 2 +- .../indexing-metadata-context-builder.mock.ts | 101 ++++++++++++ .../indexing-metadata-context-builder.test.ts | 107 ++----------- .../indexing-metadata-context-builder.ts | 2 +- .../src/lib/stack-info-builder/index.ts | 1 + .../stack-info-builder/stack-info-builder.ts | 10 +- .../ensdb-sdk/src/client/ensdb-reader.test.ts | 4 +- .../indexing-metadata-context.test.ts | 149 ++++++++++++++++++ .../validate/indexing-metadata-context.ts | 7 +- .../zod-schemas/indexing-metadata-context.ts | 16 +- .../integration-test-env/src/orchestrator.ts | 6 +- 13 files changed, 298 insertions(+), 116 deletions(-) create mode 100644 apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.mock.ts create mode 100644 apps/ensindexer/src/lib/stack-info-builder/index.ts create mode 100644 packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.test.ts diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index df899a7df..a29ab2bf7 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -24,7 +24,6 @@ import { VALID_RPC_URL, } from "@/config/config.schema.mock"; import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; -import type { EnsApiEnvironment } from "@/config/environment"; import logger from "@/lib/logger"; vi.mock("@/lib/logger", () => ({ diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 6e4bd8b16..7d733c9cb 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -8,7 +8,7 @@ import { logger } from "@/lib/logger"; /** * Interval in seconds between two consecutive attempts to upsert - * the Indexing Status Snapshot record into ENSDb. + * the IndexingMetadataContext record into ENSDb. */ const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 1; @@ -20,7 +20,7 @@ const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 1; */ export class EnsDbWriterWorker { /** - * Interval for recurring updates of Indexing Status Snapshots into ENSDb. + * Interval for recurring updates of IndexingMetadataContext record in ENSDb. */ private indexingStatusUpdateInterval: ReturnType | null = null; @@ -61,7 +61,7 @@ export class EnsDbWriterWorker { throw new Error("EnsDbWriterWorker is already running"); } - // Recurring update of the IndexingMetadataRecord record in ENSDb. + // Recurring update of the IndexingMetadataContext record in ENSDb. this.indexingStatusUpdateInterval = setInterval( () => this.updateIndexingMetadataContext().catch((error) => { @@ -95,7 +95,7 @@ export class EnsDbWriterWorker { } /** - * Update the current Indexing Status Snapshot into ENSDb. + * Update the current IndexingMetadataContext into ENSDb. * * This method is called by the scheduler at regular intervals from {@link run}. * diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 922bc577b..4dfda43f6 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -202,7 +202,7 @@ function recordEventForEps(): void { * Some event handlers may have preconditions that need to be met before * they can run. * - * The Setup and Onchain preconditions are memoized and execute their logic only + * The Onchain preconditions are memoized and execute their logic only * once per process, regardless of how often this function is called — essential * because it's invoked for every indexed event. EPS accounting via * {@link recordEventForEps} runs on every call, but its hot-path cost is a diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.mock.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.mock.ts new file mode 100644 index 000000000..0f85c3093 --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.mock.ts @@ -0,0 +1,101 @@ +import { vi } from "vitest"; + +import type { EnsDbReader } from "@ensnode/ensdb-sdk"; +import { + type CrossChainIndexingStatusSnapshot, + type EnsIndexerStackInfo, + type IndexingMetadataContext, + type IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, +} from "@ensnode/ensnode-sdk"; +import type { LocalPonderClient } from "@ensnode/ponder-sdk"; + +import { IndexingMetadataContextBuilder } from "@/lib/indexing-metadata-context-builder/indexing-metadata-context-builder"; +import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder"; +import type { StackInfoBuilder } from "@/lib/stack-info-builder"; + +export const omnichainSnapshotUnstarted: OmnichainIndexingStatusSnapshot = { + omnichainStatus: OmnichainIndexingStatusIds.Unstarted, + omnichainIndexingCursor: 0, + chains: new Map(), +}; + +export const omnichainSnapshotFollowing: OmnichainIndexingStatusSnapshot = { + omnichainStatus: OmnichainIndexingStatusIds.Following, + omnichainIndexingCursor: 100, + chains: new Map(), +}; + +export const crossChainSnapshot: CrossChainIndexingStatusSnapshot = { + strategy: "omnichain" as any, + slowestChainIndexingCursor: 100, + snapshotTime: 200, + omnichainSnapshot: omnichainSnapshotFollowing, +}; + +export const stackInfo: EnsIndexerStackInfo = { + ensDb: { versionInfo: { postgresql: "17.4" } }, + ensIndexer: {} as any, + ensRainbow: {} as any, +}; + +export const indexingMetadataContextInitialized: IndexingMetadataContextInitialized = { + statusCode: IndexingMetadataContextStatusCodes.Initialized, + indexingStatus: crossChainSnapshot, + stackInfo, +}; + +export const indexingMetadataContextUninitialized: IndexingMetadataContext = { + statusCode: IndexingMetadataContextStatusCodes.Uninitialized, +}; + +export function createMockEnsDbReader( + overrides: Partial> = {}, +): EnsDbReader { + return { + getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextUninitialized), + ...overrides, + } as unknown as EnsDbReader; +} + +export function createMockIndexingStatusBuilder( + resolvedSnapshot: OmnichainIndexingStatusSnapshot = omnichainSnapshotUnstarted, +): IndexingStatusBuilder { + return { + getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(resolvedSnapshot), + } as unknown as IndexingStatusBuilder; +} + +export function createMockStackInfoBuilder( + resolvedStackInfo: EnsIndexerStackInfo = stackInfo, +): StackInfoBuilder { + return { + getStackInfo: vi.fn().mockResolvedValue(resolvedStackInfo), + } as unknown as StackInfoBuilder; +} + +export function createMockLocalPonderClient( + options: { isInDevMode?: boolean } = {}, +): LocalPonderClient { + return { + isInDevMode: options.isInDevMode ?? false, + } as unknown as LocalPonderClient; +} + +export function createIndexingMetadataContextBuilder( + overrides: { + ensDbClient?: EnsDbReader; + indexingStatusBuilder?: IndexingStatusBuilder; + stackInfoBuilder?: StackInfoBuilder; + localPonderClient?: LocalPonderClient; + } = {}, +): IndexingMetadataContextBuilder { + return new IndexingMetadataContextBuilder( + overrides.ensDbClient ?? createMockEnsDbReader(), + overrides.indexingStatusBuilder ?? createMockIndexingStatusBuilder(), + overrides.stackInfoBuilder ?? createMockStackInfoBuilder(), + overrides.localPonderClient ?? createMockLocalPonderClient(), + ); +} diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts index d8bed80c7..24020155b 100644 --- a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.test.ts @@ -1,26 +1,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { EnsDbReader } from "@ensnode/ensdb-sdk"; import { buildCrossChainIndexingStatusSnapshotOmnichain, buildIndexingMetadataContextInitialized, - type CrossChainIndexingStatusSnapshot, - type EnsIndexerStackInfo, - type IndexingMetadataContext, type IndexingMetadataContextInitialized, - IndexingMetadataContextStatusCodes, - OmnichainIndexingStatusIds, - type OmnichainIndexingStatusSnapshot, validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; -import type { LocalPonderClient } from "@ensnode/ponder-sdk"; import "@/lib/__test__/mockLogger"; -import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; -import type { StackInfoBuilder } from "@/lib/stack-info-builder/stack-info-builder"; - -import { IndexingMetadataContextBuilder } from "./indexing-metadata-context-builder"; +import { + createIndexingMetadataContextBuilder, + createMockEnsDbReader, + createMockIndexingStatusBuilder, + createMockLocalPonderClient, + createMockStackInfoBuilder, + crossChainSnapshot, + indexingMetadataContextInitialized, + indexingMetadataContextUninitialized, + omnichainSnapshotFollowing, + omnichainSnapshotUnstarted, + stackInfo, +} from "@/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.mock"; vi.mock("@ensnode/ensnode-sdk", async () => { const actual = await vi.importActual("@ensnode/ensnode-sdk"); @@ -33,88 +34,6 @@ vi.mock("@ensnode/ensnode-sdk", async () => { }; }); -const omnichainSnapshotUnstarted: OmnichainIndexingStatusSnapshot = { - omnichainStatus: OmnichainIndexingStatusIds.Unstarted, - omnichainIndexingCursor: 0, - chains: new Map(), -}; - -const omnichainSnapshotFollowing: OmnichainIndexingStatusSnapshot = { - omnichainStatus: OmnichainIndexingStatusIds.Following, - omnichainIndexingCursor: 100, - chains: new Map(), -}; - -const crossChainSnapshot: CrossChainIndexingStatusSnapshot = { - strategy: "omnichain" as any, - slowestChainIndexingCursor: 100, - snapshotTime: 200, - omnichainSnapshot: omnichainSnapshotFollowing, -}; - -const stackInfo: EnsIndexerStackInfo = { - ensDb: { versionInfo: { postgresql: "17.4" } }, - ensIndexer: {} as any, - ensRainbow: {} as any, -}; - -const indexingMetadataContextInitialized: IndexingMetadataContextInitialized = { - statusCode: IndexingMetadataContextStatusCodes.Initialized, - indexingStatus: crossChainSnapshot, - stackInfo, -}; - -const indexingMetadataContextUninitialized: IndexingMetadataContext = { - statusCode: IndexingMetadataContextStatusCodes.Uninitialized, -}; - -function createMockEnsDbReader( - overrides: Partial> = {}, -): EnsDbReader { - return { - getIndexingMetadataContext: vi.fn().mockResolvedValue(indexingMetadataContextUninitialized), - ...overrides, - } as unknown as EnsDbReader; -} - -function createMockIndexingStatusBuilder( - resolvedSnapshot: OmnichainIndexingStatusSnapshot = omnichainSnapshotUnstarted, -): IndexingStatusBuilder { - return { - getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(resolvedSnapshot), - } as unknown as IndexingStatusBuilder; -} - -function createMockStackInfoBuilder( - resolvedStackInfo: EnsIndexerStackInfo = stackInfo, -): StackInfoBuilder { - return { - getStackInfo: vi.fn().mockResolvedValue(resolvedStackInfo), - } as unknown as StackInfoBuilder; -} - -function createMockLocalPonderClient(options: { isInDevMode?: boolean } = {}): LocalPonderClient { - return { - isInDevMode: options.isInDevMode ?? false, - } as unknown as LocalPonderClient; -} - -function createIndexingMetadataContextBuilder( - overrides: { - ensDbClient?: EnsDbReader; - indexingStatusBuilder?: IndexingStatusBuilder; - stackInfoBuilder?: StackInfoBuilder; - localPonderClient?: LocalPonderClient; - } = {}, -): IndexingMetadataContextBuilder { - return new IndexingMetadataContextBuilder( - overrides.ensDbClient ?? createMockEnsDbReader(), - overrides.indexingStatusBuilder ?? createMockIndexingStatusBuilder(), - overrides.stackInfoBuilder ?? createMockStackInfoBuilder(), - overrides.localPonderClient ?? createMockLocalPonderClient(), - ); -} - describe("IndexingMetadataContextBuilder", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts index 4a9272752..1850a76c7 100644 --- a/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts +++ b/apps/ensindexer/src/lib/indexing-metadata-context-builder/indexing-metadata-context-builder.ts @@ -77,7 +77,7 @@ export class IndexingMetadataContextBuilder { /** * Get the current {@link IndexingMetadataContextInitialized} object. * - * Expected to be called while writing an {@link IndexingMetadataContextInitialized} record into ENSDb + * Expected to be called while writing the {@link IndexingMetadataContextInitialized} record in ENSDb. */ async getIndexingMetadataContext(): Promise { const [ diff --git a/apps/ensindexer/src/lib/stack-info-builder/index.ts b/apps/ensindexer/src/lib/stack-info-builder/index.ts new file mode 100644 index 000000000..9df1b93e9 --- /dev/null +++ b/apps/ensindexer/src/lib/stack-info-builder/index.ts @@ -0,0 +1 @@ +export * from "./stack-info-builder"; diff --git a/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts index c70c7566d..4418829a7 100644 --- a/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts +++ b/apps/ensindexer/src/lib/stack-info-builder/stack-info-builder.ts @@ -30,9 +30,13 @@ export class StackInfoBuilder { */ async getStackInfo(): Promise { if (typeof this.immutableStackInfo === "undefined") { - const ensDbPublicConfig = await this.ensDbClient.buildEnsDbPublicConfig(); - const ensIndexerPublicConfig = await this.publicConfigBuilder.getPublicConfig(); - const ensRainbowPublicConfig = await this.ensRainbowClient.config(); + const [ensDbPublicConfig, ensIndexerPublicConfig, ensRainbowPublicConfig] = await Promise.all( + [ + this.ensDbClient.buildEnsDbPublicConfig(), + this.publicConfigBuilder.getPublicConfig(), + this.ensRainbowClient.config(), + ], + ); this.immutableStackInfo = buildEnsIndexerStackInfo( ensDbPublicConfig, diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts index 0be99368c..53646768e 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts @@ -5,7 +5,6 @@ import { buildIndexingMetadataContextInitialized, buildIndexingMetadataContextUninitialized, deserializeCrossChainIndexingStatusSnapshot, - deserializeIndexingMetadataContext, type EnsDbPublicConfig, serializeIndexingMetadataContext, } from "@ensnode/ensnode-sdk"; @@ -207,8 +206,7 @@ describe("EnsDbReader", () => { const result = await createEnsDbReader().getIndexingMetadataContext(); - const expected = deserializeIndexingMetadataContext(serialized); - expect(result).toStrictEqual(expected); + expect(result).toStrictEqual(context); }); // This scenario should be impossible due to the primary key constraint on diff --git a/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.test.ts b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.test.ts new file mode 100644 index 000000000..3fbf0fe19 --- /dev/null +++ b/packages/ensnode-sdk/src/ensnode/metadata/indexing-metadata-context.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import { ChainIndexingStatusIds } from "../../indexing-status/chain-indexing-status-snapshot"; +import { buildCrossChainIndexingStatusSnapshotOmnichain } from "../../indexing-status/cross-chain-indexing-status-snapshot"; +import { OmnichainIndexingStatusIds } from "../../indexing-status/omnichain-indexing-status-snapshot"; +import { RangeTypeIds } from "../../shared/blockrange"; +import { buildEnsIndexerStackInfo } from "../../stack-info/ensindexer-stack-info"; +import { deserializeIndexingMetadataContext } from "./deserialize/indexing-metadata-context"; +import { + buildIndexingMetadataContextInitialized, + buildIndexingMetadataContextUninitialized, + IndexingMetadataContextStatusCodes, +} from "./indexing-metadata-context"; +import { serializeIndexingMetadataContext } from "./serialize/indexing-metadata-context"; +import { validateIndexingMetadataContextInitialized } from "./validate/indexing-metadata-context"; + +function makeMinimalIndexingStatus() { + const earlierBlock = { timestamp: 1672531199, number: 1024 }; + const laterBlock = { timestamp: 1672531200, number: 1025 }; + const snapshotTime = laterBlock.timestamp; + + return buildCrossChainIndexingStatusSnapshotOmnichain( + { + omnichainStatus: OmnichainIndexingStatusIds.Following, + chains: new Map([ + [ + 1, + { + chainStatus: ChainIndexingStatusIds.Following, + config: { rangeType: RangeTypeIds.LeftBounded, startBlock: earlierBlock }, + latestIndexedBlock: earlierBlock, + latestKnownBlock: laterBlock, + }, + ], + ]), + omnichainIndexingCursor: earlierBlock.timestamp, + }, + snapshotTime, + ); +} + +function makeMinimalStackInfo() { + return buildEnsIndexerStackInfo( + { versionInfo: { postgresql: "17.4" } }, + { + namespace: "mainnet", + clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, + ensIndexerSchemaName: "test_schema", + ensRainbowPublicConfig: { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.0.0" }, + }, + indexedChainIds: new Set([1]), + isSubgraphCompatible: true, + plugins: ["subgraph"], + versionInfo: { + ponder: "0.11.25", + ensDb: "0.32.0", + ensIndexer: "0.32.0", + ensNormalize: "1.11.1", + }, + }, + { + serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + versionInfo: { ensRainbow: "1.0.0" }, + }, + ); +} + +describe("buildIndexingMetadataContextUninitialized", () => { + it('returns an object with statusCode "uninitialized"', () => { + const result = buildIndexingMetadataContextUninitialized(); + + expect(result).toStrictEqual({ + statusCode: IndexingMetadataContextStatusCodes.Uninitialized, + }); + }); +}); + +describe("buildIndexingMetadataContextInitialized", () => { + it("constructs a valid initialized context from valid inputs", () => { + const indexingStatus = makeMinimalIndexingStatus(); + const stackInfo = makeMinimalStackInfo(); + + const result = buildIndexingMetadataContextInitialized(indexingStatus, stackInfo); + + expect(result.statusCode).toBe(IndexingMetadataContextStatusCodes.Initialized); + expect(result.indexingStatus).toStrictEqual(indexingStatus); + expect(result.stackInfo).toStrictEqual(stackInfo); + }); + + it("throws when indexingStatus is invalid", () => { + const stackInfo = makeMinimalStackInfo(); + + expect(() => buildIndexingMetadataContextInitialized({} as any, stackInfo)).toThrow(); + }); + + it("throws when stackInfo is invalid", () => { + const indexingStatus = makeMinimalIndexingStatus(); + + expect(() => buildIndexingMetadataContextInitialized(indexingStatus, {} as any)).toThrow(); + }); +}); + +describe("serializeIndexingMetadataContext / deserializeIndexingMetadataContext roundtrip", () => { + it("roundtrips an uninitialized context", () => { + const context = buildIndexingMetadataContextUninitialized(); + const serialized = serializeIndexingMetadataContext(context); + const deserialized = deserializeIndexingMetadataContext(serialized); + + expect(deserialized).toStrictEqual(context); + }); + + it("roundtrips an initialized context", () => { + const context = buildIndexingMetadataContextInitialized( + makeMinimalIndexingStatus(), + makeMinimalStackInfo(), + ); + const serialized = serializeIndexingMetadataContext(context); + const deserialized = deserializeIndexingMetadataContext(serialized); + + expect(deserialized).toStrictEqual(context); + }); +}); + +describe("deserializeIndexingMetadataContext", () => { + it("rejects malformed serialized data (missing statusCode)", () => { + expect(() => deserializeIndexingMetadataContext({} as any)).toThrow(); + }); + + it("rejects serialized data with unrecognized statusCode", () => { + expect(() => deserializeIndexingMetadataContext({ statusCode: "bogus" } as any)).toThrow(); + }); + + it("rejects non-object input", () => { + expect(() => deserializeIndexingMetadataContext(null as any)).toThrow(); + expect(() => deserializeIndexingMetadataContext("not an object" as any)).toThrow(); + }); +}); + +describe("validateIndexingMetadataContextInitialized", () => { + it("rejects an uninitialized statusCode", () => { + expect(() => + validateIndexingMetadataContextInitialized({ + statusCode: IndexingMetadataContextStatusCodes.Uninitialized, + } as any), + ).toThrow(); + }); +}); diff --git a/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts index 691c97c8a..290eaa1e7 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/validate/indexing-metadata-context.ts @@ -11,14 +11,13 @@ export function validateIndexingMetadataContextInitialized( maybeIndexingMetadataContext: Unvalidated, valueLabel?: string, ): IndexingMetadataContextInitialized { - const result = makeIndexingMetadataContextInitializedSchema(valueLabel).safeParse( + const label = valueLabel ?? "IndexingMetadataContextInitialized"; + const result = makeIndexingMetadataContextInitializedSchema(label).safeParse( maybeIndexingMetadataContext, ); if (result.error) { - throw new Error( - `Cannot validate IndexingMetadataContextInitialized:\n${prettifyError(result.error)}\n`, - ); + throw new Error(`Cannot validate ${label}:\n${prettifyError(result.error)}\n`); } return result.data; } diff --git a/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts b/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts index 25bead23b..10291b162 100644 --- a/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts +++ b/packages/ensnode-sdk/src/ensnode/metadata/zod-schemas/indexing-metadata-context.ts @@ -10,9 +10,13 @@ import { } from "../../../stack-info/zod-schemas/ensindexer-stack-info"; import { IndexingMetadataContextStatusCodes } from "../indexing-metadata-context"; -const makeSerializedIndexingMetadataContextUninitializedSchema = (_valueLabel?: string) => { +const makeSerializedIndexingMetadataContextUninitializedSchema = (valueLabel?: string) => { + const label = valueLabel ?? "SerializedIndexingMetadataContextUninitialized"; + return z.object({ - statusCode: z.literal(IndexingMetadataContextStatusCodes.Uninitialized), + statusCode: z.literal(IndexingMetadataContextStatusCodes.Uninitialized, { + error: `${label} must have status code ${IndexingMetadataContextStatusCodes.Uninitialized}`, + }), }); }; @@ -20,7 +24,9 @@ export const makeSerializedIndexingMetadataContextInitializedSchema = (valueLabe const label = valueLabel ?? "SerializedIndexingMetadataContextInitialized"; return z.object({ - statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized), + statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized, { + error: `${label} must have status code ${IndexingMetadataContextStatusCodes.Initialized}`, + }), indexingStatus: makeSerializedCrossChainIndexingStatusSnapshotSchema(`${label}.indexingStatus`), stackInfo: makeSerializedEnsIndexerStackInfoSchema(`${label}.stackInfo`), }); @@ -42,7 +48,9 @@ export const makeIndexingMetadataContextInitializedSchema = (valueLabel?: string const label = valueLabel ?? "IndexingMetadataContextInitialized"; return z.object({ - statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized), + statusCode: z.literal(IndexingMetadataContextStatusCodes.Initialized, { + error: `${label} must have status code ${IndexingMetadataContextStatusCodes.Initialized}`, + }), indexingStatus: makeCrossChainIndexingStatusSnapshotSchema(`${label}.indexingStatus`), stackInfo: makeEnsIndexerStackInfoSchema(`${label}.stackInfo`), }); diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index c8d5f9c17..e713f44d9 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -204,7 +204,11 @@ async function pollIndexingStatus( try { const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); - if (indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Initialized) { + if ( + indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized + ) { + console.log("IndexingMetadataContext is uninitialized, waiting..."); + } else { const { omnichainStatus } = indexingMetadataContext.indexingStatus.omnichainSnapshot; log(`Omnichain status: ${omnichainStatus}`); if ( From 110927d9fdc8c654d519f6e613f64a57dd42d880 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 28 Apr 2026 10:27:54 +0200 Subject: [PATCH 29/33] Add changeset --- .changeset/clean-rivers-buy.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/clean-rivers-buy.md diff --git a/.changeset/clean-rivers-buy.md b/.changeset/clean-rivers-buy.md new file mode 100644 index 000000000..5b9991d6d --- /dev/null +++ b/.changeset/clean-rivers-buy.md @@ -0,0 +1,19 @@ +--- +"@ensnode/integration-test-env": minor +"@ensnode/ensnode-sdk": minor +"@ensnode/ensdb-sdk": minor +"ensindexer": minor +"ensapi": minor +--- + +Introduced `IndexingMetadataContext` data model which a single record type in ENSNode Metadata table replacing three separate record types (`ensdb_version`, `ensindexer_public_config`, `ensindexer_indexing_status`). Also, consolidated startup init into `initIndexingOnchainEvents()` for reliable execution on every ENSIndexer startup. + +**ensnode-sdk**: `EnsIndexerStackInfo` added as base type, `EnsNodeStackInfo` refactored to extend it. + +**ensdb-sdk**: For `EnsDbReader`, added following method: `getIndexingMetadataContext()`, `isHealthy()`, `isReady()`. For `EnsDbWriter`, added `upsertIndexingMetadataContext()` method. Old per-record read/write methods removed. `EnsNodeMetadataKeys` reduced to single `IndexingMetadataContext` key. + +**ensindexer**: `IndexingMetadataContextBuilder` and `StackInfoBuilder` added. `EnsDbWriterWorker` simplified to single recurring task. HTTP `/config` and `/indexing-status` endpoints now read from in-memory builders instead of ENSDb. `initializeIndexingSetup`/`initializeIndexingActivation` replaced by `initIndexingOnchainEvents`. + +**ensapi**: `indexing-status.cache` and `stack-info.cache` updated to consume `IndexingMetadataContext`. Config schema updated to fetch `EnsIndexerPublicConfig` from `EnsNodeStackInfo`. + +**integration-test-env**: `pollIndexingStatus` updated to use `getIndexingMetadataContext()`. From 698191f9c16eb600e193def0314e0842c0e10c98 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 28 Apr 2026 21:02:42 +0200 Subject: [PATCH 30/33] Apply AI PR feedback --- .../ensdb-writer-worker/ensdb-writer-worker.ts | 16 ++++++++-------- packages/ensdb-sdk/src/client/ensdb-reader.ts | 7 +++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 7d733c9cb..c28d86bda 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -10,7 +10,7 @@ import { logger } from "@/lib/logger"; * Interval in seconds between two consecutive attempts to upsert * the IndexingMetadataContext record into ENSDb. */ -const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 1; +const INDEXING_METADATA_CONTEXT_RECORD_UPDATE_INTERVAL: Duration = 1; /** * ENSDb Writer Worker @@ -22,7 +22,7 @@ export class EnsDbWriterWorker { /** * Interval for recurring updates of IndexingMetadataContext record in ENSDb. */ - private indexingStatusUpdateInterval: ReturnType | null = null; + private indexingMetadataContextUpdateInterval: ReturnType | null = null; /** * {@link EnsDbWriter} instance used by the worker to interact with ENSDb instance. @@ -62,7 +62,7 @@ export class EnsDbWriterWorker { } // Recurring update of the IndexingMetadataContext record in ENSDb. - this.indexingStatusUpdateInterval = setInterval( + this.indexingMetadataContextUpdateInterval = setInterval( () => this.updateIndexingMetadataContext().catch((error) => { logger.error({ @@ -71,7 +71,7 @@ export class EnsDbWriterWorker { error, }); }), - secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL), + secondsToMilliseconds(INDEXING_METADATA_CONTEXT_RECORD_UPDATE_INTERVAL), ); } @@ -79,7 +79,7 @@ export class EnsDbWriterWorker { * Indicates whether the ENSDb Writer Worker is currently running. */ get isRunning(): boolean { - return this.indexingStatusUpdateInterval !== null; + return this.indexingMetadataContextUpdateInterval !== null; } /** @@ -88,9 +88,9 @@ export class EnsDbWriterWorker { * Stops all recurring tasks in the worker. */ public stop(): void { - if (this.indexingStatusUpdateInterval) { - clearInterval(this.indexingStatusUpdateInterval); - this.indexingStatusUpdateInterval = null; + if (this.indexingMetadataContextUpdateInterval) { + clearInterval(this.indexingMetadataContextUpdateInterval); + this.indexingMetadataContextUpdateInterval = null; } } diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.ts b/packages/ensdb-sdk/src/client/ensdb-reader.ts index 72b9614c6..8bc386c0b 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.ts @@ -9,6 +9,13 @@ import { IndexingMetadataContextStatusCodes, } from "@ensnode/ensnode-sdk"; +export { + IndexingMetadataContext, + IndexingMetadataContextInitialized, + IndexingMetadataContextStatusCodes, + IndexingMetadataContextUninitialized, +} from "@ensnode/ensnode-sdk"; + import { type AbstractEnsIndexerSchema, buildEnsDbDrizzleClient, From 725901fe899b207fa7e96a3af4bc23562973d339 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 29 Apr 2026 09:47:43 +0200 Subject: [PATCH 31/33] Fix re-exports --- packages/ensdb-sdk/src/client/ensdb-reader.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.ts b/packages/ensdb-sdk/src/client/ensdb-reader.ts index 8bc386c0b..22924bba4 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.ts @@ -10,10 +10,10 @@ import { } from "@ensnode/ensnode-sdk"; export { - IndexingMetadataContext, - IndexingMetadataContextInitialized, + type IndexingMetadataContext, + type IndexingMetadataContextInitialized, IndexingMetadataContextStatusCodes, - IndexingMetadataContextUninitialized, + type IndexingMetadataContextUninitialized, } from "@ensnode/ensnode-sdk"; import { From 84b52e1d71c3d962bd2c117f81b391439d8ef4a8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 29 Apr 2026 15:18:14 +0200 Subject: [PATCH 32/33] Create ENSRainbow readiness logic inline in ENSIndexer --- apps/ensapi/src/cache/stack-info.cache.ts | 9 ----- .../src/lib/ensrainbow/singleton.ts | 38 ++++++++++++------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/ensapi/src/cache/stack-info.cache.ts b/apps/ensapi/src/cache/stack-info.cache.ts index 0df28eb6f..243f9738c 100644 --- a/apps/ensapi/src/cache/stack-info.cache.ts +++ b/apps/ensapi/src/cache/stack-info.cache.ts @@ -41,15 +41,6 @@ export type EnsNodeStackInfoCache = SWRCache; */ export const stackInfoCache = lazyProxy( () => - /** - * Cache for ENSNode stack info - * - * Once initialized successfully, this cache will always return - * the same {@link EnsNodeStackInfo} for the lifecycle of the ENSApi instance. - * - * If initialization fails, it will keep retrying on access until it succeeds, - * which is desirable because the {@link EnsNodeStackInfo} is critical for the functioning of the application and we want to recover from transient initialization failures without requiring a restart. - */ new SWRCache({ fn: async function loadEnsNodeStackInfo() { try { diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 5c4ba97d4..c6e11a210 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -119,20 +119,30 @@ export function waitForEnsRainbowToBeReady(): Promise { ensRainbowInstance: ensRainbowUrl.href, }); - waitForEnsRainbowToBeReadyPromise = pRetry(async () => ensRainbowClient.health(), { - retries: 60, // This allows for a total of over 1 hour of retries with 1 minute between attempts. - minTimeout: secondsToMilliseconds(60), - maxTimeout: secondsToMilliseconds(60), - onFailedAttempt: ({ attemptNumber, retriesLeft }) => { - logger.warn({ - msg: `ENSRainbow health check failed`, - attempt: attemptNumber, - retriesLeft, - ensRainbowInstance: ensRainbowUrl.href, - advice: `This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, - }); + waitForEnsRainbowToBeReadyPromise = pRetry( + // TODO: replace this count check with an explicit `ready()` method in ENSRainbow Client. + async () => { + const { count } = await ensRainbowClient.count(); + + if (count === 0) { + throw new Error("ENSRainbow instance is not ready yet."); + } }, - }) + { + retries: 60, // This allows for a total of over 1 hour of retries with 1 minute between attempts. + minTimeout: secondsToMilliseconds(60), + maxTimeout: secondsToMilliseconds(60), + onFailedAttempt: ({ attemptNumber, retriesLeft }) => { + logger.warn({ + msg: `ENSRainbow readiness check failed`, + attempt: attemptNumber, + retriesLeft, + ensRainbowInstance: ensRainbowUrl.href, + advice: `This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, + }); + }, + }, + ) .then(() => { logger.info({ msg: `ENSRainbow instance is ready`, @@ -141,7 +151,7 @@ export function waitForEnsRainbowToBeReady(): Promise { }) .catch((error) => { logger.error({ - msg: `ENSRainbow health check failed after multiple attempts`, + msg: `ENSRainbow readiness check failed after multiple attempts`, error, ensRainbowInstance: ensRainbowUrl.href, }); From 5cc44ea01d294fc7b8fda40a8da4f06a9396e3be Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 29 Apr 2026 15:20:11 +0200 Subject: [PATCH 33/33] Fix typo --- .changeset/clean-rivers-buy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/clean-rivers-buy.md b/.changeset/clean-rivers-buy.md index 5b9991d6d..814feb846 100644 --- a/.changeset/clean-rivers-buy.md +++ b/.changeset/clean-rivers-buy.md @@ -6,7 +6,7 @@ "ensapi": minor --- -Introduced `IndexingMetadataContext` data model which a single record type in ENSNode Metadata table replacing three separate record types (`ensdb_version`, `ensindexer_public_config`, `ensindexer_indexing_status`). Also, consolidated startup init into `initIndexingOnchainEvents()` for reliable execution on every ENSIndexer startup. +Introduced `IndexingMetadataContext` data model, a single record type in ENSNode Metadata table replacing three separate record types (`ensdb_version`, `ensindexer_public_config`, `ensindexer_indexing_status`). Also, consolidated startup init into `initIndexingOnchainEvents()` for reliable execution on every ENSIndexer startup. **ensnode-sdk**: `EnsIndexerStackInfo` added as base type, `EnsNodeStackInfo` refactored to extend it.