Jake Vanderwerf
2026-01-01 58dccc86754deda247eb49310c266f6cba86d36a
assets/js/concise/DataStore.js
@@ -6,6 +6,7 @@
 *   this.store = window.jvbStore.register('feed', { config });
 */
class DataStore {
   constructor() {
      // Singleton pattern
      if (DataStore.instance) {
@@ -14,7 +15,7 @@
      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
@@ -27,8 +28,6 @@
      this.loading = document.querySelector('dialog.loading');
      this.init();
      // window.addEventListener('beforeunload', () => this.destroy());
   }
   async init() {
@@ -69,7 +68,6 @@
            throw new Error(`Store "${config.storeName}" requires keyPath`);
         }
         const storeKey = `${name}_${config.storeName}`;
         const store = {
@@ -93,16 +91,14 @@
               // 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,
@@ -122,7 +118,6 @@
         }
      });
      // Initialize database asynchronously
      this.initDB(name).catch(error => {
         console.error(`Failed to initialize store "${name}":`, error);
@@ -146,6 +141,8 @@
         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),
@@ -157,7 +154,6 @@
         // Cache methods
         clearCache: () => this.clearCache(name),
         clearHttpHeaders: (key) => this.clearHttpHeaders(name, key),
         // Event methods
         subscribe: (callback) => this.subscribe(name, callback),
@@ -242,9 +238,10 @@
      return formData;
   }
   /**
    * Initialize database for a specific store
    */
   /***********************************************************************
    * DATABASE INITIALIZATION
    ***********************************************************************/
   async initDB(name) {
      const db = this.dbConfig.get(name);
      if (!db || db._initialized) return;
@@ -290,7 +287,7 @@
               this.loadStoreDataInBackground(storeName);
               this.notify(storeName, 'db-init');
            }
         })
         });
      } catch (error) {
         console.error(`Failed to initialize database for store "${name}":`, error);
@@ -332,29 +329,55 @@
         });
      }
      // 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');
@@ -407,71 +430,6 @@
      }
   }
   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) {
@@ -483,6 +441,42 @@
      }
   }
   /***********************************************************************
    * 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);
@@ -524,15 +518,11 @@
         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();
@@ -556,7 +546,6 @@
            }
            // 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,
@@ -581,10 +570,7 @@
         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,
@@ -628,47 +614,53 @@
      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')
      };
      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,
@@ -676,13 +668,28 @@
         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);
@@ -693,18 +700,42 @@
      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') {
@@ -719,30 +750,33 @@
      // 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
@@ -756,7 +790,7 @@
         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 };
      }
@@ -767,25 +801,27 @@
         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 });
   }
@@ -799,6 +835,64 @@
      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);
@@ -821,34 +915,48 @@
      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;
            }
         });
      }
      this.notify(name, 'filters-changed', {
         oldFilters,
         filters: store.filters,
         changed: { key, oldValue, newValue: value }
         updates
      });
      if (store.config.endpoint) {
         this.fetch(name);
         await this.fetch(name);
      }
   }
   setFilter(name, key, value) {
      return this.updateFilters(name, { [key]: value });
   }
   async setFilters(name, filters) {
      const store = this.stores.get(name);
@@ -858,87 +966,54 @@
      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());
@@ -961,49 +1036,9 @@
      });
   }
   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') {