From 383f649e2fa2c1f0248a37b07fe3af733ed17a88 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Sat, 4 Jul 2026 12:14:19 +0100 Subject: [PATCH 1/3] feat(run-ops): automatically migrate the dedicated run-ops database Adds the ability to migrate the NEW dedicated run-ops database automatically, matching how every other DB in the system is migrated. - New `@internal/run-ops-database` migrate runner (scripts/migrate.mjs) exposed as `db:migrate:deploy` / `db:migrate:status`. Resolves the connection the same way the webapp does (RUN_OPS_DATABASE_URL, falling back to TASK_RUN_DATABASE_URL; direct endpoint preferred for migrations) and expands ${VAR} refs like Prisma's dotenv. - docker/scripts/entrypoint.sh runs the run-ops migration on boot when the DB is configured, gated by SKIP_RUN_OPS_MIGRATIONS. Single-DB installs are a no-op. - Unify the NEW-DB connection URL on a single canonical `runOpsNewDatabaseUrl` (RUN_OPS_DATABASE_URL ?? TASK_RUN_DATABASE_URL) across every consumer (connect path, split-decision predicates, replication source) so migrations always target the DB the app connects to. Co-Authored-By: Claude Opus 4.8 (1M context) --- .server-changes/run-ops-auto-migrate.md | 6 ++ apps/webapp/app/db.server.ts | 6 +- apps/webapp/app/env.server.ts | 22 +++++- .../runsReplicationInstance.server.ts | 4 +- .../controlPlaneResolver.server.ts | 4 +- .../v3/runOpsMigration/splitMode.server.ts | 4 +- apps/webapp/app/v3/runStore.server.ts | 4 +- docker/scripts/entrypoint.sh | 14 ++++ .../run-ops-database/package.json | 3 +- .../run-ops-database/scripts/migrate.mjs | 78 +++++++++++++++++++ 10 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 .server-changes/run-ops-auto-migrate.md create mode 100644 internal-packages/run-ops-database/scripts/migrate.mjs diff --git a/.server-changes/run-ops-auto-migrate.md b/.server-changes/run-ops-auto-migrate.md new file mode 100644 index 00000000000..b544a0fac84 --- /dev/null +++ b/.server-changes/run-ops-auto-migrate.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Automatically migrate the dedicated run-ops database on deploy (entrypoint + `@internal/run-ops-database` deploy/status scripts) and resolve its connection through one canonical `RUN_OPS_DATABASE_URL` (falling back to `TASK_RUN_DATABASE_URL`) so migrations always target the DB the app connects to. diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 8fc033dbf03..8da825b8fdf 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -10,7 +10,7 @@ import { import { RunOpsPrismaClient } from "@internal/run-ops-database"; import invariant from "tiny-invariant"; import { z } from "zod"; -import { env } from "./env.server"; +import { env, runOpsNewDatabaseUrl } from "./env.server"; import { logger } from "./services/logger.server"; import { isValidDatabaseUrl } from "./utils/db"; import { @@ -237,7 +237,7 @@ export function selectRunOpsTopology( // nothing new. The builders apply the SAME wrapper pair the control-plane // singletons use (captureInfrastructureErrors(tagDatasource(role, raw))). const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { - const newUrl = env.TASK_RUN_DATABASE_URL; + const newUrl = runOpsNewDatabaseUrl; // Gate on the opt-in flag too: the distinct-DB sentinel only runs when the flag is on. const splitEnabled = env.RUN_OPS_SPLIT_ENABLED && !!newUrl && !!env.TASK_RUN_LEGACY_DATABASE_URL; @@ -278,7 +278,7 @@ export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({ newReplica: runOpsNewReplicaClient, controlPlaneWriter: prisma, controlPlaneReplica: $replica, - hasNewUrl: !!env.TASK_RUN_DATABASE_URL, + hasNewUrl: !!runOpsNewDatabaseUrl, hasLegacyUrl: !!env.TASK_RUN_LEGACY_DATABASE_URL, }); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 906d3aa225b..6979366654e 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -132,12 +132,19 @@ const EnvironmentSchema = z // Explicit positive opt-in. Split behavior is unreachable unless this is true // AND the distinct-DB sentinel confirms the two URLs are physically distinct DBs. RUN_OPS_SPLIT_ENABLED: BoolEnv.default(false), - // Datasource URL for the dedicated run-ops Prisma schema (migrations/generation). - // The webapp runtime pool is driven by TASK_RUN_DATABASE_URL, not this var. + // Canonical connection URL for the dedicated run-ops DB — drives the runtime pool, the split + // decision, replication, and migrations (resolved via runOpsNewDatabaseUrl). Takes precedence + // over TASK_RUN_DATABASE_URL, which remains as the compatibility fallback. RUN_OPS_DATABASE_URL: z .string() .refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_URL is invalid") .optional(), + // Direct/unpooled endpoint for the run-ops DB, consumed by the migration runner (poolers break + // Prisma's advisory locks). Falls back to TASK_RUN_DATABASE_DIRECT_URL then the pooled URL. + RUN_OPS_DATABASE_DIRECT_URL: z + .string() + .refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_DIRECT_URL is invalid") + .optional(), // The NEW dedicated run-ops DB writer. Optional so single-DB installs never set it. TASK_RUN_DATABASE_URL: z .string() @@ -1724,8 +1731,8 @@ const EnvironmentSchema = z // --- Run-ops DB split — second replication source (the NEW dedicated run-ops DB). --- // Cloud-only; only consulted when isSplitEnabled() is true. Self-host never sets these. - // The NEW source's connection URL is TASK_RUN_DATABASE_URL; these add - // the NEW source's replication slot/publication and an explicit per-source enable so it can be + // The NEW source's connection URL is runOpsNewDatabaseUrl (RUN_OPS_DATABASE_URL ?? TASK_RUN_DATABASE_URL); + // these add the NEW source's replication slot/publication and an explicit per-source enable so it can be // brought up independently of the legacy source during the transition. RUN_REPLICATION_NEW_SLOT_NAME: z.string().default("task_runs_to_clickhouse_v2"), RUN_REPLICATION_NEW_PUBLICATION_NAME: z @@ -2102,3 +2109,10 @@ const EnvironmentSchema = z export type Environment = z.infer; export const env = EnvironmentSchema.parse(process.env); + +// Canonical connection URL for the NEW dedicated run-ops DB. RUN_OPS_DATABASE_URL is the canonical +// name; TASK_RUN_DATABASE_URL is the compatibility fallback. Every NEW-DB consumer (connect path, +// split-decision predicates, replication source, migration runner) resolves through this single +// value so they can never disagree about which physical database the split targets. +export const runOpsNewDatabaseUrl: string | undefined = + env.RUN_OPS_DATABASE_URL ?? env.TASK_RUN_DATABASE_URL; diff --git a/apps/webapp/app/services/runsReplicationInstance.server.ts b/apps/webapp/app/services/runsReplicationInstance.server.ts index bfbf38478a2..78aa5328257 100644 --- a/apps/webapp/app/services/runsReplicationInstance.server.ts +++ b/apps/webapp/app/services/runsReplicationInstance.server.ts @@ -1,5 +1,5 @@ import invariant from "tiny-invariant"; -import { env } from "~/env.server"; +import { env, runOpsNewDatabaseUrl } from "~/env.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { singleton } from "~/utils/singleton"; import { isSplitEnabled } from "~/v3/runOpsMigration/splitMode.server"; @@ -172,7 +172,7 @@ function initializeRunsReplicationInstance() { const sources = buildReplicationSources({ splitEnabled, legacyUrl: DATABASE_URL, - newUrl: env.RUN_OPS_DATABASE_URL ?? env.TASK_RUN_DATABASE_URL, + newUrl: runOpsNewDatabaseUrl, newSourceOverride: env.RUN_REPLICATION_NEW_ENABLED === "disabled" ? false : undefined, legacySlotName: env.RUN_REPLICATION_SLOT_NAME, legacyPublicationName: env.RUN_REPLICATION_PUBLICATION_NAME, diff --git a/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts b/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts index ce83c632abf..e7ecd25237c 100644 --- a/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts +++ b/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts @@ -5,7 +5,7 @@ import type { RuntimeEnvironmentType, } from "@trigger.dev/database"; import { prisma, $replica } from "~/db.server"; -import { env } from "~/env.server"; +import { env, runOpsNewDatabaseUrl } from "~/env.server"; import { ControlPlaneCache, DEFAULT_CP_CACHE_MAX_ENTRIES, @@ -449,7 +449,7 @@ export class ControlPlaneResolver { // run-ops topology factory uses); the async isSplitEnabled() distinct-DB sentinel is enforced // at boot elsewhere and is never awaited on a resolver hot path. const SPLIT_ENABLED = - env.RUN_OPS_SPLIT_ENABLED && !!env.TASK_RUN_DATABASE_URL && !!env.TASK_RUN_LEGACY_DATABASE_URL; + env.RUN_OPS_SPLIT_ENABLED && !!runOpsNewDatabaseUrl && !!env.TASK_RUN_LEGACY_DATABASE_URL; export const controlPlaneResolver = new ControlPlaneResolver({ controlPlanePrimary: prisma, diff --git a/apps/webapp/app/v3/runOpsMigration/splitMode.server.ts b/apps/webapp/app/v3/runOpsMigration/splitMode.server.ts index 8d0eb807dbf..ed009111073 100644 --- a/apps/webapp/app/v3/runOpsMigration/splitMode.server.ts +++ b/apps/webapp/app/v3/runOpsMigration/splitMode.server.ts @@ -4,7 +4,7 @@ * infer split-vs-single from URL string-equality — distinctness is proven by the * runtime sentinel. */ -import { env } from "~/env.server"; +import { env, runOpsNewDatabaseUrl } from "~/env.server"; import { logger } from "~/services/logger.server"; import { probeDistinctDatabases as defaultProbe } from "./distinctDbSentinel.server"; @@ -71,7 +71,7 @@ export function isSplitEnabled(): Promise { { flagEnabled: env.RUN_OPS_SPLIT_ENABLED, legacyUrl: env.TASK_RUN_LEGACY_DATABASE_URL, - newUrl: env.TASK_RUN_DATABASE_URL, + newUrl: runOpsNewDatabaseUrl, }, { logger } ); diff --git a/apps/webapp/app/v3/runStore.server.ts b/apps/webapp/app/v3/runStore.server.ts index 4173fc55eaa..9563e2503bd 100644 --- a/apps/webapp/app/v3/runStore.server.ts +++ b/apps/webapp/app/v3/runStore.server.ts @@ -10,7 +10,7 @@ import { runOpsNewPrismaClient, runOpsNewReplicaClient, } from "~/db.server"; -import { env } from "~/env.server"; +import { env, runOpsNewDatabaseUrl } from "~/env.server"; import { singleton } from "~/utils/singleton"; type BuildRunStoreDeps = { @@ -76,7 +76,7 @@ export function buildRunStore(deps: BuildRunStoreDeps): RunStore { // RUN_OPS_SPLIT_ENABLED. Reads must fan out across both DBs so a run that lives on the new // DB stays visible even with the flag off (matches the db.server topology factory). The flag // governs write/mint residency + migration via isSplitEnabled(), not read visibility. -const ROUTING_ENABLED = !!env.TASK_RUN_DATABASE_URL && !!env.TASK_RUN_LEGACY_DATABASE_URL; +const ROUTING_ENABLED = !!runOpsNewDatabaseUrl && !!env.TASK_RUN_LEGACY_DATABASE_URL; // Resolve the run-ops handles, tolerating contexts where they are absent — tests that mock // ~/db.server minimally omit them, and accessing a missing export under vi.mock throws. A diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index bfc035dd170..cfd792372d8 100755 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -13,6 +13,20 @@ else echo "SKIP_POSTGRES_MIGRATIONS=1, skipping Postgres migrations." fi +# Run-ops split: migrate the dedicated NEW run-ops database only when it is configured. Single-DB +# installs never set the URL, so this is a no-op there. +if [ -n "$RUN_OPS_DATABASE_URL" ] || [ -n "$TASK_RUN_DATABASE_URL" ]; then + if [ "$SKIP_RUN_OPS_MIGRATIONS" != "1" ]; then + echo "Running run-ops migrations" + pnpm --filter @internal/run-ops-database db:migrate:deploy + echo "Run-ops migrations done" + else + echo "SKIP_RUN_OPS_MIGRATIONS=1, skipping run-ops migrations." + fi +else + echo "RUN_OPS_DATABASE_URL/TASK_RUN_DATABASE_URL not set, skipping run-ops migrations." +fi + if [ "$SKIP_DASHBOARD_AGENT_MIGRATIONS" != "1" ]; then echo "Running dashboard agent migrations" pnpm --filter @internal/dashboard-agent-db db:migrate:deploy diff --git a/internal-packages/run-ops-database/package.json b/internal-packages/run-ops-database/package.json index 6c26398c9c7..cbeb0279b05 100644 --- a/internal-packages/run-ops-database/package.json +++ b/internal-packages/run-ops-database/package.json @@ -27,7 +27,8 @@ "clean": "rimraf dist", "generate": "prisma generate", "db:migrate:dev:create": "prisma migrate dev --create-only", - "db:migrate:deploy": "prisma migrate deploy", + "db:migrate:deploy": "node scripts/migrate.mjs deploy", + "db:migrate:status": "node scripts/migrate.mjs status", "db:push": "prisma db push", "test": "vitest run", "typecheck": "tsc --noEmit", diff --git a/internal-packages/run-ops-database/scripts/migrate.mjs b/internal-packages/run-ops-database/scripts/migrate.mjs new file mode 100644 index 00000000000..0bdd8f27779 --- /dev/null +++ b/internal-packages/run-ops-database/scripts/migrate.mjs @@ -0,0 +1,78 @@ +// Run Prisma migrations against the dedicated NEW run-ops database (the second physical DB in the +// split). It owns its own migration history, so it is migrated independently of the control-plane +// DB. The connection is resolved the same way the webapp resolves it (RUN_OPS_DATABASE_URL, falling +// back to TASK_RUN_DATABASE_URL) so migrations always target the DB the app connects to. +// +// Usage: node scripts/migrate.mjs [deploy|status] (defaults to deploy) +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// Read from local .env files so dev works without an exported env; deploy environments inject vars directly. +function readFromEnvFiles(key) { + for (const file of [resolve(packageRoot, ".env"), resolve(packageRoot, "../../.env")]) { + let contents; + try { + contents = readFileSync(file, "utf8"); + } catch { + continue; + } + for (const line of contents.split("\n")) { + const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/); + if (!match || match[1] !== key) continue; + let value = match[2]; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (value) return value; + } + } + return undefined; +} + +// Expand `${VAR}` refs (e.g. the repo .env's RUN_OPS_DATABASE_DIRECT_URL=${RUN_OPS_DATABASE_URL}); +// our manual .env reader loads them literally, unlike Prisma's dotenv-expand. +const expand = (value) => + value?.replace(/\$\{(\w+)\}/g, (_, k) => process.env[k] ?? readFromEnvFiles(k) ?? ""); +const resolveVar = (key) => expand(process.env[key] || readFromEnvFiles(key)); +const redact = (url) => url.replace(/:\/\/[^@]*@/, "://***@"); + +const subcommand = process.argv[2] === "status" ? "status" : "deploy"; + +const databaseUrl = resolveVar("RUN_OPS_DATABASE_URL") || resolveVar("TASK_RUN_DATABASE_URL"); +// Prefer the direct/unpooled endpoint for migrations (poolers break Prisma's advisory locks). +const directUrl = + resolveVar("RUN_OPS_DATABASE_DIRECT_URL") || + resolveVar("TASK_RUN_DATABASE_DIRECT_URL") || + databaseUrl; + +if (!databaseUrl) { + // Single-DB installs never set these — safe no-op. A genuinely-expected DB is gated on by the caller. + console.log( + `run-ops migrate ${subcommand}: neither RUN_OPS_DATABASE_URL nor TASK_RUN_DATABASE_URL is set ` + + "(checked env and .env). No dedicated run-ops database configured — skipping." + ); + process.exit(0); +} + +console.log( + `Running \`prisma migrate ${subcommand}\` against the run-ops database (${redact(databaseUrl)})` +); + +const result = spawnSync("prisma", ["migrate", subcommand, "--schema", "prisma/schema.prisma"], { + cwd: packageRoot, + stdio: "inherit", + env: { + ...process.env, + RUN_OPS_DATABASE_URL: databaseUrl, + RUN_OPS_DATABASE_DIRECT_URL: directUrl, + }, +}); + +process.exit(result.status ?? 1); From 29cde4a8e777ffac9c68beea873c308702329e66 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Sat, 4 Jul 2026 12:35:29 +0100 Subject: [PATCH 2/3] refactor(run-ops): migrate on a single RUN_OPS_DATABASE_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the separate directUrl / RUN_OPS_DATABASE_DIRECT_URL. It was only consumed by `prisma migrate` (never the app runtime) to bypass a pooler for advisory locks — but the run-ops connection isn't wired to the app yet and the migration only needs one endpoint. Point RUN_OPS_DATABASE_URL at the direct DB endpoint and it's fully covered. A pooled/ direct split can be reintroduced with directUrl if the app ever connects via a pooler. Removes directUrl from the schema, the env.server.ts declaration, the .env.example line, the migrate runner's direct resolution, and the testcontainer helper's dead env var. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 1 - apps/webapp/app/env.server.ts | 6 ------ .../run-ops-database/prisma/schema.prisma | 5 ++--- internal-packages/run-ops-database/scripts/migrate.mjs | 10 ++-------- internal-packages/testcontainers/src/utils.ts | 6 +++--- 5 files changed, 7 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index ec4488d5f63..37ffa422062 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,6 @@ DIRECT_URL=${DATABASE_URL} # Dedicated run-ops database (@internal/run-ops-database). Only needed to run prisma commands # against it or to enable the run-ops split; start it with `docker compose --profile runops up`. RUN_OPS_DATABASE_URL=postgresql://postgres:postgres@localhost:5434/postgres?schema=public -RUN_OPS_DATABASE_DIRECT_URL=${RUN_OPS_DATABASE_URL} REMIX_APP_PORT=3030 # Dev-only: stream the webapp's logs over a local telnet/TCP socket (nc localhost 6767). Uncomment to enable. # WEBAPP_TELNET_LOGS_PORT=6767 diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 6979366654e..99d2f99fd2a 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -139,12 +139,6 @@ const EnvironmentSchema = z .string() .refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_URL is invalid") .optional(), - // Direct/unpooled endpoint for the run-ops DB, consumed by the migration runner (poolers break - // Prisma's advisory locks). Falls back to TASK_RUN_DATABASE_DIRECT_URL then the pooled URL. - RUN_OPS_DATABASE_DIRECT_URL: z - .string() - .refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_DIRECT_URL is invalid") - .optional(), // The NEW dedicated run-ops DB writer. Optional so single-DB installs never set it. TASK_RUN_DATABASE_URL: z .string() diff --git a/internal-packages/run-ops-database/prisma/schema.prisma b/internal-packages/run-ops-database/prisma/schema.prisma index 49842325c71..8e49fc854e6 100644 --- a/internal-packages/run-ops-database/prisma/schema.prisma +++ b/internal-packages/run-ops-database/prisma/schema.prisma @@ -1,7 +1,6 @@ datasource db { - provider = "postgresql" - url = env("RUN_OPS_DATABASE_URL") - directUrl = env("RUN_OPS_DATABASE_DIRECT_URL") + provider = "postgresql" + url = env("RUN_OPS_DATABASE_URL") } generator client { diff --git a/internal-packages/run-ops-database/scripts/migrate.mjs b/internal-packages/run-ops-database/scripts/migrate.mjs index 0bdd8f27779..80b2234c06c 100644 --- a/internal-packages/run-ops-database/scripts/migrate.mjs +++ b/internal-packages/run-ops-database/scripts/migrate.mjs @@ -36,8 +36,8 @@ function readFromEnvFiles(key) { return undefined; } -// Expand `${VAR}` refs (e.g. the repo .env's RUN_OPS_DATABASE_DIRECT_URL=${RUN_OPS_DATABASE_URL}); -// our manual .env reader loads them literally, unlike Prisma's dotenv-expand. +// Expand `${VAR}` refs in env-file values (our manual reader loads them literally, unlike Prisma's +// dotenv-expand), so a `.env` like RUN_OPS_DATABASE_URL=${DATABASE_URL} still resolves. const expand = (value) => value?.replace(/\$\{(\w+)\}/g, (_, k) => process.env[k] ?? readFromEnvFiles(k) ?? ""); const resolveVar = (key) => expand(process.env[key] || readFromEnvFiles(key)); @@ -46,11 +46,6 @@ const redact = (url) => url.replace(/:\/\/[^@]*@/, "://***@"); const subcommand = process.argv[2] === "status" ? "status" : "deploy"; const databaseUrl = resolveVar("RUN_OPS_DATABASE_URL") || resolveVar("TASK_RUN_DATABASE_URL"); -// Prefer the direct/unpooled endpoint for migrations (poolers break Prisma's advisory locks). -const directUrl = - resolveVar("RUN_OPS_DATABASE_DIRECT_URL") || - resolveVar("TASK_RUN_DATABASE_DIRECT_URL") || - databaseUrl; if (!databaseUrl) { // Single-DB installs never set these — safe no-op. A genuinely-expected DB is gated on by the caller. @@ -71,7 +66,6 @@ const result = spawnSync("prisma", ["migrate", subcommand, "--schema", "prisma/s env: { ...process.env, RUN_OPS_DATABASE_URL: databaseUrl, - RUN_OPS_DATABASE_DIRECT_URL: directUrl, }, }); diff --git a/internal-packages/testcontainers/src/utils.ts b/internal-packages/testcontainers/src/utils.ts index b13ab7ea859..385b4113f59 100644 --- a/internal-packages/testcontainers/src/utils.ts +++ b/internal-packages/testcontainers/src/utils.ts @@ -37,8 +37,8 @@ export async function pushDatabaseSchema(databaseUrl: string) { /** * Pushes the DEDICATED run-ops subset schema (@internal/run-ops-database) into the database at - * `databaseUrl`. The run-ops datasource reads RUN_OPS_DATABASE_URL/RUN_OPS_DATABASE_DIRECT_URL, and - * its schema is a subset of the control-plane schema (run-ops tables, no Organization/Project/etc). + * `databaseUrl`. The run-ops datasource reads RUN_OPS_DATABASE_URL, and its schema is a subset of + * the control-plane schema (run-ops tables, no Organization/Project/etc). */ export async function pushRunOpsSchema(databaseUrl: string) { // Resolve the schema (and the package's own prisma binary) through the @internal/run-ops-database @@ -49,7 +49,7 @@ export async function pushRunOpsSchema(databaseUrl: string) { const result = await pushPrismaSchema({ prismaBin: `${runOpsPackagePath}/node_modules/.bin/prisma`, schemaPath, - env: { RUN_OPS_DATABASE_URL: databaseUrl, RUN_OPS_DATABASE_DIRECT_URL: databaseUrl }, + env: { RUN_OPS_DATABASE_URL: databaseUrl }, }); // `db push` derives DDL from the schema datamodel and so cannot create the SQL-only partial unique From 0464ddbbfe4cb2de60698da071f57b1dce1ffb5e Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Sat, 4 Jul 2026 12:59:18 +0100 Subject: [PATCH 3/3] refactor(run-ops): one RUN_OPS_* env var family, drop TASK_RUN_* aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NEW-DB writer had two names (TASK_RUN_DATABASE_URL from the original split + RUN_OPS_DATABASE_URL used by the schema/docker/.env), bridged by a `??` coalesce. Collapse to a single canonical RUN_OPS_* family and delete the aliases (nothing deployed uses them yet): - TASK_RUN_DATABASE_URL -> RUN_OPS_DATABASE_URL (the writer; the ?? and the runOpsNewDatabaseUrl indirection are gone — consumers read env.RUN_OPS_DATABASE_URL directly) - TASK_RUN_LEGACY_DATABASE_URL -> RUN_OPS_LEGACY_DATABASE_URL - TASK_RUN_DATABASE_READ_REPLICA_URL -> RUN_OPS_DATABASE_READ_REPLICA_URL - TASK_RUN_DATABASE_DIRECT_URL -> removed (dead; the schema no longer declares directUrl) Co-Authored-By: Claude Opus 4.8 (1M context) --- .server-changes/run-ops-auto-migrate.md | 2 +- apps/webapp/app/db.server.ts | 14 +++---- apps/webapp/app/env.server.ts | 37 +++++-------------- .../runsReplicationInstance.server.ts | 4 +- .../controlPlaneResolver.server.ts | 4 +- .../v3/runOpsMigration/splitMode.server.ts | 8 ++-- apps/webapp/app/v3/runStore.server.ts | 4 +- .../test/runsReplicationInstance.test.ts | 20 +--------- docker/scripts/entrypoint.sh | 4 +- .../run-ops-database/scripts/migrate.mjs | 12 +++--- 10 files changed, 37 insertions(+), 72 deletions(-) diff --git a/.server-changes/run-ops-auto-migrate.md b/.server-changes/run-ops-auto-migrate.md index b544a0fac84..b62f7c0d9d9 100644 --- a/.server-changes/run-ops-auto-migrate.md +++ b/.server-changes/run-ops-auto-migrate.md @@ -3,4 +3,4 @@ area: webapp type: feature --- -Automatically migrate the dedicated run-ops database on deploy (entrypoint + `@internal/run-ops-database` deploy/status scripts) and resolve its connection through one canonical `RUN_OPS_DATABASE_URL` (falling back to `TASK_RUN_DATABASE_URL`) so migrations always target the DB the app connects to. +Automatically migrate the dedicated run-ops database on deploy (entrypoint + `@internal/run-ops-database` deploy/status scripts), and standardize the run-ops DB connection on a single `RUN_OPS_DATABASE_URL` family (dropping the `TASK_RUN_DATABASE_URL` aliases) so migrations always target the DB the app connects to. diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 8da825b8fdf..0284c192465 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -10,7 +10,7 @@ import { import { RunOpsPrismaClient } from "@internal/run-ops-database"; import invariant from "tiny-invariant"; import { z } from "zod"; -import { env, runOpsNewDatabaseUrl } from "./env.server"; +import { env } from "./env.server"; import { logger } from "./services/logger.server"; import { isValidDatabaseUrl } from "./utils/db"; import { @@ -237,16 +237,16 @@ export function selectRunOpsTopology( // nothing new. The builders apply the SAME wrapper pair the control-plane // singletons use (captureInfrastructureErrors(tagDatasource(role, raw))). const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { - const newUrl = runOpsNewDatabaseUrl; + const newUrl = env.RUN_OPS_DATABASE_URL; // Gate on the opt-in flag too: the distinct-DB sentinel only runs when the flag is on. - const splitEnabled = env.RUN_OPS_SPLIT_ENABLED && !!newUrl && !!env.TASK_RUN_LEGACY_DATABASE_URL; + const splitEnabled = env.RUN_OPS_SPLIT_ENABLED && !!newUrl && !!env.RUN_OPS_LEGACY_DATABASE_URL; return selectRunOpsTopology( { splitEnabled, - legacyUrl: env.TASK_RUN_LEGACY_DATABASE_URL, + legacyUrl: env.RUN_OPS_LEGACY_DATABASE_URL, newUrl, - newReplicaUrl: env.TASK_RUN_DATABASE_READ_REPLICA_URL, + newReplicaUrl: env.RUN_OPS_DATABASE_READ_REPLICA_URL, }, { controlPlane: { writer: prisma, replica: $replica }, @@ -278,8 +278,8 @@ export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({ newReplica: runOpsNewReplicaClient, controlPlaneWriter: prisma, controlPlaneReplica: $replica, - hasNewUrl: !!runOpsNewDatabaseUrl, - hasLegacyUrl: !!env.TASK_RUN_LEGACY_DATABASE_URL, + hasNewUrl: !!env.RUN_OPS_DATABASE_URL, + hasLegacyUrl: !!env.RUN_OPS_LEGACY_DATABASE_URL, }); // Boot-time interlock: if the flag is on but the distinct-DB sentinel does not diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 99d2f99fd2a..dc189720821 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -132,36 +132,24 @@ const EnvironmentSchema = z // Explicit positive opt-in. Split behavior is unreachable unless this is true // AND the distinct-DB sentinel confirms the two URLs are physically distinct DBs. RUN_OPS_SPLIT_ENABLED: BoolEnv.default(false), - // Canonical connection URL for the dedicated run-ops DB — drives the runtime pool, the split - // decision, replication, and migrations (resolved via runOpsNewDatabaseUrl). Takes precedence - // over TASK_RUN_DATABASE_URL, which remains as the compatibility fallback. + // Canonical connection URL for the dedicated NEW run-ops DB — drives the runtime pool, the split + // decision, replication, and migrations. Optional so single-DB installs never set it. RUN_OPS_DATABASE_URL: z .string() .refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_URL is invalid") .optional(), - // The NEW dedicated run-ops DB writer. Optional so single-DB installs never set it. - TASK_RUN_DATABASE_URL: z - .string() - .refine(isValidDatabaseUrl, "TASK_RUN_DATABASE_URL is invalid") - .optional(), - // The NEW run-ops DB unpooled/direct endpoint (Prisma migrate/introspection; - // connection poolers break advisory locks). Consumed by the migrations. - TASK_RUN_DATABASE_DIRECT_URL: z - .string() - .refine(isValidDatabaseUrl, "TASK_RUN_DATABASE_DIRECT_URL is invalid") - .optional(), // The LEGACY run-ops DB (the control-plane DB during the transition). When unset, legacy // run-ops reuses the existing DATABASE_URL (legacy run-ops == control-plane DB initially). - TASK_RUN_LEGACY_DATABASE_URL: z + RUN_OPS_LEGACY_DATABASE_URL: z .string() - .refine(isValidDatabaseUrl, "TASK_RUN_LEGACY_DATABASE_URL is invalid") + .refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_URL is invalid") .optional(), // The NEW dedicated run-ops DB read replica. Optional; self-host never sets it. // Refined (unlike the unrefined control-plane DATABASE_READ_REPLICA_URL) so a malformed run-ops // replica URL fails boot loudly rather than silently degrading — do not align it down to the CP shape. - TASK_RUN_DATABASE_READ_REPLICA_URL: z + RUN_OPS_DATABASE_READ_REPLICA_URL: z .string() - .refine(isValidDatabaseUrl, "TASK_RUN_DATABASE_READ_REPLICA_URL is invalid") + .refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_READ_REPLICA_URL is invalid") .optional(), // --- Control-plane datasource repoint. Additive-only. --- // Optional control-plane DB. Unset (self-host/single-DB) -> getClient()/getReplicaClient() fall back to @@ -1725,9 +1713,9 @@ const EnvironmentSchema = z // --- Run-ops DB split — second replication source (the NEW dedicated run-ops DB). --- // Cloud-only; only consulted when isSplitEnabled() is true. Self-host never sets these. - // The NEW source's connection URL is runOpsNewDatabaseUrl (RUN_OPS_DATABASE_URL ?? TASK_RUN_DATABASE_URL); - // these add the NEW source's replication slot/publication and an explicit per-source enable so it can be - // brought up independently of the legacy source during the transition. + // The NEW source's connection URL is RUN_OPS_DATABASE_URL; these add the NEW source's replication + // slot/publication and an explicit per-source enable so it can be brought up independently of the + // legacy source during the transition. RUN_REPLICATION_NEW_SLOT_NAME: z.string().default("task_runs_to_clickhouse_v2"), RUN_REPLICATION_NEW_PUBLICATION_NAME: z .string() @@ -2103,10 +2091,3 @@ const EnvironmentSchema = z export type Environment = z.infer; export const env = EnvironmentSchema.parse(process.env); - -// Canonical connection URL for the NEW dedicated run-ops DB. RUN_OPS_DATABASE_URL is the canonical -// name; TASK_RUN_DATABASE_URL is the compatibility fallback. Every NEW-DB consumer (connect path, -// split-decision predicates, replication source, migration runner) resolves through this single -// value so they can never disagree about which physical database the split targets. -export const runOpsNewDatabaseUrl: string | undefined = - env.RUN_OPS_DATABASE_URL ?? env.TASK_RUN_DATABASE_URL; diff --git a/apps/webapp/app/services/runsReplicationInstance.server.ts b/apps/webapp/app/services/runsReplicationInstance.server.ts index 78aa5328257..30a757597fa 100644 --- a/apps/webapp/app/services/runsReplicationInstance.server.ts +++ b/apps/webapp/app/services/runsReplicationInstance.server.ts @@ -1,5 +1,5 @@ import invariant from "tiny-invariant"; -import { env, runOpsNewDatabaseUrl } from "~/env.server"; +import { env } from "~/env.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { singleton } from "~/utils/singleton"; import { isSplitEnabled } from "~/v3/runOpsMigration/splitMode.server"; @@ -172,7 +172,7 @@ function initializeRunsReplicationInstance() { const sources = buildReplicationSources({ splitEnabled, legacyUrl: DATABASE_URL, - newUrl: runOpsNewDatabaseUrl, + newUrl: env.RUN_OPS_DATABASE_URL, newSourceOverride: env.RUN_REPLICATION_NEW_ENABLED === "disabled" ? false : undefined, legacySlotName: env.RUN_REPLICATION_SLOT_NAME, legacyPublicationName: env.RUN_REPLICATION_PUBLICATION_NAME, diff --git a/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts b/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts index e7ecd25237c..fb903bc8ea9 100644 --- a/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts +++ b/apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts @@ -5,7 +5,7 @@ import type { RuntimeEnvironmentType, } from "@trigger.dev/database"; import { prisma, $replica } from "~/db.server"; -import { env, runOpsNewDatabaseUrl } from "~/env.server"; +import { env } from "~/env.server"; import { ControlPlaneCache, DEFAULT_CP_CACHE_MAX_ENTRIES, @@ -449,7 +449,7 @@ export class ControlPlaneResolver { // run-ops topology factory uses); the async isSplitEnabled() distinct-DB sentinel is enforced // at boot elsewhere and is never awaited on a resolver hot path. const SPLIT_ENABLED = - env.RUN_OPS_SPLIT_ENABLED && !!runOpsNewDatabaseUrl && !!env.TASK_RUN_LEGACY_DATABASE_URL; + env.RUN_OPS_SPLIT_ENABLED && !!env.RUN_OPS_DATABASE_URL && !!env.RUN_OPS_LEGACY_DATABASE_URL; export const controlPlaneResolver = new ControlPlaneResolver({ controlPlanePrimary: prisma, diff --git a/apps/webapp/app/v3/runOpsMigration/splitMode.server.ts b/apps/webapp/app/v3/runOpsMigration/splitMode.server.ts index ed009111073..c1bf592e4a4 100644 --- a/apps/webapp/app/v3/runOpsMigration/splitMode.server.ts +++ b/apps/webapp/app/v3/runOpsMigration/splitMode.server.ts @@ -4,7 +4,7 @@ * infer split-vs-single from URL string-equality — distinctness is proven by the * runtime sentinel. */ -import { env, runOpsNewDatabaseUrl } from "~/env.server"; +import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import { probeDistinctDatabases as defaultProbe } from "./distinctDbSentinel.server"; @@ -30,7 +30,7 @@ export async function computeSplitEnabled( // Both URLs are required to even consider a split. if (!config.legacyUrl || !config.newUrl) { deps.logger?.warn( - "RUN_OPS_SPLIT_ENABLED is on but TASK_RUN_LEGACY_DATABASE_URL / TASK_RUN_DATABASE_URL are not both set; staying single-DB." + "RUN_OPS_SPLIT_ENABLED is on but RUN_OPS_LEGACY_DATABASE_URL / RUN_OPS_DATABASE_URL are not both set; staying single-DB." ); return false; } @@ -70,8 +70,8 @@ export function isSplitEnabled(): Promise { cached = computeSplitEnabled( { flagEnabled: env.RUN_OPS_SPLIT_ENABLED, - legacyUrl: env.TASK_RUN_LEGACY_DATABASE_URL, - newUrl: runOpsNewDatabaseUrl, + legacyUrl: env.RUN_OPS_LEGACY_DATABASE_URL, + newUrl: env.RUN_OPS_DATABASE_URL, }, { logger } ); diff --git a/apps/webapp/app/v3/runStore.server.ts b/apps/webapp/app/v3/runStore.server.ts index 9563e2503bd..bf1642b5f4e 100644 --- a/apps/webapp/app/v3/runStore.server.ts +++ b/apps/webapp/app/v3/runStore.server.ts @@ -10,7 +10,7 @@ import { runOpsNewPrismaClient, runOpsNewReplicaClient, } from "~/db.server"; -import { env, runOpsNewDatabaseUrl } from "~/env.server"; +import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; type BuildRunStoreDeps = { @@ -76,7 +76,7 @@ export function buildRunStore(deps: BuildRunStoreDeps): RunStore { // RUN_OPS_SPLIT_ENABLED. Reads must fan out across both DBs so a run that lives on the new // DB stays visible even with the flag off (matches the db.server topology factory). The flag // governs write/mint residency + migration via isSplitEnabled(), not read visibility. -const ROUTING_ENABLED = !!runOpsNewDatabaseUrl && !!env.TASK_RUN_LEGACY_DATABASE_URL; +const ROUTING_ENABLED = !!env.RUN_OPS_DATABASE_URL && !!env.RUN_OPS_LEGACY_DATABASE_URL; // Resolve the run-ops handles, tolerating contexts where they are absent — tests that mock // ~/db.server minimally omit them, and accessing a missing export under vi.mock throws. A diff --git a/apps/webapp/test/runsReplicationInstance.test.ts b/apps/webapp/test/runsReplicationInstance.test.ts index 39f57497e7f..9dc2dd6a106 100644 --- a/apps/webapp/test/runsReplicationInstance.test.ts +++ b/apps/webapp/test/runsReplicationInstance.test.ts @@ -126,35 +126,19 @@ describe("buildReplicationSources (pure)", () => { expect(sources[0].id).toBe("legacy"); }); - it("RUN_OPS_DATABASE_URL takes precedence: new source pgConnectionUrl === RUN_OPS_DATABASE_URL when both are supplied", () => { + it("new source pgConnectionUrl === the provided RUN_OPS_DATABASE_URL", () => { const runOpsUrl = "postgres://run-ops-dedicated"; - const taskRunUrl = "postgres://task-run-legacy-alias"; const sources = buildReplicationSources({ ...baseArgs, splitEnabled: true, - // Simulates env.RUN_OPS_DATABASE_URL ?? env.TASK_RUN_DATABASE_URL with RUN_OPS set - newUrl: runOpsUrl ?? taskRunUrl, + newUrl: runOpsUrl, }); expect(sources).toHaveLength(2); expect(sources[1]!.id).toBe("new"); expect(sources[1]!.pgConnectionUrl).toBe(runOpsUrl); }); - - it("falls back to TASK_RUN_DATABASE_URL when RUN_OPS_DATABASE_URL is absent", () => { - const taskRunUrl = "postgres://task-run-legacy-alias"; - - const sources = buildReplicationSources({ - ...baseArgs, - splitEnabled: true, - // Simulates env.RUN_OPS_DATABASE_URL ?? env.TASK_RUN_DATABASE_URL with RUN_OPS unset - newUrl: undefined ?? taskRunUrl, - }); - - expect(sources).toHaveLength(2); - expect(sources[1]!.pgConnectionUrl).toBe(taskRunUrl); - }); }); describe("assertReplicationCoversSplit (boot gate-coupling)", () => { diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index cfd792372d8..90589619143 100755 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -15,7 +15,7 @@ fi # Run-ops split: migrate the dedicated NEW run-ops database only when it is configured. Single-DB # installs never set the URL, so this is a no-op there. -if [ -n "$RUN_OPS_DATABASE_URL" ] || [ -n "$TASK_RUN_DATABASE_URL" ]; then +if [ -n "$RUN_OPS_DATABASE_URL" ]; then if [ "$SKIP_RUN_OPS_MIGRATIONS" != "1" ]; then echo "Running run-ops migrations" pnpm --filter @internal/run-ops-database db:migrate:deploy @@ -24,7 +24,7 @@ if [ -n "$RUN_OPS_DATABASE_URL" ] || [ -n "$TASK_RUN_DATABASE_URL" ]; then echo "SKIP_RUN_OPS_MIGRATIONS=1, skipping run-ops migrations." fi else - echo "RUN_OPS_DATABASE_URL/TASK_RUN_DATABASE_URL not set, skipping run-ops migrations." + echo "RUN_OPS_DATABASE_URL not set, skipping run-ops migrations." fi if [ "$SKIP_DASHBOARD_AGENT_MIGRATIONS" != "1" ]; then diff --git a/internal-packages/run-ops-database/scripts/migrate.mjs b/internal-packages/run-ops-database/scripts/migrate.mjs index 80b2234c06c..181baa2c476 100644 --- a/internal-packages/run-ops-database/scripts/migrate.mjs +++ b/internal-packages/run-ops-database/scripts/migrate.mjs @@ -1,7 +1,7 @@ // Run Prisma migrations against the dedicated NEW run-ops database (the second physical DB in the // split). It owns its own migration history, so it is migrated independently of the control-plane -// DB. The connection is resolved the same way the webapp resolves it (RUN_OPS_DATABASE_URL, falling -// back to TASK_RUN_DATABASE_URL) so migrations always target the DB the app connects to. +// DB. Connects via RUN_OPS_DATABASE_URL — the same var the webapp uses — so migrations always +// target the DB the app connects to. // // Usage: node scripts/migrate.mjs [deploy|status] (defaults to deploy) import { spawnSync } from "node:child_process"; @@ -45,13 +45,13 @@ const redact = (url) => url.replace(/:\/\/[^@]*@/, "://***@"); const subcommand = process.argv[2] === "status" ? "status" : "deploy"; -const databaseUrl = resolveVar("RUN_OPS_DATABASE_URL") || resolveVar("TASK_RUN_DATABASE_URL"); +const databaseUrl = resolveVar("RUN_OPS_DATABASE_URL"); if (!databaseUrl) { - // Single-DB installs never set these — safe no-op. A genuinely-expected DB is gated on by the caller. + // Single-DB installs never set it — safe no-op. A genuinely-expected DB is gated on by the caller. console.log( - `run-ops migrate ${subcommand}: neither RUN_OPS_DATABASE_URL nor TASK_RUN_DATABASE_URL is set ` + - "(checked env and .env). No dedicated run-ops database configured — skipping." + `run-ops migrate ${subcommand}: RUN_OPS_DATABASE_URL is not set (checked env and .env). ` + + "No dedicated run-ops database configured — skipping." ); process.exit(0); }