Files
HealthLog/scripts/pending-migrations.mjs

96 lines
3.6 KiB
JavaScript

/**
* Boot-time notice before `prisma migrate deploy`: how many migrations this
* image is about to apply, and a reminder to have a backup first.
*
* 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, so an operator who reads the count in the log still has
* a moment to stop the container and take a `pg_dump`; the entrypoint does
* not wait for that.
*
* 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. ` +
"Back up the database before updating (pg_dump, see the Updates section " +
"of docs/self-hosting/getting-started.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();
}