From 9aa612d6cd890b5e406d23d3e1ce3fd4635f7bcf Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 22 Mar 2026 08:26:25 +0100 Subject: [PATCH 1/8] Introduce `PonderAppContext` data model to Ponder SDK This data model allows to capture the internal context object of the local Ponder app --- .../src/deserialize/indexing-metrics.mock.ts | 2 +- .../src/deserialize/indexing-metrics.ts | 6 +- .../src/deserialize/ponder-app-context.ts | 67 +++++++++++++++++++ packages/ponder-sdk/src/index.ts | 2 + packages/ponder-sdk/src/indexing-metrics.ts | 13 +--- .../src/local-ponder-client.test.ts | 2 +- packages/ponder-sdk/src/ponder-app-context.ts | 23 +++++++ 7 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 packages/ponder-sdk/src/deserialize/ponder-app-context.ts create mode 100644 packages/ponder-sdk/src/ponder-app-context.ts diff --git a/packages/ponder-sdk/src/deserialize/indexing-metrics.mock.ts b/packages/ponder-sdk/src/deserialize/indexing-metrics.mock.ts index 64dff3b20e..98567c0da3 100644 --- a/packages/ponder-sdk/src/deserialize/indexing-metrics.mock.ts +++ b/packages/ponder-sdk/src/deserialize/indexing-metrics.mock.ts @@ -1,10 +1,10 @@ import { type ChainIndexingMetricsRealtime, ChainIndexingStates, - PonderAppCommands, type PonderIndexingMetrics, PonderIndexingOrderings, } from "../indexing-metrics"; +import { PonderAppCommands } from "../ponder-app-context"; export const indexingMetricsMockValid = { text: ` diff --git a/packages/ponder-sdk/src/deserialize/indexing-metrics.ts b/packages/ponder-sdk/src/deserialize/indexing-metrics.ts index 5ef3ada3d2..6eca2cb3f4 100644 --- a/packages/ponder-sdk/src/deserialize/indexing-metrics.ts +++ b/packages/ponder-sdk/src/deserialize/indexing-metrics.ts @@ -17,14 +17,14 @@ import { type ChainIndexingMetricsHistorical, type ChainIndexingMetricsRealtime, ChainIndexingStates, - type PonderAppCommand, - PonderAppCommands, type PonderIndexingMetrics, type PonderIndexingOrdering, PonderIndexingOrderings, } from "../indexing-metrics"; import { schemaPositiveInteger } from "../numbers"; +import type { PonderAppCommand } from "../ponder-app-context"; import { schemaChainIdString } from "./chains"; +import { PonderAppCommandSchema } from "./ponder-app-context"; import { deserializePrometheusMetrics, type PrometheusMetrics } from "./prometheus-metrics-text"; import type { Unvalidated } from "./utils"; @@ -158,7 +158,7 @@ function invariant_includesAtLeastOneIndexedChain(ctx: ParsePayload; + +/** + * Schema representing the context of a local Ponder app. + */ +const schemaPonderAppContext = z.object({ + command: PonderAppCommandSchema, +}); + +/** + * Build unvalidated Ponder App Context + * + * @param rawPonderAppContext valid raw Ponder App Context from Ponder app. + * @returns Unvalidated Ponder App Context + * to be validated with {@link schemaPonderAppContext}. + */ +export function buildUnvalidatedPonderAppContext( + rawPonderAppContext: RawPonderAppContext, +): Unvalidated { + return { + command: rawPonderAppContext.options.command as Unvalidated, + }; +} + +/** + * Deserialize and validate a Raw Ponder App Context. + * + * @param unvalidatedRawPonderAppContext Raw Ponder App Context to be validated. + * @returns Deserialized and validated Ponder App Context. + * @throws Error if data cannot be deserialized into a valid Ponder App Context. + */ +export function deserializePonderAppContext( + unvalidatedRawPonderAppContext: Unvalidated, +): PonderAppContext { + const validation = schemaRawPonderAppContext + .transform(buildUnvalidatedPonderAppContext) + .pipe(schemaPonderAppContext) + .safeParse(unvalidatedRawPonderAppContext); + + if (!validation.success) { + throw new Error(`Invalid raw Ponder App Context: ${prettifyError(validation.error)}`); + } + + return validation.data; +} diff --git a/packages/ponder-sdk/src/index.ts b/packages/ponder-sdk/src/index.ts index 05cfb4bdcf..a3be62ccc8 100644 --- a/packages/ponder-sdk/src/index.ts +++ b/packages/ponder-sdk/src/index.ts @@ -2,10 +2,12 @@ export * from "./blockrange"; export * from "./blocks"; export * from "./chains"; export * from "./client"; +export * from "./deserialize/ponder-app-context"; export * from "./indexing-config"; export * from "./indexing-metrics"; export * from "./indexing-status"; export * from "./local-indexing-metrics"; export * from "./local-ponder-client"; export * from "./numbers"; +export * from "./ponder-app-context"; export * from "./time"; diff --git a/packages/ponder-sdk/src/indexing-metrics.ts b/packages/ponder-sdk/src/indexing-metrics.ts index 24d0dc31fd..fbc407b25b 100644 --- a/packages/ponder-sdk/src/indexing-metrics.ts +++ b/packages/ponder-sdk/src/indexing-metrics.ts @@ -1,17 +1,6 @@ import type { BlockRef } from "./blocks"; import type { ChainId } from "./chains"; - -/** - * Ponder Application Commands - * - * Represents the commands that can be used to start a Ponder app. - */ -export const PonderAppCommands = { - Dev: "dev", - Start: "start", -} as const; - -export type PonderAppCommand = (typeof PonderAppCommands)[keyof typeof PonderAppCommands]; +import type { PonderAppCommand } from "./ponder-app-context"; /** * Ponder Indexing Orderings diff --git a/packages/ponder-sdk/src/local-ponder-client.test.ts b/packages/ponder-sdk/src/local-ponder-client.test.ts index 6c43c3209e..e3f4089c28 100644 --- a/packages/ponder-sdk/src/local-ponder-client.test.ts +++ b/packages/ponder-sdk/src/local-ponder-client.test.ts @@ -9,11 +9,11 @@ import { type ChainIndexingMetricsHistorical, type ChainIndexingMetricsRealtime, ChainIndexingStates, - PonderAppCommands, type PonderIndexingMetrics, PonderIndexingOrderings, } from "./indexing-metrics"; import { chainIds, createLocalPonderClientMock } from "./local-ponder-client.mock"; +import { PonderAppCommands } from "./ponder-app-context"; describe("LocalPonderClient", () => { afterEach(() => { diff --git a/packages/ponder-sdk/src/ponder-app-context.ts b/packages/ponder-sdk/src/ponder-app-context.ts new file mode 100644 index 0000000000..9964efdee9 --- /dev/null +++ b/packages/ponder-sdk/src/ponder-app-context.ts @@ -0,0 +1,23 @@ +/** + * Ponder app commands + * + * Represents the commands that can be used to start a Ponder app. + */ +export const PonderAppCommands = { + Dev: "dev", + Start: "start", +} as const; + +export type PonderAppCommand = (typeof PonderAppCommands)[keyof typeof PonderAppCommands]; + +/** + * Ponder app context + * + * Represents the internal context of a local Ponder app. + */ +export interface PonderAppContext { + /** + * Command used to start the Ponder app. + */ + command: PonderAppCommand; +} From 2508c3fea67d242fcadbdd1156ef533a5cfca768 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 22 Mar 2026 08:29:01 +0100 Subject: [PATCH 2/8] Add `isInDevMode` getter to `LocalPonderClient` class This getter will allow the consumers of the local Ponder client to decide business logic based on the Ponder app context. --- .../src/local-ponder-client.mock.ts | 11 ++++++++++ .../src/local-ponder-client.test.ts | 22 +++++++++++++++++++ .../ponder-sdk/src/local-ponder-client.ts | 19 ++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/packages/ponder-sdk/src/local-ponder-client.mock.ts b/packages/ponder-sdk/src/local-ponder-client.mock.ts index af4fd66de1..90e49c315b 100644 --- a/packages/ponder-sdk/src/local-ponder-client.mock.ts +++ b/packages/ponder-sdk/src/local-ponder-client.mock.ts @@ -2,6 +2,7 @@ import { type BlockNumberRangeWithStartBlock, buildBlockNumberRange } from "./bl import type { CachedPublicClient } from "./cached-public-client"; import type { ChainId, ChainIdString } from "./chains"; import { LocalPonderClient } from "./local-ponder-client"; +import { PonderAppCommands, type PonderAppContext } from "./ponder-app-context"; export const chainIds = { Mainnet: 1, @@ -13,9 +14,11 @@ export function createLocalPonderClientMock(overrides?: { indexedChainIds?: Set; indexedBlockranges?: Map; cachedPublicClients?: Record; + ponderAppContext?: PonderAppContext; }): LocalPonderClient { const indexedChainIds = overrides?.indexedChainIds ?? new Set([chainIds.Mainnet, chainIds.Optimism]); + const indexedBlockranges = overrides?.indexedBlockranges ?? new Map([ @@ -23,6 +26,7 @@ export function createLocalPonderClientMock(overrides?: { [chainIds.Optimism, buildBlockNumberRange(200, undefined)], [chainIds.Base, buildBlockNumberRange(500, undefined)], ]); + const cachedPublicClients = overrides?.cachedPublicClients ?? ({ @@ -31,10 +35,17 @@ export function createLocalPonderClientMock(overrides?: { [`${chainIds.Base}`]: {} as CachedPublicClient, } satisfies Record); + const ponderAppContext = + overrides?.ponderAppContext ?? + ({ + command: PonderAppCommands.Start, + } satisfies PonderAppContext); + return new LocalPonderClient( new URL("http://localhost:3000"), indexedChainIds, indexedBlockranges, cachedPublicClients, + ponderAppContext, ); } diff --git a/packages/ponder-sdk/src/local-ponder-client.test.ts b/packages/ponder-sdk/src/local-ponder-client.test.ts index e3f4089c28..4b02d258ed 100644 --- a/packages/ponder-sdk/src/local-ponder-client.test.ts +++ b/packages/ponder-sdk/src/local-ponder-client.test.ts @@ -206,4 +206,26 @@ describe("LocalPonderClient", () => { ); }); }); + + describe("isInDevMode", () => { + it("returns true when Ponder app command is 'dev'", () => { + // Arrange + const client = createLocalPonderClientMock({ + ponderAppContext: { command: PonderAppCommands.Dev }, + }); + + // Act & Assert + expect(client.isInDevMode).toBe(true); + }); + + it("returns false when Ponder app command is not 'dev'", () => { + // Arrange + const client = createLocalPonderClientMock({ + ponderAppContext: { command: PonderAppCommands.Start }, + }); + + // Act & Assert + expect(client.isInDevMode).toBe(false); + }); + }); }); diff --git a/packages/ponder-sdk/src/local-ponder-client.ts b/packages/ponder-sdk/src/local-ponder-client.ts index b91dea0ab8..35a1b246a4 100644 --- a/packages/ponder-sdk/src/local-ponder-client.ts +++ b/packages/ponder-sdk/src/local-ponder-client.ts @@ -12,6 +12,7 @@ import type { LocalChainIndexingMetrics, LocalPonderIndexingMetrics, } from "./local-indexing-metrics"; +import { PonderAppCommands, type PonderAppContext } from "./ponder-app-context"; /** * Local Ponder Client @@ -68,18 +69,27 @@ export class LocalPonderClient extends PonderClient { */ private cachedPublicClients: Map; + /** + * Ponder App Context + * + * The internal context of the local Ponder app. + */ + private ponderAppContext: PonderAppContext; + /** * @param localPonderAppUrl URL of the local Ponder app to connect to. * @param indexedChainIds Configured indexed chain IDs which are used to validate and filter the Ponder app metadata to only include entries for indexed chains. * @param indexedBlockranges Configured indexing blockrange for each indexed chain. * @param ponderPublicClients All cached public clients provided by the local Ponder app * (may include non-indexed chains). + * @param ponderAppContext The internal context of the local Ponder app. */ constructor( localPonderAppUrl: URL, indexedChainIds: Set, indexedBlockranges: Map, ponderPublicClients: Record, + ponderAppContext: PonderAppContext, ) { super(localPonderAppUrl); @@ -103,6 +113,8 @@ export class LocalPonderClient extends PonderClient { cachedPublicClients, "Cached Public Clients", ); + + this.ponderAppContext = ponderAppContext; } /** @@ -170,6 +182,13 @@ export class LocalPonderClient extends PonderClient { return localMetrics; } + /** + * Indicates whether the local Ponder app is running in dev mode. + */ + get isInDevMode(): boolean { + return this.ponderAppContext.command === PonderAppCommands.Dev; + } + /** * Builds a map of cached public clients based on the Ponder cached public clients. * From 8b5f1d7310a3fd7666060b85387a97a0845d3a5e Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 22 Mar 2026 08:34:44 +0100 Subject: [PATCH 3/8] Make `localPonderClient` for ENSIndexer to accept the Ponder app context --- apps/ensindexer/src/lib/local-ponder-client.ts | 4 +++- apps/ensindexer/types/env.d.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/ensindexer/src/lib/local-ponder-client.ts b/apps/ensindexer/src/lib/local-ponder-client.ts index d68b571bb0..b586b6db58 100644 --- a/apps/ensindexer/src/lib/local-ponder-client.ts +++ b/apps/ensindexer/src/lib/local-ponder-client.ts @@ -3,16 +3,18 @@ import config from "@/config"; import { publicClients } from "ponder:api"; import { buildIndexedBlockranges } from "@ensnode/ensnode-sdk"; -import { LocalPonderClient } from "@ensnode/ponder-sdk"; +import { deserializePonderAppContext, LocalPonderClient } from "@ensnode/ponder-sdk"; import { getPluginsAllDatasourceNames } from "@/lib/plugin-helpers"; const pluginsAllDatasourceNames = getPluginsAllDatasourceNames(config.plugins); const indexedBlockranges = buildIndexedBlockranges(config.namespace, pluginsAllDatasourceNames); +const ponderAppContext = deserializePonderAppContext(globalThis.PONDER_COMMON); export const localPonderClient = new LocalPonderClient( config.ensIndexerUrl, config.indexedChainIds, indexedBlockranges, publicClients, + ponderAppContext, ); diff --git a/apps/ensindexer/types/env.d.ts b/apps/ensindexer/types/env.d.ts index caa8c9c649..ed7fc9edfc 100644 --- a/apps/ensindexer/types/env.d.ts +++ b/apps/ensindexer/types/env.d.ts @@ -4,4 +4,16 @@ declare global { namespace NodeJS { interface ProcessEnv extends ENSIndexerEnvironment {} } + + /** + * Global variable injected by Ponder at runtime, + * containing internal context of the local Ponder app. + * + * @see https://github.com/ponder-sh/ponder/blob/6fcc15d4234e43862cb6e21c05f3c57f4c2f7464/packages/core/src/internal/common.ts#L7-L15 + */ + var PONDER_COMMON: { + options: { + command: string; + }; + }; } From 48c369e40b851a1d50c9469fb0719aed5b8b02d8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 22 Mar 2026 08:53:51 +0100 Subject: [PATCH 4/8] ENSDb Writer Worker conditional validation Skip ENSIndexer Pulic Config validation in dev mode. --- .../ensdb-writer-worker.ts | 19 +++++++++++++++++-- .../src/lib/ensdb-writer-worker/singleton.ts | 2 ++ 2 files changed, 19 insertions(+), 2 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 5dc5b4b1e8..d8000dd595 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 @@ -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 type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; @@ -50,19 +51,29 @@ export class EnsDbWriterWorker { */ 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, ) { this.ensDbClient = ensDbClient; this.publicConfigBuilder = publicConfigBuilder; this.indexingStatusBuilder = indexingStatusBuilder; + this.localPonderClient = localPonderClient; } /** @@ -181,8 +192,12 @@ export class EnsDbWriterWorker { } // Validate in-memory config object compatibility with the stored one, - // if the stored one is available - if (storedConfig) { + // 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) { diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index 48ef90dffe..5e0a9d9df2 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,5 +1,6 @@ import { ensDbClient } from "@/lib/ensdb/singleton"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { localPonderClient } from "@/lib/local-ponder-client"; import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import { EnsDbWriterWorker } from "./ensdb-writer-worker"; @@ -24,6 +25,7 @@ export function startEnsDbWriterWorker() { ensDbClient, publicConfigBuilder, indexingStatusBuilder, + localPonderClient, ); ensDbWriterWorker From 66aa37bc3656c3498a3d852e7768c09ca0f464ce Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 22 Mar 2026 08:57:41 +0100 Subject: [PATCH 5/8] Update testing suite for ENSDb Writer Worker --- .../ensdb-writer-worker.mock.ts | 36 ++++++ .../ensdb-writer-worker.test.ts | 110 +++++++++--------- 2 files changed, 94 insertions(+), 52 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 232428743c..daaa6968b6 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 @@ -12,7 +12,9 @@ import { 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"; @@ -103,3 +105,37 @@ export function createMockCrossChainSnapshot( ...overrides, }; } + +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; + } = {}, +) { + 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, + ); +} 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 0883978cd8..f55d752479 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 @@ -6,13 +6,13 @@ import { validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; -import { EnsDbWriterWorker } from "@/lib/ensdb-writer-worker/ensdb-writer-worker"; 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, createMockIndexingStatusBuilder, createMockOmnichainSnapshot, createMockPublicConfigBuilder, @@ -51,10 +51,10 @@ describe("EnsDbWriterWorker", () => { vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); const ensDbClient = createMockEnsDbWriter(); - const publicConfigBuilder = createMockPublicConfigBuilder(); - const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshot); - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + indexingStatusBuilder: createMockIndexingStatusBuilder(omnichainSnapshot), + }); // act await worker.run(); @@ -81,31 +81,58 @@ describe("EnsDbWriterWorker", () => { it("throws when stored config is incompatible", async () => { // arrange - const incompatibleError = new Error("incompatible"); vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { - throw incompatibleError; + throw new Error("incompatible"); }); const ensDbClient = createMockEnsDbWriter({ getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), }); - const publicConfigBuilder = createMockPublicConfigBuilder(mockPublicConfig); - const indexingStatusBuilder = createMockIndexingStatusBuilder(); - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + publicConfigBuilder: createMockPublicConfigBuilder(mockPublicConfig), + }); // act & assert await expect(worker.run()).rejects.toThrow("incompatible"); expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); }); - it("throws error when worker is already running", async () => { + it("skips config validation when in dev mode", async () => { // arrange - const ensDbClient = createMockEnsDbWriter(); - const publicConfigBuilder = createMockPublicConfigBuilder(); - const indexingStatusBuilder = createMockIndexingStatusBuilder(); + vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { + throw new Error("incompatible"); + }); + + const snapshot = createMockCrossChainSnapshot(); + vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + 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(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(mockPublicConfig); + + // cleanup + worker.stop(); + }); + + it("throws error when worker is already running", async () => { + // arrange + const worker = createMockEnsDbWriterWorker(); // act - first run await worker.run(); @@ -119,14 +146,11 @@ describe("EnsDbWriterWorker", () => { it("throws error when config fetch fails", async () => { // arrange - const networkError = new Error("Network failure"); - const ensDbClient = createMockEnsDbWriter(); const publicConfigBuilder = { - getPublicConfig: vi.fn().mockRejectedValue(networkError), + getPublicConfig: vi.fn().mockRejectedValue(new Error("Network failure")), } as unknown as PublicConfigBuilder; - const indexingStatusBuilder = createMockIndexingStatusBuilder(); - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const ensDbClient = createMockEnsDbWriter(); + const worker = createMockEnsDbWriterWorker({ ensDbClient, publicConfigBuilder }); // act & assert await expect(worker.run()).rejects.toThrow("Network failure"); @@ -136,14 +160,10 @@ describe("EnsDbWriterWorker", () => { it("throws error when stored config fetch fails", async () => { // arrange - const dbError = new Error("Database connection lost"); const ensDbClient = createMockEnsDbWriter({ - getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(dbError), + getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(new Error("Database connection lost")), }); - const publicConfigBuilder = createMockPublicConfigBuilder(); - const indexingStatusBuilder = createMockIndexingStatusBuilder(); - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker({ ensDbClient }); // act & assert await expect(worker.run()).rejects.toThrow("Database connection lost"); @@ -152,17 +172,16 @@ describe("EnsDbWriterWorker", () => { it("fetches stored and in-memory configs concurrently", async () => { // arrange - vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { - // validation passes - }); + vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => {}); const ensDbClient = createMockEnsDbWriter({ getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), }); const publicConfigBuilder = createMockPublicConfigBuilder(mockPublicConfig); - const indexingStatusBuilder = createMockIndexingStatusBuilder(); - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker({ + ensDbClient, + publicConfigBuilder, + }); // act await worker.run(); @@ -182,9 +201,7 @@ describe("EnsDbWriterWorker", () => { const ensDbClient = createMockEnsDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); - const indexingStatusBuilder = createMockIndexingStatusBuilder(); - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker({ ensDbClient, publicConfigBuilder }); // act await worker.run(); @@ -203,10 +220,7 @@ describe("EnsDbWriterWorker", () => { // arrange const upsertIndexingStatusSnapshot = vi.fn().mockResolvedValue(undefined); const ensDbClient = createMockEnsDbWriter({ upsertIndexingStatusSnapshot }); - const publicConfigBuilder = createMockPublicConfigBuilder(); - const indexingStatusBuilder = createMockIndexingStatusBuilder(); - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker({ ensDbClient }); // act await worker.run(); @@ -227,11 +241,7 @@ describe("EnsDbWriterWorker", () => { describe("isRunning - worker state", () => { it("indicates isRunning status correctly", async () => { // arrange - const ensDbClient = createMockEnsDbWriter(); - const publicConfigBuilder = createMockPublicConfigBuilder(); - const indexingStatusBuilder = createMockIndexingStatusBuilder(); - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker(); // assert - not running initially expect(worker.isRunning).toBe(false); @@ -268,15 +278,13 @@ describe("EnsDbWriterWorker", () => { vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); const ensDbClient = createMockEnsDbWriter(); - const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = { getOmnichainIndexingStatusSnapshot: vi .fn() .mockResolvedValueOnce(unstartedSnapshot) .mockResolvedValueOnce(validSnapshot), } as unknown as IndexingStatusBuilder; - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder }); // act - run returns immediately await worker.run(); @@ -324,7 +332,6 @@ describe("EnsDbWriterWorker", () => { .mockRejectedValueOnce(new Error("DB error")) .mockResolvedValueOnce(undefined), }); - const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = { getOmnichainIndexingStatusSnapshot: vi .fn() @@ -332,8 +339,7 @@ describe("EnsDbWriterWorker", () => { .mockResolvedValueOnce(snapshot2) .mockResolvedValueOnce(snapshot2), } as unknown as IndexingStatusBuilder; - - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker({ ensDbClient, indexingStatusBuilder }); // act await worker.run(); From ee4ab389a82d6f31f9614e8b440a712cc7517de9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 22 Mar 2026 08:59:30 +0100 Subject: [PATCH 6/8] docs(changeset): Introduced `PonderAppContext` data model to capture the internal context of a local Ponder app. --- .changeset/old-seals-draw.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/old-seals-draw.md diff --git a/.changeset/old-seals-draw.md b/.changeset/old-seals-draw.md new file mode 100644 index 0000000000..26af620a1d --- /dev/null +++ b/.changeset/old-seals-draw.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ponder-sdk": minor +--- + +Introduced `PonderAppContext` data model to capture the internal context of a local Ponder app. From edc01df5b8f56db6723e6437376ed8b3ca9df9ff Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 22 Mar 2026 09:00:24 +0100 Subject: [PATCH 7/8] docs(changeset): Improved developer experience by skiping validation step in ENSDb Writer Worker while in dev mode. --- .changeset/better-bats-switch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/better-bats-switch.md diff --git a/.changeset/better-bats-switch.md b/.changeset/better-bats-switch.md new file mode 100644 index 0000000000..dbb472e94d --- /dev/null +++ b/.changeset/better-bats-switch.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Improved developer experience by skipping validation step in ENSDb Writer Worker while in dev mode. From 2ad57bd3c38746638ec45ba5af81a09f68f40c26 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 22 Mar 2026 12:17:58 +0100 Subject: [PATCH 8/8] Apply AI PR feedback --- .../ensdb-writer-worker.ts | 16 ++++++----- .../ensindexer/src/lib/local-ponder-client.ts | 6 ++++- apps/ensindexer/types/env.d.ts | 13 +++------ .../src/deserialize/indexing-metrics.ts | 5 ++-- .../src/deserialize/ponder-app-context.ts | 27 ++++++++++++------- 5 files changed, 37 insertions(+), 30 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 d8000dd595..1645d196a3 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 @@ -144,14 +144,16 @@ export class EnsDbWriterWorker { * - stored config in ENSDb, if available, and * - in-memory config from ENSIndexer Client. * - * If, and only if, a stored config is available in ENSDb, then the function - * validates the compatibility of the in-memory config object against - * the stored one. Validation criteria are defined in the function body. + * 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 In-memory config object, if the validation is successful or - * if there is no stored config. - * @throws Error if the in-memory config object cannot be fetched or, - * got fetched and is incompatible with the stored config object. + * @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 { /** diff --git a/apps/ensindexer/src/lib/local-ponder-client.ts b/apps/ensindexer/src/lib/local-ponder-client.ts index b586b6db58..139d85f221 100644 --- a/apps/ensindexer/src/lib/local-ponder-client.ts +++ b/apps/ensindexer/src/lib/local-ponder-client.ts @@ -7,9 +7,13 @@ import { deserializePonderAppContext, LocalPonderClient } from "@ensnode/ponder- import { getPluginsAllDatasourceNames } from "@/lib/plugin-helpers"; +if (!globalThis.PONDER_COMMON) { + throw new Error("PONDER_COMMON must be defined by Ponder at runtime as a global variable."); +} + +const ponderAppContext = deserializePonderAppContext(globalThis.PONDER_COMMON); const pluginsAllDatasourceNames = getPluginsAllDatasourceNames(config.plugins); const indexedBlockranges = buildIndexedBlockranges(config.namespace, pluginsAllDatasourceNames); -const ponderAppContext = deserializePonderAppContext(globalThis.PONDER_COMMON); export const localPonderClient = new LocalPonderClient( config.ensIndexerUrl, diff --git a/apps/ensindexer/types/env.d.ts b/apps/ensindexer/types/env.d.ts index ed7fc9edfc..b40d0b1b61 100644 --- a/apps/ensindexer/types/env.d.ts +++ b/apps/ensindexer/types/env.d.ts @@ -1,19 +1,12 @@ import type { ENSIndexerEnvironment } from "@/config/environment"; +import type { RawPonderAppContext } from "@ensnode/ponder-sdk"; declare global { namespace NodeJS { interface ProcessEnv extends ENSIndexerEnvironment {} } - /** - * Global variable injected by Ponder at runtime, - * containing internal context of the local Ponder app. - * - * @see https://github.com/ponder-sh/ponder/blob/6fcc15d4234e43862cb6e21c05f3c57f4c2f7464/packages/core/src/internal/common.ts#L7-L15 + * The "raw" context of the local Ponder app. */ - var PONDER_COMMON: { - options: { - command: string; - }; - }; + var PONDER_COMMON: RawPonderAppContext | undefined; } diff --git a/packages/ponder-sdk/src/deserialize/indexing-metrics.ts b/packages/ponder-sdk/src/deserialize/indexing-metrics.ts index 6eca2cb3f4..59a13380aa 100644 --- a/packages/ponder-sdk/src/deserialize/indexing-metrics.ts +++ b/packages/ponder-sdk/src/deserialize/indexing-metrics.ts @@ -22,9 +22,8 @@ import { PonderIndexingOrderings, } from "../indexing-metrics"; import { schemaPositiveInteger } from "../numbers"; -import type { PonderAppCommand } from "../ponder-app-context"; +import { type PonderAppCommand, PonderAppCommands } from "../ponder-app-context"; import { schemaChainIdString } from "./chains"; -import { PonderAppCommandSchema } from "./ponder-app-context"; import { deserializePrometheusMetrics, type PrometheusMetrics } from "./prometheus-metrics-text"; import type { Unvalidated } from "./utils"; @@ -158,7 +157,7 @@ function invariant_includesAtLeastOneIndexedChain(ctx: ParsePayload; +/** + * Type representing the "raw" context of a local Ponder app. + */ +export type RawPonderAppContext = z.infer; /** - * Schema representing the context of a local Ponder app. + * Schema representing the "deserialized" context of a local Ponder app. */ const schemaPonderAppContext = z.object({ - command: PonderAppCommandSchema, + command: z.enum(PonderAppCommands), }); /** @@ -36,7 +45,7 @@ const schemaPonderAppContext = z.object({ * @returns Unvalidated Ponder App Context * to be validated with {@link schemaPonderAppContext}. */ -export function buildUnvalidatedPonderAppContext( +function buildUnvalidatedPonderAppContext( rawPonderAppContext: RawPonderAppContext, ): Unvalidated { return {