-
Notifications
You must be signed in to change notification settings - Fork 20
Add PostgreSQL database foundation with config, connection, and health check #46
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
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 ?? "", | ||
| }, | ||
| strict: true, | ||
| verbose: true, | ||
| }); | ||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"version":"7","dialect":"postgresql","entries":[]} |
| 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"); | ||
|
Comment on lines
+24
to
+31
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. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
The regex 🤖 Prompt for AI Agents |
||
|
|
||
| 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { ConfigValidationError, loadConfig, type IndexerConfig } from "./env.js"; |
| 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); | ||
| }); | ||
| }); |
| 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(); | ||
| }, | ||
| }; | ||
| } |
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 | 🟡 Minor | ⚡ Quick win
Fail fast when
INDEXER_DATABASE_URLis missing.Falling back to
""here bypasses the same required-URL contract enforced byloadConfigand turns a simple misconfiguration into a downstream Drizzle CLI failure. Throwing locally givesdb:generate/db:migratea much clearer error path.Suggested fix
🤖 Prompt for AI Agents