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/fluffy-forks-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensdb-sdk": minor
---

Added `validateEnsDbConfig` function to support validation for the `EnsDbConfig` data model.
Comment on lines +1 to +5
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

This changeset describes only adding validateEnsDbConfig, but the PR also introduces the EnsDbConfig data model and its schema. Consider updating the changeset text to mention the new EnsDbConfig export as part of the minor release so consumers understand what was added.

Copilot uses AI. Check for mistakes.
1 change: 0 additions & 1 deletion apps/ensapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
"hono": "catalog:",
"p-memoize": "^8.0.0",
"p-retry": "catalog:",
"pg-connection-string": "catalog:",
"pino": "catalog:",
"ponder-enrich-gql-docs-middleware": "^0.1.3",
"superjson": "^2.2.6",
Expand Down
11 changes: 8 additions & 3 deletions apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ vi.mock("@/lib/ensdb/singleton", () => ({
},
}));

vi.mock("@/config/ensdb-config", () => ({
default: {
ensDbUrl: "postgresql://user:password@localhost:5432/mydb",
ensIndexerSchemaName: "ensindexer_0",
},
}));

import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/config.schema";
import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import type { EnsApiEnvironment } from "@/config/environment";
Expand Down Expand Up @@ -97,9 +104,7 @@ describe("buildConfigFromEnvironment", () => {
mockExit.mockClear();
});

const TEST_ENV: EnsApiEnvironment = {
ENSDB_URL: BASE_ENV.ENSDB_URL,
};
const TEST_ENV: EnsApiEnvironment = structuredClone(BASE_ENV);

