/**
|
* ExtendedDataStore - A flexible IndexedDB wrapper with HTTP caching
|
*
|
* Configuration-based approach for different storage needs:
|
* - Configurable endpoint, keyPath, and indexes
|
* - Built-in ETag and If-Modified-Since support
|
* - Automatic DOM reference stripping
|
* - TTL-based cache invalidation
|
*/
|
class DataStore {
|
constructor(config = {}) {
|
// Core configuration with sensible defaults
|
this.config = {
|
// Storage configuration
|
name: 'default',
|
version: 1,
|
storeName: 'items',
|
keyPath: 'id',
|
indexes: [], // Array of {name, keyPath, unique}
|
|
// API configuration
|
endpoint: null,
|
apiBase: jvbSettings.api,
|
headers: {},
|
filters: {},
|
|
// Cache configuration
|
TTL: 3600000, // 1 hour default
|
useHttpCaching: true, // ETag and If-Modified-Since
|
cacheKeyStrategy: 'filters', // How to generate cache keys
|
|
// UI configuration
|
showLoading: true,
|
|
// Features
|
stripDOMReferences: true,
|
storeBlobs: false,
|
|
...config
|
};
|
|
// Initialize base properties
|
this.db = null;
|
this.data = new Map();
|
this.cache = new Map();
|
this.httpHeaders = new Map();
|
this.subscribers = new Set();
|
this.currentRequest = null;
|
this.filters = this.config.filters??{};
|
|
// Set up headers
|
this.headers = {
|
'X-WP-Nonce': jvbSettings?.nonce,
|
...this.config.headers
|
};
|
|
this.body = document.body;
|
this.loading = document.querySelector('dialog.loading');
|
|
// Auto-initialize
|
this.initDB();
|
|
// Cleanup on page unload
|
window.addEventListener('beforeunload', () => this.destroy());
|
}
|
|
/**
|
* Initialize IndexedDB with configurable schema
|
*/
|
async initDB() {
|
if (!('indexedDB' in window)) {
|
console.warn('IndexedDB not supported');
|
return;
|
}
|
|
const dbName = `jvb_${this.config.name}_db`;
|
const request = indexedDB.open(dbName, this.config.version);
|
|
request.onupgradeneeded = (e) => {
|
const db = e.target.result;
|
|
// Create main store with configurable keyPath
|
if (!db.objectStoreNames.contains(this.config.storeName)) {
|
const store = db.createObjectStore(this.config.storeName, {
|
keyPath: this.config.keyPath
|
});
|
|
// Add configured indexes
|
this.config.indexes.forEach(index => {
|
store.createIndex(
|
index.name,
|
index.keyPath || index.name,
|
{ unique: index.unique || false }
|
);
|
});
|
}
|
|
// Cache store for HTTP responses
|
if (this.config.endpoint && !db.objectStoreNames.contains('cache')) {
|
const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
|
cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
|
cacheStore.createIndex('endpoint', 'endpoint', { unique: false });
|
cacheStore.createIndex('filters', 'filters', { unique: false });
|
}
|
|
// HTTP headers store for ETag/If-Modified-Since
|
if (this.config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
|
db.createObjectStore('headers', { keyPath: 'key' });
|
}
|
|
if (this.config.storeBlobs && !db.objectStoreNames.contains('blobs')) {
|
db.createObjectStore('blobs', { keyPath: 'uploadId' });
|
}
|
|
// Call optional schema extension
|
if (this.config.onUpgrade) {
|
this.config.onUpgrade(db, e.oldVersion, e.newVersion);
|
}
|
};
|
|
request.onsuccess = (e) => {
|
this.db = e.target.result;
|
this.loadFromDB();
|
};
|
|
request.onerror = (e) => {
|
console.error(`IndexedDB error for ${dbName}:`, e);
|
if (this.config.onError) {
|
this.config.onError(e);
|
}
|
};
|
}
|
|
/**
|
* Load all data from IndexedDB
|
*/
|
async loadFromDB() {
|
if (!this.db) return;
|
|
const loadPromises = [
|
this.loadData()
|
];
|
|
if (this.config.endpoint) {
|
loadPromises.push(this.loadCache());
|
}
|
|
if (this.config.useHttpCaching) {
|
loadPromises.push(this.loadHeaders());
|
}
|
|
try {
|
await Promise.all(loadPromises);
|
this.notify('data-loaded', {
|
count: this.data.size,
|
store: this.config.storeName
|
});
|
} catch (error) {
|
console.error('Error loading from DB:', error);
|
}
|
}
|
|
/**
|
* Load main data from IndexedDB
|
*/
|
async loadData() {
|
if (!this.db) return;
|
|
return new Promise((resolve, reject) => {
|
const tx = this.db.transaction([this.config.storeName], 'readonly');
|
const store = tx.objectStore(this.config.storeName);
|
const request = store.getAll();
|
|
request.onsuccess = (e) => {
|
e.target.result.forEach(item => {
|
// Strip DOM references if needed
|
const cleaned = this.config.stripDOMReferences
|
? this.stripDOMReferences(item)
|
: item;
|
|
const key = this.getItemKey(cleaned);
|
this.data.set(key, cleaned);
|
});
|
resolve();
|
};
|
|
request.onerror = (e) => reject(e);
|
});
|
}
|
|
/**
|
* Strip DOM references from an object (recursive)
|
*/
|
stripDOMReferences(obj) {
|
if (!obj || typeof obj !== 'object') return obj;
|
|
// Handle arrays
|
if (Array.isArray(obj)) {
|
return obj.map(item => this.stripDOMReferences(item));
|
}
|
|
// Handle objects
|
const cleaned = {};
|
for (const [key, value] of Object.entries(obj)) {
|
// Skip DOM-related properties
|
if (this.isDOMReference(key, value)) {
|
continue;
|
}
|
|
// Handle Set/Map collections
|
if (value instanceof Set) {
|
cleaned[key] = Array.from(value);
|
} else if (value instanceof Map) {
|
cleaned[key] = Object.fromEntries(value);
|
} else if (typeof value === 'object' && value !== null) {
|
cleaned[key] = this.stripDOMReferences(value);
|
} else {
|
cleaned[key] = value;
|
}
|
}
|
|
return cleaned;
|
}
|
|
/**
|
* Check if a property is a DOM reference
|
*/
|
isDOMReference(key, value) {
|
// Check value types
|
if (value instanceof HTMLElement ||
|
value instanceof NodeList ||
|
value instanceof HTMLCollection ||
|
(value && value.nodeType !== undefined)) {
|
return true;
|
}
|
|
// Check key names
|
const domKeys = ['element', 'el', 'dom', 'node', 'ui', 'container', 'wrapper'];
|
if (domKeys.some(k => key.toLowerCase().includes(k))) {
|
return true;
|
}
|
|
return false;
|
}
|
|
/**
|
* Get the key for an item based on configured keyPath
|
*/
|
getItemKey(item) {
|
if (typeof this.config.keyPath === 'function') {
|
return this.config.keyPath(item);
|
}
|
|
// Support nested keypaths like 'meta.id'
|
const keys = this.config.keyPath.split('.');
|
let value = item;
|
|
for (const key of keys) {
|
value = value?.[key];
|
}
|
|
return value;
|
}
|
|
/**
|
* Save a single item
|
*/
|
async save(item) {
|
const key = this.getItemKey(item);
|
|
// Strip DOM references if configured
|
const cleaned = this.config.stripDOMReferences
|
? this.stripDOMReferences(item)
|
: item;
|
|
// Store in memory
|
this.data.set(key, cleaned);
|
|
// Persist to IndexedDB
|
await this.saveToDB(cleaned);
|
|
// Notify subscribers
|
this.notify('item-saved', { item: cleaned, key });
|
|
return cleaned;
|
}
|
|
/**
|
* Save item to IndexedDB
|
*/
|
async saveToDB(item) {
|
if (!this.db) return;
|
|
return new Promise((resolve, reject) => {
|
const tx = this.db.transaction([this.config.storeName], 'readwrite');
|
const store = tx.objectStore(this.config.storeName);
|
const request = store.put(item);
|
|
request.onsuccess = () => resolve();
|
request.onerror = (e) => reject(e);
|
});
|
}
|
|
/**
|
* Batch save multiple items
|
*/
|
async saveMany(items) {
|
if (!this.db) return;
|
|
const tx = this.db.transaction([this.config.storeName], 'readwrite');
|
const store = tx.objectStore(this.config.storeName);
|
|
const promises = items.map(item => {
|
const cleaned = this.config.stripDOMReferences
|
? this.stripDOMReferences(item)
|
: item;
|
|
const key = this.getItemKey(cleaned);
|
this.data.set(key, cleaned);
|
|
return store.put(cleaned);
|
});
|
|
await Promise.all(promises);
|
this.notify('items-saved', { count: items.length });
|
}
|
|
/**
|
* Get a single item
|
*/
|
get(key) {
|
return this.data.get(key);
|
}
|
|
/**
|
* Get all items
|
*/
|
getAll() {
|
return Array.from(this.data.values());
|
}
|
|
/**
|
* Delete an item
|
*/
|
async delete(key, storeName = null) {
|
this.data.delete(key);
|
|
if (!storeName) {
|
storeName = this.config.storeName;
|
}
|
if (this.db) {
|
const tx = this.db.transaction([storeName], 'readwrite');
|
const store = tx.objectStore(storeName);
|
await store.delete(key);
|
}
|
|
this.notify('item-deleted', { key });
|
}
|
|
async saveBlob(key, blob) {
|
if (!this.db) return;
|
|
const tx = this.db.transaction(['blobs'], 'readwrite');
|
const store = tx.objectStore('blobs');
|
await store.put({ key, data: blob, type: blob.type, name: blob.name });
|
}
|
|
async getBlob(key) {
|
if (!this.db) return null;
|
|
return new Promise(resolve => {
|
const tx = this.db.transaction(['blobs'], 'readonly');
|
const request = tx.objectStore('blobs').get(key);
|
request.onsuccess = () => resolve(request.result);
|
request.onerror = () => resolve(null);
|
});
|
}
|
|
/**
|
* Clear all data
|
*/
|
async clear() {
|
this.data.clear();
|
this.cache.clear();
|
this.httpHeaders.clear();
|
|
if (this.domCache) {
|
this.domCache.clear();
|
}
|
|
if (this.db) {
|
const stores = [this.config.storeName];
|
if (this.config.endpoint) stores.push('cache');
|
if (this.config.useHttpCaching) stores.push('headers');
|
|
const tx = this.db.transaction(stores, 'readwrite');
|
stores.forEach(storeName => {
|
if (this.db.objectStoreNames.contains(storeName)) {
|
tx.objectStore(storeName).clear();
|
}
|
});
|
}
|
|
this.notify('data-cleared');
|
}
|
|
/**
|
* Fetch data from server with HTTP caching
|
*/
|
async fetch(options = {}) {
|
if (!this.config.endpoint) {
|
throw new Error('No endpoint configured for fetch');
|
}
|
const {
|
filters = this.filters,
|
headers = {},
|
} = options;
|
|
if (this.config.showLoading) {
|
this.setLoading(true);
|
}
|
|
const cacheKey = this.generateCacheKey(filters);
|
|
//Check Cached data
|
const cachedData = this.cache.get(cacheKey);
|
if (cachedData && this.isCacheValid(cachedData)) {
|
return cachedData.data;
|
}
|
|
// Build request headers with HTTP caching
|
const requestHeaders = {
|
...this.headers,
|
...headers
|
};
|
|
if (this.config.useHttpCaching) {
|
const httpCache = this.httpHeaders.get(cacheKey);
|
if (httpCache) {
|
if (httpCache.etag) {
|
requestHeaders['If-None-Match'] = httpCache.etag;
|
}
|
if (httpCache.lastModified) {
|
requestHeaders['If-Modified-Since'] = httpCache.lastModified;
|
}
|
}
|
}
|
|
// Build URL with filters
|
|
const cleanedFilters = this.cleanFilters(filters);
|
const params = new URLSearchParams(cleanedFilters);
|
const url = `${this.config.apiBase}${this.config.endpoint}${params.toString() ? '?' + params : ''}`;
|
|
try {
|
const response = await fetch(url, {
|
method: 'GET',
|
headers: requestHeaders
|
});
|
|
// Handle 304 Not Modified
|
if (response.status === 304 && cachedData) {
|
// Update timestamp but keep existing data
|
cachedData.timestamp = Date.now();
|
this.saveCache(cacheKey, cachedData);
|
return cachedData.data;
|
}
|
|
if (!response.ok) {
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
}
|
|
const data = await response.json();
|
|
// Store HTTP caching headers
|
if (this.config.useHttpCaching) {
|
this.storeResponseHeaders(cacheKey, response);
|
}
|
|
// Cache the response
|
const cacheEntry = {
|
key: cacheKey,
|
data: data,
|
timestamp: Date.now(),
|
endpoint: this.config.endpoint,
|
filters: filters
|
};
|
|
this.cache.set(cacheKey, cacheEntry);
|
this.saveCache(cacheKey, cacheEntry);
|
|
// Process and store items
|
if (Array.isArray(data)) {
|
await this.saveMany(data);
|
} else if (data.items) {
|
await this.saveMany(data.items);
|
}
|
|
return data;
|
|
} catch (error) {
|
console.error('Fetch error:', error);
|
|
// Return cached data if available, even if expired
|
if (cachedData) {
|
console.warn('Using stale cache due to fetch error');
|
return cachedData.data;
|
}
|
|
throw error;
|
} finally {
|
if (this.config.showLoading) {
|
this.setLoading(false);
|
}
|
}
|
}
|
|
cleanFilters(filters) {
|
const cleaned = {};
|
Object.entries(filters).forEach(([key, value]) => {
|
if (value !== null && value !== undefined && value !== '') {
|
// Handle special cases based on existing patterns
|
if (key === 'taxonomies' && typeof value === 'object') {
|
Object.entries(value).forEach(([taxName, terms]) => {
|
if (Array.isArray(terms) && terms.length > 0) {
|
cleaned[`tax_${taxName}`] = terms.join(',');
|
} else if (terms) {
|
cleaned[`tax_${taxName}`] = terms;
|
}
|
});
|
} else if (key === 'date' && typeof value === 'object') {
|
if (value.after) cleaned.after = value.after;
|
if (value.before) cleaned.before = value.before;
|
} else {
|
cleaned[key] = value;
|
}
|
}
|
});
|
return cleaned;
|
}
|
|
/**
|
* Generate cache key from filters
|
*/
|
generateCacheKey(filters) {
|
if (this.config.cacheKeyStrategy === 'custom' && this.config.generateCacheKey) {
|
return this.config.generateCacheKey(filters);
|
}
|
|
// Default strategy: sort keys and create string
|
const sorted = Object.keys(filters)
|
.sort()
|
.reduce((acc, key) => {
|
acc[key] = filters[key];
|
return acc;
|
}, {});
|
|
return JSON.stringify(sorted);
|
}
|
|
setFilter(key, value) {
|
if (!this.filters) {
|
this.filters = {};
|
}
|
const oldValue = this.filters[key];
|
|
if (value === '' || value === null || value === undefined) {
|
delete this.filters[key];
|
} else {
|
this.filters[key] = value;
|
}
|
|
this.notify('filters-changed', {
|
filters: this.filters,
|
changed: { key, oldValue, newValue: value }
|
});
|
|
// Auto-fetch if endpoint is configured
|
if (this.config.endpoint) {
|
this.fetch();
|
}
|
}
|
|
/**
|
* Remove a filter
|
*/
|
removeFilter(key) {
|
const oldValue = this.filters[key];
|
|
if (oldValue !== undefined) {
|
delete this.filters[key];
|
this.notify('filters-changed', {
|
filters: this.filters,
|
removed: { key, oldValue }
|
});
|
|
// Auto-fetch if endpoint is configured
|
if (this.config.endpoint) {
|
this.fetch();
|
}
|
}
|
}
|
|
/**
|
* Clear all filters
|
*/
|
clearFilters() {
|
const oldFilters = { ...this.filters };
|
//Restore baseline filters
|
this.filters = this.config.filters;
|
|
this.notify('filters-cleared', {
|
oldFilters,
|
filters: this.filters
|
});
|
|
// Auto-fetch if endpoint is configured
|
if (this.config.endpoint) {
|
this.fetch();
|
}
|
}
|
|
/**
|
* Set multiple filters at once
|
*/
|
setFilters(filters) {
|
this.filters = { ...this.filters, ...filters };
|
if (this.config.autoFetch !== false) {
|
return this.fetch(this.filters);
|
}
|
}
|
|
/**
|
* Check if cache entry is still valid
|
*/
|
isCacheValid(cacheEntry) {
|
if (!cacheEntry || !cacheEntry.timestamp) return false;
|
|
const age = Date.now() - cacheEntry.timestamp;
|
return age < this.config.TTL;
|
}
|
|
/**
|
* Store HTTP response headers for caching
|
*/
|
storeResponseHeaders(key, response) {
|
const headers = {
|
key,
|
etag: response.headers.get('ETag'),
|
lastModified: response.headers.get('Last-Modified'),
|
timestamp: Date.now()
|
};
|
|
this.httpHeaders.set(key, headers);
|
|
if (this.db) {
|
const tx = this.db.transaction(['headers'], 'readwrite');
|
const store = tx.objectStore('headers');
|
store.put(headers);
|
}
|
}
|
|
/**
|
* Save cache entry to IndexedDB
|
*/
|
async saveCache(key, data) {
|
if (!this.db) return;
|
|
const tx = this.db.transaction(['cache'], 'readwrite');
|
const store = tx.objectStore('cache');
|
await store.put(data);
|
}
|
|
/**
|
* Load cache from IndexedDB
|
*/
|
async loadCache() {
|
if (!this.db) return;
|
|
return new Promise((resolve) => {
|
const tx = this.db.transaction(['cache'], 'readonly');
|
const store = tx.objectStore('cache');
|
const request = store.getAll();
|
|
request.onsuccess = (e) => {
|
e.target.result.forEach(item => {
|
if (this.isCacheValid(item)) {
|
this.cache.set(item.key, item);
|
}
|
});
|
resolve();
|
};
|
});
|
}
|
|
/**
|
* Load HTTP headers from IndexedDB
|
*/
|
async loadHeaders() {
|
if (!this.db) return;
|
|
return new Promise((resolve) => {
|
const tx = this.db.transaction(['headers'], 'readonly');
|
const store = tx.objectStore('headers');
|
const request = store.getAll();
|
|
request.onsuccess = (e) => {
|
e.target.result.forEach(header => {
|
this.httpHeaders.set(header.key, header);
|
});
|
resolve();
|
};
|
});
|
}
|
|
|
/**
|
* Subscribe to store events
|
*/
|
subscribe(callback) {
|
this.subscribers.add(callback);
|
return () => this.subscribers.delete(callback);
|
}
|
|
/**
|
* Notify subscribers of events
|
*/
|
notify(event, data = {}) {
|
this.subscribers.forEach(callback => {
|
try {
|
callback(event, data);
|
} catch (error) {
|
console.error('Subscriber error:', error);
|
}
|
});
|
}
|
|
/**
|
* Query items using an index
|
*/
|
async query(indexName, value) {
|
if (!this.db) return [];
|
|
return new Promise((resolve, reject) => {
|
const tx = this.db.transaction([this.config.storeName], 'readonly');
|
const store = tx.objectStore(this.config.storeName);
|
|
if (!store.indexNames.contains(indexName)) {
|
reject(new Error(`Index ${indexName} does not exist`));
|
return;
|
}
|
|
const index = store.index(indexName);
|
const request = value !== undefined
|
? index.getAll(value)
|
: index.getAll();
|
|
request.onsuccess = (e) => {
|
const results = e.target.result.map(item => {
|
return this.config.stripDOMReferences
|
? this.stripDOMReferences(item)
|
: item;
|
});
|
resolve(results);
|
};
|
|
request.onerror = (e) => reject(e);
|
});
|
}
|
|
/**
|
* Count items in store
|
*/
|
async count() {
|
if (!this.db) return this.data.size;
|
|
return new Promise((resolve, reject) => {
|
const tx = this.db.transaction([this.config.storeName], 'readonly');
|
const store = tx.objectStore(this.config.storeName);
|
const request = store.count();
|
|
request.onsuccess = (e) => resolve(e.target.result);
|
request.onerror = (e) => reject(e);
|
});
|
}
|
|
|
setLoading(on) {
|
this.body.classList.toggle('loading', on);
|
if (on) {
|
this.loading.showModal();
|
} else {
|
this.loading.close();
|
}
|
|
}
|
|
/**
|
* Cleanup and destroy
|
*/
|
destroy() {
|
if (this.currentRequest) {
|
this.currentRequest.abort();
|
}
|
|
this.subscribers.clear();
|
this.data.clear();
|
this.cache.clear();
|
this.httpHeaders.clear();
|
|
if (this.db) {
|
this.db.close();
|
this.db = null;
|
}
|
}
|
|
clearCache() {
|
this.cache.clear();
|
|
if (this.db) {
|
const tx = this.db.transaction(['cache'], 'readwrite');
|
const store = tx.objectStore('cache');
|
store.clear();
|
}
|
|
this.notify('cache-cleared');
|
}
|
}
|
|
// Export for use
|
window.jvbStore = DataStore;
|