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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,19 @@ NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# Public site URL, used to build absolute OG/social image URLs.
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# Postgres pool used by the indexer-backed views (frontend/src/lib/indexed-db.ts).
# Compose points this at its own "postgres" service; set DATABASE_URL in your
# .env to use a managed instance instead, e.g. postgresql://user:pass@host:5432/prism
# DATABASE_URL=postgresql://prism:prism@localhost:5432/prism

# Maximum number of pooled connections. Default: 10.
DATABASE_POOL_MAX=10

# Close idle connections after this many milliseconds. Default: 10000 (10s).
DATABASE_IDLE_TIMEOUT_MS=10000

# TLS for DATABASE_URL. "true" verifies the server certificate, "no-verify"
# encrypts without verifying it (for providers whose CA is not trusted by Node),
# and "false" disables TLS. Default: false.
DATABASE_SSL=false
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ services:
NEXT_PUBLIC_HORIZON_URL: ${NEXT_PUBLIC_HORIZON_URL:-https://horizon-testnet.stellar.org}
NEXT_PUBLIC_SOROBAN_RPC_URL: ${NEXT_PUBLIC_SOROBAN_RPC_URL:-https://soroban-testnet.stellar.org}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
DATABASE_URL: postgresql://prism:${POSTGRES_PASSWORD:-prism}@postgres:5432/prism
DATABASE_URL: ${DATABASE_URL:-postgresql://prism:${POSTGRES_PASSWORD:-prism}@postgres:5432/prism}
DATABASE_POOL_MAX: ${DATABASE_POOL_MAX:-10}
DATABASE_IDLE_TIMEOUT_MS: ${DATABASE_IDLE_TIMEOUT_MS:-10000}
DATABASE_SSL: ${DATABASE_SSL:-false}
ports:
- "3000:3000"
healthcheck:
Expand Down
147 changes: 42 additions & 105 deletions frontend/src/lib/indexed-db.test.ts
Original file line number Diff line number Diff line change
@@ -1,121 +1,58 @@
/**
* Integration tests for indexed-db.ts (#100).
*
* Tests the indexed queries covering latest value per key, removed keys exclusion,
* and tie ordering using pg-mem for local testing.
*/
import { describe, expect, it } from "vitest";
import { poolConfigFromEnv } from "./indexed-db";

import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
const DATABASE_URL = "postgresql://prism:prism@db.example.com:5432/prism";

// Mock pg for local testing
vi.mock("pg", () => {
const mockQuery = vi.fn();
return {
Pool: vi.fn(() => ({
query: mockQuery,
end: vi.fn(),
})),
__mockQuery: mockQuery,
};
});

import { getLatestValues, getKeyHistory, getKeysByPrefix } from "./indexed-db";

// Get the mock query function
const { __mockQuery: mockQuery } = await import("pg");

describe("Indexed DB Queries", () => {
beforeAll(() => {
// Setup mock data
mockQuery.mockClear();
});

afterAll(() => {
vi.restoreAllMocks();
});

describe("getLatestValues", () => {
it("returns latest value per key using DISTINCT ON", async () => {
const mockRows = [
{ key: "key1", value: "value1", ledger: 100, change_type: "created" },
{ key: "key2", value: "value2", ledger: 200, change_type: "updated" },
];
mockQuery.mockResolvedValueOnce({ rows: mockRows });

const result = await getLatestValues("testnet", ["key1", "key2"]);
expect(result).toHaveLength(2);
expect(result[0].key).toBe("key1");
});

it("excludes removed keys", async () => {
const mockRows = [
{ key: "key1", value: "value1", ledger: 100, change_type: "created" },
];
mockQuery.mockResolvedValueOnce({ rows: mockRows });

const result = await getLatestValues("testnet", ["key1", "key2"]);
// key2 should be excluded if it was removed
expect(result.every((r) => r.key !== "key2" || r.change_type !== "removed")).toBe(true);
});

it("handles empty key list", async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const result = await getLatestValues("testnet", []);
expect(result).toHaveLength(0);
describe("poolConfigFromEnv", () => {
it("keeps pg's defaults when nothing is configured", () => {
expect(poolConfigFromEnv({ DATABASE_URL })).toEqual({
connectionString: DATABASE_URL,
max: 10,
idleTimeoutMillis: 10_000,
ssl: undefined,
});
});

describe("getKeyHistory", () => {
it("returns history ordered by ledger", async () => {
const mockRows = [
{ key: "key1", value: "value3", ledger: 300, change_type: "updated" },
{ key: "key1", value: "value2", ledger: 200, change_type: "updated" },
{ key: "key1", value: "value1", ledger: 100, change_type: "created" },
];
mockQuery.mockResolvedValueOnce({ rows: mockRows });

const result = await getKeyHistory("testnet", "key1", 10);
expect(result).toHaveLength(3);
// Should be ordered by ledger descending (newest first)
expect(result[0].ledger).toBeGreaterThan(result[1].ledger);
it("reads the pool size and idle timeout", () => {
const config = poolConfigFromEnv({
DATABASE_URL,
DATABASE_POOL_MAX: "25",
DATABASE_IDLE_TIMEOUT_MS: "5000",
});
expect(config.max).toBe(25);
expect(config.idleTimeoutMillis).toBe(5000);
});

it("respects the limit parameter", async () => {
const mockRows = [
{ key: "key1", value: "value3", ledger: 300, change_type: "updated" },
];
mockQuery.mockResolvedValueOnce({ rows: mockRows });

const result = await getKeyHistory("testnet", "key1", 1);
expect(result).toHaveLength(1);
it("ignores surrounding whitespace and empty values", () => {
const config = poolConfigFromEnv({
DATABASE_URL,
DATABASE_POOL_MAX: " 25 ",
DATABASE_IDLE_TIMEOUT_MS: "",
});
expect(config.max).toBe(25);
expect(config.idleTimeoutMillis).toBe(10_000);
});

it("handles ties in ordering correctly", async () => {
// Two entries with same ledger should be ordered by insertion
const mockRows = [
{ key: "key1", value: "value2", ledger: 100, change_type: "updated" },
{ key: "key1", value: "value1", ledger: 100, change_type: "created" },
];
mockQuery.mockResolvedValueOnce({ rows: mockRows });
it("enables TLS with certificate verification for DATABASE_SSL=true", () => {
expect(poolConfigFromEnv({ DATABASE_URL, DATABASE_SSL: "true" }).ssl).toBe(true);
});

const result = await getKeyHistory("testnet", "key1", 10);
expect(result).toHaveLength(2);
it("encrypts without verification for DATABASE_SSL=no-verify", () => {
expect(poolConfigFromEnv({ DATABASE_URL, DATABASE_SSL: "no-verify" }).ssl).toEqual({
rejectUnauthorized: false,
});
});

describe("getKeysByPrefix", () => {
it("returns keys matching prefix", async () => {
const mockRows = [{ key: "prefix:key1" }, { key: "prefix:key2" }];
mockQuery.mockResolvedValueOnce({ rows: mockRows });

const result = await getKeysByPrefix("testnet", "prefix:");
expect(result).toHaveLength(2);
});
it("leaves TLS off for DATABASE_SSL=false", () => {
expect(poolConfigFromEnv({ DATABASE_URL, DATABASE_SSL: "false" }).ssl).toBeUndefined();
});

it("returns empty array for non-matching prefix", async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const result = await getKeysByPrefix("testnet", "nonexistent:");
expect(result).toHaveLength(0);
});
it("rejects values that would silently change behaviour", () => {
expect(() => poolConfigFromEnv({ DATABASE_POOL_MAX: "ten" })).toThrow(/DATABASE_POOL_MAX/);
expect(() => poolConfigFromEnv({ DATABASE_POOL_MAX: "0" })).toThrow(/DATABASE_POOL_MAX/);
expect(() => poolConfigFromEnv({ DATABASE_IDLE_TIMEOUT_MS: "-1" })).toThrow(
/DATABASE_IDLE_TIMEOUT_MS/
);
expect(() => poolConfigFromEnv({ DATABASE_SSL: "yes" })).toThrow(/DATABASE_SSL/);
});
});
47 changes: 45 additions & 2 deletions frontend/src/lib/indexed-db.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,58 @@
import { Pool } from "pg";
import { Pool, type PoolConfig } from "pg";
import { takePage, type HistoryPage, type PageRequest } from "./history-cursor";

let pool: Pool | undefined;

/** pg's own defaults, pinned here so the pool behaves the same across pg upgrades. */
const DEFAULT_POOL_MAX = 10;
const DEFAULT_IDLE_TIMEOUT_MS = 10_000;

type EnvLike = Record<string, string | undefined>;

function parsePositiveInt(name: string, raw: string | undefined, fallback: number): number {
const value = raw?.trim();
if (!value) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`);
}
return parsed;
}

/**
* `DATABASE_SSL` turns on TLS for managed Postgres. `no-verify` encrypts without
* validating the server certificate, for providers whose CA is not in Node's
* trust store. Unset means no TLS, which is what local compose uses.
*/
function parseSsl(raw: string | undefined): PoolConfig["ssl"] {
const value = raw?.trim().toLowerCase();
if (!value || value === "false") return undefined;
if (value === "true") return true;
if (value === "no-verify") return { rejectUnauthorized: false };
throw new Error(`DATABASE_SSL must be "true", "false" or "no-verify", got ${JSON.stringify(raw)}`);
}

/** Build the pool's config from an environment, defaulting to pg's values. */
export function poolConfigFromEnv(env: EnvLike): PoolConfig {
return {
connectionString: env.DATABASE_URL,
max: parsePositiveInt("DATABASE_POOL_MAX", env.DATABASE_POOL_MAX, DEFAULT_POOL_MAX),
idleTimeoutMillis: parsePositiveInt(
"DATABASE_IDLE_TIMEOUT_MS",
env.DATABASE_IDLE_TIMEOUT_MS,
DEFAULT_IDLE_TIMEOUT_MS
),
ssl: parseSsl(env.DATABASE_SSL),
};
}

/** Whether a Postgres-backed indexer is configured for this deployment (see indexer/). */
export function isIndexerConfigured(): boolean {
return Boolean(process.env.DATABASE_URL);
}

function getPool(): Pool {
if (!pool) pool = new Pool({ connectionString: process.env.DATABASE_URL });
if (!pool) pool = new Pool(poolConfigFromEnv(process.env));
return pool;
}

Expand Down