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 }, + ]); + }); +});