feat(versionresolver): support skip-forward resume for orphan ledger versions
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmigration',
|
||||
version: '1.1.1',
|
||||
version: '1.2.0',
|
||||
description: 'Unified migration runner for MongoDB (smartdata) and S3 (smartbucket) — designed to be invoked at SaaS app startup, with semver-based version tracking, sequential step execution, idempotent re-runs, and per-step resumable checkpoints.'
|
||||
}
|
||||
|
||||
@@ -244,6 +244,22 @@ export class SmartMigration {
|
||||
const stepStart = Date.now();
|
||||
let entry: IMigrationLedgerEntry;
|
||||
try {
|
||||
// Detect skip-forward resume: the running ledger cursor is past
|
||||
// this step's fromVersion but hasn't yet reached its toVersion.
|
||||
// The step handler is being run against data that may already be
|
||||
// partially in the target shape — handlers must be idempotent.
|
||||
const isSkipForward = VersionResolver.greaterThan(
|
||||
currentVersion,
|
||||
step.fromVersion,
|
||||
);
|
||||
if (isSkipForward) {
|
||||
this.log.log(
|
||||
'info',
|
||||
`smartmigration: step "${step.id}" running in skip-forward mode ` +
|
||||
`(ledger at "${currentVersion}", step starts at "${step.fromVersion}"). ` +
|
||||
`Step handler must be idempotent.`,
|
||||
);
|
||||
}
|
||||
this.log.log(
|
||||
'info',
|
||||
`smartmigration: running step "${step.id}" (${step.fromVersion} → ${step.toVersion})`,
|
||||
@@ -273,6 +289,8 @@ export class SmartMigration {
|
||||
data.steps[step.id] = entry;
|
||||
data.currentVersion = step.toVersion;
|
||||
await ledger.write(data);
|
||||
// Advance the running cursor used by skip-forward detection.
|
||||
currentVersion = step.toVersion;
|
||||
applied.push({ ...entry });
|
||||
this.log.log(
|
||||
'info',
|
||||
|
||||
@@ -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}`) },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user