Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/few-chefs-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensindexer": minor
---

Updated Ponder Config object to include values that indexing behavior depends on. This is to ensure that the Ponder Build ID changes when any value in indexing behavior dependencies changes.
5 changes: 5 additions & 0 deletions .changeset/purple-chefs-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensdb-sdk": minor
---

Exported `ENSDB_SCHEMA_CHECKSUM` const which changes when ENSDb Schema definition changes.
44 changes: 20 additions & 24 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 { IndexingBehaviorInjectionContract } from "./indexing-behavior-injection-contract";

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

// NOTE: here we inject all values from the ENSIndexerConfig 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.
//
// 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"
>;
/**
* NOTE: By injecting the {@link IndexingBehaviorInjectionContract} into
* the `contracts` field of the Ponder Config, we ensure that any changes to
* the indexing behavior dependencies defined in
* {@link IndexingBehaviorInjectionContract.indexingBehaviorDependencies} will
* result in a different Ponder Build ID. This ensures that running ENSIndexer
* with different configurations maintains compatibility with Ponder's default
* crash recovery behavior.
*
* @see https://ponder.sh/docs/api-reference/ponder/database#build-id-and-crash-recovery
*/
ponderConfig.contracts = {
...ponderConfig.contracts,
// @ts-expect-error - `ponderConfig.contracts` is a constant type, so the type system
// doesn't allow us to add new properties to it, but we have to inject the
// IndexingBehaviorInjectionContract here.
IndexingBehaviorInjectionContract,
};

////////
// Set indexing order strategy
Expand Down
129 changes: 129 additions & 0 deletions apps/ensindexer/src/ponder/indexing-behavior-injection-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import config from "@/config";

import type { ContractConfig } from "ponder";

import { getENSRootChainId } from "@ensnode/datasources";
import { ENSDB_SCHEMA_CHECKSUM } from "@ensnode/ensdb-sdk";

import type { EnsIndexerConfig } from "@/config/types";

/**
* Indexing Behavior Dependencies
*
* Defines all values that influence the indexing behavior of the ENSIndexer
* instance.
*/
interface IndexingBehaviorDependencies {
/**
* ENS Namespace
*
* When `namespace` changes, the datasources used for indexing may change,
* which influences the indexing behavior.
*/
namespace: string;

/**
* ENSIndexer Plugins
*
* When `plugins` change, the indexed chains and contracts may change,
* which influences the indexing behavior.
*/
plugins: EnsIndexerConfig["plugins"];

/**
* Global Blockrange
*
* When `globalBlockrange` changes, the blockrange of indexed chains may change,
* which influences the indexing behavior.
*/
globalBlockrange: EnsIndexerConfig["globalBlockrange"];

/**
* Subgraph Compatibility
*
* When `isSubgraphCompatible` changes, the indexing logic may change,
* which influences the indexing behavior.
*/
isSubgraphCompatible: boolean;

/**
* Label Set
*
* When `labelSet` changes, the label "healing" results may change during indexing,
* which influences the indexing behavior.
*/
labelSet: EnsIndexerConfig["labelSet"];

/**
* ENSDb Schema Checksum
*
* When `ensDbSchemaChecksum` changes, the ENSDb schema definition may have
* changed, which influences the indexing behavior.
*/
ensDbSchemaChecksum: string;
}

/**
* A special "indexing behavior injection" contract config
*
* This config does not reference any real onchain contract to be indexed.
* Instead, it serves as a placeholder to collect all values that influence
* the indexing behavior of the ENSIndexer instance.
*
* This contract config is designed to be injected into the `contracts` field
* of the Ponder Config object.
*/
interface IndexingBehaviorInjectionContractConfig extends ContractConfig {
indexingBehaviorDependencies: IndexingBehaviorDependencies;
}

