93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
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 ?? {},
|
|
};
|
|
}
|
|
}
|