182 lines
5.9 KiB
TypeScript
182 lines
5.9 KiB
TypeScript
/**
|
|
* Migration runner.
|
|
*
|
|
* Usage:
|
|
* npm run migrate:up -> apply pending migrations
|
|
* npm run migrate:status -> list applied vs pending
|
|
* npm run migrate:baseline -> mark every existing migration as already applied
|
|
*
|
|
* The runner is intentionally minimal: it discovers `.ts` files inside
|
|
* `scripts/migrations/`, ignores anything starting with `_` (template files,
|
|
* shared helpers), sorts them lexicographically (filename-based ordering ==
|
|
* timestamp ordering when authors follow the convention), and applies each
|
|
* file whose `name` is not yet recorded in the `migrations` table.
|
|
*
|
|
* Each migration file must `export default` an object exposing
|
|
* async up({ sequelize, queryInterface }): Promise<void>
|
|
* See `scripts/migrations/_template.ts` for the contract.
|
|
*/
|
|
|
|
import 'dotenv/config';
|
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
import { createHash } from 'crypto';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import db from '../src/database/models/index.js';
|
|
|
|
const MIGRATIONS_DIR = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'migrations'
|
|
);
|
|
|
|
type Mode = 'up' | 'status' | 'baseline';
|
|
|
|
function parseMode(argv: string[]): Mode {
|
|
if (argv.includes('--status')) return 'status';
|
|
if (argv.includes('--baseline')) return 'baseline';
|
|
return 'up';
|
|
}
|
|
|
|
async function discoverMigrationFiles(): Promise<string[]> {
|
|
const entries = await fs.readdir(MIGRATIONS_DIR);
|
|
return entries
|
|
.filter((name) => name.endsWith('.ts') && !name.startsWith('_'))
|
|
.sort();
|
|
}
|
|
|
|
async function ensureMigrationsTable(): Promise<void> {
|
|
// The Migration model has the canonical schema. We create the table only
|
|
// if it does not yet exist so this script remains safe to run on any
|
|
// environment (fresh sync OR already-migrated DB).
|
|
await db.Migration.sync();
|
|
}
|
|
|
|
async function loadMigration(file: string): Promise<{ up: (ctx: any) => Promise<void> }> {
|
|
const fullPath = path.join(MIGRATIONS_DIR, file);
|
|
const mod: any = await import(pathToFileURL(fullPath).href);
|
|
const migration = mod.default ?? mod;
|
|
if (!migration || typeof migration.up !== 'function') {
|
|
throw new Error(`Migration ${file} does not export a default { up } function.`);
|
|
}
|
|
return migration;
|
|
}
|
|
|
|
async function fileChecksum(file: string): Promise<string> {
|
|
const buf = await fs.readFile(path.join(MIGRATIONS_DIR, file));
|
|
return createHash('sha256').update(buf).digest('hex');
|
|
}
|
|
|
|
async function listApplied(): Promise<Set<string>> {
|
|
const rows = await db.Migration.findAll({ attributes: ['name'] });
|
|
return new Set(rows.map((r: any) => r.name as string));
|
|
}
|
|
|
|
function stripExt(file: string): string {
|
|
return file.replace(/\.ts$/, '');
|
|
}
|
|
|
|
async function runUp(): Promise<void> {
|
|
const files = await discoverMigrationFiles();
|
|
const applied = await listApplied();
|
|
|
|
const pending = files.filter((f) => !applied.has(stripExt(f)));
|
|
if (pending.length === 0) {
|
|
console.log('All migrations are already applied. Nothing to do.');
|
|
return;
|
|
}
|
|
|
|
console.log(`Applying ${pending.length} migration(s)...\n`);
|
|
for (const file of pending) {
|
|
const name = stripExt(file);
|
|
process.stdout.write(` → ${name} ... `);
|
|
try {
|
|
const migration = await loadMigration(file);
|
|
await db.sequelize.transaction(async (transaction: any) => {
|
|
await migration.up({
|
|
sequelize: db.sequelize,
|
|
queryInterface: db.sequelize.getQueryInterface(),
|
|
transaction
|
|
});
|
|
});
|
|
const checksum = await fileChecksum(file);
|
|
await db.Migration.create({ name, checksum });
|
|
console.log('OK');
|
|
} catch (err: any) {
|
|
console.log('FAILED');
|
|
console.error(`\n${file} failed:`, err.message || err);
|
|
throw err;
|
|
}
|
|
}
|
|
console.log('\nMigrations complete.');
|
|
}
|
|
|
|
async function runStatus(): Promise<void> {
|
|
const files = await discoverMigrationFiles();
|
|
const applied = await listApplied();
|
|
|
|
if (files.length === 0) {
|
|
console.log('No migration files in scripts/migrations/.');
|
|
return;
|
|
}
|
|
|
|
console.log('Migration status:');
|
|
for (const file of files) {
|
|
const name = stripExt(file);
|
|
const mark = applied.has(name) ? '✓' : ' ';
|
|
console.log(` [${mark}] ${name}`);
|
|
}
|
|
|
|
const pendingCount = files.filter((f) => !applied.has(stripExt(f))).length;
|
|
console.log(`\nApplied: ${files.length - pendingCount} Pending: ${pendingCount}`);
|
|
}
|
|
|
|
async function runBaseline(): Promise<void> {
|
|
const files = await discoverMigrationFiles();
|
|
const applied = await listApplied();
|
|
|
|
const toStamp = files.filter((f) => !applied.has(stripExt(f)));
|
|
if (toStamp.length === 0) {
|
|
console.log('Baseline: every migration is already recorded. Nothing to do.');
|
|
return;
|
|
}
|
|
|
|
console.log(`Stamping ${toStamp.length} migration(s) as already-applied (no SQL executed):\n`);
|
|
for (const file of toStamp) {
|
|
const name = stripExt(file);
|
|
const checksum = await fileChecksum(file);
|
|
await db.Migration.create({ name, checksum });
|
|
console.log(` + ${name}`);
|
|
}
|
|
console.log('\nBaseline complete.');
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const mode = parseMode(process.argv.slice(2));
|
|
|
|
try {
|
|
await db.sequelize.authenticate();
|
|
await ensureMigrationsTable();
|
|
|
|
if (mode === 'status') {
|
|
await runStatus();
|
|
} else if (mode === 'baseline') {
|
|
await runBaseline();
|
|
} else {
|
|
await runUp();
|
|
}
|
|
|
|
await db.sequelize.close();
|
|
process.exit(0);
|
|
} catch (err: any) {
|
|
console.error('\nMigration runner aborted:', err?.message || err);
|
|
try {
|
|
await db.sequelize.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|