feat(versionresolver): support skip-forward resume for orphan ledger versions

This commit is contained in:
2026-04-08 13:36:38 +00:00
parent 5ffeeefc7a
commit 4e3fd845d4
6 changed files with 104 additions and 15 deletions

View File

@@ -110,11 +110,27 @@ export class VersionResolver {
*
* 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.
* - 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(
@@ -137,12 +153,14 @@ export class VersionResolver {
);
}
const startIndex = steps.findIndex((s) => this.equals(s.fromVersion, currentVersion));
// 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(
'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}`) },
'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}`) },
);
}