lib/migrator/index.js

const EventEmitter = require('events')

/**
 * @typedef {object} Migration
 * @property {number} version - The migration version
 * @property {Function} migrate - Returns a promise of the migrated data
 */

/**
 * @typedef {object} MigratorOptions
 * @property {Array<Migration>} [migrations] - The list of migrations to apply
 * @property {number} [defaultVersion] - The version to use in the initial state
 */

class Migrator extends EventEmitter {

  /**
   * @constructor
   * @param {MigratorOptions} opts
   */
  constructor (opts = {}) {
    super()
    const migrations = opts.migrations || []
    // sort migrations by version
    this.migrations = migrations.sort((a, b) => a.version - b.version)
    // grab migration with highest version
    const lastMigration = this.migrations.slice(-1)[0]
    // use specified defaultVersion or highest migration version
    this.defaultVersion = opts.defaultVersion || (lastMigration && lastMigration.version) || 0
  }

  // run all pending migrations on meta in place
  async migrateData (versionedData = this.generateInitialState()) {
    // get all migrations that have not yet been run
    const pendingMigrations = this.migrations.filter(migrationIsPending)

    // perform each migration
    for (const index in pendingMigrations) {
      const migration = pendingMigrations[index]
      try {
        // attempt migration and validate
        const migratedData = await migration.migrate(versionedData)
        if (!migratedData.data) throw new Error('Migrator - migration returned empty data')
        if (migratedData.version !== undefined && migratedData.meta.version !== migration.version) throw new Error('Migrator - Migration did not update version number correctly')
        // accept the migration as good
        versionedData = migratedData
      } catch (err) {
        // rewrite error message to add context without clobbering stack
        const originalErrorMessage = err.message
        err.message = `MetaMask Migration Error #${migration.version}: ${originalErrorMessage}`
        console.warn(err.stack)
        // emit error instead of throw so as to not break the run (gracefully fail)
        this.emit('error', err)
        // stop migrating and use state as is
        return versionedData
      }
    }

    return versionedData

    /**
     * Returns whether or not the migration is pending
     *
     * A migration is considered "pending" if it has a higher
     * version number than the current version.
     * @param {Migration} migration
     * @returns {boolean}
     */
    function migrationIsPending (migration) {
      return migration.version > versionedData.meta.version
    }
  }

  /**
   * Returns the initial state for the migrator
   * @param {object} [data] - The data for the initial state
   * @returns {{meta: {version: number}, data: any}}
   */
  generateInitialState (data) {
    return {
      meta: {
        version: this.defaultVersion,
      },
      data,
    }
  }

}

module.exports = Migrator