177 lines
6.7 KiB
TypeScript
177 lines
6.7 KiB
TypeScript
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(' → ');
|
|
}
|
|
}
|