/**
|
* 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();
|
}
|
|
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,
|
...config
|
},
|
dbKey: name,
|
storeKey: storeKey,
|
data: new Map(),
|
cache: new Map(),
|
filters: {...(config.filters || {})},
|
isFetching: false,
|
currentRequest: null,
|
lastResponse: null,
|
_initialized: false
|
};
|
|
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.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),
|
getAllByIndex: (indexName, value) => this.getAllByIndex(name, indexName, value),
|
filterByIndex: (criteria) => this.filterByIndex(name, criteria),
|
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),
|
|
// 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;
|
}
|
|
/**
|
* 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;
|
}
|
|
/***********************************************************************
|
* DATABASE INITIALIZATION
|
***********************************************************************/
|
|
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 (now includes HTTP headers)
|
if (config.endpoint && !db.objectStoreNames.contains('cache')) {
|
const cacheStore = db.createObjectStore('cache', { keyPath: 'key' });
|
cacheStore.createIndex('timestamp', 'timestamp', { unique: false });
|
}
|
}
|
|
/**
|
* Generic loader for any object store
|
*/
|
async loadFromObjectStore(name, storeName, processItem) {
|
const store = this.stores.get(name);
|
if (!store?.db || !store.db.objectStoreNames.contains(storeName)) {
|
return [];
|
}
|
|
return new Promise((resolve) => {
|
const tx = store.db.transaction([storeName], 'readonly');
|
const objectStore = tx.objectStore(storeName);
|
const request = objectStore.getAll();
|
|
request.onsuccess = (e) => {
|
const items = e.target.result || [];
|
items.forEach(processItem);
|
resolve(items);
|
};
|
|
request.onerror = () => resolve([]);
|
});
|
}
|
|
loadStoreDataInBackground(name) {
|
const store = this.stores.get(name);
|
if (!store?.db) return;
|
|
Promise.all([
|
// Load main data
|
this.loadFromObjectStore(name, store.config.storeName, (item) => {
|
const key = this.getItemKey(item, store.config.keyPath);
|
store.data.set(key, item);
|
}),
|
|
// Load cache (includes HTTP headers now!)
|
this.loadFromObjectStore(name, 'cache', (item) => {
|
if (this.isCacheValid(item, store.config.TTL)) {
|
store.cache.set(item.key, item);
|
}
|
})
|
])
|
.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 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);
|
}
|
}
|
|
/***********************************************************************
|
* TRANSACTION HELPER
|
***********************************************************************/
|
|
/**
|
* Create transaction helper - reduces boilerplate
|
*/
|
async withTransaction(name, storeNames, mode, callback) {
|
const store = this.stores.get(name);
|
if (!store?.db) return null;
|
|
// Ensure storeNames is an array
|
if (typeof storeNames === 'string') storeNames = [storeNames];
|
|
return new Promise((resolve, reject) => {
|
const tx = store.db.transaction(storeNames, mode);
|
const stores = storeNames.map(name => tx.objectStore(name));
|
const objectStore = stores.length === 1 ? stores[0] : stores;
|
|
let result;
|
tx.oncomplete = () => resolve(result);
|
tx.onerror = () => reject(tx.error);
|
|
// Call callback immediately to queue operations
|
try {
|
result = callback(objectStore, tx);
|
} catch (error) {
|
reject(error);
|
}
|
});
|
}
|
|
/***********************************************************************
|
* FETCH & DATA PROCESSING
|
***********************************************************************/
|
|
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 };
|
|
// Use HTTP cache headers from cache entry
|
if (store.config.useHttpCaching && cached) {
|
if (cached.etag) headers['If-None-Match'] = cached.etag;
|
if (cached.lastModified) headers['If-Modified-Since'] = cached.lastModified;
|
}
|
|
const controller = new AbortController();
|
store.currentRequest = controller;
|
|
const response = await fetch(url, {
|
method: 'GET',
|
headers,
|
signal: controller.signal
|
});
|
|
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.notify(name, 'data-loaded', {
|
cached: false,
|
notModified: true,
|
items: []
|
});
|
|
// 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}`);
|
}
|
|
const data = await response.json();
|
|
await this.processFetchedData(name, data, cacheKey, response);
|
|
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;
|
}
|
|
/**
|
* Process fetched data (batch from server)
|
*/
|
async processFetchedData(name, data, cacheKey, response) {
|
const store = this.stores.get(name);
|
const items = data.items || [];
|
const changes = []; // Track all changes
|
|
// Batch process with single transaction
|
if (store.db && items.length > 0) {
|
await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
|
items.forEach(item => {
|
try {
|
// Use shared save logic
|
const changeInfo = this._saveItem(name, item);
|
changes.push(changeInfo);
|
|
// Queue for batch write
|
objectStore.put(changeInfo.processed);
|
} catch (error) {
|
console.error(`Error processing item:`, error);
|
}
|
});
|
});
|
}
|
|
// Update cache (now includes HTTP headers!)
|
const cacheEntry = {
|
key: cacheKey,
|
items: items.map(item => this.getItemKey(item, store.config.keyPath)),
|
timestamp: Date.now(),
|
endpoint: store.config.endpoint,
|
filters: { ...store.filters },
|
etag: response.headers.get('ETag'),
|
lastModified: response.headers.get('Last-Modified')
|
};
|
|
store.cache.set(cacheKey, cacheEntry);
|
|
// Save cache to IndexedDB
|
if (store.db?.objectStoreNames.contains('cache')) {
|
await this.withTransaction(name, 'cache', 'readwrite', (objectStore) => {
|
objectStore.put(cacheEntry);
|
});
|
}
|
|
// Update lastResponse metadata
|
store.lastResponse = {
|
...data,
|
has_more: data.has_more || false,
|
total: data.total || items.length,
|
pages: data.pages || 1,
|
queue_stats: data.queue_stats || {}
|
};
|
|
// Emit events for items with status changes
|
changes.forEach(changeInfo => {
|
if (changeInfo.statusChanged) {
|
this.notify(name, 'item-saved', {
|
item: changeInfo.item,
|
key: changeInfo.key,
|
previousItem: changeInfo.previousItem
|
});
|
}
|
});
|
}
|
|
/***********************************************************************
|
* SAVE OPERATIONS
|
***********************************************************************/
|
|
/**
|
* Internal method: Save a single item with full tracking
|
* Returns change info without writing to IndexedDB (caller handles that)
|
*/
|
_saveItem(name, item) {
|
const store = this.stores.get(name);
|
|
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);
|
|
// Capture previous state
|
const previousItem = store.data.get(key);
|
|
// Update in-memory store (with original data intact)
|
store.data.set(key, item);
|
|
// Return change info for event emission
|
return {
|
item,
|
previousItem,
|
key,
|
processed,
|
statusChanged: previousItem && previousItem.status !== item.status
|
};
|
}
|
|
/**
|
* Save single item (public API)
|
*/
|
async save(name, item) {
|
const store = this.stores.get(name);
|
const changeInfo = this._saveItem(name, item);
|
|
// Write to IndexedDB immediately for single saves
|
await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
|
objectStore.put(changeInfo.processed);
|
});
|
|
// Always emit for explicit saves
|
this.notify(name, 'item-saved', {
|
item: changeInfo.item,
|
key: changeInfo.key,
|
previousItem: changeInfo.previousItem
|
});
|
|
return changeInfo.key;
|
}
|
|
processForStorage(obj, validate = true, path = 'root') {
|
if (obj === null || obj === undefined) return { valid: true, data: obj };
|
|
const type = typeof obj;
|
|
// Handle primitives
|
if (['string', 'number', 'boolean'].includes(type)) {
|
return { valid: true, data: obj };
|
}
|
|
// Reject functions
|
if (type === 'function') {
|
if (validate) return { valid: false, error: `Function at ${path}` };
|
console.debug(`[DataStore] Stripped function at ${path}`);
|
return { valid: true, data: undefined };
|
}
|
|
// DOM elements
|
if (obj instanceof HTMLElement || obj.nodeType !== undefined) {
|
if (validate) return { valid: false, error: `DOM element at ${path}` };
|
console.debug(`[DataStore] Stripped DOM element at ${path}`);
|
return { valid: true, data: undefined };
|
}
|
|
// FormData - convert and continue
|
if (obj instanceof FormData) {
|
if (validate) return { valid: false, error: `FormData at ${path}` };
|
console.debug(`[DataStore] Converted FormData at ${path}`);
|
return { valid: true, data: this.formDataToObject(obj) };
|
}
|
|
// Preserve safe types
|
if (obj instanceof Date || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj) || obj instanceof Blob) {
|
return { valid: true, data: obj };
|
}
|
|
// Convert Sets to Arrays
|
if (obj instanceof Set) {
|
return this.processForStorage(Array.from(obj), 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.processForStorage(obj[i], validate, `${path}[${i}]`);
|
if (!result.valid) return result;
|
if (result.data !== undefined) processed.push(result.data);
|
}
|
return { valid: true, data: processed };
|
}
|
|
// Objects
|
if (type === 'object') {
|
const processed = {};
|
for (const [key, value] of Object.entries(obj)) {
|
const result = this.processForStorage(value, validate, `${path}.${key}`);
|
if (!result.valid) return result;
|
if (result.data !== undefined) processed[key] = result.data;
|
}
|
return { valid: true, data: processed };
|
}
|
|
if (validate) return { valid: false, error: `Unknown type at ${path}` };
|
console.debug(`[DataStore] Stripped unknown type at ${path}`);
|
return { valid: true, data: undefined };
|
}
|
|
/***********************************************************************
|
* DATA ACCESS
|
***********************************************************************/
|
|
async delete(name, id) {
|
const store = this.stores.get(name);
|
store.data.delete(id);
|
|
await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
|
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());
|
}
|
/**
|
* Filter in-memory data by multiple index/value pairs
|
* @param {string} name - Store name
|
* @param {Object} criteria - Object of { indexName: acceptedValue(s) }
|
* @returns {Array} - Items matching ALL criteria
|
*
|
* @example
|
* filterByIndex(name, { field: 'upload_123', status: ['queued', 'uploading'] })
|
*/
|
filterByIndex(name, criteria) {
|
const store = this.stores.get(name);
|
if (!store) return [];
|
|
return Array.from(store.data.values()).filter(item => {
|
return Object.entries(criteria).every(([key, value]) => {
|
const accepted = Array.isArray(value) ? value : [value];
|
return accepted.includes(item[key]);
|
});
|
});
|
}
|
/**
|
* Get all items matching an index value
|
* @param {string} name - Store name
|
* @param {string} indexName - Name of the index to query
|
* @param {*} value - Value to match
|
* @returns {Promise<Array>} - Matching items
|
*/
|
async getAllByIndex(name, indexName, value) {
|
const store = this.stores.get(name);
|
const values = Array.isArray(value) ? value : [value];
|
|
// Try IndexedDB index query first (more efficient for large datasets)
|
if (store.db && store.db.objectStoreNames.contains(store.config.storeName)) {
|
try {
|
const tx = store.db.transaction([store.config.storeName], 'readonly');
|
const objectStore = tx.objectStore(store.config.storeName);
|
|
if (objectStore.indexNames.contains(indexName)) {
|
const index = objectStore.index(indexName);
|
|
const results = await Promise.all(
|
values.map(v => new Promise((resolve, reject) => {
|
const request = index.getAll(v);
|
request.onsuccess = () => resolve(request.result || []);
|
request.onerror = () => reject(request.error);
|
}))
|
);
|
|
return results.flat();
|
}
|
} catch (error) {
|
console.warn(`Index query failed for "${indexName}", falling back to filter:`, error);
|
}
|
}
|
|
// Fallback: filter in-memory data
|
return Array.from(store.data.values()).filter(item => values.includes(item[indexName]));
|
}
|
|
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();
|
|
await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
|
objectStore.clear();
|
});
|
|
this.notify(name, 'data-cleared');
|
}
|
|
/***********************************************************************
|
* FILTER OPERATIONS
|
***********************************************************************/
|
async updateFilters(name, updates, clearAll = false) {
|
const store = this.stores.get(name);
|
const oldFilters = { ...store.filters };
|
|
if (clearAll) {
|
store.filters = { ...store.config.filters };
|
} else {
|
// Apply updates (null/undefined/'' = delete)
|
Object.entries(updates).forEach(([key, value]) => {
|
if (value === null || value === undefined || value === '') {
|
delete store.filters[key];
|
} else {
|
store.filters[key] = value;
|
}
|
});
|
}
|
|
this.notify(name, 'filters-changed', {
|
oldFilters,
|
filters: store.filters,
|
updates
|
});
|
|
if (store.config.endpoint) {
|
await this.fetch(name);
|
}
|
}
|
|
setFilter(name, key, value) {
|
return this.updateFilters(name, { [key]: value });
|
}
|
|
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;
|
|
return this.updateFilters(name, filters);
|
}
|
|
removeFilter(name, key) {
|
return this.updateFilters(name, { [key]: null });
|
}
|
|
clearFilters(name) {
|
return this.updateFilters(name, {}, true);
|
}
|
|
/***********************************************************************
|
* CACHE OPERATIONS
|
***********************************************************************/
|
|
clearCache(name) {
|
const store = this.stores.get(name);
|
store.cache.clear();
|
|
if (store.db?.objectStoreNames.contains('cache')) {
|
this.withTransaction(name, 'cache', 'readwrite', (objectStore) => {
|
objectStore.clear();
|
});
|
}
|
|
this.notify(name, 'cache-cleared');
|
}
|
|
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;
|
}
|
|
/***********************************************************************
|
* EVENT SYSTEM
|
***********************************************************************/
|
|
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);
|
}
|
});
|
}
|
|
/***********************************************************************
|
* UTILITIES
|
***********************************************************************/
|
|
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', async function() {
|
window.auth.subscribe((event) => {
|
if (event === 'auth-loaded') {
|
window.jvbStore = new DataStore();
|
}
|
});
|
});
|