import { ExtendedVersion, VersionRange } from '../../../base/lib/exver' import * as T from '../../../base/lib/types' import { InitFn, InitKind, InitScript, InitScriptOrFn, UninitFn, UninitScript, UninitScriptOrFn, } from '../../../base/lib/inits' import { Graph, Vertex, once } from '../util' import { IMPOSSIBLE, VersionInfo } from './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 */ export async function getDataVersion(effects: T.Effects) { const versionStr = await effects.getDataVersion() if (!versionStr) return null try { return ExtendedVersion.parse(versionStr) } catch (_) { return 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 */ export async function setDataVersion( effects: T.Effects, version: ExtendedVersion | VersionRange | null, ) { return effects.setDataVersion({ version: version?.toString() || null }) } function isExver(v: ExtendedVersion | VersionRange): v is ExtendedVersion { return 'satisfies' in v } function isRange(v: ExtendedVersion | VersionRange): v is VersionRange { 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 */ export function overlaps( a: ExtendedVersion | VersionRange, b: ExtendedVersion | VersionRange, ) { 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 */ export class VersionGraph implements InitScript, UninitScript { protected initFn = this.init.bind(this) protected uninitFn = this.uninit.bind(this) private readonly graph: () => Graph< ExtendedVersion | VersionRange, ((opts: { effects: T.Effects }) => Promise) | undefined > /** Dump the version graph as a human-readable string for debugging */ dump(): string { return this.graph().dump((metadata) => metadata?.toString()) } private constructor( readonly current: VersionInfo, versions: Array>, ) { this.graph = once(() => { const graph = new Graph< ExtendedVersion | VersionRange, ((opts: { effects: T.Effects }) => Promise) | undefined >() const flavorMap: Record< string, [ ExtendedVersion, VersionInfo, Vertex< ExtendedVersion | VersionRange, ((opts: { effects: T.Effects }) => Promise) | undefined >, ][] > = {} for (let version of [current, ...versions]) { const v = 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: | [ ExtendedVersion, VersionInfo, Vertex< ExtendedVersion | VersionRange, ((opts: { effects: T.Effects }) => Promise) | undefined >, ] | undefined = undefined for (let [v, version, vertex] of flavorMap[flavor]) { if (version.options.migrations.up !== IMPOSSIBLE) { let range if (prev) { graph.addEdge(version.options.migrations.up, prev[2], vertex) range = VersionRange.anchor('>=', prev[0]).and( VersionRange.anchor('<', v), ) } else { range = VersionRange.anchor('<', v) } const vRange = graph.addVertex(range, [], []) graph.addEdge(version.options.migrations.up, vRange, vertex) } if (version.options.migrations.down !== IMPOSSIBLE) { let range if (prev) { graph.addEdge(version.options.migrations.down, vertex, prev[2]) range = VersionRange.anchor('>=', prev[0]).and( VersionRange.anchor('<', v), ) } else { range = 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 = 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 }) } currentVersion = once(() => ExtendedVersion.parse(this.current.options.version), ) /** * 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< CurrentVersion extends string, OtherVersions extends Array>, >(options: { current: VersionInfo; other: OtherVersions }) { 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, }: { effects: T.Effects from: ExtendedVersion | VersionRange to: ExtendedVersion | VersionRange }): Promise { 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: string; prev: string | null }>( ({ 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()}`, ) } /** * Compute the version range from which the current version can be reached via migration. * Uses reverse breadth-first search from the current version vertex. */ canMigrateFrom = once(() => Array.from( this.graph().reverseBreadthFirstSearch((v) => overlaps(v.metadata, this.currentVersion()), ), ) .reduce( (acc, x) => acc.or( isRange(x.metadata) ? x.metadata : VersionRange.anchor('=', x.metadata), ), VersionRange.none(), ) .normalize(), ) /** * Compute the version range that the current version can migrate to. * Uses forward breadth-first search from the current version vertex. */ canMigrateTo = once(() => Array.from( this.graph().breadthFirstSearch((v) => overlaps(v.metadata, this.currentVersion()), ), ) .reduce( (acc, x) => acc.or( isRange(x.metadata) ? x.metadata : VersionRange.anchor('=', x.metadata), ), VersionRange.none(), ) .normalize(), ) /** * 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: T.Effects): Promise { 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: T.Effects, target: VersionRange | ExtendedVersion | null, ): Promise { if (target) { const from = await getDataVersion(effects) if (from) { target = await this.migrate({ effects, from, to: target, }) } } await setDataVersion(effects, target) } } // prettier-ignore export type EnsureUniqueId = B extends [] ? A : B extends [VersionInfo, ...infer Rest] ? ( Version extends OtherVersions ? "One or more versions are not unique"[] : EnsureUniqueId ) : "There exists a migration that is not a Migration"[]