UNPKG

hsd

Version:
366 lines (282 loc) 8.22 kB
/** * 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;