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