From f8497792b83445c4dde6369a933c12455797b25f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 7 Feb 2026 11:54:39 +0100 Subject: [PATCH 1/6] Refactor ENSIndexer data model for Indexing Status The goal of this change is to split deserialization from validation, so it's possible to: a) deserialize a serialized value, including validation; b) validate an unvalidated business-layer value. --- .../src/api/indexing-status/deserialize.ts | 34 +- .../src/api/indexing-status/zod-schemas.ts | 29 +- .../{test-helpers.ts => block-refs.mock.ts} | 0 .../chain-indexing-status-snapshot.mocks.ts | 122 ++++ .../chain-indexing-status-snapshot.test.ts | 44 ++ .../chain-indexing-status-snapshot.ts | 385 ++++++++++++ .../indexing-status/conversions.test.ts | 19 +- .../cross-chain-indexing-status-snapshot.ts | 117 ++++ .../ensindexer/indexing-status/deserialize.ts | 96 --- .../chain-indexing-status-snapshot.ts | 24 + .../cross-chain-indexing-status-snapshot.ts | 70 +++ .../omnichain-indexing-status-snapshot.ts | 179 ++++++ .../realtime-indexing-status-projection.ts | 64 ++ .../indexing-status/helpers.test.ts | 329 ---------- .../src/ensindexer/indexing-status/helpers.ts | 287 --------- .../src/ensindexer/indexing-status/index.ts | 18 +- ...omnichain-indexing-status-snapshot.test.ts | 205 ++++++ .../omnichain-indexing-status-snapshot.ts | 273 ++++++++ .../ensindexer/indexing-status/projection.ts | 28 - ...altime-indexing-status-projection.test.ts} | 14 +- .../realtime-indexing-status-projection.ts | 59 ++ .../ensindexer/indexing-status/serialize.ts | 98 --- .../chain-indexing-status-snapshot.test.ts} | 11 +- .../chain-indexing-status-snapshot.ts | 52 ++ .../cross-chain-indexing-status-snapshot.ts | 39 ++ .../omnichain-indexing-status-snapshot.ts | 98 +++ .../realtime-indexing-status-projection.ts | 35 ++ .../indexing-status/serialized-types.ts | 116 ---- .../src/ensindexer/indexing-status/types.ts | 591 ------------------ .../chain-indexing-status-snapshot.ts | 234 +++++++ .../cross-chain-indexing-status-snapshot.ts | 120 ++++ .../omnichain-indexing-status-snapshot.ts | 413 ++++++++++++ .../realtime-indexing-status-projection.ts | 80 +++ .../ensindexer/indexing-status/validations.ts | 490 --------------- .../ensindexer/indexing-status/zod-schemas.ts | 273 -------- packages/ensnode-sdk/src/internal.ts | 1 - 36 files changed, 2714 insertions(+), 2333 deletions(-) rename packages/ensnode-sdk/src/ensindexer/indexing-status/{test-helpers.ts => block-refs.mock.ts} (100%) create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.mocks.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.test.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/cross-chain-indexing-status-snapshot.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/chain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/cross-chain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/realtime-indexing-status-projection.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.test.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.test.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/projection.ts rename packages/ensnode-sdk/src/ensindexer/indexing-status/{projection.test.ts => realtime-indexing-status-projection.test.ts} (77%) create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/realtime-indexing-status-projection.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/serialize.ts rename packages/ensnode-sdk/src/ensindexer/indexing-status/{zod-schemas.test.ts => serialize/chain-indexing-status-snapshot.test.ts} (97%) create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/cross-chain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/omnichain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/realtime-indexing-status-projection.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/serialized-types.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/types.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/validate/cross-chain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/validate/realtime-indexing-status-projection.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/validations.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.ts diff --git a/packages/ensnode-sdk/src/api/indexing-status/deserialize.ts b/packages/ensnode-sdk/src/api/indexing-status/deserialize.ts index e566d9dcd..90e38fb51 100644 --- a/packages/ensnode-sdk/src/api/indexing-status/deserialize.ts +++ b/packages/ensnode-sdk/src/api/indexing-status/deserialize.ts @@ -1,8 +1,34 @@ -import { prettifyError } from "zod/v4"; +import z, { prettifyError } from "zod/v4"; +import { buildUnvalidatedRealtimeIndexingStatusProjection } from "../../ensindexer/indexing-status/deserialize/realtime-indexing-status-projection"; +import { IndexingStatusResponseCodes } from "../types"; import type { IndexingStatusResponse } from "./response"; import type { SerializedIndexingStatusResponse } from "./serialized-response"; -import { makeIndexingStatusResponseSchema } from "./zod-schemas"; +import { + makeIndexingStatusResponseSchema, + makeSerializedIndexingStatusResponseSchema, +} from "./zod-schemas"; + +/** + * Build unvalidated indexing status response to be validated. + * + * Return type is intentionally "unknown" to enforce validation by + * {@link makeIndexingStatusResponseSchema} call. + */ +function buildUnvalidatedIndexingStatusResponse( + serializedResponse: SerializedIndexingStatusResponse, +): unknown { + if (serializedResponse.responseCode === IndexingStatusResponseCodes.Error) { + return serializedResponse; + } + + const { responseCode, realtimeProjection } = serializedResponse; + + return { + responseCode, + realtimeProjection: buildUnvalidatedRealtimeIndexingStatusProjection(realtimeProjection), + }; +} /** * Deserialize a {@link IndexingStatusResponse} object. @@ -10,7 +36,9 @@ import { makeIndexingStatusResponseSchema } from "./zod-schemas"; export function deserializeIndexingStatusResponse( maybeResponse: SerializedIndexingStatusResponse, ): IndexingStatusResponse { - const parsed = makeIndexingStatusResponseSchema().safeParse(maybeResponse); + const parsed = makeSerializedIndexingStatusResponseSchema() + .pipe(z.preprocess(buildUnvalidatedIndexingStatusResponse, makeIndexingStatusResponseSchema())) + .safeParse(maybeResponse); if (parsed.error) { throw new Error(`Cannot deserialize IndexingStatusResponse:\n${prettifyError(parsed.error)}\n`); diff --git a/packages/ensnode-sdk/src/api/indexing-status/zod-schemas.ts b/packages/ensnode-sdk/src/api/indexing-status/zod-schemas.ts index 407ae50ce..0d1399189 100644 --- a/packages/ensnode-sdk/src/api/indexing-status/zod-schemas.ts +++ b/packages/ensnode-sdk/src/api/indexing-status/zod-schemas.ts @@ -1,12 +1,17 @@ import { z } from "zod/v4"; -import { makeRealtimeIndexingStatusProjectionSchema } from "../../ensindexer/indexing-status/zod-schemas"; +import { makeSerializedRealtimeIndexingStatusProjectionSchema } from "../../ensindexer/indexing-status/deserialize/realtime-indexing-status-projection"; +import { makeRealtimeIndexingStatusProjectionSchema } from "../../ensindexer/indexing-status/validate/realtime-indexing-status-projection"; import { type IndexingStatusResponse, IndexingStatusResponseCodes, type IndexingStatusResponseError, type IndexingStatusResponseOk, } from "./response"; +import { + SerializedIndexingStatusResponse, + SerializedIndexingStatusResponseOk, +} from "./serialized-response"; /** * Schema for {@link IndexingStatusResponseOk} @@ -19,6 +24,17 @@ export const makeIndexingStatusResponseOkSchema = ( realtimeProjection: makeRealtimeIndexingStatusProjectionSchema(valueLabel), }); +/** + * Schema for {@link SerializedIndexingStatusResponseOk} + **/ +export const makeSerializedIndexingStatusResponseOkSchema = ( + valueLabel: string = "Serialized Indexing Status Response OK", +) => + z.strictObject({ + responseCode: z.literal(IndexingStatusResponseCodes.Ok), + realtimeProjection: makeSerializedRealtimeIndexingStatusProjectionSchema(valueLabel), + }); + /** * Schema for {@link IndexingStatusResponseError} **/ @@ -37,3 +53,14 @@ export const makeIndexingStatusResponseSchema = (valueLabel: string = "Indexing makeIndexingStatusResponseOkSchema(valueLabel), makeIndexingStatusResponseErrorSchema(valueLabel), ]); + +/** + * Schema for {@link SerializedIndexingStatusResponse} + **/ +export const makeSerializedIndexingStatusResponseSchema = ( + valueLabel: string = "Serialized Indexing Status Response", +) => + z.discriminatedUnion("responseCode", [ + makeSerializedIndexingStatusResponseOkSchema(valueLabel), + makeIndexingStatusResponseErrorSchema(valueLabel), + ]); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/test-helpers.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/block-refs.mock.ts similarity index 100% rename from packages/ensnode-sdk/src/ensindexer/indexing-status/test-helpers.ts rename to packages/ensnode-sdk/src/ensindexer/indexing-status/block-refs.mock.ts diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.mocks.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.mocks.ts new file mode 100644 index 000000000..8aab8c4aa --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.mocks.ts @@ -0,0 +1,122 @@ +import { + earlierBlockRef, + earliestBlockRef, + laterBlockRef, + latestBlockRef, +} from "./block-refs.mock"; +import { + ChainIndexingConfigTypeIds, + ChainIndexingStatusIds, + type ChainIndexingStatusSnapshot, + type ChainIndexingStatusSnapshotBackfill, + type ChainIndexingStatusSnapshotCompleted, + type ChainIndexingStatusSnapshotFollowing, + type ChainIndexingStatusSnapshotQueued, +} from "./chain-indexing-status-snapshot"; +import type { ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill } from "./omnichain-indexing-status-snapshot"; + +export const chainStatusesQueued = [ + { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: latestBlockRef, + }, + }, + { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: laterBlockRef, + }, + }, +] satisfies ChainIndexingStatusSnapshotQueued[]; + +export const chainStatusesCompleted = [ + { + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + + endBlock: latestBlockRef, + }, + latestIndexedBlock: latestBlockRef, + }, + + { + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: laterBlockRef, + }, +] satisfies ChainIndexingStatusSnapshotCompleted[]; + +export const chainStatusesBackfillMixed = [ + { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: latestBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued, + + { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earliestBlockRef, + }, + latestIndexedBlock: laterBlockRef, + backfillEndBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill, + + { + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotCompleted, +] satisfies ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill[]; + +export const chainStatusesFollowingMixed = [ + { + chainStatus: ChainIndexingStatusIds.Following, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earlierBlockRef, + }, + latestIndexedBlock: laterBlockRef, + latestKnownBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotFollowing, + + { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: latestBlockRef, + }, + latestIndexedBlock: laterBlockRef, + backfillEndBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill, + + { + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotCompleted, +] satisfies ChainIndexingStatusSnapshot[]; diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.test.ts new file mode 100644 index 000000000..a5e8f89f1 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { earlierBlockRef, laterBlockRef } from "./block-refs.mock"; +import { + type ChainIndexingConfigDefinite, + type ChainIndexingConfigIndefinite, + ChainIndexingConfigTypeIds, + createIndexingConfig, +} from "./chain-indexing-status-snapshot"; + +describe("Chain Indexing Status Snapshot", () => { + describe("createIndexingConfig", () => { + it("returns 'definite' indexer config if the endBlock exists", () => { + // arrange + const startBlock = earlierBlockRef; + const endBlock = laterBlockRef; + + // act + const indexingConfig = createIndexingConfig(startBlock, endBlock); + + // assert + expect(indexingConfig).toStrictEqual({ + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: laterBlockRef, + } satisfies ChainIndexingConfigDefinite); + }); + + it("returns 'indefinite' indexer config if the endBlock exists", () => { + // arrange + const startBlock = earlierBlockRef; + const endBlock = null; + + // act + const indexingConfig = createIndexingConfig(startBlock, endBlock); + + // assert + expect(indexingConfig).toStrictEqual({ + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earlierBlockRef, + } satisfies ChainIndexingConfigIndefinite); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..9b2f8d3da --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.ts @@ -0,0 +1,385 @@ +import type { BlockRef, ChainId, UnixTimestamp } from "../../shared/types"; + +/** + * The type of indexing configuration for a chain. + */ +export const ChainIndexingConfigTypeIds = { + /** + * Represents that indexing of the chain should be performed for an indefinite range. + */ + Indefinite: "indefinite", + + /** + * Represents that indexing of the chain should be performed for a definite range. + */ + Definite: "definite", +} as const; + +/** + * The derived string union of possible {@link ChainIndexingConfigTypeIds}. + */ +export type ChainIndexingConfigTypeId = + (typeof ChainIndexingConfigTypeIds)[keyof typeof ChainIndexingConfigTypeIds]; + +/** + * Chain indexing config for a chain whose indexing config `configType` is + * {@link ChainIndexingConfigTypeIds.Indefinite}. + * + * Invariants: + * - `configType` is always `ChainIndexingConfigTypeIds.Indefinite`. + */ +export interface ChainIndexingConfigIndefinite { + /** + * The type of chain indexing config. + */ + configType: typeof ChainIndexingConfigTypeIds.Indefinite; + + /** + * A {@link BlockRef} to the block where indexing of the chain should start. + */ + startBlock: BlockRef; +} + +/** + * Chain indexing config for a chain whose indexing config `configType` is + * {@link ChainIndexingConfigTypeIds.Definite}. + * + * Invariants: + * - `configType` is always `ChainIndexingConfigTypeIds.Definite`. + * - `startBlock` is always before or the same as `endBlock`. + */ +export interface ChainIndexingConfigDefinite { + /** + * The type of chain indexing config. + */ + configType: typeof ChainIndexingConfigTypeIds.Definite; + + /** + * A {@link BlockRef} to the block where indexing of the chain should start. + */ + startBlock: BlockRef; + + /** + * A {@link BlockRef} to the block where indexing of the chain should end. + */ + endBlock: BlockRef; +} + +/** + * Indexing configuration for a chain. + * + * Use the `configType` field to determine the specific type interpretation + * at runtime. + */ +export type ChainIndexingConfig = ChainIndexingConfigIndefinite | ChainIndexingConfigDefinite; + +/** + * The status of indexing a chain at the time an indexing status snapshot + * is captured. + */ +export const ChainIndexingStatusIds = { + /** + * Represents that indexing of the chain is not ready to begin yet because: + * - ENSIndexer is in its initialization phase and the data to build a + * "true" {@link ChainIndexingStatusSnapshot} for the chain is still being loaded; or + * - ENSIndexer is using an omnichain indexing strategy and the + * `omnichainIndexingCursor` is <= `config.startBlock.timestamp` for the chain's + * {@link ChainIndexingStatusSnapshot}. + */ + Queued: "chain-queued", + + /** + * Represents that indexing of the chain is in progress and under a special + * "backfill" phase that optimizes for accelerated indexing until reaching the + * "fixed target" `backfillEndBlock`. + */ + Backfill: "chain-backfill", + + /** + * Represents that the "backfill" phase of indexing the chain is completed + * and that the chain is configured to be indexed for an indefinite range. + * Therefore, indexing of the chain remains indefinitely in progress where + * ENSIndexer will continuously work to discover and index new blocks as they + * are added to the chain across time. + */ + Following: "chain-following", + + /** + * Represents that indexing of the chain is completed as the chain is configured + * to be indexed for a definite range and the indexing of all blocks through + * that definite range is completed. + */ + Completed: "chain-completed", +} as const; + +/** + * The derived string union of possible {@link ChainIndexingStatusIds}. + */ +export type ChainIndexingStatusId = + (typeof ChainIndexingStatusIds)[keyof typeof ChainIndexingStatusIds]; + +/** + * Chain indexing status snapshot for a chain whose `chainStatus` is + * {@link ChainIndexingStatusIds.Queued}. + * + * Invariants: + * - `chainStatus` is always {@link ChainIndexingStatusIds.Queued}. + */ +export interface ChainIndexingStatusSnapshotQueued { + /** + * The status of indexing the chain at the time the indexing status snapshot + * was captured. + */ + chainStatus: typeof ChainIndexingStatusIds.Queued; + + /** + * The indexing configuration of the chain. + */ + config: ChainIndexingConfig; +} + +/** + * Chain indexing status snapshot for a chain whose `chainStatus` is + * {@link ChainIndexingStatusIds.Backfill}. + * + * During a backfill, special performance optimizations are applied to + * index all blocks between `config.startBlock` and `backfillEndBlock` + * as fast as possible. + * + * Note how `backfillEndBlock` is a "fixed target" that does not change during + * the lifetime of an ENSIndexer process instance: + * - If the `config` is {@link ChainIndexingConfigDefinite}: + * `backfillEndBlock` is always the same as `config.endBlock`. + * - If the `config` is {@link ChainIndexingConfigIndefinite}: + * `backfillEndBlock` is a {@link BlockRef} to what was the latest block on the + * chain when the ENSIndexer process was performing its initialization. Note how + * this means that if the backfill process takes X hours to complete, because the + * `backfillEndBlock` is a "fixed target", when `chainStatus` transitions to + * {@link ChainIndexingStatusIds.Following} the chain will be X hours behind + * "realtime" indexing. + * + * When `latestIndexedBlock` reaches `backfillEndBlock` the backfill is complete. + * The moment backfill is complete the `chainStatus` may not immediately transition. + * Instead, internal processing is completed for a period of time while + * `chainStatus` remains {@link ChainIndexingStatusIds.Backfill}. After this internal + * processing is completed `chainStatus` will transition: + * - to {@link ChainIndexingStatusIds.Following} if the `config` is + * {@link ChainIndexingConfigIndefinite}. + * - to {@link ChainIndexingStatusIds.Completed} if the `config` is + * {@link ChainIndexingConfigDefinite}. + * + * Invariants: + * - `chainStatus` is always {@link ChainIndexingStatusIds.Backfill}. + * - `config.startBlock` is always before or the same as `latestIndexedBlock` + * - `config.endBlock` is always the same as `backfillEndBlock` if and only if + * the config is {@link ChainIndexingConfigDefinite}. + * - `latestIndexedBlock` is always before or the same as `backfillEndBlock` + */ +export interface ChainIndexingStatusSnapshotBackfill { + /** + * The status of indexing the chain at the time the indexing status snapshot + * was captured. + */ + chainStatus: typeof ChainIndexingStatusIds.Backfill; + + /** + * The indexing configuration of the chain. + */ + config: ChainIndexingConfig; + + /** + * A {@link BlockRef} to the block that was most recently indexed as of the time the + * indexing status snapshot was captured. + */ + latestIndexedBlock: BlockRef; + + /** + * A {@link BlockRef} to the block where the backfill will end. + */ + backfillEndBlock: BlockRef; +} + +/** + * Chain indexing status snapshot for a chain whose `chainStatus` is + * {@link ChainIndexingStatusIds.Following}. + * + * Invariants: + * - `chainStatus` is always {@link ChainIndexingStatusIds.Following}. + * - `config` is always {@link ChainIndexingConfigIndefinite} + * - `config.startBlock` is always before or the same as `latestIndexedBlock` + * - `latestIndexedBlock` is always before or the same as `latestKnownBlock` + */ +export interface ChainIndexingStatusSnapshotFollowing { + /** + * The status of indexing the chain at the time the indexing status snapshot + * was captured. + */ + chainStatus: typeof ChainIndexingStatusIds.Following; + + /** + * The indexing configuration of the chain. + */ + config: ChainIndexingConfigIndefinite; + + /** + * A {@link BlockRef} to the block that was most recently indexed as of the time the + * indexing status snapshot was captured. + */ + latestIndexedBlock: BlockRef; + + /** + * A {@link BlockRef} to the "highest" block that has been discovered by RPCs + * and stored in the RPC cache as of the time the indexing status snapshot was + * captured. + */ + latestKnownBlock: BlockRef; +} + +/** + * Chain indexing status snapshot for a chain whose `chainStatus` is + * {@link ChainIndexingStatusIds.Completed}. + * + * After the backfill of a chain is completed, if the chain was configured + * to be indexed for a definite range, the chain indexing status will transition to + * {@link ChainIndexingStatusIds.Completed}. + * + * Invariants: + * - `chainStatus` is always {@link ChainIndexingStatusIds.Completed}. + * - `config` is always {@link ChainIndexingConfigDefinite} + * - `config.startBlock` is always before or the same as `latestIndexedBlock` + * - `latestIndexedBlock` is always the same as `config.endBlock`. + */ +export interface ChainIndexingStatusSnapshotCompleted { + /** + * The status of indexing the chain at the time the indexing status snapshot + * was captured. + */ + chainStatus: typeof ChainIndexingStatusIds.Completed; + + /** + * The indexing configuration of the chain. + */ + config: ChainIndexingConfigDefinite; + + /** + * A {@link BlockRef} to the block that was most recently indexed as of the time the + * indexing status snapshot was captured. + */ + latestIndexedBlock: BlockRef; +} + +/** + * Indexing status snapshot for a single chain. + * + * Use the `chainStatus` field to determine the specific type interpretation + * at runtime. + */ +export type ChainIndexingStatusSnapshot = + | ChainIndexingStatusSnapshotQueued + | ChainIndexingStatusSnapshotBackfill + | ChainIndexingStatusSnapshotFollowing + | ChainIndexingStatusSnapshotCompleted; + +/** + * Create {@link ChainIndexingConfig} for given block refs. + * + * @param startBlock required block ref + * @param endBlock optional block ref + */ +export function createIndexingConfig( + startBlock: BlockRef, + endBlock: BlockRef | null, +): ChainIndexingConfig { + if (endBlock) { + return { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock, + endBlock, + } satisfies ChainIndexingConfigDefinite; + } + + return { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock, + } satisfies ChainIndexingConfigIndefinite; +} + +/** + * Get the timestamp of the lowest `config.startBlock` across all chains + * in the provided array of {@link ChainIndexingStatusSnapshot}. + * + * Such timestamp is useful when presenting the "lowest" block + * to be indexed across all chains. + */ +export function getTimestampForLowestOmnichainStartBlock( + chains: ChainIndexingStatusSnapshot[], +): UnixTimestamp { + const earliestKnownBlockTimestamps: UnixTimestamp[] = chains.map( + (chain) => chain.config.startBlock.timestamp, + ); + + return Math.min(...earliestKnownBlockTimestamps); +} + +/** + * Get the timestamp of the "highest known block" across all chains + * in the provided array of {@link ChainIndexingStatusSnapshot}. + * + * Such timestamp is useful when presenting the "highest known block" + * to be indexed across all chains. + * + * The "highest known block" for a chain depends on its status: + * - `config.endBlock` for a "queued" chain, + * - `backfillEndBlock` for a "backfill" chain, + * - `latestIndexedBlock` for a "completed" chain, + * - `latestKnownBlock` for a "following" chain. + */ +export function getTimestampForHighestOmnichainKnownBlock( + chains: ChainIndexingStatusSnapshot[], +): UnixTimestamp { + const latestKnownBlockTimestamps: UnixTimestamp[] = []; + + for (const chain of chains) { + switch (chain.chainStatus) { + case ChainIndexingStatusIds.Queued: + if ( + chain.config.configType === ChainIndexingConfigTypeIds.Definite && + chain.config.endBlock + ) { + latestKnownBlockTimestamps.push(chain.config.endBlock.timestamp); + } + break; + + case ChainIndexingStatusIds.Backfill: + latestKnownBlockTimestamps.push(chain.backfillEndBlock.timestamp); + + break; + + case ChainIndexingStatusIds.Completed: + latestKnownBlockTimestamps.push(chain.latestIndexedBlock.timestamp); + break; + + case ChainIndexingStatusIds.Following: + latestKnownBlockTimestamps.push(chain.latestKnownBlock.timestamp); + break; + } + } + + return Math.max(...latestKnownBlockTimestamps); +} + +/** + * Sort a list of [{@link ChainId}, {@link ChainIndexingStatusSnapshot}] tuples + * by the omnichain start block timestamp in ascending order. + */ +export function sortChainStatusesByStartBlockAsc< + ChainStatusType extends ChainIndexingStatusSnapshot, +>(chains: [ChainId, ChainStatusType][]): [ChainId, ChainStatusType][] { + // Sort the chain statuses by the omnichain first block to index timestamp + chains.sort( + ([, chainA], [, chainB]) => + chainA.config.startBlock.timestamp - chainB.config.startBlock.timestamp, + ); + + return chains; +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/conversions.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/conversions.test.ts index 3a7b265f0..6f731860e 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/conversions.test.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/conversions.test.ts @@ -1,18 +1,27 @@ import { describe, expect, it } from "vitest"; -import { deserializeOmnichainIndexingStatusSnapshot } from "./deserialize"; -import { serializeOmnichainIndexingStatusSnapshot } from "./serialize"; -import type { SerializedOmnichainIndexingStatusSnapshot } from "./serialized-types"; -import { earlierBlockRef, earliestBlockRef, laterBlockRef, latestBlockRef } from "./test-helpers"; +import { + earlierBlockRef, + earliestBlockRef, + laterBlockRef, + latestBlockRef, +} from "./block-refs.mock"; import { ChainIndexingConfigTypeIds, ChainIndexingStatusIds, type ChainIndexingStatusSnapshotBackfill, type ChainIndexingStatusSnapshotFollowing, type ChainIndexingStatusSnapshotQueued, +} from "./chain-indexing-status-snapshot"; +import { deserializeOmnichainIndexingStatusSnapshot } from "./deserialize/omnichain-indexing-status-snapshot"; +import { OmnichainIndexingStatusIds, type OmnichainIndexingStatusSnapshot, -} from "./types"; +} from "./omnichain-indexing-status-snapshot"; +import { + type SerializedOmnichainIndexingStatusSnapshot, + serializeOmnichainIndexingStatusSnapshot, +} from "./serialize/omnichain-indexing-status-snapshot"; describe("ENSIndexer: Indexing Status", () => { describe("Omnichain Indexing Status Snapshot", () => { diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/cross-chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/cross-chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..a438783ae --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/cross-chain-indexing-status-snapshot.ts @@ -0,0 +1,117 @@ +import type { BlockRef, ChainId, UnixTimestamp } from "../../shared/types"; +import { ChainIndexingStatusIds } from "./chain-indexing-status-snapshot"; +import type { OmnichainIndexingStatusSnapshot } from "./omnichain-indexing-status-snapshot"; + +/** + * The strategy used for indexing one or more chains. + * + * @see https://ponder.sh/docs/api-reference/ponder/config#parameters + */ +export const CrossChainIndexingStrategyIds = { + /** + * Represents that the indexing of events across all indexed chains will + * proceed in a deterministic "omnichain" ordering by block timestamp, chain ID, + * and block number. + * + * This strategy is "deterministic" in that the order of processing cross-chain indexed + * events and each resulting indexed data state transition recorded in ENSDb is always + * the same for each ENSIndexer instance operating with an equivalent + * `ENSIndexerConfig` and ENSIndexer version. However it also has the drawbacks of: + * - increased indexing latency that must wait for the slowest indexed chain to + * add new blocks or to discover new blocks through the configured RPCs. + * - if any indexed chain gets "stuck" due to chain or RPC failures, all indexed chains + * will be affected. + */ + Omnichain: "omnichain", +} as const; + +/** + * The derived string union of possible {@link CrossChainIndexingStrategyIds}. + */ +export type CrossChainIndexingStrategyId = + (typeof CrossChainIndexingStrategyIds)[keyof typeof CrossChainIndexingStrategyIds]; + +/** + * Cross-chain indexing status snapshot when the `strategy` is + * {@link CrossChainIndexingStrategyId.Omnichain}. + * + * Invariants: + * - `strategy` is always {@link CrossChainIndexingStrategyId.Omnichain}. + * - `slowestChainIndexingCursor` is always equal to + * `omnichainSnapshot.omnichainIndexingCursor`. + * - `snapshotTime` is always >= the "highest known block timestamp", defined as the max of: + * - the `slowestChainIndexingCursor`. + * - the `config.startBlock.timestamp` for all indexed chains. + * - the `config.endBlock.timestamp` for all indexed chains with a `config.configType` of + * {@link ChainIndexingConfigTypeIds.Definite}. + * - the `backfillEndBlock.timestamp` for all chains with `chainStatus` of + * {@link ChainIndexingStatusIds.Backfill}. + * - the `latestKnownBlock.timestamp` for all chains with `chainStatus` of + * {@link ChainIndexingStatusIds.Following}. + */ +export interface CrossChainIndexingStatusSnapshotOmnichain { + /** + * The strategy used for indexing one or more chains. + */ + strategy: typeof CrossChainIndexingStrategyIds.Omnichain; + + /** + * The timestamp of the "slowest" latest indexed block timestamp + * across all indexed chains. + */ + slowestChainIndexingCursor: UnixTimestamp; + + /** + * The timestamp when the cross-chain indexing status snapshot was generated. + * + * Due to possible clock skew between different systems this value must be set + * to the max of each of the following values to ensure all invariants are followed: + * - the current system time of the system generating this cross-chain indexing + * status snapshot. + * - the "highest known block timestamp" (see invariants above for full definition). + */ + snapshotTime: UnixTimestamp; + + /** + * The omnichain indexing status snapshot for one or more chains. + */ + omnichainSnapshot: OmnichainIndexingStatusSnapshot; +} + +/** + * Cross-chain indexing status snapshot for one or more chains. + * + * Use the `strategy` field to determine the specific type interpretation + * at runtime. + * + * Currently, only omnichain indexing is supported. This type could theoretically + * be extended to support other cross-chain indexing strategies in the future, + * such as Ponder's "multichain" indexing strategy that indexes each chain + * independently without deterministic ordering. + */ +export type CrossChainIndexingStatusSnapshot = CrossChainIndexingStatusSnapshotOmnichain; + +/** + * Gets the latest indexed {@link BlockRef} for the given {@link ChainId}. + * + * @returns the latest indexed {@link BlockRef} for the given {@link ChainId}, or null if the chain + * isn't being indexed at all or is queued and therefore hasn't started indexing yet. + */ +export function getLatestIndexedBlockRef( + indexingStatus: CrossChainIndexingStatusSnapshot, + chainId: ChainId, +): BlockRef | null { + const chainIndexingStatus = indexingStatus.omnichainSnapshot.chains.get(chainId); + + if (chainIndexingStatus === undefined) { + // chain isn't being indexed at all + return null; + } + + if (chainIndexingStatus.chainStatus === ChainIndexingStatusIds.Queued) { + // chain is queued, so no data for the chain has been indexed yet + return null; + } + + return chainIndexingStatus.latestIndexedBlock; +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize.ts deleted file mode 100644 index e2148deac..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { prettifyError } from "zod/v4"; - -import type { - SerializedChainIndexingStatusSnapshot, - SerializedCrossChainIndexingStatusSnapshot, - SerializedOmnichainIndexingStatusSnapshot, - SerializedRealtimeIndexingStatusProjection, -} from "./serialized-types"; -import type { - ChainIndexingStatusSnapshot, - CrossChainIndexingStatusSnapshot, - OmnichainIndexingStatusSnapshot, - RealtimeIndexingStatusProjection, -} from "./types"; -import { - makeChainIndexingStatusSnapshotSchema, - makeCrossChainIndexingStatusSnapshotSchema, - makeOmnichainIndexingStatusSnapshotSchema, - makeRealtimeIndexingStatusProjectionSchema, -} from "./zod-schemas"; - -/** - * Deserialize into a {@link ChainIndexingSnapshot} object. - */ -export function deserializeChainIndexingStatusSnapshot( - maybeSnapshot: SerializedChainIndexingStatusSnapshot, - valueLabel?: string, -): ChainIndexingStatusSnapshot { - const schema = makeChainIndexingStatusSnapshotSchema(valueLabel); - const parsed = schema.safeParse(maybeSnapshot); - - if (parsed.error) { - throw new Error( - `Cannot deserialize into ChainIndexingStatusSnapshot:\n${prettifyError(parsed.error)}\n`, - ); - } - - return parsed.data; -} - -/** - * Deserialize an {@link OmnichainIndexingStatusSnapshot} object. - */ -export function deserializeOmnichainIndexingStatusSnapshot( - maybeSnapshot: SerializedOmnichainIndexingStatusSnapshot, - valueLabel?: string, -): OmnichainIndexingStatusSnapshot { - const schema = makeOmnichainIndexingStatusSnapshotSchema(valueLabel); - const parsed = schema.safeParse(maybeSnapshot); - - if (parsed.error) { - throw new Error( - `Cannot deserialize into OmnichainIndexingStatusSnapshot:\n${prettifyError(parsed.error)}\n`, - ); - } - - return parsed.data; -} - -/** - * Deserialize an {@link CrossChainIndexingStatusSnapshot} object. - */ -export function deserializeCrossChainIndexingStatusSnapshot( - maybeSnapshot: SerializedCrossChainIndexingStatusSnapshot, - valueLabel?: string, -): CrossChainIndexingStatusSnapshot { - const schema = makeCrossChainIndexingStatusSnapshotSchema(valueLabel); - const parsed = schema.safeParse(maybeSnapshot); - - if (parsed.error) { - throw new Error( - `Cannot deserialize into CrossChainIndexingStatusSnapshot:\n${prettifyError(parsed.error)}\n`, - ); - } - - return parsed.data; -} - -/** - * Deserialize into a {@link RealtimeIndexingStatusProjection} object. - */ -export function deserializeRealtimeIndexingStatusProjection( - maybeProjection: SerializedRealtimeIndexingStatusProjection, - valueLabel?: string, -): RealtimeIndexingStatusProjection { - const schema = makeRealtimeIndexingStatusProjectionSchema(valueLabel); - const parsed = schema.safeParse(maybeProjection); - - if (parsed.error) { - throw new Error( - `Cannot deserialize into RealtimeIndexingStatusProjection:\n${prettifyError(parsed.error)}\n`, - ); - } - - return parsed.data; -} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..204962b1e --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/chain-indexing-status-snapshot.ts @@ -0,0 +1,24 @@ +import { prettifyError } from "zod/v4"; + +import type { ChainIndexingStatusSnapshot } from "../chain-indexing-status-snapshot"; +import type { SerializedChainIndexingStatusSnapshot } from "../serialize/chain-indexing-status-snapshot"; +import { makeChainIndexingStatusSnapshotSchema } from "../validate/chain-indexing-status-snapshot"; + +/** + * Deserialize into a {@link ChainIndexingStatusSnapshot} object. + */ +export function deserializeChainIndexingStatusSnapshot( + maybeSnapshot: SerializedChainIndexingStatusSnapshot, + valueLabel?: string, +): ChainIndexingStatusSnapshot { + const schema = makeChainIndexingStatusSnapshotSchema(valueLabel); + const parsed = schema.safeParse(maybeSnapshot); + + if (parsed.error) { + throw new Error( + `Cannot deserialize into ChainIndexingStatusSnapshot:\n${prettifyError(parsed.error)}\n`, + ); + } + + return parsed.data; +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/cross-chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/cross-chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..0fb711c69 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/cross-chain-indexing-status-snapshot.ts @@ -0,0 +1,70 @@ +import z, { prettifyError } from "zod/v4"; + +import { makeUnixTimestampSchema } from "../../../shared/zod-schemas"; +import { + type CrossChainIndexingStatusSnapshot, + CrossChainIndexingStrategyIds, +} from "../cross-chain-indexing-status-snapshot"; +import type { SerializedCrossChainIndexingStatusSnapshot } from "../serialize/cross-chain-indexing-status-snapshot"; +import { makeCrossChainIndexingStatusSnapshotSchema } from "../validate/cross-chain-indexing-status-snapshot"; +import { + buildUnvalidatedOmnichainIndexingStatusSnapshot, + makeSerializedOmnichainIndexingStatusSnapshotSchema, +} from "./omnichain-indexing-status-snapshot"; + +/** + * Deserialize an {@link CrossChainIndexingStatusSnapshot} object. + */ +export function deserializeCrossChainIndexingStatusSnapshot( + maybeSnapshot: SerializedCrossChainIndexingStatusSnapshot, + valueLabel?: string, +): CrossChainIndexingStatusSnapshot { + const schema = makeSerializedCrossChainIndexingStatusSnapshotSchema(valueLabel).pipe( + z.preprocess( + buildUnvalidatedCrossChainIndexingStatusSnapshot, + makeCrossChainIndexingStatusSnapshotSchema(valueLabel), + ), + ); + const parsed = schema.safeParse(maybeSnapshot); + + if (parsed.error) { + throw new Error( + `Cannot deserialize into CrossChainIndexingStatusSnapshot:\n${prettifyError(parsed.error)}\n`, + ); + } + + return parsed.data; +} + +/** + * Build unvalidated cross-chain indexing status snapshot to be validated. + * + * Return type is intentionally "unknown" to enforce validation by + * {@link makeCrossChainIndexingStatusSnapshotSchema} call. + */ +export function buildUnvalidatedCrossChainIndexingStatusSnapshot( + serializedCrossChainIndexingStatusSnapshot: SerializedCrossChainIndexingStatusSnapshot, +): unknown { + const { strategy, slowestChainIndexingCursor, snapshotTime, omnichainSnapshot } = + serializedCrossChainIndexingStatusSnapshot; + + return { + strategy, + slowestChainIndexingCursor, + snapshotTime, + omnichainSnapshot: buildUnvalidatedOmnichainIndexingStatusSnapshot(omnichainSnapshot), + }; +} + +/** + * Makes Zod schema for {@link SerializedCrossChainIndexingStatusSnapshot} + */ +export const makeSerializedCrossChainIndexingStatusSnapshotSchema = ( + valueLabel: string = "Cross-chain Indexing Status Snapshot", +) => + z.object({ + strategy: z.enum(CrossChainIndexingStrategyIds), + slowestChainIndexingCursor: makeUnixTimestampSchema(valueLabel), + snapshotTime: makeUnixTimestampSchema(valueLabel), + omnichainSnapshot: makeSerializedOmnichainIndexingStatusSnapshotSchema(valueLabel), + }); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts new file mode 100644 index 000000000..30d8ae1e6 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts @@ -0,0 +1,179 @@ +import z, { prettifyError } from "zod/v4"; + +import { deserializeChainId } from "../../../shared/deserialize"; +import type { ChainIdString } from "../../../shared/serialized-types"; +import type { ChainId } from "../../../shared/types"; +import { makeChainIdStringSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; +import type { + ChainIndexingStatusSnapshot, + ChainIndexingStatusSnapshotCompleted, + ChainIndexingStatusSnapshotQueued, +} from "../chain-indexing-status-snapshot"; +import { + type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, +} from "../omnichain-indexing-status-snapshot"; +import type { SerializedChainIndexingStatusSnapshot } from "../serialize/chain-indexing-status-snapshot"; +import type { + SerializedOmnichainIndexingStatusSnapshot, + SerializedOmnichainIndexingStatusSnapshotBackfill, + SerializedOmnichainIndexingStatusSnapshotCompleted, + SerializedOmnichainIndexingStatusSnapshotUnstarted, +} from "../serialize/omnichain-indexing-status-snapshot"; +import { makeChainIndexingStatusSnapshotSchema } from "../validate/chain-indexing-status-snapshot"; +import { makeOmnichainIndexingStatusSnapshotSchema } from "../validate/omnichain-indexing-status-snapshot"; + +/** + * Build unvalidated omnichain indexing status snapshot to be validated. + * + * Return type is intentionally "unknown" to enforce validation by + * {@link makeOmnichainIndexingStatusSnapshotSchema} call. + */ +export function buildUnvalidatedOmnichainIndexingStatusSnapshot( + serializedOmnichainIndexingStatusSnapshot: SerializedOmnichainIndexingStatusSnapshot, +): unknown { + const { omnichainStatus, chains, omnichainIndexingCursor } = + serializedOmnichainIndexingStatusSnapshot; + + switch (omnichainStatus) { + case OmnichainIndexingStatusIds.Unstarted: { + return { + omnichainStatus, + chains: buildUnvalidatedChainIndexingStatuses(chains) as Map< + ChainId, + ChainIndexingStatusSnapshotQueued + >, + omnichainIndexingCursor, + }; + } + + case OmnichainIndexingStatusIds.Backfill: { + return { + omnichainStatus, + chains: buildUnvalidatedChainIndexingStatuses(chains) as Map< + ChainId, + ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill + >, + omnichainIndexingCursor, + }; + } + + case OmnichainIndexingStatusIds.Following: { + return { + omnichainStatus, + chains: buildUnvalidatedChainIndexingStatuses(chains), + omnichainIndexingCursor, + }; + } + + case OmnichainIndexingStatusIds.Completed: { + return { + omnichainStatus, + chains: buildUnvalidatedChainIndexingStatuses(chains) as Map< + ChainId, + ChainIndexingStatusSnapshotCompleted + >, + omnichainIndexingCursor, + }; + } + } +} + +/** + * Deserialize an {@link OmnichainIndexingStatusSnapshot} object. + */ +export function deserializeOmnichainIndexingStatusSnapshot( + maybeSnapshot: SerializedOmnichainIndexingStatusSnapshot, + valueLabel?: string, +): OmnichainIndexingStatusSnapshot { + const schema = makeSerializedOmnichainIndexingStatusSnapshotSchema(valueLabel).pipe( + z.preprocess( + buildUnvalidatedOmnichainIndexingStatusSnapshot, + makeOmnichainIndexingStatusSnapshotSchema(valueLabel), + ), + ); + const parsed = schema.safeParse(maybeSnapshot); + + if (parsed.error) { + throw new Error( + `Cannot deserialize into OmnichainIndexingStatusSnapshot:\n${prettifyError(parsed.error)}\n`, + ); + } + + return parsed.data; +} + +/** + * Build unvalidated chain indexing statuses map to be validated by + * {@link makeChainIndexingStatusesSchema} call. + */ +export function buildUnvalidatedChainIndexingStatuses( + serializedChainIndexingStatuses: Record, +): Map { + const chainIndexingStatuses = new Map(); + + for (const [serializedChainId, chainIndexingSnapshot] of Object.entries( + serializedChainIndexingStatuses, + )) { + const chainId = deserializeChainId(serializedChainId); + + chainIndexingStatuses.set(chainId, chainIndexingSnapshot); + } + + return chainIndexingStatuses; +} + +const makeSerializedChainIndexingStatusSnapshotsSchema = (valueLabel: string = "Value") => + z.record(makeChainIdStringSchema(), makeChainIndexingStatusSnapshotSchema(valueLabel)); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotUnstarted} + */ +const makeSerializedOmnichainIndexingStatusSnapshotUnstartedSchema = (valueLabel?: string) => + z.strictObject({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Unstarted), + chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotBackfill} + */ +const makeSerializedOmnichainIndexingStatusSnapshotBackfillSchema = (valueLabel?: string) => + z.strictObject({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Backfill), + chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotCompleted} + */ +const makeSerializedOmnichainIndexingStatusSnapshotCompletedSchema = (valueLabel?: string) => + z.strictObject({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Completed), + chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotFollowing} + */ +const makeSerializedOmnichainIndexingStatusSnapshotFollowingSchema = (valueLabel?: string) => + z.strictObject({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Following), + chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshot}. + */ +export const makeSerializedOmnichainIndexingStatusSnapshotSchema = (valueLabel: string = "Value") => + z.discriminatedUnion("omnichainStatus", [ + makeSerializedOmnichainIndexingStatusSnapshotUnstartedSchema(valueLabel), + makeSerializedOmnichainIndexingStatusSnapshotBackfillSchema(valueLabel), + makeSerializedOmnichainIndexingStatusSnapshotCompletedSchema(valueLabel), + makeSerializedOmnichainIndexingStatusSnapshotFollowingSchema(valueLabel), + ]); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/realtime-indexing-status-projection.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/realtime-indexing-status-projection.ts new file mode 100644 index 000000000..53657605c --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/realtime-indexing-status-projection.ts @@ -0,0 +1,64 @@ +import { prettifyError, z } from "zod/v4"; + +import { makeDurationSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; +import type { RealtimeIndexingStatusProjection } from "../realtime-indexing-status-projection"; +import type { SerializedRealtimeIndexingStatusProjection } from "../serialize/realtime-indexing-status-projection"; +import { makeRealtimeIndexingStatusProjectionSchema } from "../validate/realtime-indexing-status-projection"; +import { + buildUnvalidatedCrossChainIndexingStatusSnapshot, + makeSerializedCrossChainIndexingStatusSnapshotSchema, +} from "./cross-chain-indexing-status-snapshot"; + +/** + * Build unvalidated realtime indexing status projection to be validated. + * + * Return type is intentionally "unknown" to enforce validation by + * {@link makeRealtimeIndexingStatusProjectionSchema} call. + */ +export function buildUnvalidatedRealtimeIndexingStatusProjection( + serializedProjection: SerializedRealtimeIndexingStatusProjection, +): unknown { + const { snapshot, projectedAt, worstCaseDistance } = serializedProjection; + + return { + snapshot: buildUnvalidatedCrossChainIndexingStatusSnapshot(snapshot), + projectedAt, + worstCaseDistance, + }; +} + +/** + * Deserialize into a {@link RealtimeIndexingStatusProjection} object. + */ +export function deserializeRealtimeIndexingStatusProjection( + maybeProjection: SerializedRealtimeIndexingStatusProjection, + valueLabel?: string, +): RealtimeIndexingStatusProjection { + const schema = makeSerializedRealtimeIndexingStatusProjectionSchema(valueLabel).pipe( + z.preprocess( + buildUnvalidatedRealtimeIndexingStatusProjection, + makeRealtimeIndexingStatusProjectionSchema(valueLabel), + ), + ); + const parsed = schema.safeParse(maybeProjection); + + if (parsed.error) { + throw new Error( + `Cannot deserialize into RealtimeIndexingStatusProjection:\n${prettifyError(parsed.error)}\n`, + ); + } + + return parsed.data; +} + +/** + * Makes Zod schema for {@link SerializedRealtimeIndexingStatusProjection}. + */ +export const makeSerializedRealtimeIndexingStatusProjectionSchema = ( + valueLabel: string = "Value", +) => + z.strictObject({ + snapshot: makeSerializedCrossChainIndexingStatusSnapshotSchema(valueLabel), + projectedAt: makeUnixTimestampSchema(valueLabel), + worstCaseDistance: makeDurationSchema(valueLabel), + }); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.test.ts deleted file mode 100644 index 88343d2c2..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { BlockRef } from "../../shared/types"; -import { - createIndexingConfig, - getOmnichainIndexingCursor, - getOmnichainIndexingStatus, -} from "./helpers"; -import { earlierBlockRef, earliestBlockRef, laterBlockRef, latestBlockRef } from "./test-helpers"; -import { - type ChainIndexingConfigDefinite, - type ChainIndexingConfigIndefinite, - ChainIndexingConfigTypeIds, - ChainIndexingStatusIds, - type ChainIndexingStatusSnapshot, - type ChainIndexingStatusSnapshotBackfill, - type ChainIndexingStatusSnapshotCompleted, - type ChainIndexingStatusSnapshotFollowing, - type ChainIndexingStatusSnapshotQueued, - OmnichainIndexingStatusIds, -} from "./types"; - -describe("ENSIndexer: Indexing Snapshot helpers", () => { - describe("getOmnichainIndexingStatus", () => { - it("can correctly derive 'completed' status if all chains are 'completed'", () => { - // arrange - const chainStatuses: ChainIndexingStatusSnapshot[] = [ - { - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - - endBlock: latestBlockRef, - }, - latestIndexedBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted, - - { - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earliestBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted, - ]; - - // act - const overallIndexingStatus = getOmnichainIndexingStatus(chainStatuses); - - // assert - expect(overallIndexingStatus).toStrictEqual(OmnichainIndexingStatusIds.Completed); - }); - - it("can correctly derive 'unstarted' status if all chains are in 'queued' status", () => { - // arrange - const chainStatuses: ChainIndexingStatusSnapshot[] = [ - { - chainStatus: ChainIndexingStatusIds.Queued, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earliestBlockRef, - endBlock: latestBlockRef, - }, - } satisfies ChainIndexingStatusSnapshotQueued, - { - chainStatus: ChainIndexingStatusIds.Queued, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earliestBlockRef, - endBlock: laterBlockRef, - }, - } satisfies ChainIndexingStatusSnapshotQueued, - ]; - - // act - const overallIndexingStatus = getOmnichainIndexingStatus(chainStatuses); - - // assert - expect(overallIndexingStatus).toStrictEqual(OmnichainIndexingStatusIds.Unstarted); - }); - - it("can correctly derive 'backfill' status if all chains are either 'queued', 'backfill' or 'completed'", () => { - // arrange - const chainStatuses: ChainIndexingStatusSnapshot[] = [ - { - chainStatus: ChainIndexingStatusIds.Queued, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earliestBlockRef, - endBlock: latestBlockRef, - }, - } satisfies ChainIndexingStatusSnapshotQueued, - - { - chainStatus: ChainIndexingStatusIds.Backfill, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: earliestBlockRef, - }, - latestIndexedBlock: laterBlockRef, - backfillEndBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotBackfill, - - { - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earliestBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted, - ]; - - // act - const overallIndexingStatus = getOmnichainIndexingStatus(chainStatuses); - - // assert - expect(overallIndexingStatus).toStrictEqual(OmnichainIndexingStatusIds.Backfill); - }); - - it("can correctly derive 'following' status if at least one chain is 'following", () => { - // arrange - const chainStatuses: ChainIndexingStatusSnapshot[] = [ - { - chainStatus: ChainIndexingStatusIds.Following, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: earlierBlockRef, - }, - latestIndexedBlock: laterBlockRef, - latestKnownBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotFollowing, - - { - chainStatus: ChainIndexingStatusIds.Backfill, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earliestBlockRef, - endBlock: latestBlockRef, - }, - latestIndexedBlock: laterBlockRef, - backfillEndBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotBackfill, - - { - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earliestBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted, - ]; - - // act - const overallIndexingStatus = getOmnichainIndexingStatus(chainStatuses); - - // assert - expect(overallIndexingStatus).toStrictEqual(OmnichainIndexingStatusIds.Following); - }); - }); - - describe("createIndexingConfig", () => { - it("returns 'definite' indexer config if the endBlock exists", () => { - // arrange - const startBlock = earlierBlockRef; - const endBlock = laterBlockRef; - - // act - const indexingConfig = createIndexingConfig(startBlock, endBlock); - - // assert - expect(indexingConfig).toStrictEqual({ - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: laterBlockRef, - } satisfies ChainIndexingConfigDefinite); - }); - - it("returns 'indefinite' indexer config if the endBlock exists", () => { - // arrange - const startBlock = earlierBlockRef; - const endBlock = null; - - // act - const indexingConfig = createIndexingConfig(startBlock, endBlock); - - // assert - expect(indexingConfig).toStrictEqual({ - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: earlierBlockRef, - } satisfies ChainIndexingConfigIndefinite); - }); - }); -}); - -describe("getOmnichainIndexingCursor", () => { - it("returns the correct cursor for the given chains in any status", () => { - // arrange - const evenLaterBlockRef: BlockRef = { - timestamp: latestBlockRef.timestamp + 1000, - number: latestBlockRef.number + 1000, - }; - - const chainStatuses = [ - { - chainStatus: ChainIndexingStatusIds.Queued, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: evenLaterBlockRef, - }, - } satisfies ChainIndexingStatusSnapshotQueued, - - { - chainStatus: ChainIndexingStatusIds.Backfill, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earliestBlockRef, - endBlock: latestBlockRef, - }, - latestIndexedBlock: earlierBlockRef, - backfillEndBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotBackfill, - - { - chainStatus: ChainIndexingStatusIds.Following, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: earliestBlockRef, - }, - latestIndexedBlock: earlierBlockRef, - latestKnownBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotFollowing, - { - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: latestBlockRef, - }, - latestIndexedBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted, - ]; - - // act - const omnichainIndexingCursor = getOmnichainIndexingCursor(chainStatuses); - - // assert - expect(omnichainIndexingCursor).toEqual(latestBlockRef.timestamp); - }); - - it("returns the correct cursor for the given queued chains only", () => { - expect( - getOmnichainIndexingCursor([ - { - chainStatus: ChainIndexingStatusIds.Queued, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: earliestBlockRef, - }, - } satisfies ChainIndexingStatusSnapshotQueued, - { - chainStatus: ChainIndexingStatusIds.Queued, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: laterBlockRef, - }, - } satisfies ChainIndexingStatusSnapshotQueued, - ]), - ).toEqual(earliestBlockRef.timestamp - 1); - }); - - it("returns the correct cursor for the given indexed chains", () => { - // arrange - const evenLaterBlockRef: BlockRef = { - timestamp: latestBlockRef.timestamp + 1000, - number: latestBlockRef.number + 1000, - }; - - const chainStatuses = [ - { - chainStatus: ChainIndexingStatusIds.Backfill, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earliestBlockRef, - endBlock: latestBlockRef, - }, - latestIndexedBlock: earlierBlockRef, - backfillEndBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotBackfill, - - { - chainStatus: ChainIndexingStatusIds.Following, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: earliestBlockRef, - }, - latestIndexedBlock: evenLaterBlockRef, - latestKnownBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotFollowing, - { - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: latestBlockRef, - }, - latestIndexedBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted, - ]; - - // act - const omnichainIndexingCursor = getOmnichainIndexingCursor(chainStatuses); - - // assert - expect(omnichainIndexingCursor).toEqual(evenLaterBlockRef.timestamp); - }); - - it("throws error when no chains were provided", () => { - expect(() => getOmnichainIndexingCursor([])).toThrowError( - /Unable to determine omnichain indexing cursor/, - ); - }); -}); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.ts deleted file mode 100644 index 085923944..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { BlockRef, ChainId, UnixTimestamp } from "../../shared/types"; -import { - type ChainIndexingConfig, - type ChainIndexingConfigDefinite, - type ChainIndexingConfigIndefinite, - ChainIndexingConfigTypeIds, - ChainIndexingStatusIds, - type ChainIndexingStatusSnapshot, - type ChainIndexingStatusSnapshotCompleted, - type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill, - type ChainIndexingStatusSnapshotQueued, - type CrossChainIndexingStatusSnapshot, - type OmnichainIndexingStatusId, - OmnichainIndexingStatusIds, -} from "./types"; - -/** - * Get {@link OmnichainIndexingStatusId} based on indexed chains' statuses. - * - * This function decides what is the `OmnichainIndexingStatusId` is, - * based on provided chain indexing statuses. - * - * @throws an error if unable to determine overall indexing status - */ -export function getOmnichainIndexingStatus( - chains: ChainIndexingStatusSnapshot[], -): OmnichainIndexingStatusId { - if (checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing(chains)) { - return OmnichainIndexingStatusIds.Following; - } - - if (checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill(chains)) { - return OmnichainIndexingStatusIds.Backfill; - } - - if (checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted(chains)) { - return OmnichainIndexingStatusIds.Unstarted; - } - - if (checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted(chains)) { - return OmnichainIndexingStatusIds.Completed; - } - - // if none of the chain statuses matched, throw an error - throw new Error(`Unable to determine omnichain indexing status for provided chains.`); -} - -/** - * Get the timestamp of the lowest `config.startBlock` across all chains - * in the provided array of {@link ChainIndexingStatusSnapshot}. - * - * Such timestamp is useful when presenting the "lowest" block - * to be indexed across all chains. - */ -export function getTimestampForLowestOmnichainStartBlock( - chains: ChainIndexingStatusSnapshot[], -): UnixTimestamp { - const earliestKnownBlockTimestamps: UnixTimestamp[] = chains.map( - (chain) => chain.config.startBlock.timestamp, - ); - - return Math.min(...earliestKnownBlockTimestamps); -} - -/** - * Get the timestamp of the "highest known block" across all chains - * in the provided array of {@link ChainIndexingStatusSnapshot}. - * - * Such timestamp is useful when presenting the "highest known block" - * to be indexed across all chains. - * - * The "highest known block" for a chain depends on its status: - * - `config.endBlock` for a "queued" chain, - * - `backfillEndBlock` for a "backfill" chain, - * - `latestIndexedBlock` for a "completed" chain, - * - `latestKnownBlock` for a "following" chain. - */ -export function getTimestampForHighestOmnichainKnownBlock( - chains: ChainIndexingStatusSnapshot[], -): UnixTimestamp { - const latestKnownBlockTimestamps: UnixTimestamp[] = []; - - for (const chain of chains) { - switch (chain.chainStatus) { - case ChainIndexingStatusIds.Queued: - if ( - chain.config.configType === ChainIndexingConfigTypeIds.Definite && - chain.config.endBlock - ) { - latestKnownBlockTimestamps.push(chain.config.endBlock.timestamp); - } - break; - - case ChainIndexingStatusIds.Backfill: - latestKnownBlockTimestamps.push(chain.backfillEndBlock.timestamp); - - break; - - case ChainIndexingStatusIds.Completed: - latestKnownBlockTimestamps.push(chain.latestIndexedBlock.timestamp); - break; - - case ChainIndexingStatusIds.Following: - latestKnownBlockTimestamps.push(chain.latestKnownBlock.timestamp); - break; - } - } - - return Math.max(...latestKnownBlockTimestamps); -} - -/** - * Get Omnichain Indexing Cursor - * - * The cursor tracks the "highest" latest indexed block timestamp across - * all indexed chains. If all chains are queued, the cursor tracks the moment - * just before the earliest start block timestamp across those chains. - * - * @throws an error if no chains are provided - */ -export function getOmnichainIndexingCursor(chains: ChainIndexingStatusSnapshot[]): UnixTimestamp { - if (chains.length === 0) { - throw new Error(`Unable to determine omnichain indexing cursor when no chains were provided.`); - } - - // for omnichain indexing status snapshot 'unstarted', the cursor tracks - // the moment just before the indexing would start from. - if (getOmnichainIndexingStatus(chains) === OmnichainIndexingStatusIds.Unstarted) { - const earliestStartBlockTimestamps = chains.map((chain) => chain.config.startBlock.timestamp); - - return Math.min(...earliestStartBlockTimestamps) - 1; - } - - // otherwise, the cursor tracks the "highest" latest indexed block timestamp - // across all indexed chains - const latestIndexedBlockTimestamps = chains - .filter((chain) => chain.chainStatus !== ChainIndexingStatusIds.Queued) - .map((chain) => chain.latestIndexedBlock.timestamp); - - // Invariant: there's at least one element in `latestIndexedBlockTimestamps` array - // This is theoretically impossible based on the 2 checks above, - // but the invariant is explicitly added here as a formality. - if (latestIndexedBlockTimestamps.length < 1) { - throw new Error("latestIndexedBlockTimestamps array must include at least one element"); - } - - return Math.max(...latestIndexedBlockTimestamps); -} - -/** - * Create {@link ChainIndexingConfig} for given block refs. - * - * @param startBlock required block ref - * @param endBlock optional block ref - */ -export function createIndexingConfig( - startBlock: BlockRef, - endBlock: BlockRef | null, -): ChainIndexingConfig { - if (endBlock) { - return { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock, - endBlock, - } satisfies ChainIndexingConfigDefinite; - } - - return { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock, - } satisfies ChainIndexingConfigIndefinite; -} - -/** - * Check if Chain Indexing Status Snapshots fit the 'unstarted' overall status - * snapshot requirements: - * - All chains are guaranteed to have a status of "queued". - * - * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to - * {@link ChainIndexingStatusSnapshotQueued}. - */ -export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted( - chains: ChainIndexingStatusSnapshot[], -): chains is ChainIndexingStatusSnapshotQueued[] { - return chains.every((chain) => chain.chainStatus === ChainIndexingStatusIds.Queued); -} - -/** - * Check if Chain Indexing Status Snapshots fit the 'backfill' overall status - * snapshot requirements: - * - At least one chain is guaranteed to be in the "backfill" status. - * - Each chain is guaranteed to have a status of either "queued", - * "backfill" or "completed". - * - * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to - * {@link ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill}. - */ -export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill( - chains: ChainIndexingStatusSnapshot[], -): chains is ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill[] { - const atLeastOneChainInTargetStatus = chains.some( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill, - ); - const otherChainsHaveValidStatuses = chains.every( - (chain) => - chain.chainStatus === ChainIndexingStatusIds.Queued || - chain.chainStatus === ChainIndexingStatusIds.Backfill || - chain.chainStatus === ChainIndexingStatusIds.Completed, - ); - - return atLeastOneChainInTargetStatus && otherChainsHaveValidStatuses; -} - -/** - * Checks if Chain Indexing Status Snapshots fit the 'completed' overall status - * snapshot requirements: - * - All chains are guaranteed to have a status of "completed". - * - * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to - * {@link ChainIndexingStatusSnapshotCompleted}. - */ -export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted( - chains: ChainIndexingStatusSnapshot[], -): chains is ChainIndexingStatusSnapshotCompleted[] { - const allChainsHaveValidStatuses = chains.every( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Completed, - ); - - return allChainsHaveValidStatuses; -} - -/** - * Checks Chain Indexing Status Snapshots fit the 'following' overall status - * snapshot requirements: - * - At least one chain is guaranteed to be in the "following" status. - * - Any other chain can have any status. - */ -export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing( - chains: ChainIndexingStatusSnapshot[], -): chains is ChainIndexingStatusSnapshot[] { - const allChainsHaveValidStatuses = chains.some( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Following, - ); - - return allChainsHaveValidStatuses; -} - -/** - * Sort a list of [{@link ChainId}, {@link ChainIndexingStatusSnapshot}] tuples - * by the omnichain start block timestamp in ascending order. - */ -export function sortChainStatusesByStartBlockAsc< - ChainStatusType extends ChainIndexingStatusSnapshot, ->(chains: [ChainId, ChainStatusType][]): [ChainId, ChainStatusType][] { - // Sort the chain statuses by the omnichain first block to index timestamp - chains.sort( - ([, chainA], [, chainB]) => - chainA.config.startBlock.timestamp - chainB.config.startBlock.timestamp, - ); - - return chains; -} - -/** - * Gets the latest indexed {@link BlockRef} for the given {@link ChainId}. - * - * @returns the latest indexed {@link BlockRef} for the given {@link ChainId}, or null if the chain - * isn't being indexed at all or is queued and therefore hasn't started indexing yet. - */ -export function getLatestIndexedBlockRef( - indexingStatus: CrossChainIndexingStatusSnapshot, - chainId: ChainId, -): BlockRef | null { - const chainIndexingStatus = indexingStatus.omnichainSnapshot.chains.get(chainId); - - if (chainIndexingStatus === undefined) { - // chain isn't being indexed at all - return null; - } - - if (chainIndexingStatus.chainStatus === ChainIndexingStatusIds.Queued) { - // chain is queued, so no data for the chain has been indexed yet - return null; - } - - return chainIndexingStatus.latestIndexedBlock; -} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/index.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/index.ts index 62c1304c1..5feea6fea 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/index.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/index.ts @@ -1,6 +1,12 @@ -export * from "./deserialize"; -export * from "./helpers"; -export * from "./projection"; -export * from "./serialize"; -export * from "./serialized-types"; -export * from "./types"; +export * from "./chain-indexing-status-snapshot"; +export * from "./cross-chain-indexing-status-snapshot"; +export * from "./deserialize/chain-indexing-status-snapshot"; +export * from "./deserialize/cross-chain-indexing-status-snapshot"; +export * from "./deserialize/omnichain-indexing-status-snapshot"; +export * from "./deserialize/realtime-indexing-status-projection"; +export * from "./omnichain-indexing-status-snapshot"; +export * from "./realtime-indexing-status-projection"; +export * from "./serialize/chain-indexing-status-snapshot"; +export * from "./serialize/cross-chain-indexing-status-snapshot"; +export * from "./serialize/omnichain-indexing-status-snapshot"; +export * from "./serialize/realtime-indexing-status-projection"; diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.test.ts new file mode 100644 index 000000000..1af3899f0 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; + +import type { BlockRef } from "../../shared/types"; +import { + earlierBlockRef, + earliestBlockRef, + laterBlockRef, + latestBlockRef, +} from "./block-refs.mock"; +import { + ChainIndexingConfigTypeIds, + ChainIndexingStatusIds, + type ChainIndexingStatusSnapshotBackfill, + type ChainIndexingStatusSnapshotCompleted, + type ChainIndexingStatusSnapshotFollowing, + type ChainIndexingStatusSnapshotQueued, +} from "./chain-indexing-status-snapshot"; +import { + chainStatusesBackfillMixed, + chainStatusesCompleted, + chainStatusesFollowingMixed, + chainStatusesQueued, +} from "./chain-indexing-status-snapshot.mocks"; +import { + getOmnichainIndexingCursor, + getOmnichainIndexingStatus, + OmnichainIndexingStatusIds, +} from "./omnichain-indexing-status-snapshot"; + +describe("Omnichain Indexing Status", () => { + describe("getOmnichainIndexingStatus", () => { + it("can correctly derive 'completed' status if all chains are 'completed'", () => { + // arrange + const chainStatuses = chainStatusesCompleted; + + // act + const overallIndexingStatus = getOmnichainIndexingStatus(chainStatuses); + + // assert + expect(overallIndexingStatus).toStrictEqual(OmnichainIndexingStatusIds.Completed); + }); + + it("can correctly derive 'unstarted' status if all chains are in 'queued' status", () => { + // arrange + const chainStatuses = chainStatusesQueued; + + // act + const overallIndexingStatus = getOmnichainIndexingStatus(chainStatuses); + + // assert + expect(overallIndexingStatus).toStrictEqual(OmnichainIndexingStatusIds.Unstarted); + }); + + it("can correctly derive 'backfill' status if all chains are either 'queued', 'backfill' or 'completed'", () => { + // arrange + const chainStatuses = chainStatusesBackfillMixed; + + // act + const overallIndexingStatus = getOmnichainIndexingStatus(chainStatuses); + + // assert + expect(overallIndexingStatus).toStrictEqual(OmnichainIndexingStatusIds.Backfill); + }); + + it("can correctly derive 'following' status if at least one chain is 'following", () => { + // arrange + const chainStatuses = chainStatusesFollowingMixed; + + // act + const overallIndexingStatus = getOmnichainIndexingStatus(chainStatuses); + + // assert + expect(overallIndexingStatus).toStrictEqual(OmnichainIndexingStatusIds.Following); + }); + }); + + describe("getOmnichainIndexingCursor", () => { + it("returns the correct cursor for the given chains in any status", () => { + // arrange + const evenLaterBlockRef: BlockRef = { + timestamp: latestBlockRef.timestamp + 1000, + number: latestBlockRef.number + 1000, + }; + + const chainStatuses = [ + { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: evenLaterBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued, + + { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: latestBlockRef, + }, + latestIndexedBlock: earlierBlockRef, + backfillEndBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill, + + { + chainStatus: ChainIndexingStatusIds.Following, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earliestBlockRef, + }, + latestIndexedBlock: earlierBlockRef, + latestKnownBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotFollowing, + { + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: latestBlockRef, + }, + latestIndexedBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotCompleted, + ]; + + // act + const omnichainIndexingCursor = getOmnichainIndexingCursor(chainStatuses); + + // assert + expect(omnichainIndexingCursor).toEqual(latestBlockRef.timestamp); + }); + + it("returns the correct cursor for the given queued chains only", () => { + expect( + getOmnichainIndexingCursor([ + { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earliestBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued, + { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: laterBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued, + ]), + ).toEqual(earliestBlockRef.timestamp - 1); + }); + + it("returns the correct cursor for the given indexed chains", () => { + // arrange + const evenLaterBlockRef: BlockRef = { + timestamp: latestBlockRef.timestamp + 1000, + number: latestBlockRef.number + 1000, + }; + + const chainStatuses = [ + { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: latestBlockRef, + }, + latestIndexedBlock: earlierBlockRef, + backfillEndBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill, + + { + chainStatus: ChainIndexingStatusIds.Following, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earliestBlockRef, + }, + latestIndexedBlock: evenLaterBlockRef, + latestKnownBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotFollowing, + { + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: latestBlockRef, + }, + latestIndexedBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotCompleted, + ]; + + // act + const omnichainIndexingCursor = getOmnichainIndexingCursor(chainStatuses); + + // assert + expect(omnichainIndexingCursor).toEqual(evenLaterBlockRef.timestamp); + }); + + it("throws error when no chains were provided", () => { + expect(() => getOmnichainIndexingCursor([])).toThrowError( + /Unable to determine omnichain indexing cursor/, + ); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.ts new file mode 100644 index 000000000..3853dd03a --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.ts @@ -0,0 +1,273 @@ +import type { ChainId, UnixTimestamp } from "../../shared/types"; +import { + ChainIndexingStatusIds, + type ChainIndexingStatusSnapshot, + type ChainIndexingStatusSnapshotBackfill, + type ChainIndexingStatusSnapshotCompleted, + type ChainIndexingStatusSnapshotQueued, +} from "./chain-indexing-status-snapshot"; +import { + checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill, + checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted, + checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing, + checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted, +} from "./validate/omnichain-indexing-status-snapshot"; + +/** + * The status of omnichain indexing at the time an omnichain indexing status + * snapshot is captured. + */ +export const OmnichainIndexingStatusIds = { + /** + * Represents that omnichain indexing is not ready to begin yet because + * ENSIndexer is in its initialization phase and the data to build a "true" + * {@link OmnichainIndexingStatusSnapshot} is still being loaded. + */ + Unstarted: "omnichain-unstarted", + + /** + * Represents that omnichain indexing is in an overall "backfill" status because + * - At least one indexed chain has a `chainStatus` of + * {@link ChainIndexingStatusIds.Backfill}; and + * - No indexed chain has a `chainStatus` of {@link ChainIndexingStatusIds.Following}. + */ + Backfill: "omnichain-backfill", + + /** + * Represents that omnichain indexing is in an overall "following" status because + * at least one indexed chain has a `chainStatus` of + * {@link ChainIndexingStatusIds.Following}. + */ + Following: "omnichain-following", + + /** + * Represents that omnichain indexing has completed because all indexed chains have + * a `chainStatus` of {@link ChainIndexingStatusIds.Completed}. + */ + Completed: "omnichain-completed", +} as const; + +/** + * The derived string union of possible {@link OmnichainIndexingStatusIds}. + */ +export type OmnichainIndexingStatusId = + (typeof OmnichainIndexingStatusIds)[keyof typeof OmnichainIndexingStatusIds]; + +/** + * Omnichain indexing status snapshot when the overall `omnichainStatus` is + * {@link OmnichainIndexingStatusIds.Unstarted}. + * + * Invariants: + * - `omnichainStatus` is always {@link OmnichainIndexingStatusIds.Unstarted}. + * - `chains` is always a map to {@link ChainIndexingStatusSnapshotQueued} values exclusively. + * - `omnichainIndexingCursor` is always < the `config.startBlock.timestamp` for all + * chains with `chainStatus` of {@link ChainIndexingStatusIds.Queued}. + */ +export interface OmnichainIndexingStatusSnapshotUnstarted { + /** + * The status of omnichain indexing. + */ + omnichainStatus: typeof OmnichainIndexingStatusIds.Unstarted; + + /** + * The indexing status snapshot for each indexed chain. + */ + chains: Map; + + /** + * The timestamp of omnichain indexing progress across all indexed chains. + */ + omnichainIndexingCursor: UnixTimestamp; +} + +/** + * The range of {@link ChainIndexingSnapshot} types allowed when the + * overall omnichain indexing status is {@link OmnichainIndexingStatusIds.Backfill}. + * + * Note that this is all of the {@link ChainIndexingSnapshot} types with the exception + * of {@link ChainIndexingStatusSnapshotFollowing}. + */ +export type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill = + | ChainIndexingStatusSnapshotQueued + | ChainIndexingStatusSnapshotBackfill + | ChainIndexingStatusSnapshotCompleted; + +/** + * Omnichain indexing status snapshot when the `omnichainStatus` is + * {@link OmnichainIndexingStatusIds.Backfill}. + * + * Invariants: + * - `omnichainStatus` is always {@link OmnichainIndexingStatusIds.Backfill}. + * - `chains` is guaranteed to contain at least one chain with a `chainStatus` of + * {@link ChainIndexingStatusIds.Backfill}. + * - `chains` is guaranteed to not to contain any chain with a `chainStatus` of + * {@link ChainIndexingStatusIds.Following} + * - `omnichainIndexingCursor` is always < the `config.startBlock.timestamp` for all + * chains with `chainStatus` of {@link ChainIndexingStatusIds.Queued}. + * - `omnichainIndexingCursor` is always <= the `backfillEndBlock.timestamp` for all + * chains with `chainStatus` of {@link ChainIndexingStatusIds.Backfill}. + * - `omnichainIndexingCursor` is always >= the `latestIndexedBlock.timestamp` for all + * chains with `chainStatus` of {@link ChainIndexingStatusIds.Completed}. + * - `omnichainIndexingCursor` is always equal to the timestamp of the highest + * `latestIndexedBlock` across all chains that have started indexing + * (`chainStatus` is not {@link ChainIndexingStatusIds.Queued}). + */ +export interface OmnichainIndexingStatusSnapshotBackfill { + /** + * The status of omnichain indexing. + */ + omnichainStatus: typeof OmnichainIndexingStatusIds.Backfill; + + /** + * The indexing status snapshot for each indexed chain. + */ + chains: Map; + + /** + * The timestamp of omnichain indexing progress across all indexed chains. + */ + omnichainIndexingCursor: UnixTimestamp; +} + +/** + * Omnichain indexing status snapshot when the overall `omnichainStatus` is + * {@link OmnichainIndexingStatusIds.Following}. + * + * Invariants: + * - `omnichainStatus` is always {@link OmnichainIndexingStatusIds.Following}. + * - `chains` is guaranteed to contain at least one chain with a `status` of + * {@link ChainIndexingStatusIds.Following}. + * - `omnichainIndexingCursor` is always < the `config.startBlock.timestamp` for all + * chains with `chainStatus` of {@link ChainIndexingStatusIds.Queued}. + * - `omnichainIndexingCursor` is always <= the `backfillEndBlock.timestamp` for all + * chains with `chainStatus` of {@link ChainIndexingStatusIds.Backfill}. + * - `omnichainIndexingCursor` is always >= the `latestIndexedBlock.timestamp` for all + * chains with `chainStatus` of {@link ChainIndexingStatusIds.Completed}. + * - `omnichainIndexingCursor` is always equal to the timestamp of the highest + * `latestIndexedBlock` across all chains that have started indexing + * (`chainStatus` is not {@link ChainIndexingStatusIds.Queued}). + */ +export interface OmnichainIndexingStatusSnapshotFollowing { + /** + * The status of omnichain indexing. + */ + omnichainStatus: typeof OmnichainIndexingStatusIds.Following; + + /** + * The indexing status snapshot for each indexed chain. + */ + chains: Map; + + /** + * The timestamp of omnichain indexing progress across all indexed chains. + */ + omnichainIndexingCursor: UnixTimestamp; +} + +/** + * Omnichain indexing status snapshot when the overall `omnichainStatus` is + * {@link OmnichainIndexingStatusIds.Completed}. + * + * Invariants: + * - `omnichainStatus` is always {@link OmnichainIndexingStatusIds.Completed}. + * - `chains` is always a map to {@link ChainIndexingStatusSnapshotCompleted} values exclusively. + * - `omnichainIndexingCursor` is always equal to the highest + * `latestIndexedBlock.timestamp` for all chains. + */ +export interface OmnichainIndexingStatusSnapshotCompleted { + /** + * The status of omnichain indexing. + */ + omnichainStatus: typeof OmnichainIndexingStatusIds.Completed; + + /** + * The indexing status snapshot for each indexed chain. + */ + chains: Map; + + /** + * The timestamp of omnichain indexing progress across all indexed chains. + */ + omnichainIndexingCursor: UnixTimestamp; +} + +/** + * Omnichain indexing status snapshot for one or more chains. + * + * Use the `omnichainStatus` field to determine the specific type interpretation + * at runtime. + */ +export type OmnichainIndexingStatusSnapshot = + | OmnichainIndexingStatusSnapshotUnstarted + | OmnichainIndexingStatusSnapshotBackfill + | OmnichainIndexingStatusSnapshotCompleted + | OmnichainIndexingStatusSnapshotFollowing; + +/** + * Get {@link OmnichainIndexingStatusId} based on indexed chains' statuses. + * + * This function decides what is the `OmnichainIndexingStatusId` is, + * based on provided chain indexing statuses. + * + * @throws an error if unable to determine overall indexing status + */ +export function getOmnichainIndexingStatus( + chains: ChainIndexingStatusSnapshot[], +): OmnichainIndexingStatusId { + if (checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing(chains)) { + return OmnichainIndexingStatusIds.Following; + } + + if (checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill(chains)) { + return OmnichainIndexingStatusIds.Backfill; + } + + if (checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted(chains)) { + return OmnichainIndexingStatusIds.Unstarted; + } + + if (checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted(chains)) { + return OmnichainIndexingStatusIds.Completed; + } + + // if none of the chain statuses matched, throw an error + throw new Error(`Unable to determine omnichain indexing status for provided chains.`); +} + +/** + * Get Omnichain Indexing Cursor + * + * The cursor tracks the "highest" latest indexed block timestamp across + * all indexed chains. If all chains are queued, the cursor tracks the moment + * just before the earliest start block timestamp across those chains. + * + * @throws an error if no chains are provided + */ +export function getOmnichainIndexingCursor(chains: ChainIndexingStatusSnapshot[]): UnixTimestamp { + if (chains.length === 0) { + throw new Error(`Unable to determine omnichain indexing cursor when no chains were provided.`); + } + + // for omnichain indexing status snapshot 'unstarted', the cursor tracks + // the moment just before the indexing would start from. + if (getOmnichainIndexingStatus(chains) === OmnichainIndexingStatusIds.Unstarted) { + const earliestStartBlockTimestamps = chains.map((chain) => chain.config.startBlock.timestamp); + + return Math.min(...earliestStartBlockTimestamps) - 1; + } + + // otherwise, the cursor tracks the "highest" latest indexed block timestamp + // across all indexed chains + const latestIndexedBlockTimestamps = chains + .filter((chain) => chain.chainStatus !== ChainIndexingStatusIds.Queued) + .map((chain) => chain.latestIndexedBlock.timestamp); + + // Invariant: there's at least one element in `latestIndexedBlockTimestamps` array + // This is theoretically impossible based on the 2 checks above, + // but the invariant is explicitly added here as a formality. + if (latestIndexedBlockTimestamps.length < 1) { + throw new Error("latestIndexedBlockTimestamps array must include at least one element"); + } + + return Math.max(...latestIndexedBlockTimestamps); +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/projection.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/projection.ts deleted file mode 100644 index 5989d3631..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/projection.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { UnixTimestamp } from "../../shared/types"; -import type { CrossChainIndexingStatusSnapshot, RealtimeIndexingStatusProjection } from "./types"; - -/** - * Create realtime indexing status projection from - * a {@link CrossChainIndexingStatusSnapshot}. - */ -export function createRealtimeIndexingStatusProjection( - snapshot: CrossChainIndexingStatusSnapshot, - now: UnixTimestamp, -): RealtimeIndexingStatusProjection { - /** - * The timestamp when the realtime indexing status was projected. - * - * Due to possible clock skew between different systems, - * if the "now" timestamp on the system generating this indexing status - * projection is less than the snapshot time, then this value must be set to - * equal to the whichever is higher between the `now` and - * the snapshot time to ensure all invariants are followed. - */ - const projectedAt = Math.max(now, snapshot.snapshotTime); - - return { - projectedAt, - worstCaseDistance: projectedAt - snapshot.slowestChainIndexingCursor, - snapshot, - }; -} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/projection.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/realtime-indexing-status-projection.test.ts similarity index 77% rename from packages/ensnode-sdk/src/ensindexer/indexing-status/projection.test.ts rename to packages/ensnode-sdk/src/ensindexer/indexing-status/realtime-indexing-status-projection.test.ts index e6a14a9d4..91515fd83 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/projection.test.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/realtime-indexing-status-projection.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it } from "vitest"; -import { deserializeCrossChainIndexingStatusSnapshot } from "./deserialize"; -import { createRealtimeIndexingStatusProjection } from "./projection"; -import { earlierBlockRef, laterBlockRef } from "./test-helpers"; +import { earlierBlockRef, laterBlockRef } from "./block-refs.mock"; import { ChainIndexingConfigTypeIds, ChainIndexingStatusIds, - CrossChainIndexingStrategyIds, - OmnichainIndexingStatusIds, +} from "./chain-indexing-status-snapshot"; +import { CrossChainIndexingStrategyIds } from "./cross-chain-indexing-status-snapshot"; +import { deserializeCrossChainIndexingStatusSnapshot } from "./deserialize/cross-chain-indexing-status-snapshot"; +import { OmnichainIndexingStatusIds } from "./omnichain-indexing-status-snapshot"; +import { + createRealtimeIndexingStatusProjection, type RealtimeIndexingStatusProjection, -} from "./types"; +} from "./realtime-indexing-status-projection"; describe("Realtime Indexing Status Projection", () => { it("can be created from existing omnichain snapshot", () => { diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/realtime-indexing-status-projection.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/realtime-indexing-status-projection.ts new file mode 100644 index 000000000..1962fbf94 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/realtime-indexing-status-projection.ts @@ -0,0 +1,59 @@ +import type { Duration, UnixTimestamp } from "../../shared/types"; +import type { CrossChainIndexingStatusSnapshot } from "./cross-chain-indexing-status-snapshot"; + +/** + * A "realtime" indexing status projection based on worst-case assumptions + * from the `snapshot`. + * + * Invariants: + * - `projectedAt` is always >= `snapshot.snapshotTime`. + * - `worstCaseDistance` is always equal to + * `projectedAt - snapshot.slowestChainIndexingCursor`. + */ +export interface RealtimeIndexingStatusProjection { + /** + * The timestamp representing "now" as of the time this projection was generated. + */ + projectedAt: UnixTimestamp; + + /** + * The distance between `projectedAt` and `snapshot.slowestChainIndexingCursor`. + * + * This is "worst-case" because it assumes all of the following: + * - the `snapshot` (which may have `snapshot.snapshotTime < projectedAt`) is still the + * latest snapshot and no indexing progress has been made since `snapshotTime`. + * - each indexed chain has added a new block as of `projectedAt`. + */ + worstCaseDistance: Duration; + + /** + * The {@link CrossChainIndexingStatusSnapshot} that this projection is based on. + */ + snapshot: CrossChainIndexingStatusSnapshot; +} + +/** + * Create realtime indexing status projection from + * a {@link CrossChainIndexingStatusSnapshot}. + */ +export function createRealtimeIndexingStatusProjection( + snapshot: CrossChainIndexingStatusSnapshot, + now: UnixTimestamp, +): RealtimeIndexingStatusProjection { + /** + * The timestamp when the realtime indexing status was projected. + * + * Due to possible clock skew between different systems, + * if the "now" timestamp on the system generating this indexing status + * projection is less than the snapshot time, then this value must be set to + * equal to the whichever is higher between the `now` and + * the snapshot time to ensure all invariants are followed. + */ + const projectedAt = Math.max(now, snapshot.snapshotTime); + + return { + projectedAt, + worstCaseDistance: projectedAt - snapshot.slowestChainIndexingCursor, + snapshot, + }; +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize.ts deleted file mode 100644 index d35658505..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { serializeChainId } from "../../shared/serialize"; -import type { ChainIdString } from "../../shared/serialized-types"; -import type { ChainId } from "../../shared/types"; -import type { - SerializedCrossChainIndexingStatusSnapshot, - SerializedOmnichainIndexingStatusSnapshot, - SerializedOmnichainIndexingStatusSnapshotBackfill, - SerializedOmnichainIndexingStatusSnapshotCompleted, - SerializedOmnichainIndexingStatusSnapshotFollowing, - SerializedOmnichainIndexingStatusSnapshotUnstarted, - SerializedRealtimeIndexingStatusProjection, -} from "./serialized-types"; -import { - type ChainIndexingStatusSnapshot, - type CrossChainIndexingStatusSnapshot, - OmnichainIndexingStatusIds, - type OmnichainIndexingStatusSnapshot, - type RealtimeIndexingStatusProjection, -} from "./types"; - -export function serializeCrossChainIndexingStatusSnapshotOmnichain({ - strategy, - slowestChainIndexingCursor, - snapshotTime, - omnichainSnapshot, -}: CrossChainIndexingStatusSnapshot): SerializedCrossChainIndexingStatusSnapshot { - return { - strategy, - slowestChainIndexingCursor, - snapshotTime, - omnichainSnapshot: serializeOmnichainIndexingStatusSnapshot(omnichainSnapshot), - }; -} - -export function serializeRealtimeIndexingStatusProjection( - indexingProjection: RealtimeIndexingStatusProjection, -): SerializedRealtimeIndexingStatusProjection { - return { - projectedAt: indexingProjection.projectedAt, - worstCaseDistance: indexingProjection.worstCaseDistance, - snapshot: serializeCrossChainIndexingStatusSnapshotOmnichain(indexingProjection.snapshot), - } satisfies SerializedRealtimeIndexingStatusProjection; -} - -/** - * Serialize chain indexing snapshots. - */ -export function serializeChainIndexingSnapshots< - ChainIndexingStatusSnapshotType extends ChainIndexingStatusSnapshot, ->( - chains: Map, -): Record { - const serializedSnapshots: Record = {}; - - for (const [chainId, snapshot] of chains.entries()) { - serializedSnapshots[serializeChainId(chainId)] = snapshot; - } - - return serializedSnapshots; -} - -/** - * Serialize a {@link OmnichainIndexingStatusSnapshot} object. - */ -export function serializeOmnichainIndexingStatusSnapshot( - indexingStatus: OmnichainIndexingStatusSnapshot, -): SerializedOmnichainIndexingStatusSnapshot { - switch (indexingStatus.omnichainStatus) { - case OmnichainIndexingStatusIds.Unstarted: - return { - omnichainStatus: OmnichainIndexingStatusIds.Unstarted, - chains: serializeChainIndexingSnapshots(indexingStatus.chains), - omnichainIndexingCursor: indexingStatus.omnichainIndexingCursor, - } satisfies SerializedOmnichainIndexingStatusSnapshotUnstarted; - - case OmnichainIndexingStatusIds.Backfill: - return { - omnichainStatus: OmnichainIndexingStatusIds.Backfill, - chains: serializeChainIndexingSnapshots(indexingStatus.chains), - omnichainIndexingCursor: indexingStatus.omnichainIndexingCursor, - } satisfies SerializedOmnichainIndexingStatusSnapshotBackfill; - - case OmnichainIndexingStatusIds.Completed: { - return { - omnichainStatus: OmnichainIndexingStatusIds.Completed, - chains: serializeChainIndexingSnapshots(indexingStatus.chains), - omnichainIndexingCursor: indexingStatus.omnichainIndexingCursor, - } satisfies SerializedOmnichainIndexingStatusSnapshotCompleted; - } - - case OmnichainIndexingStatusIds.Following: - return { - omnichainStatus: OmnichainIndexingStatusIds.Following, - chains: serializeChainIndexingSnapshots(indexingStatus.chains), - omnichainIndexingCursor: indexingStatus.omnichainIndexingCursor, - } satisfies SerializedOmnichainIndexingStatusSnapshotFollowing; - } -} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.test.ts similarity index 97% rename from packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.test.ts rename to packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.test.ts index 1bffa8c79..f37bbb61d 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.test.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { prettifyError, type ZodSafeParseResult } from "zod/v4"; -import { earlierBlockRef, earliestBlockRef, laterBlockRef, latestBlockRef } from "./test-helpers"; +import { + earlierBlockRef, + earliestBlockRef, + laterBlockRef, + latestBlockRef, +} from "../block-refs.mock"; import { ChainIndexingConfigTypeIds, ChainIndexingStatusIds, @@ -10,8 +15,8 @@ import { type ChainIndexingStatusSnapshotCompleted, type ChainIndexingStatusSnapshotFollowing, type ChainIndexingStatusSnapshotQueued, -} from "./types"; -import { makeChainIndexingStatusSnapshotSchema } from "./zod-schemas"; +} from "../chain-indexing-status-snapshot"; +import { makeChainIndexingStatusSnapshotSchema } from "../validate/chain-indexing-status-snapshot"; describe("ENSIndexer: Indexing Status", () => { describe("Zod Schemas", () => { diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..bc9a34775 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.ts @@ -0,0 +1,52 @@ +import { serializeChainId } from "../../../shared/serialize"; +import type { ChainIdString } from "../../../shared/serialized-types"; +import type { ChainId } from "../../../shared/types"; +import type { + ChainIndexingStatusSnapshot, + ChainIndexingStatusSnapshotBackfill, + ChainIndexingStatusSnapshotCompleted, + ChainIndexingStatusSnapshotFollowing, + ChainIndexingStatusSnapshotQueued, +} from "../chain-indexing-status-snapshot"; + +/** + * Serialized representation of {@link ChainIndexingStatusSnapshot} + */ +export type SerializedChainIndexingStatusSnapshot = ChainIndexingStatusSnapshot; + +/** + * Serialized representation of {@link ChainIndexingStatusSnapshotQueued} + */ +export type SerializedChainIndexingStatusSnapshotQueued = ChainIndexingStatusSnapshotQueued; + +/** + * Serialized representation of {@link ChainIndexingStatusSnapshotBackfill} + */ +export type SerializedChainIndexingStatusSnapshotBackfill = ChainIndexingStatusSnapshotBackfill; + +/** + * Serialized representation of {@link ChainIndexingStatusSnapshotCompleted} + */ +export type SerializedChainIndexingStatusSnapshotCompleted = ChainIndexingStatusSnapshotCompleted; + +/** + * Serialized representation of {@link ChainIndexingStatusSnapshotFollowing} + */ +export type SerializedChainIndexingStatusSnapshotFollowing = ChainIndexingStatusSnapshotFollowing; + +/** + * Serialize chain indexing status snapshots. + */ +export function serializeChainIndexingSnapshots< + ChainIndexingStatusSnapshotType extends ChainIndexingStatusSnapshot, +>( + chains: Map, +): Record { + const serializedSnapshots: Record = {}; + + for (const [chainId, snapshot] of chains.entries()) { + serializedSnapshots[serializeChainId(chainId)] = snapshot; + } + + return serializedSnapshots; +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/cross-chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/cross-chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..36db58c5c --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/cross-chain-indexing-status-snapshot.ts @@ -0,0 +1,39 @@ +import type { + CrossChainIndexingStatusSnapshot, + CrossChainIndexingStatusSnapshotOmnichain, +} from "../cross-chain-indexing-status-snapshot"; +import { + type SerializedOmnichainIndexingStatusSnapshot, + serializeOmnichainIndexingStatusSnapshot, +} from "./omnichain-indexing-status-snapshot"; + +/** + * Serialized representation of {@link CrossChainIndexingStatusSnapshotOmnichain} + */ +export interface SerializedCrossChainIndexingStatusSnapshotOmnichain + extends Omit { + omnichainSnapshot: SerializedOmnichainIndexingStatusSnapshot; +} + +/** + * Serialized representation of {@link CrossChainIndexingStatusSnapshot} + */ +export type SerializedCrossChainIndexingStatusSnapshot = + SerializedCrossChainIndexingStatusSnapshotOmnichain; + +/** + * Serialize cross-chain indexing status snapshot. + */ +export function serializeCrossChainIndexingStatusSnapshotOmnichain({ + strategy, + slowestChainIndexingCursor, + snapshotTime, + omnichainSnapshot, +}: CrossChainIndexingStatusSnapshot): SerializedCrossChainIndexingStatusSnapshot { + return { + strategy, + slowestChainIndexingCursor, + snapshotTime, + omnichainSnapshot: serializeOmnichainIndexingStatusSnapshot(omnichainSnapshot), + }; +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/omnichain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/omnichain-indexing-status-snapshot.ts new file mode 100644 index 000000000..2b495fab2 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/omnichain-indexing-status-snapshot.ts @@ -0,0 +1,98 @@ +import type { ChainIdString } from "../../../shared/serialized-types"; +import type { + ChainIndexingStatusSnapshot, + ChainIndexingStatusSnapshotCompleted, + ChainIndexingStatusSnapshotQueued, +} from "../chain-indexing-status-snapshot"; +import { + type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, + type OmnichainIndexingStatusSnapshotBackfill, + type OmnichainIndexingStatusSnapshotCompleted, + type OmnichainIndexingStatusSnapshotFollowing, + type OmnichainIndexingStatusSnapshotUnstarted, +} from "../omnichain-indexing-status-snapshot"; +import { serializeChainIndexingSnapshots } from "./chain-indexing-status-snapshot"; + +/** + * Serialized representation of {@link OmnichainIndexingStatusSnapshotUnstarted} + */ +export interface SerializedOmnichainIndexingStatusSnapshotUnstarted + extends Omit { + chains: Record; +} + +/** + * Serialized representation of {@link OmnichainIndexingStatusSnapshotBackfill} + */ +export interface SerializedOmnichainIndexingStatusSnapshotBackfill + extends Omit { + chains: Record< + ChainIdString, + ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill + >; +} + +/** + * Serialized representation of {@link OmnichainIndexingStatusSnapshotCompleted} + */ +export interface SerializedOmnichainIndexingStatusSnapshotCompleted + extends Omit { + chains: Record; +} + +/** + * Serialized representation of {@link OmnichainIndexingStatusSnapshotFollowing} + */ +export interface SerializedOmnichainIndexingStatusSnapshotFollowing + extends Omit { + chains: Record; +} + +/** + * Serialized representation of {@link OmnichainIndexingStatusSnapshot} + */ +export type SerializedOmnichainIndexingStatusSnapshot = + | SerializedOmnichainIndexingStatusSnapshotUnstarted + | SerializedOmnichainIndexingStatusSnapshotBackfill + | SerializedOmnichainIndexingStatusSnapshotCompleted + | SerializedOmnichainIndexingStatusSnapshotFollowing; + +/** + * Serialize omnichain indexing status snapshot. + */ +export function serializeOmnichainIndexingStatusSnapshot( + indexingStatus: OmnichainIndexingStatusSnapshot, +): SerializedOmnichainIndexingStatusSnapshot { + switch (indexingStatus.omnichainStatus) { + case OmnichainIndexingStatusIds.Unstarted: + return { + omnichainStatus: OmnichainIndexingStatusIds.Unstarted, + chains: serializeChainIndexingSnapshots(indexingStatus.chains), + omnichainIndexingCursor: indexingStatus.omnichainIndexingCursor, + } satisfies SerializedOmnichainIndexingStatusSnapshotUnstarted; + + case OmnichainIndexingStatusIds.Backfill: + return { + omnichainStatus: OmnichainIndexingStatusIds.Backfill, + chains: serializeChainIndexingSnapshots(indexingStatus.chains), + omnichainIndexingCursor: indexingStatus.omnichainIndexingCursor, + } satisfies SerializedOmnichainIndexingStatusSnapshotBackfill; + + case OmnichainIndexingStatusIds.Completed: { + return { + omnichainStatus: OmnichainIndexingStatusIds.Completed, + chains: serializeChainIndexingSnapshots(indexingStatus.chains), + omnichainIndexingCursor: indexingStatus.omnichainIndexingCursor, + } satisfies SerializedOmnichainIndexingStatusSnapshotCompleted; + } + + case OmnichainIndexingStatusIds.Following: + return { + omnichainStatus: OmnichainIndexingStatusIds.Following, + chains: serializeChainIndexingSnapshots(indexingStatus.chains), + omnichainIndexingCursor: indexingStatus.omnichainIndexingCursor, + } satisfies SerializedOmnichainIndexingStatusSnapshotFollowing; + } +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/realtime-indexing-status-projection.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/realtime-indexing-status-projection.ts new file mode 100644 index 000000000..578af068d --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/realtime-indexing-status-projection.ts @@ -0,0 +1,35 @@ +import type { RealtimeIndexingStatusProjection } from "../realtime-indexing-status-projection"; +import { + type SerializedCrossChainIndexingStatusSnapshot, + serializeCrossChainIndexingStatusSnapshotOmnichain, +} from "./cross-chain-indexing-status-snapshot"; +import type { SerializedOmnichainIndexingStatusSnapshot } from "./omnichain-indexing-status-snapshot"; + +/** + * Serialized representation of {@link RealtimeIndexingStatusProjection} + */ +export interface SerializedCurrentIndexingProjectionOmnichain + extends Omit { + snapshot: SerializedOmnichainIndexingStatusSnapshot; +} + +/** + * Serialized representation of {@link RealtimeIndexingStatusProjection} + */ +export interface SerializedRealtimeIndexingStatusProjection + extends Omit { + snapshot: SerializedCrossChainIndexingStatusSnapshot; +} + +/** + * Serialize realtime indexing status projection. + */ +export function serializeRealtimeIndexingStatusProjection( + indexingProjection: RealtimeIndexingStatusProjection, +): SerializedRealtimeIndexingStatusProjection { + return { + projectedAt: indexingProjection.projectedAt, + worstCaseDistance: indexingProjection.worstCaseDistance, + snapshot: serializeCrossChainIndexingStatusSnapshotOmnichain(indexingProjection.snapshot), + } satisfies SerializedRealtimeIndexingStatusProjection; +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialized-types.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialized-types.ts deleted file mode 100644 index 7ebacd7b7..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialized-types.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { ChainIdString } from "../../shared/serialized-types"; -import type { - ChainIndexingStatusSnapshot, - ChainIndexingStatusSnapshotBackfill, - ChainIndexingStatusSnapshotCompleted, - ChainIndexingStatusSnapshotFollowing, - ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill, - ChainIndexingStatusSnapshotQueued, - CrossChainIndexingStatusSnapshot, - CrossChainIndexingStatusSnapshotOmnichain, - OmnichainIndexingStatusSnapshot, - OmnichainIndexingStatusSnapshotBackfill, - OmnichainIndexingStatusSnapshotCompleted, - OmnichainIndexingStatusSnapshotFollowing, - OmnichainIndexingStatusSnapshotUnstarted, - RealtimeIndexingStatusProjection, -} from "./types"; - -/** - * Serialized representation of {@link ChainIndexingStatusSnapshot} - */ -export type SerializedChainIndexingStatusSnapshot = ChainIndexingStatusSnapshot; - -/** - * Serialized representation of {@link ChainIndexingStatusSnapshotQueued} - */ -export type SerializedChainIndexingStatusSnapshotQueued = ChainIndexingStatusSnapshotQueued; - -/** - * Serialized representation of {@link ChainIndexingStatusSnapshotBackfill} - */ -export type SerializedChainIndexingStatusSnapshotBackfill = ChainIndexingStatusSnapshotBackfill; - -/** - * Serialized representation of {@link ChainIndexingStatusSnapshotCompleted} - */ -export type SerializedChainIndexingStatusSnapshotCompleted = ChainIndexingStatusSnapshotCompleted; - -/** - * Serialized representation of {@link ChainIndexingStatusSnapshotFollowing} - */ -export type SerializedChainIndexingStatusSnapshotFollowing = ChainIndexingStatusSnapshotFollowing; - -/** - * Serialized representation of {@link OmnichainIndexingStatusSnapshotUnstarted} - */ -export interface SerializedOmnichainIndexingStatusSnapshotUnstarted - extends Omit { - chains: Record; -} - -/** - * Serialized representation of {@link OmnichainIndexingStatusSnapshotBackfill} - */ -export interface SerializedOmnichainIndexingStatusSnapshotBackfill - extends Omit { - chains: Record< - ChainIdString, - ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill - >; -} - -/** - * Serialized representation of {@link OmnichainIndexingStatusSnapshotCompleted} - */ -export interface SerializedOmnichainIndexingStatusSnapshotCompleted - extends Omit { - chains: Record; -} - -/** - * Serialized representation of {@link OmnichainIndexingStatusSnapshotFollowing} - */ -export interface SerializedOmnichainIndexingStatusSnapshotFollowing - extends Omit { - chains: Record; -} - -/** - * Serialized representation of {@link OmnichainIndexingStatusSnapshot} - */ -export type SerializedOmnichainIndexingStatusSnapshot = - | SerializedOmnichainIndexingStatusSnapshotUnstarted - | SerializedOmnichainIndexingStatusSnapshotBackfill - | SerializedOmnichainIndexingStatusSnapshotCompleted - | SerializedOmnichainIndexingStatusSnapshotFollowing; - -/** - * Serialized representation of {@link CrossChainIndexingStatusSnapshotOmnichain} - */ -export interface SerializedCrossChainIndexingStatusSnapshotOmnichain - extends Omit { - omnichainSnapshot: SerializedOmnichainIndexingStatusSnapshot; -} - -/** - * Serialized representation of {@link CrossChainIndexingStatusSnapshot} - */ -export type SerializedCrossChainIndexingStatusSnapshot = - SerializedCrossChainIndexingStatusSnapshotOmnichain; - -/** - * Serialized representation of {@link RealtimeIndexingStatusProjection} - */ -export interface SerializedCurrentIndexingProjectionOmnichain - extends Omit { - snapshot: SerializedOmnichainIndexingStatusSnapshot; -} - -/** - * Serialized representation of {@link RealtimeIndexingStatusProjection} - */ -export interface SerializedRealtimeIndexingStatusProjection - extends Omit { - snapshot: SerializedCrossChainIndexingStatusSnapshot; -} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/types.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/types.ts deleted file mode 100644 index dc6f9c44d..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/types.ts +++ /dev/null @@ -1,591 +0,0 @@ -import type { BlockRef, ChainId, Duration, UnixTimestamp } from "../../shared/types"; - -/** - * The type of indexing configuration for a chain. - */ -export const ChainIndexingConfigTypeIds = { - /** - * Represents that indexing of the chain should be performed for an indefinite range. - */ - Indefinite: "indefinite", - - /** - * Represents that indexing of the chain should be performed for a definite range. - */ - Definite: "definite", -} as const; - -/** - * The derived string union of possible {@link ChainIndexingConfigTypeIds}. - */ -export type ChainIndexingConfigTypeId = - (typeof ChainIndexingConfigTypeIds)[keyof typeof ChainIndexingConfigTypeIds]; - -/** - * Chain indexing config for a chain whose indexing config `configType` is - * {@link ChainIndexingConfigTypeIds.Indefinite}. - * - * Invariants: - * - `configType` is always `ChainIndexingConfigTypeIds.Indefinite`. - */ -export interface ChainIndexingConfigIndefinite { - /** - * The type of chain indexing config. - */ - configType: typeof ChainIndexingConfigTypeIds.Indefinite; - - /** - * A {@link BlockRef} to the block where indexing of the chain should start. - */ - startBlock: BlockRef; -} - -/** - * Chain indexing config for a chain whose indexing config `configType` is - * {@link ChainIndexingConfigTypeIds.Definite}. - * - * Invariants: - * - `configType` is always `ChainIndexingConfigTypeIds.Definite`. - * - `startBlock` is always before or the same as `endBlock`. - */ -export interface ChainIndexingConfigDefinite { - /** - * The type of chain indexing config. - */ - configType: typeof ChainIndexingConfigTypeIds.Definite; - - /** - * A {@link BlockRef} to the block where indexing of the chain should start. - */ - startBlock: BlockRef; - - /** - * A {@link BlockRef} to the block where indexing of the chain should end. - */ - endBlock: BlockRef; -} - -/** - * Indexing configuration for a chain. - * - * Use the `configType` field to determine the specific type interpretation - * at runtime. - */ -export type ChainIndexingConfig = ChainIndexingConfigIndefinite | ChainIndexingConfigDefinite; - -/** - * The status of indexing a chain at the time an indexing status snapshot - * is captured. - */ -export const ChainIndexingStatusIds = { - /** - * Represents that indexing of the chain is not ready to begin yet because: - * - ENSIndexer is in its initialization phase and the data to build a - * "true" {@link ChainIndexingSnapshot} for the chain is still being loaded; or - * - ENSIndexer is using an omnichain indexing strategy and the - * `omnichainIndexingCursor` is <= `config.startBlock.timestamp` for the chain's - * {@link ChainIndexingSnapshot}. - */ - Queued: "chain-queued", - - /** - * Represents that indexing of the chain is in progress and under a special - * "backfill" phase that optimizes for accelerated indexing until reaching the - * "fixed target" `backfillEndBlock`. - */ - Backfill: "chain-backfill", - - /** - * Represents that the "backfill" phase of indexing the chain is completed - * and that the chain is configured to be indexed for an indefinite range. - * Therefore, indexing of the chain remains indefinitely in progress where - * ENSIndexer will continuously work to discover and index new blocks as they - * are added to the chain across time. - */ - Following: "chain-following", - - /** - * Represents that indexing of the chain is completed as the chain is configured - * to be indexed for a definite range and the indexing of all blocks through - * that definite range is completed. - */ - Completed: "chain-completed", -} as const; - -/** - * The derived string union of possible {@link ChainIndexingStatusIds}. - */ -export type ChainIndexingStatusId = - (typeof ChainIndexingStatusIds)[keyof typeof ChainIndexingStatusIds]; - -/** - * Chain indexing status snapshot for a chain whose `chainStatus` is - * {@link ChainIndexingStatusIds.Queued}. - * - * Invariants: - * - `chainStatus` is always {@link ChainIndexingStatusIds.Queued}. - */ -export interface ChainIndexingStatusSnapshotQueued { - /** - * The status of indexing the chain at the time the indexing status snapshot - * was captured. - */ - chainStatus: typeof ChainIndexingStatusIds.Queued; - - /** - * The indexing configuration of the chain. - */ - config: ChainIndexingConfig; -} - -/** - * Chain indexing status snapshot for a chain whose `chainStatus` is - * {@link ChainIndexingStatusIds.Backfill}. - * - * During a backfill, special performance optimizations are applied to - * index all blocks between `config.startBlock` and `backfillEndBlock` - * as fast as possible. - * - * Note how `backfillEndBlock` is a "fixed target" that does not change during - * the lifetime of an ENSIndexer process instance: - * - If the `config` is {@link ChainIndexingConfigDefinite}: - * `backfillEndBlock` is always the same as `config.endBlock`. - * - If the `config` is {@link ChainIndexingConfigIndefinite}: - * `backfillEndBlock` is a {@link BlockRef} to what was the latest block on the - * chain when the ENSIndexer process was performing its initialization. Note how - * this means that if the backfill process takes X hours to complete, because the - * `backfillEndBlock` is a "fixed target", when `chainStatus` transitions to - * {@link ChainIndexingStatusIds.Following} the chain will be X hours behind - * "realtime" indexing. - * - * When `latestIndexedBlock` reaches `backfillEndBlock` the backfill is complete. - * The moment backfill is complete the `chainStatus` may not immediately transition. - * Instead, internal processing is completed for a period of time while - * `chainStatus` remains {@link ChainIndexingStatusIds.Backfill}. After this internal - * processing is completed `chainStatus` will transition: - * - to {@link ChainIndexingStatusIds.Following} if the `config` is - * {@link ChainIndexingConfigIndefinite}. - * - to {@link ChainIndexingStatusIds.Completed} if the `config` is - * {@link ChainIndexingConfigDefinite}. - * - * Invariants: - * - `chainStatus` is always {@link ChainIndexingStatusIds.Backfill}. - * - `config.startBlock` is always before or the same as `latestIndexedBlock` - * - `config.endBlock` is always the same as `backfillEndBlock` if and only if - * the config is {@link ChainIndexingConfigDefinite}. - * - `latestIndexedBlock` is always before or the same as `backfillEndBlock` - */ -export interface ChainIndexingStatusSnapshotBackfill { - /** - * The status of indexing the chain at the time the indexing status snapshot - * was captured. - */ - chainStatus: typeof ChainIndexingStatusIds.Backfill; - - /** - * The indexing configuration of the chain. - */ - config: ChainIndexingConfig; - - /** - * A {@link BlockRef} to the block that was most recently indexed as of the time the - * indexing status snapshot was captured. - */ - latestIndexedBlock: BlockRef; - - /** - * A {@link BlockRef} to the block where the backfill will end. - */ - backfillEndBlock: BlockRef; -} - -/** - * Chain indexing status snapshot for a chain whose `chainStatus` is - * {@link ChainIndexingStatusIds.Following}. - * - * Invariants: - * - `chainStatus` is always {@link ChainIndexingStatusIds.Following}. - * - `config` is always {@link ChainIndexingConfigIndefinite} - * - `config.startBlock` is always before or the same as `latestIndexedBlock` - * - `latestIndexedBlock` is always before or the same as `latestKnownBlock` - */ -export interface ChainIndexingStatusSnapshotFollowing { - /** - * The status of indexing the chain at the time the indexing status snapshot - * was captured. - */ - chainStatus: typeof ChainIndexingStatusIds.Following; - - /** - * The indexing configuration of the chain. - */ - config: ChainIndexingConfigIndefinite; - - /** - * A {@link BlockRef} to the block that was most recently indexed as of the time the - * indexing status snapshot was captured. - */ - latestIndexedBlock: BlockRef; - - /** - * A {@link BlockRef} to the "highest" block that has been discovered by RPCs - * and stored in the RPC cache as of the time the indexing status snapshot was - * captured. - */ - latestKnownBlock: BlockRef; -} - -/** - * Chain indexing status snapshot for a chain whose `chainStatus` is - * {@link ChainIndexingStatusIds.Completed}. - * - * After the backfill of a chain is completed, if the chain was configured - * to be indexed for a definite range, the chain indexing status will transition to - * {@link ChainIndexingStatusIds.Completed}. - * - * Invariants: - * - `chainStatus` is always {@link ChainIndexingStatusIds.Completed}. - * - `config` is always {@link ChainIndexingConfigDefinite} - * - `config.startBlock` is always before or the same as `latestIndexedBlock` - * - `latestIndexedBlock` is always the same as `config.endBlock`. - */ -export interface ChainIndexingStatusSnapshotCompleted { - /** - * The status of indexing the chain at the time the indexing status snapshot - * was captured. - */ - chainStatus: typeof ChainIndexingStatusIds.Completed; - - /** - * The indexing configuration of the chain. - */ - config: ChainIndexingConfigDefinite; - - /** - * A {@link BlockRef} to the block that was most recently indexed as of the time the - * indexing status snapshot was captured. - */ - latestIndexedBlock: BlockRef; -} - -/** - * Indexing status snapshot for a single chain. - * - * Use the `chainStatus` field to determine the specific type interpretation - * at runtime. - */ -export type ChainIndexingStatusSnapshot = - | ChainIndexingStatusSnapshotQueued - | ChainIndexingStatusSnapshotBackfill - | ChainIndexingStatusSnapshotFollowing - | ChainIndexingStatusSnapshotCompleted; - -/** - * The status of omnichain indexing at the time an omnichain indexing status - * snapshot is captured. - */ -export const OmnichainIndexingStatusIds = { - /** - * Represents that omnichain indexing is not ready to begin yet because - * ENSIndexer is in its initialization phase and the data to build a "true" - * {@link OmnichainIndexingStatusSnapshot} is still being loaded. - */ - Unstarted: "omnichain-unstarted", - - /** - * Represents that omnichain indexing is in an overall "backfill" status because - * - At least one indexed chain has a `chainStatus` of - * {@link ChainIndexingStatusIds.Backfill}; and - * - No indexed chain has a `chainStatus` of {@link ChainIndexingStatusIds.Following}. - */ - Backfill: "omnichain-backfill", - - /** - * Represents that omnichain indexing is in an overall "following" status because - * at least one indexed chain has a `chainStatus` of - * {@link ChainIndexingStatusIds.Following}. - */ - Following: "omnichain-following", - - /** - * Represents that omnichain indexing has completed because all indexed chains have - * a `chainStatus` of {@link ChainIndexingStatusIds.Completed}. - */ - Completed: "omnichain-completed", -} as const; - -/** - * The derived string union of possible {@link OmnichainIndexingStatusIds}. - */ -export type OmnichainIndexingStatusId = - (typeof OmnichainIndexingStatusIds)[keyof typeof OmnichainIndexingStatusIds]; - -/** - * Omnichain indexing status snapshot when the overall `omnichainStatus` is - * {@link OmnichainIndexingStatusIds.Unstarted}. - * - * Invariants: - * - `omnichainStatus` is always {@link OmnichainIndexingStatusIds.Unstarted}. - * - `chains` is always a map to {@link ChainIndexingStatusSnapshotQueued} values exclusively. - * - `omnichainIndexingCursor` is always < the `config.startBlock.timestamp` for all - * chains with `chainStatus` of {@link ChainIndexingStatusIds.Queued}. - */ -export interface OmnichainIndexingStatusSnapshotUnstarted { - /** - * The status of omnichain indexing. - */ - omnichainStatus: typeof OmnichainIndexingStatusIds.Unstarted; - - /** - * The indexing status snapshot for each indexed chain. - */ - chains: Map; - - /** - * The timestamp of omnichain indexing progress across all indexed chains. - */ - omnichainIndexingCursor: UnixTimestamp; -} - -/** - * The range of {@link ChainIndexingSnapshot} types allowed when the - * overall omnichain indexing status is {@link OmnichainIndexingStatusIds.Backfill}. - * - * Note that this is all of the {@link ChainIndexingSnapshot} types with the exception - * of {@link ChainIndexingStatusSnapshotFollowing}. - */ -export type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill = - | ChainIndexingStatusSnapshotQueued - | ChainIndexingStatusSnapshotBackfill - | ChainIndexingStatusSnapshotCompleted; - -/** - * Omnichain indexing status snapshot when the `omnichainStatus` is - * {@link OmnichainIndexingStatusIds.Backfill}. - * - * Invariants: - * - `omnichainStatus` is always {@link OmnichainIndexingStatusIds.Backfill}. - * - `chains` is guaranteed to contain at least one chain with a `chainStatus` of - * {@link ChainIndexingStatusIds.Backfill}. - * - `chains` is guaranteed to not to contain any chain with a `chainStatus` of - * {@link ChainIndexingStatusIds.Following} - * - `omnichainIndexingCursor` is always < the `config.startBlock.timestamp` for all - * chains with `chainStatus` of {@link ChainIndexingStatusIds.Queued}. - * - `omnichainIndexingCursor` is always <= the `backfillEndBlock.timestamp` for all - * chains with `chainStatus` of {@link ChainIndexingStatusIds.Backfill}. - * - `omnichainIndexingCursor` is always >= the `latestIndexedBlock.timestamp` for all - * chains with `chainStatus` of {@link ChainIndexingStatusIds.Completed}. - * - `omnichainIndexingCursor` is always equal to the timestamp of the highest - * `latestIndexedBlock` across all chains that have started indexing - * (`chainStatus` is not {@link ChainIndexingStatusIds.Queued}). - */ -export interface OmnichainIndexingStatusSnapshotBackfill { - /** - * The status of omnichain indexing. - */ - omnichainStatus: typeof OmnichainIndexingStatusIds.Backfill; - - /** - * The indexing status snapshot for each indexed chain. - */ - chains: Map; - - /** - * The timestamp of omnichain indexing progress across all indexed chains. - */ - omnichainIndexingCursor: UnixTimestamp; -} - -/** - * Omnichain indexing status snapshot when the overall `omnichainStatus` is - * {@link OmnichainIndexingStatusIds.Following}. - * - * Invariants: - * - `omnichainStatus` is always {@link OmnichainIndexingStatusIds.Following}. - * - `chains` is guaranteed to contain at least one chain with a `status` of - * {@link ChainIndexingStatusIds.Following}. - * - `omnichainIndexingCursor` is always < the `config.startBlock.timestamp` for all - * chains with `chainStatus` of {@link ChainIndexingStatusIds.Queued}. - * - `omnichainIndexingCursor` is always <= the `backfillEndBlock.timestamp` for all - * chains with `chainStatus` of {@link ChainIndexingStatusIds.Backfill}. - * - `omnichainIndexingCursor` is always >= the `latestIndexedBlock.timestamp` for all - * chains with `chainStatus` of {@link ChainIndexingStatusIds.Completed}. - * - `omnichainIndexingCursor` is always equal to the timestamp of the highest - * `latestIndexedBlock` across all chains that have started indexing - * (`chainStatus` is not {@link ChainIndexingStatusIds.Queued}). - */ -export interface OmnichainIndexingStatusSnapshotFollowing { - /** - * The status of omnichain indexing. - */ - omnichainStatus: typeof OmnichainIndexingStatusIds.Following; - - /** - * The indexing status snapshot for each indexed chain. - */ - chains: Map; - - /** - * The timestamp of omnichain indexing progress across all indexed chains. - */ - omnichainIndexingCursor: UnixTimestamp; -} - -/** - * Omnichain indexing status snapshot when the overall `omnichainStatus` is - * {@link OmnichainIndexingStatusIds.Completed}. - * - * Invariants: - * - `omnichainStatus` is always {@link OmnichainIndexingStatusIds.Completed}. - * - `chains` is always a map to {@link ChainIndexingStatusSnapshotCompleted} values exclusively. - * - `omnichainIndexingCursor` is always equal to the highest - * `latestIndexedBlock.timestamp` for all chains. - */ -export interface OmnichainIndexingStatusSnapshotCompleted { - /** - * The status of omnichain indexing. - */ - omnichainStatus: typeof OmnichainIndexingStatusIds.Completed; - - /** - * The indexing status snapshot for each indexed chain. - */ - chains: Map; - - /** - * The timestamp of omnichain indexing progress across all indexed chains. - */ - omnichainIndexingCursor: UnixTimestamp; -} - -/** - * Omnichain indexing status snapshot for one or more chains. - * - * Use the `omnichainStatus` field to determine the specific type interpretation - * at runtime. - */ -export type OmnichainIndexingStatusSnapshot = - | OmnichainIndexingStatusSnapshotUnstarted - | OmnichainIndexingStatusSnapshotBackfill - | OmnichainIndexingStatusSnapshotCompleted - | OmnichainIndexingStatusSnapshotFollowing; - -/** - * The strategy used for indexing one or more chains. - * - * @see https://ponder.sh/docs/api-reference/ponder/config#parameters - */ -export const CrossChainIndexingStrategyIds = { - /** - * Represents that the indexing of events across all indexed chains will - * proceed in a deterministic "omnichain" ordering by block timestamp, chain ID, - * and block number. - * - * This strategy is "deterministic" in that the order of processing cross-chain indexed - * events and each resulting indexed data state transition recorded in ENSDb is always - * the same for each ENSIndexer instance operating with an equivalent - * `ENSIndexerConfig` and ENSIndexer version. However it also has the drawbacks of: - * - increased indexing latency that must wait for the slowest indexed chain to - * add new blocks or to discover new blocks through the configured RPCs. - * - if any indexed chain gets "stuck" due to chain or RPC failures, all indexed chains - * will be affected. - */ - Omnichain: "omnichain", -} as const; - -/** - * The derived string union of possible {@link CrossChainIndexingStrategyIds}. - */ -export type CrossChainIndexingStrategyId = - (typeof CrossChainIndexingStrategyIds)[keyof typeof CrossChainIndexingStrategyIds]; - -/** - * Cross-chain indexing status snapshot when the `strategy` is - * {@link CrossChainIndexingStrategyId.Omnichain}. - * - * Invariants: - * - `strategy` is always {@link CrossChainIndexingStrategyId.Omnichain}. - * - `slowestChainIndexingCursor` is always equal to - * `omnichainSnapshot.omnichainIndexingCursor`. - * - `snapshotTime` is always >= the "highest known block timestamp", defined as the max of: - * - the `slowestChainIndexingCursor`. - * - the `config.startBlock.timestamp` for all indexed chains. - * - the `config.endBlock.timestamp` for all indexed chains with a `config.configType` of - * {@link ChainIndexingConfigTypeIds.Definite}. - * - the `backfillEndBlock.timestamp` for all chains with `chainStatus` of - * {@link ChainIndexingStatusIds.Backfill}. - * - the `latestKnownBlock.timestamp` for all chains with `chainStatus` of - * {@link ChainIndexingStatusIds.Following}. - */ -export interface CrossChainIndexingStatusSnapshotOmnichain { - /** - * The strategy used for indexing one or more chains. - */ - strategy: typeof CrossChainIndexingStrategyIds.Omnichain; - - /** - * The timestamp of the "slowest" latest indexed block timestamp - * across all indexed chains. - */ - slowestChainIndexingCursor: UnixTimestamp; - - /** - * The timestamp when the cross-chain indexing status snapshot was generated. - * - * Due to possible clock skew between different systems this value must be set - * to the max of each of the following values to ensure all invariants are followed: - * - the current system time of the system generating this cross-chain indexing - * status snapshot. - * - the "highest known block timestamp" (see invariants above for full definition). - */ - snapshotTime: UnixTimestamp; - - /** - * The omnichain indexing status snapshot for one or more chains. - */ - omnichainSnapshot: OmnichainIndexingStatusSnapshot; -} - -/** - * Cross-chain indexing status snapshot for one or more chains. - * - * Use the `strategy` field to determine the specific type interpretation - * at runtime. - * - * Currently, only omnichain indexing is supported. This type could theoretically - * be extended to support other cross-chain indexing strategies in the future, - * such as Ponder's "multichain" indexing strategy that indexes each chain - * independently without deterministic ordering. - */ -export type CrossChainIndexingStatusSnapshot = CrossChainIndexingStatusSnapshotOmnichain; - -/** - * A "realtime" indexing status projection based on worst-case assumptions - * from the `snapshot`. - * - * Invariants: - * - `projectedAt` is always >= `snapshot.snapshotTime`. - * - `worstCaseDistance` is always equal to - * `projectedAt - snapshot.slowestChainIndexingCursor`. - */ -export type RealtimeIndexingStatusProjection = { - /** - * The timestamp representing "now" as of the time this projection was generated. - */ - projectedAt: UnixTimestamp; - - /** - * The distance between `projectedAt` and `snapshot.slowestChainIndexingCursor`. - * - * This is "worst-case" because it assumes all of the following: - * - the `snapshot` (which may have `snapshot.snapshotTime < projectedAt`) is still the - * latest snapshot and no indexing progress has been made since `snapshotTime`. - * - each indexed chain has added a new block as of `projectedAt`. - */ - worstCaseDistance: Duration; - - /** - * The {@link CrossChainIndexingStatusSnapshot} that this projection is based on. - */ - snapshot: CrossChainIndexingStatusSnapshot; -}; diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..c6e2e24af --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts @@ -0,0 +1,234 @@ +import z, { prettifyError } from "zod/v4"; +import type { ParsePayload } from "zod/v4/core"; + +import * as blockRef from "../../../shared/block-ref"; +import { makeBlockRefSchema } from "../../../shared/zod-schemas"; +import { + ChainIndexingConfig, + ChainIndexingConfigTypeIds, + ChainIndexingStatusIds, + type ChainIndexingStatusSnapshot, + type ChainIndexingStatusSnapshotBackfill, + type ChainIndexingStatusSnapshotCompleted, + type ChainIndexingStatusSnapshotFollowing, + type ChainIndexingStatusSnapshotQueued, +} from "../chain-indexing-status-snapshot"; +/** + * Invariants for chain snapshot in 'queued' status: + * - `config.endBlock` (if set) is after `config.startBlock`. + */ +export function invariant_chainSnapshotQueuedBlocks( + ctx: ParsePayload, +) { + const { config } = ctx.value; + + // The `config.endBlock` does not exists for `indefinite` config type + if (config.configType === ChainIndexingConfigTypeIds.Indefinite) { + // invariant holds + return; + } + + if (config.endBlock && blockRef.isBeforeOrEqualTo(config.startBlock, config.endBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`config.startBlock` must be before or same as `config.endBlock`.", + }); + } +} + +/** + * Invariants for chain snapshot in 'backfill' status: + * - `config.startBlock` is before or same as `latestIndexedBlock`. + * - `latestIndexedBlock` is before or same as `backfillEndBlock`. + * - `backfillEndBlock` is the same as `config.endBlock` (if set). + */ +export function invariant_chainSnapshotBackfillBlocks( + ctx: ParsePayload, +) { + const { config, latestIndexedBlock, backfillEndBlock } = ctx.value; + + if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", + }); + } + + if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, backfillEndBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`latestIndexedBlock` must be before or same as `backfillEndBlock`.", + }); + } + + // The `config.endBlock` does not exists for `indefinite` config type + if (config.configType === ChainIndexingConfigTypeIds.Indefinite) { + // invariant holds + return; + } + + if (config.endBlock && blockRef.isEqualTo(backfillEndBlock, config.endBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`backfillEndBlock` must be the same as `config.endBlock`.", + }); + } +} + +/** + * Invariants for chain snapshot in 'completed' status: + * - `config.startBlock` is before or same as `latestIndexedBlock`. + * - `latestIndexedBlock` is before or same as `config.endBlock`. + */ +export function invariant_chainSnapshotCompletedBlocks( + ctx: ParsePayload, +) { + const { config, latestIndexedBlock } = ctx.value; + + if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", + }); + } + + if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, config.endBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`latestIndexedBlock` must be before or same as `config.endBlock`.", + }); + } +} + +/** + * Invariants for chain snapshot in 'following' status: + * - `config.startBlock` is before or same as `latestIndexedBlock`. + * - `latestIndexedBlock` is before or same as `latestKnownBlock`. + */ +export function invariant_chainSnapshotFollowingBlocks( + ctx: ParsePayload, +) { + const { config, latestIndexedBlock, latestKnownBlock } = ctx.value; + + if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", + }); + } + + if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, latestKnownBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`latestIndexedBlock` must be before or same as `latestKnownBlock`.", + }); + } +} + +/** + * Validates a maybe {@link ChainIndexingStatusSnapshot} object. + */ +export function validateChainIndexingStatusSnapshot( + unvalidatedSnapshot: ChainIndexingStatusSnapshot, + valueLabel?: string, +): ChainIndexingStatusSnapshot { + const schema = makeChainIndexingStatusSnapshotSchema(valueLabel); + const parsed = schema.safeParse(unvalidatedSnapshot); + + if (parsed.error) { + throw new Error(`Invalid ChainIndexingStatusSnapshot:\n${prettifyError(parsed.error)}\n`); + } + + return parsed.data; +} + +/** + * Makes Zod schema for {@link ChainIndexingConfig} type. + */ +export const makeChainIndexingConfigSchema = (valueLabel: string = "Value") => + z.discriminatedUnion("configType", [ + z.strictObject({ + configType: z.literal(ChainIndexingConfigTypeIds.Indefinite), + startBlock: makeBlockRefSchema(valueLabel), + }), + z.strictObject({ + configType: z.literal(ChainIndexingConfigTypeIds.Definite), + startBlock: makeBlockRefSchema(valueLabel), + endBlock: makeBlockRefSchema(valueLabel), + }), + ]); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshotQueued} type. + */ +export const makeChainIndexingStatusSnapshotQueuedSchema = (valueLabel: string = "Value") => + z + .strictObject({ + chainStatus: z.literal(ChainIndexingStatusIds.Queued), + config: makeChainIndexingConfigSchema(valueLabel), + }) + .check(invariant_chainSnapshotQueuedBlocks); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshotBackfill} type. + */ +export const makeChainIndexingStatusSnapshotBackfillSchema = (valueLabel: string = "Value") => + z + .strictObject({ + chainStatus: z.literal(ChainIndexingStatusIds.Backfill), + config: makeChainIndexingConfigSchema(valueLabel), + latestIndexedBlock: makeBlockRefSchema(valueLabel), + backfillEndBlock: makeBlockRefSchema(valueLabel), + }) + .check(invariant_chainSnapshotBackfillBlocks); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshotCompleted} type. + */ +export const makeChainIndexingStatusSnapshotCompletedSchema = (valueLabel: string = "Value") => + z + .strictObject({ + chainStatus: z.literal(ChainIndexingStatusIds.Completed), + config: z.strictObject({ + configType: z.literal(ChainIndexingConfigTypeIds.Definite), + startBlock: makeBlockRefSchema(valueLabel), + endBlock: makeBlockRefSchema(valueLabel), + }), + latestIndexedBlock: makeBlockRefSchema(valueLabel), + }) + .check(invariant_chainSnapshotCompletedBlocks); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshotFollowing} type. + */ +export const makeChainIndexingStatusSnapshotFollowingSchema = (valueLabel: string = "Value") => + z + .strictObject({ + chainStatus: z.literal(ChainIndexingStatusIds.Following), + config: z.strictObject({ + configType: z.literal(ChainIndexingConfigTypeIds.Indefinite), + startBlock: makeBlockRefSchema(valueLabel), + }), + latestIndexedBlock: makeBlockRefSchema(valueLabel), + latestKnownBlock: makeBlockRefSchema(valueLabel), + }) + .check(invariant_chainSnapshotFollowingBlocks); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshot} + */ +export const makeChainIndexingStatusSnapshotSchema = (valueLabel: string = "Value") => + z.discriminatedUnion("chainStatus", [ + makeChainIndexingStatusSnapshotQueuedSchema(valueLabel), + makeChainIndexingStatusSnapshotBackfillSchema(valueLabel), + makeChainIndexingStatusSnapshotCompletedSchema(valueLabel), + makeChainIndexingStatusSnapshotFollowingSchema(valueLabel), + ]); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/cross-chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/cross-chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..1299313c5 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/cross-chain-indexing-status-snapshot.ts @@ -0,0 +1,120 @@ +import z, { prettifyError } from "zod/v4"; +import type { ParsePayload } from "zod/v4/core"; + +import { makeUnixTimestampSchema } from "../../../shared/zod-schemas"; +import { + ChainIndexingConfigTypeIds, + ChainIndexingStatusIds, +} from "../chain-indexing-status-snapshot"; +import { + type CrossChainIndexingStatusSnapshot, + type CrossChainIndexingStatusSnapshotOmnichain, + CrossChainIndexingStrategyIds, +} from "../cross-chain-indexing-status-snapshot"; +import { makeOmnichainIndexingStatusSnapshotSchema } from "./omnichain-indexing-status-snapshot"; + +/** + * Validate an {@link CrossChainIndexingStatusSnapshot} object. + */ +export function validateCrossChainIndexingStatusSnapshot( + unvalidatedSnapshot: CrossChainIndexingStatusSnapshot, + valueLabel?: string, +): CrossChainIndexingStatusSnapshot { + const schema = makeCrossChainIndexingStatusSnapshotSchema(valueLabel); + const parsed = schema.safeParse(unvalidatedSnapshot); + if (parsed.error) { + throw new Error(`Invalid CrossChainIndexingStatusSnapshot:\n${prettifyError(parsed.error)}\n`); + } + + return parsed.data; +} + +/** + * Invariant: for cross-chain indexing status snapshot omnichain, + * slowestChainIndexingCursor equals to omnichainSnapshot.omnichainIndexingCursor + */ +export function invariant_slowestChainEqualsToOmnichainSnapshotTime( + ctx: ParsePayload, +) { + const { slowestChainIndexingCursor, omnichainSnapshot } = ctx.value; + const { omnichainIndexingCursor } = omnichainSnapshot; + + if (slowestChainIndexingCursor !== omnichainIndexingCursor) { + console.log("invariant_slowestChainEqualsToOmnichainSnapshotTime", { + slowestChainIndexingCursor, + omnichainIndexingCursor, + }); + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'slowestChainIndexingCursor' must be equal to 'omnichainSnapshot.omnichainIndexingCursor'`, + }); + } +} + +/** + * Invariant: for cross-chain indexing status snapshot omnichain, + * snapshotTime is greater than or equal to the "highest known block" timestamp. + */ +export function invariant_snapshotTimeIsTheHighestKnownBlockTimestamp( + ctx: ParsePayload, +) { + const { snapshotTime, omnichainSnapshot } = ctx.value; + const chains = Array.from(omnichainSnapshot.chains.values()); + + const startBlockTimestamps = chains.map((chain) => chain.config.startBlock.timestamp); + + const endBlockTimestamps = chains + .map((chain) => chain.config) + .filter((chainConfig) => chainConfig.configType === ChainIndexingConfigTypeIds.Definite) + .map((chainConfig) => chainConfig.endBlock.timestamp); + + const backfillEndBlockTimestamps = chains + .filter((chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill) + .map((chain) => chain.backfillEndBlock.timestamp); + + const latestKnownBlockTimestamps = chains + .filter((chain) => chain.chainStatus === ChainIndexingStatusIds.Following) + .map((chain) => chain.latestKnownBlock.timestamp); + + const highestKnownBlockTimestamp = Math.max( + ...startBlockTimestamps, + ...endBlockTimestamps, + ...backfillEndBlockTimestamps, + ...latestKnownBlockTimestamps, + ); + + if (snapshotTime < highestKnownBlockTimestamp) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'snapshotTime' (${snapshotTime}) must be greater than or equal to the "highest known block timestamp" (${highestKnownBlockTimestamp})`, + }); + } +} + +/** + * Makes Zod schema for {@link CrossChainIndexingStatusSnapshotOmnichain} + */ +const makeCrossChainIndexingStatusSnapshotOmnichainSchema = ( + valueLabel: string = "Cross-chain Indexing Status Snapshot Omnichain", +) => + z + .strictObject({ + strategy: z.literal(CrossChainIndexingStrategyIds.Omnichain), + slowestChainIndexingCursor: makeUnixTimestampSchema(valueLabel), + snapshotTime: makeUnixTimestampSchema(valueLabel), + omnichainSnapshot: makeOmnichainIndexingStatusSnapshotSchema(valueLabel), + }) + .check(invariant_slowestChainEqualsToOmnichainSnapshotTime) + .check(invariant_snapshotTimeIsTheHighestKnownBlockTimestamp); + +/** + * Makes Zod schema for {@link CrossChainIndexingStatusSnapshot} + */ +export const makeCrossChainIndexingStatusSnapshotSchema = ( + valueLabel: string = "Cross-chain Indexing Status Snapshot", +) => + z.discriminatedUnion("strategy", [ + makeCrossChainIndexingStatusSnapshotOmnichainSchema(valueLabel), + ]); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts new file mode 100644 index 000000000..62b31135f --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts @@ -0,0 +1,413 @@ +import z, { prettifyError } from "zod/v4"; +import type { ParsePayload } from "zod/v4/core"; + +import type { ChainId } from "../../../shared/types"; +import { makeChainIdSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; +import { + ChainIndexingStatusIds, + type ChainIndexingStatusSnapshot, + type ChainIndexingStatusSnapshotCompleted, + type ChainIndexingStatusSnapshotQueued, +} from "../chain-indexing-status-snapshot"; +import { + type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill, + getOmnichainIndexingStatus, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, + type OmnichainIndexingStatusSnapshotFollowing, +} from "../omnichain-indexing-status-snapshot"; +import { makeChainIndexingStatusSnapshotSchema } from "./chain-indexing-status-snapshot"; + +/** + * Check if Chain Indexing Status Snapshots fit the 'unstarted' overall status + * snapshot requirements: + * - All chains are guaranteed to have a status of "queued". + * + * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to + * {@link ChainIndexingStatusSnapshotQueued}. + */ +export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted( + chains: ChainIndexingStatusSnapshot[], +): chains is ChainIndexingStatusSnapshotQueued[] { + return chains.every((chain) => chain.chainStatus === ChainIndexingStatusIds.Queued); +} + +/** + * Check if Chain Indexing Status Snapshots fit the 'backfill' overall status + * snapshot requirements: + * - At least one chain is guaranteed to be in the "backfill" status. + * - Each chain is guaranteed to have a status of either "queued", + * "backfill" or "completed". + * + * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to + * {@link ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill}. + */ +export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill( + chains: ChainIndexingStatusSnapshot[], +): chains is ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill[] { + const atLeastOneChainInTargetStatus = chains.some( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill, + ); + const otherChainsHaveValidStatuses = chains.every( + (chain) => + chain.chainStatus === ChainIndexingStatusIds.Queued || + chain.chainStatus === ChainIndexingStatusIds.Backfill || + chain.chainStatus === ChainIndexingStatusIds.Completed, + ); + + return atLeastOneChainInTargetStatus && otherChainsHaveValidStatuses; +} + +/** + * Checks if Chain Indexing Status Snapshots fit the 'completed' overall status + * snapshot requirements: + * - All chains are guaranteed to have a status of "completed". + * + * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to + * {@link ChainIndexingStatusSnapshotCompleted}. + */ +export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted( + chains: ChainIndexingStatusSnapshot[], +): chains is ChainIndexingStatusSnapshotCompleted[] { + const allChainsHaveValidStatuses = chains.every( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Completed, + ); + + return allChainsHaveValidStatuses; +} + +/** + * Checks Chain Indexing Status Snapshots fit the 'following' overall status + * snapshot requirements: + * - At least one chain is guaranteed to be in the "following" status. + * - Any other chain can have any status. + */ +export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing( + chains: ChainIndexingStatusSnapshot[], +): chains is ChainIndexingStatusSnapshot[] { + const allChainsHaveValidStatuses = chains.some( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Following, + ); + + return allChainsHaveValidStatuses; +} + +/** + * Validate an {@link OmnichainIndexingStatusSnapshot} object. + */ +export function validateOmnichainIndexingStatusSnapshot( + unvalidatedSnapshot: OmnichainIndexingStatusSnapshot, + valueLabel?: string, +): OmnichainIndexingStatusSnapshot { + const schema = makeOmnichainIndexingStatusSnapshotSchema(valueLabel); + const parsed = schema.safeParse(unvalidatedSnapshot); + if (parsed.error) { + throw new Error(`Invalid OmnichainIndexingStatusSnapshot:\n${prettifyError(parsed.error)}\n`); + } + + return parsed.data; +} + +/** + * Invariant: For omnichain snapshot, + * `omnichainStatus` is set based on the snapshots of individual chains. + */ +export function invariant_omnichainSnapshotStatusIsConsistentWithChainSnapshot( + ctx: ParsePayload, +) { + const snapshot = ctx.value; + const chains = Array.from(snapshot.chains.values()); + const expectedOmnichainStatus = getOmnichainIndexingStatus(chains); + const actualOmnichainStatus = snapshot.omnichainStatus; + + if (expectedOmnichainStatus !== actualOmnichainStatus) { + ctx.issues.push({ + code: "custom", + input: snapshot, + message: `'${actualOmnichainStatus}' is an invalid omnichainStatus. Expected '${expectedOmnichainStatus}' based on the statuses of individual chains.`, + }); + } +} + +/** + * Invariant: For omnichain status snapshot, + * `omnichainIndexingCursor` is lower than the earliest start block + * across all queued chains. + * + * Note: if there are no queued chains, the invariant holds. + */ +export function invariant_omnichainIndexingCursorLowerThanEarliestStartBlockAcrossQueuedChains( + ctx: ParsePayload, +) { + const snapshot = ctx.value; + const queuedChains = Array.from(snapshot.chains.values()).filter( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Queued, + ); + + // there are no queued chains + if (queuedChains.length === 0) { + // the invariant holds + return; + } + + const queuedChainStartBlocks = queuedChains.map((chain) => chain.config.startBlock.timestamp); + const queuedChainEarliestStartBlock = Math.min(...queuedChainStartBlocks); + + // there are queued chains + // the invariant holds if the omnichain indexing cursor is lower than + // the earliest start block across all queued chains + if (snapshot.omnichainIndexingCursor >= queuedChainEarliestStartBlock) { + ctx.issues.push({ + code: "custom", + input: snapshot, + message: + "`omnichainIndexingCursor` must be lower than the earliest start block across all queued chains.", + }); + } +} + +/** + * Invariant: For omnichain status snapshot, + * `omnichainIndexingCursor` is lower than or equal to + * the highest `backfillEndBlock` across all backfill chains. + * + * Note: if there are no backfill chains, the invariant holds. + */ +export function invariant_omnichainIndexingCursorLowerThanOrEqualToLatestBackfillEndBlockAcrossBackfillChains( + ctx: ParsePayload, +) { + const snapshot = ctx.value; + const backfillChains = Array.from(snapshot.chains.values()).filter( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill, + ); + + // there are no backfill chains + if (backfillChains.length === 0) { + // the invariant holds + return; + } + + const backfillEndBlocks = backfillChains.map((chain) => chain.backfillEndBlock.timestamp); + const highestBackfillEndBlock = Math.max(...backfillEndBlocks); + + // there are backfill chains + // the invariant holds if the omnichainIndexingCursor is lower than or + // equal to the highest backfillEndBlock across all backfill chains. + if (snapshot.omnichainIndexingCursor > highestBackfillEndBlock) { + ctx.issues.push({ + code: "custom", + input: snapshot, + message: + "`omnichainIndexingCursor` must be lower than or equal to the highest `backfillEndBlock` across all backfill chains.", + }); + } +} + +/** + * Invariant: For omnichain status snapshot, + * `omnichainIndexingCursor` is same as the highest latestIndexedBlock + * across all indexed chains. + * + * Note: if there are no indexed chains, the invariant holds. + */ +export function invariant_omnichainIndexingCursorIsEqualToHighestLatestIndexedBlockAcrossIndexedChain( + ctx: ParsePayload, +) { + const snapshot = ctx.value; + const indexedChains = Array.from(snapshot.chains.values()).filter( + (chain) => + chain.chainStatus === ChainIndexingStatusIds.Backfill || + chain.chainStatus === ChainIndexingStatusIds.Completed || + chain.chainStatus === ChainIndexingStatusIds.Following, + ); + + // there are no indexed chains + if (indexedChains.length === 0) { + // the invariant holds + return; + } + + const indexedChainLatestIndexedBlocks = indexedChains.map( + (chain) => chain.latestIndexedBlock.timestamp, + ); + const indexedChainHighestLatestIndexedBlock = Math.max(...indexedChainLatestIndexedBlocks); + + // there are indexed chains + // the invariant holds if the omnichain indexing cursor is same as + // the highest latestIndexedBlock across all indexed chains + if (snapshot.omnichainIndexingCursor !== indexedChainHighestLatestIndexedBlock) { + ctx.issues.push({ + code: "custom", + input: snapshot, + message: + "`omnichainIndexingCursor` must be same as the highest `latestIndexedBlock` across all indexed chains.", + }); + } +} + +/** + * Invariant: For omnichain status snapshot 'unstarted', + * all chains must have "queued" status. + */ +export function invariant_omnichainSnapshotUnstartedHasValidChains( + ctx: ParsePayload>, +) { + const chains = ctx.value; + const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted( + Array.from(chains.values()), + ); + + if (hasValidChains === false) { + ctx.issues.push({ + code: "custom", + input: chains, + message: `For omnichain status snapshot 'unstarted', all chains must have "queued" status.`, + }); + } +} + +/** + * Invariant: For omnichain status snapshot 'backfill', + * at least one chain must be in "backfill" status and + * each chain has to have a status of either "queued", "backfill" + * or "completed". + */ +export function invariant_omnichainStatusSnapshotBackfillHasValidChains( + ctx: ParsePayload>, +) { + const chains = ctx.value; + const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill( + Array.from(chains.values()), + ); + + if (hasValidChains === false) { + ctx.issues.push({ + code: "custom", + input: chains, + message: `For omnichain status snapshot 'backfill', at least one chain must be in "backfill" status and each chain has to have a status of either "queued", "backfill" or "completed".`, + }); + } +} + +/** + * Invariant: For omnichain status snapshot 'completed', + * all chains must have "completed" status. + */ +export function invariant_omnichainStatusSnapshotCompletedHasValidChains( + ctx: ParsePayload>, +) { + const chains = ctx.value; + const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted( + Array.from(chains.values()), + ); + + if (hasValidChains === false) { + ctx.issues.push({ + code: "custom", + input: chains, + message: `For omnichain status snapshot 'completed', all chains must have "completed" status.`, + }); + } +} + +/** + * Invariant: For omnichain status snapshot 'following', + * at least one chain must be in 'following' status. + */ +export function invariant_omnichainStatusSnapshotFollowingHasValidChains( + ctx: ParsePayload, +) { + const snapshot = ctx.value; + const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing( + Array.from(snapshot.chains.values()), + ); + + if (hasValidChains === false) { + ctx.issues.push({ + code: "custom", + input: snapshot, + message: "For omnichainStatus 'following', at least one chain must be in 'following' status.", + }); + } +} + +export const makeChainIndexingStatusSnapshotsSchema = (valueLabel: string = "Value") => + z.map(makeChainIdSchema(), makeChainIndexingStatusSnapshotSchema(valueLabel), { + error: + "Chains indexing statuses must be an object mapping valid chain IDs to their indexing status snapshots.", + }); + +/** + * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotUnstarted} + */ +const makeOmnichainIndexingStatusSnapshotUnstartedSchema = (valueLabel?: string) => + z.strictObject({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Unstarted), + chains: makeChainIndexingStatusSnapshotsSchema(valueLabel) + .check(invariant_omnichainSnapshotUnstartedHasValidChains) + .transform((chains) => chains as Map), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotBackfill} + */ +const makeOmnichainIndexingStatusSnapshotBackfillSchema = (valueLabel?: string) => + z.strictObject({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Backfill), + chains: makeChainIndexingStatusSnapshotsSchema(valueLabel) + .check(invariant_omnichainStatusSnapshotBackfillHasValidChains) + .transform( + (chains) => + chains as Map< + ChainId, + ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill + >, + ), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotCompleted} + */ +const makeOmnichainIndexingStatusSnapshotCompletedSchema = (valueLabel?: string) => + z.strictObject({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Completed), + chains: makeChainIndexingStatusSnapshotsSchema(valueLabel) + .check(invariant_omnichainStatusSnapshotCompletedHasValidChains) + .transform((chains) => chains as Map), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotFollowing} + */ +const makeOmnichainIndexingStatusSnapshotFollowingSchema = (valueLabel?: string) => + z.strictObject({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Following), + chains: makeChainIndexingStatusSnapshotsSchema(valueLabel), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Omnichain Indexing Snapshot Schema + * + * Makes a Zod schema definition for validating indexing snapshot + * across all chains indexed by ENSIndexer instance. + */ +export const makeOmnichainIndexingStatusSnapshotSchema = ( + valueLabel: string = "Omnichain Indexing Snapshot", +) => + z + .discriminatedUnion("omnichainStatus", [ + makeOmnichainIndexingStatusSnapshotUnstartedSchema(valueLabel), + makeOmnichainIndexingStatusSnapshotBackfillSchema(valueLabel), + makeOmnichainIndexingStatusSnapshotCompletedSchema(valueLabel), + makeOmnichainIndexingStatusSnapshotFollowingSchema(valueLabel), + ]) + .check(invariant_omnichainSnapshotStatusIsConsistentWithChainSnapshot) + .check(invariant_omnichainIndexingCursorLowerThanEarliestStartBlockAcrossQueuedChains) + .check( + invariant_omnichainIndexingCursorLowerThanOrEqualToLatestBackfillEndBlockAcrossBackfillChains, + ) + .check(invariant_omnichainIndexingCursorIsEqualToHighestLatestIndexedBlockAcrossIndexedChain); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/realtime-indexing-status-projection.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/realtime-indexing-status-projection.ts new file mode 100644 index 000000000..5c4262119 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/realtime-indexing-status-projection.ts @@ -0,0 +1,80 @@ +import z, { prettifyError } from "zod/v4"; +import type { ParsePayload } from "zod/v4/core"; + +import { makeDurationSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; +import type { RealtimeIndexingStatusProjection } from "../realtime-indexing-status-projection"; +import { makeCrossChainIndexingStatusSnapshotSchema } from "./cross-chain-indexing-status-snapshot"; + +/** + * Validate a {@link RealtimeIndexingStatusProjection} object. + */ +export function validateRealtimeIndexingStatusProjection( + unvalidatedProjection: RealtimeIndexingStatusProjection, + valueLabel?: string, +): RealtimeIndexingStatusProjection { + const schema = makeRealtimeIndexingStatusProjectionSchema(valueLabel); + const parsed = schema.safeParse(unvalidatedProjection); + if (parsed.error) { + throw new Error(`Invalid RealtimeIndexingStatusProjection:\n${prettifyError(parsed.error)}\n`); + } + + return parsed.data; +} + +/** + * Invariant: For realtime indexing status projection, + * `projectedAt` is after or same as `snapshot.snapshotTime`. + */ +export function invariant_realtimeIndexingStatusProjectionProjectedAtIsAfterOrEqualToSnapshotTime( + ctx: ParsePayload, +) { + const projection = ctx.value; + + const { snapshot, projectedAt } = projection; + + if (snapshot.snapshotTime > projectedAt) { + ctx.issues.push({ + code: "custom", + input: projection, + message: "`projectedAt` must be after or same as `snapshot.snapshotTime`.", + }); + } +} + +/** + * Invariant: For realtime indexing status projection, + * `worstCaseDistance` is the difference between `projectedAt` + * and `omnichainIndexingCursor`. + */ +export function invariant_realtimeIndexingStatusProjectionWorstCaseDistanceIsCorrect( + ctx: ParsePayload, +) { + const projection = ctx.value; + const { projectedAt, snapshot, worstCaseDistance } = projection; + const { omnichainSnapshot } = snapshot; + const expectedWorstCaseDistance = projectedAt - omnichainSnapshot.omnichainIndexingCursor; + + if (worstCaseDistance !== expectedWorstCaseDistance) { + ctx.issues.push({ + code: "custom", + input: projection, + message: + "`worstCaseDistance` must be the exact difference between `projectedAt` and `snapshot.omnichainIndexingCursor`.", + }); + } +} + +/** + * Makes Zod schema for {@link RealtimeIndexingStatusProjection} + */ +export const makeRealtimeIndexingStatusProjectionSchema = ( + valueLabel: string = "Realtime Indexing Status Projection", +) => + z + .strictObject({ + projectedAt: makeUnixTimestampSchema(valueLabel), + worstCaseDistance: makeDurationSchema(valueLabel), + snapshot: makeCrossChainIndexingStatusSnapshotSchema(valueLabel), + }) + .check(invariant_realtimeIndexingStatusProjectionProjectedAtIsAfterOrEqualToSnapshotTime) + .check(invariant_realtimeIndexingStatusProjectionWorstCaseDistanceIsCorrect); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validations.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validations.ts deleted file mode 100644 index 843a1d530..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/validations.ts +++ /dev/null @@ -1,490 +0,0 @@ -import type { ParsePayload } from "zod/v4/core"; - -import * as blockRef from "../../shared/block-ref"; -import type { ChainId } from "../../shared/types"; -import { - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill, - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted, - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing, - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted, - getOmnichainIndexingStatus, -} from "./helpers"; -import { - ChainIndexingConfigTypeIds, - ChainIndexingStatusIds, - type ChainIndexingStatusSnapshot, - type ChainIndexingStatusSnapshotBackfill, - type ChainIndexingStatusSnapshotCompleted, - type ChainIndexingStatusSnapshotFollowing, - type ChainIndexingStatusSnapshotQueued, - type CrossChainIndexingStatusSnapshotOmnichain, - type OmnichainIndexingStatusSnapshot, - type OmnichainIndexingStatusSnapshotFollowing, - type RealtimeIndexingStatusProjection, -} from "./types"; - -/** - * Invariants for {@link ChainIndexingSnapshot}. - */ - -/** - * Invariants for chain snapshot in 'queued' status: - * - `config.endBlock` (if set) is after `config.startBlock`. - */ -export function invariant_chainSnapshotQueuedBlocks( - ctx: ParsePayload, -) { - const { config } = ctx.value; - - // The `config.endBlock` does not exists for `indefinite` config type - if (config.configType === ChainIndexingConfigTypeIds.Indefinite) { - // invariant holds - return; - } - - if (config.endBlock && blockRef.isBeforeOrEqualTo(config.startBlock, config.endBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`config.startBlock` must be before or same as `config.endBlock`.", - }); - } -} - -/** - * Invariants for chain snapshot in 'backfill' status: - * - `config.startBlock` is before or same as `latestIndexedBlock`. - * - `latestIndexedBlock` is before or same as `backfillEndBlock`. - * - `backfillEndBlock` is the same as `config.endBlock` (if set). - */ -export function invariant_chainSnapshotBackfillBlocks( - ctx: ParsePayload, -) { - const { config, latestIndexedBlock, backfillEndBlock } = ctx.value; - - if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", - }); - } - - if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, backfillEndBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`latestIndexedBlock` must be before or same as `backfillEndBlock`.", - }); - } - - // The `config.endBlock` does not exists for `indefinite` config type - if (config.configType === ChainIndexingConfigTypeIds.Indefinite) { - // invariant holds - return; - } - - if (config.endBlock && blockRef.isEqualTo(backfillEndBlock, config.endBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`backfillEndBlock` must be the same as `config.endBlock`.", - }); - } -} - -/** - * Invariants for chain snapshot in 'completed' status: - * - `config.startBlock` is before or same as `latestIndexedBlock`. - * - `latestIndexedBlock` is before or same as `config.endBlock`. - */ -export function invariant_chainSnapshotCompletedBlocks( - ctx: ParsePayload, -) { - const { config, latestIndexedBlock } = ctx.value; - - if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", - }); - } - - if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, config.endBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`latestIndexedBlock` must be before or same as `config.endBlock`.", - }); - } -} - -/** - * Invariants for chain snapshot in 'following' status: - * - `config.startBlock` is before or same as `latestIndexedBlock`. - * - `latestIndexedBlock` is before or same as `latestKnownBlock`. - */ -export function invariant_chainSnapshotFollowingBlocks( - ctx: ParsePayload, -) { - const { config, latestIndexedBlock, latestKnownBlock } = ctx.value; - - if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", - }); - } - - if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, latestKnownBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`latestIndexedBlock` must be before or same as `latestKnownBlock`.", - }); - } -} - -/** - * Invariants for {@link OmnichainIndexingSnapshot}. - */ - -/** - * Invariant: For omnichain snapshot, - * `omnichainStatus` is set based on the snapshots of individual chains. - */ -export function invariant_omnichainSnapshotStatusIsConsistentWithChainSnapshot( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const chains = Array.from(snapshot.chains.values()); - const expectedOmnichainStatus = getOmnichainIndexingStatus(chains); - const actualOmnichainStatus = snapshot.omnichainStatus; - - if (expectedOmnichainStatus !== actualOmnichainStatus) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: `'${actualOmnichainStatus}' is an invalid omnichainStatus. Expected '${expectedOmnichainStatus}' based on the statuses of individual chains.`, - }); - } -} - -/** - * Invariant: For omnichain status snapshot, - * `omnichainIndexingCursor` is lower than the earliest start block - * across all queued chains. - * - * Note: if there are no queued chains, the invariant holds. - */ -export function invariant_omnichainIndexingCursorLowerThanEarliestStartBlockAcrossQueuedChains( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const queuedChains = Array.from(snapshot.chains.values()).filter( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Queued, - ); - - // there are no queued chains - if (queuedChains.length === 0) { - // the invariant holds - return; - } - - const queuedChainStartBlocks = queuedChains.map((chain) => chain.config.startBlock.timestamp); - const queuedChainEarliestStartBlock = Math.min(...queuedChainStartBlocks); - - // there are queued chains - // the invariant holds if the omnichain indexing cursor is lower than - // the earliest start block across all queued chains - if (snapshot.omnichainIndexingCursor >= queuedChainEarliestStartBlock) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: - "`omnichainIndexingCursor` must be lower than the earliest start block across all queued chains.", - }); - } -} - -/** - * Invariant: For omnichain status snapshot, - * `omnichainIndexingCursor` is lower than or equal to - * the highest `backfillEndBlock` across all backfill chains. - * - * Note: if there are no backfill chains, the invariant holds. - */ -export function invariant_omnichainIndexingCursorLowerThanOrEqualToLatestBackfillEndBlockAcrossBackfillChains( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const backfillChains = Array.from(snapshot.chains.values()).filter( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill, - ); - - // there are no backfill chains - if (backfillChains.length === 0) { - // the invariant holds - return; - } - - const backfillEndBlocks = backfillChains.map((chain) => chain.backfillEndBlock.timestamp); - const highestBackfillEndBlock = Math.max(...backfillEndBlocks); - - // there are backfill chains - // the invariant holds if the omnichainIndexingCursor is lower than or - // equal to the highest backfillEndBlock across all backfill chains. - if (snapshot.omnichainIndexingCursor > highestBackfillEndBlock) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: - "`omnichainIndexingCursor` must be lower than or equal to the highest `backfillEndBlock` across all backfill chains.", - }); - } -} - -/** - * Invariant: For omnichain status snapshot, - * `omnichainIndexingCursor` is same as the highest latestIndexedBlock - * across all indexed chains. - * - * Note: if there are no indexed chains, the invariant holds. - */ -export function invariant_omnichainIndexingCursorIsEqualToHighestLatestIndexedBlockAcrossIndexedChain( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const indexedChains = Array.from(snapshot.chains.values()).filter( - (chain) => - chain.chainStatus === ChainIndexingStatusIds.Backfill || - chain.chainStatus === ChainIndexingStatusIds.Completed || - chain.chainStatus === ChainIndexingStatusIds.Following, - ); - - // there are no indexed chains - if (indexedChains.length === 0) { - // the invariant holds - return; - } - - const indexedChainLatestIndexedBlocks = indexedChains.map( - (chain) => chain.latestIndexedBlock.timestamp, - ); - const indexedChainHighestLatestIndexedBlock = Math.max(...indexedChainLatestIndexedBlocks); - - // there are indexed chains - // the invariant holds if the omnichain indexing cursor is same as - // the highest latestIndexedBlock across all indexed chains - if (snapshot.omnichainIndexingCursor !== indexedChainHighestLatestIndexedBlock) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: - "`omnichainIndexingCursor` must be same as the highest `latestIndexedBlock` across all indexed chains.", - }); - } -} - -/** - * Invariant: For omnichain status snapshot 'unstarted', - * all chains must have "queued" status. - */ -export function invariant_omnichainSnapshotUnstartedHasValidChains( - ctx: ParsePayload>, -) { - const chains = ctx.value; - const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted( - Array.from(chains.values()), - ); - - if (hasValidChains === false) { - ctx.issues.push({ - code: "custom", - input: chains, - message: `For omnichain status snapshot 'unstarted', all chains must have "queued" status.`, - }); - } -} - -/** - * Invariant: For omnichain status snapshot 'backfill', - * at least one chain must be in "backfill" status and - * each chain has to have a status of either "queued", "backfill" - * or "completed". - */ -export function invariant_omnichainStatusSnapshotBackfillHasValidChains( - ctx: ParsePayload>, -) { - const chains = ctx.value; - const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill( - Array.from(chains.values()), - ); - - if (hasValidChains === false) { - ctx.issues.push({ - code: "custom", - input: chains, - message: `For omnichain status snapshot 'backfill', at least one chain must be in "backfill" status and each chain has to have a status of either "queued", "backfill" or "completed".`, - }); - } -} - -/** - * Invariant: For omnichain status snapshot 'completed', - * all chains must have "completed" status. - */ -export function invariant_omnichainStatusSnapshotCompletedHasValidChains( - ctx: ParsePayload>, -) { - const chains = ctx.value; - const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted( - Array.from(chains.values()), - ); - - if (hasValidChains === false) { - ctx.issues.push({ - code: "custom", - input: chains, - message: `For omnichain status snapshot 'completed', all chains must have "completed" status.`, - }); - } -} - -/** - * Invariant: For omnichain status snapshot 'following', - * at least one chain must be in 'following' status. - */ -export function invariant_omnichainStatusSnapshotFollowingHasValidChains( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing( - Array.from(snapshot.chains.values()), - ); - - if (hasValidChains === false) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: "For omnichainStatus 'following', at least one chain must be in 'following' status.", - }); - } -} - -/** - * Invariants for {@link CrossChainIndexingStatusSnapshotOmnichain}. - */ - -/** - * Invariant: for cross-chain indexing status snapshot omnichain, - * slowestChainIndexingCursor equals to omnichainSnapshot.omnichainIndexingCursor - */ -export function invariant_slowestChainEqualsToOmnichainSnapshotTime( - ctx: ParsePayload, -) { - const { slowestChainIndexingCursor, omnichainSnapshot } = ctx.value; - const { omnichainIndexingCursor } = omnichainSnapshot; - - if (slowestChainIndexingCursor !== omnichainIndexingCursor) { - console.log("invariant_slowestChainEqualsToOmnichainSnapshotTime", { - slowestChainIndexingCursor, - omnichainIndexingCursor, - }); - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'slowestChainIndexingCursor' must be equal to 'omnichainSnapshot.omnichainIndexingCursor'`, - }); - } -} - -/** - * Invariant: for cross-chain indexing status snapshot omnichain, - * snapshotTime is greater than or equal to the "highest known block" timestamp. - */ -export function invariant_snapshotTimeIsTheHighestKnownBlockTimestamp( - ctx: ParsePayload, -) { - const { snapshotTime, omnichainSnapshot } = ctx.value; - const chains = Array.from(omnichainSnapshot.chains.values()); - - const startBlockTimestamps = chains.map((chain) => chain.config.startBlock.timestamp); - - const endBlockTimestamps = chains - .map((chain) => chain.config) - .filter((chainConfig) => chainConfig.configType === ChainIndexingConfigTypeIds.Definite) - .map((chainConfig) => chainConfig.endBlock.timestamp); - - const backfillEndBlockTimestamps = chains - .filter((chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill) - .map((chain) => chain.backfillEndBlock.timestamp); - - const latestKnownBlockTimestamps = chains - .filter((chain) => chain.chainStatus === ChainIndexingStatusIds.Following) - .map((chain) => chain.latestKnownBlock.timestamp); - - const highestKnownBlockTimestamp = Math.max( - ...startBlockTimestamps, - ...endBlockTimestamps, - ...backfillEndBlockTimestamps, - ...latestKnownBlockTimestamps, - ); - - if (snapshotTime < highestKnownBlockTimestamp) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'snapshotTime' (${snapshotTime}) must be greater than or equal to the "highest known block timestamp" (${highestKnownBlockTimestamp})`, - }); - } -} - -/** - * Invariants for {@link RealtimeIndexingStatusProjection}. - */ - -/** - * Invariant: For realtime indexing status projection, - * `projectedAt` is after or same as `snapshot.snapshotTime`. - */ -export function invariant_realtimeIndexingStatusProjectionProjectedAtIsAfterOrEqualToSnapshotTime( - ctx: ParsePayload, -) { - const projection = ctx.value; - - const { snapshot, projectedAt } = projection; - - if (snapshot.snapshotTime > projectedAt) { - ctx.issues.push({ - code: "custom", - input: projection, - message: "`projectedAt` must be after or same as `snapshot.snapshotTime`.", - }); - } -} - -/** - * Invariant: For realtime indexing status projection, - * `worstCaseDistance` is the difference between `projectedAt` - * and `omnichainIndexingCursor`. - */ -export function invariant_realtimeIndexingStatusProjectionWorstCaseDistanceIsCorrect( - ctx: ParsePayload, -) { - const projection = ctx.value; - const { projectedAt, snapshot, worstCaseDistance } = projection; - const { omnichainSnapshot } = snapshot; - const expectedWorstCaseDistance = projectedAt - omnichainSnapshot.omnichainIndexingCursor; - - if (worstCaseDistance !== expectedWorstCaseDistance) { - ctx.issues.push({ - code: "custom", - input: projection, - message: - "`worstCaseDistance` must be the exact difference between `projectedAt` and `snapshot.omnichainIndexingCursor`.", - }); - } -} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.ts deleted file mode 100644 index 3fb80ab5a..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/zod-schemas.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * All zod schemas we define must remain internal implementation details. - * We want the freedom to move away from zod in the future without impacting - * any users of the ensnode-sdk package. - * - * The only way to share Zod schemas is to re-export them from - * `./src/internal.ts` file. - */ -import { z } from "zod/v4"; - -import { deserializeChainId } from "../../shared/deserialize"; -import type { ChainId } from "../../shared/types"; -import { - makeBlockRefSchema, - makeChainIdStringSchema, - makeDurationSchema, - makeUnixTimestampSchema, -} from "../../shared/zod-schemas"; -import { - ChainIndexingConfig, - ChainIndexingConfigTypeIds, - ChainIndexingStatusIds, - type ChainIndexingStatusSnapshot, - ChainIndexingStatusSnapshotBackfill, - type ChainIndexingStatusSnapshotCompleted, - ChainIndexingStatusSnapshotFollowing, - type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill, - type ChainIndexingStatusSnapshotQueued, - CrossChainIndexingStatusSnapshot, - CrossChainIndexingStatusSnapshotOmnichain, - CrossChainIndexingStrategyIds, - OmnichainIndexingStatusIds, - OmnichainIndexingStatusSnapshotBackfill, - OmnichainIndexingStatusSnapshotCompleted, - OmnichainIndexingStatusSnapshotFollowing, - OmnichainIndexingStatusSnapshotUnstarted, - RealtimeIndexingStatusProjection, -} from "./types"; -import { - invariant_chainSnapshotBackfillBlocks, - invariant_chainSnapshotCompletedBlocks, - invariant_chainSnapshotFollowingBlocks, - invariant_chainSnapshotQueuedBlocks, - invariant_omnichainIndexingCursorIsEqualToHighestLatestIndexedBlockAcrossIndexedChain, - invariant_omnichainIndexingCursorLowerThanEarliestStartBlockAcrossQueuedChains, - invariant_omnichainIndexingCursorLowerThanOrEqualToLatestBackfillEndBlockAcrossBackfillChains, - invariant_omnichainSnapshotStatusIsConsistentWithChainSnapshot, - invariant_omnichainSnapshotUnstartedHasValidChains, - invariant_omnichainStatusSnapshotBackfillHasValidChains, - invariant_omnichainStatusSnapshotCompletedHasValidChains, - invariant_realtimeIndexingStatusProjectionProjectedAtIsAfterOrEqualToSnapshotTime, - invariant_realtimeIndexingStatusProjectionWorstCaseDistanceIsCorrect, - invariant_slowestChainEqualsToOmnichainSnapshotTime, - invariant_snapshotTimeIsTheHighestKnownBlockTimestamp, -} from "./validations"; - -/** - * Makes Zod schema for {@link ChainIndexingConfig} type. - */ -const makeChainIndexingConfigSchema = (valueLabel: string = "Value") => - z.discriminatedUnion("configType", [ - z.strictObject({ - configType: z.literal(ChainIndexingConfigTypeIds.Indefinite), - startBlock: makeBlockRefSchema(valueLabel), - }), - z.strictObject({ - configType: z.literal(ChainIndexingConfigTypeIds.Definite), - startBlock: makeBlockRefSchema(valueLabel), - endBlock: makeBlockRefSchema(valueLabel), - }), - ]); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshotQueued} type. - */ -export const makeChainIndexingStatusSnapshotQueuedSchema = (valueLabel: string = "Value") => - z - .strictObject({ - chainStatus: z.literal(ChainIndexingStatusIds.Queued), - config: makeChainIndexingConfigSchema(valueLabel), - }) - .check(invariant_chainSnapshotQueuedBlocks); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshotBackfill} type. - */ -export const makeChainIndexingStatusSnapshotBackfillSchema = (valueLabel: string = "Value") => - z - .strictObject({ - chainStatus: z.literal(ChainIndexingStatusIds.Backfill), - config: makeChainIndexingConfigSchema(valueLabel), - latestIndexedBlock: makeBlockRefSchema(valueLabel), - backfillEndBlock: makeBlockRefSchema(valueLabel), - }) - .check(invariant_chainSnapshotBackfillBlocks); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshotCompleted} type. - */ -export const makeChainIndexingStatusSnapshotCompletedSchema = (valueLabel: string = "Value") => - z - .strictObject({ - chainStatus: z.literal(ChainIndexingStatusIds.Completed), - config: z.strictObject({ - configType: z.literal(ChainIndexingConfigTypeIds.Definite), - startBlock: makeBlockRefSchema(valueLabel), - endBlock: makeBlockRefSchema(valueLabel), - }), - latestIndexedBlock: makeBlockRefSchema(valueLabel), - }) - .check(invariant_chainSnapshotCompletedBlocks); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshotFollowing} type. - */ -export const makeChainIndexingStatusSnapshotFollowingSchema = (valueLabel: string = "Value") => - z - .strictObject({ - chainStatus: z.literal(ChainIndexingStatusIds.Following), - config: z.strictObject({ - configType: z.literal(ChainIndexingConfigTypeIds.Indefinite), - startBlock: makeBlockRefSchema(valueLabel), - }), - latestIndexedBlock: makeBlockRefSchema(valueLabel), - latestKnownBlock: makeBlockRefSchema(valueLabel), - }) - .check(invariant_chainSnapshotFollowingBlocks); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshot} - */ -export const makeChainIndexingStatusSnapshotSchema = (valueLabel: string = "Value") => - z.discriminatedUnion("chainStatus", [ - makeChainIndexingStatusSnapshotQueuedSchema(valueLabel), - makeChainIndexingStatusSnapshotBackfillSchema(valueLabel), - makeChainIndexingStatusSnapshotCompletedSchema(valueLabel), - makeChainIndexingStatusSnapshotFollowingSchema(valueLabel), - ]); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshot} per chain. - */ -export const makeChainIndexingStatusesSchema = (valueLabel: string = "Value") => - z - .record(makeChainIdStringSchema(), makeChainIndexingStatusSnapshotSchema(valueLabel), { - error: - "Chains indexing statuses must be an object mapping valid chain IDs to their indexing status snapshots.", - }) - .transform((serializedChainsIndexingStatus) => { - const chainsIndexingStatus = new Map(); - - for (const [chainIdString, chainStatus] of Object.entries(serializedChainsIndexingStatus)) { - chainsIndexingStatus.set(deserializeChainId(chainIdString), chainStatus); - } - - return chainsIndexingStatus; - }); - -/** - * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotUnstarted} - */ -const makeOmnichainIndexingStatusSnapshotUnstartedSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Unstarted), - chains: makeChainIndexingStatusesSchema(valueLabel) - .check(invariant_omnichainSnapshotUnstartedHasValidChains) - .transform((chains) => chains as Map), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotBackfill} - */ -const makeOmnichainIndexingStatusSnapshotBackfillSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Backfill), - chains: makeChainIndexingStatusesSchema(valueLabel) - .check(invariant_omnichainStatusSnapshotBackfillHasValidChains) - .transform( - (chains) => - chains as Map< - ChainId, - ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill - >, - ), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotCompleted} - */ -const makeOmnichainIndexingStatusSnapshotCompletedSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Completed), - chains: makeChainIndexingStatusesSchema(valueLabel) - .check(invariant_omnichainStatusSnapshotCompletedHasValidChains) - .transform((chains) => chains as Map), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotFollowing} - */ -const makeOmnichainIndexingStatusSnapshotFollowingSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Following), - chains: makeChainIndexingStatusesSchema(valueLabel), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Omnichain Indexing Snapshot Schema - * - * Makes a Zod schema definition for validating indexing snapshot - * across all chains indexed by ENSIndexer instance. - */ -export const makeOmnichainIndexingStatusSnapshotSchema = ( - valueLabel: string = "Omnichain Indexing Snapshot", -) => - z - .discriminatedUnion("omnichainStatus", [ - makeOmnichainIndexingStatusSnapshotUnstartedSchema(valueLabel), - makeOmnichainIndexingStatusSnapshotBackfillSchema(valueLabel), - makeOmnichainIndexingStatusSnapshotCompletedSchema(valueLabel), - makeOmnichainIndexingStatusSnapshotFollowingSchema(valueLabel), - ]) - .check(invariant_omnichainSnapshotStatusIsConsistentWithChainSnapshot) - .check(invariant_omnichainIndexingCursorLowerThanEarliestStartBlockAcrossQueuedChains) - .check( - invariant_omnichainIndexingCursorLowerThanOrEqualToLatestBackfillEndBlockAcrossBackfillChains, - ) - .check(invariant_omnichainIndexingCursorIsEqualToHighestLatestIndexedBlockAcrossIndexedChain); - -/** - * Makes Zod schema for {@link CrossChainIndexingStatusSnapshotOmnichain} - */ -const makeCrossChainIndexingStatusSnapshotOmnichainSchema = ( - valueLabel: string = "Cross-chain Indexing Status Snapshot Omnichain", -) => - z - .strictObject({ - strategy: z.literal(CrossChainIndexingStrategyIds.Omnichain), - slowestChainIndexingCursor: makeUnixTimestampSchema(valueLabel), - snapshotTime: makeUnixTimestampSchema(valueLabel), - omnichainSnapshot: makeOmnichainIndexingStatusSnapshotSchema(valueLabel), - }) - .check(invariant_slowestChainEqualsToOmnichainSnapshotTime) - .check(invariant_snapshotTimeIsTheHighestKnownBlockTimestamp); - -/** - * Makes Zod schema for {@link CrossChainIndexingStatusSnapshot} - */ -export const makeCrossChainIndexingStatusSnapshotSchema = ( - valueLabel: string = "Cross-chain Indexing Status Snapshot", -) => - z.discriminatedUnion("strategy", [ - makeCrossChainIndexingStatusSnapshotOmnichainSchema(valueLabel), - ]); - -/** - * Makes Zod schema for {@link RealtimeIndexingStatusProjection} - */ -export const makeRealtimeIndexingStatusProjectionSchema = ( - valueLabel: string = "Realtime Indexing Status Projection", -) => - z - .strictObject({ - projectedAt: makeUnixTimestampSchema(valueLabel), - worstCaseDistance: makeDurationSchema(valueLabel), - snapshot: makeCrossChainIndexingStatusSnapshotSchema(valueLabel), - }) - .check(invariant_realtimeIndexingStatusProjectionProjectedAtIsAfterOrEqualToSnapshotTime) - .check(invariant_realtimeIndexingStatusProjectionWorstCaseDistanceIsCorrect); diff --git a/packages/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index 7352f9a05..da2b4a166 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -20,7 +20,6 @@ export * from "./api/shared/errors/zod-schemas"; export * from "./api/shared/pagination/zod-schemas"; export * from "./ensapi/config/zod-schemas"; export * from "./ensindexer/config/zod-schemas"; -export * from "./ensindexer/indexing-status/zod-schemas"; export * from "./registrars/zod-schemas"; export * from "./rpc"; export * from "./shared/config/build-rpc-urls"; From 21c5973929f6fa54b3b333d98d7c8996b298c2ee Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 7 Feb 2026 11:55:05 +0100 Subject: [PATCH 2/6] Remove usused code --- .../ponder-metadata/validations.ts | 50 +------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/validations.ts b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/validations.ts index dab8f7160..0394c58bd 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/validations.ts +++ b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/validations.ts @@ -1,13 +1,5 @@ -import { type ParsePayload, prettifyError } from "zod/v4/core"; +import { prettifyError } from "zod/v4/core"; -import { - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill, - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted, - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing, - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted, - OmnichainIndexingStatusIds, - type SerializedOmnichainIndexingStatusSnapshot, -} from "@ensnode/ensnode-sdk"; import type { PrometheusMetrics } from "@ensnode/ponder-metadata"; import { PonderAppSettingsSchema } from "./zod-schemas"; @@ -32,43 +24,3 @@ export function validatePonderMetrics(metrics: PrometheusMetrics) { ); } } - -/** - * Invariant: SerializedOmnichainSnapshot Has Valid Chains - * - * Validates that the `chains` property of a {@link SerializedOmnichainIndexingStatusSnapshot} - * is consistent with the reported `omnichainStatus`. - */ -export function invariant_serializedOmnichainSnapshotHasValidChains( - ctx: ParsePayload, -) { - const omnichainSnapshot = ctx.value; - const chains = Object.values(omnichainSnapshot.chains); - let hasValidChains = false; - - switch (omnichainSnapshot.omnichainStatus) { - case OmnichainIndexingStatusIds.Unstarted: - hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted(chains); - break; - - case OmnichainIndexingStatusIds.Backfill: - hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill(chains); - break; - - case OmnichainIndexingStatusIds.Completed: - hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted(chains); - break; - - case OmnichainIndexingStatusIds.Following: - hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing(chains); - break; - } - - if (!hasValidChains) { - ctx.issues.push({ - code: "custom", - input: omnichainSnapshot, - message: `"chains" are not consistent with the reported '${omnichainSnapshot.omnichainStatus}' "omnichainStatus"`, - }); - } -} From e5a0bb6e3eb6376bb50135667ca7179ec0c84b8e Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 7 Feb 2026 12:08:45 +0100 Subject: [PATCH 3/6] ENSNode SDK: Export validation APIs for Indexing Status API. --- packages/ensnode-sdk/src/ensindexer/indexing-status/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/index.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/index.ts index 5feea6fea..3bef36ab6 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/index.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/index.ts @@ -10,3 +10,7 @@ export * from "./serialize/chain-indexing-status-snapshot"; export * from "./serialize/cross-chain-indexing-status-snapshot"; export * from "./serialize/omnichain-indexing-status-snapshot"; export * from "./serialize/realtime-indexing-status-projection"; +export * from "./validate/chain-indexing-status-snapshot"; +export * from "./validate/cross-chain-indexing-status-snapshot"; +export * from "./validate/omnichain-indexing-status-snapshot"; +export * from "./validate/realtime-indexing-status-projection"; From 12a845eb0b43f1f40eae2284ee82ad55dc030640 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 7 Feb 2026 12:22:35 +0100 Subject: [PATCH 4/6] docs(changeset): Introduced `validate*` functions for Indexing Status data model. These functions enable new use cases on consumers side. --- .changeset/wet-pens-mate.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wet-pens-mate.md diff --git a/.changeset/wet-pens-mate.md b/.changeset/wet-pens-mate.md new file mode 100644 index 000000000..deda049b5 --- /dev/null +++ b/.changeset/wet-pens-mate.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Introduced `validate*` functions for Indexing Status data model. These functions enable new use cases on consumers side. From 520e3935438a41f653228e1b0716a57c58de41c0 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 7 Feb 2026 13:13:14 +0100 Subject: [PATCH 5/6] Create schema-layer for Zod schemas --- .../src/api/indexing-status/deserialize.ts | 5 +- .../src/api/indexing-status/zod-schemas.ts | 6 +- .../chain-indexing-status-snapshot.test.ts | 2 +- .../chain-indexing-status-snapshot.ts | 18 +- .../chain-indexing-status-snapshot.ts | 2 +- .../cross-chain-indexing-status-snapshot.ts | 49 +- .../omnichain-indexing-status-snapshot.ts | 160 +----- .../realtime-indexing-status-projection.ts | 23 +- ...omnichain-indexing-status-snapshot.test.ts | 4 +- .../omnichain-indexing-status-snapshot.ts | 80 ++- .../chain-indexing-status-snapshot.test.ts | 320 ++++++++++++ .../schema/chain-indexing-status-snapshot.ts | 216 ++++++++ .../cross-chain-indexing-status-snapshot.ts | 137 +++++ .../omnichian-indexing-status-snapshot.ts | 467 ++++++++++++++++++ .../realtime-indexing-status-projection.ts | 77 +++ .../chain-indexing-status-snapshot.test.ts | 326 ------------ .../chain-indexing-status-snapshot.ts | 219 +------- .../cross-chain-indexing-status-snapshot.ts | 106 +--- .../omnichain-indexing-status-snapshot.ts | 399 +-------------- .../realtime-indexing-status-projection.ts | 64 +-- 20 files changed, 1345 insertions(+), 1335 deletions(-) create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/schema/chain-indexing-status-snapshot.test.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/schema/chain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/schema/cross-chain-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/schema/omnichian-indexing-status-snapshot.ts create mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/schema/realtime-indexing-status-projection.ts delete mode 100644 packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.test.ts diff --git a/packages/ensnode-sdk/src/api/indexing-status/deserialize.ts b/packages/ensnode-sdk/src/api/indexing-status/deserialize.ts index 90e38fb51..2d258194a 100644 --- a/packages/ensnode-sdk/src/api/indexing-status/deserialize.ts +++ b/packages/ensnode-sdk/src/api/indexing-status/deserialize.ts @@ -1,8 +1,7 @@ -import z, { prettifyError } from "zod/v4"; +import { prettifyError, z } from "zod/v4"; import { buildUnvalidatedRealtimeIndexingStatusProjection } from "../../ensindexer/indexing-status/deserialize/realtime-indexing-status-projection"; -import { IndexingStatusResponseCodes } from "../types"; -import type { IndexingStatusResponse } from "./response"; +import { type IndexingStatusResponse, IndexingStatusResponseCodes } from "./response"; import type { SerializedIndexingStatusResponse } from "./serialized-response"; import { makeIndexingStatusResponseSchema, diff --git a/packages/ensnode-sdk/src/api/indexing-status/zod-schemas.ts b/packages/ensnode-sdk/src/api/indexing-status/zod-schemas.ts index 0d1399189..16dfafbe8 100644 --- a/packages/ensnode-sdk/src/api/indexing-status/zod-schemas.ts +++ b/packages/ensnode-sdk/src/api/indexing-status/zod-schemas.ts @@ -1,7 +1,9 @@ import { z } from "zod/v4"; -import { makeSerializedRealtimeIndexingStatusProjectionSchema } from "../../ensindexer/indexing-status/deserialize/realtime-indexing-status-projection"; -import { makeRealtimeIndexingStatusProjectionSchema } from "../../ensindexer/indexing-status/validate/realtime-indexing-status-projection"; +import { + makeRealtimeIndexingStatusProjectionSchema, + makeSerializedRealtimeIndexingStatusProjectionSchema, +} from "../../ensindexer/indexing-status/schema/realtime-indexing-status-projection"; import { type IndexingStatusResponse, IndexingStatusResponseCodes, diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.test.ts index a5e8f89f1..c28856bf7 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.test.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.test.ts @@ -26,7 +26,7 @@ describe("Chain Indexing Status Snapshot", () => { } satisfies ChainIndexingConfigDefinite); }); - it("returns 'indefinite' indexer config if the endBlock exists", () => { + it("returns 'indefinite' indexer config if the endBlock does not exist", () => { // arrange const startBlock = earlierBlockRef; const endBlock = null; diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.ts index 9b2f8d3da..b02b3f6e2 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/chain-indexing-status-snapshot.ts @@ -318,6 +318,13 @@ export function getTimestampForLowestOmnichainStartBlock( (chain) => chain.config.startBlock.timestamp, ); + // Invariant: earliestKnownBlockTimestamps is guaranteed to have at least one element + if (earliestKnownBlockTimestamps.length === 0) { + throw new Error( + "Invariant violation: at least one chain is required to determine the lowest omnichain start block timestamp", + ); + } + return Math.min(...earliestKnownBlockTimestamps); } @@ -365,6 +372,13 @@ export function getTimestampForHighestOmnichainKnownBlock( } } + // Invariant: latestKnownBlockTimestamps is guaranteed to have at least one element + if (latestKnownBlockTimestamps.length === 0) { + throw new Error( + "Invariant violation: at least one chain is required to determine the highest omnichain known block timestamp", + ); + } + return Math.max(...latestKnownBlockTimestamps); } @@ -376,10 +390,8 @@ export function sortChainStatusesByStartBlockAsc< ChainStatusType extends ChainIndexingStatusSnapshot, >(chains: [ChainId, ChainStatusType][]): [ChainId, ChainStatusType][] { // Sort the chain statuses by the omnichain first block to index timestamp - chains.sort( + return [...chains].sort( ([, chainA], [, chainB]) => chainA.config.startBlock.timestamp - chainB.config.startBlock.timestamp, ); - - return chains; } diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/chain-indexing-status-snapshot.ts index 204962b1e..44d2d8002 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/chain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/chain-indexing-status-snapshot.ts @@ -1,8 +1,8 @@ import { prettifyError } from "zod/v4"; import type { ChainIndexingStatusSnapshot } from "../chain-indexing-status-snapshot"; +import { makeChainIndexingStatusSnapshotSchema } from "../schema/chain-indexing-status-snapshot"; import type { SerializedChainIndexingStatusSnapshot } from "../serialize/chain-indexing-status-snapshot"; -import { makeChainIndexingStatusSnapshotSchema } from "../validate/chain-indexing-status-snapshot"; /** * Deserialize into a {@link ChainIndexingStatusSnapshot} object. diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/cross-chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/cross-chain-indexing-status-snapshot.ts index 0fb711c69..e1bb7ca1e 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/cross-chain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/cross-chain-indexing-status-snapshot.ts @@ -1,16 +1,12 @@ -import z, { prettifyError } from "zod/v4"; +import { prettifyError, z } from "zod/v4"; -import { makeUnixTimestampSchema } from "../../../shared/zod-schemas"; +import type { CrossChainIndexingStatusSnapshot } from "../cross-chain-indexing-status-snapshot"; import { - type CrossChainIndexingStatusSnapshot, - CrossChainIndexingStrategyIds, -} from "../cross-chain-indexing-status-snapshot"; + buildUnvalidatedCrossChainIndexingStatusSnapshot, + makeCrossChainIndexingStatusSnapshotSchema, + makeSerializedCrossChainIndexingStatusSnapshotSchema, +} from "../schema/cross-chain-indexing-status-snapshot"; import type { SerializedCrossChainIndexingStatusSnapshot } from "../serialize/cross-chain-indexing-status-snapshot"; -import { makeCrossChainIndexingStatusSnapshotSchema } from "../validate/cross-chain-indexing-status-snapshot"; -import { - buildUnvalidatedOmnichainIndexingStatusSnapshot, - makeSerializedOmnichainIndexingStatusSnapshotSchema, -} from "./omnichain-indexing-status-snapshot"; /** * Deserialize an {@link CrossChainIndexingStatusSnapshot} object. @@ -35,36 +31,3 @@ export function deserializeCrossChainIndexingStatusSnapshot( return parsed.data; } - -/** - * Build unvalidated cross-chain indexing status snapshot to be validated. - * - * Return type is intentionally "unknown" to enforce validation by - * {@link makeCrossChainIndexingStatusSnapshotSchema} call. - */ -export function buildUnvalidatedCrossChainIndexingStatusSnapshot( - serializedCrossChainIndexingStatusSnapshot: SerializedCrossChainIndexingStatusSnapshot, -): unknown { - const { strategy, slowestChainIndexingCursor, snapshotTime, omnichainSnapshot } = - serializedCrossChainIndexingStatusSnapshot; - - return { - strategy, - slowestChainIndexingCursor, - snapshotTime, - omnichainSnapshot: buildUnvalidatedOmnichainIndexingStatusSnapshot(omnichainSnapshot), - }; -} - -/** - * Makes Zod schema for {@link SerializedCrossChainIndexingStatusSnapshot} - */ -export const makeSerializedCrossChainIndexingStatusSnapshotSchema = ( - valueLabel: string = "Cross-chain Indexing Status Snapshot", -) => - z.object({ - strategy: z.enum(CrossChainIndexingStrategyIds), - slowestChainIndexingCursor: makeUnixTimestampSchema(valueLabel), - snapshotTime: makeUnixTimestampSchema(valueLabel), - omnichainSnapshot: makeSerializedOmnichainIndexingStatusSnapshotSchema(valueLabel), - }); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts index 30d8ae1e6..4ad4a6dad 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts @@ -1,84 +1,12 @@ -import z, { prettifyError } from "zod/v4"; +import { prettifyError, z } from "zod/v4"; -import { deserializeChainId } from "../../../shared/deserialize"; -import type { ChainIdString } from "../../../shared/serialized-types"; -import type { ChainId } from "../../../shared/types"; -import { makeChainIdStringSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; -import type { - ChainIndexingStatusSnapshot, - ChainIndexingStatusSnapshotCompleted, - ChainIndexingStatusSnapshotQueued, -} from "../chain-indexing-status-snapshot"; +import type { OmnichainIndexingStatusSnapshot } from "../omnichain-indexing-status-snapshot"; import { - type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill, - OmnichainIndexingStatusIds, - type OmnichainIndexingStatusSnapshot, -} from "../omnichain-indexing-status-snapshot"; -import type { SerializedChainIndexingStatusSnapshot } from "../serialize/chain-indexing-status-snapshot"; -import type { - SerializedOmnichainIndexingStatusSnapshot, - SerializedOmnichainIndexingStatusSnapshotBackfill, - SerializedOmnichainIndexingStatusSnapshotCompleted, - SerializedOmnichainIndexingStatusSnapshotUnstarted, -} from "../serialize/omnichain-indexing-status-snapshot"; -import { makeChainIndexingStatusSnapshotSchema } from "../validate/chain-indexing-status-snapshot"; -import { makeOmnichainIndexingStatusSnapshotSchema } from "../validate/omnichain-indexing-status-snapshot"; - -/** - * Build unvalidated omnichain indexing status snapshot to be validated. - * - * Return type is intentionally "unknown" to enforce validation by - * {@link makeOmnichainIndexingStatusSnapshotSchema} call. - */ -export function buildUnvalidatedOmnichainIndexingStatusSnapshot( - serializedOmnichainIndexingStatusSnapshot: SerializedOmnichainIndexingStatusSnapshot, -): unknown { - const { omnichainStatus, chains, omnichainIndexingCursor } = - serializedOmnichainIndexingStatusSnapshot; - - switch (omnichainStatus) { - case OmnichainIndexingStatusIds.Unstarted: { - return { - omnichainStatus, - chains: buildUnvalidatedChainIndexingStatuses(chains) as Map< - ChainId, - ChainIndexingStatusSnapshotQueued - >, - omnichainIndexingCursor, - }; - } - - case OmnichainIndexingStatusIds.Backfill: { - return { - omnichainStatus, - chains: buildUnvalidatedChainIndexingStatuses(chains) as Map< - ChainId, - ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill - >, - omnichainIndexingCursor, - }; - } - - case OmnichainIndexingStatusIds.Following: { - return { - omnichainStatus, - chains: buildUnvalidatedChainIndexingStatuses(chains), - omnichainIndexingCursor, - }; - } - - case OmnichainIndexingStatusIds.Completed: { - return { - omnichainStatus, - chains: buildUnvalidatedChainIndexingStatuses(chains) as Map< - ChainId, - ChainIndexingStatusSnapshotCompleted - >, - omnichainIndexingCursor, - }; - } - } -} + buildUnvalidatedOmnichainIndexingStatusSnapshot, + makeOmnichainIndexingStatusSnapshotSchema, + makeSerializedOmnichainIndexingStatusSnapshotSchema, +} from "../schema/omnichian-indexing-status-snapshot"; +import type { SerializedOmnichainIndexingStatusSnapshot } from "../serialize/omnichain-indexing-status-snapshot"; /** * Deserialize an {@link OmnichainIndexingStatusSnapshot} object. @@ -103,77 +31,3 @@ export function deserializeOmnichainIndexingStatusSnapshot( return parsed.data; } - -/** - * Build unvalidated chain indexing statuses map to be validated by - * {@link makeChainIndexingStatusesSchema} call. - */ -export function buildUnvalidatedChainIndexingStatuses( - serializedChainIndexingStatuses: Record, -): Map { - const chainIndexingStatuses = new Map(); - - for (const [serializedChainId, chainIndexingSnapshot] of Object.entries( - serializedChainIndexingStatuses, - )) { - const chainId = deserializeChainId(serializedChainId); - - chainIndexingStatuses.set(chainId, chainIndexingSnapshot); - } - - return chainIndexingStatuses; -} - -const makeSerializedChainIndexingStatusSnapshotsSchema = (valueLabel: string = "Value") => - z.record(makeChainIdStringSchema(), makeChainIndexingStatusSnapshotSchema(valueLabel)); - -/** - * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotUnstarted} - */ -const makeSerializedOmnichainIndexingStatusSnapshotUnstartedSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Unstarted), - chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotBackfill} - */ -const makeSerializedOmnichainIndexingStatusSnapshotBackfillSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Backfill), - chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotCompleted} - */ -const makeSerializedOmnichainIndexingStatusSnapshotCompletedSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Completed), - chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotFollowing} - */ -const makeSerializedOmnichainIndexingStatusSnapshotFollowingSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Following), - chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshot}. - */ -export const makeSerializedOmnichainIndexingStatusSnapshotSchema = (valueLabel: string = "Value") => - z.discriminatedUnion("omnichainStatus", [ - makeSerializedOmnichainIndexingStatusSnapshotUnstartedSchema(valueLabel), - makeSerializedOmnichainIndexingStatusSnapshotBackfillSchema(valueLabel), - makeSerializedOmnichainIndexingStatusSnapshotCompletedSchema(valueLabel), - makeSerializedOmnichainIndexingStatusSnapshotFollowingSchema(valueLabel), - ]); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/realtime-indexing-status-projection.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/realtime-indexing-status-projection.ts index 53657605c..8d8a60228 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/realtime-indexing-status-projection.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/realtime-indexing-status-projection.ts @@ -1,13 +1,12 @@ import { prettifyError, z } from "zod/v4"; -import { makeDurationSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; import type { RealtimeIndexingStatusProjection } from "../realtime-indexing-status-projection"; -import type { SerializedRealtimeIndexingStatusProjection } from "../serialize/realtime-indexing-status-projection"; -import { makeRealtimeIndexingStatusProjectionSchema } from "../validate/realtime-indexing-status-projection"; +import { buildUnvalidatedCrossChainIndexingStatusSnapshot } from "../schema/cross-chain-indexing-status-snapshot"; import { - buildUnvalidatedCrossChainIndexingStatusSnapshot, - makeSerializedCrossChainIndexingStatusSnapshotSchema, -} from "./cross-chain-indexing-status-snapshot"; + makeRealtimeIndexingStatusProjectionSchema, + makeSerializedRealtimeIndexingStatusProjectionSchema, +} from "../schema/realtime-indexing-status-projection"; +import type { SerializedRealtimeIndexingStatusProjection } from "../serialize/realtime-indexing-status-projection"; /** * Build unvalidated realtime indexing status projection to be validated. @@ -50,15 +49,3 @@ export function deserializeRealtimeIndexingStatusProjection( return parsed.data; } - -/** - * Makes Zod schema for {@link SerializedRealtimeIndexingStatusProjection}. - */ -export const makeSerializedRealtimeIndexingStatusProjectionSchema = ( - valueLabel: string = "Value", -) => - z.strictObject({ - snapshot: makeSerializedCrossChainIndexingStatusSnapshotSchema(valueLabel), - projectedAt: makeUnixTimestampSchema(valueLabel), - worstCaseDistance: makeDurationSchema(valueLabel), - }); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.test.ts index 1af3899f0..f94d836a9 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.test.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.test.ts @@ -62,7 +62,7 @@ describe("Omnichain Indexing Status", () => { expect(overallIndexingStatus).toStrictEqual(OmnichainIndexingStatusIds.Backfill); }); - it("can correctly derive 'following' status if at least one chain is 'following", () => { + it("can correctly derive 'following' status if at least one chain is 'following'", () => { // arrange const chainStatuses = chainStatusesFollowingMixed; @@ -176,7 +176,7 @@ describe("Omnichain Indexing Status", () => { startBlock: earliestBlockRef, }, latestIndexedBlock: evenLaterBlockRef, - latestKnownBlock: laterBlockRef, + latestKnownBlock: evenLaterBlockRef, } satisfies ChainIndexingStatusSnapshotFollowing, { chainStatus: ChainIndexingStatusIds.Completed, diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.ts index 3853dd03a..a5a1c308f 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/omnichain-indexing-status-snapshot.ts @@ -6,12 +6,6 @@ import { type ChainIndexingStatusSnapshotCompleted, type ChainIndexingStatusSnapshotQueued, } from "./chain-indexing-status-snapshot"; -import { - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill, - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted, - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing, - checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted, -} from "./validate/omnichain-indexing-status-snapshot"; /** * The status of omnichain indexing at the time an omnichain indexing status @@ -203,6 +197,80 @@ export type OmnichainIndexingStatusSnapshot = | OmnichainIndexingStatusSnapshotCompleted | OmnichainIndexingStatusSnapshotFollowing; +/** + * Check if Chain Indexing Status Snapshots fit the 'unstarted' overall status + * snapshot requirements: + * - All chains are guaranteed to have a status of "queued". + * + * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to + * {@link ChainIndexingStatusSnapshotQueued}. + */ +export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted( + chains: ChainIndexingStatusSnapshot[], +): chains is ChainIndexingStatusSnapshotQueued[] { + return chains.every((chain) => chain.chainStatus === ChainIndexingStatusIds.Queued); +} + +/** + * Check if Chain Indexing Status Snapshots fit the 'backfill' overall status + * snapshot requirements: + * - At least one chain is guaranteed to be in the "backfill" status. + * - Each chain is guaranteed to have a status of either "queued", + * "backfill" or "completed". + * + * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to + * {@link ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill}. + */ +export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill( + chains: ChainIndexingStatusSnapshot[], +): chains is ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill[] { + const atLeastOneChainInTargetStatus = chains.some( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill, + ); + const otherChainsHaveValidStatuses = chains.every( + (chain) => + chain.chainStatus === ChainIndexingStatusIds.Queued || + chain.chainStatus === ChainIndexingStatusIds.Backfill || + chain.chainStatus === ChainIndexingStatusIds.Completed, + ); + + return atLeastOneChainInTargetStatus && otherChainsHaveValidStatuses; +} + +/** + * Checks if Chain Indexing Status Snapshots fit the 'completed' overall status + * snapshot requirements: + * - All chains are guaranteed to have a status of "completed". + * + * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to + * {@link ChainIndexingStatusSnapshotCompleted}. + */ +export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted( + chains: ChainIndexingStatusSnapshot[], +): chains is ChainIndexingStatusSnapshotCompleted[] { + const allChainsHaveValidStatuses = chains.every( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Completed, + ); + + return allChainsHaveValidStatuses; +} + +/** + * Checks Chain Indexing Status Snapshots fit the 'following' overall status + * snapshot requirements: + * - At least one chain is guaranteed to be in the "following" status. + * - Any other chain can have any status. + */ +export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing( + chains: ChainIndexingStatusSnapshot[], +): boolean { + const allChainsHaveValidStatuses = chains.some( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Following, + ); + + return allChainsHaveValidStatuses; +} + /** * Get {@link OmnichainIndexingStatusId} based on indexed chains' statuses. * diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/chain-indexing-status-snapshot.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/chain-indexing-status-snapshot.test.ts new file mode 100644 index 000000000..28a0ae852 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/chain-indexing-status-snapshot.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from "vitest"; +import { prettifyError, type ZodSafeParseResult } from "zod/v4"; + +import { + earlierBlockRef, + earliestBlockRef, + laterBlockRef, + latestBlockRef, +} from "../block-refs.mock"; +import { + ChainIndexingConfigTypeIds, + ChainIndexingStatusIds, + type ChainIndexingStatusSnapshot, + type ChainIndexingStatusSnapshotBackfill, + type ChainIndexingStatusSnapshotCompleted, + type ChainIndexingStatusSnapshotFollowing, + type ChainIndexingStatusSnapshotQueued, +} from "../chain-indexing-status-snapshot"; +import { makeChainIndexingStatusSnapshotSchema } from "./chain-indexing-status-snapshot"; + +describe("Chain Indexing Status Snapshot Schema", () => { + const formatParseError = (zodParseError: ZodSafeParseResult) => + prettifyError(zodParseError.error!); + + describe("ChainIndexingStatusSnapshotQueued", () => { + it("can parse a valid serialized status object", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: laterBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued; + + // act + const parsed = makeChainIndexingStatusSnapshotSchema().parse(serialized); + + // assert + expect(parsed).toStrictEqual({ + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: laterBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued); + }); + + it("won't parse if the config.startBlock is after the config.endBlock", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: laterBlockRef, + endBlock: earlierBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued; + + // act + const notParsed = formatParseError( + makeChainIndexingStatusSnapshotSchema().safeParse(serialized), + ); + + // assert + expect(notParsed).toMatch(/`config.startBlock` must be before or same as `config.endBlock`/i); + }); + }); + + describe("ChainIndexingStatusSnapshotBackfill", () => { + it("can parse a valid serialized status object", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: latestBlockRef, + }, + latestIndexedBlock: earlierBlockRef, + backfillEndBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill; + + // act + const parsed = makeChainIndexingStatusSnapshotSchema().parse(serialized); + + // assert + expect(parsed).toStrictEqual({ + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: latestBlockRef, + }, + latestIndexedBlock: earlierBlockRef, + backfillEndBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill); + }); + + it("won't parse if the config.startBlock is after the latestIndexedBlock", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: earliestBlockRef, + backfillEndBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill; + + // act + const notParsed = formatParseError( + makeChainIndexingStatusSnapshotSchema().safeParse(serialized), + ); + + // assert + expect(notParsed).toMatch( + /`config.startBlock` must be before or same as `latestIndexedBlock`/, + ); + }); + + it("won't parse if the latestIndexedBlock is after the backfillEndBlock", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: latestBlockRef, + backfillEndBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill; + + // act + const notParsed = formatParseError( + makeChainIndexingStatusSnapshotSchema().safeParse(serialized), + ); + + // assert + expect(notParsed).toMatch( + /`latestIndexedBlock` must be before or same as `backfillEndBlock`/, + ); + }); + + it("won't parse if the backfillEndBlock different than the config.endBlock", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: latestBlockRef, + backfillEndBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill; + + // act + const notParsed = formatParseError( + makeChainIndexingStatusSnapshotSchema().safeParse(serialized), + ); + + // assert + expect(notParsed).toMatch(/`backfillEndBlock` must be the same as `config.endBlock`/); + }); + }); + + describe("ChainIndexingStatusSnapshotFollowing", () => { + it("can parse a valid serialized status object", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Following, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earlierBlockRef, + }, + latestIndexedBlock: laterBlockRef, + latestKnownBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotFollowing; + + // act + const parsed = makeChainIndexingStatusSnapshotSchema().parse(serialized); + + // assert + expect(parsed).toStrictEqual({ + chainStatus: ChainIndexingStatusIds.Following, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earlierBlockRef, + }, + latestIndexedBlock: laterBlockRef, + latestKnownBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotFollowing); + }); + + it("won't parse if the config.startBlock is after the latestIndexedBlock", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Following, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: laterBlockRef, + }, + latestIndexedBlock: earlierBlockRef, + latestKnownBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotFollowing; + + // act + const notParsed = formatParseError( + makeChainIndexingStatusSnapshotSchema().safeParse(serialized), + ); + + // assert + expect(notParsed).toMatch( + /`config.startBlock` must be before or same as `latestIndexedBlock`/, + ); + }); + + it("won't parse if the latestIndexedBlock is after the latestKnownBlock", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Following, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earlierBlockRef, + }, + latestIndexedBlock: latestBlockRef, + latestKnownBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotFollowing; + + // act + const notParsed = formatParseError( + makeChainIndexingStatusSnapshotSchema().safeParse(serialized), + ); + + // assert + expect(notParsed).toMatch( + /`latestIndexedBlock` must be before or same as `latestKnownBlock`/, + ); + }); + }); + + describe("ChainIndexingStatusSnapshotCompleted", () => { + it("can parse a valid serialized status object", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotCompleted; + + // act + const parsed = makeChainIndexingStatusSnapshotSchema().parse(serialized); + + // assert + expect(parsed).toStrictEqual({ + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotCompleted); + }); + + it("won't parse if the config.startBlock is after the latestIndexedBlock", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: latestBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: laterBlockRef, + } satisfies ChainIndexingStatusSnapshotCompleted; + + // act + const notParsed = formatParseError( + makeChainIndexingStatusSnapshotSchema().safeParse(serialized), + ); + + // assert + expect(notParsed).toMatch( + /`config.startBlock` must be before or same as `latestIndexedBlock`/, + ); + }); + + it("won't parse if the latestIndexedBlock is after the config.endBlock", () => { + // arrange + const serialized: ChainIndexingStatusSnapshot = { + chainStatus: ChainIndexingStatusIds.Completed, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earlierBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotCompleted; + + // act + const notParsed = formatParseError( + makeChainIndexingStatusSnapshotSchema().safeParse(serialized), + ); + + // assert + expect(notParsed).toMatch(/`latestIndexedBlock` must be before or same as `config.endBlock`/); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..231e756ba --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/chain-indexing-status-snapshot.ts @@ -0,0 +1,216 @@ +import { z } from "zod/v4"; +import type { ParsePayload } from "zod/v4/core"; + +import * as blockRef from "../../../shared/block-ref"; +import { makeBlockRefSchema } from "../../../shared/zod-schemas"; +import { + ChainIndexingConfigTypeIds, + ChainIndexingStatusIds, + type ChainIndexingStatusSnapshotBackfill, + type ChainIndexingStatusSnapshotCompleted, + type ChainIndexingStatusSnapshotFollowing, + type ChainIndexingStatusSnapshotQueued, +} from "../chain-indexing-status-snapshot"; + +/** + * Invariants for chain snapshot in 'queued' status: + * - `config.endBlock` (if set) is after `config.startBlock`. + */ +export function invariant_chainSnapshotQueuedBlocks( + ctx: ParsePayload, +) { + const { config } = ctx.value; + + // The `config.endBlock` does not exists for `indefinite` config type + if (config.configType === ChainIndexingConfigTypeIds.Indefinite) { + // invariant holds + return; + } + + if (config.endBlock && blockRef.isBeforeOrEqualTo(config.startBlock, config.endBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`config.startBlock` must be before or same as `config.endBlock`.", + }); + } +} + +/** + * Invariants for chain snapshot in 'backfill' status: + * - `config.startBlock` is before or same as `latestIndexedBlock`. + * - `latestIndexedBlock` is before or same as `backfillEndBlock`. + * - `backfillEndBlock` is the same as `config.endBlock` (if set). + */ +export function invariant_chainSnapshotBackfillBlocks( + ctx: ParsePayload, +) { + const { config, latestIndexedBlock, backfillEndBlock } = ctx.value; + + if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", + }); + } + + if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, backfillEndBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`latestIndexedBlock` must be before or same as `backfillEndBlock`.", + }); + } + + // The `config.endBlock` does not exists for `indefinite` config type + if (config.configType === ChainIndexingConfigTypeIds.Indefinite) { + // invariant holds + return; + } + + if (config.endBlock && blockRef.isEqualTo(backfillEndBlock, config.endBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`backfillEndBlock` must be the same as `config.endBlock`.", + }); + } +} + +/** + * Invariants for chain snapshot in 'completed' status: + * - `config.startBlock` is before or same as `latestIndexedBlock`. + * - `latestIndexedBlock` is before or same as `config.endBlock`. + */ +export function invariant_chainSnapshotCompletedBlocks( + ctx: ParsePayload, +) { + const { config, latestIndexedBlock } = ctx.value; + + if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", + }); + } + + if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, config.endBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`latestIndexedBlock` must be before or same as `config.endBlock`.", + }); + } +} + +/** + * Invariants for chain snapshot in 'following' status: + * - `config.startBlock` is before or same as `latestIndexedBlock`. + * - `latestIndexedBlock` is before or same as `latestKnownBlock`. + */ +export function invariant_chainSnapshotFollowingBlocks( + ctx: ParsePayload, +) { + const { config, latestIndexedBlock, latestKnownBlock } = ctx.value; + + if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", + }); + } + + if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, latestKnownBlock) === false) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "`latestIndexedBlock` must be before or same as `latestKnownBlock`.", + }); + } +} + +/** + * Makes Zod schema for {@link ChainIndexingConfig} type. + */ +export const makeChainIndexingConfigSchema = (valueLabel: string = "Value") => + z.discriminatedUnion("configType", [ + z.object({ + configType: z.literal(ChainIndexingConfigTypeIds.Indefinite), + startBlock: makeBlockRefSchema(valueLabel), + }), + z.object({ + configType: z.literal(ChainIndexingConfigTypeIds.Definite), + startBlock: makeBlockRefSchema(valueLabel), + endBlock: makeBlockRefSchema(valueLabel), + }), + ]); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshotQueued} type. + */ +export const makeChainIndexingStatusSnapshotQueuedSchema = (valueLabel: string = "Value") => + z + .object({ + chainStatus: z.literal(ChainIndexingStatusIds.Queued), + config: makeChainIndexingConfigSchema(valueLabel), + }) + .check(invariant_chainSnapshotQueuedBlocks); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshotBackfill} type. + */ +export const makeChainIndexingStatusSnapshotBackfillSchema = (valueLabel: string = "Value") => + z + .object({ + chainStatus: z.literal(ChainIndexingStatusIds.Backfill), + config: makeChainIndexingConfigSchema(valueLabel), + latestIndexedBlock: makeBlockRefSchema(valueLabel), + backfillEndBlock: makeBlockRefSchema(valueLabel), + }) + .check(invariant_chainSnapshotBackfillBlocks); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshotCompleted} type. + */ +export const makeChainIndexingStatusSnapshotCompletedSchema = (valueLabel: string = "Value") => + z + .object({ + chainStatus: z.literal(ChainIndexingStatusIds.Completed), + config: z.object({ + configType: z.literal(ChainIndexingConfigTypeIds.Definite), + startBlock: makeBlockRefSchema(valueLabel), + endBlock: makeBlockRefSchema(valueLabel), + }), + latestIndexedBlock: makeBlockRefSchema(valueLabel), + }) + .check(invariant_chainSnapshotCompletedBlocks); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshotFollowing} type. + */ +export const makeChainIndexingStatusSnapshotFollowingSchema = (valueLabel: string = "Value") => + z + .object({ + chainStatus: z.literal(ChainIndexingStatusIds.Following), + config: z.object({ + configType: z.literal(ChainIndexingConfigTypeIds.Indefinite), + startBlock: makeBlockRefSchema(valueLabel), + }), + latestIndexedBlock: makeBlockRefSchema(valueLabel), + latestKnownBlock: makeBlockRefSchema(valueLabel), + }) + .check(invariant_chainSnapshotFollowingBlocks); + +/** + * Makes Zod schema for {@link ChainIndexingStatusSnapshot} + */ +export const makeChainIndexingStatusSnapshotSchema = (valueLabel: string = "Value") => + z.discriminatedUnion("chainStatus", [ + makeChainIndexingStatusSnapshotQueuedSchema(valueLabel), + makeChainIndexingStatusSnapshotBackfillSchema(valueLabel), + makeChainIndexingStatusSnapshotCompletedSchema(valueLabel), + makeChainIndexingStatusSnapshotFollowingSchema(valueLabel), + ]); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/cross-chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/cross-chain-indexing-status-snapshot.ts new file mode 100644 index 000000000..480f25b37 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/cross-chain-indexing-status-snapshot.ts @@ -0,0 +1,137 @@ +import { z } from "zod/v4"; +import type { ParsePayload } from "zod/v4/core"; + +import { makeUnixTimestampSchema } from "../../../shared/zod-schemas"; +import { + ChainIndexingConfigTypeIds, + ChainIndexingStatusIds, +} from "../chain-indexing-status-snapshot"; +import { + type CrossChainIndexingStatusSnapshotOmnichain, + CrossChainIndexingStrategyIds, +} from "../cross-chain-indexing-status-snapshot"; +import type { SerializedCrossChainIndexingStatusSnapshot } from "../serialize/cross-chain-indexing-status-snapshot"; +import { + buildUnvalidatedOmnichainIndexingStatusSnapshot, + makeOmnichainIndexingStatusSnapshotSchema, + makeSerializedOmnichainIndexingStatusSnapshotSchema, +} from "./omnichian-indexing-status-snapshot"; + +/** + * Invariant: for cross-chain indexing status snapshot omnichain, + * slowestChainIndexingCursor equals to omnichainSnapshot.omnichainIndexingCursor + */ +export function invariant_slowestChainEqualsToOmnichainSnapshotTime( + ctx: ParsePayload, +) { + const { slowestChainIndexingCursor, omnichainSnapshot } = ctx.value; + const { omnichainIndexingCursor } = omnichainSnapshot; + + if (slowestChainIndexingCursor !== omnichainIndexingCursor) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'slowestChainIndexingCursor' must be equal to 'omnichainSnapshot.omnichainIndexingCursor'`, + }); + } +} + +/** + * Invariant: for cross-chain indexing status snapshot omnichain, + * snapshotTime is greater than or equal to the "highest known block" timestamp. + */ +export function invariant_snapshotTimeIsTheHighestKnownBlockTimestamp( + ctx: ParsePayload, +) { + const { snapshotTime, omnichainSnapshot } = ctx.value; + const chains = Array.from(omnichainSnapshot.chains.values()); + + const startBlockTimestamps = chains.map((chain) => chain.config.startBlock.timestamp); + + const endBlockTimestamps = chains + .map((chain) => chain.config) + .filter((chainConfig) => chainConfig.configType === ChainIndexingConfigTypeIds.Definite) + .map((chainConfig) => chainConfig.endBlock.timestamp); + + const backfillEndBlockTimestamps = chains + .filter((chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill) + .map((chain) => chain.backfillEndBlock.timestamp); + + const latestKnownBlockTimestamps = chains + .filter((chain) => chain.chainStatus === ChainIndexingStatusIds.Following) + .map((chain) => chain.latestKnownBlock.timestamp); + + const highestKnownBlockTimestamp = Math.max( + ...startBlockTimestamps, + ...endBlockTimestamps, + ...backfillEndBlockTimestamps, + ...latestKnownBlockTimestamps, + ); + + if (snapshotTime < highestKnownBlockTimestamp) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'snapshotTime' (${snapshotTime}) must be greater than or equal to the "highest known block timestamp" (${highestKnownBlockTimestamp})`, + }); + } +} + +/** + * Makes Zod schema for {@link CrossChainIndexingStatusSnapshotOmnichain} + */ +const makeCrossChainIndexingStatusSnapshotOmnichainSchema = ( + valueLabel: string = "Cross-chain Indexing Status Snapshot Omnichain", +) => + z + .object({ + strategy: z.literal(CrossChainIndexingStrategyIds.Omnichain), + slowestChainIndexingCursor: makeUnixTimestampSchema(valueLabel), + snapshotTime: makeUnixTimestampSchema(valueLabel), + omnichainSnapshot: makeOmnichainIndexingStatusSnapshotSchema(valueLabel), + }) + .check(invariant_slowestChainEqualsToOmnichainSnapshotTime) + .check(invariant_snapshotTimeIsTheHighestKnownBlockTimestamp); + +/** + * Makes Zod schema for {@link CrossChainIndexingStatusSnapshot} + */ +export const makeCrossChainIndexingStatusSnapshotSchema = ( + valueLabel: string = "Cross-chain Indexing Status Snapshot", +) => + z.discriminatedUnion("strategy", [ + makeCrossChainIndexingStatusSnapshotOmnichainSchema(valueLabel), + ]); + +/** + * Makes Zod schema for {@link SerializedCrossChainIndexingStatusSnapshot} + */ +export const makeSerializedCrossChainIndexingStatusSnapshotSchema = ( + valueLabel: string = "Cross-chain Indexing Status Snapshot", +) => + z.object({ + strategy: z.enum(CrossChainIndexingStrategyIds), + slowestChainIndexingCursor: makeUnixTimestampSchema(valueLabel), + snapshotTime: makeUnixTimestampSchema(valueLabel), + omnichainSnapshot: makeSerializedOmnichainIndexingStatusSnapshotSchema(valueLabel), + }); + +/** + * Build unvalidated cross-chain indexing status snapshot to be validated. + * + * Return type is intentionally "unknown" to enforce validation by + * {@link makeCrossChainIndexingStatusSnapshotSchema} call. + */ +export function buildUnvalidatedCrossChainIndexingStatusSnapshot( + serializedCrossChainIndexingStatusSnapshot: SerializedCrossChainIndexingStatusSnapshot, +): unknown { + const { strategy, slowestChainIndexingCursor, snapshotTime, omnichainSnapshot } = + serializedCrossChainIndexingStatusSnapshot; + + return { + strategy, + slowestChainIndexingCursor, + snapshotTime, + omnichainSnapshot: buildUnvalidatedOmnichainIndexingStatusSnapshot(omnichainSnapshot), + }; +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/omnichian-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/omnichian-indexing-status-snapshot.ts new file mode 100644 index 000000000..10a26cbf8 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/omnichian-indexing-status-snapshot.ts @@ -0,0 +1,467 @@ +import { z } from "zod/v4"; +import type { ParsePayload } from "zod/v4/core"; + +import { deserializeChainId } from "../../../shared/deserialize"; +import type { ChainIdString } from "../../../shared/serialized-types"; +import type { ChainId } from "../../../shared/types"; +import { + makeChainIdSchema, + makeChainIdStringSchema, + makeUnixTimestampSchema, +} from "../../../shared/zod-schemas"; +import { + ChainIndexingStatusIds, + type ChainIndexingStatusSnapshot, + type ChainIndexingStatusSnapshotCompleted, + type ChainIndexingStatusSnapshotQueued, +} from "../chain-indexing-status-snapshot"; +import { + type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill, + checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill, + checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted, + checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing, + checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted, + getOmnichainIndexingStatus, + OmnichainIndexingStatusIds, + type OmnichainIndexingStatusSnapshot, + type OmnichainIndexingStatusSnapshotFollowing, +} from "../omnichain-indexing-status-snapshot"; +import type { SerializedChainIndexingStatusSnapshot } from "../serialize/chain-indexing-status-snapshot"; +import type { SerializedOmnichainIndexingStatusSnapshot } from "../serialize/omnichain-indexing-status-snapshot"; +import { makeChainIndexingStatusSnapshotSchema } from "./chain-indexing-status-snapshot"; + +/** + * Invariant: For omnichain snapshot, + * `omnichainStatus` is set based on the snapshots of individual chains. + */ +export function invariant_omnichainSnapshotStatusIsConsistentWithChainSnapshot( + ctx: ParsePayload, +) { + const snapshot = ctx.value; + const chains = Array.from(snapshot.chains.values()); + const expectedOmnichainStatus = getOmnichainIndexingStatus(chains); + const actualOmnichainStatus = snapshot.omnichainStatus; + + if (expectedOmnichainStatus !== actualOmnichainStatus) { + ctx.issues.push({ + code: "custom", + input: snapshot, + message: `'${actualOmnichainStatus}' is an invalid omnichainStatus. Expected '${expectedOmnichainStatus}' based on the statuses of individual chains.`, + }); + } +} + +/** + * Invariant: For omnichain status snapshot, + * `omnichainIndexingCursor` is lower than the earliest start block + * across all queued chains. + * + * Note: if there are no queued chains, the invariant holds. + */ +export function invariant_omnichainIndexingCursorLowerThanEarliestStartBlockAcrossQueuedChains( + ctx: ParsePayload, +) { + const snapshot = ctx.value; + const queuedChains = Array.from(snapshot.chains.values()).filter( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Queued, + ); + + // there are no queued chains + if (queuedChains.length === 0) { + // the invariant holds + return; + } + + const queuedChainStartBlocks = queuedChains.map((chain) => chain.config.startBlock.timestamp); + const queuedChainEarliestStartBlock = Math.min(...queuedChainStartBlocks); + + // there are queued chains + // the invariant holds if the omnichain indexing cursor is lower than + // the earliest start block across all queued chains + if (snapshot.omnichainIndexingCursor >= queuedChainEarliestStartBlock) { + ctx.issues.push({ + code: "custom", + input: snapshot, + message: + "`omnichainIndexingCursor` must be lower than the earliest start block across all queued chains.", + }); + } +} + +/** + * Invariant: For omnichain status snapshot, + * `omnichainIndexingCursor` is lower than or equal to + * the highest `backfillEndBlock` across all backfill chains. + * + * Note: if there are no backfill chains, the invariant holds. + */ +export function invariant_omnichainIndexingCursorLowerThanOrEqualToLatestBackfillEndBlockAcrossBackfillChains( + ctx: ParsePayload, +) { + const snapshot = ctx.value; + const backfillChains = Array.from(snapshot.chains.values()).filter( + (chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill, + ); + + // there are no backfill chains + if (backfillChains.length === 0) { + // the invariant holds + return; + } + + const backfillEndBlocks = backfillChains.map((chain) => chain.backfillEndBlock.timestamp); + const highestBackfillEndBlock = Math.max(...backfillEndBlocks); + + // there are backfill chains + // the invariant holds if the omnichainIndexingCursor is lower than or + // equal to the highest backfillEndBlock across all backfill chains. + if (snapshot.omnichainIndexingCursor > highestBackfillEndBlock) { + ctx.issues.push({ + code: "custom", + input: snapshot, + message: + "`omnichainIndexingCursor` must be lower than or equal to the highest `backfillEndBlock` across all backfill chains.", + }); + } +} + +/** + * Invariant: For omnichain status snapshot, + * `omnichainIndexingCursor` is same as the highest latestIndexedBlock + * across all indexed chains. + * + * Note: if there are no indexed chains, the invariant holds. + */ +export function invariant_omnichainIndexingCursorIsEqualToHighestLatestIndexedBlockAcrossIndexedChain( + ctx: ParsePayload, +) { + const snapshot = ctx.value; + const indexedChains = Array.from(snapshot.chains.values()).filter( + (chain) => + chain.chainStatus === ChainIndexingStatusIds.Backfill || + chain.chainStatus === ChainIndexingStatusIds.Completed || + chain.chainStatus === ChainIndexingStatusIds.Following, + ); + + // there are no indexed chains + if (indexedChains.length === 0) { + // the invariant holds + return; + } + + const indexedChainLatestIndexedBlocks = indexedChains.map( + (chain) => chain.latestIndexedBlock.timestamp, + ); + const indexedChainHighestLatestIndexedBlock = Math.max(...indexedChainLatestIndexedBlocks); + + // there are indexed chains + // the invariant holds if the omnichain indexing cursor is same as + // the highest latestIndexedBlock across all indexed chains + if (snapshot.omnichainIndexingCursor !== indexedChainHighestLatestIndexedBlock) { + ctx.issues.push({ + code: "custom", + input: snapshot, + message: + "`omnichainIndexingCursor` must be same as the highest `latestIndexedBlock` across all indexed chains.", + }); + } +} + +/** + * Invariant: For omnichain status snapshot 'unstarted', + * all chains must have "queued" status. + */ +export function invariant_omnichainSnapshotUnstartedHasValidChains( + ctx: ParsePayload>, +) { + const chains = ctx.value; + const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted( + Array.from(chains.values()), + ); + + if (hasValidChains === false) { + ctx.issues.push({ + code: "custom", + input: chains, + message: `For omnichain status snapshot 'unstarted', all chains must have "queued" status.`, + }); + } +} + +/** + * Invariant: For omnichain status snapshot 'backfill', + * at least one chain must be in "backfill" status and + * each chain has to have a status of either "queued", "backfill" + * or "completed". + */ +export function invariant_omnichainStatusSnapshotBackfillHasValidChains( + ctx: ParsePayload>, +) { + const chains = ctx.value; + const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill( + Array.from(chains.values()), + ); + + if (hasValidChains === false) { + ctx.issues.push({ + code: "custom", + input: chains, + message: `For omnichain status snapshot 'backfill', at least one chain must be in "backfill" status and each chain has to have a status of either "queued", "backfill" or "completed".`, + }); + } +} + +/** + * Invariant: For omnichain status snapshot 'completed', + * all chains must have "completed" status. + */ +export function invariant_omnichainStatusSnapshotCompletedHasValidChains( + ctx: ParsePayload>, +) { + const chains = ctx.value; + const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted( + Array.from(chains.values()), + ); + + if (hasValidChains === false) { + ctx.issues.push({ + code: "custom", + input: chains, + message: `For omnichain status snapshot 'completed', all chains must have "completed" status.`, + }); + } +} + +/** + * Invariant: For omnichain status snapshot 'following', + * at least one chain must be in 'following' status. + */ +export function invariant_omnichainStatusSnapshotFollowingHasValidChains( + ctx: ParsePayload>, +) { + const chains = ctx.value; + const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing( + Array.from(chains.values()), + ); + + if (hasValidChains === false) { + ctx.issues.push({ + code: "custom", + input: chains, + message: "For omnichainStatus 'following', at least one chain must be in 'following' status.", + }); + } +} + +export const makeChainIndexingStatusSnapshotsSchema = (valueLabel: string = "Value") => + z.map(makeChainIdSchema(), makeChainIndexingStatusSnapshotSchema(valueLabel), { + error: + "Chains indexing statuses must be a Map with ChainId as keys and ChainIndexingStatusSnapshot as values.", + }); + +/** + * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotUnstarted} + */ +const makeOmnichainIndexingStatusSnapshotUnstartedSchema = (valueLabel?: string) => + z.object({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Unstarted), + chains: makeChainIndexingStatusSnapshotsSchema(valueLabel) + .check(invariant_omnichainSnapshotUnstartedHasValidChains) + .transform((chains) => chains as Map), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotBackfill} + */ +const makeOmnichainIndexingStatusSnapshotBackfillSchema = (valueLabel?: string) => + z.object({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Backfill), + chains: makeChainIndexingStatusSnapshotsSchema(valueLabel) + .check(invariant_omnichainStatusSnapshotBackfillHasValidChains) + .transform( + (chains) => + chains as Map< + ChainId, + ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill + >, + ), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotCompleted} + */ +const makeOmnichainIndexingStatusSnapshotCompletedSchema = (valueLabel?: string) => + z.object({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Completed), + chains: makeChainIndexingStatusSnapshotsSchema(valueLabel) + .check(invariant_omnichainStatusSnapshotCompletedHasValidChains) + .transform((chains) => chains as Map), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotFollowing} + */ +const makeOmnichainIndexingStatusSnapshotFollowingSchema = (valueLabel?: string) => + z.object({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Following), + chains: makeChainIndexingStatusSnapshotsSchema(valueLabel).check( + invariant_omnichainStatusSnapshotFollowingHasValidChains, + ), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Omnichain Indexing Snapshot Schema + * + * Makes a Zod schema definition for validating indexing snapshot + * across all chains indexed by ENSIndexer instance. + */ +export const makeOmnichainIndexingStatusSnapshotSchema = ( + valueLabel: string = "Omnichain Indexing Snapshot", +) => + z + .discriminatedUnion("omnichainStatus", [ + makeOmnichainIndexingStatusSnapshotUnstartedSchema(valueLabel), + makeOmnichainIndexingStatusSnapshotBackfillSchema(valueLabel), + makeOmnichainIndexingStatusSnapshotCompletedSchema(valueLabel), + makeOmnichainIndexingStatusSnapshotFollowingSchema(valueLabel), + ]) + .check(invariant_omnichainSnapshotStatusIsConsistentWithChainSnapshot) + .check(invariant_omnichainIndexingCursorLowerThanEarliestStartBlockAcrossQueuedChains) + .check( + invariant_omnichainIndexingCursorLowerThanOrEqualToLatestBackfillEndBlockAcrossBackfillChains, + ) + .check(invariant_omnichainIndexingCursorIsEqualToHighestLatestIndexedBlockAcrossIndexedChain); + +const makeSerializedChainIndexingStatusSnapshotsSchema = (valueLabel: string = "Value") => + z.record(makeChainIdStringSchema(), makeChainIndexingStatusSnapshotSchema(valueLabel)); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotUnstarted} + */ +const makeSerializedOmnichainIndexingStatusSnapshotUnstartedSchema = (valueLabel?: string) => + z.object({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Unstarted), + chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotBackfill} + */ +const makeSerializedOmnichainIndexingStatusSnapshotBackfillSchema = (valueLabel?: string) => + z.object({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Backfill), + chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotCompleted} + */ +const makeSerializedOmnichainIndexingStatusSnapshotCompletedSchema = (valueLabel?: string) => + z.object({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Completed), + chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshotFollowing} + */ +const makeSerializedOmnichainIndexingStatusSnapshotFollowingSchema = (valueLabel?: string) => + z.object({ + omnichainStatus: z.literal(OmnichainIndexingStatusIds.Following), + chains: makeSerializedChainIndexingStatusSnapshotsSchema(valueLabel), + omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), + }); + +/** + * Makes Zod schema for {@link SerializedOmnichainIndexingStatusSnapshot}. + */ +export const makeSerializedOmnichainIndexingStatusSnapshotSchema = (valueLabel: string = "Value") => + z.discriminatedUnion("omnichainStatus", [ + makeSerializedOmnichainIndexingStatusSnapshotUnstartedSchema(valueLabel), + makeSerializedOmnichainIndexingStatusSnapshotBackfillSchema(valueLabel), + makeSerializedOmnichainIndexingStatusSnapshotCompletedSchema(valueLabel), + makeSerializedOmnichainIndexingStatusSnapshotFollowingSchema(valueLabel), + ]); + +/** + * Build unvalidated chain indexing statuses map to be validated by + * {@link makeChainIndexingStatusSnapshotsSchema} call. + */ +export function buildUnvalidatedChainIndexingStatuses( + serializedChainIndexingStatuses: Record, +): Map { + const chainIndexingStatuses = new Map(); + + for (const [serializedChainId, chainIndexingSnapshot] of Object.entries( + serializedChainIndexingStatuses, + )) { + const chainId = deserializeChainId(serializedChainId); + + chainIndexingStatuses.set(chainId, chainIndexingSnapshot); + } + + return chainIndexingStatuses; +} + +/** + * Build unvalidated omnichain indexing status snapshot to be validated. + * + * Return type is intentionally "unknown" to enforce validation by + * {@link makeOmnichainIndexingStatusSnapshotSchema} call. + */ +export function buildUnvalidatedOmnichainIndexingStatusSnapshot( + serializedOmnichainIndexingStatusSnapshot: SerializedOmnichainIndexingStatusSnapshot, +): unknown { + const { omnichainStatus, chains, omnichainIndexingCursor } = + serializedOmnichainIndexingStatusSnapshot; + + switch (omnichainStatus) { + case OmnichainIndexingStatusIds.Unstarted: { + return { + omnichainStatus, + chains: buildUnvalidatedChainIndexingStatuses(chains) as Map< + ChainId, + ChainIndexingStatusSnapshotQueued + >, + omnichainIndexingCursor, + }; + } + + case OmnichainIndexingStatusIds.Backfill: { + return { + omnichainStatus, + chains: buildUnvalidatedChainIndexingStatuses(chains) as Map< + ChainId, + ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill + >, + omnichainIndexingCursor, + }; + } + + case OmnichainIndexingStatusIds.Following: { + return { + omnichainStatus, + chains: buildUnvalidatedChainIndexingStatuses(chains), + omnichainIndexingCursor, + }; + } + + case OmnichainIndexingStatusIds.Completed: { + return { + omnichainStatus, + chains: buildUnvalidatedChainIndexingStatuses(chains) as Map< + ChainId, + ChainIndexingStatusSnapshotCompleted + >, + omnichainIndexingCursor, + }; + } + } +} diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/realtime-indexing-status-projection.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/realtime-indexing-status-projection.ts new file mode 100644 index 000000000..cb231d2e7 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/realtime-indexing-status-projection.ts @@ -0,0 +1,77 @@ +import { z } from "zod/v4"; +import type { ParsePayload } from "zod/v4/core"; + +import { makeDurationSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; +import type { RealtimeIndexingStatusProjection } from "../realtime-indexing-status-projection"; +import { makeCrossChainIndexingStatusSnapshotSchema } from "../schema/cross-chain-indexing-status-snapshot"; +import { makeSerializedCrossChainIndexingStatusSnapshotSchema } from "./cross-chain-indexing-status-snapshot"; + +/** + * Invariant: For realtime indexing status projection, + * `projectedAt` is after or same as `snapshot.snapshotTime`. + */ +export function invariant_realtimeIndexingStatusProjectionProjectedAtIsAfterOrEqualToSnapshotTime( + ctx: ParsePayload, +) { + const projection = ctx.value; + + const { snapshot, projectedAt } = projection; + + if (snapshot.snapshotTime > projectedAt) { + ctx.issues.push({ + code: "custom", + input: projection, + message: "`projectedAt` must be after or same as `snapshot.snapshotTime`.", + }); + } +} + +/** + * Invariant: For realtime indexing status projection, + * `worstCaseDistance` is the difference between `projectedAt` + * and `omnichainIndexingCursor`. + */ +export function invariant_realtimeIndexingStatusProjectionWorstCaseDistanceIsCorrect( + ctx: ParsePayload, +) { + const projection = ctx.value; + const { projectedAt, snapshot, worstCaseDistance } = projection; + const { omnichainSnapshot } = snapshot; + const expectedWorstCaseDistance = projectedAt - omnichainSnapshot.omnichainIndexingCursor; + + if (worstCaseDistance !== expectedWorstCaseDistance) { + ctx.issues.push({ + code: "custom", + input: projection, + message: + "`worstCaseDistance` must be the exact difference between `projectedAt` and `snapshot.omnichainIndexingCursor`.", + }); + } +} + +/** + * Makes Zod schema for {@link RealtimeIndexingStatusProjection} + */ +export const makeRealtimeIndexingStatusProjectionSchema = ( + valueLabel: string = "Realtime Indexing Status Projection", +) => + z + .object({ + projectedAt: makeUnixTimestampSchema(valueLabel), + worstCaseDistance: makeDurationSchema(valueLabel), + snapshot: makeCrossChainIndexingStatusSnapshotSchema(valueLabel), + }) + .check(invariant_realtimeIndexingStatusProjectionProjectedAtIsAfterOrEqualToSnapshotTime) + .check(invariant_realtimeIndexingStatusProjectionWorstCaseDistanceIsCorrect); + +/** + * Makes Zod schema for {@link SerializedRealtimeIndexingStatusProjection}. + */ +export const makeSerializedRealtimeIndexingStatusProjectionSchema = ( + valueLabel: string = "Value", +) => + z.object({ + snapshot: makeSerializedCrossChainIndexingStatusSnapshotSchema(valueLabel), + projectedAt: makeUnixTimestampSchema(valueLabel), + worstCaseDistance: makeDurationSchema(valueLabel), + }); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.test.ts deleted file mode 100644 index f37bbb61d..000000000 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/serialize/chain-indexing-status-snapshot.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { prettifyError, type ZodSafeParseResult } from "zod/v4"; - -import { - earlierBlockRef, - earliestBlockRef, - laterBlockRef, - latestBlockRef, -} from "../block-refs.mock"; -import { - ChainIndexingConfigTypeIds, - ChainIndexingStatusIds, - type ChainIndexingStatusSnapshot, - type ChainIndexingStatusSnapshotBackfill, - type ChainIndexingStatusSnapshotCompleted, - type ChainIndexingStatusSnapshotFollowing, - type ChainIndexingStatusSnapshotQueued, -} from "../chain-indexing-status-snapshot"; -import { makeChainIndexingStatusSnapshotSchema } from "../validate/chain-indexing-status-snapshot"; - -describe("ENSIndexer: Indexing Status", () => { - describe("Zod Schemas", () => { - const formatParseError = (zodParseError: ZodSafeParseResult) => - prettifyError(zodParseError.error!); - - describe("ChainIndexingStatusSnapshotQueued", () => { - it("can parse a valid serialized status object", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Queued, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: laterBlockRef, - }, - } satisfies ChainIndexingStatusSnapshotQueued; - - // act - const parsed = makeChainIndexingStatusSnapshotSchema().parse(serialized); - - // assert - expect(parsed).toStrictEqual({ - chainStatus: ChainIndexingStatusIds.Queued, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: laterBlockRef, - }, - } satisfies ChainIndexingStatusSnapshotQueued); - }); - - it("won't parse if the config.startBlock is after the config.endBlock", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Queued, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: laterBlockRef, - endBlock: earlierBlockRef, - }, - } satisfies ChainIndexingStatusSnapshotQueued; - - // act - const notParsed = formatParseError( - makeChainIndexingStatusSnapshotSchema().safeParse(serialized), - ); - - // assert - expect(notParsed).toMatch( - /`config.startBlock` must be before or same as `config.endBlock`/i, - ); - }); - }); - - describe("ChainIndexingStatusSnapshotBackfill", () => { - it("can parse a valid serialized status object", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Backfill, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: latestBlockRef, - }, - latestIndexedBlock: earlierBlockRef, - backfillEndBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotBackfill; - - // act - const parsed = makeChainIndexingStatusSnapshotSchema().parse(serialized); - - // assert - expect(parsed).toStrictEqual({ - chainStatus: ChainIndexingStatusIds.Backfill, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: latestBlockRef, - }, - latestIndexedBlock: earlierBlockRef, - backfillEndBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotBackfill); - }); - - it("won't parse if the config.startBlock is after the latestIndexedBlock", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Backfill, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: earliestBlockRef, - backfillEndBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotBackfill; - - // act - const notParsed = formatParseError( - makeChainIndexingStatusSnapshotSchema().safeParse(serialized), - ); - - // assert - expect(notParsed).toMatch( - /`config.startBlock` must be before or same as `latestIndexedBlock`/, - ); - }); - - it("won't parse if the latestIndexedBlock is after the backfillEndBlock", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Backfill, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: latestBlockRef, - backfillEndBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotBackfill; - - // act - const notParsed = formatParseError( - makeChainIndexingStatusSnapshotSchema().safeParse(serialized), - ); - - // assert - expect(notParsed).toMatch( - /`latestIndexedBlock` must be before or same as `backfillEndBlock`/, - ); - }); - - it("won't parse if the backfillEndBlock different than the config.endBlock", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Backfill, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: latestBlockRef, - backfillEndBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotBackfill; - - // act - const notParsed = formatParseError( - makeChainIndexingStatusSnapshotSchema().safeParse(serialized), - ); - - // assert - expect(notParsed).toMatch(/`backfillEndBlock` must be the same as `config.endBlock`/); - }); - }); - - describe("ChainIndexingStatusSnapshotFollowing", () => { - it("can parse a valid serialized status object", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Following, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: earlierBlockRef, - }, - latestIndexedBlock: laterBlockRef, - latestKnownBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotFollowing; - - // act - const parsed = makeChainIndexingStatusSnapshotSchema().parse(serialized); - - // assert - expect(parsed).toStrictEqual({ - chainStatus: ChainIndexingStatusIds.Following, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: earlierBlockRef, - }, - latestIndexedBlock: laterBlockRef, - latestKnownBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotFollowing); - }); - - it("won't parse if the config.startBlock is after the latestIndexedBlock", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Following, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: laterBlockRef, - }, - latestIndexedBlock: earlierBlockRef, - latestKnownBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotFollowing; - - // act - const notParsed = formatParseError( - makeChainIndexingStatusSnapshotSchema().safeParse(serialized), - ); - - // assert - expect(notParsed).toMatch( - /`config.startBlock` must be before or same as `latestIndexedBlock`/, - ); - }); - - it("won't parse if the latestIndexedBlock is after the latestKnownBlock", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Following, - config: { - configType: ChainIndexingConfigTypeIds.Indefinite, - startBlock: earlierBlockRef, - }, - latestIndexedBlock: latestBlockRef, - latestKnownBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotFollowing; - - // act - const notParsed = formatParseError( - makeChainIndexingStatusSnapshotSchema().safeParse(serialized), - ); - - // assert - expect(notParsed).toMatch( - /`latestIndexedBlock` must be before or same as `latestKnownBlock`/, - ); - }); - }); - - describe("ChainIndexingStatusSnapshotCompleted", () => { - it("can parse a valid serialized status object", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted; - - // act - const parsed = makeChainIndexingStatusSnapshotSchema().parse(serialized); - - // assert - expect(parsed).toStrictEqual({ - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted); - }); - - it("won't parse if the config.startBlock is after the latestIndexedBlock", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: latestBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: laterBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted; - - // act - const notParsed = formatParseError( - makeChainIndexingStatusSnapshotSchema().safeParse(serialized), - ); - - // assert - expect(notParsed).toMatch( - /`config.startBlock` must be before or same as `latestIndexedBlock`/, - ); - }); - - it("won't parse if the latestIndexedBlock is after the config.endBlock", () => { - // arrange - const serialized: ChainIndexingStatusSnapshot = { - chainStatus: ChainIndexingStatusIds.Completed, - config: { - configType: ChainIndexingConfigTypeIds.Definite, - startBlock: earlierBlockRef, - endBlock: laterBlockRef, - }, - latestIndexedBlock: latestBlockRef, - } satisfies ChainIndexingStatusSnapshotCompleted; - - // act - const notParsed = formatParseError( - makeChainIndexingStatusSnapshotSchema().safeParse(serialized), - ); - - // assert - expect(notParsed).toMatch( - /`latestIndexedBlock` must be before or same as `config.endBlock`/, - ); - }); - }); - }); -}); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts index c6e2e24af..9277d10a2 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/chain-indexing-status-snapshot.ts @@ -1,137 +1,7 @@ -import z, { prettifyError } from "zod/v4"; -import type { ParsePayload } from "zod/v4/core"; +import { prettifyError } from "zod/v4"; -import * as blockRef from "../../../shared/block-ref"; -import { makeBlockRefSchema } from "../../../shared/zod-schemas"; -import { - ChainIndexingConfig, - ChainIndexingConfigTypeIds, - ChainIndexingStatusIds, - type ChainIndexingStatusSnapshot, - type ChainIndexingStatusSnapshotBackfill, - type ChainIndexingStatusSnapshotCompleted, - type ChainIndexingStatusSnapshotFollowing, - type ChainIndexingStatusSnapshotQueued, -} from "../chain-indexing-status-snapshot"; -/** - * Invariants for chain snapshot in 'queued' status: - * - `config.endBlock` (if set) is after `config.startBlock`. - */ -export function invariant_chainSnapshotQueuedBlocks( - ctx: ParsePayload, -) { - const { config } = ctx.value; - - // The `config.endBlock` does not exists for `indefinite` config type - if (config.configType === ChainIndexingConfigTypeIds.Indefinite) { - // invariant holds - return; - } - - if (config.endBlock && blockRef.isBeforeOrEqualTo(config.startBlock, config.endBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`config.startBlock` must be before or same as `config.endBlock`.", - }); - } -} - -/** - * Invariants for chain snapshot in 'backfill' status: - * - `config.startBlock` is before or same as `latestIndexedBlock`. - * - `latestIndexedBlock` is before or same as `backfillEndBlock`. - * - `backfillEndBlock` is the same as `config.endBlock` (if set). - */ -export function invariant_chainSnapshotBackfillBlocks( - ctx: ParsePayload, -) { - const { config, latestIndexedBlock, backfillEndBlock } = ctx.value; - - if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", - }); - } - - if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, backfillEndBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`latestIndexedBlock` must be before or same as `backfillEndBlock`.", - }); - } - - // The `config.endBlock` does not exists for `indefinite` config type - if (config.configType === ChainIndexingConfigTypeIds.Indefinite) { - // invariant holds - return; - } - - if (config.endBlock && blockRef.isEqualTo(backfillEndBlock, config.endBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`backfillEndBlock` must be the same as `config.endBlock`.", - }); - } -} - -/** - * Invariants for chain snapshot in 'completed' status: - * - `config.startBlock` is before or same as `latestIndexedBlock`. - * - `latestIndexedBlock` is before or same as `config.endBlock`. - */ -export function invariant_chainSnapshotCompletedBlocks( - ctx: ParsePayload, -) { - const { config, latestIndexedBlock } = ctx.value; - - if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", - }); - } - - if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, config.endBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`latestIndexedBlock` must be before or same as `config.endBlock`.", - }); - } -} - -/** - * Invariants for chain snapshot in 'following' status: - * - `config.startBlock` is before or same as `latestIndexedBlock`. - * - `latestIndexedBlock` is before or same as `latestKnownBlock`. - */ -export function invariant_chainSnapshotFollowingBlocks( - ctx: ParsePayload, -) { - const { config, latestIndexedBlock, latestKnownBlock } = ctx.value; - - if (blockRef.isBeforeOrEqualTo(config.startBlock, latestIndexedBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`config.startBlock` must be before or same as `latestIndexedBlock`.", - }); - } - - if (blockRef.isBeforeOrEqualTo(latestIndexedBlock, latestKnownBlock) === false) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: "`latestIndexedBlock` must be before or same as `latestKnownBlock`.", - }); - } -} +import type { ChainIndexingStatusSnapshot } from "../chain-indexing-status-snapshot"; +import { makeChainIndexingStatusSnapshotSchema } from "../schema/chain-indexing-status-snapshot"; /** * Validates a maybe {@link ChainIndexingStatusSnapshot} object. @@ -149,86 +19,3 @@ export function validateChainIndexingStatusSnapshot( return parsed.data; } - -/** - * Makes Zod schema for {@link ChainIndexingConfig} type. - */ -export const makeChainIndexingConfigSchema = (valueLabel: string = "Value") => - z.discriminatedUnion("configType", [ - z.strictObject({ - configType: z.literal(ChainIndexingConfigTypeIds.Indefinite), - startBlock: makeBlockRefSchema(valueLabel), - }), - z.strictObject({ - configType: z.literal(ChainIndexingConfigTypeIds.Definite), - startBlock: makeBlockRefSchema(valueLabel), - endBlock: makeBlockRefSchema(valueLabel), - }), - ]); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshotQueued} type. - */ -export const makeChainIndexingStatusSnapshotQueuedSchema = (valueLabel: string = "Value") => - z - .strictObject({ - chainStatus: z.literal(ChainIndexingStatusIds.Queued), - config: makeChainIndexingConfigSchema(valueLabel), - }) - .check(invariant_chainSnapshotQueuedBlocks); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshotBackfill} type. - */ -export const makeChainIndexingStatusSnapshotBackfillSchema = (valueLabel: string = "Value") => - z - .strictObject({ - chainStatus: z.literal(ChainIndexingStatusIds.Backfill), - config: makeChainIndexingConfigSchema(valueLabel), - latestIndexedBlock: makeBlockRefSchema(valueLabel), - backfillEndBlock: makeBlockRefSchema(valueLabel), - }) - .check(invariant_chainSnapshotBackfillBlocks); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshotCompleted} type. - */ -export const makeChainIndexingStatusSnapshotCompletedSchema = (valueLabel: string = "Value") => - z - .strictObject({ - chainStatus: z.literal(ChainIndexingStatusIds.Completed), - config: z.strictObject({ - configType: z.literal(ChainIndexingConfigTypeIds.Definite), - startBlock: makeBlockRefSchema(valueLabel), - endBlock: makeBlockRefSchema(valueLabel), - }), - latestIndexedBlock: makeBlockRefSchema(valueLabel), - }) - .check(invariant_chainSnapshotCompletedBlocks); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshotFollowing} type. - */ -export const makeChainIndexingStatusSnapshotFollowingSchema = (valueLabel: string = "Value") => - z - .strictObject({ - chainStatus: z.literal(ChainIndexingStatusIds.Following), - config: z.strictObject({ - configType: z.literal(ChainIndexingConfigTypeIds.Indefinite), - startBlock: makeBlockRefSchema(valueLabel), - }), - latestIndexedBlock: makeBlockRefSchema(valueLabel), - latestKnownBlock: makeBlockRefSchema(valueLabel), - }) - .check(invariant_chainSnapshotFollowingBlocks); - -/** - * Makes Zod schema for {@link ChainIndexingStatusSnapshot} - */ -export const makeChainIndexingStatusSnapshotSchema = (valueLabel: string = "Value") => - z.discriminatedUnion("chainStatus", [ - makeChainIndexingStatusSnapshotQueuedSchema(valueLabel), - makeChainIndexingStatusSnapshotBackfillSchema(valueLabel), - makeChainIndexingStatusSnapshotCompletedSchema(valueLabel), - makeChainIndexingStatusSnapshotFollowingSchema(valueLabel), - ]); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/cross-chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/cross-chain-indexing-status-snapshot.ts index 1299313c5..f74185df7 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/cross-chain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/cross-chain-indexing-status-snapshot.ts @@ -1,17 +1,7 @@ -import z, { prettifyError } from "zod/v4"; -import type { ParsePayload } from "zod/v4/core"; +import { prettifyError } from "zod/v4"; -import { makeUnixTimestampSchema } from "../../../shared/zod-schemas"; -import { - ChainIndexingConfigTypeIds, - ChainIndexingStatusIds, -} from "../chain-indexing-status-snapshot"; -import { - type CrossChainIndexingStatusSnapshot, - type CrossChainIndexingStatusSnapshotOmnichain, - CrossChainIndexingStrategyIds, -} from "../cross-chain-indexing-status-snapshot"; -import { makeOmnichainIndexingStatusSnapshotSchema } from "./omnichain-indexing-status-snapshot"; +import type { CrossChainIndexingStatusSnapshot } from "../cross-chain-indexing-status-snapshot"; +import { makeCrossChainIndexingStatusSnapshotSchema } from "../schema/cross-chain-indexing-status-snapshot"; /** * Validate an {@link CrossChainIndexingStatusSnapshot} object. @@ -28,93 +18,3 @@ export function validateCrossChainIndexingStatusSnapshot( return parsed.data; } - -/** - * Invariant: for cross-chain indexing status snapshot omnichain, - * slowestChainIndexingCursor equals to omnichainSnapshot.omnichainIndexingCursor - */ -export function invariant_slowestChainEqualsToOmnichainSnapshotTime( - ctx: ParsePayload, -) { - const { slowestChainIndexingCursor, omnichainSnapshot } = ctx.value; - const { omnichainIndexingCursor } = omnichainSnapshot; - - if (slowestChainIndexingCursor !== omnichainIndexingCursor) { - console.log("invariant_slowestChainEqualsToOmnichainSnapshotTime", { - slowestChainIndexingCursor, - omnichainIndexingCursor, - }); - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'slowestChainIndexingCursor' must be equal to 'omnichainSnapshot.omnichainIndexingCursor'`, - }); - } -} - -/** - * Invariant: for cross-chain indexing status snapshot omnichain, - * snapshotTime is greater than or equal to the "highest known block" timestamp. - */ -export function invariant_snapshotTimeIsTheHighestKnownBlockTimestamp( - ctx: ParsePayload, -) { - const { snapshotTime, omnichainSnapshot } = ctx.value; - const chains = Array.from(omnichainSnapshot.chains.values()); - - const startBlockTimestamps = chains.map((chain) => chain.config.startBlock.timestamp); - - const endBlockTimestamps = chains - .map((chain) => chain.config) - .filter((chainConfig) => chainConfig.configType === ChainIndexingConfigTypeIds.Definite) - .map((chainConfig) => chainConfig.endBlock.timestamp); - - const backfillEndBlockTimestamps = chains - .filter((chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill) - .map((chain) => chain.backfillEndBlock.timestamp); - - const latestKnownBlockTimestamps = chains - .filter((chain) => chain.chainStatus === ChainIndexingStatusIds.Following) - .map((chain) => chain.latestKnownBlock.timestamp); - - const highestKnownBlockTimestamp = Math.max( - ...startBlockTimestamps, - ...endBlockTimestamps, - ...backfillEndBlockTimestamps, - ...latestKnownBlockTimestamps, - ); - - if (snapshotTime < highestKnownBlockTimestamp) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'snapshotTime' (${snapshotTime}) must be greater than or equal to the "highest known block timestamp" (${highestKnownBlockTimestamp})`, - }); - } -} - -/** - * Makes Zod schema for {@link CrossChainIndexingStatusSnapshotOmnichain} - */ -const makeCrossChainIndexingStatusSnapshotOmnichainSchema = ( - valueLabel: string = "Cross-chain Indexing Status Snapshot Omnichain", -) => - z - .strictObject({ - strategy: z.literal(CrossChainIndexingStrategyIds.Omnichain), - slowestChainIndexingCursor: makeUnixTimestampSchema(valueLabel), - snapshotTime: makeUnixTimestampSchema(valueLabel), - omnichainSnapshot: makeOmnichainIndexingStatusSnapshotSchema(valueLabel), - }) - .check(invariant_slowestChainEqualsToOmnichainSnapshotTime) - .check(invariant_snapshotTimeIsTheHighestKnownBlockTimestamp); - -/** - * Makes Zod schema for {@link CrossChainIndexingStatusSnapshot} - */ -export const makeCrossChainIndexingStatusSnapshotSchema = ( - valueLabel: string = "Cross-chain Indexing Status Snapshot", -) => - z.discriminatedUnion("strategy", [ - makeCrossChainIndexingStatusSnapshotOmnichainSchema(valueLabel), - ]); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts index 62b31135f..477fa4da4 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts @@ -1,96 +1,7 @@ -import z, { prettifyError } from "zod/v4"; -import type { ParsePayload } from "zod/v4/core"; +import { prettifyError } from "zod/v4"; -import type { ChainId } from "../../../shared/types"; -import { makeChainIdSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; -import { - ChainIndexingStatusIds, - type ChainIndexingStatusSnapshot, - type ChainIndexingStatusSnapshotCompleted, - type ChainIndexingStatusSnapshotQueued, -} from "../chain-indexing-status-snapshot"; -import { - type ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill, - getOmnichainIndexingStatus, - OmnichainIndexingStatusIds, - type OmnichainIndexingStatusSnapshot, - type OmnichainIndexingStatusSnapshotFollowing, -} from "../omnichain-indexing-status-snapshot"; -import { makeChainIndexingStatusSnapshotSchema } from "./chain-indexing-status-snapshot"; - -/** - * Check if Chain Indexing Status Snapshots fit the 'unstarted' overall status - * snapshot requirements: - * - All chains are guaranteed to have a status of "queued". - * - * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to - * {@link ChainIndexingStatusSnapshotQueued}. - */ -export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted( - chains: ChainIndexingStatusSnapshot[], -): chains is ChainIndexingStatusSnapshotQueued[] { - return chains.every((chain) => chain.chainStatus === ChainIndexingStatusIds.Queued); -} - -/** - * Check if Chain Indexing Status Snapshots fit the 'backfill' overall status - * snapshot requirements: - * - At least one chain is guaranteed to be in the "backfill" status. - * - Each chain is guaranteed to have a status of either "queued", - * "backfill" or "completed". - * - * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to - * {@link ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill}. - */ -export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill( - chains: ChainIndexingStatusSnapshot[], -): chains is ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill[] { - const atLeastOneChainInTargetStatus = chains.some( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill, - ); - const otherChainsHaveValidStatuses = chains.every( - (chain) => - chain.chainStatus === ChainIndexingStatusIds.Queued || - chain.chainStatus === ChainIndexingStatusIds.Backfill || - chain.chainStatus === ChainIndexingStatusIds.Completed, - ); - - return atLeastOneChainInTargetStatus && otherChainsHaveValidStatuses; -} - -/** - * Checks if Chain Indexing Status Snapshots fit the 'completed' overall status - * snapshot requirements: - * - All chains are guaranteed to have a status of "completed". - * - * Note: This function narrows the {@link ChainIndexingStatusSnapshot} type to - * {@link ChainIndexingStatusSnapshotCompleted}. - */ -export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted( - chains: ChainIndexingStatusSnapshot[], -): chains is ChainIndexingStatusSnapshotCompleted[] { - const allChainsHaveValidStatuses = chains.every( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Completed, - ); - - return allChainsHaveValidStatuses; -} - -/** - * Checks Chain Indexing Status Snapshots fit the 'following' overall status - * snapshot requirements: - * - At least one chain is guaranteed to be in the "following" status. - * - Any other chain can have any status. - */ -export function checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing( - chains: ChainIndexingStatusSnapshot[], -): chains is ChainIndexingStatusSnapshot[] { - const allChainsHaveValidStatuses = chains.some( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Following, - ); - - return allChainsHaveValidStatuses; -} +import type { OmnichainIndexingStatusSnapshot } from "../omnichain-indexing-status-snapshot"; +import { makeOmnichainIndexingStatusSnapshotSchema } from "../schema/omnichian-indexing-status-snapshot"; /** * Validate an {@link OmnichainIndexingStatusSnapshot} object. @@ -107,307 +18,3 @@ export function validateOmnichainIndexingStatusSnapshot( return parsed.data; } - -/** - * Invariant: For omnichain snapshot, - * `omnichainStatus` is set based on the snapshots of individual chains. - */ -export function invariant_omnichainSnapshotStatusIsConsistentWithChainSnapshot( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const chains = Array.from(snapshot.chains.values()); - const expectedOmnichainStatus = getOmnichainIndexingStatus(chains); - const actualOmnichainStatus = snapshot.omnichainStatus; - - if (expectedOmnichainStatus !== actualOmnichainStatus) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: `'${actualOmnichainStatus}' is an invalid omnichainStatus. Expected '${expectedOmnichainStatus}' based on the statuses of individual chains.`, - }); - } -} - -/** - * Invariant: For omnichain status snapshot, - * `omnichainIndexingCursor` is lower than the earliest start block - * across all queued chains. - * - * Note: if there are no queued chains, the invariant holds. - */ -export function invariant_omnichainIndexingCursorLowerThanEarliestStartBlockAcrossQueuedChains( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const queuedChains = Array.from(snapshot.chains.values()).filter( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Queued, - ); - - // there are no queued chains - if (queuedChains.length === 0) { - // the invariant holds - return; - } - - const queuedChainStartBlocks = queuedChains.map((chain) => chain.config.startBlock.timestamp); - const queuedChainEarliestStartBlock = Math.min(...queuedChainStartBlocks); - - // there are queued chains - // the invariant holds if the omnichain indexing cursor is lower than - // the earliest start block across all queued chains - if (snapshot.omnichainIndexingCursor >= queuedChainEarliestStartBlock) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: - "`omnichainIndexingCursor` must be lower than the earliest start block across all queued chains.", - }); - } -} - -/** - * Invariant: For omnichain status snapshot, - * `omnichainIndexingCursor` is lower than or equal to - * the highest `backfillEndBlock` across all backfill chains. - * - * Note: if there are no backfill chains, the invariant holds. - */ -export function invariant_omnichainIndexingCursorLowerThanOrEqualToLatestBackfillEndBlockAcrossBackfillChains( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const backfillChains = Array.from(snapshot.chains.values()).filter( - (chain) => chain.chainStatus === ChainIndexingStatusIds.Backfill, - ); - - // there are no backfill chains - if (backfillChains.length === 0) { - // the invariant holds - return; - } - - const backfillEndBlocks = backfillChains.map((chain) => chain.backfillEndBlock.timestamp); - const highestBackfillEndBlock = Math.max(...backfillEndBlocks); - - // there are backfill chains - // the invariant holds if the omnichainIndexingCursor is lower than or - // equal to the highest backfillEndBlock across all backfill chains. - if (snapshot.omnichainIndexingCursor > highestBackfillEndBlock) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: - "`omnichainIndexingCursor` must be lower than or equal to the highest `backfillEndBlock` across all backfill chains.", - }); - } -} - -/** - * Invariant: For omnichain status snapshot, - * `omnichainIndexingCursor` is same as the highest latestIndexedBlock - * across all indexed chains. - * - * Note: if there are no indexed chains, the invariant holds. - */ -export function invariant_omnichainIndexingCursorIsEqualToHighestLatestIndexedBlockAcrossIndexedChain( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const indexedChains = Array.from(snapshot.chains.values()).filter( - (chain) => - chain.chainStatus === ChainIndexingStatusIds.Backfill || - chain.chainStatus === ChainIndexingStatusIds.Completed || - chain.chainStatus === ChainIndexingStatusIds.Following, - ); - - // there are no indexed chains - if (indexedChains.length === 0) { - // the invariant holds - return; - } - - const indexedChainLatestIndexedBlocks = indexedChains.map( - (chain) => chain.latestIndexedBlock.timestamp, - ); - const indexedChainHighestLatestIndexedBlock = Math.max(...indexedChainLatestIndexedBlocks); - - // there are indexed chains - // the invariant holds if the omnichain indexing cursor is same as - // the highest latestIndexedBlock across all indexed chains - if (snapshot.omnichainIndexingCursor !== indexedChainHighestLatestIndexedBlock) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: - "`omnichainIndexingCursor` must be same as the highest `latestIndexedBlock` across all indexed chains.", - }); - } -} - -/** - * Invariant: For omnichain status snapshot 'unstarted', - * all chains must have "queued" status. - */ -export function invariant_omnichainSnapshotUnstartedHasValidChains( - ctx: ParsePayload>, -) { - const chains = ctx.value; - const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotUnstarted( - Array.from(chains.values()), - ); - - if (hasValidChains === false) { - ctx.issues.push({ - code: "custom", - input: chains, - message: `For omnichain status snapshot 'unstarted', all chains must have "queued" status.`, - }); - } -} - -/** - * Invariant: For omnichain status snapshot 'backfill', - * at least one chain must be in "backfill" status and - * each chain has to have a status of either "queued", "backfill" - * or "completed". - */ -export function invariant_omnichainStatusSnapshotBackfillHasValidChains( - ctx: ParsePayload>, -) { - const chains = ctx.value; - const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill( - Array.from(chains.values()), - ); - - if (hasValidChains === false) { - ctx.issues.push({ - code: "custom", - input: chains, - message: `For omnichain status snapshot 'backfill', at least one chain must be in "backfill" status and each chain has to have a status of either "queued", "backfill" or "completed".`, - }); - } -} - -/** - * Invariant: For omnichain status snapshot 'completed', - * all chains must have "completed" status. - */ -export function invariant_omnichainStatusSnapshotCompletedHasValidChains( - ctx: ParsePayload>, -) { - const chains = ctx.value; - const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotCompleted( - Array.from(chains.values()), - ); - - if (hasValidChains === false) { - ctx.issues.push({ - code: "custom", - input: chains, - message: `For omnichain status snapshot 'completed', all chains must have "completed" status.`, - }); - } -} - -/** - * Invariant: For omnichain status snapshot 'following', - * at least one chain must be in 'following' status. - */ -export function invariant_omnichainStatusSnapshotFollowingHasValidChains( - ctx: ParsePayload, -) { - const snapshot = ctx.value; - const hasValidChains = checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotFollowing( - Array.from(snapshot.chains.values()), - ); - - if (hasValidChains === false) { - ctx.issues.push({ - code: "custom", - input: snapshot, - message: "For omnichainStatus 'following', at least one chain must be in 'following' status.", - }); - } -} - -export const makeChainIndexingStatusSnapshotsSchema = (valueLabel: string = "Value") => - z.map(makeChainIdSchema(), makeChainIndexingStatusSnapshotSchema(valueLabel), { - error: - "Chains indexing statuses must be an object mapping valid chain IDs to their indexing status snapshots.", - }); - -/** - * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotUnstarted} - */ -const makeOmnichainIndexingStatusSnapshotUnstartedSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Unstarted), - chains: makeChainIndexingStatusSnapshotsSchema(valueLabel) - .check(invariant_omnichainSnapshotUnstartedHasValidChains) - .transform((chains) => chains as Map), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotBackfill} - */ -const makeOmnichainIndexingStatusSnapshotBackfillSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Backfill), - chains: makeChainIndexingStatusSnapshotsSchema(valueLabel) - .check(invariant_omnichainStatusSnapshotBackfillHasValidChains) - .transform( - (chains) => - chains as Map< - ChainId, - ChainIndexingStatusSnapshotForOmnichainIndexingStatusSnapshotBackfill - >, - ), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotCompleted} - */ -const makeOmnichainIndexingStatusSnapshotCompletedSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Completed), - chains: makeChainIndexingStatusSnapshotsSchema(valueLabel) - .check(invariant_omnichainStatusSnapshotCompletedHasValidChains) - .transform((chains) => chains as Map), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Makes Zod schema for {@link OmnichainIndexingStatusSnapshotFollowing} - */ -const makeOmnichainIndexingStatusSnapshotFollowingSchema = (valueLabel?: string) => - z.strictObject({ - omnichainStatus: z.literal(OmnichainIndexingStatusIds.Following), - chains: makeChainIndexingStatusSnapshotsSchema(valueLabel), - omnichainIndexingCursor: makeUnixTimestampSchema(valueLabel), - }); - -/** - * Omnichain Indexing Snapshot Schema - * - * Makes a Zod schema definition for validating indexing snapshot - * across all chains indexed by ENSIndexer instance. - */ -export const makeOmnichainIndexingStatusSnapshotSchema = ( - valueLabel: string = "Omnichain Indexing Snapshot", -) => - z - .discriminatedUnion("omnichainStatus", [ - makeOmnichainIndexingStatusSnapshotUnstartedSchema(valueLabel), - makeOmnichainIndexingStatusSnapshotBackfillSchema(valueLabel), - makeOmnichainIndexingStatusSnapshotCompletedSchema(valueLabel), - makeOmnichainIndexingStatusSnapshotFollowingSchema(valueLabel), - ]) - .check(invariant_omnichainSnapshotStatusIsConsistentWithChainSnapshot) - .check(invariant_omnichainIndexingCursorLowerThanEarliestStartBlockAcrossQueuedChains) - .check( - invariant_omnichainIndexingCursorLowerThanOrEqualToLatestBackfillEndBlockAcrossBackfillChains, - ) - .check(invariant_omnichainIndexingCursorIsEqualToHighestLatestIndexedBlockAcrossIndexedChain); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/realtime-indexing-status-projection.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/realtime-indexing-status-projection.ts index 5c4262119..8c5f08894 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/realtime-indexing-status-projection.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/realtime-indexing-status-projection.ts @@ -1,9 +1,7 @@ -import z, { prettifyError } from "zod/v4"; -import type { ParsePayload } from "zod/v4/core"; +import { prettifyError } from "zod/v4"; -import { makeDurationSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; import type { RealtimeIndexingStatusProjection } from "../realtime-indexing-status-projection"; -import { makeCrossChainIndexingStatusSnapshotSchema } from "./cross-chain-indexing-status-snapshot"; +import { makeRealtimeIndexingStatusProjectionSchema } from "../schema/realtime-indexing-status-projection"; /** * Validate a {@link RealtimeIndexingStatusProjection} object. @@ -20,61 +18,3 @@ export function validateRealtimeIndexingStatusProjection( return parsed.data; } - -/** - * Invariant: For realtime indexing status projection, - * `projectedAt` is after or same as `snapshot.snapshotTime`. - */ -export function invariant_realtimeIndexingStatusProjectionProjectedAtIsAfterOrEqualToSnapshotTime( - ctx: ParsePayload, -) { - const projection = ctx.value; - - const { snapshot, projectedAt } = projection; - - if (snapshot.snapshotTime > projectedAt) { - ctx.issues.push({ - code: "custom", - input: projection, - message: "`projectedAt` must be after or same as `snapshot.snapshotTime`.", - }); - } -} - -/** - * Invariant: For realtime indexing status projection, - * `worstCaseDistance` is the difference between `projectedAt` - * and `omnichainIndexingCursor`. - */ -export function invariant_realtimeIndexingStatusProjectionWorstCaseDistanceIsCorrect( - ctx: ParsePayload, -) { - const projection = ctx.value; - const { projectedAt, snapshot, worstCaseDistance } = projection; - const { omnichainSnapshot } = snapshot; - const expectedWorstCaseDistance = projectedAt - omnichainSnapshot.omnichainIndexingCursor; - - if (worstCaseDistance !== expectedWorstCaseDistance) { - ctx.issues.push({ - code: "custom", - input: projection, - message: - "`worstCaseDistance` must be the exact difference between `projectedAt` and `snapshot.omnichainIndexingCursor`.", - }); - } -} - -/** - * Makes Zod schema for {@link RealtimeIndexingStatusProjection} - */ -export const makeRealtimeIndexingStatusProjectionSchema = ( - valueLabel: string = "Realtime Indexing Status Projection", -) => - z - .strictObject({ - projectedAt: makeUnixTimestampSchema(valueLabel), - worstCaseDistance: makeDurationSchema(valueLabel), - snapshot: makeCrossChainIndexingStatusSnapshotSchema(valueLabel), - }) - .check(invariant_realtimeIndexingStatusProjectionProjectedAtIsAfterOrEqualToSnapshotTime) - .check(invariant_realtimeIndexingStatusProjectionWorstCaseDistanceIsCorrect); From f02c31c328d1ea211956e57e87b04061a65c23cc Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sat, 7 Feb 2026 13:26:05 +0100 Subject: [PATCH 6/6] Fix typos --- .../deserialize/omnichain-indexing-status-snapshot.ts | 2 +- .../schema/cross-chain-indexing-status-snapshot.ts | 2 +- ...us-snapshot.ts => omnichain-indexing-status-snapshot.ts} | 0 .../schema/realtime-indexing-status-projection.ts | 6 ++++-- .../validate/omnichain-indexing-status-snapshot.ts | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) rename packages/ensnode-sdk/src/ensindexer/indexing-status/schema/{omnichian-indexing-status-snapshot.ts => omnichain-indexing-status-snapshot.ts} (100%) diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts index 4ad4a6dad..e3dbf3f45 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/deserialize/omnichain-indexing-status-snapshot.ts @@ -5,7 +5,7 @@ import { buildUnvalidatedOmnichainIndexingStatusSnapshot, makeOmnichainIndexingStatusSnapshotSchema, makeSerializedOmnichainIndexingStatusSnapshotSchema, -} from "../schema/omnichian-indexing-status-snapshot"; +} from "../schema/omnichain-indexing-status-snapshot"; import type { SerializedOmnichainIndexingStatusSnapshot } from "../serialize/omnichain-indexing-status-snapshot"; /** diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/cross-chain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/cross-chain-indexing-status-snapshot.ts index 480f25b37..9fb11b4a5 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/cross-chain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/cross-chain-indexing-status-snapshot.ts @@ -15,7 +15,7 @@ import { buildUnvalidatedOmnichainIndexingStatusSnapshot, makeOmnichainIndexingStatusSnapshotSchema, makeSerializedOmnichainIndexingStatusSnapshotSchema, -} from "./omnichian-indexing-status-snapshot"; +} from "./omnichain-indexing-status-snapshot"; /** * Invariant: for cross-chain indexing status snapshot omnichain, diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/omnichian-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/omnichain-indexing-status-snapshot.ts similarity index 100% rename from packages/ensnode-sdk/src/ensindexer/indexing-status/schema/omnichian-indexing-status-snapshot.ts rename to packages/ensnode-sdk/src/ensindexer/indexing-status/schema/omnichain-indexing-status-snapshot.ts diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/realtime-indexing-status-projection.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/realtime-indexing-status-projection.ts index cb231d2e7..b8aed95d6 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/realtime-indexing-status-projection.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/schema/realtime-indexing-status-projection.ts @@ -3,8 +3,10 @@ import type { ParsePayload } from "zod/v4/core"; import { makeDurationSchema, makeUnixTimestampSchema } from "../../../shared/zod-schemas"; import type { RealtimeIndexingStatusProjection } from "../realtime-indexing-status-projection"; -import { makeCrossChainIndexingStatusSnapshotSchema } from "../schema/cross-chain-indexing-status-snapshot"; -import { makeSerializedCrossChainIndexingStatusSnapshotSchema } from "./cross-chain-indexing-status-snapshot"; +import { + makeCrossChainIndexingStatusSnapshotSchema, + makeSerializedCrossChainIndexingStatusSnapshotSchema, +} from "./cross-chain-indexing-status-snapshot"; /** * Invariant: For realtime indexing status projection, diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts index 477fa4da4..c6b47945d 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/validate/omnichain-indexing-status-snapshot.ts @@ -1,7 +1,7 @@ import { prettifyError } from "zod/v4"; import type { OmnichainIndexingStatusSnapshot } from "../omnichain-indexing-status-snapshot"; -import { makeOmnichainIndexingStatusSnapshotSchema } from "../schema/omnichian-indexing-status-snapshot"; +import { makeOmnichainIndexingStatusSnapshotSchema } from "../schema/omnichain-indexing-status-snapshot"; /** * Validate an {@link OmnichainIndexingStatusSnapshot} object.