feat(migration): add lock heartbeats, predictive dry-run planning, and stricter ledger option validation

This commit is contained in:
2026-04-14 12:31:34 +00:00
parent 19ebdee31a
commit 1b4358aca5
17 changed files with 695 additions and 180 deletions
+27 -6
View File
@@ -22,21 +22,24 @@ export class S3Ledger extends Ledger {
}
public async init(): Promise<void> {
const exists = await (this.bucket as any).fastExists({ path: this.path });
if (!exists) {
await this.write(emptyLedgerData());
}
// Intentionally empty. The sidecar object is created lazily on first write
// so `plan()` / `dryRun` stay read-only for S3-backed ledgers.
}
public async read(): Promise<ISmartMigrationLedgerData> {
const buffer = await (this.bucket as any).fastGet({ path: this.path });
const exists = await this.bucket.fastExists({ path: this.path });
if (!exists) {
return emptyLedgerData();
}
const buffer = await this.bucket.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({
await this.bucket.fastPut({
path: this.path,
contents: json,
overwrite: true,
@@ -67,6 +70,24 @@ export class S3Ledger extends Ledger {
return verify.lock.holder === holderId;
}
public async renewLock(holderId: string, ttlMs: number): Promise<boolean> {
const data = await this.read();
if (data.lock.holder !== holderId) {
return false;
}
const now = new Date();
data.lock = {
holder: holderId,
acquiredAt: data.lock.acquiredAt ?? now.toISOString(),
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
};
await this.write(data);
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) {