From b90c82cba3bb99f120f26a88d34d066b749fccfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 12:48:17 +0200 Subject: [PATCH] fix(migrate): run migrate deploy in a UTC session Prisma ignores PGTZ, so a database whose default zone is not UTC ran every migration in that zone, and a migration mixing timestamp and timestamptz wrote shifted values. The boot wrapper now appends -c TimeZone=UTC to the options parameter of DATABASE_URL for its Prisma calls, honouring DATABASE_SESSION_OPTIONS_DISABLED like the app does. --- docs/ops/deploy.md | 9 ++ scripts/__tests__/migrate-deploy.test.ts | 49 ++++++ scripts/migrate-deploy.mjs | 55 +++++++ tests/integration/migrate-deploy-utc.test.ts | 151 +++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 tests/integration/migrate-deploy-utc.test.ts diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 89ac3d380..36e78047e 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -40,6 +40,15 @@ not reachable". The entrypoint also runs the migrations on every boot, so step 2 is optional; it is there for when you want to see the migration result before the new container takes traffic. +The entrypoint runs every migration in a UTC session, the zone the app's own +connections use, whatever the database's default `TimeZone` is: it adds +`-c TimeZone=UTC` to the `options` parameter of `DATABASE_URL` for the Prisma +calls (an `options` value you set yourself is kept, with the pin after it). +Behind a connection pooler that refuses `options`, the +`DATABASE_SESSION_OPTIONS_DISABLED` switch turns this off along with the +app's own session settings; set the database or role default to UTC there +(`ALTER DATABASE healthlog SET timezone = 'UTC'`). + ### A migration that timed out on a lock Some migrations bound how long they wait for a table lock, so that a diff --git a/scripts/__tests__/migrate-deploy.test.ts b/scripts/__tests__/migrate-deploy.test.ts index a63edc96b..46b7b7915 100644 --- a/scripts/__tests__/migrate-deploy.test.ts +++ b/scripts/__tests__/migrate-deploy.test.ts @@ -24,7 +24,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { decideLockRetry, lockTimeoutInReport, + prismaEnv, retryDelays, + utcSessionUrl, } from "../migrate-deploy.mjs"; const P3018_LOCK = `Applying migration \`0412_measurement_hot_updates\` @@ -167,6 +169,53 @@ describe("which failure is retried", () => { }); }); +describe("the session time zone Prisma migrates in", () => { + const options = (url: string | undefined) => + new URL(url ?? "").searchParams.get("options"); + + it("pins TimeZone=UTC through the libpq options parameter", () => { + const url = utcSessionUrl( + "postgresql://u:p@db:5432/healthlog?schema=public", + ); + expect(options(url)).toBe("-c TimeZone=UTC"); + expect(new URL(url ?? "").searchParams.get("schema")).toBe("public"); + }); + + it("appends to options the operator set, so a later -c wins", () => { + const url = utcSessionUrl( + "postgresql://u:p@db/h?options=-c%20TimeZone%3DEurope%2FBerlin", + ); + expect(options(url)).toBe("-c TimeZone=Europe/Berlin -c TimeZone=UTC"); + // Running it again adds nothing. + expect(utcSessionUrl(url)).toBe(url); + }); + + it("leaves a URL alone when the startup options are switched off", () => { + const raw = "postgresql://u:p@db/h?schema=public"; + expect(utcSessionUrl(raw, { optionsDisabled: true })).toBe(raw); + expect( + prismaEnv({ DATABASE_URL: raw, DATABASE_SESSION_OPTIONS_DISABLED: "1" }) + .DATABASE_URL, + ).toBe(raw); + }); + + it("passes an unset or unparseable URL through untouched", () => { + expect(utcSessionUrl(undefined)).toBeUndefined(); + expect(utcSessionUrl("")).toBe(""); + expect(utcSessionUrl("not a url")).toBe("not a url"); + expect(prismaEnv({}).DATABASE_URL).toBeUndefined(); + }); + + it("hands Prisma the pinned URL and keeps the rest of the environment", () => { + const env = prismaEnv({ + DATABASE_URL: "postgresql://u:p@db/h", + HEALTHLOG_X: "1", + }); + expect(options(env.DATABASE_URL)).toBe("-c TimeZone=UTC"); + expect(env.HEALTHLOG_X).toBe("1"); + }); +}); + describe("the retry loop", () => { const dirs: string[] = []; afterEach(() => { diff --git a/scripts/migrate-deploy.mjs b/scripts/migrate-deploy.mjs index 23abb5f9a..4f06202c2 100644 --- a/scripts/migrate-deploy.mjs +++ b/scripts/migrate-deploy.mjs @@ -30,6 +30,16 @@ * after a failed one (P3009), from the `logs` Prisma stored on the failed * row in `_prisma_migrations`. * + * Every Prisma call runs with its session pinned to UTC, the way the app's + * own connections are (`src/lib/db-session.ts`). Almost every `DateTime` + * column is `timestamp` without a time zone and holds UTC, and Postgres + * converts between that and `timestamptz` / `now()` through the session time + * zone. A migration that mixes the two (0372 does) therefore wrote hours-off + * values on a database whose default zone is not UTC. Prisma ignores `PGTZ`, + * so the pin rides the connection URL as the libpq `options` parameter, which + * Prisma passes to the server. `DATABASE_SESSION_OPTIONS_DISABLED` (a pooler + * that refuses `options`) turns it off, as it does for the app. + * * Environment: * HEALTHLOG_PRISMA_CLI Prisma's CLI entry (default: the image's copy) * HEALTHLOG_MIGRATIONS_DIR where the migration files are (default: @@ -49,6 +59,7 @@ const DEFAULT_RETRY_DELAYS_S = [15, 30, 60, 120, 240]; export const RETRY_MARKER = /^--\s*@retry-on-lock-timeout\s*$/m; const LOCK_TIMEOUT = "55P03"; +const TIME_ZONE_OPTION = "-c TimeZone=UTC"; const MIGRATION_NAME = /^[A-Za-z0-9_]+$/; /** @@ -116,10 +127,54 @@ export function retryDelays(raw) { : DEFAULT_RETRY_DELAYS_S; } +/** + * `DATABASE_URL` with the session time zone pinned to UTC, for Prisma. + * + * Appends to an `options` value the operator set rather than replacing it; + * a later `-c` wins, so an operator's own `TimeZone` gives way to UTC. A URL + * that does not parse, or a host that disabled the startup options, is + * returned unchanged. + * + * @param {string | undefined} raw + * @param {{ optionsDisabled?: boolean }} [opts] + * @returns {string | undefined} + */ +export function utcSessionUrl(raw, { optionsDisabled = false } = {}) { + if (!raw || optionsDisabled) return raw; + let url; + try { + url = new URL(raw); + } catch { + return raw; + } + const existing = url.searchParams.get("options")?.trim(); + if (existing && existing.endsWith(TIME_ZONE_OPTION)) return raw; + url.searchParams.set( + "options", + existing ? `${existing} ${TIME_ZONE_OPTION}` : TIME_ZONE_OPTION, + ); + return url.toString(); +} + +/** The same switch the app reads in `src/lib/db-session.ts`. */ +function sessionOptionsDisabled(env) { + const raw = env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); + return raw !== undefined && /^(1|true|yes)$/i.test(raw); +} + +/** The environment every Prisma call runs in. */ +export function prismaEnv(env = process.env) { + const url = utcSessionUrl(env.DATABASE_URL, { + optionsDisabled: sessionOptionsDisabled(env), + }); + return url === undefined ? { ...env } : { ...env, DATABASE_URL: url }; +} + function runPrisma(cli, args) { return new Promise((resolve) => { const child = spawn(process.execPath, [cli, ...args], { stdio: ["ignore", "pipe", "pipe"], + env: prismaEnv(), }); let output = ""; child.stdout.on("data", (chunk) => { diff --git a/tests/integration/migrate-deploy-utc.test.ts b/tests/integration/migrate-deploy-utc.test.ts new file mode 100644 index 000000000..84b0e1b94 --- /dev/null +++ b/tests/integration/migrate-deploy-utc.test.ts @@ -0,0 +1,151 @@ +/** + * The boot's `migrate deploy` (`scripts/migrate-deploy.mjs`) runs every + * migration in a UTC session, whatever the database's own default zone. + * + * A role whose default `TimeZone` is Europe/Berlin stands in for a shared + * Postgres configured that way. One migration records the session zone and + * the instant a `timestamp` → `timestamptz` cast lands on, which is what a + * migration mixing the two types (0372) depends on. Through the wrapper both + * read UTC; through the bare Prisma CLI, the counter-check, they read Berlin, + * which proves the role default is what the wrapper overrode. + */ +import { spawn } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const ROLE = "hl_tz_probe"; +const PASSWORD = "tz-probe"; +const ROOT = process.cwd(); +const PRISMA_CLI = join(ROOT, "node_modules", "prisma", "build", "index.js"); + +const dirs: string[] = []; + +function adminUrl(): URL { + const raw = process.env.DATABASE_URL; + if (!raw) throw new Error("DATABASE_URL is not set for the integration run"); + const url = new URL(raw); + url.searchParams.delete("schema"); + return url; +} + +async function admin(): Promise { + const client = new Client({ connectionString: adminUrl().toString() }); + await client.connect(); + return client; +} + +/** A URL that logs in as the Berlin-default role, into its own schema. */ +function probeUrl(schema: string): string { + const url = new URL(process.env.DATABASE_URL ?? ""); + url.username = ROLE; + url.password = PASSWORD; + url.searchParams.set("schema", schema); + return url.toString(); +} + +beforeAll(async () => { + const db = await admin(); + await db.query(`DROP OWNED BY ${ROLE}`).catch(() => {}); + await db.query(`DROP ROLE IF EXISTS ${ROLE}`); + await db.query(`CREATE ROLE ${ROLE} LOGIN SUPERUSER PASSWORD '${PASSWORD}'`); + await db.query(`ALTER ROLE ${ROLE} SET timezone = 'Europe/Berlin'`); + await db.end(); +}); + +afterAll(async () => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true }); + const db = await admin(); + for (const schema of ["tz_probe_wrapper", "tz_probe_bare"]) { + await db.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`); + } + await db.query(`DROP OWNED BY ${ROLE}`).catch(() => {}); + await db.query(`DROP ROLE IF EXISTS ${ROLE}`); + await db.end(); +}); + +function migrationsDir(): string { + const dir = mkdtempSync(join(tmpdir(), "hl-migrate-utc-")); + dirs.push(dir); + writeFileSync( + join(dir, "schema.prisma"), + 'datasource db {\n provider = "postgresql"\n}\n', + ); + mkdirSync(join(dir, "migrations", "0001_zone_probe"), { recursive: true }); + cpSync( + join(ROOT, "prisma", "migrations", "migration_lock.toml"), + join(dir, "migrations", "migration_lock.toml"), + ); + writeFileSync( + join(dir, "migrations", "0001_zone_probe", "migration.sql"), + `CREATE TABLE "zone_probe" AS + SELECT current_setting('TimeZone') AS zone, + ('2026-01-15 12:00:00'::timestamp)::timestamptz + = '2026-01-15 12:00:00+00'::timestamptz AS cast_is_utc;`, + ); + return dir; +} + +function run( + args: string[], + databaseUrl: string, +): Promise<{ code: number; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, args, { + cwd: ROOT, + env: { + ...process.env, + DATABASE_URL: databaseUrl, + DATABASE_SESSION_OPTIONS_DISABLED: "", + HEALTHLOG_PRISMA_CLI: PRISMA_CLI, + }, + }); + let stderr = ""; + child.stdout.on("data", () => {}); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("close", (code) => resolve({ code: code ?? 1, stderr })); + }); +} + +async function probe(schema: string) { + const db = await admin(); + const { rows } = await db.query( + `SELECT zone, cast_is_utc FROM ${schema}.zone_probe`, + ); + await db.end(); + return rows[0] as { zone: string; cast_is_utc: boolean }; +} + +describe("migrate deploy session time zone", () => { + it("runs the migration in UTC through the boot wrapper", async () => { + const dir = migrationsDir(); + const result = await run( + [ + join(ROOT, "scripts", "migrate-deploy.mjs"), + "--schema", + join(dir, "schema.prisma"), + ], + probeUrl("tz_probe_wrapper"), + ); + expect(result.code, result.stderr).toBe(0); + expect(await probe("tz_probe_wrapper")).toEqual({ + zone: "UTC", + cast_is_utc: true, + }); + }, 60_000); + + it("counter-check: the bare CLI inherits the database's own zone", async () => { + const dir = migrationsDir(); + const result = await run( + [PRISMA_CLI, "migrate", "deploy", "--schema", join(dir, "schema.prisma")], + probeUrl("tz_probe_bare"), + ); + expect(result.code, result.stderr).toBe(0); + expect(await probe("tz_probe_bare")).toEqual({ + zone: "Europe/Berlin", + cast_is_utc: false, + }); + }, 60_000); +});