krona-staff/scripts/migrate.mjs
Ochenstarik d45bee960a
Some checks are pending
ci / verify (push) Waiting to run
fix: harden terminal recovery and deployment
2026-09-18 11:10:28 +07:00

129 lines
4.7 KiB
JavaScript

import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import pg from 'pg';
import {
assertAppliedMigrationsPresent,
assertMigrationChecksum,
migrationChecksum,
} from './migration-checksum.mjs';
const direction = process.argv[2];
if (direction !== 'up' && direction !== 'down') {
throw new Error('Usage: node scripts/migrate.mjs <up|down>');
}
const connectionString = process.env.DATABASE_MIGRATION_URL ?? process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_MIGRATION_URL or DATABASE_URL is required');
if (direction === 'down' && process.env.ALLOW_MIGRATION_DOWN !== 'true') {
throw new Error(
'Refusing destructive rollback. Set ALLOW_MIGRATION_DOWN=true to roll back the latest migration.',
);
}
const migrationsDirectory = path.resolve('migrations');
const files = (await readdir(migrationsDirectory))
.filter((name) => name.endsWith('.up.sql'))
.sort();
const client = new pg.Client({ connectionString, application_name: 'krona-staff-migrator' });
await client.connect();
const advisoryLockId = 7_347_821_009;
try {
await client.query('SELECT pg_advisory_lock($1)', [advisoryLockId]);
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
checksum TEXT,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await client.query('ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum TEXT');
if (direction === 'up') {
const appliedResult = await client.query('SELECT version, checksum FROM schema_migrations');
const applied = new Map(
appliedResult.rows.map((row) => [String(row.version), row.checksum && String(row.checksum)]),
);
const availableVersions = new Set(files.map((file) => file.replace(/\.up\.sql$/, '')));
assertAppliedMigrationsPresent(applied.keys(), availableVersions);
for (const file of files) {
const version = file.replace(/\.up\.sql$/, '');
const content = await readFile(path.join(migrationsDirectory, file));
const checksum = migrationChecksum(content);
if (applied.has(version)) {
const storedChecksum = applied.get(version);
if (storedChecksum) {
assertMigrationChecksum(version, storedChecksum, checksum);
} else {
await client.query('UPDATE schema_migrations SET checksum = $2 WHERE version = $1', [
version,
checksum,
]);
console.warn(`baselined checksum for legacy migration ${version}`);
}
continue;
}
const sql = content.toString('utf8');
await applyTransaction(async () => {
await client.query(sql);
await client.query('INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2)', [
version,
checksum,
]);
});
console.log(`applied ${file}`);
}
} else {
const requestedVersion = process.argv[3];
const latestResult = await client.query(
'SELECT version, checksum FROM schema_migrations ORDER BY version DESC LIMIT 1',
);
const latestVersion = latestResult.rows[0]?.version;
if (!latestVersion) {
console.log('no migration to roll back');
} else {
if (requestedVersion && requestedVersion !== latestVersion) {
throw new Error(
`Only the latest migration can be rolled back. Latest is ${latestVersion}.`,
);
}
const downFile = `${latestVersion}.down.sql`;
const upContent = await readFile(path.join(migrationsDirectory, `${latestVersion}.up.sql`));
const currentChecksum = migrationChecksum(upContent);
if (!latestResult.rows[0].checksum) {
throw new Error(
`Migration ${latestVersion} has no checksum. Run the up command once to establish the legacy baseline before rollback.`,
);
}
assertMigrationChecksum(
latestVersion,
String(latestResult.rows[0].checksum),
currentChecksum,
);
const sql = await readFile(path.join(migrationsDirectory, downFile), 'utf8');
await applyTransaction(async () => {
await client.query(sql);
await client.query('DELETE FROM schema_migrations WHERE version = $1', [latestVersion]);
});
console.log(`rolled back ${downFile}`);
}
}
} finally {
await client.query('SELECT pg_advisory_unlock($1)', [advisoryLockId]).catch(() => undefined);
await client.end();
}
async function applyTransaction(run) {
await client.query('BEGIN');
try {
await run();
await client.query('COMMIT');
} catch (error) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], 'Migration failed and rollback also failed');
}
throw error;
}
}