From 3d25ad7de4fdbb3eba2cde2a62422e6be5d79a0a Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 25 Aug 2026 22:57:22 +0000
Subject: [PATCH] =It's been a minute. I went through the Routes and associated Managers and ensured they played together well. Reworked the Cache.php, and I think I caught all the references. Moving away from JVBase() root class, to individuall classes that are Instanced. Double checked for the ABSPATH check in every file, and added index.php to every directory that didn't already have it. Also still working on the MenuBlock to work with WooCommerce for now for Northeh
---
assets/js/concise/DataStore.js | 1266 ++++++++++++++++++++++++++++++++++++--------------------
1 files changed, 807 insertions(+), 459 deletions(-)
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index ffe1891..539c82b 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -6,6 +6,7 @@
* this.store = window.jvbStore.register('feed', { config });
*/
class DataStore {
+
constructor() {
// Singleton pattern
if (DataStore.instance) {
@@ -14,7 +15,7 @@
DataStore.instance = this;
// Shared resources
- this.dbConfig = new Map(); // Definitions for the databases
+ 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
@@ -27,8 +28,6 @@
this.loading = document.querySelector('dialog.loading');
this.init();
-
- // window.addEventListener('beforeunload', () => this.destroy());
}
async init() {
@@ -46,13 +45,12 @@
* @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) {
+ register(name, configs = [], version = 1.25) {
if (!Array.isArray(configs)) configs = [configs];
if (configs.length === 0) return;
-
if (!this.dbConfig.has(name)) {
this.dbConfig.set(name, {
- dbName: `jvb_${name}`,
+ dbName: `${jvbBase.base}${name}`,
version: version,
stores: {},
_initialized: false
@@ -69,7 +67,6 @@
throw new Error(`Store "${config.storeName}" requires keyPath`);
}
-
const storeKey = `${name}_${config.storeName}`;
const store = {
@@ -84,8 +81,11 @@
endpoint: null,
apiBase: jvbSettings.api,
filters: {},
+ ignore: [], //any filters to ignore when filtering store locally
required: null,
+ isAuth: false,
+
// Cache
TTL: 3600000, // 1 hour
useHttpCaching: true,
@@ -93,24 +93,28 @@
// Behavior
showLoading: false,
delayFetch: true,
- validateData: true, // Validate data is serializable
+ validateData: true,
...config
},
dbKey: name,
storeKey: storeKey,
data: new Map(),
cache: new Map(),
- httpHeaders: new Map(),
- subscribers: new Map(),
- filters: {...(config.filters || {}) },
+ filters: {...(config.filters || {})},
isFetching: false,
currentRequest: null,
lastResponse: null,
_initialized: false
};
+ store.ignoreFilters = new Set([
+ ... ['search', 'page', 'per_page', 'orderby', 'order'],
+ ... ['context', 'source'],
+ ... store.config.ignore
+ ]);
+
store.config.headers = {
- 'X-WP-Nonce': jvbSettings?.nonce,
+ 'X-WP-Nonce': window.auth.getNonce(),
...store.config.headers
};
@@ -143,9 +147,14 @@
// Data methods
fetch: () => this.fetch(name),
save: (item) => this.save(name, item),
+ saveMany: (items) => this.saveMany(name, items),
delete: (id) => this.delete(name, id),
+ deleteMany: (items) => this.deleteMany(name, items),
get: (id) => this.get(name, id),
+ getMany: (ids) => this.getMany(name, ids),
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),
@@ -157,7 +166,6 @@
// Cache methods
clearCache: () => this.clearCache(name),
- clearHttpHeaders: (key) => this.clearHttpHeaders(name, key),
// Event methods
subscribe: (callback) => this.subscribe(name, callback),
@@ -183,49 +191,6 @@
}
/**
- * 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) {
@@ -285,66 +250,10 @@
return formData;
}
- /**
- * Strip DOM references from object
- */
- stripDOMReferences(obj, visited = new WeakSet()) {
- if (obj === null || obj === undefined) return obj;
+ /***********************************************************************
+ * DATABASE INITIALIZATION
+ ***********************************************************************/
- 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;
@@ -390,7 +299,7 @@
this.loadStoreDataInBackground(storeName);
this.notify(storeName, 'db-init');
}
- })
+ });
} catch (error) {
console.error(`Failed to initialize database for store "${name}":`, error);
@@ -432,29 +341,55 @@
});
}
- // Cache store
+ // 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 });
}
+ }
- // HTTP headers store
- if (config.useHttpCaching && !db.objectStoreNames.contains('headers')) {
- db.createObjectStore('headers', { keyPath: 'key' });
+ /**
+ * 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;
- const tasks = [
- this.loadStoreData(name),
- this.loadStoreCache(name),
- this.loadStoreHeaders(name)
- ];
+ 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);
+ }),
- Promise.all(tasks)
+ // 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');
@@ -507,71 +442,6 @@
}
}
- 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) {
@@ -583,6 +453,45 @@
}
}
+ /***********************************************************************
+ * 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 = () => {
+ const error = tx.error || new Error('Transaction failed with unknown error');
+ reject(error);
+ };
+
+ // Call callback immediately to queue operations
+ try {
+ result = callback(objectStore, tx);
+ } catch (error) {
+ reject(error || new Error('Callback failed with unknown error'));
+ }
+ });
+ }
+
+ /***********************************************************************
+ * FETCH & DATA PROCESSING
+ ***********************************************************************/
+
async fetch(name) {
await this.ensureStoreInitialized(name);
@@ -611,9 +520,10 @@
const cached = store.cache.get(cacheKey);
if (cached && this.isCacheValid(cached, store.config.TTL)) {
+ let items = cached.items.map(itemId => this.get(name, itemId));
this.notify(name, 'data-loaded', {
cached: true,
- items: cached.items || []
+ items: items??[]
});
return cached;
}
@@ -624,46 +534,74 @@
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;
- }
+ // 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 && cached) {
- this.notify(name, 'data-loaded', {
- cached: true,
- notModified: true,
- items: cached.items || []
+ let response;
+ if (store.isAuth) {
+ response = await window.auth.fetch(url, {
+ method: 'GET',
+ headers,
+ signal: controller.signal
});
- return cached;
+ } else {
+ response = await fetch(url, {
+ method: 'GET',
+ headers,
+ signal: controller.signal
+ });
}
if (!response.ok) {
+ // Access the error details from the response body
+ const errorBody = await response.text();
+ // Throw a new error with a descriptive message
+ throw new Error(`HTTP error! status: ${response.status}, message: ${errorBody}`);
+ }
+
+ 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();
- if (store.config.useHttpCaching) {
- this.storeResponseHeaders(name, cacheKey, response);
- }
-
- await this.processFetchedData(name, data, cacheKey);
+ await this.processFetchedData(name, data, cacheKey, response);
this.notify(name, 'data-loaded', {
cached: false,
@@ -673,11 +611,14 @@
return data;
} catch (error) {
- if (error.name !== 'AbortError') {
- console.error(`Fetch error for store "${name}":`, error);
+ const isAbortError = error?.name === 'AbortError';
+
+ if (!isAbortError) {
+ console.error(`Fetch error for store "${name}":`, error.message);
+ console.dir(error);
this.notify(name, 'fetch-error', { error });
+ throw error;
}
- throw error;
} finally {
store.isFetching = false;
@@ -707,211 +648,581 @@
return params.toString() ? `${baseUrl}?${params}` : baseUrl;
}
- async processFetchedData(name, data, cacheKey) {
+ /**
+ * Process fetched data (batch from server)
+ */
+ async processFetchedData(name, data, cacheKey, response) {
const store = this.stores.get(name);
- const items = data.items || [];
+ const items = (data.items || []).filter(item => item && typeof item === 'object');
+ const changes = [];
- for (const item of items) {
- await this.save(name, item);
+ // 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 }
+ filters: { ...store.filters },
+ etag: response.headers.get('ETag'),
+ lastModified: response.headers.get('Last-Modified'),
+ has_more: data.has_more || false
};
store.cache.set(cacheKey, cacheEntry);
- await this.saveToCache(name, 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
+ pages: data.pages || 1,
+ queue_stats: data.queue_stats || {}
+ };
+
+ for (let [key, value] of Object.entries(store.filters)) {
+ if (typeof value === 'string' && value.includes(',')) {
+ this.createSplitCacheEntries(name, items, key, store.filters, response);
+ }
+ }
+
+ // 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
+ });
+ }
+ });
+ }
+
+ createSplitCacheEntries(name, items, key, filters, response) {
+ const store = this.stores.get(name);
+ const keys = filters[key].split(',').map(v => v.trim());
+
+ keys.forEach(value => {
+ let temp = {};
+ temp[key] = value;
+ const newFilters = {
+ ... filters,
+ [key]: value
+ };
+ const cacheKey = this.generateCacheKey(newFilters);
+ if(store.cache.has(cacheKey)) return;
+ let filteredItems = this.filterByIndex(name,temp).map(item => this.getItemKey(item, store.config.keyPath));
+
+ const entry = {
+ key: cacheKey,
+ items: filteredItems,
+ timestamp: Date.now(),
+ endpoint: store.config.endpoint,
+ filters: newFilters,
+ etag: response.headers.get('Etag'),
+ lastModified: response.headers.get('Last-Modified'),
+ has_more: filteredItems.length === 20,
+ }
+ store.cache.set(cacheKey, entry);
+ if (store.db?.objectStoreNames.contains('cache')) {
+ this.withTransaction(name, 'cache', 'readwrite', (objectStore) =>{
+ objectStore.put(entry);
+ });
+ }
+ })
+ }
+ /***********************************************************************
+ * 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 item to store
- * IMPORTANT: Item must be serializable (no DOM, FormData, Blobs)
+ * Save single item (public API)
*/
async save(name, item) {
const store = this.stores.get(name);
+ const changeInfo = this._saveItem(name, item);
- // Auto-normalize Sets/Maps
- let processed = this.normalizeForStorage(item);
+ // Write to IndexedDB immediately for single saves
+ await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+ objectStore.put(changeInfo.processed);
+ });
- if (processed.data instanceof FormData) {
- processed = {
- ...processed,
- data: this.formDataToObject(processed.data)
- };
- }
+ // Always emit for explicit saves
+ this.notify(name, 'item-saved', {
+ item: changeInfo.item,
+ key: changeInfo.key,
+ previousItem: changeInfo.previousItem
+ });
- 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;
+ return changeInfo.key;
}
/**
- * Validate that data is IndexedDB-serializable
- * Rejects: DOM elements, FormData, Blobs, Functions, etc.
+ * Save multiple items in a single transaction (batch write)
+ * @param {string} name - Store name
+ * @param {Array|Map} items - Array of items or Map of items to save
+ * @returns {Promise<Array>} - Array of saved keys
*/
- validateSerializable(obj, path = 'root') {
- // Primitives are fine
- if (obj === null || obj === undefined) {
- return { valid: true };
+ async saveMany(name, items) {
+ const store = this.stores.get(name);
+ if (!store) return [];
+
+ // Convert Map to array if needed
+ const itemArray = items instanceof Map
+ ? Array.from(items.values())
+ : Array.isArray(items) ? items : Object.values(items);
+
+ if (itemArray.length === 0) return [];
+
+ const changes = [];
+
+ // Process all items and update in-memory store
+ itemArray.forEach(item => {
+ const changeInfo = this._saveItem(name, item);
+ changes.push(changeInfo);
+ });
+
+ // Single transaction for all writes
+ await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+ changes.forEach(changeInfo => {
+ objectStore.put(changeInfo.processed);
+ });
+ });
+
+ // Notify once for batch
+ this.notify(name, 'items-saved', {
+ count: changes.length,
+ keys: changes.map(c => c.key)
+ });
+
+ return changes.map(c => c.key);
+ }
+
+ processForStorage(obj, validate = true, path = 'root') {
+ if (obj === null) {
+ return { valid: true, data: null };
+ }
+ if (obj === undefined) {
+ if (validate) {
+ return { valid: false, error: `Undefined value at ${path}` };
+ }
+ return { valid: true, data: undefined };
}
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}`
- };
+ if (validate) return { valid: false, error: `Function at ${path}` };
+
+ return { valid: true, data: undefined };
}
- // Date is serializable
- if (obj instanceof Date) {
- return { valid: true };
+ // DOM elements
+ if (obj instanceof HTMLElement || obj.nodeType !== undefined) {
+ if (validate) return { valid: false, error: `DOM element at ${path}` };
+
+ return { valid: true, data: undefined };
}
- if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) {
- return { valid: true };
- }
-
- // Reject DOM elements
- if (obj instanceof HTMLElement ||
- obj instanceof NodeList ||
- obj instanceof HTMLCollection ||
- (obj.nodeType !== undefined)) {
- return {
- valid: false,
- error: `DOM element at ${path}`
- };
- }
-
- // Reject FormData
+ // FormData - convert and continue
if (obj instanceof FormData) {
- return {
- valid: false,
- error: `FormData at ${path}. Convert to object first.`
- };
+
+ return { 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) || 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.validateSerializable(obj[i], `${path}[${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 };
+ 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}`);
+ if (value === undefined) continue;
+ const result = this.processForStorage(value, validate, `${path}.${key}`);
if (!result.valid) return result;
+ // Include null values, skip undefined
+ if (result.data !== undefined || value === null) {
+ processed[key] = result.data;
+ }
}
- return { valid: true };
+ return { valid: true, data: processed };
}
- return {
- valid: false,
- error: `Unknown type at ${path}: ${type}`
- };
+ if (validate) return { valid: false, error: `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);
- if (store.db) {
- const tx = store.db.transaction([store.config.storeName], 'readwrite');
- const objectStore = tx.objectStore(store.config.storeName);
- await objectStore.delete(id);
- }
+ await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+ objectStore.delete(id);
+ });
this.notify(name, 'item-deleted', { id });
}
+ /**
+ * Delete multiple items in a single transaction (batch delete)
+ * @param {string} name - Store name
+ * @param {Array|Set} ids - Array or Set of IDs to delete
+ * @returns {Promise<Array>} - Array of deleted IDs
+ */
+ async deleteMany(name, ids) {
+ const store = this.stores.get(name);
+ if (!store) return [];
+
+ // Convert Set to array if needed
+ const idArray = ids instanceof Set
+ ? Array.from(ids)
+ : Array.isArray(ids) ? ids : Object.keys(ids);
+
+ if (idArray.length === 0) return [];
+
+ // Update in-memory store
+ idArray.forEach(id => {
+ store.data.delete(id);
+ });
+
+ // Single transaction for all deletes
+ await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+ idArray.forEach(id => {
+ objectStore.delete(id);
+ });
+ });
+
+ // Notify once for batch
+ this.notify(name, 'items-deleted', {
+ count: idArray.length,
+ ids: idArray
+ });
+
+ return idArray;
+ }
+
get(name, id) {
const store = this.stores.get(name);
return store.data.get(id);
}
+ /**
+ * Get multiple items by IDs in a single call
+ * @param {string} name - Store name
+ * @param {Array|Set} ids - Array or Set of IDs to retrieve
+ * @param {boolean} skipMissing - If true, omit missing items; if false, include null for missing
+ * @returns {Array} - Array of items (in same order as IDs)
+ */
+ getMany(name, ids, skipMissing = true) {
+ const store = this.stores.get(name);
+ if (!store) return [];
+
+ const idArray = ids instanceof Set
+ ? Array.from(ids)
+ : Array.isArray(ids) ? ids : Object.keys(ids);
+
+ if (idArray.length === 0) return [];
+
+ if (skipMissing) {
+ return idArray.reduce((acc, id) => {
+ const item = store.data.get(id);
+ if (item) acc.push(item);
+ return acc;
+ }, []);
+ }
+
+ // Preserve order, include null for missing
+ return idArray.map(id => store.data.get(id) ?? null);
+ }
+
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 => {
+ if (!item || typeof item !== 'object') return false;
+ 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) => {
+ // First check if we have cached results for exact filters
+ if (cacheEntry?.items) {
+ const items = cacheEntry.items.reduce((acc, id) => {
const item = store.data.get(id);
if (item) acc.push(item);
return acc;
}, []);
+ return this.applyOrdering(items, store);
}
- return this.getAll(name);
+ const allItems = Array.from(store.data.values());
+
+ const searchQuery = store.filters.search?.toLowerCase().trim() || '';
+
+ const filterPredicates = [];
+
+ // Handle taxonomy filters separately
+ if (store.filters.taxonomy && typeof store.filters.taxonomy === 'object') {
+ Object.entries(store.filters.taxonomy).forEach(([taxonomy, termIds]) => {
+ const acceptedTermIds = Array.isArray(termIds) ? termIds : [termIds];
+
+ filterPredicates.push(item => {
+ if (!item.taxonomies || !item.taxonomies[taxonomy]) {
+ return false;
+ }
+ const itemTermIds = Object.keys(item.taxonomies[taxonomy]).map(id => parseInt(id));
+ const matches = acceptedTermIds.some(termId => itemTermIds.includes(parseInt(termId)));
+ return matches;
+ });
+ });
+ }
+
+ // Handle other filters
+ for (const [key, value] of Object.entries(store.filters)) {
+ if (key === 'taxonomy') {
+ if (typeof value === 'string' && !value.includes(',')) {
+ filterPredicates.push(item => item.taxonomy === value);
+ }
+ continue;
+ }
+ if (store.ignoreFilters.has(key)) {
+ continue;
+ }
+ if (value === null || value === undefined || value === '') continue;
+ if (value === 'all') continue;
+
+ if (typeof value === 'string' && value.includes(',')) {
+ const accepted = value.split(',').map(v => v.trim());
+ filterPredicates.push(item => accepted.includes(String(item[key])));
+ } else {
+ filterPredicates.push(item => String(item[key]) === String(value));
+ }
+ }
+
+ const filtered = allItems.filter(item => {
+ for (const predicate of filterPredicates) {
+ if (!predicate(item)) return false;
+ }
+ return !(searchQuery && !this.searchObject(item, searchQuery));
+ });
+
+ return this.applyOrdering(filtered, store);
+ }
+
+ applyOrdering(items, store) {
+ if (!Array.isArray(items)) items = Array.from(items);
+ if (items.length === 0) return items;
+
+ const orderby = store.filters.orderby || 'date';
+ const order = (store.filters.order || 'desc').toLowerCase();
+
+ // Handle random ordering
+ if (['random', 'rand'].includes(orderby) || ['random', 'rand'].includes(order)) {
+ return this.shuffle(items);
+ }
+
+ items.sort((a, b) => {
+ let aVal, bVal;
+
+ switch (orderby) {
+ case 'alphabetical':
+ case 'title':
+ aVal = (a.title || a.name || '').toLowerCase();
+ bVal = (b.title || b.name || '').toLowerCase();
+ break;
+ case 'modified':
+ aVal = new Date(a.modified || a.date || 0);
+ bVal = new Date(b.modified || b.date || 0);
+ break;
+ case 'date':
+ default:
+ aVal = new Date(a.date || a.modified || 0);
+ bVal = new Date(b.date || b.modified || 0);
+ }
+
+ if (aVal < bVal) return order === 'asc' ? -1 : 1;
+ if (aVal > bVal) return order === 'asc' ? 1 : -1;
+ return 0;
+ });
+
+ return items;
+ }
+
+ shuffle(items) {
+ const array = items.slice();
+ for (let i = array.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [array[i], array[j]] = [array[j], array[i]];
+ }
+ return array;
+ }
+
+ searchObject(obj, search) {
+ if (!obj || typeof obj !== 'object') {
+ return typeof obj === 'string' && obj.toLowerCase().includes(search);
+ }
+
+ for (const value of Object.values(obj)) {
+ if (value === null || value === undefined) continue;
+
+ if (typeof value === 'object') {
+ if (this.searchObject(value, search)) return true;
+ continue;
+ }
+
+ if (typeof value === 'string' && value.toLowerCase().includes(search)) {
+ return true;
+ }
+ }
+ return false;
}
async clear(name) {
@@ -919,124 +1230,198 @@
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();
- }
+ await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+ objectStore.clear();
+ });
this.notify(name, 'data-cleared');
}
- setFilter(name, key, value) {
+ /***********************************************************************
+ * FILTER OPERATIONS
+ ***********************************************************************/
+ async updateFilters(name, updates, clearAll = false) {
const store = this.stores.get(name);
- const oldValue = store.filters[key];
+ const oldFilters = { ...store.filters };
- if (value === null || value === undefined || value === '') {
- delete store.filters[key];
- } else {
- store.filters[key] = value;
+ if (clearAll) {
+ store.filters = { ...store.config.filters };
}
+
+ 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,
- changed: { key, oldValue, newValue: value }
+ updates
});
- if (store.config.endpoint) {
- this.fetch(name);
+ const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters);
+
+ if (store.config.endpoint && shouldFetch) {
+ await this.fetch(name);
+ } else {
+ const filtered = this.getFiltered(name);
+ this.notify(name, 'data-loaded', {
+ cached: true,
+ items: filtered
+ });
}
}
+ /**
+ * Determine if we need to fetch or can use local data
+ * @param {string} name - Store name
+ * @param {object} updates - Filter updates being applied
+ * @param {object} oldFilters - Previous filter state
+ * @returns {Promise<boolean>} - True if fetch is needed, false if local filtering suffices
+ */
+ async shouldFetchWithFilters(name, updates, oldFilters) {
+ const store = this.stores.get(name);
+
+ // If no endpoint or no lastResponse, always fetch
+ if (!store.config.endpoint || !store.lastResponse) {
+ return true;
+ }
+
+ if (store.lastResponse.has_more === false) {
+ if (this.hasCompleteData(store, store.filters)) {
+ return false;
+ }
+ }
+
+ if ('page' in updates) {
+ const newPage = updates.page;
+ const oldPage = oldFilters.page || 1;
+
+ // If trying to go to a higher page but no more data available
+ if (newPage > oldPage && !store.lastResponse.has_more) {
+ // Reset page to last valid page
+ store.filters.page = oldPage;
+ return false;
+ }
+ }
+
+ // SEARCH OPTIMIZATION: Check if we need to fetch for search
+ if ('search' in updates) {
+ const searchQuery = updates.search?.trim() || '';
+ const oldSearch = oldFilters.search?.trim() || '';
+
+ // If search is being cleared, we might already have the data
+ if (!searchQuery && oldSearch) {
+ // Check if we have all base data (without search)
+ const baseFilters = { ...store.filters };
+ delete baseFilters.search;
+ baseFilters.page = 1;
+
+ // If we have complete base data, no need to fetch
+ if (this.hasCompleteData(store, baseFilters)) {
+ return false;
+ }
+ }
+
+ // If search is new or changed, check if we have all data to filter locally
+ if (searchQuery && searchQuery !== oldSearch) {
+ // Check: do we have all data for base filters (no search, page 1)?
+ const baseFilters = { ...store.filters };
+ delete baseFilters.search;
+ baseFilters.page = 1;
+
+ // If we have complete base data, we can filter locally
+ if (this.hasCompleteData(store, baseFilters)) {
+ return false;
+ }
+ }
+ }
+
+ // Default: fetch is needed
+ return true;
+ }
+
+ /**
+ * Check if we have complete data for given filters
+ * @param {object} store - Store instance
+ * @param {object} filters - Filters to check
+ * @returns {boolean} - True if we have all data
+ */
+ hasCompleteData(store, filters) {
+ const cacheKey = this.generateCacheKey(filters);
+ const cached = store.cache.get(cacheKey);
+
+ if (!cached) return false;
+
+ // Check if cache indicates no more data
+ return cached.has_more === false || store.lastResponse?.has_more === false;
+ }
+
+ 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]
+ ) || Object.keys(store.filters).some(
+ key => !(key in filters) && filters !== store.config.filters
);
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);
- }
+ return this.updateFilters(name, filters);
}
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);
- }
- }
+ return this.updateFilters(name, { [key]: null });
}
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);
- }
+ return this.updateFilters(name, {}, true);
}
+ /***********************************************************************
+ * CACHE OPERATIONS
+ ***********************************************************************/
+
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();
+ if (store.db?.objectStoreNames.contains('cache')) {
+ this.withTransaction(name, 'cache', 'readwrite', (objectStore) => {
+ 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();
- }
- }
+ 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());
@@ -1059,50 +1444,9 @@
});
}
- 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;
- }
+ /***********************************************************************
+ * UTILITIES
+ ***********************************************************************/
getItemKey(item, keyPath) {
if (typeof keyPath === 'function') {
@@ -1144,6 +1488,10 @@
}
// 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();
+ }
+ });
});
--
Gitblit v1.10.0