/**
|
* DataStore - Singleton pattern managing multiple store namespaces
|
*
|
* Usage:
|
* window.jvbStore = new DataStore();
|
* this.store = window.jvbStore.register('feed', { config });
|
*/
|
class DataStore {
|
constructor() {
|
// Singleton pattern
|
if (DataStore.instance) {
|
return DataStore.instance;
|
}
|
DataStore.instance = this;
|
|
// Shared resources
|
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._initialized = false;
|
this.body = document.body;
|
this.loading = document.querySelector('dialog.loading');
|
|
this.init();
|
|
// window.addEventListener('beforeunload', () => this.destroy());
|
}
|
|
async init() {
|
if (this._initialized) return;
|
this._initialized = true;
|
|
if (!('indexedDB' in window)) {
|
console.warn('IndexedDB not supported');
|
}
|
}
|
|
/**
|
* 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, 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
|
});
|
}
|
|
let dbEntry = this.dbConfig.get(name);
|
|
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`);
|
}
|
|
|
const storeKey = `${name}_${config.storeName}`;
|
|
const store = {
|
config: {
|
// Storage
|
dbName: dbEntry.dbName,
|
storeName: 'items',
|
keyPath: 'id',
|
indexes: [],
|
|
// API
|
endpoint: null,
|
apiBase: jvbSettings.api,
|
filters: {},
|
required: null,
|
|
// Cache
|
TTL: 3600000, // 1 hour
|
useHttpCaching: true,
|
|
// 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
|
};
|
|
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.initDB(name).catch(error => {
|
console.error(`Failed to initialize store "${name}":`, error);
|
});
|
|
const apis = {};
|
for (const [storeName, storeKey] of Object.entries(dbEntry.stores)) {
|
apis[storeName] = this.getStoreAPI(storeKey);
|
}
|
return apis;
|
}
|
|
/**
|
* Get the API object for a registered store
|
*/
|
getStoreAPI(name) {
|
const api = {
|
// Data methods
|
fetch: () => this.fetch(name),
|
save: (item) => this.save(name, item),
|
delete: (id) => this.delete(name, id),
|
get: (id) => this.get(name, id),
|
getAll: () => this.getAll(name),
|
getFiltered: () => this.getFiltered(name),
|
clear: () => this.clear(name),
|
|
// Filter methods
|
setFilter: (key, value) => this.setFilter(name, key, value),
|
setFilters: (filters) => this.setFilters(name, filters),
|
removeFilter: (key) => this.removeFilter(name, key),
|
clearFilters: () => this.clearFilters(name),
|
|
// Cache methods
|
clearCache: () => this.clearCache(name),
|
clearHttpHeaders: (key) => this.clearHttpHeaders(name, key),
|
|
// Event methods
|
subscribe: (callback) => this.subscribe(name, callback),
|
|
// Utility
|
ensureInitialized: () => this.ensureStoreInitialized(name),
|
|
// Exposed properties (read-only)
|
get filters() {
|
return { ...api.getStore().filters };
|
},
|
get lastResponse() {
|
return api.getStore().lastResponse;
|
},
|
get data() {
|
return api.getStore().data;
|
},
|
|
getStore: () => this.stores.get(name)
|
};
|
|
return api;
|
}
|
|
/**
|
* 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) {
|
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()) {
|
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) {
|
const db = this.dbConfig.get(name);
|
if (!db || db._initialized) return;
|
|
if (this.pendingInits.has(name)) {
|
return this.pendingInits.get(name);
|
}
|
|
const initPromise = this._performDBInit(name);
|
this.pendingInits.set(name, initPromise);
|
|
try {
|
await initPromise;
|
db._initialized = true;
|
} finally {
|
this.pendingInits.delete(name);
|
}
|
}
|
|
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) => {
|
stores.forEach(store => {
|
let storeObj = this.stores.get(store);
|
if (storeObj) {
|
this.setupStores(db, storeObj.config);
|
}
|
});
|
});
|
this.databases.set(dbName, db);
|
}
|
|
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);
|
throw error;
|
}
|
}
|
|
openDatabase(dbName, version, onUpgrade) {
|
return new Promise((resolve, reject) => {
|
const request = indexedDB.open(dbName, version);
|
|
request.onupgradeneeded = (e) => {
|
if (onUpgrade) {
|
onUpgrade(e.target.result, e.oldVersion, e.newVersion);
|
}
|
};
|
|
request.onsuccess = (e) => resolve(e.target.result);
|
request.onerror = (e) => reject(e.target.error);
|
request.onblocked = () => {
|
console.warn(`Database ${dbName} blocked. Close other tabs.`);
|
};
|
});
|
}
|
|
setupStores(db, config) {
|
// Main store
|
if (!db.objectStoreNames.contains(config.storeName)) {
|
const store = db.createObjectStore(config.storeName, {
|
keyPath: config.keyPath
|
});
|
|
config.indexes.forEach(index => {
|
store.createIndex(
|
index.name,
|
index.keyPath || index.name,
|
{ unique: index.unique || false }
|
);
|
});
|
}
|
|
// Cache store
|
if (config.endpoint && !db.objectStoreNames.contains('cache')) {
|
const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
|
cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
|
}
|
|
// HTTP headers store
|
if (config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
|
db.createObjectStore('headers', { keyPath: 'key' });
|
}
|
}
|
|
loadStoreDataInBackground(name) {
|
const store = this.stores.get(name);
|
if (!store?.db) return;
|
|
const tasks = [
|
this.loadStoreData(name),
|
this.loadStoreCache(name),
|
this.loadStoreHeaders(name)
|
];
|
|
Promise.all(tasks)
|
.then(() => {
|
this.notify(name, 'data-ready');
|
|
// Add to fetch queue instead of immediate fetch
|
if (store.config.endpoint && store.config.delayFetch) {
|
this.fetchQueue.push(name);
|
|
// Start processing queue if not already running
|
if (this.fetchQueue.length === 1) {
|
this.processFetchQueue();
|
}
|
} else if (store.config.endpoint && !store.config.delayFetch) {
|
// Immediate fetch
|
if ('requestIdleCallback' in window) {
|
requestIdleCallback(() => this.fetch(name), { timeout: 2000 });
|
} else {
|
setTimeout(() => this.fetch(name), 100);
|
}
|
}
|
})
|
.catch(error => {
|
console.error(`Background load error for store "${name}":`, error);
|
});
|
}
|
|
async processFetchQueue() {
|
if (this.fetchQueue.length === 0) return;
|
|
const name = this.fetchQueue.shift();
|
const store = this.stores.get(name);
|
|
if (!store) {
|
// Store was removed, continue with next
|
return this.processFetchQueue();
|
}
|
|
try {
|
await this.fetch(name);
|
} catch (error) {
|
console.error(`Queue fetch error for "${name}":`, error);
|
}
|
|
// Process next item with idle callback
|
if (this.fetchQueue.length > 0) {
|
if ('requestIdleCallback' in window) {
|
requestIdleCallback(() => this.processFetchQueue(), { timeout: 2000 });
|
} else {
|
setTimeout(() => this.processFetchQueue(), 50);
|
}
|
}
|
}
|
|
async loadStoreData(name) {
|
const store = this.stores.get(name);
|
if (!store?.db) return;
|
|
return new Promise((resolve) => {
|
const tx = store.db.transaction([store.config.storeName], 'readonly');
|
const objectStore = tx.objectStore(store.config.storeName);
|
const request = objectStore.getAll();
|
|
request.onsuccess = (e) => {
|
const items = e.target.result || [];
|
items.forEach(item => {
|
const key = this.getItemKey(item, store.config.keyPath);
|
store.data.set(key, item);
|
});
|
this.notify(name, 'data-loaded', { count: items.length });
|
resolve(items);
|
};
|
|
request.onerror = () => resolve([]);
|
});
|
}
|
|
async loadStoreCache(name) {
|
const store = this.stores.get(name);
|
if (!store?.db || !store.db.objectStoreNames.contains('cache')) return;
|
|
return new Promise((resolve) => {
|
const tx = store.db.transaction(['cache'], 'readonly');
|
const objectStore = tx.objectStore('cache');
|
const request = objectStore.getAll();
|
|
request.onsuccess = (e) => {
|
(e.target.result || []).forEach(item => {
|
if (this.isCacheValid(item, store.config.TTL)) {
|
store.cache.set(item.key, item);
|
}
|
});
|
resolve();
|
};
|
|
request.onerror = () => resolve();
|
});
|
}
|
|
async loadStoreHeaders(name) {
|
const store = this.stores.get(name);
|
if (!store?.db || !store.db.objectStoreNames.contains('headers')) return;
|
|
return new Promise((resolve) => {
|
const tx = store.db.transaction(['headers'], 'readonly');
|
const objectStore = tx.objectStore('headers');
|
const request = objectStore.getAll();
|
|
request.onsuccess = (e) => {
|
(e.target.result || []).forEach(header => {
|
store.httpHeaders.set(header.key, header);
|
});
|
resolve();
|
};
|
|
request.onerror = () => resolve();
|
});
|
}
|
|
async ensureStoreInitialized(name) {
|
const store = this.stores.get(name);
|
if (!store) {
|
throw new Error(`Store "${name}" not registered`);
|
}
|
|
if (!store._initialized) {
|
await this.initDB(store.dbKey);
|
}
|
}
|
|
async fetch(name) {
|
await this.ensureStoreInitialized(name);
|
|
const store = this.stores.get(name);
|
|
if (store.isFetching) return;
|
|
// Check required filters
|
if (store.config.required) {
|
const required = Array.isArray(store.config.required)
|
? store.config.required
|
: [store.config.required];
|
|
const missing = required.some(key =>
|
!store.filters[key] || store.filters[key] === ''
|
);
|
|
if (missing) return;
|
}
|
|
store.isFetching = true;
|
|
try {
|
// Check cache
|
const cacheKey = this.generateCacheKey(store.filters);
|
const cached = store.cache.get(cacheKey);
|
|
if (cached && this.isCacheValid(cached, store.config.TTL)) {
|
this.notify(name, 'data-loaded', {
|
cached: true,
|
items: cached.items || []
|
});
|
return cached;
|
}
|
|
if (store.config.showLoading) {
|
this.setLoading(true);
|
}
|
|
const url = this.buildFetchUrl(name);
|
const headers = { ...store.config.headers };
|
const cachedHeaders = store.httpHeaders.get(cacheKey);
|
|
if (store.config.useHttpCaching && cachedHeaders) {
|
if (cachedHeaders.etag) {
|
headers['If-None-Match'] = cachedHeaders.etag;
|
}
|
if (cachedHeaders.lastModified) {
|
headers['If-Modified-Since'] = cachedHeaders.lastModified;
|
}
|
}
|
|
const controller = new AbortController();
|
store.currentRequest = controller;
|
|
const response = await fetch(url, {
|
method: 'GET',
|
headers,
|
signal: controller.signal
|
});
|
|
if (response.status === 304 && cached) {
|
this.notify(name, 'data-loaded', {
|
cached: true,
|
notModified: true,
|
items: cached.items || []
|
});
|
return cached;
|
}
|
|
if (!response.ok) {
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
}
|
|
const data = await response.json();
|
|
if (store.config.useHttpCaching) {
|
this.storeResponseHeaders(name, cacheKey, response);
|
}
|
|
await this.processFetchedData(name, data, cacheKey);
|
|
this.notify(name, 'data-loaded', {
|
cached: false,
|
items: data.items || []
|
});
|
|
return data;
|
|
} catch (error) {
|
if (error.name !== 'AbortError') {
|
console.error(`Fetch error for store "${name}":`, error);
|
this.notify(name, 'fetch-error', { error });
|
}
|
throw error;
|
|
} finally {
|
store.isFetching = false;
|
store.currentRequest = null;
|
|
if (store.config.showLoading) {
|
this.setLoading(false);
|
}
|
}
|
}
|
|
buildFetchUrl(name) {
|
const store = this.stores.get(name);
|
const params = new URLSearchParams();
|
|
Object.entries(store.filters).forEach(([key, value]) => {
|
if (value !== null && value !== undefined && value !== '') {
|
if (typeof value === 'object') {
|
params.set(key, JSON.stringify(value));
|
} else {
|
params.set(key, value);
|
}
|
}
|
});
|
|
const baseUrl = store.config.apiBase + store.config.endpoint;
|
return params.toString() ? `${baseUrl}?${params}` : baseUrl;
|
}
|
|
async processFetchedData(name, data, cacheKey) {
|
const store = this.stores.get(name);
|
const items = data.items || [];
|
|
for (const item of items) {
|
await this.save(name, item);
|
}
|
|
const cacheEntry = {
|
key: cacheKey,
|
items: items.map(item => this.getItemKey(item, store.config.keyPath)),
|
timestamp: Date.now(),
|
endpoint: store.config.endpoint,
|
filters: { ...store.filters }
|
};
|
|
store.cache.set(cacheKey, cacheEntry);
|
await this.saveToCache(name, cacheKey, cacheEntry);
|
|
store.lastResponse = {
|
has_more: data.has_more || false,
|
total: data.total || items.length,
|
pages: data.pages || 1
|
};
|
}
|
|
/**
|
* Save item to store
|
* IMPORTANT: Item must be serializable (no DOM, FormData, Blobs)
|
*/
|
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)
|
};
|
}
|
|
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 key = this.getItemKey(processed, store.config.keyPath);
|
|
// Store the original in memory (with original data intact)
|
store.data.set(key, item);
|
|
// Store processed in IndexedDB
|
if (store.db) {
|
const tx = store.db.transaction([store.config.storeName], 'readwrite');
|
const objectStore = tx.objectStore(store.config.storeName);
|
await objectStore.put(processed);
|
}
|
|
this.notify(name, 'item-saved', { item, key });
|
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 };
|
}
|
|
const type = typeof obj;
|
if (type === 'string' || type === 'number' || type === 'boolean') {
|
return { valid: true };
|
}
|
|
// Functions cannot be serialized
|
if (type === 'function') {
|
return {
|
valid: false,
|
error: `Function at ${path}`
|
};
|
}
|
|
// Date is serializable
|
if (obj instanceof Date) {
|
return { valid: true };
|
}
|
|
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
|
if (obj instanceof FormData) {
|
return {
|
valid: false,
|
error: `FormData at ${path}. Convert to object first.`
|
};
|
}
|
|
// Reject Blobs/Files
|
if (obj instanceof Blob || obj instanceof File) {
|
return {
|
valid: false,
|
error: `Blob/File at ${path}. Handle file uploads separately.`
|
};
|
}
|
|
// Arrays
|
if (Array.isArray(obj)) {
|
for (let i = 0; i < obj.length; i++) {
|
const result = this.validateSerializable(obj[i], `${path}[${i}]`);
|
if (!result.valid) return result;
|
}
|
return { valid: true };
|
}
|
|
// Plain 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
|
for (const [key, value] of Object.entries(obj)) {
|
const result = this.validateSerializable(value, `${path}.${key}`);
|
if (!result.valid) return result;
|
}
|
return { valid: true };
|
}
|
|
return {
|
valid: false,
|
error: `Unknown type at ${path}: ${type}`
|
};
|
}
|
|
async delete(name, id) {
|
const store = this.stores.get(name);
|
store.data.delete(id);
|
|
if (store.db) {
|
const tx = store.db.transaction([store.config.storeName], 'readwrite');
|
const objectStore = tx.objectStore(store.config.storeName);
|
await objectStore.delete(id);
|
}
|
|
this.notify(name, 'item-deleted', { id });
|
}
|
|
get(name, id) {
|
const store = this.stores.get(name);
|
return store.data.get(id);
|
}
|
|
getAll(name) {
|
const store = this.stores.get(name);
|
return Array.from(store.data.values());
|
}
|
|
getFiltered(name) {
|
const store = this.stores.get(name);
|
const cacheKey = this.generateCacheKey(store.filters);
|
const cacheEntry = store.cache.get(cacheKey);
|
|
if (cacheEntry && cacheEntry.items) {
|
return cacheEntry.items.reduce((acc, id) => {
|
const item = store.data.get(id);
|
if (item) acc.push(item);
|
return acc;
|
}, []);
|
}
|
|
return this.getAll(name);
|
}
|
|
async clear(name) {
|
const store = this.stores.get(name);
|
store.data.clear();
|
store.cache.clear();
|
|
if (store.db) {
|
const tx = store.db.transaction([store.config.storeName], 'readwrite');
|
const objectStore = tx.objectStore(store.config.storeName);
|
await objectStore.clear();
|
}
|
|
this.notify(name, 'data-cleared');
|
}
|
|
setFilter(name, key, value) {
|
const store = this.stores.get(name);
|
const oldValue = store.filters[key];
|
|
if (value === null || value === undefined || value === '') {
|
delete store.filters[key];
|
} else {
|
store.filters[key] = value;
|
}
|
this.notify(name, 'filters-changed', {
|
filters: store.filters,
|
changed: { key, oldValue, newValue: value }
|
});
|
|
if (store.config.endpoint) {
|
this.fetch(name);
|
}
|
}
|
|
async setFilters(name, filters) {
|
const store = this.stores.get(name);
|
|
const hasChanges = Object.keys(filters).some(
|
key => store.filters[key] !== filters[key]
|
);
|
|
if (!hasChanges) return;
|
|
store.filters = { ...store.filters, ...filters };
|
|
this.notify(name, 'filters-changed', {
|
filters: store.filters,
|
changed: filters
|
});
|
|
if (store.config.endpoint) {
|
await this.fetch(name);
|
}
|
}
|
|
removeFilter(name, key) {
|
const store = this.stores.get(name);
|
const oldValue = store.filters[key];
|
|
if (oldValue !== undefined) {
|
delete store.filters[key];
|
|
this.notify(name, 'filters-changed', {
|
filters: store.filters,
|
removed: { key, oldValue }
|
});
|
|
if (store.config.endpoint) {
|
this.fetch(name);
|
}
|
}
|
}
|
|
clearFilters(name) {
|
const store = this.stores.get(name);
|
const oldFilters = { ...store.filters };
|
|
store.filters = { ...store.config.filters };
|
|
this.notify(name, 'filters-cleared', {
|
oldFilters,
|
filters: store.filters
|
});
|
|
if (store.config.endpoint) {
|
this.fetch(name);
|
}
|
}
|
|
clearCache(name) {
|
const store = this.stores.get(name);
|
store.cache.clear();
|
|
if (store.db && store.db.objectStoreNames.contains('cache')) {
|
const tx = store.db.transaction(['cache'], 'readwrite');
|
const objectStore = tx.objectStore('cache');
|
objectStore.clear();
|
}
|
|
this.notify(name, 'cache-cleared');
|
}
|
|
clearHttpHeaders(name, cacheKey = null) {
|
const store = this.stores.get(name);
|
|
if (cacheKey) {
|
store.httpHeaders.delete(cacheKey);
|
|
if (store.db && store.db.objectStoreNames.contains('headers')) {
|
const tx = store.db.transaction(['headers'], 'readwrite');
|
const objectStore = tx.objectStore('headers');
|
objectStore.delete(cacheKey);
|
}
|
} else {
|
store.httpHeaders.clear();
|
|
if (store.db && store.db.objectStoreNames.contains('headers')) {
|
const tx = store.db.transaction(['headers'], 'readwrite');
|
const objectStore = tx.objectStore('headers');
|
objectStore.clear();
|
}
|
}
|
}
|
|
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);
|
}
|
|
notify(name, event, data = {}) {
|
const subscribers = this.subscribers.get(name);
|
if (!subscribers) return;
|
|
subscribers.forEach(callback => {
|
try {
|
callback(event, data);
|
} catch (error) {
|
console.error(`Subscriber error for store "${name}":`, error);
|
}
|
});
|
}
|
|
storeResponseHeaders(name, key, response) {
|
const store = this.stores.get(name);
|
|
const headers = {
|
key,
|
etag: response.headers.get('ETag'),
|
lastModified: response.headers.get('Last-Modified'),
|
timestamp: Date.now()
|
};
|
|
store.httpHeaders.set(key, headers);
|
|
if (store.db && store.db.objectStoreNames.contains('headers')) {
|
const tx = store.db.transaction(['headers'], 'readwrite');
|
const objectStore = tx.objectStore('headers');
|
objectStore.put(headers);
|
}
|
}
|
|
async saveToCache(name, key, data) {
|
const store = this.stores.get(name);
|
if (!store.db || !store.db.objectStoreNames.contains('cache')) return;
|
|
const tx = store.db.transaction(['cache'], 'readwrite');
|
const objectStore = tx.objectStore('cache');
|
await objectStore.put(data);
|
}
|
|
generateCacheKey(filters) {
|
const normalized = Object.keys(filters)
|
.sort()
|
.reduce((acc, key) => {
|
acc[key] = filters[key];
|
return acc;
|
}, {});
|
|
return JSON.stringify(normalized);
|
}
|
|
isCacheValid(entry, ttl) {
|
if (!entry || !entry.timestamp) return false;
|
const age = Date.now() - entry.timestamp;
|
return age < ttl;
|
}
|
|
getItemKey(item, keyPath) {
|
if (typeof keyPath === 'function') {
|
return keyPath(item);
|
}
|
|
const keys = keyPath.split('.');
|
let value = item;
|
|
for (const key of keys) {
|
value = value?.[key];
|
}
|
|
return value;
|
}
|
|
setLoading(on) {
|
this.body.classList.toggle('loading', on);
|
if (on) {
|
this.loading?.showModal();
|
} else {
|
this.loading?.close();
|
}
|
}
|
|
destroy() {
|
this.stores.forEach(store => {
|
if (store.currentRequest) {
|
store.currentRequest.abort();
|
}
|
});
|
|
this.databases.forEach(db => db.close());
|
this.stores.clear();
|
this.subscribers.clear();
|
this.databases.clear();
|
this.pendingInits.clear();
|
}
|
}
|
|
// Initialize singleton on DOMContentLoaded
|
document.addEventListener('DOMContentLoaded', function() {
|
window.jvbStore = new DataStore();
|
});
|