/**
* Build a contract config placeholder with the necessary fields to be included in
* the `contracts` field of the Ponder Config.
*/
function buildContractConfigPlaceholder(): ContractConfig {
return {
// The placeholder contract does not reference any real chain,
// but we need to provide a valid chain id to satisfy the ContractConfig type.
// The ENS Root Chain ID is a reasonable choice since it's guaranteed to be
// a valid indexed chain ID for any ENSIndexer instance.
chain: `${getENSRootChainId(config.namespace)}`,
// The placeholder contract does not have any real ABI,
// but we need to provide an empty array to satisfy the ContractConfig type.
abi: [],
};
Comment thread
tk-o marked this conversation as resolved.
}

/**
* Indexing Behavior Dependencies
*/
const indexingBehaviorDependencies = {
// while technically not necessary, since these config properties are reflected in the
// generated ponderConfig, we include them here for clarity
namespace: config.namespace,
// Sort plugins to ensure canonical checksum regardless of config order.
// The actual indexing behavior does not depend on plugin order since:
// 1. All plugin checks use Array.includes() which is order-independent
// 2. Plugin execution order is determined by `ALL_PLUGINS`, not config.plugins
// Sorting ensures consistent Build IDs for semantically identical config.
plugins: [...config.plugins].sort(),
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: ENSDB_SCHEMA_CHECKSUM,
} satisfies IndexingBehaviorDependencies;
Comment thread
tk-o marked this conversation as resolved.

/**
* A special "indexing behavior injection" contract config
*
* This config is designed to be injected into the `contracts` field of
* the Ponder Config object in order to make Ponder create
* a unique build ID for any changes to {@link indexingBehaviorDependencies}.
*/
export const IndexingBehaviorInjectionContract = {
...buildContractConfigPlaceholder(),
indexingBehaviorDependencies,
} satisfies IndexingBehaviorInjectionContractConfig;
20 changes: 20 additions & 0 deletions packages/ensdb-sdk/src/client/ensdb-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import * as abstractEnsIndexerSchema from "../ensindexer-abstract";
import * as ensNodeSchema from "../ensnode";
import { getDrizzleSchemaChecksum } from "../lib/drizzle";

Comment thread
tk-o marked this conversation as resolved.
/**
* ENSDb Config
*/
Expand All @@ -19,3 +23,19 @@ export interface EnsDbConfig {
*/
ensIndexerSchemaName: string;
}

/**
* ENSDb Schema Checksum
*
* Checksum representing the ENSDb Schema definition, which is a combination of
* - the ENSIndexer Schema definition, and
* - the ENSNode Schema definition.
*
* This checksum can be used to verify compatibility between
* the ENSDb Schema definition expected by any client app connecting to ENSDb
* instance and the actual ENSDb Schema definition present in ENSDb SDK.
Comment thread
tk-o marked this conversation as resolved.
*/
export const ENSDB_SCHEMA_CHECKSUM = getDrizzleSchemaChecksum({
...abstractEnsIndexerSchema,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

is it necessary to include ensindexerschema in this checksum? i thought it was still included in the ponder-generated build id, but if that's not the case, then good catch

Copy link
Copy Markdown
Contributor Author

@tk-o tk-o Apr 11, 2026

Choose a reason for hiding this comment

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

@shrugs ENSDb Schema definition includes both, ENSIndexer Schema definition and ENSNode Schema definition. Changing any of those schema definitions may result in changed indexing behavior.

While generating the Ponder Build ID, Ponder reads the literal contents of the ponder.schema.ts file:

/**
* We re-export (just) the "abstract" ENSIndexer Schema from ENSDb for Ponder to manage.
* Ponder will internally build a "concrete" ENSIndexer Schema using
* the "abstract" ENSIndexer Schema and the ENSIndexer Schema name.
**/
export * from "@ensnode/ensdb-sdk/ensindexer-abstract";

As you can see, the ponder.schema.ts file contains a static export from ENSDb SDK. It means that even if we change the ENSIndexer Schema definition, Ponder won't notice that and will produce the same content hash value for the ponder.schema.ts file, and by extension, will produce the same Ponder Build ID.

Here's how Ponder calculated the content hash value for ponder.schema.ts file:
https://github.com/ponder-sh/ponder/blob/c8f6935fb65176c01b40cae9056be704c0e5318e/packages/core/src/build/index.ts#L252-L277

...ensNodeSchema,
});
57 changes: 57 additions & 0 deletions packages/ensdb-sdk/src/lib/checksum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";

