1222 lines
43 KiB
JavaScript
1222 lines
43 KiB
JavaScript
const _ = require('lodash');
|
|
const path = require('path');
|
|
const {sequence} = require('@tryghost/promise');
|
|
const debug = require('debug')('knex-migrator:index');
|
|
const database = require('./database');
|
|
const utils = require('./utils');
|
|
const errors = require('./errors');
|
|
const logging = require('@tryghost/logging');
|
|
const migrations = require('../migrations');
|
|
const locking = require('./locking');
|
|
|
|
/**
|
|
* @description Prototype of Knex-migrator.
|
|
* @param {Object} options
|
|
* @constructor
|
|
*/
|
|
function KnexMigrator(options = {}) {
|
|
let config = utils.loadConfig(options);
|
|
|
|
if (!config.database) {
|
|
throw new Error('MigratorConfig.js needs to export a database config.');
|
|
}
|
|
|
|
if (!config.migrationPath) {
|
|
throw new Error('MigratorConfig.js needs to export the location of your migration files.');
|
|
}
|
|
|
|
if (!config.currentVersion) {
|
|
throw new Error('MigratorConfig.js needs to export the a current version.');
|
|
}
|
|
|
|
this.executedFromShell = options.executedFromShell;
|
|
this.currentVersion = config.currentVersion;
|
|
this.migrationPath = config.migrationPath;
|
|
this.subfolder = config.subfolder || 'versions';
|
|
|
|
this.dbConfig = config.database;
|
|
}
|
|
|
|
/**
|
|
* `knex-migrator init`
|
|
*
|
|
* @description This task will run init scripts.
|
|
*
|
|
* The `init` command goes through the following steps:
|
|
*
|
|
* 1. Create a database knex connection.
|
|
* 2. Create database if it does not exist.
|
|
* 3. Create tables for knex-migrator (migrations, migrations-lock)
|
|
* 4. Run table upgrades/migrations if available.
|
|
* 5. Lock the tables to avoid running migrations in parallel.
|
|
* 6. Execute hooks and init scripts.
|
|
* 7. Init completion: add all existing migration scripts to the database to make it possible to detect a state of the database correctly.
|
|
* 8. Unlock tables.
|
|
* 9. Disconnect from database otherwise the CLI won't close properly.
|
|
*
|
|
* The `init` command can be triggered via `knex-migrator migrate --init`.
|
|
* This is a feature which makes it easier to not having to differentiate if a database needs a migration or an
|
|
* initilisation. The special characteristic of this combo is that the init completation shouldn't run, because
|
|
* otherwise we would overjump migration files to execute.
|
|
*
|
|
* @param {Object} options - Custom options you can pass in (disableHooks, noScripts, skipInitCompletion, only, skip)
|
|
* @returns {Promise<R>}
|
|
*/
|
|
KnexMigrator.prototype.init = function init(options) {
|
|
options = options || {};
|
|
|
|
let self = this,
|
|
disableHooks = options.disableHooks,
|
|
noScripts = options.noScripts,
|
|
skipInitCompletion = options.skipInitCompletion,
|
|
skippedTasks = [],
|
|
hooks = {};
|
|
|
|
try {
|
|
if (!disableHooks) {
|
|
hooks = require(path.join(self.migrationPath, '/hooks/init'));
|
|
}
|
|
} catch (err) {
|
|
debug('Hook Error: ' + err.message);
|
|
debug('No hooks found, no problem.');
|
|
}
|
|
|
|
this.connection = database.connect(this.dbConfig);
|
|
|
|
return database.createDatabaseIfNotExist(self.dbConfig)
|
|
.then(function () {
|
|
if (noScripts) {
|
|
return;
|
|
}
|
|
|
|
// @NOTE: create table outside of the transaction! (implicit)
|
|
return database.createMigrationsTable(self.connection).then(function () {
|
|
return migrations.run(self.connection).then(function () {
|
|
return locking.lock(self.connection)
|
|
.then(function () {
|
|
if (hooks.before) {
|
|
debug('Before hook');
|
|
return hooks.before({
|
|
connection: self.connection
|
|
});
|
|
}
|
|
})
|
|
.then(function executeMigrate() {
|
|
return self._migrateTo({
|
|
version: 'init',
|
|
only: options.only,
|
|
skip: options.skip
|
|
});
|
|
})
|
|
.then(function (response) {
|
|
skippedTasks = response.skippedTasks;
|
|
|
|
if (hooks.after) {
|
|
debug('After hook');
|
|
return hooks.after({
|
|
connection: self.connection
|
|
});
|
|
}
|
|
})
|
|
.then(function () {
|
|
const initTasks = utils.listFiles(path.join(self.migrationPath, 'init'));
|
|
|
|
/**
|
|
* CASE 1: You can disable init completion manually
|
|
* CASE 2: Only skip init completion if you have all init scripts in place already!!
|
|
*
|
|
* Example:
|
|
* - knex-migrator migrate --init should not execute init completion
|
|
* -> does not run init completion
|
|
*
|
|
* Example:
|
|
* - knex-migrator init (process was destroyed, you only have 1 init script in your db)
|
|
* - knex-migrator init (the other init script get's executed)
|
|
* -> run init completion
|
|
*
|
|
*/
|
|
if (skippedTasks.length === initTasks.length || skipInitCompletion) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
let versionsToMigrateTo;
|
|
|
|
// CASE: insert all migration files, otherwise you will run into problems
|
|
// e.g. you are on 1.2, you initialise the database, but there is 1.3 migration script
|
|
try {
|
|
versionsToMigrateTo = utils.readVersionFolders(
|
|
path.join(self.migrationPath, self.subfolder)) || [];
|
|
} catch (err) {
|
|
// CASE: versions folder does not exists
|
|
if (err.code === 'READ_FOLDERS') {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
|
|
return database.createTransaction(self.connection, async function (transacting) {
|
|
const existingMigrations = await transacting('migrations').select('name');
|
|
const existingMigrationsNames = existingMigrations.map(m => m.name);
|
|
const migrationsToAdd = [];
|
|
|
|
// CASE: Run over all migration scripts and add the file name to the database.
|
|
for (const versionToMigrateTo of versionsToMigrateTo) {
|
|
let versionPath = path.join(self.migrationPath, self.subfolder, versionToMigrateTo);
|
|
let filesToMigrateTo = utils.listFiles(versionPath) || [];
|
|
|
|
// CASE: check if migration exists, do not insert twice
|
|
for (const name of filesToMigrateTo) {
|
|
if (existingMigrationsNames.includes(name)) {
|
|
continue;
|
|
}
|
|
|
|
migrationsToAdd.push({
|
|
name,
|
|
version: versionToMigrateTo,
|
|
currentVersion: self.currentVersion
|
|
});
|
|
}
|
|
}
|
|
|
|
await self.connection
|
|
.batchInsert('migrations', migrationsToAdd)
|
|
.transacting(transacting);
|
|
});
|
|
})
|
|
.then(function () {
|
|
return locking.unlock(self.connection);
|
|
})
|
|
.catch(function (err) {
|
|
if (err instanceof errors.MigrationsAreLockedError) {
|
|
throw err;
|
|
}
|
|
|
|
if (err instanceof errors.LockError) {
|
|
throw err;
|
|
}
|
|
|
|
return locking.unlock(self.connection)
|
|
.then(function () {
|
|
throw err;
|
|
});
|
|
});
|
|
});
|
|
});
|
|
})
|
|
.then(function onInitSuccess() {
|
|
debug('Init Success');
|
|
})
|
|
.catch(function onInitError(err) {
|
|
// CASE: Do not rollback if migrations are locked
|
|
if (err instanceof errors.MigrationsAreLockedError) {
|
|
throw err;
|
|
}
|
|
|
|
// CASE: Do not rollback migration scripts, if lock error
|
|
if (err instanceof errors.LockError) {
|
|
throw err;
|
|
}
|
|
|
|
// CASE: ETIMEDOUT, ENOTFOUND
|
|
if (err instanceof errors.DatabaseError) {
|
|
throw err;
|
|
}
|
|
|
|
debug('Rolling back: ' + err.message);
|
|
|
|
return self._rollback({
|
|
version: 'init',
|
|
skippedTasks: {
|
|
init: skippedTasks
|
|
}
|
|
}).then(function () {
|
|
throw err;
|
|
}).catch(function (innerErr) {
|
|
if (errors.utils.isGhostError(innerErr)) {
|
|
throw err;
|
|
}
|
|
|
|
throw new errors.RollbackError({
|
|
message: innerErr.message,
|
|
err: innerErr,
|
|
context: `OuterError: ${err.message}`
|
|
});
|
|
});
|
|
})
|
|
.finally(function () {
|
|
let ops = [];
|
|
|
|
if (hooks.shutdown) {
|
|
ops.push(function shutdownHook() {
|
|
debug('Shutdown hook');
|
|
return hooks.shutdown({
|
|
executedFromShell: self.executedFromShell
|
|
});
|
|
});
|
|
}
|
|
|
|
ops.push(function destroyConnection() {
|
|
debug('Destroy connection');
|
|
return self.connection.destroy()
|
|
.then(function () {
|
|
debug('Destroyed connection');
|
|
});
|
|
});
|
|
|
|
return sequence(ops.map(op => () => {
|
|
return op.bind(self)();
|
|
}));
|
|
});
|
|
};
|
|
|
|
/**
|
|
* `knex-migrator migrate`
|
|
*
|
|
* @description This task will run migration scripts.
|
|
*
|
|
* The `migrate` task runs through the following steps:
|
|
*
|
|
* 1. Create a database knex connection.
|
|
* 2. Ensure connection works as expected.
|
|
* 3. Run table upgrades/migrations if available.
|
|
* 4. Lock the target tables to avoid running migrations in parallel.
|
|
* 5. Perform an integrity check to figure out which migrations need to run (compare files against database)
|
|
* 6. Execute migrations.
|
|
* 7. Unlock table.
|
|
* 8. Disconnect from database otherwise the CLI won't close properly.
|
|
*
|
|
* @param {Object} options - Custom options you can pass in (version, force, init, only, skip)
|
|
* @returns {Promise<any>}
|
|
*/
|
|
KnexMigrator.prototype.migrate = async function migrate(options) {
|
|
options = options || {};
|
|
|
|
let onlyVersion = options.version,
|
|
force = options.force,
|
|
init = options.init,
|
|
onlyFile = options.only,
|
|
versionsToMigrate = [],
|
|
hooks = {};
|
|
|
|
// CASE: you can only use only in combination with the version flag
|
|
if (onlyFile && !onlyVersion) {
|
|
onlyFile = null;
|
|
}
|
|
|
|
if (onlyVersion) {
|
|
debug('onlyVersion: ' + onlyVersion);
|
|
}
|
|
|
|
// CASE: `--init` flag is passed. Combo feature.
|
|
if (init) {
|
|
await this.init();
|
|
}
|
|
|
|
try {
|
|
hooks = require(path.join(this.migrationPath, '/hooks/migrate'));
|
|
} catch (err) {
|
|
debug('Hook Error: ' + err.message);
|
|
debug('No hooks found, no problem.');
|
|
}
|
|
|
|
this.connection = database.connect(this.dbConfig);
|
|
|
|
try {
|
|
await database.ensureConnectionWorks(this.connection);
|
|
|
|
await migrations.run(this.connection);
|
|
|
|
await locking.lock(this.connection);
|
|
|
|
const result = await this._integrityCheck({force});
|
|
|
|
_.each(result, function (_value, version) {
|
|
// CASE: Log which versions won't be executed based on the "only" flag
|
|
if (onlyVersion && version !== onlyVersion) {
|
|
debug('Do not execute: ' + version);
|
|
return;
|
|
}
|
|
});
|
|
|
|
if (onlyVersion) {
|
|
// CASE: filter out versions which should not run
|
|
let containsVersion = _.find(result, function (_obj, key) {
|
|
return key === onlyVersion;
|
|
});
|
|
|
|
if (!containsVersion) {
|
|
logging.warn('Cannot find requested version: ' + onlyVersion);
|
|
}
|
|
}
|
|
|
|
_.each(result, function (value, version) {
|
|
// CASE: compare files on disk with files in database
|
|
if (value.expected !== value.actual) {
|
|
debug('Need to execute migrations for: ' + version);
|
|
versionsToMigrate.push(version);
|
|
}
|
|
});
|
|
|
|
if (versionsToMigrate.length) {
|
|
if (hooks.before) {
|
|
debug('Before hook');
|
|
await hooks.before({
|
|
connection: this.connection
|
|
});
|
|
}
|
|
|
|
logging.info('Running migrations.');
|
|
|
|
for (const versionToMigrate of versionsToMigrate) {
|
|
try {
|
|
await this._migrateTo({
|
|
version: versionToMigrate,
|
|
only: onlyFile,
|
|
hooks: hooks
|
|
});
|
|
} catch (err) {
|
|
// CASE: Do not rollback if migrations are locked
|
|
if (err instanceof errors.MigrationsAreLockedError) {
|
|
throw err;
|
|
}
|
|
|
|
// CASE: Do not rollback migration scripts, if lock error
|
|
if (err instanceof errors.LockError) {
|
|
throw err;
|
|
}
|
|
|
|
// CASE: ETIMEDOUT, ENOTFOUND
|
|
if (err instanceof errors.DatabaseError) {
|
|
throw err;
|
|
}
|
|
|
|
if (err.context && err.context.name) {
|
|
debug(`Task failed: ${err.context.name}`);
|
|
}
|
|
|
|
logging.info(`Rolling back: ${err.message}.`);
|
|
|
|
const versionsMigrated = versionsToMigrate.slice(
|
|
0,
|
|
versionsToMigrate.indexOf(versionToMigrate) + 1
|
|
);
|
|
|
|
versionsMigrated.reverse();
|
|
|
|
try {
|
|
for (const versionMigrated of versionsMigrated) {
|
|
await this._rollback({version: versionMigrated, task: err.context});
|
|
}
|
|
logging.info(`Rollback was successful.`);
|
|
throw err;
|
|
} catch (innerErr) {
|
|
if (errors.utils.isGhostError(innerErr)) {
|
|
throw err;
|
|
}
|
|
|
|
throw new errors.RollbackError({
|
|
message: innerErr.message,
|
|
err: innerErr,
|
|
context: `OuterError: ${err.message}`
|
|
});
|
|
} finally {
|
|
await locking.unlock(this.connection);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hooks.after) {
|
|
debug('After hook');
|
|
await hooks.after({
|
|
connection: this.connection
|
|
});
|
|
}
|
|
}
|
|
await locking.unlock(this.connection);
|
|
} finally {
|
|
if (hooks.shutdown) {
|
|
debug('Shutdown hook');
|
|
await hooks.shutdown({
|
|
executedFromShell: this.executedFromShell
|
|
});
|
|
}
|
|
|
|
debug('Destroy connection');
|
|
await this.connection.destroy();
|
|
debug('Destroyed connection');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* `knex-migrator reset`
|
|
*
|
|
* @description The rest command will do a full hard reset.
|
|
*
|
|
* It will:
|
|
*
|
|
* 1. Create a connection to the database.
|
|
* 2. Ensure connection works as expected.
|
|
* 3. Lock the table to avoid running reset in parallel.
|
|
* 4. Run table upgrades/migrations if available.
|
|
* 5. Drop the database.
|
|
*
|
|
* If you pass the "force" flag, you will skip step 2-4.
|
|
*
|
|
* @param {Object} options - Custom options the user can pass in (force)
|
|
* @returns {*}
|
|
*/
|
|
KnexMigrator.prototype.reset = function reset(options) {
|
|
options = options || {};
|
|
|
|
let self = this;
|
|
let force = options.force;
|
|
|
|
this.connection = database.connect(this.dbConfig);
|
|
|
|
// CASE: ignore lock completely and drop the db
|
|
if (force) {
|
|
return database.drop({
|
|
connection: self.connection,
|
|
dbConfig: self.dbConfig
|
|
}).catch(function onRestError(err) {
|
|
// Database does not exist. MySql.
|
|
if (err.errno === 1049) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
throw err;
|
|
}).finally(function () {
|
|
debug('Destroy connection');
|
|
return self.connection.destroy()
|
|
.then(function () {
|
|
debug('Destroyed connection');
|
|
});
|
|
});
|
|
}
|
|
|
|
return database.ensureConnectionWorks(this.connection)
|
|
.then(function () {
|
|
return migrations.run(self.connection);
|
|
})
|
|
.then(function () {
|
|
return locking.lock(self.connection);
|
|
})
|
|
.then(function () {
|
|
return database.drop({
|
|
connection: self.connection,
|
|
dbConfig: self.dbConfig
|
|
});
|
|
})
|
|
.catch(function onRestError(err) {
|
|
if (err instanceof errors.MigrationsAreLockedError) {
|
|
throw err;
|
|
}
|
|
|
|
// CASE: ETIMEDOUT, ENOTFOUND
|
|
if (err instanceof errors.DatabaseError) {
|
|
throw err;
|
|
}
|
|
|
|
debug('Reset error: ' + err.message);
|
|
|
|
// CASE: Database does not exist. For MySql.
|
|
if (err.errno === 1049) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return locking.unlock(self.connection)
|
|
.then(function () {
|
|
throw err;
|
|
});
|
|
})
|
|
.finally(function () {
|
|
debug('Destroy connection');
|
|
return self.connection.destroy()
|
|
.then(function () {
|
|
debug('Destroyed connection');
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* `knex-migrator health`
|
|
*
|
|
* @description This task detects the (migration) state of your database.
|
|
*
|
|
* It asks the database if....
|
|
*
|
|
* - the database was initialised?
|
|
* - migration files need to be executed?
|
|
*
|
|
* The task runs through the following steps:
|
|
*
|
|
* 1. Create a database connection.
|
|
* 2. Ensure the connection works (credentials are correct)
|
|
* 3. Run table upgrades/migrations if available.
|
|
* 4. Asks if the database is locked and aborts if so.
|
|
* 5. Perform an integrity check to figure out which migrations need to run (compare files against database)
|
|
* 6. Returns result.
|
|
* 7. Destroy connection.
|
|
*
|
|
* @returns {Promise<any>}
|
|
*/
|
|
KnexMigrator.prototype.isDatabaseOK = function isDatabaseOK() {
|
|
let self = this;
|
|
this.connection = database.connect(this.dbConfig);
|
|
|
|
return database.ensureConnectionWorks(this.connection)
|
|
.then(function () {
|
|
return migrations.run(self.connection);
|
|
})
|
|
.then(function () {
|
|
return locking.isLocked(self.connection);
|
|
})
|
|
.then(function () {
|
|
return self._integrityCheck();
|
|
})
|
|
.then(function (result) {
|
|
// CASE: if an init script was removed, the health check will be positive (see #48)
|
|
if (result.init && result.init.expected > result.init.actual) {
|
|
throw new errors.DatabaseIsNotOkError({
|
|
message: 'Please run `yarn knex-migrator init`',
|
|
code: 'DB_NOT_INITIALISED'
|
|
});
|
|
}
|
|
|
|
_.each(_.omit(result, 'init'), function (value) {
|
|
// CASE: there are more migrations expected than have been run, database needs to be migrated
|
|
if (value.expected > value.actual) {
|
|
throw new errors.DatabaseIsNotOkError({
|
|
message: 'Migrations are missing. Please run `yarn knex-migrator migrate`.',
|
|
code: 'DB_NEEDS_MIGRATION',
|
|
help: `Expected: ${value.expected} items in migrations table, found: ${value.actual}`
|
|
});
|
|
// CASE: there are more actual migrations than expected, something has gone wrong :(
|
|
} else if (value.expected < value.actual) {
|
|
throw new errors.DatabaseIsNotOkError({
|
|
message: 'Detected more items in the migrations table than expected. Please manually inspect the migrations table.',
|
|
code: 'MIGRATION_STATE_ERROR',
|
|
help: `Expected: ${value.expected} items in migrations table, found: ${value.actual}`
|
|
});
|
|
}
|
|
});
|
|
})
|
|
.catch(function (err) {
|
|
// CASE: database does not exist
|
|
if (err.errno === 1049) {
|
|
throw new errors.DatabaseIsNotOkError({
|
|
message: 'Please run `yarn knex-migrator init`',
|
|
code: 'DB_NOT_INITIALISED'
|
|
});
|
|
}
|
|
|
|
throw err;
|
|
})
|
|
.finally(function () {
|
|
if (!self.connection) {
|
|
return;
|
|
}
|
|
|
|
debug('Destroy connection');
|
|
return self.connection.destroy()
|
|
.then(function () {
|
|
debug('Destroyed connection');
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* `knex-migrator rollback`
|
|
*
|
|
* @description This task will rollback the database to a version.
|
|
*
|
|
* It will:
|
|
*
|
|
* 1. Create a connection to the database.
|
|
* 2. Ensure the connection works (credentials are correct)
|
|
* 3. Asks the database if the lock is active.
|
|
* 4. If the lock is not active, you cannot rollback. This is the current default behaviour.
|
|
* 5. If you pass the "force" flag, you can rollback if the lock is inactive.
|
|
* 6. Executes rollback helper to rollback to a version.
|
|
* 7. Destroy connection.
|
|
*
|
|
* @param {Object} options - Custom options the user can pass in (force, version, v, disableHooks)
|
|
* @returns {Promise<any>}
|
|
*/
|
|
KnexMigrator.prototype.rollback = function rollback(options) {
|
|
options = options || {};
|
|
|
|
let self = this;
|
|
let force = options.force;
|
|
let version = options.version || options.v;
|
|
let disableHooks = options.disableHooks;
|
|
let hooks = {};
|
|
|
|
this.connection = database.connect(this.dbConfig);
|
|
|
|
const helper = function helper() {
|
|
return new Promise(function (resolve, reject) {
|
|
try {
|
|
if (!disableHooks) {
|
|
// @TODO: load init or migrate hooks
|
|
hooks = require(path.join(self.migrationPath, '/hooks/init'));
|
|
}
|
|
} catch (err) {
|
|
debug('Hook Error: ' + err.message);
|
|
debug('No hooks found, no problem.');
|
|
}
|
|
|
|
if (hooks.before) {
|
|
return hooks.before({
|
|
connection: self.connection
|
|
}).then(resolve).catch(reject);
|
|
}
|
|
|
|
resolve();
|
|
}).then(function () {
|
|
let whereQuery = {};
|
|
|
|
// CASE 1: rollback to specific version (query all and filter out)
|
|
// CASE 2: rollback current version you are on
|
|
if (version) {
|
|
debug(`Rollback to specific version: ${version}`);
|
|
whereQuery = {};
|
|
} else {
|
|
whereQuery = {
|
|
currentVersion: self.currentVersion
|
|
};
|
|
}
|
|
|
|
return self.connection('migrations')
|
|
.where(whereQuery)
|
|
.then(function (values) {
|
|
if (!values.length) {
|
|
throw new errors.IncorrectUsageError({
|
|
message: 'No migrations available to rollback.'
|
|
});
|
|
}
|
|
|
|
// CASE: filter out all versions which are smaller than the version we want to rollback to
|
|
if (version) {
|
|
values = _.filter(values, function (value) {
|
|
return utils.isGreaterThanVersion({
|
|
greaterVersion: value.version,
|
|
smallerVersion: version
|
|
});
|
|
});
|
|
}
|
|
|
|
// @NOTE: we never ever rollback init scripts for now.
|
|
// this can be very dangerous, because it removes tables
|
|
// @EXCEPTION: you run init scripts and they fail
|
|
values = _.filter(values, function (value) {
|
|
return value.version !== 'init';
|
|
});
|
|
|
|
values.reverse();
|
|
return sequence(values.map(value => () => {
|
|
return self._rollback({
|
|
version: value.version,
|
|
onlyTasks: [value.name]
|
|
});
|
|
}));
|
|
});
|
|
}).then(function () {
|
|
if (hooks.shutdown) {
|
|
return hooks.shutdown({
|
|
executedFromShell: self.executedFromShell
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
return database.ensureConnectionWorks(this.connection)
|
|
.then(function () {
|
|
return migrations.run(self.connection);
|
|
})
|
|
.then(function () {
|
|
return locking.isLocked(self.connection);
|
|
})
|
|
.then(function () {
|
|
// CASE: db is not locked, force
|
|
if (force) {
|
|
return helper();
|
|
}
|
|
|
|
throw new errors.IncorrectUsageError({
|
|
message: 'Rollback did not happen.',
|
|
help: 'Use --force if you want to force a rollback. By default, rollbacks are only allowed if your database is locked.'
|
|
});
|
|
})
|
|
.catch(function (err) {
|
|
if (err instanceof errors.MigrationsAreLockedError) {
|
|
return helper()
|
|
.then(function () {
|
|
return locking.unlock(self.connection);
|
|
});
|
|
}
|
|
|
|
throw err;
|
|
})
|
|
.finally(function () {
|
|
if (!self.connection) {
|
|
return;
|
|
}
|
|
|
|
debug('Destroy connection');
|
|
return self.connection.destroy()
|
|
.then(function () {
|
|
debug('Destroyed connection');
|
|
});
|
|
});
|
|
};
|
|
|
|
// @TODO: All of these functions below are helper functions. Source them out as part of https://github.com/TryGhost/knex-migrator/issues/95.
|
|
/**
|
|
* @description Private helper function for rolling back. It is called in various places to rollback to a state.
|
|
*
|
|
* Cases:
|
|
*
|
|
* 1. Init or migrate task failed, rollback the previous tasks too.
|
|
* 2. Rollback task is executed.
|
|
*
|
|
* It will:
|
|
*
|
|
* 1. Read the migration tasks from disk.
|
|
* 2. Call "down" fn of target migration script.
|
|
* 3. Delete migration entry from database.
|
|
*
|
|
* @param {Object} options - Custom options the user can pass in (version, skippedTasks, onlyTasks, task)
|
|
* @returns {Promise<IterableOrNever<R>>}
|
|
* @private
|
|
*/
|
|
KnexMigrator.prototype._rollback = function _rollback(options) {
|
|
let version = options.version;
|
|
let skippedTasks = options.skippedTasks || [];
|
|
let onlyTasks = options.onlyTasks || [];
|
|
const failedTask = options.task;
|
|
let tasks = [];
|
|
let self = this;
|
|
|
|
if (version !== 'init') {
|
|
tasks = utils.readTasks(path.join(this.migrationPath, this.subfolder, version));
|
|
} else {
|
|
try {
|
|
tasks = utils.readTasks(path.join(this.migrationPath, version));
|
|
} catch (err) {
|
|
if (err.code === 'MIGRATION_PATH') {
|
|
tasks = [];
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
// CASE: rollback failed in one of the tasks in init or migrate
|
|
// CASE: if no task available, you are about to rollback manually `knex-migrator rollback`
|
|
if (failedTask) {
|
|
const newTasks = [];
|
|
|
|
for (let i = 0; i < tasks.length; i = i + 1) {
|
|
const task = tasks[i];
|
|
|
|
if (task.name !== failedTask.name) {
|
|
newTasks.push(task);
|
|
} else if (task.name === failedTask.name) {
|
|
/**
|
|
* @NOTE
|
|
*
|
|
* The task, which has failed, is never written to the database.
|
|
* But we have to double check if the target task was running in a transaction.
|
|
*
|
|
* Transaction: no need to rollback this task
|
|
* No Transaction: we have to rollback this task, because of implicit commits
|
|
*/
|
|
if (!failedTask.config || !failedTask.config.transaction) {
|
|
newTasks.push(task);
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
tasks = newTasks;
|
|
}
|
|
|
|
// CASE: one of the migrations that are about to be rolled back is marked as irreversible. Exit early without performing any actions
|
|
const irreversibleMigrations = _.filter(tasks, function (task) {
|
|
return !!_.get(task, 'config.irreversible');
|
|
});
|
|
if (irreversibleMigrations.length) {
|
|
return Promise.reject(new errors.IrreversibleMigrationError({
|
|
message: 'Unable to rollback',
|
|
help: 'There are irreversible migrations when rolling back to the selected version, this typically means data required for earlier versions has been deleted. Please restore from a backup instead.',
|
|
code: 'IRREVERSIBLE_MIGRATION'
|
|
}));
|
|
}
|
|
|
|
tasks.reverse();
|
|
|
|
return sequence(tasks.map(task => () => {
|
|
if (skippedTasks[version] && skippedTasks[version].indexOf(task.name) !== -1) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
if (onlyTasks.length && onlyTasks.indexOf(task.name) === -1) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
if (!task.down) {
|
|
debug('No down function provided', task.name);
|
|
return self.connection('migrations')
|
|
.where({
|
|
name: task.name,
|
|
version: version,
|
|
currentVersion: self.currentVersion
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
debug('Rollback', task.name);
|
|
|
|
if (task.config && task.config.transaction) {
|
|
return database.createTransaction(self.connection, function (txn) {
|
|
return task.down({
|
|
transacting: txn
|
|
});
|
|
}).then(function () {
|
|
return self.connection('migrations')
|
|
.where({
|
|
name: task.name,
|
|
version: version
|
|
})
|
|
.delete();
|
|
});
|
|
}
|
|
|
|
return task.down({
|
|
connection: self.connection
|
|
}).then(function () {
|
|
return self.connection('migrations')
|
|
.where({
|
|
name: task.name,
|
|
version: version
|
|
})
|
|
.delete();
|
|
});
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* @description Private migrate helper.
|
|
*
|
|
* Cases:
|
|
* 1. Init task will use this helper to migrate to "init".
|
|
* 2. Migrate task will use this helper to migrate to a version e.g. "1.1"
|
|
*
|
|
* It will:
|
|
* 1. Read the migration tasks from disk.
|
|
* 2. Execute hooks.
|
|
* 3. Create a transaction for the target migration file if configured. Each migration scripts can run in one transaction.
|
|
* If multiple versions/scripts are executed, we cannot run all of them in a single txn, because implicit commands can happen in between.
|
|
* 4. Execute "up" function of migration file.
|
|
* 5. Returns any skipped task. Skipped tasks are tasks which failed. Only one task is returned, the last one which failed.
|
|
*
|
|
* @param {Object} options - Custom options the user can pass in (version, hooks, only, skip)
|
|
* @returns {Promise<{skippedTasks: Array}>}
|
|
* @private
|
|
*/
|
|
KnexMigrator.prototype._migrateTo = function _migrateTo(options) {
|
|
options = options || {};
|
|
|
|
let self = this,
|
|
version = options.version,
|
|
hooks = options.hooks || {},
|
|
only = options.only || null,
|
|
skip = options.skip || null,
|
|
subfolder = this.subfolder,
|
|
skippedTasks = [],
|
|
tasks = [];
|
|
|
|
if (version !== 'init') {
|
|
tasks = utils.readTasks(path.join(self.migrationPath, subfolder, version));
|
|
} else {
|
|
try {
|
|
tasks = utils.readTasks(path.join(self.migrationPath, version));
|
|
} catch (err) {
|
|
if (err.code === 'MIGRATION_PATH') {
|
|
tasks = [];
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (only !== null) {
|
|
debug('only: ' + only);
|
|
tasks = [tasks[only - 1]];
|
|
} else if (skip !== null) {
|
|
debug('skip: ' + skip);
|
|
tasks.splice(skip - 1, 1);
|
|
}
|
|
|
|
debug('Migrate: ' + version + ' with ' + tasks.length + ' tasks.');
|
|
debug('Tasks: ' + JSON.stringify(tasks));
|
|
|
|
return sequence(tasks.map(task => () => {
|
|
return self._beforeEach({
|
|
task: task.name,
|
|
version: version
|
|
}).then(function () {
|
|
if (hooks.beforeEach) {
|
|
return hooks.beforeEach({
|
|
connection: self.connection
|
|
});
|
|
}
|
|
}).then(function () {
|
|
debug('Running up: ' + task.name);
|
|
|
|
if (task.config && task.config.transaction) {
|
|
return database.createTransaction(self.connection, function (txn) {
|
|
return task.up({
|
|
transacting: txn
|
|
});
|
|
});
|
|
}
|
|
|
|
return task.up({
|
|
connection: self.connection
|
|
});
|
|
}).then(function () {
|
|
if (hooks.afterEach) {
|
|
return hooks.afterEach({
|
|
connection: self.connection
|
|
});
|
|
}
|
|
}).then(function () {
|
|
return self._afterEach({
|
|
task: task,
|
|
version: version
|
|
});
|
|
}).catch(function (err) {
|
|
if (err instanceof errors.MigrationExistsError) {
|
|
debug('Skipping:' + task.name);
|
|
skippedTasks.push(task.name);
|
|
return Promise.resolve();
|
|
}
|
|
|
|
/**
|
|
* @NOTE: When your database encoding is set to utf8mb4 and you set a field length > 191 characters,
|
|
* MySQL will throw an error, BUT it won't roll back the changes, because ALTER/CREATE table commands are
|
|
* implicit commands.
|
|
*
|
|
* https://bugs.mysql.com/bug.php?id=28727
|
|
* https://github.com/TryGhost/knex-migrator/issues/51
|
|
*/
|
|
if (err.code === 'ER_TOO_LONG_KEY') {
|
|
let match = err.message.match(/`\w+`/g);
|
|
let table = match[0];
|
|
let field = match[2];
|
|
|
|
throw new errors.MigrationScriptError({
|
|
message: 'Field length of %field% in %table% is too long!'.replace('%field%', field).replace('%table%', table),
|
|
context: 'This usually happens if your database encoding is utf8mb4.\n' +
|
|
'All unique fields and indexes must be lower than 191 characters.\n' +
|
|
'Please correct your field length and reset your database with `yarn knex-migrator reset`.\n',
|
|
help: 'Read more here: https://github.com/TryGhost/knex-migrator/issues/51\n',
|
|
err: err
|
|
});
|
|
}
|
|
|
|
throw new errors.MigrationScriptError({
|
|
message: err.message,
|
|
help: 'Error occurred while executing the following migration: ' + task.name,
|
|
context: task,
|
|
err: err
|
|
});
|
|
});
|
|
})).then(function () {
|
|
return {
|
|
skippedTasks: skippedTasks
|
|
};
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @description Private helper to execute logic before each migration script is executed.
|
|
*
|
|
* It ensures that migration scripts are not executed twice.
|
|
*
|
|
* @param {Object} options
|
|
* @returns {*}
|
|
* @private
|
|
*/
|
|
KnexMigrator.prototype._beforeEach = function _beforeEach(options) {
|
|
options = options || {};
|
|
|
|
let task = options.task,
|
|
version = options.version;
|
|
|
|
return this.connection('migrations')
|
|
.then(function (migrations) {
|
|
if (!migrations.length) {
|
|
return;
|
|
}
|
|
|
|
if (_.find(migrations, {name: task, version: version})) {
|
|
throw new errors.MigrationExistsError();
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @description Private helper to execute logic after each migration script is executed.
|
|
*
|
|
* It ensures that migration files are inserted into the database.
|
|
*
|
|
* @param {Object} options
|
|
* @returns {*}
|
|
* @private
|
|
*/
|
|
KnexMigrator.prototype._afterEach = function _afterEach(options) {
|
|
options = options || {};
|
|
|
|
let self = this;
|
|
let task = options.task;
|
|
let version = options.version;
|
|
|
|
return this.connection('migrations')
|
|
.insert({
|
|
name: task.name,
|
|
version: version,
|
|
currentVersion: self.currentVersion
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @description Private helper to execute an integrity check. The integrity check compares files against entries in the
|
|
* database. It returns expected and actual database state.
|
|
*
|
|
* @param {Object} options - Custom user options (force)
|
|
* @returns {Promise<any>}
|
|
* @private
|
|
*/
|
|
KnexMigrator.prototype._integrityCheck = async function _integrityCheck(options) {
|
|
options = options || {};
|
|
|
|
let self = this,
|
|
subfolder = this.subfolder,
|
|
force = options.force,
|
|
folders = [],
|
|
toReturn = {},
|
|
futureVersions = [];
|
|
|
|
// CASE: we always fetch the init scripts and check them
|
|
// 1. to be able to add more init scripts
|
|
// 2. to check if migration scripts need's to be executed or not, see https://github.com/TryGhost/knex-migrator/issues/39
|
|
folders.push('init');
|
|
|
|
// CASE: no subfolder yet. You can tell knex-migrator if scripts live on a sub folder.
|
|
try {
|
|
folders = folders.concat(utils.readVersionFolders(path.join(self.migrationPath, subfolder)));
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
try {
|
|
const dbMigrations = await this
|
|
.connection('migrations')
|
|
.select('version')
|
|
.count('version', {as: 'c'})
|
|
.groupBy('version');
|
|
|
|
_.each(folders, function (folder) {
|
|
// CASE: versions/1.1-members or versions/2.0-payments
|
|
if (folder !== 'init') {
|
|
try {
|
|
folder = folder.match(/([\d._]+)/)[0];
|
|
} catch (err) {
|
|
logging.warn('Cannot parse folder name.');
|
|
logging.warn('Ignore Folder: ' + folder);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// CASE:
|
|
// if your current version is 1.0 and you add migration scripts for the next version 1.1
|
|
// we won't execute them until your current version changes to 1.1 or until you force KM to migrate to it
|
|
if (self.currentVersion && !force) {
|
|
if (utils.isGreaterThanVersion({smallerVersion: self.currentVersion, greaterVersion: folder})) {
|
|
futureVersions.push(folder);
|
|
}
|
|
}
|
|
|
|
let actual = 0;
|
|
let expected;
|
|
|
|
const migrationCount = dbMigrations.find(m => m.version === folder);
|
|
if (migrationCount) {
|
|
actual = migrationCount.c;
|
|
}
|
|
|
|
if (folder !== 'init') {
|
|
expected = utils.listFiles(path.join(self.migrationPath, subfolder, folder)).length;
|
|
} else {
|
|
expected = utils.listFiles(path.join(self.migrationPath, folder)).length;
|
|
}
|
|
|
|
debug('Version ' + folder + ' expected: ' + expected);
|
|
debug('Version ' + folder + ' actual: ' + actual);
|
|
|
|
toReturn[folder] = {
|
|
expected: expected,
|
|
actual: actual
|
|
};
|
|
});
|
|
|
|
// CASE: ensure that either you have to run `migrate --force` or they ran already
|
|
if (futureVersions.length) {
|
|
_.each(futureVersions, function (futureVersion) {
|
|
if (toReturn[futureVersion].actual !== toReturn[futureVersion].expected) {
|
|
logging.warn('knex-migrator is skipping ' + futureVersion);
|
|
logging.warn('Current version in MigratorConfig.js is smaller then requested version, use --force to proceed!');
|
|
logging.warn('Please run `yarn knex-migrator migrate --v ' + futureVersion + ' --force` to proceed!');
|
|
delete toReturn[futureVersion];
|
|
}
|
|
});
|
|
}
|
|
|
|
return toReturn;
|
|
} catch (err) {
|
|
// CASE: no database selected (database.connection.database="")
|
|
if (err.errno === 1046) {
|
|
throw new errors.DatabaseIsNotOkError({
|
|
message: 'Please define a target database in your configuration.',
|
|
help: 'database: {\n\tconnection:\n\t\tdatabase:"database_name"\n\t}\n}\n',
|
|
code: 'DB_NOT_INITIALISED'
|
|
});
|
|
}
|
|
|
|
// CASE: database does not exist
|
|
if (err.errno === 1049) {
|
|
throw new errors.DatabaseIsNotOkError({
|
|
message: 'Please run `yarn knex-migrator init`',
|
|
code: 'DB_NOT_INITIALISED'
|
|
});
|
|
}
|
|
|
|
// CASE: migration table does not exist
|
|
if (err.errno === 1 || err.errno === 1146) {
|
|
throw new errors.DatabaseIsNotOkError({
|
|
message: 'Please run `yarn knex-migrator init`',
|
|
code: 'MIGRATION_TABLE_IS_MISSING'
|
|
});
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
module.exports = KnexMigrator;
|