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:`, 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 — 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 { 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; if ( existing.currentVersion === undefined || existing.steps === undefined || existing.lock === undefined || existing.checkpoints === undefined ) { await this.easyStore.writeAll(emptyLedgerData()); } } public async read(): Promise { 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 { 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 { 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 { 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 { // 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 { return { currentVersion: data.currentVersion ?? null, steps: data.steps ?? {}, lock: data.lock ?? { holder: null, acquiredAt: null, expiresAt: null }, checkpoints: data.checkpoints ?? {}, }; } }