Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/realtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
12 changes: 8 additions & 4 deletions apps/realtime/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
6 changes: 5 additions & 1 deletion apps/realtime/src/database/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import * as schema from '@sim/db'
import {
instrumentPoolClient,
resolveDbUrl,
workflow,
workflowBlocks,
workflowEdges,
Expand Down Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
TheodoreSpeaks marked this conversation as resolved.
// Realtime process footprint = this socketDb pool + the shared @sim/db pool.
const socketDb = drizzle(
instrumentPoolClient(
Expand Down
2 changes: 2 additions & 0 deletions apps/realtime/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions packages/db/connection-url.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions packages/db/connection-url.ts
Original file line number Diff line number Diff line change
@@ -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]
}
16 changes: 9 additions & 7 deletions packages/db/db.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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,
Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions packages/db/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './connection-url'
export * from './db'
export * from './schema'
export * from './triggers'
Expand Down
Loading