mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-29 18:47:25 +08:00
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:
@@ -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 .",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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,3 +1,4 @@
|
||||
export * from './connection-url'
|
||||
export * from './db'
|
||||
export * from './schema'
|
||||
export * from './triggers'
|
||||
|
||||
Reference in New Issue
Block a user