Skip to content
5 changes: 5 additions & 0 deletions .changeset/chatty-monkeys-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensdb-sdk": minor
---

Hotfixed the `buildConcreteEnsIndexerSchema` function by replacing the cloning approach with working mutation approach.

This file was deleted.

3 changes: 0 additions & 3 deletions packages/ensdb-sdk/src/ensindexer-abstract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
* for ENSDb, which is then used to build the ENSDb Schema for a Drizzle client for ENSDb.
*/

// TODO: remove `ensnode-metadata.schema` export when database migrations
// for ENSNode Schema are executable.
export * from "./ensnode-metadata.schema";
export * from "./ensv2.schema";
export * from "./protocol-acceleration.schema";
export * from "./registrars.schema";
Expand Down
51 changes: 5 additions & 46 deletions packages/ensdb-sdk/src/lib/drizzle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,45 +98,12 @@ describe("buildIndividualEnsDbSchemas", () => {
}
});

it("applies a different schema name to ENSIndexer objects", () => {
const otherSchemaName = "ensindexer_other";
const { concreteEnsIndexerSchema } = buildIndividualEnsDbSchemas(otherSchemaName);
it("throws an error when called a second time with a different schema name", () => {
buildIndividualEnsDbSchemas(ENSINDEXER_SCHEMA_NAME);

for (const [key, abstractValue] of Object.entries(abstractEnsIndexerSchema)) {
if (!isTable(abstractValue)) continue;
const concreteValue = concreteEnsIndexerSchema[key as keyof typeof concreteEnsIndexerSchema];
expect(isTable(concreteValue)).toBe(true);
expect(getSchemaName(concreteValue)).toBe(otherSchemaName);
}
});

it("builds two concrete schemas with respective names, leaving abstract unaffected", () => {
const schemaNameA = "ensindexer_alpha";
const schemaNameB = "ensindexer_beta";

const { concreteEnsIndexerSchema: concreteA } = buildIndividualEnsDbSchemas(schemaNameA);
const { concreteEnsIndexerSchema: concreteB } = buildIndividualEnsDbSchemas(schemaNameB);

for (const [key, abstractValue] of Object.entries(abstractEnsIndexerSchema)) {
const valueA = concreteA[key as keyof typeof concreteA];
const valueB = concreteB[key as keyof typeof concreteB];

if (isTable(abstractValue)) {
expect(isTable(valueA)).toBe(true);
expect(isTable(valueB)).toBe(true);
expect(getSchemaName(valueA)).toBe(schemaNameA);
expect(getSchemaName(valueB)).toBe(schemaNameB);
expect(getSchemaName(abstractValue)).toBeUndefined();
}

if (isPgEnum(abstractValue)) {
expect(isPgEnum(valueA)).toBe(true);
expect(isPgEnum(valueB)).toBe(true);
expect((valueA as any).schema).toBe(schemaNameA);
expect((valueB as any).schema).toBe(schemaNameB);
expect((abstractValue as any).schema).toBeUndefined();
}
}
expect(() => buildIndividualEnsDbSchemas("ensindexer_other")).toThrow(
/buildConcreteEnsIndexerSchema was already called with schema/,
);
});
});

Expand Down Expand Up @@ -165,14 +132,6 @@ describe("combined schema (via buildEnsDbDrizzleClient)", () => {
}
}
});

it("ensures ensnode metadata schema is consistent across multiple concrete schemas", () => {
const schemaA = getCombinedSchema("ensindexer_alpha");
const schemaB = getCombinedSchema("ensindexer_beta");

expect(getSchemaName(schemaA.metadata)).toBe("ensnode");
expect(getSchemaName(schemaB.metadata)).toBe("ensnode");
});
});

