UNPKG

ts-migrate-mongoose

Version:

A migration framework for Mongoose, built with TypeScript.

548 lines (535 loc) 19.5 kB
import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import mongoose, { Schema } from 'mongoose'; import readline from 'node:readline'; 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`, dim: (text) => `\x1B[2m${text}\x1B[0m`, bold: (text) => `\x1B[1m${text}\x1B[0m` }; const MIGRATION_FILE_EXTENSIONS = ["ts", "js", "mjs", "cjs"]; const MIGRATION_FILE_REGEX = new RegExp(String.raw`(?<!\.d)\.(${MIGRATION_FILE_EXTENSIONS.join("|")})$`); const MIGRATION_NAME_REGEX = /^(?!.*\.\.)[A-Za-z0-9._-]+$/; const defaults = { 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(() => { loaded = true; }).catch(() => { 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 ansi = { cursorUp: (n) => `\x1B[${n}A`, eraseDown: "\x1B[J", cursorHide: "\x1B[?25l", cursorShow: "\x1B[?25h" }; const getPageSize = () => { return Math.max((process.stdout.rows || 20) - 4, 5); }; const checkbox = async (options) => { const { message, choices } = options; if (!process.stdin.isTTY) { return choices.map((c) => c.value); } return new Promise((resolve) => { let cursor = 0; const selected = /* @__PURE__ */ new Set(); let pageSize = getPageSize(); let scrollOffset = 0; function getVisibleRange() { if (choices.length <= pageSize) { return { start: 0, end: choices.length }; } if (cursor < scrollOffset) { scrollOffset = cursor; } else if (cursor >= scrollOffset + pageSize) { scrollOffset = cursor - pageSize + 1; } return { start: scrollOffset, end: Math.min(scrollOffset + pageSize, choices.length) }; } function getRenderedLines() { const { start, end } = getVisibleRange(); return end - start + 2; } let lastRenderedLines = 0; const render = () => { if (lastRenderedLines > 0) { process.stdout.write(ansi.cursorUp(lastRenderedLines)); } process.stdout.write(ansi.eraseDown); const { start, end } = getVisibleRange(); let output = `${chalk.green("?")} ${chalk.bold(message)} `; for (let i = start; i < end; i++) { const choice = choices[i]; const isActive = i === cursor; const isChecked = selected.has(i); const pointer = isActive ? chalk.cyan("\u276F") : " "; const check = isChecked ? chalk.green("\u25C9") : chalk.dim("\u25EF"); output += `${pointer} ${check} ${choice.name} `; } const hints = ["\u2191\u2193 navigate", "space select", "a all", "enter submit"]; if (choices.length > pageSize) { hints.unshift(`${start + 1}-${end}/${choices.length}`); } output += chalk.dim(hints.join(" \u2022 ")); process.stdout.write(output); lastRenderedLines = getRenderedLines(); }; const onResize = () => { pageSize = getPageSize(); render(); }; process.stdout.write(ansi.cursorHide); process.stdout.write("\n".repeat(getRenderedLines())); lastRenderedLines = getRenderedLines(); render(); readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.resume(); const restoreCursor = () => { process.stdout.write(ansi.cursorShow); }; process.on("exit", restoreCursor); process.on("SIGTERM", restoreCursor); process.on("SIGHUP", restoreCursor); process.stdout.on("resize", onResize); const moveCursor = (direction) => { cursor = (cursor + direction + choices.length) % choices.length; render(); }; const toggleCurrent = () => { selected.has(cursor) ? selected.delete(cursor) : selected.add(cursor); render(); }; const toggleAll = () => { if (selected.size === choices.length) { selected.clear(); } else { for (let i = 0; i < choices.length; i++) selected.add(i); } render(); }; const submit = () => { cleanup(); if (lastRenderedLines > 0) { process.stdout.write(ansi.cursorUp(lastRenderedLines)); } process.stdout.write(ansi.eraseDown); const sorted = [...selected].sort((a, b) => a - b); const selectedValues = sorted.map((i) => choices[i].value); const summary = sorted.length ? sorted.map((i) => choices[i].name).join(", ") : "none"; process.stdout.write(`${chalk.green("\u2714")} ${chalk.bold(message)} ${chalk.cyan(summary)} `); process.stdout.write(ansi.cursorShow); resolve(selectedValues); }; const keyActions = { up: () => moveCursor(-1), down: () => moveCursor(1), space: toggleCurrent, a: toggleAll, return: submit }; const onKeypress = (_str, key) => { if (key.ctrl && key.name === "c") { cleanup(); process.stdout.write(`${ansi.cursorShow} `); process.exit(130); } const action = key.name ? keyActions[key.name] : void 0; if (action) action(); }; const cleanup = () => { process.stdin.removeListener("keypress", onKeypress); process.stdin.setRawMode(false); process.stdin.pause(); process.removeListener("exit", restoreCursor); process.removeListener("SIGTERM", restoreCursor); process.removeListener("SIGHUP", restoreCursor); process.stdout.removeListener("resize", onResize); }; process.stdin.on("keypress", onKeypress); }); }; 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 || {}); const resolveMigrationFile = (basePath) => { if (fs.existsSync(basePath)) { return basePath; } for (const ext of MIGRATION_FILE_EXTENSIONS) { const pathWithExt = `${basePath}.${ext}`; if (fs.existsSync(pathWithExt)) { return pathWithExt; } } return basePath; }; class Migrator { migrationModel; connection; uri; template; migrationsPath; collection; autosync; cli; 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 { throw new Error("No mongoose connection or mongo uri provided to migrator"); } this.migrationModel = getMigrationModel(this.connection, this.collection); } /** * Asynchronously creates a new migrator instance */ static async connect(options) { await loader(); const migrator = new Migrator(options); try { await migrator.connected(); return migrator; } catch (error) { await migrator.close().catch(() => void 0); throw error; } } /** * 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, _id: 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) { if (!MIGRATION_NAME_REGEX.test(migrationName)) { throw new Error(`Invalid migration name '${migrationName}'. Allowed characters: letters, digits, underscore, hyphen, and non-consecutive dots.`); } const existingMigration = await this.migrationModel.findOne({ name: migrationName }).exec(); if (existingMigration) { throw new Error(`There is already a migration with name '${migrationName}' in the database`); } 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(`${chalk.green("Created")} migration ${chalk.cyan(migrationName)} in ${this.migrationsPath}`); return migrationCreated; } /** * Runs migrations up to or down to a given migration name */ async run(direction, migrationName, single = false) { if (migrationName !== void 0 && typeof migrationName !== "string") { throw new Error(`migrationName must be a string, received ${typeof migrationName}`); } await this.sync(); const isUp = direction === "up"; const state = isUp ? "down" : "up"; const key = isUp ? "$lte" : "$gte"; const sort = isUp ? 1 : -1; const untilMigration = await this.findUntilMigration(migrationName, state, single, sort); if (!untilMigration) { if (migrationName) { throw new Error(`Could not find migration with name '${migrationName}' in the database`); } return this.noPendingMigrations(); } const migrationsToRun = await this.collectMigrationsToRun(untilMigration, key, state, single, sort); 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; } /** * Resolves the `untilMigration` boundary document for `run()`. Either the * explicitly-named migration, or the most-recent/least-recent candidate * in the target state depending on whether we're running a single step * or a full batch. */ async findUntilMigration(migrationName, state, single, sort) { if (migrationName) { return this.migrationModel.findOne({ name: migrationName }).exec(); } const tieBreaker = single ? sort : -sort; return this.migrationModel.findOne({ state }).sort({ createdAt: tieBreaker, _id: tieBreaker }).exec(); } /** * Builds the ordered list of migrations to execute for `run()`. In single * mode, returns just the boundary doc; otherwise returns every pending * migration on the same side of the boundary, sorted deterministically. */ async collectMigrationsToRun(untilMigration, key, state, single, sort) { if (single) { return [untilMigration]; } const query = { createdAt: { [key]: untilMigration.createdAt }, state }; return this.migrationModel.find(query).sort({ createdAt: sort, _id: sort }).exec(); } /** * 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) { throw new Error("Could not synchronize migrations in the migrations folder up to the database", { cause: 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.some((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(`${chalk.red("Removing")} migration(s) from database: ${chalk.cyan(migrationsToDelete.join("\n"))}`); await this.migrationModel.deleteMany({ name: { $in: migrationsToDelete } }).exec(); } return migrationsDeleted; } catch (error) { throw new Error("Could not prune extraneous migrations from database", { cause: 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(chalk.cyan("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(`${chalk.green("Adding")} migration ${chalk.cyan(filePath)} into database. 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 migrationsInFs = files.filter((filename) => /^\d{13,}-/.test(filename) && MIGRATION_FILE_REGEX.test(filename)).map((filename) => { const filenameWithoutExtension = filename.replace(MIGRATION_FILE_REGEX, ""); const [time] = filename.split("-"); const timestamp = Number.parseInt(time ?? "", 10); 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 = []; const migrationsRoot = path.resolve(this.migrationsPath) + path.sep; for (const migration of migrationsToRun) { const baseMigrationPath = path.resolve(path.join(this.migrationsPath, migration.filename)); if (!baseMigrationPath.startsWith(migrationsRoot)) { throw new Error(`Refusing to import migration '${migration.filename}' \u2014 resolved path escapes the migrations directory.`); } const migrationFilePath = resolveMigrationFile(baseMigrationPath); const fileUrl = pathToFileURL(migrationFilePath).href; const migrationFunctions = await import(fileUrl); const migrationFunction = migrationFunctions.default?.[direction] ?? migrationFunctions[direction]; if (!migrationFunction) { throw new Error(`The '${direction}' export is not defined in ${migration.filename}.`); } try { await migrationFunction(this.connection); this.logMigrationStatus(direction, migration.filename); await this.migrationModel.updateMany({ name: migration.name }, { $set: { state: direction } }).exec(); migrationsRan.push(migration); } catch (error) { throw new Error(`Failed to run migration with name '${migration.name}' due to an error`, { cause: error }); } } return migrationsRan; } } export { Env, Migrator };