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.
This commit is contained in:
Theodore Li
2026-06-30 15:17:55 -04:00
committed by GitHub
parent 8bf1989060
commit 0613cebfab
9 changed files with 102 additions and 14 deletions
+2 -2
View File
@@ -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 .",
+8 -4
View File
@@ -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')
+5 -1
View File
@@ -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(
+2
View File
@@ -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()
+6
View File
@@ -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
+57
View File
@@ -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<string, string | undefined> = {}
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')
})
})
+12
View File
@@ -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]
}
+9 -7
View File
@@ -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'
+1
View File
@@ -1,3 +1,4 @@
export * from './connection-url'
export * from './db'
export * from './schema'
export * from './triggers'