Files
HealthLog/scripts/pending-migrations.mjs
Marc-André Bombeck e665f17f52 feat(backup): dump before migrating, and restore a dump into an empty database
The image now carries postgresql18-client (pg_dump and pg_restore 18.6)
and a /var/lib/healthlog/backups directory owned by the app user, which
the bundled compose file backs with a new `backups` volume for `app` and
the commented `app-worker`; a fresh named volume inherits that owner.

Before `migrate deploy`, when migrations are pending on a database that
has applied some, the entrypoint writes healthlog-<time>-pre-migration.dump
with its manifest and keeps the last two. A failed dump on a writable
directory stops the boot and names DB_BACKUP_BEFORE_MIGRATE=false; without
a writable directory it warns and continues.

`docker compose run --rm db-restore <file|latest|list>` runs
scripts/db-restore.mjs in the app image with plain node. It opens an
off-host .dump.enc with BACKUP_ENCRYPTION_KEY (or a previous key), checks
the archive against the manifest's SHA-256, compares the keys in the
environment with the manifest's fingerprints and refuses on a different
active encryption key unless --ignore-key-mismatch, refuses a database
that holds any table, restores in one transaction, runs ANALYZE, and
compares every table's row count with the manifest, emptying the database
again on a difference.

DB_BACKUP_ENABLED, DB_BACKUP_DIR, DB_BACKUP_SCHEDULE, DB_BACKUP_KEEP and
DB_BACKUP_BEFORE_MIGRATE join the compose whitelist, both example files
and the env manifest.
2026-10-25 12:56:33 +01:00

95 lines
3.6 KiB
JavaScript

/**
* Boot-time notice before `prisma migrate deploy`: how many migrations this
* image is about to apply, and that a dump is taken first
* (`scripts/db-dump.mjs`, which imports `pendingMigrations` from here).
*
* Run by `docker-entrypoint.sh` as `node scripts/pending-migrations.mjs`.
* It only reports. A migration that changes data cannot be undone by pulling
* the previous image; the dump the entrypoint takes next is the way back.
*
* Best effort by design: any failure to count (no `_prisma_migrations` table
* yet is not a failure, it means every migration is pending) prints one line
* saying so and exits 0, because a notice is never a reason to keep the app
* from starting. `migrate deploy` right after it is what decides.
*/
import { readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
/** Migration directory names that carry a `migration.sql`, sorted. */
export function shippedMigrations(migrationsDir) {
return readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => existsSync(join(migrationsDir, name, "migration.sql")))
.sort();
}
/**
* The shipped migrations the database has not finished applying. Reads
* `_prisma_migrations` in the schema the connection string names (Prisma's
* `?schema=` parameter, `public` when absent); a database without that table
* has applied nothing.
*/
export async function pendingMigrations({ connectionString, migrationsDir }) {
const shipped = shippedMigrations(migrationsDir);
const { default: pg } = await import("pg");
const url = new URL(connectionString);
const schema = url.searchParams.get("schema") || "public";
url.searchParams.delete("schema");
const client = new pg.Client({
connectionString: url.toString(),
connectionTimeoutMillis: 5_000,
statement_timeout: 5_000,
});
await client.connect();
try {
const table = await client.query(
"SELECT to_regclass(format('%I._prisma_migrations', $1::text)) AS t",
[schema],
);
if (!table.rows[0]?.t) return shipped;
const applied = await client.query(
`SELECT migration_name FROM ${table.rows[0].t}
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`,
);
const done = new Set(applied.rows.map((row) => row.migration_name));
return shipped.filter((name) => !done.has(name));
} finally {
await client.end().catch(() => {});
}
}
/** The log line for a count, as the entrypoint prints it. */
export function pendingNotice(count) {
if (count === 0) return "HealthLog: Database schema is up to date.";
const noun = count === 1 ? "migration" : "migrations";
return (
`HealthLog: ${count} database ${noun} pending. ` +
"Unless DB_BACKUP_BEFORE_MIGRATE=false, a pg_dump of the database is " +
"written to DB_BACKUP_DIR before they run (docs/ops/disaster-recovery.md); " +
"a migration is not undone by going back to the previous image."
);
}
async function main() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) return;
try {
const pending = await pendingMigrations({
connectionString,
migrationsDir: join(process.cwd(), "prisma", "migrations"),
});
console.log(pendingNotice(pending.length));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
console.log(
`HealthLog: Could not count pending migrations (${reason}); continuing.`,
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main();
}