/** * Restore a whole-database dump into an empty database. * * docker compose run --rm db-restore list [directory] * docker compose run --rm db-restore latest * docker compose run --rm db-restore [--ignore-key-mismatch] * * Runs in the app image with plain `node` (no `tsx`, no source checkout), as * the `db-restore` compose service, so a new server needs only the compose * file, the keys and the dump. `` is a name in `DB_BACKUP_DIR` or a * path inside the container: a `.dump` written on a server, or a `.dump.enc` * downloaded from the off-host bucket (`database/` prefix), with its manifest * (`.manifest.json` or `.manifest.json.enc`) beside it. * * In this order, and it stops at the first thing that is wrong, before it * writes anything: * 1. an off-host copy is opened with BACKUP_ENCRYPTION_KEY (or a key in * BACKUP_ENCRYPTION_PREVIOUS_KEYS) into a temporary file; * 2. the dump's SHA-256 and size must equal the manifest's; * 3. the keys in this environment are compared with the manifest's * fingerprints; each one missing or different is named, and a mismatch * of the active encryption key refuses (`--ignore-key-mismatch` restores * anyway, for an operator who knows the key is lost); * 4. the target database must hold no table at all. This never overwrites. * Then `pg_restore` in one transaction, `ANALYZE`, and a count of every table * against the manifest. A count that differs empties the database again and * fails, so the app is never started on a restore that does not match. * * Exit 0 restored (or listed), 1 refused or failed, 2 usage. */ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join } from "node:path"; import { pathToFileURL } from "node:url"; import { compareKeyFingerprints, compareRowCounts, connect, countRows, decryptOffhostFile, emptyDatabase, hashFile, isGoodSet, isWritableDirectory, keyFingerprints, listDumpSets, listUserTables, offhostKeysFromEnv, offhostObjectKey, parseDumpFileName, parseManifest, resolveBackupDir, runTool, statOrNull, connectionFromUrl, } from "../src/lib/database-backup/dump-core.mjs"; import { shippedMigrations } from "./pending-migrations.mjs"; const out = (line = "") => console.log(line); const say = (line) => console.log(`db-restore: ${line}`); class Refusal extends Error {} function mb(bytes) { return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } /** * Whether this image knows the dump's newest migration. A dump from a newer * release restored under an older image leaves the app with a schema its code * does not expect, so the restore says which image to run. */ function imageKnowsMigration(name) { if (!name) return true; try { return shippedMigrations( join(process.cwd(), "prisma", "migrations"), ).includes(name); } catch { return true; } } function listCommand(dir) { const sets = listDumpSets(dir); if (sets.length === 0) { say(`no dumps in ${dir}`); return 0; } out(`Dumps in ${dir}, newest first:`); for (const set of sets) { const state = isGoodSet(set) ? `${mb(set.dump.bytes)}` : set.encryptedDump ? "off-host copy (.enc)" : set.dump ? "no manifest, not restorable" : "incomplete"; out( ` ${set.base}.dump${set.encryptedDump && !set.dump ? ".enc" : ""} ${set.kind} ${set.timestamp.toISOString()} ${state}`, ); } return 0; } /** * The dump and manifest to restore: a local pair, or an encrypted pair * opened into `scratch`. */ async function resolveInput(arg, dir, env) { if (arg === "latest") { const set = listDumpSets(dir).find( (s) => isGoodSet(s) || (s.encryptedDump !== null && (s.encryptedManifest !== null || s.manifest !== null)), ); if (!set) throw new Refusal(`no restorable dump in ${dir}`); arg = set.dump ? set.dump.path : set.encryptedDump; } const path = isAbsolute(arg) ? arg : join(dir, arg); if (!(await statOrNull(path))) throw new Refusal(`${path} does not exist`); const name = basename(path); const parsed = parseDumpFileName(name); if (!parsed || parsed.role !== "dump" || parsed.variant === "partial") { throw new Refusal( `${name} is not a HealthLog database dump (healthlog-