Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 14 additions & 20 deletions apps/ensindexer/src/ponder/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import config from "@/config";

import type { ENSIndexerConfig } from "@/config/types";
import { mergePonderConfigs } from "@/lib/merge-ponder-configs";
import { ALL_PLUGINS, type AllPluginsMergedConfig } from "@/plugins";

import { buildIndexingBehaviorInjectionContracts } from "./indexing-behavior-injection-contract";

////////
// Merge the active plugins' configs into a single ponder config.
////////
Expand All @@ -19,29 +20,22 @@ const ponderConfig = activePlugins.reduce(
{},
) as AllPluginsMergedConfig;

// NOTE: here we inject all values from the ENSIndexerConfig that alter the indexing behavior of the
// NOTE: here we inject all values that alter the indexing behavior of the
// Ponder config in order to alter the ponder-generated build id when these options change.
//
// This ensures that running ENSIndexer with different configurations maintains compatibility with
// Ponder's default crash recovery behavior.
//
// Ponder's default crash recovery behavior:
// https://ponder.sh/docs/api-reference/ponder/database#build-id-and-crash-recovery
(ponderConfig as any).indexingBehaviorDependencies = {
// while technically not necessary, since these configuration properties are reflected in the
// generated ponderConfig, we include them here for clarity
namespace: config.namespace,
plugins: config.plugins,
globalBlockrange: config.globalBlockrange,

// these config properties don't explicitly affect the generated ponderConfig and need to be
// injected here to ensure that, if they are configured differently, ponder generates a unique
// build id to differentiate between runs with otherwise-identical configs (see above).
isSubgraphCompatible: config.isSubgraphCompatible,
labelSet: config.labelSet,
} satisfies Pick<
ENSIndexerConfig,
"namespace" | "plugins" | "globalBlockrange" | "isSubgraphCompatible" | "labelSet"
>;
//
// Since Ponder Build ID is generated based on the value of
// the `contracts` field in `ponderConfig`, we inject
// a special "indexing behavior injection" contract here to make sure
// all indexing behavior dependencies are included and can drive the Ponder Build ID.
ponderConfig.contracts = Object.assign(
{},
ponderConfig.contracts,
buildIndexingBehaviorInjectionContracts(),
);

////////
// Set indexing order strategy
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import config from "@/config";

import { getENSRootChainId } from "@ensnode/datasources";

import type { EnsIndexerConfig } from "@/config/types";
import { ensDbClient } from "@/lib/ensdb/singleton";

/**
* Indexing Behavior Dependencies
*
* The following values are indexing behavior dependencies:
* - ENSDb: Schema checksum
* - Different ENSDb Schema definitions may influence indexing behavior.
* - ENSIndexer: config fields
* - `namespace`
* - Changes the datasources used for indexing, which influences the indexing behavior.
* - `plugins`
* - Can change the indexed chains and contracts, which influences the indexing behavior.
* - `globalBlockrange`
* - Changes the blockrange of indexed chains, which influences the indexing behavior.
* - `isSubgraphCompatible`
* - Changes the indexing logic, which influences the indexing behavior.
* - `labelSet`
* - Changes the label set used for healing labels during indexing, which influences the indexing behavior.
*/
const indexingBehaviorDependencies = {
// while technically not necessary, since these configuration properties are reflected in the
// generated ponderConfig, we include them here for clarity
namespace: config.namespace,
plugins: config.plugins,
globalBlockrange: config.globalBlockrange,
// these config properties don't explicitly affect the generated ponderConfig and need to be
// injected here to ensure that, if they are configured differently, ponder generates a unique
// build id to differentiate between runs with otherwise-identical configs (see above).
isSubgraphCompatible: config.isSubgraphCompatible,
labelSet: config.labelSet,
ensDbSchemaChecksum: ensDbClient.ensDbSchemaChecksum,
} satisfies Pick<
EnsIndexerConfig,
"namespace" | "plugins" | "globalBlockrange" | "isSubgraphCompatible" | "labelSet"
> & { ensDbSchemaChecksum: string };

