import { ExtendedVersion, VersionRange } from '@start9labs/start-core/exver'
import * as T from '@start9labs/start-core/types'
import {
InitFn,
InitKind,
InitScript,
InitScriptOrFn,
UninitFn,
UninitScript,
UninitScriptOrFn,
} from '@start9labs/start-core/inits'
import { FullProgressTracker } from '@start9labs/start-core/util/FullProgressTracker'
import { Graph, Vertex, once } from '../util'
import { IMPOSSIBLE, MigrationOpts, 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<CurrentVersion extends string>
implements InitScript, UninitScript
{
protected initFn = this.init.bind(this)
protected uninitFn = this.uninit.bind(this)
private readonly graph: () => Graph<
ExtendedVersion | VersionRange,
((opts: MigrationOpts) => Promise<void>) | undefined
>
dump(): string {
return this.graph().dump(metadata => metadata?.toString())
}
private constructor(
readonly current: VersionInfo<CurrentVersion>,
versions: Array<VersionInfo<any>>,
) {
this.graph = once(() => {
const graph = new Graph<
ExtendedVersion | VersionRange,
((opts: MigrationOpts) => Promise<void>) | undefined
>()
const flavorMap: Record<
string,
[
ExtendedVersion,
VersionInfo<any>,
Vertex<
ExtendedVersion | VersionRange,
((opts: MigrationOpts) => Promise<void>) | 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<any>,
Vertex<
ExtendedVersion | VersionRange,
((opts: MigrationOpts) => Promise<void>) | 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<VersionInfo<any>>,
>(options: { current: VersionInfo<CurrentVersion>; 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,
progress,
}: {
effects: T.Effects
from: ExtendedVersion | VersionRange
to: ExtendedVersion | VersionRange
progress?: FullProgressTracker
}): Promise<ExtendedVersion | VersionRange> {
if (overlaps(from, to)) return from
const reportProgress = progress ?? new FullProgressTracker()
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, progress: reportProgress })
}
dataVersion = edge.to.metadata
await setDataVersion(effects, edge.to.metadata)
}
return dataVersion
}
}
throw new Error(
`this service has no migration path from ${from.toString()} to ${to.toString()}, so its data cannot be carried over`,
)
}
* 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
* @param _kind - The init kind (unused; migration is decided by data version)
* @param progress - Tracker the harness threads into each migration step
*/
async init(
effects: T.Effects,
_kind?: InitKind,
progress?: FullProgressTracker,
): Promise<void> {
const from = await getDataVersion(effects)
if (from) {
await this.migrate({
effects,
from,
to: this.currentVersion(),
progress,
})
} 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<void> {
if (target) {
if (isRange(target) && !target.satisfiable()) {
throw new Error(
`this service has no migration path to the version being installed, so its data cannot be carried over`,
)
}
const from = await getDataVersion(effects)
if (from) {
target = await this.migrate({
effects,
from,
to: target,
})
}
}
await setDataVersion(effects, target)
}
}
export type EnsureUniqueId<A, B = A, OtherVersions = never> =
B extends [] ? A :
B extends [VersionInfo<infer Version>, ...infer Rest] ? (
Version extends OtherVersions ? "One or more versions are not unique"[] :
EnsureUniqueId<A, Rest, Version | OtherVersions>
) : "There exists a migration that is not a Migration"[]