diff --git a/.changeset/beige-brooms-prove.md b/.changeset/beige-brooms-prove.md new file mode 100644 index 0000000000..7156207f5c --- /dev/null +++ b/.changeset/beige-brooms-prove.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Replaced a bespoke `EnsDbClient` implementation with `EnsDbWriter` from ENSDb SDK. diff --git a/.changeset/common-mangos-sort.md b/.changeset/common-mangos-sort.md new file mode 100644 index 0000000000..6985dfdfe2 --- /dev/null +++ b/.changeset/common-mangos-sort.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Added running database migrations for ENSDb as a responsibility for ENSIndexer. diff --git a/.changeset/quick-years-divide.md b/.changeset/quick-years-divide.md new file mode 100644 index 0000000000..db04346d4a --- /dev/null +++ b/.changeset/quick-years-divide.md @@ -0,0 +1,6 @@ +--- +"@ensnode/ensnode-sdk": minor +"@ensnode/ensdb-sdk": minor +--- + +Moved `ensdb` module from ENSNode SDK into ENSDb SDK. diff --git a/.changeset/thin-humans-punch.md b/.changeset/thin-humans-punch.md new file mode 100644 index 0000000000..ed9858ca85 --- /dev/null +++ b/.changeset/thin-humans-punch.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensdb-sdk": minor +--- + +Introduced two client implementations for ENSDb: `EnsDbReader` and `EnsDbWriter`. diff --git a/apps/ensindexer/ponder/ponder.schema.ts b/apps/ensindexer/ponder/ponder.schema.ts index 62fe7bf34b..a837e1c867 100644 --- a/apps/ensindexer/ponder/ponder.schema.ts +++ b/apps/ensindexer/ponder/ponder.schema.ts @@ -1,2 +1,6 @@ -// export database schema definition for ENSIndexer -export * from "@ensnode/ensdb-sdk/ensindexer"; +/** + * We re-export (just) the "abstract" ENSIndexer Schema from ENSDb for Ponder to manage. + * Ponder will internally build a "concrete" ENSIndexer Schema using + * the "abstract" ENSIndexer Schema and the ENSIndexer Schema name. + **/ +export * from "@ensnode/ensdb-sdk/ensindexer-abstract"; diff --git a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts index 994b987040..fc6a46e796 100644 --- a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts +++ b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts @@ -10,7 +10,7 @@ import { serializeIndexingStatusResponse, } from "@ensnode/ensnode-sdk"; -import { ensDbClient } from "@/lib/ensdb-client/singleton"; +import { ensDbClient } from "@/lib/ensdb/singleton"; const app = new Hono(); diff --git a/apps/ensindexer/ponder/src/api/index.ts b/apps/ensindexer/ponder/src/api/index.ts index 68f28820fa..9b184f1280 100644 --- a/apps/ensindexer/ponder/src/api/index.ts +++ b/apps/ensindexer/ponder/src/api/index.ts @@ -5,6 +5,7 @@ import { cors } from "hono/cors"; import type { ErrorResponse } from "@ensnode/ensnode-sdk"; +import { migrateEnsNodeSchema } from "@/lib/ensdb/migrate-ensnode-schema"; import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; import ensNodeApi from "./handlers/ensnode-api"; @@ -13,7 +14,14 @@ import ensNodeApi from "./handlers/ensnode-api"; // the `api` directory of the Ponder app to avoid the following build issue: // Error: Invalid dependency graph. Config, schema, and indexing function files // cannot import objects from the API function file "src/api/index.ts". -startEnsDbWriterWorker(); +// Before starting the ENSDb Writer Worker, we need to ensure that +// the ENSNode Schema in ENSDb is up to date by running any pending migrations. +migrateEnsNodeSchema() + .then(startEnsDbWriterWorker) + .catch((error) => { + console.error("Failed to migrate ENSNode Schema — ", error); + process.exit(1); + }); const app = new Hono(); 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.test.ts b/apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts deleted file mode 100644 index eeb3a1e47e..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/ensdb-sdk"; -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 1d62ecb924..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/ensdb-sdk"; -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 deleted file mode 100644 index 3ab2225fd2..0000000000 --- a/apps/ensindexer/src/lib/ensdb-client/singleton.ts +++ /dev/null @@ -1,8 +0,0 @@ -import config from "@/config"; - -import { EnsDbClient } from "./ensdb-client"; - -/** - * Singleton instance of {@link EnsDbClient} for use in ENSIndexer. - */ -export const ensDbClient = new EnsDbClient(config.databaseUrl, config.databaseSchemaName); 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..232428743c 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,31 +1,64 @@ import { vi } from "vitest"; +import type { EnsDbWriter } from "@ensnode/ensdb-sdk"; import { type CrossChainIndexingStatusSnapshot, CrossChainIndexingStrategyIds, + ENSNamespaceIds, type EnsIndexerPublicConfig, + type EnsIndexerVersionInfo, + type EnsRainbowPublicConfig, 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 type { IndexingStatusBuilder } from "@/lib/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder"; +// Test fixture for EnsRainbowPublicConfig +export const mockEnsRainbowPublicConfig: EnsRainbowPublicConfig = { + version: "1.0.0", + labelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + recordsCount: 1000, +}; + +// Test fixture for EnsIndexerVersionInfo +export const mockVersionInfo: EnsIndexerVersionInfo = { + nodejs: "20.0.0", + ponder: "0.9.0", + ensDb: "1.0.0", + ensIndexer: "1.0.0", + ensNormalize: "1.10.0", +}; + +// Test fixture for EnsIndexerPublicConfig +export const mockPublicConfig: EnsIndexerPublicConfig = { + databaseSchemaName: "public", + labelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, + ensRainbowPublicConfig: mockEnsRainbowPublicConfig, + indexedChainIds: new Set([1, 8453]), + isSubgraphCompatible: true, + namespace: ENSNamespaceIds.Mainnet, + plugins: [PluginName.Subgraph], + versionInfo: mockVersionInfo, +}; + // Helper to create mock objects with consistent typing -export function createMockEnsDbClient( - overrides: Partial> = {}, -): EnsDbClient { +export function createMockEnsDbWriter( + overrides: Partial> = {}, +): EnsDbWriter { return { - ...baseEnsDbClient(), + ...baseEnsDbWriter(), ...overrides, - } as unknown as EnsDbClient; + } as unknown as EnsDbWriter; } -export function baseEnsDbClient() { +export function baseEnsDbWriter() { return { + getEnsDbVersion: vi.fn().mockResolvedValue(undefined), getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), + getIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), upsertEnsDbVersion: vi.fn().mockResolvedValue(undefined), upsertEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), upsertIndexingStatusSnapshot: vi.fn().mockResolvedValue(undefined), @@ -33,7 +66,7 @@ export function baseEnsDbClient() { } export function createMockPublicConfigBuilder( - resolvedConfig: EnsIndexerPublicConfig = ensDbClientMock.publicConfig, + resolvedConfig: EnsIndexerPublicConfig = mockPublicConfig, ): 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..0883978cd8 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,17 +6,17 @@ 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 { createMockCrossChainSnapshot, - createMockEnsDbClient, + createMockEnsDbWriter, createMockIndexingStatusBuilder, createMockOmnichainSnapshot, createMockPublicConfigBuilder, + mockPublicConfig, } from "./ensdb-writer-worker.mock"; vi.mock("@ensnode/ensnode-sdk", async () => { @@ -50,7 +50,7 @@ describe("EnsDbWriterWorker", () => { const snapshot = createMockCrossChainSnapshot({ omnichainSnapshot }); vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - const ensDbClient = createMockEnsDbClient(); + const ensDbClient = createMockEnsDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(omnichainSnapshot); @@ -61,11 +61,9 @@ describe("EnsDbWriterWorker", () => { // assert - verify initial upserts happened expect(ensDbClient.upsertEnsDbVersion).toHaveBeenCalledWith( - ensDbClientMock.publicConfig.versionInfo.ensDb, - ); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( - ensDbClientMock.publicConfig, + mockPublicConfig.versionInfo.ensDb, ); + expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(mockPublicConfig); // advance time to trigger interval await vi.advanceTimersByTimeAsync(1000); @@ -88,10 +86,10 @@ describe("EnsDbWriterWorker", () => { throw incompatibleError; }); - const ensDbClient = createMockEnsDbClient({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensDbClientMock.publicConfig), + const ensDbClient = createMockEnsDbWriter({ + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), }); - const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); + const publicConfigBuilder = createMockPublicConfigBuilder(mockPublicConfig); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); @@ -103,7 +101,7 @@ describe("EnsDbWriterWorker", () => { it("throws error when worker is already running", async () => { // arrange - const ensDbClient = createMockEnsDbClient(); + const ensDbClient = createMockEnsDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); @@ -122,7 +120,7 @@ describe("EnsDbWriterWorker", () => { it("throws error when config fetch fails", async () => { // arrange const networkError = new Error("Network failure"); - const ensDbClient = createMockEnsDbClient(); + const ensDbClient = createMockEnsDbWriter(); const publicConfigBuilder = { getPublicConfig: vi.fn().mockRejectedValue(networkError), } as unknown as PublicConfigBuilder; @@ -139,7 +137,7 @@ describe("EnsDbWriterWorker", () => { it("throws error when stored config fetch fails", async () => { // arrange const dbError = new Error("Database connection lost"); - const ensDbClient = createMockEnsDbClient({ + const ensDbClient = createMockEnsDbWriter({ getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(dbError), }); const publicConfigBuilder = createMockPublicConfigBuilder(); @@ -158,10 +156,10 @@ describe("EnsDbWriterWorker", () => { // validation passes }); - const ensDbClient = createMockEnsDbClient({ - getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(ensDbClientMock.publicConfig), + const ensDbClient = createMockEnsDbWriter({ + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockPublicConfig), }); - const publicConfigBuilder = createMockPublicConfigBuilder(ensDbClientMock.publicConfig); + const publicConfigBuilder = createMockPublicConfigBuilder(mockPublicConfig); const indexingStatusBuilder = createMockIndexingStatusBuilder(); const worker = new EnsDbWriterWorker(ensDbClient, publicConfigBuilder, indexingStatusBuilder); @@ -182,7 +180,7 @@ describe("EnsDbWriterWorker", () => { const snapshot = createMockCrossChainSnapshot(); vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(snapshot); - const ensDbClient = createMockEnsDbClient(); + const ensDbClient = createMockEnsDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); @@ -193,9 +191,7 @@ describe("EnsDbWriterWorker", () => { // assert - config should be called once (pRetry is mocked) expect(publicConfigBuilder.getPublicConfig).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith( - ensDbClientMock.publicConfig, - ); + expect(ensDbClient.upsertEnsIndexerPublicConfig).toHaveBeenCalledWith(mockPublicConfig); // cleanup worker.stop(); @@ -206,7 +202,7 @@ describe("EnsDbWriterWorker", () => { it("stops the interval when stop() is called", async () => { // arrange const upsertIndexingStatusSnapshot = vi.fn().mockResolvedValue(undefined); - const ensDbClient = createMockEnsDbClient({ upsertIndexingStatusSnapshot }); + const ensDbClient = createMockEnsDbWriter({ upsertIndexingStatusSnapshot }); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); @@ -231,7 +227,7 @@ describe("EnsDbWriterWorker", () => { describe("isRunning - worker state", () => { it("indicates isRunning status correctly", async () => { // arrange - const ensDbClient = createMockEnsDbClient(); + const ensDbClient = createMockEnsDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = createMockIndexingStatusBuilder(); @@ -271,7 +267,7 @@ describe("EnsDbWriterWorker", () => { vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); - const ensDbClient = createMockEnsDbClient(); + const ensDbClient = createMockEnsDbWriter(); const publicConfigBuilder = createMockPublicConfigBuilder(); const indexingStatusBuilder = { getOmnichainIndexingStatusSnapshot: vi @@ -321,7 +317,7 @@ describe("EnsDbWriterWorker", () => { .mockReturnValueOnce(crossChainSnapshot2) .mockReturnValueOnce(crossChainSnapshot2); - const ensDbClient = createMockEnsDbClient({ + const ensDbClient = createMockEnsDbWriter({ upsertIndexingStatusSnapshot: vi .fn() .mockResolvedValueOnce(undefined) 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..5dc5b4b1e8 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 { EnsDbWriter } from "@ensnode/ensdb-sdk"; 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,7 +38,7 @@ export class EnsDbWriterWorker { /** * ENSDb Client instance used by the worker to interact with ENSDb. */ - private ensDbClient: EnsDbClient; + private ensDbClient: EnsDbWriter; /** * Indexing Status Builder instance used by the worker to read ENSIndexer Indexing Status. @@ -51,12 +51,12 @@ export class EnsDbWriterWorker { private publicConfigBuilder: PublicConfigBuilder; /** - * @param ensDbClient ENSDb Client instance used by the worker to interact with ENSDb. + * @param ensDbClient ENSDb Writer 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. */ constructor( - ensDbClient: EnsDbClient, + ensDbClient: EnsDbWriter, publicConfigBuilder: PublicConfigBuilder, indexingStatusBuilder: IndexingStatusBuilder, ) { diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index d58ddc9e9d..48ef90dffe 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,4 +1,4 @@ -import { ensDbClient } from "@/lib/ensdb-client/singleton"; +import { ensDbClient } from "@/lib/ensdb/singleton"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; diff --git a/apps/ensindexer/src/lib/ensdb/migrate-ensnode-schema.ts b/apps/ensindexer/src/lib/ensdb/migrate-ensnode-schema.ts new file mode 100644 index 0000000000..6a3efdebb0 --- /dev/null +++ b/apps/ensindexer/src/lib/ensdb/migrate-ensnode-schema.ts @@ -0,0 +1,19 @@ +import { createRequire } from "node:module"; +import { join } from "node:path"; + +import { ensDbClient } from "./singleton"; + +// Resolve the path to the migrations directory within the ENSDb SDK package +const migrationsDirPath = join( + createRequire(import.meta.url).resolve("@ensnode/ensdb-sdk"), + "../../migrations", +); + +/** + * Execute database migrations for ENSNode Schema in ENSDb. + */ +export async function migrateEnsNodeSchema(): Promise { + console.log(`Running database migrations for ENSNode Schema in ENSDb.`); + await ensDbClient.migrateEnsNodeSchema(migrationsDirPath); + console.log(`Database migrations for ENSNode Schema in ENSDb completed successfully.`); +} diff --git a/apps/ensindexer/src/lib/ensdb/singleton.ts b/apps/ensindexer/src/lib/ensdb/singleton.ts new file mode 100644 index 0000000000..e667024d9a --- /dev/null +++ b/apps/ensindexer/src/lib/ensdb/singleton.ts @@ -0,0 +1,10 @@ +import config from "@/config"; + +import { EnsDbWriter } from "@ensnode/ensdb-sdk"; + +const { databaseUrl: ensDbConnectionString, databaseSchemaName: ensIndexerSchemaName } = config; + +/** + * Singleton instance of ENSDbWriter for the ENSIndexer application. + */ +export const ensDbClient = new EnsDbWriter(ensDbConnectionString, ensIndexerSchemaName); diff --git a/apps/ensindexer/src/lib/ensindexer-client/singleton.ts b/apps/ensindexer/src/lib/ensindexer-client/singleton.ts deleted file mode 100644 index 47c09b9ffc..0000000000 --- a/apps/ensindexer/src/lib/ensindexer-client/singleton.ts +++ /dev/null @@ -1,5 +0,0 @@ -import config from "@/config"; - -import { EnsIndexerClient } from "@ensnode/ensnode-sdk"; - -export const ensIndexerClient = new EnsIndexerClient({ url: config.ensIndexerUrl }); diff --git a/apps/ensindexer/src/lib/public-config-builder/public-config-builder.test.ts b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.test.ts index a1e8b7ac24..57c8b9f225 100644 --- a/apps/ensindexer/src/lib/public-config-builder/public-config-builder.test.ts +++ b/apps/ensindexer/src/lib/public-config-builder/public-config-builder.test.ts @@ -58,7 +58,7 @@ const mockEnsRainbowConfig: EnsRainbowPublicConfig = { }; const mockVersionInfo: EnsIndexerVersionInfo = { - nodejs: "v20.0.0", + nodejs: "20.0.0", ponder: "0.9.0", ensDb: "1.0.0", ensIndexer: "1.0.0", @@ -83,7 +83,7 @@ function createMockPublicConfig(overrides: Partial = {}) // Helper to setup standard mocks function setupStandardMocks() { vi.mocked(getEnsIndexerVersion).mockReturnValue("1.0.0"); - vi.mocked(getNodeJsVersion).mockReturnValue("v20.0.0"); + vi.mocked(getNodeJsVersion).mockReturnValue("20.0.0"); vi.mocked(getPackageVersion).mockReturnValue("0.9.0"); vi.mocked(validateEnsIndexerVersionInfo).mockReturnValue(mockVersionInfo); } @@ -117,7 +117,7 @@ describe("PublicConfigBuilder", () => { expect(getPackageVersion).toHaveBeenCalledWith("@adraffy/ens-normalize"); expect(validateEnsIndexerVersionInfo).toHaveBeenCalledWith({ - nodejs: "v20.0.0", + nodejs: "20.0.0", ponder: "0.9.0", ensDb: "1.0.0", ensIndexer: "1.0.0", @@ -183,11 +183,11 @@ describe("PublicConfigBuilder", () => { } as unknown as EnsRainbowApiClient; vi.mocked(getEnsIndexerVersion).mockReturnValue("2.0.0"); - vi.mocked(getNodeJsVersion).mockReturnValue("v22.0.0"); + vi.mocked(getNodeJsVersion).mockReturnValue("22.0.0"); vi.mocked(getPackageVersion).mockReturnValue("1.0.0"); const customVersionInfo: EnsIndexerVersionInfo = { - nodejs: "v22.0.0", + nodejs: "22.0.0", ponder: "1.0.0", ensDb: "2.0.0", ensIndexer: "2.0.0", diff --git a/package.json b/package.json index d8f2bfd90e..3ace6b00da 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,9 @@ "undici@>=7.0.0 <7.24.0": "^7.24.0", "undici@>=6.0.0 <6.24.0": "^6.24.0", "yauzl@<3.2.1": "^3.2.1", - "fast-xml-parser@>=5.0.0 <=5.5.5": ">=5.5.6", - "kysely@>=0.26.0 <0.28.12": "^0.28.12", - "h3@<1.15.6": "^1.15.6" + "fast-xml-parser@>=5.0.0 <5.5.7": ">=5.5.7", + "kysely@>=0.26.0 <0.28.14": ">=0.28.14", + "h3@<1.15.9": ">=1.15.9" }, "ignoredBuiltDependencies": [ "bun" diff --git a/packages/ensdb-sdk/package.json b/packages/ensdb-sdk/package.json index 8626a194c8..1d9af5bfe4 100644 --- a/packages/ensdb-sdk/package.json +++ b/packages/ensdb-sdk/package.json @@ -17,7 +17,7 @@ ], "exports": { ".": "./src/index.ts", - "./ensindexer": "./src/ensindexer/index.ts", + "./ensindexer-abstract": "./src/ensindexer-abstract/index.ts", "./ensnode": "./src/ensnode/index.ts" }, "files": [ @@ -33,10 +33,10 @@ "default": "./dist/index.js" } }, - "./ensindexer": { + "./ensindexer-abstract": { "import": { - "types": "./dist/ensindexer/index.d.ts", - "default": "./dist/ensindexer/index.js" + "types": "./dist/ensindexer-abstract/index.d.ts", + "default": "./dist/ensindexer-abstract/index.js" } }, "./ensnode": { @@ -54,21 +54,25 @@ "prepublish": "tsup", "lint": "biome check --write .", "lint:ci": "biome ci", + "test": "vitest", + "typecheck": "tsgo --noEmit", "drizzle-kit:generate": "drizzle-kit generate" }, "peerDependencies": { + "@ensnode/ensnode-sdk": "workspace:*", "drizzle-orm": "catalog:", "ponder": "catalog:", "viem": "catalog:" }, "devDependencies": { - "@ensnode/ensnode-sdk": "workspace:", + "@ensnode/ensnode-sdk": "workspace:*", "@ensnode/shared-configs": "workspace:*", "drizzle-kit": "0.31.10", "drizzle-orm": "catalog:", "ponder": "catalog:", "tsup": "catalog:", "typescript": "catalog:", - "viem": "catalog:" + "viem": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts b/packages/ensdb-sdk/src/client/ensdb-client.mock.ts similarity index 90% rename from apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts rename to packages/ensdb-sdk/src/client/ensdb-client.mock.ts index 7387cc19fd..7cb2c59d0b 100644 --- a/apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts +++ b/packages/ensdb-sdk/src/client/ensdb-client.mock.ts @@ -19,12 +19,12 @@ export const laterBlockRef = { number: 1025, } as const satisfies BlockRef; -export const databaseUrl = "postgres://user:pass@localhost:5432/ensdb"; +export const ensDbUrl = "postgres://user:pass@localhost:5432/ensdb"; -export const databaseSchemaName = "public"; +export const ensIndexerSchemaName = "ensindexer_0"; export const publicConfig = { - databaseSchemaName, + databaseSchemaName: ensIndexerSchemaName, ensRainbowPublicConfig: { version: "0.32.0", labelSet: { @@ -42,7 +42,7 @@ export const publicConfig = { namespace: "mainnet", plugins: [PluginName.Subgraph], versionInfo: { - nodejs: "v22.10.12", + nodejs: "22.10.12", ponder: "0.11.25", ensDb: "0.32.0", ensIndexer: "0.32.0", diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts new file mode 100644 index 0000000000..3a488c4a86 --- /dev/null +++ b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + deserializeCrossChainIndexingStatusSnapshot, + serializeEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import * as ensDbClientMock from "./ensdb-client.mock"; +import { EnsDbReader } from "./ensdb-reader"; + +const whereMock = vi.fn(async () => [] as Array<{ value: unknown }>); +const fromMock = vi.fn(() => ({ where: whereMock })); +const selectMock = vi.fn(() => ({ from: fromMock })); +const drizzleClientMock = { select: selectMock } as any; + +vi.mock("drizzle-orm/node-postgres", () => ({ + drizzle: vi.fn(() => drizzleClientMock), +})); + +describe("EnsDbReader", () => { + const selectResult = { current: [] as Array<{ value: unknown }> }; + whereMock.mockImplementation(async () => selectResult.current); + + const createEnsDbReader = () => + new EnsDbReader(ensDbClientMock.ensDbUrl, ensDbClientMock.ensIndexerSchemaName); + + beforeEach(() => { + selectResult.current = []; + whereMock.mockClear(); + fromMock.mockClear(); + selectMock.mockClear(); + }); + + describe("getEnsDbVersion", () => { + it("returns undefined when no record exists", async () => { + const ensDbClient = createEnsDbReader(); + const { ensNodeSchema } = ensDbClient; + + await expect(ensDbClient.getEnsDbVersion()).resolves.toBeUndefined(); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(fromMock).toHaveBeenCalledWith(ensNodeSchema.metadata); + }); + + it("returns value when one record exists", async () => { + selectResult.current = [{ value: "0.1.0" }]; + + await expect(createEnsDbReader().getEnsDbVersion()).resolves.toBe("0.1.0"); + }); + + // This scenario should be impossible due to the primary key constraint on + // the ('ensIndexerSchemaName', 'key') columns of the 'ensnode_metadata' table. + it("throws when multiple records exist", async () => { + selectResult.current = [{ value: "0.1.0" }, { value: "0.1.1" }]; + + await expect(createEnsDbReader().getEnsDbVersion()).rejects.toThrowError(/ensdb_version/i); + }); + }); + + describe("getEnsIndexerPublicConfig", () => { + it("returns undefined when no record exists", async () => { + await expect(createEnsDbReader().getEnsIndexerPublicConfig()).resolves.toBeUndefined(); + }); + + it("deserializes the stored config", async () => { + const serializedConfig = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); + selectResult.current = [{ value: serializedConfig }]; + + await expect(createEnsDbReader().getEnsIndexerPublicConfig()).resolves.toStrictEqual( + ensDbClientMock.publicConfig, + ); + }); + }); + + describe("getIndexingStatusSnapshot", () => { + it("deserializes the stored indexing status snapshot", async () => { + selectResult.current = [{ value: ensDbClientMock.serializedSnapshot }]; + + const expected = deserializeCrossChainIndexingStatusSnapshot( + ensDbClientMock.serializedSnapshot, + ); + + await expect(createEnsDbReader().getIndexingStatusSnapshot()).resolves.toStrictEqual( + expected, + ); + }); + }); +}); diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.ts b/packages/ensdb-sdk/src/client/ensdb-reader.ts new file mode 100644 index 0000000000..0eb3b18221 --- /dev/null +++ b/packages/ensdb-sdk/src/client/ensdb-reader.ts @@ -0,0 +1,197 @@ +import { and, eq } from "drizzle-orm/sql"; + +import { + type CrossChainIndexingStatusSnapshot, + deserializeCrossChainIndexingStatusSnapshot, + deserializeEnsIndexerPublicConfig, + type EnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import { + type AbstractEnsIndexerSchema, + buildEnsDbDrizzleClient, + buildIndividualEnsDbSchemas, + type EnsDbDrizzleClient, + type EnsNodeSchema, +} from "../lib/drizzle"; +import { EnsNodeMetadataKeys } from "./ensnode-metadata"; +import type { + SerializedEnsNodeMetadata, + SerializedEnsNodeMetadataEnsDbVersion, + SerializedEnsNodeMetadataEnsIndexerIndexingStatus, + SerializedEnsNodeMetadataEnsIndexerPublicConfig, +} from "./serialize/ensnode-metadata"; + +/** + * ENSDb Reader + * + * Enables read-only querying of an ENSDb instance, including data spanning + * the ENSNode Schema and the specified ENSIndexer Schema. + * + * Note: we use a parameter type `ConcreteEnsIndexerSchema` to represent + * the "concrete" ENSIndexer Schema type within the ENSDb Schema and + * make sure that the Drizzle client used for querying is typed with + * the same "concrete" ENSIndexer Schema type. + */ +export class EnsDbReader< + ConcreteEnsIndexerSchema extends AbstractEnsIndexerSchema = AbstractEnsIndexerSchema, +> { + /** + * Drizzle client for ENSDb. + * + * Uses the ENSDb Schema from {@link ensDbSchema}. + */ + protected drizzleClient: EnsDbDrizzleClient; + + /** + * "Concrete" ENSIndexer Schema definition for ENSDb. + * + * This is the "concrete" ENSIndexer Schema in which tables reference + * the ENSIndexer Schema name from {@link ensIndexerSchemaName}. + */ + protected _concreteEnsIndexerSchema: ConcreteEnsIndexerSchema; + + /** + * The name of the ENSIndexer schema to read from in ENSDb. + * + * This also identifies which ENSNode metadata records to read from the ENSNode Schema + * as the ENSNode Schema is multi-tenant across ENSIndexer instances / ENSIndexer Schemas in an ENSDb. + */ + protected ensIndexerSchemaName: string; + + protected _ensNodeSchema: EnsNodeSchema; + + /** + * @param ensDbConnectionString The connection string for Drizzle to connect to the ENSDb instance. + * @param ensIndexerSchemaName The name of the ENSIndexer schema to read from in ENSDb, used to identify which ENSNode metadata records to read. + */ + constructor(ensDbConnectionString: string, ensIndexerSchemaName: string) { + const { concreteEnsIndexerSchema, ensNodeSchema } = + buildIndividualEnsDbSchemas(ensIndexerSchemaName); + const ensDbDrizzleClient = buildEnsDbDrizzleClient( + ensDbConnectionString, + concreteEnsIndexerSchema, + ); + this.drizzleClient = ensDbDrizzleClient; + this._concreteEnsIndexerSchema = concreteEnsIndexerSchema; + this.ensIndexerSchemaName = ensIndexerSchemaName; + this._ensNodeSchema = ensNodeSchema; + } + + /** + * Getter for the Drizzle client for ENSDb instance + * + * Useful while working on complex queries for ENSDb. + */ + get client(): EnsDbDrizzleClient { + return this.drizzleClient; + } + + /** + * Getter for the "concrete" ENSIndexer Schema definition used in the Drizzle client + * for ENSDb instance. + * + * Useful while working on complex queries for ENSDb. + * + * Note: using `ensIndexerSchema` name for this getter to make it read better + * in the context of query building. For example: + * `this.client.select().from(this.ensIndexerSchema.event)` vs. + * `this.client.select().from(this.concreteEnsIndexerSchema.event)`. + */ + get ensIndexerSchema(): ConcreteEnsIndexerSchema { + return this._concreteEnsIndexerSchema; + } + + /** + * Getter for the ENSNode Schema definition used in the Drizzle client + * for ENSDb instance. + * + * Useful while working on complex queries for ENSDb. + */ + get ensNodeSchema(): EnsNodeSchema { + return this._ensNodeSchema; + } + + /** + * Get ENSDb Version + * + * @returns the existing record, or `undefined`. + */ + async getEnsDbVersion(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsDbVersion, + }); + + return record; + } + + /** + * Get ENSIndexer Public Config + * + * @returns the existing record, or `undefined`. + */ + async getEnsIndexerPublicConfig(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, + }); + + if (!record) { + return undefined; + } + + return deserializeEnsIndexerPublicConfig(record); + } + + /** + * Get Indexing Status Snapshot + * + * @returns the existing record, or `undefined`. + */ + 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 composite PK constraint on + * 'ensIndexerSchemaName' and 'key') + */ + private async getEnsNodeMetadata( + metadata: Pick, + ): Promise { + const result = await this.drizzleClient + .select() + .from(this.ensNodeSchema.metadata) + .where( + and( + eq(this.ensNodeSchema.metadata.ensIndexerSchemaName, this.ensIndexerSchemaName), + eq(this.ensNodeSchema.metadata.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 ('${this.ensIndexerSchemaName}', '${metadata.key}') composite key`, + ); + } +} diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.test.ts b/packages/ensdb-sdk/src/client/ensdb-writer.test.ts new file mode 100644 index 0000000000..51e7ccf754 --- /dev/null +++ b/packages/ensdb-sdk/src/client/ensdb-writer.test.ts @@ -0,0 +1,107 @@ +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + deserializeCrossChainIndexingStatusSnapshot, + serializeCrossChainIndexingStatusSnapshot, + serializeEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import * as ensDbClientMock from "./ensdb-client.mock"; +import { EnsDbWriter } from "./ensdb-writer"; +import { EnsNodeMetadataKeys } from "./ensnode-metadata"; + +const onConflictDoUpdateMock = vi.fn(async () => undefined); +const valuesMock = vi.fn(() => ({ onConflictDoUpdate: onConflictDoUpdateMock })); +const insertMock = vi.fn(() => ({ values: valuesMock })); +const drizzleClientMock = { insert: insertMock } as any; + +vi.mock("drizzle-orm/node-postgres", () => ({ + drizzle: vi.fn(() => drizzleClientMock), +})); +vi.mock("drizzle-orm/node-postgres/migrator", () => ({ migrate: vi.fn() })); + +describe("EnsDbWriter", () => { + const createEnsDbWriter = () => + new EnsDbWriter(ensDbClientMock.ensDbUrl, ensDbClientMock.ensIndexerSchemaName); + + beforeEach(() => { + onConflictDoUpdateMock.mockClear(); + valuesMock.mockClear(); + insertMock.mockClear(); + vi.mocked(migrate).mockClear(); + }); + + describe("upsertEnsDbVersion", () => { + it("writes the database version metadata", async () => { + const ensDbClient = createEnsDbWriter(); + const { ensNodeSchema } = ensDbClient; + + await ensDbClient.upsertEnsDbVersion("0.2.0"); + + expect(insertMock).toHaveBeenCalledWith(ensNodeSchema.metadata); + expect(valuesMock).toHaveBeenCalledWith({ + ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, + key: EnsNodeMetadataKeys.EnsDbVersion, + value: "0.2.0", + }); + expect(onConflictDoUpdateMock).toHaveBeenCalledWith({ + target: [ensNodeSchema.metadata.ensIndexerSchemaName, ensNodeSchema.metadata.key], + set: { value: "0.2.0" }, + }); + }); + }); + + describe("upsertEnsIndexerPublicConfig", () => { + it("serializes and writes the public config", async () => { + const expectedValue = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); + + await createEnsDbWriter().upsertEnsIndexerPublicConfig(ensDbClientMock.publicConfig); + + expect(valuesMock).toHaveBeenCalledWith({ + ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, + key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, + value: expectedValue, + }); + }); + }); + + describe("upsertIndexingStatusSnapshot", () => { + it("serializes and writes the indexing status snapshot", async () => { + const snapshot = deserializeCrossChainIndexingStatusSnapshot( + ensDbClientMock.serializedSnapshot, + ); + const expectedValue = serializeCrossChainIndexingStatusSnapshot(snapshot); + + await createEnsDbWriter().upsertIndexingStatusSnapshot(snapshot); + + expect(valuesMock).toHaveBeenCalledWith({ + ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, + key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, + value: expectedValue, + }); + }); + }); + + describe("migrateEnsNodeSchema", () => { + it("calls drizzle-orm migrateEnsNodeSchema with the correct parameters", async () => { + const migrationsDirPath = "/path/to/migrations"; + + await createEnsDbWriter().migrateEnsNodeSchema(migrationsDirPath); + + expect(vi.mocked(migrate)).toHaveBeenCalledWith(drizzleClientMock, { + migrationsFolder: migrationsDirPath, + migrationsSchema: "ensnode", + }); + }); + + it("propagates errors from the migrateEnsNodeSchema function", async () => { + const migrationsDirPath = "/path/to/migrations"; + vi.mocked(migrate).mockRejectedValueOnce(new Error("Migration failed")); + + await expect(createEnsDbWriter().migrateEnsNodeSchema(migrationsDirPath)).rejects.toThrow( + "Migration failed", + ); + }); + }); +}); diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.ts b/packages/ensdb-sdk/src/client/ensdb-writer.ts new file mode 100644 index 0000000000..725e9a6190 --- /dev/null +++ b/packages/ensdb-sdk/src/client/ensdb-writer.ts @@ -0,0 +1,94 @@ +import { migrate } from "drizzle-orm/node-postgres/migrator"; + +import { + type CrossChainIndexingStatusSnapshot, + type EnsIndexerPublicConfig, + serializeCrossChainIndexingStatusSnapshot, + serializeEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import { EnsDbReader } from "./ensdb-reader"; +import { EnsNodeMetadataKeys } from "./ensnode-metadata"; +import type { SerializedEnsNodeMetadata } from "./serialize/ensnode-metadata"; + +/** + * ENSDb Writer + * + * Allows updating an ENSDb instance, including: + * - executing database migrations for ENSNode Schema, + * - updating ENSNode Metadata records in ENSDb for the given ENSIndexer instance. + */ +export class EnsDbWriter extends EnsDbReader { + /** + * 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. + */ + async migrateEnsNodeSchema(migrationsDirPath: string): Promise { + return migrate(this.drizzleClient, { + migrationsFolder: migrationsDirPath, + migrationsSchema: "ensnode", + }); + } + + /** + * Upsert ENSDb Version + * + * @throws when upsert operation failed. + */ + async upsertEnsDbVersion(ensDbVersion: string): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsDbVersion, + value: ensDbVersion, + }); + } + + /** + * Upsert ENSIndexer Public Config + * + * @throws when upsert operation failed. + */ + async upsertEnsIndexerPublicConfig( + ensIndexerPublicConfig: EnsIndexerPublicConfig, + ): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, + value: serializeEnsIndexerPublicConfig(ensIndexerPublicConfig), + }); + } + + /** + * Upsert Indexing Status Snapshot + * + * @throws when upsert operation failed. + */ + 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(metadata: SerializedEnsNodeMetadata): Promise { + await this.drizzleClient + .insert(this.ensNodeSchema.metadata) + .values({ + ensIndexerSchemaName: this.ensIndexerSchemaName, + key: metadata.key, + value: metadata.value, + }) + .onConflictDoUpdate({ + target: [this.ensNodeSchema.metadata.ensIndexerSchemaName, this.ensNodeSchema.metadata.key], + set: { value: metadata.value }, + }); + } +} diff --git a/packages/ensnode-sdk/src/ensdb/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/ensnode-metadata.ts similarity index 85% rename from packages/ensnode-sdk/src/ensdb/ensnode-metadata.ts rename to packages/ensdb-sdk/src/client/ensnode-metadata.ts index 15c9bfefc3..bdb35c4069 100644 --- a/packages/ensnode-sdk/src/ensdb/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/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/ensdb-sdk/src/client/index.ts b/packages/ensdb-sdk/src/client/index.ts new file mode 100644 index 0000000000..f9b6c416af --- /dev/null +++ b/packages/ensdb-sdk/src/client/index.ts @@ -0,0 +1,3 @@ +export * from "./ensdb-reader"; +export * from "./ensdb-writer"; +export * from "./ensnode-metadata"; diff --git a/packages/ensnode-sdk/src/ensdb/serialize/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts similarity index 84% rename from packages/ensnode-sdk/src/ensdb/serialize/ensnode-metadata.ts rename to packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts index d74b71abcb..cae7fcdd34 100644 --- a/packages/ensnode-sdk/src/ensdb/serialize/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/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/ensdb-sdk/src/ensindexer/ensnode-metadata.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensnode-metadata.schema.ts similarity index 100% rename from packages/ensdb-sdk/src/ensindexer/ensnode-metadata.schema.ts rename to packages/ensdb-sdk/src/ensindexer-abstract/ensnode-metadata.schema.ts diff --git a/packages/ensdb-sdk/src/ensindexer/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts similarity index 100% rename from packages/ensdb-sdk/src/ensindexer/ensv2.schema.ts rename to packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts diff --git a/packages/ensdb-sdk/src/ensindexer/index.ts b/packages/ensdb-sdk/src/ensindexer-abstract/index.ts similarity index 58% rename from packages/ensdb-sdk/src/ensindexer/index.ts rename to packages/ensdb-sdk/src/ensindexer-abstract/index.ts index b245eab10f..438cbccf97 100644 --- a/packages/ensdb-sdk/src/ensindexer/index.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/index.ts @@ -1,5 +1,7 @@ /** - * Merge the various sub-schemas into ENSIndexer Schema. + * Merge the various sub-schemas into an "abstract" ENSIndexer Schema. + * This "abstract" ENSIndexer Schema is used to build the "concrete" ENSIndexer Schema + * for ENSDb, which is then used to build the ENSDb Schema for a Drizzle client for ENSDb. */ // TODO: remove `ensnode-metadata.schema` export when database migrations diff --git a/packages/ensdb-sdk/src/ensindexer/protocol-acceleration.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts similarity index 100% rename from packages/ensdb-sdk/src/ensindexer/protocol-acceleration.schema.ts rename to packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts diff --git a/packages/ensdb-sdk/src/ensindexer/registrars.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/registrars.schema.ts similarity index 100% rename from packages/ensdb-sdk/src/ensindexer/registrars.schema.ts rename to packages/ensdb-sdk/src/ensindexer-abstract/registrars.schema.ts diff --git a/packages/ensdb-sdk/src/ensindexer/subgraph.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/subgraph.schema.ts similarity index 100% rename from packages/ensdb-sdk/src/ensindexer/subgraph.schema.ts rename to packages/ensdb-sdk/src/ensindexer-abstract/subgraph.schema.ts diff --git a/packages/ensdb-sdk/src/ensindexer/tokenscope.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/tokenscope.schema.ts similarity index 100% rename from packages/ensdb-sdk/src/ensindexer/tokenscope.schema.ts rename to packages/ensdb-sdk/src/ensindexer-abstract/tokenscope.schema.ts diff --git a/packages/ensdb-sdk/src/index.ts b/packages/ensdb-sdk/src/index.ts index aba35b8105..0443c6f825 100644 --- a/packages/ensdb-sdk/src/index.ts +++ b/packages/ensdb-sdk/src/index.ts @@ -1 +1,2 @@ -export * from "./ensindexer"; +export * from "./client"; +export * from "./ensindexer-abstract"; diff --git a/packages/ensdb-sdk/src/lib/drizzle.test.ts b/packages/ensdb-sdk/src/lib/drizzle.test.ts new file mode 100644 index 0000000000..eecd357693 --- /dev/null +++ b/packages/ensdb-sdk/src/lib/drizzle.test.ts @@ -0,0 +1,240 @@ +import { isPgEnum } from "drizzle-orm/pg-core"; +import { isTable } from "drizzle-orm/table"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import * as abstractEnsIndexerSchema from "../ensindexer-abstract"; +import { buildEnsDbDrizzleClient, buildIndividualEnsDbSchemas } from "./drizzle"; + +vi.mock("drizzle-orm/node-postgres", () => ({ + drizzle: vi.fn(() => "mock-drizzle-client"), +})); + +// Re-import after mock to get the mocked version +const { drizzle } = await import("drizzle-orm/node-postgres"); +const drizzleMock = vi.mocked(drizzle); + +const ENSINDEXER_SCHEMA_NAME = "ensindexer_test"; + +const DrizzleSchemaSymbol = Symbol.for("drizzle:Schema"); + +function getSchemaName(obj: unknown): string | undefined { + return (obj as any)[DrizzleSchemaSymbol]; +} + +function getCombinedSchema(schemaName: string) { + drizzleMock.mockClear(); + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(schemaName); + buildEnsDbDrizzleClient("postgres://localhost/test", concreteEnsIndexerSchema); + const callArgs = drizzleMock.mock.calls[0][0] as { schema: Record }; + return callArgs.schema; +} + +describe("buildIndividualEnsDbSchemas", () => { + it("returns a concreteEnsIndexerSchema containing all ENSIndexer schema exports", () => { + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + + expect(concreteEnsIndexerSchema.event).toBeDefined(); + expect(concreteEnsIndexerSchema.v1Domain).toBeDefined(); + expect(concreteEnsIndexerSchema.registration).toBeDefined(); + expect(concreteEnsIndexerSchema.registrationType).toBeDefined(); + }); + + it("returns an ensNodeSchema containing metadata", () => { + const { ensNodeSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + + expect(ensNodeSchema.metadata).toBeDefined(); + }); + + it("preserves table/enum classification across abstract → concrete", () => { + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + + for (const [key, abstractValue] of Object.entries(abstractEnsIndexerSchema)) { + const concreteValue = concreteEnsIndexerSchema[key as keyof typeof concreteEnsIndexerSchema]; + + if (isTable(abstractValue)) { + expect(isTable(concreteValue)).toBe(true); + } else { + expect(isTable(concreteValue)).toBe(false); + } + + if (isPgEnum(abstractValue)) { + expect(isPgEnum(concreteValue)).toBe(true); + } else { + expect(isPgEnum(concreteValue)).toBe(false); + } + } + }); + + it("sets the schema name on all ENSIndexer tables", () => { + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + + for (const [key, abstractValue] of Object.entries(abstractEnsIndexerSchema)) { + if (!isTable(abstractValue)) continue; + const concreteValue = concreteEnsIndexerSchema[key as keyof typeof concreteEnsIndexerSchema]; + expect(isTable(concreteValue)).toBe(true); + expect(getSchemaName(concreteValue)).toBe(ENSINDEXER_SCHEMA_NAME); + } + }); + + it("sets the schema name on all ENSIndexer enums", () => { + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + + for (const [key, abstractValue] of Object.entries(abstractEnsIndexerSchema)) { + if (!isPgEnum(abstractValue)) continue; + const concreteValue = concreteEnsIndexerSchema[key as keyof typeof concreteEnsIndexerSchema]; + expect(isPgEnum(concreteValue)).toBe(true); + expect((concreteValue as any).schema).toBe(ENSINDEXER_SCHEMA_NAME); + } + }); + + it("skips relation objects (neither tables nor enums)", () => { + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + + for (const [key, value] of Object.entries(concreteEnsIndexerSchema)) { + if (key.endsWith("_relations") || key.endsWith("Relations")) { + expect(isTable(value)).toBe(false); + expect(isPgEnum(value)).toBe(false); + } + } + }); + + it("applies a different schema name to ENSIndexer objects", () => { + const otherSchemaName = "ensindexer_other"; + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(otherSchemaName); + + for (const [key, abstractValue] of Object.entries(abstractEnsIndexerSchema)) { + if (!isTable(abstractValue)) continue; + const concreteValue = concreteEnsIndexerSchema[key as keyof typeof concreteEnsIndexerSchema]; + expect(isTable(concreteValue)).toBe(true); + expect(getSchemaName(concreteValue)).toBe(otherSchemaName); + } + }); + + it("builds two concrete schemas with respective names, leaving abstract unaffected", () => { + const schemaNameA = "ensindexer_alpha"; + const schemaNameB = "ensindexer_beta"; + + const { concreteEnsIndexerSchema: concreteA } = buildIndividualEnsDbSchemas(schemaNameA); + const { concreteEnsIndexerSchema: concreteB } = buildIndividualEnsDbSchemas(schemaNameB); + + for (const [key, abstractValue] of Object.entries(abstractEnsIndexerSchema)) { + const valueA = concreteA[key as keyof typeof concreteA]; + const valueB = concreteB[key as keyof typeof concreteB]; + + if (isTable(abstractValue)) { + expect(isTable(valueA)).toBe(true); + expect(isTable(valueB)).toBe(true); + expect(getSchemaName(valueA)).toBe(schemaNameA); + expect(getSchemaName(valueB)).toBe(schemaNameB); + expect(getSchemaName(abstractValue)).toBeUndefined(); + } + + if (isPgEnum(abstractValue)) { + expect(isPgEnum(valueA)).toBe(true); + expect(isPgEnum(valueB)).toBe(true); + expect((valueA as any).schema).toBe(schemaNameA); + expect((valueB as any).schema).toBe(schemaNameB); + expect((abstractValue as any).schema).toBeUndefined(); + } + } + }); +}); + +describe("combined schema (via buildEnsDbDrizzleClient)", () => { + it("contains all ENSNode schema exports (metadata)", () => { + const schema = getCombinedSchema(ENSINDEXER_SCHEMA_NAME); + + expect(schema.metadata).toBeDefined(); + }); + + it("does not mutate the schema name on ENSNode tables", () => { + const schema = getCombinedSchema(ENSINDEXER_SCHEMA_NAME); + + expect(getSchemaName(schema.metadata)).toBe("ensnode"); + }); + + it("contains all ENSIndexer schema exports", () => { + const schema = getCombinedSchema(ENSINDEXER_SCHEMA_NAME); + + for (const [key, abstractValue] of Object.entries(abstractEnsIndexerSchema)) { + const concreteValue = schema[key as keyof typeof schema]; + if (isTable(abstractValue)) { + expect(isTable(concreteValue)).toBe(true); + } else if (isPgEnum(abstractValue)) { + expect(isPgEnum(concreteValue)).toBe(true); + } + } + }); + + it("ensures ensnode metadata schema is consistent across multiple concrete schemas", () => { + const schemaA = getCombinedSchema("ensindexer_alpha"); + const schemaB = getCombinedSchema("ensindexer_beta"); + + expect(getSchemaName(schemaA.metadata)).toBe("ensnode"); + expect(getSchemaName(schemaB.metadata)).toBe("ensnode"); + }); +}); + +describe("concrete tables — prototype and Symbol preservation", () => { + const IsDrizzleTable = Symbol.for("drizzle:IsDrizzleTable"); + const Columns = Symbol.for("drizzle:Columns"); + const TableName = Symbol.for("drizzle:Name"); + + it("preserves the Table prototype on cloned tables", () => { + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + const abstractTable = abstractEnsIndexerSchema.v1Domain; + const concreteTable = concreteEnsIndexerSchema.v1Domain; + + expect(Object.getPrototypeOf(concreteTable)).toBe(Object.getPrototypeOf(abstractTable)); + }); + + it("preserves Symbol-keyed properties (IsDrizzleTable, Columns, TableName) on cloned tables", () => { + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + const abstractTable = abstractEnsIndexerSchema.v1Domain; + const concreteTable = concreteEnsIndexerSchema.v1Domain; + + expect((concreteTable as any)[IsDrizzleTable]).toBe((abstractTable as any)[IsDrizzleTable]); + expect((concreteTable as any)[Columns]).toBe((abstractTable as any)[Columns]); + expect((concreteTable as any)[TableName]).toBe((abstractTable as any)[TableName]); + }); + + it("isTable() returns true for cloned concrete tables", () => { + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + + expect(isTable(concreteEnsIndexerSchema.v1Domain)).toBe(true); + expect(isTable(concreteEnsIndexerSchema.registration)).toBe(true); + expect(isTable(concreteEnsIndexerSchema.event)).toBe(true); + }); +}); + +describe("buildEnsDbDrizzleClient", () => { + beforeEach(() => { + drizzleMock.mockClear(); + }); + + it("calls drizzle with the correct connection config", () => { + const connectionString = "postgres://user:pass@localhost:5432/ensdb"; + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + + buildEnsDbDrizzleClient(connectionString, concreteEnsIndexerSchema); + + expect(drizzle).toHaveBeenCalledWith({ + connection: connectionString, + schema: expect.objectContaining({ + metadata: expect.anything(), + }), + casing: "snake_case", + logger: undefined, + }); + }); + + it("passes the logger to drizzle when provided", () => { + const connectionString = "postgres://user:pass@localhost:5432/ensdb"; + const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME); + const logger = { logQuery: vi.fn() }; + + buildEnsDbDrizzleClient(connectionString, concreteEnsIndexerSchema, logger); + + expect(drizzle).toHaveBeenCalledWith(expect.objectContaining({ logger })); + }); +}); diff --git a/packages/ensdb-sdk/src/lib/drizzle.ts b/packages/ensdb-sdk/src/lib/drizzle.ts new file mode 100644 index 0000000000..47886d95b1 --- /dev/null +++ b/packages/ensdb-sdk/src/lib/drizzle.ts @@ -0,0 +1,185 @@ +/** + * Utilities for Drizzle ORM integration with ENSDb. + */ +import type { Logger as DrizzleLogger } from "drizzle-orm/logger"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { isPgEnum } from "drizzle-orm/pg-core"; +import { isTable, Table } from "drizzle-orm/table"; + +// Import the "abstract" ENSIndexer Schema. +// It's called "abstract" here because tables defined in this schema do not +// reference the specific ENSIndexer Schema name, and therefore cannot be used +// directly to build a Drizzle client for ENSDb. +import * as abstractEnsIndexerSchema from "../ensindexer-abstract"; +import * as ensNodeSchema from "../ensnode"; + +/** + * Abstract ENSIndexer Schema + * + * Represents the "abstract" ENSIndexer Schema definition, where tables do not reference + * the specific ENSIndexer Schema name. + */ +export type AbstractEnsIndexerSchema = typeof abstractEnsIndexerSchema; + +/** + * Clone a Drizzle Table object with a new schema name. + * + * Drizzle tables store their identity (name, columns, schema) on + * Symbol-keyed properties. Cloning a table requires creating + * a new object with the same prototype, copying all properties, + * and updating the schema name. + */ +function cloneTableWithSchema( + table: TableType, + schemaName: string, +): TableType { + const clone = Object.create( + Object.getPrototypeOf(table), + Object.getOwnPropertyDescriptors(table), + ) as TableType; + + // @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. + clone[Table.Symbol.Schema] = schemaName; + + // Fail-fast if the clone lost the Drizzle sentinel. + if (!isTable(clone)) { + throw new Error(`Cloned table is no longer a valid Drizzle Table (schema: ${schemaName}).`); + } + + return clone; +} + +/** + * Build a "concrete" ENSIndexer Schema definition for ENSDb. + * + * This function uses the "abstract" ENSIndexer Schema definition + * to create a "concrete" ENSIndexer Schema definition referencing the provided + * ENSIndexer Schema name. The "concrete" ENSIndexer Schema definition can then + * be used to build the ENSDb Schema for a Drizzle client for ENSDb. + * + * @param ensIndexerSchemaName - The name of the ENSIndexer Schema instance in ENSDb. + * + * Note: this function is a replacement for `setDatabaseSchema` from `@ponder/client`. + */ +function buildConcreteEnsIndexerSchema( + ensIndexerSchemaName: string, +): ConcreteEnsIndexerSchema { + const ensIndexerSchema = {} as ConcreteEnsIndexerSchema; + + for (const [key, abstractSchemaObject] of Object.entries(abstractEnsIndexerSchema)) { + if (isTable(abstractSchemaObject)) { + (ensIndexerSchema as any)[key] = cloneTableWithSchema( + abstractSchemaObject, + ensIndexerSchemaName, + ); + } else if (isPgEnum(abstractSchemaObject)) { + // Enums are functions; clone by copying properties onto a new function. + // Unlike tables, enums don't rely on prototype identity, so + // Object.assign is sufficient here. + const concreteSchemaObject = Object.assign( + (...args: any[]) => abstractSchemaObject(...args), + abstractSchemaObject, + ); + // @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. + concreteSchemaObject.schema = ensIndexerSchemaName; + (ensIndexerSchema as any)[key] = concreteSchemaObject; + } else { + (ensIndexerSchema as any)[key] = abstractSchemaObject; + } + } + + return ensIndexerSchema; +} + +/** + * ENSNode Schema + * + * Represents the ENSNode Schema definition for ENSDb. + */ +export type EnsNodeSchema = typeof ensNodeSchema; + +/** + * Build individual ENSDb Schemas + * + * @param ensIndexerSchemaName - The name of the ENSIndexer Schema instance in ENSDb. + * @returns An object containing the "concrete" ENSIndexer Schema and the ENSNode Schema. + */ +export function buildIndividualEnsDbSchemas< + ConcreteEnsIndexerSchema extends AbstractEnsIndexerSchema, +>( + ensIndexerSchemaName: string, +): { + concreteEnsIndexerSchema: ConcreteEnsIndexerSchema; + ensNodeSchema: EnsNodeSchema; +} { + return { + concreteEnsIndexerSchema: buildConcreteEnsIndexerSchema(ensIndexerSchemaName), + ensNodeSchema, + }; +} + +/** + * ENSDb Schema type + * + * Represents the combined database schema for ENSDb, + * including both the "concrete" ENSIndexer Schema and the ENSNode Schema. + */ +type EnsDbSchema = + ConcreteEnsIndexerSchema & EnsNodeSchema; + +/** + * Build ENSDb Schema for Drizzle client + * + * Uses the provided "concrete" ENSIndexer Schema definition to build + * the ENSDb Schema. + * + * @param concreteEnsIndexerSchema - The "concrete" ENSIndexer Schema definition. + * @returns The ENSDb Schema definition for use in building + * a Drizzle client for ENSDb. + */ +function buildEnsDbSchema( + concreteEnsIndexerSchema: ConcreteEnsIndexerSchema, +): EnsDbSchema { + return { + ...concreteEnsIndexerSchema, + ...ensNodeSchema, + }; +} + +/** + * Drizzle client type for ENSDb. + * + * The `ConcreteEnsIndexerSchema` type parameter allows for typing + * the Drizzle client with a "concrete" ENSIndexer Schema definition + * where tables reference the specific ENSIndexer Schema name. + */ +export type EnsDbDrizzleClient = + NodePgDatabase>; + +/** + * Build a Drizzle client for ENSDb. + * + * @param connectionString - The connection string for the ENSDb. + * @param concreteEnsIndexerSchema - The "concrete" ENSIndexer Schema definition for the Drizzle client. + * @param logger - Optional Drizzle logger for query logging. + * @returns A Drizzle client for ENSDb. + */ +export function buildEnsDbDrizzleClient( + connectionString: string, + concreteEnsIndexerSchema: ConcreteEnsIndexerSchema, + logger?: DrizzleLogger, +): EnsDbDrizzleClient { + const ensDbSchema = buildEnsDbSchema(concreteEnsIndexerSchema); + + return drizzle({ + connection: connectionString, + schema: ensDbSchema, + casing: "snake_case", + logger, + }); +} diff --git a/packages/ensdb-sdk/tsup.config.ts b/packages/ensdb-sdk/tsup.config.ts index 82d273ac30..222e1cd9d9 100644 --- a/packages/ensdb-sdk/tsup.config.ts +++ b/packages/ensdb-sdk/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/ensindexer/index.ts", "src/ensnode/index.ts"], + entry: ["src/index.ts", "src/ensindexer-abstract/index.ts", "src/ensnode/index.ts"], platform: "neutral", format: ["esm"], target: "es2022", diff --git a/packages/ensdb-sdk/vitest.config.ts b/packages/ensdb-sdk/vitest.config.ts new file mode 100644 index 0000000000..8e3d64e885 --- /dev/null +++ b/packages/ensdb-sdk/vitest.config.ts @@ -0,0 +1,15 @@ +import { resolve } from "node:path"; + +import { configDefaults, defineProject } from "vitest/config"; + +export default defineProject({ + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, + test: { + environment: "node", + exclude: [...configDefaults.exclude, "**/*.integration.test.ts"], + }, +}); 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 9eb2ff248f..803d801846 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,9 +104,9 @@ overrides: undici@>=7.0.0 <7.24.0: ^7.24.0 undici@>=6.0.0 <6.24.0: ^6.24.0 yauzl@<3.2.1: ^3.2.1 - fast-xml-parser@>=5.0.0 <=5.5.5: '>=5.5.6' - kysely@>=0.26.0 <0.28.12: ^0.28.12 - h3@<1.15.6: ^1.15.6 + fast-xml-parser@>=5.0.0 <5.5.7: '>=5.5.7' + kysely@>=0.26.0 <0.28.14: '>=0.28.14' + h3@<1.15.9: '>=1.15.9' patchedDependencies: '@changesets/assemble-release-plan@6.0.9': @@ -366,7 +366,7 @@ importers: version: 1.37.0 '@ponder/client': specifier: 'catalog:' - version: 0.16.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.28.12)(pg@8.16.3)(typescript@5.9.3) + version: 0.16.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.28.14)(pg@8.16.3)(typescript@5.9.3) '@ponder/utils': specifier: 'catalog:' version: 0.2.16(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@4.3.6)) @@ -390,7 +390,7 @@ importers: version: 4.1.0 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.28.12)(pg@8.16.3) + 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.28.14)(pg@8.16.3) graphql: specifier: ^16.11.0 version: 16.11.0 @@ -478,7 +478,7 @@ importers: version: link:../../packages/ponder-sdk '@ponder/client': specifier: 'catalog:' - version: 0.16.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.28.12)(pg@8.16.3)(typescript@5.9.3) + version: 0.16.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.28.14)(pg@8.16.3)(typescript@5.9.3) caip: specifier: 'catalog:' version: 1.1.1 @@ -493,7 +493,7 @@ importers: version: 5.6.1 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.28.12)(pg@8.16.3) + 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.28.14)(pg@8.16.3) hono: specifier: 'catalog:' version: 4.12.7 @@ -834,7 +834,7 @@ importers: packages/ensdb-sdk: devDependencies: '@ensnode/ensnode-sdk': - specifier: 'workspace:' + specifier: workspace:* version: link:../ensnode-sdk '@ensnode/shared-configs': specifier: workspace:* @@ -844,7 +844,7 @@ importers: version: 0.31.10 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.28.12)(pg@8.16.3) + 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.28.14)(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.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) @@ -857,6 +857,9 @@ importers: viem: specifier: 'catalog:' version: 2.38.5(typescript@5.9.3)(zod@3.25.76) + vitest: + specifier: 'catalog:' + 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.21.0)(yaml@2.8.1) packages/ensnode-react: dependencies: @@ -1110,13 +1113,13 @@ importers: version: 2.5.1 '@ponder/client': specifier: 'catalog:' - version: 0.16.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.28.12)(pg@8.16.3)(typescript@5.9.3) + version: 0.16.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.28.14)(pg@8.16.3)(typescript@5.9.3) dataloader: specifier: ^2.2.3 version: 2.2.3 drizzle-orm: specifier: 0.41.0 - 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.28.12)(pg@8.16.3) + 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.28.14)(pg@8.16.3) graphql: specifier: ^16.10.0 version: 16.11.0 @@ -5619,7 +5622,7 @@ packages: expo-sqlite: '>=14.0.0' gel: '>=2' knex: '*' - kysely: ^0.28.12 + kysely: '>=0.28.14' mysql2: '>=2' pg: '>=8' postgres: '>=3' @@ -5896,8 +5899,8 @@ packages: fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} - fast-xml-parser@5.5.6: - resolution: {integrity: sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==} + fast-xml-parser@5.5.8: + resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} hasBin: true fastq@1.19.1: @@ -6135,8 +6138,8 @@ packages: resolution: {integrity: sha512-cvVIBILwuoSyD54U4cF/UXDh5yAobhNV/tPygI4lZhgOIJQE/WLWC4waBRb4I6bDVYb3OVx3lfHbaQOEoUD5sg==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - h3@1.15.8: - resolution: {integrity: sha512-iOH6Vl8mGd9nNfu9C0IZ+GuOAfJHcyf3VriQxWaSWIB76Fg4BnFuk4cxBxjmQSSxJS664+pgjP6e7VBnUzFfcg==} + h3@1.15.9: + resolution: {integrity: sha512-H7UPnyIupUOYUQu7f2x7ABVeMyF/IbJjqn20WSXpMdnQB260luADUkSgJU7QTWLutq8h3tUayMQ1DdbSYX5LkA==} hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -6534,8 +6537,8 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - kysely@0.28.12: - resolution: {integrity: sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw==} + kysely@0.28.14: + resolution: {integrity: sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==} engines: {node: '>=20.0.0'} langium@4.2.1: @@ -7303,8 +7306,8 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.1.3: - resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==} + path-expression-matcher@1.2.0: + resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} engines: {node: '>=14.0.0'} path-is-inside@1.0.2: @@ -9827,7 +9830,7 @@ snapshots: '@aws-sdk/xml-builder@3.972.9': dependencies: '@smithy/types': 4.13.0 - fast-xml-parser: 5.5.6 + fast-xml-parser: 5.5.8 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.3': {} @@ -11625,9 +11628,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@ponder/client@0.16.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.28.12)(pg@8.16.3)(typescript@5.9.3)': + '@ponder/client@0.16.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/pg@8.16.0)(kysely@0.28.14)(pg@8.16.3)(typescript@5.9.3)': dependencies: - 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.28.12)(pg@8.16.3) + 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.28.14)(pg@8.16.3) eventsource: 3.0.7 superjson: 2.2.6 optionalDependencies: @@ -14553,12 +14556,12 @@ snapshots: esbuild: 0.25.11 tsx: 4.21.0 - 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.28.12)(pg@8.16.3): + 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.28.14)(pg@8.16.3): optionalDependencies: '@electric-sql/pglite': 0.2.13 '@opentelemetry/api': 1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a) '@types/pg': 8.16.0 - kysely: 0.28.12 + kysely: 0.28.14 pg: 8.16.3 dset@3.1.4: {} @@ -14839,12 +14842,12 @@ snapshots: fast-xml-builder@1.1.4: dependencies: - path-expression-matcher: 1.1.3 + path-expression-matcher: 1.2.0 - fast-xml-parser@5.5.6: + fast-xml-parser@5.5.8: dependencies: fast-xml-builder: 1.1.4 - path-expression-matcher: 1.1.3 + path-expression-matcher: 1.2.0 strnum: 2.2.0 fastq@1.19.1: @@ -15115,7 +15118,7 @@ snapshots: graphql@16.8.2: {} - h3@1.15.8: + h3@1.15.9: dependencies: cookie-es: 1.2.2 crossws: 0.3.5 @@ -15615,7 +15618,7 @@ snapshots: kolorist@1.8.0: {} - kysely@0.28.12: {} + kysely@0.28.14: {} langium@4.2.1: dependencies: @@ -16635,7 +16638,7 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.1.3: {} + path-expression-matcher@1.2.0: {} path-is-inside@1.0.2: {} @@ -16817,13 +16820,13 @@ snapshots: dataloader: 2.2.3 detect-package-manager: 3.0.2 dotenv: 16.6.1 - 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.28.12)(pg@8.16.3) + 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.28.14)(pg@8.16.3) glob: 10.5.0 graphql: 16.8.2 graphql-yoga: 5.17.1(graphql@16.8.2) hono: 4.12.7 http-terminator: 3.2.0 - kysely: 0.28.12 + kysely: 0.28.14 pg: 8.16.3 pg-connection-string: 2.9.1 pg-copy-streams: 6.0.6 @@ -16899,13 +16902,13 @@ snapshots: dataloader: 2.2.3 detect-package-manager: 3.0.2 dotenv: 16.6.1 - 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.28.12)(pg@8.16.3) + 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.28.14)(pg@8.16.3) glob: 10.5.0 graphql: 16.8.2 graphql-yoga: 5.17.1(graphql@16.8.2) hono: 4.12.7 http-terminator: 3.2.0 - kysely: 0.28.12 + kysely: 0.28.14 pg: 8.16.3 pg-connection-string: 2.9.1 pg-copy-streams: 6.0.6 @@ -18346,7 +18349,7 @@ snapshots: anymatch: 3.1.3 chokidar: 4.0.3 destr: 2.0.5 - h3: 1.15.8 + h3: 1.15.9 lru-cache: 10.4.3 node-fetch-native: 1.6.7 ofetch: 1.5.0