From 6acbea09b2170892e86eb579a0765ec1051ec5db Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 06:24:19 +0100 Subject: [PATCH 01/47] Create three database schemas for ENSDb 1) Ponder Schema - describing Ponder runtime state. 2) ENSIndexer Schema - describing ENSIndexer runtime state. 3) ENSNode Schema - describing runtime state across ENSNode services. --- packages/ensnode-schema/package.json | 44 ++++- .../ensv2.subschema.ts} | 0 .../index.ts} | 0 .../protocol-acceleration.subschema.ts} | 0 .../registrars.subschema.ts} | 0 .../subgraph.subschema.ts} | 0 .../tokenscope.subschema.ts} | 0 .../src/ensnode-schema/index.ts | 66 +++++++ .../src/ensnode-schema/schema.ts | 5 + packages/ensnode-schema/src/index.ts | 2 + .../ensnode-schema/src/ponder-schema/index.ts | 186 ++++++++++++++++++ .../src/ponder-schema/schema.ts | 5 + .../src/schemas/ensnode-metadata.schema.ts | 37 ---- packages/ensnode-schema/tsconfig.json | 5 +- packages/ensnode-schema/tsup.config.ts | 8 +- pnpm-lock.yaml | 16 +- 16 files changed, 319 insertions(+), 55 deletions(-) rename packages/ensnode-schema/src/{schemas/ensv2.schema.ts => ensindexer-schema/ensv2.subschema.ts} (100%) rename packages/ensnode-schema/src/{ponder.schema.ts => ensindexer-schema/index.ts} (100%) rename packages/ensnode-schema/src/{schemas/protocol-acceleration.schema.ts => ensindexer-schema/protocol-acceleration.subschema.ts} (100%) rename packages/ensnode-schema/src/{schemas/registrars.schema.ts => ensindexer-schema/registrars.subschema.ts} (100%) rename packages/ensnode-schema/src/{schemas/subgraph.schema.ts => ensindexer-schema/subgraph.subschema.ts} (100%) rename packages/ensnode-schema/src/{schemas/tokenscope.schema.ts => ensindexer-schema/tokenscope.subschema.ts} (100%) create mode 100644 packages/ensnode-schema/src/ensnode-schema/index.ts create mode 100644 packages/ensnode-schema/src/ensnode-schema/schema.ts create mode 100644 packages/ensnode-schema/src/index.ts create mode 100644 packages/ensnode-schema/src/ponder-schema/index.ts create mode 100644 packages/ensnode-schema/src/ponder-schema/schema.ts delete mode 100644 packages/ensnode-schema/src/schemas/ensnode-metadata.schema.ts diff --git a/packages/ensnode-schema/package.json b/packages/ensnode-schema/package.json index 94cd3393a1..29a4100e8a 100644 --- a/packages/ensnode-schema/package.json +++ b/packages/ensnode-schema/package.json @@ -16,7 +16,10 @@ "Ponder" ], "exports": { - ".": "./src/ponder.schema.ts" + ".": "./src/index.ts", + "./ponder": "./src/ponder-schema/index.ts", + "./ensindexer": "./src/ensindexer-schema/index.ts", + "./ensnode": "./src/ensnode-schema/index.ts" }, "files": [ "dist" @@ -24,26 +27,49 @@ "publishConfig": { "access": "public", "exports": { - "types": "./dist/ponder.schema.d.ts", - "default": "./dist/ponder.schema.js" - }, - "main": "./dist/ponder.schema.js", - "module": "./dist/ponder.schema.mjs", - "types": "./dist/ponder.schema.d.ts" + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./ponder": { + "import": { + "types": "./dist/ponder-schema.d.ts", + "default": "./dist/ponder-schema.js" + } + }, + "./ensindexer": { + "import": { + "types": "./dist/ensindexer-schema.d.ts", + "default": "./dist/ensindexer-schema.js" + } + }, + "./ensnode": { + "import": { + "types": "./dist/ensnode-schema.d.ts", + "default": "./dist/ensnode-schema.js" + } + } + } }, "scripts": { "prepublish": "tsup", "lint": "biome check --write .", "lint:ci": "biome ci" }, - "dependencies": { + "peerDependencies": { + "drizzle-orm": "catalog:", "ponder": "catalog:", "viem": "catalog:" }, "devDependencies": { "@ensnode/ensnode-sdk": "workspace:", "@ensnode/shared-configs": "workspace:*", + "drizzle-orm": "catalog:", + "ponder": "catalog:", "tsup": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "viem": "catalog:" } } diff --git a/packages/ensnode-schema/src/schemas/ensv2.schema.ts b/packages/ensnode-schema/src/ensindexer-schema/ensv2.subschema.ts similarity index 100% rename from packages/ensnode-schema/src/schemas/ensv2.schema.ts rename to packages/ensnode-schema/src/ensindexer-schema/ensv2.subschema.ts diff --git a/packages/ensnode-schema/src/ponder.schema.ts b/packages/ensnode-schema/src/ensindexer-schema/index.ts similarity index 100% rename from packages/ensnode-schema/src/ponder.schema.ts rename to packages/ensnode-schema/src/ensindexer-schema/index.ts diff --git a/packages/ensnode-schema/src/schemas/protocol-acceleration.schema.ts b/packages/ensnode-schema/src/ensindexer-schema/protocol-acceleration.subschema.ts similarity index 100% rename from packages/ensnode-schema/src/schemas/protocol-acceleration.schema.ts rename to packages/ensnode-schema/src/ensindexer-schema/protocol-acceleration.subschema.ts diff --git a/packages/ensnode-schema/src/schemas/registrars.schema.ts b/packages/ensnode-schema/src/ensindexer-schema/registrars.subschema.ts similarity index 100% rename from packages/ensnode-schema/src/schemas/registrars.schema.ts rename to packages/ensnode-schema/src/ensindexer-schema/registrars.subschema.ts diff --git a/packages/ensnode-schema/src/schemas/subgraph.schema.ts b/packages/ensnode-schema/src/ensindexer-schema/subgraph.subschema.ts similarity index 100% rename from packages/ensnode-schema/src/schemas/subgraph.schema.ts rename to packages/ensnode-schema/src/ensindexer-schema/subgraph.subschema.ts diff --git a/packages/ensnode-schema/src/schemas/tokenscope.schema.ts b/packages/ensnode-schema/src/ensindexer-schema/tokenscope.subschema.ts similarity index 100% rename from packages/ensnode-schema/src/schemas/tokenscope.schema.ts rename to packages/ensnode-schema/src/ensindexer-schema/tokenscope.subschema.ts diff --git a/packages/ensnode-schema/src/ensnode-schema/index.ts b/packages/ensnode-schema/src/ensnode-schema/index.ts new file mode 100644 index 0000000000..2f92b82faa --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-schema/index.ts @@ -0,0 +1,66 @@ +/** + * ENSNode Schema + * + * Defines the database objects describing the ENSNode services state. + */ + +import { primaryKey } from "drizzle-orm/pg-core"; + +import { ENSNODE_SCHEMA } from "./schema"; + +export { ENSNODE_SCHEMA_NAME } from "./schema"; + +/** + * ENSNode Metadata + * + * Possible key value pairs are defined by 'EnsNodeMetadata' type: + * - `EnsNodeMetadataEnsDbVersion` + * - `EnsNodeMetadataEnsIndexerPublicConfig` + * - `EnsNodeMetadataEnsIndexerIndexingStatus` + */ +export const ensNodeMetadata = ENSNODE_SCHEMA.table( + "ensnode_metadata", + (t) => ({ + /** + * ENSIndexer Reference + * + * References the ENSIndexer instance by a unique ENSIndexer schema name + * that a metadata record is associated with. This allows us to support + * multiple ENSIndexer instances using the same database, while ensuring + * that their metadata records do not conflict with each other. + */ + ensIndexerRef: t.text().notNull(), + + /** + * Key + * + * Allowed keys: + * - `EnsNodeMetadataEnsDbVersion['key']` + * - `EnsNodeMetadataEnsIndexerPublicConfig['key']` + * - `EnsNodeMetadataEnsIndexerIndexingStatus['key']` + */ + key: t.text().notNull(), + + /** + * Value + * + * Allowed values: + * - `EnsNodeMetadataEnsDbVersion['value']` + * - `EnsNodeMetadataEnsIndexerPublicConfig['value']` + * - `EnsNodeMetadataEnsIndexerIndexingStatus['value']` + * + * Guaranteed to be a serialized representation of JSON object. + */ + value: t.jsonb().notNull(), + }), + (table) => [ + /** + * Primary key constraint on 'ensIndexerRef' and 'key' columns, + * to ensure that there is only one record for each key per ENSIndexer instance. + */ + primaryKey({ + name: "ensnode_metadata_pkey", + columns: [table.ensIndexerRef, table.key], + }), + ], +); diff --git a/packages/ensnode-schema/src/ensnode-schema/schema.ts b/packages/ensnode-schema/src/ensnode-schema/schema.ts new file mode 100644 index 0000000000..1cc926c6ba --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-schema/schema.ts @@ -0,0 +1,5 @@ +import { pgSchema } from "drizzle-orm/pg-core"; + +export const ENSNODE_SCHEMA_NAME = "ensnode"; + +export const ENSNODE_SCHEMA = pgSchema(ENSNODE_SCHEMA_NAME); diff --git a/packages/ensnode-schema/src/index.ts b/packages/ensnode-schema/src/index.ts new file mode 100644 index 0000000000..49b2d5e311 --- /dev/null +++ b/packages/ensnode-schema/src/index.ts @@ -0,0 +1,2 @@ +// Re-export relevant schema definitions for backward compatibility. +export * from "./ensindexer-schema"; diff --git a/packages/ensnode-schema/src/ponder-schema/index.ts b/packages/ensnode-schema/src/ponder-schema/index.ts new file mode 100644 index 0000000000..35af4870fd --- /dev/null +++ b/packages/ensnode-schema/src/ponder-schema/index.ts @@ -0,0 +1,186 @@ +// This file defines tables for RPC request cache schema. +// Relevant codebase was copied from Ponder Sync schema defined in the Ponder repository: +// https://github.com/ponder-sh/ponder/blob/ponder%400.16.2/packages/core/src/sync-store/schema.ts + +/** + * RPC Request Cache Schema + * + * Defines the database objects for caching RPC requests and their results. + * + * This file defines tables for RPC request cache schema. + * Relevant codebase was copied from Ponder Sync schema defined in the Ponder repository. + * @see https://github.com/ponder-sh/ponder/blob/ponder%400.16.2/packages/core/src/sync-store/schema.ts + */ + +import { customType, index, primaryKey } from "drizzle-orm/pg-core"; +import type { Address, Hash, Hex } from "viem"; + +import { PONDER_SCHEMA } from "./schema"; + +export { PONDER_SCHEMA_NAME } from "./schema"; + +const numeric78 = customType<{ data: bigint; driverData: string }>({ + dataType() { + return "numeric(78,0)"; + }, + fromDriver(value: string) { + return BigInt(value); + }, +}); + +export const blocks = PONDER_SCHEMA.table( + "blocks", + (t) => ({ + chainId: t.bigint({ mode: "bigint" }).notNull(), + number: t.bigint({ mode: "bigint" }).notNull(), + timestamp: t.bigint({ mode: "bigint" }).notNull(), + hash: t.varchar({ length: 66 }).notNull().$type(), + parentHash: t.varchar({ length: 66 }).notNull().$type(), + logsBloom: t.varchar({ length: 514 }).notNull().$type(), + miner: t.varchar({ length: 42 }).notNull().$type
(), + gasUsed: numeric78().notNull(), + gasLimit: numeric78().notNull(), + baseFeePerGas: numeric78(), + nonce: t.varchar({ length: 18 }).$type(), + mixHash: t.varchar({ length: 66 }).$type(), + stateRoot: t.varchar({ length: 66 }).notNull().$type(), + receiptsRoot: t.varchar({ length: 66 }).notNull().$type(), + transactionsRoot: t.varchar({ length: 66 }).notNull().$type(), + sha3Uncles: t.varchar({ length: 66 }).$type(), + size: numeric78().notNull(), + difficulty: numeric78().notNull(), + totalDifficulty: numeric78(), + extraData: t.text().notNull().$type(), + }), + (table) => [ + primaryKey({ + name: "blocks_pkey", + columns: [table.chainId, table.number], + }), + ], +); + +export const transactions = PONDER_SCHEMA.table( + "transactions", + (t) => ({ + chainId: t.bigint({ mode: "bigint" }).notNull(), + blockNumber: t.bigint({ mode: "bigint" }).notNull(), + transactionIndex: t.integer().notNull(), + hash: t.varchar({ length: 66 }).notNull().$type(), + blockHash: t.varchar({ length: 66 }).notNull().$type(), + from: t.varchar({ length: 42 }).notNull().$type
(), + to: t.varchar({ length: 42 }).$type
(), + input: t.text().notNull().$type(), + value: numeric78().notNull(), + nonce: t.integer().notNull(), + r: t.varchar({ length: 66 }).$type(), + s: t.varchar({ length: 66 }).$type(), + v: numeric78(), + type: t.text().notNull().$type(), + gas: numeric78().notNull(), + gasPrice: numeric78(), + maxFeePerGas: numeric78(), + maxPriorityFeePerGas: numeric78(), + accessList: t.text(), + }), + (table) => [ + primaryKey({ + name: "transactions_pkey", + columns: [table.chainId, table.blockNumber, table.transactionIndex], + }), + ], +); + +export const transactionReceipts = PONDER_SCHEMA.table( + "transaction_receipts", + (t) => ({ + chainId: t.bigint({ mode: "bigint" }).notNull(), + blockNumber: t.bigint({ mode: "bigint" }).notNull(), + transactionIndex: t.integer().notNull(), + transactionHash: t.varchar({ length: 66 }).notNull().$type(), + blockHash: t.varchar({ length: 66 }).notNull().$type(), + from: t.varchar({ length: 42 }).notNull().$type
(), + to: t.varchar({ length: 42 }).$type
(), + contractAddress: t.varchar({ length: 42 }).$type
(), // Note: incorrect + logsBloom: t.varchar({ length: 514 }).notNull().$type(), + gasUsed: numeric78().notNull(), + cumulativeGasUsed: numeric78().notNull(), + effectiveGasPrice: numeric78().notNull(), + status: t.text().notNull().$type(), + type: t.text().notNull().$type(), + }), + (table) => [ + primaryKey({ + name: "transaction_receipts_pkey", + columns: [table.chainId, table.blockNumber, table.transactionIndex], + }), + ], +); + +export const logs = PONDER_SCHEMA.table( + "logs", + (t) => ({ + chainId: t.bigint({ mode: "bigint" }).notNull(), + blockNumber: t.bigint({ mode: "bigint" }).notNull(), + logIndex: t.integer().notNull(), + transactionIndex: t.integer().notNull(), + blockHash: t.varchar({ length: 66 }).notNull().$type(), + transactionHash: t.varchar({ length: 66 }).notNull().$type(), + address: t.varchar({ length: 42 }).notNull().$type
(), + topic0: t.varchar({ length: 66 }).$type(), + topic1: t.varchar({ length: 66 }).$type(), + topic2: t.varchar({ length: 66 }).$type(), + topic3: t.varchar({ length: 66 }).$type(), + data: t.text().notNull().$type(), + }), + (table) => [ + primaryKey({ + name: "logs_pkey", + columns: [table.chainId, table.blockNumber, table.logIndex], + }), + ], +); + +export const traces = PONDER_SCHEMA.table( + "traces", + (t) => ({ + chainId: t.bigint({ mode: "bigint" }).notNull(), + blockNumber: t.bigint({ mode: "bigint" }).notNull(), + transactionIndex: t.integer().notNull(), + traceIndex: t.integer().notNull(), + from: t.varchar({ length: 42 }).notNull().$type
(), + to: t.varchar({ length: 42 }).$type
(), + input: t.text().notNull().$type(), + output: t.text().$type(), + value: numeric78(), + type: t.text().notNull(), + gas: numeric78().notNull(), + gasUsed: numeric78().notNull(), + error: t.text(), + revertReason: t.text(), + subcalls: t.integer().notNull(), + }), + (table) => [ + primaryKey({ + name: "traces_pkey", + columns: [table.chainId, table.blockNumber, table.transactionIndex, table.traceIndex], + }), + ], +); + +export const rpcRequestResults = PONDER_SCHEMA.table( + "rpc_request_results", + (t) => ({ + requestHash: t.text().notNull(), + chainId: t.bigint({ mode: "bigint" }).notNull(), + blockNumber: t.bigint({ mode: "bigint" }), + result: t.text().notNull(), + }), + (table) => [ + primaryKey({ + name: "rpc_request_results_pkey", + columns: [table.chainId, table.requestHash], + }), + index("rpc_request_results_chain_id_block_number_index").on(table.chainId, table.blockNumber), + ], +); diff --git a/packages/ensnode-schema/src/ponder-schema/schema.ts b/packages/ensnode-schema/src/ponder-schema/schema.ts new file mode 100644 index 0000000000..459507c66f --- /dev/null +++ b/packages/ensnode-schema/src/ponder-schema/schema.ts @@ -0,0 +1,5 @@ +import { pgSchema } from "drizzle-orm/pg-core"; + +export const PONDER_SCHEMA_NAME = "ponder_sync"; + +export const PONDER_SCHEMA = pgSchema(PONDER_SCHEMA_NAME); diff --git a/packages/ensnode-schema/src/schemas/ensnode-metadata.schema.ts b/packages/ensnode-schema/src/schemas/ensnode-metadata.schema.ts deleted file mode 100644 index bac75fd626..0000000000 --- a/packages/ensnode-schema/src/schemas/ensnode-metadata.schema.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Schema Definitions that hold metadata about the ENSNode instance. - */ - -import { onchainTable } from "ponder"; - -/** - * ENSNode Metadata - * - * Possible key value pairs are defined by 'EnsNodeMetadata' type: - * - `EnsNodeMetadataEnsDbVersion` - * - `EnsNodeMetadataEnsIndexerPublicConfig` - * - `EnsNodeMetadataEnsIndexerIndexingStatus` - */ -export const ensNodeMetadata = onchainTable("ensnode_metadata", (t) => ({ - /** - * Key - * - * Allowed keys: - * - `EnsNodeMetadataEnsDbVersion['key']` - * - `EnsNodeMetadataEnsIndexerPublicConfig['key']` - * - `EnsNodeMetadataEnsIndexerIndexingStatus['key']` - */ - key: t.text().primaryKey(), - - /** - * Value - * - * Allowed values: - * - `EnsNodeMetadataEnsDbVersion['value']` - * - `EnsNodeMetadataEnsIndexerPublicConfig['value']` - * - `EnsNodeMetadataEnsIndexerIndexingStatus['value']` - * - * Guaranteed to be a serialized representation of JSON object. - */ - value: t.jsonb().notNull(), -})); diff --git a/packages/ensnode-schema/tsconfig.json b/packages/ensnode-schema/tsconfig.json index d34e39a617..d4861e9935 100644 --- a/packages/ensnode-schema/tsconfig.json +++ b/packages/ensnode-schema/tsconfig.json @@ -1,5 +1,8 @@ { - "extends": "@ensnode/shared-configs/tsconfig.ponder.json", + "extends": "@ensnode/shared-configs/tsconfig.lib.json", + "compilerOptions": { + "rootDir": "." // necessary for 'The project root is ambiguous' + }, "include": ["./**/*.ts"], "exclude": ["node_modules"] } diff --git a/packages/ensnode-schema/tsup.config.ts b/packages/ensnode-schema/tsup.config.ts index afadde0d35..de83ab0d24 100644 --- a/packages/ensnode-schema/tsup.config.ts +++ b/packages/ensnode-schema/tsup.config.ts @@ -1,13 +1,19 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/ponder.schema.ts"], + entry: [ + "src/index.ts", + "src/ponder-schema/index.ts", + "src/ensindexer-schema/index.ts", + "src/ensnode-schema/index.ts", + ], platform: "neutral", format: ["esm"], target: "es2022", bundle: true, splitting: false, sourcemap: true, + dts: true, clean: true, external: ["viem", "ponder", "drizzle-orm", "drizzle-orm/pg-core"], outDir: "./dist", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 120d2868c3..1e308a2152 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -862,13 +862,6 @@ importers: version: 4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) packages/ensnode-schema: - dependencies: - ponder: - specifier: 'catalog:' - version: 0.16.3(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(@types/pg@8.16.0)(hono@4.12.5)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) - viem: - specifier: 'catalog:' - version: 2.38.5(typescript@5.9.3)(zod@3.25.76) devDependencies: '@ensnode/ensnode-sdk': specifier: 'workspace:' @@ -876,12 +869,21 @@ importers: '@ensnode/shared-configs': specifier: workspace:* version: link:../shared-configs + drizzle-orm: + specifier: 'catalog:' + version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.16.3) + ponder: + specifier: 'catalog:' + version: 0.16.3(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(@types/pg@8.16.0)(hono@4.12.5)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) tsup: specifier: 'catalog:' version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: 'catalog:' version: 5.9.3 + viem: + specifier: 'catalog:' + version: 2.38.5(typescript@5.9.3)(zod@3.25.76) packages/ensnode-sdk: dependencies: From eed4800da0893635edf414e106990d76cfda722e Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 06:24:35 +0100 Subject: [PATCH 02/47] Update import paths --- .../ensnode-schema/src/ensindexer-schema/index.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/ensnode-schema/src/ensindexer-schema/index.ts b/packages/ensnode-schema/src/ensindexer-schema/index.ts index 58508341ee..10ce6e45da 100644 --- a/packages/ensnode-schema/src/ensindexer-schema/index.ts +++ b/packages/ensnode-schema/src/ensindexer-schema/index.ts @@ -1,10 +1,9 @@ /** - * Merge the various sub-schemas into a single ponder (drizzle) schema. + * Merge the various sub-schemas into ENSIndexer Schema. */ -export * from "./schemas/ensnode-metadata.schema"; -export * from "./schemas/ensv2.schema"; -export * from "./schemas/protocol-acceleration.schema"; -export * from "./schemas/registrars.schema"; -export * from "./schemas/subgraph.schema"; -export * from "./schemas/tokenscope.schema"; +export * from "./ensv2.subschema"; +export * from "./protocol-acceleration.subschema"; +export * from "./registrars.subschema"; +export * from "./subgraph.subschema"; +export * from "./tokenscope.subschema"; From 16c55842fb3030ba1e4ad0bf3669fc0fcf289461 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 06:30:05 +0100 Subject: [PATCH 03/47] Enforce read-only connection with ENSDb for ENSApi runtime Replaces `makeDrizzle` DB connection helper with `makeReadOnlyDrizzle` one that is guaranteed to connect in read-only mode. --- apps/ensapi/src/lib/db.ts | 7 +++++-- apps/ensapi/src/lib/handlers/drizzle.ts | 11 ++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/ensapi/src/lib/db.ts b/apps/ensapi/src/lib/db.ts index 0148d37ecd..71a2f5e781 100644 --- a/apps/ensapi/src/lib/db.ts +++ b/apps/ensapi/src/lib/db.ts @@ -2,9 +2,12 @@ import config from "@/config"; import * as schema from "@ensnode/ensnode-schema"; -import { makeDrizzle } from "@/lib/handlers/drizzle"; +import { makeReadOnlyDrizzle } from "@/lib/handlers/drizzle"; -export const db = makeDrizzle({ +/** + * Read-only Drizzle instance for ENSDb queries to ENSIndexer Schema + */ +export const db = makeReadOnlyDrizzle({ databaseUrl: config.databaseUrl, databaseSchema: config.databaseSchemaName, schema, diff --git a/apps/ensapi/src/lib/handlers/drizzle.ts b/apps/ensapi/src/lib/handlers/drizzle.ts index a5d8f36daf..6847f04b30 100644 --- a/apps/ensapi/src/lib/handlers/drizzle.ts +++ b/apps/ensapi/src/lib/handlers/drizzle.ts @@ -1,5 +1,6 @@ import { setDatabaseSchema } from "@ponder/client"; import { drizzle } from "drizzle-orm/node-postgres"; +import { parseIntoClientConfig } from "pg-connection-string"; import { makeLogger } from "@/lib/logger"; @@ -8,9 +9,9 @@ type Schema = { [name: string]: unknown }; const logger = makeLogger("drizzle"); /** - * Makes a Drizzle DB object. + * Makes a read-only Drizzle DB object. */ -export const makeDrizzle = ({ +export const makeReadOnlyDrizzle = ({ schema, databaseUrl, databaseSchema, @@ -22,7 +23,11 @@ export const makeDrizzle = ({ // monkeypatch schema onto tables setDatabaseSchema(schema, databaseSchema); - return drizzle(databaseUrl, { + return drizzle({ + connection: { + ...parseIntoClientConfig(databaseUrl), + options: "-c default_transaction_read_only=on", + }, schema, casing: "snake_case", logger: { From 92afddfa46ef366d1152ff4f0e5da231b389b3da Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 06:53:02 +0100 Subject: [PATCH 04/47] Update `EnsDbClient` for ENSIndexer to handle updated DB schema definitions. Handle multi-tenancy in ENSNode Metadata table. --- .../src/lib/ensdb-client/ensdb-client.test.ts | 11 +++-- .../src/lib/ensdb-client/ensdb-client.ts | 44 ++++++++++--------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts index 0462b77886..15f28f3f4e 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts +++ b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ensNodeMetadata } from "@ensnode/ensnode-schema"; +import * as ensNodeSchema from "@ensnode/ensnode-schema/ensnode"; import { deserializeCrossChainIndexingStatusSnapshot, EnsNodeMetadataKeys, @@ -59,7 +59,7 @@ describe("EnsDbClient", () => { await expect(client.getEnsDbVersion()).resolves.toBeUndefined(); expect(selectMock).toHaveBeenCalledTimes(1); - expect(fromMock).toHaveBeenCalledWith(ensNodeMetadata); + expect(fromMock).toHaveBeenCalledWith(ensNodeSchema.ensNodeMetadata); }); it("returns value when one record exists", async () => { @@ -150,13 +150,14 @@ describe("EnsDbClient", () => { await client.upsertEnsDbVersion("0.2.0"); // assert - expect(insertMock).toHaveBeenCalledWith(ensNodeMetadata); + expect(insertMock).toHaveBeenCalledWith(ensNodeSchema.ensNodeMetadata); expect(valuesMock).toHaveBeenCalledWith({ + ensIndexerRef: ensDbClientMock.databaseSchemaName, key: EnsNodeMetadataKeys.EnsDbVersion, value: "0.2.0", }); expect(onConflictDoUpdateMock).toHaveBeenCalledWith({ - target: ensNodeMetadata.key, + target: [ensNodeSchema.ensNodeMetadata.ensIndexerRef, ensNodeSchema.ensNodeMetadata.key], set: { value: "0.2.0" }, }); }); @@ -176,6 +177,7 @@ describe("EnsDbClient", () => { // assert expect(valuesMock).toHaveBeenCalledWith({ + ensIndexerRef: ensDbClientMock.databaseSchemaName, key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, value: expectedValue, }); @@ -199,6 +201,7 @@ describe("EnsDbClient", () => { // assert expect(valuesMock).toHaveBeenCalledWith({ + ensIndexerRef: ensDbClientMock.databaseSchemaName, key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, value: expectedValue, }); diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts index 3af5de58fa..83a23f617a 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts +++ b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts @@ -1,7 +1,7 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { eq, sql } from "drizzle-orm/sql"; +import { and, eq, sql } from "drizzle-orm/sql"; -import { ensNodeMetadata } from "@ensnode/ensnode-schema"; +import * as ensNodeSchema from "@ensnode/ensnode-schema/ensnode"; import { type CrossChainIndexingStatusSnapshot, deserializeCrossChainIndexingStatusSnapshot, @@ -20,21 +20,12 @@ import { import { makeDrizzle } from "./drizzle"; -/** - * ENSDb Client Schema - * - * Includes schema definitions for {@link EnsDbClient} queries and mutations. - */ -const schema = { - ensNodeMetadata, -}; - /** * Drizzle database * * Allows interacting with Postgres database for ENSDb, using Drizzle ORM. */ -interface DrizzleDb extends NodePgDatabase {} +interface DrizzleDb extends NodePgDatabase {} /** * ENSDb Client @@ -53,16 +44,23 @@ export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { */ private db: DrizzleDb; + /** + * ENSIndexer reference string for multi-tenancy in ENSDb. + */ + private ensIndexerRef: string; + /** * @param databaseUrl connection string for ENSDb Postgres database - * @param databaseSchemaName Postgres schema name for ENSDb tables + * @param ensIndexerRef reference string for ENSIndexer instance (used for multi-tenancy in ENSDb) */ - constructor(databaseUrl: string, databaseSchemaName: string) { + constructor(databaseUrl: string, ensIndexerRef: string) { this.db = makeDrizzle({ - databaseSchema: databaseSchemaName, + databaseSchema: ensNodeSchema.ENSNODE_SCHEMA_NAME, databaseUrl, - schema, + schema: ensNodeSchema, }); + + this.ensIndexerRef = ensIndexerRef; } /** @@ -154,8 +152,13 @@ export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { ): Promise { const result = await this.db .select() - .from(ensNodeMetadata) - .where(eq(ensNodeMetadata.key, metadata.key)); + .from(ensNodeSchema.ensNodeMetadata) + .where( + and( + eq(ensNodeSchema.ensNodeMetadata.ensIndexerRef, this.ensIndexerRef), + eq(ensNodeSchema.ensNodeMetadata.key, metadata.key), + ), + ); if (result.length === 0) { return undefined; @@ -186,13 +189,14 @@ export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { ); await tx - .insert(ensNodeMetadata) + .insert(ensNodeSchema.ensNodeMetadata) .values({ + ensIndexerRef: this.ensIndexerRef, key: metadata.key, value: metadata.value, }) .onConflictDoUpdate({ - target: ensNodeMetadata.key, + target: [ensNodeSchema.ensNodeMetadata.ensIndexerRef, ensNodeSchema.ensNodeMetadata.key], set: { value: metadata.value }, }); }); From b5bd5ca8681268c21ef72eb1a076f9fa5dc20204 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 11:57:16 +0100 Subject: [PATCH 05/47] Replace Ponder Schema definitions with link to Ponder repository. --- .../ensnode-schema/src/ponder-schema/index.ts | 186 +----------------- .../src/ponder-schema/schema.ts | 5 - 2 files changed, 4 insertions(+), 187 deletions(-) delete mode 100644 packages/ensnode-schema/src/ponder-schema/schema.ts diff --git a/packages/ensnode-schema/src/ponder-schema/index.ts b/packages/ensnode-schema/src/ponder-schema/index.ts index 35af4870fd..bd2514647e 100644 --- a/packages/ensnode-schema/src/ponder-schema/index.ts +++ b/packages/ensnode-schema/src/ponder-schema/index.ts @@ -1,186 +1,8 @@ -// This file defines tables for RPC request cache schema. -// Relevant codebase was copied from Ponder Sync schema defined in the Ponder repository: -// https://github.com/ponder-sh/ponder/blob/ponder%400.16.2/packages/core/src/sync-store/schema.ts - /** - * RPC Request Cache Schema - * - * Defines the database objects for caching RPC requests and their results. + * Ponder Schema * - * This file defines tables for RPC request cache schema. - * Relevant codebase was copied from Ponder Sync schema defined in the Ponder repository. - * @see https://github.com/ponder-sh/ponder/blob/ponder%400.16.2/packages/core/src/sync-store/schema.ts + * Definition of the Ponder Schema can be found in the Ponder repository. + * @see https://github.com/ponder-sh/ponder/blob/ponder%400.16.3/packages/core/src/sync-store/schema.ts */ -import { customType, index, primaryKey } from "drizzle-orm/pg-core"; -import type { Address, Hash, Hex } from "viem"; - -import { PONDER_SCHEMA } from "./schema"; - -export { PONDER_SCHEMA_NAME } from "./schema"; - -const numeric78 = customType<{ data: bigint; driverData: string }>({ - dataType() { - return "numeric(78,0)"; - }, - fromDriver(value: string) { - return BigInt(value); - }, -}); - -export const blocks = PONDER_SCHEMA.table( - "blocks", - (t) => ({ - chainId: t.bigint({ mode: "bigint" }).notNull(), - number: t.bigint({ mode: "bigint" }).notNull(), - timestamp: t.bigint({ mode: "bigint" }).notNull(), - hash: t.varchar({ length: 66 }).notNull().$type(), - parentHash: t.varchar({ length: 66 }).notNull().$type(), - logsBloom: t.varchar({ length: 514 }).notNull().$type(), - miner: t.varchar({ length: 42 }).notNull().$type
(), - gasUsed: numeric78().notNull(), - gasLimit: numeric78().notNull(), - baseFeePerGas: numeric78(), - nonce: t.varchar({ length: 18 }).$type(), - mixHash: t.varchar({ length: 66 }).$type(), - stateRoot: t.varchar({ length: 66 }).notNull().$type(), - receiptsRoot: t.varchar({ length: 66 }).notNull().$type(), - transactionsRoot: t.varchar({ length: 66 }).notNull().$type(), - sha3Uncles: t.varchar({ length: 66 }).$type(), - size: numeric78().notNull(), - difficulty: numeric78().notNull(), - totalDifficulty: numeric78(), - extraData: t.text().notNull().$type(), - }), - (table) => [ - primaryKey({ - name: "blocks_pkey", - columns: [table.chainId, table.number], - }), - ], -); - -export const transactions = PONDER_SCHEMA.table( - "transactions", - (t) => ({ - chainId: t.bigint({ mode: "bigint" }).notNull(), - blockNumber: t.bigint({ mode: "bigint" }).notNull(), - transactionIndex: t.integer().notNull(), - hash: t.varchar({ length: 66 }).notNull().$type(), - blockHash: t.varchar({ length: 66 }).notNull().$type(), - from: t.varchar({ length: 42 }).notNull().$type
(), - to: t.varchar({ length: 42 }).$type
(), - input: t.text().notNull().$type(), - value: numeric78().notNull(), - nonce: t.integer().notNull(), - r: t.varchar({ length: 66 }).$type(), - s: t.varchar({ length: 66 }).$type(), - v: numeric78(), - type: t.text().notNull().$type(), - gas: numeric78().notNull(), - gasPrice: numeric78(), - maxFeePerGas: numeric78(), - maxPriorityFeePerGas: numeric78(), - accessList: t.text(), - }), - (table) => [ - primaryKey({ - name: "transactions_pkey", - columns: [table.chainId, table.blockNumber, table.transactionIndex], - }), - ], -); - -export const transactionReceipts = PONDER_SCHEMA.table( - "transaction_receipts", - (t) => ({ - chainId: t.bigint({ mode: "bigint" }).notNull(), - blockNumber: t.bigint({ mode: "bigint" }).notNull(), - transactionIndex: t.integer().notNull(), - transactionHash: t.varchar({ length: 66 }).notNull().$type(), - blockHash: t.varchar({ length: 66 }).notNull().$type(), - from: t.varchar({ length: 42 }).notNull().$type
(), - to: t.varchar({ length: 42 }).$type
(), - contractAddress: t.varchar({ length: 42 }).$type
(), // Note: incorrect - logsBloom: t.varchar({ length: 514 }).notNull().$type(), - gasUsed: numeric78().notNull(), - cumulativeGasUsed: numeric78().notNull(), - effectiveGasPrice: numeric78().notNull(), - status: t.text().notNull().$type(), - type: t.text().notNull().$type(), - }), - (table) => [ - primaryKey({ - name: "transaction_receipts_pkey", - columns: [table.chainId, table.blockNumber, table.transactionIndex], - }), - ], -); - -export const logs = PONDER_SCHEMA.table( - "logs", - (t) => ({ - chainId: t.bigint({ mode: "bigint" }).notNull(), - blockNumber: t.bigint({ mode: "bigint" }).notNull(), - logIndex: t.integer().notNull(), - transactionIndex: t.integer().notNull(), - blockHash: t.varchar({ length: 66 }).notNull().$type(), - transactionHash: t.varchar({ length: 66 }).notNull().$type(), - address: t.varchar({ length: 42 }).notNull().$type
(), - topic0: t.varchar({ length: 66 }).$type(), - topic1: t.varchar({ length: 66 }).$type(), - topic2: t.varchar({ length: 66 }).$type(), - topic3: t.varchar({ length: 66 }).$type(), - data: t.text().notNull().$type(), - }), - (table) => [ - primaryKey({ - name: "logs_pkey", - columns: [table.chainId, table.blockNumber, table.logIndex], - }), - ], -); - -export const traces = PONDER_SCHEMA.table( - "traces", - (t) => ({ - chainId: t.bigint({ mode: "bigint" }).notNull(), - blockNumber: t.bigint({ mode: "bigint" }).notNull(), - transactionIndex: t.integer().notNull(), - traceIndex: t.integer().notNull(), - from: t.varchar({ length: 42 }).notNull().$type
(), - to: t.varchar({ length: 42 }).$type
(), - input: t.text().notNull().$type(), - output: t.text().$type(), - value: numeric78(), - type: t.text().notNull(), - gas: numeric78().notNull(), - gasUsed: numeric78().notNull(), - error: t.text(), - revertReason: t.text(), - subcalls: t.integer().notNull(), - }), - (table) => [ - primaryKey({ - name: "traces_pkey", - columns: [table.chainId, table.blockNumber, table.transactionIndex, table.traceIndex], - }), - ], -); - -export const rpcRequestResults = PONDER_SCHEMA.table( - "rpc_request_results", - (t) => ({ - requestHash: t.text().notNull(), - chainId: t.bigint({ mode: "bigint" }).notNull(), - blockNumber: t.bigint({ mode: "bigint" }), - result: t.text().notNull(), - }), - (table) => [ - primaryKey({ - name: "rpc_request_results_pkey", - columns: [table.chainId, table.requestHash], - }), - index("rpc_request_results_chain_id_block_number_index").on(table.chainId, table.blockNumber), - ], -); +export const PONDER_SCHEMA_NAME = "ponder_sync"; diff --git a/packages/ensnode-schema/src/ponder-schema/schema.ts b/packages/ensnode-schema/src/ponder-schema/schema.ts deleted file mode 100644 index 459507c66f..0000000000 --- a/packages/ensnode-schema/src/ponder-schema/schema.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { pgSchema } from "drizzle-orm/pg-core"; - -export const PONDER_SCHEMA_NAME = "ponder_sync"; - -export const PONDER_SCHEMA = pgSchema(PONDER_SCHEMA_NAME); From 3f7f0e8332c9444cd342d14cf58ba1ef0d762a65 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 11:57:29 +0100 Subject: [PATCH 06/47] Introduce docs for the schema package --- packages/ensnode-schema/src/README.md | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 packages/ensnode-schema/src/README.md diff --git a/packages/ensnode-schema/src/README.md b/packages/ensnode-schema/src/README.md new file mode 100644 index 0000000000..022addd1a4 --- /dev/null +++ b/packages/ensnode-schema/src/README.md @@ -0,0 +1,28 @@ +# ENSDb Schemas + +Package defining database schemas used in ENSDb. + +## Ponder Schema + +- Owned by Ponder app. +- Shared across ENSIndexer instances. +- Includes responses to RPC requests made by cached public clients. +- Usually large in size, depending on selected ENS Namespace. +- Takes a long time to build. +- Backups highly recommended for sharing RPC cache across different ENSNode environments. + - For example, pulling production backup into local environment in order to test production workflows in isolation. + +## ENSIndexer Schema + +- Owned by an ENSIndexer instance and also influenced by Ponder implementation details, as ENSIndexer is a Ponder app. +- Isolated for specific ENSIndexer instance. +- Includes indexed data based on event handlers logic from active ENSIndexer plugins. +- May be large in size, depending on selected ENS Namespace and active plugins. +- May take a long time to build. Must be re-built from scratch in case of indexing logic changes (i.e. event handler code change, active plugins change). + +## ENSNode Schema + +- Owned by ENSNode services. +- Includes metadata describing configuration and state of various ENSNode services. +- Tiny in size. +- Takes virtually no time to be built. From c1964a20445ff68997553328aa9c438c02b56f34 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 12:04:24 +0100 Subject: [PATCH 07/47] Apply AI PR feedback --- .../src/lib/ensdb-client/ensdb-client.mock.ts | 4 ++ .../src/lib/ensdb-client/ensdb-client.test.ts | 51 +++++-------------- .../src/lib/ensdb-client/singleton.ts | 6 ++- packages/ensnode-schema/package.json | 12 ++--- 4 files changed, 27 insertions(+), 46 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts index 7387cc19fd..91f7cf273b 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts +++ b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts @@ -23,6 +23,10 @@ export const databaseUrl = "postgres://user:pass@localhost:5432/ensdb"; export const databaseSchemaName = "public"; +// This is the same as the default value of config.databaseSchemaName, +// which is used as the ensIndexerRef for multi-tenancy in ENSDb. +export const ensIndexerRef = databaseSchemaName; + export const publicConfig = { databaseSchemaName, ensRainbowPublicConfig: { diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts index 15f28f3f4e..480bc8a749 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts +++ b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts @@ -50,10 +50,7 @@ describe("EnsDbClient", () => { describe("getEnsDbVersion", () => { it("returns undefined when no record exists", async () => { // arrange - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); + const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); // act & assert await expect(client.getEnsDbVersion()).resolves.toBeUndefined(); @@ -66,10 +63,7 @@ describe("EnsDbClient", () => { // arrange selectResult.current = [{ value: "0.1.0" }]; - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); + const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); // act & assert await expect(client.getEnsDbVersion()).resolves.toBe("0.1.0"); @@ -81,10 +75,7 @@ describe("EnsDbClient", () => { // arrange selectResult.current = [{ value: "0.1.0" }, { value: "0.1.1" }]; - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); + const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); // act & assert await expect(client.getEnsDbVersion()).rejects.toThrowError(/ensdb_version/i); @@ -94,10 +85,7 @@ describe("EnsDbClient", () => { describe("getEnsIndexerPublicConfig", () => { it("returns undefined when no record exists", async () => { // arrange - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); + const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); // act & assert await expect(client.getEnsIndexerPublicConfig()).resolves.toBeUndefined(); @@ -108,10 +96,7 @@ describe("EnsDbClient", () => { const serializedConfig = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); selectResult.current = [{ value: serializedConfig }]; - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); + const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); // act & assert await expect(client.getEnsIndexerPublicConfig()).resolves.toStrictEqual( @@ -125,10 +110,7 @@ describe("EnsDbClient", () => { // arrange selectResult.current = [{ value: ensDbClientMock.serializedSnapshot }]; - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); + const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); const expected = deserializeCrossChainIndexingStatusSnapshot( ensDbClientMock.serializedSnapshot, ); @@ -141,10 +123,7 @@ describe("EnsDbClient", () => { describe("upsertEnsDbVersion", () => { it("writes the database version metadata", async () => { // arrange - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); + const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); // act await client.upsertEnsDbVersion("0.2.0"); @@ -152,7 +131,7 @@ describe("EnsDbClient", () => { // assert expect(insertMock).toHaveBeenCalledWith(ensNodeSchema.ensNodeMetadata); expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerRef: ensDbClientMock.databaseSchemaName, + ensIndexerRef: ensDbClientMock.ensIndexerRef, key: EnsNodeMetadataKeys.EnsDbVersion, value: "0.2.0", }); @@ -166,10 +145,7 @@ describe("EnsDbClient", () => { describe("upsertEnsIndexerPublicConfig", () => { it("serializes and writes the public config", async () => { // arrange - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); + const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); const expectedValue = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); // act @@ -177,7 +153,7 @@ describe("EnsDbClient", () => { // assert expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerRef: ensDbClientMock.databaseSchemaName, + ensIndexerRef: ensDbClientMock.ensIndexerRef, key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, value: expectedValue, }); @@ -187,10 +163,7 @@ describe("EnsDbClient", () => { describe("upsertIndexingStatusSnapshot", () => { it("serializes and writes the indexing status snapshot", async () => { // arrange - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); + const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); const snapshot = deserializeCrossChainIndexingStatusSnapshot( ensDbClientMock.serializedSnapshot, ); @@ -201,7 +174,7 @@ describe("EnsDbClient", () => { // assert expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerRef: ensDbClientMock.databaseSchemaName, + ensIndexerRef: ensDbClientMock.ensIndexerRef, key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, value: expectedValue, }); diff --git a/apps/ensindexer/src/lib/ensdb-client/singleton.ts b/apps/ensindexer/src/lib/ensdb-client/singleton.ts index 3ab2225fd2..a47ef727c6 100644 --- a/apps/ensindexer/src/lib/ensdb-client/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-client/singleton.ts @@ -2,7 +2,11 @@ import config from "@/config"; import { EnsDbClient } from "./ensdb-client"; +// config.databaseSchemaName is unique per ENSIndexer instance and is used as the ensIndexerRef +// tenant key in the shared ENSNode schema (ensnode.*). +const ensIndexerRef = config.databaseSchemaName; + /** * Singleton instance of {@link EnsDbClient} for use in ENSIndexer. */ -export const ensDbClient = new EnsDbClient(config.databaseUrl, config.databaseSchemaName); +export const ensDbClient = new EnsDbClient(config.databaseUrl, ensIndexerRef); diff --git a/packages/ensnode-schema/package.json b/packages/ensnode-schema/package.json index 29a4100e8a..7e47266968 100644 --- a/packages/ensnode-schema/package.json +++ b/packages/ensnode-schema/package.json @@ -35,20 +35,20 @@ }, "./ponder": { "import": { - "types": "./dist/ponder-schema.d.ts", - "default": "./dist/ponder-schema.js" + "types": "./dist/ponder-schema/index.d.ts", + "default": "./dist/ponder-schema/index.js" } }, "./ensindexer": { "import": { - "types": "./dist/ensindexer-schema.d.ts", - "default": "./dist/ensindexer-schema.js" + "types": "./dist/ensindexer-schema/index.d.ts", + "default": "./dist/ensindexer-schema/index.js" } }, "./ensnode": { "import": { - "types": "./dist/ensnode-schema.d.ts", - "default": "./dist/ensnode-schema.js" + "types": "./dist/ensnode-schema/index.d.ts", + "default": "./dist/ensnode-schema/index.js" } } } From b4fb135dc7d4bcc22d7db340e4e3d293c87015d3 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 12:08:12 +0100 Subject: [PATCH 08/47] docs(changeset): Split database schemas into Ponder Schema, ENSIndexer Schema, and ENSNode Schema. --- .changeset/petite-peaches-watch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/petite-peaches-watch.md diff --git a/.changeset/petite-peaches-watch.md b/.changeset/petite-peaches-watch.md new file mode 100644 index 0000000000..2fb43b5ae8 --- /dev/null +++ b/.changeset/petite-peaches-watch.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-schema": minor +--- + +Split database schemas into Ponder Schema, ENSIndexer Schema, and ENSNode Schema. From 6b6f805a110e3cf05c5b7b04e97e367a418e7c79 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 12:08:51 +0100 Subject: [PATCH 09/47] docs(changeset): Updated ENSDb connections to be always read-only. --- .changeset/vast-comics-burn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/vast-comics-burn.md diff --git a/.changeset/vast-comics-burn.md b/.changeset/vast-comics-burn.md new file mode 100644 index 0000000000..98e82272ec --- /dev/null +++ b/.changeset/vast-comics-burn.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Updated ENSDb connections to be always read-only. From 9c05883502d389a4d4dd8a4dd5a38b9a37faa4fd Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 12:36:45 +0100 Subject: [PATCH 10/47] Update make drizzle helpers to only apply schema "monkeypatching" when needed for ENSIndexer schemas --- apps/ensapi/src/lib/handlers/drizzle.ts | 17 ++++++++++++----- apps/ensindexer/src/lib/ensdb-client/drizzle.ts | 6 ------ .../src/lib/ensdb-client/ensdb-client.ts | 4 ++-- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/apps/ensapi/src/lib/handlers/drizzle.ts b/apps/ensapi/src/lib/handlers/drizzle.ts index 6847f04b30..173252c908 100644 --- a/apps/ensapi/src/lib/handlers/drizzle.ts +++ b/apps/ensapi/src/lib/handlers/drizzle.ts @@ -18,15 +18,22 @@ export const makeReadOnlyDrizzle = ({ }: { schema: SCHEMA; databaseUrl: string; - databaseSchema: string; + databaseSchema?: string; }) => { - // monkeypatch schema onto tables - setDatabaseSchema(schema, databaseSchema); + // monkeypatch schema onto tables if databaseSchema is provided + if (databaseSchema) { + setDatabaseSchema(schema, databaseSchema); + } + + const parsedConfig = parseIntoClientConfig(databaseUrl); + const existingOptions = parsedConfig.options || ""; + const readOnlyOption = "-c default_transaction_read_only=on"; return drizzle({ connection: { - ...parseIntoClientConfig(databaseUrl), - options: "-c default_transaction_read_only=on", + ...parsedConfig, + // Combine existing options from URL with read-only requirement + options: existingOptions ? `${existingOptions} ${readOnlyOption}` : readOnlyOption, }, schema, casing: "snake_case", diff --git a/apps/ensindexer/src/lib/ensdb-client/drizzle.ts b/apps/ensindexer/src/lib/ensdb-client/drizzle.ts index b5cc9b5c73..079e18c581 100644 --- a/apps/ensindexer/src/lib/ensdb-client/drizzle.ts +++ b/apps/ensindexer/src/lib/ensdb-client/drizzle.ts @@ -2,7 +2,6 @@ // We currently duplicate the makeDrizzle function, as we don't have // a shared package for backend code yet. When we do, we can move // this function to the shared package and import it in both places. -import { setDatabaseSchema } from "@ponder/client"; import { drizzle } from "drizzle-orm/node-postgres"; type Schema = { [name: string]: unknown }; @@ -13,14 +12,9 @@ type Schema = { [name: string]: unknown }; export const makeDrizzle = ({ schema, databaseUrl, - databaseSchema, }: { schema: SCHEMA; databaseUrl: string; - databaseSchema: string; }) => { - // monkeypatch schema onto tables - setDatabaseSchema(schema, databaseSchema); - return drizzle(databaseUrl, { schema, casing: "snake_case" }); }; diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts index 83a23f617a..e6769a6928 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts +++ b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts @@ -55,7 +55,6 @@ export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { */ constructor(databaseUrl: string, ensIndexerRef: string) { this.db = makeDrizzle({ - databaseSchema: ensNodeSchema.ENSNODE_SCHEMA_NAME, databaseUrl, schema: ensNodeSchema, }); @@ -145,7 +144,8 @@ export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { * * @returns selected record in ENSDb. * @throws when more than one matching metadata record is found - * (should be impossible given the PK constraint on 'key') + * (should be impossible given the composite PK constraint on + * 'ensIndexerRef' and 'key'). */ private async getEnsNodeMetadata( metadata: Pick, From f13b6afd133bce946ab19a55aa163f2c6d9bc2af Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 13:03:02 +0100 Subject: [PATCH 11/47] Remove db write workaround We do not write into ENSIndexer Schema anymore, so no need for extra queries to make writes to work. --- .../src/lib/ensdb-client/ensdb-client.ts | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts index e6769a6928..d743f35908 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts +++ b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts @@ -179,26 +179,16 @@ export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { private async upsertEnsNodeMetadata< EnsNodeMetadataType extends SerializedEnsNodeMetadata = SerializedEnsNodeMetadata, >(metadata: EnsNodeMetadataType): Promise { - await this.db.transaction(async (tx) => { - // Ponder live-query triggers insert into live_query_tables. - // Because this worker writes outside the Ponder runtime connection pool, - // the temp table must be ensured to exist on this connection. Without this, - // the upsert would fail with "relation 'live_query_tables' does not exist" error. - await tx.execute( - sql`CREATE TEMP TABLE IF NOT EXISTS live_query_tables (table_name TEXT PRIMARY KEY)`, - ); - - await tx - .insert(ensNodeSchema.ensNodeMetadata) - .values({ - ensIndexerRef: this.ensIndexerRef, - key: metadata.key, - value: metadata.value, - }) - .onConflictDoUpdate({ - target: [ensNodeSchema.ensNodeMetadata.ensIndexerRef, ensNodeSchema.ensNodeMetadata.key], - set: { value: metadata.value }, - }); - }); + await this.db + .insert(ensNodeSchema.ensNodeMetadata) + .values({ + ensIndexerRef: this.ensIndexerRef, + key: metadata.key, + value: metadata.value, + }) + .onConflictDoUpdate({ + target: [ensNodeSchema.ensNodeMetadata.ensIndexerRef, ensNodeSchema.ensNodeMetadata.key], + set: { value: metadata.value }, + }); } } From 1791217da8d4f1f83f96584762158844eb307a0e Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 13:06:39 +0100 Subject: [PATCH 12/47] Update ENSIndexer Schema references to be explicit --- apps/ensapi/src/lib/db.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ensapi/src/lib/db.ts b/apps/ensapi/src/lib/db.ts index 71a2f5e781..db88e5e2ce 100644 --- a/apps/ensapi/src/lib/db.ts +++ b/apps/ensapi/src/lib/db.ts @@ -1,6 +1,6 @@ import config from "@/config"; -import * as schema from "@ensnode/ensnode-schema"; +import * as ensIndexerSchema from "@ensnode/ensnode-schema/ensindexer"; import { makeReadOnlyDrizzle } from "@/lib/handlers/drizzle"; @@ -10,5 +10,5 @@ import { makeReadOnlyDrizzle } from "@/lib/handlers/drizzle"; export const db = makeReadOnlyDrizzle({ databaseUrl: config.databaseUrl, databaseSchema: config.databaseSchemaName, - schema, + schema: ensIndexerSchema, }); From e300888ba1e0abef1a913021b72d87a52d3c853d Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 06:53:40 +0100 Subject: [PATCH 13/47] Create a read-only `EnsDbClient` for ENSApi to handle updated DB schema definitions. Handle multi-tenancy in ENSNode Metadata table. --- .../src/lib/ensdb-client/ensdb-client.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 apps/ensapi/src/lib/ensdb-client/ensdb-client.ts 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 0000000000..35b33cf349 --- /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`); + } +} From a1656f64ebd8d4b03265fd6d181f04af93d62546 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 06:59:22 +0100 Subject: [PATCH 14/47] Build ENSApi Config with ENSDb Cleint Replaces ENSIndexer Client fetch with ENSDb Client read. --- apps/ensapi/src/config/config.schema.ts | 33 ++++++++++++------- apps/ensapi/src/config/environment.ts | 2 +- .../ensapi/src/lib/fetch-ensindexer-config.ts | 17 ---------- 3 files changed, 22 insertions(+), 30 deletions(-) delete mode 100644 apps/ensapi/src/lib/fetch-ensindexer-config.ts diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index ec402ba89d..e9fab8285f 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 119490fdf0..550a1c4b32 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/fetch-ensindexer-config.ts b/apps/ensapi/src/lib/fetch-ensindexer-config.ts deleted file mode 100644 index 0cd11beb8b..0000000000 --- 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); -} From d53db6d255207b26d82cc147f8547e1da04ade16 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 11:30:35 +0100 Subject: [PATCH 15/47] Update example env file Includes `DATABASE_SCHEMA` env var --- apps/ensapi/.env.local.example | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index 6de2ee84ba..e12b99ca87 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 From 7ec0fb8ffc9f28869fbe3581d645556135021de0 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 12:53:38 +0100 Subject: [PATCH 16/47] Update config builder test --- apps/ensapi/src/config/config.schema.test.ts | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 407effcb29..7060e76022 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, }; From 8a056f21b8207ca759135b781295f04aa6792809 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 12:59:24 +0100 Subject: [PATCH 17/47] Apply terraform script updates --- terraform/modules/ensindexer/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/modules/ensindexer/main.tf b/terraform/modules/ensindexer/main.tf index 1c5d81204a..dcfa5fd623 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 }, From 38f30d8d77e0d1e4d7d752d0dea0651ffc5e5d5d Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 13:12:38 +0100 Subject: [PATCH 18/47] docs(changeset): Replaced ENSIndexer Public Config source, from ENSIndexer to ENSDb. --- .changeset/humble-pets-trade.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/humble-pets-trade.md diff --git a/.changeset/humble-pets-trade.md b/.changeset/humble-pets-trade.md new file mode 100644 index 0000000000..b12a58333c --- /dev/null +++ b/.changeset/humble-pets-trade.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Replaced ENSIndexer Public Config source, from ENSIndexer to ENSDb. From 49c7deb685b1a5177e673c720f548eba4e54830c Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 10:35:40 +0100 Subject: [PATCH 19/47] Setup drizzle-kit migrations --- apps/ensindexer/drizzle-kit/config.ts | 13 ++++ .../migrations/0000_smiling_prima.sql | 8 +++ .../migrations/meta/0000_snapshot.json | 55 +++++++++++++++ .../drizzle-kit/migrations/meta/_journal.json | 13 ++++ apps/ensindexer/drizzle-kit/schema.ts | 2 + apps/ensindexer/package.json | 4 +- pnpm-lock.yaml | 70 +++++++++++++++++++ 7 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 apps/ensindexer/drizzle-kit/config.ts create mode 100644 apps/ensindexer/drizzle-kit/migrations/0000_smiling_prima.sql create mode 100644 apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json create mode 100644 apps/ensindexer/drizzle-kit/migrations/meta/_journal.json create mode 100644 apps/ensindexer/drizzle-kit/schema.ts diff --git a/apps/ensindexer/drizzle-kit/config.ts b/apps/ensindexer/drizzle-kit/config.ts new file mode 100644 index 0000000000..12f271e0b7 --- /dev/null +++ b/apps/ensindexer/drizzle-kit/config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "drizzle-kit"; + +// Resolve the path to the database schema file for Drizzle migrations. +const dbSchemaPath = fileURLToPath(new URL("./schema.ts", import.meta.url)); + +export default defineConfig({ + casing: "snake_case", + dialect: "postgresql", + out: `drizzle-kit/migrations`, + schema: dbSchemaPath, +}); diff --git a/apps/ensindexer/drizzle-kit/migrations/0000_smiling_prima.sql b/apps/ensindexer/drizzle-kit/migrations/0000_smiling_prima.sql new file mode 100644 index 0000000000..a8db2236bf --- /dev/null +++ b/apps/ensindexer/drizzle-kit/migrations/0000_smiling_prima.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA IF NOT EXISTS ensnode; + +CREATE TABLE "ensnode"."ensnode_metadata" ( + "ens_indexer_ref" text NOT NULL, + "key" text NOT NULL, + "value" jsonb NOT NULL, + CONSTRAINT "ensnode_metadata_pkey" PRIMARY KEY("ens_indexer_ref","key") +); diff --git a/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json b/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000000..b398463634 --- /dev/null +++ b/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json @@ -0,0 +1,55 @@ +{ + "id": "d05a87cf-3971-41ac-9eab-a15a54711a47", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "ensnode.ensnode_metadata": { + "name": "ensnode_metadata", + "schema": "ensnode", + "columns": { + "ens_indexer_ref": { + "name": "ens_indexer_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ensnode_metadata_pkey": { + "name": "ensnode_metadata_pkey", + "columns": ["ens_indexer_ref", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json b/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json new file mode 100644 index 0000000000..4423561778 --- /dev/null +++ b/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1773295356548, + "tag": "0000_smiling_prima", + "breakpoints": true + } + ] +} diff --git a/apps/ensindexer/drizzle-kit/schema.ts b/apps/ensindexer/drizzle-kit/schema.ts new file mode 100644 index 0000000000..94f4f63dd7 --- /dev/null +++ b/apps/ensindexer/drizzle-kit/schema.ts @@ -0,0 +1,2 @@ +// Re-export the ENSNode schema for Drizzle migrations. +export * from "@ensnode/ensnode-schema/ensnode"; diff --git a/apps/ensindexer/package.json b/apps/ensindexer/package.json index e559cecbee..348bdbb625 100644 --- a/apps/ensindexer/package.json +++ b/apps/ensindexer/package.json @@ -20,7 +20,8 @@ "test": "vitest", "lint": "biome check --write .", "lint:ci": "biome ci", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "drizzle-gen": "drizzle-kit generate --config ./drizzle-kit/config.ts" }, "dependencies": { "@ensdomains/ensjs": "^4.0.2", @@ -34,6 +35,7 @@ "date-fns": "catalog:", "deepmerge-ts": "^7.1.5", "dns-packet": "^5.6.1", + "drizzle-kit": "0.31.9", "drizzle-orm": "catalog:", "pg-connection-string": "catalog:", "p-retry": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0c9b53848..381e5d254c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -487,6 +487,9 @@ importers: dns-packet: specifier: ^5.6.1 version: 5.6.1 + drizzle-kit: + specifier: 0.31.9 + version: 0.31.9 drizzle-orm: specifier: 'catalog:' version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.16.3) @@ -1643,6 +1646,9 @@ packages: resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@electric-sql/pglite@0.2.13': resolution: {integrity: sha512-YRY806NnScVqa21/1L1vaysSQ+0/cAva50z7vlwzaGiBOTS9JhdzIRHN0KfgMhobFAphbznZJ7urMso4RtMBIQ==} @@ -1704,6 +1710,14 @@ packages: resolution: {integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==} engines: {node: '>=18.0.0'} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.25.11': resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} engines: {node: '>=18'} @@ -4868,6 +4882,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -5569,6 +5586,10 @@ packages: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} + drizzle-kit@0.31.9: + resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} + hasBin: true + drizzle-orm@0.41.0: resolution: {integrity: sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==} peerDependencies: @@ -5742,6 +5763,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.25.0' + esbuild@0.25.11: resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} engines: {node: '>=18'} @@ -8048,10 +8074,17 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.5.6: resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -10162,6 +10195,8 @@ snapshots: '@ctrl/tinycolor@4.2.0': {} + '@drizzle-team/brocli@0.10.2': {} + '@electric-sql/pglite@0.2.13': {} '@emmetio/abbreviation@2.3.3': @@ -10257,6 +10292,16 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.27.2 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.0 + '@esbuild/aix-ppc64@0.25.11': optional: true @@ -13787,6 +13832,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -14492,6 +14539,15 @@ snapshots: dotenv@8.6.0: {} + drizzle-kit@0.31.9: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.11 + esbuild-register: 3.6.0(esbuild@0.25.11) + transitivePeerDependencies: + - supports-color + drizzle-orm@0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.16.3): optionalDependencies: '@electric-sql/pglite': 0.2.13 @@ -14585,6 +14641,13 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild-register@3.6.0(esbuild@0.25.11): + dependencies: + debug: 4.4.3 + esbuild: 0.25.11 + transitivePeerDependencies: + - supports-color + esbuild@0.25.11: optionalDependencies: '@esbuild/aix-ppc64': 0.25.11 @@ -17662,8 +17725,15 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map@0.5.6: {} + source-map@0.6.1: {} + source-map@0.7.6: {} source-map@0.8.0-beta.0: From 0c659148ebddd6549b5b784a5525c6869f6a5078 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 10:36:14 +0100 Subject: [PATCH 20/47] Make ENSDb Writer Worker to execute database migrations --- .../src/lib/ensdb-client/ensdb-client.ts | 15 ++++++-- .../ensdb-writer-worker.ts | 36 ++++++++++++++++--- .../src/lib/ensdb-writer-worker/singleton.ts | 7 ++++ packages/ensnode-sdk/src/ensdb/client.ts | 10 ++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts index d743f35908..c60c7ce3b9 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts +++ b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts @@ -1,5 +1,6 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { and, eq, sql } from "drizzle-orm/sql"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { and, eq } from "drizzle-orm/sql"; import * as ensNodeSchema from "@ensnode/ensnode-schema/ensnode"; import { @@ -8,6 +9,7 @@ import { deserializeEnsIndexerPublicConfig, type EnsDbClientMutation, type EnsDbClientQuery, + type EnsDbMigration, type EnsIndexerPublicConfig, EnsNodeMetadataKeys, type SerializedEnsNodeMetadata, @@ -38,7 +40,7 @@ interface DrizzleDb extends NodePgDatabase {} * - ENSDb version * - ENSIndexer Public Config, and Indexing Status Snapshot and CrossChainIndexingStatusSnapshot. */ -export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { +export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation, EnsDbMigration { /** * Drizzle database instance for ENSDb. */ @@ -191,4 +193,13 @@ export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { set: { value: metadata.value }, }); } + + /** + * @inheritdoc + */ + async migrate(migrationsDirPath: string): Promise { + return migrate(this.db, { + migrationsFolder: migrationsDirPath, + }); + } } diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 78bcd921a0..e00c7d7fc3 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -24,10 +24,12 @@ const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 1; /** * ENSDb Writer Worker * - * A worker responsible for writing ENSIndexer-related metadata into ENSDb, including: - * - ENSDb version - * - ENSIndexer Public Config - * - ENSIndexer Indexing Status Snapshots + * A worker responsible for: + * - executing ENSDb database migrations on startup, and + * - writing ENSNode-related metadata into ENSDb, including: + * - ENSDb version + * - ENSIndexer Public Config + * - Indexing Status Snapshots */ export class EnsDbWriterWorker { /** @@ -50,25 +52,34 @@ export class EnsDbWriterWorker { */ private publicConfigBuilder: PublicConfigBuilder; + /** + * Path to the directory containing ENSDb migrations to be executed by the worker on startup. + */ + private migrationsDirPath: string; + /** * @param ensDbClient ENSDb Client instance used by the worker to interact with ENSDb. * @param publicConfigBuilder ENSIndexer Public Config Builder instance used by the worker to read ENSIndexer Public Config. * @param indexingStatusBuilder Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. + * @param migrationsDirPath Path to the directory containing ENSDb migrations to be executed by the worker on startup. */ constructor( ensDbClient: EnsDbClient, publicConfigBuilder: PublicConfigBuilder, indexingStatusBuilder: IndexingStatusBuilder, + migrationsDirPath: string, ) { this.ensDbClient = ensDbClient; this.publicConfigBuilder = publicConfigBuilder; this.indexingStatusBuilder = indexingStatusBuilder; + this.migrationsDirPath = migrationsDirPath; } /** * Run the ENSDb Writer Worker * * The worker performs the following tasks: + * 0) Execute pending ENSDb migrations. * 1) A single attempt to upsert ENSDb version into ENSDb. * 2) A single attempt to upsert serialized representation of * {@link EnsIndexerPublicConfig} into ENSDb. @@ -76,6 +87,7 @@ export class EnsDbWriterWorker { * {@link CrossChainIndexingStatusSnapshot} into ENSDb. * * @throws Error if the worker is already running, or + * if database migrations execution fails, or * if the in-memory ENSIndexer Public Config could not be fetched, or * if the in-memory ENSIndexer Public Config is incompatible with the stored config in ENSDb. */ @@ -85,6 +97,11 @@ export class EnsDbWriterWorker { throw new Error("EnsDbWriterWorker is already running"); } + // Task 0: execute database migrations + console.log(`[EnsDbWriterWorker]: Executing database migrations...`); + await this.executeMigrations(); + console.log(`[EnsDbWriterWorker]: Database migrations executed successfully`); + // Fetch data required for task 1 and task 2. const inMemoryConfig = await this.getValidatedEnsIndexerPublicConfig(); @@ -126,6 +143,17 @@ export class EnsDbWriterWorker { } } + /** + * Execute database migrations for the ENSDb Writer Worker. + * + * Runs all pending migrations in the defined migrations directory. + * + * @throws Error if any migration fails to execute. + */ + private async executeMigrations(): Promise { + await this.ensDbClient.migrate(this.migrationsDirPath); + } + /** * Get validated ENSIndexer Public Config object for the ENSDb Writer Worker. * diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index d58ddc9e9d..620b71181c 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url"; + import { ensDbClient } from "@/lib/ensdb-client/singleton"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; @@ -20,10 +22,15 @@ export function startEnsDbWriterWorker() { throw new Error("EnsDbWriterWorker has already been initialized"); } + const migrationsDirPath = fileURLToPath( + new URL("../../../drizzle-kit/migrations/", import.meta.url), + ); + ensDbWriterWorker = new EnsDbWriterWorker( ensDbClient, publicConfigBuilder, indexingStatusBuilder, + migrationsDirPath, ); ensDbWriterWorker diff --git a/packages/ensnode-sdk/src/ensdb/client.ts b/packages/ensnode-sdk/src/ensdb/client.ts index 0cbf9b287e..d04e033849 100644 --- a/packages/ensnode-sdk/src/ensdb/client.ts +++ b/packages/ensnode-sdk/src/ensdb/client.ts @@ -56,3 +56,13 @@ export interface EnsDbClientMutation { */ upsertIndexingStatusSnapshot(indexingStatus: CrossChainIndexingStatusSnapshot): Promise; } + +export interface EnsDbMigration { + /** + * Execute pending migrations for ENSDb. + * + * @param migrationsDirPath - The file path to the directory containing migration files. + * @throws error when migration execution fails. + */ + migrate(migrationsDirPath: string): Promise; +} From 3a4ebe6ee377dbfc680db82111412d952f6fb8b8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 13:40:44 +0100 Subject: [PATCH 21/47] Update tests and mocks for EnsDbWriterWorker --- .../ensdb-writer-worker.mock.ts | 16 +++++ .../ensdb-writer-worker.test.ts | 68 +++++++++++++++---- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts index 0b98b5e516..b8b226851d 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts @@ -10,6 +10,7 @@ import { import type { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; import * as ensDbClientMock from "@/lib/ensdb-client/ensdb-client.mock"; +import { EnsDbWriterWorker } from "@/lib/ensdb-writer-worker/ensdb-writer-worker"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder"; @@ -23,12 +24,27 @@ export function createMockEnsDbClient( } as unknown as EnsDbClient; } +export function createMockEnsDbWriterWorker( + ensDbClient: EnsDbClient, + publicConfigBuilder: PublicConfigBuilder, + indexingStatusBuilder: IndexingStatusBuilder, + migrationsDirPath: string = "/mock/migrations", +) { + return new EnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + migrationsDirPath, + ); +} + export function baseEnsDbClient() { return { getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), + migrate: vi.fn().mockResolvedValue(undefined), }; } diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index f46ad5f938..2356fc4b72 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -7,13 +7,13 @@ import { } from "@ensnode/ensnode-sdk"; import * as ensDbClientMock from "@/lib/ensdb-client/ensdb-client.mock"; -import { EnsDbWriterWorker } from "@/lib/ensdb-writer-worker/ensdb-writer-worker"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; import { createMockCrossChainSnapshot, createMockEnsDbClient, + createMockEnsDbWriterWorker, createMockIndexingStatusBuilder, createMockOmnichainSnapshot, createMockPublicConfigBuilder, @@ -54,7 +54,11 @@ describe("EnsDbWriterWorker", () => { const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshot); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); @@ -94,7 +98,11 @@ describe("EnsDbWriterWorker", () => { const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act & assert await expect(worker.run()).rejects.toThrow("incompatible"); @@ -107,7 +115,11 @@ describe("EnsDbWriterWorker", () => { const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act - first run await worker.run(); @@ -128,7 +140,11 @@ describe("EnsDbWriterWorker", () => { } as unknown as PublicConfigBuilder; const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act & assert await expect(worker.run()).rejects.toThrow("Network failure"); @@ -145,7 +161,11 @@ describe("EnsDbWriterWorker", () => { const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act & assert await expect(worker.run()).rejects.toThrow("Database connection lost"); @@ -164,7 +184,11 @@ describe("EnsDbWriterWorker", () => { const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); @@ -186,7 +210,11 @@ describe("EnsDbWriterWorker", () => { const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); @@ -210,7 +238,11 @@ describe("EnsDbWriterWorker", () => { const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); @@ -235,7 +267,11 @@ describe("EnsDbWriterWorker", () => { const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // assert - not running initially expect(worker.isRunning).toBe(false); @@ -280,7 +316,11 @@ describe("EnsDbWriterWorker", () => { .mockResolvedValueOnce(validSnapshot), } as unknown as IndexingStatusBuilder; - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act - run returns immediately await worker.run(); @@ -337,7 +377,11 @@ describe("EnsDbWriterWorker", () => { .mockResolvedValueOnce(snapshot2), } as unknown as IndexingStatusBuilder; - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); From b94f93d99b0ab6a227c0fa72ca06696e62fa8e70 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 15:21:18 +0100 Subject: [PATCH 22/47] Scope `drizzle-kit` as dev dependency to ENSIndexer --- apps/ensindexer/package.json | 2 +- pnpm-lock.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ensindexer/package.json b/apps/ensindexer/package.json index 348bdbb625..ca7192c1b6 100644 --- a/apps/ensindexer/package.json +++ b/apps/ensindexer/package.json @@ -35,7 +35,6 @@ "date-fns": "catalog:", "deepmerge-ts": "^7.1.5", "dns-packet": "^5.6.1", - "drizzle-kit": "0.31.9", "drizzle-orm": "catalog:", "pg-connection-string": "catalog:", "p-retry": "catalog:", @@ -49,6 +48,7 @@ "@types/dns-packet": "^5.6.5", "@types/node": "catalog:", "@types/pg": "8.16.0", + "drizzle-kit": "0.31.9", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 381e5d254c..c12d0a9728 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -487,9 +487,6 @@ importers: dns-packet: specifier: ^5.6.1 version: 5.6.1 - drizzle-kit: - specifier: 0.31.9 - version: 0.31.9 drizzle-orm: specifier: 'catalog:' version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.16.3) @@ -524,6 +521,9 @@ importers: '@types/pg': specifier: 8.16.0 version: 8.16.0 + drizzle-kit: + specifier: 0.31.9 + version: 0.31.9 typescript: specifier: 'catalog:' version: 5.9.3 From c5ef04d2186a3167949fcd848f42bd6af978db43 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 15:22:39 +0100 Subject: [PATCH 23/47] docs(changeset): Introduced database migration toolkit based on `drizzle-kit`. --- .changeset/silly-bats-smell.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/silly-bats-smell.md diff --git a/.changeset/silly-bats-smell.md b/.changeset/silly-bats-smell.md new file mode 100644 index 0000000000..d46ccd11e2 --- /dev/null +++ b/.changeset/silly-bats-smell.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Introduced database migration toolkit based on `drizzle-kit`. From 20af54104b2adf0c07cce83006580888aaeec20b Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 15:23:33 +0100 Subject: [PATCH 24/47] docs(changeset): Extended `EnsDbClient` with `EnsDbMigration` interface implementation. --- .changeset/big-impalas-brush.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/big-impalas-brush.md diff --git a/.changeset/big-impalas-brush.md b/.changeset/big-impalas-brush.md new file mode 100644 index 0000000000..851ed0c436 --- /dev/null +++ b/.changeset/big-impalas-brush.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Extended `EnsDbClient` with `EnsDbMigration` interface implementation. From b23570656ec4888e4604a91cd8a50b209e35c680 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 15:24:00 +0100 Subject: [PATCH 25/47] docs(changeset): Introduced `EnsDbMigration` interface. --- .changeset/seven-hands-ask.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/seven-hands-ask.md diff --git a/.changeset/seven-hands-ask.md b/.changeset/seven-hands-ask.md new file mode 100644 index 0000000000..858fdddbc0 --- /dev/null +++ b/.changeset/seven-hands-ask.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Introduced `EnsDbMigration` interface. From a6ea0b9520ed4005bdda53102ce31fee78cab013 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 15:28:07 +0100 Subject: [PATCH 26/47] Update integration tests config --- packages/integration-test-env/src/orchestrator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index f15e548138..9d9b99fa3c 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -339,6 +339,7 @@ async function main() { { ENSINDEXER_URL, DATABASE_URL, + DATABASE_SCHEMA: "public", }, "ensapi", ); From a9b1d39f328869250309d9d837c17b3d1abaa33b Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 15:51:33 +0100 Subject: [PATCH 27/47] Add unit tests for migration exec task --- .../ensdb-writer-worker.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index 2356fc4b72..f8d713a75e 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -44,6 +44,91 @@ describe("EnsDbWriterWorker", () => { }); describe("run() - worker initialization", () => { + it("executes database migrations on startup", async () => { + // arrange + const migrationsDirPath = "/custom/migrations/path"; + const ensDbClient = createMockEnsDbClient(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); + + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + migrationsDirPath, + ); + + // act + await worker.run(); + + // assert - verify migrations were executed with correct path + expect(ensDbClient.migrate).toHaveBeenCalledTimes(1); + expect(ensDbClient.migrate).toHaveBeenCalledWith(migrationsDirPath); + + // cleanup + worker.stop(); + }); + + it("throws when database migration fails", async () => { + // arrange + const migrationError = new Error("Migration failed: invalid SQL syntax"); + const ensDbClient = createMockEnsDbClient({ + migrate: vi.fn().mockRejectedValue(migrationError), + }); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); + + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); + + // act & assert + await expect(worker.run()).rejects.toThrow("Migration failed: invalid SQL syntax"); + expect(ensDbClient.migrate).toHaveBeenCalledTimes(1); + expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + expect(ensDbClient.upsertEnsIndexerPublicConfig).not.toHaveBeenCalled(); + }); + + it("executes migrations before any other operations", async () => { + // arrange + const operationOrder: string[] = []; + const ensDbClient = createMockEnsDbClient({ + migrate: vi.fn().mockImplementation(async () => { + operationOrder.push("migrate"); + }), + upsertEnsDbVersion: vi.fn().mockImplementation(async () => { + operationOrder.push("upsertEnsDbVersion"); + }), + upsertEnsIndexerPublicConfig: vi.fn().mockImplementation(async () => { + operationOrder.push("upsertEnsIndexerPublicConfig"); + }), + }); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); + + const worker = createMockEnsDbWriterWorker( + ensDbClient, + publicConfigBuilder, + indexingStatusBuilder, + ); + + // act + await worker.run(); + + // assert - verify migrations executed first + expect(operationOrder[0]).toBe("migrate"); + expect(operationOrder).toEqual([ + "migrate", + "upsertEnsDbVersion", + "upsertEnsIndexerPublicConfig", + ]); + + // cleanup + worker.stop(); + }); + it("upserts version, config, and starts interval for indexing status snapshots", async () => { // arrange const omnichainSnapshot = createMockOmnichainSnapshot(); From c07ac3681b7d16a465bd4ba21bec1f24adce0ac1 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 16:17:12 +0100 Subject: [PATCH 28/47] Streamline interface name as `EnsDbClientMigration` --- .changeset/big-impalas-brush.md | 2 +- .changeset/seven-hands-ask.md | 2 +- apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts | 4 ++-- packages/ensnode-sdk/src/ensdb/client.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/big-impalas-brush.md b/.changeset/big-impalas-brush.md index 851ed0c436..76b3b34fdb 100644 --- a/.changeset/big-impalas-brush.md +++ b/.changeset/big-impalas-brush.md @@ -2,4 +2,4 @@ "ensindexer": minor --- -Extended `EnsDbClient` with `EnsDbMigration` interface implementation. +Extended `EnsDbClient` with `EnsDbClientMigration` interface implementation. diff --git a/.changeset/seven-hands-ask.md b/.changeset/seven-hands-ask.md index 858fdddbc0..e7caf6318a 100644 --- a/.changeset/seven-hands-ask.md +++ b/.changeset/seven-hands-ask.md @@ -2,4 +2,4 @@ "@ensnode/ensnode-sdk": minor --- -Introduced `EnsDbMigration` interface. +Introduced `EnsDbClientMigration` interface. diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts index c60c7ce3b9..f8c0b768a1 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts +++ b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts @@ -7,9 +7,9 @@ import { type CrossChainIndexingStatusSnapshot, deserializeCrossChainIndexingStatusSnapshot, deserializeEnsIndexerPublicConfig, + type EnsDbClientMigration, type EnsDbClientMutation, type EnsDbClientQuery, - type EnsDbMigration, type EnsIndexerPublicConfig, EnsNodeMetadataKeys, type SerializedEnsNodeMetadata, @@ -40,7 +40,7 @@ interface DrizzleDb extends NodePgDatabase {} * - ENSDb version * - ENSIndexer Public Config, and Indexing Status Snapshot and CrossChainIndexingStatusSnapshot. */ -export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation, EnsDbMigration { +export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation, EnsDbClientMigration { /** * Drizzle database instance for ENSDb. */ diff --git a/packages/ensnode-sdk/src/ensdb/client.ts b/packages/ensnode-sdk/src/ensdb/client.ts index d04e033849..b0714c93c9 100644 --- a/packages/ensnode-sdk/src/ensdb/client.ts +++ b/packages/ensnode-sdk/src/ensdb/client.ts @@ -57,7 +57,7 @@ export interface EnsDbClientMutation { upsertIndexingStatusSnapshot(indexingStatus: CrossChainIndexingStatusSnapshot): Promise; } -export interface EnsDbMigration { +export interface EnsDbClientMigration { /** * Execute pending migrations for ENSDb. * From 1767c4e40812cfdeb533f976a48c3b96318882c8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 12 Mar 2026 16:20:53 +0100 Subject: [PATCH 29/47] Use ENSNode Schema for internal drizzle migration tables --- apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts index f8c0b768a1..91d551adc6 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts +++ b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts @@ -200,6 +200,7 @@ export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation, EnsDb async migrate(migrationsDirPath: string): Promise { return migrate(this.db, { migrationsFolder: migrationsDirPath, + migrationsSchema: ensNodeSchema.ENSNODE_SCHEMA_NAME, }); } } From 125271390614c066661c32253756456fa4ad73f5 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 16:56:36 +0100 Subject: [PATCH 30/47] Revert "Build ENSApi Config with ENSDb Cleint" in order to reduce scope of changes. This reverts commit a1656f64ebd8d4b03265fd6d181f04af93d62546. --- apps/ensapi/src/config/config.schema.ts | 33 +++++++------------ apps/ensapi/src/config/environment.ts | 2 +- .../ensapi/src/lib/fetch-ensindexer-config.ts | 17 ++++++++++ 3 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 apps/ensapi/src/lib/fetch-ensindexer-config.ts diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index e9fab8285f..ec402ba89d 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,5 +1,6 @@ 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"; @@ -20,7 +21,7 @@ import { import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import type { EnsApiEnvironment } from "@/config/environment"; import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations"; -import { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; +import { fetchENSIndexerConfig } from "@/lib/fetch-ensindexer-config"; import logger from "@/lib/logger"; export const DatabaseUrlSchema = z.string().refine( @@ -76,19 +77,6 @@ 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. * @@ -97,13 +85,16 @@ function buildEnsDbClientFromEnvironment(env: EnsApiEnvironment): EnsDbClient { */ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promise { try { - const ensDbClient = buildEnsDbClientFromEnvironment(env); - - const ensIndexerPublicConfig = await ensDbClient.getEnsIndexerPublicConfig(); - - if (!ensIndexerPublicConfig) { - throw new Error("Failed to load EnsIndexerPublicConfig from ENSDb."); - } + 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 rpcConfigs = buildRpcConfigsFromEnv(env, ensIndexerPublicConfig.namespace); diff --git a/apps/ensapi/src/config/environment.ts b/apps/ensapi/src/config/environment.ts index 550a1c4b32..119490fdf0 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 = DatabaseEnvironment & +export type EnsApiEnvironment = Omit & EnsIndexerUrlEnvironment & RpcEnvironment & PortEnvironment & diff --git a/apps/ensapi/src/lib/fetch-ensindexer-config.ts b/apps/ensapi/src/lib/fetch-ensindexer-config.ts new file mode 100644 index 0000000000..0cd11beb8b --- /dev/null +++ b/apps/ensapi/src/lib/fetch-ensindexer-config.ts @@ -0,0 +1,17 @@ +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); +} From 069f133ab9dc30fa7cbf551e8a4e5f63fdf39f06 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 16:59:55 +0100 Subject: [PATCH 31/47] Move ENSDb module contents from ENSNode SDK into ENSNode Schema package (which will be soon renamed to ENSDb SDK --- .../src/ensnode-db/ensnode-db-interfaces.ts} | 28 ++++++++++--------- .../src/ensnode-db}/ensnode-metadata.ts | 6 ++-- .../ensnode-db}/serialize/ensnode-metadata.ts | 7 +++-- packages/ensnode-sdk/src/ensdb/index.ts | 3 -- packages/ensnode-sdk/src/index.ts | 1 - 5 files changed, 24 insertions(+), 21 deletions(-) rename packages/{ensnode-sdk/src/ensdb/client.ts => ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts} (66%) rename packages/{ensnode-sdk/src/ensdb => ensnode-schema/src/ensnode-db}/ensnode-metadata.ts (85%) rename packages/{ensnode-sdk/src/ensdb => ensnode-schema/src/ensnode-db}/serialize/ensnode-metadata.ts (84%) delete mode 100644 packages/ensnode-sdk/src/ensdb/index.ts diff --git a/packages/ensnode-sdk/src/ensdb/client.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts similarity index 66% rename from packages/ensnode-sdk/src/ensdb/client.ts rename to packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts index b0714c93c9..dfa736cdd8 100644 --- a/packages/ensnode-sdk/src/ensdb/client.ts +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts @@ -1,12 +1,12 @@ -import type { EnsIndexerPublicConfig } from "../ensindexer/config"; -import type { CrossChainIndexingStatusSnapshot } from "../indexing-status/cross-chain-indexing-status-snapshot"; +import type { + CrossChainIndexingStatusSnapshot, + EnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; /** - * ENSDb Client Query - * - * Includes methods for reading from ENSDb. + * Client interface with read-only query methods for ENSNode Schema in ENSDb. */ -export interface EnsDbClientQuery { +export interface EnsNodeDbClientQuery { /** * Get ENSDb Version * @@ -30,11 +30,9 @@ export interface EnsDbClientQuery { } /** - * ENSDb Client Mutation - * - * Includes methods for writing into ENSDb. + * Client interface with mutation methods for ENSNode Schema in ENSDb. */ -export interface EnsDbClientMutation { +export interface EnsNodeDbClientMutation { /** * Upsert ENSDb Version * @@ -57,11 +55,15 @@ export interface EnsDbClientMutation { upsertIndexingStatusSnapshot(indexingStatus: CrossChainIndexingStatusSnapshot): Promise; } -export interface EnsDbClientMigration { +/** + * Client interface with migration methods for ENSNode Schema in ENSDb. + */ +export interface EnsNodeDbClientMigration { /** - * Execute pending migrations for ENSDb. + * Execute pending database migrations for ENSNode Schema in ENSDb. * - * @param migrationsDirPath - The file path to the directory containing migration files. + * @param migrationsDirPath - The file path to the directory containing + * database migration files for ENSNode Schema. * @throws error when migration execution fails. */ migrate(migrationsDirPath: string): Promise; diff --git a/packages/ensnode-sdk/src/ensdb/ensnode-metadata.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-metadata.ts similarity index 85% rename from packages/ensnode-sdk/src/ensdb/ensnode-metadata.ts rename to packages/ensnode-schema/src/ensnode-db/ensnode-metadata.ts index 15c9bfefc3..bdb35c4069 100644 --- a/packages/ensnode-sdk/src/ensdb/ensnode-metadata.ts +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-metadata.ts @@ -1,5 +1,7 @@ -import type { EnsIndexerPublicConfig } from "../ensindexer/config"; -import type { CrossChainIndexingStatusSnapshot } from "../indexing-status/cross-chain-indexing-status-snapshot"; +import type { + CrossChainIndexingStatusSnapshot, + EnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; /** * Keys used to distinguish records in `ensnode_metadata` table in the ENSDb. diff --git a/packages/ensnode-sdk/src/ensdb/serialize/ensnode-metadata.ts b/packages/ensnode-schema/src/ensnode-db/serialize/ensnode-metadata.ts similarity index 84% rename from packages/ensnode-sdk/src/ensdb/serialize/ensnode-metadata.ts rename to packages/ensnode-schema/src/ensnode-db/serialize/ensnode-metadata.ts index d74b71abcb..cae7fcdd34 100644 --- a/packages/ensnode-sdk/src/ensdb/serialize/ensnode-metadata.ts +++ b/packages/ensnode-schema/src/ensnode-db/serialize/ensnode-metadata.ts @@ -1,5 +1,8 @@ -import type { SerializedEnsIndexerPublicConfig } from "../../ensindexer/config"; -import type { SerializedCrossChainIndexingStatusSnapshot } from "../../indexing-status/serialize/cross-chain-indexing-status-snapshot"; +import type { + SerializedCrossChainIndexingStatusSnapshot, + SerializedEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + import type { EnsNodeMetadata, EnsNodeMetadataEnsDbVersion, diff --git a/packages/ensnode-sdk/src/ensdb/index.ts b/packages/ensnode-sdk/src/ensdb/index.ts deleted file mode 100644 index bec975949b..0000000000 --- a/packages/ensnode-sdk/src/ensdb/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./client"; -export * from "./ensnode-metadata"; -export * from "./serialize/ensnode-metadata"; diff --git a/packages/ensnode-sdk/src/index.ts b/packages/ensnode-sdk/src/index.ts index 83faed6663..64551c5894 100644 --- a/packages/ensnode-sdk/src/index.ts +++ b/packages/ensnode-sdk/src/index.ts @@ -1,6 +1,5 @@ export * from "./ens"; export * from "./ensapi"; -export * from "./ensdb"; export * from "./ensindexer"; export * from "./ensrainbow"; export * from "./ensv2"; From 867d64382d3977d79ff19d18caf5c433f2eb3dce Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 17:01:01 +0100 Subject: [PATCH 32/47] Create Drizzle utils for future ENSDb SDK --- packages/ensnode-schema/package.json | 2 + packages/ensnode-schema/src/lib/drizzle.ts | 105 +++++++++++++++++++++ pnpm-lock.yaml | 3 + 3 files changed, 110 insertions(+) create mode 100644 packages/ensnode-schema/src/lib/drizzle.ts diff --git a/packages/ensnode-schema/package.json b/packages/ensnode-schema/package.json index 7e47266968..5e4d7db212 100644 --- a/packages/ensnode-schema/package.json +++ b/packages/ensnode-schema/package.json @@ -60,6 +60,7 @@ }, "peerDependencies": { "drizzle-orm": "catalog:", + "pg-connection-string": "catalog:", "ponder": "catalog:", "viem": "catalog:" }, @@ -67,6 +68,7 @@ "@ensnode/ensnode-sdk": "workspace:", "@ensnode/shared-configs": "workspace:*", "drizzle-orm": "catalog:", + "pg-connection-string": "catalog:", "ponder": "catalog:", "tsup": "catalog:", "typescript": "catalog:", diff --git a/packages/ensnode-schema/src/lib/drizzle.ts b/packages/ensnode-schema/src/lib/drizzle.ts new file mode 100644 index 0000000000..7de2cdd4bd --- /dev/null +++ b/packages/ensnode-schema/src/lib/drizzle.ts @@ -0,0 +1,105 @@ +/** + * Drizzle database utilities for working with ENSDb. + */ +import type { Logger as DrizzleLogger } from "drizzle-orm/logger"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { parseIntoClientConfig } from "pg-connection-string"; + +/** + * Helper type to extract the connection configuration for Drizzle from + * the provided connection string. + */ +type DrizzleConnectionReadonlyConfig = ReturnType; + +/** + * Build a Drizzle connection configuration with read-only settings, + * based on the provided connection string. + * + * Updated drizzle connection ensures that any database interactions through + * this connection are read-only, which is important for + * the EnsNodeDbReader to prevent accidental mutations to the database. + * + * @param dbConnectionString - The connection string for the Postgres database. + */ +function buildDrizzleConnectionReadonly( + dbConnectionString: string, +): DrizzleConnectionReadonlyConfig { + const drizzleConnection = parseIntoClientConfig(dbConnectionString); + const existingConnectionOptions = drizzleConnection.options || ""; + const readonlyConnectionOption = "-c default_transaction_read_only=on"; + + const drizzleConnectionReadonly = { + ...drizzleConnection, + // Combine existing options from connection string with read-only requirement + options: existingConnectionOptions + ? `${existingConnectionOptions} ${readonlyConnectionOption}` + : readonlyConnectionOption, + }; + + return drizzleConnectionReadonly; +} + +/** + * Base definition for Drizzle database schema + * + * Represents the structure of the database schema required by Drizzle ORM. + */ +type DrizzleDbSchemaDefinition = Record; + +/** + * Drizzle database for `DbSchemaDefinition` in ENSDb. + * + * Allows interacting with `DbSchemaDefinition` in ENSDb, using Drizzle ORM. + */ +export type EnsDbDrizzle = + NodePgDatabase; + +/** + * Readonly Drizzle database for `DbSchemaDefinition` in ENSDb. + * + * Allows readonly interactions with `DbSchemaDefinition` in ENSDb, using Drizzle ORM. + */ +export type EnsDbDrizzleReadonly = Omit< + EnsDbDrizzle, + "insert" | "update" | "delete" | "transaction" +>; + +/** + * Build a Drizzle database instance + * @param connectionString - The connection string for the ENSDb. + * @param dbSchemaDef - The database schema definition for the ENSDb. + * @param logger - Optional Drizzle logger for query logging. + * @returns A Drizzle database instance for the provided database schema definition. + */ +export function buildDrizzleDb( + connectionString: string, + dbSchemaDef: DbSchemaDefinition, + logger?: DrizzleLogger, +): EnsDbDrizzle { + return drizzle({ + connection: connectionString, + schema: dbSchemaDef, + casing: "snake_case", + logger, + }); +} + +/** + * Build a read-only Drizzle database instance + * @param connectionString - The connection string for the ENSDb. + * @param dbSchemaDef - The database schema definition for the ENSDb. + * @param logger - Optional Drizzle logger for query logging. + * @returns A read-only Drizzle database instance for the provided database schema definition. + */ +export function buildDrizzleDbReadonly( + connectionString: string, + dbSchemaDef: DbSchemaDefinition, + logger?: DrizzleLogger, +): EnsDbDrizzleReadonly { + return drizzle({ + connection: buildDrizzleConnectionReadonly(connectionString), + schema: dbSchemaDef, + casing: "snake_case", + logger, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c12d0a9728..796ba4e53c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -875,6 +875,9 @@ importers: drizzle-orm: specifier: 'catalog:' version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.16.3) + pg-connection-string: + specifier: 'catalog:' + version: 2.9.1 ponder: specifier: 'catalog:' version: 0.16.3(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(@types/pg@8.16.0)(hono@4.12.7)(lightningcss@1.30.2)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) From 0d2ee64ffbb49f3acee687aec276153ce996dcb9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 17:07:18 +0100 Subject: [PATCH 33/47] Create `EnsNodeDbReader` class to be shared among ENSNode apps. --- .../src/ensnode-db/ensnode-db-interfaces.ts | 26 ---- .../src/ensnode-db/ensnode-db-queries.ts | 30 +++++ .../src/ensnode-db/ensnode-db-reader.ts | 123 ++++++++++++++++++ 3 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 packages/ensnode-schema/src/ensnode-db/ensnode-db-queries.ts create mode 100644 packages/ensnode-schema/src/ensnode-db/ensnode-db-reader.ts diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts index dfa736cdd8..7f2d3f3eb0 100644 --- a/packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts @@ -3,32 +3,6 @@ import type { EnsIndexerPublicConfig, } from "@ensnode/ensnode-sdk"; -/** - * Client interface with read-only query methods for ENSNode Schema in ENSDb. - */ -export interface EnsNodeDbClientQuery { - /** - * Get ENSDb Version - * - * @returns the existing record, or `undefined`. - */ - getEnsDbVersion(): Promise; - - /** - * Get ENSIndexer Public Config - * - * @returns the existing record, or `undefined`. - */ - getEnsIndexerPublicConfig(): Promise; - - /** - * Get Indexing Status Snapshot - * - * @returns the existing record, or `undefined`. - */ - getIndexingStatusSnapshot(): Promise; -} - /** * Client interface with mutation methods for ENSNode Schema in ENSDb. */ diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-queries.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-queries.ts new file mode 100644 index 0000000000..30fca2bf1d --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-queries.ts @@ -0,0 +1,30 @@ +import type { + CrossChainIndexingStatusSnapshot, + EnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +/** + * Client interface with read-only queries for ENSNode Schema in ENSDb. + */ +export interface EnsNodeDbQueries { + /** + * Get ENSDb Version + * + * @returns the existing record, or `undefined`. + */ + getEnsDbVersion(): Promise; + + /** + * Get ENSIndexer Public Config + * + * @returns the existing record, or `undefined`. + */ + getEnsIndexerPublicConfig(): Promise; + + /** + * Get Indexing Status Snapshot + * + * @returns the existing record, or `undefined`. + */ + getIndexingStatusSnapshot(): Promise; +} diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-reader.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-reader.ts new file mode 100644 index 0000000000..6c6c3159d4 --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-reader.ts @@ -0,0 +1,123 @@ +import { and, eq } from "drizzle-orm"; + +import { + type CrossChainIndexingStatusSnapshot, + deserializeCrossChainIndexingStatusSnapshot, + deserializeEnsIndexerPublicConfig, + type EnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import * as ensNodeSchema from "../ensnode-schema"; +import { buildDrizzleDbReadonly, type EnsDbDrizzleReadonly } from "../lib/drizzle"; +import type { EnsNodeDbQueries } from "./ensnode-db-queries"; +import { EnsNodeMetadataKeys } from "./ensnode-metadata"; +import type { + SerializedEnsNodeMetadata, + SerializedEnsNodeMetadataEnsDbVersion, + SerializedEnsNodeMetadataEnsIndexerIndexingStatus, + SerializedEnsNodeMetadataEnsIndexerPublicConfig, +} from "./serialize/ensnode-metadata"; + +/** + * ENSNode Database Reader + * + * Provides readonly access to ENSNode Schema in ENSDb. + */ +export class EnsNodeDbReader implements EnsNodeDbQueries { + /** + * Readonly Drizzle database instance for ENSNode Schema in ENSDb. + */ + private ensNodeDbReadonly: EnsDbDrizzleReadonly; + + /** + * ENSIndexer Schema Name + * + * Used for composite primary key in 'ensNodeMetadata' table to support + * multi-tenancy where records with the same `key` can coexist for different + * ENSIndexer instances without conflict. + */ + protected ensIndexerSchemaName: string; + + /** + * @param ensDbConnectionString connection string for ENSDb Postgres database + * @param ensIndexerSchemaName reference string for ENSIndexer instance (used for multi-tenancy in ENSDb) + */ + constructor(ensDbConnectionString: string, ensIndexerSchemaName: string) { + this.ensNodeDbReadonly = buildDrizzleDbReadonly(ensDbConnectionString, ensNodeSchema); + this.ensIndexerSchemaName = ensIndexerSchemaName; + } + + /** + * @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.ensNodeDbReadonly + .select() + .from(ensNodeSchema.ensNodeMetadata) + .where( + and( + eq(ensNodeSchema.ensNodeMetadata.ensIndexerSchemaName, this.ensIndexerSchemaName), + 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`); + } +} From 6b8c42f3d5ba9e2f9ef4ea31fd1045917d6a3b84 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 17:13:56 +0100 Subject: [PATCH 34/47] Create `EnsNodeDbWriter` to be shared with ENSIndexer app (which is currently the sole writer to ENSNode Schema in ENSDb. --- .../src/ensnode-db/ensnode-db-migrations.ts | 14 +++ ...-interfaces.ts => ensnode-db-mutations.ts} | 18 +-- .../src/ensnode-db/ensnode-db-writer.ts | 111 ++++++++++++++++++ .../ensnode-schema/src/ensnode-db/index.ts | 4 + 4 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 packages/ensnode-schema/src/ensnode-db/ensnode-db-migrations.ts rename packages/ensnode-schema/src/ensnode-db/{ensnode-db-interfaces.ts => ensnode-db-mutations.ts} (52%) create mode 100644 packages/ensnode-schema/src/ensnode-db/ensnode-db-writer.ts create mode 100644 packages/ensnode-schema/src/ensnode-db/index.ts diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-migrations.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-migrations.ts new file mode 100644 index 0000000000..b43a14a128 --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-migrations.ts @@ -0,0 +1,14 @@ +/** + * Client interface for executing pending database migrations on + * ENSNode Schema in ENSDb. + */ +export interface EnsNodeDbMigrations { + /** + * Execute pending database migrations for ENSNode Schema in ENSDb. + * + * @param migrationsDirPath - The file path to the directory containing + * database migration files for ENSNode Schema. + * @throws error when migration execution fails. + */ + migrate(migrationsDirPath: string): Promise; +} diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-mutations.ts similarity index 52% rename from packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts rename to packages/ensnode-schema/src/ensnode-db/ensnode-db-mutations.ts index 7f2d3f3eb0..f59b7bfc81 100644 --- a/packages/ensnode-schema/src/ensnode-db/ensnode-db-interfaces.ts +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-mutations.ts @@ -4,9 +4,9 @@ import type { } from "@ensnode/ensnode-sdk"; /** - * Client interface with mutation methods for ENSNode Schema in ENSDb. + * Client interface with mutations for ENSNode Schema in ENSDb. */ -export interface EnsNodeDbClientMutation { +export interface EnsNodeDbMutations { /** * Upsert ENSDb Version * @@ -28,17 +28,3 @@ export interface EnsNodeDbClientMutation { */ upsertIndexingStatusSnapshot(indexingStatus: CrossChainIndexingStatusSnapshot): Promise; } - -/** - * Client interface with migration methods for ENSNode Schema in ENSDb. - */ -export interface EnsNodeDbClientMigration { - /** - * Execute pending database migrations for ENSNode Schema in ENSDb. - * - * @param migrationsDirPath - The file path to the directory containing - * database migration files for ENSNode Schema. - * @throws error when migration execution fails. - */ - migrate(migrationsDirPath: string): Promise; -} diff --git a/packages/ensnode-schema/src/ensnode-db/ensnode-db-writer.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-writer.ts new file mode 100644 index 0000000000..f687935d1a --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-writer.ts @@ -0,0 +1,111 @@ +import { migrate } from "drizzle-orm/node-postgres/migrator"; + +import { + type CrossChainIndexingStatusSnapshot, + type EnsIndexerPublicConfig, + serializeCrossChainIndexingStatusSnapshot, + serializeEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import * as ensNodeSchema from "../ensnode-schema"; +import { buildDrizzleDb, type EnsDbDrizzle } from "../lib/drizzle"; +import type { EnsNodeDbMigrations } from "./ensnode-db-migrations"; +import type { EnsNodeDbMutations } from "./ensnode-db-mutations"; +import { EnsNodeDbReader } from "./ensnode-db-reader"; +import { EnsNodeMetadataKeys } from "./ensnode-metadata"; +import type { SerializedEnsNodeMetadata } from "./serialize/ensnode-metadata"; + +/** + * ENSNode Database Writer + * + * Provides read and write access to ENSNode Schema in ENSDb. + */ +export class EnsNodeDbWriter + extends EnsNodeDbReader + implements EnsNodeDbMutations, EnsNodeDbMigrations +{ + /** + * Drizzle database instance for ENSNode Schema in ENSDb. + * + * Used for read and write operations. + */ + private ensNodeDb: EnsDbDrizzle; + + /** + * @param ensDbConnectionString connection string for ENSDb Postgres database + * @param ensIndexerSchemaName reference string for ENSIndexer instance (used for multi-tenancy in ENSDb) + */ + constructor(ensDbConnectionString: string, ensIndexerSchemaName: string) { + super(ensDbConnectionString, ensIndexerSchemaName); + + this.ensNodeDb = buildDrizzleDb(ensDbConnectionString, ensNodeSchema); + } + + /** + * @inheritdoc + */ + async migrate(migrationsDirPath: string): Promise { + return migrate(this.ensNodeDb, { + migrationsFolder: migrationsDirPath, + migrationsSchema: ensNodeSchema.ENSNODE_SCHEMA_NAME, + }); + } + + /** + * @inheritdoc + */ + async upsertEnsDbVersion(ensDbVersion: string): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsDbVersion, + value: ensDbVersion, + }); + } + + /** + * @inheritdoc + */ + async upsertEnsIndexerPublicConfig( + ensIndexerPublicConfig: EnsIndexerPublicConfig, + ): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, + value: serializeEnsIndexerPublicConfig(ensIndexerPublicConfig), + }); + } + + /** + * @inheritdoc + */ + async upsertIndexingStatusSnapshot( + indexingStatus: CrossChainIndexingStatusSnapshot, + ): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, + value: serializeCrossChainIndexingStatusSnapshot(indexingStatus), + }); + } + + /** + * Upsert ENSNode metadata + * + * @throws when upsert operation failed. + */ + private async upsertEnsNodeMetadata< + EnsNodeMetadataType extends SerializedEnsNodeMetadata = SerializedEnsNodeMetadata, + >(metadata: EnsNodeMetadataType): Promise { + await this.ensNodeDb + .insert(ensNodeSchema.ensNodeMetadata) + .values({ + ensIndexerSchemaName: this.ensIndexerSchemaName, + key: metadata.key, + value: metadata.value, + }) + .onConflictDoUpdate({ + target: [ + ensNodeSchema.ensNodeMetadata.ensIndexerSchemaName, + ensNodeSchema.ensNodeMetadata.key, + ], + set: { value: metadata.value }, + }); + } +} diff --git a/packages/ensnode-schema/src/ensnode-db/index.ts b/packages/ensnode-schema/src/ensnode-db/index.ts new file mode 100644 index 0000000000..424878f33a --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/index.ts @@ -0,0 +1,4 @@ +export * from "./ensnode-db-mutations"; +export * from "./ensnode-db-reader"; +export * from "./ensnode-db-writer"; +export * from "./ensnode-metadata"; From b803f5028c2f39351f8eb6f5d4ddbe8414af49c8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 17:14:46 +0100 Subject: [PATCH 35/47] Improve naming and code docs for ENSIndexer Schema defintions --- .../ensnode-schema/src/ensnode-schema/index.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/ensnode-schema/src/ensnode-schema/index.ts b/packages/ensnode-schema/src/ensnode-schema/index.ts index 2f92b82faa..df62dae3f4 100644 --- a/packages/ensnode-schema/src/ensnode-schema/index.ts +++ b/packages/ensnode-schema/src/ensnode-schema/index.ts @@ -22,14 +22,13 @@ export const ensNodeMetadata = ENSNODE_SCHEMA.table( "ensnode_metadata", (t) => ({ /** - * ENSIndexer Reference + * ENSIndexer Schema Name * - * References the ENSIndexer instance by a unique ENSIndexer schema name - * that a metadata record is associated with. This allows us to support - * multiple ENSIndexer instances using the same database, while ensuring - * that their metadata records do not conflict with each other. + * References the name of the ENSIndexer Schema that the metadata record + * belongs to. This allows multi-tenancy where multiple ENSIndexer + * instances can write to the same ENSNode Metadata table. */ - ensIndexerRef: t.text().notNull(), + ensIndexerSchemaName: t.text().notNull(), /** * Key @@ -55,12 +54,12 @@ export const ensNodeMetadata = ENSNODE_SCHEMA.table( }), (table) => [ /** - * Primary key constraint on 'ensIndexerRef' and 'key' columns, + * Primary key constraint on 'ensIndexerSchemaName' and 'key' columns, * to ensure that there is only one record for each key per ENSIndexer instance. */ primaryKey({ name: "ensnode_metadata_pkey", - columns: [table.ensIndexerRef, table.key], + columns: [table.ensIndexerSchemaName, table.key], }), ], ); From d08743ce0872d114b8a2e75e21dba2005da21a13 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 17:16:30 +0100 Subject: [PATCH 36/47] Use `EnsNodeDbWriter` in ENSIndexer app Replaces previous implementation of `EnsDbClient` made specifically for ENSIndexer --- .../src/lib/ensdb-client/drizzle.ts | 20 -- .../src/lib/ensdb-client/ensdb-client.mock.ts | 76 ------- .../src/lib/ensdb-client/ensdb-client.test.ts | 183 ---------------- .../src/lib/ensdb-client/ensdb-client.ts | 206 ------------------ .../src/lib/ensdb-client/singleton.ts | 12 +- packages/ensnode-schema/src/index.ts | 4 +- 6 files changed, 8 insertions(+), 493 deletions(-) delete mode 100644 apps/ensindexer/src/lib/ensdb-client/drizzle.ts delete mode 100644 apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts delete mode 100644 apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts delete mode 100644 apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts diff --git a/apps/ensindexer/src/lib/ensdb-client/drizzle.ts b/apps/ensindexer/src/lib/ensdb-client/drizzle.ts deleted file mode 100644 index 079e18c581..0000000000 --- a/apps/ensindexer/src/lib/ensdb-client/drizzle.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file is based on `packages/ponder-subgraph/src/drizzle.ts` file. -// We currently duplicate the makeDrizzle function, as we don't have -// a shared package for backend code yet. When we do, we can move -// this function to the shared package and import it in both places. -import { drizzle } from "drizzle-orm/node-postgres"; - -type Schema = { [name: string]: unknown }; - -/** - * Makes a Drizzle DB object. - */ -export const makeDrizzle = ({ - schema, - databaseUrl, -}: { - schema: SCHEMA; - databaseUrl: string; -}) => { - return drizzle(databaseUrl, { schema, casing: "snake_case" }); -}; diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts deleted file mode 100644 index 91f7cf273b..0000000000 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - type BlockRef, - ChainIndexingStatusIds, - CrossChainIndexingStrategyIds, - type EnsIndexerPublicConfig, - OmnichainIndexingStatusIds, - PluginName, - RangeTypeIds, - type SerializedCrossChainIndexingStatusSnapshot, -} from "@ensnode/ensnode-sdk"; - -export const earlierBlockRef = { - timestamp: 1672531199, - number: 1024, -} as const satisfies BlockRef; - -export const laterBlockRef = { - timestamp: 1672531200, - number: 1025, -} as const satisfies BlockRef; - -export const databaseUrl = "postgres://user:pass@localhost:5432/ensdb"; - -export const databaseSchemaName = "public"; - -// This is the same as the default value of config.databaseSchemaName, -// which is used as the ensIndexerRef for multi-tenancy in ENSDb. -export const ensIndexerRef = databaseSchemaName; - -export const publicConfig = { - databaseSchemaName, - ensRainbowPublicConfig: { - version: "0.32.0", - labelSet: { - labelSetId: "subgraph", - highestLabelSetVersion: 0, - }, - recordsCount: 100, - }, - labelSet: { - labelSetId: "subgraph", - labelSetVersion: 0, - }, - indexedChainIds: new Set([1]), - isSubgraphCompatible: true, - namespace: "mainnet", - plugins: [PluginName.Subgraph], - versionInfo: { - nodejs: "v22.10.12", - ponder: "0.11.25", - ensDb: "0.32.0", - ensIndexer: "0.32.0", - ensNormalize: "1.11.1", - }, -} satisfies EnsIndexerPublicConfig; - -export const serializedSnapshot = { - strategy: CrossChainIndexingStrategyIds.Omnichain, - slowestChainIndexingCursor: earlierBlockRef.timestamp, - snapshotTime: earlierBlockRef.timestamp + 20, - omnichainSnapshot: { - omnichainStatus: OmnichainIndexingStatusIds.Following, - chains: { - "1": { - chainStatus: ChainIndexingStatusIds.Following, - config: { - rangeType: RangeTypeIds.LeftBounded, - startBlock: earlierBlockRef, - }, - latestIndexedBlock: earlierBlockRef, - latestKnownBlock: laterBlockRef, - }, - }, - omnichainIndexingCursor: earlierBlockRef.timestamp, - }, -} satisfies SerializedCrossChainIndexingStatusSnapshot; diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts deleted file mode 100644 index 480bc8a749..0000000000 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import * as ensNodeSchema from "@ensnode/ensnode-schema/ensnode"; -import { - deserializeCrossChainIndexingStatusSnapshot, - EnsNodeMetadataKeys, - serializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, -} from "@ensnode/ensnode-sdk"; - -import { makeDrizzle } from "./drizzle"; -import { EnsDbClient } from "./ensdb-client"; -import * as ensDbClientMock from "./ensdb-client.mock"; - -// Mock the config module to prevent it from trying to load actual environment variables during tests -vi.mock("@/config", () => ({ default: {} })); - -// Mock the makeDrizzle function to return a mock database instance -vi.mock("./drizzle", () => ({ makeDrizzle: vi.fn() })); - -describe("EnsDbClient", () => { - // Mock database query results and methods - const selectResult = { current: [] as Array<{ value: unknown }> }; - const whereMock = vi.fn(async () => selectResult.current); - const fromMock = vi.fn(() => ({ where: whereMock })); - const selectMock = vi.fn(() => ({ from: fromMock })); - const onConflictDoUpdateMock = vi.fn(async () => undefined); - const valuesMock = vi.fn(() => ({ onConflictDoUpdate: onConflictDoUpdateMock })); - const insertMock = vi.fn(() => ({ values: valuesMock })); - const executeMock = vi.fn(async () => undefined); - const txMock = { insert: insertMock, execute: executeMock }; - const transactionMock = vi.fn(async (callback: (tx: typeof txMock) => Promise) => - callback(txMock), - ); - const dbMock = { select: selectMock, insert: insertMock, transaction: transactionMock }; - - beforeEach(() => { - selectResult.current = []; - whereMock.mockClear(); - fromMock.mockClear(); - selectMock.mockClear(); - onConflictDoUpdateMock.mockClear(); - valuesMock.mockClear(); - insertMock.mockClear(); - executeMock.mockClear(); - transactionMock.mockClear(); - vi.mocked(makeDrizzle).mockReturnValue(dbMock as unknown as ReturnType); - }); - - describe("getEnsDbVersion", () => { - it("returns undefined when no record exists", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsDbVersion()).resolves.toBeUndefined(); - - expect(selectMock).toHaveBeenCalledTimes(1); - expect(fromMock).toHaveBeenCalledWith(ensNodeSchema.ensNodeMetadata); - }); - - it("returns value when one record exists", async () => { - // arrange - selectResult.current = [{ value: "0.1.0" }]; - - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsDbVersion()).resolves.toBe("0.1.0"); - }); - - // This scenario should be impossible due to the primary key constraint on - // the 'key' column of 'ensnode_metadata' table. - it("throws when multiple records exist", async () => { - // arrange - selectResult.current = [{ value: "0.1.0" }, { value: "0.1.1" }]; - - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsDbVersion()).rejects.toThrowError(/ensdb_version/i); - }); - }); - - describe("getEnsIndexerPublicConfig", () => { - it("returns undefined when no record exists", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsIndexerPublicConfig()).resolves.toBeUndefined(); - }); - - it("deserializes the stored config", async () => { - // arrange - const serializedConfig = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - selectResult.current = [{ value: serializedConfig }]; - - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act & assert - await expect(client.getEnsIndexerPublicConfig()).resolves.toStrictEqual( - ensDbClientMock.publicConfig, - ); - }); - }); - - describe("getIndexingStatusSnapshot", () => { - it("deserializes the stored indexing status snapshot", async () => { - // arrange - selectResult.current = [{ value: ensDbClientMock.serializedSnapshot }]; - - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - const expected = deserializeCrossChainIndexingStatusSnapshot( - ensDbClientMock.serializedSnapshot, - ); - - // act & assert - await expect(client.getIndexingStatusSnapshot()).resolves.toStrictEqual(expected); - }); - }); - - describe("upsertEnsDbVersion", () => { - it("writes the database version metadata", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - - // act - await client.upsertEnsDbVersion("0.2.0"); - - // assert - expect(insertMock).toHaveBeenCalledWith(ensNodeSchema.ensNodeMetadata); - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerRef: ensDbClientMock.ensIndexerRef, - key: EnsNodeMetadataKeys.EnsDbVersion, - value: "0.2.0", - }); - expect(onConflictDoUpdateMock).toHaveBeenCalledWith({ - target: [ensNodeSchema.ensNodeMetadata.ensIndexerRef, ensNodeSchema.ensNodeMetadata.key], - set: { value: "0.2.0" }, - }); - }); - }); - - describe("upsertEnsIndexerPublicConfig", () => { - it("serializes and writes the public config", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - const expectedValue = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - // act - await client.upsertEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - // assert - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerRef: ensDbClientMock.ensIndexerRef, - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - value: expectedValue, - }); - }); - }); - - describe("upsertIndexingStatusSnapshot", () => { - it("serializes and writes the indexing status snapshot", async () => { - // arrange - const client = new EnsDbClient(ensDbClientMock.databaseUrl, ensDbClientMock.ensIndexerRef); - const snapshot = deserializeCrossChainIndexingStatusSnapshot( - ensDbClientMock.serializedSnapshot, - ); - const expectedValue = serializeCrossChainIndexingStatusSnapshot(snapshot); - - // act - await client.upsertIndexingStatusSnapshot(snapshot); - - // assert - expect(valuesMock).toHaveBeenCalledWith({ - ensIndexerRef: ensDbClientMock.ensIndexerRef, - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, - value: expectedValue, - }); - }); - }); -}); diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts deleted file mode 100644 index 91d551adc6..0000000000 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { migrate } from "drizzle-orm/node-postgres/migrator"; -import { and, eq } from "drizzle-orm/sql"; - -import * as ensNodeSchema from "@ensnode/ensnode-schema/ensnode"; -import { - type CrossChainIndexingStatusSnapshot, - deserializeCrossChainIndexingStatusSnapshot, - deserializeEnsIndexerPublicConfig, - type EnsDbClientMigration, - type EnsDbClientMutation, - type EnsDbClientQuery, - type EnsIndexerPublicConfig, - EnsNodeMetadataKeys, - type SerializedEnsNodeMetadata, - type SerializedEnsNodeMetadataEnsDbVersion, - type SerializedEnsNodeMetadataEnsIndexerIndexingStatus, - type SerializedEnsNodeMetadataEnsIndexerPublicConfig, - serializeCrossChainIndexingStatusSnapshot, - serializeEnsIndexerPublicConfig, -} from "@ensnode/ensnode-sdk"; - -import { makeDrizzle } from "./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 and mutating ENSDb data, such as: - * - ENSDb version - * - ENSIndexer Public Config, and Indexing Status Snapshot and CrossChainIndexingStatusSnapshot. - */ -export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation, EnsDbClientMigration { - /** - * Drizzle database instance for ENSDb. - */ - 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 = makeDrizzle({ - 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); - } - - /** - * @inheritdoc - */ - async upsertEnsDbVersion(ensDbVersion: string): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsDbVersion, - value: ensDbVersion, - }); - } - - /** - * @inheritdoc - */ - async upsertEnsIndexerPublicConfig( - ensIndexerPublicConfig: EnsIndexerPublicConfig, - ): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - value: serializeEnsIndexerPublicConfig(ensIndexerPublicConfig), - }); - } - - /** - * @inheritdoc - */ - async upsertIndexingStatusSnapshot( - indexingStatus: CrossChainIndexingStatusSnapshot, - ): Promise { - await this.upsertEnsNodeMetadata({ - key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, - value: serializeCrossChainIndexingStatusSnapshot(indexingStatus), - }); - } - - /** - * Get ENSNode metadata record - * - * @returns selected record in ENSDb. - * @throws when more than one matching metadata record is found - * (should be impossible given the composite PK constraint on - * 'ensIndexerRef' and '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`); - } - - /** - * Upsert ENSNode metadata - * - * @throws when upsert operation failed. - */ - private async upsertEnsNodeMetadata< - EnsNodeMetadataType extends SerializedEnsNodeMetadata = SerializedEnsNodeMetadata, - >(metadata: EnsNodeMetadataType): Promise { - await this.db - .insert(ensNodeSchema.ensNodeMetadata) - .values({ - ensIndexerRef: this.ensIndexerRef, - key: metadata.key, - value: metadata.value, - }) - .onConflictDoUpdate({ - target: [ensNodeSchema.ensNodeMetadata.ensIndexerRef, ensNodeSchema.ensNodeMetadata.key], - set: { value: metadata.value }, - }); - } - - /** - * @inheritdoc - */ - async migrate(migrationsDirPath: string): Promise { - return migrate(this.db, { - migrationsFolder: migrationsDirPath, - migrationsSchema: ensNodeSchema.ENSNODE_SCHEMA_NAME, - }); - } -} diff --git a/apps/ensindexer/src/lib/ensdb-client/singleton.ts b/apps/ensindexer/src/lib/ensdb-client/singleton.ts index a47ef727c6..a41975775d 100644 --- a/apps/ensindexer/src/lib/ensdb-client/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-client/singleton.ts @@ -1,12 +1,12 @@ import config from "@/config"; -import { EnsDbClient } from "./ensdb-client"; +import { EnsNodeDbWriter } from "@ensnode/ensnode-schema"; -// config.databaseSchemaName is unique per ENSIndexer instance and is used as the ensIndexerRef -// tenant key in the shared ENSNode schema (ensnode.*). -const ensIndexerRef = config.databaseSchemaName; +// TODO: pending rename `config.databaseSchemaName` to `config.ensIndexerSchemaName` +// Will be executed once https://github.com/namehash/ensnode/issues/1762 is resolved. +const ensIndexerSchemaName = config.databaseSchemaName; /** - * Singleton instance of {@link EnsDbClient} for use in ENSIndexer. + * Singleton instance of {@link EnsNodeDbWriter} for use in ENSIndexer. */ -export const ensDbClient = new EnsDbClient(config.databaseUrl, ensIndexerRef); +export const ensNodeDbWriter = new EnsNodeDbWriter(config.databaseUrl, ensIndexerSchemaName); diff --git a/packages/ensnode-schema/src/index.ts b/packages/ensnode-schema/src/index.ts index 49b2d5e311..ca1eb7ca44 100644 --- a/packages/ensnode-schema/src/index.ts +++ b/packages/ensnode-schema/src/index.ts @@ -1,2 +1,2 @@ -// Re-export relevant schema definitions for backward compatibility. -export * from "./ensindexer-schema"; +export * from "./ensnode-db"; +export * from "./lib/drizzle"; From 2d760eeabe0bec8b3b7d0fa328d21141a20df8e9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 17:51:58 +0100 Subject: [PATCH 37/47] Create `EnsIndexerDbReader` class to be used among ENSNode apps for use cases including complex database querires. --- .../src/ensindexer-db/ensindexer-db-reader.ts | 66 +++++++++++++++++++ .../ensnode-schema/src/ensindexer-db/index.ts | 1 + packages/ensnode-schema/src/index.ts | 1 + 3 files changed, 68 insertions(+) create mode 100644 packages/ensnode-schema/src/ensindexer-db/ensindexer-db-reader.ts create mode 100644 packages/ensnode-schema/src/ensindexer-db/index.ts diff --git a/packages/ensnode-schema/src/ensindexer-db/ensindexer-db-reader.ts b/packages/ensnode-schema/src/ensindexer-db/ensindexer-db-reader.ts new file mode 100644 index 0000000000..757e7275ae --- /dev/null +++ b/packages/ensnode-schema/src/ensindexer-db/ensindexer-db-reader.ts @@ -0,0 +1,66 @@ +import { isTable, Table } from "drizzle-orm"; +import { isPgEnum } from "drizzle-orm/pg-core"; + +import * as ensIndexerSchema from "../ensindexer-schema"; +import { buildDrizzleDbReadonly, type EnsDbDrizzleReadonly } from "../lib/drizzle"; + +/** + * ENSIndexer Database Reader + * + * Provides readonly access to ENSIndexer Schema in ENSDb. + */ +export class EnsIndexerDbReader { + /** + * Readonly Drizzle database instance for ENSIndexer Schema in ENSDb. + */ + private ensIndexerDbReadonly: EnsDbDrizzleReadonly; + + /** + * @param ensDbConnectionString connection string for ENSDb Postgres database + */ + constructor(ensDbConnectionString: string, ensIndexerSchemaName: string) { + const boundSchemaDef = EnsIndexerDbReader.bindDbSchemaDefWithDbSchemaName(ensIndexerSchemaName); + this.ensIndexerDbReadonly = buildDrizzleDbReadonly(ensDbConnectionString, boundSchemaDef); + } + + /** + * Bind a database schema definition with a specific database schema name. + * This is necessary to ensure that all tables and enums in the schema + * definition are associated with the correct database schema in ENSDb. + * + * @param dbSchemaDef - The database schema definition to bind. + * @param dbSchemaName - The schema name to bind to the database schema definition. + * @returns The database schema definition with the bound schema name. + * + * Note: this function is a replacement for `setDatabaseSchema` from `@ponder/client`. + */ + static bindDbSchemaDefWithDbSchemaName(dbSchemaName: string): typeof ensIndexerSchema { + const resultDbSchemaDef = structuredClone(ensIndexerSchema); + + for (const dbObjectDef of Object.values(resultDbSchemaDef)) { + if (isTable(dbObjectDef)) { + // @ts-expect-error - Drizzle's Table type for the schema symbol is + // not typed in a way that allows us to set it directly, + // but we know it exists and can be set. + dbObjectDef[Table.Symbol.Schema] = dbSchemaName; + } else if (isPgEnum(dbObjectDef)) { + // @ts-expect-error - Drizzle's PgEnum type for the schema symbol is + // typed as readonly, but we need to set it here so + // the output schema definition has the correct schema for + // all table and enum objects. + dbObjectDef.schema = dbSchemaName; + } + } + + return resultDbSchemaDef; + } + + /** + * Getter for the readonly Drizzle database instance for ENSIndexer Schema in ENSDb. + * + * Useful while working on complex queries directly with the ENSIndexer schema in ENSDb. + */ + get db(): EnsDbDrizzleReadonly { + return this.ensIndexerDbReadonly; + } +} diff --git a/packages/ensnode-schema/src/ensindexer-db/index.ts b/packages/ensnode-schema/src/ensindexer-db/index.ts new file mode 100644 index 0000000000..1af8aca211 --- /dev/null +++ b/packages/ensnode-schema/src/ensindexer-db/index.ts @@ -0,0 +1 @@ +export * from "./ensindexer-db-reader"; diff --git a/packages/ensnode-schema/src/index.ts b/packages/ensnode-schema/src/index.ts index ca1eb7ca44..6d4b2c3a72 100644 --- a/packages/ensnode-schema/src/index.ts +++ b/packages/ensnode-schema/src/index.ts @@ -1,2 +1,3 @@ +export * from "./ensindexer-db"; export * from "./ensnode-db"; export * from "./lib/drizzle"; From 6c8b6d7b86c50c3f06f85836763b0b54cd06a8ed Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 17:57:09 +0100 Subject: [PATCH 38/47] Revert "Update example env file" This reverts commit d53db6d255207b26d82cc147f8547e1da04ade16. --- apps/ensapi/.env.local.example | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index e12b99ca87..6de2ee84ba 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -12,12 +12,9 @@ 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 From 1be0947dac6af4934e234f64fd9034b88d2eef91 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 17:57:24 +0100 Subject: [PATCH 39/47] Revert "Update config builder test" This reverts commit 7ec0fb8ffc9f28869fbe3581d645556135021de0. --- apps/ensapi/src/config/config.schema.test.ts | 23 -------------------- 1 file changed, 23 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 7060e76022..407effcb29 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -19,20 +19,12 @@ 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; @@ -58,20 +50,6 @@ 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); @@ -138,7 +116,6 @@ describe("buildConfigFromEnvironment", () => { const TEST_ENV: EnsApiEnvironment = { DATABASE_URL: BASE_ENV.DATABASE_URL, - DATABASE_SCHEMA: BASE_ENV.DATABASE_SCHEMA, ENSINDEXER_URL: BASE_ENV.ENSINDEXER_URL, }; From 7705910849ff36d173a0f5724fcc71cf1784230c Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 18:12:49 +0100 Subject: [PATCH 40/47] Update EnsNodeDb migrations to match the updated schema --- ...000_smiling_prima.sql => 0000_certain_slyde.sql} | 4 ++-- .../drizzle-kit/migrations/meta/0000_snapshot.json | 13 ++++++++----- .../drizzle-kit/migrations/meta/_journal.json | 6 +++--- 3 files changed, 13 insertions(+), 10 deletions(-) rename apps/ensindexer/drizzle-kit/migrations/{0000_smiling_prima.sql => 0000_certain_slyde.sql} (51%) diff --git a/apps/ensindexer/drizzle-kit/migrations/0000_smiling_prima.sql b/apps/ensindexer/drizzle-kit/migrations/0000_certain_slyde.sql similarity index 51% rename from apps/ensindexer/drizzle-kit/migrations/0000_smiling_prima.sql rename to apps/ensindexer/drizzle-kit/migrations/0000_certain_slyde.sql index a8db2236bf..c7daf971ba 100644 --- a/apps/ensindexer/drizzle-kit/migrations/0000_smiling_prima.sql +++ b/apps/ensindexer/drizzle-kit/migrations/0000_certain_slyde.sql @@ -1,8 +1,8 @@ CREATE SCHEMA IF NOT EXISTS ensnode; CREATE TABLE "ensnode"."ensnode_metadata" ( - "ens_indexer_ref" text NOT NULL, + "ens_indexer_schema_name" text NOT NULL, "key" text NOT NULL, "value" jsonb NOT NULL, - CONSTRAINT "ensnode_metadata_pkey" PRIMARY KEY("ens_indexer_ref","key") + CONSTRAINT "ensnode_metadata_pkey" PRIMARY KEY("ens_indexer_schema_name","key") ); diff --git a/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json b/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json index b398463634..491d0e496e 100644 --- a/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json +++ b/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "d05a87cf-3971-41ac-9eab-a15a54711a47", + "id": "033e8b27-4739-4da9-b9da-517b0c2700d7", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -8,8 +8,8 @@ "name": "ensnode_metadata", "schema": "ensnode", "columns": { - "ens_indexer_ref": { - "name": "ens_indexer_ref", + "ens_indexer_schema_name": { + "name": "ens_indexer_schema_name", "type": "text", "primaryKey": false, "notNull": true @@ -32,7 +32,10 @@ "compositePrimaryKeys": { "ensnode_metadata_pkey": { "name": "ensnode_metadata_pkey", - "columns": ["ens_indexer_ref", "key"] + "columns": [ + "ens_indexer_schema_name", + "key" + ] } }, "uniqueConstraints": {}, @@ -52,4 +55,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json b/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json index 4423561778..2b6baba5ca 100644 --- a/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json +++ b/apps/ensindexer/drizzle-kit/migrations/meta/_journal.json @@ -5,9 +5,9 @@ { "idx": 0, "version": "7", - "when": 1773295356548, - "tag": "0000_smiling_prima", + "when": 1773421837301, + "tag": "0000_certain_slyde", "breakpoints": true } ] -} +} \ No newline at end of file From 16e5660972545da8aa4e24845117c296be02c234 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 18:14:22 +0100 Subject: [PATCH 41/47] Integrate `EnsNodeDbWriter` into ENSIndexer app --- .../ponder/src/api/handlers/ensnode-api.ts | 6 ++--- .../ensdb-writer-worker.ts | 22 +++++++++---------- .../src/lib/ensdb-writer-worker/singleton.ts | 4 ++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts index 994b987040..33f022d858 100644 --- a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts +++ b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts @@ -10,13 +10,13 @@ import { serializeIndexingStatusResponse, } from "@ensnode/ensnode-sdk"; -import { ensDbClient } from "@/lib/ensdb-client/singleton"; +import { ensNodeDbWriter } from "@/lib/ensdb-client/singleton"; const app = new Hono(); // include ENSIndexer Public Config endpoint app.get("/config", async (c) => { - const publicConfig = await ensDbClient.getEnsIndexerPublicConfig(); + const publicConfig = await ensNodeDbWriter.getEnsIndexerPublicConfig(); // Invariant: the public config is guaranteed to be available in ENSDb after // application startup. @@ -30,7 +30,7 @@ app.get("/config", async (c) => { app.get("/indexing-status", async (c) => { try { - const crossChainSnapshot = await ensDbClient.getIndexingStatusSnapshot(); + const crossChainSnapshot = await ensNodeDbWriter.getIndexingStatusSnapshot(); // Invariant: the Indexing Status Snapshot is expected to be available in // ENSDb shortly after application startup. There is a possibility that diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index e00c7d7fc3..410a3c5374 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -1,6 +1,7 @@ import { getUnixTime, secondsToMilliseconds } from "date-fns"; import pRetry from "p-retry"; +import type { EnsNodeDbWriter } from "@ensnode/ensnode-schema"; import { buildCrossChainIndexingStatusSnapshotOmnichain, type CrossChainIndexingStatusSnapshot, @@ -11,7 +12,6 @@ import { validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; -import type { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; @@ -38,9 +38,9 @@ export class EnsDbWriterWorker { private indexingStatusInterval: ReturnType | null = null; /** - * ENSDb Client instance used by the worker to interact with ENSDb. + * Client instance used by the worker to write data into ENSNode Schema in ENSDb. */ - private ensDbClient: EnsDbClient; + private ensNodeDbWriter: EnsNodeDbWriter; /** * Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. @@ -58,18 +58,18 @@ export class EnsDbWriterWorker { private migrationsDirPath: string; /** - * @param ensDbClient ENSDb Client instance used by the worker to interact with ENSDb. + * @param ensNodeDbWriter ENSNodeDbWriter instance used by the worker to write data into ENSNode Schema in ENSDb. * @param publicConfigBuilder ENSIndexer Public Config Builder instance used by the worker to read ENSIndexer Public Config. * @param indexingStatusBuilder Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. * @param migrationsDirPath Path to the directory containing ENSDb migrations to be executed by the worker on startup. */ constructor( - ensDbClient: EnsDbClient, + ensNodeDbWriter: EnsNodeDbWriter, publicConfigBuilder: PublicConfigBuilder, indexingStatusBuilder: IndexingStatusBuilder, migrationsDirPath: string, ) { - this.ensDbClient = ensDbClient; + this.ensNodeDbWriter = ensNodeDbWriter; this.publicConfigBuilder = publicConfigBuilder; this.indexingStatusBuilder = indexingStatusBuilder; this.migrationsDirPath = migrationsDirPath; @@ -107,14 +107,14 @@ export class EnsDbWriterWorker { // Task 1: upsert ENSDb version into ENSDb. console.log(`[EnsDbWriterWorker]: Upserting ENSDb version into ENSDb...`); - await this.ensDbClient.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb); + await this.ensNodeDbWriter.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb); console.log( `[EnsDbWriterWorker]: ENSDb version upserted successfully: ${inMemoryConfig.versionInfo.ensDb}`, ); // Task 2: upsert of EnsIndexerPublicConfig into ENSDb. console.log(`[EnsDbWriterWorker]: Upserting ENSIndexer Public Config into ENSDb...`); - await this.ensDbClient.upsertEnsIndexerPublicConfig(inMemoryConfig); + await this.ensNodeDbWriter.upsertEnsIndexerPublicConfig(inMemoryConfig); console.log(`[EnsDbWriterWorker]: ENSIndexer Public Config upserted successfully`); // Task 3: recurring upsert of Indexing Status Snapshot into ENSDb. @@ -151,7 +151,7 @@ export class EnsDbWriterWorker { * @throws Error if any migration fails to execute. */ private async executeMigrations(): Promise { - await this.ensDbClient.migrate(this.migrationsDirPath); + await this.ensNodeDbWriter.migrate(this.migrationsDirPath); } /** @@ -192,7 +192,7 @@ export class EnsDbWriterWorker { try { [storedConfig, inMemoryConfig] = await Promise.all([ - this.ensDbClient.getEnsIndexerPublicConfig(), + this.ensNodeDbWriter.getEnsIndexerPublicConfig(), inMemoryConfigPromise, ]); } catch (error) { @@ -249,7 +249,7 @@ export class EnsDbWriterWorker { snapshotTime, ); - await this.ensDbClient.upsertIndexingStatusSnapshot(crossChainSnapshot); + await this.ensNodeDbWriter.upsertIndexingStatusSnapshot(crossChainSnapshot); } catch (error) { console.error( `[EnsDbWriterWorker]: Error retrieving or validating Indexing Status Snapshot:`, diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index 620b71181c..8891c6407a 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; -import { ensDbClient } from "@/lib/ensdb-client/singleton"; +import { ensNodeDbWriter } from "@/lib/ensdb-client/singleton"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; @@ -27,7 +27,7 @@ export function startEnsDbWriterWorker() { ); ensDbWriterWorker = new EnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, migrationsDirPath, From d53f0bf5897a6bd472bd53f00ec97229d5d8199a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 18:14:58 +0100 Subject: [PATCH 42/47] Update tests and mocks following `EnsDbWriterWorker` integration --- .../ensdb-writer-worker.mock.ts | 49 ++++++-- .../ensdb-writer-worker.test.ts | 112 +++++++++--------- 2 files changed, 95 insertions(+), 66 deletions(-) diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts index b8b226851d..ba0be91a81 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.mock.ts @@ -1,44 +1,71 @@ import { vi } from "vitest"; +import type { EnsNodeDbWriter } from "@ensnode/ensnode-schema"; import { type CrossChainIndexingStatusSnapshot, CrossChainIndexingStrategyIds, type EnsIndexerPublicConfig, OmnichainIndexingStatusIds, type OmnichainIndexingStatusSnapshot, + PluginName, } from "@ensnode/ensnode-sdk"; -import type { EnsDbClient } from "@/lib/ensdb-client/ensdb-client"; -import * as ensDbClientMock from "@/lib/ensdb-client/ensdb-client.mock"; import { EnsDbWriterWorker } from "@/lib/ensdb-writer-worker/ensdb-writer-worker"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder"; +export const publicConfig = { + databaseSchemaName: "ensindexer_0", + ensRainbowPublicConfig: { + version: "0.32.0", + labelSet: { + labelSetId: "subgraph", + highestLabelSetVersion: 0, + }, + recordsCount: 100, + }, + labelSet: { + labelSetId: "subgraph", + labelSetVersion: 0, + }, + indexedChainIds: new Set([1]), + isSubgraphCompatible: true, + namespace: "mainnet", + plugins: [PluginName.Subgraph], + versionInfo: { + nodejs: "v22.10.12", + ponder: "0.11.25", + ensDb: "0.32.0", + ensIndexer: "0.32.0", + ensNormalize: "1.11.1", + }, +} satisfies EnsIndexerPublicConfig; + // Helper to create mock objects with consistent typing -export function createMockEnsDbClient( - overrides: Partial> = {}, -): EnsDbClient { +export function createMockEnsNodeDbWriter( + overrides: Partial> = {}, +): EnsNodeDbWriter { return { - ...baseEnsDbClient(), + ...baseEnsNodeDbWriter(), ...overrides, - } as unknown as EnsDbClient; + } as unknown as EnsNodeDbWriter; } export function createMockEnsDbWriterWorker( - ensDbClient: EnsDbClient, + ensNodeDbWriter: EnsNodeDbWriter, publicConfigBuilder: PublicConfigBuilder, indexingStatusBuilder: IndexingStatusBuilder, migrationsDirPath: string = "/mock/migrations", ) { return new EnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, migrationsDirPath, ); } -export function baseEnsDbClient() { +export function baseEnsNodeDbWriter() { return { getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), @@ -49,7 +76,7 @@ export function baseEnsDbClient() { } export function createMockPublicConfigBuilder( - resolvedConfig: EnsIndexerPublicConfig = ensDbClientMock.publicConfig, + resolvedConfig: EnsIndexerPublicConfig = publicConfig, ): PublicConfigBuilder { return { getPublicConfig: vi.fn().mockResolvedValue(resolvedConfig), diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index f8d713a75e..e1d8b71f09 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -6,14 +6,14 @@ import { validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; -import * as ensDbClientMock from "@/lib/ensdb-client/ensdb-client.mock"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; +import * as ensNodeDbWriterMock from "./ensdb-writer-worker.mock"; import { createMockCrossChainSnapshot, - createMockEnsDbClient, createMockEnsDbWriterWorker, + createMockEnsNodeDbWriter, createMockIndexingStatusBuilder, createMockOmnichainSnapshot, createMockPublicConfigBuilder, @@ -47,12 +47,12 @@ describe("EnsDbWriterWorker", () => { it("executes database migrations on startup", async () => { // arrange const migrationsDirPath = "/custom/migrations/path"; - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, migrationsDirPath, @@ -62,8 +62,8 @@ describe("EnsDbWriterWorker", () => { await worker.run(); // assert - verify migrations were executed with correct path - expect(ensDbClient.migrate).toHaveBeenCalledTimes(1); - expect(ensDbClient.migrate).toHaveBeenCalledWith(migrationsDirPath); + expect(ensNodeDbWriter.migrate).toHaveBeenCalledTimes(1); + expect(ensNodeDbWriter.migrate).toHaveBeenCalledWith(migrationsDirPath); // cleanup worker.stop(); @@ -72,29 +72,29 @@ describe("EnsDbWriterWorker", () => { it("throws when database migration fails", async () => { // arrange const migrationError = new Error("Migration failed: invalid SQL syntax"); - const ensDbClient = createMockEnsDbClient({ + const ensNodeDbWriter = createMockEnsNodeDbWriter({ migrate: vi.fn().mockRejectedValue(migrationError), }); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); // act & assert await expect(worker.run()).rejects.toThrow("Migration failed: invalid SQL syntax"); - expect(ensDbClient.migrate).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); - expect(ensDbClient.upsertEnsIndexerPublicConfig).not.toHaveBeenCalled(); + expect(ensNodeDbWriter.migrate).toHaveBeenCalledTimes(1); + expect(ensNodeDbWriter.upsertEnsDbVersion).not.toHaveBeenCalled(); + expect(ensNodeDbWriter.upsertEnsIndexerPublicConfig).not.toHaveBeenCalled(); }); it("executes migrations before any other operations", async () => { // arrange const operationOrder: string[] = []; - const ensDbClient = createMockEnsDbClient({ + const ensNodeDbWriter = createMockEnsNodeDbWriter({ migrate: vi.fn().mockImplementation(async () => { operationOrder.push("migrate"); }), @@ -109,7 +109,7 @@ describe("EnsDbWriterWorker", () => { const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -135,12 +135,12 @@ describe("EnsDbWriterWorker", () => { const snapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshot); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -149,18 +149,18 @@ describe("EnsDbWriterWorker", () => { await worker.run(); // assert - verify initial upserts happened - expect(ensDbClient.upsertEnsDbVersion).toHaveBeenCalledWith( - ensDbClientMock.publicConfig.versionInfo.ensDb, + expect(ensNodeDbWriter.upsertEnsDbVersion).toHaveBeenCalledWith( + ensNodeDbWriterMock.publicConfig.versionInfo.ensDb, ); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( - ensDbClientMock.publicConfig, + expect(ensNodeDbWriter.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( + ensNodeDbWriterMock.publicConfig, ); // advance time to trigger interval await vi.advanceTimersByTimeAsync(1000); // assert - snapshot should be upserted - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(snapshot); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(snapshot); expect(buildCrossChainIndexingStatusSnapshotOmnichain).toHaveBeenCalledWith( omnichainSnapshot, expect.any(Number), @@ -177,31 +177,31 @@ describe("EnsDbWriterWorker", () => { throw incompatibleError; }); - const ensDbClient = createMockEnsDbClient({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensDbClientMock.publicConfig), + const ensNodeDbWriter = createMockEnsNodeDbWriter({ + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensNodeDbWriterMock.publicConfig), }); - const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); + const publicConfigBuilder = createMockPublicConfigBuilder(ensNodeDbWriterMock.publicConfig); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); // act & assert await expect(worker.run()).rejects.toThrow("incompatible"); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + expect(ensNodeDbWriter.upsertEnsDbVersion).not.toHaveBeenCalled(); }); it("throws error when worker is already running", async () => { // arrange - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -219,14 +219,14 @@ describe("EnsDbWriterWorker", () => { it("throws error when config fetch fails", async () => { // arrange const networkError = new Error("Network failure"); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = { getPublicConfig: vi.fn().mockRejectedValue(networkError), } as unknown as PublicConfigBuilder; const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -234,27 +234,27 @@ describe("EnsDbWriterWorker", () => { // act & assert await expect(worker.run()).rejects.toThrow("Network failure"); expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + expect(ensNodeDbWriter.upsertEnsDbVersion).not.toHaveBeenCalled(); }); it("throws error when stored config fetch fails", async () => { // arrange const dbError = new Error("Database connection lost"); - const ensDbClient = createMockEnsDbClient({ + const ensNodeDbWriter = createMockEnsNodeDbWriter({ getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(dbError), }); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); // act & assert await expect(worker.run()).rejects.toThrow("Database connection lost"); - expect(ensDbClient.upsertEnsDbVersion).not.toHaveBeenCalled(); + expect(ensNodeDbWriter.upsertEnsDbVersion).not.toHaveBeenCalled(); }); it("fetches stored and in-memory configs concurrently", async () => { @@ -263,14 +263,14 @@ describe("EnsDbWriterWorker", () => { // validation passes }); - const ensDbClient = createMockEnsDbClient({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensDbClientMock.publicConfig), + const ensNodeDbWriter = createMockEnsNodeDbWriter({ + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensNodeDbWriterMock.publicConfig), }); - const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); + const publicConfigBuilder = createMockPublicConfigBuilder(ensNodeDbWriterMock.publicConfig); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -279,7 +279,7 @@ describe("EnsDbWriterWorker", () => { await worker.run(); // assert - both should have been called (concurrent execution via Promise.all) - expect(ensDbClient.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); + expect(ensNodeDbWriter.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); // cleanup @@ -291,12 +291,12 @@ describe("EnsDbWriterWorker", () => { const snapshot = createMockCrossChainSnapshot(); vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -306,8 +306,8 @@ describe("EnsDbWriterWorker", () => { // assert - config should be called once (pRetry is mocked) expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( - ensDbClientMock.publicConfig, + expect(ensNodeDbWriter.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( + ensNodeDbWriterMock.publicConfig, ); // cleanup @@ -319,12 +319,12 @@ describe("EnsDbWriterWorker", () => { it("stops the interval when stop() is called", async () => { // arrange const upsertIndexingStatusSnapshot = vi.fn().mockResolvedValue(undefined); - const ensDbClient = createMockEnsDbClient({ upsertIndexingStatusSnapshot }); + const ensNodeDbWriter = createMockEnsNodeDbWriter({ upsertIndexingStatusSnapshot }); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -348,12 +348,12 @@ describe("EnsDbWriterWorker", () => { describe("isRunning - worker state", () => { it("indicates isRunning status correctly", async () => { // arrange - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -392,7 +392,7 @@ describe("EnsDbWriterWorker", () => { vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = { getOmnichainIndexingStatusSnapshot: vi @@ -402,7 +402,7 @@ describe("EnsDbWriterWorker", () => { } as unknown as IndexingStatusBuilder; const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -418,8 +418,8 @@ describe("EnsDbWriterWorker", () => { // assert expect(indexingStatusBuilder.getOmnichainIndexingStatusSnapshot).toHaveBeenCalledTimes(2); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(1); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot); // cleanup worker.stop(); @@ -446,7 +446,7 @@ describe("EnsDbWriterWorker", () => { .mockReturnValueOnce(crossChainSnapshot2) .mockReturnValueOnce(crossChainSnapshot2); - const ensDbClient = createMockEnsDbClient({ + const ensNodeDbWriter = createMockEnsNodeDbWriter({ upsertIndexingStatusSnapshot: vi .fn() .mockResolvedValueOnce(undefined) @@ -463,7 +463,7 @@ describe("EnsDbWriterWorker", () => { } as unknown as IndexingStatusBuilder; const worker = createMockEnsDbWriterWorker( - ensDbClient, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, ); @@ -473,17 +473,19 @@ describe("EnsDbWriterWorker", () => { // first tick - succeeds await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot1); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledWith( + crossChainSnapshot1, + ); // second tick - fails with DB error, but continues await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenLastCalledWith( + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenLastCalledWith( crossChainSnapshot2, ); // third tick - succeeds again await vi.advanceTimersByTimeAsync(1000); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(3); + expect(ensNodeDbWriter.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(3); // cleanup worker.stop(); From 5297c9c637a83288f7120c8ebc634db425fce4fb Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 18:15:46 +0100 Subject: [PATCH 43/47] Remove unused code --- .../src/lib/ensdb-client/ensdb-client.ts | 139 ------------------ 1 file changed, 139 deletions(-) delete mode 100644 apps/ensapi/src/lib/ensdb-client/ensdb-client.ts diff --git a/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts b/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts deleted file mode 100644 index 35b33cf349..0000000000 --- a/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts +++ /dev/null @@ -1,139 +0,0 @@ -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`); - } -} From 12359f82ad96fc86968574de15e364f336ac701a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 18:19:01 +0100 Subject: [PATCH 44/47] Integrate `EnsIndexerDbReader` into ENSApi` --- apps/ensapi/src/lib/db.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/ensapi/src/lib/db.ts b/apps/ensapi/src/lib/db.ts index db88e5e2ce..7be431f8c2 100644 --- a/apps/ensapi/src/lib/db.ts +++ b/apps/ensapi/src/lib/db.ts @@ -1,14 +1,22 @@ import config from "@/config"; -import * as ensIndexerSchema from "@ensnode/ensnode-schema/ensindexer"; +import { EnsIndexerDbReader } from "@ensnode/ensnode-schema"; -import { makeReadOnlyDrizzle } from "@/lib/handlers/drizzle"; +// TODO: pending rename `config.databaseUrl` to `config.ensDbUrl` +// Will be executed once https://github.com/namehash/ensnode/issues/1763 is resolved. +const ensDbUrl = config.databaseUrl; +// TODO: pending rename `config.databaseSchemaName` to `config.ensIndexerSchemaName` +// Will be executed once https://github.com/namehash/ensnode/issues/1762 is resolved. +const ensIndexerSchemaName = config.databaseSchemaName; + +const ensIndexerDbReader = new EnsIndexerDbReader(ensDbUrl, ensIndexerSchemaName); + +/** + * Read-only Drizzle instance for queries to ENSIndexer Schema in ENSDb. + */ +export const ensIndexerDbReadonly = ensIndexerDbReader.db; /** - * Read-only Drizzle instance for ENSDb queries to ENSIndexer Schema + * Read-only Drizzle instance for queries to ENSIndexer Schema in ENSDb. */ -export const db = makeReadOnlyDrizzle({ - databaseUrl: config.databaseUrl, - databaseSchema: config.databaseSchemaName, - schema: ensIndexerSchema, -}); +export const db = ensIndexerDbReadonly; From f35edb0348fcd6cb4ad5549deaea56262603a997 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 18:19:29 +0100 Subject: [PATCH 45/47] Fix exports for ENSDb Schema definitions --- apps/ensindexer/ponder/ponder.schema.ts | 4 ++-- packages/ensnode-schema/src/index.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/ensindexer/ponder/ponder.schema.ts b/apps/ensindexer/ponder/ponder.schema.ts index 27bdf0a083..f329f417ba 100644 --- a/apps/ensindexer/ponder/ponder.schema.ts +++ b/apps/ensindexer/ponder/ponder.schema.ts @@ -1,2 +1,2 @@ -// export the shared ponder schema -export * from "@ensnode/ensnode-schema"; +// export the ENSIndexer schema for Ponder to use when indexing data +export * from "@ensnode/ensnode-schema/ensindexer"; diff --git a/packages/ensnode-schema/src/index.ts b/packages/ensnode-schema/src/index.ts index 6d4b2c3a72..fb4ec9f95d 100644 --- a/packages/ensnode-schema/src/index.ts +++ b/packages/ensnode-schema/src/index.ts @@ -1,3 +1,5 @@ export * from "./ensindexer-db"; export * from "./ensnode-db"; export * from "./lib/drizzle"; +export * from "./ensnode-schema"; +export * from "./ensindexer-schema"; From 79a176ddf45515f44f07b9b11d8d00090d653092 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 18:22:35 +0100 Subject: [PATCH 46/47] Revert "Update integration tests config" This reverts commit a6ea0b9520ed4005bdda53102ce31fee78cab013. --- packages/integration-test-env/src/orchestrator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index 9d9b99fa3c..f15e548138 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -339,7 +339,6 @@ async function main() { { ENSINDEXER_URL, DATABASE_URL, - DATABASE_SCHEMA: "public", }, "ensapi", ); From b4b5b0b3ad2ce2b0ef2ba0651270889b8ca3144f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 13 Mar 2026 18:23:52 +0100 Subject: [PATCH 47/47] Revert "Apply terraform script updates" This reverts commit 8a056f21b8207ca759135b781295f04aa6792809. --- terraform/modules/ensindexer/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/modules/ensindexer/main.tf b/terraform/modules/ensindexer/main.tf index dcfa5fd623..1c5d81204a 100644 --- a/terraform/modules/ensindexer/main.tf +++ b/terraform/modules/ensindexer/main.tf @@ -1,7 +1,6 @@ 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 } @@ -29,6 +28,7 @@ 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 },