Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions apps/ensapi/src/cache/indexing-status.cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk";
import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk";
import {
type CrossChainIndexingStatusSnapshot,
IndexingMetadataContextStatusCodes,
SWRCache,
} from "@ensnode/ensnode-sdk";

import { ensDbClient } from "@/lib/ensdb/singleton";
import { lazyProxy } from "@/lib/lazy";
Expand All @@ -16,31 +20,33 @@ export const indexingStatusCache = lazyProxy<SWRCache<CrossChainIndexingStatusSn
new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async (_cachedResult) =>
ensDbClient
.getIndexingStatusSnapshot() // get the latest indexing status snapshot
.then((snapshot) => {
if (snapshot === undefined) {
// An indexing status snapshot has not been found in ENSDb yet.
.getIndexingMetadataContext() // get the latest indexing status snapshot
.then((indexingMetadataContext) => {
if (
indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized
) {
// The Indexing Metadata Context has not been initialized in ENSDb yet.
// This might happen during application startup, i.e. when ENSDb
// has not yet been populated with the first snapshot.
// Therefore, throw an error to trigger the subsequent `.catch` handler.
throw new Error("Indexing Status snapshot not found in ENSDb yet.");
throw new Error("Indexing Metadata Context was uninitialized in ENSDb.");
}

// The indexing status snapshot has been fetched and successfully validated for caching.
// Therefore, return it so that this current invocation of `readCache` will:
// - Replace the currently cached value (if any) with this new value.
// - Return this non-null value.
return snapshot;
return indexingMetadataContext.indexingStatus;
})
.catch((error) => {
// Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
// Indexing Metadata Context was uninitialized in ENSDb.
// Therefore, throw an error so that this current invocation of `readCache` will:
// - Reject the newly fetched response (if any) such that it won't be cached.
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
logger.error(
error,
`Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` +
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` +
`Error occurred while loading Indexing Metadata Context record from ENSNode Metadata table in ENSDb. ` +
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.IndexingMetadataContext}"). ` +
`The cached indexing status snapshot (if any) will not be updated.`,
);
throw error;
Expand Down
172 changes: 126 additions & 46 deletions apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,111 @@ import packageJson from "@/../package.json" with { type: "json" };

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { type ENSIndexerPublicConfig, PluginName } from "@ensnode/ensnode-sdk";
import {
ChainIndexingStatusIds,
CrossChainIndexingStrategyIds,
deserializeIndexingMetadataContext,
type EnsRainbowPublicConfig,
type IndexingMetadataContextInitialized,
IndexingMetadataContextStatusCodes,
OmnichainIndexingStatusIds,
PluginName,
RangeTypeIds,
type SerializedCrossChainIndexingStatusSnapshot,
type SerializedEnsDbPublicConfig,
type SerializedEnsIndexerPublicConfig,
type SerializedEnsIndexerStackInfo,
type SerializedIndexingMetadataContextInitialized,
} from "@ensnode/ensnode-sdk";
import type { RpcConfig } from "@ensnode/ensnode-sdk/internal";

import { ensApiVersionInfo } from "@/lib/version-info";

const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234";

const ENSDB_PUBLIC_CONFIG = {
versionInfo: {
postgresql: "17.4",
},
} satisfies SerializedEnsDbPublicConfig;

const ENSINDEXER_PUBLIC_CONFIG = {
namespace: "mainnet",
ensIndexerSchemaName: "ensindexer_0",
ensRainbowPublicConfig: {
serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 },
versionInfo: {
ensRainbow: packageJson.version,
},
},
indexedChainIds: [1],
isSubgraphCompatible: false,
clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 },
plugins: [PluginName.Subgraph],
versionInfo: {
ensDb: packageJson.version,
ensIndexer: packageJson.version,
ensNormalize: ensApiVersionInfo.ensNormalize,
ponder: "0.8.0",
},
} satisfies SerializedEnsIndexerPublicConfig;

const ENSRAINBOW_PUBLIC_CONFIG = {
serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 },
versionInfo: {
ensRainbow: packageJson.version,
},
} satisfies EnsRainbowPublicConfig;

const INDEXING_STATUS = {
strategy: CrossChainIndexingStrategyIds.Omnichain,
slowestChainIndexingCursor: 1777147427,
snapshotTime: 1777147440,
omnichainSnapshot: {
omnichainStatus: OmnichainIndexingStatusIds.Following,
chains: {
"1": {
chainStatus: ChainIndexingStatusIds.Following,
config: {
rangeType: RangeTypeIds.LeftBounded,
startBlock: {
timestamp: 1489165544,
number: 3327417,
},
},
latestIndexedBlock: {
timestamp: 1777147427,
number: 24959286,
},
latestKnownBlock: {
timestamp: 1777147427,
number: 24959286,
},
},
},
omnichainIndexingCursor: 1777147427,
},
} satisfies SerializedCrossChainIndexingStatusSnapshot;

const ENSINDEXER_STACK_INFO = {
ensDb: ENSDB_PUBLIC_CONFIG,
ensIndexer: ENSINDEXER_PUBLIC_CONFIG,
ensRainbow: ENSRAINBOW_PUBLIC_CONFIG,
} satisfies SerializedEnsIndexerStackInfo;

const INDEXING_METADATA_CONTEXT = {
statusCode: IndexingMetadataContextStatusCodes.Initialized,
indexingStatus: INDEXING_STATUS,
stackInfo: ENSINDEXER_STACK_INFO,
} satisfies SerializedIndexingMetadataContextInitialized;

const indexingMetadataContextInitialized = deserializeIndexingMetadataContext(
INDEXING_METADATA_CONTEXT,
) as IndexingMetadataContextInitialized;
Comment on lines +103 to +105
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Prefer a runtime assertion over the bare type cast.

deserializeIndexingMetadataContext(...) returns a union, and the as IndexingMetadataContextInitialized cast silently lies if the deserializer ever produces a non-initialized variant (e.g., due to a future shape change in the fixture). A small invariant check (e.g., if (deserialized.statusCode !== IndexingMetadataContextStatusCodes.Initialized) throw new Error(...)) makes the test fail loudly at the fixture rather than at an assertion deep inside buildConfigFromEnvironment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensapi/src/config/config.schema.test.ts` around lines 103 - 105, Replace
the bare cast of deserializeIndexingMetadataContext(INDEXING_METADATA_CONTEXT)
to IndexingMetadataContextInitialized with a runtime invariant check: call
deserializeIndexingMetadataContext(...) into a variable, verify its statusCode
equals IndexingMetadataContextStatusCodes.Initialized, and if not throw a clear
Error (mentioning INDEXING_METADATA_CONTEXT and the unexpected status) so the
test fails immediately; only after this check treat the value as an
IndexingMetadataContextInitialized before passing it to
buildConfigFromEnvironment or further assertions.


vi.mock("@/lib/ensdb/singleton", () => ({
ensDbClient: {
getEnsIndexerPublicConfig: vi.fn(async () => ENSINDEXER_PUBLIC_CONFIG),
getIndexingMetadataContext: vi.fn(async () => indexingMetadataContextInitialized),
},
}));

Expand All @@ -22,7 +121,6 @@ import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/co
import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import type { EnsApiEnvironment } from "@/config/environment";
import logger from "@/lib/logger";
import { ensApiVersionInfo } from "@/lib/version-info";

vi.mock("@/lib/logger", () => ({
default: {
Expand All @@ -31,44 +129,23 @@ vi.mock("@/lib/logger", () => ({
},
}));

const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234";

const BASE_ENV = {
ENSDB_URL: "postgresql://user:password@localhost:5432/mydb",
ENSINDEXER_SCHEMA_NAME: "ensindexer_0",
RPC_URL_1: VALID_RPC_URL,
} satisfies EnsApiEnvironment;

const ENSINDEXER_PUBLIC_CONFIG = {
namespace: "mainnet",
ensIndexerSchemaName: "ensindexer_0",
ensRainbowPublicConfig: {
serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 },
versionInfo: {
ensRainbow: packageJson.version,
},
},
indexedChainIds: new Set([1]),
isSubgraphCompatible: false,
clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 },
plugins: [PluginName.Subgraph],
versionInfo: {
ensDb: packageJson.version,
ensIndexer: packageJson.version,
ensNormalize: ensApiVersionInfo.ensNormalize,
ponder: "0.8.0",
},
} satisfies ENSIndexerPublicConfig;

describe("buildConfigFromEnvironment", () => {
it("returns a valid config object using environment variables", async () => {
const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo;
await expect(buildConfigFromEnvironment(BASE_ENV)).resolves.toStrictEqual({
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME,
theGraphApiKey: undefined,

ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs: new Map([
[
1,
Expand Down Expand Up @@ -153,12 +230,13 @@ describe("buildConfigFromEnvironment", () => {

describe("buildEnsApiPublicConfig", () => {
it("returns a valid ENSApi public config with correct structure", () => {
const mockConfig = {
const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo;
const ensApiConfig = {
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME,
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs: new Map([
[
1,
Expand All @@ -171,52 +249,54 @@ describe("buildEnsApiPublicConfig", () => {
referralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);
const result = buildEnsApiPublicConfig(ensApiConfig);

expect(result).toStrictEqual({
versionInfo: ensApiVersionInfo,
theGraphFallback: {
canFallback: false,
reason: "not-subgraph-compatible",
},
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
ensIndexerPublicConfig,
});
});

it("preserves the complete ENSIndexer public config structure", () => {
const mockConfig = {
const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo;
const ensApiConfig = {
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME,
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs: new Map(),
referralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);
const result = buildEnsApiPublicConfig(ensApiConfig);

// Verify that all ENSIndexer public config fields are preserved
expect(result.ensIndexerPublicConfig).toStrictEqual(ENSINDEXER_PUBLIC_CONFIG);
expect(result.ensIndexerPublicConfig).toStrictEqual(ensIndexerPublicConfig);
});

it("includes the theGraphFallback and redacts api key", () => {
const mockConfig = {
const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo;
const ensApiConfig = {
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME,
ensIndexerPublicConfig: {
...ENSINDEXER_PUBLIC_CONFIG,
...ensIndexerPublicConfig,
plugins: ["subgraph"],
isSubgraphCompatible: true,
},
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs: new Map(),
referralProgramEditionConfigSetUrl: undefined,
theGraphApiKey: "secret-api-key",
};

const result = buildEnsApiPublicConfig(mockConfig);
const result = buildEnsApiPublicConfig(ensApiConfig);

expect(result.theGraphFallback.canFallback).toBe(true);
// discriminate the type...
Expand Down
10 changes: 5 additions & 5 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pRetry from "p-retry";
import { prettifyError, ZodError, z } from "zod/v4";

import type { EnsApiPublicConfig } from "@ensnode/ensnode-sdk";
import { type EnsApiPublicConfig, IndexingMetadataContextStatusCodes } from "@ensnode/ensnode-sdk";
import {
buildRpcConfigsFromEnv,
canFallbackToTheGraph,
Expand Down Expand Up @@ -70,13 +70,13 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis
// https://github.com/namehash/ensnode/issues/1806
const ensIndexerPublicConfig = await pRetry(
async () => {
const config = await ensDbClient.getEnsIndexerPublicConfig();
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

if (!config) {
throw new Error("ENSIndexer Public Config not yet available in ENSDb.");
if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) {
throw new Error("Indexing metadata context is uninitialized in ENSDb.");
}

return config;
return indexingMetadataContext.stackInfo.ensIndexer;
},
{
retries: 13, // This allows for a total of over 1 hour of retries with the exponential backoff strategy
Expand Down
12 changes: 7 additions & 5 deletions apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
EnsIndexerIndexingStatusResponseCodes,
type EnsIndexerIndexingStatusResponseError,
type EnsIndexerIndexingStatusResponseOk,
IndexingMetadataContextStatusCodes,
serializeEnsIndexerIndexingStatusResponse,
serializeEnsIndexerPublicConfig,
} from "@ensnode/ensnode-sdk";
Expand All @@ -17,32 +18,33 @@ const app = new Hono();

// include ENSIndexer Public Config endpoint
app.get("/config", async (c) => {
const publicConfig = await ensDbClient.getEnsIndexerPublicConfig();
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

// Invariant: the public config is guaranteed to be available in ENSDb after
// application startup.
if (typeof publicConfig === "undefined") {
if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) {
throw new Error("Unreachable: ENSIndexer Public Config is not available in ENSDb");
}

// respond with the serialized public config object
return c.json(serializeEnsIndexerPublicConfig(publicConfig));
return c.json(serializeEnsIndexerPublicConfig(indexingMetadataContext.stackInfo.ensIndexer));
});

app.get("/indexing-status", async (c) => {
try {
const crossChainSnapshot = await ensDbClient.getIndexingStatusSnapshot();
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

// Invariant: the Indexing Status Snapshot is expected to be available in
// ENSDb shortly after application startup. There is a possibility that
// the snapshot is not yet available at the time of the request,
// i.e. when ENSDb has not yet been populated with the first snapshot.
// In this case, we treat the snapshot as unavailable and respond with
// an error response.
if (typeof crossChainSnapshot === "undefined") {
if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) {
throw new Error("ENSDb does not contain an Indexing Status Snapshot");
}

const crossChainSnapshot = indexingMetadataContext.indexingStatus;
const projectedAt = getUnixTime(new Date());
const realtimeProjection = createRealtimeIndexingStatusProjection(
crossChainSnapshot,
Expand Down
Loading
Loading