Skip to content
Open
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
142 changes: 121 additions & 21 deletions bun.lock

Large diffs are not rendered by default.

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
33 changes: 30 additions & 3 deletions indexer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,35 @@ just lint
Indexer environment variables are documented in the repository root
`.env.example` under "Soroban indexer".

The `common` package exposes a validated config loader (`loadConfig`) 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 [Drizzle ORM](https://orm.drizzle.team) and
Drizzle Kit for migrations. The schema entrypoint and connection factory live in
`common/src/db/`, and migration SQL is generated into `common/migrations/`.

```bash
# Generate migration SQL from common/src/db/schema.ts
bun run indexer:db:generate

# Apply pending migrations to INDEXER_DATABASE_URL
bun run indexer:db:migrate
```

Both commands read `INDEXER_DATABASE_URL` from the environment (see
`.env.example`). Domain tables (cursors, indexed events, streams, distributions)
are added by later scoped issues; until then `db:generate` reports no changes.

From within `common`, the same steps are available as `bun run db:generate` and
`bun run db:migrate`.

## 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 database tooling foundation — validated config, a
PostgreSQL connection factory, a health check, and Drizzle migration tooling.
The poller, domain tables, cursor persistence, event handlers, and GraphQL API
are 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
23 changes: 23 additions & 0 deletions indexer/common/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { defineConfig } from "drizzle-kit";

/**
* Drizzle Kit configuration for the indexer.
*
* Migration SQL is generated into ./migrations from the schema entrypoint in
* ./src/db/schema.ts. Domain tables are added by later scoped issues; this
* config only establishes the tooling and on-disk layout.
*
* The database URL is read directly from the environment here (rather than the
* validated config loader) because Drizzle Kit runs as a standalone CLI outside
* the application runtime.
*/
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./migrations",
dbCredentials: {
url: process.env.INDEXER_DATABASE_URL ?? "",
},
Comment on lines +18 to +20

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 | 🟡 Minor | ⚡ Quick win

Fail fast when INDEXER_DATABASE_URL is missing.

Falling back to "" here bypasses the same required-URL contract enforced by loadConfig and turns a simple misconfiguration into a downstream Drizzle CLI failure. Throwing locally gives db:generate/db:migrate a much clearer error path.

Suggested fix
+const databaseUrl = process.env.INDEXER_DATABASE_URL;
+if (!databaseUrl) {
+  throw new Error("INDEXER_DATABASE_URL is required for Drizzle Kit commands");
+}
+
 export default defineConfig({
   dialect: "postgresql",
   schema: "./src/db/schema.ts",
   out: "./migrations",
   dbCredentials: {
-    url: process.env.INDEXER_DATABASE_URL ?? "",
+    url: databaseUrl,
   },
   strict: true,
   verbose: true,
 });
🤖 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/drizzle.config.ts` around lines 18 - 20, The Drizzle config is
silently defaulting `dbCredentials.url` to an empty string when
`INDEXER_DATABASE_URL` is missing, which should instead fail immediately. Update
the config in `drizzle.config.ts` to validate `process.env.INDEXER_DATABASE_URL`
up front and throw a clear local error if it is absent, matching the
required-URL behavior used by `loadConfig`; keep the fix centered on the
`dbCredentials` setup so `db:generate` and `db:migrate` fail fast with a clear
message.

strict: true,
verbose: true,
});
5 changes: 5 additions & 0 deletions indexer/common/migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Drizzle migrations

Generated SQL migrations and the `meta/` journal live here.
Run `bun run db:generate` (from this package) to create migrations from
`src/db/schema.ts`, and `bun run db:migrate` to apply them.
1 change: 1 addition & 0 deletions indexer/common/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":"7","dialect":"postgresql","entries":[]}
10 changes: 10 additions & 0 deletions indexer/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"codegen": "bun --print \"'common: no codegen configured yet'\"",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"dev": "bun --watch src/index.ts",
"lint": "biome check .",
"test": "vitest run src",
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"drizzle-orm": "0.45.2",
"postgres": "3.4.9",
"zod": "3.25.67"
},
"devDependencies": {
"drizzle-kit": "0.31.10"
}
}
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 +24 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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

positiveIntFromString allows 0, which is not a positive integer.

The regex /^\d+$/ and subsequent parseInt accept "0". Zero is not a positive integer, and it is semantically invalid for PORT (ephemeral bind) and POLL_INTERVAL_MS (busy-loop). Either rename this to nonNegativeIntFromString and add a separate strictly-positive schema for PORT/POLL_INTERVAL_MS, or add .refine((v) => v > 0, "must be a positive integer") after the safe-integer check.

🤖 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 24 - 31, The positive integer
parser in positiveIntFromString currently accepts 0, so tighten the schema to
reject zero. Update the zod chain in env.ts by either adding a value check after
the safe-integer refine or splitting this into a non-negative parser plus a
separate strictly-positive schema, and then use the strictly-positive version
for PORT and POLL_INTERVAL_MS.


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<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;
}
1 change: 1 addition & 0 deletions indexer/common/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ConfigValidationError, loadConfig, type IndexerConfig } from "./env.js";
39 changes: 39 additions & 0 deletions indexer/common/src/db/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Sql } from "postgres";
import { describe, expect, test, vi } from "vitest";

import { createDbClient } from "./client.js";

/** Minimal postgres.js stand-in sufficient for Drizzle construction + close. */
function mockSql(): Sql {
const sql = vi.fn(() => Promise.resolve([]));
Object.assign(sql, {
end: vi.fn(() => Promise.resolve()),
options: { parsers: {}, serializers: {} },
});
return sql as unknown as Sql;
}

describe("createDbClient", () => {
test("builds a client from the configured database URL", () => {
const sql = mockSql();
const createSql = vi.fn(() => sql);

const client = createDbClient({ databaseUrl: "postgres://localhost:5432/test" }, { createSql });

expect(createSql).toHaveBeenCalledWith("postgres://localhost:5432/test");
expect(client.sql).toBe(sql);
expect(client.db).toBeDefined();
});

test("close() ends the underlying connection pool", async () => {
const sql = mockSql();

const client = createDbClient(
{ databaseUrl: "postgres://localhost:5432/test" },
{ createSql: () => sql },
);
await client.close();

expect((sql as unknown as { end: ReturnType<typeof vi.fn> }).end).toHaveBeenCalledTimes(1);
});
});
52 changes: 52 additions & 0 deletions indexer/common/src/db/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
import postgres, { type Sql } from "postgres";

import type { IndexerConfig } from "../config/index.js";

/**
* A ready-to-use database entry point shared across the indexer.
*
* `db` is the Drizzle query interface; `sql` is the underlying postgres.js
* client for raw queries and lifecycle control (e.g. health checks, shutdown).
*/
export interface DbClient {
readonly db: PostgresJsDatabase;
readonly sql: Sql;
/** Close the connection pool. Call during graceful shutdown. */
close(): Promise<void>;
}

export interface CreateDbClientOptions {
/** Maximum number of pooled connections. Defaults to 10. */
readonly maxConnections?: number;
/**
* Factory for the underlying postgres.js client. Defaults to the real
* `postgres` driver; override in tests to inject a mocked connection.
*/
readonly createSql?: (url: string) => Sql;
}

/**
* Create a PostgreSQL connection factory from validated configuration.
*
* The connection pool is lazy: no socket is opened until the first query, so
* constructing a client is cheap and side-effect free.
*/
export function createDbClient(
config: Pick<IndexerConfig, "databaseUrl">,
options: CreateDbClientOptions = {},
): DbClient {
const createSql =
options.createSql ?? ((url: string) => postgres(url, { max: options.maxConnections ?? 10 }));

const sql = createSql(config.databaseUrl);
const db = drizzle(sql);

return {
db,
sql,
async close() {
await sql.end();
},
};
}
Loading