Dealer_Onboarding_Backend/scripts/run-migrations.ts

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();