diff --git a/.changeset/config.json b/.changeset/config.json index e555cf165..95b4e8175 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -13,9 +13,9 @@ "fallback-ensapi", "@ensnode/datasources", "@ensnode/ensrainbow-sdk", - "@ensnode/ponder-metadata", "@ensnode/ensnode-schema", "@ensnode/ensnode-react", + "@ensnode/ponder-sdk", "@ensnode/ponder-subgraph", "@ensnode/ensnode-sdk", "@ensnode/shared-configs", diff --git a/.changeset/green-deer-tan.md b/.changeset/green-deer-tan.md new file mode 100644 index 000000000..576c69b26 --- /dev/null +++ b/.changeset/green-deer-tan.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ponder-sdk": minor +--- + +Renames `@ensnode/ponder-metadata` package to `@ensnode/ponder-sdk`. diff --git a/apps/ensadmin/package.json b/apps/ensadmin/package.json index d15ea7cc2..1d4fb6962 100644 --- a/apps/ensadmin/package.json +++ b/apps/ensadmin/package.json @@ -26,7 +26,6 @@ "@ensnode/ensnode-react": "workspace:*", "@ensnode/ensnode-schema": "workspace:*", "@ensnode/ensnode-sdk": "workspace:*", - "@ensnode/ponder-metadata": "workspace:*", "@formkit/auto-animate": "^0.9.0", "@graphiql/plugin-explorer": "5.1.1", "@graphiql/react": "0.37.1", diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index d421d0f66..301d42d52 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -47,7 +47,7 @@ "hono": "catalog:", "hono-openapi": "^1.1.1", "p-memoize": "^8.0.0", - "p-retry": "^7.1.0", + "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..e56edae83 100644 --- a/apps/ensindexer/package.json +++ b/apps/ensindexer/package.json @@ -28,11 +28,12 @@ "@ensnode/ensnode-schema": "workspace:*", "@ensnode/ensnode-sdk": "workspace:*", "@ensnode/ensrainbow-sdk": "workspace:*", - "@ensnode/ponder-metadata": "workspace:*", + "@ensnode/ponder-sdk": "workspace:*", "caip": "catalog:", "date-fns": "catalog:", "deepmerge-ts": "^7.1.5", "dns-packet": "^5.6.1", + "p-retry": "catalog:", "pg-connection-string": "catalog:", "hono": "catalog:", "ponder": "catalog:", diff --git a/apps/ensindexer/src/lib/indexing-status/build-index-status.ts b/apps/ensindexer/src/lib/indexing-status/build-index-status.ts index 26dfba833..18beac466 100644 --- a/apps/ensindexer/src/lib/indexing-status/build-index-status.ts +++ b/apps/ensindexer/src/lib/indexing-status/build-index-status.ts @@ -19,27 +19,25 @@ import { type OmnichainIndexingStatusSnapshot, type UnixTimestamp, } from "@ensnode/ensnode-sdk"; - -import ponderConfig from "@/ponder/config"; - import { type ChainBlockRefs, type ChainName, createSerializedChainSnapshots, createSerializedOmnichainIndexingStatusSnapshot, - fetchPonderMetrics, - fetchPonderStatus, getChainsBlockRefs, getChainsBlockrange, - type PonderStatus, - type PrometheusMetrics, + type PonderMetricsResponse, + type PonderStatusResponse, type PublicClient, -} from "./ponder-metadata"; +} from "@ensnode/ponder-sdk"; + +import { ponderClient, waitForPonderApplicationToBecomeHealthy } from "@/lib/ponder-local-client"; +import ponderConfig from "@/ponder/config"; /** - * Names for each indexed chain + * Stringified chain IDs for each indexed chain */ -const chainNames = Object.keys(ponderConfig.chains) as string[]; +const chainIds = Object.keys(ponderConfig.chains) as string[]; /** * A {@link Blockrange} for each indexed chain. @@ -69,7 +67,7 @@ let chainsBlockRefs = new Map(); * re-use it for further `getChainsBlockRefs` calls. */ async function getChainsBlockRefsCached( - metrics: PrometheusMetrics, + metrics: PonderMetricsResponse, publicClients: Record, ): Promise> { // early-return the cached chain block refs @@ -77,7 +75,7 @@ async function getChainsBlockRefsCached( return chainsBlockRefs; } - chainsBlockRefs = await getChainsBlockRefs(chainNames, chainsBlockrange, metrics, publicClients); + chainsBlockRefs = await getChainsBlockRefs(chainIds, chainsBlockrange, metrics, publicClients); return chainsBlockRefs; } @@ -85,14 +83,16 @@ async function getChainsBlockRefsCached( export async function buildOmnichainIndexingStatusSnapshot( publicClients: Record, ): Promise { - let metrics: PrometheusMetrics; - let status: PonderStatus; + await waitForPonderApplicationToBecomeHealthy; + + let metrics: PonderMetricsResponse; + let status: PonderStatusResponse; try { // Get current Ponder metadata (metrics, status) const [ponderMetrics, ponderStatus] = await Promise.all([ - fetchPonderMetrics(config.ensIndexerUrl), - fetchPonderStatus(config.ensIndexerUrl), + ponderClient.metrics(), + ponderClient.status(), ]); metrics = ponderMetrics; @@ -109,7 +109,7 @@ export async function buildOmnichainIndexingStatusSnapshot( // create serialized chain indexing snapshot for each indexed chain const serializedChainSnapshots = createSerializedChainSnapshots( - chainNames, + chainIds, chainsBlockRefs, metrics, status, diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/metrics.ts b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/metrics.ts deleted file mode 100644 index 28b5bec6b..000000000 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/metrics.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Ponder Metadata: Metrics - * - * This file describes ideas and functionality related to Ponder metrics for - * each indexed chain. Ponder metrics are defined by `/metrics` endpoint. - */ - -import { PrometheusMetrics } from "@ensnode/ponder-metadata"; - -import { validatePonderMetrics } from "./validations"; - -export { PrometheusMetrics } from "@ensnode/ponder-metadata"; - -/** - * Fetch metrics for requested Ponder instance. - * - * @throws Will throw if the Ponder metrics are not valid. - */ -export async function fetchPonderMetrics(ponderAppUrl: URL): Promise { - const ponderMetricsUrl = new URL("/metrics", ponderAppUrl); - - try { - const metricsText = await fetch(ponderMetricsUrl).then((r) => r.text()); - - const ponderMetrics = PrometheusMetrics.parse(metricsText); - - validatePonderMetrics(ponderMetrics); - - return ponderMetrics; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - throw new Error( - `Could not fetch Ponder metrics from '${ponderMetricsUrl}' due to: ${errorMessage}`, - ); - } -} diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/status.ts b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/status.ts deleted file mode 100644 index 3a62155c9..000000000 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/status.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Ponder Metadata: Status - * - * This file describes ideas and functionality related to Ponder status for - * each indexed chain. Ponder status is defined by `/status` endpoint. - */ - -import type { PonderStatus } from "@ensnode/ponder-metadata"; - -export type { PonderStatus } from "@ensnode/ponder-metadata"; - -/** - * Fetch Status for requested Ponder instance. - */ -export async function fetchPonderStatus(ponderAppUrl: URL): Promise { - const ponderStatusUrl = new URL("/status", ponderAppUrl); - - try { - const statusJson = await fetch(ponderStatusUrl).then((r) => r.json()); - - return statusJson as PonderStatus; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - throw new Error( - `Could not fetch Ponder status from '${ponderStatusUrl}' due to: ${errorMessage}`, - ); - } -} diff --git a/apps/ensindexer/src/lib/ponder-helpers.test.ts b/apps/ensindexer/src/lib/ponder-helpers.test.ts index e2db67e64..f9bdaebb8 100644 --- a/apps/ensindexer/src/lib/ponder-helpers.test.ts +++ b/apps/ensindexer/src/lib/ponder-helpers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Blockrange } from "@ensnode/ensnode-sdk"; -import { constrainBlockrange, createStartBlockByChainIdMap } from "./ponder-helpers"; +import { constrainBlockrange } from "./ponder-helpers"; const UNDEFINED_BLOCKRANGE = { startBlock: undefined, endBlock: undefined } satisfies Blockrange; const BLOCKRANGE_WITH_END = { startBlock: undefined, endBlock: 1234 } satisfies Blockrange; @@ -90,38 +90,4 @@ describe("ponder helpers", () => { }); }); }); - - describe("createStartBlockByChainIdMap", () => { - it("should return a map of start blocks by chain ID", async () => { - const partialPonderConfig = { - contracts: { - "subgraph/Registrar": { - chain: { - "1": { id: 1, startBlock: 444_444_444 }, - }, - }, - "subgraph/Registry": { - chain: { - "1": { id: 1, startBlock: 444_444_333 }, - }, - }, - "basenames/Registrar": { - chain: { - "8453": { id: 8453, startBlock: 1_799_433 }, - }, - }, - "basenames/Registry": { - chain: { - "8453": { id: 8453, startBlock: 1_799_430 }, - }, - }, - }, - }; - - expect(await createStartBlockByChainIdMap(Promise.resolve(partialPonderConfig))).toEqual({ - 1: 444_444_333, - 8453: 1_799_430, - }); - }); - }); }); diff --git a/apps/ensindexer/src/lib/ponder-helpers.ts b/apps/ensindexer/src/lib/ponder-helpers.ts index 21d1dd493..1ac060420 100644 --- a/apps/ensindexer/src/lib/ponder-helpers.ts +++ b/apps/ensindexer/src/lib/ponder-helpers.ts @@ -6,12 +6,10 @@ import type { Event } from "ponder:registry"; import type { ChainConfig } from "ponder"; -import type { Address, PublicClient } from "viem"; -import * as z from "zod/v4"; +import type { Address } from "viem"; import type { ContractConfig } from "@ensnode/datasources"; import type { Blockrange, ChainId } from "@ensnode/ensnode-sdk"; -import type { BlockInfo, PonderStatus } from "@ensnode/ponder-metadata"; import type { ENSIndexerConfig } from "@/config/types"; @@ -51,209 +49,6 @@ export const constrainBlockrange = ( }; }; -/** - * Creates a Prometheus metrics fetcher for the Ponder application. - * - * It's a workaround for the lack of an internal API allowing to access - * Prometheus metrics for the Ponder application. - * - * @param ensIndexerUrl the URL of the "primary" ENSIndexer started using `ponder start` and not `ponder serve` - * @returns fetcher function - */ -export function createPrometheusMetricsFetcher(ensIndexerUrl: URL): () => Promise { - /** - * Fetches the Prometheus metrics from the Ponder application endpoint. - * @returns Prometheus metrics as a text string - */ - return async function fetchPrometheusMetrics(): Promise { - const response = await fetch(new URL("/metrics", ensIndexerUrl)); - return response.text(); - }; -} - -/** - * Ponder Data Schemas - * - * These schemas allow data validation with Zod. - */ -const PonderDataSchema = { - get Block() { - return z.object({ - number: z.number().int().min(1), - timestamp: z.number().int().min(1), - }); - }, - - get ChainId() { - return z.number().int().min(1); - }, - - get Status() { - return z.record( - z.string().transform(Number).pipe(PonderDataSchema.ChainId), - z.object({ - id: PonderDataSchema.ChainId, - block: PonderDataSchema.Block, - }), - ); - }, -}; - -/** - * Creates Ponder Status fetcher for the Ponder application. - * - * It's a workaround for the lack of an internal API allowing to access - * Ponder Status metrics for the Ponder application. - * - * @param ensIndexerUrl the URL of the "primary" ENSIndexer started using `ponder start` and not `ponder serve` - * @returns fetcher function - */ -export function createPonderStatusFetcher(ensIndexerUrl: URL): () => Promise { - /** - * Fetches the Ponder Ponder status from the Ponder application endpoint. - * @returns Parsed Ponder Status object. - */ - return async function fetchPonderStatus() { - const response = await fetch(new URL("/status", ensIndexerUrl)); - const responseData = await response.json(); - - return PonderDataSchema.Status.parse(responseData) satisfies PonderStatus; - }; -} - -/** - * Ponder contracts configuration including block range. - */ -interface PonderContractBlockConfig { - contracts: Record< - string, - { - chain: Record; - } - >; -} - -/** - * Creates a first block to index fetcher for the given ponder configuration. - */ -export function createFirstBlockToIndexByChainIdFetcher( - ponderConfig: Promise, -) { - /** - * Fetches the first block to index for the requested chain ID. - * - * @param chainId the chain ID to get the first block to index for - * @param publicClient the public client to fetch the block from - * - * @returns {Promise} the first block to index for the requested chain ID - * @throws if the start block number is not found for the chain ID - * @throws if the block is not available on the network - */ - return async function fetchFirstBlockToIndexByChainId( - chainId: number, - publicClient: PublicClient, - ): Promise { - const startBlockNumbers: Record = - await createStartBlockByChainIdMap(ponderConfig); - const startBlockNumberForChainId = startBlockNumbers[chainId]; - - // each chain should have a start block number - if (typeof startBlockNumberForChainId !== "number") { - // throw an error if the start block number is not found for the chain ID - throw new Error(`No start block number found for chain ID ${chainId}`); - } - - if (startBlockNumberForChainId < 0) { - // throw an error if the start block number is invalid block number - throw new Error( - `Start block number "${startBlockNumberForChainId}" for chain ID ${chainId} must be a non-negative integer`, - ); - } - - const block = await publicClient.getBlock({ - blockNumber: BigInt(startBlockNumberForChainId), - }); - - // the decided start block number should be available on the network - if (!block) { - // throw an error if the block is not available - throw Error(`Failed to fetch block ${startBlockNumberForChainId} for chainId ${chainId}`); - } - - // otherwise, return the start block info - return { - number: Number(block.number), - timestamp: Number(block.timestamp), - }; - }; -} - -/** - * Get start block number for each chain ID. - * - * @returns start block number for each chain ID. - * @example - * ```ts - * const ponderConfig = { - * contracts: { - * "subgraph/Registrar": { - * chain: { - * "1": { id: 1, startBlock: 444_444_444 } - * } - * }, - * "subgraph/Registry": { - * chain: { - * "1": { id: 1, startBlock: 444_444_333 } - * } - * }, - * "basenames/Registrar": { - * chain: { - * "8453": { id: 8453, startBlock: 1_799_433 } - * } - * }, - * "basenames/Registry": { - * chain: { - * "8453": { id: 8453, startBlock: 1_799_430 } - * } - * } - * }; - * - * const startBlockNumbers = await createStartBlockByChainIdMap(ponderConfig); - * - * console.log(startBlockNumbers); - * - * // Output: - * // { - * // 1: 444_444_333, - * // 8453: 1_799_430 - * // } - * ``` - */ -export async function createStartBlockByChainIdMap( - ponderConfig: Promise, -): Promise> { - const contractsConfig = Object.values((await ponderConfig).contracts); - - const startBlockNumbers: Record = {}; - - // go through each contract configuration - for (const contractConfig of contractsConfig) { - // and then through each chain configuration for the contract - for (const [_chainId, contractChainConfig] of Object.entries(contractConfig.chain)) { - // map string to number - const chainId = Number(_chainId); - const startBlock = contractChainConfig.startBlock || 0; - - // update the start block number for the chain ID if it's lower than the current one - if (!startBlockNumbers[chainId] || startBlock < startBlockNumbers[chainId]) { - startBlockNumbers[chainId] = startBlock; - } - } - } - - return startBlockNumbers; -} - /** /** * Builds a ponder#Config["chains"] for a single, specific chain in the context of the ENSIndexerConfig. diff --git a/apps/ensindexer/src/lib/ponder-local-client.ts b/apps/ensindexer/src/lib/ponder-local-client.ts new file mode 100644 index 000000000..81cba8e13 --- /dev/null +++ b/apps/ensindexer/src/lib/ponder-local-client.ts @@ -0,0 +1,27 @@ +import config from "@/config"; + +import pRetry from "p-retry"; + +import { PonderClient, PonderHealthCheckResults } from "@ensnode/ponder-sdk"; + +/** + * How many times retries should be attempted before + * {@link waitForPonderApplicationToBecomeHealthy} becomes + * a rejected promise. + */ +export const MAX_PONDER_APPLICATION_HEALTHCHECK_ATTEMPTS = 5; + +export const ponderClient = new PonderClient(config.ensIndexerUrl); + +export const waitForPonderApplicationToBecomeHealthy = pRetry( + async () => { + const response = await ponderClient.health(); + + if (response !== PonderHealthCheckResults.Ok) { + throw new Error("Ponder application is not healthy yet"); + } + }, + { + retries: MAX_PONDER_APPLICATION_HEALTHCHECK_ATTEMPTS, + }, +); diff --git a/packages/ponder-metadata/README.md b/packages/ponder-metadata/README.md deleted file mode 100644 index bf309a3fb..000000000 --- a/packages/ponder-metadata/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Ponder Metadata API - -Ponder Metadata API is a Hono middleware for making Ponder app metadata available to clients. diff --git a/packages/ponder-metadata/src/db-helpers.ts b/packages/ponder-metadata/src/db-helpers.ts deleted file mode 100644 index a3e3b4bae..000000000 --- a/packages/ponder-metadata/src/db-helpers.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { pgSchema, pgTable } from "drizzle-orm/pg-core"; -import { eq, type ReadonlyDrizzle } from "ponder"; - -/** - * Internal ponder metadata type. - * Copied from https://github.com/ponder-sh/ponder/blob/32634897bf65e92a85dc4cccdaba70c9425d90f3/packages/core/src/database/index.ts#L94-L102 - */ -type PonderAppMeta = { - is_locked: 0 | 1; - is_dev: 0 | 1; - heartbeat_at: number; - build_id: string; - table_names: Array; - version: string; - is_ready: 0 | 1; -}; - -/** - * Get DB schema for _ponder_meta table. - * Akin to https://github.com/ponder-sh/ponder/blob/32634897bf65e92a85dc4cccdaba70c9425d90f3/packages/core/src/database/index.ts#L129-L141 - * - * @param databaseNamespace A namespace for the database. - * @returns A table schema for _ponder_meta table. - * */ -const getPonderMetaTableSchema = (databaseNamespace: string) => { - if (databaseNamespace === "public") { - return pgTable("_ponder_meta", (t) => ({ - key: t.text().primaryKey().$type<"app">(), - value: t.jsonb().$type().notNull(), - })); - } - - return pgSchema(databaseNamespace).table("_ponder_meta", (t) => ({ - key: t.text().primaryKey().$type<"app">(), - value: t.jsonb().$type().notNull(), - })); -}; - -type PonderMetaTableSchema = ReturnType; - -/** - * Get ponder metadata for the app. - * - * @param namespace A namespace for the database (e.g. "public"). - * @param db Drizzle DB Client instance. - * @returns ponder metadata for the app. - * @throws Error if ponder metadata not found. - */ -export async function queryPonderMeta( - namespace: string, - db: ReadonlyDrizzle>, -): Promise { - const PONDER_META = getPonderMetaTableSchema(namespace); - - const [ponderAppMeta] = await db - .select({ value: PONDER_META.value }) - .from(PONDER_META) - .where(eq(PONDER_META.key, "app")) - .limit(1); - - if (!ponderAppMeta) { - throw new Error("Ponder metadata not found"); - } - - return ponderAppMeta.value; -} diff --git a/packages/ponder-metadata/src/index.ts b/packages/ponder-metadata/src/index.ts deleted file mode 100644 index ccaafe727..000000000 --- a/packages/ponder-metadata/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { queryPonderMeta } from "./db-helpers"; -export { type MetadataMiddlewareResponse, ponderMetadata } from "./middleware"; -export { PrometheusMetrics } from "./prometheus-metrics"; -export type { PonderMetadataMiddlewareResponse } from "./types/api"; -export type { BlockInfo, ChainIndexingStatus, PonderStatus } from "./types/common"; diff --git a/packages/ponder-metadata/src/middleware.ts b/packages/ponder-metadata/src/middleware.ts deleted file mode 100644 index d63bcca54..000000000 --- a/packages/ponder-metadata/src/middleware.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { MiddlewareHandler } from "hono"; -import { HTTPException } from "hono/http-exception"; - -import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; - -import { queryPonderMeta } from "./db-helpers"; -import { PrometheusMetrics } from "./prometheus-metrics"; -import type { - PonderEnvVarsInfo, - PonderMetadataMiddlewareOptions, - PonderMetadataMiddlewareResponse, -} from "./types/api"; -import type { BlockInfo, ChainIndexingStatus } from "./types/common"; - -/** - * Ponder Metadata types definition. - */ -interface PonderMetadataModule { - /** Application info */ - AppInfo: { - /** Application name */ - name: string; - /** Application version */ - version: string; - }; - - /** Environment Variables info */ - EnvVars: { - /** Database schema */ - DATABASE_SCHEMA: string; - } & PonderEnvVarsInfo; - - /** Runtime info */ - RuntimeInfo: { - /** - * Application build id - * https://github.com/ponder-sh/ponder/blob/626e524/packages/core/src/build/index.ts#L425-L431 - **/ - codebaseBuildId: string; - - /** Chain indexing statuses by chain ID */ - chainIndexingStatuses: { [chainId: number]: ChainIndexingStatus }; - - /** ENSRainbow version info */ - ensRainbow?: EnsRainbow.VersionInfo; - }; -} - -export type MetadataMiddlewareResponse = PonderMetadataMiddlewareResponse< - PonderMetadataModule["AppInfo"], - PonderMetadataModule["EnvVars"], - PonderMetadataModule["RuntimeInfo"] ->; - -export function ponderMetadata< - AppInfo extends PonderMetadataModule["AppInfo"], - EnvVars extends PonderMetadataModule["EnvVars"], ->({ - app, - db, - env, - query, - publicClients, -}: PonderMetadataMiddlewareOptions): MiddlewareHandler { - return async function ponderMetadataMiddleware(ctx) { - const indexedChainNames = Object.keys(publicClients); - - const ponderStatus = await query.ponderStatus(); - - const metrics = PrometheusMetrics.parse(await query.prometheusMetrics()); - - const chainIndexingStatuses: Record = {}; - - for (const indexedChainName of indexedChainNames) { - const publicClient = publicClients[indexedChainName]; - - if (!publicClient || typeof publicClient.chain === "undefined") { - throw new HTTPException(500, { - message: `No public client found for "${indexedChainName}" chain name`, - }); - } - - const publicClientChainId = publicClient.chain.id; - - /** - * Fetches block metadata from blockchain network for a given block number. - * @param blockNumber - * @returns block metadata - * @throws {Error} if failed to fetch block metadata from blockchain network - */ - const fetchBlockMetadata = async (blockNumber: number): Promise => { - const block = await publicClient.getBlock({ - blockNumber: BigInt(blockNumber), - }); - - if (!block) { - throw new Error( - `Failed to fetch block metadata for block number ${blockNumber} on chain ID "${publicClientChainId}"`, - ); - } - - return { - number: Number(block.number), - timestamp: Number(block.timestamp), - } satisfies BlockInfo; - }; - - const latestSafeBlockData = await publicClient.getBlock(); - - if (!latestSafeBlockData) { - throw new HTTPException(500, { - message: `Failed to fetch latest safe block for chain ID "${publicClientChainId}"`, - }); - } - - // mapping latest safe block - const latestSafeBlock = { - number: Number(latestSafeBlockData.number), - timestamp: Number(latestSafeBlockData.timestamp), - } satisfies BlockInfo; - - // mapping indexed chain name to its metric representation for metric queries - const chain = indexedChainName; - - // mapping last synced block if available - const lastSyncedBlockHeight = metrics.getValue("ponder_sync_block", { - chain, - }); - let lastSyncedBlock: BlockInfo | null = null; - if (lastSyncedBlockHeight) { - try { - lastSyncedBlock = await fetchBlockMetadata(lastSyncedBlockHeight); - } catch (error) { - console.error("Failed to fetch block metadata for last synced block", error); - } - } - - const firstBlockToIndex = await query.firstBlockToIndexByChainId( - publicClientChainId, - publicClient, - ); - - // mapping ponder status for current chain - const ponderStatusForChain = Object.values(ponderStatus).find( - (ponderStatusEntry) => ponderStatusEntry.id === publicClientChainId, - ); - - // mapping last indexed block if available - let lastIndexedBlock: BlockInfo | null = null; - if (ponderStatusForChain) { - // Since Ponder 0.11, the `block` value in the PonderStatus object - // is always provided. It represents either the very first block to be indexed - // or the last block that has been indexed. - // - // We compare the first block to be indexed (from ponder.config.ts) - // with the block value from the PonderStatus object (from `GET /status` response). - // We only set the `lastIndexedBlock` value if the `block` from the PonderStatus object - // is not the same as the `firstBlockToIndex`. - if (firstBlockToIndex.number < ponderStatusForChain.block.number) { - lastIndexedBlock = ponderStatusForChain.block; - } - } - - chainIndexingStatuses[publicClientChainId] = { - chainId: publicClientChainId, - lastSyncedBlock, - lastIndexedBlock, - latestSafeBlock, - firstBlockToIndex, - } satisfies ChainIndexingStatus; - } - - // mapping ponder app build id if available - let ponderAppBuildId: string | undefined; - try { - ponderAppBuildId = (await queryPonderMeta(env.DATABASE_SCHEMA, db)).build_id; - } catch (error) { - console.error("Failed to fetch ponder metadata", error); - } - - // fetch ENSRainbow version if available - let ensRainbowVersionInfo: EnsRainbow.VersionInfo | undefined; - if (query.ensRainbowVersion) { - try { - ensRainbowVersionInfo = await query.ensRainbowVersion(); - } catch (error) { - console.error("Failed to fetch ENSRainbow version", error); - } - } - - const response = { - app, - deps: { - ponder: formatTextMetricValue(metrics.getLabel("ponder_version_info", "version")), - nodejs: formatTextMetricValue(metrics.getLabel("nodejs_version_info", "version")), - }, - env, - runtime: { - codebaseBuildId: formatTextMetricValue(ponderAppBuildId), - chainIndexingStatuses, - ensRainbow: ensRainbowVersionInfo, - }, - } satisfies MetadataMiddlewareResponse; - - // validate if response is in correct state - validateResponse(response); - - return ctx.json(response); - }; -} - -/** - * Validates the metadata middleware response to ensure correct state. - * - * @param response The response to validate - * @throws {HTTPException} if the response is in an invalid state - */ -function validateResponse(response: MetadataMiddlewareResponse): void { - const { chainIndexingStatuses } = response.runtime; - - if (Object.keys(chainIndexingStatuses).length === 0) { - throw new HTTPException(500, { - message: "No chain indexing status found", - }); - } - - if (Object.values(chainIndexingStatuses).some((n) => n.firstBlockToIndex === null)) { - throw new HTTPException(500, { - message: "Failed to fetch first block to index for some chains", - }); - } -} - -/** - * Formats a text metric value. - * @param value - * @returns - */ -function formatTextMetricValue(value?: string): string { - return value ?? "unknown"; -} diff --git a/packages/ponder-metadata/src/types/api.ts b/packages/ponder-metadata/src/types/api.ts deleted file mode 100644 index 9215ea522..000000000 --- a/packages/ponder-metadata/src/types/api.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ReadonlyDrizzle } from "ponder"; -import type { PublicClient } from "viem"; - -import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; - -import type { BlockInfo, PonderStatus } from "./common"; - -export type PonderEnvVarsInfo = Record; - -/** - * Helper type which describes public clients grouped by chain name - */ -type PublicClientsByChainName = Record; - -export interface PonderMetadataMiddlewareOptions { - /** Database access object (readonly Drizzle) */ - db: ReadonlyDrizzle>; - - /** Application info */ - app: AppInfo; - - /** Environment settings info */ - env: EnvVars; - - /** Query methods */ - query: { - /** Fetches Ponder Status object for Ponder application */ - ponderStatus(): Promise; - - /** Fetches prometheus metrics for Ponder application */ - prometheusMetrics(): Promise; - - /** Fetches the first block do be indexed for a requested chain ID */ - firstBlockToIndexByChainId(chainId: number, publicClient: PublicClient): Promise; - - /** Fetches ENSRainbow version information */ - ensRainbowVersion?(): Promise; - }; - - /** Public clients for fetching data from each chain */ - publicClients: PublicClientsByChainName; -} - -export interface PonderMetadataMiddlewareResponse< - AppInfo, - EnvVarsInfo extends PonderEnvVarsInfo, - RuntimeInfo, -> { - /** Application info */ - app: AppInfo; - - /** Dependencies info */ - deps: { - /** Ponder application version */ - ponder: string; - - /** Node.js runtime version */ - nodejs: string; - }; - - /** Environment settings info */ - env: EnvVarsInfo; - - /** Runtime status info */ - runtime: RuntimeInfo; -} diff --git a/packages/ponder-metadata/src/types/common.ts b/packages/ponder-metadata/src/types/common.ts deleted file mode 100644 index dddc0b9d0..000000000 --- a/packages/ponder-metadata/src/types/common.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Basic information about a block. - */ -export interface BlockInfo { - /** block number */ - number: number; - - /** block unix timestamp */ - timestamp: number; -} - -/** - * Ponder Status type - * - * It's a type of value returned by the `GET /status` endpoint on ponder server. - * - * Akin to: - * https://github.com/ponder-sh/ponder/blob/8c012a3/packages/client/src/index.ts#L13-L18 - */ -export interface PonderStatus { - [chainName: string]: { - /** @var id Chain ID */ - id: number; - - /** @var block Last Indexed Block data */ - block: BlockInfo; - }; -} - -/** - * Indexing status for a chain. - */ -export interface ChainIndexingStatus { - /** Chain ID of the indexed chain */ - chainId: number; - - /** - * First block required to be indexed during the historical sync. - */ - firstBlockToIndex: BlockInfo; - - /** - * Latest block synced into indexer's RPC cache. - */ - lastSyncedBlock: BlockInfo | null; - - /** - * Last block processed & indexed by the indexer. - */ - lastIndexedBlock: BlockInfo | null; - - /** - * Latest safe block available on the chain. - */ - latestSafeBlock: BlockInfo; -} diff --git a/packages/ponder-metadata/tsup.config.ts b/packages/ponder-metadata/tsup.config.ts deleted file mode 100644 index f78ede093..000000000 --- a/packages/ponder-metadata/tsup.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["./src/index.ts"], - platform: "node", - format: ["esm"], - target: "node16", - bundle: true, - splitting: false, - sourcemap: true, - dts: true, - clean: true, - external: ["drizzle-orm", "ponder", "hono"], - outDir: "./dist", -}); diff --git a/packages/ponder-metadata/CHANGELOG.md b/packages/ponder-sdk/CHANGELOG.md similarity index 99% rename from packages/ponder-metadata/CHANGELOG.md rename to packages/ponder-sdk/CHANGELOG.md index 39f34ec90..5bbd58180 100644 --- a/packages/ponder-metadata/CHANGELOG.md +++ b/packages/ponder-sdk/CHANGELOG.md @@ -1,4 +1,4 @@ -# @ensnode/ponder-metadata +# @ensnode/ponder-sdk ## 1.3.1 diff --git a/packages/ponder-metadata/LICENSE b/packages/ponder-sdk/LICENSE similarity index 100% rename from packages/ponder-metadata/LICENSE rename to packages/ponder-sdk/LICENSE diff --git a/packages/ponder-sdk/README.md b/packages/ponder-sdk/README.md new file mode 100644 index 000000000..1921e6521 --- /dev/null +++ b/packages/ponder-sdk/README.md @@ -0,0 +1,3 @@ +# Ponder SDK + +This package is a set of libraries enabling smooth interaction with Ponder application and data, including shared types, data processing (such as validating data and enforcing invariants), and Ponder-oriented helper functions. diff --git a/packages/ponder-metadata/package.json b/packages/ponder-sdk/package.json similarity index 57% rename from packages/ponder-metadata/package.json rename to packages/ponder-sdk/package.json index 1e6833144..da3be6773 100644 --- a/packages/ponder-metadata/package.json +++ b/packages/ponder-sdk/package.json @@ -1,8 +1,8 @@ { - "name": "@ensnode/ponder-metadata", + "name": "@ensnode/ponder-sdk", "version": "1.3.1", "type": "module", - "description": "A Hono middleware for making Ponder app metadata available to clients.", + "description": "A utility library for interacting with Ponder application and data", "license": "MIT", "repository": { "type": "git", @@ -11,6 +11,7 @@ }, "homepage": "https://github.com/namehash/ensnode/tree/main/packages/ponder-metadata-api", "keywords": [ + "ENSNode", "Ponder" ], "files": [ @@ -23,12 +24,18 @@ "access": "public", "exports": { ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } } }, - "main": "./dist/index.js", - "module": "./dist/index.mjs", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts" }, "scripts": { @@ -39,22 +46,23 @@ "lint:ci": "biome ci" }, "dependencies": { - "@ensnode/ensrainbow-sdk": "workspace:*", - "drizzle-orm": "catalog:", - "parse-prometheus-text-format": "^1.1.1", - "viem": "catalog:" + "parse-prometheus-text-format": "^1.1.1" }, "devDependencies": { + "@ensnode/ensnode-sdk": "workspace:*", "@ensnode/shared-configs": "workspace:*", "@types/node": "catalog:", - "hono": "catalog:", "ponder": "catalog:", "tsup": "catalog:", "typescript": "catalog:", - "vitest": "catalog:" + "viem": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" }, "peerDependencies": { - "hono": "catalog:", - "ponder": "catalog:" + "@ensnode/ensnode-sdk": "workspace:*", + "ponder": "catalog:", + "viem": "catalog:", + "zod": "catalog:" } } diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/block-refs.ts b/packages/ponder-sdk/src/block-refs.ts similarity index 80% rename from apps/ensindexer/src/lib/indexing-status/ponder-metadata/block-refs.ts rename to packages/ponder-sdk/src/block-refs.ts index 7ac0987e2..72b6f745e 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/block-refs.ts +++ b/packages/ponder-sdk/src/block-refs.ts @@ -1,11 +1,11 @@ /** - * Ponder Metadata: Block Refs + * Ponder SDK: Block Refs * * This file describes ideas and functionality related to block references * based on configured chain names, chains blockranges, and RPC calls. */ -import type { BlockRef, Blockrange } from "@ensnode/ensnode-sdk"; +import type { BlockRef, Blockrange, ChainIdString } from "@ensnode/ensnode-sdk"; import type { ChainName } from "./config"; import type { PrometheusMetrics } from "./metrics"; @@ -38,34 +38,34 @@ export interface ChainBlockRefs { * Guaranteed to include {@link ChainBlockRefs} for each indexed chain. */ export async function getChainsBlockRefs( - chainNames: ChainName[], + chainIds: ChainIdString[], chainsBlockrange: Record, metrics: PrometheusMetrics, publicClients: Record, ): Promise> { const chainsBlockRefs = new Map(); - for (const chainName of chainNames) { - const blockrange = chainsBlockrange[chainName]; + for (const chainId of chainIds) { + const blockrange = chainsBlockrange[chainId]; const startBlock = blockrange?.startBlock; const endBlock = blockrange?.endBlock; - const publicClient = publicClients[chainName]; + const publicClient = publicClients[chainId]; if (typeof startBlock !== "number") { - throw new Error(`startBlock not found for chain ${chainName}`); + throw new Error(`startBlock not found for chain ${chainId}`); } if (typeof publicClient === "undefined") { - throw new Error(`publicClient not found for chain ${chainName}`); + throw new Error(`publicClient not found for chain ${chainId}`); } const historicalTotalBlocks = metrics.getValue("ponder_historical_total_blocks", { - chain: chainName, + chain: chainId, }); if (typeof historicalTotalBlocks !== "number") { - throw new Error(`No historical total blocks metric found for chain ${chainName}`); + throw new Error(`No historical total blocks metric found for chain ${chainId}`); } const backfillEndBlock = startBlock + historicalTotalBlocks - 1; @@ -86,9 +86,9 @@ export async function getChainsBlockRefs( backfillEndBlock: backfillEndBlockRef, } satisfies ChainBlockRefs; - chainsBlockRefs.set(chainName, chainBlockRef); + chainsBlockRefs.set(chainId, chainBlockRef); } catch { - throw new Error(`Could not get BlockRefs for chain ${chainName}`); + throw new Error(`Could not get BlockRefs for chain ${chainId}`); } } diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/ponder-metadata.test.ts b/packages/ponder-sdk/src/chains.test.ts similarity index 100% rename from apps/ensindexer/src/lib/indexing-status/ponder-metadata/ponder-metadata.test.ts rename to packages/ponder-sdk/src/chains.test.ts diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/chains.ts b/packages/ponder-sdk/src/chains.ts similarity index 91% rename from apps/ensindexer/src/lib/indexing-status/ponder-metadata/chains.ts rename to packages/ponder-sdk/src/chains.ts index 76887f495..d74017221 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/chains.ts +++ b/packages/ponder-sdk/src/chains.ts @@ -1,8 +1,8 @@ /** - * Ponder Metadata: Chains + * Ponder SDK: Chains * * This file describes ideas and functionality related to metadata about chains - * indexing status. In this module, ideas represented in other Ponder Metadata + * indexing status. In this module, ideas represented in other Ponder SDK * modules, such as: * - Config * - Metrics @@ -38,13 +38,23 @@ import { type SerializedOmnichainIndexingStatusSnapshotFollowing, type SerializedOmnichainIndexingStatusSnapshotUnstarted, } from "@ensnode/ensnode-sdk"; -import type { PrometheusMetrics } from "@ensnode/ponder-metadata"; import type { ChainBlockRefs } from "./block-refs"; import type { ChainName } from "./config"; -import type { PonderStatus } from "./status"; +import type { PonderMetricsResponse, PonderStatusResponse } from "./response"; import { makePonderChainMetadataSchema } from "./zod-schemas"; +/** + * Ponder Status Chain + */ +export interface PonderStatusChain { + /** Chain ID */ + id: ChainId; + + /** Latest Indexed Block Ref */ + block: BlockRef; +} + /** * Chain Metadata * @@ -248,41 +258,41 @@ export function createSerializedOmnichainIndexingStatusSnapshot( * calling {@link createOmnichainIndexingSnapshot}. */ export function createSerializedChainSnapshots( - chainNames: ChainName[], + chainIds: ChainIdString[], chainsBlockRefs: Map, - metrics: PrometheusMetrics, - status: PonderStatus, + metrics: PonderMetricsResponse, + status: PonderStatusResponse, ): Record { const chainsMetadata = new Map(); // collect unvalidated chain metadata for each indexed chain - for (const chainName of chainNames) { - const chainBlockRefs = chainsBlockRefs.get(chainName); + for (const chainId of chainIds) { + const chainBlockRefs = chainsBlockRefs.get(chainId); const chainMetadata = { - chainId: status[chainName]?.id, + chainId: status[chainId]?.id, config: chainBlockRefs?.config, backfillEndBlock: chainBlockRefs?.backfillEndBlock, historicalTotalBlocks: metrics.getValue("ponder_historical_total_blocks", { - chain: chainName, + chain: chainId, }), - isSyncComplete: metrics.getValue("ponder_sync_is_complete", { chain: chainName }), - isSyncRealtime: metrics.getValue("ponder_sync_is_realtime", { chain: chainName }), + isSyncComplete: metrics.getValue("ponder_sync_is_complete", { chain: chainId }), + isSyncRealtime: metrics.getValue("ponder_sync_is_realtime", { chain: chainId }), syncBlock: { - number: metrics.getValue("ponder_sync_block", { chain: chainName }), - timestamp: metrics.getValue("ponder_sync_block_timestamp", { chain: chainName }), + number: metrics.getValue("ponder_sync_block", { chain: chainId }), + timestamp: metrics.getValue("ponder_sync_block_timestamp", { chain: chainId }), }, statusBlock: { - number: status[chainName]?.block.number, - timestamp: status[chainName]?.block.timestamp, + number: status[chainId]?.block.number, + timestamp: status[chainId]?.block.timestamp, }, } satisfies UnvalidatedChainMetadata; - chainsMetadata.set(chainName, chainMetadata); + chainsMetadata.set(chainId, chainMetadata); } // parse chain metadata for each indexed chain - const schema = makePonderChainMetadataSchema(chainNames); + const schema = makePonderChainMetadataSchema(chainIds); const parsed = schema.safeParse(chainsMetadata); if (!parsed.success) { diff --git a/packages/ponder-sdk/src/client.ts b/packages/ponder-sdk/src/client.ts new file mode 100644 index 000000000..3fa318081 --- /dev/null +++ b/packages/ponder-sdk/src/client.ts @@ -0,0 +1,119 @@ +import { + deserializePonderMetricsResponse, + deserializePonderStatusResponse, + type PonderMetricsResponse, + type PonderStatusResponse, +} from "./response"; + +export const PonderHealthCheckResults = { + /** + * Ponder Health is unknown if the health check endpoint is unavailable. + */ + Unknown: "unknown", + + /** + * Ponder Health is not OK if the health check endpoint returned + * HTTP status other than `2xx`. + */ + NotOk: "not-ok", + + /** + * Ponder Health is OK if the health check endpoint returned + * `2xx` HTTP status. + */ + Ok: "ok", +} as const; + +export type PonderHealthCheckResult = + (typeof PonderHealthCheckResults)[keyof typeof PonderHealthCheckResults]; + +export class PonderClient { + #healthCheckResult: PonderHealthCheckResult | undefined; + + constructor(private ponderApplicationUrl: URL) {} + + /** + * Ponder health check endpoint. + * + * @returns Ponder health check result. + */ + public async health(): Promise { + let response: Response; + + try { + response = await fetch(new URL("/health", this.ponderApplicationUrl)); + + if (!response.ok) { + this.#healthCheckResult = PonderHealthCheckResults.NotOk; + } else { + this.#healthCheckResult = PonderHealthCheckResults.Ok; + } + } catch { + this.#healthCheckResult = PonderHealthCheckResults.Unknown; + } + + return this.#healthCheckResult; + } + + /** + * Is Ponder app "ready"? + * + * @throws error about Ponder `/ready` endpoint not being supported. + * ENSNode makes no use of that endpoint. + */ + public ready(): never { + throw new Error("Ponder `/ready` endpoint is not supported by this client."); + } + + /** + * Ponder status + * + * @throws if the Ponder application request fails + * @throws if the Ponder application returns an error response + * @throws if the Ponder application response breaks required invariants + */ + public async status(): Promise { + this.validateHealthCheckResult(); + + const response = await fetch(new URL("/status", this.ponderApplicationUrl)); + const responseJson = await response.json(); + + return deserializePonderStatusResponse(responseJson); + } + + /** + * Ponder metrics + * + * @throws if the Ponder application request fails + * @throws if the Ponder application returns an error response + * @throws if the Ponder application response breaks required invariants + */ + public async metrics(): Promise { + this.validateHealthCheckResult(); + + const response = await fetch(new URL("/metrics", this.ponderApplicationUrl)); + const responseText = await response.text(); + + return deserializePonderMetricsResponse(responseText); + } + + /** + * Validate ENSIndexer health check result. + * + * @throws if the health check result is other than + * {@link PonderHealthCheckResults.Ok}. + */ + private validateHealthCheckResult(): void { + if (typeof this.#healthCheckResult === "undefined") { + throw new Error( + "Running health check for Ponder application is required. Call the 'health()' method first.", + ); + } + + if (this.#healthCheckResult !== PonderHealthCheckResults.Ok) { + throw new Error( + `Ponder application 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/apps/ensindexer/src/lib/indexing-status/ponder-metadata/config.ts b/packages/ponder-sdk/src/config.ts similarity index 99% rename from apps/ensindexer/src/lib/indexing-status/ponder-metadata/config.ts rename to packages/ponder-sdk/src/config.ts index 9f6d7ac1c..5f53c692f 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/config.ts +++ b/packages/ponder-sdk/src/config.ts @@ -1,5 +1,5 @@ /** - * Ponder Metadata: Config + * Ponder SDK: Config * * This file is about parsing the object that is exported by `ponder.config.ts`. * diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/index.ts b/packages/ponder-sdk/src/index.ts similarity index 70% rename from apps/ensindexer/src/lib/indexing-status/ponder-metadata/index.ts rename to packages/ponder-sdk/src/index.ts index cbc1dd349..311b5ff41 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/index.ts +++ b/packages/ponder-sdk/src/index.ts @@ -1,6 +1,7 @@ export * from "./block-refs"; export * from "./chains"; +export * from "./client"; export * from "./config"; export * from "./metrics"; +export * from "./response"; export * from "./rpc"; -export * from "./status"; diff --git a/packages/ponder-sdk/src/metrics/index.ts b/packages/ponder-sdk/src/metrics/index.ts new file mode 100644 index 000000000..700321713 --- /dev/null +++ b/packages/ponder-sdk/src/metrics/index.ts @@ -0,0 +1,9 @@ +/** + * Ponder SDK: Metrics + * + * This file describes ideas and functionality related to Ponder metrics for + * each indexed chain. Ponder metrics are defined by `/metrics` endpoint. + */ + +export * from "./prometheus-metrics"; +export * from "./validate-ponder-metrics"; diff --git a/packages/ponder-metadata/src/types/parse-prometheus-text-format.ts b/packages/ponder-sdk/src/metrics/parse-prometheus-text-format.ts similarity index 100% rename from packages/ponder-metadata/src/types/parse-prometheus-text-format.ts rename to packages/ponder-sdk/src/metrics/parse-prometheus-text-format.ts diff --git a/packages/ponder-metadata/src/prometheus-metrics.test.ts b/packages/ponder-sdk/src/metrics/prometheus-metrics.test.ts similarity index 100% rename from packages/ponder-metadata/src/prometheus-metrics.test.ts rename to packages/ponder-sdk/src/metrics/prometheus-metrics.test.ts diff --git a/packages/ponder-metadata/src/prometheus-metrics.ts b/packages/ponder-sdk/src/metrics/prometheus-metrics.ts similarity index 99% rename from packages/ponder-metadata/src/prometheus-metrics.ts rename to packages/ponder-sdk/src/metrics/prometheus-metrics.ts index 24c547069..ec94b8a77 100644 --- a/packages/ponder-metadata/src/prometheus-metrics.ts +++ b/packages/ponder-sdk/src/metrics/prometheus-metrics.ts @@ -1,6 +1,6 @@ import parsePrometheusTextFormat, { type PrometheusMetric } from "parse-prometheus-text-format"; // Ensures local declaration file is available to downstream consumers -import "./types/parse-prometheus-text-format"; +import "./parse-prometheus-text-format"; interface ParsedPrometheusMetric extends Omit { metrics: Array<{ diff --git a/packages/ponder-sdk/src/metrics/validate-ponder-metrics.ts b/packages/ponder-sdk/src/metrics/validate-ponder-metrics.ts new file mode 100644 index 000000000..f1acb7114 --- /dev/null +++ b/packages/ponder-sdk/src/metrics/validate-ponder-metrics.ts @@ -0,0 +1,25 @@ +import { prettifyError } from "zod/v4"; + +import { PonderAppSettingsSchema } from "../zod-schemas"; +import type { PrometheusMetrics } from "./prometheus-metrics"; + +/** + * Validate Ponder Metrics + * + * @param metrics - Prometheus Metrics from Ponder + * + * @throws Will throw if the Ponder metrics are not valid. + */ +export function validatePonderMetrics(metrics: PrometheusMetrics) { + // Invariant: Ponder command & ordering are as expected + const parsedAppSettings = PonderAppSettingsSchema.safeParse({ + command: metrics.getLabel("ponder_settings_info", "command"), + ordering: metrics.getLabel("ponder_settings_info", "ordering"), + }); + + if (parsedAppSettings.error) { + throw new Error( + `Failed to build IndexingStatus object: \n${prettifyError(parsedAppSettings.error)}\n`, + ); + } +} diff --git a/packages/ponder-sdk/src/response.ts b/packages/ponder-sdk/src/response.ts new file mode 100644 index 000000000..3c03bd018 --- /dev/null +++ b/packages/ponder-sdk/src/response.ts @@ -0,0 +1,47 @@ +import { prettifyError } from "zod/v4"; + +import type { ChainIdString } from "@ensnode/ensnode-sdk"; + +import type { PonderStatusChain } from "./chains"; +import { PrometheusMetrics, validatePonderMetrics } from "./metrics"; +import { makePonderStatusResponseSchema } from "./zod-schemas"; + +export type PonderStatusResponse = Record; + +export type PonderMetricsResponse = PrometheusMetrics; + +/** + * Deserialized Ponder Status Response + * + * @throws when provided input cannot be parsed successfully + */ +export function deserializePonderStatusResponse(maybePonderStatus: unknown): PonderStatusResponse { + const schema = makePonderStatusResponseSchema(); + const parsed = schema.safeParse(maybePonderStatus); + + if (parsed.error) { + throw new Error(`Cannot deserialize PonderStatusResponse:\n${prettifyError(parsed.error)}\n`); + } + + return parsed.data; +} + +/** + * Deserialize Ponder Metrics Response + * + * @throws when provided input cannot be parsed successfully + */ +export function deserializePonderMetricsResponse( + maybePonderMetrics: string, +): PonderMetricsResponse { + try { + const ponderMetrics = PrometheusMetrics.parse(maybePonderMetrics); + + validatePonderMetrics(ponderMetrics); + + return ponderMetrics; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Cannot deserialize PonderMetricsResponse:\n${errorMessage}\n`); + } +} diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/rpc.ts b/packages/ponder-sdk/src/rpc.ts similarity index 97% rename from apps/ensindexer/src/lib/indexing-status/ponder-metadata/rpc.ts rename to packages/ponder-sdk/src/rpc.ts index 69b88c9f9..936d4efb0 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/rpc.ts +++ b/packages/ponder-sdk/src/rpc.ts @@ -1,5 +1,5 @@ /** - * Ponder Metadata: RPC + * Ponder SDK: RPC * * This file includes functionality required to read RPC data. * The `PublicClient` type matches the type used for values of diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/validations.ts b/packages/ponder-sdk/src/validations.ts similarity index 68% rename from apps/ensindexer/src/lib/indexing-status/ponder-metadata/validations.ts rename to packages/ponder-sdk/src/validations.ts index dab8f7160..12bcf3234 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/validations.ts +++ b/packages/ponder-sdk/src/validations.ts @@ -1,4 +1,4 @@ -import { type ParsePayload, prettifyError } from "zod/v4/core"; +import type { ParsePayload } from "zod/v4/core"; import { checkChainIndexingStatusSnapshotsForOmnichainStatusSnapshotBackfill, @@ -8,30 +8,6 @@ import { OmnichainIndexingStatusIds, type SerializedOmnichainIndexingStatusSnapshot, } from "@ensnode/ensnode-sdk"; -import type { PrometheusMetrics } from "@ensnode/ponder-metadata"; - -import { PonderAppSettingsSchema } from "./zod-schemas"; - -/** - * Validate Ponder Metrics - * - * @param metrics - Prometheus Metrics from Ponder - * - * @throws Will throw if the Ponder metrics are not valid. - */ -export function validatePonderMetrics(metrics: PrometheusMetrics) { - // Invariant: Ponder command & ordering are as expected - const parsedAppSettings = PonderAppSettingsSchema.safeParse({ - command: metrics.getLabel("ponder_settings_info", "command"), - ordering: metrics.getLabel("ponder_settings_info", "ordering"), - }); - - if (parsedAppSettings.error) { - throw new Error( - `Failed to build IndexingStatus object: \n${prettifyError(parsedAppSettings.error)}\n`, - ); - } -} /** * Invariant: SerializedOmnichainSnapshot Has Valid Chains diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/zod-schemas.ts b/packages/ponder-sdk/src/zod-schemas.ts similarity index 69% rename from apps/ensindexer/src/lib/indexing-status/ponder-metadata/zod-schemas.ts rename to packages/ponder-sdk/src/zod-schemas.ts index 2e0f0d777..7ce4038b3 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/zod-schemas.ts +++ b/packages/ponder-sdk/src/zod-schemas.ts @@ -17,13 +17,13 @@ import type { ChainIdString, ChainIndexingStatusSnapshot } from "@ensnode/ensnod import { makeBlockRefSchema, makeChainIdSchema, + makeChainIdStringSchema, makeNonNegativeIntegerSchema, } from "@ensnode/ensnode-sdk/internal"; import { createChainIndexingSnapshot } from "./chains"; -import type { ChainName } from "./config"; -const makeChainNameSchema = (indexedChainNames: string[]) => z.enum(indexedChainNames); +const makeChainIdsSchema = (chainIds: string[]) => z.enum(chainIds); const PonderBlockRefSchema = makeBlockRefSchema(); @@ -52,16 +52,16 @@ const PonderChainMetadataSchema = z.strictObject({ statusBlock: PonderBlockRefSchema, }); -export const makePonderChainMetadataSchema = (indexedChainNames: string[]) => { - const ChainNameSchema = makeChainNameSchema(indexedChainNames); +export const makePonderChainMetadataSchema = (chainIds: ChainIdString[]) => { + const ChainIdsSchema = makeChainIdsSchema(chainIds); - const invariant_definedEntryForEachIndexedChain = (v: Map) => - indexedChainNames.every((chainName) => Array.from(v.keys()).includes(chainName)); + const invariant_definedEntryForEachIndexedChain = (v: Map) => + chainIds.every((chainIds) => Array.from(v.keys()).includes(chainIds)); return z - .map(ChainNameSchema, PonderChainMetadataSchema) + .map(ChainIdsSchema, PonderChainMetadataSchema) .refine(invariant_definedEntryForEachIndexedChain, { - error: "All `indexedChainNames` must be represented by Ponder Chains Block Refs object.", + error: "All `chainIds` must be represented by Ponder Chains Block Refs object.", }) .transform((chains) => { @@ -70,9 +70,9 @@ export const makePonderChainMetadataSchema = (indexedChainNames: string[]) => { ChainIndexingStatusSnapshot >; - for (const chainName of indexedChainNames) { + for (const chainId of chainIds) { // biome-ignore lint/style/noNonNullAssertion: guaranteed to exist - const indexedChain = chains.get(chainName)!; + const indexedChain = chains.get(chainId)!; serializedChainIndexingStatusSnapshots[indexedChain.chainId] = createChainIndexingSnapshot(indexedChain); @@ -82,18 +82,22 @@ export const makePonderChainMetadataSchema = (indexedChainNames: string[]) => { }); }; -export const makePonderStatusSchema = (valueLabel?: "Value") => { +export const makePonderStatusChainSchema = (valueLabel = "Ponder Status Chain") => { const chainIdSchema = makeChainIdSchema(valueLabel); const blockRefSchema = makeBlockRefSchema(valueLabel); - return z.record( - z.string().transform(Number).pipe(chainIdSchema), - z.object({ - id: chainIdSchema, - block: blockRefSchema, - }), + return z.object({ + id: chainIdSchema, + block: blockRefSchema, + }); +}; + +export const makePonderStatusResponseSchema = (valueLabel = "Ponder Status Response") => + z.record( + makeChainIdStringSchema(`${valueLabel}.key`), + makePonderStatusChainSchema(`${valueLabel}.value`), { - error: "Ponder Status must be an object mapping valid chain name to a chain status object.", + error: + "Ponder Status Response must be an object mapping valid chain ID to a ponder status chain object.", }, ); -}; diff --git a/packages/ponder-metadata/tsconfig.json b/packages/ponder-sdk/tsconfig.json similarity index 100% rename from packages/ponder-metadata/tsconfig.json rename to packages/ponder-sdk/tsconfig.json diff --git a/packages/ponder-sdk/tsup.config.ts b/packages/ponder-sdk/tsup.config.ts new file mode 100644 index 000000000..5e48f2e9d --- /dev/null +++ b/packages/ponder-sdk/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + platform: "neutral", + format: ["esm", "cjs"], + target: "es2022", + bundle: true, + splitting: false, + sourcemap: true, + dts: true, + clean: true, + external: ["@ensnode/ensnode-sdk", "ponder", "viem", "zod"], + noExternal: ["parse-prometheus-text-format"], + outDir: "./dist", +}); diff --git a/packages/ponder-metadata/vitest.config.ts b/packages/ponder-sdk/vitest.config.ts similarity index 100% rename from packages/ponder-metadata/vitest.config.ts rename to packages/ponder-sdk/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a91677811..c8198892c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ catalogs: hono: specifier: ^4.10.2 version: 4.10.3 + p-retry: + specifier: ^7.1.1 + version: 7.1.1 pg-connection-string: specifier: ^2.9.1 version: 2.9.1 @@ -129,9 +132,6 @@ importers: '@ensnode/ensnode-sdk': specifier: workspace:* version: link:../../packages/ensnode-sdk - '@ensnode/ponder-metadata': - specifier: workspace:* - version: link:../../packages/ponder-metadata '@formkit/auto-animate': specifier: ^0.9.0 version: 0.9.0 @@ -356,8 +356,8 @@ importers: specifier: ^8.0.0 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 @@ -410,9 +410,9 @@ importers: '@ensnode/ensrainbow-sdk': specifier: workspace:* version: link:../../packages/ensrainbow-sdk - '@ensnode/ponder-metadata': + '@ensnode/ponder-sdk': specifier: workspace:* - version: link:../../packages/ponder-metadata + version: link:../../packages/ponder-sdk caip: specifier: 'catalog:' version: 1.1.1 @@ -428,6 +428,9 @@ importers: hono: specifier: 'catalog:' version: 4.10.3 + p-retry: + specifier: 'catalog:' + version: 7.1.1 pg-connection-string: specifier: 'catalog:' version: 2.9.1 @@ -859,30 +862,21 @@ importers: specifier: 'catalog:' version: 4.0.5(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) - packages/ponder-metadata: + packages/ponder-sdk: dependencies: - '@ensnode/ensrainbow-sdk': - specifier: workspace:* - 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) parse-prometheus-text-format: specifier: ^1.1.1 version: 1.1.1 - viem: - specifier: 'catalog:' - version: 2.38.5(typescript@5.9.3)(zod@3.25.76) devDependencies: + '@ensnode/ensnode-sdk': + specifier: workspace:* + version: link:../ensnode-sdk '@ensnode/shared-configs': specifier: workspace:* version: link:../shared-configs '@types/node': specifier: 'catalog:' version: 22.18.13 - hono: - specifier: 'catalog:' - 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) @@ -892,9 +886,15 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + 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@22.18.13)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + zod: + specifier: 'catalog:' + version: 3.25.76 packages/ponder-subgraph: dependencies: @@ -6651,8 +6651,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: @@ -15317,7 +15317,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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1f6fab8c..b2ed6d731 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,7 @@ catalog: date-fns: 4.1.0 drizzle-orm: "=0.41.0" hono: ^4.10.2 + p-retry: ^7.1.1 pg-connection-string: ^2.9.1 pino: 10.1.0 ponder: 0.13.16