Jake Vanderwerf
2025-12-21 3aada9949d51024a92a8b5c6cb70d12f9c3cac16
assets/js/concise/DataStore.js
@@ -14,10 +14,11 @@
      DataStore.instance = this;
      // Shared resources
      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.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 = [];
      // Global state
@@ -27,7 +28,7 @@
      this.init();
      window.addEventListener('beforeunload', () => this.destroy());
      // window.addEventListener('beforeunload', () => this.destroy());
   }
   async init() {
@@ -41,71 +42,97 @@
   /**
    * 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
    */
   register(name, config = {}) {
      if (this.stores.has(name)) {
         console.warn(`Store "${name}" already registered`);
         return this.getStoreAPI(name);
   register(name, configs = [], version = 1.1) {
      if (!Array.isArray(configs)) configs = [configs];
      if (configs.length === 0) return;
      if (!this.dbConfig.has(name)) {
         this.dbConfig.set(name, {
            dbName: `jvb_${name}`,
            version: version,
            stores: {},
            _initialized: false
         });
      }
      if (!config.keyPath) {
         throw new Error(`Store "${name}" requires a keyPath`);
      }
      let dbEntry = this.dbConfig.get(name);
      const store = {
         name,
         config: {
            // Storage
            dbName: `jvb_${name}_db`,
            version: 1,
            storeName: 'items',
            keyPath: 'id',
            indexes: [],
      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`);
         }
            // API
            endpoint: null,
            apiBase: jvbSettings.api,
            filters: {},
            required: null,
            // Cache
            TTL: 3600000, // 1 hour
            useHttpCaching: true,
         const storeKey = `${name}_${config.storeName}`;
            // Behavior
            showLoading: false,
            delayFetch: true,
            validateData: true, // Validate data is serializable
         const store = {
            config: {
               // Storage
               dbName: dbEntry.dbName,
               storeName: 'items',
               keyPath: 'id',
               indexes: [],
            ...config
         },
               // API
               endpoint: null,
               apiBase: jvbSettings.api,
               filters: {},
               required: null,
         // State
         db: null,
         data: new Map(),
         cache: new Map(),
         httpHeaders: new Map(),
         filters: { ...config.filters },
         isFetching: false,
         currentRequest: null,
         lastResponse: null,
         _initialized: false
      };
               // Cache
               TTL: 3600000, // 1 hour
               useHttpCaching: true,
      store.config.headers = {
         'X-WP-Nonce': jvbSettings?.nonce,
         ...store.config.headers
      };
               // Behavior
               showLoading: false,
               delayFetch: true,
               validateData: true, // Validate data is serializable
               ...config
            },
            dbKey: name,
            storeKey: storeKey,
            data: new Map(),
            cache: new Map(),
            httpHeaders: new Map(),
            subscribers: new Map(),
            filters: {...(config.filters || {}) },
            isFetching: false,
            currentRequest: null,
            lastResponse: null,
            _initialized: false
         };
      this.stores.set(name, store);
      this.subscribers.set(name, new Set());
         store.config.headers = {
            'X-WP-Nonce': window.auth.getNonce(),
            ...store.config.headers
         };
         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.initStoreDB(name).catch(error => {
      this.initDB(name).catch(error => {
         console.error(`Failed to initialize store "${name}":`, error);
      });
      return this.getStoreAPI(name);
      const apis = {};
      for (const [storeName, storeKey] of Object.entries(dbEntry.stores)) {
         apis[storeName] = this.getStoreAPI(storeKey);
      }
      return apis;
   }
   /**
@@ -156,126 +183,114 @@
   }
   /**
    * Normalize data before saving - convert Sets/Maps automatically
    * Convert FormData to plain object for storage
    */
   normalizeForStorage(obj) {
      if (obj === null || obj === undefined) return obj;
   formDataToObject(formData) {
      const obj = {
         _isFormData: true,
         entries: {}
      };
      // Convert Set to Array
      if (obj instanceof Set) {
         return Array.from(obj);
      }
      // Convert Map to Object
      if (obj instanceof Map) {
         return Object.fromEntries(obj);
      }
      // Handle Arrays
      if (Array.isArray(obj)) {
         return obj.map(item => this.normalizeForStorage(item));
      }
      // Handle Objects
      if (typeof obj === 'object') {
         const normalized = {};
         for (const [key, value] of Object.entries(obj)) {
            normalized[key] = this.normalizeForStorage(value);
      for (const [key, value] of formData.entries()) {
         // Skip File/Blob objects - they're stored separately in UploadManager
         if (value instanceof File || value instanceof Blob) {
            continue;
         }
         return normalized;
         // Handle multiple values for same key
         if (obj.entries[key]) {
            if (!Array.isArray(obj.entries[key])) {
               obj.entries[key] = [obj.entries[key]];
            }
            obj.entries[key].push(value);
         } else {
            obj.entries[key] = value;
         }
      }
      return obj;
   }
   /**
    * Strip DOM references from object
    * Convert stored object back to FormData
    */
   stripDOMReferences(obj, visited = new WeakSet()) {
      if (obj === null || obj === undefined) return obj;
   async objectToFormData(obj) {
      if (!obj._isFormData) return obj;
      const type = typeof obj;
      if (type === 'string' || type === 'number' || type === 'boolean') {
         return obj;
      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));
         } else {
            formData.append(key, value);
         }
      }
      // Prevent circular references
      if (type === 'object' && visited.has(obj)) {
         return '[Circular]';
      }
      if (window.jvbUploads && obj.entries.upload_ids) {
         const uploadIds = JSON.parse(obj.entries.upload_ids);
      // Remove DOM elements
      if (obj instanceof HTMLElement ||
         obj instanceof NodeList ||
         obj instanceof HTMLCollection ||
         obj.nodeType !== undefined) {
         return null;
      }
      // Handle Date
      if (obj instanceof Date) {
         return obj;
      }
      // Handle Arrays
      if (Array.isArray(obj)) {
         visited.add(obj);
         return obj.map(item => this.stripDOMReferences(item, visited)).filter(v => v !== null);
      }
      // Handle Objects
      if (type === 'object') {
         visited.add(obj);
         const cleaned = {};
         for (const [key, value] of Object.entries(obj)) {
            const cleanedValue = this.stripDOMReferences(value, visited);
            if (cleanedValue !== null) {
               cleaned[key] = cleanedValue;
         for (const uploadId of uploadIds) {
            const file = await window.jvbUploads.getBlobData(uploadId);
            if (file) {
               formData.append('files[]', file);
            }
         }
         return cleaned;
      }
      return obj;
      return formData;
   }
   /**
    * Initialize database for a specific store
    */
   async initStoreDB(name) {
      const store = this.stores.get(name);
      if (!store || store._initialized) return;
   async initDB(name) {
      const db = this.dbConfig.get(name);
      if (!db || db._initialized) return;
      if (this.pendingInits.has(name)) {
         return this.pendingInits.get(name);
      }
      const initPromise = this._performStoreInit(name);
      const initPromise = this._performDBInit(name);
      this.pendingInits.set(name, initPromise);
      try {
         await initPromise;
         store._initialized = true;
         db._initialized = true;
      } finally {
         this.pendingInits.delete(name);
      }
   }
   async _performStoreInit(name) {
      const store = this.stores.get(name);
      const { dbName, version } = store.config;
   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) => {
               this.setupStores(db, store.config);
               stores.forEach(store => {
                  let storeObj = this.stores.get(store);
                  if (storeObj) {
                     this.setupStores(db, storeObj.config);
                  }
               });
            });
            this.databases.set(dbName, db);
         }
         store.db = this.databases.get(dbName);
         this.loadStoreDataInBackground(name);
         this.notify(name, 'db-init');
         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');
            }
         })
      } catch (error) {
         console.error(`Failed to initialize database for store "${name}":`, error);
@@ -464,7 +479,7 @@
      }
      if (!store._initialized) {
         await this.initStoreDB(name);
         await this.initDB(store.dbKey);
      }
   }
@@ -529,15 +544,37 @@
            signal: controller.signal
         });
         if (response.status === 304 && cached) {
         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 can happen on first load when cache headers exist but data doesn't
            this.notify(name, 'data-loaded', {
               cached: true,
               cached: false,
               notModified: true,
               items: cached.items || []
               items: []
            });
            return cached;
            // 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}`);
         }
@@ -547,7 +584,6 @@
         if (store.config.useHttpCaching) {
            this.storeResponseHeaders(name, cacheKey, response);
         }
         await this.processFetchedData(name, data, cacheKey);
         this.notify(name, 'data-loaded', {
@@ -596,8 +632,30 @@
      const store = this.stores.get(name);
      const items = data.items || [];
      for (const item of items) {
         await this.save(name, item);
      // Batch process all items in a single transaction
      if (store.db && items.length > 0) {
         const tx = store.db.transaction([store.config.storeName], 'readwrite');
         const objectStore = tx.objectStore(store.config.storeName);
         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);
         });
      }
      const cacheEntry = {
@@ -612,9 +670,11 @@
      await this.saveToCache(name, cacheKey, cacheEntry);
      store.lastResponse = {
         ...data,
         has_more: data.has_more || false,
         total: data.total || items.length,
         pages: data.pages || 1
         pages: data.pages || 1,
         queue_stats: data.queue_stats || {}
      };
   }
@@ -625,25 +685,18 @@
   async save(name, item) {
      const store = this.stores.get(name);
      // Auto-normalize Sets/Maps
      let processed = this.normalizeForStorage(item);
      processed = this.stripDOMReferences(processed);
      // Validate data is serializable
      if (store.config.validateData) {
         const validation = this.validateSerializable(processed);
         if (!validation.valid) {
            console.error(`Cannot save non-serializable data to store "${name}":`, validation.error);
            throw new Error(`Non-serializable data: ${validation.error}`);
         }
      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);
      // Store in memory
      // Store the original in memory (with original data intact)
      store.data.set(key, item);
      // Store in IndexedDB
      // Store processed in IndexedDB
      if (store.db) {
         const tx = store.db.transaction([store.config.storeName], 'readwrite');
         const objectStore = tx.objectStore(store.config.storeName);
@@ -654,98 +707,74 @@
      return key;
   }
   /**
    * Validate that data is IndexedDB-serializable
    * Rejects: DOM elements, FormData, Blobs, Functions, etc.
    */
   validateSerializable(obj, path = 'root') {
      // Primitives are fine
      if (obj === null || obj === undefined) {
         return { valid: true };
      }
   processForStorage(obj, validate = true, path = 'root') {
      if (obj === null || obj === undefined) return { valid: true, data: obj };
      const type = typeof obj;
      if (type === 'string' || type === 'number' || type === 'boolean') {
         return { valid: true };
      // Handle primitives
      if (['string', 'number', 'boolean'].includes(type)) {
         return { valid: true, data: obj };
      }
      // Functions cannot be serialized
      // Reject functions
      if (type === 'function') {
         return {
            valid: false,
            error: `Function at ${path}`
         };
         return validate ? { valid: false, error: `Function at ${path}` } : { valid: true, data: null };
      }
      // Date is serializable
      if (obj instanceof Date) {
         return { valid: true };
      // DOM elements
      if (obj instanceof HTMLElement || obj.nodeType !== undefined) {
         return validate ? { valid: false, error: `DOM element at ${path}` } : { valid: true, data: null };
      }
      // Reject DOM elements
      if (obj instanceof HTMLElement ||
         obj instanceof NodeList ||
         obj instanceof HTMLCollection ||
         (obj.nodeType !== undefined)) {
         return {
            valid: false,
            error: `DOM element at ${path}`
         };
      }
      // Reject FormData
      // FormData - convert and continue
      if (obj instanceof FormData) {
         return {
            valid: false,
            error: `FormData at ${path}. Convert to object first.`
         };
         return validate
            ? { valid: false, error: `FormData at ${path}` }
            : { valid: true, data: this.formDataToObject(obj) };
      }
      // Reject Blobs/Files
      if (obj instanceof Blob || obj instanceof File) {
         return {
            valid: false,
            error: `Blob/File at ${path}. Handle file uploads separately.`
         };
      // Preserve safe types
      if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
         return { valid: true, data: obj };
      }
      // Convert Sets to Arrays
      if (obj instanceof Set) {
         const arr = Array.from(obj);
         return this.processForStorage(arr, 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.validateSerializable(obj[i], `${path}[${i}]`);
            const result = this.processForStorage(obj[i], validate, `${path}[${i}]`);
            if (!result.valid) return result;
            if (result.data !== null) processed.push(result.data);
         }
         return { valid: true };
         return { valid: true, data: processed };
      }
      // Plain objects
      // Objects
      if (type === 'object') {
         // Check for Sets/Maps (IndexedDB doesn't support them)
         if (obj instanceof Set) {
            return {
               valid: false,
               error: `Set at ${path}. Convert to Array first: Array.from(set)`
            };
         }
         if (obj instanceof Map) {
            return {
               valid: false,
               error: `Map at ${path}. Convert to Object first: Object.fromEntries(map)`
            };
         }
         // Check all properties
         const processed = {};
         for (const [key, value] of Object.entries(obj)) {
            const result = this.validateSerializable(value, `${path}.${key}`);
            const result = this.processForStorage(value, validate, `${path}.${key}`);
            if (!result.valid) return result;
            if (result.data !== null) processed[key] = result.data;
         }
         return { valid: true };
         return { valid: true, data: processed };
      }
      return {
         valid: false,
         error: `Unknown type at ${path}: ${type}`
      };
      return validate
         ? { valid: false, error: `Unknown type at ${path}` }
         : { valid: true, data: null };
   }
   async delete(name, id) {
@@ -810,7 +839,6 @@
      } else {
         store.filters[key] = value;
      }
      this.notify(name, 'filters-changed', {
         filters: store.filters,
         changed: { key, oldValue, newValue: value }
@@ -912,6 +940,9 @@
   }
   subscribe(name, callback) {
      if (!this.subscribers.has(name)) {
         this.subscribers.set(name, new Set());
      }
      const subscribers = this.subscribers.get(name);
      subscribers.add(callback);
      return () => subscribers.delete(callback);
@@ -965,7 +996,6 @@
            acc[key] = filters[key];
            return acc;
         }, {});
      return JSON.stringify(normalized);
   }
@@ -1015,6 +1045,10 @@
}
// Initialize singleton on DOMContentLoaded
document.addEventListener('DOMContentLoaded', function() {
   window.jvbStore = new DataStore();
document.addEventListener('DOMContentLoaded', async function() {
   window.auth.subscribe((event) => {
      if (event === 'auth-loaded') {
         window.jvbStore = new DataStore();
      }
   });
});