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