// We use the root chain ID to build a minimal representation of a valid contract config for ENSIndexer.
const rootChainId = getENSRootChainId(config.namespace);

/**
* Build a special "indexing behavior injection" contracts config for ENSIndexer
* to inject into `contracts` field of the Ponder config. The contracts config
* does not represent any real contracts to index, but rather serves as
* a container for all indexing behavior dependencies that should be reflected
* in the Ponder build ID.
*/
export function buildIndexingBehaviorInjectionContracts() {
return {
IndexingBehaviorInjectionContract: {
chain: `${rootChainId}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
chain: `${rootChainId}`,
chain: {
[rootChainId.toString()]: {
address: "0x0000000000000000000000000000000000000000",
startBlock: 0,
endBlock: 0,
},
},

The IndexingBehaviorInjectionContract has an invalid chain property structure - it's a string instead of an object mapping chain IDs to configuration objects

Fix on Vercel

indexingBehaviorDependencies,
},
Comment on lines +53 to +58
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildIndexingBehaviorInjectionContracts() adds an entry under ponderConfig.contracts, but the returned object doesn’t match the contract config shape used throughout the codebase (e.g. plugins always provide abi and a chain object keyed by chainId via chainConfigForContract(...)). With the current { chain: "${rootChainId}", indexingBehaviorDependencies }, Ponder config validation/typechecking is likely to fail at runtime or silently ignore the entry for build-id purposes.

Suggestion: make this a valid minimal contract config (include abi and use a chain mapping like { [rootChainId.toString()]: { address, startBlock, endBlock } }), and ensure the chosen blockrange/address won’t cause unnecessary indexing work.

Copilot uses AI. Check for mistakes.
} as const;
}
Comment on lines +53 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Module-level initialization occurs at import time.

The indexingBehaviorDependencies and rootChainId constants are evaluated when this module is first imported, not when buildIndexingBehaviorInjectionContracts() is called. This is fine for the current use case where config and ensDbClient are singleton modules that initialize synchronously. Just be aware that any import-time failures in those dependencies would surface here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensindexer/src/ponder/indexing-behavior-injection-contract.ts` around
lines 53 - 60, The current code uses module-level constants
indexingBehaviorDependencies and rootChainId which are evaluated at import time,
so change buildIndexingBehaviorInjectionContracts() to compute or fetch those
values inside the function instead of relying on the module-level variables:
move the logic that derives indexingBehaviorDependencies and rootChainId into
the body of buildIndexingBehaviorInjectionContracts (or call the functions that
return them) so the values are evaluated at call time; update references inside
the function to use the locally computed variables and leave the exported
function signature (buildIndexingBehaviorInjectionContracts) unchanged.

18 changes: 18 additions & 0 deletions packages/ensdb-sdk/src/client/ensdb-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
buildIndividualEnsDbSchemas,
type EnsDbDrizzleClient,
type EnsNodeSchema,
getDrizzleSchemaChecksum,
} from "../lib/drizzle";
import { EnsNodeMetadataKeys } from "./ensnode-metadata";
import type {
Expand Down Expand Up @@ -94,6 +95,23 @@ export class EnsDbReader<
return this.drizzleClient;
}

/**
* Getter for the ENSDb Schema Checksum
*
* The Schema Checksum is a hash based on the "concrete" ENSIndexer Schema and
* the ENSNode Schema definitions used in the Drizzle client for ENSDb.
*
* It can be used to verify that the {@link ensDb} Drizzle client
* is using the expected schema definitions while interacting with
* the ENSDb instance.
*/
get ensDbSchemaChecksum(): string {
return getDrizzleSchemaChecksum({
...this._concreteEnsIndexerSchema,
...this._ensNodeSchema,
});
}
Comment on lines +108 to +113
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensDbSchemaChecksum recomputes a full schema stringify + SHA-256 hash on every access. This can be relatively expensive given the size of the Drizzle schema objects.

Suggestion: compute once (e.g., in the constructor) or memoize the getter in a private field so repeated accesses don’t repeatedly hash the same data.

Copilot uses AI. Check for mistakes.

/**
* Getter for the "concrete" ENSIndexer Schema definition used in the Drizzle client
* for ENSDb instance.
Expand Down
39 changes: 39 additions & 0 deletions packages/ensdb-sdk/src/lib/drizzle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/**
* Utilities for Drizzle ORM integration with ENSDb.
*/

import { createHash } from "node:crypto";

import type { Logger as DrizzleLogger } from "drizzle-orm/logger";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { isPgEnum } from "drizzle-orm/pg-core";
Expand Down Expand Up @@ -169,3 +172,39 @@ export function buildEnsDbDrizzleClient<ConcreteEnsIndexerSchema extends Abstrac
logger,
});
}

