Files
Stirling-PDF/frontend/src/core/services/folderStorage.ts
T
2026-04-30 22:23:00 +01:00

192 lines
7.4 KiB
TypeScript

/**
* Service for managing folder-file associations in IndexedDB.
* File blobs are stored in the main stirling-pdf-files database (fileStorage).
* This service only maintains folder record metadata: which file IDs belong to
* which folders and their processing status.
*/
import { FolderFileMetadata, FolderRecord } from "@app/types/smartFolders";
const FOLDER_CHANGE_EVENT = "folder-storage-changed";
class FolderStorage {
private dbName = "stirling-pdf-folder-files";
private dbVersion = 3;
private recordsStore = "folderRecords";
private db: IDBDatabase | null = null;
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
reject(new Error("Failed to open folder files database"));
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.recordsStore)) {
db.createObjectStore(this.recordsStore, { keyPath: "folderId" });
}
// Remove legacy blob stores — files are now unified in stirling-pdf-files
if (db.objectStoreNames.contains("folderOutputFiles")) {
db.deleteObjectStore("folderOutputFiles");
}
if (db.objectStoreNames.contains("folderInputFiles")) {
db.deleteObjectStore("folderInputFiles");
}
};
});
}
private async ensureDB(): Promise<IDBDatabase> {
if (!this.db) {
await this.init();
}
if (!this.db) {
throw new Error("Folder files database not initialized");
}
return this.db;
}
private dispatchChange(folderId: string): void {
window.dispatchEvent(new CustomEvent(FOLDER_CHANGE_EVENT, { detail: { folderId } }));
}
onFolderChange(listener: (folderId: string) => void): () => void {
const handler = (e: Event) => {
listener((e as CustomEvent).detail.folderId);
};
window.addEventListener(FOLDER_CHANGE_EVENT, handler);
return () => window.removeEventListener(FOLDER_CHANGE_EVENT, handler);
}
async getFolderData(folderId: string): Promise<FolderRecord | null> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.recordsStore], "readonly");
const store = transaction.objectStore(this.recordsStore);
const request = store.get(folderId);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(new Error("Failed to get folder data"));
});
}
async addFileToFolder(folderId: string, fileId: string, metadata?: Partial<FolderFileMetadata>): Promise<void> {
const db = await this.ensureDB();
const now = new Date();
return new Promise((resolve, reject) => {
// Single readwrite transaction for both read and write — prevents lost-update
// races when multiple files are added to the same folder concurrently.
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const getRequest = store.get(folderId);
getRequest.onsuccess = () => {
const record: FolderRecord = getRequest.result || { folderId, files: {}, lastUpdated: Date.now() };
record.files[fileId] = { addedAt: now, status: "pending", ...metadata };
record.lastUpdated = Date.now();
const putRequest = store.put(record);
putRequest.onsuccess = () => {
this.dispatchChange(folderId);
resolve();
};
putRequest.onerror = () => reject(new Error("Failed to add file to folder"));
};
getRequest.onerror = () => reject(new Error("Failed to read folder for add"));
});
}
async updateFileMetadata(folderId: string, fileId: string, updates: Partial<FolderFileMetadata>): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
// Single readwrite transaction — prevents lost-update races during concurrent
// pipeline runs where multiple files update their status simultaneously.
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const getRequest = store.get(folderId);
getRequest.onsuccess = () => {
const existing: FolderRecord | undefined = getRequest.result;
if (!existing) {
resolve();
return;
}
existing.files[fileId] = { ...existing.files[fileId], ...updates };
existing.lastUpdated = Date.now();
const putRequest = store.put(existing);
putRequest.onsuccess = () => {
this.dispatchChange(folderId);
resolve();
};
putRequest.onerror = () => reject(new Error("Failed to update file metadata"));
};
getRequest.onerror = () => reject(new Error("Failed to read folder for update"));
});
}
async removeFileFromFolder(folderId: string, fileId: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const getRequest = store.get(folderId);
getRequest.onsuccess = () => {
const existing: FolderRecord | undefined = getRequest.result;
if (!existing) {
resolve();
return;
}
delete existing.files[fileId];
existing.lastUpdated = Date.now();
const putRequest = store.put(existing);
putRequest.onsuccess = () => {
this.dispatchChange(folderId);
resolve();
};
putRequest.onerror = () => reject(new Error("Failed to remove file from folder"));
};
getRequest.onerror = () => reject(new Error("Failed to read folder for remove"));
});
}
/**
* Overwrite the entire folder record (used by sync from server).
* Pass `{ silent: true }` for server-mirror writes — these reflect a read,
* not a user action, so dispatching change events would cause subscribers
* (which themselves call getFolderData) to re-fetch in an infinite loop.
*/
async setFolderData(folderId: string, record: FolderRecord, opts?: { silent?: boolean }): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const request = store.put(record);
request.onsuccess = () => {
if (!opts?.silent) this.dispatchChange(folderId);
resolve();
};
request.onerror = () => reject(new Error("Failed to set folder data"));
});
}
async clearFolder(folderId: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const request = store.delete(folderId);
request.onsuccess = () => {
this.dispatchChange(folderId);
resolve();
};
request.onerror = () => reject(new Error("Failed to clear folder"));
});
}
}
export const folderStorage = new FolderStorage();