/** * 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 * 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 { const entries = await fs.readdir(MIGRATIONS_DIR); return entries .filter((name) => name.endsWith('.ts') && !name.startsWith('_')) .sort(); } async function ensureMigrationsTable(): Promise { // 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 }> { 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 { const buf = await fs.readFile(path.join(MIGRATIONS_DIR, file)); return createHash('sha256').update(buf).digest('hex'); } async function listApplied(): Promise> { 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 { 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 { 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 { 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 { 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();