diff --git a/.changeset/humble-pets-trade.md b/.changeset/humble-pets-trade.md new file mode 100644 index 000000000..b12a58333 --- /dev/null +++ b/.changeset/humble-pets-trade.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Replaced ENSIndexer Public Config source, from ENSIndexer to ENSDb. diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index 6de2ee84b..e12b99ca8 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -12,9 +12,12 @@ ENSINDEXER_URL=http://localhost:42069 # It should be in the format of `postgresql://:@:/` # # See https://ensnode.io/ensindexer/usage/configuration/ for additional information. -# NOTE that ENSApi does NOT need to define DATABASE_SCHEMA, as it is inferred from the connected ENSIndexer's Config. DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database +# ENSDb: Database Schema name for ENSIndexer Schema +# Required. Should match the DATABASE_SCHEMA used by the connected ENSIndexer. +DATABASE_SCHEMA=public + # ENSApi: RPC Configuration # Required. ENSApi requires an HTTP RPC to the connected ENSIndexer's ENS Root Chain, which depends # on ENSIndexer's NAMESPACE (ex: mainnet, sepolia, ens-test-env). This ENS Root Chain RPC diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 407effcb2..7060e7602 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -19,12 +19,20 @@ vi.mock("@/lib/logger", () => ({ error: vi.fn(), info: vi.fn(), }, + makeLogger: vi.fn(() => ({ + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + })), })); const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234"; const BASE_ENV = { DATABASE_URL: "postgresql://user:password@localhost:5432/mydb", + DATABASE_SCHEMA: "public", ENSINDEXER_URL: "http://localhost:42069", RPC_URL_1: VALID_RPC_URL, } satisfies EnsApiEnvironment; @@ -50,6 +58,20 @@ const ENSINDEXER_PUBLIC_CONFIG = { }, } satisfies ENSIndexerPublicConfig; +// Mock EnsDbClient - must be defined after ENSINDEXER_PUBLIC_CONFIG since vi.mock is hoisted +// We'll use a simple class mock and configure it in beforeEach +const mockGetVersion = vi.fn().mockResolvedValue("1.0.0"); +const mockGetEnsIndexerPublicConfig = vi.fn().mockResolvedValue(ENSINDEXER_PUBLIC_CONFIG); +const mockGetIndexingStatusSnapshot = vi.fn().mockResolvedValue(null); + +vi.mock("@/lib/ensdb-client/ensdb-client", () => ({ + EnsDbClient: class MockEnsDbClient { + getVersion = mockGetVersion; + getEnsIndexerPublicConfig = mockGetEnsIndexerPublicConfig; + getIndexingStatusSnapshot = mockGetIndexingStatusSnapshot; + }, +})); + const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -116,6 +138,7 @@ describe("buildConfigFromEnvironment", () => { const TEST_ENV: EnsApiEnvironment = { DATABASE_URL: BASE_ENV.DATABASE_URL, + DATABASE_SCHEMA: BASE_ENV.DATABASE_SCHEMA, ENSINDEXER_URL: BASE_ENV.ENSINDEXER_URL, }; diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index ec402ba89..e9fab8285 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,6 +1,5 @@ import packageJson from "@/../package.json" with { type: "json" }; -import pRetry from "p-retry"; import { parse as parseConnectionString } from "pg-connection-string"; import { prettifyError, ZodError, z } from "zod/v4"; @@ -21,7 +20,7 @@ import { import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import type { EnsApiEnvironment } from "@/config/environment"; import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations"; -import { fetchENSIndexerConfig } from "@/lib/fetch-ensindexer-config"; +import { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; import logger from "@/lib/logger"; export const DatabaseUrlSchema = z.string().refine( @@ -77,6 +76,19 @@ const EnsApiConfigSchema = z export type EnsApiConfig = z.infer; +/** + * Builds an instance of {@link EnsDbClient} using environment variables. + * + * @returns instance of {@link EnsDbClient} + * @throws Error with formatted validation messages if environment parsing fails + */ +function buildEnsDbClientFromEnvironment(env: EnsApiEnvironment): EnsDbClient { + const databaseUrl = DatabaseUrlSchema.parse(env.DATABASE_URL); + const ensIndexerSchemaName = DatabaseSchemaNameSchema.parse(env.DATABASE_SCHEMA); + + return new EnsDbClient(databaseUrl, ensIndexerSchemaName); +} + /** * Builds the EnsApiConfig from an EnsApiEnvironment object, fetching the EnsIndexerPublicConfig. * @@ -85,16 +97,13 @@ export type EnsApiConfig = z.infer; */ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promise { try { - const ensIndexerUrl = EnsIndexerUrlSchema.parse(env.ENSINDEXER_URL); - - const ensIndexerPublicConfig = await pRetry(() => fetchENSIndexerConfig(ensIndexerUrl), { - retries: 3, - onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { - logger.info( - `ENSIndexer Config fetch attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`, - ); - }, - }); + const ensDbClient = buildEnsDbClientFromEnvironment(env); + + const ensIndexerPublicConfig = await ensDbClient.getEnsIndexerPublicConfig(); + + if (!ensIndexerPublicConfig) { + throw new Error("Failed to load EnsIndexerPublicConfig from ENSDb."); + } const rpcConfigs = buildRpcConfigsFromEnv(env, ensIndexerPublicConfig.namespace); diff --git a/apps/ensapi/src/config/environment.ts b/apps/ensapi/src/config/environment.ts index 119490fdf..550a1c4b3 100644 --- a/apps/ensapi/src/config/environment.ts +++ b/apps/ensapi/src/config/environment.ts @@ -15,7 +15,7 @@ import type { * their state in `process.env`. This interface is intended to be the source type which then gets * mapped/parsed into a structured configuration object like `EnsApiConfig`. */ -export type EnsApiEnvironment = Omit & +export type EnsApiEnvironment = DatabaseEnvironment & EnsIndexerUrlEnvironment & RpcEnvironment & PortEnvironment & diff --git a/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts b/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts new file mode 100644 index 000000000..35b33cf34 --- /dev/null +++ b/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts @@ -0,0 +1,139 @@ +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { and, eq } from "drizzle-orm/sql"; + +import * as ensNodeSchema from "@ensnode/ensnode-schema/ensnode"; +import { + type CrossChainIndexingStatusSnapshot, + deserializeCrossChainIndexingStatusSnapshot, + deserializeEnsIndexerPublicConfig, + type EnsDbClientQuery, + type EnsIndexerPublicConfig, + EnsNodeMetadataKeys, + type SerializedEnsNodeMetadata, + type SerializedEnsNodeMetadataEnsDbVersion, + type SerializedEnsNodeMetadataEnsIndexerIndexingStatus, + type SerializedEnsNodeMetadataEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import { makeReadOnlyDrizzle } from "@/lib/handlers/drizzle"; + +/** + * Drizzle database + * + * Allows interacting with Postgres database for ENSDb, using Drizzle ORM. + */ +interface DrizzleDb extends NodePgDatabase {} + +/** + * ENSDb Client + * + * This client exists to provide an abstraction layer for interacting with ENSDb. + * It enables ENSIndexer and ENSApi to decouple from each other, and use + * ENSDb as the integration point between the two (via ENSDb Client). + * + * Enables querying ENSDb data, such as: + * - ENSDb version + * - ENSIndexer Public Config, + * - Indexing Status Snapshot. + */ +export class EnsDbClient implements EnsDbClientQuery { + /** + * Drizzle database instance for ENSDb. + * + * This is a read-only Drizzle instance, since ENSApi should not be + * performing any mutations on the database. + */ + private db: DrizzleDb; + + /** + * ENSIndexer reference string for multi-tenancy in ENSDb. + */ + private ensIndexerRef: string; + + /** + * @param databaseUrl connection string for ENSDb Postgres database + * @param ensIndexerRef reference string for ENSIndexer instance (used for multi-tenancy in ENSDb) + */ + constructor(databaseUrl: string, ensIndexerRef: string) { + this.db = makeReadOnlyDrizzle({ + databaseUrl, + schema: ensNodeSchema, + }); + + this.ensIndexerRef = ensIndexerRef; + } + + /** + * @inheritdoc + */ + async getEnsDbVersion(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsDbVersion, + }); + + return record; + } + + /** + * @inheritdoc + */ + async getEnsIndexerPublicConfig(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, + }); + + if (!record) { + return undefined; + } + + return deserializeEnsIndexerPublicConfig(record); + } + + /** + * @inheritdoc + */ + async getIndexingStatusSnapshot(): Promise { + const record = await this.getEnsNodeMetadata( + { + key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, + }, + ); + + if (!record) { + return undefined; + } + + return deserializeCrossChainIndexingStatusSnapshot(record); + } + + /** + * Get ENSNode metadata record + * + * @returns selected record in ENSDb. + * @throws when more than one matching metadata record is found + * (should be impossible given the PK constraint on 'key') + */ + private async getEnsNodeMetadata( + metadata: Pick, + ): Promise { + const result = await this.db + .select() + .from(ensNodeSchema.ensNodeMetadata) + .where( + and( + eq(ensNodeSchema.ensNodeMetadata.ensIndexerRef, this.ensIndexerRef), + eq(ensNodeSchema.ensNodeMetadata.key, metadata.key), + ), + ); + + if (result.length === 0) { + return undefined; + } + + if (result.length === 1 && result[0]) { + return result[0].value as EnsNodeMetadataType["value"]; + } + + throw new Error(`There must be exactly one ENSNodeMetadata record for '${metadata.key}' key`); + } +} diff --git a/apps/ensapi/src/lib/fetch-ensindexer-config.ts b/apps/ensapi/src/lib/fetch-ensindexer-config.ts deleted file mode 100644 index 0cd11beb8..000000000 --- a/apps/ensapi/src/lib/fetch-ensindexer-config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - deserializeENSIndexerPublicConfig, - deserializeErrorResponse, - type SerializedENSIndexerPublicConfig, -} from "@ensnode/ensnode-sdk"; - -export async function fetchENSIndexerConfig(url: URL) { - const response = await fetch(new URL(`/api/config`, url)); - const responseData = await response.json(); - - if (!response.ok) { - const errorResponse = deserializeErrorResponse(responseData); - throw new Error(`Fetching ENSNode Config Failed: ${errorResponse.message}`); - } - - return deserializeENSIndexerPublicConfig(responseData as SerializedENSIndexerPublicConfig); -} diff --git a/terraform/modules/ensindexer/main.tf b/terraform/modules/ensindexer/main.tf index 1c5d81204..dcfa5fd62 100644 --- a/terraform/modules/ensindexer/main.tf +++ b/terraform/modules/ensindexer/main.tf @@ -1,6 +1,7 @@ locals { common_variables = { # Common configuration + "DATABASE_SCHEMA" = { value = var.database_schema }, "DATABASE_URL" = { value = var.ensdb_url }, "ALCHEMY_API_KEY" = { value = var.alchemy_api_key } "QUICKNODE_API_KEY" = { value = var.quicknode_api_key } @@ -28,7 +29,6 @@ resource "render_web_service" "ensindexer" { } env_vars = merge(local.common_variables, { - "DATABASE_SCHEMA" = { value = var.database_schema }, "ENSRAINBOW_URL" = { value = var.ensrainbow_url }, "LABEL_SET_ID" = { value = var.ensindexer_label_set_id }, "LABEL_SET_VERSION" = { value = var.ensindexer_label_set_version },