diff --git a/.changeset/big-impalas-brush.md b/.changeset/big-impalas-brush.md new file mode 100644 index 0000000000..76b3b34fdb --- /dev/null +++ b/.changeset/big-impalas-brush.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Extended `EnsDbClient` with `EnsDbClientMigration` interface implementation. 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. 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. diff --git a/.changeset/seven-hands-ask.md b/.changeset/seven-hands-ask.md new file mode 100644 index 0000000000..e7caf6318a --- /dev/null +++ b/.changeset/seven-hands-ask.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Introduced `EnsDbClientMigration` interface. 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`. 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. diff --git a/apps/ensapi/src/lib/db.ts b/apps/ensapi/src/lib/db.ts index 0148d37ecd..7be431f8c2 100644 --- a/apps/ensapi/src/lib/db.ts +++ b/apps/ensapi/src/lib/db.ts @@ -1,11 +1,22 @@ import config from "@/config"; -import * as schema from "@ensnode/ensnode-schema"; +import { EnsIndexerDbReader } from "@ensnode/ensnode-schema"; -import { makeDrizzle } 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; -export const db = makeDrizzle({ - databaseUrl: config.databaseUrl, - databaseSchema: config.databaseSchemaName, - schema, -}); +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 queries to ENSIndexer Schema in ENSDb. + */ +export const db = ensIndexerDbReadonly; diff --git a/apps/ensapi/src/lib/handlers/drizzle.ts b/apps/ensapi/src/lib/handlers/drizzle.ts index a5d8f36daf..173252c908 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,21 +9,32 @@ 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, }: { 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); + } - return drizzle(databaseUrl, { + const parsedConfig = parseIntoClientConfig(databaseUrl); + const existingOptions = parsedConfig.options || ""; + const readOnlyOption = "-c default_transaction_read_only=on"; + + return drizzle({ + connection: { + ...parsedConfig, + // Combine existing options from URL with read-only requirement + options: existingOptions ? `${existingOptions} ${readOnlyOption}` : readOnlyOption, + }, schema, casing: "snake_case", logger: { 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_certain_slyde.sql b/apps/ensindexer/drizzle-kit/migrations/0000_certain_slyde.sql new file mode 100644 index 0000000000..c7daf971ba --- /dev/null +++ b/apps/ensindexer/drizzle-kit/migrations/0000_certain_slyde.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA IF NOT EXISTS ensnode; + +CREATE TABLE "ensnode"."ensnode_metadata" ( + "ens_indexer_schema_name" text NOT NULL, + "key" text NOT NULL, + "value" jsonb NOT NULL, + 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 new file mode 100644 index 0000000000..491d0e496e --- /dev/null +++ b/apps/ensindexer/drizzle-kit/migrations/meta/0000_snapshot.json @@ -0,0 +1,58 @@ +{ + "id": "033e8b27-4739-4da9-b9da-517b0c2700d7", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "ensnode.ensnode_metadata": { + "name": "ensnode_metadata", + "schema": "ensnode", + "columns": { + "ens_indexer_schema_name": { + "name": "ens_indexer_schema_name", + "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_schema_name", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "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 new file mode 100644 index 0000000000..2b6baba5ca --- /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": 1773421837301, + "tag": "0000_certain_slyde", + "breakpoints": true + } + ] +} \ No newline at end of file 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..ca7192c1b6 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", @@ -47,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/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/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-client/drizzle.ts b/apps/ensindexer/src/lib/ensdb-client/drizzle.ts deleted file mode 100644 index b5cc9b5c73..0000000000 --- a/apps/ensindexer/src/lib/ensdb-client/drizzle.ts +++ /dev/null @@ -1,26 +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 { setDatabaseSchema } from "@ponder/client"; -import { drizzle } from "drizzle-orm/node-postgres"; - -type Schema = { [name: string]: unknown }; - -/** - * Makes a Drizzle DB object. - */ -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.mock.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts deleted file mode 100644 index 7387cc19fd..0000000000 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts +++ /dev/null @@ -1,72 +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"; - -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 0462b77886..0000000000 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { ensNodeMetadata } from "@ensnode/ensnode-schema"; -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.databaseSchemaName, - ); - - // act & assert - await expect(client.getEnsDbVersion()).resolves.toBeUndefined(); - - expect(selectMock).toHaveBeenCalledTimes(1); - expect(fromMock).toHaveBeenCalledWith(ensNodeMetadata); - }); - - it("returns value when one record exists", async () => { - // arrange - selectResult.current = [{ value: "0.1.0" }]; - - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); - - // 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.databaseSchemaName, - ); - - // 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.databaseSchemaName, - ); - - // 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.databaseSchemaName, - ); - - // 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.databaseSchemaName, - ); - 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.databaseSchemaName, - ); - - // act - await client.upsertEnsDbVersion("0.2.0"); - - // assert - expect(insertMock).toHaveBeenCalledWith(ensNodeMetadata); - expect(valuesMock).toHaveBeenCalledWith({ - key: EnsNodeMetadataKeys.EnsDbVersion, - value: "0.2.0", - }); - expect(onConflictDoUpdateMock).toHaveBeenCalledWith({ - target: 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.databaseSchemaName, - ); - const expectedValue = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - // act - await client.upsertEnsIndexerPublicConfig(ensDbClientMock.publicConfig); - - // assert - expect(valuesMock).toHaveBeenCalledWith({ - key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, - value: expectedValue, - }); - }); - }); - - describe("upsertIndexingStatusSnapshot", () => { - it("serializes and writes the indexing status snapshot", async () => { - // arrange - const client = new EnsDbClient( - ensDbClientMock.databaseUrl, - ensDbClientMock.databaseSchemaName, - ); - const snapshot = deserializeCrossChainIndexingStatusSnapshot( - ensDbClientMock.serializedSnapshot, - ); - const expectedValue = serializeCrossChainIndexingStatusSnapshot(snapshot); - - // act - await client.upsertIndexingStatusSnapshot(snapshot); - - // assert - expect(valuesMock).toHaveBeenCalledWith({ - 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 3af5de58fa..0000000000 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { eq, sql } from "drizzle-orm/sql"; - -import { ensNodeMetadata } from "@ensnode/ensnode-schema"; -import { - type CrossChainIndexingStatusSnapshot, - deserializeCrossChainIndexingStatusSnapshot, - deserializeEnsIndexerPublicConfig, - 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"; - -/** - * 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 {} - -/** - * 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 { - /** - * Drizzle database instance for ENSDb. - */ - private db: DrizzleDb; - - /** - * @param databaseUrl connection string for ENSDb Postgres database - * @param databaseSchemaName Postgres schema name for ENSDb tables - */ - constructor(databaseUrl: string, databaseSchemaName: string) { - this.db = makeDrizzle({ - databaseSchema: databaseSchemaName, - databaseUrl, - schema, - }); - } - - /** - * @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 PK constraint on 'key') - */ - private async getEnsNodeMetadata( - metadata: Pick, - ): Promise { - const result = await this.db - .select() - .from(ensNodeMetadata) - .where(eq(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.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(ensNodeMetadata) - .values({ - key: metadata.key, - value: metadata.value, - }) - .onConflictDoUpdate({ - target: ensNodeMetadata.key, - set: { value: metadata.value }, - }); - }); - } -} diff --git a/apps/ensindexer/src/lib/ensdb-client/singleton.ts b/apps/ensindexer/src/lib/ensdb-client/singleton.ts index 3ab2225fd2..a41975775d 100644 --- a/apps/ensindexer/src/lib/ensdb-client/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-client/singleton.ts @@ -1,8 +1,12 @@ import config from "@/config"; -import { EnsDbClient } from "./ensdb-client"; +import { EnsNodeDbWriter } from "@ensnode/ensnode-schema"; + +// 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, config.databaseSchemaName); +export const ensNodeDbWriter = new EnsNodeDbWriter(config.databaseUrl, ensIndexerSchemaName); 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..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,39 +1,82 @@ 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( + ensNodeDbWriter: EnsNodeDbWriter, + publicConfigBuilder: PublicConfigBuilder, + indexingStatusBuilder: IndexingStatusBuilder, + migrationsDirPath: string = "/mock/migrations", +) { + return new EnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + migrationsDirPath, + ); } -export function baseEnsDbClient() { +export function baseEnsNodeDbWriter() { 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), }; } 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 f46ad5f938..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 { 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 * as ensNodeDbWriterMock from "./ensdb-writer-worker.mock"; import { createMockCrossChainSnapshot, - createMockEnsDbClient, + createMockEnsDbWriterWorker, + createMockEnsNodeDbWriter, createMockIndexingStatusBuilder, createMockOmnichainSnapshot, createMockPublicConfigBuilder, @@ -44,34 +44,123 @@ describe("EnsDbWriterWorker", () => { }); describe("run() - worker initialization", () => { + it("executes database migrations on startup", async () => { + // arrange + const migrationsDirPath = "/custom/migrations/path"; + const ensNodeDbWriter = createMockEnsNodeDbWriter(); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); + + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + migrationsDirPath, + ); + + // act + await worker.run(); + + // assert - verify migrations were executed with correct path + expect(ensNodeDbWriter.migrate).toHaveBeenCalledTimes(1); + expect(ensNodeDbWriter.migrate).toHaveBeenCalledWith(migrationsDirPath); + + // cleanup + worker.stop(); + }); + + it("throws when database migration fails", async () => { + // arrange + const migrationError = new Error("Migration failed: invalid SQL syntax"); + const ensNodeDbWriter = createMockEnsNodeDbWriter({ + migrate: vi.fn().mockRejectedValue(migrationError), + }); + const publicConfigBuilder = createMockPublicConfigBuilder(); + const indexingStatusBuilder = createMockIndexingStatusBuilder(); + + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); + + // act & assert + await expect(worker.run()).rejects.toThrow("Migration failed: invalid SQL syntax"); + 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 ensNodeDbWriter = createMockEnsNodeDbWriter({ + 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( + ensNodeDbWriter, + 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(); const snapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshot); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act 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), @@ -88,26 +177,34 @@ 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 = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + 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 = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act - first run await worker.run(); @@ -122,34 +219,42 @@ 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 = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // 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 = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + 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 () => { @@ -158,19 +263,23 @@ 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 = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act 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 @@ -182,19 +291,23 @@ describe("EnsDbWriterWorker", () => { const snapshot = createMockCrossChainSnapshot(); vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); // 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 @@ -206,11 +319,15 @@ 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 = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); @@ -231,11 +348,15 @@ 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 = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // assert - not running initially expect(worker.isRunning).toBe(false); @@ -271,7 +392,7 @@ describe("EnsDbWriterWorker", () => { vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); - const ensDbClient = createMockEnsDbClient(); + const ensNodeDbWriter = createMockEnsNodeDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = { getOmnichainIndexingStatusSnapshot: vi @@ -280,7 +401,11 @@ describe("EnsDbWriterWorker", () => { .mockResolvedValueOnce(validSnapshot), } as unknown as IndexingStatusBuilder; - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act - run returns immediately await worker.run(); @@ -293,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(); @@ -321,7 +446,7 @@ describe("EnsDbWriterWorker", () => { .mockReturnValueOnce(crossChainSnapshot2) .mockReturnValueOnce(crossChainSnapshot2); - const ensDbClient = createMockEnsDbClient({ + const ensNodeDbWriter = createMockEnsNodeDbWriter({ upsertIndexingStatusSnapshot: vi .fn() .mockResolvedValueOnce(undefined) @@ -337,24 +462,30 @@ describe("EnsDbWriterWorker", () => { .mockResolvedValueOnce(snapshot2), } as unknown as IndexingStatusBuilder; - const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); + const worker = createMockEnsDbWriterWorker( + ensNodeDbWriter, + publicConfigBuilder, + indexingStatusBuilder, + ); // act await worker.run(); // 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(); 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..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"; @@ -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 { /** @@ -36,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. @@ -51,24 +53,33 @@ export class EnsDbWriterWorker { private publicConfigBuilder: PublicConfigBuilder; /** - * @param ensDbClient ENSDb Client instance used by the worker to interact with ENSDb. + * Path to the directory containing ENSDb migrations to be executed by the worker on startup. + */ + private migrationsDirPath: string; + + /** + * @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; } /** * 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,19 +97,24 @@ 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(); // 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. @@ -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.ensNodeDbWriter.migrate(this.migrationsDirPath); + } + /** * Get validated ENSIndexer Public Config object for the ENSDb Writer Worker. * @@ -164,7 +192,7 @@ export class EnsDbWriterWorker { try { [storedConfig, inMemoryConfig] = await Promise.all([ - this.ensDbClient.getEnsIndexerPublicConfig(), + this.ensNodeDbWriter.getEnsIndexerPublicConfig(), inMemoryConfigPromise, ]); } catch (error) { @@ -221,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 d58ddc9e9d..8891c6407a 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,4 +1,6 @@ -import { ensDbClient } from "@/lib/ensdb-client/singleton"; +import { fileURLToPath } from "node:url"; + +import { ensNodeDbWriter } 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, + ensNodeDbWriter, publicConfigBuilder, indexingStatusBuilder, + migrationsDirPath, ); ensDbWriterWorker diff --git a/packages/ensnode-schema/package.json b/packages/ensnode-schema/package.json index 94cd3393a1..5e4d7db212 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,51 @@ "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/index.d.ts", + "default": "./dist/ponder-schema/index.js" + } + }, + "./ensindexer": { + "import": { + "types": "./dist/ensindexer-schema/index.d.ts", + "default": "./dist/ensindexer-schema/index.js" + } + }, + "./ensnode": { + "import": { + "types": "./dist/ensnode-schema/index.d.ts", + "default": "./dist/ensnode-schema/index.js" + } + } + } }, "scripts": { "prepublish": "tsup", "lint": "biome check --write .", "lint:ci": "biome ci" }, - "dependencies": { + "peerDependencies": { + "drizzle-orm": "catalog:", + "pg-connection-string": "catalog:", "ponder": "catalog:", "viem": "catalog:" }, "devDependencies": { "@ensnode/ensnode-sdk": "workspace:", "@ensnode/shared-configs": "workspace:*", + "drizzle-orm": "catalog:", + "pg-connection-string": "catalog:", + "ponder": "catalog:", "tsup": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "viem": "catalog:" } } 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. 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/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/ensindexer-schema/index.ts b/packages/ensnode-schema/src/ensindexer-schema/index.ts new file mode 100644 index 0000000000..10ce6e45da --- /dev/null +++ b/packages/ensnode-schema/src/ensindexer-schema/index.ts @@ -0,0 +1,9 @@ +/** + * Merge the various sub-schemas into ENSIndexer Schema. + */ + +export * from "./ensv2.subschema"; +export * from "./protocol-acceleration.subschema"; +export * from "./registrars.subschema"; +export * from "./subgraph.subschema"; +export * from "./tokenscope.subschema"; 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-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-mutations.ts b/packages/ensnode-schema/src/ensnode-db/ensnode-db-mutations.ts new file mode 100644 index 0000000000..f59b7bfc81 --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-db/ensnode-db-mutations.ts @@ -0,0 +1,30 @@ +import type { + CrossChainIndexingStatusSnapshot, + EnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +/** + * Client interface with mutations for ENSNode Schema in ENSDb. + */ +export interface EnsNodeDbMutations { + /** + * Upsert ENSDb Version + * + * @throws when upsert operation failed. + */ + upsertEnsDbVersion(ensDbVersion: string): Promise; + + /** + * Upsert ENSIndexer Public Config + * + * @throws when upsert operation failed. + */ + upsertEnsIndexerPublicConfig(ensIndexerPublicConfig: EnsIndexerPublicConfig): Promise; + + /** + * Upsert Indexing Status Snapshot + * + * @throws when upsert operation failed. + */ + upsertIndexingStatusSnapshot(indexingStatus: CrossChainIndexingStatusSnapshot): Promise; +} 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`); + } +} 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-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-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"; 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-schema/src/ensnode-schema/index.ts b/packages/ensnode-schema/src/ensnode-schema/index.ts new file mode 100644 index 0000000000..df62dae3f4 --- /dev/null +++ b/packages/ensnode-schema/src/ensnode-schema/index.ts @@ -0,0 +1,65 @@ +/** + * 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 Schema Name + * + * 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. + */ + ensIndexerSchemaName: 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 '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.ensIndexerSchemaName, 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..fb4ec9f95d --- /dev/null +++ b/packages/ensnode-schema/src/index.ts @@ -0,0 +1,5 @@ +export * from "./ensindexer-db"; +export * from "./ensnode-db"; +export * from "./lib/drizzle"; +export * from "./ensnode-schema"; +export * from "./ensindexer-schema"; 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/packages/ensnode-schema/src/ponder-schema/index.ts b/packages/ensnode-schema/src/ponder-schema/index.ts new file mode 100644 index 0000000000..bd2514647e --- /dev/null +++ b/packages/ensnode-schema/src/ponder-schema/index.ts @@ -0,0 +1,8 @@ +/** + * Ponder Schema + * + * 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 + */ + +export const PONDER_SCHEMA_NAME = "ponder_sync"; diff --git a/packages/ensnode-schema/src/ponder.schema.ts b/packages/ensnode-schema/src/ponder.schema.ts deleted file mode 100644 index 58508341ee..0000000000 --- a/packages/ensnode-schema/src/ponder.schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Merge the various sub-schemas into a single ponder (drizzle) 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"; 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/packages/ensnode-sdk/src/ensdb/client.ts b/packages/ensnode-sdk/src/ensdb/client.ts deleted file mode 100644 index 0cbf9b287e..0000000000 --- a/packages/ensnode-sdk/src/ensdb/client.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { EnsIndexerPublicConfig } from "../ensindexer/config"; -import type { CrossChainIndexingStatusSnapshot } from "../indexing-status/cross-chain-indexing-status-snapshot"; - -/** - * ENSDb Client Query - * - * Includes methods for reading from ENSDb. - */ -export interface EnsDbClientQuery { - /** - * 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; -} - -/** - * ENSDb Client Mutation - * - * Includes methods for writing into ENSDb. - */ -export interface EnsDbClientMutation { - /** - * Upsert ENSDb Version - * - * @throws when upsert operation failed. - */ - upsertEnsDbVersion(ensDbVersion: string): Promise; - - /** - * Upsert ENSIndexer Public Config - * - * @throws when upsert operation failed. - */ - upsertEnsIndexerPublicConfig(ensIndexerPublicConfig: EnsIndexerPublicConfig): Promise; - - /** - * Upsert Indexing Status Snapshot - * - * @throws when upsert operation failed. - */ - upsertIndexingStatusSnapshot(indexingStatus: CrossChainIndexingStatusSnapshot): Promise; -} 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"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41c6c442e9..3b0ad5351e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -525,6 +525,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 @@ -866,13 +869,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.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) - viem: - specifier: 'catalog:' - version: 2.38.5(typescript@5.9.3)(zod@3.25.76) devDependencies: '@ensnode/ensnode-sdk': specifier: 'workspace:' @@ -880,12 +876,24 @@ 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) + 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) 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: @@ -1645,6 +1653,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==} @@ -1706,6 +1717,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'} @@ -4870,6 +4889,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==} @@ -5571,6 +5593,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: @@ -5744,6 +5770,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'} @@ -8047,10 +8078,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 +10200,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 +10297,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 +13837,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -14492,6 +14544,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 +14646,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 @@ -17658,8 +17726,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: