krona-staff/scripts/migrate.mjs

89 lines
3.2 KiB
JavaScript

import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import pg from 'pg';
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,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
if (direction === 'up') {
const appliedResult = await client.query('SELECT version FROM schema_migrations');
const applied = new Set(appliedResult.rows.map((row) => String(row.version)));
for (const file of files) {
const version = file.replace(/\.up\.sql$/, '');
if (applied.has(version)) continue;
const sql = await readFile(path.join(migrationsDirectory, file), 'utf8');
await applyTransaction(async () => {
await client.query(sql);
await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [version]);
});
console.log(`applied ${file}`);
}
} else {
const requestedVersion = process.argv[3];
const latestResult = await client.query(
'SELECT version 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 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;
}
}