From c34c0bda80410277550c6ddeadbf9346e8365f45 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 22 Dec 2025 14:06:34 +0100 Subject: [PATCH 01/57] feat(config): add configuration schema and environment handling for ENSRainbow application --- apps/ensrainbow/src/cli.ts | 13 +-- apps/ensrainbow/src/config/config.schema.ts | 85 +++++++++++++++++++ apps/ensrainbow/src/config/defaults.ts | 5 ++ apps/ensrainbow/src/config/environment.ts | 31 +++++++ apps/ensrainbow/src/config/index.ts | 4 + apps/ensrainbow/src/config/types.ts | 1 + apps/ensrainbow/src/config/validations.ts | 25 ++++++ apps/ensrainbow/src/lib/env.ts | 34 +++----- .../src/shared/config/zod-schemas.ts | 2 + 9 files changed, 174 insertions(+), 26 deletions(-) create mode 100644 apps/ensrainbow/src/config/config.schema.ts create mode 100644 apps/ensrainbow/src/config/defaults.ts create mode 100644 apps/ensrainbow/src/config/environment.ts create mode 100644 apps/ensrainbow/src/config/index.ts create mode 100644 apps/ensrainbow/src/config/types.ts create mode 100644 apps/ensrainbow/src/config/validations.ts diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 3fdc0d530..cc721c2d6 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -18,7 +18,8 @@ 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; @@ -85,7 +86,7 @@ export function createCLI(options: CLIOptions = {}) { // .option("data-dir", { // type: "string", // description: "Directory to store LevelDB data", - // default: getDefaultDataSubDir(), + // default: getDefaultDataDir(), // }); // }, // async (argv: ArgumentsCamelCase) => { @@ -108,7 +109,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory to store LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }); }, async (argv: ArgumentsCamelCase) => { @@ -131,7 +132,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }); }, async (argv: ArgumentsCamelCase) => { @@ -150,7 +151,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: getDefaultDataDir(), }) .option("lite", { type: "boolean", @@ -173,7 +174,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) => { diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts new file mode 100644 index 000000000..d8844f2cf --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -0,0 +1,85 @@ +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_dataDirValid, 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()), + 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_dataDirValid) + .check(invariant_dbSchemaVersionMatch); + +export type ENSRainbowConfig = z.infer; + +/** + * 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); + } +} diff --git a/apps/ensrainbow/src/config/defaults.ts b/apps/ensrainbow/src/config/defaults.ts new file mode 100644 index 000000000..528376734 --- /dev/null +++ b/apps/ensrainbow/src/config/defaults.ts @@ -0,0 +1,5 @@ +import { join } from "node:path"; + +export const ENSRAINBOW_DEFAULT_PORT = 3223; + +export const getDefaultDataDir = () => join(process.cwd(), "data"); diff --git a/apps/ensrainbow/src/config/environment.ts b/apps/ensrainbow/src/config/environment.ts new file mode 100644 index 000000000..eed970cf5 --- /dev/null +++ b/apps/ensrainbow/src/config/environment.ts @@ -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; + }; diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts new file mode 100644 index 000000000..6404675c9 --- /dev/null +++ b/apps/ensrainbow/src/config/index.ts @@ -0,0 +1,4 @@ +export type { ENSRainbowConfig } from "./config.schema"; +export { buildConfigFromEnvironment } from "./config.schema"; +export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; +export type { ENSRainbowEnvironment } from "./environment"; diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts new file mode 100644 index 000000000..cbf9c57be --- /dev/null +++ b/apps/ensrainbow/src/config/types.ts @@ -0,0 +1 @@ +export type { ENSRainbowConfig } from "./config.schema"; diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts new file mode 100644 index 000000000..dfc57a061 --- /dev/null +++ b/apps/ensrainbow/src/config/validations.ts @@ -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 = z.core.ParsePayload; + +/** + * Invariant: dbSchemaVersion must match the version expected by the code. + */ +export function invariant_dbSchemaVersionMatch( + ctx: ZodCheckFnInput>, +): 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.`, + ); + } +} diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts index 048f47ae4..d7ab219e1 100644 --- a/apps/ensrainbow/src/lib/env.ts +++ b/apps/ensrainbow/src/lib/env.ts @@ -1,24 +1,18 @@ -import { join } from "node:path"; +import { buildConfigFromEnvironment } from "@/config/config.schema"; +import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults"; +import type { ENSRainbowEnvironment } from "@/config/environment"; -import { parseNonNegativeInteger } from "@ensnode/ensnode-sdk"; +/** + * @deprecated Use buildConfigFromEnvironment() instead. This constant is kept for backward compatibility. + */ +export const DEFAULT_PORT = ENSRAINBOW_DEFAULT_PORT; -import { logger } from "@/utils/logger"; - -export const getDefaultDataSubDir = () => join(process.cwd(), "data"); - -export const DEFAULT_PORT = 3223; +/** + * Gets the port from environment variables. + * + * @deprecated Use buildConfigFromEnvironment() instead. This function is kept for backward compatibility. + */ 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); - } + const config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); + return config.port; } diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index aa99edb64..95ddad2b5 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -60,9 +60,11 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { /** * Parses a numeric value as a port number. + * Ensures the value is an integer (not a float) within the valid port range. */ export const PortSchema = z.coerce .number({ error: "PORT must be a number." }) + .int({ error: "PORT must be an integer." }) .min(1, { error: "PORT must be greater than 1." }) .max(65535, { error: "PORT must be less than 65535" }) .optional(); From bbe487e56d5df365f72171f85cc8b62032669a84 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 22 Dec 2025 14:41:35 +0100 Subject: [PATCH 02/57] chore: update dependencies and clean up imports in ENSRainbow configuration files --- apps/ensrainbow/package.json | 3 ++- apps/ensrainbow/src/cli.test.ts | 21 ++++++++++++++------- apps/ensrainbow/src/config/config.schema.ts | 3 +-- apps/ensrainbow/src/lib/env.ts | 8 -------- pnpm-lock.yaml | 3 +++ 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/ensrainbow/package.json b/apps/ensrainbow/package.json index 88e149cc8..f4700b111 100644 --- a/apps/ensrainbow/package.json +++ b/apps/ensrainbow/package.json @@ -38,7 +38,8 @@ "progress": "^2.0.3", "protobufjs": "^7.4.0", "viem": "catalog:", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "zod": "catalog:" }, "devDependencies": { "@ensnode/shared-configs": "workspace:*", diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index ff9364a32..c3f3cdbaf 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -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"; @@ -38,8 +39,8 @@ 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", () => { @@ -50,14 +51,20 @@ describe("CLI", () => { 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', - ); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit called"); + }) as never); + expect(() => getEnvPort()).toThrow(); + 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'); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit called"); + }) as never); + expect(() => getEnvPort()).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); }); }); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index d8844f2cf..e74f166c7 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -6,7 +6,7 @@ import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/ import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -import { invariant_dataDirValid, invariant_dbSchemaVersionMatch } from "@/config/validations"; +import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { logger } from "@/utils/logger"; const DataDirSchema = z @@ -44,7 +44,6 @@ const ENSRainbowConfigSchema = z * by calling `.check()` function with relevant invariant-enforcing logic. * Each such function has access to config values that were already parsed. */ - .check(invariant_dataDirValid) .check(invariant_dbSchemaVersionMatch); export type ENSRainbowConfig = z.infer; diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts index d7ab219e1..4de34ea46 100644 --- a/apps/ensrainbow/src/lib/env.ts +++ b/apps/ensrainbow/src/lib/env.ts @@ -1,16 +1,8 @@ import { buildConfigFromEnvironment } from "@/config/config.schema"; -import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -/** - * @deprecated Use buildConfigFromEnvironment() instead. This constant is kept for backward compatibility. - */ -export const DEFAULT_PORT = ENSRAINBOW_DEFAULT_PORT; - /** * Gets the port from environment variables. - * - * @deprecated Use buildConfigFromEnvironment() instead. This function is kept for backward compatibility. */ export function getEnvPort(): number { const config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60fec97a1..99c484151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -480,6 +480,9 @@ importers: yargs: specifier: ^17.7.2 version: 17.7.2 + zod: + specifier: 'catalog:' + version: 3.25.76 devDependencies: '@ensnode/shared-configs': specifier: workspace:* From 22a81f061eefd0806490650700f16027f0b388d5 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 21 Jan 2026 14:48:01 +0100 Subject: [PATCH 03/57] feat(config): build and export ENSRainbowConfig from environment variables --- apps/ensrainbow/src/config/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 6404675c9..00d40f5f3 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,4 +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); From 03722acbc3b81cd332f47bbe25c4bc801d1a76c8 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 21 Jan 2026 16:27:42 +0100 Subject: [PATCH 04/57] refactor(tests): update CLI tests to use vi.stubEnv and async imports for environment variable handling --- apps/ensrainbow/src/cli.test.ts | 54 +++++++++++++++++++++------------ apps/ensrainbow/src/lib/env.ts | 4 +-- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index a8d674a47..650cb7216 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -43,27 +43,31 @@ describe("CLI", () => { 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"; + it("should throw error for invalid port number", async () => { const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit called"); }) as never); - expect(() => getEnvPort()).toThrow(); + 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"; + it("should throw error for negative port number", async () => { const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit called"); }) as never); - expect(() => getEnvPort()).toThrow(); + vi.stubEnv("PORT", "-1"); + vi.resetModules(); + await expect(import("@/lib/env")).rejects.toThrow("process.exit called"); expect(exitSpy).toHaveBeenCalledWith(1); }); }); @@ -73,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"); }); }); @@ -533,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, @@ -546,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)); @@ -594,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"); }); }); diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts index 4de34ea46..e58ccc55e 100644 --- a/apps/ensrainbow/src/lib/env.ts +++ b/apps/ensrainbow/src/lib/env.ts @@ -1,10 +1,8 @@ -import { buildConfigFromEnvironment } from "@/config/config.schema"; -import type { ENSRainbowEnvironment } from "@/config/environment"; +import config from "@/config"; /** * Gets the port from environment variables. */ export function getEnvPort(): number { - const config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); return config.port; } From ec75cfef2f456a405dc3dade1e0e9c93993300a2 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 21 Jan 2026 16:45:02 +0100 Subject: [PATCH 05/57] fix(cli): improve port validation logic to use configured port instead of environment variable --- apps/ensrainbow/src/cli.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 110086122..c4a85334e 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -14,14 +14,16 @@ 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 config from "@/config"; 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.`, ); } From 887aeccff5e3ec6d1fb157ebc0a8fb88db5ec3b2 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 21 Jan 2026 16:48:23 +0100 Subject: [PATCH 06/57] fix lint --- apps/ensrainbow/src/cli.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index c4a85334e..96b33d67f 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,3 +1,5 @@ +import config from "@/config"; + import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,7 +16,6 @@ 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 config from "@/config"; import { getDefaultDataDir } from "@/config/defaults"; import { getEnvPort } from "@/lib/env"; From bd1dd1c36066634843ca4ce23319d5880b1850a1 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 14:12:07 +0100 Subject: [PATCH 07/57] refactor(cli): update CLI to use configured port and improve test imports for environment variables --- apps/ensrainbow/src/cli.test.ts | 6 ++++-- apps/ensrainbow/src/cli.ts | 5 ++--- apps/ensrainbow/src/config/validations.ts | 7 +------ 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 650cb7216..a12073f28 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -39,8 +39,10 @@ describe("CLI", () => { }); describe("getEnvPort", () => { - it("should return ENSRAINBOW_DEFAULT_PORT when PORT is not set", () => { - expect(getEnvPort()).toBe(ENSRAINBOW_DEFAULT_PORT); + it("should return ENSRAINBOW_DEFAULT_PORT when PORT is not set", async () => { + vi.resetModules(); + const { getEnvPort: getEnvPortFresh } = await import("@/lib/env"); + expect(getEnvPortFresh()).toBe(ENSRAINBOW_DEFAULT_PORT); }); it("should return port from environment variable", async () => { diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 96b33d67f..48bf0ae95 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,4 +1,4 @@ -import config from "@/config"; +import config, { getDefaultDataDir } from "@/config"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,7 +16,6 @@ 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 { getDefaultDataDir } from "@/config/defaults"; import { getEnvPort } from "@/lib/env"; export function validatePortConfiguration(cliPort: number): void { @@ -134,7 +133,7 @@ export function createCLI(options: CLIOptions = {}) { .option("port", { type: "number", description: "Port to listen on", - default: getEnvPort(), + default: config.port, }) .option("data-dir", { type: "string", diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index dfc57a061..82f54bc74 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,14 +1,9 @@ -import type { z } from "zod/v4"; +import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; import { DB_SCHEMA_VERSION } from "@/lib/database"; import type { ENSRainbowConfig } from "./config.schema"; -/** - * Zod `.check()` function input. - */ -type ZodCheckFnInput = z.core.ParsePayload; - /** * Invariant: dbSchemaVersion must match the version expected by the code. */ From 1ddfc33e5e49bbfc591b6b8fa2510f2b41b5bb30 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 14:42:47 +0100 Subject: [PATCH 08/57] refactor(config): enhance path resolution and improve error handling in validations --- apps/ensrainbow/src/cli.test.ts | 7 ++++--- apps/ensrainbow/src/cli.ts | 1 - apps/ensrainbow/src/config/config.schema.ts | 8 ++++---- apps/ensrainbow/src/config/validations.ts | 9 ++++++--- packages/ensnode-sdk/src/shared/config/zod-schemas.ts | 4 ++-- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index a12073f28..820f082f1 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -5,7 +5,6 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults"; -import { getEnvPort } from "@/lib/env"; import { createCLI, validatePortConfiguration } from "./cli"; @@ -75,8 +74,10 @@ describe("CLI", () => { }); describe("validatePortConfiguration", () => { - it("should not throw when PORT env var is not set", () => { - expect(() => validatePortConfiguration(3000)).not.toThrow(); + it("should not throw when PORT env var is not set", async () => { + vi.resetModules(); + const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli"); + expect(() => validatePortConfigurationFresh(3000)).not.toThrow(); }); it("should not throw when PORT matches CLI port", async () => { diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 48bf0ae95..81547ef5c 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -16,7 +16,6 @@ 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 { getEnvPort } from "@/lib/env"; export function validatePortConfiguration(cliPort: number): void { // Only validate if PORT was explicitly set in the environment diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index e74f166c7..f3f5be73a 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { isAbsolute, resolve } from "node:path"; import { prettifyError, ZodError, z } from "zod/v4"; @@ -16,11 +16,11 @@ const DataDirSchema = z error: "DATA_DIR must be a non-empty string.", }) .transform((path: string) => { - // Resolve relative paths to absolute paths - if (path.startsWith("/")) { + // Resolve relative paths to absolute paths (cross-platform) + if (isAbsolute(path)) { return path; } - return join(process.cwd(), path); + return resolve(process.cwd(), path); }); const DbSchemaVersionSchema = z.coerce diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 82f54bc74..0de092eb1 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -13,8 +13,11 @@ export function invariant_dbSchemaVersionMatch( 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.`, - ); + ctx.issues.push({ + code: "custom", + path: ["dbSchemaVersion"], + input: config.dbSchemaVersion, + message: `DB_SCHEMA_VERSION mismatch! Expected version ${DB_SCHEMA_VERSION} from code, but found ${config.dbSchemaVersion} in environment variables.`, + }); } } diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index 95ddad2b5..49490c74a 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -65,8 +65,8 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { export const PortSchema = z.coerce .number({ error: "PORT must be a number." }) .int({ error: "PORT must be an integer." }) - .min(1, { error: "PORT must be greater than 1." }) - .max(65535, { error: "PORT must be less than 65535" }) + .min(1, { error: "PORT must be greater than or equal to 1." }) + .max(65535, { error: "PORT must be less than or equal to 65535" }) .optional(); export const TheGraphApiKeySchema = z.string().optional(); From 782e32963eafdaeda4d00b72a8f3525b4447c992 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 14:54:30 +0100 Subject: [PATCH 09/57] test(config): add comprehensive tests for buildConfigFromEnvironment function to validate environment variable handling --- .../src/config/config.schema.test.ts | 450 ++++++++++++++++++ .../src/shared/config/zod-schemas.ts | 2 +- 2 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 apps/ensrainbow/src/config/config.schema.test.ts diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts new file mode 100644 index 000000000..d208241cc --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -0,0 +1,450 @@ +import { isAbsolute, resolve } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DB_SCHEMA_VERSION } from "@/lib/database"; +import { logger } from "@/utils/logger"; + +import { buildConfigFromEnvironment } from "./config.schema"; +import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; +import type { ENSRainbowEnvironment } from "./environment"; + +vi.mock("@/utils/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe("buildConfigFromEnvironment", () => { + // Mock process.exit to prevent actual exit + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + mockExit.mockClear(); + }); + + describe("Success cases", () => { + it("returns a valid config with all defaults when environment is empty", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config).toStrictEqual({ + port: ENSRAINBOW_DEFAULT_PORT, + dataDir: getDefaultDataDir(), + dbSchemaVersion: undefined, + labelSet: undefined, + }); + }); + + it("applies custom port when PORT is set", () => { + const env: ENSRainbowEnvironment = { + PORT: "5000", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.port).toBe(5000); + expect(config.dataDir).toBe(getDefaultDataDir()); + }); + + it("applies custom DATA_DIR when set", () => { + const customDataDir = "/var/lib/ensrainbow/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: customDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dataDir).toBe(customDataDir); + }); + + it("normalizes relative DATA_DIR to absolute path", () => { + const relativeDataDir = "my-data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); + }); + + it("resolves nested relative DATA_DIR correctly", () => { + const relativeDataDir = "./data/ensrainbow/db"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); + }); + + it("preserves absolute DATA_DIR", () => { + const absoluteDataDir = "/absolute/path/to/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: absoluteDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dataDir).toBe(absoluteDataDir); + }); + + it("applies DB_SCHEMA_VERSION when set and matches code version", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + + it("allows DB_SCHEMA_VERSION to be undefined", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBeUndefined(); + }); + + it("applies full label set configuration when both ID and version are set", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + LABEL_SET_VERSION: "0", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.labelSet).toStrictEqual({ + labelSetId: "subgraph", + labelSetVersion: 0, + }); + }); + + it("handles all valid configuration options together", () => { + const env: ENSRainbowEnvironment = { + PORT: "4444", + DATA_DIR: "/opt/ensrainbow/data", + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + LABEL_SET_ID: "ens-normalize-latest", + LABEL_SET_VERSION: "2", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config).toStrictEqual({ + port: 4444, + dataDir: "/opt/ensrainbow/data", + dbSchemaVersion: DB_SCHEMA_VERSION, + labelSet: { + labelSetId: "ens-normalize-latest", + labelSetVersion: 2, + }, + }); + }); + }); + + describe("Validation errors", () => { + it("fails when PORT is not a number", () => { + const env: ENSRainbowEnvironment = { + PORT: "not-a-number", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when PORT is a float", () => { + const env: ENSRainbowEnvironment = { + PORT: "3000.5", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when PORT is less than 1", () => { + const env: ENSRainbowEnvironment = { + PORT: "0", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when PORT is negative", () => { + const env: ENSRainbowEnvironment = { + PORT: "-100", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when PORT is greater than 65535", () => { + const env: ENSRainbowEnvironment = { + PORT: "65536", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when DATA_DIR is empty string", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: "", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when DATA_DIR is only whitespace", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: " ", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when DB_SCHEMA_VERSION is not a number", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "not-a-number", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when DB_SCHEMA_VERSION is a float", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "3.5", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when LABEL_SET_VERSION is not a number", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + LABEL_SET_VERSION: "not-a-number", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when LABEL_SET_VERSION is negative", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + LABEL_SET_VERSION: "-1", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when LABEL_SET_ID is empty", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "", + LABEL_SET_VERSION: "0", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when only LABEL_SET_ID is set (both ID and version required)", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("fails when only LABEL_SET_VERSION is set (both ID and version required)", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_VERSION: "0", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + }); + + describe("Invariant: DB_SCHEMA_VERSION must match code version", () => { + it("fails when DB_SCHEMA_VERSION does not match code version", () => { + const wrongVersion = DB_SCHEMA_VERSION + 1; + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: wrongVersion.toString(), + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + // Verify the error message mentions version mismatch + const errorCall = vi.mocked(logger.error).mock.calls[0]; + expect(errorCall[0]).toContain("Failed to parse environment configuration"); + expect(errorCall[0]).toContain("DB_SCHEMA_VERSION mismatch"); + expect(errorCall[0]).toContain(DB_SCHEMA_VERSION.toString()); + expect(errorCall[0]).toContain(wrongVersion.toString()); + }); + + it("passes when DB_SCHEMA_VERSION matches code version", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + expect(logger.error).not.toHaveBeenCalled(); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it("passes when DB_SCHEMA_VERSION is undefined", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBeUndefined(); + expect(logger.error).not.toHaveBeenCalled(); + expect(process.exit).not.toHaveBeenCalled(); + }); + }); + + describe("Edge cases", () => { + it("handles PORT at minimum valid value (1)", () => { + const env: ENSRainbowEnvironment = { + PORT: "1", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.port).toBe(1); + }); + + it("handles PORT at maximum valid value (65535)", () => { + const env: ENSRainbowEnvironment = { + PORT: "65535", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.port).toBe(65535); + }); + + it("handles DB_SCHEMA_VERSION of 0", () => { + // This test assumes 0 is not the current DB_SCHEMA_VERSION + // If DB_SCHEMA_VERSION is 0, this test would pass which is correct + if (DB_SCHEMA_VERSION === 0) { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "0", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(0); + } else { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "0", + }; + + buildConfigFromEnvironment(env); + + expect(logger.error).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + } + }); + + it("handles LABEL_SET_VERSION of 0", () => { + const env: ENSRainbowEnvironment = { + LABEL_SET_ID: "subgraph", + LABEL_SET_VERSION: "0", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.labelSet?.labelSetVersion).toBe(0); + }); + + it("trims whitespace from DATA_DIR", () => { + const dataDir = "/my/path/to/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: ` ${dataDir} `, + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dataDir).toBe(dataDir); + }); + + it("handles DATA_DIR with .. (parent directory)", () => { + const relativeDataDir = "../data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); + }); + + it("handles DATA_DIR with ~ (not expanded, treated as relative)", () => { + // Note: The config schema does NOT expand ~ to home directory + // It would be treated as a relative path + const tildeDataDir = "~/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: tildeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + // ~ is treated as a directory name, not home expansion + expect(config.dataDir).toBe(resolve(process.cwd(), tildeDataDir)); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index 49490c74a..1c4fd5f7b 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -65,7 +65,7 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { export const PortSchema = z.coerce .number({ error: "PORT must be a number." }) .int({ error: "PORT must be an integer." }) - .min(1, { error: "PORT must be greater than or equal to 1." }) + .min(1, { error: "PORT must be greater than or equal to 1" }) .max(65535, { error: "PORT must be less than or equal to 65535" }) .optional(); From 2e1f9d9df89f6eefb75f6292720c459bf052cd1a Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 15:03:04 +0100 Subject: [PATCH 10/57] feat(api): add public configuration endpoint and enhance ENSRainbowApiClient with config method --- apps/ensrainbow/src/config/config.schema.ts | 24 ++++++++ apps/ensrainbow/src/config/index.ts | 2 +- apps/ensrainbow/src/lib/api.ts | 29 ++++++++++ packages/ensrainbow-sdk/src/client.ts | 64 +++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index f3f5be73a..1fe85fcc0 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -1,8 +1,12 @@ +import packageJson from "@/../package.json" with { type: "json" }; + import { isAbsolute, resolve } from "node:path"; import { prettifyError, ZodError, z } from "zod/v4"; +import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; +import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; @@ -82,3 +86,23 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb process.exit(1); } } + +/** + * Builds the ENSRainbow public configuration from an ENSRainbowConfig object and server state. + * + * @param config - The validated ENSRainbowConfig object + * @param labelSet - The label set managed by the ENSRainbow server + * @param recordsCount - The total count of records managed by the ENSRainbow service + * @returns A complete ENSRainbowPublicConfig object + */ +export function buildENSRainbowPublicConfig( + config: ENSRainbowConfig, + labelSet: EnsRainbowServerLabelSet, + recordsCount: number, +): EnsRainbow.ENSRainbowPublicConfig { + return { + version: packageJson.version, + labelSet, + recordsCount, + }; +} diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 00d40f5f3..83c99cb89 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -2,7 +2,7 @@ import { buildConfigFromEnvironment } from "./config.schema"; import type { ENSRainbowEnvironment } from "./environment"; export type { ENSRainbowConfig } from "./config.schema"; -export { buildConfigFromEnvironment } from "./config.schema"; +export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 767b180aa..220af6990 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,4 +1,5 @@ import packageJson from "@/../package.json"; +import config from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -14,6 +15,7 @@ import { } from "@ensnode/ensnode-sdk"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; +import { buildENSRainbowPublicConfig } from "@/config/config.schema"; import { DB_SCHEMA_VERSION, type ENSRainbowDB } from "@/lib/database"; import { ENSRainbowServer } from "@/lib/server"; import { getErrorMessage } from "@/utils/error-utils"; @@ -101,6 +103,33 @@ export async function createApi(db: ENSRainbowDB): Promise { return c.json(result, result.errorCode); }); + api.get("/v1/config", async (c: HonoContext) => { + logger.debug("Config request"); + const countResult = await server.labelCount(); + if (countResult.status === StatusCode.Error) { + logger.error("Failed to get records count for config endpoint"); + return c.json( + { + status: StatusCode.Error, + error: countResult.error, + errorCode: countResult.errorCode, + }, + 500, + ); + } + + const publicConfig = buildENSRainbowPublicConfig( + config, + server.getServerLabelSet(), + countResult.count, + ); + logger.debug(publicConfig, `Config result`); + return c.json(publicConfig); + }); + + /** + * @deprecated Use GET /v1/config instead. This endpoint will be removed in a future version. + */ api.get("/v1/version", (c: HonoContext) => { logger.debug("Version request"); const result: EnsRainbow.VersionResponse = { diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 87182d976..308ed8436 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -16,6 +16,13 @@ export namespace EnsRainbow { export interface ApiClient { count(): Promise; + /** + * Get the public configuration of the ENSRainbow service + * + * @returns the public configuration of the ENSRainbow service + */ + config(): Promise; + /** * Heal a labelhash to its original label * @param labelHash The labelhash to heal @@ -24,6 +31,12 @@ export namespace EnsRainbow { health(): Promise; + /** + * Get the version information of the ENSRainbow service + * + * @deprecated Use {@link ApiClient.config} instead. This method will be removed in a future version. + * @returns the version information of the ENSRainbow service + */ version(): Promise; getOptions(): Readonly; @@ -118,6 +131,8 @@ export namespace EnsRainbow { /** * ENSRainbow version information. + * + * @deprecated Use {@link ENSRainbowPublicConfig} instead. This type will be removed in a future version. */ export interface VersionInfo { /** @@ -138,11 +153,41 @@ export namespace EnsRainbow { /** * Interface for the version endpoint response + * + * @deprecated Use {@link ENSRainbowPublicConfig} instead. This type will be removed in a future version. */ export interface VersionResponse { status: typeof StatusCode.Success; versionInfo: VersionInfo; } + + /** + * Complete public configuration object for ENSRainbow. + * + * Contains all public configuration information about the ENSRainbow service instance, + * including version, label set information, and record counts. + */ + export interface ENSRainbowPublicConfig { + /** + * ENSRainbow service version + * + * @see https://ghcr.io/namehash/ensnode/ensrainbow + */ + version: string; + + /** + * The label set reference managed by the ENSRainbow server. + * This includes both the label set ID and the highest label set version available. + */ + labelSet: EnsRainbowServerLabelSet; + + /** + * The total count of records managed by the ENSRainbow service. + * This represents the number of rainbow records that can be healed. + * Always a non-negative integer. + */ + recordsCount: number; + } } export interface EnsRainbowApiClientOptions { @@ -351,9 +396,28 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { return response.json() as Promise; } + /** + * Get the public configuration of the ENSRainbow service + * + * @returns the public configuration of the ENSRainbow service + */ + async config(): Promise { + const response = await fetch(new URL("/v1/config", this.options.endpointUrl)); + + if (!response.ok) { + const errorData = (await response.json()) as { error?: string; errorCode?: number }; + throw new Error( + errorData.error ?? `Failed to fetch ENSRainbow config: ${response.statusText}`, + ); + } + + return response.json() as Promise; + } + /** * Get the version information of the ENSRainbow service * + * @deprecated Use {@link EnsRainbowApiClient.config} instead. This method will be removed in a future version. * @returns the version information of the ENSRainbow service * @throws if the request fails due to network failures, DNS lookup failures, request * timeouts, CORS violations, or invalid URLs From ddaaf29dfe7a4a950b3110b2b2437980f017f2e8 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 15:07:56 +0100 Subject: [PATCH 11/57] test(config): remove redundant test for DB_SCHEMA_VERSION handling in buildConfigFromEnvironment --- .../src/config/config.schema.test.ts | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index d208241cc..1387cdd14 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -375,29 +375,6 @@ describe("buildConfigFromEnvironment", () => { expect(config.port).toBe(65535); }); - it("handles DB_SCHEMA_VERSION of 0", () => { - // This test assumes 0 is not the current DB_SCHEMA_VERSION - // If DB_SCHEMA_VERSION is 0, this test would pass which is correct - if (DB_SCHEMA_VERSION === 0) { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: "0", - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.dbSchemaVersion).toBe(0); - } else { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: "0", - }; - - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); - } - }); - it("handles LABEL_SET_VERSION of 0", () => { const env: ENSRainbowEnvironment = { LABEL_SET_ID: "subgraph", From a43b125d948d2a5817c2347c4b258d4d39cc0289 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 16:00:38 +0100 Subject: [PATCH 12/57] refactor(config): improve environment configuration validation and error handling in buildConfigFromEnvironment --- .../src/config/config.schema.test.ts | 100 ++++-------------- apps/ensrainbow/src/config/config.schema.ts | 68 ++++++++---- apps/ensrainbow/src/config/environment.ts | 2 + apps/ensrainbow/src/config/index.ts | 11 +- 4 files changed, 79 insertions(+), 102 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 1387cdd14..899758048 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -16,17 +16,6 @@ vi.mock("@/utils/logger", () => ({ })); describe("buildConfigFromEnvironment", () => { - // Mock process.exit to prevent actual exit - const mockExit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - mockExit.mockClear(); - }); - describe("Success cases", () => { it("returns a valid config with all defaults when environment is empty", () => { const env: ENSRainbowEnvironment = {}; @@ -159,10 +148,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "not-a-number", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is a float", () => { @@ -170,10 +156,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "3000.5", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is less than 1", () => { @@ -181,10 +164,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "0", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is negative", () => { @@ -192,10 +172,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "-100", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is greater than 65535", () => { @@ -203,10 +180,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "65536", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when DATA_DIR is empty string", () => { @@ -214,10 +188,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: "", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when DATA_DIR is only whitespace", () => { @@ -225,10 +196,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: " ", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when DB_SCHEMA_VERSION is not a number", () => { @@ -236,10 +204,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: "not-a-number", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when DB_SCHEMA_VERSION is a float", () => { @@ -247,10 +212,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: "3.5", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when LABEL_SET_VERSION is not a number", () => { @@ -259,10 +221,7 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_VERSION: "not-a-number", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when LABEL_SET_VERSION is negative", () => { @@ -271,10 +230,7 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_VERSION: "-1", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when LABEL_SET_ID is empty", () => { @@ -283,10 +239,7 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_VERSION: "0", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow(); }); it("fails when only LABEL_SET_ID is set (both ID and version required)", () => { @@ -294,10 +247,9 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_ID: "subgraph", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow( + "LABEL_SET_ID is set but LABEL_SET_VERSION is missing", + ); }); it("fails when only LABEL_SET_VERSION is set (both ID and version required)", () => { @@ -305,10 +257,9 @@ describe("buildConfigFromEnvironment", () => { LABEL_SET_VERSION: "0", }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(() => buildConfigFromEnvironment(env)).toThrow( + "LABEL_SET_VERSION is set but LABEL_SET_ID is missing", + ); }); }); @@ -319,16 +270,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: wrongVersion.toString(), }; - buildConfigFromEnvironment(env); - - expect(logger.error).toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); - // Verify the error message mentions version mismatch - const errorCall = vi.mocked(logger.error).mock.calls[0]; - expect(errorCall[0]).toContain("Failed to parse environment configuration"); - expect(errorCall[0]).toContain("DB_SCHEMA_VERSION mismatch"); - expect(errorCall[0]).toContain(DB_SCHEMA_VERSION.toString()); - expect(errorCall[0]).toContain(wrongVersion.toString()); + expect(() => buildConfigFromEnvironment(env)).toThrow(/DB_SCHEMA_VERSION mismatch/); }); it("passes when DB_SCHEMA_VERSION matches code version", () => { @@ -339,8 +281,6 @@ describe("buildConfigFromEnvironment", () => { const config = buildConfigFromEnvironment(env); expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); - expect(logger.error).not.toHaveBeenCalled(); - expect(process.exit).not.toHaveBeenCalled(); }); it("passes when DB_SCHEMA_VERSION is undefined", () => { @@ -349,8 +289,6 @@ describe("buildConfigFromEnvironment", () => { const config = buildConfigFromEnvironment(env); expect(config.dbSchemaVersion).toBeUndefined(); - expect(logger.error).not.toHaveBeenCalled(); - expect(process.exit).not.toHaveBeenCalled(); }); }); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 1fe85fcc0..130a39d0a 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -58,32 +58,60 @@ export type ENSRainbowConfig = z.infer; * Validates and parses the complete environment configuration using ENSRainbowConfigSchema. * * @returns A validated ENSRainbowConfig object - * @throws Error with formatted validation messages if environment parsing fails + * @throws ZodError with detailed 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, + // Transform environment variables into config shape with validation + const envToConfigSchema = z + .object({ + PORT: z.string().optional(), + DATA_DIR: z.string().optional(), + DB_SCHEMA_VERSION: z.string().optional(), + LABEL_SET_ID: z.string().optional(), + LABEL_SET_VERSION: z.string().optional(), + }) + .transform((env) => { + // Validate label set configuration: both must be provided together, or neither + const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; + const hasLabelSetVersion = + env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; + + if (hasLabelSetId && !hasLabelSetVersion) { + throw new Error( + `LABEL_SET_ID is set but LABEL_SET_VERSION is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, + ); + } + + if (!hasLabelSetId && hasLabelSetVersion) { + throw new Error( + `LABEL_SET_VERSION is set but LABEL_SET_ID is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, + ); + } + + return { + port: env.PORT, + dataDir: env.DATA_DIR, + dbSchemaVersion: env.DB_SCHEMA_VERSION, + labelSet: + hasLabelSetId && hasLabelSetVersion + ? { + labelSetId: env.LABEL_SET_ID, + labelSetVersion: env.LABEL_SET_VERSION, + } + : undefined, + }; }); + + try { + const configInput = envToConfigSchema.parse(env); + return ENSRainbowConfigSchema.parse(configInput); } 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`); + // Re-throw ZodError to preserve structured error information + throw error; } - - process.exit(1); + // Re-throw other errors (like our custom label set validation errors) + throw error; } } diff --git a/apps/ensrainbow/src/config/environment.ts b/apps/ensrainbow/src/config/environment.ts index eed970cf5..c07579aa9 100644 --- a/apps/ensrainbow/src/config/environment.ts +++ b/apps/ensrainbow/src/config/environment.ts @@ -21,11 +21,13 @@ export type ENSRainbowEnvironment = PortEnvironment & /** * Expected Label Set ID. + * Must be provided together with LABEL_SET_VERSION, or neither should be set. */ LABEL_SET_ID?: string; /** * Expected Label Set Version. + * Must be provided together with LABEL_SET_ID, or neither should be set. */ LABEL_SET_VERSION?: string; }; diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 83c99cb89..6dffbafe8 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -7,4 +7,13 @@ 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); +let config: ReturnType; +try { + config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); +} catch (error) { + // For CLI applications, invalid configuration should exit the process + console.error("Configuration error:", error instanceof Error ? error.message : String(error)); + process.exit(1); +} + +export default config; From c57e7f676e8f165f256e683f6542a1d5ce8a9df4 Mon Sep 17 00:00:00 2001 From: "kwrobel.eth" Date: Fri, 23 Jan 2026 16:25:48 +0100 Subject: [PATCH 13/57] Create young-carrots-cheer.md --- .changeset/young-carrots-cheer.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/young-carrots-cheer.md diff --git a/.changeset/young-carrots-cheer.md b/.changeset/young-carrots-cheer.md new file mode 100644 index 000000000..819d19d75 --- /dev/null +++ b/.changeset/young-carrots-cheer.md @@ -0,0 +1,7 @@ +--- +"ensrainbow": patch +"@ensnode/ensnode-sdk": patch +"@ensnode/ensrainbow-sdk": patch +--- + +Build ENSRainbow config From e3a6c909c48a75f1eb27d324de9198bb9512c3e9 Mon Sep 17 00:00:00 2001 From: djstrong Date: Fri, 23 Jan 2026 16:28:54 +0100 Subject: [PATCH 14/57] refactor(config): remove unused imports from config schema files to streamline code --- apps/ensrainbow/src/config/config.schema.test.ts | 3 +-- apps/ensrainbow/src/config/config.schema.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 899758048..4e80f5724 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -1,9 +1,8 @@ import { isAbsolute, resolve } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -import { logger } from "@/utils/logger"; import { buildConfigFromEnvironment } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 130a39d0a..7d6200995 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -2,7 +2,7 @@ import packageJson from "@/../package.json" with { type: "json" }; import { isAbsolute, resolve } from "node:path"; -import { prettifyError, ZodError, z } from "zod/v4"; +import { ZodError, z } from "zod/v4"; import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; @@ -11,7 +11,6 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; 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() From 8dc1b6e3641eb17c75240449b93d589ab2840787 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 26 Jan 2026 15:47:14 +0100 Subject: [PATCH 15/57] test(server): add tests for GET /v1/config endpoint to validate error handling and successful responses --- .../src/commands/server-command.test.ts | 36 +++++++++ apps/ensrainbow/src/config/config.schema.ts | 79 +++++++++++-------- apps/ensrainbow/src/config/index.ts | 14 +++- 3 files changed, 93 insertions(+), 36 deletions(-) diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index d6d04e911..173f9cbb7 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -167,6 +167,39 @@ describe("Server Command Tests", () => { }); }); + describe("GET /v1/config", () => { + it("should return an error when database is empty", async () => { + const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); + expect(response.status).toBe(500); + const data = (await response.json()) as { + status: typeof StatusCode.Error; + error: string; + errorCode: typeof ErrorCode.ServerError; + }; + const expectedData = { + status: StatusCode.Error, + error: "Label count not initialized. Check the validate command.", + errorCode: ErrorCode.ServerError, + }; + expect(data).toEqual(expectedData); + }); + + it("should return correct config when label count is initialized", async () => { + // Set a specific precalculated rainbow record count in the database + await db.setPrecalculatedRainbowRecordCount(42); + + const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); + expect(response.status).toBe(200); + const data = (await response.json()) as EnsRainbow.ENSRainbowPublicConfig; + + expect(typeof data.version).toBe("string"); + expect(data.version.length).toBeGreaterThan(0); + expect(data.labelSet.labelSetId).toBe("test-label-set-id"); + expect(data.labelSet.highestLabelSetVersion).toBe(0); + expect(data.recordsCount).toBe(42); + }); + }); + describe("CORS headers for /v1/* routes", () => { it("should return CORS headers for /v1/* routes", async () => { const validLabel = "test-label"; @@ -188,6 +221,9 @@ describe("Server Command Tests", () => { fetch(`http://localhost:${nonDefaultPort}/v1/labels/count`, { method: "OPTIONS", }), + fetch(`http://localhost:${nonDefaultPort}/v1/config`, { + method: "OPTIONS", + }), fetch(`http://localhost:${nonDefaultPort}/v1/version`, { method: "OPTIONS", }), diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 7d6200995..24cb220fb 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -12,6 +12,44 @@ import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; +/** + * Validates and extracts label set configuration from environment variables. + * + * Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither should be set. + * + * @param labelSetId - The raw LABEL_SET_ID environment variable + * @param labelSetVersion - The raw LABEL_SET_VERSION environment variable + * @returns The validated label set configuration object, or undefined if neither is set + * @throws Error if only one of the label set variables is provided + */ +function validateLabelSetConfiguration( + labelSetId: string | undefined, + labelSetVersion: string | undefined, +): { labelSetId: string; labelSetVersion: string } | undefined { + // Validate label set configuration: both must be provided together, or neither + const hasLabelSetId = labelSetId !== undefined && labelSetId.trim() !== ""; + const hasLabelSetVersion = labelSetVersion !== undefined && labelSetVersion.trim() !== ""; + + if (hasLabelSetId && !hasLabelSetVersion) { + throw new Error( + `LABEL_SET_ID is set but LABEL_SET_VERSION is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, + ); + } + + if (!hasLabelSetId && hasLabelSetVersion) { + throw new Error( + `LABEL_SET_VERSION is set but LABEL_SET_ID is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, + ); + } + + return hasLabelSetId && hasLabelSetVersion + ? { + labelSetId, + labelSetVersion, + } + : undefined; +} + const DataDirSchema = z .string() .trim() @@ -57,7 +95,8 @@ export type ENSRainbowConfig = z.infer; * Validates and parses the complete environment configuration using ENSRainbowConfigSchema. * * @returns A validated ENSRainbowConfig object - * @throws ZodError with detailed validation messages if environment parsing fails + * @throws {ZodError} with detailed validation messages if environment parsing fails + * @throws {Error} if label set configuration is invalid (e.g., only one of LABEL_SET_ID or LABEL_SET_VERSION is provided) */ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig { // Transform environment variables into config shape with validation @@ -70,48 +109,18 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb LABEL_SET_VERSION: z.string().optional(), }) .transform((env) => { - // Validate label set configuration: both must be provided together, or neither - const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; - const hasLabelSetVersion = - env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; - - if (hasLabelSetId && !hasLabelSetVersion) { - throw new Error( - `LABEL_SET_ID is set but LABEL_SET_VERSION is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, - ); - } - - if (!hasLabelSetId && hasLabelSetVersion) { - throw new Error( - `LABEL_SET_VERSION is set but LABEL_SET_ID is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, - ); - } + const labelSet = validateLabelSetConfiguration(env.LABEL_SET_ID, env.LABEL_SET_VERSION); return { port: env.PORT, dataDir: env.DATA_DIR, dbSchemaVersion: env.DB_SCHEMA_VERSION, - labelSet: - hasLabelSetId && hasLabelSetVersion - ? { - labelSetId: env.LABEL_SET_ID, - labelSetVersion: env.LABEL_SET_VERSION, - } - : undefined, + labelSet, }; }); - try { - const configInput = envToConfigSchema.parse(env); - return ENSRainbowConfigSchema.parse(configInput); - } catch (error) { - if (error instanceof ZodError) { - // Re-throw ZodError to preserve structured error information - throw error; - } - // Re-throw other errors (like our custom label set validation errors) - throw error; - } + const configInput = envToConfigSchema.parse(env); + return ENSRainbowConfigSchema.parse(configInput); } /** diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 6dffbafe8..873ce0281 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,3 +1,7 @@ +import { prettifyError, ZodError } from "zod/v4"; + +import { logger } from "@/utils/logger"; + import { buildConfigFromEnvironment } from "./config.schema"; import type { ENSRainbowEnvironment } from "./environment"; @@ -12,7 +16,15 @@ try { config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); } catch (error) { // For CLI applications, invalid configuration should exit the process - console.error("Configuration error:", error instanceof Error ? error.message : String(error)); + if (error instanceof ZodError) { + logger.error( + `Failed to parse ENSRainbow environment configuration: \n${prettifyError(error)}\n`, + ); + } else if (error instanceof Error) { + logger.error(error, "Failed to build ENSRainbowConfig"); + } else { + logger.error("Unknown error occurred during configuration"); + } process.exit(1); } From d165cd5c05425e8d0920dad78a0448c6205ae6a3 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 26 Jan 2026 16:07:57 +0100 Subject: [PATCH 16/57] refactor(validations): simplify invariant_dbSchemaVersionMatch function signature for improved clarity --- apps/ensrainbow/src/config/validations.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 0de092eb1..7f72559a0 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -7,9 +7,7 @@ import type { ENSRainbowConfig } from "./config.schema"; /** * Invariant: dbSchemaVersion must match the version expected by the code. */ -export function invariant_dbSchemaVersionMatch( - ctx: ZodCheckFnInput>, -): void { +export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { const { value: config } = ctx; if (config.dbSchemaVersion !== undefined && config.dbSchemaVersion !== DB_SCHEMA_VERSION) { From 79cc9addb6c43f5c2717221284603c137bc79c6f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 26 Jan 2026 16:54:32 +0100 Subject: [PATCH 17/57] fix(deps): update misconfigured lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d36f6cdf..7e8a066f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,7 +536,7 @@ importers: version: 17.7.2 zod: specifier: 'catalog:' - version: 3.25.76 + version: 4.3.6 devDependencies: '@ensnode/shared-configs': specifier: workspace:* From b9a41e91e8daffd6f43be6a5364d75a5ffbb0da1 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 26 Jan 2026 16:55:01 +0100 Subject: [PATCH 18/57] fix(ci): clear linter warnings --- apps/ensrainbow/src/cli.test.ts | 2 +- apps/ensrainbow/src/config/config.schema.ts | 3 +-- apps/ensrainbow/src/lib/api.ts | 7 +------ 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 820f082f1..184ab16ed 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENSRAINBOW_DEFAULT_PORT } from "@/config/defaults"; -import { createCLI, validatePortConfiguration } from "./cli"; +import { createCLI } from "./cli"; // Path to test fixtures const TEST_FIXTURES_DIR = join(__dirname, "..", "test", "fixtures"); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 24cb220fb..4b50c0a8a 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -2,7 +2,7 @@ import packageJson from "@/../package.json" with { type: "json" }; import { isAbsolute, resolve } from "node:path"; -import { ZodError, z } from "zod/v4"; +import { z } from "zod/v4"; import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; @@ -132,7 +132,6 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb * @returns A complete ENSRainbowPublicConfig object */ export function buildENSRainbowPublicConfig( - config: ENSRainbowConfig, labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 220af6990..91e5739ec 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,5 +1,4 @@ import packageJson from "@/../package.json"; -import config from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -118,11 +117,7 @@ export async function createApi(db: ENSRainbowDB): Promise { ); } - const publicConfig = buildENSRainbowPublicConfig( - config, - server.getServerLabelSet(), - countResult.count, - ); + const publicConfig = buildENSRainbowPublicConfig(server.getServerLabelSet(), countResult.count); logger.debug(publicConfig, `Config result`); return c.json(publicConfig); }); From 096049a8ba826664fd214c64b555fae2d8e56038 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 28 Jan 2026 14:34:03 +0100 Subject: [PATCH 19/57] feat(api): Update DB_SCHEMA_VERSION handling in configuration and tests. --- .changeset/young-carrots-cheer.md | 3 +- apps/ensrainbow/src/cli.test.ts | 36 ------------------- .../src/config/config.schema.test.ts | 10 +++--- apps/ensrainbow/src/config/config.schema.ts | 3 +- apps/ensrainbow/src/config/validations.ts | 4 +-- apps/ensrainbow/src/lib/env.ts | 8 ----- 6 files changed, 10 insertions(+), 54 deletions(-) delete mode 100644 apps/ensrainbow/src/lib/env.ts diff --git a/.changeset/young-carrots-cheer.md b/.changeset/young-carrots-cheer.md index 819d19d75..1f3e96ab3 100644 --- a/.changeset/young-carrots-cheer.md +++ b/.changeset/young-carrots-cheer.md @@ -1,7 +1,6 @@ --- "ensrainbow": patch -"@ensnode/ensnode-sdk": patch "@ensnode/ensrainbow-sdk": patch --- -Build ENSRainbow config +Adds `/v1/config` endpoint to ENSRainbow API returning public configuration (version, label set, records count) and deprecates `/v1/version` endpoint. The new endpoint provides comprehensive service discovery capabilities for clients. diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 184ab16ed..8fcb8c8b2 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -37,42 +37,6 @@ describe("CLI", () => { await rm(tempDir, { recursive: true, force: true }); }); - describe("getEnvPort", () => { - it("should return ENSRAINBOW_DEFAULT_PORT when PORT is not set", async () => { - vi.resetModules(); - const { getEnvPort: getEnvPortFresh } = await import("@/lib/env"); - expect(getEnvPortFresh()).toBe(ENSRAINBOW_DEFAULT_PORT); - }); - - it("should return port from environment variable", async () => { - const customPort = 4000; - 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", 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", 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); - }); - }); - describe("validatePortConfiguration", () => { it("should not throw when PORT env var is not set", async () => { vi.resetModules(); diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 4e80f5724..9b791ebd9 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -24,7 +24,7 @@ describe("buildConfigFromEnvironment", () => { expect(config).toStrictEqual({ port: ENSRAINBOW_DEFAULT_PORT, dataDir: getDefaultDataDir(), - dbSchemaVersion: undefined, + dbSchemaVersion: DB_SCHEMA_VERSION, labelSet: undefined, }); }); @@ -96,12 +96,12 @@ describe("buildConfigFromEnvironment", () => { expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); - it("allows DB_SCHEMA_VERSION to be undefined", () => { + it("defaults DB_SCHEMA_VERSION to code version when not set", () => { const env: ENSRainbowEnvironment = {}; const config = buildConfigFromEnvironment(env); - expect(config.dbSchemaVersion).toBeUndefined(); + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); it("applies full label set configuration when both ID and version are set", () => { @@ -282,12 +282,12 @@ describe("buildConfigFromEnvironment", () => { expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); - it("passes when DB_SCHEMA_VERSION is undefined", () => { + it("passes when DB_SCHEMA_VERSION defaults to code version", () => { const env: ENSRainbowEnvironment = {}; const config = buildConfigFromEnvironment(env); - expect(config.dbSchemaVersion).toBeUndefined(); + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); }); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 4b50c0a8a..e12be2eee 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -10,6 +10,7 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; +import { DB_SCHEMA_VERSION } from "@/lib/database"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; /** @@ -67,7 +68,7 @@ const DataDirSchema = z const DbSchemaVersionSchema = z.coerce .number({ error: "DB_SCHEMA_VERSION must be a number." }) .int({ error: "DB_SCHEMA_VERSION must be an integer." }) - .optional(); + .default(DB_SCHEMA_VERSION); const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 7f72559a0..4171a6a65 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -10,12 +10,12 @@ import type { ENSRainbowConfig } from "./config.schema"; export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { const { value: config } = ctx; - if (config.dbSchemaVersion !== undefined && config.dbSchemaVersion !== DB_SCHEMA_VERSION) { + if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { ctx.issues.push({ code: "custom", path: ["dbSchemaVersion"], input: config.dbSchemaVersion, - message: `DB_SCHEMA_VERSION mismatch! Expected version ${DB_SCHEMA_VERSION} from code, but found ${config.dbSchemaVersion} in environment variables.`, + message: `DB_SCHEMA_VERSION mismatch! Code expects version ${DB_SCHEMA_VERSION}, but found ${config.dbSchemaVersion} in environment variables.`, }); } } diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts deleted file mode 100644 index e58ccc55e..000000000 --- a/apps/ensrainbow/src/lib/env.ts +++ /dev/null @@ -1,8 +0,0 @@ -import config from "@/config"; - -/** - * Gets the port from environment variables. - */ -export function getEnvPort(): number { - return config.port; -} From f4859d78f62fe42231afe1aa4b576a59dab8ea5e Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 28 Jan 2026 14:41:06 +0100 Subject: [PATCH 20/57] refactor(config): enhance error handling and validation in buildConfigFromEnvironment function --- apps/ensrainbow/src/config/config.schema.ts | 57 ++++++++++++--------- apps/ensrainbow/src/config/index.ts | 24 +-------- apps/ensrainbow/types/env.d.ts | 4 +- 3 files changed, 35 insertions(+), 50 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index e12be2eee..fcf4c3acd 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -2,7 +2,7 @@ import packageJson from "@/../package.json" with { type: "json" }; import { isAbsolute, resolve } from "node:path"; -import { z } from "zod/v4"; +import { prettifyError, ZodError, z } from "zod/v4"; import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; @@ -96,32 +96,39 @@ export type ENSRainbowConfig = z.infer; * Validates and parses the complete environment configuration using ENSRainbowConfigSchema. * * @returns A validated ENSRainbowConfig object - * @throws {ZodError} with detailed validation messages if environment parsing fails - * @throws {Error} if label set configuration is invalid (e.g., only one of LABEL_SET_ID or LABEL_SET_VERSION is provided) + * @throws Error with formatted validation messages if environment parsing fails */ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig { - // Transform environment variables into config shape with validation - const envToConfigSchema = z - .object({ - PORT: z.string().optional(), - DATA_DIR: z.string().optional(), - DB_SCHEMA_VERSION: z.string().optional(), - LABEL_SET_ID: z.string().optional(), - LABEL_SET_VERSION: z.string().optional(), - }) - .transform((env) => { - const labelSet = validateLabelSetConfiguration(env.LABEL_SET_ID, env.LABEL_SET_VERSION); - - return { - port: env.PORT, - dataDir: env.DATA_DIR, - dbSchemaVersion: env.DB_SCHEMA_VERSION, - labelSet, - }; - }); - - const configInput = envToConfigSchema.parse(env); - return ENSRainbowConfigSchema.parse(configInput); + try { + // Transform environment variables into config shape with validation + const envToConfigSchema = z + .object({ + PORT: z.string().optional(), + DATA_DIR: z.string().optional(), + DB_SCHEMA_VERSION: z.string().optional(), + LABEL_SET_ID: z.string().optional(), + LABEL_SET_VERSION: z.string().optional(), + }) + .transform((env) => { + const labelSet = validateLabelSetConfiguration(env.LABEL_SET_ID, env.LABEL_SET_VERSION); + + return { + port: env.PORT, + dataDir: env.DATA_DIR, + dbSchemaVersion: env.DB_SCHEMA_VERSION, + labelSet, + }; + }); + + const configInput = envToConfigSchema.parse(env); + return ENSRainbowConfigSchema.parse(configInput); + } catch (error) { + if (error instanceof ZodError) { + throw new Error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`); + } + + throw error; + } } /** diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 873ce0281..666e1870d 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,9 +1,4 @@ -import { prettifyError, ZodError } from "zod/v4"; - -import { logger } from "@/utils/logger"; - import { buildConfigFromEnvironment } from "./config.schema"; -import type { ENSRainbowEnvironment } from "./environment"; export type { ENSRainbowConfig } from "./config.schema"; export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; @@ -11,21 +6,4 @@ export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; // build, validate, and export the ENSRainbowConfig from process.env -let config: ReturnType; -try { - config = buildConfigFromEnvironment(process.env as ENSRainbowEnvironment); -} catch (error) { - // For CLI applications, invalid configuration should exit the process - if (error instanceof ZodError) { - logger.error( - `Failed to parse ENSRainbow environment configuration: \n${prettifyError(error)}\n`, - ); - } else if (error instanceof Error) { - logger.error(error, "Failed to build ENSRainbowConfig"); - } else { - logger.error("Unknown error occurred during configuration"); - } - process.exit(1); -} - -export default config; +export default buildConfigFromEnvironment(process.env); diff --git a/apps/ensrainbow/types/env.d.ts b/apps/ensrainbow/types/env.d.ts index dd956c600..55fd948b3 100644 --- a/apps/ensrainbow/types/env.d.ts +++ b/apps/ensrainbow/types/env.d.ts @@ -1,7 +1,7 @@ -import type { LogLevelEnvironment } from "@ensnode/ensnode-sdk/internal"; +import type { ENSRainbowEnvironment } from "@/config/environment"; declare global { namespace NodeJS { - interface ProcessEnv extends LogLevelEnvironment {} + interface ProcessEnv extends ENSRainbowEnvironment {} } } From 3d9399f5becc0da37276b47fbccfa8ea2b19132c Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 28 Jan 2026 16:51:40 +0100 Subject: [PATCH 21/57] refactor(cli): streamline port configuration handling and enhance CLI tests for port overrides --- apps/ensrainbow/src/cli.test.ts | 100 +++++++++++++++----- apps/ensrainbow/src/cli.ts | 22 +---- apps/ensrainbow/src/config/config.schema.ts | 2 +- apps/ensrainbow/src/config/index.ts | 2 +- 4 files changed, 84 insertions(+), 42 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 8fcb8c8b2..789b416a7 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -37,25 +37,28 @@ describe("CLI", () => { await rm(tempDir, { recursive: true, force: true }); }); - describe("validatePortConfiguration", () => { - it("should not throw when PORT env var is not set", async () => { + describe("port configuration", () => { + it("should allow CLI port to override PORT env var", async () => { + // Mock serverCommand so we only test argument resolution here vi.resetModules(); - const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli"); - expect(() => validatePortConfigurationFresh(3000)).not.toThrow(); - }); + const serverCommandMock = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/commands/server-command", () => ({ + serverCommand: serverCommandMock, + })); - it("should not throw when PORT matches CLI port", async () => { + // Simulate PORT being set in the environment 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", async () => { - vi.stubEnv("PORT", "3000"); - vi.resetModules(); - const { validatePortConfiguration: validatePortConfigurationFresh } = await import("./cli"); - expect(() => validatePortConfigurationFresh(4000)).toThrow("Port conflict"); + const { createCLI: createCLIFresh } = await import("./cli"); + const cliWithPort = createCLIFresh({ exitProcess: false }); + + // CLI port should override env PORT + await cliWithPort.parse(["serve", "--port", "4000", "--data-dir", testDataDir]); + + expect(serverCommandMock).toHaveBeenCalledWith(expect.objectContaining({ port: 4000 })); + + // Restore real implementation for subsequent tests + vi.doUnmock("@/commands/server-command"); }); }); @@ -494,8 +497,8 @@ describe("CLI", () => { testDataDir, ]); - // Give server time to start - await new Promise((resolve) => setTimeout(resolve, 100)); + // Give server time to start (DB open + validation can take a bit) + await new Promise((resolve) => setTimeout(resolve, 500)); // Make a request to health endpoint const response = await fetch(`http://localhost:${customPort}/health`); @@ -506,6 +509,30 @@ describe("CLI", () => { await serverPromise; }); + it("should use the default port when PORT env var is not set", async () => { + // Mock serverCommand so we don't actually start a server or touch the DB here + vi.resetModules(); + const serverCommandMock = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/commands/server-command", () => ({ + serverCommand: serverCommandMock, + })); + + // PORT is cleared in beforeEach; no CLI --port is provided + const { createCLI: createCLIFresh } = await import("./cli"); + const cliWithDefaultPort = createCLIFresh({ exitProcess: false }); + + // Invoke serve without specifying --port + await cliWithDefaultPort.parse(["serve", "--data-dir", testDataDir]); + + // Assert that serverCommand was called with the default port + expect(serverCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ port: ENSRAINBOW_DEFAULT_PORT }), + ); + + // Restore real implementation for subsequent tests + vi.doUnmock("@/commands/server-command"); + }); + it("should respect PORT environment variable", async () => { const customPort = 5115; vi.stubEnv("PORT", customPort.toString()); @@ -526,8 +553,8 @@ describe("CLI", () => { // Start server const serverPromise = cliWithCustomPort.parse(["serve", "--data-dir", testDataDir]); - // Give server time to start - await new Promise((resolve) => setTimeout(resolve, 100)); + // Give server time to start (DB open + validation can take a bit) + await new Promise((resolve) => setTimeout(resolve, 500)); // Make a request to health endpoint const response = await fetch(`http://localhost:${customPort}/health`); @@ -571,14 +598,41 @@ describe("CLI", () => { await serverPromise; }); - it("should throw on port conflict", async () => { + it("should allow CLI port to override PORT env var", async () => { vi.stubEnv("PORT", "5000"); vi.resetModules(); const { createCLI: createCLIFresh } = await import("./cli"); const cliWithPort = createCLIFresh({ exitProcess: false }); - await expect( - cliWithPort.parse(["serve", "--port", "4000", "--data-dir", testDataDir]), - ).rejects.toThrow("Port conflict"); + + // First ingest data + const ensrainbowOutputFile = join(TEST_FIXTURES_DIR, "test_ens_names_0.ensrainbow"); + await cliWithPort.parse([ + "ingest-ensrainbow", + "--input-file", + ensrainbowOutputFile, + "--data-dir", + testDataDir, + ]); + + // CLI port should override env PORT without error + const serverPromise = cliWithPort.parse([ + "serve", + "--port", + "4000", + "--data-dir", + testDataDir, + ]); + + // Give server time to start (DB open + validation can take a bit) + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify server is running on the CLI port (not env port) + const response = await fetch("http://localhost:4000/health"); + expect(response.status).toBe(200); + + // Cleanup - send SIGINT to stop server + process.emit("SIGINT", "SIGINT"); + await serverPromise; }); }); diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 81547ef5c..3bf0c160f 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,4 +1,4 @@ -import config, { getDefaultDataDir } from "@/config"; +import config from "@/config"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,17 +17,6 @@ import { purgeCommand } from "@/commands/purge-command"; import { serverCommand } from "@/commands/server-command"; import { validateCommand } from "@/commands/validate-command"; -export function validatePortConfiguration(cliPort: number): void { - // 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 configured port (${config.port}). ` + - `Please use only one method to specify the port.`, - ); - } -} - // interface IngestArgs { // "input-file": string; // "data-dir": string; @@ -114,7 +103,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory to store LevelDB data", - default: getDefaultDataDir(), + default: config.dataDir, }); }, async (argv: ArgumentsCamelCase) => { @@ -137,11 +126,10 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataDir(), + default: config.dataDir, }); }, async (argv: ArgumentsCamelCase) => { - validatePortConfiguration(argv.port); await serverCommand({ port: argv.port, dataDir: argv["data-dir"], @@ -156,7 +144,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataDir(), + default: config.dataDir, }) .option("lite", { type: "boolean", @@ -179,7 +167,7 @@ export function createCLI(options: CLIOptions = {}) { return yargs.option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataDir(), + default: config.dataDir, }); }, async (argv: ArgumentsCamelCase) => { diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index fcf4c3acd..6fc160457 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -10,8 +10,8 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -import { DB_SCHEMA_VERSION } from "@/lib/database"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; +import { DB_SCHEMA_VERSION } from "@/lib/database"; /** * Validates and extracts label set configuration from environment variables. diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 666e1870d..533794107 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -2,7 +2,7 @@ import { buildConfigFromEnvironment } from "./config.schema"; export type { ENSRainbowConfig } from "./config.schema"; export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; -export { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; +export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; // build, validate, and export the ENSRainbowConfig from process.env From e735747a57c9dc5127e94c7f8c699ec00a6bdd84 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 14:58:56 +0100 Subject: [PATCH 22/57] refactor(api): remove debug logging from various API endpoints for cleaner output --- apps/ensrainbow/src/lib/api.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 91e5739ec..12b2381ab 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -79,31 +79,21 @@ export async function createApi(db: ENSRainbowDB): Promise { ); } - logger.debug( - `Healing request for labelhash: ${labelhash}, with labelSet: ${JSON.stringify( - clientLabelSet, - )}`, - ); const result = await server.heal(labelhash, clientLabelSet); - logger.debug(result, `Heal result:`); return c.json(result, result.errorCode); }); api.get("/health", (c: HonoContext) => { - logger.debug("Health check request"); const result: EnsRainbow.HealthResponse = { status: "ok" }; return c.json(result); }); api.get("/v1/labels/count", async (c: HonoContext) => { - logger.debug("Label count request"); const result = await server.labelCount(); - logger.debug(result, `Count result`); return c.json(result, result.errorCode); }); api.get("/v1/config", async (c: HonoContext) => { - logger.debug("Config request"); const countResult = await server.labelCount(); if (countResult.status === StatusCode.Error) { logger.error("Failed to get records count for config endpoint"); @@ -118,7 +108,6 @@ export async function createApi(db: ENSRainbowDB): Promise { } const publicConfig = buildENSRainbowPublicConfig(server.getServerLabelSet(), countResult.count); - logger.debug(publicConfig, `Config result`); return c.json(publicConfig); }); @@ -126,7 +115,6 @@ export async function createApi(db: ENSRainbowDB): Promise { * @deprecated Use GET /v1/config instead. This endpoint will be removed in a future version. */ api.get("/v1/version", (c: HonoContext) => { - logger.debug("Version request"); const result: EnsRainbow.VersionResponse = { status: StatusCode.Success, versionInfo: { @@ -135,7 +123,6 @@ export async function createApi(db: ENSRainbowDB): Promise { labelSet: server.getServerLabelSet(), }, }; - logger.debug(result, `Version result`); return c.json(result); }); From 5bafcdb5a72e5753cdaea4ce37415a42bef3a971 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 15:49:48 +0100 Subject: [PATCH 23/57] revert --- apps/ensrainbow/src/config/config.schema.ts | 1 + apps/ensrainbow/src/lib/api.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 6fc160457..53c2f8205 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -140,6 +140,7 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb * @returns A complete ENSRainbowPublicConfig object */ export function buildENSRainbowPublicConfig( + config: ENSRainbowConfig, labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 12b2381ab..7449ba8f4 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,4 +1,5 @@ import packageJson from "@/../package.json"; +import config from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -107,7 +108,11 @@ export async function createApi(db: ENSRainbowDB): Promise { ); } - const publicConfig = buildENSRainbowPublicConfig(server.getServerLabelSet(), countResult.count); + const publicConfig = buildENSRainbowPublicConfig( + config, + server.getServerLabelSet(), + countResult.count, + ); return c.json(publicConfig); }); From 7a646e097c70db22f1d949f95148ddc75e825bdd Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 16:15:15 +0100 Subject: [PATCH 24/57] refactor(config): remove label set validation function and enhance schema validation for environment variables --- apps/ensrainbow/src/config/config.schema.ts | 103 ++++++++++---------- apps/ensrainbow/src/config/types.ts | 85 +++++++++++++++- apps/ensrainbow/src/config/validations.ts | 6 +- 3 files changed, 139 insertions(+), 55 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 53c2f8205..131c48aab 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -10,47 +10,10 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; +import type { ENSRainbowConfig } from "@/config/types"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -/** - * Validates and extracts label set configuration from environment variables. - * - * Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither should be set. - * - * @param labelSetId - The raw LABEL_SET_ID environment variable - * @param labelSetVersion - The raw LABEL_SET_VERSION environment variable - * @returns The validated label set configuration object, or undefined if neither is set - * @throws Error if only one of the label set variables is provided - */ -function validateLabelSetConfiguration( - labelSetId: string | undefined, - labelSetVersion: string | undefined, -): { labelSetId: string; labelSetVersion: string } | undefined { - // Validate label set configuration: both must be provided together, or neither - const hasLabelSetId = labelSetId !== undefined && labelSetId.trim() !== ""; - const hasLabelSetVersion = labelSetVersion !== undefined && labelSetVersion.trim() !== ""; - - if (hasLabelSetId && !hasLabelSetVersion) { - throw new Error( - `LABEL_SET_ID is set but LABEL_SET_VERSION is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, - ); - } - - if (!hasLabelSetId && hasLabelSetVersion) { - throw new Error( - `LABEL_SET_VERSION is set but LABEL_SET_ID is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.`, - ); - } - - return hasLabelSetId && hasLabelSetVersion - ? { - labelSetId, - labelSetVersion, - } - : undefined; -} - const DataDirSchema = z .string() .trim() @@ -68,17 +31,19 @@ const DataDirSchema = z const DbSchemaVersionSchema = z.coerce .number({ error: "DB_SCHEMA_VERSION must be a number." }) .int({ error: "DB_SCHEMA_VERSION must be an integer." }) + .positive({ error: "DB_SCHEMA_VERSION must be greater than 0." }) .default(DB_SCHEMA_VERSION); const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); -const ENSRainbowConfigSchema = z - .object({ - port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), - dataDir: DataDirSchema.default(getDefaultDataDir()), - dbSchemaVersion: DbSchemaVersionSchema, - labelSet: LabelSetSchema.optional(), - }) +const ENSRainbowConfigBaseSchema = z.object({ + port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), + dataDir: DataDirSchema.default(getDefaultDataDir()), + dbSchemaVersion: DbSchemaVersionSchema, + labelSet: LabelSetSchema.optional(), +}); + +const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema /** * Invariant enforcement * @@ -88,8 +53,6 @@ const ENSRainbowConfigSchema = z */ .check(invariant_dbSchemaVersionMatch); -export type ENSRainbowConfig = z.infer; - /** * Builds the ENSRainbow configuration object from an ENSRainbowEnvironment object. * @@ -100,7 +63,7 @@ export type ENSRainbowConfig = z.infer; */ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowConfig { try { - // Transform environment variables into config shape with validation + // Transform environment variables into config shape const envToConfigSchema = z .object({ PORT: z.string().optional(), @@ -109,8 +72,50 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb LABEL_SET_ID: z.string().optional(), LABEL_SET_VERSION: z.string().optional(), }) + /** + * Invariant enforcement on environment variables + * + * We check that LABEL_SET_ID and LABEL_SET_VERSION are provided together, or neither. + * This check happens before transformation to ensure we don't create invalid config objects. + */ + .check((ctx) => { + const { value: env } = ctx; + const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; + const hasLabelSetVersion = + env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; + + if (hasLabelSetId && !hasLabelSetVersion) { + ctx.issues.push({ + code: "custom", + path: ["LABEL_SET_VERSION"], + input: env, + message: + "LABEL_SET_ID is set but LABEL_SET_VERSION is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.", + }); + } + + if (!hasLabelSetId && hasLabelSetVersion) { + ctx.issues.push({ + code: "custom", + path: ["LABEL_SET_ID"], + input: env, + message: + "LABEL_SET_VERSION is set but LABEL_SET_ID is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.", + }); + } + }) .transform((env) => { - const labelSet = validateLabelSetConfiguration(env.LABEL_SET_ID, env.LABEL_SET_VERSION); + const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; + const hasLabelSetVersion = + env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; + + const labelSet = + hasLabelSetId && hasLabelSetVersion + ? { + labelSetId: env.LABEL_SET_ID, + labelSetVersion: env.LABEL_SET_VERSION, + } + : undefined; return { port: env.PORT, diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index cbf9c57be..2b0c379dd 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -1 +1,84 @@ -export type { ENSRainbowConfig } from "./config.schema"; +import type { EnsRainbowClientLabelSet } from "@ensnode/ensnode-sdk"; + +/** + * The complete runtime configuration for an ENSRainbow instance. + * + * This interface defines all configuration parameters needed to run an ENSRainbow server, + * which provides label healing services for ENS names. + */ +export interface ENSRainbowConfig { + /** + * The port number on which the ENSRainbow server listens. + * + * The HTTP server will bind to this port and serve the ENSRainbow API. + * + * Default: 3223 (defined by {@link ENSRAINBOW_DEFAULT_PORT}) + * + * Invariants: + * - Must be a valid port number (1-65535) + * - Must not be already in use by another process + */ + port: number; + + /** + * The absolute path to the data directory where ENSRainbow stores its database and other files. + * + * This directory will contain: + * - The SQLite database file with label set data + * - Any temporary files created during operation + * + * If a relative path is provided in the environment variable, it will be resolved to an + * absolute path relative to the current working directory. + * + * Default: `{cwd}/ensrainbow-data` or `/data` in Docker + * + * Invariants: + * - Must be a non-empty string + * - Must be an absolute path after resolution + * - The process must have read/write permissions to this directory + */ + dataDir: string; + + /** + * The database schema version expected by the code. + * + * This version number corresponds to the structure of the SQLite database that ENSRainbow + * uses to store label set data. If the version in the environment doesn't match the version + * expected by the code, the application will fail to start. + * + * This prevents version mismatches between the codebase and the database schema, which could + * lead to data corruption or runtime errors. + * + * Default: {@link DB_SCHEMA_VERSION} (currently 3) + * + * Invariants: + * - Must be a positive integer + * - Must match {@link DB_SCHEMA_VERSION} exactly + */ + dbSchemaVersion: number; + + /** + * Optional label set configuration that specifies which label set to use. + * + * A label set defines which labels (domain name segments) are available for label healing. + * Both `labelSetId` and `labelSetVersion` must be provided together to create a "fully pinned" + * label set reference, ensuring deterministic and reproducible label healing. + * + * If not provided, ENSRainbow will start without any label set loaded, and label healing + * requests will fail until a label set is loaded via the management API. + * + * Examples: + * - `{ labelSetId: "subgraph", labelSetVersion: 0 }` - The legacy subgraph label set + * - `{ labelSetId: "ensip-15", labelSetVersion: 1 }` - ENSIP-15 normalized labels + * + * Default: undefined (no label set) + * + * Invariants: + * - If provided, both `labelSetId` and `labelSetVersion` must be defined + * - `labelSetId` must be 1-50 characters, containing only lowercase letters (a-z) and hyphens (-) + * - `labelSetVersion` must be a non-negative integer + * - If only one of LABEL_SET_ID or LABEL_SET_VERSION is provided in the environment, + * configuration parsing will fail with a clear error message + */ + labelSet?: Required; +} diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 4171a6a65..280084955 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,13 +1,9 @@ -import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; - import { DB_SCHEMA_VERSION } from "@/lib/database"; -import type { ENSRainbowConfig } from "./config.schema"; - /** * Invariant: dbSchemaVersion must match the version expected by the code. */ -export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { +export function invariant_dbSchemaVersionMatch(ctx: any): void { const { value: config } = ctx; if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { From 9d1208868d9958e67619eeeded60f4cd48b2f08f Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 16:20:17 +0100 Subject: [PATCH 25/57] feat(server): log configuration details on server startup for improved visibility --- apps/ensrainbow/src/commands/server-command.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 05c0f42f7..c979d3a64 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -1,5 +1,8 @@ import { serve } from "@hono/node-server"; +import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; + +import config from "@/config"; import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; import { logger } from "@/utils/logger"; @@ -17,6 +20,10 @@ export async function createServer(db: ENSRainbowDB) { } export async function serverCommand(options: ServerCommandOptions): Promise { + // Log the config that ENSRainbow is running with + console.log("ENSRainbow running with config:"); + console.log(prettyPrintJson(config)); + logger.info(`ENS Rainbow server starting on port ${options.port}...`); const db = await ENSRainbowDB.open(options.dataDir); From 89ff961200013a271c1b0cf6637c5453e5aba0e7 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 16:26:21 +0100 Subject: [PATCH 26/57] feat(server): add database initialization check to prevent server startup with empty database --- apps/ensrainbow/src/commands/server-command.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index c979d3a64..cd163cd35 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -1,8 +1,9 @@ +import config from "@/config"; + import { serve } from "@hono/node-server"; import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; -import config from "@/config"; import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; import { logger } from "@/utils/logger"; @@ -29,6 +30,19 @@ export async function serverCommand(options: ServerCommandOptions): Promise Date: Mon, 2 Feb 2026 16:29:36 +0100 Subject: [PATCH 27/57] feat(config): export ENSRainbowConfig type for improved type safety in configuration schema --- apps/ensrainbow/src/config/config.schema.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 131c48aab..753a838b2 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -14,6 +14,8 @@ import type { ENSRainbowConfig } from "@/config/types"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { DB_SCHEMA_VERSION } from "@/lib/database"; +export type { ENSRainbowConfig }; + const DataDirSchema = z .string() .trim() From 363208c0f7de168b5939d0def10191d3e97c4823 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 16:40:26 +0100 Subject: [PATCH 28/57] refactor(config): introduce hasValue helper function for string validation and update environment variable checks --- apps/ensrainbow/src/config/config.schema.ts | 20 ++++++++++++++------ apps/ensrainbow/src/config/types.ts | 6 +++--- apps/ensrainbow/src/config/validations.ts | 6 +++++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 753a838b2..4601b3bff 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -45,6 +45,16 @@ const ENSRainbowConfigBaseSchema = z.object({ labelSet: LabelSetSchema.optional(), }); +/** + * Helper function to check if a string value is present (not undefined and not empty after trimming). + * + * @param str - The string value to check + * @returns true if the string is defined and has non-whitespace content after trimming + */ +const hasValue = (str: string | undefined): boolean => { + return str !== undefined && str.trim() !== ""; +}; + const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema /** * Invariant enforcement @@ -82,9 +92,8 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb */ .check((ctx) => { const { value: env } = ctx; - const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; - const hasLabelSetVersion = - env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; + const hasLabelSetId = hasValue(env.LABEL_SET_ID); + const hasLabelSetVersion = hasValue(env.LABEL_SET_VERSION); if (hasLabelSetId && !hasLabelSetVersion) { ctx.issues.push({ @@ -107,9 +116,8 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb } }) .transform((env) => { - const hasLabelSetId = env.LABEL_SET_ID !== undefined && env.LABEL_SET_ID.trim() !== ""; - const hasLabelSetVersion = - env.LABEL_SET_VERSION !== undefined && env.LABEL_SET_VERSION.trim() !== ""; + const hasLabelSetId = hasValue(env.LABEL_SET_ID); + const hasLabelSetVersion = hasValue(env.LABEL_SET_VERSION); const labelSet = hasLabelSetId && hasLabelSetVersion diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index 2b0c379dd..45f279d89 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -24,13 +24,13 @@ export interface ENSRainbowConfig { * The absolute path to the data directory where ENSRainbow stores its database and other files. * * This directory will contain: - * - The SQLite database file with label set data + * - The LevelDB database file with label set data * - Any temporary files created during operation * * If a relative path is provided in the environment variable, it will be resolved to an * absolute path relative to the current working directory. * - * Default: `{cwd}/ensrainbow-data` or `/data` in Docker + * Default: `{cwd}/data` * * Invariants: * - Must be a non-empty string @@ -42,7 +42,7 @@ export interface ENSRainbowConfig { /** * The database schema version expected by the code. * - * This version number corresponds to the structure of the SQLite database that ENSRainbow + * This version number corresponds to the structure of the LevelDB database that ENSRainbow * uses to store label set data. If the version in the environment doesn't match the version * expected by the code, the application will fail to start. * diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 280084955..d5f02d22e 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,9 +1,13 @@ +import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; + import { DB_SCHEMA_VERSION } from "@/lib/database"; +import type { ENSRainbowConfig } from "./types"; + /** * Invariant: dbSchemaVersion must match the version expected by the code. */ -export function invariant_dbSchemaVersionMatch(ctx: any): void { +export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { const { value: config } = ctx; if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { From 30873a0d9c6e4d9f4a3d67ec54aa932e362530b0 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 17:32:59 +0100 Subject: [PATCH 29/57] refactor(config): update parameter naming in buildENSRainbowPublicConfig for clarity --- apps/ensrainbow/src/config/config.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 4601b3bff..c92fa5214 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -155,7 +155,7 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb * @returns A complete ENSRainbowPublicConfig object */ export function buildENSRainbowPublicConfig( - config: ENSRainbowConfig, + _config: ENSRainbowConfig, // kept for semantic purposes, not used in the function labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { From 7d7a1f617a779d4e30f13097a02ba8fe66bf2426 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 17:46:00 +0100 Subject: [PATCH 30/57] docs(cli): add detailed comments for 'serve' command arguments and clarify port option behavior --- apps/ensrainbow/src/cli.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 3bf0c160f..c1535077d 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -27,6 +27,12 @@ interface IngestProtobufArgs { "data-dir": string; } +/** + * Arguments for the 'serve' command. + * + * Note: CLI arguments take precedence over environment variables. + * If both --port and PORT are set, --port will be used and a warning will be logged. + */ interface ServeArgs { port: number; "data-dir": string; @@ -120,7 +126,7 @@ export function createCLI(options: CLIOptions = {}) { return yargs .option("port", { type: "number", - description: "Port to listen on", + description: "Port to listen on (overrides PORT env var if both are set)", default: config.port, }) .option("data-dir", { From 2d7207d966c7d624aace55693bec3bc9b14f7028 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 18:09:37 +0100 Subject: [PATCH 31/57] refactor(api): implement caching for public config to optimize /v1/config endpoint and update related tests --- apps/ensrainbow/src/cli.ts | 1 - .../src/commands/server-command.test.ts | 33 +++++++------- .../ensrainbow/src/commands/server-command.ts | 1 - apps/ensrainbow/src/lib/api.ts | 44 ++++++++++--------- 4 files changed, 41 insertions(+), 38 deletions(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index c1535077d..34e551354 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -31,7 +31,6 @@ interface IngestProtobufArgs { * Arguments for the 'serve' command. * * Note: CLI arguments take precedence over environment variables. - * If both --port and PORT are set, --port will be used and a warning will be logged. */ interface ServeArgs { port: number; diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index 173f9cbb7..386da34b0 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -168,24 +168,24 @@ describe("Server Command Tests", () => { }); describe("GET /v1/config", () => { - it("should return an error when database is empty", async () => { + it("should return cached config from startup", async () => { + // The config is cached on startup with count = 0 (set in beforeAll) + // Even if the database is cleared in beforeEach, the cached config is returned const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); - expect(response.status).toBe(500); - const data = (await response.json()) as { - status: typeof StatusCode.Error; - error: string; - errorCode: typeof ErrorCode.ServerError; - }; - const expectedData = { - status: StatusCode.Error, - error: "Label count not initialized. Check the validate command.", - errorCode: ErrorCode.ServerError, - }; - expect(data).toEqual(expectedData); + expect(response.status).toBe(200); + const data = (await response.json()) as EnsRainbow.ENSRainbowPublicConfig; + + expect(typeof data.version).toBe("string"); + expect(data.version.length).toBeGreaterThan(0); + expect(data.labelSet.labelSetId).toBe("test-label-set-id"); + expect(data.labelSet.highestLabelSetVersion).toBe(0); + // Config is cached on startup with count = 0, so it returns the cached value + expect(data.recordsCount).toBe(0); }); - it("should return correct config when label count is initialized", async () => { - // Set a specific precalculated rainbow record count in the database + it("should return cached config even if database count changes", async () => { + // Set a different count in the database + // However, the config is cached on startup, so it will still return the cached value await db.setPrecalculatedRainbowRecordCount(42); const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); @@ -196,7 +196,8 @@ describe("Server Command Tests", () => { expect(data.version.length).toBeGreaterThan(0); expect(data.labelSet.labelSetId).toBe("test-label-set-id"); expect(data.labelSet.highestLabelSetVersion).toBe(0); - expect(data.recordsCount).toBe(42); + // Config is cached on startup with count = 0, so changing the DB doesn't affect it + expect(data.recordsCount).toBe(0); }); }); diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index cd163cd35..b380fe1d7 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -35,7 +35,6 @@ export async function serverCommand(options: ServerCommandOptions): Promise { const api = new Hono(); const server = await ENSRainbowServer.init(db); + // Build and cache the public config once on startup + // This avoids calling labelCount() on every /v1/config request + const countResult = await server.labelCount(); + if (countResult.status === StatusCode.Error) { + logger.error("Failed to get records count during API initialization"); + throw new Error( + `Cannot initialize API: ${countResult.error} (errorCode: ${countResult.errorCode})`, + ); + } + + const cachedPublicConfig = buildENSRainbowPublicConfig( + config, + server.getServerLabelSet(), + countResult.count, + ); + + console.log("ENSRainbow public config:"); + console.log(prettyPrintJson(cachedPublicConfig)); + // Enable CORS for all versioned API routes api.use( "/v1/*", @@ -94,26 +114,10 @@ export async function createApi(db: ENSRainbowDB): Promise { return c.json(result, result.errorCode); }); - api.get("/v1/config", async (c: HonoContext) => { - const countResult = await server.labelCount(); - if (countResult.status === StatusCode.Error) { - logger.error("Failed to get records count for config endpoint"); - return c.json( - { - status: StatusCode.Error, - error: countResult.error, - errorCode: countResult.errorCode, - }, - 500, - ); - } - - const publicConfig = buildENSRainbowPublicConfig( - config, - server.getServerLabelSet(), - countResult.count, - ); - return c.json(publicConfig); + api.get("/v1/config", (c: HonoContext) => { + // Return the cached public config built on startup + // This avoids database queries on every request + return c.json(cachedPublicConfig); }); /** From 100cda3c4ce8ed967a0c19e735a0adc3e2080309 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 2 Feb 2026 18:39:21 +0100 Subject: [PATCH 32/57] test(config): add unit tests for buildENSRainbowPublicConfig to validate output structure --- .../src/config/config.schema.test.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 9b791ebd9..3ab3b7cb9 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -1,12 +1,17 @@ +import packageJson from "@/../package.json" with { type: "json" }; + import { isAbsolute, resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; + import { DB_SCHEMA_VERSION } from "@/lib/database"; -import { buildConfigFromEnvironment } from "./config.schema"; +import { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; import type { ENSRainbowEnvironment } from "./environment"; +import type { ENSRainbowConfig } from "./types"; vi.mock("@/utils/logger", () => ({ logger: { @@ -362,3 +367,29 @@ describe("buildConfigFromEnvironment", () => { }); }); }); + +describe("buildENSRainbowPublicConfig", () => { + describe("Success cases", () => { + it("returns a valid ENSRainbow public config with correct structure", () => { + const mockConfig: ENSRainbowConfig = { + port: ENSRAINBOW_DEFAULT_PORT, + dataDir: getDefaultDataDir(), + dbSchemaVersion: DB_SCHEMA_VERSION, + labelSet: undefined, + }; + const labelSet: EnsRainbowServerLabelSet = { + labelSetId: "subgraph", + highestLabelSetVersion: 0, + }; + const recordsCount = 1000; + + const result = buildENSRainbowPublicConfig(mockConfig, labelSet, recordsCount); + + expect(result).toStrictEqual({ + version: packageJson.version, + labelSet, + recordsCount, + }); + }); + }); +}); From 9a00f37ad0f18e7fa7b7ab5ce6823b1ba1a83f27 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 3 Feb 2026 16:30:50 +0100 Subject: [PATCH 33/57] feat(cli): add port validation in CLI arguments to ensure valid port numbers --- apps/ensrainbow/src/cli.test.ts | 58 +++++++++++++++++++++ apps/ensrainbow/src/cli.ts | 13 +++++ apps/ensrainbow/src/config/config.schema.ts | 2 +- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index 789b416a7..c1408eb22 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -60,6 +60,64 @@ describe("CLI", () => { // Restore real implementation for subsequent tests vi.doUnmock("@/commands/server-command"); }); + + it("should reject port less than 1", async () => { + // Validation happens during argument parsing, before command handler is called + try { + await cli.parse(["serve", "--port", "0", "--data-dir", testDataDir]); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect(error).toBeDefined(); + expect(String(error)).toContain("Invalid port"); + } + }); + + it("should reject negative port", async () => { + // Validation happens during argument parsing, before command handler is called + try { + await cli.parse(["serve", "--port", "-1", "--data-dir", testDataDir]); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect(error).toBeDefined(); + expect(String(error)).toContain("Invalid port"); + } + }); + + it("should reject port greater than 65535", async () => { + // Validation happens during argument parsing, before command handler is called + try { + await cli.parse(["serve", "--port", "65536", "--data-dir", testDataDir]); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect(error).toBeDefined(); + expect(String(error)).toContain("Invalid port"); + } + }); + + it("should accept valid port numbers", async () => { + // Mock serverCommand so we only test argument resolution here + vi.resetModules(); + const serverCommandMock = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/commands/server-command", () => ({ + serverCommand: serverCommandMock, + })); + + const { createCLI: createCLIFresh } = await import("./cli"); + const cliWithPort = createCLIFresh({ exitProcess: false }); + + // Test valid ports + await cliWithPort.parse(["serve", "--port", "1", "--data-dir", testDataDir]); + expect(serverCommandMock).toHaveBeenCalledWith(expect.objectContaining({ port: 1 })); + + await cliWithPort.parse(["serve", "--port", "65535", "--data-dir", testDataDir]); + expect(serverCommandMock).toHaveBeenCalledWith(expect.objectContaining({ port: 65535 })); + + await cliWithPort.parse(["serve", "--port", "3223", "--data-dir", testDataDir]); + expect(serverCommandMock).toHaveBeenCalledWith(expect.objectContaining({ port: 3223 })); + + // Restore real implementation for subsequent tests + vi.doUnmock("@/commands/server-command"); + }); }); describe("purge command", () => { diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 34e551354..b2a3810a5 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -8,6 +8,7 @@ import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; import { buildLabelSetId, type LabelSetId } from "@ensnode/ensnode-sdk"; +import { PortSchema } from "@ensnode/ensnode-sdk/internal"; import { convertCommand } from "@/commands/convert-command-sql"; import { convertCsvCommand } from "@/commands/convert-csv-command"; @@ -127,6 +128,18 @@ export function createCLI(options: CLIOptions = {}) { type: "number", description: "Port to listen on (overrides PORT env var if both are set)", default: config.port, + coerce: (port: number) => { + // Validate port using PortSchema (make it required by parsing with a non-optional schema) + const result = PortSchema.safeParse(port); + if (!result.success) { + const firstError = result.error.issues[0]; + throw new Error(`Invalid port: ${firstError?.message ?? "invalid port number"}`); + } + if (result.data === undefined) { + throw new Error("Invalid port: port is required"); + } + return result.data; + }, }) .option("data-dir", { type: "string", diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index c92fa5214..73ae7f3b3 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -40,7 +40,7 @@ const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); const ENSRainbowConfigBaseSchema = z.object({ port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), - dataDir: DataDirSchema.default(getDefaultDataDir()), + dataDir: DataDirSchema.default(() => getDefaultDataDir()), dbSchemaVersion: DbSchemaVersionSchema, labelSet: LabelSetSchema.optional(), }); From e834196418ea709294b1451fe9423af63a983d68 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 3 Feb 2026 21:39:06 +0100 Subject: [PATCH 34/57] refactor(config): rename ENSRainbowConfig to ENSRainbowEnvConfig for clarity and update related types and functions --- .../src/config/config.schema.test.ts | 4 +- apps/ensrainbow/src/config/config.schema.ts | 63 +++--------- apps/ensrainbow/src/config/environment.ts | 23 +---- apps/ensrainbow/src/config/index.ts | 3 +- apps/ensrainbow/src/config/types.ts | 96 ++++--------------- apps/ensrainbow/src/config/validations.ts | 16 ++-- .../ensnode-sdk/src/shared/config/types.ts | 8 ++ .../src/shared/config/zod-schemas.ts | 9 +- 8 files changed, 60 insertions(+), 162 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 3ab3b7cb9..832afce12 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -11,7 +11,7 @@ import { DB_SCHEMA_VERSION } from "@/lib/database"; import { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; import type { ENSRainbowEnvironment } from "./environment"; -import type { ENSRainbowConfig } from "./types"; +import type { ENSRainbowEnvConfig } from "./types"; vi.mock("@/utils/logger", () => ({ logger: { @@ -371,7 +371,7 @@ describe("buildConfigFromEnvironment", () => { describe("buildENSRainbowPublicConfig", () => { describe("Success cases", () => { it("returns a valid ENSRainbow public config with correct structure", () => { - const mockConfig: ENSRainbowConfig = { + const mockConfig: ENSRainbowEnvConfig = { port: ENSRAINBOW_DEFAULT_PORT, dataDir: getDefaultDataDir(), dbSchemaVersion: DB_SCHEMA_VERSION, diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 73ae7f3b3..4c8bb67f8 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -10,31 +10,33 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -import type { ENSRainbowConfig } from "@/config/types"; +import type { ENSRainbowEnvConfig } from "@/config/types"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -export type { ENSRainbowConfig }; +export type { ENSRainbowEnvConfig }; -const DataDirSchema = z +export const AbsolutePathSchemaBase = z .string() .trim() .min(1, { - error: "DATA_DIR must be a non-empty string.", + error: "Path must be a non-empty string.", }) .transform((path: string) => { - // Resolve relative paths to absolute paths (cross-platform) if (isAbsolute(path)) { return path; } return resolve(process.cwd(), path); }); -const DbSchemaVersionSchema = z.coerce +const DataDirSchema = AbsolutePathSchemaBase; + +export const DbSchemaVersionSchemaBase = z.coerce .number({ error: "DB_SCHEMA_VERSION must be a number." }) .int({ error: "DB_SCHEMA_VERSION must be an integer." }) - .positive({ error: "DB_SCHEMA_VERSION must be greater than 0." }) - .default(DB_SCHEMA_VERSION); + .positive({ error: "DB_SCHEMA_VERSION must be greater than 0." }); + +const DbSchemaVersionSchema = DbSchemaVersionSchemaBase.default(DB_SCHEMA_VERSION); const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); @@ -45,37 +47,14 @@ const ENSRainbowConfigBaseSchema = z.object({ labelSet: LabelSetSchema.optional(), }); -/** - * Helper function to check if a string value is present (not undefined and not empty after trimming). - * - * @param str - The string value to check - * @returns true if the string is defined and has non-whitespace content after trimming - */ const hasValue = (str: string | undefined): boolean => { return str !== undefined && str.trim() !== ""; }; -const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema - /** - * 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); - -/** - * 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 { +const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema.check(invariant_dbSchemaVersionMatch); + +export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowEnvConfig { try { - // Transform environment variables into config shape const envToConfigSchema = z .object({ PORT: z.string().optional(), @@ -84,12 +63,6 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb LABEL_SET_ID: z.string().optional(), LABEL_SET_VERSION: z.string().optional(), }) - /** - * Invariant enforcement on environment variables - * - * We check that LABEL_SET_ID and LABEL_SET_VERSION are provided together, or neither. - * This check happens before transformation to ensure we don't create invalid config objects. - */ .check((ctx) => { const { value: env } = ctx; const hasLabelSetId = hasValue(env.LABEL_SET_ID); @@ -146,16 +119,8 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb } } -/** - * Builds the ENSRainbow public configuration from an ENSRainbowConfig object and server state. - * - * @param config - The validated ENSRainbowConfig object - * @param labelSet - The label set managed by the ENSRainbow server - * @param recordsCount - The total count of records managed by the ENSRainbow service - * @returns A complete ENSRainbowPublicConfig object - */ export function buildENSRainbowPublicConfig( - _config: ENSRainbowConfig, // kept for semantic purposes, not used in the function + _config: ENSRainbowEnvConfig, labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { diff --git a/apps/ensrainbow/src/config/environment.ts b/apps/ensrainbow/src/config/environment.ts index c07579aa9..66dff8dc3 100644 --- a/apps/ensrainbow/src/config/environment.ts +++ b/apps/ensrainbow/src/config/environment.ts @@ -1,33 +1,12 @@ 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`. + * Raw, unvalidated environment variables for ENSRainbow. */ 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. - * Must be provided together with LABEL_SET_VERSION, or neither should be set. - */ LABEL_SET_ID?: string; - - /** - * Expected Label Set Version. - * Must be provided together with LABEL_SET_ID, or neither should be set. - */ LABEL_SET_VERSION?: string; }; diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 533794107..115a51b7b 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,9 +1,8 @@ import { buildConfigFromEnvironment } from "./config.schema"; -export type { ENSRainbowConfig } from "./config.schema"; +export type { ENSRainbowEnvConfig } from "./config.schema"; export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; -// build, validate, and export the ENSRainbowConfig from process.env export default buildConfigFromEnvironment(process.env); diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index 45f279d89..8aa5fd268 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -1,84 +1,28 @@ +import type { z } from "zod/v4"; + import type { EnsRainbowClientLabelSet } from "@ensnode/ensnode-sdk"; +import type { PortNumber } from "@ensnode/ensnode-sdk/internal"; + +import type { AbsolutePathSchemaBase, DbSchemaVersionSchemaBase } from "./config.schema"; /** - * The complete runtime configuration for an ENSRainbow instance. - * - * This interface defines all configuration parameters needed to run an ENSRainbow server, - * which provides label healing services for ENS names. + * Absolute filesystem path. + * Inferred from {@link AbsolutePathSchemaBase} - see that schema for invariants. */ -export interface ENSRainbowConfig { - /** - * The port number on which the ENSRainbow server listens. - * - * The HTTP server will bind to this port and serve the ENSRainbow API. - * - * Default: 3223 (defined by {@link ENSRAINBOW_DEFAULT_PORT}) - * - * Invariants: - * - Must be a valid port number (1-65535) - * - Must not be already in use by another process - */ - port: number; +export type AbsolutePath = z.infer; - /** - * The absolute path to the data directory where ENSRainbow stores its database and other files. - * - * This directory will contain: - * - The LevelDB database file with label set data - * - Any temporary files created during operation - * - * If a relative path is provided in the environment variable, it will be resolved to an - * absolute path relative to the current working directory. - * - * Default: `{cwd}/data` - * - * Invariants: - * - Must be a non-empty string - * - Must be an absolute path after resolution - * - The process must have read/write permissions to this directory - */ - dataDir: string; - - /** - * The database schema version expected by the code. - * - * This version number corresponds to the structure of the LevelDB database that ENSRainbow - * uses to store label set data. If the version in the environment doesn't match the version - * expected by the code, the application will fail to start. - * - * This prevents version mismatches between the codebase and the database schema, which could - * lead to data corruption or runtime errors. - * - * Default: {@link DB_SCHEMA_VERSION} (currently 3) - * - * Invariants: - * - Must be a positive integer - * - Must match {@link DB_SCHEMA_VERSION} exactly - */ - dbSchemaVersion: number; +/** + * Database schema version number. + * Inferred from {@link DbSchemaVersionSchemaBase} - see that schema for invariants. + */ +export type DbSchemaVersion = z.infer; - /** - * Optional label set configuration that specifies which label set to use. - * - * A label set defines which labels (domain name segments) are available for label healing. - * Both `labelSetId` and `labelSetVersion` must be provided together to create a "fully pinned" - * label set reference, ensuring deterministic and reproducible label healing. - * - * If not provided, ENSRainbow will start without any label set loaded, and label healing - * requests will fail until a label set is loaded via the management API. - * - * Examples: - * - `{ labelSetId: "subgraph", labelSetVersion: 0 }` - The legacy subgraph label set - * - `{ labelSetId: "ensip-15", labelSetVersion: 1 }` - ENSIP-15 normalized labels - * - * Default: undefined (no label set) - * - * Invariants: - * - If provided, both `labelSetId` and `labelSetVersion` must be defined - * - `labelSetId` must be 1-50 characters, containing only lowercase letters (a-z) and hyphens (-) - * - `labelSetVersion` must be a non-negative integer - * - If only one of LABEL_SET_ID or LABEL_SET_VERSION is provided in the environment, - * configuration parsing will fail with a clear error message - */ +/** + * Configuration derived from environment variables for ENSRainbow. + */ +export interface ENSRainbowEnvConfig { + port: PortNumber; + dataDir: AbsolutePath; + dbSchemaVersion: DbSchemaVersion; labelSet?: Required; } diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index d5f02d22e..74be01899 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,13 +1,15 @@ -import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; +import type { z } from "zod/v4"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -import type { ENSRainbowConfig } from "./types"; - -/** - * Invariant: dbSchemaVersion must match the version expected by the code. - */ -export function invariant_dbSchemaVersionMatch(ctx: ZodCheckFnInput): void { +export function invariant_dbSchemaVersionMatch( + ctx: z.core.ParsePayload<{ + port: number; + dataDir: string; + dbSchemaVersion: number; + labelSet?: { labelSetId: string; labelSetVersion: number }; + }>, +): void { const { value: config } = ctx; if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { diff --git a/packages/ensnode-sdk/src/shared/config/types.ts b/packages/ensnode-sdk/src/shared/config/types.ts index 3fe843c11..b1081a702 100644 --- a/packages/ensnode-sdk/src/shared/config/types.ts +++ b/packages/ensnode-sdk/src/shared/config/types.ts @@ -5,6 +5,7 @@ import type { ChainId } from "../types"; import type { DatabaseSchemaNameSchema, EnsIndexerUrlSchema, + PortSchemaBase, TheGraphApiKeySchema, } from "./zod-schemas"; @@ -48,3 +49,10 @@ export type DatabaseUrl = UrlString; export type DatabaseSchemaName = z.infer; export type EnsIndexerUrl = z.infer; export type TheGraphApiKey = z.infer; + +/** + * Port number for network services. + * + * Inferred from {@link PortSchemaBase} - see that schema for invariants. + */ +export type PortNumber = z.infer; diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index 1c4fd5f7b..27cc9485d 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -60,13 +60,14 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { /** * Parses a numeric value as a port number. - * Ensures the value is an integer (not a float) within the valid port range. + * Ensures the value is an integer (not a float) within the valid port range (1-65535). */ -export const PortSchema = z.coerce +export const PortSchemaBase = z.coerce .number({ error: "PORT must be a number." }) .int({ error: "PORT must be an integer." }) .min(1, { error: "PORT must be greater than or equal to 1" }) - .max(65535, { error: "PORT must be less than or equal to 65535" }) - .optional(); + .max(65535, { error: "PORT must be less than or equal to 65535" }); + +export const PortSchema = PortSchemaBase.optional(); export const TheGraphApiKeySchema = z.string().optional(); From 75e72f89643d38c8d530a04215f3a32efc879fab Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 3 Feb 2026 22:46:51 +0100 Subject: [PATCH 35/57] refactor(config): remove labelSet from configuration schema and related tests for simplification --- .../src/config/config.schema.test.ts | 80 ----- apps/ensrainbow/src/config/config.schema.ts | 48 +-- apps/ensrainbow/src/config/env-config.test.ts | 285 ++++++++++++++++++ apps/ensrainbow/src/config/types.ts | 2 - 4 files changed, 286 insertions(+), 129 deletions(-) create mode 100644 apps/ensrainbow/src/config/env-config.test.ts diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 832afce12..bcd148b93 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -30,7 +30,6 @@ describe("buildConfigFromEnvironment", () => { port: ENSRAINBOW_DEFAULT_PORT, dataDir: getDefaultDataDir(), dbSchemaVersion: DB_SCHEMA_VERSION, - labelSet: undefined, }); }); @@ -109,27 +108,11 @@ describe("buildConfigFromEnvironment", () => { expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); - it("applies full label set configuration when both ID and version are set", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "subgraph", - LABEL_SET_VERSION: "0", - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.labelSet).toStrictEqual({ - labelSetId: "subgraph", - labelSetVersion: 0, - }); - }); - it("handles all valid configuration options together", () => { const env: ENSRainbowEnvironment = { PORT: "4444", DATA_DIR: "/opt/ensrainbow/data", DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), - LABEL_SET_ID: "ens-normalize-latest", - LABEL_SET_VERSION: "2", }; const config = buildConfigFromEnvironment(env); @@ -138,10 +121,6 @@ describe("buildConfigFromEnvironment", () => { port: 4444, dataDir: "/opt/ensrainbow/data", dbSchemaVersion: DB_SCHEMA_VERSION, - labelSet: { - labelSetId: "ens-normalize-latest", - labelSetVersion: 2, - }, }); }); }); @@ -218,53 +197,6 @@ describe("buildConfigFromEnvironment", () => { expect(() => buildConfigFromEnvironment(env)).toThrow(); }); - - it("fails when LABEL_SET_VERSION is not a number", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "subgraph", - LABEL_SET_VERSION: "not-a-number", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when LABEL_SET_VERSION is negative", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "subgraph", - LABEL_SET_VERSION: "-1", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when LABEL_SET_ID is empty", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "", - LABEL_SET_VERSION: "0", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when only LABEL_SET_ID is set (both ID and version required)", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "subgraph", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow( - "LABEL_SET_ID is set but LABEL_SET_VERSION is missing", - ); - }); - - it("fails when only LABEL_SET_VERSION is set (both ID and version required)", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_VERSION: "0", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow( - "LABEL_SET_VERSION is set but LABEL_SET_ID is missing", - ); - }); }); describe("Invariant: DB_SCHEMA_VERSION must match code version", () => { @@ -317,17 +249,6 @@ describe("buildConfigFromEnvironment", () => { expect(config.port).toBe(65535); }); - it("handles LABEL_SET_VERSION of 0", () => { - const env: ENSRainbowEnvironment = { - LABEL_SET_ID: "subgraph", - LABEL_SET_VERSION: "0", - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.labelSet?.labelSetVersion).toBe(0); - }); - it("trims whitespace from DATA_DIR", () => { const dataDir = "/my/path/to/data"; const env: ENSRainbowEnvironment = { @@ -375,7 +296,6 @@ describe("buildENSRainbowPublicConfig", () => { port: ENSRAINBOW_DEFAULT_PORT, dataDir: getDefaultDataDir(), dbSchemaVersion: DB_SCHEMA_VERSION, - labelSet: undefined, }; const labelSet: EnsRainbowServerLabelSet = { labelSetId: "subgraph", diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 4c8bb67f8..359af6000 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -5,7 +5,7 @@ import { isAbsolute, resolve } from "node:path"; import { prettifyError, ZodError, z } from "zod/v4"; import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; -import { makeFullyPinnedLabelSetSchema, PortSchema } from "@ensnode/ensnode-sdk/internal"; +import { PortSchema } from "@ensnode/ensnode-sdk/internal"; import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; @@ -38,19 +38,12 @@ export const DbSchemaVersionSchemaBase = z.coerce const DbSchemaVersionSchema = DbSchemaVersionSchemaBase.default(DB_SCHEMA_VERSION); -const LabelSetSchema = makeFullyPinnedLabelSetSchema("LABEL_SET"); - const ENSRainbowConfigBaseSchema = z.object({ port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), dataDir: DataDirSchema.default(() => getDefaultDataDir()), dbSchemaVersion: DbSchemaVersionSchema, - labelSet: LabelSetSchema.optional(), }); -const hasValue = (str: string | undefined): boolean => { - return str !== undefined && str.trim() !== ""; -}; - const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema.check(invariant_dbSchemaVersionMatch); export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowEnvConfig { @@ -60,51 +53,12 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb PORT: z.string().optional(), DATA_DIR: z.string().optional(), DB_SCHEMA_VERSION: z.string().optional(), - LABEL_SET_ID: z.string().optional(), - LABEL_SET_VERSION: z.string().optional(), - }) - .check((ctx) => { - const { value: env } = ctx; - const hasLabelSetId = hasValue(env.LABEL_SET_ID); - const hasLabelSetVersion = hasValue(env.LABEL_SET_VERSION); - - if (hasLabelSetId && !hasLabelSetVersion) { - ctx.issues.push({ - code: "custom", - path: ["LABEL_SET_VERSION"], - input: env, - message: - "LABEL_SET_ID is set but LABEL_SET_VERSION is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.", - }); - } - - if (!hasLabelSetId && hasLabelSetVersion) { - ctx.issues.push({ - code: "custom", - path: ["LABEL_SET_ID"], - input: env, - message: - "LABEL_SET_VERSION is set but LABEL_SET_ID is missing. Both LABEL_SET_ID and LABEL_SET_VERSION must be provided together, or neither.", - }); - } }) .transform((env) => { - const hasLabelSetId = hasValue(env.LABEL_SET_ID); - const hasLabelSetVersion = hasValue(env.LABEL_SET_VERSION); - - const labelSet = - hasLabelSetId && hasLabelSetVersion - ? { - labelSetId: env.LABEL_SET_ID, - labelSetVersion: env.LABEL_SET_VERSION, - } - : undefined; - return { port: env.PORT, dataDir: env.DATA_DIR, dbSchemaVersion: env.DB_SCHEMA_VERSION, - labelSet, }; }); diff --git a/apps/ensrainbow/src/config/env-config.test.ts b/apps/ensrainbow/src/config/env-config.test.ts new file mode 100644 index 000000000..fb8f619ac --- /dev/null +++ b/apps/ensrainbow/src/config/env-config.test.ts @@ -0,0 +1,285 @@ +import { isAbsolute, resolve } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { DB_SCHEMA_VERSION } from "@/lib/database"; + +import { buildConfigFromEnvironment } from "./config.schema"; +import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; +import type { ENSRainbowEnvironment } from "./environment"; + +vi.mock("@/utils/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe("buildConfigFromEnvironment", () => { + describe("Success cases", () => { + it("returns a valid config with all defaults when environment is empty", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config).toStrictEqual({ + port: ENSRAINBOW_DEFAULT_PORT, + dataDir: getDefaultDataDir(), + dbSchemaVersion: DB_SCHEMA_VERSION, + }); + }); + + it("applies custom port when PORT is set", () => { + const env: ENSRainbowEnvironment = { + PORT: "5000", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.port).toBe(5000); + expect(config.dataDir).toBe(getDefaultDataDir()); + }); + + it("applies custom DATA_DIR when set", () => { + const customDataDir = "/var/lib/ensrainbow/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: customDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dataDir).toBe(customDataDir); + }); + + it("normalizes relative DATA_DIR to absolute path", () => { + const relativeDataDir = "my-data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); + }); + + it("resolves nested relative DATA_DIR correctly", () => { + const relativeDataDir = "./data/ensrainbow/db"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); + }); + + it("preserves absolute DATA_DIR", () => { + const absoluteDataDir = "/absolute/path/to/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: absoluteDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dataDir).toBe(absoluteDataDir); + }); + + it("applies DB_SCHEMA_VERSION when set and matches code version", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + + it("defaults DB_SCHEMA_VERSION to code version when not set", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + + it("handles all valid configuration options together", () => { + const env: ENSRainbowEnvironment = { + PORT: "4444", + DATA_DIR: "/opt/ensrainbow/data", + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + }; + + const config = buildConfigFromEnvironment(env); + + expect(config).toStrictEqual({ + port: 4444, + dataDir: "/opt/ensrainbow/data", + dbSchemaVersion: DB_SCHEMA_VERSION, + }); + }); + }); + + describe("Validation errors", () => { + it("fails when PORT is not a number", () => { + const env: ENSRainbowEnvironment = { + PORT: "not-a-number", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is a float", () => { + const env: ENSRainbowEnvironment = { + PORT: "3000.5", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is less than 1", () => { + const env: ENSRainbowEnvironment = { + PORT: "0", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is negative", () => { + const env: ENSRainbowEnvironment = { + PORT: "-100", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is greater than 65535", () => { + const env: ENSRainbowEnvironment = { + PORT: "65536", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DATA_DIR is empty string", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: "", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DATA_DIR is only whitespace", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: " ", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DB_SCHEMA_VERSION is not a number", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "not-a-number", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DB_SCHEMA_VERSION is a float", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "3.5", + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(); + }); + }); + + describe("Invariant: DB_SCHEMA_VERSION must match code version", () => { + it("fails when DB_SCHEMA_VERSION does not match code version", () => { + const wrongVersion = DB_SCHEMA_VERSION + 1; + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: wrongVersion.toString(), + }; + + expect(() => buildConfigFromEnvironment(env)).toThrow(/DB_SCHEMA_VERSION mismatch/); + }); + + it("passes when DB_SCHEMA_VERSION matches code version", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + + it("passes when DB_SCHEMA_VERSION defaults to code version", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + }); + + describe("Edge cases", () => { + it("handles PORT at minimum valid value (1)", () => { + const env: ENSRainbowEnvironment = { + PORT: "1", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.port).toBe(1); + }); + + it("handles PORT at maximum valid value (65535)", () => { + const env: ENSRainbowEnvironment = { + PORT: "65535", + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.port).toBe(65535); + }); + + it("trims whitespace from DATA_DIR", () => { + const dataDir = "/my/path/to/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: ` ${dataDir} `, + }; + + const config = buildConfigFromEnvironment(env); + + expect(config.dataDir).toBe(dataDir); + }); + + it("handles DATA_DIR with .. (parent directory)", () => { + const relativeDataDir = "../data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); + }); + + it("handles DATA_DIR with ~ (not expanded, treated as relative)", () => { + // Note: The config schema does NOT expand ~ to home directory + // It would be treated as a relative path + const tildeDataDir = "~/data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: tildeDataDir, + }; + + const config = buildConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + // ~ is treated as a directory name, not home expansion + expect(config.dataDir).toBe(resolve(process.cwd(), tildeDataDir)); + }); + }); +}); diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index 8aa5fd268..12efb2e6b 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -1,6 +1,5 @@ import type { z } from "zod/v4"; -import type { EnsRainbowClientLabelSet } from "@ensnode/ensnode-sdk"; import type { PortNumber } from "@ensnode/ensnode-sdk/internal"; import type { AbsolutePathSchemaBase, DbSchemaVersionSchemaBase } from "./config.schema"; @@ -24,5 +23,4 @@ export interface ENSRainbowEnvConfig { port: PortNumber; dataDir: AbsolutePath; dbSchemaVersion: DbSchemaVersion; - labelSet?: Required; } From 70389d4865d2ab624af8242394a9d7d5a8634b82 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 9 Feb 2026 21:18:03 +0100 Subject: [PATCH 36/57] refactor(api): remove redundant comments from /v1/config endpoint and clean up related code --- apps/ensrainbow/src/lib/api.ts | 2 -- packages/ensnode-sdk/src/shared/config/types.ts | 5 ----- packages/ensnode-sdk/src/shared/config/zod-schemas.ts | 4 ---- packages/ensrainbow-sdk/src/client.ts | 10 +--------- 4 files changed, 1 insertion(+), 20 deletions(-) diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index fcb737d37..f509212af 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -115,8 +115,6 @@ export async function createApi(db: ENSRainbowDB): Promise { }); api.get("/v1/config", (c: HonoContext) => { - // Return the cached public config built on startup - // This avoids database queries on every request return c.json(cachedPublicConfig); }); diff --git a/packages/ensnode-sdk/src/shared/config/types.ts b/packages/ensnode-sdk/src/shared/config/types.ts index b1081a702..e2ba18ae8 100644 --- a/packages/ensnode-sdk/src/shared/config/types.ts +++ b/packages/ensnode-sdk/src/shared/config/types.ts @@ -50,9 +50,4 @@ export type DatabaseSchemaName = z.infer; export type EnsIndexerUrl = z.infer; export type TheGraphApiKey = z.infer; -/** - * Port number for network services. - * - * Inferred from {@link PortSchemaBase} - see that schema for invariants. - */ export type PortNumber = z.infer; diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index 27cc9485d..b0e6bf431 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -58,10 +58,6 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { `Invalid NAMESPACE. Got '${input}', but supported ENS namespaces are: ${Object.keys(ENSNamespaceIds).join(", ")}`, }); -/** - * Parses a numeric value as a port number. - * Ensures the value is an integer (not a float) within the valid port range (1-65535). - */ export const PortSchemaBase = z.coerce .number({ error: "PORT must be a number." }) .int({ error: "PORT must be an integer." }) diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 308ed8436..c216d27a2 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -18,8 +18,6 @@ export namespace EnsRainbow { /** * Get the public configuration of the ENSRainbow service - * - * @returns the public configuration of the ENSRainbow service */ config(): Promise; @@ -35,7 +33,6 @@ export namespace EnsRainbow { * Get the version information of the ENSRainbow service * * @deprecated Use {@link ApiClient.config} instead. This method will be removed in a future version. - * @returns the version information of the ENSRainbow service */ version(): Promise; @@ -177,14 +174,11 @@ export namespace EnsRainbow { /** * The label set reference managed by the ENSRainbow server. - * This includes both the label set ID and the highest label set version available. */ labelSet: EnsRainbowServerLabelSet; /** * The total count of records managed by the ENSRainbow service. - * This represents the number of rainbow records that can be healed. - * Always a non-negative integer. */ recordsCount: number; } @@ -397,9 +391,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { } /** - * Get the public configuration of the ENSRainbow service - * - * @returns the public configuration of the ENSRainbow service + * Get the public configuration of the ENSRainbow service. */ async config(): Promise { const response = await fetch(new URL("/v1/config", this.options.endpointUrl)); From 163f873cd6232ede2e054979e26f331e0b0fa8b6 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 9 Feb 2026 21:37:02 +0100 Subject: [PATCH 37/57] refactor(config): move buildENSRainbowPublicConfig to a separate file and update imports accordingly --- .../src/config/config.schema.test.ts | 3 ++- apps/ensrainbow/src/config/config.schema.ts | 16 ---------------- apps/ensrainbow/src/config/index.ts | 3 ++- apps/ensrainbow/src/config/public.ts | 18 ++++++++++++++++++ apps/ensrainbow/src/lib/api.ts | 3 +-- 5 files changed, 23 insertions(+), 20 deletions(-) create mode 100644 apps/ensrainbow/src/config/public.ts diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index bcd148b93..d9c00be3c 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -8,9 +8,10 @@ import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -import { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; +import { buildConfigFromEnvironment } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; import type { ENSRainbowEnvironment } from "./environment"; +import { buildENSRainbowPublicConfig } from "./public"; import type { ENSRainbowEnvConfig } from "./types"; vi.mock("@/utils/logger", () => ({ diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 359af6000..21414f4e3 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -1,12 +1,8 @@ -import packageJson from "@/../package.json" with { type: "json" }; - import { isAbsolute, resolve } from "node:path"; import { prettifyError, ZodError, z } from "zod/v4"; -import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { PortSchema } from "@ensnode/ensnode-sdk/internal"; -import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; @@ -72,15 +68,3 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb throw error; } } - -export function buildENSRainbowPublicConfig( - _config: ENSRainbowEnvConfig, - labelSet: EnsRainbowServerLabelSet, - recordsCount: number, -): EnsRainbow.ENSRainbowPublicConfig { - return { - version: packageJson.version, - labelSet, - recordsCount, - }; -} diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 115a51b7b..bdfa2e1e8 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,8 +1,9 @@ import { buildConfigFromEnvironment } from "./config.schema"; export type { ENSRainbowEnvConfig } from "./config.schema"; -export { buildConfigFromEnvironment, buildENSRainbowPublicConfig } from "./config.schema"; +export { buildConfigFromEnvironment } from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; +export { buildENSRainbowPublicConfig } from "./public"; export default buildConfigFromEnvironment(process.env); diff --git a/apps/ensrainbow/src/config/public.ts b/apps/ensrainbow/src/config/public.ts new file mode 100644 index 000000000..54fccbf71 --- /dev/null +++ b/apps/ensrainbow/src/config/public.ts @@ -0,0 +1,18 @@ +import packageJson from "@/../package.json" with { type: "json" }; + +import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; +import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; + +import type { ENSRainbowEnvConfig } from "./types"; + +export function buildENSRainbowPublicConfig( + _config: ENSRainbowEnvConfig, // kept for semantic purposes + labelSet: EnsRainbowServerLabelSet, + recordsCount: number, +): EnsRainbow.ENSRainbowPublicConfig { + return { + version: packageJson.version, + labelSet, + recordsCount, + }; +} diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index f509212af..6426e207b 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,5 +1,5 @@ import packageJson from "@/../package.json"; -import config from "@/config"; +import config, { buildENSRainbowPublicConfig } from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -16,7 +16,6 @@ import { import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; -import { buildENSRainbowPublicConfig } from "@/config/config.schema"; import { DB_SCHEMA_VERSION, type ENSRainbowDB } from "@/lib/database"; import { ENSRainbowServer } from "@/lib/server"; import { getErrorMessage } from "@/utils/error-utils"; From 4cca4f7584a833428fc5ef1b21486998e27cb7b5 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 9 Feb 2026 21:58:39 +0100 Subject: [PATCH 38/57] refactor(cli): update port validation to use PortSchemaBase and improve logging in server command --- apps/ensrainbow/src/cli.ts | 8 +- .../ensrainbow/src/commands/server-command.ts | 2 +- apps/ensrainbow/src/config/env-config.test.ts | 285 ------------------ 3 files changed, 3 insertions(+), 292 deletions(-) delete mode 100644 apps/ensrainbow/src/config/env-config.test.ts diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index b2a3810a5..d48822649 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -8,7 +8,7 @@ import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; import { buildLabelSetId, type LabelSetId } from "@ensnode/ensnode-sdk"; -import { PortSchema } from "@ensnode/ensnode-sdk/internal"; +import { PortSchemaBase } from "@ensnode/ensnode-sdk/internal"; import { convertCommand } from "@/commands/convert-command-sql"; import { convertCsvCommand } from "@/commands/convert-csv-command"; @@ -129,15 +129,11 @@ export function createCLI(options: CLIOptions = {}) { description: "Port to listen on (overrides PORT env var if both are set)", default: config.port, coerce: (port: number) => { - // Validate port using PortSchema (make it required by parsing with a non-optional schema) - const result = PortSchema.safeParse(port); + const result = PortSchemaBase.safeParse(port); if (!result.success) { const firstError = result.error.issues[0]; throw new Error(`Invalid port: ${firstError?.message ?? "invalid port number"}`); } - if (result.data === undefined) { - throw new Error("Invalid port: port is required"); - } return result.data; }, }) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index b380fe1d7..729a32045 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -22,7 +22,7 @@ export async function createServer(db: ENSRainbowDB) { export async function serverCommand(options: ServerCommandOptions): Promise { // Log the config that ENSRainbow is running with - console.log("ENSRainbow running with config:"); + console.log("ENSRainbow running with environment config:"); console.log(prettyPrintJson(config)); logger.info(`ENS Rainbow server starting on port ${options.port}...`); diff --git a/apps/ensrainbow/src/config/env-config.test.ts b/apps/ensrainbow/src/config/env-config.test.ts deleted file mode 100644 index fb8f619ac..000000000 --- a/apps/ensrainbow/src/config/env-config.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { isAbsolute, resolve } from "node:path"; - -import { describe, expect, it, vi } from "vitest"; - -import { DB_SCHEMA_VERSION } from "@/lib/database"; - -import { buildConfigFromEnvironment } from "./config.schema"; -import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; -import type { ENSRainbowEnvironment } from "./environment"; - -vi.mock("@/utils/logger", () => ({ - logger: { - error: vi.fn(), - }, -})); - -describe("buildConfigFromEnvironment", () => { - describe("Success cases", () => { - it("returns a valid config with all defaults when environment is empty", () => { - const env: ENSRainbowEnvironment = {}; - - const config = buildConfigFromEnvironment(env); - - expect(config).toStrictEqual({ - port: ENSRAINBOW_DEFAULT_PORT, - dataDir: getDefaultDataDir(), - dbSchemaVersion: DB_SCHEMA_VERSION, - }); - }); - - it("applies custom port when PORT is set", () => { - const env: ENSRainbowEnvironment = { - PORT: "5000", - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.port).toBe(5000); - expect(config.dataDir).toBe(getDefaultDataDir()); - }); - - it("applies custom DATA_DIR when set", () => { - const customDataDir = "/var/lib/ensrainbow/data"; - const env: ENSRainbowEnvironment = { - DATA_DIR: customDataDir, - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.dataDir).toBe(customDataDir); - }); - - it("normalizes relative DATA_DIR to absolute path", () => { - const relativeDataDir = "my-data"; - const env: ENSRainbowEnvironment = { - DATA_DIR: relativeDataDir, - }; - - const config = buildConfigFromEnvironment(env); - - expect(isAbsolute(config.dataDir)).toBe(true); - expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); - }); - - it("resolves nested relative DATA_DIR correctly", () => { - const relativeDataDir = "./data/ensrainbow/db"; - const env: ENSRainbowEnvironment = { - DATA_DIR: relativeDataDir, - }; - - const config = buildConfigFromEnvironment(env); - - expect(isAbsolute(config.dataDir)).toBe(true); - expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); - }); - - it("preserves absolute DATA_DIR", () => { - const absoluteDataDir = "/absolute/path/to/data"; - const env: ENSRainbowEnvironment = { - DATA_DIR: absoluteDataDir, - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.dataDir).toBe(absoluteDataDir); - }); - - it("applies DB_SCHEMA_VERSION when set and matches code version", () => { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); - }); - - it("defaults DB_SCHEMA_VERSION to code version when not set", () => { - const env: ENSRainbowEnvironment = {}; - - const config = buildConfigFromEnvironment(env); - - expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); - }); - - it("handles all valid configuration options together", () => { - const env: ENSRainbowEnvironment = { - PORT: "4444", - DATA_DIR: "/opt/ensrainbow/data", - DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), - }; - - const config = buildConfigFromEnvironment(env); - - expect(config).toStrictEqual({ - port: 4444, - dataDir: "/opt/ensrainbow/data", - dbSchemaVersion: DB_SCHEMA_VERSION, - }); - }); - }); - - describe("Validation errors", () => { - it("fails when PORT is not a number", () => { - const env: ENSRainbowEnvironment = { - PORT: "not-a-number", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when PORT is a float", () => { - const env: ENSRainbowEnvironment = { - PORT: "3000.5", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when PORT is less than 1", () => { - const env: ENSRainbowEnvironment = { - PORT: "0", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when PORT is negative", () => { - const env: ENSRainbowEnvironment = { - PORT: "-100", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when PORT is greater than 65535", () => { - const env: ENSRainbowEnvironment = { - PORT: "65536", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when DATA_DIR is empty string", () => { - const env: ENSRainbowEnvironment = { - DATA_DIR: "", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when DATA_DIR is only whitespace", () => { - const env: ENSRainbowEnvironment = { - DATA_DIR: " ", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when DB_SCHEMA_VERSION is not a number", () => { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: "not-a-number", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - - it("fails when DB_SCHEMA_VERSION is a float", () => { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: "3.5", - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(); - }); - }); - - describe("Invariant: DB_SCHEMA_VERSION must match code version", () => { - it("fails when DB_SCHEMA_VERSION does not match code version", () => { - const wrongVersion = DB_SCHEMA_VERSION + 1; - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: wrongVersion.toString(), - }; - - expect(() => buildConfigFromEnvironment(env)).toThrow(/DB_SCHEMA_VERSION mismatch/); - }); - - it("passes when DB_SCHEMA_VERSION matches code version", () => { - const env: ENSRainbowEnvironment = { - DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); - }); - - it("passes when DB_SCHEMA_VERSION defaults to code version", () => { - const env: ENSRainbowEnvironment = {}; - - const config = buildConfigFromEnvironment(env); - - expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); - }); - }); - - describe("Edge cases", () => { - it("handles PORT at minimum valid value (1)", () => { - const env: ENSRainbowEnvironment = { - PORT: "1", - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.port).toBe(1); - }); - - it("handles PORT at maximum valid value (65535)", () => { - const env: ENSRainbowEnvironment = { - PORT: "65535", - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.port).toBe(65535); - }); - - it("trims whitespace from DATA_DIR", () => { - const dataDir = "/my/path/to/data"; - const env: ENSRainbowEnvironment = { - DATA_DIR: ` ${dataDir} `, - }; - - const config = buildConfigFromEnvironment(env); - - expect(config.dataDir).toBe(dataDir); - }); - - it("handles DATA_DIR with .. (parent directory)", () => { - const relativeDataDir = "../data"; - const env: ENSRainbowEnvironment = { - DATA_DIR: relativeDataDir, - }; - - const config = buildConfigFromEnvironment(env); - - expect(isAbsolute(config.dataDir)).toBe(true); - expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); - }); - - it("handles DATA_DIR with ~ (not expanded, treated as relative)", () => { - // Note: The config schema does NOT expand ~ to home directory - // It would be treated as a relative path - const tildeDataDir = "~/data"; - const env: ENSRainbowEnvironment = { - DATA_DIR: tildeDataDir, - }; - - const config = buildConfigFromEnvironment(env); - - expect(isAbsolute(config.dataDir)).toBe(true); - // ~ is treated as a directory name, not home expansion - expect(config.dataDir).toBe(resolve(process.cwd(), tildeDataDir)); - }); - }); -}); From 54bf4724fb01c7e7cfc568ddbd9416ac6f0301eb Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 9 Feb 2026 23:46:24 +0100 Subject: [PATCH 39/57] fix(server): enhance error handling for server startup by checking specific database error messages --- apps/ensrainbow/src/commands/server-command.ts | 16 ++++++++++------ apps/ensrainbow/src/config/config.schema.test.ts | 8 +------- apps/ensrainbow/src/config/validations.ts | 4 ++-- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 729a32045..0b7ed300d 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -34,12 +34,16 @@ export async function serverCommand(options: ServerCommandOptions): Promise ({ - logger: { - error: vi.fn(), - }, -})); - describe("buildConfigFromEnvironment", () => { describe("Success cases", () => { it("returns a valid config with all defaults when environment is empty", () => { diff --git a/apps/ensrainbow/src/config/validations.ts b/apps/ensrainbow/src/config/validations.ts index 74be01899..601181e12 100644 --- a/apps/ensrainbow/src/config/validations.ts +++ b/apps/ensrainbow/src/config/validations.ts @@ -1,9 +1,9 @@ -import type { z } from "zod/v4"; +import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; import { DB_SCHEMA_VERSION } from "@/lib/database"; export function invariant_dbSchemaVersionMatch( - ctx: z.core.ParsePayload<{ + ctx: ZodCheckFnInput<{ port: number; dataDir: string; dbSchemaVersion: number; From 65704299e9d08be62772d5038fa7fd58c1d4d020 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 11 Feb 2026 15:27:59 +0100 Subject: [PATCH 40/57] feat(config): introduce ArgsConfig for merging CLI args and environment variables, enhancing server command configuration --- apps/ensrainbow/README.md | 5 ++++ apps/ensrainbow/src/cli.ts | 8 +++--- .../src/commands/server-command.test.ts | 11 ++++++-- .../ensrainbow/src/commands/server-command.ts | 20 ++++++-------- .../src/config/config.schema.test.ts | 27 ++++++++++++++----- apps/ensrainbow/src/config/config.schema.ts | 11 ++++++-- apps/ensrainbow/src/config/index.ts | 4 +-- apps/ensrainbow/src/config/public.ts | 4 +-- apps/ensrainbow/src/config/types.ts | 5 ++++ apps/ensrainbow/src/lib/api.ts | 9 ++++--- 10 files changed, 69 insertions(+), 35 deletions(-) diff --git a/apps/ensrainbow/README.md b/apps/ensrainbow/README.md index 008570d3e..f6f22ec51 100644 --- a/apps/ensrainbow/README.md +++ b/apps/ensrainbow/README.md @@ -8,6 +8,11 @@ ENSRainbow is an ENSNode service for healing ENS labels. It provides a simple AP For detailed documentation and guides, see the [ENSRainbow Documentation](https://ensnode.io/ensrainbow). +### Configuration + +- **EnvConfig**: from environment variables (PORT, DATA_DIR, DB_SCHEMA_VERSION), validated at startup. +- **ArgsConfig**: effective config for the `serve` command: merge of CLI args and EnvConfig; CLI args take precedence. The API builds the public config (GET /v1/config) from ArgsConfig. + The initial release of ENSRainbow focuses on backwards compatibility with the ENS Subgraph, providing the same label healing capabilities that ENS ecosystem tools rely on today. However, we're actively working on significant enhancements that will expand ENSRainbow's healing capabilities far beyond what's currently possible with the ENS Subgraph. These upcoming features will allow ENSRainbow to heal many previously unknown labels, making it an even more powerful tool for ENS data analysis and integration. ## Special Thanks diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index d48822649..5f7d090b9 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,4 +1,4 @@ -import config from "@/config"; +import config, { buildServeArgsConfig } from "@/config"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -144,10 +144,8 @@ export function createCLI(options: CLIOptions = {}) { }); }, async (argv: ArgumentsCamelCase) => { - await serverCommand({ - port: argv.port, - dataDir: argv["data-dir"], - }); + const argsConfig = buildServeArgsConfig(config, argv); + await serverCommand(argsConfig); }, ) .command( diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index 386da34b0..df41ee1ea 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -1,3 +1,5 @@ +import type { ArgsConfig } from "@/config"; + import { promises as fs } from "node:fs"; import { serve } from "@hono/node-server"; @@ -7,7 +9,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; -import { ENSRainbowDB } from "@/lib/database"; +import { DB_SCHEMA_VERSION, ENSRainbowDB } from "@/lib/database"; import { createServer } from "./server-command"; @@ -30,7 +32,12 @@ describe("Server Command Tests", () => { await db.markIngestionFinished(); await db.setLabelSetId("test-label-set-id"); await db.setHighestLabelSetVersion(0); - app = await createServer(db); + const argsConfig: ArgsConfig = { + port: nonDefaultPort, + dataDir: TEST_DB_DIR, + dbSchemaVersion: DB_SCHEMA_VERSION, + }; + app = await createServer(db, argsConfig); // Start the server on a different port than what ENSRainbow defaults to server = serve({ diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 0b7ed300d..916a89ef5 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -1,4 +1,4 @@ -import config from "@/config"; +import type { ArgsConfig } from "@/config"; import { serve } from "@hono/node-server"; @@ -8,22 +8,18 @@ import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; import { logger } from "@/utils/logger"; -export interface ServerCommandOptions { - dataDir: string; - port: number; -} +export type ServerCommandOptions = ArgsConfig; /** - * Creates and configures the ENS Rainbow server application + * Creates and configures the ENS Rainbow server application. */ -export async function createServer(db: ENSRainbowDB) { - return createApi(db); +export async function createServer(db: ENSRainbowDB, argsConfig: ServerCommandOptions) { + return createApi(db, argsConfig); } export async function serverCommand(options: ServerCommandOptions): Promise { - // Log the config that ENSRainbow is running with - console.log("ENSRainbow running with environment config:"); - console.log(prettyPrintJson(config)); + console.log("ENSRainbow running with config:"); + console.log(prettyPrintJson(options)); logger.info(`ENS Rainbow server starting on port ${options.port}...`); @@ -46,7 +42,7 @@ export async function serverCommand(options: ServerCommandOptions): Promise { describe("Success cases", () => { @@ -285,6 +285,12 @@ describe("buildConfigFromEnvironment", () => { }); describe("buildENSRainbowPublicConfig", () => { + const labelSet: EnsRainbowServerLabelSet = { + labelSetId: "subgraph", + highestLabelSetVersion: 0, + }; + const recordsCount = 1000; + describe("Success cases", () => { it("returns a valid ENSRainbow public config with correct structure", () => { const mockConfig: ENSRainbowEnvConfig = { @@ -292,11 +298,6 @@ describe("buildENSRainbowPublicConfig", () => { dataDir: getDefaultDataDir(), dbSchemaVersion: DB_SCHEMA_VERSION, }; - const labelSet: EnsRainbowServerLabelSet = { - labelSetId: "subgraph", - highestLabelSetVersion: 0, - }; - const recordsCount = 1000; const result = buildENSRainbowPublicConfig(mockConfig, labelSet, recordsCount); @@ -306,5 +307,19 @@ describe("buildENSRainbowPublicConfig", () => { recordsCount, }); }); + + it("accepts ArgsConfig (effective config: merge of CLI args and EnvConfig)", () => { + const argsConfig: ArgsConfig = { + port: 4000, + dataDir: getDefaultDataDir(), + dbSchemaVersion: DB_SCHEMA_VERSION, + }; + + const result = buildENSRainbowPublicConfig(argsConfig, labelSet, recordsCount); + + expect(result.version).toBe(packageJson.version); + expect(result.labelSet).toStrictEqual(labelSet); + expect(result.recordsCount).toBe(recordsCount); + }); }); }); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 21414f4e3..3905cd1be 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -6,11 +6,11 @@ import { PortSchema } from "@ensnode/ensnode-sdk/internal"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -import type { ENSRainbowEnvConfig } from "@/config/types"; +import type { ArgsConfig, ENSRainbowEnvConfig } from "@/config/types"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -export type { ENSRainbowEnvConfig }; +export type { ArgsConfig, ENSRainbowEnvConfig }; export const AbsolutePathSchemaBase = z .string() @@ -68,3 +68,10 @@ export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainb throw error; } } + +export function buildServeArgsConfig( + envConfig: ENSRainbowEnvConfig, + args: { port: number; "data-dir": string }, +): ArgsConfig { + return { ...envConfig, port: args.port, dataDir: args["data-dir"] }; +} diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index bdfa2e1e8..2cb0b94af 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,7 +1,7 @@ import { buildConfigFromEnvironment } from "./config.schema"; -export type { ENSRainbowEnvConfig } from "./config.schema"; -export { buildConfigFromEnvironment } from "./config.schema"; +export type { ArgsConfig, ENSRainbowEnvConfig } from "./config.schema"; +export { buildConfigFromEnvironment, buildServeArgsConfig } from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; export { buildENSRainbowPublicConfig } from "./public"; diff --git a/apps/ensrainbow/src/config/public.ts b/apps/ensrainbow/src/config/public.ts index 54fccbf71..b6533fd9b 100644 --- a/apps/ensrainbow/src/config/public.ts +++ b/apps/ensrainbow/src/config/public.ts @@ -3,10 +3,10 @@ import packageJson from "@/../package.json" with { type: "json" }; import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; -import type { ENSRainbowEnvConfig } from "./types"; +import type { ArgsConfig } from "./types"; export function buildENSRainbowPublicConfig( - _config: ENSRainbowEnvConfig, // kept for semantic purposes + _argsConfig: ArgsConfig, // kept for semantic purposes labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index 12efb2e6b..c938cf616 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -24,3 +24,8 @@ export interface ENSRainbowEnvConfig { dataDir: AbsolutePath; dbSchemaVersion: DbSchemaVersion; } + +/** + * Effective config for the serve command: merge(CLI args, EnvConfig). + */ +export type ArgsConfig = ENSRainbowEnvConfig; diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 6426e207b..89bf2a175 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,5 +1,6 @@ import packageJson from "@/../package.json"; -import config, { buildENSRainbowPublicConfig } from "@/config"; +import { buildENSRainbowPublicConfig } from "@/config"; +import type { ArgsConfig } from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -22,9 +23,9 @@ import { getErrorMessage } from "@/utils/error-utils"; import { logger } from "@/utils/logger"; /** - * Creates and configures an ENS Rainbow api + * Creates and configures an ENS Rainbow API */ -export async function createApi(db: ENSRainbowDB): Promise { +export async function createApi(db: ENSRainbowDB, argsConfig: ArgsConfig): Promise { const api = new Hono(); const server = await ENSRainbowServer.init(db); @@ -39,7 +40,7 @@ export async function createApi(db: ENSRainbowDB): Promise { } const cachedPublicConfig = buildENSRainbowPublicConfig( - config, + argsConfig, server.getServerLabelSet(), countResult.count, ); From 6bdf9c890a451be3947778bca94a19f90d4dec39 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 11 Feb 2026 15:35:48 +0100 Subject: [PATCH 41/57] refactor(database): introduce NoPrecalculatedCountError for better error handling in database operations --- apps/ensrainbow/src/commands/server-command.ts | 8 ++------ apps/ensrainbow/src/lib/api.ts | 4 ++-- apps/ensrainbow/src/lib/database.ts | 14 ++++++++++++-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 916a89ef5..4acdd98a2 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -5,7 +5,7 @@ import { serve } from "@hono/node-server"; import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; import { createApi } from "@/lib/api"; -import { ENSRainbowDB } from "@/lib/database"; +import { ENSRainbowDB, NoPrecalculatedCountError } from "@/lib/database"; import { logger } from "@/utils/logger"; export type ServerCommandOptions = ArgsConfig; @@ -26,19 +26,15 @@ export async function serverCommand(options: ServerCommandOptions): Promise; +/** Thrown when the database has no precalculated rainbow record count (empty or uninitialized). */ +export class NoPrecalculatedCountError extends Error { + constructor(dataDir: string) { + super(`No precalculated count found in database at ${dataDir}`); + this.name = "NoPrecalculatedCountError"; + Object.setPrototypeOf(this, NoPrecalculatedCountError.prototype); + } +} + /** * Generates a consistent error message for database issues that require purging and re-ingesting. * @param errorDescription The specific error description @@ -435,12 +444,13 @@ export class ENSRainbowDB { /** * Gets the precalculated count of rainbow records in the database. The accuracy of the returned value is dependent on setting the precalculated count correctly. - * @throws Error if the precalculated count is not found or is improperly formatted + * @throws NoPrecalculatedCountError if the precalculated count is not found (empty/uninitialized DB) + * @throws Error if the precalculated count is improperly formatted */ public async getPrecalculatedRainbowRecordCount(): Promise { const countStr = await this.get(SYSTEM_KEY_PRECALCULATED_RAINBOW_RECORD_COUNT); if (countStr === null) { - throw new Error(`No precalculated count found in database at ${this.dataDir}`); + throw new NoPrecalculatedCountError(this.dataDir); } try { From a0b9809e056e901dd9ec168552e552b41e6d027d Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 11 Feb 2026 15:38:51 +0100 Subject: [PATCH 42/57] feat(api): add /v1/config endpoint for public configuration and deprecate /v1/version; enforce initialized database requirement for server startup --- .changeset/young-carrots-cheer.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/young-carrots-cheer.md b/.changeset/young-carrots-cheer.md index 1f3e96ab3..9212d6f33 100644 --- a/.changeset/young-carrots-cheer.md +++ b/.changeset/young-carrots-cheer.md @@ -4,3 +4,5 @@ --- Adds `/v1/config` endpoint to ENSRainbow API returning public configuration (version, label set, records count) and deprecates `/v1/version` endpoint. The new endpoint provides comprehensive service discovery capabilities for clients. + +Server startup now requires an initialized database (with a precalculated record count). Run ingestion before starting the server so `/v1/config` is accurate and the service is ready to serve. If the database is empty or uninitialized, startup fails with a clear error directing you to run ingestion first. From 0de157668c9fabbccafc34a23d6e683debb3c759 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 11 Feb 2026 15:54:18 +0100 Subject: [PATCH 43/57] feat(config): implement buildServeArgsConfig function to normalize and validate data-dir input, enhancing configuration handling in server commands --- .../src/config/config.schema.test.ts | 53 ++++++++++++++++++- apps/ensrainbow/src/config/config.schema.ts | 10 +++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 2ae77d315..a5eb03491 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -8,7 +8,7 @@ import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -import { buildConfigFromEnvironment } from "./config.schema"; +import { buildConfigFromEnvironment, buildServeArgsConfig } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; import type { ENSRainbowEnvironment } from "./environment"; import { buildENSRainbowPublicConfig } from "./public"; @@ -284,6 +284,57 @@ describe("buildConfigFromEnvironment", () => { }); }); +describe("buildServeArgsConfig", () => { + const baseEnvConfig: ENSRainbowEnvConfig = { + port: ENSRAINBOW_DEFAULT_PORT, + dataDir: "/env/data/dir", + dbSchemaVersion: DB_SCHEMA_VERSION, + }; + + it("normalizes relative data-dir to absolute path", () => { + const result = buildServeArgsConfig(baseEnvConfig, { + port: 4000, + "data-dir": "my-data", + }); + + expect(result.port).toBe(4000); + expect(isAbsolute(result.dataDir)).toBe(true); + expect(result.dataDir).toBe(resolve(process.cwd(), "my-data")); + expect(result.dataDir).not.toBe("my-data"); + }); + + it("preserves absolute data-dir and merges port", () => { + const result = buildServeArgsConfig(baseEnvConfig, { + port: 3000, + "data-dir": "/absolute/cli/path", + }); + + expect(result.port).toBe(3000); + expect(result.dataDir).toBe("/absolute/cli/path"); + }); + + it("trims whitespace from data-dir", () => { + const result = buildServeArgsConfig(baseEnvConfig, { + port: baseEnvConfig.port, + "data-dir": " /trimmed/path ", + }); + + expect(result.dataDir).toBe("/trimmed/path"); + }); + + it("throws on empty data-dir", () => { + expect(() => buildServeArgsConfig(baseEnvConfig, { port: 4000, "data-dir": "" })).toThrow( + /Invalid data-dir/, + ); + }); + + it("throws on whitespace-only data-dir", () => { + expect(() => buildServeArgsConfig(baseEnvConfig, { port: 4000, "data-dir": " " })).toThrow( + /Invalid data-dir/, + ); + }); +}); + describe("buildENSRainbowPublicConfig", () => { const labelSet: EnsRainbowServerLabelSet = { labelSetId: "subgraph", diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 3905cd1be..10bf82107 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -73,5 +73,13 @@ export function buildServeArgsConfig( envConfig: ENSRainbowEnvConfig, args: { port: number; "data-dir": string }, ): ArgsConfig { - return { ...envConfig, port: args.port, dataDir: args["data-dir"] }; + try { + const dataDir = AbsolutePathSchemaBase.parse(args["data-dir"]); + return { ...envConfig, port: args.port, dataDir }; + } catch (error) { + if (error instanceof ZodError) { + throw new Error(`Invalid data-dir: \n${prettifyError(error)}\n`); + } + throw error; + } } From d5ad075d80e04074dfc306b38085ec41bf3ce279 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 11 Feb 2026 16:01:56 +0100 Subject: [PATCH 44/57] refactor(server-command): remove unnecessary error handling for database initialization, simplifying server startup logic --- apps/ensrainbow/src/commands/server-command.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 4acdd98a2..3860d7047 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -5,7 +5,7 @@ import { serve } from "@hono/node-server"; import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; import { createApi } from "@/lib/api"; -import { ENSRainbowDB, NoPrecalculatedCountError } from "@/lib/database"; +import { ENSRainbowDB } from "@/lib/database"; import { logger } from "@/utils/logger"; export type ServerCommandOptions = ArgsConfig; @@ -26,18 +26,6 @@ export async function serverCommand(options: ServerCommandOptions): Promise Date: Wed, 11 Feb 2026 16:06:33 +0100 Subject: [PATCH 45/57] refactor(api): cache label count response on startup to ensure consistency between /v1/config and /v1/labels/count endpoints --- .../src/commands/server-command.test.ts | 31 +++++++++---------- apps/ensrainbow/src/lib/api.ts | 10 +++--- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index df41ee1ea..e9c6e7cd6 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -131,20 +131,8 @@ describe("Server Command Tests", () => { }); describe("GET /v1/labels/count", () => { - it("should throw an error when database is empty", async () => { - const response = await fetch(`http://localhost:${nonDefaultPort}/v1/labels/count`); - expect(response.status).toBe(500); - const data = (await response.json()) as EnsRainbow.CountResponse; - const expectedData: EnsRainbow.CountServerError = { - status: StatusCode.Error, - error: "Label count not initialized. Check the validate command.", - errorCode: ErrorCode.ServerError, - }; - expect(data).toEqual(expectedData); - }); - - it("should return correct count from LABEL_COUNT_KEY", async () => { - // Set a specific precalculated rainbow record count in the database + it("should return cached count from startup (same as /v1/config)", async () => { + // Count is fixed at server start; changing the DB does not affect the response await db.setPrecalculatedRainbowRecordCount(42); const response = await fetch(`http://localhost:${nonDefaultPort}/v1/labels/count`); @@ -152,11 +140,22 @@ describe("Server Command Tests", () => { const data = (await response.json()) as EnsRainbow.CountResponse; const expectedData: EnsRainbow.CountSuccess = { status: StatusCode.Success, - count: 42, + count: 0, timestamp: expect.any(String), }; expect(data).toEqual(expectedData); - expect(() => new Date(data.timestamp as string)).not.toThrow(); // valid timestamp + expect(() => new Date(data.timestamp as string)).not.toThrow(); + }); + + it("should match recordsCount in /v1/config", async () => { + const [countRes, configRes] = await Promise.all([ + fetch(`http://localhost:${nonDefaultPort}/v1/labels/count`), + fetch(`http://localhost:${nonDefaultPort}/v1/config`), + ]); + const countData = (await countRes.json()) as EnsRainbow.CountSuccess; + const configData = (await configRes.json()) as EnsRainbow.ENSRainbowPublicConfig; + expect(countData.status).toBe(StatusCode.Success); + expect(countData.count).toBe(configData.recordsCount); }); }); diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 224a92577..185c2ca46 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -29,8 +29,8 @@ export async function createApi(db: ENSRainbowDB, argsConfig: ArgsConfig): Promi const api = new Hono(); const server = await ENSRainbowServer.init(db); - // Build and cache the public config once on startup - // This avoids calling labelCount() on every /v1/config request + // Build and cache the public config and count once on startup. + // /v1/config and /v1/labels/count both return this static snapshot so they never diverge. const countResult = await server.labelCount(); if (countResult.status === StatusCode.Error) { logger.error("Failed to get records count during API initialization"); @@ -38,6 +38,7 @@ export async function createApi(db: ENSRainbowDB, argsConfig: ArgsConfig): Promi `Cannot initialize API: ${countResult.error} (errorCode: ${countResult.errorCode})`, ); } + const cachedCountResponse = countResult; const cachedPublicConfig = buildENSRainbowPublicConfig( argsConfig, @@ -109,9 +110,8 @@ export async function createApi(db: ENSRainbowDB, argsConfig: ArgsConfig): Promi return c.json(result); }); - api.get("/v1/labels/count", async (c: HonoContext) => { - const result = await server.labelCount(); - return c.json(result, result.errorCode); + api.get("/v1/labels/count", (c: HonoContext) => { + return c.json(cachedCountResponse, cachedCountResponse.errorCode); }); api.get("/v1/config", (c: HonoContext) => { From ca9709a2586ce6899725624a70a5faf16406d1fe Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 11 Feb 2026 16:23:17 +0100 Subject: [PATCH 46/57] refactor(api): update imports for public configuration and type definitions to streamline API functionality --- apps/ensrainbow/src/lib/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 185c2ca46..0b4e11176 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,6 +1,4 @@ import packageJson from "@/../package.json" with { type: "json" }; -import type { ArgsConfig } from "@/config"; -import { buildENSRainbowPublicConfig } from "@/config"; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -17,6 +15,8 @@ import { import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; +import { buildENSRainbowPublicConfig } from "@/config/public"; +import type { ArgsConfig } from "@/config/types"; import { DB_SCHEMA_VERSION, type ENSRainbowDB } from "@/lib/database"; import { ENSRainbowServer } from "@/lib/server"; import { getErrorMessage } from "@/utils/error-utils"; From bf1892e2ed112ee34ab050133f006837a925eb14 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 16 Feb 2026 14:09:29 +0100 Subject: [PATCH 47/57] refactor(cli.test): replace manual health check with waitForHealth function for improved reliability in server readiness checks --- apps/ensrainbow/src/cli.test.ts | 36 +++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index c1408eb22..058b33fd1 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -11,6 +11,24 @@ import { createCLI } from "./cli"; // Path to test fixtures const TEST_FIXTURES_DIR = join(__dirname, "..", "test", "fixtures"); +/** Polls url until GET returns 200 or timeout. Rejects on timeout. */ +async function waitForHealth( + url: string, + { intervalMs = 50, timeoutMs = 5000 }: { intervalMs?: number; timeoutMs?: number } = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.status === 200) return response; + } catch { + // Connection refused or other error; retry + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error(`Health check did not return 200 within ${timeoutMs}ms: ${url}`); +} + describe("CLI", () => { let tempDir: string; let testDataDir: string; @@ -555,11 +573,7 @@ describe("CLI", () => { testDataDir, ]); - // Give server time to start (DB open + validation can take a bit) - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Make a request to health endpoint - const response = await fetch(`http://localhost:${customPort}/health`); + const response = await waitForHealth(`http://localhost:${customPort}/health`); expect(response.status).toBe(200); // Cleanup - send SIGINT to stop server @@ -611,11 +625,7 @@ describe("CLI", () => { // Start server const serverPromise = cliWithCustomPort.parse(["serve", "--data-dir", testDataDir]); - // Give server time to start (DB open + validation can take a bit) - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Make a request to health endpoint - const response = await fetch(`http://localhost:${customPort}/health`); + const response = await waitForHealth(`http://localhost:${customPort}/health`); expect(response.status).toBe(200); // Make a request to count endpoint @@ -681,11 +691,7 @@ describe("CLI", () => { testDataDir, ]); - // Give server time to start (DB open + validation can take a bit) - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Verify server is running on the CLI port (not env port) - const response = await fetch("http://localhost:4000/health"); + const response = await waitForHealth("http://localhost:4000/health"); expect(response.status).toBe(200); // Cleanup - send SIGINT to stop server From 3acaf855c19a941c40789057f196200505c206c4 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 16 Feb 2026 14:24:49 +0100 Subject: [PATCH 48/57] refactor(config): replace PortSchema with OptionalPortNumberSchema across multiple files to enhance port configuration handling --- apps/ensapi/src/config/config.schema.ts | 4 ++-- apps/ensrainbow/src/cli.ts | 4 ++-- apps/ensrainbow/src/config/config.schema.ts | 4 ++-- packages/ensnode-sdk/src/shared/config/types.ts | 4 ++-- packages/ensnode-sdk/src/shared/config/zod-schemas.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index af74bd41c..4bebe4f8b 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -13,7 +13,7 @@ import { EnsIndexerUrlSchema, invariant_rpcConfigsSpecifiedForRootChain, makeENSIndexerPublicConfigSchema, - PortSchema, + OptionalPortNumberSchema, RpcConfigsSchema, TheGraphApiKeySchema, } from "@ensnode/ensnode-sdk/internal"; @@ -62,7 +62,7 @@ const CustomReferralProgramEditionConfigSetUrlSchema = z const EnsApiConfigSchema = z .object({ - port: PortSchema.default(ENSApi_DEFAULT_PORT), + port: OptionalPortNumberSchema.default(ENSApi_DEFAULT_PORT), databaseUrl: DatabaseUrlSchema, databaseSchemaName: DatabaseSchemaNameSchema, ensIndexerUrl: EnsIndexerUrlSchema, diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 5f7d090b9..ba784816d 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -8,7 +8,7 @@ import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; import { buildLabelSetId, type LabelSetId } from "@ensnode/ensnode-sdk"; -import { PortSchemaBase } from "@ensnode/ensnode-sdk/internal"; +import { PortNumberSchema } from "@ensnode/ensnode-sdk/internal"; import { convertCommand } from "@/commands/convert-command-sql"; import { convertCsvCommand } from "@/commands/convert-csv-command"; @@ -129,7 +129,7 @@ export function createCLI(options: CLIOptions = {}) { description: "Port to listen on (overrides PORT env var if both are set)", default: config.port, coerce: (port: number) => { - const result = PortSchemaBase.safeParse(port); + const result = PortNumberSchema.safeParse(port); if (!result.success) { const firstError = result.error.issues[0]; throw new Error(`Invalid port: ${firstError?.message ?? "invalid port number"}`); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 10bf82107..34f8006c0 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -2,7 +2,7 @@ import { isAbsolute, resolve } from "node:path"; import { prettifyError, ZodError, z } from "zod/v4"; -import { PortSchema } from "@ensnode/ensnode-sdk/internal"; +import { OptionalPortNumberSchema } from "@ensnode/ensnode-sdk/internal"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; @@ -35,7 +35,7 @@ export const DbSchemaVersionSchemaBase = z.coerce const DbSchemaVersionSchema = DbSchemaVersionSchemaBase.default(DB_SCHEMA_VERSION); const ENSRainbowConfigBaseSchema = z.object({ - port: PortSchema.default(ENSRAINBOW_DEFAULT_PORT), + port: OptionalPortNumberSchema.default(ENSRAINBOW_DEFAULT_PORT), dataDir: DataDirSchema.default(() => getDefaultDataDir()), dbSchemaVersion: DbSchemaVersionSchema, }); diff --git a/packages/ensnode-sdk/src/shared/config/types.ts b/packages/ensnode-sdk/src/shared/config/types.ts index e2ba18ae8..1248870a7 100644 --- a/packages/ensnode-sdk/src/shared/config/types.ts +++ b/packages/ensnode-sdk/src/shared/config/types.ts @@ -5,7 +5,7 @@ import type { ChainId } from "../types"; import type { DatabaseSchemaNameSchema, EnsIndexerUrlSchema, - PortSchemaBase, + PortNumberSchema, TheGraphApiKeySchema, } from "./zod-schemas"; @@ -50,4 +50,4 @@ export type DatabaseSchemaName = z.infer; export type EnsIndexerUrl = z.infer; export type TheGraphApiKey = z.infer; -export type PortNumber = z.infer; +export type PortNumber = z.infer; diff --git a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts index b0e6bf431..eef547608 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -58,12 +58,12 @@ export const ENSNamespaceSchema = z.enum(ENSNamespaceIds, { `Invalid NAMESPACE. Got '${input}', but supported ENS namespaces are: ${Object.keys(ENSNamespaceIds).join(", ")}`, }); -export const PortSchemaBase = z.coerce +export const PortNumberSchema = z.coerce .number({ error: "PORT must be a number." }) .int({ error: "PORT must be an integer." }) .min(1, { error: "PORT must be greater than or equal to 1" }) .max(65535, { error: "PORT must be less than or equal to 65535" }); -export const PortSchema = PortSchemaBase.optional(); +export const OptionalPortNumberSchema = PortNumberSchema.optional(); export const TheGraphApiKeySchema = z.string().optional(); From 8b942a9118e2d5efe2b6d71830359c13ab33a857 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 16 Feb 2026 14:34:26 +0100 Subject: [PATCH 49/57] refactor(config): rename buildConfigFromEnvironment to buildEnvConfigFromEnvironment for clarity and consistency in configuration handling --- .../src/config/config.schema.test.ts | 56 +++++++++---------- apps/ensrainbow/src/config/config.schema.ts | 2 +- apps/ensrainbow/src/config/index.ts | 9 ++- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index a5eb03491..bce8cd9ad 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -8,18 +8,18 @@ import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -import { buildConfigFromEnvironment, buildServeArgsConfig } from "./config.schema"; +import { buildEnvConfigFromEnvironment, buildServeArgsConfig } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; import type { ENSRainbowEnvironment } from "./environment"; import { buildENSRainbowPublicConfig } from "./public"; import type { ArgsConfig, ENSRainbowEnvConfig } from "./types"; -describe("buildConfigFromEnvironment", () => { +describe("buildEnvConfigFromEnvironment", () => { describe("Success cases", () => { it("returns a valid config with all defaults when environment is empty", () => { const env: ENSRainbowEnvironment = {}; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config).toStrictEqual({ port: ENSRAINBOW_DEFAULT_PORT, @@ -33,7 +33,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "5000", }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.port).toBe(5000); expect(config.dataDir).toBe(getDefaultDataDir()); @@ -45,7 +45,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: customDataDir, }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.dataDir).toBe(customDataDir); }); @@ -56,7 +56,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: relativeDataDir, }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(isAbsolute(config.dataDir)).toBe(true); expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); @@ -68,7 +68,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: relativeDataDir, }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(isAbsolute(config.dataDir)).toBe(true); expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); @@ -80,7 +80,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: absoluteDataDir, }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.dataDir).toBe(absoluteDataDir); }); @@ -90,7 +90,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); @@ -98,7 +98,7 @@ describe("buildConfigFromEnvironment", () => { it("defaults DB_SCHEMA_VERSION to code version when not set", () => { const env: ENSRainbowEnvironment = {}; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); @@ -110,7 +110,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config).toStrictEqual({ port: 4444, @@ -126,7 +126,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "not-a-number", }; - expect(() => buildConfigFromEnvironment(env)).toThrow(); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is a float", () => { @@ -134,7 +134,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "3000.5", }; - expect(() => buildConfigFromEnvironment(env)).toThrow(); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is less than 1", () => { @@ -142,7 +142,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "0", }; - expect(() => buildConfigFromEnvironment(env)).toThrow(); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is negative", () => { @@ -150,7 +150,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "-100", }; - expect(() => buildConfigFromEnvironment(env)).toThrow(); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); }); it("fails when PORT is greater than 65535", () => { @@ -158,7 +158,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "65536", }; - expect(() => buildConfigFromEnvironment(env)).toThrow(); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); }); it("fails when DATA_DIR is empty string", () => { @@ -166,7 +166,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: "", }; - expect(() => buildConfigFromEnvironment(env)).toThrow(); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); }); it("fails when DATA_DIR is only whitespace", () => { @@ -174,7 +174,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: " ", }; - expect(() => buildConfigFromEnvironment(env)).toThrow(); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); }); it("fails when DB_SCHEMA_VERSION is not a number", () => { @@ -182,7 +182,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: "not-a-number", }; - expect(() => buildConfigFromEnvironment(env)).toThrow(); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); }); it("fails when DB_SCHEMA_VERSION is a float", () => { @@ -190,7 +190,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: "3.5", }; - expect(() => buildConfigFromEnvironment(env)).toThrow(); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); }); }); @@ -201,7 +201,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: wrongVersion.toString(), }; - expect(() => buildConfigFromEnvironment(env)).toThrow(/DB_SCHEMA_VERSION mismatch/); + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(/DB_SCHEMA_VERSION mismatch/); }); it("passes when DB_SCHEMA_VERSION matches code version", () => { @@ -209,7 +209,7 @@ describe("buildConfigFromEnvironment", () => { DB_SCHEMA_VERSION: DB_SCHEMA_VERSION.toString(), }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); @@ -217,7 +217,7 @@ describe("buildConfigFromEnvironment", () => { it("passes when DB_SCHEMA_VERSION defaults to code version", () => { const env: ENSRainbowEnvironment = {}; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); }); @@ -229,7 +229,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "1", }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.port).toBe(1); }); @@ -239,7 +239,7 @@ describe("buildConfigFromEnvironment", () => { PORT: "65535", }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.port).toBe(65535); }); @@ -250,7 +250,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: ` ${dataDir} `, }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(config.dataDir).toBe(dataDir); }); @@ -261,7 +261,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: relativeDataDir, }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(isAbsolute(config.dataDir)).toBe(true); expect(config.dataDir).toBe(resolve(process.cwd(), relativeDataDir)); @@ -275,7 +275,7 @@ describe("buildConfigFromEnvironment", () => { DATA_DIR: tildeDataDir, }; - const config = buildConfigFromEnvironment(env); + const config = buildEnvConfigFromEnvironment(env); expect(isAbsolute(config.dataDir)).toBe(true); // ~ is treated as a directory name, not home expansion diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 34f8006c0..d4c2e91f2 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -42,7 +42,7 @@ const ENSRainbowConfigBaseSchema = z.object({ const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema.check(invariant_dbSchemaVersionMatch); -export function buildConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowEnvConfig { +export function buildEnvConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowEnvConfig { try { const envToConfigSchema = z .object({ diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 2cb0b94af..90035b39a 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,9 +1,12 @@ -import { buildConfigFromEnvironment } from "./config.schema"; +import { buildEnvConfigFromEnvironment } from "./config.schema"; export type { ArgsConfig, ENSRainbowEnvConfig } from "./config.schema"; -export { buildConfigFromEnvironment, buildServeArgsConfig } from "./config.schema"; +export { + buildEnvConfigFromEnvironment, + buildServeArgsConfig, +} from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; export { buildENSRainbowPublicConfig } from "./public"; -export default buildConfigFromEnvironment(process.env); +export default buildEnvConfigFromEnvironment(process.env); From 9f27809227720509c76856d00449c62237535b4a Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 16 Feb 2026 14:54:40 +0100 Subject: [PATCH 50/57] refactor(cli): update command argument interfaces and configuration handling to improve clarity and consistency --- apps/ensrainbow/src/cli.ts | 40 ++++++++++----------- apps/ensrainbow/src/config/config.schema.ts | 4 +-- apps/ensrainbow/src/config/environment.ts | 2 -- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index ba784816d..3c452fd41 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,4 +1,4 @@ -import config, { buildServeArgsConfig } from "@/config"; +import envConfig, { buildServeArgsConfig } from "@/config"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -23,37 +23,37 @@ import { validateCommand } from "@/commands/validate-command"; // "data-dir": string; // } -interface IngestProtobufArgs { +interface IngestProtobufCommandConfig { "input-file": string; "data-dir": string; } /** - * Arguments for the 'serve' command. + * Config for the 'serve' command. * * Note: CLI arguments take precedence over environment variables. */ -interface ServeArgs { +interface ServeCommandConfig { port: number; "data-dir": string; } -interface ValidateArgs { +interface ValidateCommandConfig { "data-dir": string; lite: boolean; } -interface PurgeArgs { +interface PurgeCommandConfig { "data-dir": string; } -interface ConvertArgs { +interface ConvertSqlCommandConfig { "input-file": string; "output-file"?: string; "label-set-id": LabelSetId; } -interface ConvertCsvArgs { +interface ConvertCsvCommandConfig { "input-file": string; "output-file"?: string; "label-set-id": LabelSetId; @@ -109,10 +109,10 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory to store LevelDB data", - default: config.dataDir, + default: envConfig.dataDir, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await ingestProtobufCommand({ inputFile: argv["input-file"], dataDir: argv["data-dir"], @@ -127,7 +127,7 @@ export function createCLI(options: CLIOptions = {}) { .option("port", { type: "number", description: "Port to listen on (overrides PORT env var if both are set)", - default: config.port, + default: envConfig.port, coerce: (port: number) => { const result = PortNumberSchema.safeParse(port); if (!result.success) { @@ -140,11 +140,11 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: config.dataDir, + default: envConfig.dataDir, }); }, - async (argv: ArgumentsCamelCase) => { - const argsConfig = buildServeArgsConfig(config, argv); + async (argv: ArgumentsCamelCase) => { + const argsConfig = buildServeArgsConfig(envConfig, argv); await serverCommand(argsConfig); }, ) @@ -156,7 +156,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: config.dataDir, + default: envConfig.dataDir, }) .option("lite", { type: "boolean", @@ -165,7 +165,7 @@ export function createCLI(options: CLIOptions = {}) { default: false, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await validateCommand({ dataDir: argv["data-dir"], lite: argv.lite, @@ -179,10 +179,10 @@ export function createCLI(options: CLIOptions = {}) { return yargs.option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: config.dataDir, + default: envConfig.dataDir, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await purgeCommand({ dataDir: argv["data-dir"], }); @@ -225,7 +225,7 @@ export function createCLI(options: CLIOptions = {}) { default: false, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await convertCsvCommand({ inputFile: argv["input-file"], outputFile: argv["output-file"], @@ -257,7 +257,7 @@ export function createCLI(options: CLIOptions = {}) { description: "Path to where the resulting ensrainbow file will be output", }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { const outputFile = argv["output-file"] ?? join(process.cwd(), `${argv["label-set-id"]}_0.ensrainbow`); await convertCommand({ diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index d4c2e91f2..99f2948eb 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -25,8 +25,6 @@ export const AbsolutePathSchemaBase = z return resolve(process.cwd(), path); }); -const DataDirSchema = AbsolutePathSchemaBase; - export const DbSchemaVersionSchemaBase = z.coerce .number({ error: "DB_SCHEMA_VERSION must be a number." }) .int({ error: "DB_SCHEMA_VERSION must be an integer." }) @@ -36,7 +34,7 @@ const DbSchemaVersionSchema = DbSchemaVersionSchemaBase.default(DB_SCHEMA_VERSIO const ENSRainbowConfigBaseSchema = z.object({ port: OptionalPortNumberSchema.default(ENSRAINBOW_DEFAULT_PORT), - dataDir: DataDirSchema.default(() => getDefaultDataDir()), + dataDir: AbsolutePathSchemaBase.default(() => getDefaultDataDir()), dbSchemaVersion: DbSchemaVersionSchema, }); diff --git a/apps/ensrainbow/src/config/environment.ts b/apps/ensrainbow/src/config/environment.ts index 66dff8dc3..1cdf781af 100644 --- a/apps/ensrainbow/src/config/environment.ts +++ b/apps/ensrainbow/src/config/environment.ts @@ -7,6 +7,4 @@ export type ENSRainbowEnvironment = PortEnvironment & LogLevelEnvironment & { DATA_DIR?: string; DB_SCHEMA_VERSION?: string; - LABEL_SET_ID?: string; - LABEL_SET_VERSION?: string; }; From 9abbac99e0aabe6d914ed56ee55faf163c09ff7c Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 16 Feb 2026 15:09:12 +0100 Subject: [PATCH 51/57] refactor(config): rename ArgsConfig to ServeCommandConfig for improved clarity and consistency in configuration handling --- apps/ensrainbow/README.md | 4 +--- apps/ensrainbow/src/cli.ts | 6 ++--- .../src/commands/server-command.test.ts | 6 ++--- .../ensrainbow/src/commands/server-command.ts | 8 +++---- .../src/config/config.schema.test.ts | 22 +++++++++---------- apps/ensrainbow/src/config/config.schema.ts | 8 +++---- apps/ensrainbow/src/config/index.ts | 4 ++-- apps/ensrainbow/src/config/public.ts | 4 ++-- apps/ensrainbow/src/config/types.ts | 2 +- apps/ensrainbow/src/lib/api.ts | 9 +++++--- 10 files changed, 37 insertions(+), 36 deletions(-) diff --git a/apps/ensrainbow/README.md b/apps/ensrainbow/README.md index f6f22ec51..e3de6dcae 100644 --- a/apps/ensrainbow/README.md +++ b/apps/ensrainbow/README.md @@ -11,9 +11,7 @@ For detailed documentation and guides, see the [ENSRainbow Documentation](https: ### Configuration - **EnvConfig**: from environment variables (PORT, DATA_DIR, DB_SCHEMA_VERSION), validated at startup. -- **ArgsConfig**: effective config for the `serve` command: merge of CLI args and EnvConfig; CLI args take precedence. The API builds the public config (GET /v1/config) from ArgsConfig. - -The initial release of ENSRainbow focuses on backwards compatibility with the ENS Subgraph, providing the same label healing capabilities that ENS ecosystem tools rely on today. However, we're actively working on significant enhancements that will expand ENSRainbow's healing capabilities far beyond what's currently possible with the ENS Subgraph. These upcoming features will allow ENSRainbow to heal many previously unknown labels, making it an even more powerful tool for ENS data analysis and integration. +- **ServeCommandConfig**: effective config for the `serve` command: merge of CLI args and EnvConfig; CLI args take precedence. The API builds the public config (GET /v1/config) from ServeCommandConfig. ## Special Thanks diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 3c452fd41..66dc55ed2 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,4 +1,4 @@ -import envConfig, { buildServeArgsConfig } from "@/config"; +import envConfig, { buildServeCommandConfig } from "@/config"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -144,8 +144,8 @@ export function createCLI(options: CLIOptions = {}) { }); }, async (argv: ArgumentsCamelCase) => { - const argsConfig = buildServeArgsConfig(envConfig, argv); - await serverCommand(argsConfig); + const serveCommandConfig = buildServeCommandConfig(envConfig, argv); + await serverCommand(serveCommandConfig); }, ) .command( diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index e9c6e7cd6..6afd4fe07 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -1,4 +1,4 @@ -import type { ArgsConfig } from "@/config"; +import type { ServeCommandConfig } from "@/config"; import { promises as fs } from "node:fs"; @@ -32,12 +32,12 @@ describe("Server Command Tests", () => { await db.markIngestionFinished(); await db.setLabelSetId("test-label-set-id"); await db.setHighestLabelSetVersion(0); - const argsConfig: ArgsConfig = { + const serveCommandConfig: ServeCommandConfig = { port: nonDefaultPort, dataDir: TEST_DB_DIR, dbSchemaVersion: DB_SCHEMA_VERSION, }; - app = await createServer(db, argsConfig); + app = await createServer(db, serveCommandConfig); // Start the server on a different port than what ENSRainbow defaults to server = serve({ diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 3860d7047..5962eedd2 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -1,4 +1,4 @@ -import type { ArgsConfig } from "@/config"; +import type { ServeCommandConfig } from "@/config"; import { serve } from "@hono/node-server"; @@ -8,13 +8,13 @@ import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; import { logger } from "@/utils/logger"; -export type ServerCommandOptions = ArgsConfig; +export type ServerCommandOptions = ServeCommandConfig; /** * Creates and configures the ENS Rainbow server application. */ -export async function createServer(db: ENSRainbowDB, argsConfig: ServerCommandOptions) { - return createApi(db, argsConfig); +export async function createServer(db: ENSRainbowDB, serveCommandConfig: ServerCommandOptions) { + return createApi(db, serveCommandConfig); } export async function serverCommand(options: ServerCommandOptions): Promise { diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index bce8cd9ad..91a7f489e 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -8,11 +8,11 @@ import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -import { buildEnvConfigFromEnvironment, buildServeArgsConfig } from "./config.schema"; +import { buildEnvConfigFromEnvironment, buildServeCommandConfig } from "./config.schema"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; import type { ENSRainbowEnvironment } from "./environment"; import { buildENSRainbowPublicConfig } from "./public"; -import type { ArgsConfig, ENSRainbowEnvConfig } from "./types"; +import type { ENSRainbowEnvConfig, ServeCommandConfig } from "./types"; describe("buildEnvConfigFromEnvironment", () => { describe("Success cases", () => { @@ -284,7 +284,7 @@ describe("buildEnvConfigFromEnvironment", () => { }); }); -describe("buildServeArgsConfig", () => { +describe("buildServeCommandConfig", () => { const baseEnvConfig: ENSRainbowEnvConfig = { port: ENSRAINBOW_DEFAULT_PORT, dataDir: "/env/data/dir", @@ -292,7 +292,7 @@ describe("buildServeArgsConfig", () => { }; it("normalizes relative data-dir to absolute path", () => { - const result = buildServeArgsConfig(baseEnvConfig, { + const result = buildServeCommandConfig(baseEnvConfig, { port: 4000, "data-dir": "my-data", }); @@ -304,7 +304,7 @@ describe("buildServeArgsConfig", () => { }); it("preserves absolute data-dir and merges port", () => { - const result = buildServeArgsConfig(baseEnvConfig, { + const result = buildServeCommandConfig(baseEnvConfig, { port: 3000, "data-dir": "/absolute/cli/path", }); @@ -314,7 +314,7 @@ describe("buildServeArgsConfig", () => { }); it("trims whitespace from data-dir", () => { - const result = buildServeArgsConfig(baseEnvConfig, { + const result = buildServeCommandConfig(baseEnvConfig, { port: baseEnvConfig.port, "data-dir": " /trimmed/path ", }); @@ -323,13 +323,13 @@ describe("buildServeArgsConfig", () => { }); it("throws on empty data-dir", () => { - expect(() => buildServeArgsConfig(baseEnvConfig, { port: 4000, "data-dir": "" })).toThrow( + expect(() => buildServeCommandConfig(baseEnvConfig, { port: 4000, "data-dir": "" })).toThrow( /Invalid data-dir/, ); }); it("throws on whitespace-only data-dir", () => { - expect(() => buildServeArgsConfig(baseEnvConfig, { port: 4000, "data-dir": " " })).toThrow( + expect(() => buildServeCommandConfig(baseEnvConfig, { port: 4000, "data-dir": " " })).toThrow( /Invalid data-dir/, ); }); @@ -359,14 +359,14 @@ describe("buildENSRainbowPublicConfig", () => { }); }); - it("accepts ArgsConfig (effective config: merge of CLI args and EnvConfig)", () => { - const argsConfig: ArgsConfig = { + it("accepts ServeCommandConfig (effective config: merge of CLI args and EnvConfig)", () => { + const serveCommandConfig: ServeCommandConfig = { port: 4000, dataDir: getDefaultDataDir(), dbSchemaVersion: DB_SCHEMA_VERSION, }; - const result = buildENSRainbowPublicConfig(argsConfig, labelSet, recordsCount); + const result = buildENSRainbowPublicConfig(serveCommandConfig, labelSet, recordsCount); expect(result.version).toBe(packageJson.version); expect(result.labelSet).toStrictEqual(labelSet); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 99f2948eb..6484aa3d2 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -6,11 +6,11 @@ import { OptionalPortNumberSchema } from "@ensnode/ensnode-sdk/internal"; import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; import type { ENSRainbowEnvironment } from "@/config/environment"; -import type { ArgsConfig, ENSRainbowEnvConfig } from "@/config/types"; +import type { ENSRainbowEnvConfig, ServeCommandConfig } from "@/config/types"; import { invariant_dbSchemaVersionMatch } from "@/config/validations"; import { DB_SCHEMA_VERSION } from "@/lib/database"; -export type { ArgsConfig, ENSRainbowEnvConfig }; +export type { ServeCommandConfig, ENSRainbowEnvConfig }; export const AbsolutePathSchemaBase = z .string() @@ -67,10 +67,10 @@ export function buildEnvConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRa } } -export function buildServeArgsConfig( +export function buildServeCommandConfig( envConfig: ENSRainbowEnvConfig, args: { port: number; "data-dir": string }, -): ArgsConfig { +): ServeCommandConfig { try { const dataDir = AbsolutePathSchemaBase.parse(args["data-dir"]); return { ...envConfig, port: args.port, dataDir }; diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 90035b39a..541dc6556 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,9 +1,9 @@ import { buildEnvConfigFromEnvironment } from "./config.schema"; -export type { ArgsConfig, ENSRainbowEnvConfig } from "./config.schema"; +export type { ENSRainbowEnvConfig, ServeCommandConfig } from "./config.schema"; export { buildEnvConfigFromEnvironment, - buildServeArgsConfig, + buildServeCommandConfig, } from "./config.schema"; export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; diff --git a/apps/ensrainbow/src/config/public.ts b/apps/ensrainbow/src/config/public.ts index b6533fd9b..513781fbc 100644 --- a/apps/ensrainbow/src/config/public.ts +++ b/apps/ensrainbow/src/config/public.ts @@ -3,10 +3,10 @@ import packageJson from "@/../package.json" with { type: "json" }; import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; -import type { ArgsConfig } from "./types"; +import type { ServeCommandConfig } from "./types"; export function buildENSRainbowPublicConfig( - _argsConfig: ArgsConfig, // kept for semantic purposes + _serveCommandConfig: ServeCommandConfig, // kept for semantic purposes labelSet: EnsRainbowServerLabelSet, recordsCount: number, ): EnsRainbow.ENSRainbowPublicConfig { diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index c938cf616..560388e63 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -28,4 +28,4 @@ export interface ENSRainbowEnvConfig { /** * Effective config for the serve command: merge(CLI args, EnvConfig). */ -export type ArgsConfig = ENSRainbowEnvConfig; +export type ServeCommandConfig = ENSRainbowEnvConfig; diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 0b4e11176..a21a0d92f 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -16,7 +16,7 @@ import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; import { buildENSRainbowPublicConfig } from "@/config/public"; -import type { ArgsConfig } from "@/config/types"; +import type { ServeCommandConfig } from "@/config/types"; import { DB_SCHEMA_VERSION, type ENSRainbowDB } from "@/lib/database"; import { ENSRainbowServer } from "@/lib/server"; import { getErrorMessage } from "@/utils/error-utils"; @@ -25,7 +25,10 @@ import { logger } from "@/utils/logger"; /** * Creates and configures an ENS Rainbow API */ -export async function createApi(db: ENSRainbowDB, argsConfig: ArgsConfig): Promise { +export async function createApi( + db: ENSRainbowDB, + serveCommandConfig: ServeCommandConfig, +): Promise { const api = new Hono(); const server = await ENSRainbowServer.init(db); @@ -41,7 +44,7 @@ export async function createApi(db: ENSRainbowDB, argsConfig: ArgsConfig): Promi const cachedCountResponse = countResult; const cachedPublicConfig = buildENSRainbowPublicConfig( - argsConfig, + serveCommandConfig, server.getServerLabelSet(), countResult.count, ); From d5d8b16952396bc932d26aba79b0970350d490e7 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 16 Feb 2026 15:32:19 +0100 Subject: [PATCH 52/57] refactor(server): enhance labelCount method to handle NoPrecalculatedCountError and streamline response structure --- apps/ensrainbow/src/lib/server.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/ensrainbow/src/lib/server.ts b/apps/ensrainbow/src/lib/server.ts index e801da492..593e1eb1b 100644 --- a/apps/ensrainbow/src/lib/server.ts +++ b/apps/ensrainbow/src/lib/server.ts @@ -13,7 +13,7 @@ import { StatusCode, } from "@ensnode/ensrainbow-sdk"; -import type { ENSRainbowDB } from "@/lib/database"; +import { type ENSRainbowDB, NoPrecalculatedCountError } from "@/lib/database"; import type { VersionedRainbowRecord } from "@/lib/rainbow-record"; import { getErrorMessage } from "@/utils/error-utils"; import { logger } from "@/utils/logger"; @@ -132,7 +132,13 @@ export class ENSRainbowServer { async labelCount(): Promise { try { const precalculatedCount = await this.db.getPrecalculatedRainbowRecordCount(); - if (precalculatedCount === null) { + return { + status: StatusCode.Success, + count: precalculatedCount, + timestamp: new Date().toISOString(), + } satisfies EnsRainbow.CountSuccess; + } catch (error) { + if (error instanceof NoPrecalculatedCountError) { return { status: StatusCode.Error, error: @@ -140,13 +146,6 @@ export class ENSRainbowServer { errorCode: ErrorCode.ServerError, } satisfies EnsRainbow.CountServerError; } - - return { - status: StatusCode.Success, - count: precalculatedCount, - timestamp: new Date().toISOString(), - } satisfies EnsRainbow.CountSuccess; - } catch (error) { logger.error(error, "Failed to retrieve precalculated rainbow record count"); return { status: StatusCode.Error, From b5cf60c24f40eb44e791a687438549f1b58162af Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 16 Feb 2026 16:37:13 +0100 Subject: [PATCH 53/57] refactor(logging): add console.log statements in server and API commands for improved visibility of configuration --- apps/ensrainbow/src/commands/server-command.ts | 1 + apps/ensrainbow/src/lib/api.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 5962eedd2..b13609b57 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -18,6 +18,7 @@ export async function createServer(db: ENSRainbowDB, serveCommandConfig: ServerC } export async function serverCommand(options: ServerCommandOptions): Promise { + // console.log is used so it can't be skipped by the logger console.log("ENSRainbow running with config:"); console.log(prettyPrintJson(options)); diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index a21a0d92f..f6d0ec08d 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -49,6 +49,7 @@ export async function createApi( countResult.count, ); + // console.log is used so it can't be skipped by the logger console.log("ENSRainbow public config:"); console.log(prettyPrintJson(cachedPublicConfig)); From 98ca18fb5824c053ff356522775c1b515bc85a30 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 16 Feb 2026 21:45:56 +0100 Subject: [PATCH 54/57] refactor(cli): update command argument interfaces to enhance type safety and improve clarity in CLI configuration --- apps/ensrainbow/src/cli.ts | 76 +++++-------------- .../src/commands/convert-command-sql.ts | 6 ++ .../src/commands/convert-csv-command.ts | 11 ++- .../src/commands/ingest-protobuf-command.ts | 5 ++ apps/ensrainbow/src/commands/purge-command.ts | 4 + .../src/commands/server-command.test.ts | 3 +- .../src/commands/validate-command.ts | 5 ++ .../src/config/config.schema.test.ts | 11 ++- apps/ensrainbow/src/config/config.schema.ts | 12 +-- apps/ensrainbow/src/config/index.ts | 2 +- apps/ensrainbow/src/config/types.ts | 7 +- 11 files changed, 66 insertions(+), 76 deletions(-) diff --git a/apps/ensrainbow/src/cli.ts b/apps/ensrainbow/src/cli.ts index 66dc55ed2..9a2ae5656 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,4 +1,4 @@ -import envConfig, { buildServeCommandConfig } from "@/config"; +import envConfig, { buildServeCommandConfig, type ServeCommandCliArgs } from "@/config"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -7,60 +7,18 @@ import type { ArgumentsCamelCase, Argv } from "yargs"; import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; -import { buildLabelSetId, type LabelSetId } from "@ensnode/ensnode-sdk"; +import { buildLabelSetId } from "@ensnode/ensnode-sdk"; import { PortNumberSchema } from "@ensnode/ensnode-sdk/internal"; -import { convertCommand } from "@/commands/convert-command-sql"; -import { convertCsvCommand } from "@/commands/convert-csv-command"; -// import { ingestCommand } from "@/commands/ingest-command"; -import { ingestProtobufCommand } from "@/commands/ingest-protobuf-command"; -import { purgeCommand } from "@/commands/purge-command"; +import { type ConvertSqlCommandCliArgs, convertCommand } from "@/commands/convert-command-sql"; +import { type ConvertCsvCommandCliArgs, convertCsvCommand } from "@/commands/convert-csv-command"; +import { + type IngestProtobufCommandCliArgs, + ingestProtobufCommand, +} from "@/commands/ingest-protobuf-command"; +import { type PurgeCommandCliArgs, purgeCommand } from "@/commands/purge-command"; import { serverCommand } from "@/commands/server-command"; -import { validateCommand } from "@/commands/validate-command"; - -// interface IngestArgs { -// "input-file": string; -// "data-dir": string; -// } - -interface IngestProtobufCommandConfig { - "input-file": string; - "data-dir": string; -} - -/** - * Config for the 'serve' command. - * - * Note: CLI arguments take precedence over environment variables. - */ -interface ServeCommandConfig { - port: number; - "data-dir": string; -} - -interface ValidateCommandConfig { - "data-dir": string; - lite: boolean; -} - -interface PurgeCommandConfig { - "data-dir": string; -} - -interface ConvertSqlCommandConfig { - "input-file": string; - "output-file"?: string; - "label-set-id": LabelSetId; -} - -interface ConvertCsvCommandConfig { - "input-file": string; - "output-file"?: string; - "label-set-id": LabelSetId; - "progress-interval"?: number; - "existing-db-path"?: string; - silent?: boolean; -} +import { type ValidateCommandCliArgs, validateCommand } from "@/commands/validate-command"; export interface CLIOptions { exitProcess?: boolean; @@ -112,7 +70,7 @@ export function createCLI(options: CLIOptions = {}) { default: envConfig.dataDir, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await ingestProtobufCommand({ inputFile: argv["input-file"], dataDir: argv["data-dir"], @@ -143,8 +101,8 @@ export function createCLI(options: CLIOptions = {}) { default: envConfig.dataDir, }); }, - async (argv: ArgumentsCamelCase) => { - const serveCommandConfig = buildServeCommandConfig(envConfig, argv); + async (argv: ArgumentsCamelCase) => { + const serveCommandConfig = buildServeCommandConfig(argv); await serverCommand(serveCommandConfig); }, ) @@ -165,7 +123,7 @@ export function createCLI(options: CLIOptions = {}) { default: false, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await validateCommand({ dataDir: argv["data-dir"], lite: argv.lite, @@ -182,7 +140,7 @@ export function createCLI(options: CLIOptions = {}) { default: envConfig.dataDir, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await purgeCommand({ dataDir: argv["data-dir"], }); @@ -225,7 +183,7 @@ export function createCLI(options: CLIOptions = {}) { default: false, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await convertCsvCommand({ inputFile: argv["input-file"], outputFile: argv["output-file"], @@ -257,7 +215,7 @@ export function createCLI(options: CLIOptions = {}) { description: "Path to where the resulting ensrainbow file will be output", }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { const outputFile = argv["output-file"] ?? join(process.cwd(), `${argv["label-set-id"]}_0.ensrainbow`); await convertCommand({ diff --git a/apps/ensrainbow/src/commands/convert-command-sql.ts b/apps/ensrainbow/src/commands/convert-command-sql.ts index e48258e6a..1f991c03d 100644 --- a/apps/ensrainbow/src/commands/convert-command-sql.ts +++ b/apps/ensrainbow/src/commands/convert-command-sql.ts @@ -19,6 +19,12 @@ import { } from "@/utils/protobuf-schema"; import { buildRainbowRecord } from "@/utils/rainbow-record"; +export interface ConvertSqlCommandCliArgs { + "input-file": string; + "output-file"?: string; + "label-set-id": LabelSetId; +} + export interface ConvertCommandOptions { inputFile: string; outputFile: string; diff --git a/apps/ensrainbow/src/commands/convert-csv-command.ts b/apps/ensrainbow/src/commands/convert-csv-command.ts index e64db9dd8..68f3e4e1f 100644 --- a/apps/ensrainbow/src/commands/convert-csv-command.ts +++ b/apps/ensrainbow/src/commands/convert-csv-command.ts @@ -13,7 +13,7 @@ import { ClassicLevel } from "classic-level"; import ProgressBar from "progress"; import { labelhash } from "viem"; -import { type LabelHash, labelHashToBytes } from "@ensnode/ensnode-sdk"; +import { type LabelHash, type LabelSetId, labelHashToBytes } from "@ensnode/ensnode-sdk"; import { ENSRainbowDB } from "../lib/database.js"; import { logger } from "../utils/logger.js"; @@ -120,6 +120,15 @@ function setupProgressBar(): ProgressBar { /** * Options for CSV conversion command */ +export interface ConvertCsvCommandCliArgs { + "input-file": string; + "output-file"?: string; + "label-set-id": LabelSetId; + "progress-interval"?: number; + "existing-db-path"?: string; + silent?: boolean; +} + export interface ConvertCsvCommandOptions { inputFile: string; outputFile?: string; // Optional - will be generated if not provided diff --git a/apps/ensrainbow/src/commands/ingest-protobuf-command.ts b/apps/ensrainbow/src/commands/ingest-protobuf-command.ts index e11e69d3f..7eed7fbb9 100644 --- a/apps/ensrainbow/src/commands/ingest-protobuf-command.ts +++ b/apps/ensrainbow/src/commands/ingest-protobuf-command.ts @@ -19,6 +19,11 @@ import { createRainbowProtobufRoot, } from "@/utils/protobuf-schema"; +export interface IngestProtobufCommandCliArgs { + "input-file": string; + "data-dir": string; +} + export interface IngestProtobufCommandOptions { inputFile: string; dataDir: string; diff --git a/apps/ensrainbow/src/commands/purge-command.ts b/apps/ensrainbow/src/commands/purge-command.ts index b80b295de..698c10880 100644 --- a/apps/ensrainbow/src/commands/purge-command.ts +++ b/apps/ensrainbow/src/commands/purge-command.ts @@ -3,6 +3,10 @@ import { rm } from "node:fs/promises"; import { getErrorMessage } from "@/utils/error-utils"; import { logger } from "@/utils/logger"; +export interface PurgeCommandCliArgs { + "data-dir": string; +} + export interface PurgeCommandOptions { dataDir: string; } diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index 6afd4fe07..16b98201e 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -9,7 +9,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; -import { DB_SCHEMA_VERSION, ENSRainbowDB } from "@/lib/database"; +import { ENSRainbowDB } from "@/lib/database"; import { createServer } from "./server-command"; @@ -35,7 +35,6 @@ describe("Server Command Tests", () => { const serveCommandConfig: ServeCommandConfig = { port: nonDefaultPort, dataDir: TEST_DB_DIR, - dbSchemaVersion: DB_SCHEMA_VERSION, }; app = await createServer(db, serveCommandConfig); diff --git a/apps/ensrainbow/src/commands/validate-command.ts b/apps/ensrainbow/src/commands/validate-command.ts index 94bb6e860..d2e362d37 100644 --- a/apps/ensrainbow/src/commands/validate-command.ts +++ b/apps/ensrainbow/src/commands/validate-command.ts @@ -1,5 +1,10 @@ import { ENSRainbowDB } from "@/lib/database"; +export interface ValidateCommandCliArgs { + "data-dir": string; + lite: boolean; +} + export interface ValidateCommandOptions { dataDir: string; lite?: boolean; // defaults to false diff --git a/apps/ensrainbow/src/config/config.schema.test.ts b/apps/ensrainbow/src/config/config.schema.test.ts index 91a7f489e..e8d5930a1 100644 --- a/apps/ensrainbow/src/config/config.schema.test.ts +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -292,7 +292,7 @@ describe("buildServeCommandConfig", () => { }; it("normalizes relative data-dir to absolute path", () => { - const result = buildServeCommandConfig(baseEnvConfig, { + const result = buildServeCommandConfig({ port: 4000, "data-dir": "my-data", }); @@ -304,7 +304,7 @@ describe("buildServeCommandConfig", () => { }); it("preserves absolute data-dir and merges port", () => { - const result = buildServeCommandConfig(baseEnvConfig, { + const result = buildServeCommandConfig({ port: 3000, "data-dir": "/absolute/cli/path", }); @@ -314,7 +314,7 @@ describe("buildServeCommandConfig", () => { }); it("trims whitespace from data-dir", () => { - const result = buildServeCommandConfig(baseEnvConfig, { + const result = buildServeCommandConfig({ port: baseEnvConfig.port, "data-dir": " /trimmed/path ", }); @@ -323,13 +323,13 @@ describe("buildServeCommandConfig", () => { }); it("throws on empty data-dir", () => { - expect(() => buildServeCommandConfig(baseEnvConfig, { port: 4000, "data-dir": "" })).toThrow( + expect(() => buildServeCommandConfig({ port: 4000, "data-dir": "" })).toThrow( /Invalid data-dir/, ); }); it("throws on whitespace-only data-dir", () => { - expect(() => buildServeCommandConfig(baseEnvConfig, { port: 4000, "data-dir": " " })).toThrow( + expect(() => buildServeCommandConfig({ port: 4000, "data-dir": " " })).toThrow( /Invalid data-dir/, ); }); @@ -363,7 +363,6 @@ describe("buildENSRainbowPublicConfig", () => { const serveCommandConfig: ServeCommandConfig = { port: 4000, dataDir: getDefaultDataDir(), - dbSchemaVersion: DB_SCHEMA_VERSION, }; const result = buildENSRainbowPublicConfig(serveCommandConfig, labelSet, recordsCount); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts index 6484aa3d2..c74055f51 100644 --- a/apps/ensrainbow/src/config/config.schema.ts +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -67,13 +67,15 @@ export function buildEnvConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRa } } -export function buildServeCommandConfig( - envConfig: ENSRainbowEnvConfig, - args: { port: number; "data-dir": string }, -): ServeCommandConfig { +export interface ServeCommandCliArgs { + port: number; + "data-dir": string; +} + +export function buildServeCommandConfig(args: ServeCommandCliArgs): ServeCommandConfig { try { const dataDir = AbsolutePathSchemaBase.parse(args["data-dir"]); - return { ...envConfig, port: args.port, dataDir }; + return { port: args.port, dataDir }; } catch (error) { if (error instanceof ZodError) { throw new Error(`Invalid data-dir: \n${prettifyError(error)}\n`); diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 541dc6556..2a3ba1b34 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -1,6 +1,6 @@ import { buildEnvConfigFromEnvironment } from "./config.schema"; -export type { ENSRainbowEnvConfig, ServeCommandConfig } from "./config.schema"; +export type { ENSRainbowEnvConfig, ServeCommandCliArgs, ServeCommandConfig } from "./config.schema"; export { buildEnvConfigFromEnvironment, buildServeCommandConfig, diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index 560388e63..b56507aed 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -26,6 +26,9 @@ export interface ENSRainbowEnvConfig { } /** - * Effective config for the serve command: merge(CLI args, EnvConfig). + * Validated configuration for the serve command. */ -export type ServeCommandConfig = ENSRainbowEnvConfig; +export interface ServeCommandConfig { + port: PortNumber; + dataDir: AbsolutePath; +} From 44c7d6e5b38898aea2fc77710350905bc8a28568 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 17 Feb 2026 13:37:31 +0100 Subject: [PATCH 55/57] refactor(server): simplify server creation by removing unnecessary configuration parameters and streamline public config building --- .../src/commands/server-command.test.ts | 9 +--- .../ensrainbow/src/commands/server-command.ts | 6 +-- .../src/config/config.schema.test.ts | 47 +++++-------------- apps/ensrainbow/src/config/index.ts | 1 + apps/ensrainbow/src/config/public.ts | 13 ++--- apps/ensrainbow/src/config/types.ts | 9 ++++ apps/ensrainbow/src/lib/api.ts | 18 ++++--- 7 files changed, 40 insertions(+), 63 deletions(-) diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index 16b98201e..1a65730a6 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -1,5 +1,3 @@ -import type { ServeCommandConfig } from "@/config"; - import { promises as fs } from "node:fs"; import { serve } from "@hono/node-server"; @@ -32,11 +30,8 @@ describe("Server Command Tests", () => { await db.markIngestionFinished(); await db.setLabelSetId("test-label-set-id"); await db.setHighestLabelSetVersion(0); - const serveCommandConfig: ServeCommandConfig = { - port: nonDefaultPort, - dataDir: TEST_DB_DIR, - }; - app = await createServer(db, serveCommandConfig); + + app = await createServer(db); // Start the server on a different port than what ENSRainbow defaults to server = serve({ diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index b13609b57..611d3dcad 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -13,8 +13,8 @@ export type ServerCommandOptions = ServeCommandConfig; /** * Creates and configures the ENS Rainbow server application. */ -export async function createServer(db: ENSRainbowDB, serveCommandConfig: ServerCommandOptions) { - return createApi(db, serveCommandConfig); +export async function createServer(db: ENSRainbowDB) { + return createApi(db); } export async function serverCommand(options: ServerCommandOptions): Promise { @@ -27,7 +27,7 @@ export async function serverCommand(options: ServerCommandOptions): Promise { describe("Success cases", () => { @@ -336,40 +334,21 @@ describe("buildServeCommandConfig", () => { }); describe("buildENSRainbowPublicConfig", () => { - const labelSet: EnsRainbowServerLabelSet = { - labelSetId: "subgraph", - highestLabelSetVersion: 0, + const dbConfig: DbConfig = { + labelSet: { + labelSetId: "subgraph", + highestLabelSetVersion: 0, + }, + recordsCount: 1000, }; - const recordsCount = 1000; - - describe("Success cases", () => { - it("returns a valid ENSRainbow public config with correct structure", () => { - const mockConfig: ENSRainbowEnvConfig = { - port: ENSRAINBOW_DEFAULT_PORT, - dataDir: getDefaultDataDir(), - dbSchemaVersion: DB_SCHEMA_VERSION, - }; - - const result = buildENSRainbowPublicConfig(mockConfig, labelSet, recordsCount); - - expect(result).toStrictEqual({ - version: packageJson.version, - labelSet, - recordsCount, - }); - }); - - it("accepts ServeCommandConfig (effective config: merge of CLI args and EnvConfig)", () => { - const serveCommandConfig: ServeCommandConfig = { - port: 4000, - dataDir: getDefaultDataDir(), - }; - const result = buildENSRainbowPublicConfig(serveCommandConfig, labelSet, recordsCount); + it("returns a valid ENSRainbow public config with correct structure", () => { + const result = buildENSRainbowPublicConfig(dbConfig); - expect(result.version).toBe(packageJson.version); - expect(result.labelSet).toStrictEqual(labelSet); - expect(result.recordsCount).toBe(recordsCount); + expect(result).toStrictEqual({ + version: packageJson.version, + labelSet: dbConfig.labelSet, + recordsCount: dbConfig.recordsCount, }); }); }); diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts index 2a3ba1b34..f0640fd16 100644 --- a/apps/ensrainbow/src/config/index.ts +++ b/apps/ensrainbow/src/config/index.ts @@ -8,5 +8,6 @@ export { export { ENSRAINBOW_DEFAULT_PORT } from "./defaults"; export type { ENSRainbowEnvironment } from "./environment"; export { buildENSRainbowPublicConfig } from "./public"; +export type { DbConfig } from "./types"; export default buildEnvConfigFromEnvironment(process.env); diff --git a/apps/ensrainbow/src/config/public.ts b/apps/ensrainbow/src/config/public.ts index 513781fbc..89b28c834 100644 --- a/apps/ensrainbow/src/config/public.ts +++ b/apps/ensrainbow/src/config/public.ts @@ -1,18 +1,13 @@ import packageJson from "@/../package.json" with { type: "json" }; -import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; -import type { ServeCommandConfig } from "./types"; +import type { DbConfig } from "./types"; -export function buildENSRainbowPublicConfig( - _serveCommandConfig: ServeCommandConfig, // kept for semantic purposes - labelSet: EnsRainbowServerLabelSet, - recordsCount: number, -): EnsRainbow.ENSRainbowPublicConfig { +export function buildENSRainbowPublicConfig(dbConfig: DbConfig): EnsRainbow.ENSRainbowPublicConfig { return { version: packageJson.version, - labelSet, - recordsCount, + labelSet: dbConfig.labelSet, + recordsCount: dbConfig.recordsCount, }; } diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts index b56507aed..eb5d82254 100644 --- a/apps/ensrainbow/src/config/types.ts +++ b/apps/ensrainbow/src/config/types.ts @@ -1,5 +1,6 @@ import type { z } from "zod/v4"; +import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk"; import type { PortNumber } from "@ensnode/ensnode-sdk/internal"; import type { AbsolutePathSchemaBase, DbSchemaVersionSchemaBase } from "./config.schema"; @@ -32,3 +33,11 @@ export interface ServeCommandConfig { port: PortNumber; dataDir: AbsolutePath; } + +/** + * Metadata read from an opened ENSRainbow database. + */ +export interface DbConfig { + labelSet: EnsRainbowServerLabelSet; + recordsCount: number; +} diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index f6d0ec08d..4d5bd496a 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -16,7 +16,7 @@ import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; import { buildENSRainbowPublicConfig } from "@/config/public"; -import type { ServeCommandConfig } from "@/config/types"; +import type { DbConfig } from "@/config/types"; import { DB_SCHEMA_VERSION, type ENSRainbowDB } from "@/lib/database"; import { ENSRainbowServer } from "@/lib/server"; import { getErrorMessage } from "@/utils/error-utils"; @@ -25,10 +25,7 @@ import { logger } from "@/utils/logger"; /** * Creates and configures an ENS Rainbow API */ -export async function createApi( - db: ENSRainbowDB, - serveCommandConfig: ServeCommandConfig, -): Promise { +export async function createApi(db: ENSRainbowDB): Promise { const api = new Hono(); const server = await ENSRainbowServer.init(db); @@ -43,11 +40,12 @@ export async function createApi( } const cachedCountResponse = countResult; - const cachedPublicConfig = buildENSRainbowPublicConfig( - serveCommandConfig, - server.getServerLabelSet(), - countResult.count, - ); + const dbConfig: DbConfig = { + labelSet: server.getServerLabelSet(), + recordsCount: countResult.count, + }; + + const cachedPublicConfig = buildENSRainbowPublicConfig(dbConfig); // console.log is used so it can't be skipped by the logger console.log("ENSRainbow public config:"); From 7e242fa95d8886d8cd6a3d5ad37db81ef92423d8 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 17 Feb 2026 13:51:59 +0100 Subject: [PATCH 56/57] refactor(server): replace createServer function with direct createApi call for streamlined server initialization --- apps/ensrainbow/src/commands/server-command.test.ts | 5 ++--- apps/ensrainbow/src/commands/server-command.ts | 9 +-------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index 1a65730a6..899e314a8 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -7,10 +7,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; +import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; -import { createServer } from "./server-command"; - describe("Server Command Tests", () => { let db: ENSRainbowDB; const nonDefaultPort = 3224; @@ -31,7 +30,7 @@ describe("Server Command Tests", () => { await db.setLabelSetId("test-label-set-id"); await db.setHighestLabelSetVersion(0); - app = await createServer(db); + app = await createApi(db); // Start the server on a different port than what ENSRainbow defaults to server = serve({ diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 611d3dcad..f98ccf1ca 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -10,13 +10,6 @@ import { logger } from "@/utils/logger"; export type ServerCommandOptions = ServeCommandConfig; -/** - * Creates and configures the ENS Rainbow server application. - */ -export async function createServer(db: ENSRainbowDB) { - return createApi(db); -} - export async function serverCommand(options: ServerCommandOptions): Promise { // console.log is used so it can't be skipped by the logger console.log("ENSRainbow running with config:"); @@ -27,7 +20,7 @@ export async function serverCommand(options: ServerCommandOptions): Promise Date: Tue, 17 Feb 2026 14:45:54 +0100 Subject: [PATCH 57/57] refactor(server): enhance server initialization by integrating public config building and improving API response structure --- apps/ensrainbow/README.md | 4 +- .../src/commands/server-command.test.ts | 23 ++++++---- .../ensrainbow/src/commands/server-command.ts | 16 +++++-- apps/ensrainbow/src/lib/api.ts | 45 ++++++------------- apps/ensrainbow/src/lib/server.ts | 19 ++++++++ 5 files changed, 62 insertions(+), 45 deletions(-) diff --git a/apps/ensrainbow/README.md b/apps/ensrainbow/README.md index e3de6dcae..72be2a083 100644 --- a/apps/ensrainbow/README.md +++ b/apps/ensrainbow/README.md @@ -10,8 +10,8 @@ For detailed documentation and guides, see the [ENSRainbow Documentation](https: ### Configuration -- **EnvConfig**: from environment variables (PORT, DATA_DIR, DB_SCHEMA_VERSION), validated at startup. -- **ServeCommandConfig**: effective config for the `serve` command: merge of CLI args and EnvConfig; CLI args take precedence. The API builds the public config (GET /v1/config) from ServeCommandConfig. +- **Environment Config**: Built from environment variables (PORT, DATA_DIR, DB_SCHEMA_VERSION) and validated at module load time. +- **Serve Command Config**: Built from CLI arguments and environment config for the `serve` command. CLI arguments override environment variables. The server builds the public config (GET /v1/config) from the database and command config at startup. ## Special Thanks diff --git a/apps/ensrainbow/src/commands/server-command.test.ts b/apps/ensrainbow/src/commands/server-command.test.ts index 899e314a8..6b0788896 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -7,8 +7,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; +import { buildENSRainbowPublicConfig } from "@/config/public"; import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; +import { buildDbConfig, ENSRainbowServer } from "@/lib/server"; describe("Server Command Tests", () => { let db: ENSRainbowDB; @@ -30,7 +32,10 @@ describe("Server Command Tests", () => { await db.setLabelSetId("test-label-set-id"); await db.setHighestLabelSetVersion(0); - app = await createApi(db); + const ensRainbowServer = await ENSRainbowServer.init(db); + const dbConfig = await buildDbConfig(ensRainbowServer); + const publicConfig = buildENSRainbowPublicConfig(dbConfig); + app = createApi(ensRainbowServer, publicConfig); // Start the server on a different port than what ENSRainbow defaults to server = serve({ @@ -124,7 +129,7 @@ describe("Server Command Tests", () => { }); describe("GET /v1/labels/count", () => { - it("should return cached count from startup (same as /v1/config)", async () => { + it("should return count snapshot from startup (same as /v1/config)", async () => { // Count is fixed at server start; changing the DB does not affect the response await db.setPrecalculatedRainbowRecordCount(42); @@ -167,9 +172,9 @@ describe("Server Command Tests", () => { }); describe("GET /v1/config", () => { - it("should return cached config from startup", async () => { - // The config is cached on startup with count = 0 (set in beforeAll) - // Even if the database is cleared in beforeEach, the cached config is returned + it("should return config snapshot from startup", async () => { + // The config is built once on startup with count = 0 (set in beforeAll) + // Even if the database is cleared in beforeEach, the same config is returned const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); expect(response.status).toBe(200); const data = (await response.json()) as EnsRainbow.ENSRainbowPublicConfig; @@ -178,13 +183,13 @@ describe("Server Command Tests", () => { expect(data.version.length).toBeGreaterThan(0); expect(data.labelSet.labelSetId).toBe("test-label-set-id"); expect(data.labelSet.highestLabelSetVersion).toBe(0); - // Config is cached on startup with count = 0, so it returns the cached value + // Config is built on startup with count = 0, so it returns the startup value expect(data.recordsCount).toBe(0); }); - it("should return cached config even if database count changes", async () => { + it("should return same config even if database count changes", async () => { // Set a different count in the database - // However, the config is cached on startup, so it will still return the cached value + // However, the config is built once on startup, so it will still return the startup value await db.setPrecalculatedRainbowRecordCount(42); const response = await fetch(`http://localhost:${nonDefaultPort}/v1/config`); @@ -195,7 +200,7 @@ describe("Server Command Tests", () => { expect(data.version.length).toBeGreaterThan(0); expect(data.labelSet.labelSetId).toBe("test-label-set-id"); expect(data.labelSet.highestLabelSetVersion).toBe(0); - // Config is cached on startup with count = 0, so changing the DB doesn't affect it + // Config is built on startup with count = 0, so changing the DB doesn't affect it expect(data.recordsCount).toBe(0); }); }); diff --git a/apps/ensrainbow/src/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index f98ccf1ca..85a8bf3a8 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -4,8 +4,10 @@ import { serve } from "@hono/node-server"; import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; +import { buildENSRainbowPublicConfig } from "@/config/public"; import { createApi } from "@/lib/api"; import { ENSRainbowDB } from "@/lib/database"; +import { buildDbConfig, ENSRainbowServer } from "@/lib/server"; import { logger } from "@/utils/logger"; export type ServerCommandOptions = ServeCommandConfig; @@ -20,9 +22,17 @@ export async function serverCommand(options: ServerCommandOptions): Promise { logger.info("Shutting down server..."); try { - await server.close(); + await httpServer.close(); await db.close(); logger.info("Server shutdown complete"); } catch (error) { diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 4d5bd496a..26dec82cf 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -12,44 +12,27 @@ import { type LabelSetId, type LabelSetVersion, } from "@ensnode/ensnode-sdk"; -import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; -import { buildENSRainbowPublicConfig } from "@/config/public"; -import type { DbConfig } from "@/config/types"; -import { DB_SCHEMA_VERSION, type ENSRainbowDB } from "@/lib/database"; -import { ENSRainbowServer } from "@/lib/server"; +import { DB_SCHEMA_VERSION } from "@/lib/database"; +import type { ENSRainbowServer } from "@/lib/server"; import { getErrorMessage } from "@/utils/error-utils"; import { logger } from "@/utils/logger"; /** - * Creates and configures an ENS Rainbow API + * Creates and configures the ENS Rainbow API routes. */ -export async function createApi(db: ENSRainbowDB): Promise { +export function createApi( + server: ENSRainbowServer, + publicConfig: EnsRainbow.ENSRainbowPublicConfig, +): Hono { const api = new Hono(); - const server = await ENSRainbowServer.init(db); - - // Build and cache the public config and count once on startup. - // /v1/config and /v1/labels/count both return this static snapshot so they never diverge. - const countResult = await server.labelCount(); - if (countResult.status === StatusCode.Error) { - logger.error("Failed to get records count during API initialization"); - throw new Error( - `Cannot initialize API: ${countResult.error} (errorCode: ${countResult.errorCode})`, - ); - } - const cachedCountResponse = countResult; - - const dbConfig: DbConfig = { - labelSet: server.getServerLabelSet(), - recordsCount: countResult.count, - }; - - const cachedPublicConfig = buildENSRainbowPublicConfig(dbConfig); - // console.log is used so it can't be skipped by the logger - console.log("ENSRainbow public config:"); - console.log(prettyPrintJson(cachedPublicConfig)); + const countResponse: EnsRainbow.CountSuccess = { + status: StatusCode.Success, + count: publicConfig.recordsCount, + timestamp: new Date().toISOString(), + }; // Enable CORS for all versioned API routes api.use( @@ -113,11 +96,11 @@ export async function createApi(db: ENSRainbowDB): Promise { }); api.get("/v1/labels/count", (c: HonoContext) => { - return c.json(cachedCountResponse, cachedCountResponse.errorCode); + return c.json(countResponse); }); api.get("/v1/config", (c: HonoContext) => { - return c.json(cachedPublicConfig); + return c.json(publicConfig); }); /** diff --git a/apps/ensrainbow/src/lib/server.ts b/apps/ensrainbow/src/lib/server.ts index 593e1eb1b..56c124c22 100644 --- a/apps/ensrainbow/src/lib/server.ts +++ b/apps/ensrainbow/src/lib/server.ts @@ -13,11 +13,30 @@ import { StatusCode, } from "@ensnode/ensrainbow-sdk"; +import type { DbConfig } from "@/config/types"; import { type ENSRainbowDB, NoPrecalculatedCountError } from "@/lib/database"; import type { VersionedRainbowRecord } from "@/lib/rainbow-record"; import { getErrorMessage } from "@/utils/error-utils"; import { logger } from "@/utils/logger"; +/** + * Reads label set and record count from an initialized ENSRainbowServer. + * @throws Error if the record count cannot be read from the database. + */ +export async function buildDbConfig(server: ENSRainbowServer): Promise { + const countResult = await server.labelCount(); + if (countResult.status === StatusCode.Error) { + throw new Error( + `Failed to read record count from database: ${countResult.error} (errorCode: ${countResult.errorCode})`, + ); + } + + return { + labelSet: server.getServerLabelSet(), + recordsCount: countResult.count, + }; +} + export class ENSRainbowServer { private readonly db: ENSRainbowDB; private readonly serverLabelSet: EnsRainbowServerLabelSet;