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 `/.smartmigration/.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 { const exists = await (this.bucket as any).fastExists({ path: this.path }); if (!exists) { await this.write(emptyLedgerData()); } } public async read(): Promise { const buffer = await (this.bucket as any).fastGet({ path: this.path }); const data = JSON.parse(buffer.toString('utf8')) as Partial; return this.normalize(data); } public async write(data: ISmartMigrationLedgerData): Promise { 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 { 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 { 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 { // No persistent connection to release; the smartbucket Bucket lives on // the user's SmartBucket instance. } private normalize(data: Partial): ISmartMigrationLedgerData { return { currentVersion: data.currentVersion ?? null, steps: data.steps ?? {}, lock: data.lock ?? { holder: null, acquiredAt: null, expiresAt: null }, checkpoints: data.checkpoints ?? {}, }; } }