UNPKG

@statezero/core

Version:

The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate

342 lines (341 loc) 12.7 kB
const dbConnections = new Map(); /** * Simple IndexedDB key-value store with batched operations */ export class IndexedDBStore { constructor(dbName, options = {}) { this.dbName = dbName; this.storeName = options.storeName || "keyval-store"; this.batchDelay = options.batchDelay || 50; this.version = options.version || 5; // List of error names or messages that should trigger database deletion this.resetOnErrors = options.resetOnErrors || [ "VersionError", "InvalidStateError", "Failed to open database" ]; // Queue of pending operations this.pendingOps = []; this.activeCommit = null; // Whether destroy() has been called this._destroyed = false; // Open the database once this.dbPromise = this._openDatabaseWithRecovery(); } /** * Attempt to open the database, with automatic recovery if it fails */ async _openDatabaseWithRecovery() { try { return await this._openDatabase(); } catch (error) { // Check if this is an error type that should trigger reset const shouldReset = this.resetOnErrors.some(errorPattern => error.name === errorPattern || error.message.includes(errorPattern)); if (shouldReset) { console.warn(`[IndexedDBStore] Database error detected, attempting to reset database "${this.dbName}":`, error); try { // Delete the database await this._deleteDatabase(); console.log(`[IndexedDBStore] Successfully deleted database "${this.dbName}", attempting to reopen...`); // Try to open it again at the desired version return await this._openDatabase(); } catch (resetError) { console.error(`[IndexedDBStore] Failed to recover database "${this.dbName}":`, resetError); throw resetError; } } else { // Not a recoverable error throw error; } } } /** * Open the database */ _openDatabase() { return new Promise((resolve, reject) => { const openRequest = indexedDB.open(this.dbName, this.version); openRequest.onupgradeneeded = () => { try { // Create the object store if it doesn't exist if (!openRequest.result.objectStoreNames.contains(this.storeName)) { openRequest.result.createObjectStore(this.storeName); } } catch (error) { console.error(`[IndexedDBStore] Error during upgrade:`, error); // Error during upgrade should reject the promise reject(error); } }; openRequest.onsuccess = () => resolve(openRequest.result); openRequest.onerror = () => reject(new Error(`Failed to open database: ${openRequest.error}`)); }); } /** * Delete the database */ _deleteDatabase() { return new Promise((resolve, reject) => { const deleteRequest = indexedDB.deleteDatabase(this.dbName); deleteRequest.onsuccess = () => resolve(); deleteRequest.onerror = () => reject(new Error(`Failed to delete database: ${deleteRequest.error}`)); }); } /** * Retrieve a value by key */ async get(key) { if (this._destroyed) { throw new Error("Database has been destroyed"); } // Schedule the get in the next batch const getPromise = new Promise((resolve, reject) => { this.pendingOps.push({ operation: "get", key, resolve, reject }); }); // Wait for that batch to run (or reject if destroyed) await this._startCommitIfNeeded(); return getPromise; } /** * Store a value with the given key */ async set(key, value) { if (this._destroyed) { throw new Error("Database has been destroyed"); } // Schedule the set in the next batch with a promise const setPromise = new Promise((resolve, reject) => { this.pendingOps.push({ operation: "set", key, value, resolve, reject }); }); // Wait for that batch to run (or reject if destroyed) await this._startCommitIfNeeded(); return setPromise; } /** * Remove a key-value pair */ async delete(key) { if (this._destroyed) { throw new Error("Database has been destroyed"); } // Schedule the delete in the next batch with a promise const deletePromise = new Promise((resolve, reject) => { this.pendingOps.push({ operation: "delete", key, resolve, reject }); }); // Wait for that batch to run (or reject if destroyed) await this._startCommitIfNeeded(); return deletePromise; } /** * Delete the entire database */ async destroy() { // Prevent any future actions this._destroyed = true; // Reject all pending operations const error = new Error("Database has been destroyed"); for (const op of this.pendingOps) { if (op.reject) { op.reject(error); } } this.pendingOps = []; try { // Close current connection if we have one const db = await this.dbPromise; db.close(); // Drop the whole database await new Promise((resolve, reject) => { const deleteRequest = indexedDB.deleteDatabase(this.dbName); deleteRequest.onsuccess = () => resolve(); deleteRequest.onerror = () => reject(deleteRequest.error); }); } catch (error) { // If database opening failed, we can safely ignore this // as there's no active connection to close } } /** * Kick off a batch if none is running */ _startCommitIfNeeded() { if (this._destroyed) { return Promise.reject(new Error("Database has been destroyed")); } if (!this.activeCommit) { this.activeCommit = this._executeBatch(); } return this.activeCommit; } /** * Add this method to the IndexedDBStore class */ async getAllKeys() { if (this._destroyed) { throw new Error("Database has been destroyed"); } const db = await this.dbPromise; const tx = db.transaction(this.storeName, "readonly"); const store = tx.objectStore(this.storeName); return new Promise((resolve, reject) => { const request = store.getAllKeys(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } /** * Get all entries from the store * @returns {Promise<Array<[any, any]>>} Array of [key, value] pairs */ async getAll() { if (this._destroyed) { throw new Error("Database has been destroyed"); } // Use a direct transaction for better performance with large datasets const db = await this.dbPromise; const tx = db.transaction(this.storeName, "readonly"); const store = tx.objectStore(this.storeName); // Get all keys and values const keysRequest = store.getAllKeys(); const valuesRequest = store.getAll(); // Wait for both requests to complete const [keys, values] = await Promise.all([ new Promise((resolve, reject) => { keysRequest.onsuccess = () => resolve(keysRequest.result); keysRequest.onerror = () => reject(keysRequest.error); }), new Promise((resolve, reject) => { valuesRequest.onsuccess = () => resolve(valuesRequest.result); valuesRequest.onerror = () => reject(valuesRequest.error); }) ]); // Combine keys and values into entries return keys.map((key, index) => [key, values[index]]); } /** * Execute all pending operations in one transaction */ async _executeBatch() { // Brief pause so multiple ops can be coalesced await new Promise((r) => setTimeout(r, this.batchDelay)); try { // If we were destroyed in the meantime, abort and reject all pending operations if (this._destroyed) { const error = new Error("Database has been destroyed"); // Reject all pending operations for (const op of this.pendingOps) { if (op.reject) { op.reject(error); } } this.pendingOps = []; return; } const db = await this.dbPromise; const tx = db.transaction(this.storeName, "readwrite"); const store = tx.objectStore(this.storeName); // Drain the queue for (const op of this.pendingOps) { switch (op.operation) { case "get": { const req = store.get(op.key); req.onsuccess = () => op.resolve(req.result); req.onerror = () => op.reject(req.error); break; } case "set": { const req = store.put(op.value, op.key); req.onsuccess = () => op.resolve(); req.onerror = () => op.reject(req.error); break; } case "delete": { const req = store.delete(op.key); req.onsuccess = () => op.resolve(); req.onerror = () => op.reject(req.error); break; } } } this.pendingOps = []; // Wait for the transaction to finish or fail await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = (e) => reject(e.target.error); }); } catch (error) { // If there's an error, make sure to reject all pending operations that have a reject function for (const op of this.pendingOps) { if (op.reject) { op.reject(error); } } this.pendingOps = []; throw error; // Re-throw to propagate the error } finally { // Allow a new batch to start this.activeCommit = null; } } } export class Cache { constructor(dbName, options = {}, onHydrated = null) { this.store = new IndexedDBStore(dbName, options); this.localMap = new Map(); // don't await - will hydrate during app setup this.hydrate() .then(result => { if (typeof onHydrated === 'function') { onHydrated(result); } }) .catch(err => { console.error(`Cache hydration failed for "${dbName}":`, err); }); } async hydrate() { this.localMap = new Map(await this.store.getAll()); } get(key) { return this.localMap.get(key); } set(key, value) { this.localMap.set(key, value); this.store.set(key, value); } delete(key) { this.localMap.delete(key); this.store.delete(key); } async getAllKeys() { return await this.store.getAllKeys(); } /** * Clear all entries from the cache * Clears the in-memory map and schedules deletion of all keys in IndexedDB */ clear() { // Clear the in-memory map this.localMap.clear(); // Get all keys from the store and delete them this.store.getAll() .then(entries => { // Delete each key from the store entries.forEach(([key]) => { this.store.delete(key); }); }) .catch(err => { console.error('Error clearing cache:', err); }); } }