ts-migrate-mongoose
Version:
A migration framework for Mongoose, built with TypeScript.
548 lines (535 loc) • 19.5 kB
JavaScript
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 };