├── .gitignore ├── .jscsrc ├── .jshintrc ├── README.md ├── bin └── cli ├── circle.yml ├── lib ├── index.js └── models │ ├── migration-map.js │ ├── migration-map.json │ ├── migration.js │ └── migration.json ├── migration-skeleton.js ├── package.json └── test ├── fixtures └── simple-app │ ├── common │ └── models │ │ ├── widget.js │ │ └── widget.json │ └── server │ ├── component-config.json │ ├── config.json │ ├── datasources.json │ ├── middleware.json │ ├── migrations │ ├── 0000-error.js │ ├── 0001-initialize.js │ ├── 0002-somechanges.js │ └── 0003-morechanges.js │ ├── model-config.json │ └── server.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | node_modules -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "google", 3 | "excludeFiles": ["node_modules"], 4 | "requireCurlyBraces": [ 5 | "else", 6 | "for", 7 | "while", 8 | "do", 9 | "try", 10 | "catch" 11 | ], 12 | "disallowMultipleVarDecl": "exceptUndefined", 13 | "disallowSpacesInsideObjectBrackets": null, 14 | "maximumLineLength": { 15 | "value": 120, 16 | "allowComments": true, 17 | "allowRegex": true 18 | }, 19 | "jsDoc": { 20 | "checkParamNames": true, 21 | "requireParamTypes": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "esnext": true, 4 | "bitwise": true, 5 | "camelcase": true, 6 | "eqeqeq": true, 7 | "eqnull": true, 8 | "immed": true, 9 | "indent": 2, 10 | "latedef": "nofunc", 11 | "newcap": true, 12 | "nonew": true, 13 | "noarg": true, 14 | "quotmark": "single", 15 | "regexp": true, 16 | "undef": true, 17 | "unused": false, 18 | "trailing": true, 19 | "sub": true, 20 | "maxlen": 120, 21 | "predef": ["-prompt"], 22 | "browser": true, 23 | "devel": true, 24 | "mocha": true, 25 | "expr": true 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A library to add simple database migration support to loopback projects. 2 | 3 | [![Dependencies](http://img.shields.io/david/fullcube/loopback-component-migrate.svg?style=flat)](https://david-dm.org/fullcube/loopback-component-migrate) 4 | 5 | Migrations that have been run will be stored in a table called 'Migrations'. 6 | The library will read the loopback datasources.json files based on the NODE_ENV environment variable just like loopback does. 7 | The usage is based on the node-db-migrate project. 8 | 9 | ## Installation 10 | 11 | [![Greenkeeper badge](https://badges.greenkeeper.io/fullcube/loopback-component-migrate.svg)](https://greenkeeper.io/) 12 | 13 | 1. Install in you loopback project: 14 | 15 | `npm install --save loopback-component-migrate` 16 | 17 | 2. Create a component-config.json file in your server folder (if you don't already have one) 18 | 19 | 3. Enable the component inside `component-config.json`. 20 | 21 | ```json 22 | { 23 | "loopback-component-migrate": { 24 | "key": "value" 25 | } 26 | } 27 | ``` 28 | 29 | **Options:** 30 | 31 | - `log` 32 | 33 | [String] : Name of the logging class to use for log messages. *(default: 'console')* 34 | 35 | - `enableRest` 36 | 37 | [Boolean] : A boolean indicating wether migrate/rollback REST api methods should be exposed on the Migration model. *(default: false)* 38 | 39 | - `migrationsDir` 40 | 41 | [String] : Directory containing migration scripts. *(default: server/migrations)* 42 | 43 | - `dataSource` 44 | 45 | [String] : Datasource to connect the Migration and MigrationMap models to. *(default: db)* 46 | 47 | - `acls` 48 | 49 | [Array] : ACLs to apply to Migration and MigrationMap models. *(default: [])* 50 | 51 | 52 | ## Running Migrations 53 | 54 | Migrations can be run by calling the static `migrate` method on the Migration model. If you do not specify a callback, a promise will be returned. 55 | 56 | **Run all pending migrations:** 57 | ```javascript 58 | Migrate.migrate('up', function(err) {}); 59 | ``` 60 | 61 | **Run all pending migrations upto and including 0002-somechanges:** 62 | ```javascript 63 | Migrate.migrate('up', '0002-somechanges', function(err) {}); 64 | ``` 65 | 66 | **Rollback all migrations:** 67 | ```javascript 68 | Migrate.migrate('down', function(err) {}); 69 | ``` 70 | 71 | **Rollback migrations upto and including 0002-somechanges:** 72 | ```javascript 73 | Migrate.migrate('down', '0002-somechanges', function(err) {}); 74 | ``` 75 | 76 | ## Example migrations 77 | ```javascript 78 | module.exports = { 79 | up: function(app, next) { 80 | app.models.Users.create({ ... }, next); 81 | }, 82 | down: function(app, next) { 83 | app.models.Users.destroyAll({ ... }, next); 84 | } 85 | }; 86 | ``` 87 | 88 | ```javascript 89 | /* executing raw sql */ 90 | module.exports = { 91 | up: function(app, next) { 92 | app.dataSources.mysql.connector.query('CREATE TABLE `my_table` ...;', next); 93 | }, 94 | down: function(app, next) { 95 | app.dataSources.mysql.connector.query('DROP TABLE `my_table`;', next); 96 | } 97 | }; 98 | ``` 99 | -------------------------------------------------------------------------------- /bin/cli: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | var path = require('path'); 3 | var fs = require('fs'); 4 | var mkdirp = require('mkdirp'); 5 | var debug = require('debug')('loopback-component-migrate'); 6 | 7 | 8 | /** 9 | * Command line implementation for loopback-component-migrate. 10 | * 11 | * Common usage case is: 12 | * 13 | * ./node_modules/.bin/loopback-component-migrate create|up|down 14 | */ 15 | var program = require('commander'); 16 | 17 | program 18 | .version(require(path.join(__dirname, '..', './package.json')).version) 19 | .option('-d, --migrations-dir ', 'set migrations directory. defaults to ./migrations') 20 | .option('-s, --server ', 'set server path. defaults to ./server/server.js'); 21 | 22 | program 23 | .command('create [name]') 24 | .description('create a new migration script') 25 | .action(function(name, options){ 26 | 27 | function stringifyAndPadLeading(num) { 28 | var str = num + ''; 29 | return (str.length === 1) ? '0' + str : str; 30 | } 31 | 32 | function generateFileName(name) { 33 | var d = new Date(), 34 | year = d.getFullYear() + '', 35 | month = stringifyAndPadLeading(d.getMonth()+1), 36 | day = stringifyAndPadLeading(d.getDate()), 37 | hours = stringifyAndPadLeading(d.getHours()), 38 | minutes = stringifyAndPadLeading(d.getMinutes()), 39 | seconds = stringifyAndPadLeading(d.getSeconds()), 40 | dateString = year + month + day + hours + minutes + seconds, 41 | fileName = dateString + (name ? '-' + name : '') + '.js'; 42 | return fileName; 43 | } 44 | 45 | function getMigrationsDir () { 46 | var dir = path.join(process.cwd(), 'migrations'); 47 | debug('Using migrations directory: %s', dir); 48 | return dir; 49 | } 50 | 51 | function ensureDirectory (dir) { 52 | debug('Preparing migrations directory: %s', dir); 53 | mkdirp.sync(dir); 54 | } 55 | 56 | function writeFile (fileName, contents) { 57 | debug('Creating migration script: %s', fileName); 58 | var migrationsDir = getMigrationsDir(); 59 | ensureDirectory(migrationsDir); 60 | var filePath = path.join(migrationsDir, fileName); 61 | fs.writeFileSync(filePath, contents); 62 | } 63 | 64 | // Create the migration file. 65 | var fileName = generateFileName(name); 66 | var migrationsDir = path.join(process.cwd(), 'migrations'); 67 | console.log('Creating migration script %s in %s', fileName, migrationsDir); 68 | 69 | var filePath = path.join(migrationsDir,fileName); 70 | var fileContent = fs.readFileSync(path.join(__dirname, '..', 'migration-skeleton.js')); 71 | writeFile(fileName, fileContent); 72 | }); 73 | 74 | program 75 | .command('migrate ') 76 | .alias('up') 77 | .description('Migrate to the given migration') 78 | .action(function(to, options){ 79 | console.log('Migrating up to: "%s"', to); 80 | var server = program.server || process.cwd() + '/server/server.js'; 81 | var app = require(path.resolve(process.cwd(), server)); 82 | app.models.Migration.migrate('up', to) 83 | .then(function (res) { 84 | console.log('Done.'); 85 | }) 86 | .catch(function (err) { 87 | console.log(err); 88 | }) 89 | .finally(function () { 90 | process.exit(); 91 | }); 92 | }).on('--help', function() { 93 | console.log(' Examples:'); 94 | console.log(); 95 | console.log(' $ migrate 005'); 96 | console.log(' $ up 005'); 97 | console.log(); 98 | }); 99 | 100 | program 101 | .command('rollback ') 102 | .alias('down') 103 | .description('Rollback to the given migration') 104 | .action(function(to, options){ 105 | console.log('Rolling back to: "%s"', to); 106 | var server = program.server || process.cwd() + '/server/server.js'; 107 | var app = require(path.resolve(process.cwd(), server)); 108 | app.models.Migration.migrate('down', to) 109 | .then(function (res) { 110 | console.log('Done.'); 111 | }) 112 | .catch(function (err) { 113 | console.log(err); 114 | }) 115 | .finally(function () { 116 | process.exit(); 117 | }); 118 | }).on('--help', function() { 119 | console.log(' Examples:'); 120 | console.log(); 121 | console.log(' $ rollback 001'); 122 | console.log(' $ down 001'); 123 | console.log(); 124 | }); 125 | 126 | program.parse(process.argv); 127 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | deployment: 2 | master: 3 | branch: [master] 4 | commands: 5 | - npm run semantic-release 6 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug')('loopback-component-migrate'); 2 | var loopback = require('loopback'); 3 | var migrationDef = require('./models/migration.json'); 4 | var migrationMapDef = require('./models/migration-map.json'); 5 | 6 | // Remove proerties that will confuse LB 7 | function getSettings(def) { 8 | var settings = {}; 9 | for (var s in def) { 10 | if (s === 'name' || s === 'properties') { 11 | continue; 12 | } else { 13 | settings[s] = def[s]; 14 | } 15 | } 16 | return settings; 17 | } 18 | 19 | /** 20 | * @param {Object} app The app instance 21 | * @param {Object} options The options object 22 | */ 23 | module.exports = function(app, options) { 24 | var loopback = app.loopback; 25 | options = options || {}; 26 | 27 | var dataSource = options.dataSource || 'db'; 28 | if (typeof dataSource === 'string') { 29 | dataSource = app.dataSources[dataSource]; 30 | } 31 | 32 | var migrationModelSettings = getSettings(migrationDef); 33 | var migrationMapModelSettings = getSettings(migrationMapDef); 34 | 35 | if (typeof options.acls !== 'object') { 36 | migrationModelSettings.acls = migrationMapModelSettings.acls = []; 37 | } else { 38 | migrationModelSettings.acls = migrationMapModelSettings.acls = options.acls; 39 | } 40 | 41 | // Support for loopback 2.x. 42 | if (app.loopback.version.startsWith(2)) { 43 | Object.keys(migrationModelSettings.methods).forEach(key => { 44 | migrationModelSettings.methods[key].isStatic = true; 45 | }); 46 | } 47 | 48 | debug('Creating Migration model using settings: %o', migrationModelSettings); 49 | var MigrationModel = dataSource.createModel( 50 | migrationDef.name, 51 | migrationDef.properties, 52 | migrationModelSettings); 53 | 54 | debug('Creating MigrationMap model using settings: %o', migrationModelSettings); 55 | var MigrationMapModel = dataSource.createModel( 56 | migrationMapDef.name, 57 | migrationMapDef.properties, 58 | migrationMapModelSettings); 59 | 60 | var Migration = require('./models/migration')(MigrationModel, options); 61 | var MigrationMap = require('./models/migration-map')(MigrationMapModel, options); 62 | 63 | app.model(Migration); 64 | app.model(MigrationMap); 65 | 66 | if (!options.enableRest) { 67 | if (Migration.disableRemoteMethodByName) { 68 | // Loopback 3.x+ 69 | Migration.disableRemoteMethodByName('migrateTo'); 70 | Migration.disableRemoteMethodByName('rollbackTo'); 71 | } else { 72 | // Loopback 2.x 73 | Migration.disableRemoteMethod('migrateTo', true); 74 | Migration.disableRemoteMethod('rollbackTo', true); 75 | } 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /lib/models/migration-map.js: -------------------------------------------------------------------------------- 1 | module.exports = function(MigrationMap) { 2 | return MigrationMap; 3 | }; 4 | -------------------------------------------------------------------------------- /lib/models/migration-map.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MigrationMap", 3 | "plural": "MigrationMaps", 4 | "base": "PersistedModel", 5 | "description": "Migration Mappings.", 6 | "properties": { 7 | "type": { 8 | "type": "String", 9 | "comments": "Mapping type.", 10 | "required": true 11 | }, 12 | "from": { 13 | "type": "String", 14 | "comments": "Source Id.", 15 | "required": true 16 | }, 17 | "to": { 18 | "type": "String", 19 | "comments": "Target Id.", 20 | "required": true 21 | }, 22 | "data": { 23 | "type": "Object", 24 | "comments": "Additional data." 25 | } 26 | }, 27 | "indexes": { 28 | "type_from_index": { 29 | "keys": { 30 | "type": 1, 31 | "from": 1 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/models/migration.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var debug = require('debug')('loopback-component-migrate'); 4 | var path = require('path'); 5 | var fs = require('fs'); 6 | var assert = require('assert'); 7 | var utils = require('loopback/lib/utils'); 8 | var util = require('util'); 9 | 10 | module.exports = function(Migration, options) { 11 | options = options || {}; 12 | Migration.log = options.log || console; 13 | Migration.log = typeof Migration.log === 'string' ? require(Migration.log) : Migration.log; 14 | Migration.migrationsDir = options.migrationsDir || path.join(process.cwd(), 'server', 'migrations'); 15 | debug('Migrations directory set to: %s', Migration.migrationsDir); 16 | 17 | /** 18 | * Remote Method: Run pending migrations. 19 | * 20 | * @param {String} [to] Name of the migration script to migrate to. 21 | * @param {Function} [cb] Callback function. 22 | */ 23 | Migration.migrateTo = function(to, cb) { 24 | to = to || ''; 25 | cb = cb || utils.createPromiseCallback(); 26 | assert(typeof to === 'string', 'The to argument must be a string, not ' + typeof to); 27 | assert(typeof cb === 'function', 'The cb argument must be a function, not ' + typeof cb); 28 | Migration.migrate('up', to, cb); 29 | return cb.promise; 30 | }; 31 | 32 | /** 33 | * Remote Method: Rollback migrations. 34 | * 35 | * @param {String} [to] Name of migration script to rollback to. 36 | * @param {Function} [cb] Callback function. 37 | */ 38 | Migration.rollbackTo = function(to, cb) { 39 | to = to || ''; 40 | cb = cb || utils.createPromiseCallback(); 41 | assert(typeof to === 'string', 'The to argument must be a string, not ' + typeof to); 42 | assert(typeof cb === 'function', 'The cb argument must be a function, not ' + typeof cb); 43 | Migration.migrate('down', to, cb); 44 | return cb.promise; 45 | }; 46 | 47 | /** 48 | * Remote Method: Run specific migration by name. 49 | * 50 | * @param {String} [name] Name of migration script to run. 51 | * @param {String} [record] Record the migration runtime to database. 52 | * @param {Function} [cb] Callback function. 53 | */ 54 | Migration.migrateByName = function(name, record, cb) { 55 | if (typeof cb === 'undefined' && typeof record === 'function') { 56 | cb = record; 57 | record = false; 58 | } 59 | 60 | record = record || false; 61 | cb = cb || utils.createPromiseCallback(); 62 | assert(typeof name === 'string', 'The to argument must be a string, not ' + typeof name); 63 | assert(typeof cb === 'function', 'The cb argument must be a function, not ' + typeof cb); 64 | 65 | if (Migration.app.migrating) { 66 | Migration.log.warn('Migration: Unable to start migration (already running)'); 67 | process.nextTick(function() { 68 | cb(); 69 | }); 70 | return cb.promise; 71 | } 72 | 73 | Migration.hrstart = process.hrtime(); 74 | Migration.app.migrating = true; 75 | 76 | Migration.log.info('Migration: running script', name); 77 | const scriptPath = path.resolve(path.join(Migration.migrationsDir, name)); 78 | 79 | try { 80 | require(scriptPath).up(Migration.app, function(err) { 81 | if (record) { 82 | Migration.create({ 83 | name: name, 84 | runDtTm: new Date() 85 | }); 86 | } 87 | Migration.finish(err); 88 | return cb(); 89 | }); 90 | } catch (err) { 91 | Migration.log.error(`Migration: Error running script ${name}:`, err); 92 | Migration.finish(err); 93 | cb(err); 94 | } 95 | 96 | return cb.promise; 97 | }; 98 | 99 | /** 100 | * Run migrations (up or down). 101 | * 102 | * @param {String} [upOrDown] Direction (up or down) 103 | * @param {String} [to] Name of migration script to migrate/rollback to. 104 | * @param {Function} [cb] Callback function. 105 | */ 106 | Migration.migrate = function(upOrDown, to, cb) { 107 | if (cb === undefined) { 108 | if (typeof to === 'function') { 109 | cb = to; 110 | to = ''; 111 | } 112 | } 113 | upOrDown = upOrDown || 'up'; 114 | to = to || ''; 115 | cb = cb || utils.createPromiseCallback(); 116 | 117 | assert(typeof upOrDown === 'string', 'The upOrDown argument must be a string, not ' + typeof upOrDown); 118 | assert(typeof to === 'string', 'The to argument must be a string, not ' + typeof to); 119 | assert(typeof cb === 'function', 'The cb argument must be a function, not ' + typeof cb); 120 | 121 | if (Migration.app.migrating) { 122 | Migration.log.warn('Migration: Unable to start migrations (already running)'); 123 | process.nextTick(function() { 124 | cb(); 125 | }); 126 | return cb.promise; 127 | } 128 | 129 | Migration.hrstart = process.hrtime(); 130 | Migration.app.migrating = true; 131 | 132 | Migration.findScriptsToRun(upOrDown, to, function runScripts(err, scriptsToRun) { 133 | scriptsToRun = scriptsToRun || []; 134 | var migrationCallStack = []; 135 | var migrationCallIndex = 0; 136 | 137 | if (scriptsToRun.length) { 138 | Migration.log.info('Migration: Running migration scripts', scriptsToRun); 139 | } 140 | 141 | scriptsToRun.forEach(function(localScriptName) { 142 | migrationCallStack.push(function() { 143 | 144 | var migrationStartTime; 145 | 146 | // keep calling scripts recursively until we are done, then exit 147 | function runNextScript(err) { 148 | if (err) { 149 | Migration.log.error('Migration: Error saving migration %s to database', localScriptName); 150 | Migration.finish(err); 151 | return cb(err); 152 | } 153 | 154 | var migrationEndTime = process.hrtime(migrationStartTime); 155 | Migration.log.info('Migration: %s finished sucessfully. Migration time was %ds %dms', 156 | localScriptName, migrationEndTime[0], migrationEndTime[1] / 1000000); 157 | migrationCallIndex++; 158 | if (migrationCallIndex < migrationCallStack.length) { 159 | migrationCallStack[migrationCallIndex](); 160 | } else { 161 | Migration.finish(err); 162 | return cb(); 163 | } 164 | } 165 | 166 | try { 167 | // include the script, run the up/down function, update the migrations table, and continue 168 | migrationStartTime = process.hrtime(); 169 | Migration.log.info('Migration: Running script', localScriptName); 170 | const scriptPath = path.resolve(path.join(Migration.migrationsDir, localScriptName)); 171 | require(scriptPath)[upOrDown](Migration.app, function(err) { 172 | if (err) { 173 | Migration.finish(err); 174 | return cb(err); 175 | } else if (upOrDown === 'up') { 176 | Migration.create({ 177 | name: localScriptName, 178 | runDtTm: new Date() 179 | }, runNextScript); 180 | } else { 181 | Migration.destroyAll({ 182 | name: localScriptName 183 | }, runNextScript); 184 | } 185 | }); 186 | } catch (err) { 187 | Migration.finish(err); 188 | cb(err); 189 | } 190 | }); 191 | }); 192 | 193 | // kick off recursive calls 194 | if (migrationCallStack.length) { 195 | migrationCallStack[migrationCallIndex](); 196 | } else { 197 | delete Migration.app.migrating; 198 | Migration.emit('complete'); 199 | Migration.log.info('Migration: No new migrations to run.'); 200 | return cb(); 201 | } 202 | }); 203 | 204 | return cb.promise; 205 | }; 206 | 207 | Migration.finish = function(err) { 208 | if (err) { 209 | Migration.emit('error', err); 210 | } else { 211 | Migration.emit('complete'); 212 | } 213 | }; 214 | 215 | Migration.findScriptsToRun = function(upOrDown, to, cb) { 216 | upOrDown = upOrDown || 'up'; 217 | to = to || ''; 218 | cb = cb || utils.createPromiseCallback(); 219 | 220 | debug('findScriptsToRun direction:%s, to:%s', upOrDown, to ? to : 'undefined'); 221 | 222 | // Add .js to the script name if it wasn't provided. 223 | if (to && to.substring(to.length - 3, to.length) !== '.js') { 224 | to = to + '.js'; 225 | } 226 | 227 | var scriptsToRun = []; 228 | var order = upOrDown === 'down' ? 'name DESC' : 'name ASC'; 229 | var filters = { 230 | order: order 231 | }; 232 | 233 | if (to) { 234 | // DOWN: find only those that are greater than the 'to' point in descending order. 235 | if (upOrDown === 'down') { 236 | filters.where = { 237 | name: { 238 | gte: to 239 | } 240 | }; 241 | } 242 | // UP: find only those that are less than the 'to' point in ascending order. 243 | else { 244 | filters.where = { 245 | name: { 246 | lte: to 247 | } 248 | }; 249 | } 250 | } 251 | debug('fetching migrations from db using filter %j', filters); 252 | Migration.find(filters) 253 | .then(function(scriptsAlreadyRan) { 254 | scriptsAlreadyRan = scriptsAlreadyRan.map(Migration.mapScriptObjName); 255 | debug('scriptsAlreadyRan: %j', scriptsAlreadyRan); 256 | 257 | // Find rollback scripts. 258 | if (upOrDown === 'down') { 259 | 260 | // If the requested rollback script has not already run return just the requested one if it is a valid script. 261 | // This facilitates rollback of failed migrations. 262 | if (to && scriptsAlreadyRan.indexOf(to) === -1) { 263 | debug('requested script has not already run - returning single script as standalone rollback script'); 264 | scriptsToRun = [to]; 265 | return cb(null, scriptsToRun); 266 | } 267 | 268 | // Remove the last item since we don't want to roll back the requested script. 269 | if (scriptsAlreadyRan.length && to) { 270 | scriptsAlreadyRan.pop(); 271 | debug('remove last item. scriptsAlreadyRan: %j', scriptsAlreadyRan); 272 | } 273 | scriptsToRun = scriptsAlreadyRan; 274 | 275 | debug('Found scripts to run: %j', scriptsToRun); 276 | cb(null, scriptsToRun); 277 | } 278 | 279 | // Find migration scripts. 280 | else { 281 | // get all local scripts and filter for only .js files 282 | var candidateScripts = fs.readdirSync(Migration.migrationsDir).filter(function(fileName) { 283 | return fileName.substring(fileName.length - 3, fileName.length) === '.js'; 284 | }); 285 | debug('Found %s candidate scripts: %j', candidateScripts.length, candidateScripts); 286 | 287 | // filter out those that come after the requested to value. 288 | if (to) { 289 | candidateScripts = candidateScripts.filter(function(fileName) { 290 | var inRange = fileName <= to; 291 | debug('checking wether %s is in range (%s <= %s): %s', fileName, fileName, to, inRange); 292 | return inRange; 293 | }); 294 | } 295 | 296 | // filter out those that have already ran 297 | candidateScripts = candidateScripts.filter(function(fileName) { 298 | debug('checking wether %s has already run', fileName); 299 | var alreadyRan = scriptsAlreadyRan.indexOf(fileName) !== -1; 300 | debug('checking wether %s has already run: %s', fileName, alreadyRan); 301 | return !alreadyRan; 302 | }); 303 | 304 | scriptsToRun = candidateScripts; 305 | debug('Found scripts to run: %j', scriptsToRun); 306 | cb(null, scriptsToRun); 307 | } 308 | }) 309 | .catch(function(err) { 310 | Migration.log.error('Migration: Error retrieving migrations', err); 311 | cb(err); 312 | }); 313 | 314 | return cb.promise; 315 | }; 316 | 317 | Migration.mapScriptObjName = function(scriptObj) { 318 | return scriptObj.name; 319 | }; 320 | 321 | Migration.on('error', (err) => { 322 | Migration.log.error('Migration: Migrations did not complete. An error was encountered:', err); 323 | delete Migration.app.migrating; 324 | }); 325 | 326 | Migration.on('complete', (err) => { 327 | var hrend = process.hrtime(Migration.hrstart); 328 | Migration.log.info('Migration: All migrations have run without any errors.'); 329 | Migration.log.info('Migration: Total migration time was %ds %dms', hrend[0], hrend[1] / 1000000); 330 | delete Migration.app.migrating; 331 | }); 332 | 333 | return Migration; 334 | }; 335 | -------------------------------------------------------------------------------- /lib/models/migration.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Migration", 3 | "plural": "Migrations", 4 | "base": "PersistedModel", 5 | "description": "View and run pending migrations.", 6 | "properties": { 7 | "name": { 8 | "type": "String", 9 | "comments": "Name of the migration.", 10 | "required": true 11 | }, 12 | "runDtTm": { 13 | "type": "Date", 14 | "comments": "Date and time the migration ran.", 15 | "required": true 16 | } 17 | }, 18 | "indexes": { 19 | "name_index": { 20 | "name": 1 21 | } 22 | }, 23 | "methods": { 24 | "migrateTo": { 25 | "description": "Run all pending migrations", 26 | "http": { 27 | "path": "/migrate", 28 | "verb": "get" 29 | }, 30 | "accepts": [{ 31 | "arg": "to", 32 | "type": "String", 33 | "description": "Name of the migration script to migrate to. If no name is provided all pending migrations will run." 34 | }] 35 | }, 36 | "migrateByName": { 37 | "description": "Run specific migration by name", 38 | "http": { 39 | "path": "/migrateByName", 40 | "verb": "get" 41 | }, 42 | "accepts": [{ 43 | "arg": "name", 44 | "type": "String", 45 | "required": true, 46 | "description": "Name of the migration script to run." 47 | }, 48 | { 49 | "arg" : "record", 50 | "type" : "boolean", 51 | "required" : false 52 | }] 53 | }, 54 | "rollbackTo": { 55 | "description": "Rollback migrations", 56 | "http": { 57 | "path": "/rollback", 58 | "verb": "get" 59 | }, 60 | "accepts": [{ 61 | "arg": "to", 62 | "type": "String", 63 | "required": true, 64 | "description": "Name of the migration script to rollback to." 65 | }] 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /migration-skeleton.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: function(dataSource, next) { 3 | next(); 4 | }, 5 | down: function(dataSource, next) { 6 | next(); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-component-migrate", 3 | "version": "0.0.0-semantically-released.0", 4 | "description": "Migration framework for Loopback.", 5 | "author": { 6 | "name": "Tom Kirkpatrick @mrfelton" 7 | }, 8 | "contributors": [ 9 | "Scott Lively @slively", 10 | "Ivan Tugay @listepo" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/fullcube/loopback-component-migrate.git" 15 | }, 16 | "keywords": [ 17 | "loopback", 18 | "strongloop", 19 | "migrate" 20 | ], 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/fullcube/loopback-component-migrate/issues" 24 | }, 25 | "homepage": "https://github.com/fullcube/loopback-component-migrate", 26 | "files": [ 27 | "migration-skeleton.js", 28 | "lib", 29 | "bin", 30 | "test" 31 | ], 32 | "directories": { 33 | "lib": "lib", 34 | "test": "test" 35 | }, 36 | "main": "./lib/index.js", 37 | "bin": { 38 | "loopback-component-migrate": "./bin/cli" 39 | }, 40 | "scripts": { 41 | "lint": "jscs lib && jshint lib", 42 | "test": "mocha -R spec --timeout 10000 test/test.js", 43 | "test:watch": "npm run test -- -w", 44 | "pretest": "npm run lint", 45 | "coverage": "nyc report --reporter=text-lcov | coveralls", 46 | "outdated": "npm outdated --depth=0", 47 | "semantic-release": "semantic-release pre && npm publish && semantic-release post" 48 | }, 49 | "dependencies": { 50 | "cli-prompt": "^0.6.0", 51 | "commander": "^2.8.1", 52 | "debug": "^2.6.8", 53 | "mkdirp": "^0.5.1" 54 | }, 55 | "devDependencies": { 56 | "bluebird": "^3.5.0", 57 | "chai": "latest", 58 | "condition-circle": "^1.5.0", 59 | "ghooks": "^2.0.0", 60 | "jscs": "latest", 61 | "jshint": "latest", 62 | "lodash": "latest", 63 | "loopback": "^3.8.0", 64 | "loopback-boot": "^2.24.1", 65 | "loopback-component-explorer": "^4.2.0", 66 | "loopback-testing": "^1.4.0", 67 | "mocha": "latest", 68 | "nyc": "latest", 69 | "semantic-release": "^6.3.6", 70 | "sinon": "latest", 71 | "sinon-chai": "latest", 72 | "validate-commit-msg": "^2.12.2" 73 | }, 74 | "config": { 75 | "ghooks": { 76 | "commit-msg": "validate-commit-msg", 77 | "pre-commit": "npm run lint" 78 | } 79 | }, 80 | "release": { 81 | "verifyConditions": "condition-circle" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/widget.js: -------------------------------------------------------------------------------- 1 | module.exports = function(Widget) { 2 | 3 | }; 4 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/widget.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Widget", 3 | "base": "PersistedModel", 4 | "idInjection": true, 5 | "options": { 6 | "validateUpsert": true 7 | }, 8 | "properties": { 9 | "name": { 10 | "type": "string", 11 | "required": false 12 | }, 13 | "type": { 14 | "type": "string", 15 | "required": false 16 | } 17 | }, 18 | "validations": [], 19 | "relations": {}, 20 | "methods": {} 21 | } 22 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/component-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "../../../../lib": { 3 | "enableRest": false, 4 | "migrationsDir": "./test/fixtures/simple-app/server/migrations" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "restApiRoot": "/api", 3 | "host": "0.0.0.0", 4 | "port": 3000, 5 | "remoting": { 6 | "context": false, 7 | "rest": { 8 | "normalizeHttpPath": false, 9 | "xml": false 10 | }, 11 | "json": { 12 | "strict": false, 13 | "limit": "100kb" 14 | }, 15 | "urlencoded": { 16 | "extended": true, 17 | "limit": "100kb" 18 | }, 19 | "cors": false 20 | }, 21 | "legacyExplorer": false 22 | } 23 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/datasources.json: -------------------------------------------------------------------------------- 1 | { 2 | "db": { 3 | "name": "db", 4 | "connector": "memory" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/middleware.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial": {}, 3 | "session": {}, 4 | "auth": {}, 5 | "parse": {}, 6 | "routes": {}, 7 | "files": {}, 8 | "final": {} 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/migrations/0000-error.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: function(dataSource, next) { 3 | process.nextTick(() => next(new Error('some error in up'))) 4 | }, 5 | down: function(dataSource, next) { 6 | process.nextTick(() => next()) 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/migrations/0001-initialize.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: function(dataSource, next) { 3 | process.nextTick(() => next()) 4 | }, 5 | down: function(dataSource, next) { 6 | process.nextTick(() => next()) 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/migrations/0002-somechanges.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: function(dataSource, next) { 3 | process.nextTick(() => next()) 4 | }, 5 | down: function(dataSource, next) { 6 | process.nextTick(() => next()) 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/migrations/0003-morechanges.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: function(dataSource, next) { 3 | process.nextTick(() => next()) 4 | }, 5 | down: function(dataSource, next) { 6 | process.nextTick(() => next()) 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/model-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "sources": [ 4 | "../common/models" 5 | ] 6 | }, 7 | "User": { 8 | "dataSource": "db" 9 | }, 10 | "AccessToken": { 11 | "dataSource": "db", 12 | "public": false 13 | }, 14 | "ACL": { 15 | "dataSource": "db", 16 | "public": false 17 | }, 18 | "RoleMapping": { 19 | "dataSource": "db", 20 | "public": false 21 | }, 22 | "Role": { 23 | "dataSource": "db", 24 | "public": false 25 | }, 26 | "Widget": { 27 | "dataSource": "db", 28 | "public": false 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/server.js: -------------------------------------------------------------------------------- 1 | var loopback = require('loopback'); 2 | var boot = require('loopback-boot'); 3 | var explorer = require('loopback-component-explorer'); 4 | var path = require('path'); 5 | 6 | var app = module.exports = loopback(); 7 | 8 | app.use('/api', loopback.rest()); 9 | 10 | app.start = function() { 11 | // start the web server 12 | return app.listen(function() { 13 | app.emit('started'); 14 | console.log('Web server listening at: %s', app.get('url')); 15 | console.log('Explorer mounted at : %s', app.get('url') + 'explorer'); 16 | }); 17 | }; 18 | 19 | // Bootstrap the application, configure models, datasources and middleware. 20 | // Sub-apps like REST API are mounted via boot scripts. 21 | boot(app, __dirname, function(err) { 22 | if (err) throw err; 23 | 24 | // Register explorer using component-centric API: 25 | explorer(app, { basePath: '/api', mountPath: '/explorer' }); 26 | 27 | // start the server if `$ node server.js` 28 | if (require.main === module) 29 | app.start(); 30 | }); 31 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var debug = require('debug')('loopback-component-migrate'); 4 | var _ = require('lodash'); 5 | 6 | var loopback = require('loopback'); 7 | var lt = require('loopback-testing'); 8 | 9 | var chai = require('chai'); 10 | var expect = chai.expect; 11 | var sinon = require('sinon'); 12 | chai.use(require('sinon-chai')); 13 | 14 | var path = require('path'); 15 | var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); 16 | var app = require(path.join(SIMPLE_APP, 'server/server.js')); 17 | 18 | global.Promise = require('bluebird'); 19 | 20 | lt.beforeEach.withApp(app); 21 | 22 | describe('loopback db migrate', function() { 23 | 24 | describe('initialization', function() { 25 | it('should attach a Migration model to the app', function() { 26 | expect(app.models.Migration).to.exist; 27 | expect(app.models.Migration).itself.to.respondTo('migrate'); 28 | }); 29 | it('should attach a MigrationMap model to the app', function() { 30 | expect(app.models.Migration).to.exist; 31 | }); 32 | it('should provide a Migration.migrate() method', function() { 33 | expect(app.models.Migration).itself.to.respondTo('migrate'); 34 | }); 35 | }); 36 | 37 | describe('migration', function() { 38 | // Set up a spy for each migration function. 39 | before(function() { 40 | var m1 = require(path.join(SIMPLE_APP, 'server/migrations/0001-initialize.js')); 41 | var m2 = require(path.join(SIMPLE_APP, 'server/migrations/0002-somechanges.js')); 42 | var m3 = require(path.join(SIMPLE_APP, 'server/migrations/0003-morechanges.js')); 43 | this.spies = { 44 | m1Up: sinon.spy(m1, 'up'), 45 | m1Down: sinon.spy(m1, 'down'), 46 | m2Up: sinon.spy(m2, 'up'), 47 | m2Down: sinon.spy(m2, 'down'), 48 | m3Up: sinon.spy(m3, 'up'), 49 | m3Down: sinon.spy(m3, 'down') 50 | }; 51 | 52 | this.resetSpies = function() { 53 | _.forEach(this.spies, function(spy) { 54 | spy.reset(); 55 | }); 56 | }; 57 | 58 | this.expectNoDown = function() { 59 | expect(this.spies.m1Down).not.to.have.been.called; 60 | expect(this.spies.m2Down).not.to.have.been.called; 61 | expect(this.spies.m3Down).not.to.have.been.called; 62 | }; 63 | 64 | this.expectNoUp = function() { 65 | expect(this.spies.m1Up).not.to.have.been.called; 66 | expect(this.spies.m2Up).not.to.have.been.called; 67 | expect(this.spies.m3Up).not.to.have.been.called; 68 | }; 69 | }); 70 | 71 | // Reset all the spies after each test. 72 | afterEach(function() { 73 | this.resetSpies(); 74 | }); 75 | 76 | // Delete all data after each test. 77 | beforeEach(function() { 78 | return Promise.all([ 79 | app.models.Migration.destroyAll(), 80 | app.models.Migration.destroyAll() 81 | ]) 82 | .then(function() { 83 | return app.models.Migration.create({ 84 | name: '0000-error.js', 85 | runDtTm: new Date() 86 | }) 87 | }) 88 | }); 89 | 90 | describe('migrateByName', function() { 91 | it('should set a property on app to indicate that migration is running', function(done) { 92 | var self = this; 93 | expect(app.migrating).to.be.undefined; 94 | var promise = app.models.Migration.migrateByName('0002-somechanges.js'); 95 | expect(app.migrating).to.be.true; 96 | promise.then(function() { 97 | expect(app.migrating).to.be.undefined; 98 | done(); 99 | }) 100 | .catch(done); 101 | }); 102 | 103 | it('should log errors', function() { 104 | return app.models.Migration.migrateByName('0000-error.js') 105 | .catch(function(err) { 106 | expect(err).to.not.be.undefined; 107 | }) 108 | }); 109 | 110 | }); 111 | describe('migrate', function() { 112 | it('should set a property on app to indicate that migrations are running', function() { 113 | var self = this; 114 | expect(app.migrating).to.be.undefined; 115 | var promise = app.models.Migration.migrate(); 116 | expect(app.migrating).to.be.true; 117 | return promise.then(function() { 118 | expect(app.migrating).to.be.undefined; 119 | }) 120 | }); 121 | }); 122 | 123 | describe('up', function() { 124 | it('should run all migration scripts', function() { 125 | var self = this; 126 | return app.models.Migration.migrate() 127 | .then(function() { 128 | expect(self.spies.m1Up).to.have.been.called; 129 | expect(self.spies.m2Up).to.have.been.calledAfter(self.spies.m1Up); 130 | expect(self.spies.m3Up).to.have.been.calledAfter(self.spies.m2Up); 131 | self.expectNoDown(); 132 | }) 133 | }); 134 | it('should run migrations up to the specificed point only', function() { 135 | var self = this; 136 | return app.models.Migration.migrate('up', '0002-somechanges') 137 | .then(function() { 138 | expect(self.spies.m1Up).to.have.been.calledBefore(self.spies.m2Up); 139 | expect(self.spies.m2Up).to.have.been.calledAfter(self.spies.m1Up); 140 | expect(self.spies.m3Up).not.to.have.been.called; 141 | self.expectNoDown(); 142 | }) 143 | }); 144 | it('should not rerun migrations that hae already been run', function() { 145 | var self = this; 146 | return app.models.Migration.migrate('up', '0002-somechanges') 147 | .then(function() { 148 | self.resetSpies(); 149 | return app.models.Migration.migrate('up'); 150 | }) 151 | .then(function() { 152 | expect(self.spies.m1Up).not.to.have.been.called; 153 | expect(self.spies.m2Up).not.to.have.been.called; 154 | expect(self.spies.m3Up).to.have.been.called; 155 | self.expectNoDown(); 156 | }) 157 | }); 158 | }); 159 | 160 | describe('down', function() { 161 | it('should run all rollback scripts in reverse order', function() { 162 | var self = this; 163 | return app.models.Migration.migrate('up') 164 | .then(function() { 165 | self.expectNoDown(); 166 | self.resetSpies(); 167 | return app.models.Migration.migrate('down'); 168 | }) 169 | .then(function() { 170 | expect(self.spies.m3Down).to.have.been.calledBefore(self.spies.m2Down); 171 | expect(self.spies.m2Down).to.have.been.calledAfter(self.spies.m3Down); 172 | expect(self.spies.m1Down).to.have.been.calledAfter(self.spies.m2Down); 173 | self.expectNoUp(); 174 | }) 175 | }); 176 | it('should run rollbacks up to the specificed point only', function() { 177 | var self = this; 178 | return app.models.Migration.migrate('up') 179 | .then(function() { 180 | self.expectNoDown(); 181 | self.resetSpies(); 182 | return app.models.Migration.migrate('down', '0001-initialize'); 183 | }) 184 | .then(function() { 185 | expect(self.spies.m3Down).to.have.been.called; 186 | expect(self.spies.m2Down).to.have.been.calledAfter(self.spies.m3Down); 187 | expect(self.spies.m1Down).not.to.have.been.called; 188 | self.expectNoUp(); 189 | }) 190 | }); 191 | it('should not rerun rollbacks that hae already been run', function() { 192 | var self = this; 193 | return app.models.Migration.migrate('up') 194 | .then(function() { 195 | return app.models.Migration.migrate('down', '0001-initialize'); 196 | }) 197 | .then(function() { 198 | self.resetSpies(); 199 | return app.models.Migration.migrate('down'); 200 | }) 201 | .then(function() { 202 | expect(self.spies.m3Down).to.not.have.been.called; 203 | expect(self.spies.m2Down).to.not.have.been.called; 204 | expect(self.spies.m1Down).to.have.been.called; 205 | self.expectNoUp(); 206 | }) 207 | }); 208 | it('should rollback a single migration that has not already run', function() { 209 | var self = this; 210 | return app.models.Migration.migrate('up', '0002-somechanges') 211 | .then(function() { 212 | self.resetSpies(); 213 | return app.models.Migration.migrate('down', '0003-morechanges'); 214 | }) 215 | .then(function() { 216 | expect(self.spies.m3Down).to.have.been.called; 217 | expect(self.spies.m2Down).to.not.have.been.called; 218 | expect(self.spies.m1Down).to.not.have.been.called; 219 | self.expectNoUp(); 220 | }) 221 | }); 222 | }); 223 | }); 224 | 225 | }); 226 | --------------------------------------------------------------------------------