diff --git a/eslint.config.mjs b/eslint.config.mjs index 755fe95..288bf19 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,8 @@ export default [ 'dist/**', 'coverage/**', 'src/migrations/**', + // The indexer is a separate workspace linted by Biome (bun run indexer:lint). + 'indexer/**', ], }, { files: ['**/*.{js,mjs,cjs,ts}'] }, diff --git a/indexer/README.md b/indexer/README.md index d6a5415..b6ac419 100644 --- a/indexer/README.md +++ b/indexer/README.md @@ -41,8 +41,25 @@ just lint Indexer environment variables are documented in the repository root `.env.example` under "Soroban indexer". +The `common` package exposes a validated config loader (`loadIndexerConfig`) +that reads these variables, parses numeric values (`INDEXER_PORT`, +`POLL_INTERVAL_MS`, `START_LEDGER`), and fails fast with a clear error when +required values are missing or malformed. + +## Database & Migrations + +Persistence uses PostgreSQL with [TypeORM](https://typeorm.io). Entities live +under each workspace's `src/db/entity/` (e.g. `common/src/db/entity/`, +`streams/src/db/entity/`), with the shared `EventRepository` in +`common/src/db/repository.ts`. Migrations are kept per workspace under +`src/db/migrations/`. + +TypeORM relies on decorator metadata, which is enabled in `tsconfig.base.json` +via `experimentalDecorators` and `emitDecoratorMetadata`. + ## Status -This workspace is currently a scaffold. The poller, database repositories, -cursor persistence, event handlers, and GraphQL API are planned but not yet -implemented. +This workspace provides the indexer foundation — validated config, a Soroban RPC +client, a TypeORM persistence layer (entities and repository), a poller, and an +event-handler registry, plus the streams and distributions handlers. The GraphQL +API is planned but not yet implemented. diff --git a/indexer/biome.jsonc b/indexer/biome.jsonc index 04e3336..e55c666 100644 --- a/indexer/biome.jsonc +++ b/indexer/biome.jsonc @@ -1,7 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "files": { - "ignore": ["**/.turbo", "**/coverage", "**/dist", "**/node_modules"] + "ignore": ["**/.turbo", "**/coverage", "**/dist", "**/migrations", "**/node_modules"] }, "formatter": { "enabled": true, diff --git a/indexer/common/src/config/env.test.ts b/indexer/common/src/config/env.test.ts new file mode 100644 index 0000000..6ed0c56 --- /dev/null +++ b/indexer/common/src/config/env.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; + +import { ConfigValidationError, loadConfig } from "./env.js"; + +const validEnv = { + INDEXER_DATABASE_URL: "postgres://postgres:postgres@localhost:5432/fundable_indexer", + INDEXER_PORT: "4000", + POLL_INTERVAL_MS: "5000", + START_LEDGER: "1000", + INDEXER_LOG_LEVEL: "debug", +} satisfies NodeJS.ProcessEnv; + +describe("loadConfig", () => { + test("returns typed values for a valid environment", () => { + const config = loadConfig(validEnv); + + expect(config).toEqual({ + databaseUrl: "postgres://postgres:postgres@localhost:5432/fundable_indexer", + port: 4000, + pollIntervalMs: 5000, + startLedger: 1000, + logLevel: "debug", + }); + }); + + test("defaults the log level and treats blank START_LEDGER as unset", () => { + const config = loadConfig({ + INDEXER_DATABASE_URL: validEnv.INDEXER_DATABASE_URL, + INDEXER_PORT: "4000", + POLL_INTERVAL_MS: "5000", + START_LEDGER: "", + }); + + expect(config.logLevel).toBe("info"); + expect(config.startLedger).toBeUndefined(); + expect("startLedger" in config).toBe(false); + }); + + test("fails with a clear error when a required value is missing", () => { + const { INDEXER_DATABASE_URL: _omitted, ...withoutUrl } = validEnv; + + expect(() => loadConfig(withoutUrl)).toThrow(ConfigValidationError); + expect(() => loadConfig(withoutUrl)).toThrow(/INDEXER_DATABASE_URL is required/); + }); + + test("fails with a clear error for a non-numeric port", () => { + expect(() => loadConfig({ ...validEnv, INDEXER_PORT: "not-a-number" })).toThrow( + /INDEXER_PORT must be a positive integer/, + ); + }); + + test("fails with a clear error for an invalid database URL", () => { + expect(() => loadConfig({ ...validEnv, INDEXER_DATABASE_URL: "not-a-url" })).toThrow( + /INDEXER_DATABASE_URL must be a valid connection URL/, + ); + }); + + test("aggregates multiple problems into one error", () => { + try { + loadConfig({ INDEXER_PORT: "abc", POLL_INTERVAL_MS: "xyz" }); + throw new Error("expected loadConfig to throw"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigValidationError); + const issues = (error as ConfigValidationError).issues; + expect(issues).toContain("INDEXER_DATABASE_URL is required"); + expect(issues).toContain("INDEXER_PORT must be a positive integer"); + expect(issues).toContain("POLL_INTERVAL_MS must be a positive integer"); + } + }); +}); diff --git a/indexer/common/src/config/env.ts b/indexer/common/src/config/env.ts new file mode 100644 index 0000000..d53210a --- /dev/null +++ b/indexer/common/src/config/env.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +/** + * Runtime configuration for the indexer. + * + * Values originate from environment variables (see `.env.example` under + * "Soroban indexer") and are validated once at startup. Downstream code — + * database, poller, and the future API — should depend on this typed shape + * rather than reading `process.env` directly. + */ +export interface IndexerConfig { + /** Postgres connection string, e.g. `postgres://user:pass@host:5432/db`. */ + readonly databaseUrl: string; + /** HTTP port the indexer/API listens on. */ + readonly port: number; + /** Delay between ledger polls, in milliseconds. */ + readonly pollIntervalMs: number; + /** Optional ledger to start indexing from; omit to resume from the cursor. */ + readonly startLedger?: number; + /** Logging verbosity. */ + readonly logLevel: "error" | "warn" | "info" | "debug"; +} + +/** A positive integer parsed from an environment string. */ +const positiveIntFromString = z + .string() + .trim() + .min(1, "must not be empty") + .regex(/^\d+$/, "must be a positive integer") + .transform((value) => Number.parseInt(value, 10)) + .refine((value) => Number.isSafeInteger(value), "must be a safe integer"); + +const configSchema = z.object({ + INDEXER_DATABASE_URL: z + .string({ required_error: "is required" }) + .trim() + .min(1, "is required") + .url("must be a valid connection URL"), + INDEXER_PORT: positiveIntFromString, + POLL_INTERVAL_MS: positiveIntFromString, + START_LEDGER: positiveIntFromString.optional(), + INDEXER_LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).optional().default("info"), +}); + +/** + * Raised when one or more environment variables are missing or invalid. + * The message lists every problem so misconfiguration can be fixed in one pass. + */ +export class ConfigValidationError extends Error { + constructor(public readonly issues: string[]) { + super(`Invalid indexer configuration:\n${issues.map((i) => ` - ${i}`).join("\n")}`); + this.name = "ConfigValidationError"; + } +} + +/** + * Validate environment variables and return a typed {@link IndexerConfig}. + * + * Treats empty strings as absent so that a blank `START_LEDGER=` in an env file + * is interpreted as "unset" rather than an invalid number. + * + * @throws {ConfigValidationError} if required values are missing or malformed. + */ +export function loadConfig(env: NodeJS.ProcessEnv = process.env): IndexerConfig { + const normalized: Record = {}; + for (const key of [ + "INDEXER_DATABASE_URL", + "INDEXER_PORT", + "POLL_INTERVAL_MS", + "START_LEDGER", + "INDEXER_LOG_LEVEL", + ]) { + const value = env[key]; + normalized[key] = value === undefined || value.trim() === "" ? undefined : value; + } + + const result = configSchema.safeParse(normalized); + if (!result.success) { + const issues = result.error.issues.map((issue) => { + const key = issue.path.join(".") || "config"; + return `${key} ${issue.message}`; + }); + throw new ConfigValidationError(issues); + } + + const parsed = result.data; + const config: IndexerConfig = { + databaseUrl: parsed.INDEXER_DATABASE_URL, + port: parsed.INDEXER_PORT, + pollIntervalMs: parsed.POLL_INTERVAL_MS, + logLevel: parsed.INDEXER_LOG_LEVEL, + ...(parsed.START_LEDGER !== undefined ? { startLedger: parsed.START_LEDGER } : {}), + }; + + return config; +} diff --git a/indexer/common/src/config/index.ts b/indexer/common/src/config/index.ts index 54e306d..a1d1040 100644 --- a/indexer/common/src/config/index.ts +++ b/indexer/common/src/config/index.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +// Runtime/database configuration loader (validated, fail-fast). Exposed as +// `loadIndexerConfig` to avoid colliding with the RPC `loadConfig` below. +export { + ConfigValidationError, + loadConfig as loadIndexerConfig, + type IndexerConfig, +} from "./env.js"; + export const ConfigSchema = z.object({ RPC_URL: z.string().url().default("https://soroban-testnet.stellar.org"), NETWORK_PASSPHRASE: z.string().default("Test SDF Network ; September 2015"), diff --git a/indexer/common/src/index.ts b/indexer/common/src/index.ts index ad2f597..da3ade8 100644 --- a/indexer/common/src/index.ts +++ b/indexer/common/src/index.ts @@ -3,7 +3,13 @@ export const commonPackage = { role: "shared-infrastructure", } as const; -export { config, loadConfig } from "./config/index.js"; +export { + ConfigValidationError, + config, + loadConfig, + loadIndexerConfig, + type IndexerConfig, +} from "./config/index.js"; export { createSorobanClient, sorobanClient } from "./rpc/client.js"; export { IndexedEvent } from "./db/entity/IndexedEvent.js"; export { EventRepository } from "./db/repository.js";