import { Db } from 'mongodb' import { readdir } from 'node:fs/promises' import { join } from 'node:path' import { pathToFileURL } from 'node:url' export abstract class MongoMigration { protected db: Db constructor(db: Db) { this.db = db } abstract up(): Promise abstract down(): Promise } export class MigratorRunner { private db: Db private migrationsPath: string constructor(db: Db, migrationsPath: string) { this.db = db this.migrationsPath = migrationsPath } private get migrationsCollection() { return this.db.collection('_migrations') } async run(): Promise { const files = await this.getMigrationFiles() const executed = await this.getExecutedMigrations() const pending = files.filter((f) => !executed.includes(f)) if (pending.length === 0) { return [] } const batch = await this.getNextBatch() const ran: string[] = [] for (const file of pending) { const migration = await this.loadMigration(file) await migration.up() await this.migrationsCollection.insertOne({ name: file, batch, executed_at: new Date(), }) ran.push(file) } return ran } async rollback(): Promise { const lastBatch = await this.getLastBatch() if (lastBatch === 0) return [] const migrations = await this.migrationsCollection .find({ batch: lastBatch }) .sort({ executed_at: -1 }) .toArray() const rolledBack: string[] = [] for (const record of migrations) { const migration = await this.loadMigration(record.name) await migration.down() await this.migrationsCollection.deleteOne({ _id: record._id }) rolledBack.push(record.name) } return rolledBack } async status(): Promise<{ name: string; batch: number | null; ran: boolean }[]> { const files = await this.getMigrationFiles() const executed = await this.migrationsCollection.find({}).toArray() const executedMap = new Map(executed.map((e) => [e.name, e.batch])) return files.map((file) => ({ name: file, batch: executedMap.get(file) ?? null, ran: executedMap.has(file), })) } private async getMigrationFiles(): Promise { try { const files = await readdir(this.migrationsPath) return files.filter((f) => f.endsWith('.ts') || f.endsWith('.js')).sort() } catch { return [] } } private async getExecutedMigrations(): Promise { const docs = await this.migrationsCollection.find({}).toArray() return docs.map((d) => d.name) } private async getNextBatch(): Promise { const last = await this.getLastBatch() return last + 1 } private async getLastBatch(): Promise { const doc = await this.migrationsCollection.find({}).sort({ batch: -1 }).limit(1).toArray() return doc.length > 0 ? doc[0].batch : 0 } private async loadMigration(file: string): Promise { const filePath = join(this.migrationsPath, file) const fileUrl = pathToFileURL(filePath).href const module = await import(fileUrl) const MigrationClass = module.default return new MigrationClass(this.db) } }