| | |
| | | }; |
| | | |
| | | store.config.headers = { |
| | | 'X-WP-Nonce': jvbSettings?.nonce, |
| | | 'X-WP-Nonce': window.auth.getNonce(), |
| | | ...store.config.headers |
| | | }; |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * Normalize data before saving - convert Sets/Maps automatically |
| | | */ |
| | | normalizeForStorage(obj) { |
| | | if (obj === null || obj === undefined) return obj; |
| | | |
| | | // Convert Set to Array |
| | | if (obj instanceof Set) { |
| | | return Array.from(obj); |
| | | } |
| | | |
| | | // Convert Map to Object |
| | | if (obj instanceof Map) { |
| | | 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)); |
| | | } |
| | | |
| | | // Handle Objects |
| | | if (typeof obj === 'object') { |
| | | const normalized = {}; |
| | | for (const [key, value] of Object.entries(obj)) { |
| | | normalized[key] = this.normalizeForStorage(value); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | return obj; |
| | | } |
| | | |
| | | /** |
| | | * Convert FormData to plain object for storage |
| | | */ |
| | | formDataToObject(formData) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Strip DOM references from object |
| | | */ |
| | | stripDOMReferences(obj, visited = new WeakSet()) { |
| | | if (obj === null || obj === undefined) return obj; |
| | | |
| | | const type = typeof obj; |
| | | if (type === 'string' || type === 'number' || type === 'boolean') { |
| | | return obj; |
| | | } |
| | | |
| | | // Prevent circular references |
| | | if (type === 'object' && visited.has(obj)) { |
| | | return '[Circular]'; |
| | | } |
| | | |
| | | // Remove DOM elements |
| | | if (obj instanceof HTMLElement || |
| | | obj instanceof NodeList || |
| | | obj instanceof HTMLCollection || |
| | | obj.nodeType !== undefined) { |
| | | 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; |
| | | } |
| | | |
| | | // 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; |
| | | } |
| | | } |
| | | return cleaned; |
| | | } |
| | | |
| | | return obj; |
| | | } |
| | | |
| | | /** |
| | | * Initialize database for a specific store |
| | | */ |
| | | async initDB(name) { |
| | |
| | | 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}`); |
| | | } |
| | |
| | | if (store.config.useHttpCaching) { |
| | | this.storeResponseHeaders(name, cacheKey, response); |
| | | } |
| | | |
| | | await this.processFetchedData(name, data, cacheKey); |
| | | |
| | | this.notify(name, 'data-loaded', { |
| | |
| | | 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 = { |
| | |
| | | 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 || {} |
| | | }; |
| | | } |
| | | |
| | |
| | | async save(name, item) { |
| | | const store = this.stores.get(name); |
| | | |
| | | // Auto-normalize Sets/Maps |
| | | let processed = this.normalizeForStorage(item); |
| | | |
| | | if (processed.data instanceof FormData) { |
| | | processed = { |
| | | ...processed, |
| | | data: this.formDataToObject(processed.data) |
| | | }; |
| | | const result = this.processForStorage(item, store.config.validateData); |
| | | if (!result.valid) { |
| | | throw new Error(`Non-serializable data: ${result.error}`); |
| | | } |
| | | |
| | | 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 processed = result.data; |
| | | |
| | | const key = this.getItemKey(processed, store.config.keyPath); |
| | | |
| | |
| | | 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 }; |
| | | } |
| | | |
| | | if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) { |
| | | return { valid: true }; |
| | | } |
| | | |
| | | // 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) { |
| | |
| | | acc[key] = filters[key]; |
| | | return acc; |
| | | }, {}); |
| | | |
| | | return JSON.stringify(normalized); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | // 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(); |
| | | } |
| | | }); |
| | | }); |