diff --git a/scripts/__tests__/db-dump.test.ts b/scripts/__tests__/db-dump.test.ts new file mode 100644 index 000000000..bad66da72 --- /dev/null +++ b/scripts/__tests__/db-dump.test.ts @@ -0,0 +1,70 @@ +/** + * The pre-migration dump decides whether to take a dump from the count of + * pending migrations. A count that fails (a connect or statement timeout, a + * database still starting up) used to read as "nothing pending": the step + * exited 0 with no dump, and the migration a second later ran without its + * way back. "Cannot tell" now takes the dump. + * + * The database is a stand-in: `createDatabaseDump` is a spy, `connect` + * answers the two facts the step reads (an applied migration, some tables). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const pendingMigrations = vi.fn<() => Promise>(); +const createDatabaseDump = vi.fn(async () => ({ + dumpPath: "/b/healthlog-20261025T013000Z-pre-migration.dump", + manifest: { dump: { bytes: 1024 }, rowCounts: { "public.users": 1 } }, +})); + +vi.mock("../pending-migrations.mjs", () => ({ + pendingMigrations: () => pendingMigrations(), +})); +vi.mock("../../src/lib/database-backup/dump-core.mjs", async (original) => ({ + ...(await original>()), + connect: async () => ({ + query: async () => ({ rows: [] }), + end: async () => {}, + }), + latestMigration: async () => "0488_account_deletion_grace", + listUserTables: async () => ["public.users"], + createDatabaseDump: () => createDatabaseDump(), + applyRetention: async () => [], +})); + +import { preMigrationDump } from "../db-dump.mjs"; + +const env = { + DATABASE_URL: "postgresql://u:p@db:5432/healthlog", + DB_BACKUP_DIR: "/b", +}; +const kept = () => + ({ writable: true, persistent: true, reason: "volume" }) as const; + +beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + createDatabaseDump.mockClear(); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("the dump before migrating", () => { + it("takes the dump when the pending migrations cannot be counted", async () => { + pendingMigrations.mockRejectedValue( + new Error("the database system is starting up"), + ); + expect(await preMigrationDump(env, { directoryStatus: kept })).toBe(0); + expect(createDatabaseDump).toHaveBeenCalledTimes(1); + }); + + it("takes none when nothing is pending", async () => { + pendingMigrations.mockResolvedValue([]); + expect(await preMigrationDump(env, { directoryStatus: kept })).toBe(0); + expect(createDatabaseDump).not.toHaveBeenCalled(); + }); + + it("takes one before pending migrations", async () => { + pendingMigrations.mockResolvedValue(["0489_database_backup_run_key"]); + expect(await preMigrationDump(env, { directoryStatus: kept })).toBe(0); + expect(createDatabaseDump).toHaveBeenCalledTimes(1); + }); +}); diff --git a/scripts/db-dump.mjs b/scripts/db-dump.mjs index f85cf535d..6337658f6 100644 --- a/scripts/db-dump.mjs +++ b/scripts/db-dump.mjs @@ -12,7 +12,9 @@ * newest two, neither older than the oldest nightly dump except the newest. * * Exit 0: the dump was written, or there was nothing to do (no pending - * migration, a new database, `DB_BACKUP_BEFORE_MIGRATE=false`), or there is no + * migration, a new database, `DB_BACKUP_BEFORE_MIGRATE=false`; a count of + * pending migrations that fails is not "none", and the dump is taken), or + * there is no * directory to put it in that survives the container (none writable, or only * the image's own directory without the volume, which the update that brings * these migrations is about to throw away), which is said as a warning and is @@ -74,6 +76,11 @@ export async function preMigrationDump(env = process.env, options = {}) { const databaseUrl = env.DATABASE_URL; if (!databaseUrl) return 0; + // "Cannot tell" is not "nothing to do": a count that timed out on a + // database still starting up was read as no pending migration, and the + // migration a second later ran without its way back. Unknown, the dump is + // taken. + /** @type {string[] | null} */ let pending; try { pending = await pendingMigrations({ @@ -82,11 +89,15 @@ export async function preMigrationDump(env = process.env, options = {}) { }); } catch (error) { warn( - `could not count pending migrations (${error instanceof Error ? error.message : String(error)}); no dump before migrating.`, + `could not count pending migrations (${error instanceof Error ? error.message : String(error)}); taking the dump before migrating anyway.`, ); - return 0; + pending = null; } - if (pending.length === 0) return 0; + if (pending !== null && pending.length === 0) return 0; + const migrations = + pending === null + ? "any pending migrations" + : `${pending.length} pending migration(s)`; // A database no migration has touched holds nothing to lose. const client = await connect(databaseUrl); @@ -115,15 +126,13 @@ export async function preMigrationDump(env = process.env, options = {}) { ? "is in memory (tmpfs) and would not keep a dump past a restart" : "is inside the container, not on a mounted volume, and would not keep a dump past this update"; warn( - `${dir} ${why}, so no dump is taken before the ${pending.length} pending migration(s). ` + + `${dir} ${why}, so no dump is taken before ${pending === null ? migrations : `the ${migrations}`}. ` + "Mount the backups volume (docker-compose.yml) to keep a way back; continuing.", ); return 0; } - log( - `taking a database dump before ${pending.length} pending migration(s) into ${dir}...`, - ); + log(`taking a database dump before ${migrations} into ${dir}...`); const started = Date.now(); try { const { dumpPath, manifest } = await createDatabaseDump({