From 211053d9000e696c647c110157e53ac99ad16711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:40:06 +0200 Subject: [PATCH] fix(migrations): idempotent 0440, refill emptied insights cache, deploy note 0440 adds its columns with ADD COLUMN IF NOT EXISTS like 0424 and 0432; a structural test holds every migration from 0422 on to that form. 0430 clears insights_cached_at where no sealed payload is left, so the nightly pregenerate refills an account whose only copy was the readable column instead of keeping an empty card fresh. The deploy runbook notes that 0430 drops a users column: an old process fails on its next full user read, so stop before starting rather than running old and new side by side. --- docs/ops/deploy.md | 11 +++ .../migration.sql | 9 +++ .../migration.sql | 4 +- .../migration-add-column-idempotent.test.ts | 61 ++++++++++++++++ .../migration-0430-insights-stamp.test.ts | 71 +++++++++++++++++++ 5 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/migration-add-column-idempotent.test.ts create mode 100644 tests/integration/migration-0430-insights-stamp.test.ts diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index c87d7926b..c57c738ce 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -109,6 +109,17 @@ that for you, and each boot retries). Anything else is a session you can judge: a stuck client may be ended with `SELECT pg_terminate_backend()`. You never need to run `migrate resolve` by hand for this case. +### v1.44.0 notes + +Migration `0430` drops the `insights_cached_text` column from `users`. A +process still running the previous image selects that column on every full +user read and fails once it is gone, so do not run old and new containers side +by side across this release: stop the old app container, let the new one +apply the migrations, then start it (the sequence above does this on a single +host). A web/worker split deployment stops both roles before the first new +container starts. Rolling back to an image older than v1.44.0 needs the column +back first (see Rollback below). + ### v1.11.0 notes Migrations `0110`–`0114` are additive (new tables / columns for the diff --git a/prisma/migrations/0430_drop_insights_cached_text/migration.sql b/prisma/migrations/0430_drop_insights_cached_text/migration.sql index 39724eb38..07620ff66 100644 --- a/prisma/migrations/0430_drop_insights_cached_text/migration.sql +++ b/prisma/migrations/0430_drop_insights_cached_text/migration.sql @@ -13,3 +13,12 @@ DROP INDEX IF EXISTS "users_insights_cached_text_backfill_idx"; ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_cached_text"; + +-- A row whose only copy of the payload was the readable one now holds no +-- cached text at all, while its stamp still says the cache is fresh. Clear the +-- stamp so the nightly pregenerate picks the account up again instead of +-- waiting out the staleness window with an empty card. +UPDATE "users" + SET "insights_cached_at" = NULL + WHERE "insights_cached_text_encrypted" IS NULL + AND "insights_cached_at" IS NOT NULL; diff --git a/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql index 6e9beaa6c..be965332f 100644 --- a/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql +++ b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql @@ -13,5 +13,5 @@ -- Reversibility: dropping the columns returns every list to its default -- presentation and switches every preview feature off. -ALTER TABLE "users" ADD COLUMN "module_list_layout_json" JSONB; -ALTER TABLE "users" ADD COLUMN "preview_features_json" JSONB; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "module_list_layout_json" JSONB; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "preview_features_json" JSONB; diff --git a/src/__tests__/migration-add-column-idempotent.test.ts b/src/__tests__/migration-add-column-idempotent.test.ts new file mode 100644 index 000000000..65a8b4401 --- /dev/null +++ b/src/__tests__/migration-add-column-idempotent.test.ts @@ -0,0 +1,61 @@ +/** + * Structural guard: a column a migration adds is added idempotently. + * + * Parallel release branches each add columns to `users`, and a host that ran + * a beta image already holds the columns a later rebuilt migration adds again. + * A bare `ADD COLUMN` then fails the boot with "column already exists". Every + * migration from `0422` on (the first one after the last release that shipped + * a bare form) writes `ADD COLUMN IF NOT EXISTS`, as `0424` and `0432` did from + * the start. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const MIGRATIONS_DIR = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../prisma/migrations", +); + +const FIRST_GUARDED = 422; + +/** Lines that add a column without `IF NOT EXISTS`, comments stripped. */ +function bareAddColumns(sql: string): string[] { + return sql + .split("\n") + .map((line) => line.replace(/--.*$/, "")) + .filter((line) => /\bADD\s+COLUMN\b/i.test(line)) + .filter((line) => !/\bADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\b/i.test(line)) + .map((line) => line.trim()); +} + +describe("migrations add columns idempotently", () => { + it("recognises the bare form and accepts the guarded one", () => { + expect( + bareAddColumns('ALTER TABLE "u" ADD COLUMN "a" JSONB;'), + ).toHaveLength(1); + expect( + bareAddColumns('ALTER TABLE "u" ADD COLUMN IF NOT EXISTS "a" JSONB;'), + ).toEqual([]); + expect(bareAddColumns("-- ADD COLUMN in a comment")).toEqual([]); + }); + + it(`uses ADD COLUMN IF NOT EXISTS from ${FIRST_GUARDED} on`, () => { + const offenders: string[] = []; + for (const entry of readdirSync(MIGRATIONS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const number = Number.parseInt(entry.name.slice(0, 4), 10); + if (!Number.isFinite(number) || number < FIRST_GUARDED) continue; + const sql = readFileSync( + resolve(MIGRATIONS_DIR, entry.name, "migration.sql"), + "utf8", + ); + for (const line of bareAddColumns(sql)) { + offenders.push(`${entry.name}: ${line}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/tests/integration/migration-0430-insights-stamp.test.ts b/tests/integration/migration-0430-insights-stamp.test.ts new file mode 100644 index 000000000..f48003037 --- /dev/null +++ b/tests/integration/migration-0430-insights-stamp.test.ts @@ -0,0 +1,71 @@ +/** + * Migration 0430 drops the readable copy of the cached insights payload. A row + * whose only copy was the readable one keeps no text afterwards, so the same + * migration clears its `insights_cached_at` stamp: the nightly pregenerate + * selects `insights_cached_at IS NULL OR < stale`, and a fresh stamp over an + * empty cache would have kept the card empty for the whole staleness window. + * + * The schema here is already migrated; the statement is replayed from the + * migration file against rows seeded in both shapes. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const MIGRATION = resolve( + __dirname, + "../../prisma/migrations/0430_drop_insights_cached_text/migration.sql", +); + +function stampStatement(): string { + const sql = readFileSync(MIGRATION, "utf8"); + const start = sql.indexOf('UPDATE "users"'); + if (start < 0) throw new Error("0430 carries no stamp reset"); + return sql.slice(start, sql.indexOf(";", start) + 1); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("migration 0430 clears the stamp of an emptied cache", () => { + it("resets the stamp only where no sealed payload is left", async () => { + const prisma = getPrismaClient(); + const stamp = new Date("2026-10-20T04:30:00Z"); + await prisma.user.create({ + data: { + id: "emptied", + username: "emptied", + email: "emptied@example.test", + role: "USER", + insightsCachedAt: stamp, + insightsCachedTextEncrypted: null, + }, + }); + await prisma.user.create({ + data: { + id: "sealed", + username: "sealed", + email: "sealed@example.test", + role: "USER", + insightsCachedAt: stamp, + insightsCachedTextEncrypted: new Uint8Array([1, 2, 3]), + }, + }); + + await prisma.$executeRawUnsafe(stampStatement()); + + const rows = await prisma.user.findMany({ + where: { id: { in: ["emptied", "sealed"] } }, + select: { id: true, insightsCachedAt: true }, + orderBy: { id: "asc" }, + }); + expect(rows).toEqual([ + { id: "emptied", insightsCachedAt: null }, + { id: "sealed", insightsCachedAt: stamp }, + ]); + }); +});