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 first step whose `toVersion > currentVersion` * (i.e. the first step that hasn't been fully applied yet) and returns * it plus all subsequent steps up to (and including) the one whose * `toVersion === targetVersion`. * - Supports two resume modes: * 1. **Exact resume**: currentVersion === step.fromVersion — the normal * case, where the ledger sits exactly at a step's starting point. * 2. **Skip-forward resume**: currentVersion > step.fromVersion but * currentVersion < step.toVersion — the orphan case, where the * ledger was stamped to an intermediate version that no registered * step starts at (e.g. fresh installs using * `freshInstallVersion: targetVersion` across releases that didn't * add migrations). Skip-forward assumes step handlers are * idempotent (safe to re-run against data already partially in the * target shape). This is the documented contract for all step * handlers. * - If a step's `toVersion` overshoots `targetVersion` → throws * TARGET_NOT_REACHABLE. * - If no step can advance `currentVersion` toward `targetVersion` * (currentVersion is past the end of the chain) → 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 }, ); } // Find the first step that hasn't been fully applied yet. // A step is "not yet applied" iff its toVersion > currentVersion. const startIndex = steps.findIndex((s) => this.greaterThan(s.toVersion, currentVersion)); if (startIndex === -1) { throw new SmartMigrationError( 'TARGET_NOT_REACHABLE', `Current data version "${currentVersion}" is past the end of the registered chain, but target "${targetVersion}" has not been reached. No step can advance. Registered chain: ${this.describeChain(steps)}`, { currentVersion, targetVersion, 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(' → '); } }