knex/src/migrate/index.js

421 lines
13 KiB
JavaScript
Raw Normal View History

2015-05-09 13:58:18 -04:00
// Migrator
// -------
import fs from 'fs';
import path from 'path';
import mkdirp from 'mkdirp';
import Promise from 'bluebird';
import * as helpers from '../helpers';
import {
assign, bind, difference, each, filter, get, includes, isBoolean,
isEmpty, isUndefined, map, max, template
} from 'lodash'
import inherits from 'inherits';
function LockError(msg) {
this.name = 'MigrationLocked';
this.message = msg;
}
inherits(LockError, Error);
2015-05-09 13:58:18 -04:00
const SUPPORTED_EXTENSIONS = Object.freeze([
'.co', '.coffee', '.eg', '.iced', '.js', '.litcoffee', '.ls', '.ts'
]);
const CONFIG_DEFAULT = Object.freeze({
extension: 'js',
tableName: 'knex_migrations',
directory: './migrations',
disableTransactions: false
});
2015-05-09 13:58:18 -04:00
// The new migration we're performing, typically called from the `knex.migrate`
// interface on the main `knex` object. Passes the `knex` instance performing
// the migration.
2015-05-20 11:08:27 -04:00
export default class Migrator {
2015-05-20 11:08:27 -04:00
constructor(knex) {
this.knex = knex
2015-05-20 11:08:27 -04:00
this.config = this.setConfig(knex.client.config.migrations);
}
2015-05-09 13:58:18 -04:00
2015-05-20 11:08:27 -04:00
// Migrators to the latest configuration.
latest(config) {
this.config = this.setConfig(config);
return this._migrationData()
.tap(validateMigrationList)
.spread((all, completed) => {
const migrations = difference(all, completed);
const transactionForAll = !this.config.disableTransactions
&& isEmpty(filter(migrations, name => {
const migration = require(path.join(this._absoluteConfigDir(), name));
return !this._useTransaction(migration);
}));
if (transactionForAll) {
return this.knex.transaction(trx => this._runBatch(migrations, 'up', trx));
}
else {
return this._runBatch(migrations, 'up');
}
2015-05-20 11:08:27 -04:00
})
}
2015-05-09 13:58:18 -04:00
2015-05-20 11:08:27 -04:00
// Rollback the last "batch" of migrations that were run.
rollback(config) {
return Promise.try(() => {
this.config = this.setConfig(config);
return this._migrationData()
.tap(validateMigrationList)
.then((val) => this._getLastBatch(val))
.then((migrations) => {
2016-03-02 16:52:32 +01:00
return this._runBatch(map(migrations, 'name'), 'down');
2015-05-20 11:08:27 -04:00
});
})
}
2015-05-09 13:58:18 -04:00
status(config) {
this.config = this.setConfig(config);
return Promise.all([
this.knex(this.config.tableName).select('*'),
this._listAll()
])
.spread((db, code) => db.length - code.length);
}
// Retrieves and returns the current migration version we're on, as a promise.
// If no migrations have been run yet, return "none".
2015-05-20 11:08:27 -04:00
currentVersion(config) {
this.config = this.setConfig(config);
return this._listCompleted(config)
.then((completed) => {
const val = max(map(completed, value => value.split('_')[0]));
2016-03-02 16:52:32 +01:00
return (isUndefined(val) ? 'none' : val);
2015-05-20 11:08:27 -04:00
})
}
2015-05-09 13:58:18 -04:00
forceFreeMigrationsLock(config) {
this.config = this.setConfig(config);
const lockTable = this._getLockTableName();
return this.knex.schema.hasTable(lockTable)
.then(exist => exist && this._freeLock());
}
2015-05-20 11:08:27 -04:00
// Creates a new migration, with a given name.
make(name, config) {
this.config = this.setConfig(config);
2016-10-11 11:00:46 +02:00
if (!name) {
return Promise.reject(new Error('A name must be specified for the generated migration'));
}
2015-05-20 11:08:27 -04:00
return this._ensureFolder(config)
.then((val) => this._generateStubTemplate(val))
.then((val) => this._writeNewMigration(name, val));
}
2015-05-09 13:58:18 -04:00
2015-05-20 11:08:27 -04:00
// Lists all available migration versions, as a sorted array.
_listAll(config) {
this.config = this.setConfig(config);
return Promise.promisify(fs.readdir, {context: fs})(this._absoluteConfigDir())
.then(migrations => {
2016-03-02 16:52:32 +01:00
return filter(migrations, function(value) {
const extension = path.extname(value);
return includes(SUPPORTED_EXTENSIONS, extension);
2015-05-20 11:08:27 -04:00
}).sort();
})
}
2015-05-09 13:58:18 -04:00
// Ensures a folder for the migrations exist, dependent on the migration
// config settings.
2015-05-20 11:08:27 -04:00
_ensureFolder() {
const dir = this._absoluteConfigDir();
return Promise.promisify(fs.stat, {context: fs})(dir)
.catch(() => Promise.promisify(mkdirp)(dir));
2015-05-20 11:08:27 -04:00
}
2015-05-09 13:58:18 -04:00
// Ensures that a proper table has been created, dependent on the migration
// config settings.
_ensureTable(trx = this.knex) {
const table = this.config.tableName;
const lockTable = this._getLockTableName();
return trx.schema.hasTable(table)
.then(exists => !exists && this._createMigrationTable(table, trx))
.then(() => trx.schema.hasTable(lockTable))
.then(exists => !exists && this._createMigrationLockTable(lockTable, trx))
.then(() => trx.from(lockTable).select('*'))
.then(data => !data.length && trx.into(lockTable).insert({ is_locked: 0 }));
2015-05-20 11:08:27 -04:00
}
2015-05-09 13:58:18 -04:00
2015-05-20 11:08:27 -04:00
// Create the migration table, if it doesn't already exist.
_createMigrationTable(tableName, trx = this.knex) {
return trx.schema.createTableIfNotExists(tableName, function(t) {
2015-05-20 11:08:27 -04:00
t.increments();
t.string('name');
t.integer('batch');
t.timestamp('migration_time');
2015-05-09 13:58:18 -04:00
});
2015-05-20 11:08:27 -04:00
}
2015-05-09 13:58:18 -04:00
_createMigrationLockTable(tableName, trx = this.knex) {
return trx.schema.createTableIfNotExists(tableName, function(t) {
t.integer('is_locked');
});
}
_getLockTableName() {
return this.config.tableName + '_lock';
}
_isLocked(trx) {
const tableName = this._getLockTableName();
return this.knex(tableName)
.transacting(trx)
.forUpdate()
.select('*')
.then(data => data[0].is_locked);
}
_lockMigrations(trx) {
const tableName = this._getLockTableName();
return this.knex(tableName)
.transacting(trx)
.update({ is_locked: 1 });
}
_getLock(trx) {
const transact = trx ? fn => fn(trx) : fn => this.knex.transaction(fn);
return transact(trx => {
return this._isLocked(trx)
.then(isLocked => {
if (isLocked) {
throw new Error("Migration table is already locked");
}
})
.then(() => this._lockMigrations(trx));
}).catch(err => {
throw new LockError(err.message);
});
}
_freeLock(trx = this.knex) {
const tableName = this._getLockTableName();
return trx.table(tableName)
.update({ is_locked: 0 });
}
// Run a batch of current migrations, in sequence.
_runBatch(migrations, direction, trx) {
return this._getLock(trx)
// When there is a wrapping transaction, some migrations
// could have been done while waiting for the lock:
.then(() => trx ? this._listCompleted(trx) : [])
.then(completed => migrations = difference(migrations, completed))
.then(() => Promise.all(map(migrations, bind(this._validateMigrationStructure, this))))
.then(() => this._latestBatchNumber(trx))
.then(batchNo => {
if (direction === 'up') batchNo++;
return batchNo;
})
.then(batchNo => {
return this._waterfallBatch(batchNo, migrations, direction, trx)
})
.tap(() => this._freeLock(trx))
.catch(error => {
let cleanupReady = Promise.resolve();
if (error instanceof LockError) {
// If locking error do not free the lock.
helpers.warn(`Can't take lock to run migrations: ${error.message}`);
helpers.warn(
'If you are sure migrations are not running you can release the ' +
'lock manually by deleting all the rows from migrations lock ' +
'table: ' + this._getLockTableName()
);
} else {
helpers.warn(`migrations failed with error: ${error.message}`)
// If the error was not due to a locking issue, then remove the lock.
cleanupReady = this._freeLock(trx);
}
return cleanupReady.finally(function() {
throw error;
});
});
}
2015-05-09 13:58:18 -04:00
// Validates some migrations by requiring and checking for an `up` and `down`
// function.
2015-05-20 11:08:27 -04:00
_validateMigrationStructure(name) {
const migration = require(path.join(this._absoluteConfigDir(), name));
2015-05-20 11:08:27 -04:00
if (typeof migration.up !== 'function' || typeof migration.down !== 'function') {
throw new Error(`Invalid migration: ${name} must have both an up and down function`);
2015-05-20 11:08:27 -04:00
}
return name;
}
2015-05-09 13:58:18 -04:00
// Lists all migrations that have been completed for the current db, as an
// array.
_listCompleted(trx = this.knex) {
const { tableName } = this.config
return this._ensureTable(trx)
.then(() => trx.from(tableName).orderBy('id').select('name'))
2016-03-02 16:52:32 +01:00
.then((migrations) => map(migrations, 'name'))
2015-05-20 11:08:27 -04:00
}
// Gets the migration list from the specified migration directory, as well as
// the list of completed migrations to check what should be run.
2015-05-20 11:08:27 -04:00
_migrationData() {
return Promise.all([
this._listAll(),
this._listCompleted()
]);
}
2015-05-09 13:58:18 -04:00
// Generates the stub template for the current migration, returning a compiled
// template.
2015-05-20 11:08:27 -04:00
_generateStubTemplate() {
const stubPath = this.config.stub ||
path.join(__dirname, 'stub', this.config.extension + '.stub');
return Promise.promisify(fs.readFile, {context: fs})(stubPath).then(stub =>
template(stub.toString(), {variable: 'd'})
);
2015-05-20 11:08:27 -04:00
}
2015-05-09 13:58:18 -04:00
2015-05-20 11:08:27 -04:00
// Write a new migration to disk, using the config and generated filename,
// passing any `variables` given in the config to the template.
_writeNewMigration(name, tmpl) {
const { config } = this;
const dir = this._absoluteConfigDir();
2015-05-09 13:58:18 -04:00
if (name[0] === '-') name = name.slice(1);
const filename = yyyymmddhhmmss() + '_' + name + '.' + config.extension;
return Promise.promisify(fs.writeFile, {context: fs})(
2015-05-09 13:58:18 -04:00
path.join(dir, filename),
tmpl(config.variables || {})
).return(path.join(dir, filename));
2015-05-20 11:08:27 -04:00
}
2015-05-09 13:58:18 -04:00
// Get the last batch of migrations, by name, ordered by insert id in reverse
// order.
2015-05-20 11:08:27 -04:00
_getLastBatch() {
const { tableName } = this.config;
2015-05-20 11:08:27 -04:00
return this.knex(tableName)
.where('batch', function(qb) {
qb.max('batch').from(tableName)
})
.orderBy('id', 'desc');
}
2015-05-09 13:58:18 -04:00
2015-05-20 11:08:27 -04:00
// Returns the latest batch number.
_latestBatchNumber(trx = this.knex) {
return trx.from(this.config.tableName)
.max('batch as max_batch').then(obj => obj[0].max_batch || 0);
2015-05-20 11:08:27 -04:00
}
2015-05-09 13:58:18 -04:00
// If transaction config for a single migration is defined, use that.
// Otherwise, rely on the common config. This allows enabling/disabling
// transaction for a single migration at will, regardless of the common
// config.
_useTransaction(migration, allTransactionsDisabled) {
const singleTransactionValue = get(migration, 'config.transaction');
2016-03-02 16:52:32 +01:00
return isBoolean(singleTransactionValue) ?
singleTransactionValue :
!allTransactionsDisabled;
}
// Runs a batch of `migrations` in a specified `direction`, saving the
// appropriate database information as the migrations are run.
_waterfallBatch(batchNo, migrations, direction, trx) {
const trxOrKnex = trx || this.knex;
const {tableName, disableTransactions} = this.config;
const directory = this._absoluteConfigDir();
let current = Promise.bind({failed: false, failedOn: 0});
const log = [];
2016-03-02 16:52:32 +01:00
each(migrations, (migration) => {
const name = migration;
2015-05-20 11:08:27 -04:00
migration = require(directory + '/' + name);
2015-05-09 13:58:18 -04:00
// We're going to run each of the migrations in the current "up".
current = current.then(() => {
if (!trx && this._useTransaction(migration, disableTransactions)) {
return this._transaction(migration, direction, name)
2015-05-20 11:08:27 -04:00
}
return warnPromise(migration[direction](trxOrKnex, Promise), name)
2015-05-20 11:08:27 -04:00
})
.then(() => {
log.push(path.join(directory, name));
if (direction === 'up') {
return trxOrKnex.into(tableName).insert({
name,
batch: batchNo,
migration_time: new Date()
});
}
if (direction === 'down') {
return trxOrKnex.from(tableName).where({name}).del();
}
});
2015-05-20 11:08:27 -04:00
})
2015-05-09 13:58:18 -04:00
2015-05-20 11:08:27 -04:00
return current.thenReturn([batchNo, log]);
}
2015-05-09 13:58:18 -04:00
_transaction(migration, direction, name) {
return this.knex.transaction((trx) => {
return warnPromise(migration[direction](trx, Promise), name, () => {
trx.commit()
})
})
}
2015-05-20 11:08:27 -04:00
_absoluteConfigDir() {
return path.resolve(process.cwd(), this.config.directory);
}
2015-05-09 13:58:18 -04:00
2015-05-20 11:08:27 -04:00
setConfig(config) {
return assign({}, CONFIG_DEFAULT, this.config || {}, config);
2015-05-20 11:08:27 -04:00
}
2015-05-09 13:58:18 -04:00
2015-05-20 11:08:27 -04:00
}
2015-05-09 13:58:18 -04:00
// Validates that migrations are present in the appropriate directories.
function validateMigrationList(migrations) {
const all = migrations[0];
const completed = migrations[1];
const diff = difference(completed, all);
2016-03-02 16:52:32 +01:00
if (!isEmpty(diff)) {
2015-05-09 13:58:18 -04:00
throw new Error(
`The migration directory is corrupt, the following files are missing: ${diff.join(', ')}`
2015-05-09 13:58:18 -04:00
);
}
}
function warnPromise(value, name, fn) {
2015-05-09 13:58:18 -04:00
if (!value || typeof value.then !== 'function') {
helpers.warn(`migration ${name} did not return a promise`);
if (fn && typeof fn === 'function') fn()
2015-05-09 13:58:18 -04:00
}
return value;
}
// Ensure that we have 2 places for each of the date segments.
2015-05-20 11:08:27 -04:00
function padDate(segment) {
2015-05-09 13:58:18 -04:00
segment = segment.toString();
return segment[1] ? segment : `0${segment}`;
2015-05-20 11:08:27 -04:00
}
2015-05-09 13:58:18 -04:00
// Get a date object in the correct format, without requiring a full out library
// like "moment.js".
2015-05-20 11:08:27 -04:00
function yyyymmddhhmmss() {
const d = new Date();
2015-05-09 13:58:18 -04:00
return d.getFullYear().toString() +
padDate(d.getMonth() + 1) +
padDate(d.getDate()) +
padDate(d.getHours()) +
padDate(d.getMinutes()) +
padDate(d.getSeconds());
2015-05-20 11:08:27 -04:00
}