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; constructor(code: string, message: string, details?: Record) { 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(); 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(' → '); } }