You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
276 lines
7.9 KiB
276 lines
7.9 KiB
|
10 years ago
|
/*
|
||
|
|
Adds migration capabilities. Migrations are defined like:
|
||
|
|
|
||
|
|
Migrations.add({
|
||
|
|
up: function() {}, //*required* code to run to migrate upwards
|
||
|
|
version: 1, //*required* number to identify migration order
|
||
|
|
down: function() {}, //*optional* code to run to migrate downwards
|
||
|
|
name: 'Something' //*optional* display name for the migration
|
||
|
|
});
|
||
|
|
|
||
|
|
The ordering of migrations is determined by the version you set.
|
||
|
|
|
||
|
|
To run the migrations, set the MIGRATE environment variable to either
|
||
|
|
'latest' or the version number you want to migrate to. Optionally, append
|
||
|
|
',exit' if you want the migrations to exit the meteor process, e.g if you're
|
||
|
|
migrating from a script (remember to pass the --once parameter).
|
||
|
|
|
||
|
|
e.g:
|
||
|
|
MIGRATE="latest" mrt # ensure we'll be at the latest version and run the app
|
||
|
|
MIGRATE="latest,exit" mrt --once # ensure we'll be at the latest version and exit
|
||
|
|
MIGRATE="2,exit" mrt --once # migrate to version 2 and exit
|
||
|
|
|
||
|
|
Note: Migrations will lock ensuring only 1 app can be migrating at once. If
|
||
|
|
a migration crashes, the control record in the migrations collection will
|
||
|
|
remain locked and at the version it was at previously, however the db could
|
||
|
|
be in an inconsistant state.
|
||
|
|
*/
|
||
|
|
|
||
|
|
// since we'll be at version 0 by default, we should have a migration set for
|
||
|
|
// it.
|
||
|
|
var DefaultMigration = {version: 0, up: function(){}};
|
||
|
|
|
||
|
|
Migrations = {
|
||
|
|
_list: [DefaultMigration],
|
||
|
|
options: {
|
||
|
|
// false disables logging
|
||
|
|
log: true,
|
||
|
|
// null or a function
|
||
|
|
logger: null,
|
||
|
|
// enable/disable info log "already at latest."
|
||
|
|
logIfLatest: true,
|
||
|
|
// migrations collection name
|
||
|
|
collectionName: "migrations"
|
||
|
|
},
|
||
|
|
config: function(opts) {
|
||
|
|
this.options = _.extend({}, this.options, opts);
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
Logger factory function. Takes a prefix string and options object
|
||
|
|
and uses an injected `logger` if provided, else falls back to
|
||
|
|
Meteor's `Log` package.
|
||
|
|
Will send a log object to the injected logger, on the following form:
|
||
|
|
message: String
|
||
|
|
level: String (info, warn, error, debug)
|
||
|
|
tag: 'Migrations'
|
||
|
|
*/
|
||
|
|
function createLogger(prefix) {
|
||
|
|
check(prefix, String);
|
||
|
|
|
||
|
|
// Return noop if logging is disabled.
|
||
|
|
if(Migrations.options.log === false) {
|
||
|
|
return function() {};
|
||
|
|
}
|
||
|
|
|
||
|
|
return function(level, message) {
|
||
|
|
check(level, Match.OneOf('info', 'error', 'warn', 'debug'));
|
||
|
|
check(message, String);
|
||
|
|
|
||
|
|
var logger = Migrations.options && Migrations.options.logger;
|
||
|
|
|
||
|
|
if(logger && _.isFunction(logger)) {
|
||
|
|
|
||
|
|
logger({
|
||
|
|
level: level,
|
||
|
|
message: message,
|
||
|
|
tag: prefix
|
||
|
|
});
|
||
|
|
|
||
|
|
} else {
|
||
|
|
Log[level]({ message: prefix + ': ' + message });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var log;
|
||
|
|
|
||
|
|
Meteor.startup(function () {
|
||
|
|
var options = Migrations.options;
|
||
|
|
|
||
|
|
// collection holding the control record
|
||
|
|
Migrations._collection = new Mongo.Collection(options.collectionName);
|
||
|
|
|
||
|
|
log = createLogger('Migrations');
|
||
|
|
|
||
|
|
['info', 'warn', 'error', 'debug'].forEach(function(level) {
|
||
|
|
log[level] = _.partial(log, level);
|
||
|
|
});
|
||
|
|
|
||
|
|
if (process.env.MIGRATE)
|
||
|
|
Migrations.migrateTo(process.env.MIGRATE);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Add a new migration:
|
||
|
|
// {up: function *required
|
||
|
|
// version: Number *required
|
||
|
|
// down: function *optional
|
||
|
|
// name: String *optional
|
||
|
|
// }
|
||
|
|
Migrations.add = function(migration) {
|
||
|
|
if (typeof migration.up !== 'function')
|
||
|
|
throw new Meteor.Error('Migration must supply an up function.');
|
||
|
|
|
||
|
|
if (typeof migration.version !== 'number')
|
||
|
|
throw new Meteor.Error('Migration must supply a version number.');
|
||
|
|
|
||
|
|
if (migration.version <= 0)
|
||
|
|
throw new Meteor.Error('Migration version must be greater than 0');
|
||
|
|
|
||
|
|
// Freeze the migration object to make it hereafter immutable
|
||
|
|
Object.freeze(migration);
|
||
|
|
|
||
|
|
this._list.push(migration);
|
||
|
|
this._list = _.sortBy(this._list, function(m) {return m.version;});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Attempts to run the migrations using command in the form of:
|
||
|
|
// e.g 'latest', 'latest,exit', 2
|
||
|
|
// use 'XX,rerun' to re-run the migration at that version
|
||
|
|
Migrations.migrateTo = function(command) {
|
||
|
|
if (_.isUndefined(command) || command === '' || this._list.length === 0)
|
||
|
|
throw new Error("Cannot migrate using invalid command: " + command);
|
||
|
|
|
||
|
|
if (typeof command === 'number') {
|
||
|
|
var version = command;
|
||
|
|
} else {
|
||
|
|
var version = command.split(',')[0];
|
||
|
|
var subcommand = command.split(',')[1];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (version === 'latest') {
|
||
|
|
this._migrateTo(_.last(this._list).version);
|
||
|
|
} else {
|
||
|
|
this._migrateTo(parseInt(version), (subcommand === 'rerun'));
|
||
|
|
}
|
||
|
|
|
||
|
|
// remember to run meteor with --once otherwise it will restart
|
||
|
|
if (subcommand === 'exit')
|
||
|
|
process.exit(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
// just returns the current version
|
||
|
|
Migrations.getVersion = function() {
|
||
|
|
return this._getControl().version;
|
||
|
|
}
|
||
|
|
|
||
|
|
// migrates to the specific version passed in
|
||
|
|
Migrations._migrateTo = function(version, rerun) {
|
||
|
|
var self = this;
|
||
|
|
var control = this._getControl(); // Side effect: upserts control document.
|
||
|
|
var currentVersion = control.version;
|
||
|
|
|
||
|
|
if (lock() === false) {
|
||
|
|
log.info('Not migrating, control is locked.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (rerun) {
|
||
|
|
log.info('Rerunning version ' + version);
|
||
|
|
migrate('up', version);
|
||
|
|
log.info('Finished migrating.');
|
||
|
|
unlock();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentVersion === version) {
|
||
|
|
if (Migrations.options.logIfLatest) {
|
||
|
|
log.info('Not migrating, already at version ' + version);
|
||
|
|
}
|
||
|
|
unlock();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var startIdx = this._findIndexByVersion(currentVersion);
|
||
|
|
var endIdx = this._findIndexByVersion(version);
|
||
|
|
|
||
|
|
// log.info('startIdx:' + startIdx + ' endIdx:' + endIdx);
|
||
|
|
log.info('Migrating from version ' + this._list[startIdx].version
|
||
|
|
+ ' -> ' + this._list[endIdx].version);
|
||
|
|
|
||
|
|
// run the actual migration
|
||
|
|
function migrate(direction, idx) {
|
||
|
|
var migration = self._list[idx];
|
||
|
|
|
||
|
|
if (typeof migration[direction] !== 'function') {
|
||
|
|
unlock();
|
||
|
|
throw new Meteor.Error('Cannot migrate ' + direction + ' on version '
|
||
|
|
+ migration.version);
|
||
|
|
}
|
||
|
|
|
||
|
|
function maybeName() {
|
||
|
|
return migration.name ? ' (' + migration.name + ')' : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
log.info('Running ' + direction + '() on version '
|
||
|
|
+ migration.version + maybeName());
|
||
|
|
|
||
|
|
migration[direction](migration);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Returns true if lock was acquired.
|
||
|
|
function lock() {
|
||
|
|
// This is atomic. The selector ensures only one caller at a time will see
|
||
|
|
// the unlocked control, and locking occurs in the same update's modifier.
|
||
|
|
// All other simultaneous callers will get false back from the update.
|
||
|
|
return self._collection.update(
|
||
|
|
{_id: 'control', locked: false}, {$set: {locked: true, lockedAt: new Date()}}
|
||
|
|
) === 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Side effect: saves version.
|
||
|
|
function unlock() {
|
||
|
|
self._setControl({locked: false, version: currentVersion});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentVersion < version) {
|
||
|
|
for (var i = startIdx;i < endIdx;i++) {
|
||
|
|
migrate('up', i + 1);
|
||
|
|
currentVersion = self._list[i + 1].version;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
for (var i = startIdx;i > endIdx;i--) {
|
||
|
|
migrate('down', i);
|
||
|
|
currentVersion = self._list[i - 1].version;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
unlock();
|
||
|
|
log.info('Finished migrating.');
|
||
|
|
}
|
||
|
|
|
||
|
|
// gets the current control record, optionally creating it if non-existant
|
||
|
|
Migrations._getControl = function() {
|
||
|
|
var control = this._collection.findOne({_id: 'control'});
|
||
|
|
|
||
|
|
return control || this._setControl({version: 0, locked: false});
|
||
|
|
}
|
||
|
|
|
||
|
|
// sets the control record
|
||
|
|
Migrations._setControl = function(control) {
|
||
|
|
// be quite strict
|
||
|
|
check(control.version, Number);
|
||
|
|
check(control.locked, Boolean);
|
||
|
|
|
||
|
|
this._collection.update({_id: 'control'},
|
||
|
|
{$set: {version: control.version, locked: control.locked}}, {upsert: true});
|
||
|
|
|
||
|
|
return control;
|
||
|
|
}
|
||
|
|
|
||
|
|
// returns the migration index in _list or throws if not found
|
||
|
|
Migrations._findIndexByVersion = function(version) {
|
||
|
|
for (var i = 0;i < this._list.length;i++) {
|
||
|
|
if (this._list[i].version === version)
|
||
|
|
return i;
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new Meteor.Error('Can\'t find migration version ' + version);
|
||
|
|
}
|
||
|
|
|
||
|
|
//reset (mainly intended for tests)
|
||
|
|
Migrations._reset = function() {
|
||
|
|
this._list = [{version: 0, up: function(){}}];
|
||
|
|
this._collection.remove({});
|
||
|
|
}
|