feat(planner): add bridge target version strategy for auto-stamping ledger to app versions

This commit is contained in:
2026-05-19 21:44:57 +00:00
parent c04a094723
commit b3078029e3
8 changed files with 250 additions and 3 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## Pending
### Features
- add bridge target version strategy for auto-stamping ledger to app versions (planner)
- introduces targetVersionStrategy with strict and bridge modes
- appends an internal no-op bridge step when the migration chain ends before targetVersion
- validates invalid targetVersionStrategy values with a dedicated error
- exports the new strategy type and documents bridge behavior
- adds mongo run and planning tests covering bridge execution, read-only planning, and future migrations after bridging
## 2026-04-30 - 1.3.1 - fix(build)
tighten TypeScript compiler settings and refresh package metadata and dependency versions
+26
View File
@@ -31,6 +31,7 @@ Report bugs and security issues at [community.foss.global](https://community.fos
| **Resumable** | Mark a step `.resumable()` and it gets `ctx.checkpoint.read/write/clear` for restartable bulk operations; successful runs clear the step checkpoint automatically |
| **Lockable** | Mongo-backed lock uses atomic updates plus TTL heartbeats to serialize concurrent SaaS instances — safe for rolling deploys |
| **Fresh-install fast path** | Configure `freshInstallVersion` to skip migrations on a brand-new database |
| **Target bridging** | Optionally bridge schema/data version to the app version without hand-written no-op steps |
| **Dry-run** | `dryRun: true` or `.plan()` returns the execution plan without writing anything |
| **Structured errors** | All failures throw `SmartMigrationError` with a stable `code` field for branching |
@@ -131,6 +132,29 @@ When `run()` reads the ledger and finds a current version, it computes a plan: t
If no step's `toVersion` is greater than `currentVersion` (the ledger is past the end of the chain), the runner throws `TARGET_NOT_REACHABLE`.
#### Target version strategies
By default, `smartmigration` is strict: the registered chain must contain a real step whose `toVersion` exactly matches `targetVersion`. This is best when you maintain an explicit schema/data version.
If your app uses its package version as `targetVersion`, releases without schema changes can leave the migration chain behind the app version. In that case, enable bridge mode:
```ts
const migration = new SmartMigration({
targetVersion: commitinfo.version,
db,
freshInstallVersion: commitinfo.version,
targetVersionStrategy: 'bridge',
});
```
Bridge mode appends an internal no-op step from the last registered migration version to `targetVersion` when the chain ends before the target. It stamps the ledger to the app version without requiring hand-written no-op migrations for every release.
Important behavior:
- Existing real migrations still run first.
- If the ledger is already past the last registered migration but below `targetVersion`, only the internal bridge runs.
- Future real migrations still work because skip-forward resume runs the first real step whose `toVersion` is above the ledger version.
- Downgrades and steps that overshoot the target still fail.
### The ledger
The ledger is the source of truth for "what data version are we at, what steps have been applied, who holds the lock right now." It is persisted in one of two backends:
@@ -300,6 +324,7 @@ const result = await m.run(); // returns plan, doesn't write
| `ledgerName` | `string` | `"smartmigration"` | Logical name; lets multiple migrations coexist on the same db/bucket |
| `ledgerBackend` | `'mongo'` \| `'s3'` | mongo if db, else s3 | Where to persist the ledger |
| `freshInstallVersion` | `string` | undefined | When the resource is empty, jump straight to this version |
| `targetVersionStrategy` | `'strict'` \| `'bridge'` | `'strict'` | Require the chain to end exactly at targetVersion, or auto-bridge to it |
| `lockWaitMs` | `number` | `60_000` | How long to wait for a stale lock from another instance |
| `lockTtlMs` | `number` | `600_000` | How long this instance's own lock auto-expires after |
| `dryRun` | `boolean` | `false` | If true, `run()` returns the plan without executing or locking |
@@ -314,6 +339,7 @@ The constructor throws `SmartMigrationError` with one of these `code`s on bad in
- `INVALID_LEDGER_NAME``ledgerName` is blank or not a string
- `INVALID_LOCK_WAIT_MS``lockWaitMs` is not an integer `>= 0`
- `INVALID_LOCK_TTL_MS``lockTtlMs` is not an integer `>= 1`
- `INVALID_TARGET_VERSION_STRATEGY``targetVersionStrategy` is not `strict` or `bridge`
### `migration.step(id: string).from(v).to(v).[description(t)].[resumable()].up(handler)`
+15
View File
@@ -145,6 +145,21 @@ tap.test('constructor: rejects s3 backend without bucket', async () => {
expect(caught!.code).toEqual('LEDGER_BACKEND_MISMATCH');
});
tap.test('constructor: rejects invalid targetVersionStrategy', async () => {
let caught: SmartMigrationError | undefined;
try {
new SmartMigration({
targetVersion: '1.0.0',
db: {} as any,
targetVersionStrategy: 'loose' as any,
});
} catch (err) {
caught = err as SmartMigrationError;
}
expect(caught).toBeInstanceOf(SmartMigrationError);
expect(caught!.code).toEqual('INVALID_TARGET_VERSION_STRATEGY');
});
tap.test('step(): rejects empty id', async () => {
const m = new SmartMigration({ targetVersion: '1.0.0', db: {} as any });
expect(() => m.step('')).toThrow();
+128
View File
@@ -8,6 +8,24 @@ import { SmartMigration, SmartMigrationError } from '../ts/index.js';
let db: smartdata.SmartdataDb;
let cleanup: () => Promise<void>;
async function setLedgerVersion(ledgerName: string, currentVersion: string): Promise<void> {
await db.mongoDb.collection('SmartdataEasyStore').updateOne(
{ nameId: `smartmigration:${ledgerName}` },
{
$set: {
nameId: `smartmigration:${ledgerName}`,
data: {
currentVersion,
steps: {},
lock: { holder: null, acquiredAt: null, expiresAt: null },
checkpoints: {},
},
},
},
{ upsert: true },
);
}
tap.test('setup: connect shared db', async () => {
const r = await makeTestDb('run_mongo');
db = r.db;
@@ -153,6 +171,116 @@ tap.test('run: lock heartbeat prevents concurrent execution of a slow step', asy
expect(maxActiveSteps).toEqual(1);
});
tap.test('run: strict target strategy rejects target beyond registered chain', async () => {
await setLedgerVersion('strict_target_gap', '2.0.0');
const m = new SmartMigration({ targetVersion: '3.0.0', db, ledgerName: 'strict_target_gap' });
m.step('a').from('1.0.0').to('2.0.0').up(async () => {});
let caught: SmartMigrationError | undefined;
try {
await m.run();
} catch (err) {
caught = err as SmartMigrationError;
}
expect(caught).toBeInstanceOf(SmartMigrationError);
expect(caught!.code).toEqual('TARGET_NOT_REACHABLE');
});
tap.test('run: bridge target strategy stamps from past chain end to target', async () => {
await setLedgerVersion('bridge_from_past_chain_end', '2.5.0');
let realStepCalled = false;
const m = new SmartMigration({
targetVersion: '3.0.0',
db,
ledgerName: 'bridge_from_past_chain_end',
targetVersionStrategy: 'bridge',
});
m.step('a').from('1.0.0').to('2.0.0').up(async () => { realStepCalled = true; });
const r = await m.run();
expect(realStepCalled).toBeFalse();
expect(r.stepsApplied).toHaveLength(1);
expect(r.stepsApplied[0].id).toEqual('smartmigration-auto-bridge-to-3.0.0');
expect(r.stepsApplied[0].fromVersion).toEqual('2.0.0');
expect(r.currentVersionAfter).toEqual('3.0.0');
expect(await m.getCurrentVersion()).toEqual('3.0.0');
});
tap.test('run: bridge target strategy runs real migrations before bridge', async () => {
await setLedgerVersion('bridge_after_real_steps', '1.0.0');
const log: string[] = [];
const m = new SmartMigration({
targetVersion: '3.0.0',
db,
ledgerName: 'bridge_after_real_steps',
targetVersionStrategy: 'bridge',
});
m.step('a').from('1.0.0').to('2.0.0').up(async () => { log.push('a'); });
const r = await m.run();
expect(log).toEqual(['a']);
expect(r.stepsApplied.map((step) => step.id)).toEqual([
'a',
'smartmigration-auto-bridge-to-3.0.0',
]);
expect(r.currentVersionAfter).toEqual('3.0.0');
});
tap.test('plan: bridge target strategy is read-only', async () => {
await setLedgerVersion('bridge_plan_readonly', '2.5.0');
const m = new SmartMigration({
targetVersion: '3.0.0',
db,
ledgerName: 'bridge_plan_readonly',
targetVersionStrategy: 'bridge',
});
m.step('a').from('1.0.0').to('2.0.0').up(async () => {});
const r = await m.plan();
expect(r.stepsSkipped).toHaveLength(1);
expect(r.stepsSkipped[0].id).toEqual('smartmigration-auto-bridge-to-3.0.0');
expect(r.currentVersionAfter).toEqual('3.0.0');
expect(await m.getCurrentVersion()).toEqual('2.5.0');
});
tap.test('run: future real migration applies after a bridged version stamp', async () => {
await setLedgerVersion('bridge_future_migration', '2.5.0');
const bridgeRunner = new SmartMigration({
targetVersion: '3.0.0',
db,
ledgerName: 'bridge_future_migration',
targetVersionStrategy: 'bridge',
});
bridgeRunner.step('a').from('1.0.0').to('2.0.0').up(async () => {});
await bridgeRunner.run();
let futureStepCalled = false;
const futureRunner = new SmartMigration({
targetVersion: '4.0.0',
db,
ledgerName: 'bridge_future_migration',
targetVersionStrategy: 'bridge',
});
futureRunner
.step('a').from('1.0.0').to('2.0.0').up(async () => {})
.step('future').from('2.0.0').to('4.0.0').up(async () => { futureStepCalled = true; });
const r = await futureRunner.run();
expect(futureStepCalled).toBeTrue();
expect(r.stepsApplied.map((step) => step.id)).toEqual(['future']);
expect(r.currentVersionAfter).toEqual('4.0.0');
});
tap.test('cleanup: close shared db', async () => {
await cleanup();
});
+51 -1
View File
@@ -23,6 +23,7 @@ const DEFAULT_LOCK_WAIT_MS = 60_000;
const DEFAULT_LOCK_TTL_MS = 600_000;
const LOCK_POLL_INTERVAL_MS = 500;
const MIN_LOCK_HEARTBEAT_MS = 1;
const AUTO_BRIDGE_STEP_ID_PREFIX = 'smartmigration-auto-bridge-to';
interface IResolvedLedgerState {
currentVersionBefore: string | null;
@@ -120,6 +121,18 @@ export class SmartMigration {
});
}
if (
options.targetVersionStrategy !== undefined &&
options.targetVersionStrategy !== 'strict' &&
options.targetVersionStrategy !== 'bridge'
) {
throw new SmartMigrationError(
'INVALID_TARGET_VERSION_STRATEGY',
'targetVersionStrategy must be "strict" or "bridge" when provided.',
{ targetVersionStrategy: options.targetVersionStrategy },
);
}
this.settings = {
targetVersion: options.targetVersion,
db: options.db,
@@ -130,6 +143,7 @@ export class SmartMigration {
lockWaitMs: options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS,
lockTtlMs: options.lockTtlMs ?? DEFAULT_LOCK_TTL_MS,
dryRun: options.dryRun ?? false,
targetVersionStrategy: options.targetVersionStrategy ?? 'strict',
logger: options.logger,
};
@@ -443,13 +457,14 @@ export class SmartMigration {
effectiveCurrentVersion = data.currentVersion;
}
const planningSteps = this.getPlanningSteps(effectiveCurrentVersion);
const plannedSteps = VersionResolver.equals(
effectiveCurrentVersion,
this.settings.targetVersion,
)
? []
: VersionResolver.computePlan(
this.steps,
planningSteps,
effectiveCurrentVersion,
this.settings.targetVersion,
);
@@ -468,6 +483,41 @@ export class SmartMigration {
};
}
private getPlanningSteps(effectiveCurrentVersion: string): IMigrationStepDefinition[] {
if (this.settings.targetVersionStrategy === 'strict') {
return this.steps;
}
const targetVersion = this.settings.targetVersion;
if (this.steps.length === 0) {
return VersionResolver.lessThan(effectiveCurrentVersion, targetVersion)
? [this.createAutoBridgeStep(effectiveCurrentVersion, targetVersion)]
: [];
}
const lastStep = this.steps[this.steps.length - 1];
if (!VersionResolver.lessThan(lastStep.toVersion, targetVersion)) {
return this.steps;
}
return [
...this.steps,
this.createAutoBridgeStep(lastStep.toVersion, targetVersion),
];
}
private createAutoBridgeStep(fromVersion: string, toVersion: string): IMigrationStepDefinition {
return {
id: `${AUTO_BRIDGE_STEP_ID_PREFIX}-${toVersion}`,
fromVersion,
toVersion,
description: `Auto bridge migration ledger to target version ${toVersion}`,
isResumable: false,
isVirtual: true,
handler: async () => {},
};
}
private async ensureLedger(): Promise<Ledger> {
if (this.ledger) return this.ledger;
const ledgerName = this.settings.ledgerName;
+1 -1
View File
@@ -18,4 +18,4 @@ export type {
ISmartMigrationLedgerData,
} from './interfaces.js';
export type { TMigrationStatus, TLedgerBackend } from './types.js';
export type { TMigrationStatus, TLedgerBackend, TTargetVersionStrategy } from './types.js';
+13 -1
View File
@@ -1,5 +1,5 @@
import type * as plugins from './plugins.js';
import type { TLedgerBackend, TMigrationStatus } from './types.js';
import type { TLedgerBackend, TMigrationStatus, TTargetVersionStrategy } from './types.js';
/**
* Constructor options for SmartMigration.
@@ -42,6 +42,16 @@ export interface ISmartMigrationOptions {
/** If true, run() returns the plan without executing anything. Default false. */
dryRun?: boolean;
/**
* Controls whether the registered chain must end exactly at targetVersion.
*
* - 'strict' (default): require a real registered step ending at targetVersion.
* - 'bridge': append an internal no-op bridge to targetVersion when the
* registered chain ends before it. Useful when targetVersion follows the
* application/package version but migrations are only added for schema changes.
*/
targetVersionStrategy?: TTargetVersionStrategy;
/** Custom logger. Defaults to module logger. */
logger?: plugins.smartlog.Smartlog;
}
@@ -151,6 +161,8 @@ export interface IMigrationStepDefinition {
toVersion: string;
description?: string;
isResumable: boolean;
/** Internal virtual steps are generated by smartmigration and have no user handler effects. */
isVirtual?: boolean;
handler: (ctx: IMigrationContext) => Promise<void>;
}
+5
View File
@@ -7,3 +7,8 @@ export type TMigrationStatus = 'applied' | 'skipped' | 'failed';
* Backend used to persist the migration ledger.
*/
export type TLedgerBackend = 'mongo' | 's3';
/**
* How strictly the registered migration chain must end at targetVersion.
*/
export type TTargetVersionStrategy = 'strict' | 'bridge';