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 ( ' → ' ) ;
}
}