migrate-mongoose
Version:
A mongoose based migration framework for node
345 lines (294 loc) • 11.4 kB
JavaScript
const fs = require('fs');
const path = require('path');
require('colors')
const _ = require('lodash');
const ask = require('inquirer');
const mkdirp = require('mkdirp');
const mongoose = require('mongoose');
const Promise = require('bluebird');
const MigrationModelFactory = require('./db');
let MigrationModel;
Promise.config({
warnings: false
});
const migrationTemplate =`/**
* Make any changes you need to make to the database here
*/
async function up () {
// Write migration here
}
/**
* Make any changes that UNDO the up function side effects here (if possible)
*/
async function down () {
// Write migration here
}
module.exports = { up, down };
`;
class Migrator {
constructor({
templatePath,
migrationsPath = './migrations',
dbConnectionUri,
collectionName = 'migrations',
autosync = false,
cli = false,
connection
}) {
const defaultTemplate = migrationTemplate;
this.template = templatePath ? fs.readFileSync(templatePath, 'utf-8') : defaultTemplate;
this.migrationPath = path.resolve(migrationsPath);
this.connection = connection || mongoose.createConnection(dbConnectionUri, { useNewUrlParser: true });
this.collection = collectionName;
this.autosync = autosync;
this.cli = cli;
MigrationModel = MigrationModelFactory(collectionName, this.connection);
}
log(logString, force = false) {
if (force || this.cli) {
console.log(logString);
}
}
/**
* Use your own Mongoose connection object (so you can use this('modelname')
* @param {mongoose.connection} connection - Mongoose connection
*/
setMongooseConnection(connection) {
MigrationModel = MigrationModelFactory(this.collection, connection)
}
/**
* Close the underlying connection to mongo
* @returns {Promise} A promise that resolves when connection is closed
*/
close() {
return this.connection ? this.connection.close() : Promise.resolve();
}
/**
* Create a new migration
* @param {string} migrationName
* @returns {Promise<Object>} A promise of the Migration created
*/
async create(migrationName) {
try {
const existingMigration = await MigrationModel.findOne({ name: migrationName });
if (!!existingMigration) {
throw new Error(`There is already a migration with name '${migrationName}' in the database`.red);
}
await this.sync();
const now = Date.now();
const newMigrationFile = `${now}-${migrationName}.js`;
mkdirp.sync(this.migrationPath);
fs.writeFileSync(path.join(this.migrationPath, newMigrationFile), this.template);
// create instance in db
await this.connection;
const migrationCreated = await MigrationModel.create({
name: migrationName,
createdAt: now
});
this.log(`Created migration ${migrationName} in ${this.migrationPath}.`);
return migrationCreated;
} catch (error) {
this.log(error.stack);
fileRequired(error);
}
}
/**
* Runs migrations up to or down to a given migration name
*
* @param migrationName
* @param direction
*/
async run(direction = 'up', migrationName) {
await this.sync();
if (direction !== 'up' && direction !== 'down') {
throw new Error(`The '${direction}' is not supported, use the 'up' or 'down' direction`);
}
const untilMigration = migrationName ?
await MigrationModel.findOne({ name: migrationName }) :
await MigrationModel.findOne().sort({ createdAt: direction === 'up' ? -1 : 1 });
if (!untilMigration) {
if (migrationName) throw new ReferenceError("Could not find that migration in the database");
else throw new Error("There are no pending migrations.");
}
let query = {
createdAt: { $lte: untilMigration.createdAt },
state: 'down'
};
if (direction == 'down') {
query = {
createdAt: { $gte: untilMigration.createdAt },
state: 'up'
};
}
const sortDirection = direction == 'up' ? 1 : -1;
const migrationsToRun = await MigrationModel.find(query)
.sort({ createdAt: sortDirection });
if (!migrationsToRun.length) {
if (this.cli) {
this.log('There are no migrations to run'.yellow);
this.log(`Current Migrations' Statuses: `);
await this.list();
}
}
let self = this;
let numMigrationsRan = 0;
let migrationsRan = [];
for (const migration of migrationsToRun) {
const migrationFilePath = path.join(self.migrationPath, migration.filename);
let migrationFunctions;
migrationFunctions = require(migrationFilePath);
if (!migrationFunctions[direction]) {
throw new Error(`The "${direction}" export is not defined in ${migration.filename}.`.red);
}
try {
await new Promise((resolve, reject) => {
const callPromise = migrationFunctions[direction].call(
this.connection.model.bind(this.connection),
function callback(err) {
if (err) return reject(err);
resolve();
}
);
if (callPromise && typeof callPromise.then === 'function') {
callPromise.then(resolve).catch(reject);
}
});
this.log(`${direction.toUpperCase()}: `[direction == 'up' ? 'green' : 'red'] + ` ${migration.filename} `);
await MigrationModel.where({ name: migration.name }).updateMany({ $set: { state: direction } });
migrationsRan.push(migration.toJSON());
numMigrationsRan++;
} catch (err) {
this.log(`Failed to run migration ${migration.name} due to an error.`.red);
this.log(`Not continuing. Make sure your data is in consistent state`.red);
throw err instanceof (Error) ? err : new Error(err);
}
}
if (migrationsToRun.length == numMigrationsRan && numMigrationsRan > 0) this.log('All migrations finished successfully.'.green);
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 filesInMigrationFolder = fs.readdirSync(this.migrationPath);
const migrationsInDatabase = await MigrationModel.find({});
// Go over migrations in folder and delete any files not in DB
const migrationsInFolder = _.filter(filesInMigrationFolder, file => /\d{13,}\-.+.js$/.test(file))
.map(filename => {
const fileCreatedAt = parseInt(filename.split('-')[0]);
const existsInDatabase = migrationsInDatabase.some(m => filename == m.filename);
return { createdAt: fileCreatedAt, filename, existsInDatabase };
});
const filesNotInDb = _.filter(migrationsInFolder, { existsInDatabase: false }).map(f => f.filename);
let migrationsToImport = filesNotInDb;
this.log('Synchronizing database with file system migrations...');
if (!this.autosync && migrationsToImport.length) {
const answers = await new Promise(function (resolve) {
ask.prompt({
type: 'checkbox',
message: 'The following migrations exist in the migrations folder but not in the database. Select the ones you want to import into the database',
name: 'migrationsToImport',
choices: filesNotInDb
}, (answers) => {
resolve(answers);
});
});
migrationsToImport = answers.migrationsToImport;
}
return Promise.map(migrationsToImport, async (migrationToImport) => {
const filePath = path.join(this.migrationPath, migrationToImport),
timestampSeparatorIndex = migrationToImport.indexOf('-'),
timestamp = migrationToImport.slice(0, timestampSeparatorIndex),
migrationName = migrationToImport.slice(timestampSeparatorIndex + 1, migrationToImport.lastIndexOf('.'));
this.log(`Adding migration ${filePath} into database from file system. State is ` + `DOWN`.red);
const createdMigration = await MigrationModel.create({
name: migrationName,
createdAt: timestamp
});
return createdMigration.toJSON();
});
} catch (error) {
this.log(`Could not synchronise migrations in the migrations folder up to the database.`.red);
throw error;
}
}
/**
* Opposite of sync().
* Removes files in migration directory which don't exist in database.
*/
async prune() {
try {
const filesInMigrationFolder = fs.readdirSync(this.migrationPath);
const migrationsInDatabase = await MigrationModel.find({});
// Go over migrations in folder and delete any files not in DB
const migrationsInFolder = _.filter(filesInMigrationFolder, file => /\d{13,}\-.+.js/.test(file))
.map(filename => {
const fileCreatedAt = parseInt(filename.split('-')[0]);
const existsInDatabase = migrationsInDatabase.some(m => filename == m.filename);
return { createdAt: fileCreatedAt, filename, existsInDatabase };
});
const dbMigrationsNotOnFs = _.filter(migrationsInDatabase, m => {
return !_.find(migrationsInFolder, { filename: m.filename })
});
let migrationsToDelete = dbMigrationsNotOnFs.map(m => m.name);
if (!this.autosync && !!migrationsToDelete.length) {
const answers = await new Promise(function (resolve) {
ask.prompt({
type: 'checkbox',
message: 'The following migrations exist in the database but not in the migrations folder. Select the ones you want to remove from the file system.',
name: 'migrationsToDelete',
choices: migrationsToDelete
}, (answers) => {
resolve(answers);
});
});
migrationsToDelete = answers.migrationsToDelete;
}
const migrationsToDeleteDocs = await MigrationModel
.find({
name: { $in: migrationsToDelete }
}).lean();
if (migrationsToDelete.length) {
this.log(`Removing migration(s) `, `${migrationsToDelete.join(', ')}`.cyan, ` from database`);
await MigrationModel.deleteMany({
name: { $in: migrationsToDelete }
});
}
return migrationsToDeleteDocs;
} catch (error) {
this.log(`Could not prune extraneous migrations from database.`.red);
throw error;
}
}
/**
* Lists the current migrations and their statuses
* @returns {Promise<Array<Object>>}
* @example
* [
* { name: 'my-migration', filename: '149213223424_my-migration.js', state: 'up' },
* { name: 'add-cows', filename: '149213223453_add-cows.js', state: 'down' }
* ]
*/
async list() {
await this.sync();
const migrations = await MigrationModel.find().sort({ createdAt: 1 });
if (!migrations.length) this.log('There are no migrations to list.'.yellow);
return migrations.map((m) => {
this.log(
`${m.state == 'up' ? 'UP: \t' : 'DOWN:\t'}`[m.state == 'up' ? 'green' : 'red'] +
` ${m.filename}`
);
return m.toJSON();
});
}
}
function fileRequired(error) {
if (error && error.code == 'ENOENT') {
throw new ReferenceError(`Could not find any files at path '${error.path}'`);
}
}
module.exports = Migrator;