describe("concrete tables — prototype and Symbol preservation", () => {
Expand Down
88 changes: 37 additions & 51 deletions packages/ensdb-sdk/src/lib/drizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,16 @@ import * as ensNodeSchema from "../ensnode";
*/
export type AbstractEnsIndexerSchema = typeof abstractEnsIndexerSchema;

// TODO: remove the `appliedNameForConcreteEnsIndexerSchema` variable and
// related logic when the `buildConcreteEnsIndexerSchema` function is
// refactored to avoid mutating the "abstract" ENSIndexer Schema definition.
/**
* Clone a Drizzle Table object with a new schema name.
* Applied name for the "concrete" ENSIndexer Schema.
*
* Drizzle tables store their identity (name, columns, schema) on
* Symbol-keyed properties. Cloning a table requires creating
* a new object with the same prototype, copying all properties,
* and updating the schema name.
* This is needed to prevent multiple calls to `buildConcreteEnsIndexerSchema` with different schema names,
* which would mutate the same "abstract" ENSIndexer Schema and cause schema corruption.
*/
function cloneTableWithSchema<TableType extends Table>(
table: TableType,
schemaName: string,
): TableType {
const clone = Object.create(
Object.getPrototypeOf(table),
Object.getOwnPropertyDescriptors(table),
) as TableType;

// @ts-expect-error - Drizzle's Table type for the schema symbol is
// not typed in a way that allows us to set it directly,
// but we know it exists and can be set.
clone[Table.Symbol.Schema] = schemaName;

// Fail-fast if the clone lost the Drizzle sentinel.
if (!isTable(clone)) {
throw new Error(`Cloned table is no longer a valid Drizzle Table (schema: ${schemaName}).`);
}

return clone;
}
let appliedNameForConcreteEnsIndexerSchema: string | undefined;

/**
* Build a "concrete" ENSIndexer Schema definition for ENSDb.
Expand All @@ -66,34 +47,39 @@ function cloneTableWithSchema<TableType extends Table>(
function buildConcreteEnsIndexerSchema<ConcreteEnsIndexerSchema extends AbstractEnsIndexerSchema>(
ensIndexerSchemaName: string,
): ConcreteEnsIndexerSchema {
const ensIndexerSchema = {} as ConcreteEnsIndexerSchema;

for (const [key, abstractSchemaObject] of Object.entries(abstractEnsIndexerSchema)) {
if (isTable(abstractSchemaObject)) {
(ensIndexerSchema as any)[key] = cloneTableWithSchema(
abstractSchemaObject,
ensIndexerSchemaName,
);
} else if (isPgEnum(abstractSchemaObject)) {
// Enums are functions; clone by copying properties onto a new function.
// Unlike tables, enums don't rely on prototype identity, so
// Object.assign is sufficient here.
const concreteSchemaObject = Object.assign(
(...args: any[]) => abstractSchemaObject(...args),
abstractSchemaObject,
);
// @ts-expect-error - Drizzle's PgEnum type for the schema symbol is
// typed as readonly, but we need to set it here so
// the output schema definition has the correct schema for
// all table and enum objects.
concreteSchemaObject.schema = ensIndexerSchemaName;
(ensIndexerSchema as any)[key] = concreteSchemaObject;
} else {
(ensIndexerSchema as any)[key] = abstractSchemaObject;
// TODO: Refactor this function to avoid mutating the "abstract" ENSIndexer Schema definition.
// https://github.com/namehash/ensnode/issues/1830

if (
appliedNameForConcreteEnsIndexerSchema !== undefined &&
appliedNameForConcreteEnsIndexerSchema !== ensIndexerSchemaName
) {
throw new Error(
`buildConcreteEnsIndexerSchema was already called with schema "${appliedNameForConcreteEnsIndexerSchema}". ` +
`Calling it again with "${ensIndexerSchemaName}" would corrupt the previously built schema.`,
);
Comment on lines +53 to +60
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

This adds module-level state (appliedNameForConcreteEnsIndexerSchema) so schema-building becomes a process-wide singleton: after the first call, building for a different ensIndexerSchemaName throws. Since callers reach this via exported buildIndividualEnsDbSchemas/EnsDbReader, this constraint should be documented on the public API (and/or in the changeset) to avoid surprising runtime failures in multi-tenant or multi-db scenarios.

Copilot uses AI. Check for mistakes.
}
appliedNameForConcreteEnsIndexerSchema = ensIndexerSchemaName;

const concreteEnsIndexerSchema = abstractEnsIndexerSchema as ConcreteEnsIndexerSchema;

for (const dbObject of Object.values(abstractEnsIndexerSchema)) {
if (isTable(dbObject)) {
// Update Drizzle table definition to reference
// the specific `ensIndexerSchemaName` name of the ENSIndexer Schema.
// @ts-expect-error - Drizzle types don't define `Table.Symbol.Schema` type,
// but it's present at runtime.
dbObject[Table.Symbol.Schema] = ensIndexerSchemaName;
} else if (isPgEnum(dbObject)) {
// Update Drizzle enum definition to reference
// the specific `ensIndexerSchemaName` name of the ENSIndexer Schema.
// @ts-expect-error - Drizzle types consider `schema` to be
// a readonly property.
dbObject.schema = ensIndexerSchemaName;
}
}

return ensIndexerSchema;
return concreteEnsIndexerSchema;
}

/**
Expand Down
50 changes: 28 additions & 22 deletions packages/integration-test-env/src/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,35 +189,41 @@ async function pollIndexingStatus(
ensIndexerSchemaName: string,
timeoutMs: number,
): Promise<void> {
const client = new (await import("@ensnode/ensdb-sdk")).EnsDbReader(
ensDbUrl,
ensIndexerSchemaName,
);
const { EnsDbReader } = await import("@ensnode/ensdb-sdk");
const ensDbClient = new EnsDbReader(ensDbUrl, ensIndexerSchemaName);

const start = Date.now();
log("Polling indexing status...");

while (Date.now() - start < timeoutMs) {
checkAborted();
try {
const snapshot = await client.getIndexingStatusSnapshot();
if (snapshot !== undefined) {
const omnichainStatus = snapshot.omnichainSnapshot.omnichainStatus;
log(`Omnichain status: ${omnichainStatus}`);
if (
omnichainStatus === OmnichainIndexingStatusIds.Following ||
omnichainStatus === OmnichainIndexingStatusIds.Completed
) {
log("Indexing reached target status");
return;
try {
while (Date.now() - start < timeoutMs) {
checkAborted();
try {
const snapshot = await ensDbClient.getIndexingStatusSnapshot();
if (snapshot !== undefined) {
const omnichainStatus = snapshot.omnichainSnapshot.omnichainStatus;
log(`Omnichain status: ${omnichainStatus}`);
if (
omnichainStatus === OmnichainIndexingStatusIds.Following ||
omnichainStatus === OmnichainIndexingStatusIds.Completed
) {
log("Indexing reached target status");
return;
}
}
} catch {
// indexer may not be ready yet
Copy link
Copy Markdown
Contributor

@vercel vercel bot Mar 26, 2026

Choose a reason for hiding this comment

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

Bare catch block and finally block error masking in the indexing status polling loop

Fix on Vercel

}
} catch {
// indexer may not be ready yet
await new Promise((r) => setTimeout(r, 3000));
}
await new Promise((r) => setTimeout(r, 3000));
throw new Error(`Indexing did not complete within ${timeoutMs / 1000}s`);
} finally {
console.log("Closing ENSDb client...");
// @ts-expect-error - DrizzleClient.$client is not typed to have an `end` method,
// but in practice it does (e.g. pg's Client does).
await ensDbClient.ensDb.$client.end();
console.log("ENSDb client closed");
Comment on lines +222 to +225
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The cleanup logic reaches into Drizzle internals (ensDbClient.ensDb.$client.end()) and suppresses typing with @ts-expect-error. This is brittle (a Drizzle type update that adds end will break the build because @ts-expect-error expects an error, and a driver change could make $client.end absent/different). Prefer a small explicit shutdown helper (e.g. a close() method on EnsDbReader) or a runtime/typed guard that checks for an end() function before calling it, without @ts-expect-error.

Suggested change
// @ts-expect-error - DrizzleClient.$client is not typed to have an `end` method,
// but in practice it does (e.g. pg's Client does).
await ensDbClient.ensDb.$client.end();
console.log("ENSDb client closed");
const anyClient = ensDbClient as any;
try {
const rawEnsDb = anyClient?.ensDb;
const rawUnderlyingClient = rawEnsDb?.$client;
const endFn = rawUnderlyingClient?.end;
if (typeof endFn === "function") {
await endFn.call(rawUnderlyingClient);
console.log("ENSDb client closed");
} else {
console.log("ENSDb client has no 'end' method; skipping close");
}
} catch (closeErr) {
console.log("Failed to close ENSDb client cleanly:", closeErr);
}

Copilot uses AI. Check for mistakes.
Comment on lines +221 to +225
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
console.log("Closing ENSDb client...");
// @ts-expect-error - DrizzleClient.$client is not typed to have an `end` method,
// but in practice it does (e.g. pg's Client does).
await ensDbClient.ensDb.$client.end();
console.log("ENSDb client closed");
log("Closing ENSDb client...");
// @ts-expect-error - DrizzleClient.$client is not typed to have an `end` method,
// but in practice it does (e.g. pg's Client does).
await ensDbClient.ensDb.$client.end();
log("ENSDb client closed");

Finally block uses console.log() instead of the standard log() function, causing inconsistent logging formatting

Fix on Vercel

}
throw new Error(`Indexing did not complete within ${timeoutMs / 1000}s`);
}

function logVersions() {
Expand Down Expand Up @@ -312,7 +318,7 @@ async function main() {

// Phase 3: Start ENSIndexer
const ENSINDEXER_URL = `http://localhost:${ENSINDEXER_PORT}`;
const ENSINDEXER_SCHEMA_NAME = "public";
const ENSINDEXER_SCHEMA_NAME = "ensindexer_0";

log("Starting ENSIndexer...");
spawnService(
Expand Down
Loading