Jake Vanderwerf
4 hours ago 56a9a1ccf764ff7a6af8f8a2292cb07443cb4aa7
assets/js/concise/DataStore.js
@@ -1,355 +1,193 @@
/**
 * ExtendedDataStore - A flexible IndexedDB wrapper with HTTP caching
 * DataStore - Singleton pattern managing multiple store namespaces
 *
 * Configuration-based approach for different storage needs:
 * - Configurable endpoint, keyPath, and indexes
 * - Built-in ETag and If-Modified-Since support
 * - Automatic DOM reference stripping
 * - TTL-based cache invalidation
 *
 * All notifications:
 *
      this.store.subscribe((event, data) => {
         switch (event) {
            case 'data-loaded':
               break;
            case 'item-saved':
               break;
            case 'items-saved':
               break;
            case 'item-deleted':
               break;
            case 'data-cleared':
               break;
            case 'filters-changed':
               break;
            case 'filters-cleared':
               break;
            case 'cache-cleared':
               break;
         }
      });
 * Usage:
 *   window.jvbStore = new DataStore();
 *   this.store = window.jvbStore.register('feed', { config });
 */
class DataStore {
   constructor(config = {}) {
      // Core configuration with sensible defaults
      this.config = {
         // Storage configuration
         name: 'default',
         version: 1,
         storeName: 'items',
         keyPath: 'id',
         indexes: [], // Array of {name, keyPath, unique}
         // API configuration
         endpoint: null,
         saveToServer: false,
         apiBase: jvbSettings.api,
         headers: {},
         filters: {},
         required:  null, //any required filters before fetching
         icon: null,
         getBlobs: null,
   constructor() {
      // Singleton pattern
      if (DataStore.instance) {
         return DataStore.instance;
      }
      DataStore.instance = this;
         // Cache configuration
         TTL: 3600000, // 1 hour default
         useHttpCaching: true, // ETag and If-Modified-Since
         cacheKeyStrategy: 'filters', // How to generate cache keys
      // Shared resources
      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.pendingInits = new Map();  // Track initialization promises
      this.fetchQueue = [];
         // UI configuration
         showLoading: true,
         // Features
         stripDOMReferences: true,
         storeBlobs: false,
         ...config
      };
      // Initialize base properties
      this.db = null;
      this.data = new Map();
      this.cache = new Map();
      this.isFetching = false;
      this.pendingFetch = null;
      this.httpHeaders = new Map();
      this.subscribers = new Set();
      this.currentRequest = null;
      this.filters = this.config.filters??{};
      // Set up headers
      this.headers = {
         'X-WP-Nonce': jvbSettings?.nonce,
         ...this.config.headers
      };
      // Global state
      this._initialized = false;
      this.body = document.body;
      this.loading = document.querySelector('dialog.loading');
      // Auto-initialize
      this.initDB();
      // Cleanup on page unload
      window.addEventListener('beforeunload', () => this.destroy());
      this.init();
   }
   /**
    * Initialize IndexedDB with configurable schema
    */
   async initDB() {
   async init() {
      if (this._initialized) return;
      this._initialized = true;
      if (!('indexedDB' in window)) {
         console.warn('IndexedDB not supported');
         return;
      }
      const dbName = `jvb_${this.config.name}_db`;
      const request = indexedDB.open(dbName, this.config.version);
      request.onupgradeneeded = (e) => {
         const db = e.target.result;
         // Create main store with configurable keyPath
         if (!db.objectStoreNames.contains(this.config.storeName)) {
            const store = db.createObjectStore(this.config.storeName, {
               keyPath: this.config.keyPath
            });
            // Add configured indexes
            this.config.indexes.forEach(index => {
               store.createIndex(
                  index.name,
                  index.keyPath || index.name,
                  { unique: index.unique || false }
               );
            });
         }
         // Cache store for HTTP responses
         if (this.config.endpoint && !db.objectStoreNames.contains('cache')) {
            const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
            cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
            cacheStore.createIndex('endpoint', 'endpoint', { unique: false });
            cacheStore.createIndex('filters', 'filters', { unique: false });
         }
         // HTTP headers store for ETag/If-Modified-Since
         if (this.config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
            db.createObjectStore('headers', { keyPath: 'key' });
         }
         if (this.config.storeBlobs && !db.objectStoreNames.contains('blobs')) {
            db.createObjectStore('blobs', { keyPath: 'uploadId' });
         }
         // Call optional schema extension
         if (this.config.onUpgrade) {
            this.config.onUpgrade(db, e.oldVersion, e.newVersion);
         }
      };
      request.onsuccess = async (e) => {
         this.db = e.target.result;
         // Load cache and headers BEFORE fetching (only if stores exist)
         const loadTasks = [this.loadFromDB()];
         if (this.db.objectStoreNames.contains('cache')) {
            loadTasks.push(this.loadCache());
         }
         if (this.config.useHttpCaching && this.db.objectStoreNames.contains('headers')) {
            loadTasks.push(this.loadHeaders());
         }
         await Promise.all(loadTasks);
         this.notify('db-init');
         // Now fetch if needed (cache might already have data)
         if (this.config.endpoint) {
            this.fetch();
         }
      };
      request.onerror = (e) => {
         console.error(`IndexedDB error for ${dbName}:`, e);
         if (this.config.onError) {
            this.config.onError(e);
         }
      };
   }
   /**
    * Load all data from IndexedDB
    * Register a new store namespace
    * @param {string} name Database Name
    * @param {object|array} configs An object defining the store, or an array of objects defining the stores
    * @param {number} version the database version
    */
   async loadFromDB() {
      if (!this.db) return;
   register(name, configs = [], version = 1.25) {
      if (!Array.isArray(configs)) configs = [configs];
      if (configs.length === 0) return;
      return new Promise(async (resolve, reject) => {
         const tx = this.db.transaction([this.config.storeName], 'readonly');
         const store = tx.objectStore(this.config.storeName);
         const request = store.getAll();
      if (!this.dbConfig.has(name)) {
         this.dbConfig.set(name, {
            dbName: `${jvbBase.base}${name}`,
            version: version,
            stores: {},
            _initialized: false
         });
      }
         request.onsuccess = async (e) => {
            const items = e.target.result;
      let dbEntry = this.dbConfig.get(name);
            // Restore FormData for ALL items on startup
            for (const item of items) {
               if (item.data?._isFormData && this.config.getBlobs) {
                  item.data = await this.objectToFormData(item.data);
               }
               const key = this.getItemKey(item);
               this.data.set(key, item);
            }
      configs.forEach(config => {
         if (!config.storeName) {
            throw new Error(`Store config for "${name}" missing storeName`);
         }
         if (!config.keyPath) {
            throw new Error(`Store "${config.storeName}" requires keyPath`);
         }
            this.notify('data-loaded', { count: items.length });
            resolve(items);
         const storeKey = `${name}_${config.storeName}`;
         const store = {
            config: {
               // Storage
               dbName: dbEntry.dbName,
               storeName: 'items',
               keyPath: 'id',
               indexes: [],
               // API
               endpoint: null,
               apiBase: jvbSettings.api,
               filters: {},
               ignore: [],       //any filters to ignore when filtering store locally
               required: null,
               isAuth: false,
               // Cache
               TTL: 3600000, // 1 hour
               useHttpCaching: true,
               // Behavior
               showLoading: false,
               delayFetch: true,
               validateData: true,
               ...config
            },
            dbKey: name,
            storeKey: storeKey,
            data: new Map(),
            cache: new Map(),
            filters: {...(config.filters || {})},
            isFetching: false,
            currentRequest: null,
            lastResponse: null,
            _initialized: false
         };
         request.onerror = (e) => reject(e);
      });
   }
         store.ignoreFilters = new Set([
            ... ['search', 'page', 'per_page', 'orderby', 'order'],
            ... ['context', 'source'],
            ... store.config.ignore
         ]);
   /**
    * Load main data from IndexedDB
    */
   async loadData() {
      if (!this.db) return;
      return new Promise((resolve, reject) => {
         const tx = this.db.transaction([this.config.storeName], 'readonly');
         const store = tx.objectStore(this.config.storeName);
         const request = store.getAll();
         request.onsuccess = (e) => {
            e.target.result.forEach(item => {
               // Strip DOM references if needed
               const cleaned = this.config.stripDOMReferences
                  ? this.stripDOMReferences(item)
                  : item;
               const key = this.getItemKey(cleaned);
               this.data.set(key, cleaned);
            });
            resolve();
         store.config.headers = {
            'X-WP-Nonce': window.auth.getNonce(),
            ...store.config.headers
         };
         request.onerror = (e) => reject(e);
         dbEntry.stores[config.storeName] = storeKey;
         this.stores.set(storeKey, store);
         if (!this.subscribers.has(storeKey)) {
            this.subscribers.set(storeKey, new Set());
         }
      });
      // Initialize database asynchronously
      this.initDB(name).catch(error => {
         console.error(`Failed to initialize store "${name}":`, error);
      });
      const apis = {};
      for (const [storeName, storeKey] of Object.entries(dbEntry.stores)) {
         apis[storeName] = this.getStoreAPI(storeKey);
      }
      return apis;
   }
   /**
    * Strip DOM references from an object (recursive)
    * Get the API object for a registered store
    */
   stripDOMReferences(obj) {
      if (!obj || typeof obj !== 'object') return obj;
   getStoreAPI(name) {
      const api = {
         // Data methods
         fetch: () => this.fetch(name),
         save: (item) => this.save(name, item),
         saveMany: (items) => this.saveMany(name, items),
         delete: (id) => this.delete(name, id),
         deleteMany: (items) => this.deleteMany(name, items),
         get: (id) => this.get(name, id),
         getMany: (ids) => this.getMany(name, ids),
         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),
      // Handle arrays
      if (Array.isArray(obj)) {
         return obj.map(item => this.stripDOMReferences(item));
      }
         // Filter methods
         setFilter: (key, value) => this.setFilter(name, key, value),
         setFilters: (filters) => this.setFilters(name, filters),
         removeFilter: (key) => this.removeFilter(name, key),
         clearFilters: () => this.clearFilters(name),
      // Handle objects
      const cleaned = {};
      for (const [key, value] of Object.entries(obj)) {
         // Skip DOM-related properties
         if (this.isDOMReference(key, value)) {
            continue;
         }
         // Cache methods
         clearCache: () => this.clearCache(name),
         // Handle Set/Map collections
         if (value instanceof Set) {
            cleaned[key] = Array.from(value);
         } else if (value instanceof Map) {
            cleaned[key] = Object.fromEntries(value);
         } else if (typeof value === 'object' && value !== null) {
            cleaned[key] = this.stripDOMReferences(value);
         } else {
            cleaned[key] = value;
         }
      }
         // Event methods
         subscribe: (callback) => this.subscribe(name, callback),
      return cleaned;
   }
         // Utility
         ensureInitialized: () => this.ensureStoreInitialized(name),
   /**
    * Check if a property is a DOM reference
    */
   isDOMReference(key, value) {
      // Check value types
      if (value instanceof HTMLElement ||
         value instanceof NodeList ||
         value instanceof HTMLCollection ||
         (value && value.nodeType !== undefined)) {
         return true;
      }
         // Exposed properties (read-only)
         get filters() {
            return { ...api.getStore().filters };
         },
         get lastResponse() {
            return api.getStore().lastResponse;
         },
         get data() {
            return api.getStore().data;
         },
      // Check key names - use exact match or word boundaries
      const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper'];
      const lowerKey = key.toLowerCase();
         getStore: () => this.stores.get(name)
      };
      // Only match if it's the exact key OR starts/ends with the pattern
      if (domKeys.includes(lowerKey) ||
         domKeys.some(k => lowerKey === k || lowerKey.startsWith(k + '_') || lowerKey.endsWith('_' + k))) {
         return true;
      }
      return false;
   }
   /**
    * Get the key for an item based on configured keyPath
    */
   getItemKey(item) {
      if (typeof this.config.keyPath === 'function') {
         return this.config.keyPath(item);
      }
      // Support nested keypaths like 'meta.id'
      const keys = this.config.keyPath.split('.');
      let value = item;
      for (const key of keys) {
         value = value?.[key];
      }
      return value;
   }
   /**
    * Save a single item
    */
   /**
    * Save a single item
    */
   async save(item) {
      const key = this.getItemKey(item);
      // Keep ORIGINAL item in memory (with FormData intact)
      this.data.set(key, item);  // ← Store original
      // Create cleaned version ONLY for IndexedDB
      let cleaned = { ...item };
      if (cleaned.data instanceof FormData) {
         cleaned.data = this.formDataToObject(cleaned.data);
      }
      if (this.config.stripDOMReferences) {
         cleaned = this.stripDOMReferences(cleaned);
      }
      // Persist cleaned version to IndexedDB
      await this.saveToDB(cleaned);
      if(this.config.endpoint){
         this.saveToServer(item);
      }
      this.notify('item-saved', { item: cleaned, key });
      return cleaned;
      return api;
   }
   /**
@@ -357,12 +195,12 @@
    */
   formDataToObject(formData) {
      const obj = {
         _isFormData: true, // Flag to reconstruct later
         _isFormData: true,
         entries: {}
      };
      for (const [key, value] of formData.entries()) {
         // Skip File/Blob objects - they're stored separately
         // Skip File/Blob objects - they're stored separately in UploadManager
         if (value instanceof File || value instanceof Blob) {
            continue;
         }
@@ -389,6 +227,7 @@
      const formData = new FormData();
      // Restore text entries
      for (const [key, value] of Object.entries(obj.entries)) {
         if (Array.isArray(value)) {
            value.forEach(v => formData.append(key, v));
@@ -396,18 +235,13 @@
            formData.append(key, value);
         }
      }
      // Restore files from external blob store (UploadManager)
      if (this.config.getBlobs && obj.entries.upload_ids) {
         const uploadIds = JSON.parse(obj.entries.upload_ids);
         const blobs = await this.config.getBlobs(uploadIds);  // ← Await here
         for (const blobData of blobs) {
            if (blobData) {
               const file = new File(
                  [blobData.data],
                  blobData.name,
                  { type: blobData.type, lastModified: blobData.lastModified }
               );
      if (window.jvbUploads && obj.entries.upload_ids) {
         const uploadIds = JSON.parse(obj.entries.upload_ids);
         for (const uploadId of uploadIds) {
            const file = await window.jvbUploads.getBlobData(uploadId);
            if (file) {
               formData.append('files[]', file);
            }
         }
@@ -416,691 +250,1248 @@
      return formData;
   }
   /**
    * Save item to IndexedDB
    */
   async saveToDB(item) {
      if (!this.db) return;
   /***********************************************************************
    * DATABASE INITIALIZATION
    ***********************************************************************/
      return new Promise((resolve, reject) => {
         const tx = this.db.transaction([this.config.storeName], 'readwrite');
         const store = tx.objectStore(this.config.storeName);
         const request = store.put(item);
   async initDB(name) {
      const db = this.dbConfig.get(name);
      if (!db || db._initialized) return;
         request.onsuccess = () => resolve();
         request.onerror = (e) => reject(e);
      });
   }
   /**
    * Batch save multiple items
    */
   async saveMany(items) {
      if (!this.db) return;
      const tx = this.db.transaction([this.config.storeName], 'readwrite');
      const store = tx.objectStore(this.config.storeName);
      const promises = items.map(item => {
         const cleaned = this.config.stripDOMReferences
            ? this.stripDOMReferences(item)
            : item;
         const key = this.getItemKey(cleaned);
         this.data.set(key, cleaned);
         return store.put(cleaned);
      });
      await Promise.all(promises);
      this.notify('items-saved', { count: items.length });
   }
   /**
    * Get a single item
    */
   get(key) {
      return this.data.get(key);  // ← Returns original with FormData
   }
   /**
    * Get all items
    */
   getAll() {
      return Array.from(this.data.values());
   }
   /**
    * Delete an item
    */
   async delete(key, storeName = null) {
      this.data.delete(key);
      if (!storeName) {
         storeName = this.config.storeName;
      }
      if (this.db) {
         const tx = this.db.transaction([storeName], 'readwrite');
         const store = tx.objectStore(storeName);
         await store.delete(key);
      if (this.pendingInits.has(name)) {
         return this.pendingInits.get(name);
      }
      this.notify('item-deleted', { key });
   }
   async saveBlob(key, blob) {
      if (!this.db) return;
      const tx = this.db.transaction(['blobs'], 'readwrite');
      const store = tx.objectStore('blobs');
      await store.put({
         uploadId: key,  // Match keyPath
         data: blob,
         type: blob.type,
         name: blob.name,
         lastModified: blob.lastModified || Date.now()
      });
   }
   async getBlob(key) {
      if (!this.db) return null;
      return new Promise(resolve => {
         const tx = this.db.transaction(['blobs'], 'readonly');
         const request = tx.objectStore('blobs').get(key);
         request.onsuccess = () => resolve(request.result);
         request.onerror = () => resolve(null);
      });
   }
   /**
    * Clear all data
    */
   async clear() {
      this.data.clear();
      this.cache.clear();
      this.httpHeaders.clear();
      if (this.domCache) {
         this.domCache.clear();
      }
      if (this.db) {
         const stores = [this.config.storeName];
         if (this.config.endpoint) stores.push('cache');
         if (this.config.useHttpCaching) stores.push('headers');
         const tx = this.db.transaction(stores, 'readwrite');
         stores.forEach(storeName => {
            if (this.db.objectStoreNames.contains(storeName)) {
               tx.objectStore(storeName).clear();
            }
         });
      }
      this.notify('data-cleared');
   }
   /**
    * Fetch data from server with HTTP caching
    */
   async fetch(options = {}) {
      if (!this.config.endpoint) {
         throw new Error('No endpoint configured for fetch');
      }
      const {
         filters = this.filters,
         headers = {},
      } = options;
      if (this.config.required && this.filters[this.config.required] === ''){
         console.log(this.config.storeName+ ': Not fetch as we don\'t have the required items');
         return;
      }
      // PREVENT CONCURRENT FETCHES FOR SAME DATA
      const cacheKey = this.generateCacheKey(filters);
      console.log('CacheKey: ', cacheKey);
      // If already fetching this exact query, return a promise that resolves when done
      if (this.isFetching && this.currentCacheKey === cacheKey) {
         return new Promise((resolve) => {
            // Store multiple waiting promises if needed
            if (!this.pendingFetches) {
               this.pendingFetches = [];
            }
            this.pendingFetches.push(resolve);
         });
      }
      this.isFetching = true;
      this.currentCacheKey = cacheKey;
      let fetchResult = null; // Capture result for pending fetches
      if (this.config.showLoading) {
         this.setLoading(true);
      }
      //Check Cached data
      const cachedData = this.cache.get(cacheKey);
      console.log('Cached Data: ', cachedData);
      if (cachedData && this.isCacheValid(cachedData)) {
         console.log('Returning cached data: ');
         this.isFetching = false;
         this.currentCacheKey = null;
         if (this.config.showLoading) {
            this.setLoading(false);
         }
         return cachedData.data;
      }
      // Build request headers with HTTP caching
      const requestHeaders = {
         ...this.headers,
         ...headers
      };
      if (this.config.useHttpCaching) {
         const httpCache = this.httpHeaders.get(cacheKey);
         if (httpCache) {
            if (httpCache.etag) {
               requestHeaders['If-None-Match'] = httpCache.etag;
            }
            if (httpCache.lastModified) {
               requestHeaders['If-Modified-Since'] = httpCache.lastModified;
            }
         }
      }
      // Build URL with filters
      const cleanedFilters = this.cleanFilters(filters);
      const params = new URLSearchParams(cleanedFilters);
      const url = `${this.config.apiBase}${this.config.endpoint}${params.toString() ? '?' + params : ''}`;
      const initPromise = this._performDBInit(name);
      this.pendingInits.set(name, initPromise);
      try {
         const response = await fetch(url, {
            method: 'GET',
            headers: requestHeaders
         await initPromise;
         db._initialized = true;
      } finally {
         this.pendingInits.delete(name);
      }
   }
   async _performDBInit(name) {
      const database = this.dbConfig.get(name);
      const { dbName, version } = database;
      const stores = Object.values(database.stores);
      try {
         if (!this.databases.has(dbName)) {
            const db = await this.openDatabase(dbName, version, (db) => {
               stores.forEach(store => {
                  let storeObj = this.stores.get(store);
                  if (storeObj) {
                     this.setupStores(db, storeObj.config);
                  }
               });
            });
            this.databases.set(dbName, db);
         }
         stores.forEach(storeName => {
            let store = this.stores.get(storeName);
            if (store) {
               store.db = this.databases.get(dbName);
               store._initialized = true;
               this.loadStoreDataInBackground(storeName);
               this.notify(storeName, 'db-init');
            }
         });
         // Handle 304 Not Modified
         if (response.status === 304 && cachedData) {
            // Update timestamp but keep existing data
            cachedData.timestamp = Date.now();
            cachedData.fromCache = true;
            cachedData.isError = false;
            this.saveCache(cacheKey, cachedData);
            console.log(this.config.storeName+' Data loaded from cache');
            this.notify('data-loaded', cachedData);
            fetchResult = cachedData.data;
            return cachedData.data;
      } catch (error) {
         console.error(`Failed to initialize database for store "${name}":`, error);
         throw error;
      }
   }
   openDatabase(dbName, version, onUpgrade) {
      return new Promise((resolve, reject) => {
         const request = indexedDB.open(dbName, version);
         request.onupgradeneeded = (e) => {
            if (onUpgrade) {
               onUpgrade(e.target.result, e.oldVersion, e.newVersion);
            }
         };
         request.onsuccess = (e) => resolve(e.target.result);
         request.onerror = (e) => reject(e.target.error);
         request.onblocked = () => {
            console.warn(`Database ${dbName} blocked. Close other tabs.`);
         };
      });
   }
   setupStores(db, config) {
      // Main store
      if (!db.objectStoreNames.contains(config.storeName)) {
         const store = db.createObjectStore(config.storeName, {
            keyPath: config.keyPath
         });
         config.indexes.forEach(index => {
            store.createIndex(
               index.name,
               index.keyPath || index.name,
               { unique: index.unique || false }
            );
         });
      }
      // 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 });
      }
   }
   /**
    * 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;
      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);
         }),
         // 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');
            // Add to fetch queue instead of immediate fetch
            if (store.config.endpoint && store.config.delayFetch) {
               this.fetchQueue.push(name);
               // Start processing queue if not already running
               if (this.fetchQueue.length === 1) {
                  this.processFetchQueue();
               }
            } else if (store.config.endpoint && !store.config.delayFetch) {
               // Immediate fetch
               if ('requestIdleCallback' in window) {
                  requestIdleCallback(() => this.fetch(name), { timeout: 2000 });
               } else {
                  setTimeout(() => this.fetch(name), 100);
               }
            }
         })
         .catch(error => {
            console.error(`Background load error for store "${name}":`, error);
         });
   }
   async processFetchQueue() {
      if (this.fetchQueue.length === 0) return;
      const name = this.fetchQueue.shift();
      const store = this.stores.get(name);
      if (!store) {
         // Store was removed, continue with next
         return this.processFetchQueue();
      }
      try {
         await this.fetch(name);
      } catch (error) {
         console.error(`Queue fetch error for "${name}":`, error);
      }
      // Process next item with idle callback
      if (this.fetchQueue.length > 0) {
         if ('requestIdleCallback' in window) {
            requestIdleCallback(() => this.processFetchQueue(), { timeout: 2000 });
         } else {
            setTimeout(() => this.processFetchQueue(), 50);
         }
      }
   }
   async ensureStoreInitialized(name) {
      const store = this.stores.get(name);
      if (!store) {
         throw new Error(`Store "${name}" not registered`);
      }
      if (!store._initialized) {
         await this.initDB(store.dbKey);
      }
   }
   /***********************************************************************
    * 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 = () => {
            const error = tx.error || new Error('Transaction failed with unknown error');
            reject(error);
         };
         // Call callback immediately to queue operations
         try {
            result = callback(objectStore, tx);
         } catch (error) {
            reject(error || new Error('Callback failed with unknown error'));
         }
      });
   }
   /***********************************************************************
    * FETCH & DATA PROCESSING
    ***********************************************************************/
   async fetch(name) {
      await this.ensureStoreInitialized(name);
      const store = this.stores.get(name);
      if (store.isFetching) return;
      // Check required filters
      if (store.config.required) {
         const required = Array.isArray(store.config.required)
            ? store.config.required
            : [store.config.required];
         const missing = required.some(key =>
            !store.filters[key] || store.filters[key] === ''
         );
         if (missing) return;
      }
      store.isFetching = true;
      try {
         // Check cache
         const cacheKey = this.generateCacheKey(store.filters);
         const cached = store.cache.get(cacheKey);
         if (cached && this.isCacheValid(cached, store.config.TTL)) {
            let items = cached.items.map(itemId => this.get(name, itemId));
            this.notify(name, 'data-loaded', {
               cached: true,
               items: items??[]
            });
            return cached;
         }
         if (store.config.showLoading) {
            this.setLoading(true);
         }
         const url = this.buildFetchUrl(name);
         const headers = { ...store.config.headers };
         // 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();
         store.currentRequest = controller;
         let response;
         if (store.isAuth) {
            response = await window.auth.fetch(url, {
               method: 'GET',
               headers,
               signal: controller.signal
            });
         } else {
            response = await fetch(url, {
               method: 'GET',
               headers,
               signal: controller.signal
            });
         }
         if (!response.ok) {
            // Access the error details from the response body
            const errorBody = await response.text();
            // Throw a new error with a descriptive message
            throw new Error(`HTTP error! status: ${response.status}, message: ${errorBody}`);
         }
         if (response.status === 304) {
            // 304 means "Not Modified" - use cached data if available
            if (cached) {
               this.notify(name, 'data-loaded', {
                  cached: true,
                  notModified: true,
                  items: cached.items || []
               });
               return cached;
            }
            // No cached data but server says not modified - return empty result
            this.notify(name, 'data-loaded', {
               cached: false,
               notModified: true,
               items: []
            });
            // Initialize empty lastResponse
            store.lastResponse = {
               has_more: false,
               total: 0,
               pages: 1,
               queue_stats: {}
            };
            return { items: [] };
         }
         // Now check for other non-OK responses
         if (!response.ok) {
            throw new Error(`HTTP ${response.status}: ${response.statusText}`);
         }
         const data = await response.json();
         // Store HTTP caching headers
         if (this.config.useHttpCaching) {
            this.storeResponseHeaders(cacheKey, response);
         }
         await this.processFetchedData(name, data, cacheKey, response);
         // Cache the response
         const cacheEntry = {
            key: cacheKey,
            data: data,
            timestamp: Date.now(),
            endpoint: this.config.endpoint,
            filters: filters
         };
         console.log(this.config.storeName + 'Fetched fresh from server');
         this.cache.set(cacheKey, cacheEntry);
         this.saveCache(cacheKey, cacheEntry);
         let items = (Array.isArray(data)) ? data : data.items;
         await this.saveMany(items);
         this.notify('data-loaded', {
            data: {
               items: items,
               ...data
            },
            count: items.length,
            filters: filters,
            fromCache: false,
            isError: false
         this.notify(name, 'data-loaded', {
            cached: false,
            items: data.items || []
         });
         fetchResult = data;
         return data;
      } catch (error) {
         console.error('Fetch error:', error);
         const isAbortError = error?.name === 'AbortError';
         // Return cached data if available, even if expired
         if (cachedData) {
            console.warn('Using stale cache due to fetch error');
            cachedData.isError = true;
            this.notify('data-loaded', cachedData);
            fetchResult = cachedData.data;
            return cachedData.data;
         if (!isAbortError) {
            console.error(`Fetch error for store "${name}":`, error.message);
            console.dir(error);
            this.notify(name, 'fetch-error', { error });
            throw error;
         }
         throw error;
      } finally {
         if (this.config.showLoading) {
         store.isFetching = false;
         store.currentRequest = null;
         if (store.config.showLoading) {
            this.setLoading(false);
         }
         this.isFetching = false;
         this.currentCacheKey = null;
         // Resolve any pending fetches that were waiting
         if (this.pendingFetches && this.pendingFetches.length > 0) {
            this.pendingFetches.forEach(resolve => resolve(fetchResult));
            this.pendingFetches = [];
         }
      }
   }
   /**
    * Fetch data from server with HTTP caching
    */
   async saveToServer(item) {
      if (!this.config.saveToServer || !jvbSettings.currentUser) {
         return;
      }
      if (!this.config.endpoint && this.config.saveToServer) {
         throw new Error('No endpoint configured for saving to server');
      }
   buildFetchUrl(name) {
      const store = this.stores.get(name);
      const params = new URLSearchParams();
      let requestBody;
      let headers = this.config.headers;
      headers['X-WP-Nonce'] = jvbSettings.nonce;
      if (item instanceof FormData) {
         item.append('user', jvbSettings.currentUser);
         requestBody = item;
         // console.log('Sending formData: ');
         // for (const pair of requestBody.entries()) {
         //    console.log(pair[0], pair[1]);
         // }
      } else {
         requestBody = JSON.stringify({
            ...item,
            user: jvbSettings.currentUser
         });
         // console.log('Sending data: ', {
         //    ...operation.data,
         //    id: operation.id,
         //    user: this.user
         // });
         headers['Content-Type'] = 'application/json';
      }
      const response = await fetch(
         `${this.config.apiBase}${this.config.endpoint}`,
         {
            method: 'POST',
            headers: headers,
            body: requestBody
         }
      );
      const result = await response.json();
      this.notify(
         'saved-to-server',
         {
            success: result.ok && result.success
         }
      );
   }
   cleanFilters(filters) {
      const cleaned = {};
      Object.entries(filters).forEach(([key, value]) => {
      Object.entries(store.filters).forEach(([key, value]) => {
         if (value !== null && value !== undefined && value !== '') {
            // Handle special cases based on existing patterns
            if (key === 'taxonomies' && typeof value === 'object') {
               Object.entries(value).forEach(([taxName, terms]) => {
                  if (Array.isArray(terms) && terms.length > 0) {
                     cleaned[`tax_${taxName}`] = terms.join(',');
                  } else if (terms) {
                     cleaned[`tax_${taxName}`] = terms;
                  }
               });
            } else if (key === 'date' && typeof value === 'object') {
               if (value.after) cleaned.after = value.after;
               if (value.before) cleaned.before = value.before;
            if (typeof value === 'object') {
               params.set(key, JSON.stringify(value));
            } else {
               cleaned[key] = value;
               params.set(key, value);
            }
         }
      });
      return cleaned;
      const baseUrl = store.config.apiBase + store.config.endpoint;
      return params.toString() ? `${baseUrl}?${params}` : baseUrl;
   }
   /**
    * Generate cache key from filters
    * Process fetched data (batch from server)
    */
   generateCacheKey(filters) {
      if (this.config.cacheKeyStrategy === 'custom' && this.config.generateCacheKey) {
         return this.config.generateCacheKey(filters);
   async processFetchedData(name, data, cacheKey, response) {
      const store = this.stores.get(name);
      const items = (data.items || []).filter(item => item && typeof item === 'object');
      const changes = [];
      // Batch process with single transaction
      if (store.db && items.length > 0) {
         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);
                  // Queue for batch write
                  objectStore.put(changeInfo.processed);
               } catch (error) {
                  console.error(`Error processing item:`, error);
               }
            });
         });
      }
      // Default strategy: sort keys and create string
      const sorted = Object.keys(filters)
      // 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 },
         etag: response.headers.get('ETag'),
         lastModified: response.headers.get('Last-Modified'),
         has_more: data.has_more || false
      };
      store.cache.set(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,
         total: data.total || items.length,
         pages: data.pages || 1,
         queue_stats: data.queue_stats || {}
      };
      for (let [key, value] of Object.entries(store.filters)) {
         if (typeof value === 'string' && value.includes(',')) {
            this.createSplitCacheEntries(name, items, key, store.filters, response);
         }
      }
      // 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
            });
         }
      });
   }
   createSplitCacheEntries(name, items, key, filters, response) {
      const store = this.stores.get(name);
      const keys = filters[key].split(',').map(v => v.trim());
      keys.forEach(value => {
         let temp = {};
         temp[key] = value;
         const newFilters = {
            ... filters,
            [key]: value
         };
         const cacheKey = this.generateCacheKey(newFilters);
         if(store.cache.has(cacheKey)) return;
         let filteredItems = this.filterByIndex(name,temp).map(item => this.getItemKey(item, store.config.keyPath));
         const entry = {
            key: cacheKey,
            items: filteredItems,
            timestamp: Date.now(),
            endpoint: store.config.endpoint,
            filters: newFilters,
            etag: response.headers.get('Etag'),
            lastModified: response.headers.get('Last-Modified'),
            has_more: filteredItems.length === 20,
         }
         store.cache.set(cacheKey, entry);
         if (store.db?.objectStoreNames.contains('cache')) {
            this.withTransaction(name, 'cache', 'readwrite', (objectStore) =>{
               objectStore.put(entry);
            });
         }
      })
   }
   /***********************************************************************
    * SAVE OPERATIONS
    ***********************************************************************/
   /**
    * Internal method: Save a single item with full tracking
    * Returns change info without writing to IndexedDB (caller handles that)
    */
   _saveItem(name, item) {
      const store = this.stores.get(name);
      const result = this.processForStorage(item, store.config.validateData);
      if (!result.valid) {
         throw new Error(`Non-serializable data: ${result.error}`);
      }
      const processed = result.data;
      const key = this.getItemKey(processed, store.config.keyPath);
      // Capture previous state
      const previousItem = store.data.get(key);
      // Update in-memory store (with original data intact)
      store.data.set(key, item);
      // Return change info for event emission
      return {
         item,
         previousItem,
         key,
         processed,
         statusChanged: previousItem && previousItem.status !== item.status
      };
   }
   /**
    * 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;
   }
   /**
    * Save multiple items in a single transaction (batch write)
    * @param {string} name - Store name
    * @param {Array|Map} items - Array of items or Map of items to save
    * @returns {Promise<Array>} - Array of saved keys
    */
   async saveMany(name, items) {
      const store = this.stores.get(name);
      if (!store) return [];
      // Convert Map to array if needed
      const itemArray = items instanceof Map
         ? Array.from(items.values())
         : Array.isArray(items) ? items : Object.values(items);
      if (itemArray.length === 0) return [];
      const changes = [];
      // Process all items and update in-memory store
      itemArray.forEach(item => {
         const changeInfo = this._saveItem(name, item);
         changes.push(changeInfo);
      });
      // Single transaction for all writes
      await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
         changes.forEach(changeInfo => {
            objectStore.put(changeInfo.processed);
         });
      });
      // Notify once for batch
      this.notify(name, 'items-saved', {
         count: changes.length,
         keys: changes.map(c => c.key)
      });
      return changes.map(c => c.key);
   }
   processForStorage(obj, validate = true, path = 'root') {
      if (obj === null) {
         return { valid: true, data: null };
      }
      if (obj === undefined) {
         if (validate) {
            return { valid: false, error: `Undefined value at ${path}` };
         }
         return { valid: true, data: undefined };
      }
      const type = typeof obj;
      // Handle primitives
      if (['string', 'number', 'boolean'].includes(type)) {
         return { valid: true, data: obj };
      }
      // Reject functions
      if (type === 'function') {
         if (validate) return { valid: false, error: `Function at ${path}` };
         return { valid: true, data: undefined };
      }
      // DOM elements
      if (obj instanceof HTMLElement || obj.nodeType !== undefined) {
         if (validate) return { valid: false, error: `DOM element at ${path}` };
         return { valid: true, data: undefined };
      }
      // FormData - convert and continue
      if (obj instanceof FormData) {
         return { valid: true, data: this.formDataToObject(obj) };
      }
      // Preserve safe types
      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) {
         return this.processForStorage(Array.from(obj), validate, path);
      }
      // Convert Maps to Objects
      if (obj instanceof Map) {
         obj = Object.fromEntries(obj);
      }
      // Arrays
      if (Array.isArray(obj)) {
         const processed = [];
         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 !== undefined) processed.push(result.data);
         }
         return { valid: true, data: processed };
      }
      // Objects
      if (type === 'object') {
         const processed = {};
         for (const [key, value] of Object.entries(obj)) {
            if (value === undefined) continue;
            const result = this.processForStorage(value, validate, `${path}.${key}`);
            if (!result.valid) return result;
            // Include null values, skip undefined
            if (result.data !== undefined || value === null) {
               processed[key] = result.data;
            }
         }
         return { valid: true, data: processed };
      }
      if (validate) return { valid: false, error: `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);
      await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
         objectStore.delete(id);
      });
      this.notify(name, 'item-deleted', { id });
   }
   /**
    * Delete multiple items in a single transaction (batch delete)
    * @param {string} name - Store name
    * @param {Array|Set} ids - Array or Set of IDs to delete
    * @returns {Promise<Array>} - Array of deleted IDs
    */
   async deleteMany(name, ids) {
      const store = this.stores.get(name);
      if (!store) return [];
      // Convert Set to array if needed
      const idArray = ids instanceof Set
         ? Array.from(ids)
         : Array.isArray(ids) ? ids : Object.keys(ids);
      if (idArray.length === 0) return [];
      // Update in-memory store
      idArray.forEach(id => {
         store.data.delete(id);
      });
      // Single transaction for all deletes
      await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
         idArray.forEach(id => {
            objectStore.delete(id);
         });
      });
      // Notify once for batch
      this.notify(name, 'items-deleted', {
         count: idArray.length,
         ids: idArray
      });
      return idArray;
   }
   get(name, id) {
      const store = this.stores.get(name);
      return store.data.get(id);
   }
   /**
    * Get multiple items by IDs in a single call
    * @param {string} name - Store name
    * @param {Array|Set} ids - Array or Set of IDs to retrieve
    * @param {boolean} skipMissing - If true, omit missing items; if false, include null for missing
    * @returns {Array} - Array of items (in same order as IDs)
    */
   getMany(name, ids, skipMissing = true) {
      const store = this.stores.get(name);
      if (!store) return [];
      const idArray = ids instanceof Set
         ? Array.from(ids)
         : Array.isArray(ids) ? ids : Object.keys(ids);
      if (idArray.length === 0) return [];
      if (skipMissing) {
         return idArray.reduce((acc, id) => {
            const item = store.data.get(id);
            if (item) acc.push(item);
            return acc;
         }, []);
      }
      // Preserve order, include null for missing
      return idArray.map(id => store.data.get(id) ?? null);
   }
   getAll(name) {
      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 => {
         if (!item || typeof item !== 'object') return false;
         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?.items) {
         const items = cacheEntry.items.reduce((acc, id) => {
            const item = store.data.get(id);
            if (item) acc.push(item);
            return acc;
         }, []);
         return this.applyOrdering(items, store);
      }
      const allItems = Array.from(store.data.values());
      const searchQuery = store.filters.search?.toLowerCase().trim() || '';
      const filterPredicates = [];
      // Handle taxonomy filters separately
      if (store.filters.taxonomy && typeof store.filters.taxonomy === 'object') {
         Object.entries(store.filters.taxonomy).forEach(([taxonomy, termIds]) => {
            const acceptedTermIds = Array.isArray(termIds) ? termIds : [termIds];
            filterPredicates.push(item => {
               if (!item.taxonomies || !item.taxonomies[taxonomy]) {
                  return false;
               }
               const itemTermIds = Object.keys(item.taxonomies[taxonomy]).map(id => parseInt(id));
               const matches = acceptedTermIds.some(termId => itemTermIds.includes(parseInt(termId)));
               return matches;
            });
         });
      }
      // Handle other filters
      for (const [key, value] of Object.entries(store.filters)) {
         if (key === 'taxonomy') {
            if (typeof value === 'string' && !value.includes(',')) {
               filterPredicates.push(item => item.taxonomy === value);
            }
            continue;
         }
         if (store.ignoreFilters.has(key)) {
            continue;
         }
         if (value === null || value === undefined || value === '') continue;
         if (value === 'all') continue;
         if (typeof value === 'string' && value.includes(',')) {
            const accepted = value.split(',').map(v => v.trim());
            filterPredicates.push(item => accepted.includes(String(item[key])));
         } else {
            filterPredicates.push(item => String(item[key]) === String(value));
         }
      }
      const filtered = allItems.filter(item => {
         for (const predicate of filterPredicates) {
            if (!predicate(item)) return false;
         }
         return !(searchQuery && !this.searchObject(item, searchQuery));
      });
      return this.applyOrdering(filtered, store);
   }
   applyOrdering(items, store) {
      if (!Array.isArray(items)) items = Array.from(items);
      if (items.length === 0) return items;
      const orderby = store.filters.orderby || 'date';
      const order = (store.filters.order || 'desc').toLowerCase();
      // Handle random ordering
      if (['random', 'rand'].includes(orderby) || ['random', 'rand'].includes(order)) {
         return this.shuffle(items);
      }
      items.sort((a, b) => {
         let aVal, bVal;
         switch (orderby) {
            case 'alphabetical':
            case 'title':
               aVal = (a.title || a.name || '').toLowerCase();
               bVal = (b.title || b.name || '').toLowerCase();
               break;
            case 'modified':
               aVal = new Date(a.modified || a.date || 0);
               bVal = new Date(b.modified || b.date || 0);
               break;
            case 'date':
            default:
               aVal = new Date(a.date || a.modified || 0);
               bVal = new Date(b.date || b.modified || 0);
         }
         if (aVal < bVal) return order === 'asc' ? -1 : 1;
         if (aVal > bVal) return order === 'asc' ? 1 : -1;
         return 0;
      });
      return items;
   }
   shuffle(items) {
      const array = items.slice();
      for (let i = array.length - 1; i > 0; i--) {
         const j = Math.floor(Math.random() * (i + 1));
         [array[i], array[j]] = [array[j], array[i]];
      }
      return array;
   }
   searchObject(obj, search) {
      if (!obj || typeof obj !== 'object') {
         return typeof obj === 'string' && obj.toLowerCase().includes(search);
      }
      for (const value of Object.values(obj)) {
         if (value === null || value === undefined) continue;
         if (typeof value === 'object') {
            if (this.searchObject(value, search)) return true;
            continue;
         }
         if (typeof value === 'string' && value.toLowerCase().includes(search)) {
            return true;
         }
      }
      return false;
   }
   async clear(name) {
      const store = this.stores.get(name);
      store.data.clear();
      store.cache.clear();
      await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
         objectStore.clear();
      });
      this.notify(name, 'data-cleared');
   }
   /***********************************************************************
    * FILTER OPERATIONS
    ***********************************************************************/
   async updateFilters(name, updates, clearAll = false) {
      const store = this.stores.get(name);
      const oldFilters = { ...store.filters };
      if (clearAll) {
         store.filters = { ...store.config.filters };
      }
      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,
         updates
      });
      const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters);
      if (store.config.endpoint && shouldFetch) {
         await this.fetch(name);
      } else {
         const filtered = this.getFiltered(name);
         this.notify(name, 'data-loaded', {
            cached: true,
            items: filtered
         });
      }
   }
   /**
    * 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;
      }
      if (store.lastResponse.has_more === false) {
         if (this.hasCompleteData(store, store.filters)) {
            return false;
         }
      }
      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);
      const hasChanges = Object.keys(filters).some(
         key => store.filters[key] !== filters[key]
      ) || Object.keys(store.filters).some(
         key => !(key in filters) && filters !== store.config.filters
      );
      if (!hasChanges) return;
      return this.updateFilters(name, filters);
   }
   removeFilter(name, key) {
      return this.updateFilters(name, { [key]: null });
   }
   clearFilters(name) {
      return this.updateFilters(name, {}, true);
   }
   /***********************************************************************
    * CACHE OPERATIONS
    ***********************************************************************/
   clearCache(name) {
      const store = this.stores.get(name);
      store.cache.clear();
      if (store.db?.objectStoreNames.contains('cache')) {
         this.withTransaction(name, 'cache', 'readwrite', (objectStore) => {
            objectStore.clear();
         });
      }
      this.notify(name, 'cache-cleared');
   }
   generateCacheKey(filters) {
      const normalized = Object.keys(filters)
         .sort()
         .reduce((acc, key) => {
            acc[key] = filters[key];
            return acc;
         }, {});
      return JSON.stringify(sorted);
      return JSON.stringify(normalized);
   }
   setFilter(key, value) {
      if (!this.filters) {
         this.filters = {};
   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());
      }
      const oldValue = this.filters[key];
      if (oldValue === value) {
         return;
      }else if (value === '' || value === null || value === undefined) {
         delete this.filters[key];
      } else {
         this.filters[key] = value;
      }
      this.notify('filters-changed', {
         filters: this.filters,
         changed: { key, oldValue, newValue: value }
      });
      // Auto-fetch if endpoint is configured
      if (this.config.endpoint) {
         window.debouncer.schedule(
            this.config.endpoint,
            this.fetch.bind(this),
            100
         );
      }
      const subscribers = this.subscribers.get(name);
      subscribers.add(callback);
      return () => subscribers.delete(callback);
   }
   notify(name, event, data = {}) {
      const subscribers = this.subscribers.get(name);
      if (!subscribers) return;
   /**
    * Remove a filter
    */
   removeFilter(key) {
      const oldValue = this.filters[key];
      if (oldValue !== undefined) {
         delete this.filters[key];
         this.notify('filters-changed', {
            filters: this.filters,
            removed: { key, oldValue }
         });
         // Auto-fetch if endpoint is configured
         if (this.config.endpoint) {
            window.debouncer.schedule(
               this.config.endpoint,
               this.fetch.bind(this),
               100
            );
         }
      }
   }
   /**
    * Clear all filters
    */
   clearFilters() {
      const oldFilters = { ...this.filters };
      //Restore baseline filters
      this.filters = this.config.filters;
      this.notify('filters-cleared', {
         oldFilters,
         filters: this.filters
      });
      // Auto-fetch if endpoint is configured
      if (this.config.endpoint) {
         this.fetch();
      }
   }
   /**
    * Set multiple filters at once
    */
   async setFilters(filters) {
      const hasChanges = Object.keys(filters).some(
         key => this.filters[key] !== filters[key]
      );
      if (!hasChanges) {
         return;
      }
      this.filters = { ...this.filters, ...filters };
      this.notify('filters-changed', {
         filters: this.filters,
         changed: filters,
      });
      // Only fetch if endpoint configured
      if (this.config.endpoint) {
         window.debouncer.schedule(
            this.config.endpoint,
            this.fetch.bind(this),
            100
         );
      }
   }
   /**
    * Check if cache entry is still valid
    */
   isCacheValid(cacheEntry) {
      if (!cacheEntry || !cacheEntry.timestamp) return false;
      const age = Date.now() - cacheEntry.timestamp;
      return age < this.config.TTL;
   }
   /**
    * Store HTTP response headers for caching
    */
   storeResponseHeaders(key, response) {
      const headers = {
         key,
         etag: response.headers.get('ETag'),
         lastModified: response.headers.get('Last-Modified'),
         timestamp: Date.now()
      };
      this.httpHeaders.set(key, headers);
      if (this.db && this.db.objectStoreNames.contains('headers')) {
         const tx = this.db.transaction(['headers'], 'readwrite');
         const store = tx.objectStore('headers');
         store.put(headers);
      }
   }
   /**
    * Save cache entry to IndexedDB
    */
   async saveCache(key, data) {
      if (!this.db || !this.db.objectStoreNames.contains('cache')) return;
      const tx = this.db.transaction(['cache'], 'readwrite');
      const store = tx.objectStore('cache');
      await store.put(data);
   }
   /**
    * Load cache from IndexedDB
    */
   async loadCache() {
      if (!this.db) return;
      return new Promise((resolve) => {
         const tx = this.db.transaction(['cache'], 'readonly');
         const store = tx.objectStore('cache');
         const request = store.getAll();
         request.onsuccess = (e) => {
            e.target.result.forEach(item => {
               if (this.isCacheValid(item)) {
                  this.cache.set(item.key, item);
               }
            });
            resolve();
         };
      });
   }
   /**
    * Load HTTP headers from IndexedDB
    */
   async loadHeaders() {
      if (!this.db) return;
      return new Promise((resolve) => {
         const tx = this.db.transaction(['headers'], 'readonly');
         const store = tx.objectStore('headers');
         const request = store.getAll();
         request.onsuccess = (e) => {
            e.target.result.forEach(header => {
               this.httpHeaders.set(header.key, header);
            });
            resolve();
         };
      });
   }
   /**
    * Subscribe to store events
    */
   subscribe(callback) {
      this.subscribers.add(callback);
      return () => this.subscribers.delete(callback);
   }
   /**
    * Notify subscribers of events
    */
   notify(event, data = {}) {
      this.subscribers.forEach(callback => {
      subscribers.forEach(callback => {
         try {
            callback(event, data);
         } catch (error) {
            console.error('Subscriber error:', error);
            console.error(`Subscriber error for store "${name}":`, error);
         }
      });
   }
   /**
    * Query items using an index
    */
   async query(indexName, value) {
      if (!this.db) return [];
   /***********************************************************************
    * UTILITIES
    ***********************************************************************/
      return new Promise((resolve, reject) => {
         const tx = this.db.transaction([this.config.storeName], 'readonly');
         const store = tx.objectStore(this.config.storeName);
   getItemKey(item, keyPath) {
      if (typeof keyPath === 'function') {
         return keyPath(item);
      }
         if (!store.indexNames.contains(indexName)) {
            reject(new Error(`Index ${indexName} does not exist`));
            return;
         }
      const keys = keyPath.split('.');
      let value = item;
         const index = store.index(indexName);
         const request = value !== undefined
            ? index.getAll(value)
            : index.getAll();
      for (const key of keys) {
         value = value?.[key];
      }
         request.onsuccess = (e) => {
            const results = e.target.result.map(item => {
               return this.config.stripDOMReferences
                  ? this.stripDOMReferences(item)
                  : item;
            });
            resolve(results);
         };
         request.onerror = (e) => reject(e);
      });
      return value;
   }
   /**
    * Count items in store
    */
   async count() {
      if (!this.db) return this.data.size;
      return new Promise((resolve, reject) => {
         const tx = this.db.transaction([this.config.storeName], 'readonly');
         const store = tx.objectStore(this.config.storeName);
         const request = store.count();
         request.onsuccess = (e) => resolve(e.target.result);
         request.onerror = (e) => reject(e);
      });
   }
   setLoading(on) {
      console.log('Setting Loading ' + (on) ? 'on' : 'off' + ' from '.this.config.storeName);
      this.body.classList.toggle('loading', on);
      if (on) {
         this.loading.showModal();
         this.loading?.showModal();
      } else {
         this.loading.close();
         this.loading?.close();
      }
   }
   /**
    * Cleanup and destroy
    */
   destroy() {
      if (this.currentRequest) {
         this.currentRequest.abort();
      }
      this.stores.forEach(store => {
         if (store.currentRequest) {
            store.currentRequest.abort();
         }
      });
      this.databases.forEach(db => db.close());
      this.stores.clear();
      this.subscribers.clear();
      this.data.clear();
      this.cache.clear();
      this.httpHeaders.clear();
      if (this.db) {
         this.db.close();
         this.db = null;
      }
   }
   clearCache() {
      this.cache.clear();
      if (this.db) {
         const tx = this.db.transaction(['cache'], 'readwrite');
         const store = tx.objectStore('cache');
         store.clear();
      }
      this.notify('cache-cleared');
      this.databases.clear();
      this.pendingInits.clear();
   }
}
// Export for use
window.jvbStore = DataStore;
// Initialize singleton on DOMContentLoaded
document.addEventListener('DOMContentLoaded', async function() {
   window.auth.subscribe((event) => {
      if (event === 'auth-loaded') {
         window.jvbStore = new DataStore();
      }
   });
});