Skip to content

Commit 0613ceb

Browse files
feat(db): resolve DATABASE_URL per role (DATABASE_URL_<ROLE> with fallback) (#5276)
* feat(db): resolve DATABASE_URL per role (DATABASE_URL_<ROLE> with fallback) * 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.
1 parent 8bf1989 commit 0613ceb

9 files changed

Lines changed: 102 additions & 14 deletions

File tree

apps/realtime/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
"node": ">=20.0.0"
1010
},
1111
"scripts": {
12-
"dev": "DB_APP_NAME=sim-realtime bun --watch src/index.ts",
13-
"start": "DB_APP_NAME=sim-realtime bun src/index.ts",
12+
"dev": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun --watch src/index.ts",
13+
"start": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun src/index.ts",
1414
"type-check": "tsc --noEmit",
1515
"lint": "biome check --write --unsafe .",
1616
"lint:check": "biome check .",

apps/realtime/src/bootstrap.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import { loadRuntimeSecrets } from '@sim/runtime-secrets'
77

88
await loadRuntimeSecrets()
99
/**
10-
* Label every Postgres connection this process opens as `sim-realtime` — both
11-
* the realtime `socketDb` pool and the shared `@sim/db` client used by handlers,
12-
* preflight, and permissions. Set before importing `@/index` so it lands before
13-
* `@sim/db` reads it at module-eval time. `??=` respects an explicit override.
10+
* Pin this process to the `realtime` DB role — covering both the realtime
11+
* `socketDb` pool and the shared `@sim/db` client used by handlers, preflight,
12+
* and permissions. The role drives the pool-size profile, `application_name`,
13+
* and the role-keyed connection URL, so every realtime connection resolves
14+
* consistently (without it the shared client would default to `web`). Set
15+
* before importing `@/index` so it lands before `@sim/db` reads it at
16+
* module-eval time; `??=` respects an explicit override.
1417
*/
18+
process.env.SIM_DB_ROLE ??= 'realtime'
1519
process.env.DB_APP_NAME ??= 'sim-realtime'
1620
await import('@/index')

apps/realtime/src/database/operations.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import * as schema from '@sim/db'
33
import {
44
instrumentPoolClient,
5+
resolveDbUrl,
56
workflow,
67
workflowBlocks,
78
workflowEdges,
@@ -31,7 +32,10 @@ import { env } from '@/env'
3132

3233
const logger = createLogger('SocketDatabase')
3334

34-
const connectionString = env.DATABASE_URL
35+
// Both realtime pools (this socketDb + the shared @sim/db pool) resolve the
36+
// realtime-keyed URL when set, falling back to the shared DATABASE_URL.
37+
const connectionString =
38+
resolveDbUrl('DATABASE_URL', process.env.SIM_DB_ROLE ?? 'realtime') ?? env.DATABASE_URL
3539
// Realtime process footprint = this socketDb pool + the shared @sim/db pool.
3640
const socketDb = drizzle(
3741
instrumentPoolClient(

apps/realtime/src/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { z } from 'zod'
33
const EnvSchema = z.object({
44
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
55
DATABASE_URL: z.string().url(),
6+
DATABASE_URL_REALTIME: z.string().url().optional(),
7+
DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(),
68
REDIS_URL: z.preprocess(
79
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
810
z.string().url().optional()

apps/sim/lib/core/config/env.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export const env = createEnv({
2222
DATABASE_REPLICA_URL: z.string().url().optional(), // Read-replica connection string; opt-in reads fall back to the primary when unset
2323
DB_APP_NAME: z.string().optional(), // Postgres application_name for query attribution (sim-app/sim-trigger/sim-realtime)
2424
SIM_DB_ROLE: z.enum(['web', 'trigger', 'realtime']).optional(), // Per-process pool profile selector (read directly by @sim/db)
25+
DATABASE_URL_WEB: z.string().url().optional(), // Per-role primary URL override; @sim/db falls back to DATABASE_URL
26+
DATABASE_URL_TRIGGER: z.string().url().optional(), // Per-role primary URL override (trigger)
27+
DATABASE_URL_REALTIME: z.string().url().optional(), // Per-role primary URL override (realtime)
28+
DATABASE_REPLICA_URL_WEB: z.string().url().optional(), // Per-role replica URL override; falls back to DATABASE_REPLICA_URL
29+
DATABASE_REPLICA_URL_TRIGGER: z.string().url().optional(), // Per-role replica URL override (trigger)
30+
DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), // Per-role replica URL override (realtime)
2531
BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service
2632
BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing
2733
DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration

packages/db/connection-url.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { resolveDbUrl } from './connection-url'
6+
7+
describe('resolveDbUrl', () => {
8+
const KEYS = [
9+
'DATABASE_URL',
10+
'DATABASE_URL_WEB',
11+
'DATABASE_URL_TRIGGER',
12+
'DATABASE_REPLICA_URL',
13+
'DATABASE_REPLICA_URL_TRIGGER',
14+
] as const
15+
const saved: Record<string, string | undefined> = {}
16+
17+
beforeEach(() => {
18+
for (const key of KEYS) {
19+
saved[key] = process.env[key]
20+
delete process.env[key]
21+
}
22+
})
23+
24+
afterEach(() => {
25+
for (const key of KEYS) {
26+
if (saved[key] === undefined) delete process.env[key]
27+
else process.env[key] = saved[key]
28+
}
29+
})
30+
31+
it('prefers the role-keyed primary URL over the base', () => {
32+
process.env.DATABASE_URL = 'postgres://base/db'
33+
process.env.DATABASE_URL_TRIGGER = 'postgres://trigger/db'
34+
expect(resolveDbUrl('DATABASE_URL', 'trigger')).toBe('postgres://trigger/db')
35+
})
36+
37+
it('falls back to the base URL when the keyed var is unset', () => {
38+
process.env.DATABASE_URL = 'postgres://base/db'
39+
expect(resolveDbUrl('DATABASE_URL', 'web')).toBe('postgres://base/db')
40+
})
41+
42+
it('returns undefined when neither keyed nor base is set', () => {
43+
expect(resolveDbUrl('DATABASE_URL', 'realtime')).toBeUndefined()
44+
})
45+
46+
it('resolves the replica variant independently of the primary', () => {
47+
process.env.DATABASE_REPLICA_URL = 'postgres://replica/db'
48+
process.env.DATABASE_REPLICA_URL_TRIGGER = 'postgres://trigger-replica/db'
49+
expect(resolveDbUrl('DATABASE_REPLICA_URL', 'trigger')).toBe('postgres://trigger-replica/db')
50+
expect(resolveDbUrl('DATABASE_REPLICA_URL', 'web')).toBe('postgres://replica/db')
51+
})
52+
53+
it('uppercases the role to build the keyed var name', () => {
54+
process.env.DATABASE_URL_WEB = 'postgres://web/db'
55+
expect(resolveDbUrl('DATABASE_URL', 'web')).toBe('postgres://web/db')
56+
})
57+
})

packages/db/connection-url.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Resolve a connection URL for the active DB role, preferring the role-keyed
3+
* variant (e.g. `DATABASE_URL_TRIGGER`) and falling back to the shared base.
4+
* Lets each deploy point its surface at its own Postgres user + PgBouncer via
5+
* env alone; unset keyed vars preserve the prior single-URL behavior.
6+
*/
7+
export function resolveDbUrl(
8+
base: 'DATABASE_URL' | 'DATABASE_REPLICA_URL',
9+
role: string
10+
): string | undefined {
11+
return process.env[`${base}_${role.toUpperCase()}`] ?? process.env[base]
12+
}

packages/db/db.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import { drizzle } from 'drizzle-orm/postgres-js'
22
import postgres from 'postgres'
3+
import { resolveDbUrl } from './connection-url'
34
import * as schema from './schema'
45
import { instrumentPoolClient } from './tx-tripwire'
56

6-
const connectionString = process.env.DATABASE_URL!
7-
if (!connectionString) {
8-
throw new Error('Missing DATABASE_URL environment variable')
9-
}
10-
117
/**
128
* Per-role pool profiles. Starting numbers — validate against real per-role
139
* process counts (PgBouncer transaction mode, max_connections=200).
@@ -28,7 +24,13 @@ if (roleEnv && !Object.hasOwn(DB_POOL_PROFILES, roleEnv)) {
2824
`Invalid SIM_DB_ROLE '${roleEnv}' — expected one of ${Object.keys(DB_POOL_PROFILES).join(', ')} (or unset for web)`
2925
)
3026
}
31-
const profile = DB_POOL_PROFILES[(roleEnv as DbRole) || 'web']
27+
const role = (roleEnv as DbRole) || 'web'
28+
const profile = DB_POOL_PROFILES[role]
29+
30+
const connectionString = resolveDbUrl('DATABASE_URL', role)
31+
if (!connectionString) {
32+
throw new Error('Missing DATABASE_URL environment variable')
33+
}
3234

3335
const poolOptions = {
3436
prepare: false,
@@ -51,7 +53,7 @@ export const db = drizzle(postgresClient, { schema })
5153
* for auth, workflow state, or billing enforcement. Falls back to the primary
5254
* when `DATABASE_REPLICA_URL` is unset, so call sites never branch.
5355
*/
54-
const replicaUrl = process.env.DATABASE_REPLICA_URL
56+
const replicaUrl = resolveDbUrl('DATABASE_REPLICA_URL', role)
5557
if (replicaUrl && !/^postgres(ql)?:\/\//.test(replicaUrl)) {
5658
throw new Error(
5759
'DATABASE_REPLICA_URL is set but is not a postgres:// DSN — fix the URL or unset the variable'

packages/db/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './connection-url'
12
export * from './db'
23
export * from './schema'
34
export * from './triggers'

0 commit comments

Comments
 (0)