Jake Vanderwerf
2026-01-22 58e8ae0759ccfa97c478ccae4e0778bdce70966f
assets/js/concise/DataStore.js
@@ -45,7 +45,7 @@
    * @param {object|array} configs An object defining the store, or an array of objects defining the stores
    * @param {number} version the database version
    */
   register(name, configs = [], version = 1.1) {
   register(name, configs = [], version = 1.2) {
      if (!Array.isArray(configs)) configs = [configs];
      if (configs.length === 0) return;
@@ -144,8 +144,11 @@
         // 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),
@@ -468,13 +471,16 @@
         let result;
         tx.oncomplete = () => resolve(result);
         tx.onerror = () => reject(tx.error);
         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);
            reject(error || new Error('Callback failed with unknown error'));
         }
      });
   }
@@ -586,11 +592,13 @@
         return data;
      } catch (error) {
         if (error.name !== 'AbortError') {
         const isAbortError = error?.name === 'AbortError';
         if (!isAbortError) {
            console.error(`Fetch error for store "${name}":`, error);
            this.notify(name, 'fetch-error', { error });
            throw error;
         }
         throw error;
      } finally {
         store.isFetching = false;
@@ -625,8 +633,8 @@
    */
   async processFetchedData(name, data, cacheKey, response) {
      const store = this.stores.get(name);
      const items = data.items || [];
      const changes = []; // Track all changes
      const items = (data.items || []).filter(item => item && typeof item === 'object');
      const changes = [];
      // Batch process with single transaction
      if (store.db && items.length > 0) {
@@ -784,8 +792,57 @@
      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 || obj === undefined) return { valid: true, data: obj };
      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;
@@ -846,7 +903,10 @@
         for (const [key, value] of Object.entries(obj)) {
            const result = this.processForStorage(value, validate, `${path}.${key}`);
            if (!result.valid) return result;
            if (result.data !== undefined) processed[key] = result.data;
            // Include null values, skip undefined
            if (result.data !== undefined || value === null) {
               processed[key] = result.data;
            }
         }
         return { valid: true, data: processed };
      }
@@ -871,11 +931,78 @@
      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());
@@ -894,6 +1021,7 @@
      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]);
@@ -959,27 +1087,30 @@
      const allItems = Array.from(store.data.values());
      const searchQuery = store.filters.search?.toLowerCase().trim() || '';
      const filterPredicates = [];
      for (const [key, value] of Object.entries(store.filters)) {
         if (store.ignoreFilters.has(key)) continue;
         if (value === null || value === undefined || value === '') continue;
         if (value === 'all') continue;
         // Comma-separated values
         if (typeof value === 'string' && value.includes(',')) {
            const accepted = value.split(',').map(v => v.trim());
            filterPredicates.push(item => accepted.includes(String(item[key])));
            continue;
         }
         filterPredicates.push(item => String(item[key]) === String(value));
      }
      const filtered = allItems.filter(item => {
         // Apply all non-ignored filters
         for (const [key, value] of Object.entries(store.filters)) {
            if (store.ignoreFilters.has(key)) continue;
            if (value === null || value === undefined || value === '') continue;
            if (value === 'all') continue;
            // Comma-separated values
            if (typeof value === 'string' && value.includes(',')) {
               const accepted = value.split(',').map(v => v.trim());
               if (!accepted.includes(String(item[key]))) return false;
               continue;
            }
            if (String(item[key]) !== String(value)) return false;
         // Apply all non-search filters
         for (const predicate of filterPredicates) {
            if (!predicate(item)) return false;
         }
         // Apply search if present
         return !(searchQuery && !this.searchObject(item, searchQuery));
      });
      return this.applyOrdering(filtered, store);
@@ -1021,7 +1152,9 @@
   }
   searchObject(obj, search) {
      if (!obj || typeof obj !== 'object') return false;
      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;