From 98c5fdd26d37586c22050494e3bf976fae153b51 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 29 Jun 2026 18:52:05 -0700 Subject: [PATCH 1/2] feat(db): resolve DATABASE_URL per role (DATABASE_URL_ with fallback) --- apps/realtime/src/database/operations.ts | 6 ++- apps/realtime/src/env.ts | 2 + apps/sim/lib/core/config/env.ts | 6 +++ packages/db/connection-url.test.ts | 57 ++++++++++++++++++++++++ packages/db/connection-url.ts | 12 +++++ packages/db/db.ts | 16 ++++--- packages/db/index.ts | 1 + 7 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 packages/db/connection-url.test.ts create mode 100644 packages/db/connection-url.ts diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 9fbda3fe27d..4c2735a2087 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import * as schema from '@sim/db' import { instrumentPoolClient, + resolveDbUrl, workflow, workflowBlocks, workflowEdges, @@ -31,7 +32,10 @@ import { env } from '@/env' const logger = createLogger('SocketDatabase') -const connectionString = env.DATABASE_URL +// Both realtime pools (this socketDb + the shared @sim/db pool) resolve the +// realtime-keyed URL when set, falling back to the shared DATABASE_URL. +const connectionString = + resolveDbUrl('DATABASE_URL', process.env.SIM_DB_ROLE ?? 'realtime') ?? env.DATABASE_URL // Realtime process footprint = this socketDb pool + the shared @sim/db pool. const socketDb = drizzle( instrumentPoolClient( diff --git a/apps/realtime/src/env.ts b/apps/realtime/src/env.ts index fb1f37a391a..5afdf3a20f3 100644 --- a/apps/realtime/src/env.ts +++ b/apps/realtime/src/env.ts @@ -3,6 +3,8 @@ import { z } from 'zod' const EnvSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), DATABASE_URL: z.string().url(), + DATABASE_URL_REALTIME: z.string().url().optional(), + DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), REDIS_URL: z.preprocess( (value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), z.string().url().optional() diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5f313138a3f..4458c25e3db 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -22,6 +22,12 @@ export const env = createEnv({ DATABASE_REPLICA_URL: z.string().url().optional(), // Read-replica connection string; opt-in reads fall back to the primary when unset DB_APP_NAME: z.string().optional(), // Postgres application_name for query attribution (sim-app/sim-trigger/sim-realtime) SIM_DB_ROLE: z.enum(['web', 'trigger', 'realtime']).optional(), // Per-process pool profile selector (read directly by @sim/db) + DATABASE_URL_WEB: z.string().url().optional(), // Per-role primary URL override; @sim/db falls back to DATABASE_URL + DATABASE_URL_TRIGGER: z.string().url().optional(), // Per-role primary URL override (trigger) + DATABASE_URL_REALTIME: z.string().url().optional(), // Per-role primary URL override (realtime) + DATABASE_REPLICA_URL_WEB: z.string().url().optional(), // Per-role replica URL override; falls back to DATABASE_REPLICA_URL + DATABASE_REPLICA_URL_TRIGGER: z.string().url().optional(), // Per-role replica URL override (trigger) + DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), // Per-role replica URL override (realtime) BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration diff --git a/packages/db/connection-url.test.ts b/packages/db/connection-url.test.ts new file mode 100644 index 00000000000..5bc0b3f12db --- /dev/null +++ b/packages/db/connection-url.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resolveDbUrl } from './connection-url' + +describe('resolveDbUrl', () => { + const KEYS = [ + 'DATABASE_URL', + 'DATABASE_URL_WEB', + 'DATABASE_URL_TRIGGER', + 'DATABASE_REPLICA_URL', + 'DATABASE_REPLICA_URL_TRIGGER', + ] as const + const saved: Record = {} + + beforeEach(() => { + for (const key of KEYS) { + saved[key] = process.env[key] + delete process.env[key] + } + }) + + afterEach(() => { + for (const key of KEYS) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + }) + + it('prefers the role-keyed primary URL over the base', () => { + process.env.DATABASE_URL = 'postgres://base/db' + process.env.DATABASE_URL_TRIGGER = 'postgres://trigger/db' + expect(resolveDbUrl('DATABASE_URL', 'trigger')).toBe('postgres://trigger/db') + }) + + it('falls back to the base URL when the keyed var is unset', () => { + process.env.DATABASE_URL = 'postgres://base/db' + expect(resolveDbUrl('DATABASE_URL', 'web')).toBe('postgres://base/db') + }) + + it('returns undefined when neither keyed nor base is set', () => { + expect(resolveDbUrl('DATABASE_URL', 'realtime')).toBeUndefined() + }) + + it('resolves the replica variant independently of the primary', () => { + process.env.DATABASE_REPLICA_URL = 'postgres://replica/db' + process.env.DATABASE_REPLICA_URL_TRIGGER = 'postgres://trigger-replica/db' + expect(resolveDbUrl('DATABASE_REPLICA_URL', 'trigger')).toBe('postgres://trigger-replica/db') + expect(resolveDbUrl('DATABASE_REPLICA_URL', 'web')).toBe('postgres://replica/db') + }) + + it('uppercases the role to build the keyed var name', () => { + process.env.DATABASE_URL_WEB = 'postgres://web/db' + expect(resolveDbUrl('DATABASE_URL', 'web')).toBe('postgres://web/db') + }) +}) diff --git a/packages/db/connection-url.ts b/packages/db/connection-url.ts new file mode 100644 index 00000000000..14203ee6423 --- /dev/null +++ b/packages/db/connection-url.ts @@ -0,0 +1,12 @@ +/** + * Resolve a connection URL for the active DB role, preferring the role-keyed + * variant (e.g. `DATABASE_URL_TRIGGER`) and falling back to the shared base. + * Lets each deploy point its surface at its own Postgres user + PgBouncer via + * env alone; unset keyed vars preserve the prior single-URL behavior. + */ +export function resolveDbUrl( + base: 'DATABASE_URL' | 'DATABASE_REPLICA_URL', + role: string +): string | undefined { + return process.env[`${base}_${role.toUpperCase()}`] ?? process.env[base] +} diff --git a/packages/db/db.ts b/packages/db/db.ts index af469947f15..a122948995e 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -1,13 +1,9 @@ import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' +import { resolveDbUrl } from './connection-url' import * as schema from './schema' import { instrumentPoolClient } from './tx-tripwire' -const connectionString = process.env.DATABASE_URL! -if (!connectionString) { - throw new Error('Missing DATABASE_URL environment variable') -} - /** * Per-role pool profiles. Starting numbers — validate against real per-role * process counts (PgBouncer transaction mode, max_connections=200). @@ -28,7 +24,13 @@ if (roleEnv && !Object.hasOwn(DB_POOL_PROFILES, roleEnv)) { `Invalid SIM_DB_ROLE '${roleEnv}' — expected one of ${Object.keys(DB_POOL_PROFILES).join(', ')} (or unset for web)` ) } -const profile = DB_POOL_PROFILES[(roleEnv as DbRole) || 'web'] +const role = (roleEnv as DbRole) || 'web' +const profile = DB_POOL_PROFILES[role] + +const connectionString = resolveDbUrl('DATABASE_URL', role) +if (!connectionString) { + throw new Error('Missing DATABASE_URL environment variable') +} const poolOptions = { prepare: false, @@ -51,7 +53,7 @@ export const db = drizzle(postgresClient, { schema }) * for auth, workflow state, or billing enforcement. Falls back to the primary * when `DATABASE_REPLICA_URL` is unset, so call sites never branch. */ -const replicaUrl = process.env.DATABASE_REPLICA_URL +const replicaUrl = resolveDbUrl('DATABASE_REPLICA_URL', role) if (replicaUrl && !/^postgres(ql)?:\/\//.test(replicaUrl)) { throw new Error( 'DATABASE_REPLICA_URL is set but is not a postgres:// DSN — fix the URL or unset the variable' diff --git a/packages/db/index.ts b/packages/db/index.ts index a28cf889527..a472c56fa07 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,3 +1,4 @@ +export * from './connection-url' export * from './db' export * from './schema' export * from './triggers' From 6cdb5a679f5a733232769c8bca5e05ffe7bd0dc9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 29 Jun 2026 19:01:06 -0700 Subject: [PATCH 2/2] fix(db): pin realtime process to SIM_DB_ROLE=realtime so both pools share the role Without it, the realtime process left SIM_DB_ROLE unset: the shared @sim/db client defaulted role to 'web' (web pool profile + DATABASE_URL_WEB) while socketDb used 'realtime', so the two pools diverged after cutover. Set it at the process level (bootstrap + dev/start scripts), mirroring DB_APP_NAME, so the shared client and socketDb both resolve the realtime profile and URL. --- apps/realtime/package.json | 4 ++-- apps/realtime/src/bootstrap.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/realtime/package.json b/apps/realtime/package.json index ce9bbdaec52..83ca341b44d 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -9,8 +9,8 @@ "node": ">=20.0.0" }, "scripts": { - "dev": "DB_APP_NAME=sim-realtime bun --watch src/index.ts", - "start": "DB_APP_NAME=sim-realtime bun src/index.ts", + "dev": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun --watch src/index.ts", + "start": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun src/index.ts", "type-check": "tsc --noEmit", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", diff --git a/apps/realtime/src/bootstrap.ts b/apps/realtime/src/bootstrap.ts index 1eaea2b80c2..bf0840fb05f 100644 --- a/apps/realtime/src/bootstrap.ts +++ b/apps/realtime/src/bootstrap.ts @@ -7,10 +7,14 @@ import { loadRuntimeSecrets } from '@sim/runtime-secrets' await loadRuntimeSecrets() /** - * Label every Postgres connection this process opens as `sim-realtime` — both - * the realtime `socketDb` pool and the shared `@sim/db` client used by handlers, - * preflight, and permissions. Set before importing `@/index` so it lands before - * `@sim/db` reads it at module-eval time. `??=` respects an explicit override. + * Pin this process to the `realtime` DB role — covering both the realtime + * `socketDb` pool and the shared `@sim/db` client used by handlers, preflight, + * and permissions. The role drives the pool-size profile, `application_name`, + * and the role-keyed connection URL, so every realtime connection resolves + * consistently (without it the shared client would default to `web`). Set + * before importing `@/index` so it lands before `@sim/db` reads it at + * module-eval time; `??=` respects an explicit override. */ +process.env.SIM_DB_ROLE ??= 'realtime' process.env.DB_APP_NAME ??= 'sim-realtime' await import('@/index')