import { createChecksum } from "./checksum";

describe("createChecksum", () => {
it("returns a 10-character hex string", () => {
const checksum = createChecksum("test data");

expect(checksum).toHaveLength(10);
expect(checksum).toMatch(/^[a-f0-9]{10}$/);
});

it("returns consistent results for the same input", () => {
const input = "consistent input";
const checksum1 = createChecksum(input);
const checksum2 = createChecksum(input);

expect(checksum1).toBe(checksum2);
});

it("returns different results for different inputs", () => {
const checksum1 = createChecksum("input one");
const checksum2 = createChecksum("input two");

expect(checksum1).not.toBe(checksum2);
});

it("handles empty string input", () => {
const checksum = createChecksum("");

expect(checksum).toHaveLength(10);
expect(checksum).toMatch(/^[a-f0-9]{10}$/);
});

it("handles Buffer input", () => {
const buffer = Buffer.from("buffer data");
const checksum = createChecksum(buffer);

expect(checksum).toHaveLength(10);
expect(checksum).toMatch(/^[a-f0-9]{10}$/);
});

it("handles Uint8Array input", () => {
const uint8Array = new Uint8Array([1, 2, 3, 4, 5]);
const checksum = createChecksum(uint8Array);

expect(checksum).toHaveLength(10);
expect(checksum).toMatch(/^[a-f0-9]{10}$/);
});

it("produces expected checksum for known input", () => {
// SHA-256 of "hello" starts with "2cf24dba5f..."
const checksum = createChecksum("hello");

expect(checksum).toBe("2cf24dba5f");
});
});
Comment thread
tk-o marked this conversation as resolved.
12 changes: 12 additions & 0 deletions packages/ensdb-sdk/src/lib/checksum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type BinaryLike, createHash } from "node:crypto";

/**
* Create a checksum for the given data
*
* @param data - The data to create a checksum for
* @returns A 10-character hash string representing the checksum of the data
*
*/
Comment thread
tk-o marked this conversation as resolved.
export function createChecksum(data: BinaryLike): string {
return createHash("sha256").update(data).digest("hex").slice(0, 10);
Comment thread
tk-o marked this conversation as resolved.
}
44 changes: 44 additions & 0 deletions packages/ensdb-sdk/src/lib/drizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { isTable, Table } from "drizzle-orm/table";
// directly to build a Drizzle client for ENSDb.
import * as abstractEnsIndexerSchema from "../ensindexer-abstract";
import * as ensNodeSchema from "../ensnode";
import { createChecksum } from "./checksum";

/**
* Abstract ENSIndexer Schema
Expand Down Expand Up @@ -169,3 +170,46 @@ 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.
*
* Note: {@link JSON.stringify} omits function-valued properties, so
* column-level attributes such as `.$defaultFn()` or `.$onUpdateFn()` will not
* be included in the stringified output and will not affect the checksum.
* Schema changes that only modify such function-valued properties may go
* undetected.
*
* @param schema - A Drizzle schema definition to stringify.
* @returns A JSON string representation of the schema, with circular
* references replaced by "[circular]".
*/
Comment thread
tk-o marked this conversation as resolved.
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 thread
tk-o marked this conversation as resolved.
}

Comment thread
tk-o marked this conversation as resolved.
/**
* Get a checksum for a Drizzle schema definition.
*
* @param schema - A Drizzle schema definition to get the checksum for.
* @returns A 10-character checksum string for the schema.
*/
export function getDrizzleSchemaChecksum(schema: Record<string, unknown>): string {
const stringifiedSchema = safeStringifyDrizzleSchema(schema);

return createChecksum(stringifiedSchema);
}
Comment thread
tk-o marked this conversation as resolved.
Comment thread
tk-o marked this conversation as resolved.
Loading