From f654253150fe5d34edb61991832fcb0f6b373eec Mon Sep 17 00:00:00 2001 From: od-hunter Date: Wed, 24 Jun 2026 15:11:52 +0100 Subject: [PATCH 1/3] feat(db): add read-replica routing with lag monitoring and Terraform Route SELECT/WITH queries to read replicas via ReadWritePool, with automatic failback to primary on high lag or replica failure, PgBouncer-backed local dev, Prometheus replication metrics, and RDS read replica Terraform provisioning. --- backend/config/__tests__/database.test.ts | 74 ++++ backend/config/database.ts | 135 ++++++ .../__tests__/replicationLagExporter.test.ts | 92 ++++ backend/monitoring/replicationLagExporter.ts | 114 +++++ .../db/__tests__/queryClassifier.test.ts | 66 +++ .../db/__tests__/readWriteRouter.test.ts | 333 ++++++++++++++ backend/shared/db/connectionPool.ts | 44 +- backend/shared/db/queryClassifier.ts | 78 ++++ backend/shared/db/readWriteRouter.ts | 411 ++++++++++++++++++ docker-compose.yml | 153 +++++++ infra/pgbouncer/pgbouncer.ini | 22 + infra/pgbouncer/userlist.txt | 2 + infra/terraform/main.tf | 25 ++ infra/terraform/outputs.tf | 42 ++ infra/terraform/rds.tf | 180 ++++++++ infra/terraform/variables.tf | 93 ++++ 16 files changed, 1847 insertions(+), 17 deletions(-) create mode 100644 backend/config/__tests__/database.test.ts create mode 100644 backend/config/database.ts create mode 100644 backend/monitoring/__tests__/replicationLagExporter.test.ts create mode 100644 backend/monitoring/replicationLagExporter.ts create mode 100644 backend/shared/db/__tests__/queryClassifier.test.ts create mode 100644 backend/shared/db/__tests__/readWriteRouter.test.ts create mode 100644 backend/shared/db/queryClassifier.ts create mode 100644 backend/shared/db/readWriteRouter.ts create mode 100644 docker-compose.yml create mode 100644 infra/pgbouncer/pgbouncer.ini create mode 100644 infra/pgbouncer/userlist.txt create mode 100644 infra/terraform/main.tf create mode 100644 infra/terraform/outputs.tf create mode 100644 infra/terraform/rds.tf create mode 100644 infra/terraform/variables.tf diff --git a/backend/config/__tests__/database.test.ts b/backend/config/__tests__/database.test.ts new file mode 100644 index 00000000..63e022d4 --- /dev/null +++ b/backend/config/__tests__/database.test.ts @@ -0,0 +1,74 @@ +import { + DEFAULT_DATABASE_CONFIG, + loadDatabaseConfig, + replicaPoolConfig, +} from '../database'; + +describe('database config', () => { + it('loads primary defaults when env vars are unset', () => { + const config = loadDatabaseConfig({}); + expect(config.primary.host).toBe('localhost'); + expect(config.primary.port).toBe(5432); + expect(config.primary.database).toBe('subtrackr'); + expect(config.primary.user).toBe('postgres'); + expect(config.primary.max).toBe(20); + expect(config.replicas).toEqual([]); + expect(config.replicaPoolSize).toBe(DEFAULT_DATABASE_CONFIG.replicaPoolSize); + expect(config.replicationLagP99AlarmMs).toBe(1_000); + expect(config.replicationLagFailoverMs).toBe(5_000); + expect(config.staleReadDefaultSeconds).toBe(30); + }); + + it('parses comma-separated read replica endpoints', () => { + const config = loadDatabaseConfig({ + DB_READ_REPLICAS: 'replica-a.internal:6432,replica-b.internal:6433', + }); + expect(config.replicas).toEqual([ + { name: 'replica-1', host: 'replica-a.internal', port: 6432 }, + { name: 'replica-2', host: 'replica-b.internal', port: 6433 }, + ]); + }); + + it('parses replica host without explicit port', () => { + const config = loadDatabaseConfig({ + DB_READ_REPLICAS: 'replica-only.internal', + }); + expect(config.replicas).toEqual([ + { name: 'replica-1', host: 'replica-only.internal', port: 5432 }, + ]); + }); + + it('reads custom lag and pool thresholds', () => { + const config = loadDatabaseConfig({ + DB_REPLICA_POOL_SIZE: '50', + DB_REPLICATION_LAG_P99_ALARM_MS: '800', + DB_REPLICATION_LAG_FAILOVER_MS: '4000', + DB_STALE_READ_DEFAULT_SECONDS: '60', + DB_LAG_POLL_INTERVAL_MS: '10000', + }); + expect(config.replicaPoolSize).toBe(50); + expect(config.replicationLagP99AlarmMs).toBe(800); + expect(config.replicationLagFailoverMs).toBe(4_000); + expect(config.staleReadDefaultSeconds).toBe(60); + expect(config.lagPollIntervalMs).toBe(10_000); + }); + + it('falls back for invalid numeric env values', () => { + const config = loadDatabaseConfig({ + DB_PORT: 'not-a-number', + DB_REPLICA_POOL_SIZE: '-1', + }); + expect(config.primary.port).toBe(5432); + expect(config.replicaPoolSize).toBe(DEFAULT_DATABASE_CONFIG.replicaPoolSize); + }); + + it('builds replica pool config with PgBouncer pool size', () => { + const base = loadDatabaseConfig({}).primary; + const replica = { name: 'replica-1', host: 'pgbouncer-1', port: 6433 }; + const poolConfig = replicaPoolConfig(replica, base, 25); + expect(poolConfig.host).toBe('pgbouncer-1'); + expect(poolConfig.port).toBe(6433); + expect(poolConfig.max).toBe(25); + expect(poolConfig.database).toBe(base.database); + }); +}); diff --git a/backend/config/database.ts b/backend/config/database.ts new file mode 100644 index 00000000..a539a9af --- /dev/null +++ b/backend/config/database.ts @@ -0,0 +1,135 @@ +/** + * PostgreSQL connection configuration with read-replica endpoints. + * + * Environment variables (primary): + * DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DB_SSL + * + * Read replicas (optional — comma-separated host:port pairs): + * DB_READ_REPLICAS – e.g. "replica-1.internal:6432,replica-2.internal:6433" + * DB_REPLICA_POOL_SIZE – PgBouncer pool size per replica (default: 25) + * + * Replication lag thresholds (milliseconds): + * DB_REPLICATION_LAG_P99_ALARM_MS – P99 alarm threshold (default: 1000) + * DB_REPLICATION_LAG_FAILOVER_MS – route reads to primary above this (default: 5000) + * + * Stale reads: + * DB_STALE_READ_DEFAULT_SECONDS – default X-Stale-Accept for analytics (default: 30) + */ + +import type { PoolConfig } from '../shared/db/connectionPool'; + +export interface ReplicaEndpoint { + /** Logical name used in metrics labels (replica-1, replica-2, …). */ + name: string; + host: string; + port: number; +} + +export interface DatabaseConfig { + primary: Required; + replicas: ReplicaEndpoint[]; + /** PgBouncer pool size per replica. Default: 25 */ + replicaPoolSize: number; + /** P99 replication lag alarm threshold in ms. Default: 1000 */ + replicationLagP99AlarmMs: number; + /** Lag above which reads fail back to primary. Default: 5000 */ + replicationLagFailoverMs: number; + /** Default stale-read tolerance for analytics endpoints (seconds). Default: 30 */ + staleReadDefaultSeconds: number; + /** How often to poll replication lag (ms). Default: 5000 */ + lagPollIntervalMs: number; +} + +export const DEFAULT_DATABASE_CONFIG: Readonly<{ + replicaPoolSize: number; + replicationLagP99AlarmMs: number; + replicationLagFailoverMs: number; + staleReadDefaultSeconds: number; + lagPollIntervalMs: number; +}> = { + replicaPoolSize: 25, + replicationLagP99AlarmMs: 1_000, + replicationLagFailoverMs: 5_000, + staleReadDefaultSeconds: 30, + lagPollIntervalMs: 5_000, +}; + +function parsePositiveInt(value: string | undefined, fallback: number): number { + if (value === undefined || value === '') return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function parseReplicaEndpoints(raw: string | undefined): ReplicaEndpoint[] { + if (!raw?.trim()) return []; + + return raw + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry, index) => { + const [host, portStr] = entry.includes(':') ? entry.split(':') : [entry, undefined]; + return { + name: `replica-${index + 1}`, + host: host.trim(), + port: parsePositiveInt(portStr, 5432), + }; + }); +} + +function buildPrimaryConfig(env: NodeJS.ProcessEnv): Required { + return { + host: env.DB_HOST?.trim() || 'localhost', + port: parsePositiveInt(env.DB_PORT, 5432), + database: env.DB_NAME?.trim() || 'subtrackr', + user: env.DB_USER?.trim() || 'postgres', + password: env.DB_PASSWORD ?? '', + max: parsePositiveInt(env.DB_POOL_MAX, 20), + idleTimeoutMillis: parsePositiveInt(env.DB_IDLE_TIMEOUT_MS, 10_000), + connectionTimeoutMillis: parsePositiveInt(env.DB_CONNECTION_TIMEOUT_MS, 30_000), + statementTimeout: parsePositiveInt(env.DB_STATEMENT_TIMEOUT_MS, 30_000), + ssl: env.DB_SSL === 'true' ? { rejectUnauthorized: true } : false, + }; +} + +/** Load database configuration from environment variables. */ +export function loadDatabaseConfig(env: NodeJS.ProcessEnv = process.env): DatabaseConfig { + return { + primary: buildPrimaryConfig(env), + replicas: parseReplicaEndpoints(env.DB_READ_REPLICAS), + replicaPoolSize: parsePositiveInt( + env.DB_REPLICA_POOL_SIZE, + DEFAULT_DATABASE_CONFIG.replicaPoolSize, + ), + replicationLagP99AlarmMs: parsePositiveInt( + env.DB_REPLICATION_LAG_P99_ALARM_MS, + DEFAULT_DATABASE_CONFIG.replicationLagP99AlarmMs, + ), + replicationLagFailoverMs: parsePositiveInt( + env.DB_REPLICATION_LAG_FAILOVER_MS, + DEFAULT_DATABASE_CONFIG.replicationLagFailoverMs, + ), + staleReadDefaultSeconds: parsePositiveInt( + env.DB_STALE_READ_DEFAULT_SECONDS, + DEFAULT_DATABASE_CONFIG.staleReadDefaultSeconds, + ), + lagPollIntervalMs: parsePositiveInt( + env.DB_LAG_POLL_INTERVAL_MS, + DEFAULT_DATABASE_CONFIG.lagPollIntervalMs, + ), + }; +} + +/** Build a pg PoolConfig for a read replica (via PgBouncer). */ +export function replicaPoolConfig( + replica: ReplicaEndpoint, + base: Required, + poolSize: number, +): Required { + return { + ...base, + host: replica.host, + port: replica.port, + max: poolSize, + }; +} diff --git a/backend/monitoring/__tests__/replicationLagExporter.test.ts b/backend/monitoring/__tests__/replicationLagExporter.test.ts new file mode 100644 index 00000000..f398a0c4 --- /dev/null +++ b/backend/monitoring/__tests__/replicationLagExporter.test.ts @@ -0,0 +1,92 @@ +import type { DatabaseConfig } from '../../config/database'; +import type { Pool } from '../shared/db/connectionPool'; +import type { ReplicaLagState, ReplicaQueryStats } from '../shared/db/readWriteRouter'; +import { formatReplicationPrometheus } from '../replicationLagExporter'; + +function makePoolStats(total: number, idle: number, waiting: number): Pool { + return { + query: jest.fn(), + connect: jest.fn(), + end: jest.fn(), + on: jest.fn(), + totalCount: total, + idleCount: idle, + waitingCount: waiting, + } as unknown as Pool; +} + +describe('replicationLagExporter', () => { + it('formats lag, pool, and query latency metrics', () => { + const config: DatabaseConfig = { + primary: { + host: 'primary', + port: 5432, + database: 'subtrackr', + user: 'postgres', + password: '', + max: 20, + idleTimeoutMillis: 10_000, + connectionTimeoutMillis: 30_000, + statementTimeout: 30_000, + ssl: false, + }, + replicas: [{ name: 'replica-1', host: 'r1', port: 6433 }], + replicaPoolSize: 25, + replicationLagP99AlarmMs: 1_000, + replicationLagFailoverMs: 5_000, + staleReadDefaultSeconds: 30, + lagPollIntervalMs: 5_000, + }; + + const lagStates: ReplicaLagState[] = [ + { name: 'replica-1', lagMs: 250, lagP99Ms: 400, available: true, lastCheckedAt: Date.now() }, + ]; + const queryStats: ReplicaQueryStats[] = [ + { + name: 'replica-1', + queryCount: 42, + totalLatencyMs: 840, + lastLatencyMs: 20, + errors: 1, + }, + ]; + + const replicaPools = new Map([['replica-1', makePoolStats(25, 10, 2)]]); + + const mockPool = { + getReplicaPools: () => replicaPools, + }; + + const output = formatReplicationPrometheus( + { lagStates, queryStats, config }, + mockPool as never, + ); + + expect(output).toContain('subtrackr_replication_lag_ms{replica="replica-1"} 250'); + expect(output).toContain('subtrackr_replication_lag_p99_ms{replica="replica-1"} 400'); + expect(output).toContain('subtrackr_replication_lag_failover_ms 5000'); + expect(output).toContain('subtrackr_replica_available{replica="replica-1"} 1'); + expect(output).toContain('subtrackr_replica_pool_idle{replica="replica-1"} 10'); + expect(output).toContain('subtrackr_replica_query_latency_ms{replica="replica-1"} 20'); + expect(output).toContain('subtrackr_replica_query_total{replica="replica-1"} 42'); + expect(output).toContain('subtrackr_replica_query_errors_total{replica="replica-1"} 1'); + }); + + it('handles unavailable replica with -1 lag', () => { + const config = { + replicationLagP99AlarmMs: 1_000, + replicationLagFailoverMs: 5_000, + } as DatabaseConfig; + + const lagStates: ReplicaLagState[] = [ + { name: 'replica-2', lagMs: Infinity, lagP99Ms: 0, available: false, lastCheckedAt: 0 }, + ]; + + const output = formatReplicationPrometheus( + { lagStates, queryStats: [], config }, + { getReplicaPools: () => new Map() } as never, + ); + + expect(output).toContain('subtrackr_replica_available{replica="replica-2"} 0'); + }); +}); diff --git a/backend/monitoring/replicationLagExporter.ts b/backend/monitoring/replicationLagExporter.ts new file mode 100644 index 00000000..20108c36 --- /dev/null +++ b/backend/monitoring/replicationLagExporter.ts @@ -0,0 +1,114 @@ +/** + * Replication Lag Prometheus Exporter + * + * Exposes replication lag, replica pool utilisation, and per-replica query + * latency metrics for Prometheus scraping. + */ + +import type { DatabaseConfig } from '../config/database'; +import type { ReadWritePool, ReplicaLagState, ReplicaQueryStats } from '../shared/db/readWriteRouter'; + +export interface ReplicationMetricsSnapshot { + lagStates: ReplicaLagState[]; + queryStats: ReplicaQueryStats[]; + config: DatabaseConfig; +} + +export function collectReplicationMetrics(pool: ReadWritePool): ReplicationMetricsSnapshot { + return { + lagStates: pool.getLagStates(), + queryStats: pool.getQueryStats(), + config: pool.getConfig(), + }; +} + +/** + * Render Prometheus text format for replication monitoring. + * + * Metrics: + * subtrackr_replication_lag_ms{replica="..."} + * subtrackr_replication_lag_p99_ms{replica="..."} + * subtrackr_replication_lag_p99_alarm_ms (constant threshold) + * subtrackr_replica_available{replica="..."} + * subtrackr_replica_pool_total{replica="..."} + * subtrackr_replica_pool_idle{replica="..."} + * subtrackr_replica_pool_waiting{replica="..."} + * subtrackr_replica_query_latency_ms{replica="..."} + * subtrackr_replica_query_total{replica="..."} + * subtrackr_replica_query_errors_total{replica="..."} + */ +export function formatReplicationPrometheus(snapshot: ReplicationMetricsSnapshot, pool: ReadWritePool): string { + const lines: string[] = []; + const { config, lagStates, queryStats } = snapshot; + const replicaPools = pool.getReplicaPools(); + + lines.push('# HELP subtrackr_replication_lag_ms Replication lag in milliseconds per replica'); + lines.push('# TYPE subtrackr_replication_lag_ms gauge'); + for (const state of lagStates) { + const lag = Number.isFinite(state.lagMs) ? Math.round(state.lagMs) : -1; + lines.push(`subtrackr_replication_lag_ms{replica="${state.name}"} ${lag}`); + } + + lines.push('# HELP subtrackr_replication_lag_p99_ms Rolling P99 replication lag in milliseconds'); + lines.push('# TYPE subtrackr_replication_lag_p99_ms gauge'); + for (const state of lagStates) { + const p99 = Number.isFinite(state.lagP99Ms) ? Math.round(state.lagP99Ms) : -1; + lines.push(`subtrackr_replication_lag_p99_ms{replica="${state.name}"} ${p99}`); + } + + lines.push('# HELP subtrackr_replication_lag_p99_alarm_ms P99 replication lag alarm threshold'); + lines.push('# TYPE subtrackr_replication_lag_p99_alarm_ms gauge'); + lines.push(`subtrackr_replication_lag_p99_alarm_ms ${config.replicationLagP99AlarmMs}`); + + lines.push('# HELP subtrackr_replication_lag_failover_ms Lag threshold for primary failback'); + lines.push('# TYPE subtrackr_replication_lag_failover_ms gauge'); + lines.push(`subtrackr_replication_lag_failover_ms ${config.replicationLagFailoverMs}`); + + lines.push('# HELP subtrackr_replica_available Whether the replica is reachable (1=yes, 0=no)'); + lines.push('# TYPE subtrackr_replica_available gauge'); + for (const state of lagStates) { + lines.push(`subtrackr_replica_available{replica="${state.name}"} ${state.available ? 1 : 0}`); + } + + lines.push('# HELP subtrackr_replica_pool_total Total connections in the replica pool'); + lines.push('# TYPE subtrackr_replica_pool_total gauge'); + lines.push('# HELP subtrackr_replica_pool_idle Idle connections in the replica pool'); + lines.push('# TYPE subtrackr_replica_pool_idle gauge'); + lines.push('# HELP subtrackr_replica_pool_waiting Clients waiting for a replica connection'); + lines.push('# TYPE subtrackr_replica_pool_waiting gauge'); + + for (const [name, replicaPool] of replicaPools) { + lines.push(`subtrackr_replica_pool_total{replica="${name}"} ${replicaPool.totalCount}`); + lines.push(`subtrackr_replica_pool_idle{replica="${name}"} ${replicaPool.idleCount}`); + lines.push(`subtrackr_replica_pool_waiting{replica="${name}"} ${replicaPool.waitingCount}`); + } + + lines.push('# HELP subtrackr_replica_query_latency_ms Last query latency on replica in ms'); + lines.push('# TYPE subtrackr_replica_query_latency_ms gauge'); + lines.push('# HELP subtrackr_replica_query_total Total queries routed to replica'); + lines.push('# TYPE subtrackr_replica_query_total counter'); + lines.push('# HELP subtrackr_replica_query_errors_total Total failed replica queries'); + lines.push('# TYPE subtrackr_replica_query_errors_total counter'); + + for (const stats of queryStats) { + lines.push( + `subtrackr_replica_query_latency_ms{replica="${stats.name}"} ${Math.round(stats.lastLatencyMs)}`, + ); + lines.push(`subtrackr_replica_query_total{replica="${stats.name}"} ${stats.queryCount}`); + lines.push(`subtrackr_replica_query_errors_total{replica="${stats.name}"} ${stats.errors}`); + } + + return lines.join('\n'); +} + +export function createReplicationMetricsHandler(pool: ReadWritePool) { + return function handleReplicationMetrics( + _req: unknown, + res: { setHeader(name: string, value: string): void; end(body: string): void }, + ): void { + const snapshot = collectReplicationMetrics(pool); + const body = formatReplicationPrometheus(snapshot, pool); + res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + res.end(body); + }; +} diff --git a/backend/shared/db/__tests__/queryClassifier.test.ts b/backend/shared/db/__tests__/queryClassifier.test.ts new file mode 100644 index 00000000..bed3fdb7 --- /dev/null +++ b/backend/shared/db/__tests__/queryClassifier.test.ts @@ -0,0 +1,66 @@ +import { isReadQuery, normalizeSql } from '../queryClassifier'; + +describe('queryClassifier', () => { + describe('normalizeSql', () => { + it('strips leading line comments', () => { + expect(normalizeSql('-- comment\nSELECT 1')).toBe('SELECT 1'); + }); + + it('strips leading block comments', () => { + expect(normalizeSql('/* block */ SELECT 1')).toBe('SELECT 1'); + }); + + it('returns empty string for comment-only input', () => { + expect(normalizeSql('-- only comment')).toBe(''); + }); + }); + + describe('isReadQuery', () => { + it('classifies SELECT as read', () => { + expect(isReadQuery('SELECT * FROM subscriptions')).toBe(true); + expect(isReadQuery(' select id from plans')).toBe(true); + }); + + it('classifies WITH (CTE) as read', () => { + expect(isReadQuery('WITH cte AS (SELECT 1) SELECT * FROM cte')).toBe(true); + }); + + it('classifies data-modifying WITH (CTE) as write', () => { + expect( + isReadQuery( + 'WITH inserted AS (INSERT INTO plans (id, name) VALUES ($1, $2) RETURNING *) SELECT * FROM inserted', + ), + ).toBe(false); + expect(isReadQuery('WITH updated AS (UPDATE plans SET name = $1 RETURNING *) SELECT * FROM updated')).toBe( + false, + ); + expect(isReadQuery('WITH deleted AS (DELETE FROM plans WHERE id = $1 RETURNING *) SELECT * FROM deleted')).toBe( + false, + ); + }); + + it('classifies INSERT/UPDATE/DELETE as write', () => { + expect(isReadQuery('INSERT INTO plans VALUES (1)')).toBe(false); + expect(isReadQuery('UPDATE plans SET name = $1')).toBe(false); + expect(isReadQuery('DELETE FROM plans WHERE id = $1')).toBe(false); + }); + + it('classifies DDL and REFRESH as write', () => { + expect(isReadQuery('CREATE TABLE foo (id int)')).toBe(false); + expect(isReadQuery('REFRESH MATERIALIZED VIEW CONCURRENTLY churn_summary_mv')).toBe(false); + }); + + it('treats SELECT FOR UPDATE as write', () => { + expect(isReadQuery('SELECT * FROM plans WHERE id = $1 FOR UPDATE')).toBe(false); + }); + + it('treats empty SQL as write (safe default)', () => { + expect(isReadQuery('')).toBe(false); + expect(isReadQuery(' ')).toBe(false); + }); + + it('handles commented SELECT as read', () => { + expect(isReadQuery('-- analytics\nSELECT COUNT(*) FROM transactions')).toBe(true); + }); + }); +}); diff --git a/backend/shared/db/__tests__/readWriteRouter.test.ts b/backend/shared/db/__tests__/readWriteRouter.test.ts new file mode 100644 index 00000000..8803ec29 --- /dev/null +++ b/backend/shared/db/__tests__/readWriteRouter.test.ts @@ -0,0 +1,333 @@ +import type { DatabaseConfig } from '../../../config/database'; +import type { Pool, QueryResult } from '../connectionPool'; +import { + ReadWritePool, + attachRoutingHeaderInterceptor, + computeLagP99, + createRoutingContextFromRequest, + parseStaleAcceptHeader, + runWithQueryRoutingContext, +} from '../readWriteRouter'; +import { formatReplicationPrometheus, collectReplicationMetrics } from '../../../monitoring/replicationLagExporter'; + +function makeMockPool(label: string, fail = false): Pool { + return { + query: jest.fn(async (sql: string) => { + if (fail) throw new Error(`${label} unavailable`); + if (sql.includes('pg_last_xact_replay_timestamp')) { + return { rows: [{ lag_ms: label === 'replica-1' ? 500 : 200 }], rowCount: 1 }; + } + return { rows: [{ source: label }], rowCount: 1 } as QueryResult<{ source: string }>; + }), + connect: jest.fn(), + end: jest.fn(), + on: jest.fn(), + totalCount: 10, + idleCount: 5, + waitingCount: 0, + } as unknown as Pool; +} + +function makeConfig(overrides: Partial = {}): DatabaseConfig { + return { + primary: { + host: 'primary', + port: 5432, + database: 'subtrackr', + user: 'postgres', + password: '', + max: 20, + idleTimeoutMillis: 10_000, + connectionTimeoutMillis: 30_000, + statementTimeout: 30_000, + ssl: false, + }, + replicas: [ + { name: 'replica-1', host: 'replica-1', port: 6433 }, + { name: 'replica-2', host: 'replica-2', port: 6434 }, + ], + replicaPoolSize: 25, + replicationLagP99AlarmMs: 1_000, + replicationLagFailoverMs: 5_000, + staleReadDefaultSeconds: 30, + lagPollIntervalMs: 60_000, + ...overrides, + }; +} + +describe('readWriteRouter', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('parseStaleAcceptHeader', () => { + it('parses valid header values', () => { + expect(parseStaleAcceptHeader('30')).toBe(30); + expect(parseStaleAcceptHeader(['15'])).toBe(15); + }); + + it('returns undefined for missing or invalid values', () => { + expect(parseStaleAcceptHeader(undefined)).toBeUndefined(); + expect(parseStaleAcceptHeader('')).toBeUndefined(); + expect(parseStaleAcceptHeader('abc')).toBeUndefined(); + expect(parseStaleAcceptHeader('0')).toBeUndefined(); + }); + }); + + describe('createRoutingContextFromRequest', () => { + it('reads X-Stale-Accept header', () => { + const ctx = createRoutingContextFromRequest({ 'x-stale-accept': '30' }); + expect(ctx.staleAcceptSeconds).toBe(30); + expect(ctx.responseHeaders).toBeInstanceOf(Map); + }); + + it('defaults stale accept for analytics requests', () => { + const ctx = createRoutingContextFromRequest({ 'x-analytics-request': 'true' }); + expect(ctx.staleAcceptSeconds).toBe(30); + }); + }); + + describe('ReadWritePool', () => { + it('routes SELECT to a read replica', async () => { + const primary = makeMockPool('primary'); + const replica1 = makeMockPool('replica-1'); + const replica2 = makeMockPool('replica-2'); + const replicas = new Map([ + ['replica-1', replica1], + ['replica-2', replica2], + ]); + + const pool = new ReadWritePool(primary, replicas, makeConfig().replicas, makeConfig()); + await pool.pollReplicationLag(); + + const responseHeaders = new Map(); + await runWithQueryRoutingContext({ responseHeaders }, async () => { + const result = await pool.query('SELECT * FROM plans'); + expect(result.rows[0]).toEqual({ source: expect.stringMatching(/replica/) }); + }); + + expect(primary.query).not.toHaveBeenCalled(); + expect(responseHeaders.get('X-DB-Route')).toMatch(/^replica:/); + }); + + it('routes INSERT to primary', async () => { + const primary = makeMockPool('primary'); + const replica1 = makeMockPool('replica-1'); + const pool = new ReadWritePool( + primary, + new Map([['replica-1', replica1]]), + [{ name: 'replica-1', host: 'r1', port: 6433 }], + makeConfig({ replicas: [{ name: 'replica-1', host: 'r1', port: 6433 }] }), + ); + + await pool.query('INSERT INTO plans (id) VALUES ($1)', ['p1']); + expect(primary.query).toHaveBeenCalled(); + expect(replica1.query).not.toHaveBeenCalled(); + }); + + it('falls back to primary when replica lag exceeds failover threshold', async () => { + const primary = makeMockPool('primary'); + const replica1 = makeMockPool('replica-1'); + replica1.query = jest.fn(async (sql: string) => { + if (sql.includes('pg_last_xact_replay_timestamp')) { + return { rows: [{ lag_ms: 6_000 }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + + const pool = new ReadWritePool( + primary, + new Map([['replica-1', replica1]]), + [{ name: 'replica-1', host: 'r1', port: 6433 }], + makeConfig({ replicas: [{ name: 'replica-1', host: 'r1', port: 6433 }] }), + ); + await pool.pollReplicationLag(); + + const responseHeaders = new Map(); + await runWithQueryRoutingContext({ responseHeaders }, async () => { + await pool.query('SELECT 1'); + }); + + expect(primary.query).toHaveBeenCalledWith('SELECT 1', undefined); + expect(responseHeaders.get('X-DB-Route')).toBe('primary'); + expect(responseHeaders.get('X-DB-Route-Reason')).toBe('lag-or-unavailable'); + expect(responseHeaders.get('X-DB-Route-Warning')).toBe('replication-lag-fallback-primary'); + }); + + it('allows higher lag for analytics with X-Stale-Accept', async () => { + const primary = makeMockPool('primary'); + const replica1 = makeMockPool('replica-1'); + replica1.query = jest.fn(async (sql: string) => { + if (sql.includes('pg_last_xact_replay_timestamp')) { + return { rows: [{ lag_ms: 10_000 }], rowCount: 1 }; + } + return { rows: [{ source: 'replica-1' }], rowCount: 1 }; + }); + + const pool = new ReadWritePool( + primary, + new Map([['replica-1', replica1]]), + [{ name: 'replica-1', host: 'r1', port: 6433 }], + makeConfig({ replicas: [{ name: 'replica-1', host: 'r1', port: 6433 }] }), + ); + await pool.pollReplicationLag(); + + const responseHeaders = new Map(); + await runWithQueryRoutingContext({ staleAcceptSeconds: 30, responseHeaders }, async () => { + await pool.query('SELECT COUNT(*) FROM transactions'); + }); + + expect(primary.query).not.toHaveBeenCalledWith('SELECT COUNT(*) FROM transactions', undefined); + expect(responseHeaders.get('X-DB-Route')).toBe('replica:replica-1'); + }); + + it('falls back to primary when replica query fails', async () => { + const primary = makeMockPool('primary'); + const replica1 = makeMockPool('replica-1'); + replica1.query = jest.fn(async (sql: string) => { + if (sql.includes('pg_last_xact_replay_timestamp')) { + return { rows: [{ lag_ms: 100 }], rowCount: 1 }; + } + throw new Error('replica-1 unavailable'); + }); + + const pool = new ReadWritePool( + primary, + new Map([['replica-1', replica1]]), + [{ name: 'replica-1', host: 'r1', port: 6433 }], + makeConfig({ replicas: [{ name: 'replica-1', host: 'r1', port: 6433 }] }), + ); + await pool.pollReplicationLag(); + + const responseHeaders = new Map(); + await runWithQueryRoutingContext({ responseHeaders }, async () => { + await pool.query('SELECT 1'); + }); + + expect(primary.query).toHaveBeenCalledWith('SELECT 1', undefined); + expect(responseHeaders.get('X-DB-Route-Warning')).toBe('replica-unavailable-fallback-primary'); + }); + + it('connect() always uses primary for transactions', async () => { + const primary = makeMockPool('primary'); + const client = { query: jest.fn(), release: jest.fn() }; + primary.connect = jest.fn(async () => client); + + const pool = new ReadWritePool(primary, new Map(), [], makeConfig({ replicas: [] })); + const connected = await pool.connect(); + expect(connected).toBe(client); + expect(primary.connect).toHaveBeenCalled(); + }); + + it('round-robins across healthy replicas', async () => { + const primary = makeMockPool('primary'); + const replica1 = makeMockPool('replica-1'); + const replica2 = makeMockPool('replica-2'); + const pool = new ReadWritePool( + primary, + new Map([ + ['replica-1', replica1], + ['replica-2', replica2], + ]), + makeConfig().replicas, + makeConfig(), + ); + await pool.pollReplicationLag(); + + const routes: string[] = []; + for (let i = 0; i < 4; i++) { + const responseHeaders = new Map(); + await runWithQueryRoutingContext({ responseHeaders }, async () => { + await pool.query('SELECT 1'); + routes.push(responseHeaders.get('X-DB-Route') ?? ''); + }); + } + + expect(routes).toContain('replica:replica-1'); + expect(routes).toContain('replica:replica-2'); + expect(routes[0]).toBe('replica:replica-1'); + }); + + it('tracks rolling P99 lag samples', async () => { + expect(computeLagP99([])).toBe(0); + expect(computeLagP99([100, 200, 300, 400, 500])).toBe(500); + + const primary = makeMockPool('primary'); + const replica1 = makeMockPool('replica-1'); + let pollCount = 0; + replica1.query = jest.fn(async (sql: string) => { + if (sql.includes('pg_last_xact_replay_timestamp')) { + pollCount += 1; + return { rows: [{ lag_ms: pollCount * 100 }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + + const pool = new ReadWritePool( + primary, + new Map([['replica-1', replica1]]), + [{ name: 'replica-1', host: 'r1', port: 6433 }], + makeConfig({ replicas: [{ name: 'replica-1', host: 'r1', port: 6433 }] }), + ); + + await pool.pollReplicationLag(); + await pool.pollReplicationLag(); + await pool.pollReplicationLag(); + + const [state] = pool.getLagStates(); + expect(state?.lagMs).toBe(300); + expect(state?.lagP99Ms).toBe(300); + }); + }); + + describe('attachRoutingHeaderInterceptor', () => { + it('applies routing headers on writeHead', () => { + const headers: Record = {}; + const res = { + headersSent: false, + setHeader: (name: string, value: string) => { + headers[name] = value; + }, + writeHead: jest.fn(function (this: { headersSent: boolean }, ..._args: unknown[]) { + this.headersSent = true; + return this; + }), + end: jest.fn(), + }; + + const context = createRoutingContextFromRequest({}); + context.responseHeaders?.set('X-DB-Route', 'replica:replica-1'); + attachRoutingHeaderInterceptor(res, context); + res.writeHead(200, { 'Content-Type': 'application/json' }); + + expect(headers['X-DB-Route']).toBe('replica:replica-1'); + }); + }); + + describe('replication metrics', () => { + it('exports Prometheus metrics for lag and pool utilisation', async () => { + const primary = makeMockPool('primary'); + const replica1 = makeMockPool('replica-1'); + const pool = new ReadWritePool( + primary, + new Map([['replica-1', replica1]]), + [{ name: 'replica-1', host: 'r1', port: 6433 }], + makeConfig({ replicas: [{ name: 'replica-1', host: 'r1', port: 6433 }] }), + ); + await pool.pollReplicationLag(); + + await runWithQueryRoutingContext({ responseHeaders: new Map() }, async () => { + await pool.query('SELECT 1'); + }); + + const snapshot = collectReplicationMetrics(pool); + const output = formatReplicationPrometheus(snapshot, pool); + + expect(output).toContain('subtrackr_replication_lag_ms{replica="replica-1"}'); + expect(output).toContain('subtrackr_replication_lag_p99_ms{replica="replica-1"}'); + expect(output).toContain('subtrackr_replication_lag_p99_alarm_ms 1000'); + expect(output).toContain('subtrackr_replica_pool_total{replica="replica-1"} 10'); + expect(output).toContain('subtrackr_replica_query_total{replica="replica-1"}'); + }); + }); +}); diff --git a/backend/shared/db/connectionPool.ts b/backend/shared/db/connectionPool.ts index 0cf5fb77..7af2eccb 100644 --- a/backend/shared/db/connectionPool.ts +++ b/backend/shared/db/connectionPool.ts @@ -1,16 +1,22 @@ /** * PostgreSQL connection pool configuration using pg-pool. * + * When DB_READ_REPLICAS is set, getPool() returns a ReadWritePool that routes + * SELECT/WITH to replicas and writes to the primary (see readWriteRouter.ts). + * * Acceptance criteria targets: - * - max 20 connections + * - max 20 connections (primary) + * - replica pool size 25 per PgBouncer (configurable via DB_REPLICA_POOL_SIZE) * - idle timeout 10 s * - statement timeout 30 s - * - list query for 1000 subscriptions uses <5 connections in <500 ms * * NOTE: pg and pg-pool are Node.js-only dependencies used exclusively in the * backend service layer, not bundled into the React Native app. */ +import { loadDatabaseConfig } from '../../config/database'; +import { type ReadWritePool, createReadWritePool } from './readWriteRouter'; + // pg-pool type interface (install: npm i pg pg-pool @types/pg) // Defined inline to avoid a hard runtime dependency in environments // where pg is not installed (e.g., the mobile bundle). @@ -52,18 +58,9 @@ export interface Pool { waitingCount: number; } -const DEFAULT_CONFIG: Required = { - host: process.env['DB_HOST'] ?? 'localhost', - port: Number(process.env['DB_PORT'] ?? 5432), - database: process.env['DB_NAME'] ?? 'subtrackr', - user: process.env['DB_USER'] ?? 'postgres', - password: process.env['DB_PASSWORD'] ?? '', - max: 20, - idleTimeoutMillis: 10_000, - connectionTimeoutMillis: 30_000, - statementTimeout: 30_000, - ssl: process.env['DB_SSL'] === 'true' ? { rejectUnauthorized: true } : false, -}; +function defaultPoolConfig(): Required { + return loadDatabaseConfig().primary; +} /** * Create a configured pg Pool. @@ -73,7 +70,7 @@ export async function createPool(overrides: Partial = {}): Promise

Pool }; - const config: PoolConfig = { ...DEFAULT_CONFIG, ...overrides }; + const config: PoolConfig = { ...defaultPoolConfig(), ...overrides }; const pool = new PgPool(config); @@ -99,18 +96,31 @@ export async function createPool(overrides: Partial = {}): Promise

{ if (!_pool) { - _pool = await createPool(); + const dbConfig = loadDatabaseConfig(); + if (dbConfig.replicas.length > 0) { + _pool = await createReadWritePool({ config: dbConfig }); + } else { + _pool = await createPool(dbConfig.primary); + } } return _pool; } +/** Return the underlying ReadWritePool when replica routing is enabled. */ +export async function getReadWritePool(): Promise { + const pool = await getPool(); + return 'getLagStates' in pool ? (pool as ReadWritePool) : null; +} + export async function closePool(): Promise { if (_pool) { await _pool.end(); _pool = null; } } + +export type { ReadWritePool }; diff --git a/backend/shared/db/queryClassifier.ts b/backend/shared/db/queryClassifier.ts new file mode 100644 index 00000000..92408b2a --- /dev/null +++ b/backend/shared/db/queryClassifier.ts @@ -0,0 +1,78 @@ +/** + * Classifies SQL statements for read/write routing. + * + * SELECT and WITH (CTE) queries route to read replicas. + * INSERT, UPDATE, DELETE, and DDL route to the primary. + */ + +const WRITE_PREFIXES = [ + 'INSERT', + 'UPDATE', + 'DELETE', + 'MERGE', + 'CREATE', + 'ALTER', + 'DROP', + 'TRUNCATE', + 'REFRESH', + 'GRANT', + 'REVOKE', + 'COPY', + 'VACUUM', + 'ANALYZE', + 'REINDEX', + 'CLUSTER', + 'COMMENT', + 'LOCK', + 'CALL', + 'DO', + 'SET', +]; + +/** Strip leading SQL comments and whitespace. */ +export function normalizeSql(sql: string): string { + let text = sql.trim(); + while (text.startsWith('/*') || text.startsWith('--')) { + if (text.startsWith('--')) { + const newline = text.indexOf('\n'); + text = newline === -1 ? '' : text.slice(newline + 1).trim(); + continue; + } + const end = text.indexOf('*/'); + text = end === -1 ? '' : text.slice(end + 2).trim(); + } + return text; +} + +const DATA_MODIFYING_KEYWORDS = /\b(INSERT|UPDATE|DELETE)\b/i; + +/** True when the statement should be routed to a read replica. */ +export function isReadQuery(sql: string): boolean { + const normalized = normalizeSql(sql).toUpperCase(); + + if (!normalized) return false; + + if (normalized.startsWith('WITH')) { + // Data-modifying CTEs (WITH … INSERT/UPDATE/DELETE) must hit primary + if (DATA_MODIFYING_KEYWORDS.test(normalized)) { + return false; + } + } + + if (normalized.startsWith('SELECT') || normalized.startsWith('WITH')) { + // SELECT … FOR UPDATE / FOR SHARE must hit primary + if (/\bFOR\s+(UPDATE|NO\s+KEY\s+UPDATE|SHARE|KEY\s+SHARE)\b/i.test(sql)) { + return false; + } + return true; + } + + for (const prefix of WRITE_PREFIXES) { + if (normalized.startsWith(prefix)) { + return false; + } + } + + // Unknown statements default to primary (safe) + return false; +} diff --git a/backend/shared/db/readWriteRouter.ts b/backend/shared/db/readWriteRouter.ts new file mode 100644 index 00000000..687e7ef2 --- /dev/null +++ b/backend/shared/db/readWriteRouter.ts @@ -0,0 +1,411 @@ +/** + * Read/Write Query Routing Middleware + * + * Routes SELECT / WITH queries to read replicas and write operations to the + * primary. Monitors replication lag and fails back to primary when lag exceeds + * configurable thresholds. Exposes routing metadata via response headers. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { + type DatabaseConfig, + type ReplicaEndpoint, + loadDatabaseConfig, + replicaPoolConfig, +} from '../../config/database'; +import { type Pool, type PoolClient, type PoolConfig, type QueryResult, createPool } from './connectionPool'; +import { isReadQuery } from './queryClassifier'; + +// ── Request-scoped routing context ──────────────────────────────────────────── + +export interface QueryRoutingContext { + /** Max acceptable replication lag in seconds (from X-Stale-Accept header). */ + staleAcceptSeconds?: number; + /** Mutable map populated with routing headers for the HTTP response. */ + responseHeaders?: Map; +} + +const routingContextStorage = new AsyncLocalStorage(); + +/** Run `fn` with query-routing context (stale-read tolerance, response headers). */ +export function runWithQueryRoutingContext( + context: QueryRoutingContext, + fn: () => T | Promise, +): T | Promise { + return routingContextStorage.run(context, fn); +} + +export function getQueryRoutingContext(): QueryRoutingContext | undefined { + return routingContextStorage.getStore(); +} + +/** Parse X-Stale-Accept header value (seconds). Returns undefined when absent/invalid. */ +export function parseStaleAcceptHeader(value: string | string[] | undefined): number | undefined { + const raw = Array.isArray(value) ? value[0] : value; + if (!raw?.trim()) return undefined; + const seconds = Number.parseInt(raw.trim(), 10); + return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined; +} + +// ── Replication lag state ───────────────────────────────────────────────────── + +export interface ReplicaLagState { + name: string; + lagMs: number; + /** Rolling P99 lag computed from recent poll samples (ms). */ + lagP99Ms: number; + available: boolean; + lastCheckedAt: number; +} + +const LAG_SAMPLE_WINDOW = 100; + +/** Compute P99 from a sorted sample window. */ +export function computeLagP99(samples: number[]): number { + if (samples.length === 0) return 0; + const sorted = [...samples].sort((a, b) => a - b); + const index = Math.ceil(0.99 * sorted.length) - 1; + return sorted[Math.max(0, index)] ?? 0; +} + +export interface ReplicaQueryStats { + name: string; + queryCount: number; + totalLatencyMs: number; + lastLatencyMs: number; + errors: number; +} + +const LAG_QUERY = ` + SELECT COALESCE( + EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) * 1000, + 0 + )::float AS lag_ms +`; + +// ── Read/Write pool ─────────────────────────────────────────────────────────── + +export interface ReadWritePoolOptions { + config?: DatabaseConfig; + primaryPool?: Pool; + replicaPools?: Map; +} + +export class ReadWritePool implements Pool { + readonly primary: Pool; + private readonly replicas: Map; + private readonly replicaEndpoints: ReplicaEndpoint[]; + private readonly config: DatabaseConfig; + private readonly lagState: Map = new Map(); + private readonly queryStats: Map = new Map(); + private readonly lagSamples: Map = new Map(); + private roundRobinIndex = 0; + private lagPollTimer: ReturnType | null = null; + + constructor( + primary: Pool, + replicas: Map, + endpoints: ReplicaEndpoint[], + config: DatabaseConfig, + ) { + this.primary = primary; + this.replicas = replicas; + this.replicaEndpoints = endpoints; + this.config = config; + + for (const endpoint of endpoints) { + this.lagState.set(endpoint.name, { + name: endpoint.name, + lagMs: 0, + lagP99Ms: 0, + available: true, + lastCheckedAt: 0, + }); + this.lagSamples.set(endpoint.name, []); + this.queryStats.set(endpoint.name, { + name: endpoint.name, + queryCount: 0, + totalLatencyMs: 0, + lastLatencyMs: 0, + errors: 0, + }); + } + } + + get totalCount(): number { + let total = this.primary.totalCount; + for (const pool of this.replicas.values()) { + total += pool.totalCount; + } + return total; + } + + get idleCount(): number { + let total = this.primary.idleCount; + for (const pool of this.replicas.values()) { + total += pool.idleCount; + } + return total; + } + + get waitingCount(): number { + let total = this.primary.waitingCount; + for (const pool of this.replicas.values()) { + total += pool.waitingCount; + } + return total; + } + + on(event: 'error', handler: (err: Error) => void): void { + this.primary.on(event, handler); + for (const pool of this.replicas.values()) { + pool.on(event, handler); + } + } + + /** Start background replication-lag polling. */ + startLagMonitoring(): void { + if (this.lagPollTimer || this.replicas.size === 0) return; + + void this.pollReplicationLag(); + this.lagPollTimer = setInterval( + () => void this.pollReplicationLag(), + this.config.lagPollIntervalMs, + ); + } + + stopLagMonitoring(): void { + if (this.lagPollTimer) { + clearInterval(this.lagPollTimer); + this.lagPollTimer = null; + } + } + + async pollReplicationLag(): Promise { + for (const [name, pool] of this.replicas) { + const state = this.lagState.get(name); + if (!state) continue; + + try { + const result = await pool.query<{ lag_ms: number }>(LAG_QUERY); + const lagMs = Number(result.rows[0]?.lag_ms ?? 0); + const resolvedLag = Number.isFinite(lagMs) ? Math.max(0, lagMs) : 0; + state.lagMs = resolvedLag; + const samples = this.lagSamples.get(name) ?? []; + samples.push(resolvedLag); + if (samples.length > LAG_SAMPLE_WINDOW) { + samples.shift(); + } + this.lagSamples.set(name, samples); + state.lagP99Ms = computeLagP99(samples); + state.available = true; + state.lastCheckedAt = Date.now(); + } catch { + state.available = false; + state.lastCheckedAt = Date.now(); + } + } + } + + getLagStates(): ReplicaLagState[] { + return [...this.lagState.values()]; + } + + getQueryStats(): ReplicaQueryStats[] { + return [...this.queryStats.values()]; + } + + getReplicaPools(): Map { + return this.replicas; + } + + getConfig(): DatabaseConfig { + return this.config; + } + + /** Max acceptable lag before routing reads to primary. */ + private maxAcceptableLagMs(context?: QueryRoutingContext): number { + if (context?.staleAcceptSeconds) { + return context.staleAcceptSeconds * 1_000; + } + return this.config.replicationLagFailoverMs; + } + + private selectReplica(context?: QueryRoutingContext): { pool: Pool; name: string } | null { + if (this.replicas.size === 0) return null; + + const maxLag = this.maxAcceptableLagMs(context); + const candidates: Array<{ pool: Pool; name: string; lagMs: number }> = []; + + for (const endpoint of this.replicaEndpoints) { + const pool = this.replicas.get(endpoint.name); + const state = this.lagState.get(endpoint.name); + if (!pool || !state?.available) continue; + if (state.lagMs > maxLag) continue; + candidates.push({ pool, name: endpoint.name, lagMs: state.lagMs }); + } + + if (candidates.length === 0) return null; + + const index = this.roundRobinIndex % candidates.length; + const selected = candidates[index]!; + this.roundRobinIndex = (this.roundRobinIndex + 1) % candidates.length; + return { pool: selected.pool, name: selected.name }; + } + + private setResponseHeader(key: string, value: string): void { + const ctx = getQueryRoutingContext(); + ctx?.responseHeaders?.set(key, value); + } + + private recordReplicaQuery(name: string, latencyMs: number, isError: boolean): void { + const stats = this.queryStats.get(name); + if (!stats) return; + stats.queryCount += 1; + stats.totalLatencyMs += latencyMs; + stats.lastLatencyMs = latencyMs; + if (isError) stats.errors += 1; + } + + async query(sql: string, params?: unknown[]): Promise> { + if (!isReadQuery(sql)) { + return this.primary.query(sql, params); + } + + const context = getQueryRoutingContext(); + const replica = this.selectReplica(context); + + if (!replica) { + this.setResponseHeader('X-DB-Route', 'primary'); + const reason = this.replicas.size === 0 ? 'no-replicas' : 'lag-or-unavailable'; + this.setResponseHeader('X-DB-Route-Reason', reason); + if (reason === 'lag-or-unavailable') { + this.setResponseHeader('X-DB-Route-Warning', 'replication-lag-fallback-primary'); + } + return this.primary.query(sql, params); + } + + const start = Date.now(); + try { + const result = await replica.pool.query(sql, params); + const latency = Date.now() - start; + this.recordReplicaQuery(replica.name, latency, false); + + const state = this.lagState.get(replica.name); + this.setResponseHeader('X-DB-Route', `replica:${replica.name}`); + if (state) { + this.setResponseHeader('X-DB-Replication-Lag-Ms', String(Math.round(state.lagMs))); + } + return result; + } catch (err) { + const latency = Date.now() - start; + this.recordReplicaQuery(replica.name, latency, true); + + const state = this.lagState.get(replica.name); + if (state) state.available = false; + + this.setResponseHeader('X-DB-Route', 'primary'); + this.setResponseHeader('X-DB-Route-Warning', 'replica-unavailable-fallback-primary'); + + console.warn( + `[ReadWritePool] Replica ${replica.name} query failed, falling back to primary:`, + err instanceof Error ? err.message : err, + ); + return this.primary.query(sql, params); + } + } + + async connect(): Promise { + // Transactions must use primary for consistency + return this.primary.connect(); + } + + async end(): Promise { + this.stopLagMonitoring(); + await this.primary.end(); + for (const pool of this.replicas.values()) { + await pool.end(); + } + } +} + +// ── Factory ─────────────────────────────────────────────────────────────────── + +export async function createReadWritePool( + options: ReadWritePoolOptions = {}, +): Promise { + const config = options.config ?? loadDatabaseConfig(); + const primary = options.primaryPool ?? (await createPool(config.primary)); + + const replicaPools = options.replicaPools ?? new Map(); + + if (replicaPools.size === 0 && config.replicas.length > 0) { + for (const endpoint of config.replicas) { + const poolConfig = replicaPoolConfig(endpoint, config.primary, config.replicaPoolSize); + const pool = await createPool(poolConfig); + replicaPools.set(endpoint.name, pool); + } + } + + const rwPool = new ReadWritePool(primary, replicaPools, config.replicas, config); + rwPool.startLagMonitoring(); + return rwPool; +} + +/** Apply routing response headers from query context onto an HTTP response. */ +export function applyRoutingHeaders( + context: QueryRoutingContext | undefined, + setHeader: (name: string, value: string) => void, +): void { + if (!context?.responseHeaders) return; + for (const [key, value] of context.responseHeaders) { + setHeader(key, value); + } +} + +/** + * Intercept response.writeHead/end so routing headers are attached before the + * body is sent (covers GraphQL and any handler that writes directly to res). + */ +export function attachRoutingHeaderInterceptor( + res: { + headersSent: boolean; + writeHead: (...args: unknown[]) => unknown; + end: (...args: unknown[]) => unknown; + setHeader: (name: string, value: string) => void; + }, + context: QueryRoutingContext, +): void { + const apply = () => applyRoutingHeaders(context, (k, v) => res.setHeader(k, v)); + + const originalWriteHead = res.writeHead.bind(res); + res.writeHead = (...args: unknown[]) => { + apply(); + return originalWriteHead(...args); + }; + + const originalEnd = res.end.bind(res); + res.end = (...args: unknown[]) => { + if (!res.headersSent) { + apply(); + } + return originalEnd(...args); + }; +} + +/** Create routing context from an incoming HTTP request. */ +export function createRoutingContextFromRequest( + headers: Record, +): QueryRoutingContext { + const staleAcceptSeconds = + parseStaleAcceptHeader(headers['x-stale-accept']) ?? + (headers['x-analytics-request'] ? loadDatabaseConfig().staleReadDefaultSeconds : undefined); + + return { + staleAcceptSeconds, + responseHeaders: new Map(), + }; +} + +export type { PoolConfig }; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..9a93f88c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,153 @@ +# Local development services for SubTrackr backend. +# +# Usage: +# docker compose up -d +# npm run server:start +# +# With read-replica routing (PgBouncer → replica simulation): +# DB_READ_REPLICAS=localhost:6433,localhost:6434 npm run server:start +# +# Environment (optional .env): +# DB_HOST=localhost DB_PORT=6432 DB_NAME=subtrackr DB_USER=postgres DB_PASSWORD=postgres +# DB_READ_REPLICAS=localhost:6433,localhost:6434 +# REDIS_HOST=localhost REDIS_PORT=6379 + +services: + redis: + image: redis:7-alpine + container_name: subtrackr-redis + ports: + - '${REDIS_PORT:-6379}:6379' + command: ['redis-server', '--save', '', '--appendonly', 'no'] + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 + volumes: + - redis-data:/data + + postgres-primary: + image: postgres:16-alpine + container_name: subtrackr-postgres-primary + ports: + - '${DB_PORT:-5432}:5432' + environment: + POSTGRES_DB: ${DB_NAME:-subtrackr} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-subtrackr}'] + interval: 5s + timeout: 3s + retries: 5 + volumes: + - postgres-primary-data:/var/lib/postgresql/data + + # Read-replica simulation — separate Postgres instances for routing tests. + # In production, replicas stream from the primary via RDS. + postgres-replica-1: + image: postgres:16-alpine + container_name: subtrackr-postgres-replica-1 + ports: + - '5433:5432' + environment: + POSTGRES_DB: ${DB_NAME:-subtrackr} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-subtrackr}'] + interval: 5s + timeout: 3s + retries: 5 + volumes: + - postgres-replica-1-data:/var/lib/postgresql/data + + postgres-replica-2: + image: postgres:16-alpine + container_name: subtrackr-postgres-replica-2 + ports: + - '5434:5432' + environment: + POSTGRES_DB: ${DB_NAME:-subtrackr} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-subtrackr}'] + interval: 5s + timeout: 3s + retries: 5 + volumes: + - postgres-replica-2-data:/var/lib/postgresql/data + + # PgBouncer connection poolers (default pool size: 25) + pgbouncer-primary: + image: edoburu/pgbouncer:1.22.1-p0 + container_name: subtrackr-pgbouncer-primary + ports: + - '6432:5432' + environment: + DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres-primary:5432/${DB_NAME:-subtrackr} + POOL_MODE: transaction + DEFAULT_POOL_SIZE: ${DB_REPLICA_POOL_SIZE:-25} + MAX_CLIENT_CONN: 200 + AUTH_TYPE: plain + depends_on: + postgres-primary: + condition: service_healthy + + pgbouncer-replica-1: + image: edoburu/pgbouncer:1.22.1-p0 + container_name: subtrackr-pgbouncer-replica-1 + ports: + - '6433:5432' + environment: + DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres-replica-1:5432/${DB_NAME:-subtrackr} + POOL_MODE: transaction + DEFAULT_POOL_SIZE: ${DB_REPLICA_POOL_SIZE:-25} + MAX_CLIENT_CONN: 200 + AUTH_TYPE: plain + depends_on: + postgres-replica-1: + condition: service_healthy + + pgbouncer-replica-2: + image: edoburu/pgbouncer:1.22.1-p0 + container_name: subtrackr-pgbouncer-replica-2 + ports: + - '6434:5432' + environment: + DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres-replica-2:5432/${DB_NAME:-subtrackr} + POOL_MODE: transaction + DEFAULT_POOL_SIZE: ${DB_REPLICA_POOL_SIZE:-25} + MAX_CLIENT_CONN: 200 + AUTH_TYPE: plain + depends_on: + postgres-replica-2: + condition: service_healthy + + # Backward-compatible alias for scripts expecting `postgres` service name + postgres: + image: postgres:16-alpine + container_name: subtrackr-postgres + profiles: ['legacy'] + ports: + - '5435:5432' + environment: + POSTGRES_DB: ${DB_NAME:-subtrackr} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-subtrackr}'] + interval: 5s + timeout: 3s + retries: 5 + volumes: + - postgres-data:/var/lib/postgresql/data + +volumes: + redis-data: + postgres-primary-data: + postgres-replica-1-data: + postgres-replica-2-data: + postgres-data: diff --git a/infra/pgbouncer/pgbouncer.ini b/infra/pgbouncer/pgbouncer.ini new file mode 100644 index 00000000..6b69cc56 --- /dev/null +++ b/infra/pgbouncer/pgbouncer.ini @@ -0,0 +1,22 @@ +# PgBouncer configuration for SubTrackr read replicas. +# Mount into pgbouncer-replica-* containers in docker-compose. +# +# Pool mode: transaction (suitable for read-heavy analytics workloads) +# Default pool size: 25 (matches DB_REPLICA_POOL_SIZE) + +[databases] +* = host=__PGBOUNCER_DB_HOST__ port=5432 dbname=subtrackr + +[pgbouncer] +listen_addr = 0.0.0.0 +listen_port = 6432 +auth_type = md5 +auth_file = /etc/pgbouncer/userlist.txt +pool_mode = transaction +default_pool_size = 25 +min_pool_size = 5 +reserve_pool_size = 5 +reserve_pool_timeout = 3 +max_client_conn = 200 +server_reset_query = DISCARD ALL +ignore_startup_parameters = extra_float_digits diff --git a/infra/pgbouncer/userlist.txt b/infra/pgbouncer/userlist.txt new file mode 100644 index 00000000..d2daf5ea --- /dev/null +++ b/infra/pgbouncer/userlist.txt @@ -0,0 +1,2 @@ +"postgres" "postgres" +"subtrackr" "postgres" diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 00000000..5cad6a25 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,25 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +data "aws_availability_zones" "available" { + state = "available" +} + +locals { + replica_azs = [ + data.aws_availability_zones.available.names[0], + data.aws_availability_zones.available.names[1], + ] +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 00000000..a8d0d7f0 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,42 @@ +output "primary_endpoint" { + description = "RDS primary (writer) endpoint hostname" + value = aws_db_instance.primary.address +} + +output "primary_port" { + description = "RDS primary port" + value = aws_db_instance.primary.port +} + +output "read_replica_endpoints" { + description = "Read replica endpoint hostnames (one per AZ)" + value = [for r in aws_db_instance.read_replica : r.address] +} + +output "read_replica_ports" { + description = "Read replica ports" + value = [for r in aws_db_instance.read_replica : r.port] +} + +output "read_replica_azs" { + description = "Availability zones for each read replica" + value = [for r in aws_db_instance.read_replica : r.availability_zone] +} + +output "db_read_replicas_env" { + description = "Value for DB_READ_REPLICAS environment variable (PgBouncer endpoints)" + value = join(",", [ + for i, r in aws_db_instance.read_replica : + "pgbouncer-replica-${i + 1}.${var.project_name}.internal:${6432 + i}" + ]) +} + +output "pgbouncer_pool_size" { + description = "Recommended PgBouncer pool size per replica" + value = var.pgbouncer_pool_size +} + +output "security_group_id" { + description = "Security group ID for RDS instances" + value = aws_security_group.rds.id +} diff --git a/infra/terraform/rds.tf b/infra/terraform/rds.tf new file mode 100644 index 00000000..9ed4d9d8 --- /dev/null +++ b/infra/terraform/rds.tf @@ -0,0 +1,180 @@ +# ── Networking ──────────────────────────────────────────────────────────────── + +resource "aws_db_subnet_group" "subtrackr" { + name = "${var.project_name}-${var.environment}-db-subnet" + subnet_ids = var.private_subnet_ids + + tags = { + Name = "${var.project_name}-${var.environment}-db-subnet" + Environment = var.environment + Project = var.project_name + } +} + +resource "aws_security_group" "rds" { + name = "${var.project_name}-${var.environment}-rds" + description = "PostgreSQL access for SubTrackr RDS primary and read replicas" + vpc_id = var.vpc_id + + ingress { + description = "PostgreSQL from application security groups" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = var.allowed_security_group_ids + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-${var.environment}-rds-sg" + Environment = var.environment + Project = var.project_name + } +} + +# ── Primary RDS instance ────────────────────────────────────────────────────── + +resource "aws_db_instance" "primary" { + identifier = "${var.project_name}-${var.environment}-primary" + + engine = "postgres" + engine_version = "16.4" + instance_class = var.db_instance_class + allocated_storage = var.db_allocated_storage_gb + storage_type = "gp3" + storage_encrypted = true + db_name = var.db_name + username = var.db_username + password = var.db_password + port = 5432 + + db_subnet_group_name = aws_db_subnet_group.subtrackr.name + vpc_security_group_ids = [aws_security_group.rds.id] + publicly_accessible = false + multi_az = true + + backup_retention_period = var.backup_retention_days + backup_window = "03:00-04:00" + maintenance_window = "sun:04:00-sun:05:00" + deletion_protection = var.deletion_protection + skip_final_snapshot = false + final_snapshot_identifier = "${var.project_name}-${var.environment}-final-snapshot" + copy_tags_to_snapshot = true + + performance_insights_enabled = var.performance_insights_enabled + + parameter_group_name = aws_db_parameter_group.postgres16.name + + tags = { + Name = "${var.project_name}-${var.environment}-primary" + Environment = var.environment + Project = var.project_name + Role = "primary" + } +} + +resource "aws_db_parameter_group" "postgres16" { + name = "${var.project_name}-${var.environment}-postgres16" + family = "postgres16" + + parameter { + name = "rds.logical_replication" + value = "1" + } + + parameter { + name = "max_connections" + value = "200" + } + + tags = { + Environment = var.environment + Project = var.project_name + } +} + +# ── Read replicas (2 AZs) ───────────────────────────────────────────────────── + +resource "aws_db_instance" "read_replica" { + count = 2 + + identifier = "${var.project_name}-${var.environment}-replica-${count.index + 1}" + replicate_source_db = aws_db_instance.primary.identifier + instance_class = var.replica_instance_class + storage_encrypted = true + + availability_zone = local.replica_azs[count.index] + + vpc_security_group_ids = [aws_security_group.rds.id] + publicly_accessible = false + + performance_insights_enabled = var.performance_insights_enabled + auto_minor_version_upgrade = true + + # Replicas inherit backup settings from primary; skip_final_snapshot for replicas + skip_final_snapshot = true + + tags = { + Name = "${var.project_name}-${var.environment}-replica-${count.index + 1}" + Environment = var.environment + Project = var.project_name + Role = "read-replica" + AZ = local.replica_azs[count.index] + } +} + +# ── CloudWatch alarms for replication lag ─────────────────────────────────────── + +resource "aws_cloudwatch_metric_alarm" "replication_lag_p99" { + count = 2 + + alarm_name = "${var.project_name}-${var.environment}-replica-${count.index + 1}-lag-p99" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "ReplicaLag" + namespace = "AWS/RDS" + period = 60 + statistic = "p99" + threshold = 1 + alarm_description = "P99 replication lag exceeds 1 second on read replica ${count.index + 1}" + treat_missing_data = "notBreaching" + + dimensions = { + DBInstanceIdentifier = aws_db_instance.read_replica[count.index].identifier + } + + tags = { + Environment = var.environment + Project = var.project_name + } +} + +resource "aws_cloudwatch_metric_alarm" "replication_lag_failover" { + count = 2 + + alarm_name = "${var.project_name}-${var.environment}-replica-${count.index + 1}-lag-failover" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "ReplicaLag" + namespace = "AWS/RDS" + period = 60 + statistic = "Maximum" + threshold = 5 + alarm_description = "Replication lag exceeds 5s — application should route reads to primary" + treat_missing_data = "notBreaching" + + dimensions = { + DBInstanceIdentifier = aws_db_instance.read_replica[count.index].identifier + } + + tags = { + Environment = var.environment + Project = var.project_name + } +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 00000000..4fef470a --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,93 @@ +variable "aws_region" { + description = "AWS region for RDS and networking resources" + type = string + default = "us-east-1" +} + +variable "environment" { + description = "Deployment environment (dev, staging, prod)" + type = string + default = "dev" +} + +variable "project_name" { + description = "Project name used in resource naming" + type = string + default = "subtrackr" +} + +variable "db_instance_class" { + description = "RDS instance class for the primary database" + type = string + default = "db.t3.medium" +} + +variable "db_allocated_storage_gb" { + description = "Allocated storage for the primary RDS instance (GB)" + type = number + default = 100 +} + +variable "db_name" { + description = "PostgreSQL database name" + type = string + default = "subtrackr" +} + +variable "db_username" { + description = "Master username for the RDS instance" + type = string + default = "subtrackr_admin" +} + +variable "db_password" { + description = "Master password for the RDS instance (override via TF_VAR_db_password)" + type = string + sensitive = true +} + +variable "vpc_id" { + description = "VPC ID where RDS will be provisioned" + type = string +} + +variable "private_subnet_ids" { + description = "Private subnet IDs for the RDS subnet group (minimum 2 AZs)" + type = list(string) +} + +variable "allowed_security_group_ids" { + description = "Security groups allowed to connect to RDS (app servers, PgBouncer)" + type = list(string) + default = [] +} + +variable "replica_instance_class" { + description = "RDS instance class for read replicas" + type = string + default = "db.t3.medium" +} + +variable "pgbouncer_pool_size" { + description = "Default PgBouncer pool size per replica" + type = number + default = 25 +} + +variable "backup_retention_days" { + description = "Number of days to retain automated backups" + type = number + default = 7 +} + +variable "deletion_protection" { + description = "Enable deletion protection on the primary instance" + type = bool + default = true +} + +variable "performance_insights_enabled" { + description = "Enable Performance Insights on RDS instances" + type = bool + default = true +} From 2e4caba089796a8f7aa2fb3a50b83bce5f9d6bc0 Mon Sep 17 00:00:00 2001 From: od-hunter Date: Wed, 24 Jun 2026 15:11:52 +0100 Subject: [PATCH 2/3] chore: automated code formatting fixes via CI pipeline --- src/design-system/DESIGN_SYSTEM.md | 37 +- src/design-system/__tests__/Button.test.tsx | 54 +- .../__tests__/visualRegression.e2e.ts | 9 +- src/design-system/components/Button.tsx | 34 +- src/design-system/components/Card.tsx | 19 +- src/design-system/components/Input.tsx | 27 +- src/design-system/components/Modal.tsx | 9 +- src/design-system/components/Toast.tsx | 21 +- src/design-system/stories/Button.stories.tsx | 17 +- src/design-system/tokens/animations.ts | 12 +- src/design-system/tokens/shadows.ts | 9 +- src/design-system/tokens/spacing.ts | 7 +- src/design-system/tokens/typography.ts | 5 +- src/design-system/utils/platform.ts | 2 +- src/design-system/utils/rtl.ts | 5 +- src/hooks/useDebounce.ts | 3 +- src/hooks/useFilteredSubscriptions.ts | 5 +- src/hooks/useSubscriptionFilters.ts | 5 +- src/screens/DunningDashboard.tsx | 45 +- src/store/authStore.ts | 6 +- src/store/developerPortalStore.ts | 524 +++++------ src/store/dunningStore.ts | 10 +- src/store/index.ts | 1 - src/store/walletStore.ts | 890 +++++++++--------- src/utils/storage.ts | 7 +- 25 files changed, 840 insertions(+), 923 deletions(-) diff --git a/src/design-system/DESIGN_SYSTEM.md b/src/design-system/DESIGN_SYSTEM.md index 74e57cb0..9aa7cbd3 100644 --- a/src/design-system/DESIGN_SYSTEM.md +++ b/src/design-system/DESIGN_SYSTEM.md @@ -1,9 +1,10 @@ -/** - * SubTrackr Design System - Complete Documentation - * - * This document provides comprehensive guidance on using the design system - * for consistent, accessible, and maintainable UI development. - */ +/\*\* + +- SubTrackr Design System - Complete Documentation +- +- This document provides comprehensive guidance on using the design system +- for consistent, accessible, and maintainable UI development. + \*/ # SubTrackr Design System Documentation @@ -28,6 +29,7 @@ The SubTrackr Design System provides a comprehensive set of design tokens, compo The design system uses a semantic color system with three built-in themes: #### Dark Theme (Default) + - **Primary**: #6366f1 (Indigo) - **Secondary**: #8b5cf6 (Purple) - **Accent**: #06b6d4 (Cyan) @@ -37,9 +39,11 @@ The design system uses a semantic color system with three built-in themes: - **Info**: #0ea5e9 (Sky) #### Light Theme + Optimized for daytime use with adjusted saturation and contrast. #### High Contrast Theme + WCAG AAA compliant with 7:1 minimum contrast ratios for accessibility. ### Spacing @@ -343,10 +347,10 @@ All components support standard accessibility props: ```typescript interface AccessibilityProps { - accessibilityLabel: string; // Required - screen reader label - accessibilityHint?: string; // Optional - additional context - accessibilityRole?: string; // Optional - semantic role - testID?: string; // Optional - testing identifier + accessibilityLabel: string; // Required - screen reader label + accessibilityHint?: string; // Optional - additional context + accessibilityRole?: string; // Optional - semantic role + testID?: string; // Optional - testing identifier } ``` @@ -420,8 +424,8 @@ if (isIOS()) { // Conditional values const elevation = getPlatformValue( - 4, // iOS shadow - 8, // Android elevation + 4, // iOS shadow + 8, // Android elevation '0 2px 4px rgba(0,0,0,0.1)' // Web box-shadow ); ``` @@ -493,6 +497,7 @@ npm test src/design-system ``` Tests cover: + - Component rendering - User interactions - Accessibility compliance @@ -506,6 +511,7 @@ npm run e2e:test-android ``` E2E tests verify: + - Visual consistency across platforms - Theme application - RTL layout @@ -531,15 +537,15 @@ const handleChange = (field: string, value: string) => { const handleSubmit = () => { const newErrors: Record = {}; - + if (!isValidEmail(formData.email)) { newErrors.email = 'Invalid email address'; } - + if (!isValidAmount(formData.amount)) { newErrors.amount = 'Amount must be between $1-$1000'; } - + if (Object.keys(newErrors).length === 0) { // Submit form } else { @@ -612,6 +618,7 @@ To migrate existing components to use the design system: ## Support For questions or issues with the design system: + 1. Check this documentation 2. Review Storybook examples 3. Check existing tests diff --git a/src/design-system/__tests__/Button.test.tsx b/src/design-system/__tests__/Button.test.tsx index a5249383..a5209338 100644 --- a/src/design-system/__tests__/Button.test.tsx +++ b/src/design-system/__tests__/Button.test.tsx @@ -13,20 +13,22 @@ describe('Button Component', () => { // ======================================================================== it('should render with label text', () => { - render( -