Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c34c0bd
feat(config): add configuration schema and environment handling for E…
djstrong Dec 22, 2025
bbe487e
chore: update dependencies and clean up imports in ENSRainbow configu…
djstrong Dec 22, 2025
6acc196
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Jan 21, 2026
22a81f0
feat(config): build and export ENSRainbowConfig from environment vari…
djstrong Jan 21, 2026
03722ac
refactor(tests): update CLI tests to use vi.stubEnv and async imports…
djstrong Jan 21, 2026
ec75cfe
fix(cli): improve port validation logic to use configured port instea…
djstrong Jan 21, 2026
887aecc
fix lint
djstrong Jan 21, 2026
bd1dd1c
refactor(cli): update CLI to use configured port and improve test imp…
djstrong Jan 23, 2026
1ddfc33
refactor(config): enhance path resolution and improve error handling …
djstrong Jan 23, 2026
3b5c7cc
Merge branch 'main' into 1407-build-ensrainbow-config
djstrong Jan 23, 2026
782e329
test(config): add comprehensive tests for buildConfigFromEnvironment …
djstrong Jan 23, 2026
2e1f9d9
feat(api): add public configuration endpoint and enhance ENSRainbowAp…
djstrong Jan 23, 2026
ddaaf29
test(config): remove redundant test for DB_SCHEMA_VERSION handling in…
djstrong Jan 23, 2026
a43b125
refactor(config): improve environment configuration validation and er…
djstrong Jan 23, 2026
c57e7f6
Create young-carrots-cheer.md
djstrong Jan 23, 2026
e3a6c90
refactor(config): remove unused imports from config schema files to s…
djstrong Jan 23, 2026
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
3 changes: 2 additions & 1 deletion apps/ensrainbow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"protobufjs": "^7.4.0",
"viem": "catalog:",
"yargs": "^17.7.2",
"@fast-csv/parse": "^5.0.0"
"@fast-csv/parse": "^5.0.0",
"zod": "catalog:"
},
"devDependencies": {
"@ensnode/shared-configs": "workspace:*",
Expand Down
71 changes: 46 additions & 25 deletions apps/ensrainbow/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { DEFAULT_PORT, getEnvPort } from "@/lib/env";
import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults";
import { getEnvPort } from "@/lib/env";

import { createCLI, validatePortConfiguration } from "./cli";

Expand Down Expand Up @@ -38,26 +39,36 @@ describe("CLI", () => {
});

describe("getEnvPort", () => {
it("should return DEFAULT_PORT when PORT is not set", () => {
expect(getEnvPort()).toBe(DEFAULT_PORT);
it("should return ENSRAINBOW_DEFAULT_PORT when PORT is not set", () => {
expect(getEnvPort()).toBe(ENSRAINBOW_DEFAULT_PORT);
});

it("should return port from environment variable", () => {
it("should return port from environment variable", async () => {
const customPort = 4000;
process.env.PORT = customPort.toString();
expect(getEnvPort()).toBe(customPort);
vi.stubEnv("PORT", customPort.toString());
vi.resetModules();
const { getEnvPort: getEnvPortFresh } = await import("@/lib/env");
expect(getEnvPortFresh()).toBe(customPort);
});

it("should throw error for invalid port number", () => {
process.env.PORT = "invalid";
expect(() => getEnvPort()).toThrow(
'Invalid PORT value "invalid": must be a non-negative integer',
);
it("should throw error for invalid port number", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit called");
}) as never);
vi.stubEnv("PORT", "invalid");
vi.resetModules();
await expect(import("@/lib/env")).rejects.toThrow("process.exit called");
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("should throw error for negative port number", () => {
process.env.PORT = "-1";
expect(() => getEnvPort()).toThrow('Invalid PORT value "-1": must be a non-negative integer');
it("should throw error for negative port number", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit called");
}) as never);
vi.stubEnv("PORT", "-1");
vi.resetModules();
await expect(import("@/lib/env")).rejects.toThrow("process.exit called");
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

Expand All @@ -66,14 +77,18 @@ describe("CLI", () => {
expect(() => validatePortConfiguration(3000)).not.toThrow();
});

it("should not throw when PORT matches CLI port", () => {
process.env.PORT = "3000";
expect(() => validatePortConfiguration(3000)).not.toThrow();
it("should not throw when PORT matches CLI port", async () => {
vi.stubEnv("PORT", "3000");
vi.resetModules();
const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli");
expect(() => validatePortConfigurationFresh(3000)).not.toThrow();
});

it("should throw when PORT conflicts with CLI port", () => {
process.env.PORT = "3000";
expect(() => validatePortConfiguration(4000)).toThrow("Port conflict");
it("should throw when PORT conflicts with CLI port", async () => {
vi.stubEnv("PORT", "3000");
vi.resetModules();
const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli");
expect(() => validatePortConfigurationFresh(4000)).toThrow("Port conflict");
});
});

Expand Down Expand Up @@ -526,11 +541,14 @@ describe("CLI", () => {

it("should respect PORT environment variable", async () => {
const customPort = 5115;
process.env.PORT = customPort.toString();
vi.stubEnv("PORT", customPort.toString());
vi.resetModules();
const { createCLI: createCLIFresh } = await import("./cli");
const cliWithCustomPort = createCLIFresh({ exitProcess: false });

// First ingest some test data
const ensrainbowOutputFile = join(TEST_FIXTURES_DIR, "test_ens_names_0.ensrainbow");
await cli.parse([
await cliWithCustomPort.parse([
"ingest-ensrainbow",
"--input-file",
ensrainbowOutputFile,
Expand All @@ -539,7 +557,7 @@ describe("CLI", () => {
]);

// Start server
const serverPromise = cli.parse(["serve", "--data-dir", testDataDir]);
const serverPromise = cliWithCustomPort.parse(["serve", "--data-dir", testDataDir]);

// Give server time to start
await new Promise((resolve) => setTimeout(resolve, 100));
Expand Down Expand Up @@ -587,9 +605,12 @@ describe("CLI", () => {
});

it("should throw on port conflict", async () => {
process.env.PORT = "5000";
vi.stubEnv("PORT", "5000");
vi.resetModules();
const { createCLI: createCLIFresh } = await import("./cli");
const cliWithPort = createCLIFresh({ exitProcess: false });
await expect(
cli.parse(["serve", "--port", "4000", "--data-dir", testDataDir]),
cliWithPort.parse(["serve", "--port", "4000", "--data-dir", testDataDir]),
).rejects.toThrow("Port conflict");
});
});
Expand Down
22 changes: 13 additions & 9 deletions apps/ensrainbow/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import config from "@/config";

import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

Expand All @@ -14,13 +16,15 @@ import { ingestProtobufCommand } from "@/commands/ingest-protobuf-command";
import { purgeCommand } from "@/commands/purge-command";
import { serverCommand } from "@/commands/server-command";
import { validateCommand } from "@/commands/validate-command";
import { getDefaultDataSubDir, getEnvPort } from "@/lib/env";
import { getDefaultDataDir } from "@/config/defaults";
import { getEnvPort } from "@/lib/env";

export function validatePortConfiguration(cliPort: number): void {
const envPort = process.env.PORT;
if (envPort !== undefined && cliPort !== getEnvPort()) {
// Only validate if PORT was explicitly set in the environment
// If PORT is not set, CLI port can override the default
if (process.env.PORT !== undefined && cliPort !== config.port) {
throw new Error(
`Port conflict: Command line argument (${cliPort}) differs from PORT environment variable (${envPort}). ` +
`Port conflict: Command line argument (${cliPort}) differs from configured port (${config.port}). ` +
`Please use only one method to specify the port.`,
);
}
Expand Down Expand Up @@ -89,7 +93,7 @@ export function createCLI(options: CLIOptions = {}) {
// .option("data-dir", {
// type: "string",
// description: "Directory to store LevelDB data",
// default: getDefaultDataSubDir(),
// default: getDefaultDataDir(),
// });
// },
// async (argv: ArgumentsCamelCase<IngestArgs>) => {
Expand All @@ -112,7 +116,7 @@ export function createCLI(options: CLIOptions = {}) {
.option("data-dir", {
type: "string",
description: "Directory to store LevelDB data",
default: getDefaultDataSubDir(),
default: getDefaultDataDir(),
});
},
async (argv: ArgumentsCamelCase<IngestProtobufArgs>) => {
Expand All @@ -135,7 +139,7 @@ export function createCLI(options: CLIOptions = {}) {
.option("data-dir", {
type: "string",
description: "Directory containing LevelDB data",
default: getDefaultDataSubDir(),
default: getDefaultDataDir(),
});
},
async (argv: ArgumentsCamelCase<ServeArgs>) => {
Expand All @@ -154,7 +158,7 @@ export function createCLI(options: CLIOptions = {}) {
.option("data-dir", {
type: "string",
description: "Directory containing LevelDB data",
default: getDefaultDataSubDir(),
default: getDefaultDataDir(),
})
.option("lite", {
type: "boolean",
Expand All @@ -177,7 +181,7 @@ export function createCLI(options: CLIOptions = {}) {
return yargs.option("data-dir", {
type: "string",
description: "Directory containing LevelDB data",
default: getDefaultDataSubDir(),
default: getDefaultDataDir(),
});
},
async (argv: ArgumentsCamelCase<PurgeArgs>) => {
Expand Down
84 changes: 84 additions & 0 deletions apps/ensrainbow/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { join } from "node:path";

import { prettifyError, ZodError, z } from "zod/v4";

import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal";

import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults";
import type { ENSRainbowEnvironment } from "@/config/environment";
import { invariant_dbSchemaVersionMatch } from "@/config/validations";
import { logger } from "@/utils/logger";

const DataDirSchema = z
.string()
.trim()
.min(1, {
error: "DATA_DIR must be a non-empty string.",
})
.transform((path: string) => {
// Resolve relative paths to absolute paths
if (path.startsWith("/")) {
return path;
}
return join(process.cwd(), path);
});

const DbSchemaVersionSchema = z.coerce
.number({ error: "DB_SCHEMA_VERSION must be a number." })
.int({ error: "DB_SCHEMA_VERSION must be an integer." })
.optional();

const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET");

const ENSRainbowConfigSchema = z
.object({
port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT),
dataDir: DataDirSchema.default(getDefaultDataDir()),
Comment on lines +36 to +39
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Default data directory is evaluated at module load time.

getDefaultDataDir() is called once when the module loads, capturing process.cwd() at that moment. If the working directory changes before buildConfigFromEnvironment is called, the default will be stale.

Consider using a getter function for lazy evaluation:

♻️ Suggested lazy evaluation
 const ENSRainbowConfigSchema = z
   .object({
     port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT),
-    dataDir: DataDirSchema.default(getDefaultDataDir()),
+    dataDir: DataDirSchema.optional(),
     dbSchemaVersion: DbSchemaVersionSchema,
     labelSet: LabelSetSchema.optional(),
   })

Then handle the default in buildConfigFromEnvironment:

return ENSRainbowConfigSchema.parse({
  port: env.PORT,
  dataDir: env.DATA_DIR ?? getDefaultDataDir(),
  // ...
});
🤖 Prompt for AI Agents
In `@apps/ensrainbow/src/config/config.schema.ts` around lines 33 - 36, The schema
currently calls getDefaultDataDir() at module load in ENSRainbowConfigSchema
(dataDir: DataDirSchema.default(getDefaultDataDir())), capturing process.cwd()
too early; remove the eager default from ENSRainbowConfigSchema and instead
handle lazy evaluation in buildConfigFromEnvironment by supplying dataDir:
env.DATA_DIR ?? getDefaultDataDir() when parsing/building the config, keeping
ENSRainbowConfigSchema (and DataDirSchema/PortSchema) purely declarative and
ensuring getDefaultDataDir() runs only at build time.

dbSchemaVersion: DbSchemaVersionSchema,
labelSet: LabelSetSchema.optional(),
})
/**
* Invariant enforcement
*
* We enforce invariants across multiple values parsed with `ENSRainbowConfigSchema`
* by calling `.check()` function with relevant invariant-enforcing logic.
* Each such function has access to config values that were already parsed.
*/
.check(invariant_dbSchemaVersionMatch);

export type ENSRainbowConfig = z.infer<typeof ENSRainbowConfigSchema>;
Copy link
Contributor

Choose a reason for hiding this comment

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

To stay aligned with rest of the codebase, I suggest replacing ENS* type name prefix with Ens*.

Suggested change
export type ENSRainbowConfig = z.infer<typeof ENSRainbowConfigSchema>;
export type EnsRainbowConfig = z.infer<typeof EnsRainbowConfigSchema>;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see that most of types use ENS


/**
* Builds the ENSRainbow configuration object from an ENSRainbowEnvironment object.
*
* Validates and parses the complete environment configuration using ENSRainbowConfigSchema.
*
* @returns A validated ENSRainbowConfig object
* @throws Error with formatted validation messages if environment parsing fails
*/
export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig {
try {
return ENSRainbowConfigSchema.parse({
port: env.PORT,
dataDir: env.DATA_DIR,
dbSchemaVersion: env.DB_SCHEMA_VERSION,
labelSet:
env.LABEL_SET_ID || env.LABEL_SET_VERSION
? {
labelSetId: env.LABEL_SET_ID,
labelSetVersion: env.LABEL_SET_VERSION,
}
: undefined,
});
} catch (error) {
if (error instanceof ZodError) {
logger.error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`);
} else if (error instanceof Error) {
logger.error(error, `Failed to build ENSRainbowConfig`);
} else {
logger.error(`Unknown Error`);
}

process.exit(1);
}
Comment on lines 107 to 114
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

process.exit(1) prevents testability and graceful error handling.

Calling process.exit(1) terminates the process immediately, making this function difficult to test and preventing callers from handling errors gracefully. Consider throwing a custom error and letting the caller decide how to handle it.

🔧 Suggested refactor for better error handling
+export class ConfigurationError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "ConfigurationError";
+  }
+}
+
 export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig {
   try {
     return ENSRainbowConfigSchema.parse({
       // ... parsing logic
     });
   } catch (error) {
     if (error instanceof ZodError) {
       logger.error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`);
+      throw new ConfigurationError(`Invalid configuration: ${prettifyError(error)}`);
     } else if (error instanceof Error) {
       logger.error(error, `Failed to build ENSRainbowConfig`);
+      throw error;
     } else {
       logger.error(`Unknown Error`);
+      throw new ConfigurationError("Unknown configuration error");
     }
-
-    process.exit(1);
   }
 }

Then handle the exit at the call site (e.g., in CLI entry points):

try {
  const config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment);
} catch (error) {
  process.exit(1);
}
🤖 Prompt for AI Agents
In `@apps/ensrainbow/src/config/config.schema.ts` around lines 73 - 83, Replace
the terminal process.exit(1) in the catch block with throwing a descriptive
error so callers can handle failures; specifically, inside the catch for
buildConfigFromEnvironment (or whatever function constructs ENSRainbowConfig)
throw a custom error (e.g., ConfigBuildError) or rethrow the existing Error with
context including the prettified ZodError output and the message "Failed to
build ENSRainbowConfig", while keeping the existing logger calls for ZodError
and generic Error; move any process.exit(1) behavior out to the CLI/entrypoint
so tests can catch the thrown error and decide whether to exit.

}
5 changes: 5 additions & 0 deletions apps/ensrainbow/src/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { join } from "node:path";

export const ENSRAINBOW_DEFAULT_PORT = 3223;

export const getDefaultDataDir = () => join(process.cwd(), "data");
31 changes: 31 additions & 0 deletions apps/ensrainbow/src/config/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { LogLevelEnvironment, PortEnvironment } from "@ensnode/ensnode-sdk/internal";

/**
* Represents the raw, unvalidated environment variables for the ENSRainbow application.
*
* Keys correspond to the environment variable names, and all values are optional strings, reflecting
* their state in `process.env`. This interface is intended to be the source type which then gets
* mapped/parsed into a structured configuration object like `ENSRainbowConfig`.
*/
export type ENSRainbowEnvironment = PortEnvironment &
LogLevelEnvironment & {
/**
* Directory path where the LevelDB database is stored.
*/
DATA_DIR?: string;

/**
* Expected Database Schema Version.
*/
DB_SCHEMA_VERSION?: string;

/**
* Expected Label Set ID.
*/
LABEL_SET_ID?: string;

/**
* Expected Label Set Version.
*/
LABEL_SET_VERSION?: string;
};
10 changes: 10 additions & 0 deletions apps/ensrainbow/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { buildConfigFromEnvironment } from "./config.schema";
import type { ENSRainbowEnvironment } from "./environment";

export type { ENSRainbowConfig } from "./config.schema";
export { buildConfigFromEnvironment } from "./config.schema";
export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults";
export type { ENSRainbowEnvironment } from "./environment";

// build, validate, and export the ENSRainbowConfig from process.env
export default buildConfigFromEnvironment(process.env as ENSRainbowEnvironment);
1 change: 1 addition & 0 deletions apps/ensrainbow/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { ENSRainbowConfig } from "./config.schema";
25 changes: 25 additions & 0 deletions apps/ensrainbow/src/config/validations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { z } from "zod/v4";

import { DB_SCHEMA_VERSION } from "@/lib/database";

import type { ENSRainbowConfig } from "./config.schema";

/**
* Zod `.check()` function input.
*/
type ZodCheckFnInput<T> = z.core.ParsePayload<T>;

/**
* Invariant: dbSchemaVersion must match the version expected by the code.
*/
export function invariant_dbSchemaVersionMatch(
ctx: ZodCheckFnInput<Pick<ENSRainbowConfig, "dbSchemaVersion">>,
): void {
const { value: config } = ctx;

if (config.dbSchemaVersion !== undefined && config.dbSchemaVersion !== DB_SCHEMA_VERSION) {
throw new Error(
`DB_SCHEMA_VERSION mismatch! Expected version ${DB_SCHEMA_VERSION} from code, but found ${config.dbSchemaVersion} in environment variables.`,
);
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

This .check() invariant throws an Error directly. In most config invariants in this repo, the check adds a custom issue to ctx.issues so the failure is reported via ZodError and formatted by prettifyError (see apps/ensapi/src/config/validations.ts:18-24). Consider pushing a custom issue (with path: ["dbSchemaVersion"]) instead of throwing, so users get consistent, nicely formatted config errors.

Copilot uses AI. Check for mistakes.
}
}
26 changes: 5 additions & 21 deletions apps/ensrainbow/src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,8 @@
import { join } from "node:path";
import config from "@/config";

import { parseNonNegativeInteger } from "@ensnode/ensnode-sdk";

import { logger } from "@/utils/logger";

export const getDefaultDataSubDir = () => join(process.cwd(), "data");

export const DEFAULT_PORT = 3223;
/**
* Gets the port from environment variables.
*/
export function getEnvPort(): number {
const envPort = process.env.PORT;
if (!envPort) {
return DEFAULT_PORT;
}

try {
const port = parseNonNegativeInteger(envPort);
return port;
} catch (_error: unknown) {
const errorMessage = `Invalid PORT value "${envPort}": must be a non-negative integer`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
return config.port;
}
Loading
Loading