it("logs error and exits when CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => {
await buildConfigFromEnvironment({
Expand Down
13 changes: 9 additions & 4 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from "@ensnode/ensnode-sdk/internal";

import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import { EnsDbConfigSchema } from "@/config/ensdb-config.schema";
import ensDbConfig from "@/config/ensdb-config";
import type { EnsApiEnvironment } from "@/config/environment";
import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations";
import { ensDbClient } from "@/lib/ensdb/singleton";
Expand Down Expand Up @@ -47,8 +47,11 @@ const EnsApiConfigSchema = z
rpcConfigs: RpcConfigsSchema,
ensIndexerPublicConfig: makeENSIndexerPublicConfigSchema("ensIndexerPublicConfig"),
customReferralProgramEditionConfigSetUrl: CustomReferralProgramEditionConfigSetUrlSchema,

// include the ENSDbConfig params in the EnsApiConfigSchema
ensDbUrl: z.string(),
ensIndexerSchemaName: z.string(),
})
.extend(EnsDbConfigSchema.shape)
.check(invariant_rpcConfigsSpecifiedForRootChain)
.check(invariant_ensIndexerPublicConfigVersionInfo);

Expand Down Expand Up @@ -89,13 +92,15 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis

return EnsApiConfigSchema.parse({
port: env.PORT,
ensDbUrl: env.ENSDB_URL,
theGraphApiKey: env.THEGRAPH_API_KEY,
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
ensIndexerSchemaName: ensIndexerPublicConfig.ensIndexerSchemaName,
rpcConfigs,
customReferralProgramEditionConfigSetUrl: env.CUSTOM_REFERRAL_PROGRAM_EDITIONS,

// include the validated ENSDb config values in the parsed EnsApiConfig
ensDbUrl: ensDbConfig.ensDbUrl,
ensIndexerSchemaName: ensDbConfig.ensIndexerSchemaName,
});
} catch (error) {
if (error instanceof ZodError) {
Expand Down
60 changes: 0 additions & 60 deletions apps/ensapi/src/config/ensdb-config.schema.ts

This file was deleted.

32 changes: 30 additions & 2 deletions apps/ensapi/src/config/ensdb-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
import { buildEnsDbConfigFromEnvironment } from "./ensdb-config.schema";
import { type EnsDbConfig, validateEnsDbConfig } from "@ensnode/ensdb-sdk";
import type { Unvalidated } from "@ensnode/ensnode-sdk";

export default buildEnsDbConfigFromEnvironment(process.env);
import { lazyProxy } from "@/lib/lazy";
import logger from "@/lib/logger";

/**
* Build ENSDb config from environment variables for ENSApi app.
*
* Exits the process if the configuration is invalid, logging the error details.
*/
export function buildEnsDbConfigFromEnvironment(env: NodeJS.ProcessEnv): EnsDbConfig {
const unvalidatedConfig = {
ensDbUrl: env.ENSDB_URL,
ensIndexerSchemaName: env.ENSINDEXER_SCHEMA_NAME,
} satisfies Unvalidated<EnsDbConfig>;

try {
return validateEnsDbConfig(unvalidatedConfig);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to validate ENSDb config from environment: ${errorMessage}`);
process.exit(1);
}
}

// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
const ensDbConfig = lazyProxy<EnsDbConfig>(() => buildEnsDbConfigFromEnvironment(process.env));

export default ensDbConfig;
2 changes: 1 addition & 1 deletion apps/ensapi/src/lib/ensdb/singleton.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EnsDbReader } from "@ensnode/ensdb-sdk";

import { buildEnsDbConfigFromEnvironment } from "@/config/ensdb-config.schema";
import { buildEnsDbConfigFromEnvironment } from "@/config/ensdb-config";
import { lazyProxy } from "@/lib/lazy";

// lazyProxy defers construction until first use so that this module can be
Expand Down
1 change: 0 additions & 1 deletion apps/ensindexer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
"deepmerge-ts": "^7.1.5",
"dns-packet": "^5.6.1",
"drizzle-orm": "catalog:",
"pg-connection-string": "catalog:",
"p-retry": "catalog:",
"hono": "catalog:",
"ponder": "catalog:",
Expand Down
33 changes: 9 additions & 24 deletions apps/ensindexer/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { parse as parseConnectionString } from "pg-connection-string";
import { prettifyError, ZodError, z } from "zod/v4";

import { buildBlockNumberRange, PluginName, uniq } from "@ensnode/ensnode-sdk";
import {
buildRpcConfigsFromEnv,
ENSNamespaceSchema,
EnsIndexerSchemaNameSchema,
invariant_isSubgraphCompatibleRequirements,
invariant_rpcConfigsSpecifiedForRootChain,
makeFullyPinnedLabelSetSchema,
Expand All @@ -14,6 +12,7 @@ import {
} from "@ensnode/ensnode-sdk/internal";

import { DEFAULT_SUBGRAPH_COMPAT } from "@/config/defaults";
import ensDbConfig from "@/config/ensdb-config";
import type { ENSIndexerEnvironment } from "@/config/environment";
import { applyDefaults, EnvironmentDefaults } from "@/config/environment-defaults";

Expand All @@ -27,24 +26,6 @@ import {
invariant_validContractConfigs,
} from "./validations";

export const EnsDbUrlSchema = z.string().refine(
(url) => {
try {
if (!url.startsWith("postgresql://") && !url.startsWith("postgres://")) {
return false;
}
const config = parseConnectionString(url);
return !!(config.host && config.port && config.database);
} catch {
return false;
}
},
{
error:
"Invalid PostgreSQL connection string for ENSDb. Expected format: postgresql://username:password@host:port/database",
},
);

// parses an env string bool with strict requirement of 'true' or 'false'
const makeEnvStringBoolSchema = (envVarKey: string) =>
z
Expand Down Expand Up @@ -106,8 +87,6 @@ const IsSubgraphCompatibleSchema =

const ENSIndexerConfigSchema = z
.object({
ensDbUrl: EnsDbUrlSchema,
ensIndexerSchemaName: EnsIndexerSchemaNameSchema,
rpcConfigs: RpcConfigsSchema,

namespace: ENSNamespaceSchema,
Expand All @@ -116,6 +95,10 @@ const ENSIndexerConfigSchema = z
globalBlockrange: BlockrangeSchema,
ensRainbowUrl: EnsRainbowUrlSchema,
labelSet: LabelSetSchema,

// include the ENSDbConfig params in the ENSIndexerConfigSchema
ensDbUrl: z.string(),
ensIndexerSchemaName: z.string(),
})
/**
* Derived configuration
Expand Down Expand Up @@ -184,8 +167,6 @@ export function buildConfigFromEnvironment(_env: ENSIndexerEnvironment): EnsInde

// parse/validate with ENSIndexerConfigSchema
return ENSIndexerConfigSchema.parse({
ensDbUrl: env.ENSDB_URL,
ensIndexerSchemaName: env.ENSINDEXER_SCHEMA_NAME,
namespace: env.NAMESPACE,
rpcConfigs,

Expand All @@ -200,6 +181,10 @@ export function buildConfigFromEnvironment(_env: ENSIndexerEnvironment): EnsInde
labelSetId: env.LABEL_SET_ID,
labelSetVersion: env.LABEL_SET_VERSION,
},

// include the validated ENSDb config values in the parsed EnsIndexerConfig
ensDbUrl: ensDbConfig.ensDbUrl,
ensIndexerSchemaName: ensDbConfig.ensIndexerSchemaName,
Comment on lines +185 to +187
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

This comment says “parsed EnsApiConfig” but this is the ENSIndexer config builder. Please rename to “parsed EnsIndexerConfig” (and similarly adjust any other EnsApi references) to avoid confusion.

Copilot uses AI. Check for mistakes.
});
} catch (error) {
if (error instanceof ZodError) {
Expand Down
33 changes: 26 additions & 7 deletions apps/ensindexer/src/config/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// For config.test.ts, we need a mock that validates env vars without providing defaults,
// because this test file specifically tests validation failures.
vi.mock("@/config/ensdb-config", async () => {
const { validateEnsDbConfig } =
await vi.importActual<typeof import("@ensnode/ensdb-sdk")>("@ensnode/ensdb-sdk");
return {
default: {
get ensDbUrl() {
const url = process.env.ENSDB_URL;
const schema = process.env.ENSINDEXER_SCHEMA_NAME;
validateEnsDbConfig({ ensDbUrl: url, ensIndexerSchemaName: schema });
return url;
},
get ensIndexerSchemaName() {
const url = process.env.ENSDB_URL;
const schema = process.env.ENSINDEXER_SCHEMA_NAME;
validateEnsDbConfig({ ensDbUrl: url, ensIndexerSchemaName: schema });
return schema;
},
},
};
});

import {
type ENSNamespaceId,
ensTestEnvChain,
Expand Down Expand Up @@ -170,21 +193,17 @@ describe("config (with base env)", () => {

it("throws an error when ENSINDEXER_SCHEMA_NAME is not set", async () => {
vi.stubEnv("ENSINDEXER_SCHEMA_NAME", undefined);
await expect(getConfig()).rejects.toThrow(/ENSINDEXER_SCHEMA_NAME is required/);
await expect(getConfig()).rejects.toThrow(/ENSIndexer Schema Name is required/);
});

it("throws an error when ENSINDEXER_SCHEMA_NAME is empty", async () => {
vi.stubEnv("ENSINDEXER_SCHEMA_NAME", "");
await expect(getConfig()).rejects.toThrow(
/ENSINDEXER_SCHEMA_NAME is required and cannot be an empty string/,
);
await expect(getConfig()).rejects.toThrow(/ENSIndexer Schema Name cannot be an empty string/);
});

it("throws an error when ENSINDEXER_SCHEMA_NAME is only whitespace", async () => {
vi.stubEnv("ENSINDEXER_SCHEMA_NAME", " ");
await expect(getConfig()).rejects.toThrow(
/ENSINDEXER_SCHEMA_NAME is required and cannot be an empty string/,
);
await expect(getConfig()).rejects.toThrow(/ENSIndexer Schema Name cannot be an empty string/);
});
});

Expand Down
24 changes: 24 additions & 0 deletions apps/ensindexer/src/config/ensdb-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { type EnsDbConfig, validateEnsDbConfig } from "@ensnode/ensdb-sdk";
import type { Unvalidated } from "@ensnode/ensnode-sdk";

/**
* Build ENSDb config from environment variables for ENSIndexer app.
*
* Exits the process if the configuration is invalid, logging the error details.
*/
function buildEnsDbConfigFromEnvironment(env: NodeJS.ProcessEnv): EnsDbConfig {
const unvalidatedConfig = {
ensDbUrl: env.ENSDB_URL,
ensIndexerSchemaName: env.ENSINDEXER_SCHEMA_NAME,
} satisfies Unvalidated<EnsDbConfig>;

try {
return validateEnsDbConfig(unvalidatedConfig);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Failed to validate ENSDb config from environment: ${errorMessage}`);
process.exit(1);
}
}

export default buildEnsDbConfigFromEnvironment(process.env);
12 changes: 4 additions & 8 deletions apps/ensindexer/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import type { ENSNamespaceId } from "@ensnode/datasources";
import type { EnsDbConfig } from "@ensnode/ensdb-sdk";
import type { BlockNumberRange, ChainId, PluginName } from "@ensnode/ensnode-sdk";
import {
type DatabaseUrl,
type EnsIndexerSchemaName,
RpcConfig,
type RpcConfigs,
} from "@ensnode/ensnode-sdk/internal";
import { RpcConfig, type RpcConfigs } from "@ensnode/ensnode-sdk/internal";
import type { EnsRainbowClientLabelSet } from "@ensnode/ensrainbow-sdk";

/**
Expand Down Expand Up @@ -81,7 +77,7 @@ export interface EnsIndexerConfig {
* Invariants:
* - Must be a non-empty string that is a valid Postgres database schema identifier.
*/
ensIndexerSchemaName: EnsIndexerSchemaName;
ensIndexerSchemaName: EnsDbConfig["ensIndexerSchemaName"];

/**
* A set of {@link PluginName}s indicating which plugins to activate.
Expand Down Expand Up @@ -119,7 +115,7 @@ export interface EnsIndexerConfig {
* Invariants:
* - The URL must be a valid PostgreSQL connection string
*/
ensDbUrl: DatabaseUrl;
ensDbUrl: EnsDbConfig["ensDbUrl"];

/**
* Constrains the global blockrange for indexing, useful for testing purposes.
Expand Down
Loading
Loading