/**
* migrations/migrator.js - abstract migrator for hsd.
* Copyright (c) 2021, Nodari Chkuaselidze (MIT License)
*/
'use strict';
const assert = require('bsert');
const Logger = require('blgr');
const bdb = require('bdb');
const MigrationState = require('../migrations/state');
/**
* This entry needs to be part of all dbs that support migrations.
* V -> DB Version
* M -> migration state
*/
const migrationLayout = {
V: bdb.key('V'),
M: bdb.key('M')
};
/**
* Previous layout used M[id]-s to list of executed migrations
*/
const oldLayout = {
M: bdb.key('M', ['uint32'])
};
const types = {
MIGRATE: 0,
SKIP: 1,
FAKE_MIGRATE: 2
};
/**
* Store migration results.
* @alias module:migrations.MigrationResult
*/
class MigrationResult {
constructor() {
this.migrated = new Set();
this.skipped = new Set();
}
skip(id) {
this.skipped.add(id);
}
migrate(id) {
this.migrated.add(id);
}
}
/**
* class for migrations.
* @alias module:migrations.Migrator
*/
class Migrator {
/**
* Create Migrator object.
* @constructor
* @param {Object} options
*/
constructor(options) {
this.options = options;
this.logger = Logger.global;
this.migrations = {};
this.migrateFlag = -1;
this.layout = migrationLayout;
this.db = null;
this.ldb = null;
this.dbVersion = 0;
this.pending = new MigrationResult();
this.flagError = '';
this.fromOptions(options);
this.lastMigration = this.getLastMigrationID();
}
/**
* Recheck options
* @private
*/
fromOptions(options) {
assert(options, 'Migration options are required.');
assert(options.db != null, 'options.db is required.');
assert(options.ldb != null, 'options.ldb is required.');
assert(typeof options.db === 'object', 'options.db needs to be an object.');
assert(typeof options.ldb === 'object',
'options.ldb needs to be an object.');
this.db = options.db;
this.ldb = options.ldb;
if (options.migrations != null) {
assert(typeof options.migrations === 'object');
this.migrations = options.migrations;
}
if (options.logger != null) {
assert(typeof options.logger === 'object');
this.logger = options.logger;
}
if (options.migrateFlag != null) {
assert(typeof options.migrateFlag === 'number');
this.migrateFlag = options.migrateFlag;
}
if (options.layout != null) {
assert(typeof options.layout === 'object');
this.layout = options.layout;
}
if (options.dbVersion != null) {
assert(typeof options.dbVersion === 'number');
this.dbVersion = options.dbVersion;
}
}
/**
* Do the actual migrations
* @returns {Promise}
*/
async migrate() {
const version = await this.ldb.get(this.layout.V.encode());
const lastID = this.getLastMigrationID();
if (version === null) {
if (this.migrateFlag !== -1) {
if (this.migrateFlag !== lastID) {
throw new Error(
`Migrate flag ${this.migrateFlag} does not match last ID: ${lastID}`
);
}
this.logger.warning('Fresh start, ignoring migration flag.');
}
const state = new MigrationState();
state.nextMigration = this.getLastMigrationID() + 1;
this.logger.info('Fresh start, saving last migration id: %d',
state.lastMigration);
await this.saveState(state);
return this.pending;
}
await this.ensure();
await this.verifyDB();
await this.checkMigrations();
let state = await this.getState();
if (this.migrateFlag !== -1 && this.migrateFlag !== lastID) {
throw new Error(
`Migrate flag ${this.migrateFlag} does not match last ID: ${lastID}`
);
}
this.logger.debug('Last migration %d, last available migration: %d',
state.lastMigration, lastID);
this.logger.info('There are %d migrations.', lastID - state.lastMigration);
for (const id of state.skipped) {
const skippedMigration = new this.migrations[id](this.options);
skippedMigration.warning();
}
if (state.inProgress)
this.logger.info('Continue progress on migration: ', state.nextMigration);
while (state.nextMigration <= lastID) {
const id = state.nextMigration;
const currentMigration = new this.migrations[id](this.options);
const type = await currentMigration.check();
switch (type) {
case types.FAKE_MIGRATE: {
this.logger.info('Migration %d does not apply, fake migrating.', id);
state.nextMigration = id + 1;
this.pending.migrate(id);
await this.saveState(state);
break;
}
case types.SKIP: {
this.logger.info('Migration %d can not run, skipping.', id);
currentMigration.warning();
state.nextMigration = id + 1;
state.skipped.push(id);
this.pending.skip(id);
await this.saveState(state);
break;
}
case types.MIGRATE: {
assert(this.migrateFlag > -1);
state.inProgress = true;
await this.saveState(state);
this.logger.info('Migration %d in progress...', id);
const batch = this.ldb.batch();
// queue state updates first, so migration can modify the state.
state.inProgress = false;
state.nextMigration = id + 1;
this.writeState(batch, state);
await currentMigration.migrate(batch, this.pending);
await batch.write();
this.pending.migrate(id);
this.logger.info('Migration %d is done.', id);
break;
}
default:
throw new Error('Unknown migration type.');
}
state = await this.getState();
}
return this.pending;
}
/**
* Get migration list
*/
async checkMigrations() {
const lastID = this.getLastMigrationID();
const ids = await this.getMigrationsToRun();
if (ids.size === 0) {
this.logger.debug('There are no migrations pending. last id: %d',
lastID);
return;
}
let error = 'Database needs migration(s):\n';
for (const id of ids) {
const MigrationClass = this.migrations[id];
assert(MigrationClass);
const info = MigrationClass.info();
error += ` - ${info.name} - ${info.description}\n`;
}
if (this.migrateFlag !== lastID) {
error += this.flagError;
this.logger.error(error);
throw new Error(error);
}
this.logger.info(error);
}
/**
* Do any necessary database checks
* @returns {Promise}
*/
async verifyDB() {
}
/**
* Get list of migrations to run
* @returns {Promise<Set>}
*/
async getMigrationsToRun() {
const state = await this.getState();
const lastID = this.getLastMigrationID();
const ids = new Set();
for (let i = state.nextMigration; i <= lastID; i++)
ids.add(i);
return ids;
}
/**
* Ensure we have migration entry in DB.
* @returns {Promise}
*/
async ensure() {
if (await this.ldb.get(this.layout.M.encode()))
return;
const state = new MigrationState();
await this.ldb.put(this.layout.M.encode(), state.encode());
}
/**
* Get max migration ID from the map
* @returns {Number}
*/
getLastMigrationID() {
const ids = Object.keys(this.migrations);
if (ids.length === 0)
return -1;
return Math.max(...ids);
}
/**
* Save state
* @param {MigrationState} state
*/
async saveState(state) {
const batch = this.ldb.batch();
this.writeState(batch, state);
await batch.write();
}
/**
* Write state
* @param {Batch} b
* @param {MigrationState} state
*/
writeState(b, state) {
b.put(this.layout.M.encode(), state.encode());
}
/**
* Get state
* @returns {Promise<MigrationState>}
*/
async getState() {
const data = await this.ldb.get(this.layout.M.encode());
assert(data, 'State was corrupted.');
return MigrationState.decode(data);
}
}
exports.Migrator = Migrator;
exports.MigrationResult = MigrationResult;
exports.types = types;
exports.oldLayout = oldLayout;