/**
* Safely stringify a Drizzle schema definition.
*
* Handles circular references in the Drizzle schema definition by replacing
* them with the string "[circular]". Thanks to this, we can safely stringify
* any Drizzle schema definition without running into errors due to inability
* of {@link JSON.stringify} to handle circular references by default.
*
* @param schema - The Drizzle schema definition to stringify.
* @returns A JSON string representation of the schema, with circular
* references replaced by "[circular]".
*/
function safeStringifyDrizzleSchema(schema: Record<string, unknown>): string {
const seen = new WeakSet();

return JSON.stringify(schema, (_key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return "[circular]";
seen.add(value);
}

return value;
});
}
Comment on lines +188 to +199
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Non-deterministic key ordering may cause inconsistent checksums.

JSON.stringify does not guarantee consistent key ordering across different object instantiation orders or JavaScript engines. If the schema object's keys are enumerated in a different order (e.g., due to how Drizzle constructs the schema internally), the resulting checksum could differ for semantically identical schemas, potentially causing unexpected Ponder build-id changes.

Consider sorting keys during serialization for deterministic output:

🔧 Proposed fix for deterministic serialization
 function safeStringifyDrizzleSchema(schema: Record<string, unknown>): string {
   const seen = new WeakSet();

-  return JSON.stringify(schema, (_key, value) => {
+  return JSON.stringify(schema, (_key, value) => {
     if (typeof value === "object" && value !== null) {
       if (seen.has(value)) return "[circular]";
       seen.add(value);
+      // Sort object keys for deterministic output
+      if (!Array.isArray(value)) {
+        const sorted: Record<string, unknown> = {};
+        for (const k of Object.keys(value).sort()) {
+          sorted[k] = (value as Record<string, unknown>)[k];
+        }
+        return sorted;
+      }
     }

     return value;
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ensdb-sdk/src/lib/drizzle.ts` around lines 188 - 199, The
safeStringifyDrizzleSchema function can produce non-deterministic output because
JSON.stringify uses native key enumeration order; modify
safeStringifyDrizzleSchema to produce deterministic serialization by sorting
object keys before serializing while preserving arrays and the existing
circular-reference handling: in the replacer used by JSON.stringify (inside
safeStringifyDrizzleSchema) detect plain objects (not arrays), collect and sort
Object.keys(value) and build a new object with keys in sorted order (or return a
sorted-key representation) so the same schema yields identical strings across
environments; keep the WeakSet circular detection logic unchanged so circular
references still produce "[circular]".


/**
* Get a checksum for a Drizzle schema definition.
* @param schema - The Drizzle schema definition.
* @returns A 10-character checksum string for the schema.
*/
export function getDrizzleSchemaChecksum(schema: Record<string, unknown>): string {
const stringifiedSchema = safeStringifyDrizzleSchema(schema);

return createHash("sha256").update(stringifiedSchema).digest("hex").slice(0, 10);
}
Comment on lines +206 to +210
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getDrizzleSchemaChecksum() is new exported behavior that will influence indexing build IDs, but there are no tests covering checksum determinism and that schema objects with circular references won’t throw.

Suggestion: add Vitest coverage in packages/ensdb-sdk/src/lib/drizzle.test.ts to assert (1) stable checksum for the same schema input and (2) no throw + stable output when circular references exist (and ideally that a meaningful schema change alters the checksum).

Copilot uses AI. Check for mistakes.
Loading