| | |
| | | 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': 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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); |
| | |
| | | } |
| | | |
| | | if (!store._initialized) { |
| | | await this.initStoreDB(name); |
| | | await this.initDB(store.dbKey); |
| | | } |
| | | } |
| | | |
| | |
| | | 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); |
| | | 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); |
| | |
| | | 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) { |
| | |
| | | } 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); |
| | |
| | | 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(); |
| | | } |
| | | }); |
| | | }); |