UNPKG

ts-migrate-mongoose

Version:

A migration framework for Mongoose, built with TypeScript.

372 lines (317 loc) 13.1 kB
import fs from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import { checkbox } from '@inquirer/prompts' import mongoose from 'mongoose' import { chalk } from './chalk' import { defaults } from './defaults' import { loader } from './loader' import { getMigrationModel } from './model' import { template } from './template' import type { Connection, FilterQuery, HydratedDocument, Model } from 'mongoose' import type { Migration, MigrationFile, MigrationFunctions, MigrationFunctionsDefault, MigratorOptions } from './types' export * from './types' /** * This class is responsible for running migrations in the CLI and Programmatic mode */ export class Migrator { readonly migrationModel: Model<Migration> readonly connection: Connection private readonly uri?: string private readonly template: string private readonly migrationsPath: string private readonly collection: string private readonly autosync: boolean private readonly cli: boolean private constructor(options: MigratorOptions) { // https://mongoosejs.com/docs/guide.html mongoose.set('strictQuery', false) this.template = this.getTemplate(options.templatePath) this.migrationsPath = path.resolve(options.migrationsPath ?? defaults.MIGRATE_MIGRATIONS_PATH) this.collection = options.collection ?? defaults.MIGRATE_MONGO_COLLECTION this.autosync = options.autosync ?? defaults.MIGRATE_AUTOSYNC this.cli = options.cli ?? defaults.MIGRATE_CLI this.ensureMigrationsPath() if (options.uri) { this.uri = options.uri this.connection = mongoose.createConnection(this.uri, options.connectOptions) } else { const message = chalk.red('No mongoose connection or mongo uri provided to migrator') throw new Error(message) } this.migrationModel = getMigrationModel(this.connection, this.collection) } /** * Asynchronously creates a new migrator instance */ static async connect(options: MigratorOptions): Promise<Migrator> { await loader() const migrator = new Migrator(options) await migrator.connected() return migrator } /** * Close the underlying connection to mongo */ async close(): Promise<void> { await this.connection.close() } /** * Lists all migrations in the database and their status */ async list(): Promise<HydratedDocument<Migration>[]> { await this.sync() const migrations = await this.migrationModel.find().sort({ createdAt: 1 }).exec() if (!migrations.length) this.log(chalk.yellow('There are no migrations to list')) return migrations.map((migration: HydratedDocument<Migration>) => { this.logMigrationStatus(migration.state, migration.filename) return migration }) } /** * Create a new migration file */ async create(migrationName: string): Promise<HydratedDocument<Migration>> { const existingMigration = await this.migrationModel.findOne({ name: migrationName }).exec() if (existingMigration) { const message = chalk.red(`There is already a migration with name '${migrationName}' in the database`) throw new Error(message) } await this.sync() const now = Date.now() const newMigrationFile = `${now.toString()}-${migrationName}.ts` fs.writeFileSync(path.join(this.migrationsPath, newMigrationFile), this.template) const migrationCreated = await this.migrationModel.create({ name: migrationName, createdAt: now, }) this.log(`Created migration ${migrationName} in ${this.migrationsPath}`) return migrationCreated } /** * Runs migrations up to or down to a given migration name */ async run(direction: 'up' | 'down', migrationName?: string, single = false): Promise<HydratedDocument<Migration>[]> { await this.sync() let untilMigration: HydratedDocument<Migration> | null = null const state = direction === 'up' ? 'down' : 'up' const key = direction === 'up' ? '$lte' : '$gte' const sort = direction === 'up' ? 1 : -1 if (migrationName) { untilMigration = await this.migrationModel.findOne({ name: migrationName }).exec() } else { untilMigration = await this.migrationModel .findOne({ state }) .sort({ createdAt: single ? sort : (-sort as -1 | 1) }) .exec() } if (!untilMigration) { if (migrationName) { const message = chalk.red(`Could not find migration with name '${migrationName}' in the database`) throw new ReferenceError(message) } return this.noPendingMigrations() } const query: FilterQuery<Migration> = { createdAt: { [key]: untilMigration.createdAt }, state, } const migrationsToRun = [] if (single) { migrationsToRun.push(untilMigration) } else { const migrations = await this.migrationModel.find(query).sort({ createdAt: sort }).exec() migrationsToRun.push(...migrations) } if (!migrationsToRun.length) { return this.noPendingMigrations() } const migrationsRan = await this.runMigrations(migrationsToRun, direction) if (migrationsToRun.length === migrationsRan.length && migrationsRan.length > 0) { this.log(chalk.green('All migrations finished successfully')) } return migrationsRan } /** * Looks at the file system migrations and imports any migrations that are * on the file system but missing in the database into the database * * This functionality is opposite of prune() */ async sync(): Promise<HydratedDocument<Migration>[]> { try { const { migrationsInFs } = await this.getMigrations() let migrationsToImport = migrationsInFs.filter((file) => !file.existsInDatabase).map((file) => file.filename) migrationsToImport = await this.choseMigrations(migrationsToImport, 'The following migrations exist in the migrations folder but not in the database.\nSelect the ones you want to import into the database') return this.syncMigrations(migrationsToImport) } catch (error) { const message = 'Could not synchronize migrations in the migrations folder up to the database' if (error instanceof Error) { error.message = `${message}\n${error.message}` } throw error } } /** * Removes files in migration directory which don't exist in database. * This is useful when you want to remove old migrations from the file system * And then remove them from the database using prune() * * This functionality is opposite of sync(). */ async prune(): Promise<HydratedDocument<Migration>[]> { try { let migrationsDeleted: HydratedDocument<Migration>[] = [] const { migrationsInDb, migrationsInFs } = await this.getMigrations() let migrationsToDelete = migrationsInDb.filter((migration) => !migrationsInFs.find((file) => file.filename === migration.filename)).map((migration) => migration.name) migrationsToDelete = await this.choseMigrations(migrationsToDelete, 'The following migrations exist in the database but not in the migrations folder.\nSelect the ones you want to remove from the database') if (migrationsToDelete.length) { migrationsDeleted = await this.migrationModel.find({ name: { $in: migrationsToDelete } }).exec() this.log(`Removing migration(s) from database: \n${chalk.cyan(migrationsToDelete.join('\n'))} `) await this.migrationModel.deleteMany({ name: { $in: migrationsToDelete } }).exec() } return migrationsDeleted } catch (error) { const message = 'Could not prune extraneous migrations from database' if (error instanceof Error) { error.message = `${message}\n${error.message}` } throw error } } /** * Logs a message to the console if there are no pending migrations * In cli mode, it also lists all migrations and their status */ private async noPendingMigrations(): Promise<HydratedDocument<Migration>[]> { this.log(chalk.yellow('There are no pending migrations')) if (this.cli) { this.log('Current migrations status: ') await this.list() } return [] } /** * Logs a message to the console if the migrator is running in cli mode or if force is true */ private log(message: string): void { if (this.cli) { console.log(message) } } /** * Logs migration status to the console */ private logMigrationStatus(direction: 'down' | 'up', filename: string): void { const color = direction === 'up' ? 'green' : 'red' const directionWithColor = chalk[color](`${direction}:`) this.log(`${directionWithColor} ${filename} `) } /** * Gets template from file system */ private getTemplate(templatePath: string | undefined): string { if (templatePath && fs.existsSync(templatePath)) { return fs.readFileSync(templatePath, 'utf8') } return template } /** * Ensures that the migrations path exists */ private ensureMigrationsPath(): void { if (!fs.existsSync(this.migrationsPath)) { fs.mkdirSync(this.migrationsPath, { recursive: true }) } } /** * Connection status of the migrator to the database */ private async connected(): Promise<Connection> { return this.connection.asPromise() } /** * Creates a new migration in database to reflect the changes in file system */ private async syncMigrations(migrationsInFs: string[]): Promise<HydratedDocument<Migration>[]> { const promises = migrationsInFs.map(async (filename) => { const filePath = path.join(this.migrationsPath, filename) const timestampSeparatorIndex = filename.indexOf('-') const timestamp = filename.slice(0, timestampSeparatorIndex) const migrationName = filename.slice(timestampSeparatorIndex + 1) this.log(`Adding migration ${filePath} into database from file system. State is ${chalk.red('down')}`) return this.migrationModel.create({ name: migrationName, createdAt: timestamp, }) }) return Promise.all(promises) } /** * Get migrations in database and in file system at the same time */ private async getMigrations(): Promise<{ migrationsInDb: Migration[] migrationsInFs: MigrationFile[] }> { const files = fs.readdirSync(this.migrationsPath) const migrationsInDb = await this.migrationModel.find({}).exec() const fileExtensionMatch = /(\.js|(?<!\.d)\.ts)$/ // allow .js and .ts files, but not .d.ts files const migrationsInFs = files .filter((filename) => /^\d{13,}-/.test(filename) && fileExtensionMatch.test(filename)) .map((filename) => { const filenameWithoutExtension = filename.replace(/\.(js|ts)$/, '') const [time] = filename.split('-') const timestamp = Number.parseInt(time ?? '') const createdAt = new Date(timestamp) const existsInDatabase = migrationsInDb.some((migration) => filenameWithoutExtension === migration.filename) return { createdAt, filename: filenameWithoutExtension, existsInDatabase } }) return { migrationsInDb, migrationsInFs } } /** * Creates a prompt for the user to chose the migrations to run */ private async choseMigrations(migrations: string[], message: string): Promise<string[]> { if (!this.autosync && migrations.length) { const selected = await checkbox({ message, choices: migrations.map((migration) => ({ name: migration, value: migration })), }) return selected } return migrations } /** * Run migrations in a given direction */ private async runMigrations(migrationsToRun: HydratedDocument<Migration>[], direction: 'down' | 'up'): Promise<HydratedDocument<Migration>[]> { const migrationsRan: HydratedDocument<Migration>[] = [] for await (const migration of migrationsToRun) { const migrationFilePath = path.resolve(path.join(this.migrationsPath, migration.filename)) const fileUrl = pathToFileURL(migrationFilePath).href const migrationFunctions = (await import(fileUrl)) as MigrationFunctions | MigrationFunctionsDefault const migrationFunction = 'default' in migrationFunctions ? migrationFunctions.default[direction] : (migrationFunctions as MigrationFunctions)[direction] if (!migrationFunction) { const message = chalk.red(`The '${direction}' export is not defined in ${migration.filename}.`) throw new Error(message) } try { await migrationFunction(this.connection) this.logMigrationStatus(direction, migration.filename) await this.migrationModel .where({ name: migration.name }) .updateMany({ $set: { state: direction } }) .exec() migrationsRan.push(migration) } catch (error) { const message = `Failed to run migration with name '${migration.name}' due to an error` if (error instanceof Error) { error.message = `${message}\n${error.message}` } throw error } } return migrationsRan } }