| | |
| | | * this.store = window.jvbStore.register('feed', { config }); |
| | | */ |
| | | class DataStore { |
| | | |
| | | constructor() { |
| | | // Singleton pattern |
| | | if (DataStore.instance) { |
| | |
| | | DataStore.instance = this; |
| | | |
| | | // Shared resources |
| | | this.dbConfig = new Map(); // Definitions for the databases |
| | | this.dbConfig = new Map(); // Definitions for the databases |
| | | this.databases = new Map(); // Shared IndexedDB connections |
| | | this.stores = new Map(); // Registered store namespaces |
| | | this.subscribers = new Map(); // Per-store event subscribers |
| | |
| | | this.loading = document.querySelector('dialog.loading'); |
| | | |
| | | this.init(); |
| | | |
| | | // window.addEventListener('beforeunload', () => this.destroy()); |
| | | } |
| | | |
| | | async init() { |
| | |
| | | throw new Error(`Store "${config.storeName}" requires keyPath`); |
| | | } |
| | | |
| | | |
| | | const storeKey = `${name}_${config.storeName}`; |
| | | |
| | | const store = { |
| | |
| | | // Behavior |
| | | showLoading: false, |
| | | delayFetch: true, |
| | | validateData: true, // Validate data is serializable |
| | | validateData: true, |
| | | ...config |
| | | }, |
| | | dbKey: name, |
| | | storeKey: storeKey, |
| | | data: new Map(), |
| | | cache: new Map(), |
| | | httpHeaders: new Map(), |
| | | subscribers: new Map(), |
| | | filters: {...(config.filters || {}) }, |
| | | filters: {...(config.filters || {})}, |
| | | isFetching: false, |
| | | currentRequest: null, |
| | | lastResponse: null, |
| | |
| | | } |
| | | }); |
| | | |
| | | |
| | | // Initialize database asynchronously |
| | | this.initDB(name).catch(error => { |
| | | console.error(`Failed to initialize store "${name}":`, error); |
| | |
| | | delete: (id) => this.delete(name, id), |
| | | get: (id) => this.get(name, id), |
| | | getAll: () => this.getAll(name), |
| | | getAllByIndex: (indexName, value) => this.getAllByIndex(name, indexName, value), |
| | | filterByIndex: (criteria) => this.filterByIndex(name, criteria), |
| | | getFiltered: () => this.getFiltered(name), |
| | | clear: () => this.clear(name), |
| | | |
| | |
| | | |
| | | // Cache methods |
| | | clearCache: () => this.clearCache(name), |
| | | clearHttpHeaders: (key) => this.clearHttpHeaders(name, key), |
| | | |
| | | // Event methods |
| | | subscribe: (callback) => this.subscribe(name, callback), |
| | |
| | | return formData; |
| | | } |
| | | |
| | | /** |
| | | * Initialize database for a specific store |
| | | */ |
| | | /*********************************************************************** |
| | | * DATABASE INITIALIZATION |
| | | ***********************************************************************/ |
| | | |
| | | async initDB(name) { |
| | | const db = this.dbConfig.get(name); |
| | | if (!db || db._initialized) return; |
| | |
| | | this.loadStoreDataInBackground(storeName); |
| | | this.notify(storeName, 'db-init'); |
| | | } |
| | | }) |
| | | }); |
| | | |
| | | } catch (error) { |
| | | console.error(`Failed to initialize database for store "${name}":`, error); |
| | |
| | | }); |
| | | } |
| | | |
| | | // Cache store |
| | | // Cache store (now includes HTTP headers) |
| | | if (config.endpoint && !db.objectStoreNames.contains('cache')) { |
| | | const cacheStore = db.createObjectStore('cache', { keyPath: 'key' }); |
| | | cacheStore.createIndex('timestamp', 'timestamp', { unique: false }); |
| | | } |
| | | } |
| | | |
| | | // HTTP headers store |
| | | if (config.useHttpCaching && !db.objectStoreNames.contains('headers')) { |
| | | db.createObjectStore('headers', { keyPath: 'key' }); |
| | | /** |
| | | * Generic loader for any object store |
| | | */ |
| | | async loadFromObjectStore(name, storeName, processItem) { |
| | | const store = this.stores.get(name); |
| | | if (!store?.db || !store.db.objectStoreNames.contains(storeName)) { |
| | | return []; |
| | | } |
| | | |
| | | return new Promise((resolve) => { |
| | | const tx = store.db.transaction([storeName], 'readonly'); |
| | | const objectStore = tx.objectStore(storeName); |
| | | const request = objectStore.getAll(); |
| | | |
| | | request.onsuccess = (e) => { |
| | | const items = e.target.result || []; |
| | | items.forEach(processItem); |
| | | resolve(items); |
| | | }; |
| | | |
| | | request.onerror = () => resolve([]); |
| | | }); |
| | | } |
| | | |
| | | loadStoreDataInBackground(name) { |
| | | const store = this.stores.get(name); |
| | | if (!store?.db) return; |
| | | |
| | | const tasks = [ |
| | | this.loadStoreData(name), |
| | | this.loadStoreCache(name), |
| | | this.loadStoreHeaders(name) |
| | | ]; |
| | | Promise.all([ |
| | | // Load main data |
| | | this.loadFromObjectStore(name, store.config.storeName, (item) => { |
| | | const key = this.getItemKey(item, store.config.keyPath); |
| | | store.data.set(key, item); |
| | | }), |
| | | |
| | | Promise.all(tasks) |
| | | // Load cache (includes HTTP headers now!) |
| | | this.loadFromObjectStore(name, 'cache', (item) => { |
| | | if (this.isCacheValid(item, store.config.TTL)) { |
| | | store.cache.set(item.key, item); |
| | | } |
| | | }) |
| | | ]) |
| | | .then(() => { |
| | | this.notify(name, 'data-ready'); |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | async loadStoreData(name) { |
| | | const store = this.stores.get(name); |
| | | if (!store?.db) return; |
| | | |
| | | return new Promise((resolve) => { |
| | | const tx = store.db.transaction([store.config.storeName], 'readonly'); |
| | | const objectStore = tx.objectStore(store.config.storeName); |
| | | const request = objectStore.getAll(); |
| | | |
| | | request.onsuccess = (e) => { |
| | | const items = e.target.result || []; |
| | | items.forEach(item => { |
| | | const key = this.getItemKey(item, store.config.keyPath); |
| | | store.data.set(key, item); |
| | | }); |
| | | this.notify(name, 'data-loaded', { count: items.length }); |
| | | resolve(items); |
| | | }; |
| | | |
| | | request.onerror = () => resolve([]); |
| | | }); |
| | | } |
| | | |
| | | async loadStoreCache(name) { |
| | | const store = this.stores.get(name); |
| | | if (!store?.db || !store.db.objectStoreNames.contains('cache')) return; |
| | | |
| | | return new Promise((resolve) => { |
| | | const tx = store.db.transaction(['cache'], 'readonly'); |
| | | const objectStore = tx.objectStore('cache'); |
| | | const request = objectStore.getAll(); |
| | | |
| | | request.onsuccess = (e) => { |
| | | (e.target.result || []).forEach(item => { |
| | | if (this.isCacheValid(item, store.config.TTL)) { |
| | | store.cache.set(item.key, item); |
| | | } |
| | | }); |
| | | resolve(); |
| | | }; |
| | | |
| | | request.onerror = () => resolve(); |
| | | }); |
| | | } |
| | | |
| | | async loadStoreHeaders(name) { |
| | | const store = this.stores.get(name); |
| | | if (!store?.db || !store.db.objectStoreNames.contains('headers')) return; |
| | | |
| | | return new Promise((resolve) => { |
| | | const tx = store.db.transaction(['headers'], 'readonly'); |
| | | const objectStore = tx.objectStore('headers'); |
| | | const request = objectStore.getAll(); |
| | | |
| | | request.onsuccess = (e) => { |
| | | (e.target.result || []).forEach(header => { |
| | | store.httpHeaders.set(header.key, header); |
| | | }); |
| | | resolve(); |
| | | }; |
| | | |
| | | request.onerror = () => resolve(); |
| | | }); |
| | | } |
| | | |
| | | async ensureStoreInitialized(name) { |
| | | const store = this.stores.get(name); |
| | | if (!store) { |
| | |
| | | } |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * TRANSACTION HELPER |
| | | ***********************************************************************/ |
| | | |
| | | /** |
| | | * Create transaction helper - reduces boilerplate |
| | | */ |
| | | async withTransaction(name, storeNames, mode, callback) { |
| | | const store = this.stores.get(name); |
| | | if (!store?.db) return null; |
| | | |
| | | // Ensure storeNames is an array |
| | | if (typeof storeNames === 'string') storeNames = [storeNames]; |
| | | |
| | | return new Promise((resolve, reject) => { |
| | | const tx = store.db.transaction(storeNames, mode); |
| | | const stores = storeNames.map(name => tx.objectStore(name)); |
| | | const objectStore = stores.length === 1 ? stores[0] : stores; |
| | | |
| | | let result; |
| | | tx.oncomplete = () => resolve(result); |
| | | tx.onerror = () => reject(tx.error); |
| | | |
| | | // Call callback immediately to queue operations |
| | | try { |
| | | result = callback(objectStore, tx); |
| | | } catch (error) { |
| | | reject(error); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * FETCH & DATA PROCESSING |
| | | ***********************************************************************/ |
| | | |
| | | async fetch(name) { |
| | | await this.ensureStoreInitialized(name); |
| | | |
| | |
| | | |
| | | const url = this.buildFetchUrl(name); |
| | | const headers = { ...store.config.headers }; |
| | | const cachedHeaders = store.httpHeaders.get(cacheKey); |
| | | |
| | | if (store.config.useHttpCaching && cachedHeaders) { |
| | | if (cachedHeaders.etag) { |
| | | headers['If-None-Match'] = cachedHeaders.etag; |
| | | } |
| | | if (cachedHeaders.lastModified) { |
| | | headers['If-Modified-Since'] = cachedHeaders.lastModified; |
| | | } |
| | | // Use HTTP cache headers from cache entry |
| | | if (store.config.useHttpCaching && cached) { |
| | | if (cached.etag) headers['If-None-Match'] = cached.etag; |
| | | if (cached.lastModified) headers['If-Modified-Since'] = cached.lastModified; |
| | | } |
| | | |
| | | const controller = new AbortController(); |
| | |
| | | } |
| | | |
| | | // No cached data but server says not modified - return empty result |
| | | // This can happen on first load when cache headers exist but data doesn't |
| | | this.notify(name, 'data-loaded', { |
| | | cached: false, |
| | | notModified: true, |
| | |
| | | |
| | | const data = await response.json(); |
| | | |
| | | if (store.config.useHttpCaching) { |
| | | this.storeResponseHeaders(name, cacheKey, response); |
| | | } |
| | | await this.processFetchedData(name, data, cacheKey); |
| | | await this.processFetchedData(name, data, cacheKey, response); |
| | | |
| | | this.notify(name, 'data-loaded', { |
| | | cached: false, |
| | |
| | | return params.toString() ? `${baseUrl}?${params}` : baseUrl; |
| | | } |
| | | |
| | | async processFetchedData(name, data, cacheKey) { |
| | | /** |
| | | * Process fetched data (batch from server) |
| | | */ |
| | | async processFetchedData(name, data, cacheKey, response) { |
| | | const store = this.stores.get(name); |
| | | const items = data.items || []; |
| | | const changes = []; // Track all changes |
| | | |
| | | // Batch process all items in a single transaction |
| | | // Batch process with single transaction |
| | | if (store.db && items.length > 0) { |
| | | const tx = store.db.transaction([store.config.storeName], 'readwrite'); |
| | | const objectStore = tx.objectStore(store.config.storeName); |
| | | await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { |
| | | items.forEach(item => { |
| | | try { |
| | | // Use shared save logic |
| | | const changeInfo = this._saveItem(name, item); |
| | | changes.push(changeInfo); |
| | | |
| | | for (const item of items) { |
| | | const result = this.processForStorage(item, store.config.validateData); |
| | | if (result.valid) { |
| | | const key = this.getItemKey(result.data, store.config.keyPath); |
| | | |
| | | // Store in memory |
| | | store.data.set(key, item); |
| | | |
| | | // Queue for batch write |
| | | await objectStore.put(result.data); |
| | | } |
| | | } |
| | | |
| | | // Wait for transaction to complete |
| | | await new Promise((resolve, reject) => { |
| | | tx.oncomplete = () => resolve(); |
| | | tx.onerror = () => reject(tx.error); |
| | | // Queue for batch write |
| | | objectStore.put(changeInfo.processed); |
| | | } catch (error) { |
| | | console.error(`Error processing item:`, error); |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | } |
| | | |
| | | // Update cache (now includes HTTP headers!) |
| | | const cacheEntry = { |
| | | key: cacheKey, |
| | | items: items.map(item => this.getItemKey(item, store.config.keyPath)), |
| | | timestamp: Date.now(), |
| | | endpoint: store.config.endpoint, |
| | | filters: { ...store.filters } |
| | | filters: { ...store.filters }, |
| | | etag: response.headers.get('ETag'), |
| | | lastModified: response.headers.get('Last-Modified'), |
| | | has_more: data.has_more || false |
| | | }; |
| | | |
| | | store.cache.set(cacheKey, cacheEntry); |
| | | await this.saveToCache(name, cacheKey, cacheEntry); |
| | | |
| | | // Save cache to IndexedDB |
| | | if (store.db?.objectStoreNames.contains('cache')) { |
| | | await this.withTransaction(name, 'cache', 'readwrite', (objectStore) => { |
| | | objectStore.put(cacheEntry); |
| | | }); |
| | | } |
| | | |
| | | // Update lastResponse metadata |
| | | store.lastResponse = { |
| | | ...data, |
| | | has_more: data.has_more || false, |
| | |
| | | pages: data.pages || 1, |
| | | queue_stats: data.queue_stats || {} |
| | | }; |
| | | |
| | | // Emit events for items with status changes |
| | | changes.forEach(changeInfo => { |
| | | if (changeInfo.statusChanged) { |
| | | this.notify(name, 'item-saved', { |
| | | item: changeInfo.item, |
| | | key: changeInfo.key, |
| | | previousItem: changeInfo.previousItem |
| | | }); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * SAVE OPERATIONS |
| | | ***********************************************************************/ |
| | | |
| | | /** |
| | | * Save item to store |
| | | * IMPORTANT: Item must be serializable (no DOM, FormData, Blobs) |
| | | * Internal method: Save a single item with full tracking |
| | | * Returns change info without writing to IndexedDB (caller handles that) |
| | | */ |
| | | async save(name, item) { |
| | | _saveItem(name, item) { |
| | | const store = this.stores.get(name); |
| | | |
| | | const result = this.processForStorage(item, store.config.validateData); |
| | |
| | | |
| | | const key = this.getItemKey(processed, store.config.keyPath); |
| | | |
| | | // Store the original in memory (with original data intact) |
| | | // Capture previous state |
| | | const previousItem = store.data.get(key); |
| | | |
| | | // Update in-memory store (with original data intact) |
| | | store.data.set(key, item); |
| | | |
| | | // Store processed in IndexedDB |
| | | if (store.db) { |
| | | const tx = store.db.transaction([store.config.storeName], 'readwrite'); |
| | | const objectStore = tx.objectStore(store.config.storeName); |
| | | await objectStore.put(processed); |
| | | } |
| | | // Return change info for event emission |
| | | return { |
| | | item, |
| | | previousItem, |
| | | key, |
| | | processed, |
| | | statusChanged: previousItem && previousItem.status !== item.status |
| | | }; |
| | | } |
| | | |
| | | this.notify(name, 'item-saved', { item, key }); |
| | | return key; |
| | | /** |
| | | * Save single item (public API) |
| | | */ |
| | | async save(name, item) { |
| | | const store = this.stores.get(name); |
| | | const changeInfo = this._saveItem(name, item); |
| | | |
| | | // Write to IndexedDB immediately for single saves |
| | | await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { |
| | | objectStore.put(changeInfo.processed); |
| | | }); |
| | | |
| | | // Always emit for explicit saves |
| | | this.notify(name, 'item-saved', { |
| | | item: changeInfo.item, |
| | | key: changeInfo.key, |
| | | previousItem: changeInfo.previousItem |
| | | }); |
| | | |
| | | return changeInfo.key; |
| | | } |
| | | |
| | | processForStorage(obj, validate = true, path = 'root') { |
| | |
| | | |
| | | // Reject functions |
| | | if (type === 'function') { |
| | | return validate ? { valid: false, error: `Function at ${path}` } : { valid: true, data: null }; |
| | | if (validate) return { valid: false, error: `Function at ${path}` }; |
| | | console.debug(`[DataStore] Stripped function at ${path}`); |
| | | return { valid: true, data: undefined }; |
| | | } |
| | | |
| | | // DOM elements |
| | | if (obj instanceof HTMLElement || obj.nodeType !== undefined) { |
| | | return validate ? { valid: false, error: `DOM element at ${path}` } : { valid: true, data: null }; |
| | | if (validate) return { valid: false, error: `DOM element at ${path}` }; |
| | | console.debug(`[DataStore] Stripped DOM element at ${path}`); |
| | | return { valid: true, data: undefined }; |
| | | } |
| | | |
| | | // FormData - convert and continue |
| | | if (obj instanceof FormData) { |
| | | return validate |
| | | ? { valid: false, error: `FormData at ${path}` } |
| | | : { valid: true, data: this.formDataToObject(obj) }; |
| | | if (validate) return { valid: false, error: `FormData at ${path}` }; |
| | | console.debug(`[DataStore] Converted FormData at ${path}`); |
| | | return { valid: true, data: this.formDataToObject(obj) }; |
| | | } |
| | | |
| | | // Preserve safe types |
| | | if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) { |
| | | if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj) || obj instanceof Blob) { |
| | | return { valid: true, data: obj }; |
| | | } |
| | | |
| | | // Convert Sets to Arrays |
| | | if (obj instanceof Set) { |
| | | const arr = Array.from(obj); |
| | | return this.processForStorage(arr, validate, path); |
| | | return this.processForStorage(Array.from(obj), validate, path); |
| | | } |
| | | |
| | | // Convert Maps to Objects |
| | |
| | | for (let i = 0; i < obj.length; i++) { |
| | | const result = this.processForStorage(obj[i], validate, `${path}[${i}]`); |
| | | if (!result.valid) return result; |
| | | if (result.data !== null) processed.push(result.data); |
| | | if (result.data !== undefined) processed.push(result.data); |
| | | } |
| | | return { valid: true, data: processed }; |
| | | } |
| | |
| | | for (const [key, value] of Object.entries(obj)) { |
| | | const result = this.processForStorage(value, validate, `${path}.${key}`); |
| | | if (!result.valid) return result; |
| | | if (result.data !== null) processed[key] = result.data; |
| | | if (result.data !== undefined) processed[key] = result.data; |
| | | } |
| | | return { valid: true, data: processed }; |
| | | } |
| | | |
| | | return validate |
| | | ? { valid: false, error: `Unknown type at ${path}` } |
| | | : { valid: true, data: null }; |
| | | if (validate) return { valid: false, error: `Unknown type at ${path}` }; |
| | | console.debug(`[DataStore] Stripped unknown type at ${path}`); |
| | | return { valid: true, data: undefined }; |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * DATA ACCESS |
| | | ***********************************************************************/ |
| | | |
| | | async delete(name, id) { |
| | | const store = this.stores.get(name); |
| | | store.data.delete(id); |
| | | |
| | | if (store.db) { |
| | | const tx = store.db.transaction([store.config.storeName], 'readwrite'); |
| | | const objectStore = tx.objectStore(store.config.storeName); |
| | | await objectStore.delete(id); |
| | | } |
| | | await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { |
| | | objectStore.delete(id); |
| | | }); |
| | | |
| | | this.notify(name, 'item-deleted', { id }); |
| | | } |
| | |
| | | const store = this.stores.get(name); |
| | | return Array.from(store.data.values()); |
| | | } |
| | | /** |
| | | * Filter in-memory data by multiple index/value pairs |
| | | * @param {string} name - Store name |
| | | * @param {Object} criteria - Object of { indexName: acceptedValue(s) } |
| | | * @returns {Array} - Items matching ALL criteria |
| | | * |
| | | * @example |
| | | * filterByIndex(name, { field: 'upload_123', status: ['queued', 'uploading'] }) |
| | | */ |
| | | filterByIndex(name, criteria) { |
| | | const store = this.stores.get(name); |
| | | if (!store) return []; |
| | | |
| | | return Array.from(store.data.values()).filter(item => { |
| | | return Object.entries(criteria).every(([key, value]) => { |
| | | const accepted = Array.isArray(value) ? value : [value]; |
| | | return accepted.includes(item[key]); |
| | | }); |
| | | }); |
| | | } |
| | | /** |
| | | * Get all items matching an index value |
| | | * @param {string} name - Store name |
| | | * @param {string} indexName - Name of the index to query |
| | | * @param {*} value - Value to match |
| | | * @returns {Promise<Array>} - Matching items |
| | | */ |
| | | async getAllByIndex(name, indexName, value) { |
| | | const store = this.stores.get(name); |
| | | const values = Array.isArray(value) ? value : [value]; |
| | | |
| | | // Try IndexedDB index query first (more efficient for large datasets) |
| | | if (store.db && store.db.objectStoreNames.contains(store.config.storeName)) { |
| | | try { |
| | | const tx = store.db.transaction([store.config.storeName], 'readonly'); |
| | | const objectStore = tx.objectStore(store.config.storeName); |
| | | |
| | | if (objectStore.indexNames.contains(indexName)) { |
| | | const index = objectStore.index(indexName); |
| | | |
| | | const results = await Promise.all( |
| | | values.map(v => new Promise((resolve, reject) => { |
| | | const request = index.getAll(v); |
| | | request.onsuccess = () => resolve(request.result || []); |
| | | request.onerror = () => reject(request.error); |
| | | })) |
| | | ); |
| | | |
| | | return results.flat(); |
| | | } |
| | | } catch (error) { |
| | | console.warn(`Index query failed for "${indexName}", falling back to filter:`, error); |
| | | } |
| | | } |
| | | |
| | | // Fallback: filter in-memory data |
| | | return Array.from(store.data.values()).filter(item => values.includes(item[indexName])); |
| | | } |
| | | |
| | | getFiltered(name) { |
| | | const store = this.stores.get(name); |
| | | const cacheKey = this.generateCacheKey(store.filters); |
| | | const cacheEntry = store.cache.get(cacheKey); |
| | | |
| | | // First check if we have cached results for exact filters |
| | | if (cacheEntry && cacheEntry.items) { |
| | | return cacheEntry.items.reduce((acc, id) => { |
| | | const item = store.data.get(id); |
| | |
| | | }, []); |
| | | } |
| | | |
| | | // If we have a search filter and complete base data, filter locally |
| | | if (store.filters.search && store.filters.search.trim()) { |
| | | const searchQuery = store.filters.search.toLowerCase().trim(); |
| | | |
| | | // Get all items and filter them locally |
| | | const allItems = Array.from(store.data.values()); |
| | | |
| | | // Filter by current filters (excluding search and page) |
| | | let filtered = allItems.filter(item => { |
| | | // Apply all filters except search and page |
| | | for (const [key, value] of Object.entries(store.filters)) { |
| | | if (key === 'search' || key === 'page') continue; |
| | | |
| | | if (value !== null && value !== undefined && value !== '') { |
| | | if (item[key] !== value) return false; |
| | | } |
| | | } |
| | | return true; |
| | | }); |
| | | |
| | | // Apply search filter to common searchable fields |
| | | filtered = filtered.filter(item => { |
| | | // Search in common fields: name, title, path, description |
| | | const searchableFields = ['name', 'title', 'path', 'description', 'slug']; |
| | | |
| | | return searchableFields.some(field => { |
| | | const value = item[field]; |
| | | if (!value) return false; |
| | | return value.toLowerCase().includes(searchQuery); |
| | | }); |
| | | }); |
| | | |
| | | return filtered; |
| | | } |
| | | |
| | | // Fallback to all data |
| | | return this.getAll(name); |
| | | } |
| | | |
| | |
| | | store.data.clear(); |
| | | store.cache.clear(); |
| | | |
| | | if (store.db) { |
| | | const tx = store.db.transaction([store.config.storeName], 'readwrite'); |
| | | const objectStore = tx.objectStore(store.config.storeName); |
| | | await objectStore.clear(); |
| | | } |
| | | await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => { |
| | | objectStore.clear(); |
| | | }); |
| | | |
| | | this.notify(name, 'data-cleared'); |
| | | } |
| | | |
| | | setFilter(name, key, value) { |
| | | /*********************************************************************** |
| | | * FILTER OPERATIONS |
| | | ***********************************************************************/ |
| | | async updateFilters(name, updates, clearAll = false) { |
| | | const store = this.stores.get(name); |
| | | const oldValue = store.filters[key]; |
| | | const oldFilters = { ...store.filters }; |
| | | |
| | | if (value === null || value === undefined || value === '') { |
| | | delete store.filters[key]; |
| | | if (clearAll) { |
| | | store.filters = { ...store.config.filters }; |
| | | } else { |
| | | store.filters[key] = value; |
| | | // Apply updates (null/undefined/'' = delete) |
| | | Object.entries(updates).forEach(([key, value]) => { |
| | | if (value === null || value === undefined || value === '') { |
| | | delete store.filters[key]; |
| | | } else { |
| | | store.filters[key] = value; |
| | | } |
| | | }); |
| | | } |
| | | const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters); |
| | | |
| | | this.notify(name, 'filters-changed', { |
| | | oldFilters, |
| | | filters: store.filters, |
| | | changed: { key, oldValue, newValue: value } |
| | | updates |
| | | }); |
| | | |
| | | if (store.config.endpoint) { |
| | | this.fetch(name); |
| | | if (store.config.endpoint && shouldFetch) { |
| | | await this.fetch(name); |
| | | } else if (store.config.endpoint) { |
| | | this.notify(name, 'data-loaded'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Determine if we need to fetch or can use local data |
| | | * @param {string} name - Store name |
| | | * @param {object} updates - Filter updates being applied |
| | | * @param {object} oldFilters - Previous filter state |
| | | * @returns {Promise<boolean>} - True if fetch is needed, false if local filtering suffices |
| | | */ |
| | | async shouldFetchWithFilters(name, updates, oldFilters) { |
| | | const store = this.stores.get(name); |
| | | |
| | | // If no endpoint or no lastResponse, always fetch |
| | | if (!store.config.endpoint || !store.lastResponse) { |
| | | return true; |
| | | } |
| | | |
| | | // PAGE OPTIMIZATION: Don't fetch if trying to go beyond available pages |
| | | if ('page' in updates) { |
| | | const newPage = updates.page; |
| | | const oldPage = oldFilters.page || 1; |
| | | |
| | | // If trying to go to a higher page but no more data available |
| | | if (newPage > oldPage && !store.lastResponse.has_more) { |
| | | // Reset page to last valid page |
| | | store.filters.page = oldPage; |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | // SEARCH OPTIMIZATION: Check if we need to fetch for search |
| | | if ('search' in updates) { |
| | | const searchQuery = updates.search?.trim() || ''; |
| | | const oldSearch = oldFilters.search?.trim() || ''; |
| | | |
| | | // If search is being cleared, we might already have the data |
| | | if (!searchQuery && oldSearch) { |
| | | // Check if we have all base data (without search) |
| | | const baseFilters = { ...store.filters }; |
| | | delete baseFilters.search; |
| | | baseFilters.page = 1; |
| | | |
| | | // If we have complete base data, no need to fetch |
| | | if (this.hasCompleteData(store, baseFilters)) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | // If search is new or changed, check if we have all data to filter locally |
| | | if (searchQuery && searchQuery !== oldSearch) { |
| | | // Check: do we have all data for base filters (no search, page 1)? |
| | | const baseFilters = { ...store.filters }; |
| | | delete baseFilters.search; |
| | | baseFilters.page = 1; |
| | | |
| | | // If we have complete base data, we can filter locally |
| | | if (this.hasCompleteData(store, baseFilters)) { |
| | | return false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Default: fetch is needed |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Check if we have complete data for given filters |
| | | * @param {object} store - Store instance |
| | | * @param {object} filters - Filters to check |
| | | * @returns {boolean} - True if we have all data |
| | | */ |
| | | hasCompleteData(store, filters) { |
| | | const cacheKey = this.generateCacheKey(filters); |
| | | const cached = store.cache.get(cacheKey); |
| | | |
| | | if (!cached) return false; |
| | | |
| | | // Check if cache indicates no more data |
| | | return cached.has_more === false || store.lastResponse?.has_more === false; |
| | | } |
| | | |
| | | setFilter(name, key, value) { |
| | | return this.updateFilters(name, { [key]: value }); |
| | | } |
| | | |
| | | async setFilters(name, filters) { |
| | | const store = this.stores.get(name); |
| | | |
| | |
| | | |
| | | if (!hasChanges) return; |
| | | |
| | | store.filters = { ...store.filters, ...filters }; |
| | | |
| | | this.notify(name, 'filters-changed', { |
| | | filters: store.filters, |
| | | changed: filters |
| | | }); |
| | | |
| | | if (store.config.endpoint) { |
| | | await this.fetch(name); |
| | | } |
| | | return this.updateFilters(name, filters); |
| | | } |
| | | |
| | | removeFilter(name, key) { |
| | | const store = this.stores.get(name); |
| | | const oldValue = store.filters[key]; |
| | | |
| | | if (oldValue !== undefined) { |
| | | delete store.filters[key]; |
| | | |
| | | this.notify(name, 'filters-changed', { |
| | | filters: store.filters, |
| | | removed: { key, oldValue } |
| | | }); |
| | | |
| | | if (store.config.endpoint) { |
| | | this.fetch(name); |
| | | } |
| | | } |
| | | return this.updateFilters(name, { [key]: null }); |
| | | } |
| | | |
| | | clearFilters(name) { |
| | | const store = this.stores.get(name); |
| | | const oldFilters = { ...store.filters }; |
| | | |
| | | store.filters = { ...store.config.filters }; |
| | | |
| | | this.notify(name, 'filters-cleared', { |
| | | oldFilters, |
| | | filters: store.filters |
| | | }); |
| | | |
| | | if (store.config.endpoint) { |
| | | this.fetch(name); |
| | | } |
| | | return this.updateFilters(name, {}, true); |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * CACHE OPERATIONS |
| | | ***********************************************************************/ |
| | | |
| | | clearCache(name) { |
| | | const store = this.stores.get(name); |
| | | store.cache.clear(); |
| | | |
| | | if (store.db && store.db.objectStoreNames.contains('cache')) { |
| | | const tx = store.db.transaction(['cache'], 'readwrite'); |
| | | const objectStore = tx.objectStore('cache'); |
| | | objectStore.clear(); |
| | | if (store.db?.objectStoreNames.contains('cache')) { |
| | | this.withTransaction(name, 'cache', 'readwrite', (objectStore) => { |
| | | objectStore.clear(); |
| | | }); |
| | | } |
| | | |
| | | this.notify(name, 'cache-cleared'); |
| | | } |
| | | |
| | | clearHttpHeaders(name, cacheKey = null) { |
| | | const store = this.stores.get(name); |
| | | |
| | | if (cacheKey) { |
| | | store.httpHeaders.delete(cacheKey); |
| | | |
| | | if (store.db && store.db.objectStoreNames.contains('headers')) { |
| | | const tx = store.db.transaction(['headers'], 'readwrite'); |
| | | const objectStore = tx.objectStore('headers'); |
| | | objectStore.delete(cacheKey); |
| | | } |
| | | } else { |
| | | store.httpHeaders.clear(); |
| | | |
| | | if (store.db && store.db.objectStoreNames.contains('headers')) { |
| | | const tx = store.db.transaction(['headers'], 'readwrite'); |
| | | const objectStore = tx.objectStore('headers'); |
| | | objectStore.clear(); |
| | | } |
| | | } |
| | | generateCacheKey(filters) { |
| | | const normalized = Object.keys(filters) |
| | | .sort() |
| | | .reduce((acc, key) => { |
| | | acc[key] = filters[key]; |
| | | return acc; |
| | | }, {}); |
| | | return JSON.stringify(normalized); |
| | | } |
| | | |
| | | isCacheValid(entry, ttl) { |
| | | if (!entry || !entry.timestamp) return false; |
| | | const age = Date.now() - entry.timestamp; |
| | | return age < ttl; |
| | | } |
| | | |
| | | /*********************************************************************** |
| | | * EVENT SYSTEM |
| | | ***********************************************************************/ |
| | | |
| | | subscribe(name, callback) { |
| | | if (!this.subscribers.has(name)) { |
| | | this.subscribers.set(name, new Set()); |
| | |
| | | }); |
| | | } |
| | | |
| | | storeResponseHeaders(name, key, response) { |
| | | const store = this.stores.get(name); |
| | | |
| | | const headers = { |
| | | key, |
| | | etag: response.headers.get('ETag'), |
| | | lastModified: response.headers.get('Last-Modified'), |
| | | timestamp: Date.now() |
| | | }; |
| | | |
| | | store.httpHeaders.set(key, headers); |
| | | |
| | | if (store.db && store.db.objectStoreNames.contains('headers')) { |
| | | const tx = store.db.transaction(['headers'], 'readwrite'); |
| | | const objectStore = tx.objectStore('headers'); |
| | | objectStore.put(headers); |
| | | } |
| | | } |
| | | |
| | | async saveToCache(name, key, data) { |
| | | const store = this.stores.get(name); |
| | | if (!store.db || !store.db.objectStoreNames.contains('cache')) return; |
| | | |
| | | const tx = store.db.transaction(['cache'], 'readwrite'); |
| | | const objectStore = tx.objectStore('cache'); |
| | | await objectStore.put(data); |
| | | } |
| | | |
| | | generateCacheKey(filters) { |
| | | const normalized = Object.keys(filters) |
| | | .sort() |
| | | .reduce((acc, key) => { |
| | | acc[key] = filters[key]; |
| | | return acc; |
| | | }, {}); |
| | | return JSON.stringify(normalized); |
| | | } |
| | | |
| | | isCacheValid(entry, ttl) { |
| | | if (!entry || !entry.timestamp) return false; |
| | | const age = Date.now() - entry.timestamp; |
| | | return age < ttl; |
| | | } |
| | | /*********************************************************************** |
| | | * UTILITIES |
| | | ***********************************************************************/ |
| | | |
| | | getItemKey(item, keyPath) { |
| | | if (typeof keyPath === 'function') { |