diff --git a/.changeset/young-carrots-cheer.md b/.changeset/young-carrots-cheer.md new file mode 100644 index 000000000..9212d6f33 --- /dev/null +++ b/.changeset/young-carrots-cheer.md @@ -0,0 +1,8 @@ +--- +"ensrainbow": patch +"@ensnode/ensrainbow-sdk": patch +--- + +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. 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/README.md b/apps/ensrainbow/README.md index 008570d3e..72be2a083 100644 --- a/apps/ensrainbow/README.md +++ b/apps/ensrainbow/README.md @@ -8,7 +8,10 @@ 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). -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. +### Configuration + +- **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/package.json b/apps/ensrainbow/package.json index 6a3400165..ea836c348 100644 --- a/apps/ensrainbow/package.json +++ b/apps/ensrainbow/package.json @@ -40,7 +40,8 @@ "protobufjs": "^7.4.0", "viem": "catalog:", "yargs": "^17.7.2", - "@fast-csv/parse": "^5.0.0" + "@fast-csv/parse": "^5.0.0", + "zod": "catalog:" }, "devDependencies": { "@ensnode/shared-configs": "workspace:*", diff --git a/apps/ensrainbow/src/cli.test.ts b/apps/ensrainbow/src/cli.test.ts index dedf1b88a..058b33fd1 100644 --- a/apps/ensrainbow/src/cli.test.ts +++ b/apps/ensrainbow/src/cli.test.ts @@ -4,13 +4,31 @@ 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 { createCLI, validatePortConfiguration } from "./cli"; +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; @@ -37,43 +55,86 @@ describe("CLI", () => { await rm(tempDir, { recursive: true, force: true }); }); - describe("getEnvPort", () => { - it("should return DEFAULT_PORT when PORT is not set", () => { - expect(getEnvPort()).toBe(DEFAULT_PORT); - }); + 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 serverCommandMock = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/commands/server-command", () => ({ + serverCommand: serverCommandMock, + })); - it("should return port from environment variable", () => { - const customPort = 4000; - process.env.PORT = customPort.toString(); - expect(getEnvPort()).toBe(customPort); - }); + // Simulate PORT being set in the environment + vi.stubEnv("PORT", "3000"); + + 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 })); - 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', - ); + // Restore real implementation for subsequent tests + vi.doUnmock("@/commands/server-command"); }); - it("should throw error for negative port number", () => { - process.env.PORT = "-1"; - expect(() => getEnvPort()).toThrow('Invalid PORT value "-1": must be a non-negative integer'); + it("should 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"); + } }); - }); - describe("validatePortConfiguration", () => { - it("should not throw when PORT env var is not set", () => { - expect(() => validatePortConfiguration(3000)).not.toThrow(); + 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 not throw when PORT matches CLI port", () => { - process.env.PORT = "3000"; - expect(() => validatePortConfiguration(3000)).not.toThrow(); + 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 throw when PORT conflicts with CLI port", () => { - process.env.PORT = "3000"; - expect(() => validatePortConfiguration(4000)).toThrow("Port conflict"); + 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"); }); }); @@ -512,11 +573,7 @@ describe("CLI", () => { testDataDir, ]); - // Give server time to start - await new Promise((resolve) => setTimeout(resolve, 100)); - - // 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 @@ -524,13 +581,40 @@ 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; - 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, @@ -539,13 +623,9 @@ describe("CLI", () => { ]); // Start server - const serverPromise = cli.parse(["serve", "--data-dir", testDataDir]); - - // Give server time to start - await new Promise((resolve) => setTimeout(resolve, 100)); + const serverPromise = cliWithCustomPort.parse(["serve", "--data-dir", testDataDir]); - // 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 @@ -586,11 +666,37 @@ describe("CLI", () => { await serverPromise; }); - it("should throw on port conflict", async () => { - process.env.PORT = "5000"; - await expect( - cli.parse(["serve", "--port", "4000", "--data-dir", testDataDir]), - ).rejects.toThrow("Port conflict"); + 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 }); + + // 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, + ]); + + const response = await waitForHealth("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 730fa79a0..9a2ae5656 100644 --- a/apps/ensrainbow/src/cli.ts +++ b/apps/ensrainbow/src/cli.ts @@ -1,3 +1,5 @@ +import envConfig, { buildServeCommandConfig, type ServeCommandCliArgs } from "@/config"; + import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -5,65 +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"; -import { getDefaultDataSubDir, getEnvPort } from "@/lib/env"; - -export function validatePortConfiguration(cliPort: number): void { - const envPort = process.env.PORT; - if (envPort !== undefined && cliPort !== getEnvPort()) { - throw new Error( - `Port conflict: Command line argument (${cliPort}) differs from PORT environment variable (${envPort}). ` + - `Please use only one method to specify the port.`, - ); - } -} - -// interface IngestArgs { -// "input-file": string; -// "data-dir": string; -// } - -interface IngestProtobufArgs { - "input-file": string; - "data-dir": string; -} - -interface ServeArgs { - port: number; - "data-dir": string; -} - -interface ValidateArgs { - "data-dir": string; - lite: boolean; -} - -interface PurgeArgs { - "data-dir": string; -} - -interface ConvertArgs { - "input-file": string; - "output-file"?: string; - "label-set-id": LabelSetId; -} - -interface ConvertCsvArgs { - "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; @@ -89,7 +44,7 @@ export function createCLI(options: CLIOptions = {}) { // .option("data-dir", { // type: "string", // description: "Directory to store LevelDB data", - // default: getDefaultDataSubDir(), + // default: getDefaultDataDir(), // }); // }, // async (argv: ArgumentsCamelCase) => { @@ -112,10 +67,10 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory to store LevelDB data", - default: getDefaultDataSubDir(), + default: envConfig.dataDir, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await ingestProtobufCommand({ inputFile: argv["input-file"], dataDir: argv["data-dir"], @@ -129,21 +84,26 @@ export function createCLI(options: CLIOptions = {}) { return yargs .option("port", { type: "number", - description: "Port to listen on", - default: getEnvPort(), + description: "Port to listen on (overrides PORT env var if both are set)", + default: envConfig.port, + coerce: (port: number) => { + const result = PortNumberSchema.safeParse(port); + if (!result.success) { + const firstError = result.error.issues[0]; + throw new Error(`Invalid port: ${firstError?.message ?? "invalid port number"}`); + } + return result.data; + }, }) .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: envConfig.dataDir, }); }, - async (argv: ArgumentsCamelCase) => { - validatePortConfiguration(argv.port); - await serverCommand({ - port: argv.port, - dataDir: argv["data-dir"], - }); + async (argv: ArgumentsCamelCase) => { + const serveCommandConfig = buildServeCommandConfig(argv); + await serverCommand(serveCommandConfig); }, ) .command( @@ -154,7 +114,7 @@ export function createCLI(options: CLIOptions = {}) { .option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: envConfig.dataDir, }) .option("lite", { type: "boolean", @@ -163,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, @@ -177,10 +137,10 @@ export function createCLI(options: CLIOptions = {}) { return yargs.option("data-dir", { type: "string", description: "Directory containing LevelDB data", - default: getDefaultDataSubDir(), + default: envConfig.dataDir, }); }, - async (argv: ArgumentsCamelCase) => { + async (argv: ArgumentsCamelCase) => { await purgeCommand({ dataDir: argv["data-dir"], }); @@ -223,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"], @@ -255,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 d6d04e911..6b0788896 100644 --- a/apps/ensrainbow/src/commands/server-command.test.ts +++ b/apps/ensrainbow/src/commands/server-command.test.ts @@ -7,9 +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 { createServer } from "./server-command"; +import { buildDbConfig, ENSRainbowServer } from "@/lib/server"; describe("Server Command Tests", () => { let db: ENSRainbowDB; @@ -30,7 +31,11 @@ describe("Server Command Tests", () => { await db.markIngestionFinished(); await db.setLabelSetId("test-label-set-id"); await db.setHighestLabelSetVersion(0); - app = await createServer(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,20 +129,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 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); const response = await fetch(`http://localhost:${nonDefaultPort}/v1/labels/count`); @@ -145,11 +138,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); }); }); @@ -167,6 +171,40 @@ describe("Server Command Tests", () => { }); }); + describe("GET /v1/config", () => { + 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; + + 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 built on startup with count = 0, so it returns the startup value + expect(data.recordsCount).toBe(0); + }); + + it("should return same config even if database count changes", async () => { + // Set a different count in the database + // 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`); + 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 built on startup with count = 0, so changing the DB doesn't affect it + expect(data.recordsCount).toBe(0); + }); + }); + describe("CORS headers for /v1/* routes", () => { it("should return CORS headers for /v1/* routes", async () => { const validLabel = "test-label"; @@ -188,6 +226,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/commands/server-command.ts b/apps/ensrainbow/src/commands/server-command.ts index 05c0f42f7..85a8bf3a8 100644 --- a/apps/ensrainbow/src/commands/server-command.ts +++ b/apps/ensrainbow/src/commands/server-command.ts @@ -1,30 +1,38 @@ +import type { ServeCommandConfig } from "@/config"; + 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 interface ServerCommandOptions { - dataDir: string; - port: number; -} - -/** - * Creates and configures the ENS Rainbow server application - */ -export async function createServer(db: ENSRainbowDB) { - return createApi(db); -} +export type ServerCommandOptions = ServeCommandConfig; 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)); + logger.info(`ENS Rainbow server starting on port ${options.port}...`); const db = await ENSRainbowDB.open(options.dataDir); try { - const app = await createServer(db); + const ensRainbowServer = await ENSRainbowServer.init(db); + const dbConfig = await buildDbConfig(ensRainbowServer); + const publicConfig = buildENSRainbowPublicConfig(dbConfig); + + // console.log is used so it can't be skipped by the logger + console.log("ENSRainbow public config:"); + console.log(prettyPrintJson(publicConfig)); + + const app = createApi(ensRainbowServer, publicConfig); - const server = serve({ + const httpServer = serve({ fetch: app.fetch, port: options.port, }); @@ -33,7 +41,7 @@ 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/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 new file mode 100644 index 000000000..5ccc69b03 --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.test.ts @@ -0,0 +1,354 @@ +import packageJson from "@/../package.json" with { type: "json" }; + +import { isAbsolute, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { DB_SCHEMA_VERSION } from "@/lib/database"; + +import { buildEnvConfigFromEnvironment, buildServeCommandConfig } from "./config.schema"; +import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "./defaults"; +import type { ENSRainbowEnvironment } from "./environment"; +import { buildENSRainbowPublicConfig } from "./public"; +import type { DbConfig, ENSRainbowEnvConfig } from "./types"; + +describe("buildEnvConfigFromEnvironment", () => { + describe("Success cases", () => { + it("returns a valid config with all defaults when environment is empty", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + + it("defaults DB_SCHEMA_VERSION to code version when not set", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(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(() => buildEnvConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is a float", () => { + const env: ENSRainbowEnvironment = { + PORT: "3000.5", + }; + + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is less than 1", () => { + const env: ENSRainbowEnvironment = { + PORT: "0", + }; + + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is negative", () => { + const env: ENSRainbowEnvironment = { + PORT: "-100", + }; + + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when PORT is greater than 65535", () => { + const env: ENSRainbowEnvironment = { + PORT: "65536", + }; + + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DATA_DIR is empty string", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: "", + }; + + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DATA_DIR is only whitespace", () => { + const env: ENSRainbowEnvironment = { + DATA_DIR: " ", + }; + + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DB_SCHEMA_VERSION is not a number", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "not-a-number", + }; + + expect(() => buildEnvConfigFromEnvironment(env)).toThrow(); + }); + + it("fails when DB_SCHEMA_VERSION is a float", () => { + const env: ENSRainbowEnvironment = { + DB_SCHEMA_VERSION: "3.5", + }; + + expect(() => buildEnvConfigFromEnvironment(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(() => buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(env); + + expect(config.dbSchemaVersion).toBe(DB_SCHEMA_VERSION); + }); + + it("passes when DB_SCHEMA_VERSION defaults to code version", () => { + const env: ENSRainbowEnvironment = {}; + + const config = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(env); + + expect(config.port).toBe(1); + }); + + it("handles PORT at maximum valid value (65535)", () => { + const env: ENSRainbowEnvironment = { + PORT: "65535", + }; + + const config = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(env); + + expect(config.dataDir).toBe(dataDir); + }); + + it("handles DATA_DIR with .. (parent directory)", () => { + const relativeDataDir = "../data"; + const env: ENSRainbowEnvironment = { + DATA_DIR: relativeDataDir, + }; + + const config = buildEnvConfigFromEnvironment(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 = buildEnvConfigFromEnvironment(env); + + expect(isAbsolute(config.dataDir)).toBe(true); + // ~ is treated as a directory name, not home expansion + expect(config.dataDir).toBe(resolve(process.cwd(), tildeDataDir)); + }); + }); +}); + +describe("buildServeCommandConfig", () => { + 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 = buildServeCommandConfig({ + 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 = buildServeCommandConfig({ + 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 = buildServeCommandConfig({ + port: baseEnvConfig.port, + "data-dir": " /trimmed/path ", + }); + + expect(result.dataDir).toBe("/trimmed/path"); + }); + + it("throws on empty data-dir", () => { + expect(() => buildServeCommandConfig({ port: 4000, "data-dir": "" })).toThrow( + /Invalid data-dir/, + ); + }); + + it("throws on whitespace-only data-dir", () => { + expect(() => buildServeCommandConfig({ port: 4000, "data-dir": " " })).toThrow( + /Invalid data-dir/, + ); + }); +}); + +describe("buildENSRainbowPublicConfig", () => { + const dbConfig: DbConfig = { + labelSet: { + labelSetId: "subgraph", + highestLabelSetVersion: 0, + }, + recordsCount: 1000, + }; + + it("returns a valid ENSRainbow public config with correct structure", () => { + const result = buildENSRainbowPublicConfig(dbConfig); + + expect(result).toStrictEqual({ + version: packageJson.version, + labelSet: dbConfig.labelSet, + recordsCount: dbConfig.recordsCount, + }); + }); +}); diff --git a/apps/ensrainbow/src/config/config.schema.ts b/apps/ensrainbow/src/config/config.schema.ts new file mode 100644 index 000000000..c74055f51 --- /dev/null +++ b/apps/ensrainbow/src/config/config.schema.ts @@ -0,0 +1,85 @@ +import { isAbsolute, resolve } from "node:path"; + +import { prettifyError, ZodError, z } from "zod/v4"; + +import { OptionalPortNumberSchema } from "@ensnode/ensnode-sdk/internal"; + +import { ENSRAINBOW_DEFAULT_PORT, getDefaultDataDir } from "@/config/defaults"; +import type { ENSRainbowEnvironment } from "@/config/environment"; +import type { ENSRainbowEnvConfig, ServeCommandConfig } from "@/config/types"; +import { invariant_dbSchemaVersionMatch } from "@/config/validations"; +import { DB_SCHEMA_VERSION } from "@/lib/database"; + +export type { ServeCommandConfig, ENSRainbowEnvConfig }; + +export const AbsolutePathSchemaBase = z + .string() + .trim() + .min(1, { + error: "Path must be a non-empty string.", + }) + .transform((path: string) => { + if (isAbsolute(path)) { + return path; + } + return resolve(process.cwd(), path); + }); + +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." }); + +const DbSchemaVersionSchema = DbSchemaVersionSchemaBase.default(DB_SCHEMA_VERSION); + +const ENSRainbowConfigBaseSchema = z.object({ + port: OptionalPortNumberSchema.default(ENSRAINBOW_DEFAULT_PORT), + dataDir: AbsolutePathSchemaBase.default(() => getDefaultDataDir()), + dbSchemaVersion: DbSchemaVersionSchema, +}); + +const ENSRainbowConfigSchema = ENSRainbowConfigBaseSchema.check(invariant_dbSchemaVersionMatch); + +export function buildEnvConfigFromEnvironment(env: ENSRainbowEnvironment): ENSRainbowEnvConfig { + try { + const envToConfigSchema = z + .object({ + PORT: z.string().optional(), + DATA_DIR: z.string().optional(), + DB_SCHEMA_VERSION: z.string().optional(), + }) + .transform((env) => { + return { + port: env.PORT, + dataDir: env.DATA_DIR, + dbSchemaVersion: env.DB_SCHEMA_VERSION, + }; + }); + + 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; + } +} + +export interface ServeCommandCliArgs { + port: number; + "data-dir": string; +} + +export function buildServeCommandConfig(args: ServeCommandCliArgs): ServeCommandConfig { + try { + const dataDir = AbsolutePathSchemaBase.parse(args["data-dir"]); + return { port: args.port, dataDir }; + } catch (error) { + if (error instanceof ZodError) { + throw new Error(`Invalid data-dir: \n${prettifyError(error)}\n`); + } + throw error; + } +} 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..1cdf781af --- /dev/null +++ b/apps/ensrainbow/src/config/environment.ts @@ -0,0 +1,10 @@ +import type { LogLevelEnvironment, PortEnvironment } from "@ensnode/ensnode-sdk/internal"; + +/** + * Raw, unvalidated environment variables for ENSRainbow. + */ +export type ENSRainbowEnvironment = PortEnvironment & + LogLevelEnvironment & { + DATA_DIR?: string; + DB_SCHEMA_VERSION?: string; + }; diff --git a/apps/ensrainbow/src/config/index.ts b/apps/ensrainbow/src/config/index.ts new file mode 100644 index 000000000..f0640fd16 --- /dev/null +++ b/apps/ensrainbow/src/config/index.ts @@ -0,0 +1,13 @@ +import { buildEnvConfigFromEnvironment } from "./config.schema"; + +export type { ENSRainbowEnvConfig, ServeCommandCliArgs, ServeCommandConfig } from "./config.schema"; +export { + buildEnvConfigFromEnvironment, + buildServeCommandConfig, +} from "./config.schema"; +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 new file mode 100644 index 000000000..89b28c834 --- /dev/null +++ b/apps/ensrainbow/src/config/public.ts @@ -0,0 +1,13 @@ +import packageJson from "@/../package.json" with { type: "json" }; + +import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; + +import type { DbConfig } from "./types"; + +export function buildENSRainbowPublicConfig(dbConfig: DbConfig): EnsRainbow.ENSRainbowPublicConfig { + return { + version: packageJson.version, + labelSet: dbConfig.labelSet, + recordsCount: dbConfig.recordsCount, + }; +} diff --git a/apps/ensrainbow/src/config/types.ts b/apps/ensrainbow/src/config/types.ts new file mode 100644 index 000000000..eb5d82254 --- /dev/null +++ b/apps/ensrainbow/src/config/types.ts @@ -0,0 +1,43 @@ +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"; + +/** + * Absolute filesystem path. + * Inferred from {@link AbsolutePathSchemaBase} - see that schema for invariants. + */ +export type AbsolutePath = z.infer; + +/** + * Database schema version number. + * Inferred from {@link DbSchemaVersionSchemaBase} - see that schema for invariants. + */ +export type DbSchemaVersion = z.infer; + +/** + * Configuration derived from environment variables for ENSRainbow. + */ +export interface ENSRainbowEnvConfig { + port: PortNumber; + dataDir: AbsolutePath; + dbSchemaVersion: DbSchemaVersion; +} + +/** + * Validated configuration for the serve command. + */ +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/config/validations.ts b/apps/ensrainbow/src/config/validations.ts new file mode 100644 index 000000000..601181e12 --- /dev/null +++ b/apps/ensrainbow/src/config/validations.ts @@ -0,0 +1,23 @@ +import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; + +import { DB_SCHEMA_VERSION } from "@/lib/database"; + +export function invariant_dbSchemaVersionMatch( + ctx: ZodCheckFnInput<{ + port: number; + dataDir: string; + dbSchemaVersion: number; + labelSet?: { labelSetId: string; labelSetVersion: number }; + }>, +): void { + const { value: config } = ctx; + + if (config.dbSchemaVersion !== DB_SCHEMA_VERSION) { + ctx.issues.push({ + code: "custom", + path: ["dbSchemaVersion"], + input: config.dbSchemaVersion, + 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/api.ts b/apps/ensrainbow/src/lib/api.ts index 767b180aa..26dec82cf 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,4 +1,4 @@ -import packageJson from "@/../package.json"; +import packageJson from "@/../package.json" with { type: "json" }; import type { Context as HonoContext } from "hono"; import { Hono } from "hono"; @@ -14,17 +14,25 @@ import { } from "@ensnode/ensnode-sdk"; import { type EnsRainbow, ErrorCode, StatusCode } from "@ensnode/ensrainbow-sdk"; -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); + + const countResponse: EnsRainbow.CountSuccess = { + status: StatusCode.Success, + count: publicConfig.recordsCount, + timestamp: new Date().toISOString(), + }; // Enable CORS for all versioned API routes api.use( @@ -78,31 +86,27 @@ 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/labels/count", (c: HonoContext) => { + return c.json(countResponse); + }); + + api.get("/v1/config", (c: HonoContext) => { + 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 = { status: StatusCode.Success, versionInfo: { @@ -111,7 +115,6 @@ export async function createApi(db: ENSRainbowDB): Promise { labelSet: server.getServerLabelSet(), }, }; - logger.debug(result, `Version result`); return c.json(result); }); diff --git a/apps/ensrainbow/src/lib/database.ts b/apps/ensrainbow/src/lib/database.ts index 027d7ff53..77c758332 100644 --- a/apps/ensrainbow/src/lib/database.ts +++ b/apps/ensrainbow/src/lib/database.ts @@ -104,6 +104,15 @@ export function isRainbowRecordKey(key: ByteArray): boolean { */ type ENSRainbowLevelDB = ClassicLevel; +/** 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 { diff --git a/apps/ensrainbow/src/lib/env.ts b/apps/ensrainbow/src/lib/env.ts deleted file mode 100644 index 048f47ae4..000000000 --- a/apps/ensrainbow/src/lib/env.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { join } from "node:path"; - -import { parseNonNegativeInteger } from "@ensnode/ensnode-sdk"; - -import { logger } from "@/utils/logger"; - -export const getDefaultDataSubDir = () => join(process.cwd(), "data"); - -export const DEFAULT_PORT = 3223; -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); - } -} diff --git a/apps/ensrainbow/src/lib/server.ts b/apps/ensrainbow/src/lib/server.ts index e801da492..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 { ENSRainbowDB } from "@/lib/database"; +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; @@ -132,7 +151,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 +165,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, 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 {} } } diff --git a/packages/ensnode-sdk/src/shared/config/types.ts b/packages/ensnode-sdk/src/shared/config/types.ts index 3fe843c11..1248870a7 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, + PortNumberSchema, TheGraphApiKeySchema, } from "./zod-schemas"; @@ -48,3 +49,5 @@ export type DatabaseUrl = UrlString; export type DatabaseSchemaName = z.infer; export type EnsIndexerUrl = z.infer; export type TheGraphApiKey = 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 aa99edb64..eef547608 100644 --- a/packages/ensnode-sdk/src/shared/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/config/zod-schemas.ts @@ -58,13 +58,12 @@ 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. - */ -export const PortSchema = z.coerce +export const PortNumberSchema = z.coerce .number({ error: "PORT must be a number." }) - .min(1, { error: "PORT must be greater than 1." }) - .max(65535, { error: "PORT must be less than 65535" }) - .optional(); + .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 OptionalPortNumberSchema = PortNumberSchema.optional(); export const TheGraphApiKeySchema = z.string().optional(); diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 87182d976..c216d27a2 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -16,6 +16,11 @@ export namespace EnsRainbow { export interface ApiClient { count(): Promise; + /** + * Get the public configuration of the ENSRainbow service + */ + config(): Promise; + /** * Heal a labelhash to its original label * @param labelHash The labelhash to heal @@ -24,6 +29,11 @@ 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. + */ version(): Promise; getOptions(): Readonly; @@ -118,6 +128,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 +150,38 @@ 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. + */ + labelSet: EnsRainbowServerLabelSet; + + /** + * The total count of records managed by the ENSRainbow service. + */ + recordsCount: number; + } } export interface EnsRainbowApiClientOptions { @@ -351,9 +390,26 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { return response.json() as Promise; } + /** + * Get 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 916699704..fd22f7119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -535,6 +535,9 @@ importers: yargs: specifier: ^17.7.2 version: 17.7.2 + zod: + specifier: 'catalog:' + version: 4.3.6 devDependencies: '@ensnode/shared-configs': specifier: workspace:*