UNPKG

object-migrations

Version:

Linear, in-memory migrations for versioned objects

290 lines (285 loc) 10.3 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ObjectMigrations = {})); })(this, (function (exports) { 'use strict'; /** * Base class for all errors thrown by the migrator. */ class MigratorError extends Error { constructor(name, message) { super(message); this.name = name; } } /** * Wraps an error thrown within the migration function, while migrating an object between successive * versions. */ class MigrationError extends MigratorError { constructor( /** * Version from which migration was attempted (version of the object being migrated). */ from, /** * Version to which migration was attempted. */ to, /** * The underlying (wrapped) error. */ cause) { super('MigrationError', `Error "${String(cause)}" thrown while migrating an object from version ` + `${describeVersion(from)} to ${describeVersion(to)}`); this.from = from; this.to = to; this.cause = cause; } } /** * Indicates that the sequence of steps to migrate an object between two versions couldn't be * determined. * * Occurs when migrations between the versions (or a chain of migrations between successive versions * starting & ending at the them) haven't been registered beforehand. */ class NoMigrationStepsError extends MigratorError { constructor( /** * Version from which migration was attempted. */ from, /** * Version to which migration was attempted. */ to) { super('NoMigrationStepsError', `No migration steps from version ${describeVersion(from)} to ${describeVersion(to)}`); this.from = from; this.to = to; } } function describeVersion(v) { return typeof v === 'function' && v.name !== '' ? v.name : String(v); } /** * Allows registering migrations between object versions and then migrating objects forward and * backward accordingly. * * @remarks * * - First register migrations with {@linkcode register}. Then, migrate objects forward * with {@linkcode forward}/{@linkcode forwardAsync} and backward with * {@linkcode backward}/{@linkcode backwardAsync}. * * ```typescript * const m = new Migrator(); * * m.register(1, 2, ...); * m.register(2, 3, ...); * * const v2 = m.forward<V2>(v1Object, 1, 2); * ``` * * - A record of version-type mappings can be optionally specified, so that subsequent * {@linkcode register}, {@linkcode forward}, {@linkcode forwardAsync}, {@linkcode backward}, and * {@linkcode backwardAsync} calls are typed accordingly. * * ```typescript * const m = new Migrator<{ * 1: V1, * 2: V2, * }>(); * * m.register(1, 2, ...); * * const v2: V2 = m.forward(v1Object, 1, 2); * ``` */ class Migrator { constructor() { /** @internal */ this.forwardStep = new Map(); /** @internal */ this.backwardStep = new Map(); /** @internal */ this.cache = new Map(); } register(from, to, forward, backward) { this.forwardStep.set(from, { to, migration: forward, }); if (backward !== undefined) { this.backwardStep.set(to, { to: from, migration: backward, }); } } forward(obj, fromVersionOrToClass, toVersion) { if (toVersion !== undefined) { const fromVersion = fromVersionOrToClass; return this.migrate(obj, fromVersion, toVersion, this.forwardStep); } else { const fromClass = obj.constructor; const toClass = fromVersionOrToClass; return this.migrate(obj, fromClass, toClass, this.forwardStep); } } async forwardAsync(obj, fromVersionOrToClass, toVersion) { if (toVersion !== undefined) { const fromVersion = fromVersionOrToClass; return await this.migrateAsync(obj, fromVersion, toVersion, this.forwardStep); } else { const fromClass = obj.constructor; const toClass = fromVersionOrToClass; return await this.migrateAsync(obj, fromClass, toClass, this.forwardStep); } } backward(obj, fromVersionOrToClass, toVersion) { const versions = this.resolveOverloadVersions(obj, fromVersionOrToClass, toVersion); return this.migrate(obj, versions.from, versions.to, this.backwardStep); } async backwardAsync(obj, fromVersionOrToClass, toVersion) { const versions = this.resolveOverloadVersions(obj, fromVersionOrToClass, toVersion); return await this.migrateAsync(obj, versions.from, versions.to, this.backwardStep); } /** @internal */ resolveOverloadVersions(obj, fromVersionOrToClass, toVersion) { if (toVersion !== undefined) { // 3 args: (plainObject, v1, v2) return { from: fromVersionOrToClass, to: toVersion }; } else { // 2 args: (new V1(), V2) return { from: obj.constructor, to: fromVersionOrToClass }; } } /** @internal */ migrate(obj, from, to, nextStep) { // Same versions? Return immediately. if (from === to) { return { value: obj, changed: false, }; } // Get steps. let steps = this.tryGetCachedSteps(from, to); if (steps === undefined) { steps = this.computeSteps(from, to, nextStep); this.cacheSteps(from, to, steps); } // Migrate. let migratedObj = obj; for (const step of steps) { try { const migrate = step.migration; migratedObj = migrate(migratedObj); } catch (e) { throw new MigrationError(from, to, e); } } return { value: migratedObj, changed: true, }; } /** @internal */ async migrateAsync(obj, from, to, nextStep) { // Same versions? Return immediately. if (from === to) { return { value: obj, changed: false, }; } // Get steps. let steps = this.tryGetCachedSteps(from, to); if (steps === undefined) { steps = this.computeSteps(from, to, nextStep); this.cacheSteps(from, to, steps); } // Migrate. let migratedObj = obj; for (const step of steps) { try { const migrateAsync = step.migration; migratedObj = await Promise.resolve(migrateAsync(migratedObj)); } catch (e) { throw new MigrationError(from, to, e); } } return { value: migratedObj, changed: true, }; } /** * Computes steps to migrate an object from between two versions. * * @remarks * - Iterate the steps in order and apply their migrations to migrate the object. * - Throws {@linkcode NoMigrationStepsError}. * * @internal */ computeSteps(from, to, nextStep) { const steps = []; // Repeatedly append the next step, starting at version `from`, until (and including) the // step that results in version `to`. // // Assuming versions 1 through 4 are registered, and an object is being migrated from 1 to 4, // the computed steps would be [1to2, 2to3, 3to4]. let step = nextStep.get(from); if (step === undefined) { throw new NoMigrationStepsError(from, to); } while (step !== undefined) { steps.push(step); if (step.to === to) { break; } step = nextStep.get(step.to); } if (steps.length === 0 || steps[steps.length - 1].to !== to) { throw new NoMigrationStepsError(from, to); } return steps; } /** * Gets the previously cached steps to migrate an object between two versions, or `undefined` * if no steps have been cached yet. * * @internal */ tryGetCachedSteps(from, to) { return this.cache.get(from)?.get(to); } /** * Caches steps to migrate an object between two versions. * * @internal */ cacheSteps(from, to, steps) { if (steps.length === 0) { throw new RangeError('There must be at least 1 step to be cached.'); } let toMap = this.cache.get(from); if (toMap === undefined) { toMap = new Map(); this.cache.set(from, toMap); } toMap.set(to, steps); } } exports.MigrationError = MigrationError; exports.Migrator = Migrator; exports.MigratorError = MigratorError; exports.NoMigrationStepsError = NoMigrationStepsError; }));