Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}'] },
Expand Down
23 changes: 20 additions & 3 deletions indexer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion indexer/biome.jsonc
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
70 changes: 70 additions & 0 deletions indexer/common/src/config/env.test.ts
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");
}
});
});
96 changes: 96 additions & 0 deletions indexer/common/src/config/env.ts
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");
Comment on lines +25 to +31

Copy link
Copy Markdown
Contributor

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 0 in the numeric env validator.

Line 29 says “positive integer”, but /^\d+$/ plus Number.isSafeInteger still accepts 0. That lets INDEXER_PORT=0 bind an ephemeral port and POLL_INTERVAL_MS=0 turn the poller into a tight loop instead of failing fast.

Suggested fix
 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");
+  .refine((value) => Number.isSafeInteger(value), "must be a safe integer")
+  .refine((value) => value > 0, "must be a positive integer");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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")
.refine((value) => value > 0, "must be a positive integer");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indexer/common/src/config/env.ts` around lines 25 - 31, The positive integer
env validator in positiveIntFromString still accepts 0, so update the refinement
in env.ts to require values greater than 0 while keeping the existing
safe-integer check. Use the existing positiveIntFromString symbol to locate the
parser and make sure invalid zero-valued inputs like INDEXER_PORT and
POLL_INTERVAL_MS fail validation instead of being parsed successfully.


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

Copy link
Copy Markdown
Contributor

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

Require a PostgreSQL DSN, not just any URL.

Lines 34-38 accept any absolute URL, so https://example.com passes validation even though downstream code expects a Postgres connection string. That pushes a config error into the connection/startup path instead of catching it here.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
INDEXER_DATABASE_URL: z
.string({ required_error: "is required" })
.trim()
.min(1, "is required")
.url("must be a valid connection URL"),
INDEXER_DATABASE_URL: z
.string({ required_error: "is required" })
.trim()
.min(1, "is required")
.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"),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indexer/common/src/config/env.ts` around lines 34 - 38, The
INDEXER_DATABASE_URL validation in env config is too permissive because it
accepts any URL instead of a PostgreSQL DSN. Update the schema in the env.ts
config validator to enforce a Postgres connection string (for example by
requiring a postgres/postgresql scheme or using a stricter custom refinement) so
invalid values are rejected before startup. Keep the existing required/non-empty
checks, but make the final validation specific to the database URL expected by
the downstream connection code.

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;
}
8 changes: 8 additions & 0 deletions indexer/common/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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"),
Expand Down
8 changes: 7 additions & 1 deletion indexer/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down