Files
smartmigration/ts/classes.versionresolver.ts

177 lines
6.7 KiB
TypeScript
Raw Normal View History

import * as plugins from './plugins.js';
import type { IMigrationStepDefinition } from './interfaces.js';
/**
* SmartMigrationError thrown for all user-visible failures (validation,
* planning, lock acquisition, ledger I/O). The exact error site is encoded
* in `code` so callers can branch on it.
*/
export class SmartMigrationError extends Error {
public readonly code: string;
public readonly details?: Record<string, unknown>;
constructor(code: string, message: string, details?: Record<string, unknown>) {
super(message);
this.name = 'SmartMigrationError';
this.code = code;
this.details = details;
}
}
/**
* Wraps `@push.rocks/smartversion` to provide simple comparison helpers.
* All public methods accept and return plain semver strings.
*/
export class VersionResolver {
/** True iff `a` and `b` represent the same semver version. */
public static equals(a: string, b: string): boolean {
// Uses SmartVersion.equalsString (added in @push.rocks/smartversion 3.1.0)
// which delegates to semver.eq under the hood.
return new plugins.smartversion.SmartVersion(a).equalsString(b);
}
/** True iff `a < b` semver-wise. */
public static lessThan(a: string, b: string): boolean {
return new plugins.smartversion.SmartVersion(a).lessThanString(b);
}
/** True iff `a > b` semver-wise. */
public static greaterThan(a: string, b: string): boolean {
return new plugins.smartversion.SmartVersion(a).greaterThanString(b);
}
/** Throws if `version` is not a valid semver string. */
public static assertValid(version: string, label: string): void {
try {
// Constructing SmartVersion throws if the string is invalid.
// eslint-disable-next-line no-new
new plugins.smartversion.SmartVersion(version);
} catch (err) {
throw new SmartMigrationError(
'INVALID_VERSION',
`${label} is not a valid semver string: "${version}"`,
{ version, label, cause: (err as Error).message },
);
}
}
/**
* Validate the chain of registered steps:
* - ids are unique
* - from/to versions are valid semver
* - each step's from < to
* - each step's `from` strictly equals the previous step's `to`
*/
public static validateChain(steps: IMigrationStepDefinition[]): void {
const seenIds = new Set<string>();
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (seenIds.has(step.id)) {
throw new SmartMigrationError(
'DUPLICATE_STEP_ID',
`Migration step id "${step.id}" is registered more than once.`,
{ stepId: step.id },
);
}
seenIds.add(step.id);
this.assertValid(step.fromVersion, `step "${step.id}" fromVersion`);
this.assertValid(step.toVersion, `step "${step.id}" toVersion`);
if (!this.lessThan(step.fromVersion, step.toVersion)) {
throw new SmartMigrationError(
'NON_INCREASING_STEP',
`Migration step "${step.id}" has fromVersion "${step.fromVersion}" >= toVersion "${step.toVersion}". Steps must strictly upgrade.`,
{ stepId: step.id, fromVersion: step.fromVersion, toVersion: step.toVersion },
);
}
if (i > 0) {
const prev = steps[i - 1];
if (!this.equals(prev.toVersion, step.fromVersion)) {
throw new SmartMigrationError(
'CHAIN_GAP',
`Migration step "${step.id}" starts at "${step.fromVersion}", but the previous step "${prev.id}" ended at "${prev.toVersion}". Steps must form a contiguous chain in registration order.`,
{
prevStepId: prev.id,
prevToVersion: prev.toVersion,
stepId: step.id,
fromVersion: step.fromVersion,
},
);
}
}
}
}
/**
* Compute which subset of `steps` should be executed to advance the data
* from `currentVersion` to `targetVersion`.
*
* Behavior:
* - If currentVersion === targetVersion returns []
* - Otherwise, finds the step whose `fromVersion === currentVersion`
* and returns it plus all subsequent steps up to (and including)
* the one whose `toVersion === targetVersion`.
* - If no step starts at currentVersion throws CHAIN_NOT_AT_CURRENT.
* - If walking past targetVersion never matches throws TARGET_NOT_REACHABLE.
* - If currentVersion > targetVersion throws DOWNGRADE_NOT_SUPPORTED.
*/
public static computePlan(
steps: IMigrationStepDefinition[],
currentVersion: string,
targetVersion: string,
): IMigrationStepDefinition[] {
this.assertValid(currentVersion, 'currentVersion');
this.assertValid(targetVersion, 'targetVersion');
if (this.equals(currentVersion, targetVersion)) {
return [];
}
if (this.greaterThan(currentVersion, targetVersion)) {
throw new SmartMigrationError(
'DOWNGRADE_NOT_SUPPORTED',
`Current data version "${currentVersion}" is ahead of target version "${targetVersion}". smartmigration v1 does not support rollback.`,
{ currentVersion, targetVersion },
);
}
const startIndex = steps.findIndex((s) => this.equals(s.fromVersion, currentVersion));
if (startIndex === -1) {
throw new SmartMigrationError(
'CHAIN_NOT_AT_CURRENT',
`No registered migration step starts at the current data version "${currentVersion}". Registered chain: ${this.describeChain(steps)}`,
{ currentVersion, registeredChain: steps.map((s) => `${s.fromVersion}${s.toVersion}`) },
);
}
const plan: IMigrationStepDefinition[] = [];
for (let i = startIndex; i < steps.length; i++) {
const step = steps[i];
plan.push(step);
if (this.equals(step.toVersion, targetVersion)) {
return plan;
}
if (this.greaterThan(step.toVersion, targetVersion)) {
throw new SmartMigrationError(
'TARGET_NOT_REACHABLE',
`Migration step "${step.id}" upgrades to "${step.toVersion}", which overshoots target "${targetVersion}". No exact-match step ends at the target.`,
{ stepId: step.id, stepTo: step.toVersion, targetVersion },
);
}
}
throw new SmartMigrationError(
'TARGET_NOT_REACHABLE',
`Walked the entire registered chain from "${currentVersion}" but never reached target "${targetVersion}". Registered chain: ${this.describeChain(steps)}`,
{ currentVersion, targetVersion, registeredChain: steps.map((s) => `${s.fromVersion}${s.toVersion}`) },
);
}
private static describeChain(steps: IMigrationStepDefinition[]): string {
if (steps.length === 0) return '(empty)';
return steps.map((s) => `${s.id}(${s.fromVersion}${s.toVersion})`).join(' → ');
}
}