feat(smartmigration): add initial smartmigration package with MongoDB and S3 migration runner
This commit is contained in:
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmigration',
|
||||
version: '1.1.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.'
|
||||
}
|
||||
109
ts/classes.migrationcontext.ts
Normal file
109
ts/classes.migrationcontext.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type {
|
||||
IMigrationCheckpoint,
|
||||
IMigrationContext,
|
||||
IMigrationStepDefinition,
|
||||
ISmartMigrationLedgerData,
|
||||
ISmartMigrationOptions,
|
||||
} from './interfaces.js';
|
||||
import type { Ledger } from './ledgers/classes.ledger.js';
|
||||
import { SmartMigrationError } from './classes.versionresolver.js';
|
||||
|
||||
/**
|
||||
* Per-step parameters needed to build a context. Kept separate from
|
||||
* `IMigrationContext` so the factory can stay framework-internal.
|
||||
*/
|
||||
export interface IBuildContextParams {
|
||||
step: IMigrationStepDefinition;
|
||||
options: ISmartMigrationOptions;
|
||||
ledger: Ledger;
|
||||
isDryRun: boolean;
|
||||
log: plugins.smartlog.Smartlog;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `IMigrationCheckpoint` implementation that round-trips through the ledger.
|
||||
* Each read fetches the latest ledger state; each write performs a
|
||||
* read-modify-write of the `checkpoints[stepId]` sub-object.
|
||||
*/
|
||||
class LedgerCheckpoint implements IMigrationCheckpoint {
|
||||
private ledger: Ledger;
|
||||
private stepId: string;
|
||||
|
||||
constructor(ledger: Ledger, stepId: string) {
|
||||
this.ledger = ledger;
|
||||
this.stepId = stepId;
|
||||
}
|
||||
|
||||
public async read<T = unknown>(key: string): Promise<T | undefined> {
|
||||
const data = await this.ledger.read();
|
||||
const bag = data.checkpoints[this.stepId];
|
||||
if (!bag) return undefined;
|
||||
return bag[key] as T | undefined;
|
||||
}
|
||||
|
||||
public async write<T = unknown>(key: string, value: T): Promise<void> {
|
||||
const data = await this.ledger.read();
|
||||
const bag = data.checkpoints[this.stepId] ?? {};
|
||||
bag[key] = value as unknown;
|
||||
data.checkpoints[this.stepId] = bag;
|
||||
await this.ledger.write(data);
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
const data = await this.ledger.read();
|
||||
delete data.checkpoints[this.stepId];
|
||||
await this.ledger.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `IMigrationContext` passed to a step's `up` handler.
|
||||
*/
|
||||
export function buildContext(params: IBuildContextParams): IMigrationContext {
|
||||
const { step, options, ledger, isDryRun, log } = params;
|
||||
|
||||
const ctx: IMigrationContext = {
|
||||
db: options.db,
|
||||
bucket: options.bucket,
|
||||
mongo: options.db?.mongoDb,
|
||||
s3: extractS3Client(options.bucket),
|
||||
step: {
|
||||
id: step.id,
|
||||
fromVersion: step.fromVersion,
|
||||
toVersion: step.toVersion,
|
||||
description: step.description,
|
||||
isResumable: step.isResumable,
|
||||
},
|
||||
log,
|
||||
isDryRun,
|
||||
checkpoint: step.isResumable ? new LedgerCheckpoint(ledger, step.id) : undefined,
|
||||
startSession: () => {
|
||||
if (!options.db) {
|
||||
throw new SmartMigrationError(
|
||||
'NO_DB_CONFIGURED',
|
||||
`Step "${step.id}" called ctx.startSession(), but no SmartdataDb was passed to SmartMigration.`,
|
||||
{ stepId: step.id },
|
||||
);
|
||||
}
|
||||
return options.db.startSession();
|
||||
},
|
||||
};
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the underlying AWS SDK v3 S3Client from a smartbucket Bucket.
|
||||
* Uses `bucket.getStorageClient()` (added in @push.rocks/smartbucket 4.6.0)
|
||||
* which is the typed, documented escape hatch for advanced S3 operations.
|
||||
*/
|
||||
function extractS3Client(
|
||||
bucket: plugins.smartbucket.Bucket | undefined,
|
||||
): plugins.TRawS3Client | undefined {
|
||||
if (!bucket) return undefined;
|
||||
// `getStorageClient` is typed as the actual S3Client, but it lives behind
|
||||
// an optional peer dep type so we cast through unknown to keep this file
|
||||
// compiling when smartbucket isn't installed by the consumer.
|
||||
return (bucket as any).getStorageClient() as plugins.TRawS3Client | undefined;
|
||||
}
|
||||
95
ts/classes.migrationstep.ts
Normal file
95
ts/classes.migrationstep.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
IMigrationContext,
|
||||
IMigrationStepDefinition,
|
||||
} from './interfaces.js';
|
||||
import type { SmartMigration } from './classes.smartmigration.js';
|
||||
import { SmartMigrationError } from './classes.versionresolver.js';
|
||||
|
||||
/**
|
||||
* Builder returned by `SmartMigration.step(id)`. The chain is:
|
||||
* .from('1.0.0').to('1.1.0').description('...').resumable().up(async ctx => {...})
|
||||
*
|
||||
* `.up()` is the terminal call: it commits the step to its parent SmartMigration
|
||||
* and returns that parent so further `.step(...)` calls can chain naturally.
|
||||
*/
|
||||
export class MigrationStepBuilder {
|
||||
private parent: SmartMigration;
|
||||
private id: string;
|
||||
private _from: string | null = null;
|
||||
private _to: string | null = null;
|
||||
private _description: string | undefined;
|
||||
private _resumable = false;
|
||||
|
||||
constructor(parent: SmartMigration, id: string) {
|
||||
this.parent = parent;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** REQUIRED: the data version this step starts from. */
|
||||
public from(version: string): this {
|
||||
this._from = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** REQUIRED: the data version this step ends at. */
|
||||
public to(version: string): this {
|
||||
this._to = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Optional human-readable description. */
|
||||
public description(text: string): this {
|
||||
this._description = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the step as resumable. When set, the step's `up` handler receives a
|
||||
* `ctx.checkpoint` object that persists arbitrary key/value state to the
|
||||
* ledger between runs.
|
||||
*/
|
||||
public resumable(): this {
|
||||
this._resumable = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal: register the step on the parent SmartMigration and return the
|
||||
* parent so that further steps can be chained.
|
||||
*/
|
||||
public up(handler: (ctx: IMigrationContext) => Promise<void>): SmartMigration {
|
||||
if (this._from === null) {
|
||||
throw new SmartMigrationError(
|
||||
'STEP_MISSING_FROM',
|
||||
`Migration step "${this.id}" is missing .from(version) before .up().`,
|
||||
{ stepId: this.id },
|
||||
);
|
||||
}
|
||||
if (this._to === null) {
|
||||
throw new SmartMigrationError(
|
||||
'STEP_MISSING_TO',
|
||||
`Migration step "${this.id}" is missing .to(version) before .up().`,
|
||||
{ stepId: this.id },
|
||||
);
|
||||
}
|
||||
if (typeof handler !== 'function') {
|
||||
throw new SmartMigrationError(
|
||||
'STEP_HANDLER_NOT_FUNCTION',
|
||||
`Migration step "${this.id}" was passed a non-function to .up().`,
|
||||
{ stepId: this.id },
|
||||
);
|
||||
}
|
||||
|
||||
const definition: IMigrationStepDefinition = {
|
||||
id: this.id,
|
||||
fromVersion: this._from,
|
||||
toVersion: this._to,
|
||||
description: this._description,
|
||||
isResumable: this._resumable,
|
||||
handler,
|
||||
};
|
||||
|
||||
this.parent.registerStep(definition);
|
||||
return this.parent;
|
||||
}
|
||||
}
|
||||
442
ts/classes.smartmigration.ts
Normal file
442
ts/classes.smartmigration.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { logger as defaultLogger } from './logger.js';
|
||||
import { MigrationStepBuilder } from './classes.migrationstep.js';
|
||||
import { buildContext } from './classes.migrationcontext.js';
|
||||
import { SmartMigrationError, VersionResolver } from './classes.versionresolver.js';
|
||||
import { Ledger } from './ledgers/classes.ledger.js';
|
||||
import { MongoLedger } from './ledgers/classes.mongoledger.js';
|
||||
import { S3Ledger } from './ledgers/classes.s3ledger.js';
|
||||
import type {
|
||||
IMigrationLedgerEntry,
|
||||
IMigrationRunResult,
|
||||
IMigrationStepDefinition,
|
||||
IMigrationStepResult,
|
||||
ISmartMigrationLedgerData,
|
||||
ISmartMigrationOptions,
|
||||
} from './interfaces.js';
|
||||
import type { TLedgerBackend } from './types.js';
|
||||
|
||||
export { SmartMigrationError };
|
||||
|
||||
const DEFAULT_LEDGER_NAME = 'smartmigration';
|
||||
const DEFAULT_LOCK_WAIT_MS = 60_000;
|
||||
const DEFAULT_LOCK_TTL_MS = 600_000;
|
||||
const LOCK_POLL_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* SmartMigration — the runner. See readme.md for the full API.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. `new SmartMigration({...})` — creates the runner with the target version.
|
||||
* 2. `.step('id').from(...).to(...).up(async ctx => { ... })` — register steps
|
||||
* in the desired execution order.
|
||||
* 3. `await migration.run()` — invoked at SaaS app startup; idempotent and
|
||||
* fast on the happy path.
|
||||
*/
|
||||
export class SmartMigration {
|
||||
public readonly settings: Required<
|
||||
Omit<ISmartMigrationOptions, 'db' | 'bucket' | 'freshInstallVersion' | 'logger'>
|
||||
> & Pick<ISmartMigrationOptions, 'db' | 'bucket' | 'freshInstallVersion' | 'logger'>;
|
||||
|
||||
private steps: IMigrationStepDefinition[] = [];
|
||||
private ledger: Ledger | null = null;
|
||||
private instanceId: string;
|
||||
private log: plugins.smartlog.Smartlog;
|
||||
|
||||
constructor(options: ISmartMigrationOptions) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new SmartMigrationError('INVALID_OPTIONS', 'SmartMigration requires an options object.');
|
||||
}
|
||||
if (!options.targetVersion) {
|
||||
throw new SmartMigrationError(
|
||||
'MISSING_TARGET_VERSION',
|
||||
'SmartMigration requires `targetVersion` in its options.',
|
||||
);
|
||||
}
|
||||
VersionResolver.assertValid(options.targetVersion, 'options.targetVersion');
|
||||
|
||||
if (!options.db && !options.bucket) {
|
||||
throw new SmartMigrationError(
|
||||
'NO_RESOURCES',
|
||||
'SmartMigration requires at least one of `db` (SmartdataDb) or `bucket` (smartbucket Bucket).',
|
||||
);
|
||||
}
|
||||
|
||||
const ledgerBackend: TLedgerBackend =
|
||||
options.ledgerBackend ?? (options.db ? 'mongo' : 's3');
|
||||
|
||||
if (ledgerBackend === 'mongo' && !options.db) {
|
||||
throw new SmartMigrationError(
|
||||
'LEDGER_BACKEND_MISMATCH',
|
||||
'ledgerBackend "mongo" requires `db` to be set.',
|
||||
);
|
||||
}
|
||||
if (ledgerBackend === 's3' && !options.bucket) {
|
||||
throw new SmartMigrationError(
|
||||
'LEDGER_BACKEND_MISMATCH',
|
||||
'ledgerBackend "s3" requires `bucket` to be set.',
|
||||
);
|
||||
}
|
||||
|
||||
this.settings = {
|
||||
targetVersion: options.targetVersion,
|
||||
db: options.db,
|
||||
bucket: options.bucket,
|
||||
ledgerName: options.ledgerName ?? DEFAULT_LEDGER_NAME,
|
||||
ledgerBackend,
|
||||
freshInstallVersion: options.freshInstallVersion,
|
||||
lockWaitMs: options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS,
|
||||
lockTtlMs: options.lockTtlMs ?? DEFAULT_LOCK_TTL_MS,
|
||||
dryRun: options.dryRun ?? false,
|
||||
logger: options.logger,
|
||||
};
|
||||
|
||||
this.log = options.logger ?? defaultLogger;
|
||||
this.instanceId = plugins.randomUUID();
|
||||
}
|
||||
|
||||
// ─── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Begin defining a step. Returns a chainable builder. */
|
||||
public step(id: string): MigrationStepBuilder {
|
||||
if (!id || typeof id !== 'string') {
|
||||
throw new SmartMigrationError('INVALID_STEP_ID', 'step(id) requires a non-empty string id.');
|
||||
}
|
||||
return new MigrationStepBuilder(this, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a step. Called by `MigrationStepBuilder.up()`. End users do not
|
||||
* normally call this directly.
|
||||
*/
|
||||
public registerStep(definition: IMigrationStepDefinition): void {
|
||||
this.steps.push(definition);
|
||||
}
|
||||
|
||||
/** All registered steps in registration order. Mostly useful for tests. */
|
||||
public getRegisteredSteps(): ReadonlyArray<IMigrationStepDefinition> {
|
||||
return this.steps;
|
||||
}
|
||||
|
||||
/** Returns the current data version from the ledger, or null if uninitialized. */
|
||||
public async getCurrentVersion(): Promise<string | null> {
|
||||
const ledger = await this.ensureLedger();
|
||||
const data = await ledger.read();
|
||||
return data.currentVersion;
|
||||
}
|
||||
|
||||
/** Returns the plan that would be executed without actually running it. */
|
||||
public async plan(): Promise<IMigrationRunResult> {
|
||||
return this.runInternal({ planOnly: true });
|
||||
}
|
||||
|
||||
/** The startup entry point. Idempotent. Fast no-op if already at target. */
|
||||
public async run(): Promise<IMigrationRunResult> {
|
||||
return this.runInternal({ planOnly: false });
|
||||
}
|
||||
|
||||
// ─── internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private async runInternal(opts: { planOnly: boolean }): Promise<IMigrationRunResult> {
|
||||
VersionResolver.validateChain(this.steps);
|
||||
|
||||
const ledger = await this.ensureLedger();
|
||||
|
||||
// Fast path: read once, return immediately if already at target.
|
||||
const earlyData = await ledger.read();
|
||||
if (
|
||||
earlyData.currentVersion !== null &&
|
||||
VersionResolver.equals(earlyData.currentVersion, this.settings.targetVersion)
|
||||
) {
|
||||
return {
|
||||
currentVersionBefore: earlyData.currentVersion,
|
||||
currentVersionAfter: earlyData.currentVersion,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: true,
|
||||
wasFreshInstall: false,
|
||||
stepsApplied: [],
|
||||
stepsSkipped: [],
|
||||
totalDurationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// For dry runs, skip the lock entirely — we don't write anything.
|
||||
if (opts.planOnly || this.settings.dryRun) {
|
||||
return this.computeResultWithoutRun(earlyData);
|
||||
}
|
||||
|
||||
const lockHeld = await this.acquireLockWithBackoff();
|
||||
if (!lockHeld) {
|
||||
throw new SmartMigrationError(
|
||||
'LOCK_TIMEOUT',
|
||||
`Could not acquire migration lock within ${this.settings.lockWaitMs}ms. Another instance may be running migrations.`,
|
||||
{ lockWaitMs: this.settings.lockWaitMs },
|
||||
);
|
||||
}
|
||||
|
||||
const runStart = Date.now();
|
||||
const applied: IMigrationStepResult[] = [];
|
||||
let wasFreshInstall = false;
|
||||
let currentVersionBefore: string | null = null;
|
||||
|
||||
try {
|
||||
// Re-read after acquiring lock (state may have changed while we waited).
|
||||
let data = await ledger.read();
|
||||
currentVersionBefore = data.currentVersion;
|
||||
|
||||
// Resolve initial version.
|
||||
let currentVersion: string;
|
||||
if (data.currentVersion === null) {
|
||||
const fresh = await this.detectFreshInstall();
|
||||
if (fresh && this.settings.freshInstallVersion) {
|
||||
wasFreshInstall = true;
|
||||
currentVersion = this.settings.freshInstallVersion;
|
||||
VersionResolver.assertValid(currentVersion, 'freshInstallVersion');
|
||||
data.currentVersion = currentVersion;
|
||||
await ledger.write(data);
|
||||
this.log.log('info', `smartmigration: fresh install detected, jumping to ${currentVersion}`);
|
||||
} else {
|
||||
if (this.steps.length === 0) {
|
||||
// No steps and no current version — nothing to do.
|
||||
data.currentVersion = this.settings.targetVersion;
|
||||
await ledger.write(data);
|
||||
return {
|
||||
currentVersionBefore: null,
|
||||
currentVersionAfter: this.settings.targetVersion,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: false,
|
||||
wasFreshInstall: true,
|
||||
stepsApplied: [],
|
||||
stepsSkipped: [],
|
||||
totalDurationMs: Date.now() - runStart,
|
||||
};
|
||||
}
|
||||
currentVersion = this.steps[0].fromVersion;
|
||||
data.currentVersion = currentVersion;
|
||||
await ledger.write(data);
|
||||
}
|
||||
} else {
|
||||
currentVersion = data.currentVersion;
|
||||
}
|
||||
|
||||
// Already at target after fresh-install resolution?
|
||||
if (VersionResolver.equals(currentVersion, this.settings.targetVersion)) {
|
||||
return {
|
||||
currentVersionBefore,
|
||||
currentVersionAfter: currentVersion,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: true,
|
||||
wasFreshInstall,
|
||||
stepsApplied: [],
|
||||
stepsSkipped: [],
|
||||
totalDurationMs: Date.now() - runStart,
|
||||
};
|
||||
}
|
||||
|
||||
const plan = VersionResolver.computePlan(
|
||||
this.steps,
|
||||
currentVersion,
|
||||
this.settings.targetVersion,
|
||||
);
|
||||
|
||||
for (const step of plan) {
|
||||
const startedAt = new Date();
|
||||
const stepStart = Date.now();
|
||||
let entry: IMigrationLedgerEntry;
|
||||
try {
|
||||
this.log.log(
|
||||
'info',
|
||||
`smartmigration: running step "${step.id}" (${step.fromVersion} → ${step.toVersion})`,
|
||||
);
|
||||
const ctx = buildContext({
|
||||
step,
|
||||
options: { ...this.settings, db: this.settings.db, bucket: this.settings.bucket },
|
||||
ledger,
|
||||
isDryRun: false,
|
||||
log: this.log,
|
||||
});
|
||||
await step.handler(ctx);
|
||||
|
||||
const finishedAt = new Date();
|
||||
const durationMs = Date.now() - stepStart;
|
||||
entry = {
|
||||
id: step.id,
|
||||
fromVersion: step.fromVersion,
|
||||
toVersion: step.toVersion,
|
||||
status: 'applied',
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
durationMs,
|
||||
};
|
||||
// Re-read ledger to pick up any checkpoint writes the step made.
|
||||
data = await ledger.read();
|
||||
data.steps[step.id] = entry;
|
||||
data.currentVersion = step.toVersion;
|
||||
await ledger.write(data);
|
||||
applied.push({ ...entry });
|
||||
this.log.log(
|
||||
'info',
|
||||
`smartmigration: step "${step.id}" applied in ${durationMs}ms`,
|
||||
);
|
||||
} catch (err) {
|
||||
const finishedAt = new Date();
|
||||
const durationMs = Date.now() - stepStart;
|
||||
const error = err as Error;
|
||||
entry = {
|
||||
id: step.id,
|
||||
fromVersion: step.fromVersion,
|
||||
toVersion: step.toVersion,
|
||||
status: 'failed',
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
durationMs,
|
||||
error: { message: error.message, stack: error.stack },
|
||||
};
|
||||
// Persist failure to ledger so re-runs see it.
|
||||
try {
|
||||
data = await ledger.read();
|
||||
data.steps[step.id] = entry;
|
||||
await ledger.write(data);
|
||||
} catch {
|
||||
// Ledger write failed — re-throw the original error anyway.
|
||||
}
|
||||
this.log.log(
|
||||
'error',
|
||||
`smartmigration: step "${step.id}" failed after ${durationMs}ms: ${error.message}`,
|
||||
);
|
||||
throw new SmartMigrationError(
|
||||
'STEP_FAILED',
|
||||
`Migration step "${step.id}" (${step.fromVersion} → ${step.toVersion}) failed: ${error.message}`,
|
||||
{ stepId: step.id, originalError: error.message, stack: error.stack },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const finalData = await ledger.read();
|
||||
return {
|
||||
currentVersionBefore,
|
||||
currentVersionAfter: finalData.currentVersion ?? this.settings.targetVersion,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: false,
|
||||
wasFreshInstall,
|
||||
stepsApplied: applied,
|
||||
stepsSkipped: [],
|
||||
totalDurationMs: Date.now() - runStart,
|
||||
};
|
||||
} finally {
|
||||
await ledger.releaseLock(this.instanceId).catch((err) => {
|
||||
this.log.log(
|
||||
'warn',
|
||||
`smartmigration: failed to release lock: ${(err as Error).message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the plan against the current ledger state without acquiring a
|
||||
* lock or executing anything. Used by `plan()` and `dryRun: true`.
|
||||
*/
|
||||
private computeResultWithoutRun(data: ISmartMigrationLedgerData): IMigrationRunResult {
|
||||
const currentVersion =
|
||||
data.currentVersion ??
|
||||
(this.steps.length > 0 ? this.steps[0].fromVersion : this.settings.targetVersion);
|
||||
|
||||
if (VersionResolver.equals(currentVersion, this.settings.targetVersion)) {
|
||||
return {
|
||||
currentVersionBefore: data.currentVersion,
|
||||
currentVersionAfter: currentVersion,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: true,
|
||||
wasFreshInstall: false,
|
||||
stepsApplied: [],
|
||||
stepsSkipped: [],
|
||||
totalDurationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const plan = VersionResolver.computePlan(
|
||||
this.steps,
|
||||
currentVersion,
|
||||
this.settings.targetVersion,
|
||||
);
|
||||
|
||||
const skipped: IMigrationStepResult[] = plan.map((step) => ({
|
||||
id: step.id,
|
||||
fromVersion: step.fromVersion,
|
||||
toVersion: step.toVersion,
|
||||
status: 'skipped' as const,
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
durationMs: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
currentVersionBefore: data.currentVersion,
|
||||
currentVersionAfter: currentVersion,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: false,
|
||||
wasFreshInstall: false,
|
||||
stepsApplied: [],
|
||||
stepsSkipped: skipped,
|
||||
totalDurationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureLedger(): Promise<Ledger> {
|
||||
if (this.ledger) return this.ledger;
|
||||
const ledgerName = this.settings.ledgerName;
|
||||
if (this.settings.ledgerBackend === 'mongo') {
|
||||
this.ledger = new MongoLedger(this.settings.db!, ledgerName);
|
||||
} else {
|
||||
this.ledger = new S3Ledger(this.settings.bucket!, ledgerName);
|
||||
}
|
||||
await this.ledger.init();
|
||||
return this.ledger;
|
||||
}
|
||||
|
||||
private async acquireLockWithBackoff(): Promise<boolean> {
|
||||
const ledger = await this.ensureLedger();
|
||||
const deadline = Date.now() + this.settings.lockWaitMs;
|
||||
while (Date.now() <= deadline) {
|
||||
const got = await ledger.acquireLock(this.instanceId, this.settings.lockTtlMs);
|
||||
if (got) return true;
|
||||
await this.sleep(LOCK_POLL_INTERVAL_MS);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic fresh-install detector. Returns true when neither mongo nor S3
|
||||
* contain anything besides smartmigration's own ledger artifacts.
|
||||
*/
|
||||
private async detectFreshInstall(): Promise<boolean> {
|
||||
if (this.settings.db) {
|
||||
const collections = await this.settings.db.mongoDb
|
||||
.listCollections({}, { nameOnly: true })
|
||||
.toArray();
|
||||
const userCollections = collections.filter(
|
||||
(c: { name: string }) => !this.isReservedCollectionName(c.name),
|
||||
);
|
||||
if (userCollections.length > 0) return false;
|
||||
}
|
||||
if (this.settings.bucket) {
|
||||
const cursor = (this.settings.bucket as any).createCursor('', { pageSize: 5 });
|
||||
const batch = (await cursor.next()) as string[] | undefined;
|
||||
if (batch && batch.length > 0) {
|
||||
const userKeys = batch.filter((k) => !k.startsWith('.smartmigration/'));
|
||||
if (userKeys.length > 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private isReservedCollectionName(name: string): boolean {
|
||||
// smartdata's EasyStore creates a collection named "SmartdataEasyStore".
|
||||
return name === 'SmartdataEasyStore' || name.startsWith('system.');
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
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(' → ');
|
||||
}
|
||||
}
|
||||
21
ts/index.ts
Normal file
21
ts/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @push.rocks/smartmigration — unified migration runner for MongoDB and S3.
|
||||
*/
|
||||
|
||||
export { SmartMigration, SmartMigrationError } from './classes.smartmigration.js';
|
||||
export { MigrationStepBuilder } from './classes.migrationstep.js';
|
||||
export { VersionResolver } from './classes.versionresolver.js';
|
||||
|
||||
export type {
|
||||
ISmartMigrationOptions,
|
||||
IMigrationContext,
|
||||
IMigrationCheckpoint,
|
||||
IMigrationStepInfo,
|
||||
IMigrationStepDefinition,
|
||||
IMigrationStepResult,
|
||||
IMigrationLedgerEntry,
|
||||
IMigrationRunResult,
|
||||
ISmartMigrationLedgerData,
|
||||
} from './interfaces.js';
|
||||
|
||||
export type { TMigrationStatus, TLedgerBackend } from './types.js';
|
||||
169
ts/interfaces.ts
Normal file
169
ts/interfaces.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type * as plugins from './plugins.js';
|
||||
import type { TLedgerBackend, TMigrationStatus } from './types.js';
|
||||
|
||||
/**
|
||||
* Constructor options for SmartMigration.
|
||||
*/
|
||||
export interface ISmartMigrationOptions {
|
||||
/** Target version for the data. Typically the app's package.json version. */
|
||||
targetVersion: string;
|
||||
|
||||
/**
|
||||
* Optional smartdata instance. Required if any step uses ctx.db / ctx.mongo,
|
||||
* and required if you want a mongo-backed ledger.
|
||||
*/
|
||||
db?: plugins.smartdata.SmartdataDb;
|
||||
|
||||
/**
|
||||
* Optional smartbucket Bucket. Required if any step uses ctx.bucket / ctx.s3,
|
||||
* and required if you want an S3-backed ledger.
|
||||
*/
|
||||
bucket?: plugins.smartbucket.Bucket;
|
||||
|
||||
/** Logical name for this migration ledger. Defaults to "smartmigration". */
|
||||
ledgerName?: string;
|
||||
|
||||
/** Where to persist the ledger. Defaults to "mongo" if db provided, otherwise "s3". */
|
||||
ledgerBackend?: TLedgerBackend;
|
||||
|
||||
/**
|
||||
* For a fresh install (no ledger AND no app data), jump straight to this version
|
||||
* instead of running every step from the earliest. Defaults to undefined,
|
||||
* which means "run every step from earliest from-version".
|
||||
*/
|
||||
freshInstallVersion?: string;
|
||||
|
||||
/** How long (ms) to wait for a stale lock from another instance. Default 60_000. */
|
||||
lockWaitMs?: number;
|
||||
|
||||
/** How long (ms) before this instance's own lock auto-expires. Default 600_000. */
|
||||
lockTtlMs?: number;
|
||||
|
||||
/** If true, run() returns the plan without executing anything. Default false. */
|
||||
dryRun?: boolean;
|
||||
|
||||
/** Custom logger. Defaults to module logger. */
|
||||
logger?: plugins.smartlog.Smartlog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a single step exposed to the migration handler via ctx.step.
|
||||
*/
|
||||
export interface IMigrationStepInfo {
|
||||
id: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
description?: string;
|
||||
isResumable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The context object passed into every step's `up()` handler.
|
||||
*/
|
||||
export interface IMigrationContext {
|
||||
/** High-level smartdata instance. Present iff `db` was passed to the constructor. */
|
||||
db?: plugins.smartdata.SmartdataDb;
|
||||
|
||||
/** High-level smartbucket Bucket. Present iff `bucket` was passed to the constructor. */
|
||||
bucket?: plugins.smartbucket.Bucket;
|
||||
|
||||
/** Raw mongo `Db`. Present iff `db` was passed to the constructor. */
|
||||
mongo?: plugins.TRawMongoDb;
|
||||
|
||||
/** Raw S3 client. Present iff `bucket` was passed to the constructor. */
|
||||
s3?: plugins.TRawS3Client;
|
||||
|
||||
/** Metadata about the currently running step. */
|
||||
step: IMigrationStepInfo;
|
||||
|
||||
/** Logger scoped to this run. */
|
||||
log: plugins.smartlog.Smartlog;
|
||||
|
||||
/** True when run() was called with `dryRun: true`. */
|
||||
isDryRun: boolean;
|
||||
|
||||
/** Only present when the step was marked `.resumable()`. */
|
||||
checkpoint?: IMigrationCheckpoint;
|
||||
|
||||
/** Convenience for transactional mongo migrations. Throws if no db configured. */
|
||||
startSession(): plugins.TMongoClientSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-step persistent key/value store, used by resumable migrations to
|
||||
* record progress so a subsequent run can pick up where the previous one
|
||||
* stopped.
|
||||
*/
|
||||
export interface IMigrationCheckpoint {
|
||||
read<T = unknown>(key: string): Promise<T | undefined>;
|
||||
write<T = unknown>(key: string, value: T): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The serialized form of a migration step's outcome, as stored in the ledger.
|
||||
*/
|
||||
export interface IMigrationLedgerEntry {
|
||||
id: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
status: TMigrationStatus;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
error?: { message: string; stack?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-step result returned by run().
|
||||
*/
|
||||
export interface IMigrationStepResult {
|
||||
id: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
status: TMigrationStatus;
|
||||
durationMs: number;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
error?: { message: string; stack?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a single call to SmartMigration.run() or .plan().
|
||||
*/
|
||||
export interface IMigrationRunResult {
|
||||
currentVersionBefore: string | null;
|
||||
currentVersionAfter: string;
|
||||
targetVersion: string;
|
||||
wasUpToDate: boolean;
|
||||
wasFreshInstall: boolean;
|
||||
stepsApplied: IMigrationStepResult[];
|
||||
stepsSkipped: IMigrationStepResult[];
|
||||
totalDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The opaque definition of a registered step. Created by MigrationStepBuilder.
|
||||
*/
|
||||
export interface IMigrationStepDefinition {
|
||||
id: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
description?: string;
|
||||
isResumable: boolean;
|
||||
handler: (ctx: IMigrationContext) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: the shape of the data persisted in the ledger backend.
|
||||
*/
|
||||
export interface ISmartMigrationLedgerData {
|
||||
currentVersion: string | null;
|
||||
steps: Record<string, IMigrationLedgerEntry>;
|
||||
lock: {
|
||||
holder: string | null;
|
||||
acquiredAt: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
checkpoints: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
41
ts/ledgers/classes.ledger.ts
Normal file
41
ts/ledgers/classes.ledger.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
ISmartMigrationLedgerData,
|
||||
} from '../interfaces.js';
|
||||
|
||||
/**
|
||||
* Abstract ledger interface — both MongoLedger and S3Ledger implement this.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. `init()` — open the underlying store, create empty document if needed
|
||||
* 2. `read()` — return the current ledger data
|
||||
* 3. `write(data)` — overwrite the ledger with the given data
|
||||
* 4. `acquireLock(holderId, ttlMs)` — best-effort lock; returns true on success
|
||||
* 5. `releaseLock(holderId)` — clear the lock if we still hold it
|
||||
* 6. `close()` — release any resources
|
||||
*
|
||||
* The ledger data is a single self-contained JSON-serializable object.
|
||||
* Both backends store it as a single document (mongo via EasyStore, s3 via
|
||||
* a single sidecar object).
|
||||
*/
|
||||
export abstract class Ledger {
|
||||
public abstract init(): Promise<void>;
|
||||
public abstract read(): Promise<ISmartMigrationLedgerData>;
|
||||
public abstract write(data: ISmartMigrationLedgerData): Promise<void>;
|
||||
public abstract acquireLock(holderId: string, ttlMs: number): Promise<boolean>;
|
||||
public abstract releaseLock(holderId: string): Promise<void>;
|
||||
public abstract close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Build a fresh, empty ledger document. */
|
||||
export function emptyLedgerData(): ISmartMigrationLedgerData {
|
||||
return {
|
||||
currentVersion: null,
|
||||
steps: {},
|
||||
lock: {
|
||||
holder: null,
|
||||
acquiredAt: null,
|
||||
expiresAt: null,
|
||||
},
|
||||
checkpoints: {},
|
||||
};
|
||||
}
|
||||
106
ts/ledgers/classes.mongoledger.ts
Normal file
106
ts/ledgers/classes.mongoledger.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type * as plugins from '../plugins.js';
|
||||
import type { ISmartMigrationLedgerData } from '../interfaces.js';
|
||||
import { Ledger, emptyLedgerData } from './classes.ledger.js';
|
||||
|
||||
/**
|
||||
* Mongo-backed ledger that persists `ISmartMigrationLedgerData` as a single
|
||||
* document via smartdata's `EasyStore`. The EasyStore's nameId is
|
||||
* `smartmigration:<ledgerName>`, scoping multiple migration ledgers in the
|
||||
* same database.
|
||||
*/
|
||||
export class MongoLedger extends Ledger {
|
||||
private db: plugins.smartdata.SmartdataDb;
|
||||
private ledgerName: string;
|
||||
private easyStore: any | null = null; // EasyStore<ISmartMigrationLedgerData> — typed loosely because the peer type may not be present at compile time
|
||||
|
||||
constructor(db: plugins.smartdata.SmartdataDb, ledgerName: string) {
|
||||
super();
|
||||
this.db = db;
|
||||
this.ledgerName = ledgerName;
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
this.easyStore = await this.db.createEasyStore(`smartmigration:${this.ledgerName}`);
|
||||
// EasyStore creates an empty `data: {}` on first read. Hydrate it to the
|
||||
// canonical empty shape so subsequent reads always return all fields.
|
||||
const existing = (await this.easyStore.readAll()) as Partial<ISmartMigrationLedgerData>;
|
||||
if (
|
||||
existing.currentVersion === undefined ||
|
||||
existing.steps === undefined ||
|
||||
existing.lock === undefined ||
|
||||
existing.checkpoints === undefined
|
||||
) {
|
||||
await this.easyStore.writeAll(emptyLedgerData());
|
||||
}
|
||||
}
|
||||
|
||||
public async read(): Promise<ISmartMigrationLedgerData> {
|
||||
if (!this.easyStore) {
|
||||
throw new Error('MongoLedger.read() called before init()');
|
||||
}
|
||||
const data = (await this.easyStore.readAll()) as ISmartMigrationLedgerData;
|
||||
return this.normalize(data);
|
||||
}
|
||||
|
||||
public async write(data: ISmartMigrationLedgerData): Promise<void> {
|
||||
if (!this.easyStore) {
|
||||
throw new Error('MongoLedger.write() called before init()');
|
||||
}
|
||||
// Use EasyStore.replace (added in @push.rocks/smartdata 7.1.7) for true
|
||||
// overwrite semantics. This lets us actually delete keys from
|
||||
// checkpoints / steps when the in-memory ledger drops them — writeAll
|
||||
// would merge and silently retain them.
|
||||
await this.easyStore.replace(data);
|
||||
}
|
||||
|
||||
public async acquireLock(holderId: string, ttlMs: number): Promise<boolean> {
|
||||
const data = await this.read();
|
||||
const now = new Date();
|
||||
const lockHeld = data.lock.holder !== null;
|
||||
const lockExpired =
|
||||
data.lock.expiresAt !== null && new Date(data.lock.expiresAt).getTime() < now.getTime();
|
||||
|
||||
if (lockHeld && !lockExpired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(now.getTime() + ttlMs);
|
||||
data.lock = {
|
||||
holder: holderId,
|
||||
acquiredAt: now.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
await this.write(data);
|
||||
|
||||
// Re-read to confirm we won the race. EasyStore is last-writer-wins so
|
||||
// this is a probabilistic CAS, not a true atomic CAS — adequate for v1.
|
||||
const verify = await this.read();
|
||||
return verify.lock.holder === holderId;
|
||||
}
|
||||
|
||||
public async releaseLock(holderId: string): Promise<void> {
|
||||
const data = await this.read();
|
||||
if (data.lock.holder !== holderId) {
|
||||
// Lock was stolen or never held — nothing to release.
|
||||
return;
|
||||
}
|
||||
data.lock = { holder: null, acquiredAt: null, expiresAt: null };
|
||||
await this.write(data);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
// EasyStore has no explicit close — it just dereferences when the parent
|
||||
// SmartdataDb closes.
|
||||
this.easyStore = null;
|
||||
}
|
||||
|
||||
/** Fill in any missing top-level fields with their defaults. */
|
||||
private normalize(data: Partial<ISmartMigrationLedgerData>): ISmartMigrationLedgerData {
|
||||
return {
|
||||
currentVersion: data.currentVersion ?? null,
|
||||
steps: data.steps ?? {},
|
||||
lock: data.lock ?? { holder: null, acquiredAt: null, expiresAt: null },
|
||||
checkpoints: data.checkpoints ?? {},
|
||||
};
|
||||
}
|
||||
}
|
||||
92
ts/ledgers/classes.s3ledger.ts
Normal file
92
ts/ledgers/classes.s3ledger.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type * as plugins from '../plugins.js';
|
||||
import type { ISmartMigrationLedgerData } from '../interfaces.js';
|
||||
import { Ledger, emptyLedgerData } from './classes.ledger.js';
|
||||
|
||||
/**
|
||||
* S3-backed ledger that persists `ISmartMigrationLedgerData` as a single
|
||||
* JSON object at `<bucket>/.smartmigration/<ledgerName>.json`.
|
||||
*
|
||||
* Locking is best-effort: S3 has no conditional writes (without versioning
|
||||
* + a separate index). Single-instance SaaS deployments are fine; multi-
|
||||
* instance deployments should use the mongo ledger or provide external
|
||||
* coordination.
|
||||
*/
|
||||
export class S3Ledger extends Ledger {
|
||||
private bucket: plugins.smartbucket.Bucket;
|
||||
private path: string;
|
||||
|
||||
constructor(bucket: plugins.smartbucket.Bucket, ledgerName: string) {
|
||||
super();
|
||||
this.bucket = bucket;
|
||||
this.path = `.smartmigration/${ledgerName}.json`;
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const exists = await (this.bucket as any).fastExists({ path: this.path });
|
||||
if (!exists) {
|
||||
await this.write(emptyLedgerData());
|
||||
}
|
||||
}
|
||||
|
||||
public async read(): Promise<ISmartMigrationLedgerData> {
|
||||
const buffer = await (this.bucket as any).fastGet({ path: this.path });
|
||||
const data = JSON.parse(buffer.toString('utf8')) as Partial<ISmartMigrationLedgerData>;
|
||||
return this.normalize(data);
|
||||
}
|
||||
|
||||
public async write(data: ISmartMigrationLedgerData): Promise<void> {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
await (this.bucket as any).fastPut({
|
||||
path: this.path,
|
||||
contents: json,
|
||||
overwrite: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async acquireLock(holderId: string, ttlMs: number): Promise<boolean> {
|
||||
const data = await this.read();
|
||||
const now = new Date();
|
||||
const lockHeld = data.lock.holder !== null;
|
||||
const lockExpired =
|
||||
data.lock.expiresAt !== null && new Date(data.lock.expiresAt).getTime() < now.getTime();
|
||||
|
||||
if (lockHeld && !lockExpired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(now.getTime() + ttlMs);
|
||||
data.lock = {
|
||||
holder: holderId,
|
||||
acquiredAt: now.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
await this.write(data);
|
||||
|
||||
// Re-read to detect races. Best-effort only.
|
||||
const verify = await this.read();
|
||||
return verify.lock.holder === holderId;
|
||||
}
|
||||
|
||||
public async releaseLock(holderId: string): Promise<void> {
|
||||
const data = await this.read();
|
||||
if (data.lock.holder !== holderId) {
|
||||
return;
|
||||
}
|
||||
data.lock = { holder: null, acquiredAt: null, expiresAt: null };
|
||||
await this.write(data);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
// No persistent connection to release; the smartbucket Bucket lives on
|
||||
// the user's SmartBucket instance.
|
||||
}
|
||||
|
||||
private normalize(data: Partial<ISmartMigrationLedgerData>): ISmartMigrationLedgerData {
|
||||
return {
|
||||
currentVersion: data.currentVersion ?? null,
|
||||
steps: data.steps ?? {},
|
||||
lock: data.lock ?? { holder: null, acquiredAt: null, expiresAt: null },
|
||||
checkpoints: data.checkpoints ?? {},
|
||||
};
|
||||
}
|
||||
}
|
||||
19
ts/logger.ts
Normal file
19
ts/logger.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
/**
|
||||
* Module-scoped Smartlog singleton. Users may pass their own logger via
|
||||
* `ISmartMigrationOptions.logger` to override this default.
|
||||
*/
|
||||
export const logger = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
company: 'Lossless GmbH',
|
||||
companyunit: 'push.rocks',
|
||||
containerName: 'smartmigration',
|
||||
environment: 'production',
|
||||
runtime: 'node',
|
||||
zone: 'pushrocks',
|
||||
},
|
||||
minimumLogLevel: 'info',
|
||||
});
|
||||
|
||||
logger.addLogDestination(new plugins.smartlogDestinationLocal.DestinationLocal());
|
||||
25
ts/plugins.ts
Normal file
25
ts/plugins.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// node native scope
|
||||
import { randomUUID } from 'node:crypto';
|
||||
export { randomUUID };
|
||||
|
||||
// pushrocks scope
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
|
||||
import * as smartversion from '@push.rocks/smartversion';
|
||||
export { smartlog, smartlogDestinationLocal, smartversion };
|
||||
|
||||
// optional pushrocks peers — type-only imports so the module compiles even
|
||||
// when end users only install one of them. The type-import loads the .d.ts
|
||||
// at compile time but emits no runtime require/import. Both packages are
|
||||
// devDeps locally so the build can resolve them.
|
||||
import type * as smartdata from '@push.rocks/smartdata';
|
||||
import type * as smartbucket from '@push.rocks/smartbucket';
|
||||
export type { smartdata, smartbucket };
|
||||
|
||||
// Driver types are derived from the smartdata / smartbucket public surface
|
||||
// to avoid having to declare `mongodb` or `@aws-sdk/client-s3` as direct
|
||||
// dependencies. These aliases are exported for use in the public interfaces.
|
||||
export type TRawMongoDb = smartdata.SmartdataDb['mongoDb'];
|
||||
export type TRawMongoClient = smartdata.SmartdataDb['mongoDbClient'];
|
||||
export type TMongoClientSession = ReturnType<smartdata.SmartdataDb['startSession']>;
|
||||
export type TRawS3Client = smartbucket.SmartBucket['storageClient'];
|
||||
9
ts/types.ts
Normal file
9
ts/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Status of a migration step in a run result.
|
||||
*/
|
||||
export type TMigrationStatus = 'applied' | 'skipped' | 'failed';
|
||||
|
||||
/**
|
||||
* Backend used to persist the migration ledger.
|
||||
*/
|
||||
export type TLedgerBackend = 'mongo' | 's3';
|
||||
Reference in New Issue
Block a user