| | |
| | | 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 |
| | |
| | | |
| | | this.init(); |
| | | |
| | | window.addEventListener('beforeunload', () => this.destroy()); |
| | | // window.addEventListener('beforeunload', () => this.destroy()); |
| | | } |
| | | |
| | | async init() { |
| | |
| | | |
| | | /** |
| | | * 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': jvbSettings?.nonce, |
| | | ...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; |
| | | } |
| | | |
| | | /** |
| | |
| | | return Object.fromEntries(obj); |
| | | } |
| | | |
| | | // Preserve ArrayBuffer and TypedArrays (needed for blob storage) |
| | | if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) { |
| | | return obj; |
| | | } |
| | | |
| | | // Preserve Date objects |
| | | if (obj instanceof Date) { |
| | | return obj; |
| | | } |
| | | |
| | | // Handle Arrays |
| | | if (Array.isArray(obj)) { |
| | | return obj.map(item => this.normalizeForStorage(item)); |
| | |
| | | } |
| | | |
| | | /** |
| | | * Convert FormData to plain object for storage |
| | | */ |
| | | formDataToObject(formData) { |
| | | const obj = { |
| | | _isFormData: true, |
| | | entries: {} |
| | | }; |
| | | |
| | | 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; |
| | | } |
| | | |
| | | // 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; |
| | | } |
| | | |
| | | /** |
| | | * Convert stored object back to FormData |
| | | */ |
| | | async objectToFormData(obj) { |
| | | if (!obj._isFormData) 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); |
| | | } |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return formData; |
| | | } |
| | | |
| | | /** |
| | | * Strip DOM references from object |
| | | */ |
| | | stripDOMReferences(obj, visited = new WeakSet()) { |
| | |
| | | return null; |
| | | } |
| | | |
| | | // ✅ PRESERVE ArrayBuffer and TypedArrays (needed for blob storage) |
| | | if (obj instanceof ArrayBuffer || |
| | | ArrayBuffer.isView(obj)) { |
| | | return obj; |
| | | } |
| | | |
| | | // Handle Date |
| | | if (obj instanceof Date) { |
| | | return obj; |
| | |
| | | /** |
| | | * 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); |
| | |
| | | } |
| | | |
| | | if (!store._initialized) { |
| | | await this.initStoreDB(name); |
| | | await this.initDB(store.dbKey); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | // Auto-normalize Sets/Maps |
| | | let processed = this.normalizeForStorage(item); |
| | | |
| | | if (processed.data instanceof FormData) { |
| | | processed = { |
| | | ...processed, |
| | | data: this.formDataToObject(processed.data) |
| | | }; |
| | | } |
| | | |
| | | processed = this.stripDOMReferences(processed); |
| | | |
| | | // Validate data is serializable |
| | |
| | | |
| | | 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); |
| | |
| | | return { valid: true }; |
| | | } |
| | | |
| | | if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) { |
| | | return { valid: true }; |
| | | } |
| | | |
| | | // Reject DOM elements |
| | | if (obj instanceof HTMLElement || |
| | | obj instanceof NodeList || |
| | |
| | | } else { |
| | | store.filters[key] = value; |
| | | } |
| | | |
| | | this.notify(name, 'filters-changed', { |
| | | filters: store.filters, |
| | | changed: { key, oldValue, newValue: value } |
| | |
| | | } |
| | | |
| | | 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); |