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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 12:48:17 +02:00
1 parent 67cc56f707
commit b90c82cba3
4 files changed
+264

No files matched your search

+49
View File
@@ -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(() => {
+55
View File
@@ -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) => {