-
Notifications
You must be signed in to change notification settings - Fork 29
Feat/indexer db foundation #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
27ecd5a
8cecb99
e8315aa
a007668
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| } | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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"), | ||||||||||||||||||||||||||||||
|
Comment on lines
+34
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Require a PostgreSQL DSN, not just any URL. Lines 34-38 accept any absolute URL, so Suggested fix INDEXER_DATABASE_URL: z
.string({ required_error: "is required" })
.trim()
.min(1, "is required")
- .url("must be a valid connection URL"),
+ .url("must be a valid connection URL")
+ .refine((value) => {
+ const protocol = new URL(value).protocol;
+ return protocol === "postgres:" || protocol === "postgresql:";
+ }, "must be a PostgreSQL connection URL"),📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| 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<string, string | undefined> = {}; | ||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Reject
0in the numeric env validator.Line 29 says “positive integer”, but
/^\d+$/plusNumber.isSafeIntegerstill accepts0. That letsINDEXER_PORT=0bind an ephemeral port andPOLL_INTERVAL_MS=0turn the poller into a tight loop instead of failing fast.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents