feat(smartmigration): add initial smartmigration package with MongoDB and S3 migration runner
This commit is contained in:
176
ts/classes.versionresolver.ts
Normal file
176
ts/classes.versionresolver.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
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 []
|
||||
* - 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.
|
||||
* - 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 },
|
||||
);
|
||||
}
|
||||
|
||||
const startIndex = steps.findIndex((s) => this.equals(s.fromVersion, 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}`) },
|
||||
);
|
||||
}
|
||||
|
||||
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(' → ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user