diff --git a/.changeset/thirty-bees-divide.md b/.changeset/thirty-bees-divide.md new file mode 100644 index 0000000000..21a09b5390 --- /dev/null +++ b/.changeset/thirty-bees-divide.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Added `peek` method to `SWRCache` class. diff --git a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts index 2761b93f7a..f062774735 100644 --- a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts +++ b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { hasEnsAnalyticsConfigSupport, hasEnsAnalyticsIndexingStatusSupport, @@ -13,6 +11,7 @@ import { minutesToSeconds } from "date-fns"; import { type CachedResult, getLatestIndexedBlockRef, SWRCache } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { assumeReferralProgramEditionImmutablyClosed } from "@/lib/ensanalytics/referrer-leaderboard/closeout"; import { getReferralEditionSnapshot } from "@/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot"; import { makeLogger } from "@/lib/logger"; @@ -73,7 +72,7 @@ function createEditionSnapshotBuilder( // the cache could capture a snapshot derived from a not-yet-final indexer state, or one with // silently dropped rows because a required namespace plugin is inactive, and serve it for the // rest of its (effectively infinite, for closed editions) TTL. - const configSupport = hasEnsAnalyticsConfigSupport(config.ensIndexerPublicConfig); + const configSupport = hasEnsAnalyticsConfigSupport(di.context.stackInfo.ensIndexer); if (!configSupport.supported) { throw new Error( `Unable to generate edition snapshot for ${editionSlug}. ${configSupport.reason}`, diff --git a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts index fd30202abc..b4d7eec1df 100644 --- a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { buildReferralProgramEditionConfigSet, ENSReferralsClient, @@ -32,20 +30,26 @@ function partiallyRedactUrl(url: URL): string { async function loadReferralProgramEditionConfigSet( _cachedResult?: CachedResult, ): Promise { + // Async import `di` here to avoid circular dependency between this cache module and the DI container module. + // NOTE: It will not be required soon, as we plan to create a factory function for this cache + // that accepts the necessary dependencies as parameters, instead of importing from the DI container. + const di = await import("@/di").then((mod) => mod.default); + const { referralProgramEditionConfigSetUrl } = di.context.ensApiConfig; + // If no URL is configured, treat the referral program as having zero editions. - if (!config.referralProgramEditionConfigSetUrl) { + if (!referralProgramEditionConfigSetUrl) { logger.info( "REFERRAL_PROGRAM_EDITIONS is not set; referral program edition config set is empty", ); return buildReferralProgramEditionConfigSet([]); } - const logSafeUrl = partiallyRedactUrl(config.referralProgramEditionConfigSetUrl); + const logSafeUrl = partiallyRedactUrl(referralProgramEditionConfigSetUrl); logger.info(`Loading referral program edition config set from: ${logSafeUrl}`); try { const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet( - config.referralProgramEditionConfigSetUrl, + referralProgramEditionConfigSetUrl, ); // Strip any unrecognized editions immediately — they are client-side forward-compatibility @@ -72,7 +76,7 @@ async function loadReferralProgramEditionConfigSet( } } -type ReferralProgramEditionConfigSetCache = SWRCache; +export type ReferralProgramEditionConfigSetCache = SWRCache; /** * SWR Cache for the referral program edition config set. diff --git a/apps/ensapi/src/cache/stack-info.cache.ts b/apps/ensapi/src/cache/stack-info.cache.ts index 243f9738c8..253be6e3cb 100644 --- a/apps/ensapi/src/cache/stack-info.cache.ts +++ b/apps/ensapi/src/cache/stack-info.cache.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { minutesToSeconds } from "date-fns"; import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk"; @@ -56,9 +54,14 @@ export const stackInfoCache = lazyProxy( throw new Error("Indexing Metadata Context was uninitialized in ENSDb."); } + // Async import `di` here to avoid circular dependency between this cache module and the DI container module. + // NOTE: It will not be required soon, as we plan to create a factory function for this cache + // that accepts the necessary dependencies as parameters, instead of importing from the DI container. + const di = await import("@/di").then((mod) => mod.default); + const ensIndexerStackInfo = indexingMetadataContext.stackInfo; const ensNodeStackInfo = buildEnsNodeStackInfo( - buildEnsApiPublicConfig(config, ensIndexerStackInfo.ensIndexer), + buildEnsApiPublicConfig(di.context.ensApiConfig, ensIndexerStackInfo.ensIndexer), ensIndexerStackInfo.ensDb, ensIndexerStackInfo.ensIndexer, ensIndexerStackInfo.ensRainbow, diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index a29ab2bf74..a56083d87b 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; - import { ensApiVersionInfo } from "@/lib/version-info"; vi.mock("@/lib/ensdb/singleton", () => ({ @@ -17,12 +15,14 @@ vi.mock("@/config/ensdb-config", () => ({ }, })); -import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/config.schema"; +import { ENSNamespaceIds } from "@ensnode/ensnode-sdk"; + import { - BASE_ENV, - indexingMetadataContextInitialized, - VALID_RPC_URL, -} from "@/config/config.schema.mock"; + buildConfigFromEnvironment, + buildEnsApiPublicConfig, + buildRootChainRpcConfig, +} from "@/config/config.schema"; +import { BASE_ENV, indexingMetadataContextInitialized } from "@/config/config.schema.mock"; import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import logger from "@/lib/logger"; @@ -42,29 +42,14 @@ describe("buildConfigFromEnvironment", () => { it("returns a valid config object using environment variables", async () => { const exitSpy = mockProcessExit(); - const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; - const config = await buildConfigFromEnvironment(BASE_ENV); + const config = buildConfigFromEnvironment(BASE_ENV); expect(exitSpy).not.toHaveBeenCalled(); exitSpy.mockRestore(); expect(config).toStrictEqual({ port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, theGraphApiKey: undefined, - - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map([ - [ - 1, - { - httpRPCs: [new URL(BASE_ENV.RPC_URL_1)], - websocketRPC: undefined, - } satisfies RpcConfig, - ], - ]), referralProgramEditionConfigSetUrl: undefined, }); }); @@ -73,7 +58,7 @@ describe("buildConfigFromEnvironment", () => { const exitSpy = mockProcessExit(); const editionsUrl = "https://example.com/editions.json"; - const config = await buildConfigFromEnvironment({ + const config = buildConfigFromEnvironment({ ...BASE_ENV, REFERRAL_PROGRAM_EDITIONS: editionsUrl, }); @@ -87,7 +72,7 @@ describe("buildConfigFromEnvironment", () => { it("includes theGraphApiKey when provided", async () => { const exitSpy = mockProcessExit(); - const config = await buildConfigFromEnvironment({ + const config = buildConfigFromEnvironment({ ...BASE_ENV, THEGRAPH_API_KEY: "my-api-key", }); @@ -113,12 +98,12 @@ describe("buildConfigFromEnvironment", () => { it("logs error and exits when REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => { const testEnv = structuredClone(BASE_ENV); - await expect( + expect(() => buildConfigFromEnvironment({ ...testEnv, REFERRAL_PROGRAM_EDITIONS: "not-a-url", }), - ).rejects.toThrow("process.exit"); + ).toThrow("process.exit"); expect(logger.error).toHaveBeenCalledExactlyOnceWith( expect.stringContaining("REFERRAL_PROGRAM_EDITIONS is not a valid URL: not-a-url"), @@ -129,18 +114,21 @@ describe("buildConfigFromEnvironment", () => { it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", async () => { const testEnv = structuredClone(BASE_ENV); - await expect( - buildConfigFromEnvironment({ - ...testEnv, - QUICKNODE_API_KEY: "my-api-key", - }), - ).rejects.toThrow("process.exit"); + expect(() => + buildRootChainRpcConfig( + { + ...testEnv, + QUICKNODE_API_KEY: "my-api-key", + }, + ENSNamespaceIds.Mainnet, + ), + ).toThrow("process.exit"); expect(logger.error).toHaveBeenCalledExactlyOnceWith( new Error( "Use of the QUICKNODE_API_KEY environment variable requires use of the QUICKNODE_ENDPOINT_NAME environment variable as well.", ), - "Failed to build EnsApiConfig", + "Failed to build the root chain RPC config", ); expect(process.exit).toHaveBeenCalledExactlyOnceWith(1); }); @@ -148,18 +136,21 @@ describe("buildConfigFromEnvironment", () => { it("logs error message when QuickNode RPC config was partially configured (missing API key)", async () => { const testEnv = structuredClone(BASE_ENV); - await expect( - buildConfigFromEnvironment({ - ...testEnv, - QUICKNODE_ENDPOINT_NAME: "my-endpoint-name", - }), - ).rejects.toThrow("process.exit"); + expect(() => + buildRootChainRpcConfig( + { + ...testEnv, + QUICKNODE_ENDPOINT_NAME: "my-endpoint-name", + }, + ENSNamespaceIds.Mainnet, + ), + ).toThrow("process.exit"); expect(logger.error).toHaveBeenCalledExactlyOnceWith( new Error( "Use of the QUICKNODE_ENDPOINT_NAME environment variable requires use of the QUICKNODE_API_KEY environment variable as well.", ), - "Failed to build EnsApiConfig", + "Failed to build the root chain RPC config", ); expect(process.exit).toHaveBeenCalledExactlyOnceWith(1); }); @@ -171,19 +162,6 @@ describe("buildEnsApiPublicConfig", () => { const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; const ensApiConfig = { port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map([ - [ - 1, - { - httpRPCs: [new URL(VALID_RPC_URL)], - websocketRPC: undefined, - } satisfies RpcConfig, - ], - ]), referralProgramEditionConfigSetUrl: undefined, }; @@ -203,11 +181,7 @@ describe("buildEnsApiPublicConfig", () => { const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; const ensApiConfig = { port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map(), + theGraphApiKey: "my-api-key", referralProgramEditionConfigSetUrl: undefined, }; @@ -225,11 +199,6 @@ describe("buildEnsApiPublicConfig", () => { const ensApiConfig = { port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map(), referralProgramEditionConfigSetUrl: undefined, theGraphApiKey: "secret-api-key", }; @@ -254,11 +223,6 @@ describe("buildEnsApiPublicConfig", () => { const ensApiConfig = { port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map(), referralProgramEditionConfigSetUrl: undefined, theGraphApiKey: undefined, }; diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 733bf0ec04..01bd98ca0e 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,27 +1,22 @@ -import pRetry from "p-retry"; import { prettifyError, ZodError, z } from "zod/v4"; import { + type ENSNamespaceId, type EnsApiPublicConfig, type EnsIndexerPublicConfig, - IndexingMetadataContextStatusCodes, + getENSRootChainId, } from "@ensnode/ensnode-sdk"; import { buildRpcConfigsFromEnv, canFallbackToTheGraph, - ENSNamespaceSchema, - invariant_rpcConfigsSpecifiedForRootChain, - makeENSIndexerPublicConfigSchema, OptionalPortNumberSchema, + type RpcConfig, RpcConfigsSchema, TheGraphApiKeySchema, } from "@ensnode/ensnode-sdk/internal"; import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; -import ensDbConfig from "@/config/ensdb-config"; import type { EnsApiEnvironment } from "@/config/environment"; -import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations"; -import { ensDbClient } from "@/lib/ensdb/singleton"; import logger from "@/lib/logger"; import { ensApiVersionInfo } from "@/lib/version-info"; @@ -43,72 +38,28 @@ const ReferralProgramEditionConfigSetUrlSchema = z }) .optional(); -const EnsApiConfigSchema = z - .object({ - port: OptionalPortNumberSchema.default(ENSApi_DEFAULT_PORT), - theGraphApiKey: TheGraphApiKeySchema, - namespace: ENSNamespaceSchema, - rpcConfigs: RpcConfigsSchema, - ensIndexerPublicConfig: makeENSIndexerPublicConfigSchema("ensIndexerPublicConfig"), - referralProgramEditionConfigSetUrl: ReferralProgramEditionConfigSetUrlSchema, - - // include the ENSDbConfig params in the EnsApiConfigSchema - ensDbUrl: z.string(), - ensIndexerSchemaName: z.string(), - }) - .check(invariant_rpcConfigsSpecifiedForRootChain) - .check(invariant_ensIndexerPublicConfigVersionInfo); +const EnsApiConfigSchema = z.object({ + port: OptionalPortNumberSchema.default(ENSApi_DEFAULT_PORT), + theGraphApiKey: TheGraphApiKeySchema, + referralProgramEditionConfigSetUrl: ReferralProgramEditionConfigSetUrlSchema, +}); export type EnsApiConfig = z.infer; /** - * Builds the EnsApiConfig from an EnsApiEnvironment object, fetching the EnsIndexerPublicConfig. + * Builds the EnsApiConfig from an EnsApiEnvironment object. + * + * Note: If error occurs during parsing/validation, the error will be logged and the process + * will exit with code 1. * * @returns A validated EnsApiConfig object - * @throws Error with formatted validation messages if environment parsing fails */ -export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promise { +export function buildConfigFromEnvironment(env: EnsApiEnvironment): EnsApiConfig { try { - // TODO: transfer the responsibility of fetching - // the ENSIndexer Public Config to a middleware layer, as per: - // https://github.com/namehash/ensnode/issues/1806 - const ensIndexerPublicConfig = await pRetry( - async () => { - const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); - - if ( - indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized - ) { - throw new Error( - "EnsIndexerPublicConfig could not be fetched, the IndexingMetadataContext record has not been initialized in ENSDb yet.", - ); - } - - return indexingMetadataContext.stackInfo.ensIndexer; - }, - { - retries: 13, // This allows for a total of over 1 hour of retries with the exponential backoff strategy - onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { - logger.info( - `ENSIndexer Public Config fetch attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`, - ); - }, - }, - ); - - const rpcConfigs = buildRpcConfigsFromEnv(env, ensIndexerPublicConfig.namespace); - return EnsApiConfigSchema.parse({ port: env.PORT, theGraphApiKey: env.THEGRAPH_API_KEY, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs, referralProgramEditionConfigSetUrl: env.REFERRAL_PROGRAM_EDITIONS, - - // include the validated ENSDb config values in the parsed EnsApiConfig - ensDbUrl: ensDbConfig.ensDbUrl, - ensIndexerSchemaName: ensDbConfig.ensIndexerSchemaName, }); } catch (error) { if (error instanceof ZodError) { @@ -123,6 +74,45 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis } } +/** + * Builds the RPC config for the root chain based on the provided environment and ENS namespace ID. + * @param env - The environment variables for the ENSApi + * @param namespace - The ENS namespace ID + * @returns The RPC config for the root chain + * + * Note: If error occurs during parsing/validation, the error will be logged and the process + * will exit with code 1. + */ +export function buildRootChainRpcConfig( + env: EnsApiEnvironment, + namespace: ENSNamespaceId, +): RpcConfig { + try { + const unvalidatedRpcConfigs = buildRpcConfigsFromEnv(env, namespace); + const rootChainId = getENSRootChainId(namespace); + const rpcConfigs = RpcConfigsSchema.parse(unvalidatedRpcConfigs); + const rootChainRpcConfig = rpcConfigs.get(rootChainId); + + if (!rootChainRpcConfig) { + throw new Error( + `RPC configuration for root chain (chainId: ${rootChainId}) is required but was not found in the environment variables.`, + ); + } + + return rootChainRpcConfig; + } catch (error) { + if (error instanceof ZodError) { + logger.error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`); + } else if (error instanceof Error) { + logger.error(error, `Failed to build the root chain RPC config`); + } else { + logger.error(`Unknown Error`); + } + + process.exit(1); + } +} + /** * Builds the ENSApi public configuration from an EnsApiConfig object. * diff --git a/apps/ensapi/src/config/index.ts b/apps/ensapi/src/config/index.ts deleted file mode 100644 index f8eab6ac2c..0000000000 --- a/apps/ensapi/src/config/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EnsApiConfig } from "@/config/config.schema"; -import { buildConfigFromEnvironment } from "@/config/config.schema"; - -let _config: EnsApiConfig | null = null; - -/** - * Initializes the global config from environment variables. - * Must be called before any config property is accessed (i.e. as the first - * statement in index.ts, before the server starts). - */ -export async function initEnvConfig(env: NodeJS.ProcessEnv): Promise { - _config = await buildConfigFromEnvironment(env); -} - -/** - * Lazy config proxy — defers access to the underlying config object until it - * has been initialized via initConfig(). - * - * This allows app.ts (and all route/handler modules it imports) to be loaded - * at module evaluation time without requiring env vars to be present. That - * property is essential for the OpenAPI generation script, which imports app.ts - * to introspect routes but never starts the server or calls initConfig(). - * - * Any attempt to read a config property before initConfig() is called will - * throw a descriptive error. Use @/lib/lazy to defer config-dependent - * initialization in modules that are evaluated at import time. - */ -export default new Proxy({} as EnsApiConfig, { - get(_, prop: string | symbol) { - if (_config === null) { - throw err(prop); - } - return _config[prop as keyof EnsApiConfig]; - }, - has(_, prop: string | symbol) { - if (_config === null) { - throw err(prop); - } - return prop in _config; - }, -}); - -const err = (prop: string | symbol) => { - throw new Error( - `Config not initialized — call initConfig() before accessing config.${String(prop)} - Probably you access config in top level of the module. Use @/lib/lazy for lazy loading dependencies.`, - ); -}; diff --git a/apps/ensapi/src/config/redact.ts b/apps/ensapi/src/config/redact.ts deleted file mode 100644 index f21d376d4c..0000000000 --- a/apps/ensapi/src/config/redact.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { redactRpcConfigs, redactString, redactUrl } from "@ensnode/ensnode-sdk/internal"; - -import type { EnsApiConfig } from "@/config/config.schema"; - -/** - * Redact sensitive values from ENSApi configuration. - */ -export function redactEnsApiConfig(config: EnsApiConfig) { - return { - port: config.port, - namespace: config.namespace, - referralProgramEditionConfigSetUrl: config.referralProgramEditionConfigSetUrl - ? redactUrl(config.referralProgramEditionConfigSetUrl) - : undefined, - ensIndexerPublicConfig: config.ensIndexerPublicConfig, - ensDbUrl: redactString(config.ensDbUrl), - rpcConfigs: redactRpcConfigs(config.rpcConfigs), - ensIndexerSchemaName: config.ensIndexerSchemaName, - theGraphApiKey: config.theGraphApiKey ? redactString(config.theGraphApiKey) : undefined, - }; -} diff --git a/apps/ensapi/src/config/validations.ts b/apps/ensapi/src/config/validations.ts deleted file mode 100644 index b091e21085..0000000000 --- a/apps/ensapi/src/config/validations.ts +++ /dev/null @@ -1,59 +0,0 @@ -import packageJson from "@/../package.json" with { type: "json" }; - -import type { ENSIndexerPublicConfig } from "@ensnode/ensnode-sdk"; -import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; - -import { ensApiVersionInfo } from "@/lib/version-info"; - -// Invariant: ENSIndexerPublicConfig VersionInfo must match ENSApi -export function invariant_ensIndexerPublicConfigVersionInfo( - ctx: ZodCheckFnInput<{ - ensIndexerPublicConfig: ENSIndexerPublicConfig; - }>, -) { - const { - value: { ensIndexerPublicConfig }, - } = ctx; - - // Invariant: ENSApi & ENSDB must match version numbers - if (ensIndexerPublicConfig.versionInfo.ensDb !== packageJson.version) { - ctx.issues.push({ - code: "custom", - path: ["ensIndexerPublicConfig.versionInfo.ensDb"], - input: ensIndexerPublicConfig.versionInfo.ensDb, - message: `Version Mismatch: ENSDB@${ensIndexerPublicConfig.versionInfo.ensDb} !== ENSApi@${packageJson.version}`, - }); - } - - // Invariant: ENSApi & ENSIndexer must match version numbers - if (ensIndexerPublicConfig.versionInfo.ensIndexer !== packageJson.version) { - ctx.issues.push({ - code: "custom", - path: ["ensIndexerPublicConfig.versionInfo.ensIndexer"], - input: ensIndexerPublicConfig.versionInfo.ensIndexer, - message: `Version Mismatch: ENSIndexer@${ensIndexerPublicConfig.versionInfo.ensIndexer} !== ENSApi@${packageJson.version}`, - }); - } - - // Invariant: ENSApi & ENSRainbow must match version numbers - if ( - ensIndexerPublicConfig.ensRainbowPublicConfig.versionInfo.ensRainbow !== packageJson.version - ) { - ctx.issues.push({ - code: "custom", - path: ["ensIndexerPublicConfig.ensRainbowPublicConfig.versionInfo.ensRainbow"], - input: ensIndexerPublicConfig.ensRainbowPublicConfig.versionInfo.ensRainbow, - message: `Version Mismatch: ENSRainbow@${ensIndexerPublicConfig.ensRainbowPublicConfig.versionInfo.ensRainbow} !== ENSApi@${packageJson.version}`, - }); - } - - // Invariant: `@adraffy/ens-normalize` package version must match between ENSApi & ENSIndexer - if (ensIndexerPublicConfig.versionInfo.ensNormalize !== ensApiVersionInfo.ensNormalize) { - ctx.issues.push({ - code: "custom", - path: ["ensIndexerPublicConfig.versionInfo.ensNormalize"], - input: ensIndexerPublicConfig.versionInfo.ensNormalize, - message: `Dependency Version Mismatch: '@adraffy/ens-normalize' version must be the same between ENSIndexer and ENSApi. Found ENSApi@${ensApiVersionInfo.ensNormalize} and ENSIndexer@${ensIndexerPublicConfig.versionInfo.ensNormalize}`, - }); - } -} diff --git a/apps/ensapi/src/di.ts b/apps/ensapi/src/di.ts new file mode 100644 index 0000000000..8293f8fb33 --- /dev/null +++ b/apps/ensapi/src/di.ts @@ -0,0 +1,321 @@ +import type { ChainId } from "enssdk"; +import { createPublicClient, fallback, http, type PublicClient } from "viem"; + +import { type ENSNamespaceId, getENSRootChainId } from "@ensnode/datasources"; +import type { EnsDbConfig, EnsDbReader } from "@ensnode/ensdb-sdk"; +import type { EnsNodeStackInfo } from "@ensnode/ensnode-sdk"; +import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; + +import { type IndexingStatusCache, indexingStatusCache } from "@/cache/indexing-status.cache"; +import { + type ReferralProgramEditionConfigSetCache, + referralProgramEditionConfigSetCache, +} from "@/cache/referral-program-edition-set.cache"; +import type { EnsNodeStackInfoCache } from "@/cache/stack-info.cache"; +import { stackInfoCache } from "@/cache/stack-info.cache"; +import type { EnsApiConfig } from "@/config/config.schema"; +import { buildConfigFromEnvironment, buildRootChainRpcConfig } from "@/config/config.schema"; +import ensDbConfig from "@/config/ensdb-config"; +import type { EnsApiEnvironment } from "@/config/environment"; +import { ensDbClient } from "@/lib/ensdb/singleton"; +import { makeLogger } from "@/lib/logger"; + +const logger = makeLogger("di"); + +/** + * Dependency Injection Container for ENSApi. + */ +export interface EnsApiDiContext { + /** + * The ENSApi config. + */ + ensApiConfig: EnsApiConfig; + + /** + * The ENSDb config to be used by ENSApi. + */ + ensDbConfig: EnsDbConfig; + + /** + * The ENSDb client to be used by ENSApi for ENSDb access. + */ + ensDbClient: EnsDbReader; + + /** + * Alias for {@link ensDbClient.ensDb} to simplify access to the actual database connection. + */ + ensDb: EnsDbReader["ensDb"]; + + /** + * Alias for {@link ensDbClient.ensIndexerSchema} to simplify access to the ENSIndexer Schema. + */ + ensIndexerSchema: EnsDbReader["ensIndexerSchema"]; + + /** + * The ENS Namespace ID used by the ENSApi instance. + */ + namespace: ENSNamespaceId; + + /** + * Chain ID of the ENS Root Chain for the {@link namespace}. + */ + rootChainId: ChainId; + + /** + * RPC config for the ENS Root Chain. + */ + rootChainRpcConfig: RpcConfig; + + /** + * A cached instance of viem's {@link PublicClient} for the ENS Root Chain. + */ + rootChainPublicClient: PublicClient; + + /** + * Singleton {@link IndexingStatusCache} instance to be used across ENSApi. + */ + indexingStatusCache: IndexingStatusCache; + + /** + * Singleton {@link ReferralProgramEditionConfigSetCache} instance to be used across ENSApi. + */ + referralProgramEditionConfigSetCache: ReferralProgramEditionConfigSetCache; + + /** + * Singleton {@link EnsNodeStackInfoCache} instance to be used across ENSApi. + */ + stackInfoCache: EnsNodeStackInfoCache; + + /** + * Synchronous getter for {@link EnsNodeStackInfo} that reads from the {@link stackInfoCache}. + */ + stackInfo: EnsNodeStackInfo; +} + +export function buildEnsApiDiContext(ensApiEnvironment: EnsApiEnvironment): EnsApiDiContext { + const instances = {} as EnsApiDiContext; + + const context = { + get ensApiConfig(): EnsApiConfig { + if (instances.ensApiConfig === undefined) { + instances.ensApiConfig = buildConfigFromEnvironment(ensApiEnvironment); + } + + return instances.ensApiConfig; + }, + + get ensDbConfig(): EnsDbConfig { + if (instances.ensDbConfig === undefined) { + instances.ensDbConfig = ensDbConfig; + } + return instances.ensDbConfig; + }, + + get ensDbClient(): EnsDbReader { + return ensDbClient; + }, + + get ensDb(): EnsDbReader["ensDb"] { + return context.ensDbClient.ensDb; + }, + + get ensIndexerSchema(): EnsDbReader["ensIndexerSchema"] { + return context.ensDbClient.ensIndexerSchema; + }, + + get namespace(): ENSNamespaceId { + return context.stackInfo.ensIndexer.namespace; + }, + + get rootChainRpcConfig(): RpcConfig { + if (instances.rootChainRpcConfig === undefined) { + instances.rootChainRpcConfig = buildRootChainRpcConfig( + ensApiEnvironment, + context.namespace, + ); + } + + return instances.rootChainRpcConfig; + }, + + get rootChainId(): ChainId { + if (instances.rootChainId === undefined) { + instances.rootChainId = getENSRootChainId(context.namespace); + } + + return instances.rootChainId; + }, + + get rootChainPublicClient(): PublicClient { + if (instances.rootChainPublicClient === undefined) { + // Create an viem#PublicClient that uses a fallback() transport with all specified HTTP RPCs + instances.rootChainPublicClient = createPublicClient({ + transport: fallback( + context.rootChainRpcConfig.httpRPCs.map((url) => http(url.toString())), + ), + }); + } + + return instances.rootChainPublicClient; + }, + + get indexingStatusCache(): IndexingStatusCache { + if (instances.indexingStatusCache === undefined) { + instances.indexingStatusCache = indexingStatusCache; + } + + return instances.indexingStatusCache; + }, + + get referralProgramEditionConfigSetCache(): ReferralProgramEditionConfigSetCache { + if (instances.referralProgramEditionConfigSetCache === undefined) { + instances.referralProgramEditionConfigSetCache = referralProgramEditionConfigSetCache; + } + + return instances.referralProgramEditionConfigSetCache; + }, + + get stackInfoCache(): EnsNodeStackInfoCache { + if (instances.stackInfoCache === undefined) { + instances.stackInfoCache = stackInfoCache; + } + + return instances.stackInfoCache; + }, + + /** + * Synchronous getter for stack info that reads from the stackInfoCache. + * + * Note: This assumes that the stack info has already been loaded into the cache + * (e.g. by calling `di.context.stackInfoCache.read()` during ENSApi startup). + */ + get stackInfo(): EnsNodeStackInfo { + const stackInfo = context.stackInfoCache.peek(); + + if (stackInfo instanceof Error) { + throw stackInfo; + } + + return stackInfo; + }, + } satisfies EnsApiDiContext; + + return context; +} + +/** + * Dependency Injection Container class for ENSApi + * + * It allows for lazy loading of the DI context and provides methods to + * initialize and destroy resources as needed. + * + * The lifecycle of the DI container is managed manually by calling + * the `init()` and `destroy()` methods, which allows for flexibility + * in when resources are initialized and cleaned up, such as during application + * startup and shutdown. + * + * @example + * ```ts + * const di = new EnsApiDiContainer(); + * di.init(); // Initializes the DI context and any necessary resources + * const namespace = di.context.namespace; // Access a member of the DI context + * di.destroy(); // Clean up resources when they are no longer needed + * ``` + */ +class EnsApiDiContainer { + private _context: EnsApiDiContext | undefined; + /** + * The DI context for ENSApi, which is lazily loaded on first access. + * + * Note: the context can be re-loaded by calling {@link di.loadContext()}. + */ + get context(): EnsApiDiContext { + if (!this._context) { + throw new Error( + "DI context has not been loaded yet. Call `di.init()` to load the context and initialize necessary resources.", + ); + } + return this._context; + } + + /** + * Initializes the DI container by loading the context and initializing + * necessary resources. + */ + init(): void { + if (this._context) { + throw new Error( + "DI context has already been initialized. If you want to re-initialize, call `di.destroy()` first to clean up resources.", + ); + } + + // Load the DI context + this.loadContext(); + + logger.info("Initializing caches"); + void Promise.all([ + this.context.indexingStatusCache.read(), + this.context.stackInfoCache.read(), + this.context.referralProgramEditionConfigSetCache.read(), + ]).then(() => logger.info("Caches initialized")); + } + + /** + * Destroys any resources held by the DI container, such as caches, to + * allow for clean shutdown or re-initialization. + */ + destroy(): void { + if (!this._context) { + logger.warn( + "DI context is not loaded, so there are no resources to destroy. If you are trying to reload the context, call `di.init()` to load the context and initialize necessary resources.", + ); + + return; + } + + logger.info("Destroying caches"); + this.context.stackInfoCache.destroy(); + this.context.indexingStatusCache.destroy(); + this.context.referralProgramEditionConfigSetCache.destroy(); + logger.info("Caches destroyed"); + + this._context = undefined; + } + + /** + * Loads the DI context by building it from the environment variables and + * freezing it to prevent modification at runtime. + * + * Note: useful for testing purposes to reset the DI context between tests, + * or during hot-reloading in development to reload the context. + * + * @throws Error if the context has already been loaded to prevent accidental + * overwriting of the context. Call `di.destroy()` first to clean up + * resources if you want to reload the context. + */ + private loadContext(): void { + if (this._context) { + throw new Error( + "DI context has already been loaded. If you want to reload the context, call `di.destroy()` first to clean up resources.", + ); + } + + logger.info("Loading context"); + + // Load the current environment variables into the DI context + // and freeze the context to prevent modification at runtime + this._context = Object.freeze(buildEnsApiDiContext(process.env)); + + logger.info( + { context: Object.keys(this.context) }, + "Context loaded, available members at `di.context` are", + ); + } +} + +/** + * The singleton instance of the {@link EnsApiDiContainer} for ENSApi. + */ +const di = new EnsApiDiContainer(); + +export default di; diff --git a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts index 4c96521dcb..cb2b1d1f41 100644 --- a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { asInterpretedName, getParentInterpretedName, @@ -18,8 +16,8 @@ import { serializeNameTokensResponse, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { createApp } from "@/lib/hono-factory"; -import { lazyProxy } from "@/lib/lazy"; import { findRegisteredNameTokensForDomain } from "@/lib/name-tokens/find-name-tokens-for-domain"; import { getIndexedSubregistries } from "@/lib/name-tokens/get-indexed-subregistries"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; @@ -29,12 +27,6 @@ import { getNameTokensRoute } from "./name-tokens-api.routes"; const app = createApp({ middlewares: [indexingStatusMiddleware, nameTokensApiMiddleware] }); -// lazyProxy defers construction until first use so that this module can be -// imported without env vars being present (e.g. during OpenAPI generation). -const indexedSubregistries = lazyProxy(() => - getIndexedSubregistries(config.namespace, config.ensIndexerPublicConfig.plugins as PluginName[]), -); - /** * Factory function for creating a 404 Name Tokens Not Indexed error response */ @@ -85,6 +77,10 @@ app.openapi(getNameTokensRoute, async (c) => { ); } + const indexedSubregistries = getIndexedSubregistries( + di.context.namespace, + di.context.stackInfo.ensIndexer.plugins as PluginName[], + ); const parentNode = namehashInterpretedName(parentName); const subregistry = indexedSubregistries.find((s) => s.node === parentNode); diff --git a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts index ec2d40362c..563e31c05e 100644 --- a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts +++ b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts @@ -1,17 +1,16 @@ -import config from "@/config"; - import { hasOmnigraphApiConfigSupport, hasOmnigraphApiIndexingStatusSupport, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { createApp } from "@/lib/hono-factory"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; const app = createApp({ middlewares: [indexingStatusMiddleware] }); app.use(async (c, next) => { - const configPrerequisite = hasOmnigraphApiConfigSupport(config.ensIndexerPublicConfig); + const configPrerequisite = hasOmnigraphApiConfigSupport(di.context.stackInfo.ensIndexer); // 503 if Omnigraph API is not available due to config prerequisites not met if (!configPrerequisite.supported) { return c.text(`Service Unavailable: ${configPrerequisite.reason}`, 503); diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts index 7b97784290..b2b4ab7750 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts @@ -1,22 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ENSNamespaceIds } from "@ensnode/datasources"; - -import type { EnsApiConfig } from "@/config/config.schema"; import * as ensanalyticsMiddleware from "@/middleware/ensanalytics.middleware"; import * as editionsCachesMiddleware from "@/middleware/referral-edition-snapshots-caches.middleware"; import * as editionSetMiddleware from "@/middleware/referral-program-edition-set.middleware"; -vi.mock("@/config", () => ({ - get default() { - const mockedConfig: Pick = { - namespace: ENSNamespaceIds.Mainnet, - }; - - return mockedConfig; - }, -})); - vi.mock("@/middleware/referral-program-edition-set.middleware", () => ({ referralProgramEditionConfigSetMiddleware: vi.fn(), })); diff --git a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts index 97fa5ed225..4b401c3300 100644 --- a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts @@ -1,23 +1,14 @@ -import config from "@/config"; - import type { Duration } from "enssdk"; import { createDocumentationMiddleware } from "ponder-enrich-gql-docs-middleware"; -// FIXME: use the import from: -// import { ensIndexerSchema } from "@/lib/ensdb/singleton"; -// Once the lazy proxy implemented for `ensIndexerSchema` export is improved -// to support Drizzle ORM in `ponder-subgraph` package. -import * as ensIndexerSchema from "@ensnode/ensdb-sdk/ensindexer-abstract"; import { hasSubgraphApiConfigSupport, hasSubgraphApiIndexingStatusSupport, } from "@ensnode/ensnode-sdk"; -import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; +import di from "@/di"; import { createApp } from "@/lib/hono-factory"; -import { lazy } from "@/lib/lazy"; import { makeSubgraphApiDocumentation } from "@/lib/subgraph/api-documentation"; -import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; import { fixContentLengthMiddleware } from "@/middleware/fix-content-length.middleware"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; @@ -26,13 +17,10 @@ import { thegraphFallbackMiddleware } from "@/middleware/thegraph-fallback.middl const MAX_REALTIME_DISTANCE_TO_RESOLVE: Duration = 10 * 60; // 10 minutes in seconds -// generate a subgraph-specific subset of the schema -const subgraphSchema = filterSchemaByPrefix("subgraph_", ensIndexerSchema); - const app = createApp({ middlewares: [indexingStatusMiddleware] }); app.use(async (c, next) => { - const configPrerequisite = hasSubgraphApiConfigSupport(config.ensIndexerPublicConfig); + const configPrerequisite = hasSubgraphApiConfigSupport(di.context.stackInfo.ensIndexer); // 503 if Subgraph API is not available due to config prerequisites not met if (!configPrerequisite.supported) { return c.text(`Service Unavailable: ${configPrerequisite.reason}`, 503); @@ -70,53 +58,18 @@ app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/ // inject _meta into the hono (and yoga) context for the subgraph middleware app.use(subgraphMetaMiddleware); -// lazy() defers construction until first use so that this module can be -// imported without env vars being present (e.g. during OpenAPI generation). -const getSubgraphMiddleware = lazy(() => - subgraphGraphQLMiddleware({ - databaseUrl: config.ensDbUrl, - databaseSchema: config.ensIndexerSchemaName, - schema: subgraphSchema, - // describes the polymorphic (interface) relationships in the schema - polymorphicConfig: { - types: { - DomainEvent: [ - subgraphSchema.transfer, - subgraphSchema.newOwner, - subgraphSchema.newResolver, - subgraphSchema.newTTL, - subgraphSchema.wrappedTransfer, - subgraphSchema.nameWrapped, - subgraphSchema.nameUnwrapped, - subgraphSchema.fusesSet, - subgraphSchema.expiryExtended, - ], - RegistrationEvent: [ - subgraphSchema.nameRegistered, - subgraphSchema.nameRenewed, - subgraphSchema.nameTransferred, - ], - ResolverEvent: [ - subgraphSchema.addrChanged, - subgraphSchema.multicoinAddrChanged, - subgraphSchema.nameChanged, - subgraphSchema.abiChanged, - subgraphSchema.pubkeyChanged, - subgraphSchema.textChanged, - subgraphSchema.contenthashChanged, - subgraphSchema.interfaceChanged, - subgraphSchema.authorisationChanged, - subgraphSchema.versionChanged, - ], - }, - fields: { - "Domain.events": "DomainEvent", - "Registration.events": "RegistrationEvent", - "Resolver.events": "ResolverEvent", - }, - }, - }), -); -app.use((c, next) => getSubgraphMiddleware()(c, next)); +// inject the GraphQL middleware for the Subgraph API +app.use(async (c, next) => { + // Note: we import the middleware with a dynamic import to defer its construction until runtime. + // This allows the middleware internal logic to only access the DI container at runtime, + // which prevents potential issues with eager evaluation of the DI container's dependencies. + // Thanks to the dynamic import, the `gql.middleware` module to be resolved just once, + // and reused for subsequent requests. + const subgraphApiGqlMiddleware = await import("@/lib/subgraph/gql.middleware").then( + (mod) => mod.default, + ); + + return subgraphApiGqlMiddleware(c, next); +}); export default app; diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index e3255f0121..d8e660ed8c 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -1,11 +1,7 @@ -import config, { initEnvConfig } from "@/config"; - import { serve } from "@hono/node-server"; -import { indexingStatusCache } from "@/cache/indexing-status.cache"; import { getReferralEditionSnapshotsCaches } from "@/cache/referral-edition-snapshots.cache"; -import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache"; -import { redactEnsApiConfig } from "@/config/redact"; +import di from "@/di"; import { sdk } from "@/lib/instrumentation"; import logger from "@/lib/logger"; import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; @@ -13,28 +9,25 @@ import { writeGraphQLSchema } from "@/omnigraph-api/lib/write-graphql-schema"; import app from "./app"; -await initEnvConfig(process.env); - // start ENSNode API OpenTelemetry SDK sdk.start(); +// initialize DI container and its resources +di.init(); // start hono server const server = serve( { fetch: app.fetch, - port: config.port, + port: di.context.ensApiConfig.port, }, async (info) => { - logger.info({ config: redactEnsApiConfig(config) }, `ENSApi listening on port ${info.port}`); - // Write the generated graphql schema in the background. Skipped when // a) in production, or // b) dev methods are enabled (to avoid dirty schema diff) const shouldWriteSchema = !(process.env.NODE_ENV === "production") && !INCLUDE_DEV_METHODS; if (shouldWriteSchema) void writeGraphQLSchema(); - // proactively read the indexing status to warm cache - void indexingStatusCache.read(); + logger.info(`ENSApi listening on port ${info.port}`); }, ); @@ -50,13 +43,14 @@ const closeServer = () => // perform graceful shutdown const gracefulShutdown = async () => { try { + // Close the server to stop accepting new requests + await closeServer(); + logger.info("Closed application server"); + + // Shutdown the OpenTelemetry SDK to flush any remaining spans await sdk.shutdown(); logger.info("Destroyed tracing instrumentation"); - // Destroy referral program edition config set cache - referralProgramEditionConfigSetCache.destroy(); - logger.info("Destroyed referralProgramEditionConfigSetCache"); - // Destroy all edition caches (if initialized) const editionsCaches = getReferralEditionSnapshotsCaches(); if (editionsCaches) { @@ -66,11 +60,8 @@ const gracefulShutdown = async () => { } } - indexingStatusCache.destroy(); - logger.info("Destroyed indexingStatusCache"); - - await closeServer(); - logger.info("Closed application server"); + // Destroy DI container resources + di.destroy(); process.exit(0); } catch (error) { diff --git a/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts b/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts index 3b4b6ca956..e56981302a 100644 --- a/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts +++ b/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { eq } from "drizzle-orm/sql"; import { type AccountId, asInterpretedName, type Node, type UnixTimestamp } from "enssdk"; @@ -13,6 +11,7 @@ import { type RegisteredNameTokens, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; interface FindRegisteredNameTokensForDomainRecord { @@ -103,7 +102,7 @@ function _recordsToRegisteredNameTokens( } satisfies AccountId; // biome-ignore lint/style/noNonNullAssertion: domain.name guaranteed to exist const name = asInterpretedName(record.domains.name!); - const ownership = getNameTokenOwnership(config.namespace, name, owner); + const ownership = getNameTokenOwnership(di.context.namespace, name, owner); const token = _recordToNameToken(record, ownership); const expiresAt = bigIntToNumber(record.registrationLifecycles.expiresAt); diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index 5511f804bd..d009afa039 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { bytesToPacket } from "@ensdomains/ensjs/utils"; import { SpanStatusCode, trace } from "@opentelemetry/api"; import { @@ -18,6 +16,7 @@ import { packetToBytes } from "viem/ens"; import { DatasourceNames, getDatasource } from "@ensnode/datasources"; import { accountIdEqual, isENSv1Registry } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { ensDb } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; @@ -71,9 +70,9 @@ export async function findResolver({ } // Invariant: UniversalResolver#findResolver only works for ENS Root Registry - if (!isENSv1Registry(config.namespace, registry)) { + if (!isENSv1Registry(di.context.namespace, registry)) { throw new Error( - `Invariant(findResolver): UniversalResolver#findResolver only identifies active resolvers agains the ENs Root Registry, but a different Registry contract was passed: ${JSON.stringify(registry)}.`, + `Invariant(findResolver): UniversalResolver#findResolver only identifies active resolvers against the ENS Root Registry, but a different Registry contract was passed: ${JSON.stringify(registry)}.`, ); } @@ -98,7 +97,7 @@ async function findResolverWithUniversalResolver( contracts: { UniversalResolver: { address, abi }, }, - } = getDatasource(config.namespace, DatasourceNames.ENSRoot); + } = getDatasource(di.context.namespace, DatasourceNames.ENSRoot); // 2. Call UniversalResolver#findResolver via RPC const dnsEncodedNameBytes = packetToBytes(name); diff --git a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts index 43da88b1b5..4c68cd4493 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts @@ -1,10 +1,9 @@ -import config from "@/config"; - import { type AccountId, DEFAULT_EVM_COIN_TYPE, type Node } from "enssdk"; import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; import { staticResolverImplementsAddressRecordDefaulting } from "@ensnode/ensnode-sdk/internal"; +import di from "@/di"; import { ensDb } from "@/lib/ensdb/singleton"; const DEFAULT_EVM_COIN_TYPE_BIGINT = BigInt(DEFAULT_EVM_COIN_TYPE); @@ -41,7 +40,7 @@ export async function getRecordsFromIndex(); - -/** - * Gets a viem#PublicClient for the specified `chainId` using the ENSApiConfig's RPCConfig. Caches - * the instance itself to minimize unnecessary allocations. - */ -export function getPublicClient(chainId: ChainId): PublicClient { - // Invariant: ENSApi must have an rpcConfig for the requested `chainId` - const rpcConfig = config.rpcConfigs.get(chainId); - if (!rpcConfig) { - throw new Error(`Invariant: ENSApi does not have an RPC to chain id '${chainId}'.`); - } - - if (!_cache.has(chainId)) { - _cache.set( - chainId, - // Create an viem#PublicClient that uses a fallback() transport with all specified HTTP RPCs - createPublicClient({ - transport: fallback(rpcConfig.httpRPCs.map((url) => http(url.toString()))), - }), - ); - } - - const publicClient = _cache.get(chainId); - - // publicClient guaranteed to exist due to cache-setting logic above - if (!publicClient) throw new Error("never"); - - return publicClient; -} diff --git a/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts index 33ba525d6c..9e80098553 100644 --- a/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts +++ b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts @@ -1,14 +1,20 @@ +import { createPublicClient, http } from "viem"; import { vi } from "vitest"; -import { ENSNamespaceIds, ensTestEnvChain } from "@ensnode/datasources"; +import { ENSNamespaceIds } from "@ensnode/datasources"; // we're testing a function specifically, not fetching through the running ensapi instance, so // we need to mock the config when this worker process attempts to import ./execute-operations // (and this is an integration test because we want to RPC fetch against the running devnet) -vi.mock("@/config", () => ({ +vi.mock("@/di", () => ({ default: { - namespace: ENSNamespaceIds.EnsTestEnv, - rpcConfigs: new Map([[ensTestEnvChain.id, { httpRPCs: [new URL("http://localhost:8545")] }]]), + context: { + get rootChainPublicClient() { + return createPublicClient({ + transport: http("http://localhost:8545"), + }); + }, + }, }, })); @@ -26,7 +32,7 @@ import { describe, expect, it } from "vitest"; import { DatasourceNames } from "@ensnode/datasources"; import { getDatasourceContract } from "@ensnode/ensnode-sdk"; -import { getPublicClient } from "@/lib/public-client"; +import di from "@/di"; import { executeOperations } from "@/lib/resolution/execute-operations"; import { makeOperations } from "@/lib/resolution/operations"; @@ -38,7 +44,7 @@ const NAME_WITH_ENCODED_LABELHASHES = interpretedLabelsToInterpretedName([ const EXPECTED_DESCRIPTION = "example.eth"; -const publicClient = getPublicClient(ensTestEnvChain.id); +const publicClient = di.context.rootChainPublicClient; const UniversalResolverV2 = getDatasourceContract( ENSNamespaceIds.EnsTestEnv, diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 75a21b3046..7d7e9dd409 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { trace } from "@opentelemetry/api"; import { type AccountId, @@ -30,12 +28,11 @@ import { isStaticResolver, } from "@ensnode/ensnode-sdk/internal"; +import di from "@/di"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; -import { lazy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; import { findResolver } from "@/lib/protocol-acceleration/find-resolver"; import { areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId } from "@/lib/protocol-acceleration/resolver-records-indexed-on-chain"; -import { getPublicClient } from "@/lib/public-client"; import { accelerateENSIP19ReverseResolver } from "@/lib/resolution/accelerate-ensip19-reverse-resolver"; import { accelerateKnownOnchainStaticResolver } from "@/lib/resolution/accelerate-known-onchain-static-resolver"; import { executeOperations } from "@/lib/resolution/execute-operations"; @@ -49,14 +46,6 @@ import { const logger = makeLogger("forward-resolution"); const tracer = trace.getTracer("forward-resolution"); -const getUniversalResolverV1 = lazy(() => - getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolver"), -); - -const getUniversalResolverV2 = lazy(() => - maybeGetDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolverV2"), -); - /** * Implements Forward Resolution of record values for a specified ENS Name. * @@ -98,7 +87,7 @@ export async function resolveForward // initially be ENS Root Registry: see `_resolveForward` for additional context. return _resolveForward(interpretedName, selection, { ...options, - registry: getENSv1RootRegistry(config.namespace), + registry: getENSv1RootRegistry(di.context.namespace), }); } @@ -168,7 +157,7 @@ async function _resolveForward( // if no operations were generated, this was an empty selection; give them what they asked for if (operations.length === 0) return makeRecordsResponse(operations); - const publicClient = getPublicClient(chainId); + const publicClient = di.context.rootChainPublicClient; //////////////////////////// /// 0. Temporary ENSv2 Bailout @@ -177,9 +166,21 @@ async function _resolveForward( // NOTE: gate on the namespace containing an ENSv2Root datasource rather than the ENSv2 // plugin being configured — a namespace may be ENSv1-only even when the Unigraph plugin is // defined, and forward resolution must follow the ENSv1 path in that case. - if (maybeGetDatasource(config.namespace, DatasourceNames.ENSv2Root)) { + if (maybeGetDatasource(di.context.namespace, DatasourceNames.ENSv2Root)) { + const universalResolverV1 = getDatasourceContract( + di.context.namespace, + DatasourceNames.ENSRoot, + "UniversalResolver", + ); + + const universalResolverV2 = maybeGetDatasourceContract( + di.context.namespace, + DatasourceNames.ENSRoot, + "UniversalResolverV2", + ); + const UniversalResolverAddress = - getUniversalResolverV2()?.address ?? getUniversalResolverV1().address; + universalResolverV2?.address ?? universalResolverV1.address; operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.ExecuteResolveCalls, @@ -241,7 +242,7 @@ async function _resolveForward( ///////////////////////////////////// if (accelerate && canAccelerate) { const resolver = { chainId, address: activeResolver }; - const bridged = isBridgedResolver(config.namespace, resolver); + const bridged = isBridgedResolver(di.context.namespace, resolver); if (bridged) { return withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, @@ -270,7 +271,7 @@ async function _resolveForward( const resolver = { chainId, address: activeResolver }; // Pass: ENSIP-19 Reverse Resolver - if (isKnownENSIP19ReverseResolver(config.namespace, resolver)) { + if (isKnownENSIP19ReverseResolver(di.context.namespace, resolver)) { operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateENSIP19ReverseResolver, @@ -282,10 +283,10 @@ async function _resolveForward( // Pass: Known On-Chain Static Resolver with indexed records const resolverRecordsAreIndexed = areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId( - config.namespace, + di.context.namespace, chainId, ); - if (resolverRecordsAreIndexed && isStaticResolver(config.namespace, resolver)) { + if (resolverRecordsAreIndexed && isStaticResolver(di.context.namespace, resolver)) { operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, diff --git a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts index 930b0142e9..dc433dccbd 100644 --- a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts +++ b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts @@ -1,7 +1,4 @@ -import config from "@/config"; - import { trace } from "@opentelemetry/api"; -import type { ChainId } from "enssdk"; import { mainnet } from "viem/chains"; import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; @@ -11,31 +8,31 @@ import { uniq, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; -import { lazy } from "@/lib/lazy"; import { resolveReverse } from "@/lib/resolution/reverse-resolution"; const tracer = trace.getTracer("multichain-primary-name-resolution"); -const getENSIP19SupportedChainIds = lazy(() => [ - // always include Mainnet, because its chainId corresponds to the ENS Root Chain's coinType, - // regardless of the current namespace - mainnet.id, +const getENSIP19SupportedChainIds = () => { + return uniq([ + // always include Mainnet, because its chainId corresponds to the ENS Root Chain's coinType, + // regardless of the current namespace + mainnet.id, - // then include any ENSIP-19 Supported Chains defined in this namespace - ...uniq( - [ - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverRoot), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverBase), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverLinea), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverOptimism), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverArbitrum), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverScroll), + // then include any ENSIP-19 Supported Chains defined in this namespace + ...[ + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverRoot), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverBase), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverLinea), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverOptimism), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverArbitrum), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverScroll), ] .filter((ds) => ds !== undefined) .map((ds) => ds.chain.id), - ), -]); + ]); +}; /** * Implements batch resolution of an address' Primary Name across the provided `chainIds`. diff --git a/apps/ensapi/src/lib/subgraph/gql.middleware.ts b/apps/ensapi/src/lib/subgraph/gql.middleware.ts new file mode 100644 index 0000000000..57ee2e4a0f --- /dev/null +++ b/apps/ensapi/src/lib/subgraph/gql.middleware.ts @@ -0,0 +1,51 @@ +import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; + +import di from "@/di"; +import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; + +// generate a subgraph-specific subset of the schema +const subgraphSchema = filterSchemaByPrefix("subgraph_", di.context.ensIndexerSchema); + +export default subgraphGraphQLMiddleware({ + databaseUrl: di.context.ensDbConfig.ensDbUrl, + databaseSchema: di.context.ensDbConfig.ensIndexerSchemaName, + schema: subgraphSchema, + // describes the polymorphic (interface) relationships in the schema + polymorphicConfig: { + types: { + DomainEvent: [ + subgraphSchema.transfer, + subgraphSchema.newOwner, + subgraphSchema.newResolver, + subgraphSchema.newTTL, + subgraphSchema.wrappedTransfer, + subgraphSchema.nameWrapped, + subgraphSchema.nameUnwrapped, + subgraphSchema.fusesSet, + subgraphSchema.expiryExtended, + ], + RegistrationEvent: [ + subgraphSchema.nameRegistered, + subgraphSchema.nameRenewed, + subgraphSchema.nameTransferred, + ], + ResolverEvent: [ + subgraphSchema.addrChanged, + subgraphSchema.multicoinAddrChanged, + subgraphSchema.nameChanged, + subgraphSchema.abiChanged, + subgraphSchema.pubkeyChanged, + subgraphSchema.textChanged, + subgraphSchema.contenthashChanged, + subgraphSchema.interfaceChanged, + subgraphSchema.authorisationChanged, + subgraphSchema.versionChanged, + ], + }, + fields: { + "Domain.events": "DomainEvent", + "Registration.events": "RegistrationEvent", + "Resolver.events": "ResolverEvent", + }, + }, +}); diff --git a/apps/ensapi/src/lib/subgraph/indexing-status-to-subgraph-meta.ts b/apps/ensapi/src/lib/subgraph/indexing-status-to-subgraph-meta.ts index 40323fcc6b..3401c7c1e4 100644 --- a/apps/ensapi/src/lib/subgraph/indexing-status-to-subgraph-meta.ts +++ b/apps/ensapi/src/lib/subgraph/indexing-status-to-subgraph-meta.ts @@ -1,8 +1,7 @@ -import config from "@/config"; - -import { ChainIndexingStatusIds, getENSRootChainId } from "@ensnode/ensnode-sdk"; +import { ChainIndexingStatusIds } from "@ensnode/ensnode-sdk"; import type { SubgraphMeta } from "@ensnode/ponder-subgraph"; +import di from "@/di"; import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware"; /** @@ -23,9 +22,7 @@ export function indexingContextToSubgraphMeta( // for the lifetime of this service instance. if (indexingStatus instanceof Error) return null; - const rootChain = indexingStatus.snapshot.omnichainSnapshot.chains.get( - getENSRootChainId(config.namespace), - ); + const rootChain = indexingStatus.snapshot.omnichainSnapshot.chains.get(di.context.rootChainId); if (!rootChain) return null; switch (rootChain.chainStatus) { @@ -36,7 +33,7 @@ export function indexingContextToSubgraphMeta( case ChainIndexingStatusIds.Backfill: case ChainIndexingStatusIds.Following: { return { - deployment: config.ensIndexerPublicConfig.versionInfo.ensIndexer, + deployment: di.context.stackInfo.ensIndexer.versionInfo.ensIndexer, hasIndexingErrors: false, block: { hash: null, diff --git a/apps/ensapi/src/middleware/can-accelerate.middleware.ts b/apps/ensapi/src/middleware/can-accelerate.middleware.ts index c795f9696a..abc29d59ea 100644 --- a/apps/ensapi/src/middleware/can-accelerate.middleware.ts +++ b/apps/ensapi/src/middleware/can-accelerate.middleware.ts @@ -1,8 +1,7 @@ -import config from "@/config"; - import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { factory, producing } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; @@ -39,7 +38,7 @@ export const canAccelerateMiddleware = producing( // NOTE: gate on the namespace containing an ENSv2Root datasource rather than the ENSv2 // plugin being configured — a namespace may be ENSv1-only even when the Unigraph plugin is // defined, and forward resolution must follow the ENSv1 path in that case. - if (maybeGetDatasource(config.namespace, DatasourceNames.ENSv2Root)) { + if (maybeGetDatasource(di.context.namespace, DatasourceNames.ENSv2Root)) { if (!didWarnCannotAccelerateENSv2) { logger.warn( `ENSApi is temporarily unable to accelerate Resolution API requests while indexing ENSv2. Protocol Acceleration is DISABLED.`, @@ -56,7 +55,7 @@ export const canAccelerateMiddleware = producing( /// Protocol Acceleration Plugin Availability ////////////////////////////////////////////// - const hasProtocolAccelerationPlugin = config.ensIndexerPublicConfig.plugins.includes( + const hasProtocolAccelerationPlugin = di.context.stackInfo.ensIndexer.plugins.includes( PluginName.ProtocolAcceleration, ); diff --git a/apps/ensapi/src/middleware/ensanalytics.middleware.ts b/apps/ensapi/src/middleware/ensanalytics.middleware.ts index c3ea70fe0f..8c938887af 100644 --- a/apps/ensapi/src/middleware/ensanalytics.middleware.ts +++ b/apps/ensapi/src/middleware/ensanalytics.middleware.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { hasEnsAnalyticsConfigSupport, hasEnsAnalyticsIndexingStatusSupport, @@ -7,6 +5,7 @@ import { import type { PrerequisiteResult } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { factory, producing } from "@/lib/hono-factory"; /** @@ -51,7 +50,7 @@ export const ensanalyticsApiMiddleware = producing( throw new Error(`Invariant(ensanalytics.middleware): indexingStatusMiddleware required`); } - const configSupport = hasEnsAnalyticsConfigSupport(config.ensIndexerPublicConfig); + const configSupport = hasEnsAnalyticsConfigSupport(di.context.stackInfo.ensIndexer); if (!configSupport.supported) { c.set("ensAnalyticsPrerequisites", configSupport); return await next(); diff --git a/apps/ensapi/src/middleware/name-tokens.middleware.ts b/apps/ensapi/src/middleware/name-tokens.middleware.ts index 8f78643e76..ea74319ba8 100644 --- a/apps/ensapi/src/middleware/name-tokens.middleware.ts +++ b/apps/ensapi/src/middleware/name-tokens.middleware.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { NameTokensResponseCodes, NameTokensResponseErrorCodes, @@ -7,6 +5,7 @@ import { serializeNameTokensResponse, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; @@ -36,7 +35,7 @@ export const nameTokensApiMiddleware = factory.createMiddleware( throw new Error(`Invariant(name-tokens.middleware): indexingStatusMiddleware required`); } - if (!nameTokensPrerequisites.hasEnsIndexerConfigSupport(config.ensIndexerPublicConfig)) { + if (!nameTokensPrerequisites.hasEnsIndexerConfigSupport(di.context.stackInfo.ensIndexer)) { return c.json( serializeNameTokensResponse({ responseCode: NameTokensResponseCodes.Error, diff --git a/apps/ensapi/src/middleware/registrar-actions.middleware.ts b/apps/ensapi/src/middleware/registrar-actions.middleware.ts index fc43df3d39..096f61307b 100644 --- a/apps/ensapi/src/middleware/registrar-actions.middleware.ts +++ b/apps/ensapi/src/middleware/registrar-actions.middleware.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { hasRegistrarActionsConfigSupport, hasRegistrarActionsIndexingStatusSupport, @@ -7,6 +5,7 @@ import { serializeRegistrarActionsResponse, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; @@ -35,7 +34,7 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( throw new Error(`Invariant(registrar-actions.middleware): indexingStatusMiddleware required`); } - const configSupport = hasRegistrarActionsConfigSupport(config.ensIndexerPublicConfig); + const configSupport = hasRegistrarActionsConfigSupport(di.context.stackInfo.ensIndexer); if (!configSupport.supported) { return c.json( serializeRegistrarActionsResponse({ diff --git a/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts b/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts index 0cac9478a5..c3f569a93f 100644 --- a/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts +++ b/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts @@ -1,9 +1,8 @@ -import config from "@/config"; - import { proxy } from "hono/proxy"; import { canFallbackToTheGraph } from "@ensnode/ensnode-sdk/internal"; +import di from "@/di"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; @@ -26,9 +25,9 @@ export const thegraphFallbackMiddleware = factory.createMiddleware(async (c, nex } const fallback = canFallbackToTheGraph({ - namespace: config.namespace, - theGraphApiKey: config.theGraphApiKey, - isSubgraphCompatible: config.ensIndexerPublicConfig.isSubgraphCompatible, + namespace: di.context.namespace, + theGraphApiKey: di.context.ensApiConfig.theGraphApiKey, + isSubgraphCompatible: di.context.stackInfo.ensIndexer.isSubgraphCompatible, }); // log one warning to the console if !canFallback @@ -46,7 +45,7 @@ export const thegraphFallbackMiddleware = factory.createMiddleware(async (c, nex } case "no-subgraph-url": { logger.warn( - `ENSApi can NOT fallback to The Graph: the connected ENSIndexer's namespace ('${config.namespace}') is not supported by The Graph.`, + `ENSApi can NOT fallback to The Graph: the connected ENSIndexer's namespace ('${di.context.namespace}') is not supported by The Graph.`, ); break; } diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.test.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.test.ts index 7582acbaaf..84864f9aaf 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.test.ts @@ -1,6 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@/config", () => ({ default: { namespace: "mainnet" } })); +import { describe, expect, it } from "vitest"; import { isEffectiveDesc } from "./find-domains-resolver-helpers"; diff --git a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts index 8092e31d09..745c1ec0cd 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { trace } from "@opentelemetry/api"; import { Param, sql } from "drizzle-orm"; import { @@ -24,6 +22,7 @@ import { } from "@ensnode/ensnode-sdk"; import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; +import di from "@/di"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; @@ -84,7 +83,7 @@ export async function getDomainIdByInterpretedName( } return withActiveSpanAsync(tracer, "getDomainIdByInterpretedName", { name }, () => - forwardWalkNamegraph(getRootRegistryId(config.namespace), path), + forwardWalkNamegraph(getRootRegistryId(di.context.namespace), path), ); } @@ -129,10 +128,10 @@ async function forwardWalkNamegraph( // otherwise, identify the deepest element with a Resolver const deepestResolver = rows.find(hasResolver); if (deepestResolver) { - const resolverEq = makeContractMatcher(config.namespace, deepestResolver); + const resolverEq = makeContractMatcher(di.context.namespace, deepestResolver); // Bridged Resolvers // if the deepest Resolver is a Bridged Resolver, recurse to the target Registry - const bridged = isBridgedResolver(config.namespace, deepestResolver); + const bridged = isBridgedResolver(di.context.namespace, deepestResolver); if (bridged) { // to follow a Bridged Resolver, continue walking the namegraph from the target `registryId` // with the remaining portion of `path` @@ -151,13 +150,13 @@ async function forwardWalkNamegraph( // if the deepest Resolver is the ENSv1Resolver, fallback to ENSv1 if (resolverEq(DatasourceNames.ENSv2Root, "ENSv1Resolver")) { // to implement the ENSv1Resolver, walk the ENSv1 disjoint namegraph with the full path - return forwardWalkNamegraph(getENSv1RootRegistryId(config.namespace), path, depth + 1); + return forwardWalkNamegraph(getENSv1RootRegistryId(di.context.namespace), path, depth + 1); } // ENSv2Resolver (ENSv2 Fallback) if (resolverEq(DatasourceNames.ENSv2Root, "ENSv2Resolver")) { // to implement the ENSv2Resolver, walk the ENSv2 disjoint namegraph with the full path - return forwardWalkNamegraph(getENSv2RootRegistryId(config.namespace), path, depth + 1); + return forwardWalkNamegraph(getENSv2RootRegistryId(di.context.namespace), path, depth + 1); } } diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 245c72470b..56464f2914 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -1,10 +1,9 @@ -import config from "@/config"; - import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { makeConcreteRegistryId, makePermissionsId, makeResolverId } from "enssdk"; import { getRootRegistryId } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; @@ -182,7 +181,7 @@ builder.queryType({ "The Root Registry for this namespace. It will be the ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry.", type: RegistryInterfaceRef, nullable: false, - resolve: () => getRootRegistryId(config.namespace), + resolve: () => getRootRegistryId(di.context.namespace), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/resolver.ts b/apps/ensapi/src/omnigraph-api/schema/resolver.ts index 07e8d65bea..f1db948a7d 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolver.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, eq } from "drizzle-orm"; import { @@ -11,6 +9,7 @@ import { import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; +import di from "@/di"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; @@ -129,7 +128,7 @@ ResolverRef.implement({ type: RegistryInterfaceRef, nullable: true, resolve: (parent) => { - const bridged = isBridgedResolver(config.namespace, parent); + const bridged = isBridgedResolver(di.context.namespace, parent); return bridged?.targetRegistryId ?? null; }, }), diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts index 3545ee8ee7..520eefd215 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts @@ -903,4 +903,55 @@ describe("SWRCache", () => { cache.destroy(); }); }); + + describe("peek", () => { + it("throws when cache is not initialized", () => { + const fn = vi.fn(async () => "value1"); + const cache = new SWRCache({ fn, ttl: 1 }); + + expect(() => cache.peek()).toThrow("Cache is not initialized yet"); + }); + + it("returns cached value without triggering revalidation", async () => { + const fn = vi.fn(async () => "value1"); + const cache = new SWRCache({ fn, ttl: 1 }); + + await cache.read(); + expect(fn).toHaveBeenCalledTimes(1); + + const result = cache.peek(); + expect(result).toBe("value1"); + expect(fn).toHaveBeenCalledTimes(1); // no revalidation triggered + }); + + it("returns cached error when result is an Error", async () => { + const error = new Error("Cached error"); + const fn = vi.fn(async () => { + throw error; + }); + const cache = new SWRCache({ fn, ttl: 1 }); + + await cache.read(); + + const result = cache.peek(); + + expect(result).toBeInstanceOf(Error); + }); + + it("returns stale cached value without revalidating", async () => { + const fn = vi.fn(async () => "value1"); + const cache = new SWRCache({ fn, ttl: 1 }); + + await cache.read(); + expect(fn).toHaveBeenCalledTimes(1); + + // advance past TTL + vi.advanceTimersByTime(2000); + + // peek should return stale value synchronously without triggering revalidation + const result = cache.peek(); + expect(result).toBe("value1"); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts index a7be2064f4..82acb10454 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts @@ -173,6 +173,31 @@ export class SWRCache { return this.cache.result; } + /** + * Synchronously returns the most recently cached result without triggering revalidation. + * + * Unlike {@link read}, this method is synchronous and never causes any I/O — it simply + * returns whatever value is already in memory. Use this when you need a best-effort + * snapshot of the cached value and can tolerate stale data. + * + * @returns the most recently cached `ValueType`, or an `Error` if the last fetch failed. + * @throws if the cache has not been initialized yet (i.e. {@link read} has never been awaited + * and `proactivelyInitialize` was not set to `true`). + * + * @example + * ```ts + * // Ensure cache is initialized first + * await cache.read(); + * + * // Later, peek synchronously without triggering revalidation + * const result = cache.peek(); + * ``` + */ + public peek(): ValueType | Error { + if (!this.cache) throw new Error("Cache is not initialized yet"); + return this.cache.result; + } + /** * Destroys the background revalidation interval, if exists. */