import * as plugins from './npmextra.plugins.js'; import * as paths from './npmextra.paths.js'; import { Task } from '@push.rocks/taskbuffer'; export type TKeyValueStore = 'custom' | 'userHomeDir' | 'ephemeral'; export interface IKvStoreConstructorOptions { typeArg: TKeyValueStore; identityArg: string; customPath?: string; mandatoryKeys?: Array; } /** * kvStore is a simple key value store to store data about projects between runs */ export class KeyValueStore { private dataObject: Partial = {}; private deletedObject: Partial = {}; private mandatoryKeys: Set = new Set(); public changeSubject = new plugins.smartrx.rxjs.Subject>(); private storedStateString: string = ''; public syncTask = new Task({ name: 'syncTask', buffered: true, bufferMax: 1, execDelay: 0, taskFunction: async () => { if (this.type !== 'ephemeral') { this.dataObject = { ...plugins.smartfile.fs.toObjectSync(this.filePath), ...this.dataObject, }; for (const key of Object.keys(this.deletedObject) as Array) { delete this.dataObject[key]; } this.deletedObject = {}; await plugins.smartfile.memory.toFs( plugins.smartjson.stringifyPretty(this.dataObject), this.filePath ); } const newStateString = plugins.smartjson.stringify(this.dataObject); // change detection if (newStateString !== this.storedStateString) { this.storedStateString = newStateString; this.changeSubject.next(this.dataObject); } }, }); /** * computes the identity and filePath */ private initFilePath = () => { if (this.type === 'ephemeral') { // No file path is needed for ephemeral type return; } if (this.customPath) { // Use custom path if provided const absolutePath = plugins.smartpath.transform.makeAbsolute(this.customPath, paths.cwd); this.filePath = absolutePath; if (plugins.smartfile.fs.isDirectorySync(this.filePath)) { this.filePath = plugins.path.join(this.filePath, this.identity + '.json'); } plugins.smartfile.fs.ensureFileSync(this.filePath, '{}'); return; } let baseDir: string; if (this.type === 'userHomeDir') { baseDir = paths.kvUserHomeDirBase; } else { throw new Error('kv type not supported'); } this.filePath = plugins.path.join(baseDir, this.identity + '.json'); plugins.smartfile.fs.ensureDirSync(baseDir); plugins.smartfile.fs.ensureFileSync(this.filePath, '{}'); }; // if no custom path is provided, try to store at home directory public type: TKeyValueStore; public identity: string; public filePath?: string; private customPath?: string; // Optionally allow custom path /** * the constructor of keyvalue store * @param typeArg * @param identityArg * @param customPath Optional custom path for the keyValue store */ constructor(optionsArg: IKvStoreConstructorOptions) { if (optionsArg.customPath && optionsArg.typeArg !== 'custom') { throw new Error('customPath can only be provided if typeArg is custom'); } if (optionsArg.typeArg === 'custom' && !optionsArg.customPath) { throw new Error('customPath must be provided if typeArg is custom'); } this.type = optionsArg.typeArg; this.identity = optionsArg.identityArg; this.customPath = optionsArg.customPath; // Store custom path if provided this.initFilePath(); if (optionsArg.mandatoryKeys) { this.setMandatoryKeys(optionsArg.mandatoryKeys); } } /** * reads all keyValue pairs at once and returns them */ public async readAll(): Promise> { await this.syncTask.trigger(); return this.dataObject; } /** * reads a keyValueFile from disk */ public async readKey(keyArg: K): Promise { await this.syncTask.trigger(); return this.dataObject[keyArg] as T[K]; } /** * writes a specific key to the keyValueStore */ public async writeKey(keyArg: K, valueArg: T[K]): Promise { await this.writeAll({ [keyArg]: valueArg, } as unknown as Partial); } public async deleteKey(keyArg: K): Promise { this.deletedObject[keyArg] = this.dataObject[keyArg]; await this.syncTask.trigger(); } /** * writes all keyValue pairs in the object argument */ public async writeAll(keyValueObject: Partial): Promise { this.dataObject = { ...this.dataObject, ...keyValueObject }; await this.syncTask.trigger(); } /** * wipes a key value store from disk */ public async wipe(): Promise { this.dataObject = {}; if (this.type !== 'ephemeral') { await plugins.smartfile.fs.remove(this.filePath); } } /** * resets the KeyValueStore to the initial state by syncing first, deleting all keys, and then triggering a sync again */ public async reset(): Promise { await this.syncTask.trigger(); // Sync to get the latest state // Delete all keys from the dataObject and add them to deletedObject for (const key of Object.keys(this.dataObject) as Array) { this.deletedObject[key] = this.dataObject[key]; delete this.dataObject[key]; } await this.syncTask.trigger(); // Sync again to reflect the deletion } private setMandatoryKeys(keys: Array) { keys.forEach(key => this.mandatoryKeys.add(key)); } public async getMissingMandatoryKeys(): Promise> { await this.readAll(); return Array.from(this.mandatoryKeys).filter(key => !(key in this.dataObject)); } public async waitForKeysPresent(keysArg: K[]): Promise { const missingKeys = keysArg.filter(keyArg => !this.dataObject[keyArg]); if (missingKeys.length === 0) { return; } return new Promise((resolve, reject) => { const subscription = this.changeSubject.subscribe(() => { const missingKeys = keysArg.filter(keyArg => !this.dataObject[keyArg]); if (missingKeys.length === 0) { subscription.unsubscribe(); resolve(); } }); }); } public async waitForAndGetKey(keyArg: K): Promise { await this.waitForKeysPresent([keyArg]); return this.readKey(keyArg); } }