object-migrations
Version:
Linear, in-memory migrations for versioned objects
290 lines (285 loc) • 10.3 kB
JavaScript
(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;
}));