UNPKG

@kwaeri/migration

Version:

The @kwaeri/migration component of the @kwaer/node-kit application platform.

408 lines (407 loc) 24.5 kB
/*----------------------------------------------------------------------------- * @package: node-kit migration * @author: Richard B Winters * @copyright: 2015-2019 Massively Modified, Inc. * @license: Apache-2.0 * @version: 0.1.2 *---------------------------------------------------------------------------*/ 'use strict'; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); // INCLUDES const developer_tools_1 = require("@kwaeri/developer-tools"); const filesystem_1 = require("@kwaeri/filesystem"); const configuration_1 = require("@kwaeri/configuration"); const driver_1 = require("@kwaeri/driver/build/src/driver"); const console_1 = require("@kwaeri/console"); const debug_1 = require("debug"); // DEFINES let _ = new developer_tools_1.kdt(), output = new console_1.Console({ color: false, background: false, decor: [] }); /* Configure Debug module support */ let DEBUG = debug_1.default('nkm:migrator'); /** * The Migrator Class * * The { Migrator } class handles everything that has to do with the migration * system, including; Setup, Management, and Execution of migrations. */ class Migration extends filesystem_1.Filesystem { /** * Class constructor * * @since 0.1.10 */ constructor(configuration) { super(); /** * @var { string } */ this.path = 'data/migrations'; /** * @var { string } */ this.file = 'migrations.json'; /** * @var {string } */ this.version = '0.1.10'; /** * @var { string } */ this.type = 'mysql'; /** * @var { string } */ this.table = 'nodekit_migrations'; // Organize all of the uncertainty: this.version = (configuration && configuration.version) ? configuration.version : null, this.type = (configuration && configuration.type) ? configuration.type : "mysql", this.table = (configuration && configuration.table) ? configuration.table : "nodekit_migrations"; // Get the current environment: let environment = (configuration && configuration.environment) ? configuration.environment : "default"; // Instantiate a new configuration object to wrap the migrations configuration: this.configuration = new configuration_1.Configuration("data/migrations", "migrations.json", "migrations"); // Grab the database configuration pre-emptively as well, if it exists: this.databaseConfiguration = new configuration_1.Configuration('conf', `database.${environment}.json`, 'database'); } /** * Checks if the migration system has been installed * * @pram { void } * * @return { Promise<boolean> } A promise with a boolean value indicating whether the migration system is installed. * * @since 0.1.10 */ checkInstall() { return __awaiter(this, void 0, void 0, function* () { // Check that the migration configuration exists @ ./data/migrations/: let installed = yield this.configuration.get(); // If not -> return false (It will generate the migration configuration): if (!installed.success) { return Promise.resolve(false); } // Next, grab the database configuration: let dbConf = yield this.databaseConfiguration.get(); // If it doesn't exist -> throw an error, a database configuration should // have already been set up: if (!dbConf.success) { return Promise.reject(new Error(`[MIGRATOR][CHECK_INSTALL] There was an issue reading the database configuration. `)); } // Otherwise instantiate a database driver to query the database with: this.dbo = new driver_1.Driver(dbConf.configuration).get(); // Finally, run a query to determine if the migrations table exists:: let checkMigrationsInstalledResult = yield this.dbo .query(`SELECT table_name FROM information_schema.tables WHERE table_schema = '${dbConf.configuration.database}' AND table_name = '${this.table}';`); DEBUG(checkMigrationsInstalledResult); DEBUG(checkMigrationsInstalledResult.rows); // Check if there was some sort of error with our query: if (!checkMigrationsInstalledResult || !checkMigrationsInstalledResult.rows || _.empty(checkMigrationsInstalledResult.rows)) { return Promise.reject(new Error(`[MIGRATOR][CHECK_INSTALL] There was an issue checking for the migrations table. If the migrations table does not exist, delete '${this.file}' in '${this.path}' and generate a new migration ('nkm add migration <MigrationName>') to force install the migrations system.`)); } // Alert caller that the migration system is installed: return Promise.resolve(true); }); } /** * Installs the migration system * * @param { string } content The migration configuration file content * * @return { Promise<T> } A promise that indicates the result of installing the migration system * * @since 0.1.10 */ install(content) { return __awaiter(this, void 0, void 0, function* () { // First write the migration configuration to disk: let createdMigrationsConfiguration = yield this.createFile(this.path, this.file, content); // If there was an error, reject the promise: if (!createdMigrationsConfiguration.result) { return Promise.reject(new Error(`[MIGRATOR][INSTALL] There was an issue writing the migration configuration to disk. `)); } // Next, get the databae configuration: let dbConf = yield this.databaseConfiguration.get(); // If there was an error, reject the promise:: if (!dbConf.success) { return Promise.reject(new Error(`[MIGRATOR][INSTALL] There was an issue reading the database configuration. `)); } // Next, create the migrations table: this.dbo = new driver_1.Driver(dbConf.configuration).get(); let createMigrationsTable = yield this.dbo .query(`create table if not exists ${this.table} ` + `( id int(11) not null auto_increment, ` + `date varchar(255), ` + `name varchar(255), ` + `sequel text, ` + `applied int(11), ` + `primary key (id), ` + `unique key unique_date (date) );`); // If the query failed: if (!createMigrationsTable && !createMigrationsTable.rows) { // Reject the promise: return Promise.reject(new Error(`[MIGRATOR][INSTALL] There was an error installing migrations into the project. `)); } // Return a resolved promise: return Promise.resolve({ result: true, migrations: [] }); }); } /** * Applies or reverts [a] migration(s) * * @param options Parameters that shape the migration command * * @returns { Promise<MigrationPromise> } * * @since 0.1.0 */ migrate(options) { return __awaiter(this, void 0, void 0, function* () { let migrated = null, startMigrationTS = new Date(), timeOptions = { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }; // First check that the migration system is already installed - if it is NOT // then we will need to install it and generate a migration prior to running // any migration(s): //console.log //( // output.normalize().buffer( `[` ).color( 'gray' ).decor( ['bright'] ) // .buffer( new Date().toLocaleTimeString( "en-us", timeOptions ) ) // .normalize().buffer( `] ` ).decor( ['bright'] ) // .buffer( `Checking the migration system install.` ).normalize().dump() //); DEBUG(`Check migration system installation`); let installed = yield this.checkInstall(); if (!installed) { return Promise.reject(new Error(`[MIGRATOR][RUN] Unable to verify whether the migration system is installed. If the 'data/migrations' path and/or the 'data/migrations/migrations.json' configuration are missing - generate a migration in order to force installation on your system. `)); } // Next we need to get a list of files within the 'data/migrations' directory: //console.log //( // output.normalize().buffer( `[` ).color( 'gray' ).decor( ['bright'] ) // .buffer( new Date().toLocaleTimeString( "en-us", timeOptions ) ) // .normalize().buffer( `] ` ).decor( ['bright'] ) // .buffer( `Getting the list of migrations.` ).normalize().dump() //); DEBUG(`Get list of available migrations`); let hierarchy = { root: { type: "directory", children: {} } }; hierarchy.root.children = yield this.getDirectoryStructure(this.path); // We know the directory structure so lets loop through each month of each year // and make a list of all migrations: let files = [], map = {}, count = 0; // Loop through each year for (let year in hierarchy.root.children.files) { // For each year, loop through each month: for (let month in hierarchy.root.children.files[year].children) { // For each month, add the migrations to our list: for (let migration in hierarchy.root.children.files[year].children[month].children) { // Create a simple array to store the migration files in, this is the device // we'll use to sort and ensure the proper sequence of migrations: files.push(migration); // Build a map to keep necessary data available to us: map[files[count]] = { timestamp: migration.split('_')[0], name: migration.split('_')[1].split('.js')[0], path: this.path + '/' + year + '/' + month + '/' + migration }; DEBUG(`Migration '${map[files[count]].name}' found at '${map[files[count]].path}'`); count++; } } } // Now sort the keys array so we apply migrations in order!: files = files.sort(this.alphaNumericSort); // Next we need a list of migrations which have already been applied. Fetch // the database configuration: //console.log //( // output.normalize().buffer( `[` ).color( 'gray' ).decor( ['bright'] ) // .buffer( new Date().toLocaleTimeString( "en-us", timeOptions ) ) // .normalize().buffer( `] ` ).decor( ['bright'] ) // .buffer( `Getting the list of applied migrations.` ).normalize().dump() //); DEBUG(`Get list of applied migrations`); let dbConf = yield this.databaseConfiguration.get(); // If there was an error, reject the promise:: if (!dbConf.success) { return Promise.reject(new Error(`[MIGRATOR][MIGRATE] There was an issue reading the database configuration. `)); } // Get a database object:: this.dbo = new driver_1.Driver(dbConf.configuration).get(); // Query the migrations table for applied migrations: let appliedMigrations = yield this.dbo .query(`select * from ${this.table} ` + `where applied=1 ` + `order by date asc, name asc;`); // If the query failed: if (!appliedMigrations && !appliedMigrations.rows) { // Reject the promise: return Promise.reject(new Error(`[MIGRATOR][MIGRATE] There was an error getting a list of applied migrations from ${this.table} table in the database. `)); } // Now we need to take any rows, and rebuild the migration filename to use as a // comparitor so as to ensure we do not rerun the respective migration as we // loop through our list of migrations and apply any unapplied migrations: let applied = []; for (let record in appliedMigrations.rows) { applied.push(`${appliedMigrations.rows[record].date}_${appliedMigrations.rows[record].name}.js`); } // Since we sort by the date and name it should already be sorted appropriately, queue // up the migrations to process, and process them - keeping in mind whether we are // processing forward or stepping back: //DEBUG( `${(options.stepBack)?`Step-back ${options.stepBack} migrations` : 'Process migrations forward '}` ); let processed = 0; console.log(output.normalize().buffer(`[`).color('gray').decor(['bright']) .buffer(new Date().toLocaleTimeString("en-us", timeOptions)) .normalize().buffer(`] `).decor(['bright']) .buffer(`${(options.stepBack) ? `Stepping back ${options.stepBack} migrations.\n` : 'Processing migrations forward.\n'}`).normalize().dump()); let workingSet = []; if (!options.stepBack) { // If we're not stepping back the working set is the files we have in our data/migrations // directory, and we'll apply only migrations which haven't already been applied: workingSet = files; } else { // If we ARE stepping back, the working set will be only the migrations we are going to // revert -> so we'll slice the working set such that we only have the number of migrations // that we are stepping back, to work with: workingSet = applied; // Now let's only get the migrations we're going to revert: workingSet = workingSet.slice(workingSet.length - options.stepBack, workingSet.length); // Finally, let's re-sort them in reverse: workingSet = workingSet.reverse(); } if (workingSet.length) { for (let toProcess in workingSet) { // Because of the simplicity in the logic behind applying migrations, we need to // ensure that if we are applying migrations, that we do not attempt to apply // any migrations we iterate over that have already been applied. However, if // we're reverting, we actually don't need this check at all, because were ONLY // reverting migrations that have for sure been applied -> but we have to create // a condition where this code flow is entered: if ((!options.stepBack && applied.indexOf(workingSet[toProcess]) < 0) || (options.stepBack && applied.indexOf(workingSet[toProcess]) >= 0)) { let startProcessTS = new Date(); console.log(output.normalize().buffer(`[`).color('gray').decor(['bright']) .buffer(startProcessTS.toLocaleTimeString("en-us", timeOptions)) .normalize().buffer(`] `).decor(['bright']) .buffer(`${(options.stepBack) ? `Reverting '` : `Applying '`}`).color('cyan') .buffer(`${map[workingSet[toProcess]].name}`).color('white').buffer(`'`).normalize().dump()); DEBUG(`Checking migration '${map[workingSet[toProcess]].name}`); // As part of the queueing process, check whether the migration is already added to // the migrations table and set as unapplied: let migrationIsQueued = yield this.dbo .query(`select * from ${this.table} ` + `where date='${map[workingSet[toProcess]].timestamp}' and name='${map[workingSet[toProcess]].name}' and applied=0;`); if (!migrationIsQueued || !migrationIsQueued.rows) { return Promise.reject(new Error(`[MIGRATION][MIGRATE] There was an issue checking whether a queued migration had been recorded into the ${this.table} table in the database, but had not been applied. `)); } // If not, we need to queue it in the database first, then process it. For reverting // migrations, this will conveniently never matter: if (migrationIsQueued.rows.length <= 0 && !options.stepBack) { DEBUG(`Queueing migration '${map[workingSet[toProcess]].name}'`); let queueMigration = yield this.dbo .query(`insert into ${this.table} ` + `(date, name, applied) ` + `values( '${map[workingSet[toProcess]].timestamp}', '${map[workingSet[toProcess]].name}', 0);`); if (!queueMigration || !queueMigration.rows) { return Promise.reject(new Error(`[MIGRATION][${map[workingSet[toProcess]].name}] There was an issue queueing the migration in the ${this.table} table in the database. `)); } } DEBUG(`Migration '${map[workingSet[toProcess]].name}' ${(options.stepBack) ? 'to revert was previously applied' : 'queued'}, processing...`); // Require the migration file: let applicableMigration = require(`${filesystem_1.Filesystem.getPathToCWD()}/${map[workingSet[toProcess]].path}`); // Instantiate the migration class: let applicableMigrator = new applicableMigration(); // Composition rocks: applicableMigrator.dbo = this.dbo; // Run the migration: let migrationProcessed; // If it's not a stepBack request, step up!: if (!options.stepBack) { migrationProcessed = yield applicableMigrator.up(); } else { migrationProcessed = yield applicableMigrator.down(); } if (!migrationProcessed || !migrationProcessed.result || !migrationProcessed.rows) { return Promise.reject(new Error(`[MIGRATION][${map[workingSet[toProcess]].name}]: There was an issue processing the migration. `)); } DEBUG(`Migration '${map[workingSet[toProcess]].name}' ${(options.stepBack) ? 'reverted' : 'applied'}...`); // We then need to update the table record such that it reflects // that we have applied the migration: let markApplied = yield this.dbo .query(`update ${this.table} set applied=${(options.stepBack) ? '0' : '1'} ` + `where name='${map[workingSet[toProcess]].name}' and date='${map[workingSet[toProcess]].timestamp}';`); if (!markApplied || !markApplied.rows) { return Promise.reject(new Error(`[MIGRATION][${map[workingSet[toProcess]].name}]: There was an issue marking a${(options.stepBack) ? ' reverted migration as unapplied' : 'n applied migration as completed.'}`)); } let finishProcessTS = new Date(); console.log(output.normalize().buffer(`[`).color('gray').decor(['bright']) .buffer(finishProcessTS.toLocaleTimeString("en-us", timeOptions)) .normalize().buffer(`] `).decor(['bright']) .buffer(`${(options.stepBack) ? `Finished reverting '` : `Finished applying '`}`).color('cyan') .buffer(`${map[workingSet[toProcess]].name}`).color('white').buffer(`' after `) .color('magenta').buffer(`${finishProcessTS.getTime() - startProcessTS.getTime()} ms\n`).normalize().dump()); processed++; } } let finishedMigrationTS = new Date(); console.log(output.normalize().buffer(`[`).color('gray').decor(['bright']) .buffer(finishedMigrationTS.toLocaleTimeString("en-us", timeOptions)) .normalize().buffer(`] `).decor(['bright']) .buffer(`${(processed > 0) ? `${(options.stepBack) ? `Successfully reverted ${options.stepBack} migrations.` : `Successfully applied ${processed} migrations.`}` : `${(options.stepBack) ? `Nothing to revert,` : `Migrations are up to date.`}`}`).color('cyan') .color('white').buffer(` after `) .color('magenta').buffer(`${finishedMigrationTS.getTime() - startMigrationTS.getTime()} ms`).normalize().dump()); } else { let finishedMigrationTS = new Date(); console.log(output.normalize().buffer(`[`).color('gray').decor(['bright']) .buffer(finishedMigrationTS.toLocaleTimeString("en-us", timeOptions)) .normalize().buffer(`] `).decor(['bright']) .buffer(`${(options.stepBack) ? `No applied migrations exist.` : `No migrations exist.`}`).color('cyan') .color('white').buffer(` after `) .color('magenta').buffer(`${finishedMigrationTS.getTime() - startMigrationTS.getTime()} ms`).normalize().dump()); } migrated = { result: true, migrations: hierarchy.root.children.files }; return Promise.resolve(Object.assign({}, migrated)); }); } /** * [DEPRECATED] Runs a [series of] migration(s), if any exist which have not already been applied * * @param void * * @returns { Promise<T> } A promise indicating the result of running migrations * * @since 0.1.11 */ run(options) { return __awaiter(this, void 0, void 0, function* () { // First and foremost let's grab // First check that the migration system is already installed - if it is NOT // then we will install it prior to running any migration(s): let installed = yield this.checkInstall(); if (!installed) { return Promise.reject(new Error(`[MIGRATOR][RUN] Unable to verify whether the migration system is installed. If the 'data/migrations' path and/or the 'data/migrations/migrations.json' configuration are missing - generate a migration in order to force installation on your system. `)); } return Promise.resolve({ result: true }); }); } /** * [DEPRECATED] Adds a migration to the filesystem * * @param void * * @returns { Promise<MigrationPromise> } * * @since 0.1.0 */ add() { return __awaiter(this, void 0, void 0, function* () { return Promise.resolve({ result: true }); }); } } exports.Migration = Migration;