diff --git a/.changeset/moody-tips-run.md b/.changeset/moody-tips-run.md new file mode 100644 index 000000000..57a068258 --- /dev/null +++ b/.changeset/moody-tips-run.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Introduces a worker that writes serialized representations of ENSIndexer Public Config and Indexing Status to ENSDb. diff --git a/.changeset/rich-buttons-shine.md b/.changeset/rich-buttons-shine.md new file mode 100644 index 000000000..8fd6b1102 --- /dev/null +++ b/.changeset/rich-buttons-shine.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-schema": minor +--- + +Includes schema for `ENSNodeMetadata`. diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index 420b7861f..3a36d57fa 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -43,8 +43,8 @@ "date-fns": "catalog:", "drizzle-orm": "catalog:", "hono": "catalog:", - "p-memoize": "^8.0.0", - "p-retry": "^7.1.0", + "p-memoize": "catalog:", + "p-retry": "catalog:", "pg-connection-string": "catalog:", "pino": "catalog:", "ponder-enrich-gql-docs-middleware": "^0.1.3", diff --git a/apps/ensindexer/package.json b/apps/ensindexer/package.json index 8d1daaa24..3b31c32a3 100644 --- a/apps/ensindexer/package.json +++ b/apps/ensindexer/package.json @@ -29,10 +29,13 @@ "@ensnode/ensnode-sdk": "workspace:*", "@ensnode/ensrainbow-sdk": "workspace:*", "@ensnode/ponder-metadata": "workspace:*", + "@ponder/client": "catalog:", "caip": "catalog:", "date-fns": "catalog:", "deepmerge-ts": "^7.1.5", "dns-packet": "^5.6.1", + "drizzle-orm": "catalog:", + "p-retry": "catalog:", "pg-connection-string": "catalog:", "hono": "catalog:", "ponder": "catalog:", @@ -43,6 +46,7 @@ "@ensnode/shared-configs": "workspace:*", "@types/dns-packet": "^5.6.5", "@types/node": "catalog:", + "@types/pg": "8.16.0", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/apps/ensindexer/ponder/src/ensdb-writer-worker.ts b/apps/ensindexer/ponder/src/ensdb-writer-worker.ts new file mode 100644 index 000000000..cc294113c --- /dev/null +++ b/apps/ensindexer/ponder/src/ensdb-writer-worker.ts @@ -0,0 +1,134 @@ +/** + * This file manages syncing ENSNode metadata: + * - ENSIndexer Public Config + * - Indexing Status + * into the ENSDb. + */ +import { secondsToMilliseconds } from "date-fns"; +import pRetry from "p-retry"; + +import { + CrossChainIndexingStatusSnapshot, + type Duration, + ENSIndexerPublicConfig, + IndexingStatusResponseCodes, + OmnichainIndexingStatusIds, + validateENSIndexerPublicConfigCompatibility, +} from "@ensnode/ensnode-sdk"; + +import { EnsDbClient } from "@/lib/ensdb"; +import { ensIndexerClient, waitForEnsIndexerToBecomeHealthy } from "@/lib/ensindexer"; + +const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 1; + +/** + * ENSDb Writer Worker + * + * Runs the following tasks: + * 1) On application startup, attempt to upsert serialized representation of + * {@link ENSIndexerPublicConfig} into ENSDb. + * 2) On application startup, and then on recurring basis, + * following the {@link INDEXING_STATUS_RECORD_UPDATE_INTERVAL}, attempt to + * upsert serialized representation of {@link CrossChainIndexingStatusSnapshot} + * into ENSDb. + */ +async function ensDbWriterWorker() { + console.log("ENSDb Writer Worker: waiting for ENSIndexer to become healthy."); + + // 0. Wait for ENSIndexer to become healthy before running the worker's logic + await waitForEnsIndexerToBecomeHealthy; + + console.log("ENSDb Writer Worker: ENSIndexer is healthy, starting tasks."); + + // 1. Create ENSDb Client + const ensDbClient = new EnsDbClient(); + + /** + * Handle ENSIndexerPublicConfig and ENSDb Version Records + */ + const handleEnsIndexerPublicConfigAndEnsDbVersionRecords = async () => { + // Read stored config and in-memory config. + // Note: we wrap each operation in pRetry to ensure all of them can be + // completed successfully. + const [storedConfig, inMemoryConfig] = await Promise.all([ + pRetry(() => ensDbClient.getEnsIndexerPublicConfig()), + pRetry(() => ensIndexerClient.config()), + ]); + + // Validate in-memory config object compatibility with the stored one, + // if the stored one is available + if (storedConfig) { + try { + validateENSIndexerPublicConfigCompatibility(storedConfig, inMemoryConfig); + } catch (error) { + const errorMessage = `In-memory ENSIndexerPublicConfig object is not compatible with its counterpart stored in ENSDb.`; + + // Throw the error to terminate the ENSIndexer process due to + // found config incompatibility + throw new Error(errorMessage, { + cause: error, + }); + } + } else { + // Upsert ENSDb Version into ENSDb. + await ensDbClient.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb); + // Upsert ENSIndexerPublicConfig into ENSDb. + await ensDbClient.upsertEnsIndexerPublicConfig(inMemoryConfig); + } + }; + + /** + * Handle Indexing Status Record Recursively + */ + const handleIndexingStatusRecordRecursively = async () => { + try { + // Read in-memory Indexing Status. + const inMemoryIndexingStatus = await ensIndexerClient.indexingStatus(); + + // Check if Indexing Status is available. + if (inMemoryIndexingStatus.responseCode !== IndexingStatusResponseCodes.Ok) { + throw new Error("Indexing Status must be available."); + } + + const { snapshot } = inMemoryIndexingStatus.realtimeProjection; + const { omnichainSnapshot } = snapshot; + + // Check if Indexing Status is in expected status. + // The Omnichain Status must indicate that indexing has started already. + // Throw an error if Omnichain Status is "Unstarted". + if (omnichainSnapshot.omnichainStatus === OmnichainIndexingStatusIds.Unstarted) { + throw new Error("Omnichain Status must be different than 'Unstarted'."); + } + + // Upsert ENSIndexerPublicConfig into ENSDb. + await ensDbClient.upsertIndexingStatus(snapshot); + } catch (error) { + // Do nothing about this error, but having it logged. + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error(`Could not upsert Indexing Status record due to: ${errorMessage}`); + } finally { + // Regardless of current iteration result, + // schedule the next callback to handle Indexing Status Record. + setTimeout( + handleIndexingStatusRecordRecursively, + secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL), + ); + } + }; + + // 4. Handle ENSIndexer Public Config and ENSDb Version just once. + console.log("Task: store ENSIndexer Public Config and ENSDb Version in ENSDb."); + await handleEnsIndexerPublicConfigAndEnsDbVersionRecords(); + console.log("ENSIndexer Public Config and ENSDb Version successfully stored in ENSDb."); + + // 5. Handle Indexing Status on recurring basis. + console.log("Task: store Indexing Status in ENSDb."); + await handleIndexingStatusRecordRecursively(); + console.log("Indexing Status successfully stored in ENSDb."); +} + +// Run ENSDb Writer Worker in the background. +ensDbWriterWorker().catch((error) => { + console.error("ENSDb Writer Worker failed to perform its tasks", error); + process.exit(1); +}); diff --git a/apps/ensindexer/src/lib/ensdb/drizzle.ts b/apps/ensindexer/src/lib/ensdb/drizzle.ts new file mode 100644 index 000000000..64f3b5db1 --- /dev/null +++ b/apps/ensindexer/src/lib/ensdb/drizzle.ts @@ -0,0 +1,25 @@ +// This file was copied 1-to-1 from ENSApi. +// TODO: deduplicate with apps/ensapi/src/lib/handlers/drizzle.ts when ensnode nodejs internal package is created + +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/ensdb-client.ts b/apps/ensindexer/src/lib/ensdb/ensdb-client.ts new file mode 100644 index 000000000..d1e98e452 --- /dev/null +++ b/apps/ensindexer/src/lib/ensdb/ensdb-client.ts @@ -0,0 +1,192 @@ +import config from "@/config"; + +import { eq } from "drizzle-orm/sql"; + +import * as schema from "@ensnode/ensnode-schema"; +import { + type CrossChainIndexingStatusSnapshot, + deserializeCrossChainIndexingStatusSnapshot, + deserializeENSIndexerPublicConfig, + type ENSIndexerPublicConfig, + serializeCrossChainIndexingStatusSnapshotOmnichain, + serializeENSIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import { makeDrizzle } from "./drizzle"; +import { + EnsNodeMetadataKeys, + type SerializedEnsNodeMetadata, + type SerializedEnsNodeMetadataEnsDbVersion, + type SerializedEnsNodeMetadataEnsIndexerPublicConfig, + type SerializedEnsNodeMetadataIndexingStatus, +} from "./ensnode-metadata"; + +/** + * ENSDb Client Query + * + Includes methods for reading from ENSDb. + */ +export interface EnsDbClientQuery { + /** + * Get ENSDb Version + * + * @returns the existing record, or `undefined`. + * @throws if not exactly one record was found. + */ + getEnsDbVersion(): Promise; + + /** + * Get ENSIndexer Public Config + * + * @returns the existing record, or `undefined`. + * @throws if not exactly one record was found. + */ + getEnsIndexerPublicConfig(): Promise; + + /** + * Get Indexing Status + * + * @returns the existing record, or `undefined`. + * @throws if not exactly one record was found. + */ + getIndexingStatus(): 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 + * + * @throws when upsert operation failed. + */ + upsertIndexingStatus(indexingStatus: CrossChainIndexingStatusSnapshot): Promise; +} + +/** + * ENSDb Client + */ +export class EnsDbClient implements EnsDbClientQuery, EnsDbClientMutation { + #db = makeDrizzle({ + databaseSchema: config.databaseSchemaName, + databaseUrl: config.databaseUrl, + schema, + }); + + async getEnsDbVersion(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsDbVersion, + }); + + return record; + } + + async getEnsIndexerPublicConfig(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, + }); + + if (!record) { + return undefined; + } + + return deserializeENSIndexerPublicConfig(record); + } + + async getIndexingStatus(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.IndexingStatus, + }); + + if (!record) { + return undefined; + } + + return deserializeCrossChainIndexingStatusSnapshot(record); + } + + async upsertEnsDbVersion(ensDbVersion: string): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsDbVersion, + value: ensDbVersion, + }); + } + + async upsertEnsIndexerPublicConfig( + ensIndexerPublicConfig: ENSIndexerPublicConfig, + ): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, + value: serializeENSIndexerPublicConfig(ensIndexerPublicConfig), + }); + } + + async upsertIndexingStatus(indexingStatus: CrossChainIndexingStatusSnapshot): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.IndexingStatus, + value: serializeCrossChainIndexingStatusSnapshotOmnichain(indexingStatus), + }); + } + + /** + * Get ENSNode metadata record + * + * @returns selected record in ENSDb. + * @throws when exactly one matching metadata record was not found + */ + private async getEnsNodeMetadata< + EnsNodeMetadataType extends SerializedEnsNodeMetadata = SerializedEnsNodeMetadata, + >(metadata: Pick): Promise { + const result = await this.#db + .select() + .from(schema.ensNodeMetadata) + .where(eq(schema.ensNodeMetadata.key, metadata.key)); + + if (result.length === 0) { + return undefined; + } + + if (result.length === 1 && result[0]) { + return result[0].value as EnsNodeMetadataType["value"]; + } + + throw new Error(`There must be exactly one ENSNodeMetadata record for '${metadata.key}' key`); + } + + /** + * Upsert ENSNode metadata + * + * @throws when upsert operation failed. + */ + private async upsertEnsNodeMetadata< + EnsNodeMetadataType extends SerializedEnsNodeMetadata = SerializedEnsNodeMetadata, + >(metadata: EnsNodeMetadataType): Promise { + await this.#db + .insert(schema.ensNodeMetadata) + .values({ + key: metadata.key, + value: metadata.value, + }) + .onConflictDoUpdate({ + target: schema.ensNodeMetadata.key, + set: { value: metadata.value }, + }); + } +} diff --git a/apps/ensindexer/src/lib/ensdb/ensnode-metadata.ts b/apps/ensindexer/src/lib/ensdb/ensnode-metadata.ts new file mode 100644 index 000000000..4914a7a4d --- /dev/null +++ b/apps/ensindexer/src/lib/ensdb/ensnode-metadata.ts @@ -0,0 +1,71 @@ +import type { + CrossChainIndexingStatusSnapshot, + ENSIndexerPublicConfig, + SerializedCrossChainIndexingStatusSnapshot, + SerializedENSIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +/** + * Keys used to distinguish records in `ensnode_metadata` table in the ENSDb. + */ +export const EnsNodeMetadataKeys = { + EnsDbVersion: "ensdb_version", + EnsIndexerPublicConfig: "ensindexer_public_config", + IndexingStatus: "indexing_status", +} as const; + +export type EnsNodeMetadataKey = (typeof EnsNodeMetadataKeys)[keyof typeof EnsNodeMetadataKeys]; + +export interface EnsNodeMetadataEnsDbVersion { + key: typeof EnsNodeMetadataKeys.EnsDbVersion; + value: string; +} + +/** + * Serialized representation of {@link EnsNodeMetadataEnsDbVersion}. + */ +export type SerializedEnsNodeMetadataEnsDbVersion = EnsNodeMetadataEnsDbVersion; + +export interface EnsNodeMetadataEnsIndexerPublicConfig { + key: typeof EnsNodeMetadataKeys.EnsIndexerPublicConfig; + value: ENSIndexerPublicConfig; +} + +/** + * Serialized representation of {@link EnsNodeMetadataEnsIndexerPublicConfig}. + */ +export interface SerializedEnsNodeMetadataEnsIndexerPublicConfig { + key: typeof EnsNodeMetadataKeys.EnsIndexerPublicConfig; + value: SerializedENSIndexerPublicConfig; +} + +export interface EnsNodeMetadataIndexingStatus { + key: typeof EnsNodeMetadataKeys.IndexingStatus; + value: CrossChainIndexingStatusSnapshot; +} + +/** + * Serialized representation of {@link EnsNodeMetadataIndexingStatus}. + */ +export interface SerializedEnsNodeMetadataIndexingStatus { + key: typeof EnsNodeMetadataKeys.IndexingStatus; + value: SerializedCrossChainIndexingStatusSnapshot; +} + +/** + * ENSNode Metadata + * + * Union type gathering all variants of ENSNode Metadata. + */ +export type EnsNodeMetadata = + | EnsNodeMetadataEnsDbVersion + | EnsNodeMetadataEnsIndexerPublicConfig + | EnsNodeMetadataIndexingStatus; + +/** + * Serialized representation of {@link EnsNodeMetadata} + */ +export type SerializedEnsNodeMetadata = + | SerializedEnsNodeMetadataEnsDbVersion + | SerializedEnsNodeMetadataEnsIndexerPublicConfig + | SerializedEnsNodeMetadataIndexingStatus; diff --git a/apps/ensindexer/src/lib/ensdb/index.ts b/apps/ensindexer/src/lib/ensdb/index.ts new file mode 100644 index 000000000..b499bfc1c --- /dev/null +++ b/apps/ensindexer/src/lib/ensdb/index.ts @@ -0,0 +1 @@ +export * from "./ensdb-client"; diff --git a/apps/ensindexer/src/lib/ensindexer/index.ts b/apps/ensindexer/src/lib/ensindexer/index.ts new file mode 100644 index 000000000..bee6566ef --- /dev/null +++ b/apps/ensindexer/src/lib/ensindexer/index.ts @@ -0,0 +1 @@ +export * from "./local-client"; diff --git a/apps/ensindexer/src/lib/ensindexer/local-client.ts b/apps/ensindexer/src/lib/ensindexer/local-client.ts new file mode 100644 index 000000000..5ef7f8165 --- /dev/null +++ b/apps/ensindexer/src/lib/ensindexer/local-client.ts @@ -0,0 +1,31 @@ +import config from "@/config"; + +import pRetry from "p-retry"; + +import { EnsIndexerClient, EnsIndexerHealthCheckResults } from "@ensnode/ensnode-sdk"; + +/** + * How many times retries should be attempted before + * {@link waitForEnsIndexerToBecomeHealthy} becomes a rejected promise. + */ +export const MAX_ENSINDEXER_HEALTHCHECK_ATTEMPTS = 5; + +export const ensIndexerClient = new EnsIndexerClient(config.ensIndexerUrl); + +/** + * Wait for ENSIndexer to become healthy. + * + * The global promise that will only resolve after the ENSIndexer has become healthy. + */ +export const waitForEnsIndexerToBecomeHealthy = pRetry( + async () => { + const response = await ensIndexerClient.health(); + + if (response !== EnsIndexerHealthCheckResults.Ok) { + throw new Error("ENSIndexer is not healthy yet"); + } + }, + { + retries: MAX_ENSINDEXER_HEALTHCHECK_ATTEMPTS, + }, +); diff --git a/packages/ensnode-schema/src/ponder.schema.ts b/packages/ensnode-schema/src/ponder.schema.ts index 8137dd0f7..a5909df25 100644 --- a/packages/ensnode-schema/src/ponder.schema.ts +++ b/packages/ensnode-schema/src/ponder.schema.ts @@ -2,6 +2,7 @@ * Merge the various sub-schemas into a single ponder (drizzle) schema. */ +export * from "./schemas/ensnode-metadata.schema"; export * from "./schemas/protocol-acceleration.schema"; export * from "./schemas/registrars.schema"; export * from "./schemas/subgraph.schema"; diff --git a/packages/ensnode-schema/src/schemas/ensnode-metadata.schema.ts b/packages/ensnode-schema/src/schemas/ensnode-metadata.schema.ts new file mode 100644 index 000000000..1750d032b --- /dev/null +++ b/packages/ensnode-schema/src/schemas/ensnode-metadata.schema.ts @@ -0,0 +1,37 @@ +/** + * 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` + * - `EnsNodeMetadataIndexingStatus` + */ +export const ensNodeMetadata = onchainTable("ensnode_metadata", (t) => ({ + /** + * Key + * + * Allowed keys: + * - `EnsNodeMetadataEnsDbVersion['key']` + * - `EnsNodeMetadataEnsIndexerPublicConfig['key']` + * - `EnsNodeMetadataIndexingStatus['key']` + */ + key: t.text().primaryKey(), + + /** + * Value + * + * Allowed values: + * - `EnsNodeMetadataEnsDbVersion['value']` + * - `EnsNodeMetadataEnsIndexerPublicConfig['value']` + * - `EnsNodeMetadataIndexingStatus['value']` + * + * Guaranteed to be a JSON object. + */ + value: t.jsonb().notNull(), +})); diff --git a/packages/ensnode-sdk/src/api/name-tokens/prerequisites.ts b/packages/ensnode-sdk/src/api/name-tokens/prerequisites.ts index 6690f93c8..264b2d35b 100644 --- a/packages/ensnode-sdk/src/api/name-tokens/prerequisites.ts +++ b/packages/ensnode-sdk/src/api/name-tokens/prerequisites.ts @@ -1,9 +1,8 @@ +import { type ENSIndexerPublicConfig, PluginName } from "../../ensindexer/config/types"; import { - type ENSIndexerPublicConfig, type OmnichainIndexingStatusId, OmnichainIndexingStatusIds, - PluginName, -} from "../../ensindexer"; +} from "../../ensindexer/indexing-status/types"; export const nameTokensPrerequisites = Object.freeze({ /** diff --git a/packages/ensnode-sdk/src/api/registrar-actions/prerequisites.ts b/packages/ensnode-sdk/src/api/registrar-actions/prerequisites.ts index afdc12f11..dac980d22 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/prerequisites.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/prerequisites.ts @@ -1,9 +1,8 @@ +import { type ENSIndexerPublicConfig, PluginName } from "../../ensindexer/config/types"; import { - type ENSIndexerPublicConfig, type OmnichainIndexingStatusId, OmnichainIndexingStatusIds, - PluginName, -} from "../../ensindexer"; +} from "../../ensindexer/indexing-status/types"; export const registrarActionsPrerequisites = Object.freeze({ /** diff --git a/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts b/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts index c394b339e..2e13a9f33 100644 --- a/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts +++ b/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest"; import { ENSNamespaceIds } from "@ensnode/datasources"; -import { PluginName } from "../../ensindexer"; -import { deserializeENSApiPublicConfig, serializeENSApiPublicConfig } from "."; +import { PluginName } from "../../ensindexer/config/types"; +import { deserializeENSApiPublicConfig } from "./deserialize"; +import { serializeENSApiPublicConfig } from "./serialize"; import type { ENSApiPublicConfig } from "./types"; const MOCK_ENSAPI_PUBLIC_CONFIG = { diff --git a/packages/ensnode-sdk/src/ensapi/config/serialize.ts b/packages/ensnode-sdk/src/ensapi/config/serialize.ts index 44a8d6009..27f5114a7 100644 --- a/packages/ensnode-sdk/src/ensapi/config/serialize.ts +++ b/packages/ensnode-sdk/src/ensapi/config/serialize.ts @@ -1,4 +1,4 @@ -import { serializeENSIndexerPublicConfig } from "../../ensindexer"; +import { serializeENSIndexerPublicConfig } from "../../ensindexer/config/serialize"; import type { SerializedENSApiPublicConfig } from "./serialized-types"; import type { ENSApiPublicConfig } from "./types"; diff --git a/packages/ensnode-sdk/src/ensapi/config/serialized-types.ts b/packages/ensnode-sdk/src/ensapi/config/serialized-types.ts index f437da884..41199141c 100644 --- a/packages/ensnode-sdk/src/ensapi/config/serialized-types.ts +++ b/packages/ensnode-sdk/src/ensapi/config/serialized-types.ts @@ -1,4 +1,4 @@ -import type { SerializedENSIndexerPublicConfig } from "../../ensindexer"; +import type { SerializedENSIndexerPublicConfig } from "../../ensindexer/config/serialized-types"; import type { ENSApiPublicConfig } from "./types"; /** diff --git a/packages/ensnode-sdk/src/ensapi/config/types.ts b/packages/ensnode-sdk/src/ensapi/config/types.ts index b627b98da..16ba34e59 100644 --- a/packages/ensnode-sdk/src/ensapi/config/types.ts +++ b/packages/ensnode-sdk/src/ensapi/config/types.ts @@ -1,6 +1,6 @@ import type { z } from "zod/v4"; -import type { ENSIndexerPublicConfig } from "../../ensindexer"; +import type { ENSIndexerPublicConfig } from "../../ensindexer/config/types"; import type { TheGraphCannotFallbackReasonSchema, TheGraphFallbackSchema } from "./zod-schemas"; export type TheGraphCannotFallbackReason = z.infer; diff --git a/packages/ensnode-sdk/src/ensindexer/client.test.ts b/packages/ensnode-sdk/src/ensindexer/client.test.ts new file mode 100644 index 000000000..11a498fff --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/client.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { EnsIndexerClient, EnsIndexerHealthCheckResults } from "./client"; +import { mockedConfig, mockedSerializedConfig } from "./config/mocks"; + +describe("EnsIndexerClient", () => { + const mockFetch = vi.fn(); + const baseUrl = new URL("http://exmple.com"); + + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("health()", () => { + it("should return Ok when fetch returns 2xx status", async () => { + mockFetch + .mockResolvedValueOnce( + new Response("ok", { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response("ok", { + status: 299, + }), + ); + + const client = new EnsIndexerClient(baseUrl); + + const result1 = await client.health(); + expect(result1).toBe(EnsIndexerHealthCheckResults.Ok); + + const result2 = await client.health(); + expect(result2).toBe(EnsIndexerHealthCheckResults.Ok); + }); + + it("should return NotOk when fetch returns non-2xx status", async () => { + mockFetch + .mockResolvedValueOnce( + new Response("bad request", { + status: 400, + }), + ) + .mockResolvedValueOnce( + new Response("internal server error", { + status: 500, + }), + ); + + const client = new EnsIndexerClient(baseUrl); + + const result1 = await client.health(); + expect(result1).toBe(EnsIndexerHealthCheckResults.NotOk); + + const result2 = await client.health(); + expect(result2).toBe(EnsIndexerHealthCheckResults.NotOk); + }); + + it("should return Unknown when fetch throws error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const client = new EnsIndexerClient(baseUrl); + const result = await client.health(); + + expect(result).toBe(EnsIndexerHealthCheckResults.Unknown); + }); + + it("should update internal health state on successful health check", async () => { + // arrange + mockFetch + .mockResolvedValueOnce( + // `/health` mock + new Response("ok", { + status: 200, + }), + ) + .mockResolvedValueOnce( + // `/api/config` mock + new Response(JSON.stringify(mockedSerializedConfig), { + status: 200, + }), + ); + + const client = new EnsIndexerClient(baseUrl); + + // act + await client.health(); + const config = await client.config(); + + // assert + expect(config).toStrictEqual(mockedConfig); + }); + }); + + describe("other methods", () => { + it("should throw when a method called and health check result is Unknown", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + + const client = new EnsIndexerClient(baseUrl); + + await expect(client.config()).rejects.toThrowError(/Call the 'health\(\)' method first/i); + await expect(client.indexingStatus()).rejects.toThrowError( + /Call the 'health\(\)' method first/i, + ); + + await expect(client.health()).resolves.toBe(EnsIndexerHealthCheckResults.Unknown); + + await expect(client.config()).rejects.toThrowError(/ENSIndexer must be healthy/); + await expect(client.indexingStatus()).rejects.toThrowError(/ENSIndexer must be healthy/); + }); + + it("should throw when a method called and health check result is NotOk", async () => { + mockFetch.mockResolvedValue( + new Response("internal server error", { + status: 500, + }), + ); + + const client = new EnsIndexerClient(baseUrl); + + await expect(client.config()).rejects.toThrowError(/Call the 'health\(\)' method first/i); + await expect(client.indexingStatus()).rejects.toThrowError( + /Call the 'health\(\)' method first/i, + ); + + await expect(client.health()).resolves.toBe(EnsIndexerHealthCheckResults.NotOk); + + await expect(client.config()).rejects.toThrowError(/ENSIndexer must be healthy/); + await expect(client.indexingStatus()).rejects.toThrowError(/ENSIndexer must be healthy/); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/ensindexer/client.ts b/packages/ensnode-sdk/src/ensindexer/client.ts new file mode 100644 index 000000000..e09ccbb64 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/client.ts @@ -0,0 +1,127 @@ +import { + deserializeENSIndexerPublicConfig, + deserializeIndexingStatusResponse, + type ENSIndexerPublicConfig, + type IndexingStatusResponse, + type SerializedENSIndexerPublicConfig, + type SerializedIndexingStatusResponse, +} from "@ensnode/ensnode-sdk"; + +export const EnsIndexerHealthCheckResults = { + /** + * ENSIndexer Health is unknown if the health check endpoint is unavailable. + */ + Unknown: "unknown", + + /** + * ENSIndexer Health is not OK if the health check endpoint returned + * HTTP status other than `2xx`. + */ + NotOk: "not-ok", + + /** + * ENSIndexer Health is OK if the health check endpoint returned + * `2xx` HTTP status. + */ + Ok: "ok", +} as const; + +export type EnsIndexerHealthCheckResult = + (typeof EnsIndexerHealthCheckResults)[keyof typeof EnsIndexerHealthCheckResults]; + +/** + * ENSIndexer Client + * + * Using this client methods requires first calling `health()` method and + * waiting for it to return {@link EnsIndexerHealthCheckResults.Ok} result. + */ +export class EnsIndexerClient { + #healthCheckResult: EnsIndexerHealthCheckResult | undefined; + + constructor(private ensIndexerUrl: URL) {} + + /** + * ENSIndexer health check endpoint. + * + * @returns ENSIndexer health check result. + */ + public async health(): Promise { + let response: Response; + + try { + response = await fetch(new URL("/health", this.ensIndexerUrl)); + + if (!response.ok) { + this.#healthCheckResult = EnsIndexerHealthCheckResults.NotOk; + } else { + this.#healthCheckResult = EnsIndexerHealthCheckResults.Ok; + } + } catch { + this.#healthCheckResult = EnsIndexerHealthCheckResults.Unknown; + } + + return this.#healthCheckResult; + } + + /** + * Fetch ENSIndexer Public Config + * + * @returns ENSIndexer Public Config + * + * @throws if the ENSIndexer request fails + * @throws if the ENSIndexer returns an error response + * @throws if the ENSIndexer response breaks required invariants + */ + public async config(): Promise { + this.validateEnsIndexerHealthCheckResult(); + + const ensIndexerPublicConfigSerialized = await fetch( + new URL("/api/config", this.ensIndexerUrl), + ).then((response) => response.json()); + + return deserializeENSIndexerPublicConfig( + ensIndexerPublicConfigSerialized as SerializedENSIndexerPublicConfig, + ); + } + + /** + * Fetch ENSIndexer Indexing Status + * + * @returns ENSIndexer Indexing Status + * + * @throws if the ENSIndexer request fails + * @throws if the ENSIndexer returns an error response + * @throws if the ENSIndexer response breaks required invariants + */ + public async indexingStatus(): Promise { + this.validateEnsIndexerHealthCheckResult(); + + const indexingStatusSerialized = await fetch( + new URL("/api/indexing-status", this.ensIndexerUrl), + ).then((response) => response.json()); + + return deserializeIndexingStatusResponse( + indexingStatusSerialized as SerializedIndexingStatusResponse, + ); + } + + /** + * Validate ENSIndexer health check result. + * + * @throws if the health check result is other than + * {@link EnsIndexerHealthCheckResults.Ok}. + */ + private validateEnsIndexerHealthCheckResult(): void { + if (typeof this.#healthCheckResult === "undefined") { + throw new Error( + "Running health check for ENSIndexer is required. Call the 'health()' method first.", + ); + } + + if (this.#healthCheckResult !== EnsIndexerHealthCheckResults.Ok) { + throw new Error( + `ENSIndexer must be healthy. Current health check result is '${this.#healthCheckResult}'. You can keep calling the 'health()' method until it returns the 'ok' result.`, + ); + } + } +} diff --git a/packages/ensnode-sdk/src/ensindexer/config/compatibility.test.ts b/packages/ensnode-sdk/src/ensindexer/config/compatibility.test.ts new file mode 100644 index 000000000..9ee6f2074 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/config/compatibility.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { ENSNamespaceIds } from "@ensnode/datasources"; +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { + type ENSIndexerPublicConfigCompatibilityCheck, + validateENSIndexerPublicConfigCompatibility, +} from "./compatibility"; + +describe("ENSIndexerConfig compatibility", () => { + describe("validateENSIndexerPublicConfigCompatibility()", () => { + const config = { + indexedChainIds: new Set([1, 10, 8453]), + isSubgraphCompatible: false, + namespace: ENSNamespaceIds.Mainnet, + plugins: [PluginName.Subgraph, PluginName.Basenames, PluginName.ThreeDNS], + } satisfies ENSIndexerPublicConfigCompatibilityCheck; + + it("does not throw error when 'configB' is compatible with 'configA' ('configA' is subset of 'configB')", () => { + const configA = structuredClone(config); + + const configB = structuredClone(config); + configB.indexedChainIds.add(59144); + configB.plugins.push(PluginName.Lineanames); + + expect(() => + validateENSIndexerPublicConfigCompatibility(configA, configB), + ).not.toThrowError(); + }); + + it("throws error when 'configA.indexedChainIds' are not subset of 'configB.indexedChainIds'", () => { + const configA = structuredClone(config); + + const configB = structuredClone(config); + configB.indexedChainIds.delete(8453); + + expect(() => validateENSIndexerPublicConfigCompatibility(configA, configB)).toThrowError( + /'indexedChainIds' must be compatible. Stored Config 'indexedChainIds': '1, 10, 8453'. Current Config 'indexedChainIds': '1, 10'/i, + ); + }); + + it("throws error when 'configA.isSubgraphCompatible' is not same as 'configB.isSubgraphCompatible'", () => { + const configA = structuredClone(config); + + const configB = { + ...structuredClone(config), + isSubgraphCompatible: !configA.isSubgraphCompatible, + } satisfies ENSIndexerPublicConfigCompatibilityCheck; + + expect(() => validateENSIndexerPublicConfigCompatibility(configA, configB)).toThrowError( + /'isSubgraphCompatible' flag must be compatible. Stored Config 'isSubgraphCompatible' flag: 'false'. Current Config 'isSubgraphCompatible' flag: 'true'/i, + ); + }); + + it("throws error when 'configA.isSubgraphCompatible' is not same as 'configB.isSubgraphCompatible'", () => { + const configA = structuredClone(config); + + const configB = { + ...structuredClone(config), + namespace: ENSNamespaceIds.Sepolia, + } satisfies ENSIndexerPublicConfigCompatibilityCheck; + + expect(() => validateENSIndexerPublicConfigCompatibility(configA, configB)).toThrowError( + /'namespace' must be compatible. Stored Config 'namespace': 'mainnet'. Current Config 'namespace': 'sepolia'/i, + ); + }); + + it("throws error when 'configA.plugins' are not subset of 'configB.plugins'", () => { + const configA = structuredClone(config); + + const configB = structuredClone(config); + configB.plugins.pop(); + + expect(() => validateENSIndexerPublicConfigCompatibility(configA, configB)).toThrowError( + /'plugins' must be compatible. Stored Config 'plugins': 'subgraph, basenames, threedns'. Current Config 'plugins': 'subgraph, basenames'/i, + ); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/ensindexer/config/compatibility.ts b/packages/ensnode-sdk/src/ensindexer/config/compatibility.ts new file mode 100644 index 000000000..84ad58d47 --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/config/compatibility.ts @@ -0,0 +1,63 @@ +import type { ENSIndexerPublicConfig } from "./types"; + +export type ENSIndexerPublicConfigCompatibilityCheck = Pick< + ENSIndexerPublicConfig, + "indexedChainIds" | "isSubgraphCompatible" | "namespace" | "plugins" +>; + +/** + * Validate if `configB` is compatible with `configA`, such that `configA` is + * a subset of `configB`. + * + * @throws error if configs are incompatible. + */ +export function validateENSIndexerPublicConfigCompatibility( + configA: ENSIndexerPublicConfigCompatibilityCheck, + configB: ENSIndexerPublicConfigCompatibilityCheck, +): void { + const configAIndexedChainIds = Array.from(configA.indexedChainIds); + const configBIndexedChainIds = Array.from(configB.indexedChainIds); + if ( + !configAIndexedChainIds.every((configAChainId) => + configBIndexedChainIds.includes(configAChainId), + ) + ) { + throw new Error( + [ + `'indexedChainIds' must be compatible.`, + `Stored Config 'indexedChainIds': '${configAIndexedChainIds.join(", ")}'.`, + `Current Config 'indexedChainIds': '${configBIndexedChainIds.join(", ")}'.`, + ].join(" "), + ); + } + + if (configA.isSubgraphCompatible !== configB.isSubgraphCompatible) { + throw new Error( + [ + `'isSubgraphCompatible' flag must be compatible.`, + `Stored Config 'isSubgraphCompatible' flag: '${configA.isSubgraphCompatible}'.`, + `Current Config 'isSubgraphCompatible' flag: '${configB.isSubgraphCompatible}'.`, + ].join(" "), + ); + } + + if (configA.namespace !== configB.namespace) { + throw new Error( + [ + `'namespace' must be compatible.`, + `Stored Config 'namespace': '${configA.namespace}'.`, + `Current Config 'namespace': '${configB.namespace}'.`, + ].join(" "), + ); + } + + if (!configA.plugins.every((configAPlugin) => configB.plugins.includes(configAPlugin))) { + throw new Error( + [ + `'plugins' must be compatible.`, + `Stored Config 'plugins': '${configA.plugins.join(", ")}'.`, + `Current Config 'plugins': '${configB.plugins.join(", ")}'.`, + ].join(" "), + ); + } +} diff --git a/packages/ensnode-sdk/src/ensindexer/config/conversions.test.ts b/packages/ensnode-sdk/src/ensindexer/config/conversions.test.ts index e6350f587..0bcc0065d 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/conversions.test.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/conversions.test.ts @@ -1,36 +1,18 @@ import { describe, expect, it } from "vitest"; import { deserializeENSIndexerPublicConfig } from "./deserialize"; +import { mockedConfig, mockedSerializedConfig } from "./mocks"; import { serializeENSIndexerPublicConfig } from "./serialize"; import type { SerializedENSIndexerPublicConfig } from "./serialized-types"; import { type ENSIndexerPublicConfig, PluginName } from "./types"; describe("ENSIndexer: Config", () => { + const config = mockedConfig; + const serializedConfig = mockedSerializedConfig; + describe("serialization", () => { it("can serialize ENSIndexerPublicConfig", () => { - // arrange - const config = { - databaseSchemaName: "public", - 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", - ensRainbow: "0.32.0", - ensRainbowSchema: 2, - }, - } satisfies ENSIndexerPublicConfig; - - // act + // arrange & act const result = serializeENSIndexerPublicConfig(config); // assert @@ -49,27 +31,7 @@ describe("ENSIndexer: Config", () => { }); describe("deserialization", () => { - const correctSerializedConfig = { - databaseSchemaName: "public", - labelSet: { - labelSetId: "subgraph", - labelSetVersion: 0, - }, - indexedChainIds: [1, 10, 8453], - 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", - ensRainbow: "0.32.0", - ensRainbowSchema: 2, - }, - } satisfies SerializedENSIndexerPublicConfig; - + const correctSerializedConfig = serializedConfig; it("can deserialize SerializedENSIndexerPublicConfig", () => { // arrange const serializedConfig = structuredClone(correctSerializedConfig); @@ -80,7 +42,7 @@ describe("ENSIndexer: Config", () => { // assert expect(result).toStrictEqual({ ...serializedConfig, - indexedChainIds: new Set([1, 10, 8453]), + indexedChainIds: new Set([1]), } satisfies ENSIndexerPublicConfig); }); diff --git a/packages/ensnode-sdk/src/ensindexer/config/index.ts b/packages/ensnode-sdk/src/ensindexer/config/index.ts index 617d962e0..77df62706 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/index.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/index.ts @@ -1,3 +1,4 @@ +export * from "./compatibility"; export * from "./deserialize"; export * from "./is-subgraph-compatible"; export * from "./label-utils"; diff --git a/packages/ensnode-sdk/src/ensindexer/config/mocks.ts b/packages/ensnode-sdk/src/ensindexer/config/mocks.ts new file mode 100644 index 000000000..5892e3d6c --- /dev/null +++ b/packages/ensnode-sdk/src/ensindexer/config/mocks.ts @@ -0,0 +1,44 @@ +import type { SerializedENSIndexerPublicConfig } from "./serialized-types"; +import { type ENSIndexerPublicConfig, PluginName } from "./types"; + +export const mockedConfig = { + databaseSchemaName: "public", + 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", + ensRainbow: "0.32.0", + ensRainbowSchema: 2, + }, +} satisfies ENSIndexerPublicConfig; + +export const mockedSerializedConfig = { + databaseSchemaName: "public", + labelSet: { + labelSetId: "subgraph", + labelSetVersion: 0, + }, + indexedChainIds: [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", + ensRainbow: "0.32.0", + ensRainbowSchema: 2, + }, +} satisfies SerializedENSIndexerPublicConfig; diff --git a/packages/ensnode-sdk/src/ensindexer/config/serialized-types.ts b/packages/ensnode-sdk/src/ensindexer/config/serialized-types.ts index 97a786916..9ddc2181e 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/serialized-types.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/serialized-types.ts @@ -1,4 +1,4 @@ -import type { ChainId } from "../../shared"; +import type { ChainId } from "../../shared/types"; import type { ENSIndexerPublicConfig, ENSIndexerVersionInfo } from "./types"; export type SerializedIndexedChainIds = Array; diff --git a/packages/ensnode-sdk/src/ensindexer/config/types.ts b/packages/ensnode-sdk/src/ensindexer/config/types.ts index 14417376a..aa1514e56 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/types.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/types.ts @@ -1,7 +1,7 @@ import type { ENSNamespaceId } from "@ensnode/datasources"; -import type { EnsRainbowClientLabelSet } from "../../ensrainbow"; -import type { ChainId } from "../../shared"; +import type { EnsRainbowClientLabelSet } from "../../ensrainbow/types"; +import type { ChainId } from "../../shared/types"; /** * A PluginName is a unique id for a 'plugin': we use the notion of diff --git a/packages/ensnode-sdk/src/ensindexer/index.ts b/packages/ensnode-sdk/src/ensindexer/index.ts index c896748f2..fcd36988f 100644 --- a/packages/ensnode-sdk/src/ensindexer/index.ts +++ b/packages/ensnode-sdk/src/ensindexer/index.ts @@ -1,2 +1,3 @@ +export * from "./client"; export * from "./config"; export * from "./indexing-status"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60fec97a1..cd95d5fab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ catalogs: '@namehash/namekit-react': specifier: 0.12.0 version: 0.12.0 + '@ponder/client': + specifier: 0.13.14 + version: 0.13.14 '@ponder/utils': specifier: 0.2.14 version: 0.2.14 @@ -45,6 +48,12 @@ catalogs: hono: specifier: ^4.10.2 version: 4.10.3 + p-memoize: + specifier: ^8.0.0 + version: 8.0.0 + p-retry: + specifier: ^7.1.1 + version: 7.1.1 pg-connection-string: specifier: ^2.9.1 version: 2.9.1 @@ -339,16 +348,16 @@ 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))(kysely@0.26.3)(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.26.3)(pg@8.16.3) hono: specifier: 'catalog:' version: 4.10.3 p-memoize: - specifier: ^8.0.0 + specifier: 'catalog:' version: 8.0.0 p-retry: - specifier: ^7.1.0 - version: 7.1.0 + specifier: 'catalog:' + version: 7.1.1 pg-connection-string: specifier: 'catalog:' version: 2.9.1 @@ -404,6 +413,9 @@ importers: '@ensnode/ponder-metadata': specifier: workspace:* version: link:../../packages/ponder-metadata + '@ponder/client': + specifier: 'catalog:' + version: 0.13.14(@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)(typescript@5.9.3) caip: specifier: 'catalog:' version: 1.1.1 @@ -416,15 +428,21 @@ importers: dns-packet: specifier: ^5.6.1 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.26.3)(pg@8.16.3) hono: specifier: 'catalog:' version: 4.10.3 + p-retry: + specifier: 'catalog:' + version: 7.1.1 pg-connection-string: specifier: 'catalog:' version: 2.9.1 ponder: specifier: 'catalog:' - version: 0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) + version: 0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(@types/pg@8.16.0)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) viem: specifier: 'catalog:' version: 2.38.5(typescript@5.9.3)(zod@3.25.76) @@ -441,6 +459,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.18.13 + '@types/pg': + specifier: 8.16.0 + version: 8.16.0 typescript: specifier: 'catalog:' version: 5.9.3 @@ -733,7 +754,7 @@ importers: dependencies: ponder: specifier: 'catalog:' - version: 0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) + version: 0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(@types/pg@8.16.0)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) viem: specifier: 'catalog:' version: 2.38.5(typescript@5.9.3)(zod@3.25.76) @@ -820,7 +841,7 @@ importers: version: link:../ensrainbow-sdk drizzle-orm: specifier: 'catalog:' - version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(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.26.3)(pg@8.16.3) parse-prometheus-text-format: specifier: ^1.1.1 version: 1.1.1 @@ -839,7 +860,7 @@ importers: version: 4.10.3 ponder: specifier: 'catalog:' - version: 0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76) + version: 0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(@types/pg@8.16.0)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(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) @@ -866,7 +887,7 @@ importers: version: 2.2.3 drizzle-orm: specifier: 'catalog:' - version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(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.26.3)(pg@8.16.3) graphql: specifier: ^16.10.0 version: 16.11.0 @@ -2409,6 +2430,14 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@ponder/client@0.13.14': + resolution: {integrity: sha512-T1hC2wP+47nJA+arDfCdaqaJGyj3z7QSHtqMv2ojy+nA0T5mHACs8kweWzsyxrI6H0jnuBgDviNijs9wUlo5Xw==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + '@ponder/utils@0.2.14': resolution: {integrity: sha512-O4t14Hb6/tVcD0WoS13ghFnDntP6x33/DDvA+sd0tRjemzS+Cne4YTkXl9TKW3AawBIEwMjGrGbAn82C8gXQWQ==} peerDependencies: @@ -3404,6 +3433,9 @@ packages: '@types/node@22.18.13': resolution: {integrity: sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/progress@2.0.7': resolution: {integrity: sha512-iadjw02vte8qWx7U0YM++EybBha2CQLPGu9iJ97whVgJUT5Zq9MjAPYUnbfRI2Kpehimf1QjFJYxD0t8nqzu5w==} @@ -4711,6 +4743,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -6012,8 +6052,8 @@ packages: resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} engines: {node: '>=18'} - p-retry@7.1.0: - resolution: {integrity: sha512-xL4PiFRQa/f9L9ZvR4/gUCRNus4N8YX80ku8kv9Jqz+ZokkiZLM0bcvX0gm1F3PDi9SPRsww1BDsTWgE6Y1GLQ==} + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} engines: {node: '>=20'} p-timeout@3.2.0: @@ -9711,6 +9751,43 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@ponder/client@0.13.14(@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)(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.26.3)(pg@8.16.3) + eventsource: 3.0.7 + superjson: 2.2.5 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@neondatabase/serverless' + - '@op-engineering/op-sqlite' + - '@opentelemetry/api' + - '@planetscale/database' + - '@prisma/client' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/pg' + - '@types/sql.js' + - '@vercel/postgres' + - '@xata.io/client' + - better-sqlite3 + - bun-types + - expo-sqlite + - gel + - knex + - kysely + - mysql2 + - pg + - postgres + - prisma + - sql.js + - sqlite3 + '@ponder/utils@0.2.14(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))': dependencies: viem: 2.38.5(typescript@5.9.3)(zod@3.25.76) @@ -10758,6 +10835,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.16.0': + dependencies: + '@types/node': 22.18.13 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/progress@2.0.7': dependencies: '@types/node': 22.18.13 @@ -11975,10 +12058,11 @@ snapshots: dotenv@8.6.0: {} - drizzle-orm@0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(kysely@0.26.3)(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.26.3)(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.26.3 pg: 8.16.3 @@ -12143,6 +12227,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -13810,7 +13900,7 @@ snapshots: eventemitter3: 5.0.1 p-timeout: 6.1.4 - p-retry@7.1.0: + p-retry@7.1.1: dependencies: is-network-error: 1.3.0 @@ -14054,7 +14144,7 @@ snapshots: graphql: 16.11.0 hono: 4.10.3 - ponder@0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76): + ponder@0.13.16(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@22.18.13)(@types/pg@8.16.0)(hono@4.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@3.25.76))(yaml@2.8.1)(zod@3.25.76): dependencies: '@babel/code-frame': 7.27.1 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) @@ -14071,7 +14161,7 @@ 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))(kysely@0.26.3)(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.26.3)(pg@8.16.3) glob: 12.0.0 graphql: 16.11.0 graphql-yoga: 5.16.0(graphql@16.11.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1f6fab8c..119de7253 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ catalog: "@astrojs/react": ^4.4.1 "@astrojs/tailwind": ^6.0.2 "@namehash/namekit-react": 0.12.0 + "@ponder/client": 0.13.14 "@ponder/utils": 0.2.14 "@testing-library/react": ^16.3.0 "@types/node": 22.18.13 @@ -18,6 +19,8 @@ catalog: date-fns: 4.1.0 drizzle-orm: "=0.41.0" hono: ^4.10.2 + p-memoize: ^8.0.0 + p-retry: ^7.1.1 pg-connection-string: ^2.9.1 pino: 10.1.0 ponder: 0.13.16