diff --git a/.changeset/flat-flowers-shave.md b/.changeset/flat-flowers-shave.md new file mode 100644 index 000000000..b15c2e838 --- /dev/null +++ b/.changeset/flat-flowers-shave.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Added `validateEnsIndexerPublicConfig` and `validateEnsIndexerVersionInfo` functions. diff --git a/.changeset/violet-tires-sell.md b/.changeset/violet-tires-sell.md new file mode 100644 index 000000000..9ff7f2b5e --- /dev/null +++ b/.changeset/violet-tires-sell.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Refactored HTTP handlers to rely solely on ENSDb Client for data. diff --git a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts index 58c4b015d..994b98704 100644 --- a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts +++ b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts @@ -1,10 +1,7 @@ -import config from "@/config"; - import { getUnixTime } from "date-fns"; import { Hono } from "hono"; import { - buildCrossChainIndexingStatusSnapshotOmnichain, createRealtimeIndexingStatusProjection, IndexingStatusResponseCodes, type IndexingStatusResponseError, @@ -13,31 +10,37 @@ import { serializeIndexingStatusResponse, } from "@ensnode/ensnode-sdk"; -import { buildENSIndexerPublicConfig } from "@/config/public"; -import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { ensDbClient } from "@/lib/ensdb-client/singleton"; const app = new Hono(); // include ENSIndexer Public Config endpoint app.get("/config", async (c) => { - // prepare the public config object, including dependency info - const publicConfig = await buildENSIndexerPublicConfig(config); + const publicConfig = await ensDbClient.getEnsIndexerPublicConfig(); + + // Invariant: the public config is guaranteed to be available in ENSDb after + // application startup. + if (typeof publicConfig === "undefined") { + throw new Error("Unreachable: ENSIndexer Public Config is not available in ENSDb"); + } // respond with the serialized public config object return c.json(serializeENSIndexerPublicConfig(publicConfig)); }); app.get("/indexing-status", async (c) => { - // get system timestamp for the current request - const snapshotTime = getUnixTime(new Date()); - try { - const omnichainSnapshot = await indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); + const crossChainSnapshot = await ensDbClient.getIndexingStatusSnapshot(); - const crossChainSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain( - omnichainSnapshot, - snapshotTime, - ); + // Invariant: the Indexing Status Snapshot is expected to be available in + // ENSDb shortly after application startup. There is a possibility that + // the snapshot is not yet available at the time of the request, + // i.e. when ENSDb has not yet been populated with the first snapshot. + // In this case, we treat the snapshot as unavailable and respond with + // an error response. + if (typeof crossChainSnapshot === "undefined") { + throw new Error("ENSDb does not contain an Indexing Status Snapshot"); + } const projectedAt = getUnixTime(new Date()); const realtimeProjection = createRealtimeIndexingStatusProjection( @@ -53,7 +56,7 @@ app.get("/indexing-status", async (c) => { ); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error(`Omnichain snapshot is currently not available: ${errorMessage}`); + console.error(`Indexing Status Snapshot is currently not available: ${errorMessage}`); return c.json( serializeIndexingStatusResponse({ diff --git a/apps/ensindexer/src/config/public.ts b/apps/ensindexer/src/config/public.ts deleted file mode 100644 index b80fa9f07..000000000 --- a/apps/ensindexer/src/config/public.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { EnsIndexerPublicConfig } from "@ensnode/ensnode-sdk"; - -import { getENSRainbowApiClient } from "@/lib/ensraibow-api-client"; -import { getENSIndexerVersionInfo } from "@/lib/version-info"; - -import type { EnsIndexerConfig } from "./types"; - -const ensRainbowApiClient = getENSRainbowApiClient(); - -/** - * Build a public version of {@link EnsIndexerConfig}. - * - * Note: some values required to build an {@link EnsIndexerPublicConfig} object - * have to fetched over the network. - */ -export async function buildENSIndexerPublicConfig( - config: EnsIndexerConfig, -): Promise { - const [versionInfo, ensRainbowPublicConfig] = await Promise.all([ - getENSIndexerVersionInfo(), - ensRainbowApiClient.config(), - ]); - - return { - databaseSchemaName: config.databaseSchemaName, - ensRainbowPublicConfig, - labelSet: config.labelSet, - indexedChainIds: config.indexedChainIds, - isSubgraphCompatible: config.isSubgraphCompatible, - namespace: config.namespace, - plugins: config.plugins, - versionInfo, - }; -} 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 new file mode 100644 index 000000000..0b98b5e51 --- /dev/null +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts @@ -0,0 +1,72 @@ +import { vi } from "vitest"; + +import { + type CrossChainIndexingStatusSnapshot, + CrossChainIndexingStrategyIds, + type EnsIndexerPublicConfig, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, +} from "@ensnode/ensnode-sdk"; + +import type { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; +import * as ensDbClientMock from "@/lib/ensdb-client/ensdb-client.mock"; +import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder"; +import type { PublicConfigBuilder } from "@/lib/public-config-builder"; + +// Helper to create mock objects with consistent typing +export function createMockEnsDbClient( + overrides: Partial> = {}, +): EnsDbClient { + return { + ...baseEnsDbClient(), + ...overrides, + } as unknown as EnsDbClient; +} + +export function baseEnsDbClient() { + return { + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), + upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), + upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), + upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), + }; +} + +export function createMockPublicConfigBuilder( + resolvedConfig: EnsIndexerPublicConfig = ensDbClientMock.publicConfig, +): PublicConfigBuilder { + return { + getPublicConfig: vi.fn().mockResolvedValue(resolvedConfig), + } as unknown as PublicConfigBuilder; +} + +export function createMockIndexingStatusBuilder( + resolvedSnapshot: OmnichainIndexingStatusSnapshot = createMockOmnichainSnapshot(), +): IndexingStatusBuilder { + return { + getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(resolvedSnapshot), + } as unknown as IndexingStatusBuilder; +} + +export function createMockOmnichainSnapshot( + overrides: Partial = {}, +): OmnichainIndexingStatusSnapshot { + return { + omnichainStatus: OmnichainIndexingStatusIds.Following, + omnichainIndexingCursor: 100, + chains: new Map(), + ...overrides, + }; +} + +export function createMockCrossChainSnapshot( + overrides: Partial = {}, +): CrossChainIndexingStatusSnapshot { + return { + strategy: CrossChainIndexingStrategyIds.Omnichain, + slowestChainIndexingCursor: 100, + snapshotTime: 200, + omnichainSnapshot: createMockOmnichainSnapshot(), + ...overrides, + }; +} 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 27f3f2ff3..f46ad5f93 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,19 +2,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildCrossChainIndexingStatusSnapshotOmnichain, - type CrossChainIndexingStatusSnapshot, - CrossChainIndexingStrategyIds, - type EnsIndexerClient, - type EnsIndexerPublicConfig, OmnichainIndexingStatusIds, - type OmnichainIndexingStatusSnapshot, validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; -import type { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; -import { publicConfig } from "@/lib/ensdb-client/ensdb-client.mock"; +import * as ensDbClientMock from "@/lib/ensdb-client/ensdb-client.mock"; 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, + createMockEnsDbClient, + createMockIndexingStatusBuilder, + createMockOmnichainSnapshot, + createMockPublicConfigBuilder, +} from "./ensdb-writer-worker.mock"; vi.mock("@ensnode/ensnode-sdk", async () => { const actual = await vi.importActual("@ensnode/ensnode-sdk"); @@ -40,404 +43,321 @@ describe("EnsDbWriterWorker", () => { vi.clearAllMocks(); }); - it("upserts version, config, and starts interval for indexing status snapshots", async () => { - // arrange - const omnichainSnapshot = { - omnichainStatus: OmnichainIndexingStatusIds.Following, - omnichainIndexingCursor: 100, - chains: {}, - } as OmnichainIndexingStatusSnapshot; - - const snapshot = { - strategy: CrossChainIndexingStrategyIds.Omnichain, - slowestChainIndexingCursor: 100, - snapshotTime: 200, - omnichainSnapshot, - } as CrossChainIndexingStatusSnapshot; - - const buildSnapshot = vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain); - buildSnapshot.mockReturnValue(snapshot); - - const ensDbClient = { - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), - } as unknown as EnsDbClient; - - const ensIndexerClient = { - config: vi.fn().mockResolvedValue(publicConfig), - } as unknown as EnsIndexerClient; - - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(omnichainSnapshot), - } as unknown as IndexingStatusBuilder; - - const worker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); - - // act - run() returns immediately after setting up interval - await worker.run(); - - // assert - verify initial upserts happened - expect(ensDbClient.upsertEnsDbVersion).toHaveBeenCalledWith(publicConfig.versionInfo.ensDb); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(publicConfig); - - // advance time to trigger interval - await vi.advanceTimersByTimeAsync(1000); - - // assert - snapshot should be upserted - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(snapshot); - expect(buildSnapshot).toHaveBeenCalledWith(omnichainSnapshot, expect.any(Number)); - - // cleanup - worker.stop(); - }); - - it("throws when stored config is incompatible", async () => { - // arrange - const incompatibleError = new Error("incompatible"); + describe("run() - worker initialization", () => { + it("upserts version, config, and starts interval for indexing status snapshots", async () => { + // arrange + const omnichainSnapshot = createMockOmnichainSnapshot(); + const snapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); + vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); + + const ensDbClient = createMockEnsDbClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshot); + + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + + // act + await worker.run(); + + // assert - verify initial upserts happened + expect(ensDbClient.upsertEnsDbVersion).toHaveBeenCalledWith( + ensDbClientMock.publicConfig.versionInfo.ensDb, + ); + expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( + ensDbClientMock.publicConfig, + ); + + // advance time to trigger interval + await vi.advanceTimersByTimeAsync(1000); + + // assert - snapshot should be upserted + expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(snapshot); + expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( + omnichainSnapshot, + expect.any(Number), + ); + + // cleanup + worker.stop(); + }); - const ensDbClient = { - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(publicConfig), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), - } as unknown as EnsDbClient; + it("throws when stored config is incompatible", async () => { + // arrange + const incompatibleError = new Error("incompatible"); + vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { + throw incompatibleError; + }); - const ensIndexerClient = { - config: vi.fn().mockResolvedValue(publicConfig as EnsIndexerPublicConfig), - } as unknown as EnsIndexerClient; + const ensDbClient = createMockEnsDbClient({ + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensDbClientMock.publicConfig), + }); + const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi.fn(), - } as unknown as IndexingStatusBuilder; + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); - vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { - throw incompatibleError; + // act & assert + await expect(worker.run()).rejects.toThrow("incompatible"); + expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); }); - const worker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); - - // act - await expect(worker.run()).rejects.toThrow("incompatible"); + it("throws error when worker is already running", async () => { + // arrange + const ensDbClient = createMockEnsDbClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); - // assert - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); - }); - - it("continues upserting after snapshot validation errors", async () => { - // arrange - const unstartedSnapshot = { - omnichainStatus: OmnichainIndexingStatusIds.Unstarted, - } as OmnichainIndexingStatusSnapshot; - - const validSnapshot = { - omnichainStatus: OmnichainIndexingStatusIds.Following, - omnichainIndexingCursor: 200, - chains: {}, - } as OmnichainIndexingStatusSnapshot; - - const crossChainSnapshot = { - strategy: CrossChainIndexingStrategyIds.Omnichain, - slowestChainIndexingCursor: 200, - snapshotTime: 300, - omnichainSnapshot: validSnapshot, - } as CrossChainIndexingStatusSnapshot; - - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); - - const ensDbClient = { - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), - } as unknown as EnsDbClient; - - const ensIndexerClient = { - config: vi.fn().mockResolvedValue(publicConfig), - } as unknown as EnsIndexerClient; - - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi - .fn() - .mockResolvedValueOnce(unstartedSnapshot) - .mockResolvedValueOnce(validSnapshot), - } as unknown as IndexingStatusBuilder; - - const worker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); - - // act - run returns immediately - await worker.run(); - - // first interval tick - should error but not throw - 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); - - // cleanup - worker.stop(); - }); + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); - it("stops the interval when stop() is called", async () => { - // arrange - const omnichainSnapshot = { - omnichainStatus: OmnichainIndexingStatusIds.Following, - omnichainIndexingCursor: 100, - chains: {}, - } as OmnichainIndexingStatusSnapshot; + // act - first run + await worker.run(); - const upsertIndexingStatusSnapshot = vi.fn().mockResolvedValue(undefined); + // assert - second run should throw + await expect(worker.run()).rejects.toThrow("EnsDbWriterWorker is already running"); - const ensDbClient = { - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot, - } as unknown as EnsDbClient; - - const ensIndexerClient = { - config: vi.fn().mockResolvedValue(publicConfig), - } as unknown as EnsIndexerClient; + // cleanup + worker.stop(); + }); - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(omnichainSnapshot), - } as unknown as IndexingStatusBuilder; + it("throws error when config fetch fails", async () => { + // arrange + const networkError = new Error("Network failure"); + const ensDbClient = createMockEnsDbClient(); + const publicConfigBuilder = { + getPublicConfig: vi.fn().mockRejectedValue(networkError), + } as unknown as PublicConfigBuilder; + const indexingStatusBuilder = createMockIndexingStatusBuilder(); + + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + + // act & assert + await expect(worker.run()).rejects.toThrow("Network failure"); + expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); + expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + }); - const worker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); + it("throws error when stored config fetch fails", async () => { + // arrange + const dbError = new Error("Database connection lost"); + const ensDbClient = createMockEnsDbClient({ + getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(dbError), + }); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); - // act - await worker.run(); - await vi.advanceTimersByTimeAsync(1000); + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); - const callCountBeforeStop = upsertIndexingStatusSnapshot.mock.calls.length; + // act & assert + await expect(worker.run()).rejects.toThrow("Database connection lost"); + expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + }); - worker.stop(); + it("fetches stored and in-memory configs concurrently", async () => { + // arrange + vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { + // validation passes + }); - // advance time after stop - await vi.advanceTimersByTimeAsync(2000); + const ensDbClient = createMockEnsDbClient({ + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensDbClientMock.publicConfig), + }); + const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); - // assert - no more calls after stop - expect(upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(callCountBeforeStop); - }); + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); - it("calls pRetry for config fetch with retry logic", async () => { - // arrange - pRetry is mocked to call fn directly - const omnichainSnapshot = { - omnichainStatus: OmnichainIndexingStatusIds.Following, - omnichainIndexingCursor: 100, - chains: {}, - } as OmnichainIndexingStatusSnapshot; - - const snapshot = { - strategy: CrossChainIndexingStrategyIds.Omnichain, - slowestChainIndexingCursor: 100, - snapshotTime: 200, - omnichainSnapshot, - } as CrossChainIndexingStatusSnapshot; - - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - - const ensDbClient = { - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), - } as unknown as EnsDbClient; - - const ensIndexerClient = { - config: vi.fn().mockResolvedValue(publicConfig), - } as unknown as EnsIndexerClient; - - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi.fn().mockResolvedValue(omnichainSnapshot), - } as unknown as IndexingStatusBuilder; - - const worker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); - - // act - await worker.run(); - - // assert - config should be called once (pRetry is mocked) - expect(ensIndexerClient.config).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(publicConfig); - - // cleanup - worker.stop(); - }); + // act + await worker.run(); - it("fetches stored and in-memory configs concurrently", async () => { - // arrange - const storedConfig = { ...publicConfig, versionInfo: { ...publicConfig.versionInfo } }; - const inMemoryConfig = publicConfig; + // assert - both should have been called (concurrent execution via Promise.all) + expect(ensDbClient.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); + expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - // Ensure validation passes for this test - vi.mocked(validateEnsIndexerPublicConfigCompatibility).mockImplementation(() => { - // validation passes + // cleanup + worker.stop(); }); - const ensDbClient = { - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(storedConfig), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), - } as unknown as EnsDbClient; + 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 ensIndexerClient = { - config: vi.fn().mockResolvedValue(inMemoryConfig), - } as unknown as EnsIndexerClient; + const ensDbClient = createMockEnsDbClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi.fn(), - } as unknown as IndexingStatusBuilder; + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); - const worker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); + // act + await worker.run(); - // act - await worker.run(); + // assert - config should be called once (pRetry is mocked) + expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); + expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( + ensDbClientMock.publicConfig, + ); - // assert - both should have been called (concurrent execution via Promise.all) - expect(ensDbClient.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); - expect(ensIndexerClient.config).toHaveBeenCalledTimes(1); - - // cleanup - worker.stop(); + // cleanup + worker.stop(); + }); }); - it("throws error when config fetch fails", async () => { - // arrange - const networkError = new Error("Network failure"); + describe("stop() - worker termination", () => { + it("stops the interval when stop() is called", async () => { + // arrange + const upsertIndexingStatusSnapshot = vi.fn().mockResolvedValue(undefined); + const ensDbClient = createMockEnsDbClient({ upsertIndexingStatusSnapshot }); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const ensDbClient = { - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), - } as unknown as EnsDbClient; + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); - const ensIndexerClient = { - config: vi.fn().mockRejectedValue(networkError), - } as unknown as EnsIndexerClient; + // act + await worker.run(); + await vi.advanceTimersByTimeAsync(1000); - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi.fn(), - } as unknown as IndexingStatusBuilder; + const callCountBeforeStop = upsertIndexingStatusSnapshot.mock.calls.length; - const worker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); + worker.stop(); - // act & assert - await expect(worker.run()).rejects.toThrow("Network failure"); + // advance time after stop + await vi.advanceTimersByTimeAsync(2000); - // should be called once (pRetry is mocked to call once) - expect(ensIndexerClient.config).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + // assert - no more calls after stop + expect(upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(callCountBeforeStop); + }); }); - it("throws error when stored config fetch fails", async () => { - // arrange - const dbError = new Error("Database connection lost"); + describe("isRunning - worker state", () => { + it("indicates isRunning status correctly", async () => { + // arrange + const ensDbClient = createMockEnsDbClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const ensDbClient = { - getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(dbError), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), - } as unknown as EnsDbClient; + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); - const ensIndexerClient = { - config: vi.fn().mockResolvedValue(publicConfig), - } as unknown as EnsIndexerClient; + // assert - not running initially + expect(worker.isRunning).toBe(false); - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi.fn(), - } as unknown as IndexingStatusBuilder; + // act - start worker + await worker.run(); - const worker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); + // assert - running after start + expect(worker.isRunning).toBe(true); - // act & assert - await expect(worker.run()).rejects.toThrow("Database connection lost"); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + // act - stop worker + worker.stop(); + + // assert - not running after stop + expect(worker.isRunning).toBe(false); + }); }); - it("recovers from errors and continues upserting snapshots", async () => { - // arrange - const snapshot1 = { - omnichainStatus: OmnichainIndexingStatusIds.Following, - omnichainIndexingCursor: 100, - chains: {}, - } as OmnichainIndexingStatusSnapshot; - - const snapshot2 = { - omnichainStatus: OmnichainIndexingStatusIds.Following, - omnichainIndexingCursor: 200, - chains: {}, - } as OmnichainIndexingStatusSnapshot; - - const crossChainSnapshot1 = { - strategy: CrossChainIndexingStrategyIds.Omnichain, - slowestChainIndexingCursor: 100, - snapshotTime: 1000, - omnichainSnapshot: snapshot1, - } as CrossChainIndexingStatusSnapshot; - - const crossChainSnapshot2 = { - strategy: CrossChainIndexingStrategyIds.Omnichain, - slowestChainIndexingCursor: 200, - snapshotTime: 2000, - omnichainSnapshot: snapshot2, - } as CrossChainIndexingStatusSnapshot; - - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain) - .mockReturnValueOnce(crossChainSnapshot1) - .mockReturnValueOnce(crossChainSnapshot2) - .mockReturnValueOnce(crossChainSnapshot2); - - const ensDbClient = { - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), - upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), - upsertIndexingStatusSnapshot: vi - .fn() - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error("DB error")) - .mockResolvedValueOnce(undefined), - } as unknown as EnsDbClient; - - const ensIndexerClient = { - config: vi.fn().mockResolvedValue(publicConfig), - } as unknown as EnsIndexerClient; - - const indexingStatusBuilder = { - getOmnichainIndexingStatusSnapshot: vi - .fn() - .mockResolvedValueOnce(snapshot1) - .mockResolvedValueOnce(snapshot2) - .mockResolvedValueOnce(snapshot2), - } as unknown as IndexingStatusBuilder; - - const worker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); - - // act - await worker.run(); - - // first tick - succeeds - await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot1); - - // second tick - fails with DB error, but continues - await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenLastCalledWith(crossChainSnapshot2); - - // third tick - succeeds again - await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(3); - - // cleanup - worker.stop(); + describe("interval behavior - snapshot upserts", () => { + it("continues upserting after snapshot validation errors", async () => { + // arrange + const unstartedSnapshot = createMockOmnichainSnapshot({ + omnichainStatus: OmnichainIndexingStatusIds.Unstarted, + }); + const validSnapshot = createMockOmnichainSnapshot({ + omnichainIndexingCursor: 200, + }); + const crossChainSnapshot = createMockCrossChainSnapshot({ + slowestChainIndexingCursor: 200, + snapshotTime: 300, + omnichainSnapshot: validSnapshot, + }); + + vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); + + const ensDbClient = createMockEnsDbClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = { + getOmnichainIndexingStatusSnapshot: vi + .fn() + .mockResolvedValueOnce(unstartedSnapshot) + .mockResolvedValueOnce(validSnapshot), + } as unknown as IndexingStatusBuilder; + + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + + // act - run returns immediately + await worker.run(); + + // first interval tick - should error but not throw + 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); + + // cleanup + worker.stop(); + }); + + it("recovers from errors and continues upserting snapshots", async () => { + // arrange + const snapshot1 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 100 }); + const snapshot2 = createMockOmnichainSnapshot({ omnichainIndexingCursor: 200 }); + + const crossChainSnapshot1 = createMockCrossChainSnapshot({ + slowestChainIndexingCursor: 100, + snapshotTime: 1000, + omnichainSnapshot: snapshot1, + }); + const crossChainSnapshot2 = createMockCrossChainSnapshot({ + slowestChainIndexingCursor: 200, + snapshotTime: 2000, + omnichainSnapshot: snapshot2, + }); + + vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain) + .mockReturnValueOnce(crossChainSnapshot1) + .mockReturnValueOnce(crossChainSnapshot2) + .mockReturnValueOnce(crossChainSnapshot2); + + const ensDbClient = createMockEnsDbClient({ + upsertIndexingStatusSnapshot: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("DB error")) + .mockResolvedValueOnce(undefined), + }); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = { + getOmnichainIndexingStatusSnapshot: vi + .fn() + .mockResolvedValueOnce(snapshot1) + .mockResolvedValueOnce(snapshot2) + .mockResolvedValueOnce(snapshot2), + } as unknown as IndexingStatusBuilder; + + const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + + // act + await worker.run(); + + // first tick - succeeds + await vi.advanceTimersByTimeAsync(1000); + expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot1); + + // second tick - fails with DB error, but continues + await vi.advanceTimersByTimeAsync(1000); + expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenLastCalledWith( + crossChainSnapshot2, + ); + + // third tick - succeeds again + await vi.advanceTimersByTimeAsync(1000); + expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(3); + + // cleanup + worker.stop(); + }); }); }); diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 2a29b3c0a..78bcd921a 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,7 +5,6 @@ import { buildCrossChainIndexingStatusSnapshotOmnichain, type CrossChainIndexingStatusSnapshot, type Duration, - type EnsIndexerClient, type EnsIndexerPublicConfig, OmnichainIndexingStatusIds, type OmnichainIndexingStatusSnapshot, @@ -14,6 +13,7 @@ import { import type { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; +import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; /** * Interval in seconds between two consecutive attempts to upsert @@ -41,27 +41,27 @@ export class EnsDbWriterWorker { private ensDbClient: EnsDbClient; /** - * ENSIndexer Client instance used by the worker to read ENSIndexer Public Config. + * Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. */ - private ensIndexerClient: EnsIndexerClient; + private indexingStatusBuilder: IndexingStatusBuilder; /** - * Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. + * ENSIndexer Public Config Builder instance used by the worker to read ENSIndexer Public Config. */ - private indexingStatusBuilder: IndexingStatusBuilder; + private publicConfigBuilder: PublicConfigBuilder; /** * @param ensDbClient ENSDb Client instance used by the worker to interact with ENSDb. - * @param ensIndexerClient ENSIndexer Client instance used by the worker to read ENSIndexer Public Config. + * @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. */ constructor( ensDbClient: EnsDbClient, - ensIndexerClient: EnsIndexerClient, + publicConfigBuilder: PublicConfigBuilder, indexingStatusBuilder: IndexingStatusBuilder, ) { this.ensDbClient = ensDbClient; - this.ensIndexerClient = ensIndexerClient; + this.publicConfigBuilder = publicConfigBuilder; this.indexingStatusBuilder = indexingStatusBuilder; } @@ -145,11 +145,12 @@ export class EnsDbWriterWorker { private async getValidatedEnsIndexerPublicConfig(): Promise { /** * Fetch the in-memory config with retries, to handle potential transient errors - * in the ENSIndexer Client (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. + * 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 inMemoryConfigPromise = pRetry(() => this.ensIndexerClient.config(), { + const inMemoryConfigPromise = pRetry(() => this.publicConfigBuilder.getPublicConfig(), { retries: 3, onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { console.warn( diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index e61e89dee..d58ddc9e9 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,6 +1,6 @@ import { ensDbClient } from "@/lib/ensdb-client/singleton"; -import { ensIndexerClient } from "@/lib/ensindexer-client/singleton"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import { EnsDbWriterWorker } from "./ensdb-writer-worker"; @@ -20,7 +20,11 @@ export function startEnsDbWriterWorker() { throw new Error("EnsDbWriterWorker has already been initialized"); } - ensDbWriterWorker = new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder); + ensDbWriterWorker = new EnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); ensDbWriterWorker .run() diff --git a/apps/ensindexer/src/lib/public-config-builder/index.ts b/apps/ensindexer/src/lib/public-config-builder/index.ts new file mode 100644 index 000000000..645a128e5 --- /dev/null +++ b/apps/ensindexer/src/lib/public-config-builder/index.ts @@ -0,0 +1 @@ +export * from "./public-config-builder"; diff --git a/apps/ensindexer/src/lib/public-config-builder/public-config-builder.test.ts b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.test.ts new file mode 100644 index 000000000..a1e8b7ac2 --- /dev/null +++ b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.test.ts @@ -0,0 +1,363 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ENSNamespaceIds, + type EnsIndexerPublicConfig, + type EnsIndexerVersionInfo, + type EnsRainbowPublicConfig, + PluginName, +} from "@ensnode/ensnode-sdk"; +import type { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; + +import { PublicConfigBuilder } from "./public-config-builder"; + +// Mock the config module +vi.mock("@/config", () => ({ + default: { + databaseSchemaName: "public", + labelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, + indexedChainIds: new Set([1, 8453]), + isSubgraphCompatible: true, + namespace: ENSNamespaceIds.Mainnet, + plugins: [PluginName.Subgraph], + }, +})); + +// Mock the version-info module +vi.mock("@/lib/version-info", () => ({ + getEnsIndexerVersion: vi.fn(), + getNodeJsVersion: vi.fn(), + getPackageVersion: vi.fn(), +})); + +// Mock the SDK validation functions +vi.mock("@ensnode/ensnode-sdk", async () => { + const actual = + await vi.importActual("@ensnode/ensnode-sdk"); + return { + ...actual, + validateEnsIndexerPublicConfig: vi.fn(), + validateEnsIndexerVersionInfo: vi.fn(), + }; +}); + +import config from "@/config"; + +import { + validateEnsIndexerPublicConfig, + validateEnsIndexerVersionInfo, +} from "@ensnode/ensnode-sdk"; + +import { getEnsIndexerVersion, getNodeJsVersion, getPackageVersion } from "@/lib/version-info"; + +// Test fixtures +const mockEnsRainbowConfig: EnsRainbowPublicConfig = { + version: "1.0.0", + labelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + recordsCount: 1000, +}; + +const mockVersionInfo: EnsIndexerVersionInfo = { + nodejs: "v20.0.0", + ponder: "0.9.0", + ensDb: "1.0.0", + ensIndexer: "1.0.0", + ensNormalize: "1.10.0", +}; + +// Helper to create unique mock config objects for each call +function createMockPublicConfig(overrides: Partial = {}) { + return { + databaseSchemaName: "public", + labelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, + ensRainbowPublicConfig: mockEnsRainbowConfig, + indexedChainIds: new Set([1, 8453]), + isSubgraphCompatible: true, + namespace: ENSNamespaceIds.Mainnet, + plugins: ["subgraph"], + versionInfo: mockVersionInfo, + ...overrides, + } as EnsIndexerPublicConfig; +} + +// Helper to setup standard mocks +function setupStandardMocks() { + vi.mocked(getEnsIndexerVersion).mockReturnValue("1.0.0"); + vi.mocked(getNodeJsVersion).mockReturnValue("v20.0.0"); + vi.mocked(getPackageVersion).mockReturnValue("0.9.0"); + vi.mocked(validateEnsIndexerVersionInfo).mockReturnValue(mockVersionInfo); +} + +describe("PublicConfigBuilder", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getPublicConfig() - successful builds", () => { + it("builds and returns public config on first call", async () => { + // Arrange + const ensRainbowClientMock = { + config: vi.fn().mockResolvedValue(mockEnsRainbowConfig), + } as unknown as EnsRainbowApiClient; + + setupStandardMocks(); + const mockPublicConfig = createMockPublicConfig(); + vi.mocked(validateEnsIndexerPublicConfig).mockReturnValue(mockPublicConfig); + + const builder = new PublicConfigBuilder(ensRainbowClientMock); + + // Act + const result = await builder.getPublicConfig(); + + // Assert + expect(ensRainbowClientMock.config).toHaveBeenCalledTimes(1); + expect(getEnsIndexerVersion).toHaveBeenCalledTimes(1); + expect(getNodeJsVersion).toHaveBeenCalledTimes(1); + expect(getPackageVersion).toHaveBeenCalledWith("ponder"); + expect(getPackageVersion).toHaveBeenCalledWith("@adraffy/ens-normalize"); + + expect(validateEnsIndexerVersionInfo).toHaveBeenCalledWith({ + nodejs: "v20.0.0", + ponder: "0.9.0", + ensDb: "1.0.0", + ensIndexer: "1.0.0", + ensNormalize: "0.9.0", + }); + + expect(validateEnsIndexerPublicConfig).toHaveBeenCalledWith({ + databaseSchemaName: config.databaseSchemaName, + ensRainbowPublicConfig: mockEnsRainbowConfig, + labelSet: config.labelSet, + indexedChainIds: config.indexedChainIds, + isSubgraphCompatible: config.isSubgraphCompatible, + namespace: config.namespace, + plugins: config.plugins, + versionInfo: mockVersionInfo, + }); + + expect(result).toBe(mockPublicConfig); + }); + + it("caches public config and returns cached version on subsequent calls", async () => { + // Arrange + const ensRainbowClientMock = { + config: vi.fn().mockResolvedValue(mockEnsRainbowConfig), + } as unknown as EnsRainbowApiClient; + + setupStandardMocks(); + const mockPublicConfig = createMockPublicConfig(); + vi.mocked(validateEnsIndexerPublicConfig).mockReturnValue(mockPublicConfig); + + const builder = new PublicConfigBuilder(ensRainbowClientMock); + + // Act + const result1 = await builder.getPublicConfig(); + const result2 = await builder.getPublicConfig(); + const result3 = await builder.getPublicConfig(); + + // Assert + expect(ensRainbowClientMock.config).toHaveBeenCalledTimes(1); + expect(getEnsIndexerVersion).toHaveBeenCalledTimes(1); + expect(getNodeJsVersion).toHaveBeenCalledTimes(1); + expect(getPackageVersion).toHaveBeenCalledTimes(2); + expect(validateEnsIndexerVersionInfo).toHaveBeenCalledTimes(1); + expect(validateEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); + + // All results should be the same cached object + expect(result1).toBe(mockPublicConfig); + expect(result2).toBe(mockPublicConfig); + expect(result3).toBe(mockPublicConfig); + expect(result1).toBe(result2); + expect(result2).toBe(result3); + }); + + it("handles different plugin configurations", async () => { + // Arrange + const customConfig = createMockPublicConfig({ + plugins: ["basenames", "lineanames", "subgraph"], + indexedChainIds: new Set([1, 8453, 59144]), + }); + + const ensRainbowClientMock = { + config: vi.fn().mockResolvedValue(mockEnsRainbowConfig), + } as unknown as EnsRainbowApiClient; + + vi.mocked(getEnsIndexerVersion).mockReturnValue("2.0.0"); + vi.mocked(getNodeJsVersion).mockReturnValue("v22.0.0"); + vi.mocked(getPackageVersion).mockReturnValue("1.0.0"); + + const customVersionInfo: EnsIndexerVersionInfo = { + nodejs: "v22.0.0", + ponder: "1.0.0", + ensDb: "2.0.0", + ensIndexer: "2.0.0", + ensNormalize: "1.10.0", + }; + + vi.mocked(validateEnsIndexerVersionInfo).mockReturnValue(customVersionInfo); + vi.mocked(validateEnsIndexerPublicConfig).mockReturnValue(customConfig); + + const builder = new PublicConfigBuilder(ensRainbowClientMock); + + // Act + const result = await builder.getPublicConfig(); + + // Assert + expect(result).toBe(customConfig); + expect(result.plugins).toHaveLength(3); + }); + + it("handles non-subgraph-compatible mode", async () => { + // Arrange + const customConfig = createMockPublicConfig({ + isSubgraphCompatible: false, + labelSet: { labelSetId: "custom", labelSetVersion: 1 }, + }); + + const customEnsRainbowConfig: EnsRainbowPublicConfig = { + version: "1.0.0", + labelSet: { labelSetId: "custom", highestLabelSetVersion: 1 }, + recordsCount: 2000, + }; + + const ensRainbowClientMock = { + config: vi.fn().mockResolvedValue(customEnsRainbowConfig), + } as unknown as EnsRainbowApiClient; + + setupStandardMocks(); + vi.mocked(validateEnsIndexerPublicConfig).mockReturnValue(customConfig); + + const builder = new PublicConfigBuilder(ensRainbowClientMock); + + // Act + const result = await builder.getPublicConfig(); + + // Assert + expect(result).toBe(customConfig); + expect(result.isSubgraphCompatible).toBe(false); + }); + }); + + describe("getPublicConfig() - error handling", () => { + it("throws when ENSRainbow client config() fails", async () => { + // Arrange + const ensRainbowError = new Error("ENSRainbow service unavailable"); + const ensRainbowClientMock = { + config: vi.fn().mockRejectedValue(ensRainbowError), + } as unknown as EnsRainbowApiClient; + + setupStandardMocks(); + + const builder = new PublicConfigBuilder(ensRainbowClientMock); + + // Act & Assert + await expect(builder.getPublicConfig()).rejects.toThrow(ensRainbowError); + expect(ensRainbowClientMock.config).toHaveBeenCalledTimes(1); + expect(validateEnsIndexerPublicConfig).not.toHaveBeenCalled(); + }); + + it("throws when version info validation fails", async () => { + // Arrange + const ensRainbowClientMock = { + config: vi.fn().mockResolvedValue(mockEnsRainbowConfig), + } as unknown as EnsRainbowApiClient; + + setupStandardMocks(); + + const validationError = new Error("Invalid version info: missing required fields"); + vi.mocked(validateEnsIndexerVersionInfo).mockImplementation(() => { + throw validationError; + }); + + const builder = new PublicConfigBuilder(ensRainbowClientMock); + + // Act & Assert + await expect(builder.getPublicConfig()).rejects.toThrow(validationError); + expect(validateEnsIndexerVersionInfo).toHaveBeenCalledTimes(1); + expect(validateEnsIndexerPublicConfig).not.toHaveBeenCalled(); + }); + + it("throws when public config validation fails", async () => { + // Arrange + const ensRainbowClientMock = { + config: vi.fn().mockResolvedValue(mockEnsRainbowConfig), + } as unknown as EnsRainbowApiClient; + + setupStandardMocks(); + + const validationError = new Error("Invalid public config: invalid namespace"); + vi.mocked(validateEnsIndexerPublicConfig).mockImplementation(() => { + throw validationError; + }); + + const builder = new PublicConfigBuilder(ensRainbowClientMock); + + // Act & Assert + await expect(builder.getPublicConfig()).rejects.toThrow(validationError); + expect(validateEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); + }); + }); + + describe("Caching behavior", () => { + it("each builder instance has its own independent cache", async () => { + // Arrange - create unique config objects for each builder + const config1 = createMockPublicConfig({ databaseSchemaName: "schema1" }); + const config2 = createMockPublicConfig({ databaseSchemaName: "schema2" }); + + let callCount = 0; + const ensRainbowClientMock = { + config: vi.fn().mockImplementation(() => { + callCount++; + return Promise.resolve(mockEnsRainbowConfig); + }), + } as unknown as EnsRainbowApiClient; + + setupStandardMocks(); + + // Return different configs for each builder instance + vi.mocked(validateEnsIndexerPublicConfig).mockImplementation(() => { + return callCount === 1 ? config1 : config2; + }); + + // Act + const builder1 = new PublicConfigBuilder(ensRainbowClientMock); + const result1 = await builder1.getPublicConfig(); + + const builder2 = new PublicConfigBuilder(ensRainbowClientMock); + const result2 = await builder2.getPublicConfig(); + + // Assert - each builder should have fetched and cached its own config independently + expect(ensRainbowClientMock.config).toHaveBeenCalledTimes(2); + expect(result1).toBe(config1); + expect(result2).toBe(config2); + expect(result1).not.toBe(result2); + expect(result1.databaseSchemaName).toBe("schema1"); + expect(result2.databaseSchemaName).toBe("schema2"); + }); + + it("retries building config on subsequent calls after failure", async () => { + // Arrange + const ensRainbowClientMock = { + config: vi.fn().mockRejectedValueOnce(new Error("ENSRainbow down")), + } as unknown as EnsRainbowApiClient; + + const builder = new PublicConfigBuilder(ensRainbowClientMock); + + // Act & Assert - first call fails + await expect(builder.getPublicConfig()).rejects.toThrow("ENSRainbow down"); + + // Simulate recovery + vi.mocked(ensRainbowClientMock.config).mockResolvedValue(mockEnsRainbowConfig); + setupStandardMocks(); + const mockPublicConfig = createMockPublicConfig(); + vi.mocked(validateEnsIndexerPublicConfig).mockReturnValue(mockPublicConfig); + + // Second call should succeed + const result = await builder.getPublicConfig(); + + // Assert + expect(ensRainbowClientMock.config).toHaveBeenCalledTimes(2); + expect(result).toBe(mockPublicConfig); + }); + }); +}); diff --git a/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts new file mode 100644 index 000000000..d5c6a2d8d --- /dev/null +++ b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts @@ -0,0 +1,90 @@ +import config from "@/config"; + +import { + type EnsIndexerPublicConfig, + type EnsIndexerVersionInfo, + validateEnsIndexerPublicConfig, + validateEnsIndexerVersionInfo, +} from "@ensnode/ensnode-sdk"; +import type { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; + +import { getEnsIndexerVersion, getNodeJsVersion, getPackageVersion } from "@/lib/version-info"; + +export class PublicConfigBuilder { + /** + * ENSRainbow Client + * + * Used to fetch ENSRainbow Public Config, which is part of + * the ENSIndexer Public Config. + */ + private ensRainbowClient: EnsRainbowApiClient; + + /** + * Immutable ENSIndexer Public Config + * + * The cached ENSIndexer Public Config object, which is built and validated + * on the first call to `getPublicConfig()`, and returned as-is on subsequent calls. + */ + private immutablePublicConfig: EnsIndexerPublicConfig | undefined; + + /** + * @param ensRainbowClient ENSRainbow Client instance used to fetch ENSRainbow Public Config + */ + constructor(ensRainbowClient: EnsRainbowApiClient) { + this.ensRainbowClient = ensRainbowClient; + } + + /** + * Get ENSIndexer Public Config + * + * Note: ENSIndexer Public Config is cached after the first call, so + * subsequent calls will return the cached version without rebuilding it. + * + * @throws if the built ENSIndexer Public Config does not conform to + * the expected schema + */ + async getPublicConfig(): Promise { + if (typeof this.immutablePublicConfig === "undefined") { + const [versionInfo, ensRainbowPublicConfig] = await Promise.all([ + this.getEnsIndexerVersionInfo(), + this.ensRainbowClient.config(), + ]); + + this.immutablePublicConfig = validateEnsIndexerPublicConfig({ + databaseSchemaName: config.databaseSchemaName, + ensRainbowPublicConfig, + labelSet: config.labelSet, + indexedChainIds: config.indexedChainIds, + isSubgraphCompatible: config.isSubgraphCompatible, + namespace: config.namespace, + plugins: config.plugins, + versionInfo, + }); + } + + return this.immutablePublicConfig; + } + + /** + * Get ENSIndexer Version Info + * + * @throws if the built ENSIndexer Version Info does not conform to + * the expected schema. + */ + private getEnsIndexerVersionInfo(): EnsIndexerVersionInfo { + // ENSIndexer version + const ensIndexerVersion = getEnsIndexerVersion(); + + // ENSDb version + // ENSDb version is always the same as the ENSIndexer version number + const ensDbVersion = ensIndexerVersion; + + return validateEnsIndexerVersionInfo({ + nodejs: getNodeJsVersion(), + ponder: getPackageVersion("ponder"), + ensDb: ensDbVersion, + ensIndexer: ensIndexerVersion, + ensNormalize: getPackageVersion("@adraffy/ens-normalize"), + }); + } +} diff --git a/apps/ensindexer/src/lib/public-config-builder/singleton.ts b/apps/ensindexer/src/lib/public-config-builder/singleton.ts new file mode 100644 index 000000000..18ccfe402 --- /dev/null +++ b/apps/ensindexer/src/lib/public-config-builder/singleton.ts @@ -0,0 +1,6 @@ +import { getENSRainbowApiClient } from "@/lib/ensraibow-api-client"; +import { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; + +const ensRainbowClient = getENSRainbowApiClient(); + +export const publicConfigBuilder = new PublicConfigBuilder(ensRainbowClient); diff --git a/apps/ensindexer/src/lib/version-info.ts b/apps/ensindexer/src/lib/version-info.ts index 55bd8b65f..c13100735 100644 --- a/apps/ensindexer/src/lib/version-info.ts +++ b/apps/ensindexer/src/lib/version-info.ts @@ -9,6 +9,20 @@ import { prettifyError } from "zod/v4"; import type { ENSIndexerVersionInfo, SerializedENSIndexerVersionInfo } from "@ensnode/ensnode-sdk"; import { makeENSIndexerVersionInfoSchema } from "@ensnode/ensnode-sdk/internal"; +/** + * Get ENSIndexer version + */ +export function getEnsIndexerVersion(): string { + return packageJson.version; +} + +/** + * Get Node.js version + */ +export function getNodeJsVersion(): string { + return process.versions.node; +} + /** * Get NPM package version. * @@ -102,32 +116,3 @@ function getPackageVersionFromPnpmStore(pnpmDir: string, packageName: string): s return null; } - -/** - * Get complete {@link ENSIndexerVersionInfo} for ENSIndexer app. - */ -export async function getENSIndexerVersionInfo(): Promise { - // ENSIndexer version - const ensIndexerVersion = packageJson.version; - - // ENSDb version - // ENSDb version is always the same as the ENSIndexer version number - const ensDbVersion = ensIndexerVersion; - - // parse unvalidated version info - const schema = makeENSIndexerVersionInfoSchema(); - const parsed = schema.safeParse({ - nodejs: process.versions.node, - ponder: getPackageVersion("ponder"), - ensDb: ensDbVersion, - ensIndexer: ensIndexerVersion, - ensNormalize: getPackageVersion("@adraffy/ens-normalize"), - } satisfies SerializedENSIndexerVersionInfo); - - if (parsed.error) { - throw new Error(`Cannot deserialize ENSIndexerVersionInfo:\n${prettifyError(parsed.error)}\n`); - } - - // return version info we have now validated - return parsed.data; -} diff --git a/packages/ensnode-sdk/src/ensindexer/config/index.ts b/packages/ensnode-sdk/src/ensindexer/config/index.ts index 77df62706..efba37f8a 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/index.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/index.ts @@ -7,3 +7,5 @@ export * from "./parsing"; export * from "./serialize"; export * from "./serialized-types"; export * from "./types"; +export * from "./validate/ensindexer-public-config"; +export * from "./validate/ensindexer-version-info"; diff --git a/packages/ensnode-sdk/src/ensindexer/config/validate/ensindexer-public-config.ts b/packages/ensnode-sdk/src/ensindexer/config/validate/ensindexer-public-config.ts new file mode 100644 index 000000000..8f9c4ca8f --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/config/validate/ensindexer-public-config.ts @@ -0,0 +1,25 @@ +import { prettifyError } from "zod/v4"; + +import type { Unvalidated } from "../../../shared/types"; +import type { EnsIndexerPublicConfig } from "../types"; +import { makeEnsIndexerPublicConfigSchema } from "../zod-schemas"; + +/** + * Validates an unvalidated representation of + * {@link EnsIndexerPublicConfig} object. + * + * @throws Error if the provided object is not + * a valid {@link EnsIndexerPublicConfig}. + */ +export function validateEnsIndexerPublicConfig( + unvalidatedConfig: Unvalidated, +): EnsIndexerPublicConfig { + const schema = makeEnsIndexerPublicConfigSchema(); + const result = schema.safeParse(unvalidatedConfig); + + if (!result.success) { + throw new Error(`Invalid ENSIndexerPublicConfig: ${prettifyError(result.error)}`); + } + + return result.data; +} diff --git a/packages/ensnode-sdk/src/ensindexer/config/validate/ensindexer-version-info.ts b/packages/ensnode-sdk/src/ensindexer/config/validate/ensindexer-version-info.ts new file mode 100644 index 000000000..997bc4e3c --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/config/validate/ensindexer-version-info.ts @@ -0,0 +1,25 @@ +import { prettifyError } from "zod/v4"; + +import type { Unvalidated } from "../../../shared/types"; +import type { EnsIndexerVersionInfo } from "../types"; +import { makeEnsIndexerVersionInfoSchema } from "../zod-schemas"; + +/** + * Validates an unvalidated representation of + * {@link EnsIndexerVersionInfo} object. + * + * @throws Error if the provided object is not + * a valid {@link EnsIndexerVersionInfo}. + */ +export function validateEnsIndexerVersionInfo( + unvalidatedVersionInfo: Unvalidated, +): EnsIndexerVersionInfo { + const schema = makeEnsIndexerVersionInfoSchema(); + const result = schema.safeParse(unvalidatedVersionInfo); + + if (!result.success) { + throw new Error(`Invalid EnsIndexerVersionInfo: ${prettifyError(result.error)}`); + } + + return result.data; +}