2026-04-07 17:35:05 +00:00
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 []
2026-04-08 13:36:38 +00:00
* - 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.
2026-04-07 17:35:05 +00:00
* - 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 } ,
) ;
}
2026-04-08 13:36:38 +00:00
// 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 ) ) ;
2026-04-07 17:35:05 +00:00
if ( startIndex === - 1 ) {
throw new SmartMigrationError (
2026-04-08 13:36:38 +00:00
'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 } ` ) } ,
2026-04-07 17:35:05 +00:00
) ;
}
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 ( ' → ' ) ;
}
}