From 4b125d0dba1fca1759ad60758f3c5aa0eaab93b5 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 24 Apr 2026 16:58:40 +0200 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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;