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