247 lines
10 KiB
JavaScript
247 lines
10 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.VersionGraph = void 0;
|
|
exports.getDataVersion = getDataVersion;
|
|
exports.setDataVersion = setDataVersion;
|
|
exports.overlaps = overlaps;
|
|
const exver_1 = require("../../../base/lib/exver");
|
|
const util_1 = require("../util");
|
|
const VersionInfo_1 = require("./VersionInfo");
|
|
/**
|
|
* Read the current data version from the effects system.
|
|
* @param effects - The effects context
|
|
* @returns The parsed ExtendedVersion or VersionRange, or null if no version is set
|
|
*/
|
|
async function getDataVersion(effects) {
|
|
const versionStr = await effects.getDataVersion();
|
|
if (!versionStr)
|
|
return null;
|
|
try {
|
|
return exver_1.ExtendedVersion.parse(versionStr);
|
|
}
|
|
catch (_) {
|
|
return exver_1.VersionRange.parse(versionStr);
|
|
}
|
|
}
|
|
/**
|
|
* Persist a data version to the effects system.
|
|
* @param effects - The effects context
|
|
* @param version - The version to set, or null to clear it
|
|
*/
|
|
async function setDataVersion(effects, version) {
|
|
return effects.setDataVersion({ version: version?.toString() || null });
|
|
}
|
|
function isExver(v) {
|
|
return 'satisfies' in v;
|
|
}
|
|
function isRange(v) {
|
|
return 'satisfiedBy' in v;
|
|
}
|
|
/**
|
|
* Check whether two version specifiers overlap (i.e. share at least one common version).
|
|
* Works with any combination of ExtendedVersion and VersionRange.
|
|
*
|
|
* @param a - First version or range
|
|
* @param b - Second version or range
|
|
* @returns True if the two specifiers overlap
|
|
*/
|
|
function overlaps(a, b) {
|
|
return ((isRange(a) && isRange(b) && a.intersects(b)) ||
|
|
(isRange(a) && isExver(b) && a.satisfiedBy(b)) ||
|
|
(isExver(a) && isRange(b) && a.satisfies(b)) ||
|
|
(isExver(a) && isExver(b) && a.equals(b)));
|
|
}
|
|
/**
|
|
* A directed graph of service versions and their migration paths.
|
|
*
|
|
* Builds a graph from {@link VersionInfo} definitions, then uses shortest-path
|
|
* search to find and execute migration sequences between any two versions.
|
|
* Implements both {@link InitScript} (for install/update migrations) and
|
|
* {@link UninitScript} (for uninstall/downgrade migrations).
|
|
*
|
|
* @typeParam CurrentVersion - The string literal type of the current service version
|
|
*/
|
|
class VersionGraph {
|
|
/** Dump the version graph as a human-readable string for debugging */
|
|
dump() {
|
|
return this.graph().dump((metadata) => metadata?.toString());
|
|
}
|
|
constructor(current, versions) {
|
|
this.current = current;
|
|
this.initFn = this.init.bind(this);
|
|
this.uninitFn = this.uninit.bind(this);
|
|
this.currentVersion = (0, util_1.once)(() => exver_1.ExtendedVersion.parse(this.current.options.version));
|
|
/**
|
|
* Compute the version range from which the current version can be reached via migration.
|
|
* Uses reverse breadth-first search from the current version vertex.
|
|
*/
|
|
this.canMigrateFrom = (0, util_1.once)(() => Array.from(this.graph().reverseBreadthFirstSearch((v) => overlaps(v.metadata, this.currentVersion())))
|
|
.reduce((acc, x) => acc.or(isRange(x.metadata)
|
|
? x.metadata
|
|
: exver_1.VersionRange.anchor('=', x.metadata)), exver_1.VersionRange.none())
|
|
.normalize());
|
|
/**
|
|
* Compute the version range that the current version can migrate to.
|
|
* Uses forward breadth-first search from the current version vertex.
|
|
*/
|
|
this.canMigrateTo = (0, util_1.once)(() => Array.from(this.graph().breadthFirstSearch((v) => overlaps(v.metadata, this.currentVersion())))
|
|
.reduce((acc, x) => acc.or(isRange(x.metadata)
|
|
? x.metadata
|
|
: exver_1.VersionRange.anchor('=', x.metadata)), exver_1.VersionRange.none())
|
|
.normalize());
|
|
this.graph = (0, util_1.once)(() => {
|
|
const graph = new util_1.Graph();
|
|
const flavorMap = {};
|
|
for (let version of [current, ...versions]) {
|
|
const v = exver_1.ExtendedVersion.parse(version.options.version);
|
|
const vertex = graph.addVertex(v, [], []);
|
|
const flavor = v.flavor || '';
|
|
if (!flavorMap[flavor]) {
|
|
flavorMap[flavor] = [];
|
|
}
|
|
flavorMap[flavor].push([v, version, vertex]);
|
|
}
|
|
for (let flavor in flavorMap) {
|
|
flavorMap[flavor].sort((a, b) => a[0].compareForSort(b[0]));
|
|
let prev = undefined;
|
|
for (let [v, version, vertex] of flavorMap[flavor]) {
|
|
if (version.options.migrations.up !== VersionInfo_1.IMPOSSIBLE) {
|
|
let range;
|
|
if (prev) {
|
|
graph.addEdge(version.options.migrations.up, prev[2], vertex);
|
|
range = exver_1.VersionRange.anchor('>=', prev[0]).and(exver_1.VersionRange.anchor('<', v));
|
|
}
|
|
else {
|
|
range = exver_1.VersionRange.anchor('<', v);
|
|
}
|
|
const vRange = graph.addVertex(range, [], []);
|
|
graph.addEdge(version.options.migrations.up, vRange, vertex);
|
|
}
|
|
if (version.options.migrations.down !== VersionInfo_1.IMPOSSIBLE) {
|
|
let range;
|
|
if (prev) {
|
|
graph.addEdge(version.options.migrations.down, vertex, prev[2]);
|
|
range = exver_1.VersionRange.anchor('>=', prev[0]).and(exver_1.VersionRange.anchor('<', v));
|
|
}
|
|
else {
|
|
range = exver_1.VersionRange.anchor('<', v);
|
|
}
|
|
const vRange = graph.addVertex(range, [], []);
|
|
graph.addEdge(version.options.migrations.down, vertex, vRange);
|
|
}
|
|
if (version.options.migrations.other) {
|
|
for (let rangeStr in version.options.migrations.other) {
|
|
const range = exver_1.VersionRange.parse(rangeStr);
|
|
const vRange = graph.addVertex(range, [], []);
|
|
const migration = version.options.migrations.other[rangeStr];
|
|
if (migration.up)
|
|
graph.addEdge(migration.up, vRange, vertex);
|
|
if (migration.down)
|
|
graph.addEdge(migration.down, vertex, vRange);
|
|
for (let matching of graph.findVertex((v) => isExver(v.metadata) && v.metadata.satisfies(range))) {
|
|
if (migration.up)
|
|
graph.addEdge(migration.up, matching, vertex);
|
|
if (migration.down)
|
|
graph.addEdge(migration.down, vertex, matching);
|
|
}
|
|
}
|
|
}
|
|
prev = [v, version, vertex];
|
|
}
|
|
}
|
|
return graph;
|
|
});
|
|
}
|
|
/**
|
|
* Each exported `VersionInfo.of()` should be imported and provided as an argument to this function.
|
|
*
|
|
* ** The current version must be the FIRST argument. **
|
|
*/
|
|
static of(options) {
|
|
return new VersionGraph(options.current, options.other);
|
|
}
|
|
/**
|
|
* Execute the shortest migration path between two versions.
|
|
*
|
|
* Finds the shortest path in the version graph from `from` to `to`,
|
|
* executes each migration step in order, and updates the data version after each step.
|
|
*
|
|
* @param options.effects - The effects context
|
|
* @param options.from - The source version or range
|
|
* @param options.to - The target version or range
|
|
* @returns The final data version after migration
|
|
* @throws If no migration path exists between the two versions
|
|
*/
|
|
async migrate({ effects, from, to, }) {
|
|
if (overlaps(from, to))
|
|
return from;
|
|
const graph = this.graph();
|
|
if (from && to) {
|
|
const path = graph.shortestPath((v) => overlaps(v.metadata, from), (v) => overlaps(v.metadata, to));
|
|
if (path) {
|
|
console.log(`Migrating ${path.reduce(({ acc, prev }, x) => ({
|
|
acc: acc +
|
|
(prev && prev != x.from.metadata.toString()
|
|
? ` (as ${prev})`
|
|
: '') +
|
|
' -> ' +
|
|
x.to.metadata.toString(),
|
|
prev: x.to.metadata.toString(),
|
|
}), { acc: from.toString(), prev: null }).acc}`);
|
|
let dataVersion = from;
|
|
for (let edge of path) {
|
|
if (edge.metadata) {
|
|
await edge.metadata({ effects });
|
|
}
|
|
dataVersion = edge.to.metadata;
|
|
await setDataVersion(effects, edge.to.metadata);
|
|
}
|
|
return dataVersion;
|
|
}
|
|
}
|
|
throw new Error(`cannot migrate from ${from.toString()} to ${to.toString()}`);
|
|
}
|
|
/**
|
|
* InitScript implementation: migrate from the stored data version to the current version.
|
|
* If no data version exists (fresh install), sets it to the current version.
|
|
* @param effects - The effects context
|
|
*/
|
|
async init(effects) {
|
|
const from = await getDataVersion(effects);
|
|
if (from) {
|
|
await this.migrate({
|
|
effects,
|
|
from,
|
|
to: this.currentVersion(),
|
|
});
|
|
}
|
|
else {
|
|
await effects.setDataVersion({ version: this.current.options.version });
|
|
}
|
|
}
|
|
/**
|
|
* UninitScript implementation: migrate from the current data version to the target version.
|
|
* Used during uninstall or downgrade to prepare data for the target version.
|
|
*
|
|
* @param effects - The effects context
|
|
* @param target - The target version to migrate to, or null to clear the data version
|
|
*/
|
|
async uninit(effects, target) {
|
|
if (target) {
|
|
if (isRange(target) && !target.satisfiable()) {
|
|
return;
|
|
}
|
|
const from = await getDataVersion(effects);
|
|
if (from) {
|
|
target = await this.migrate({
|
|
effects,
|
|
from,
|
|
to: target,
|
|
});
|
|
}
|
|
}
|
|
await setDataVersion(effects, target);
|
|
}
|
|
}
|
|
exports.VersionGraph = VersionGraph;
|
|
//# sourceMappingURL=VersionGraph.js.map
|