UNPKG

ts-migrate-mongoose

Version:

A migration framework for Mongoose, built with TypeScript.

377 lines (366 loc) 13.3 kB
import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { checkbox } from '@inquirer/prompts'; import mongoose, { Schema } from 'mongoose'; const chalk = { red: (text) => `\x1B[31m${text}\x1B[0m`, green: (text) => `\x1B[32m${text}\x1B[0m`, yellow: (text) => `\x1B[33m${text}\x1B[0m`, cyan: (text) => `\x1B[36m${text}\x1B[0m` }; const defaults = { MIGRATE_CONFIG_PATH: "./migrate", MIGRATE_MONGO_COLLECTION: "migrations", MIGRATE_MIGRATIONS_PATH: "./migrations", MIGRATE_AUTOSYNC: false, MIGRATE_CLI: false }; let loaded = false; const loader = async () => { if (loaded) return; await import('tsx').then(() => { console.log("Loaded tsx"); loaded = true; }).catch(() => { console.log("Skipped tsx"); loaded = false; }); }; const getMigrationModel = (connection, collection) => { const MigrationSchema = new Schema( { name: { type: String, required: true }, state: { type: String, enum: ["down", "up"], default: "down" }, createdAt: { type: Date, index: true }, updatedAt: { type: Date } }, { collection, autoCreate: true, timestamps: true } ); MigrationSchema.virtual("filename").get(function() { return `${this.createdAt.getTime().toString()}-${this.name}`; }); return connection.model(collection, MigrationSchema); }; const template = `// Import your schemas here import type { Connection } from 'mongoose' export async function up (connection: Connection): Promise<void> { // Write migration here } export async function down (connection: Connection): Promise<void> { // Write migration here } `; var Env = /* @__PURE__ */ ((Env2) => { Env2["MIGRATE_CONFIG_PATH"] = "MIGRATE_CONFIG_PATH"; Env2["MIGRATE_MONGO_COLLECTION"] = "MIGRATE_MONGO_COLLECTION"; Env2["MIGRATE_MIGRATIONS_PATH"] = "MIGRATE_MIGRATIONS_PATH"; Env2["MIGRATE_AUTOSYNC"] = "MIGRATE_AUTOSYNC"; Env2["MIGRATE_CLI"] = "MIGRATE_CLI"; Env2["MIGRATE_MODE"] = "MIGRATE_MODE"; Env2["MIGRATE_MONGO_URI"] = "MIGRATE_MONGO_URI"; Env2["MIGRATE_TEMPLATE_PATH"] = "MIGRATE_TEMPLATE_PATH"; return Env2; })(Env || {}); class Migrator { constructor(options) { 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) { await loader(); const migrator = new Migrator(options); await migrator.connected(); return migrator; } /** * Close the underlying connection to mongo */ async close() { await this.connection.close(); } /** * Lists all migrations in the database and their status */ async list() { 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) => { this.logMigrationStatus(migration.state, migration.filename); return migration; }); } /** * Create a new migration file */ async create(migrationName) { 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, migrationName, single = false) { await this.sync(); let untilMigration = 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 }).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 = { 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() { 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} ${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() { try { let migrationsDeleted = []; 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: ${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} ${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 */ async noPendingMigrations() { 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 */ log(message) { if (this.cli) { console.log(message); } } /** * Logs migration status to the console */ logMigrationStatus(direction, filename) { const color = direction === "up" ? "green" : "red"; const directionWithColor = chalk[color](`${direction}:`); this.log(`${directionWithColor} ${filename} `); } /** * Gets template from file system */ getTemplate(templatePath) { if (templatePath && fs.existsSync(templatePath)) { return fs.readFileSync(templatePath, "utf8"); } return template; } /** * Ensures that the migrations path exists */ ensureMigrationsPath() { if (!fs.existsSync(this.migrationsPath)) { fs.mkdirSync(this.migrationsPath, { recursive: true }); } } /** * Connection status of the migrator to the database */ async connected() { return this.connection.asPromise(); } /** * Creates a new migration in database to reflect the changes in file system */ async syncMigrations(migrationsInFs) { 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 */ async getMigrations() { const files = fs.readdirSync(this.migrationsPath); const migrationsInDb = await this.migrationModel.find({}).exec(); const fileExtensionMatch = /(\.js|(?<!\.d)\.ts)$/; 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 */ async choseMigrations(migrations, message) { 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 */ async runMigrations(migrationsToRun, direction) { const migrationsRan = []; 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); const migrationFunction = "default" in migrationFunctions ? migrationFunctions.default[direction] : 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} ${error.message}`; } throw error; } } return migrationsRan; } } export { Env as E, Migrator as M, chalk as c, defaults as d, loader as l };