import type * as plugins from '../plugins.js'; import type { ISmartMigrationLedgerData } from '../interfaces.js'; import { Ledger, emptyLedgerData } from './classes.ledger.js'; interface IMongoLedgerDocument { nameId: string; data: ISmartMigrationLedgerData; } /** * Mongo-backed ledger that persists `ISmartMigrationLedgerData` as a single * document in smartdata's `SmartdataEasyStore` collection, keyed by * `smartmigration:`. */ export class MongoLedger extends Ledger { private db: plugins.smartdata.SmartdataDb; private ledgerName: string; private indexReadyPromise: Promise | null = null; constructor(db: plugins.smartdata.SmartdataDb, ledgerName: string) { super(); this.db = db; this.ledgerName = ledgerName; } public async init(): Promise { // Intentionally empty. The backing collection and unique index are created // lazily on first write so `plan()` / `getCurrentVersion()` stay read-only. } public async read(): Promise { const document = await this.getCollection().findOne({ nameId: this.getNameId() }); return this.normalize(document?.data); } public async write(data: ISmartMigrationLedgerData): Promise { await this.ensureCollectionReady(); await this.getCollection().updateOne( { nameId: this.getNameId() }, { $set: { nameId: this.getNameId(), data, }, }, { upsert: true }, ); } public async acquireLock(holderId: string, ttlMs: number): Promise { await this.ensureDocumentExists(); const now = new Date(); const nowIso = now.toISOString(); const result = await this.getCollection().findOneAndUpdate( { nameId: this.getNameId(), $or: [ { 'data.lock.holder': null }, { 'data.lock.holder': { $exists: false } }, { 'data.lock.expiresAt': null }, { 'data.lock.expiresAt': { $exists: false } }, { 'data.lock.expiresAt': { $lt: nowIso } }, ], }, { $set: { 'data.lock': { holder: holderId, acquiredAt: nowIso, expiresAt: new Date(now.getTime() + ttlMs).toISOString(), }, }, }, { returnDocument: 'after' }, ); return result !== null; } public async renewLock(holderId: string, ttlMs: number): Promise { await this.ensureCollectionReady(); const now = new Date(); const nowIso = now.toISOString(); const result = await this.getCollection().updateOne( { nameId: this.getNameId(), 'data.lock.holder': holderId, 'data.lock.expiresAt': { $gte: nowIso }, }, { $set: { 'data.lock.expiresAt': new Date(now.getTime() + ttlMs).toISOString(), }, }, ); return result.modifiedCount === 1; } public async releaseLock(holderId: string): Promise { await this.ensureCollectionReady(); await this.getCollection().updateOne( { nameId: this.getNameId(), 'data.lock.holder': holderId, }, { $set: { 'data.lock': { holder: null, acquiredAt: null, expiresAt: null, }, }, }, ); } public async close(): Promise { // No per-ledger resources to release. The parent SmartdataDb owns the // Mongo connection lifecycle. } /** Fill in any missing top-level fields with their defaults. */ private normalize(data: Partial | undefined): ISmartMigrationLedgerData { return { currentVersion: data?.currentVersion ?? null, steps: data?.steps ?? {}, lock: data?.lock ?? { holder: null, acquiredAt: null, expiresAt: null }, checkpoints: data?.checkpoints ?? {}, }; } private getCollection() { return this.db.mongoDb.collection('SmartdataEasyStore'); } private getNameId(): string { return `smartmigration:${this.ledgerName}`; } private async ensureDocumentExists(): Promise { await this.ensureCollectionReady(); await this.getCollection().updateOne( { nameId: this.getNameId() }, { $setOnInsert: { nameId: this.getNameId(), data: emptyLedgerData(), }, }, { upsert: true }, ); } private async ensureCollectionReady(): Promise { if (!this.indexReadyPromise) { this.indexReadyPromise = this.getCollection() .createIndex({ nameId: 1 }, { unique: true }) .then(() => undefined); } await this.indexReadyPromise; } }