"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