/** * Service for managing Watched Folder run state in IndexedDB */ import { WatchedFolderRunEntry } from "@app/types/watchedFolders"; const FOLDER_RUN_STATE_CHANGE_EVENT = "folder-run-state-changed"; interface RunStateRecord { folderId: string; runs: WatchedFolderRunEntry[]; lastUpdated: number; } class FolderRunStateStorage { private dbName = "stirling-pdf-folder-run-state"; private dbVersion = 1; private storeName = "runStates"; private db: IDBDatabase | null = null; private initPromise: Promise | null = null; async init(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.dbVersion); request.onerror = () => reject(new Error("Failed to open folder run state database")); request.onsuccess = () => { this.db = request.result; this.db.onclose = () => { this.db = null; this.initPromise = null; }; resolve(); }; request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName, { keyPath: "folderId" }); } }; }); } private async ensureDB(): Promise { if (!this.db) { this.initPromise ??= this.init(); await this.initPromise; } if (!this.db) { throw new Error("Folder run state database not initialized"); } return this.db; } async getFolderRunState(folderId: string): Promise { const db = await this.ensureDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readonly"); const store = transaction.objectStore(this.storeName); const request = store.get(folderId); request.onsuccess = () => { const record: RunStateRecord | undefined = request.result; resolve(record?.runs || []); }; request.onerror = () => reject(new Error("Failed to get folder run state")); }); } async setFolderRunState( folderId: string, runs: WatchedFolderRunEntry[], ): Promise { const db = await this.ensureDB(); const record: RunStateRecord = { folderId, runs, lastUpdated: Date.now() }; return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); const request = store.put(record); request.onsuccess = () => { window.dispatchEvent( new CustomEvent(FOLDER_RUN_STATE_CHANGE_EVENT, { detail: { folderId }, }), ); resolve(); }; request.onerror = () => reject(new Error("Failed to set folder run state")); }); } onRunStateChange(listener: (folderId: string) => void): () => void { const handler = (e: Event) => listener((e as CustomEvent).detail.folderId); window.addEventListener(FOLDER_RUN_STATE_CHANGE_EVENT, handler); return () => window.removeEventListener(FOLDER_RUN_STATE_CHANGE_EVENT, handler); } /** Atomically appends entries to a folder's run state within a single readwrite transaction, * preventing lost-update races when multiple files are processed concurrently. */ async appendRunEntries( folderId: string, entries: WatchedFolderRunEntry[], ): Promise { if (entries.length === 0) return; const db = await this.ensureDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); const getRequest = store.get(folderId); getRequest.onsuccess = () => { const existing: RunStateRecord | undefined = getRequest.result; const MAX_RUN_ENTRIES = 500; const combined = [...(existing?.runs ?? []), ...entries]; const record: RunStateRecord = { folderId, runs: combined.length > MAX_RUN_ENTRIES ? combined.slice(-MAX_RUN_ENTRIES) : combined, lastUpdated: Date.now(), }; const putRequest = store.put(record); putRequest.onsuccess = () => { window.dispatchEvent( new CustomEvent(FOLDER_RUN_STATE_CHANGE_EVENT, { detail: { folderId }, }), ); resolve(); }; putRequest.onerror = () => reject(new Error("Failed to append run entries")); }; getRequest.onerror = () => reject(new Error("Failed to read run state for append")); }); } async clearFolderRunState(folderId: string): Promise { const db = await this.ensureDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); const request = store.delete(folderId); request.onsuccess = () => resolve(); request.onerror = () => reject(new Error("Failed to clear folder run state")); }); } } export const folderRunStateStorage = new FolderRunStateStorage();