From a81f7043fc44382775f9afac48e4c7a651e7ac6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 04 Jan 2026 18:29:10 +0000
Subject: [PATCH] =PopulateForm.js and ContentRoutes.php minor changes
---
assets/js/concise/DataStore.js | 969 ++++++++++++++++++++++++++++++--------------------------
1 files changed, 519 insertions(+), 450 deletions(-)
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index 47f72e8..95027e3 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,10 +15,11 @@
DataStore.instance = this;
// Shared resources
- this.databases = new Map(); // Shared IndexedDB connections
- this.stores = new Map(); // Registered store namespaces
- this.subscribers = new Map(); // Per-store event subscribers
- this.pendingInits = new Map(); // Track initialization promises
+ this.dbConfig = new Map(); // Definitions for the databases
+ this.databases = new Map(); // Shared IndexedDB connections
+ this.stores = new Map(); // Registered store namespaces
+ this.subscribers = new Map(); // Per-store event subscribers
+ this.pendingInits = new Map(); // Track initialization promises
this.fetchQueue = [];
// Global state
@@ -26,8 +28,6 @@
this.loading = document.querySelector('dialog.loading');
this.init();
-
- window.addEventListener('beforeunload', () => this.destroy());
}
async init() {
@@ -41,71 +41,93 @@
/**
* Register a new store namespace
+ * @param {string} name Database Name
+ * @param {object|array} configs An object defining the store, or an array of objects defining the stores
+ * @param {number} version the database version
*/
- register(name, config = {}) {
- if (this.stores.has(name)) {
- console.warn(`Store "${name}" already registered`);
- return this.getStoreAPI(name);
+ register(name, configs = [], version = 1.1) {
+ if (!Array.isArray(configs)) configs = [configs];
+ if (configs.length === 0) return;
+
+ if (!this.dbConfig.has(name)) {
+ this.dbConfig.set(name, {
+ dbName: `jvb_${name}`,
+ version: version,
+ stores: {},
+ _initialized: false
+ });
}
- if (!config.keyPath) {
- throw new Error(`Store "${name}" requires a keyPath`);
- }
+ let dbEntry = this.dbConfig.get(name);
- const store = {
- name,
- config: {
- // Storage
- dbName: `jvb_${name}_db`,
- version: 1,
- storeName: 'items',
- keyPath: 'id',
- indexes: [],
+ configs.forEach(config => {
+ if (!config.storeName) {
+ throw new Error(`Store config for "${name}" missing storeName`);
+ }
+ if (!config.keyPath) {
+ throw new Error(`Store "${config.storeName}" requires keyPath`);
+ }
- // API
- endpoint: null,
- apiBase: jvbSettings.api,
- filters: {},
- required: null,
+ const storeKey = `${name}_${config.storeName}`;
- // Cache
- TTL: 3600000, // 1 hour
- useHttpCaching: true,
+ const store = {
+ config: {
+ // Storage
+ dbName: dbEntry.dbName,
+ storeName: 'items',
+ keyPath: 'id',
+ indexes: [],
- // Behavior
- showLoading: false,
- delayFetch: true,
- validateData: true, // Validate data is serializable
+ // API
+ endpoint: null,
+ apiBase: jvbSettings.api,
+ filters: {},
+ required: null,
- ...config
- },
+ // Cache
+ TTL: 3600000, // 1 hour
+ useHttpCaching: true,
- // State
- db: null,
- data: new Map(),
- cache: new Map(),
- httpHeaders: new Map(),
- filters: { ...config.filters },
- isFetching: false,
- currentRequest: null,
- lastResponse: null,
- _initialized: false
- };
+ // 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': jvbSettings?.nonce,
- ...store.config.headers
- };
+ store.config.headers = {
+ 'X-WP-Nonce': window.auth.getNonce(),
+ ...store.config.headers
+ };
- this.stores.set(name, store);
- this.subscribers.set(name, new Set());
+ dbEntry.stores[config.storeName] = storeKey;
+
+ this.stores.set(storeKey, store);
+ if (!this.subscribers.has(storeKey)) {
+ this.subscribers.set(storeKey, new Set());
+ }
+ });
// Initialize database asynchronously
- this.initStoreDB(name).catch(error => {
+ this.initDB(name).catch(error => {
console.error(`Failed to initialize store "${name}":`, error);
});
- return this.getStoreAPI(name);
+ const apis = {};
+ for (const [storeName, storeKey] of Object.entries(dbEntry.stores)) {
+ apis[storeName] = this.getStoreAPI(storeKey);
+ }
+ return apis;
}
/**
@@ -119,6 +141,8 @@
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),
@@ -130,7 +154,6 @@
// Cache methods
clearCache: () => this.clearCache(name),
- clearHttpHeaders: (key) => this.clearHttpHeaders(name, key),
// Event methods
subscribe: (callback) => this.subscribe(name, callback),
@@ -156,126 +179,115 @@
}
/**
- * Normalize data before saving - convert Sets/Maps automatically
+ * Convert FormData to plain object for storage
*/
- normalizeForStorage(obj) {
- if (obj === null || obj === undefined) return obj;
+ formDataToObject(formData) {
+ const obj = {
+ _isFormData: true,
+ entries: {}
+ };
- // Convert Set to Array
- if (obj instanceof Set) {
- return Array.from(obj);
- }
-
- // Convert Map to Object
- if (obj instanceof Map) {
- return Object.fromEntries(obj);
- }
-
- // Handle Arrays
- if (Array.isArray(obj)) {
- return obj.map(item => this.normalizeForStorage(item));
- }
-
- // Handle Objects
- if (typeof obj === 'object') {
- const normalized = {};
- for (const [key, value] of Object.entries(obj)) {
- normalized[key] = this.normalizeForStorage(value);
+ for (const [key, value] of formData.entries()) {
+ // Skip File/Blob objects - they're stored separately in UploadManager
+ if (value instanceof File || value instanceof Blob) {
+ continue;
}
- return normalized;
+
+ // Handle multiple values for same key
+ if (obj.entries[key]) {
+ if (!Array.isArray(obj.entries[key])) {
+ obj.entries[key] = [obj.entries[key]];
+ }
+ obj.entries[key].push(value);
+ } else {
+ obj.entries[key] = value;
+ }
}
return obj;
}
/**
- * Strip DOM references from object
+ * Convert stored object back to FormData
*/
- stripDOMReferences(obj, visited = new WeakSet()) {
- if (obj === null || obj === undefined) return obj;
+ async objectToFormData(obj) {
+ if (!obj._isFormData) return obj;
- const type = typeof obj;
- if (type === 'string' || type === 'number' || type === 'boolean') {
- return obj;
+ const formData = new FormData();
+
+ // Restore text entries
+ for (const [key, value] of Object.entries(obj.entries)) {
+ if (Array.isArray(value)) {
+ value.forEach(v => formData.append(key, v));
+ } else {
+ formData.append(key, value);
+ }
}
- // Prevent circular references
- if (type === 'object' && visited.has(obj)) {
- return '[Circular]';
- }
+ if (window.jvbUploads && obj.entries.upload_ids) {
+ const uploadIds = JSON.parse(obj.entries.upload_ids);
- // Remove DOM elements
- if (obj instanceof HTMLElement ||
- obj instanceof NodeList ||
- obj instanceof HTMLCollection ||
- obj.nodeType !== undefined) {
- return null;
- }
-
- // Handle Date
- if (obj instanceof Date) {
- return obj;
- }
-
- // Handle Arrays
- if (Array.isArray(obj)) {
- visited.add(obj);
- return obj.map(item => this.stripDOMReferences(item, visited)).filter(v => v !== null);
- }
-
- // Handle Objects
- if (type === 'object') {
- visited.add(obj);
- const cleaned = {};
- for (const [key, value] of Object.entries(obj)) {
- const cleanedValue = this.stripDOMReferences(value, visited);
- if (cleanedValue !== null) {
- cleaned[key] = cleanedValue;
+ for (const uploadId of uploadIds) {
+ const file = await window.jvbUploads.getBlobData(uploadId);
+ if (file) {
+ formData.append('files[]', file);
}
}
- return cleaned;
}
- return obj;
+ return formData;
}
- /**
- * Initialize database for a specific store
- */
- async initStoreDB(name) {
- const store = this.stores.get(name);
- if (!store || store._initialized) return;
+ /***********************************************************************
+ * 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._performStoreInit(name);
+ const initPromise = this._performDBInit(name);
this.pendingInits.set(name, initPromise);
try {
await initPromise;
- store._initialized = true;
+ db._initialized = true;
} finally {
this.pendingInits.delete(name);
}
}
- async _performStoreInit(name) {
- const store = this.stores.get(name);
- const { dbName, version } = store.config;
+ async _performDBInit(name) {
+ const database = this.dbConfig.get(name);
+ const { dbName, version } = database;
+ const stores = Object.values(database.stores);
try {
if (!this.databases.has(dbName)) {
const db = await this.openDatabase(dbName, version, (db) => {
- this.setupStores(db, store.config);
+ stores.forEach(store => {
+ let storeObj = this.stores.get(store);
+ if (storeObj) {
+ this.setupStores(db, storeObj.config);
+ }
+ });
});
this.databases.set(dbName, db);
}
- store.db = this.databases.get(dbName);
- this.loadStoreDataInBackground(name);
- this.notify(name, 'db-init');
+ stores.forEach(storeName => {
+ let store = this.stores.get(storeName);
+ if (store) {
+ store.db = this.databases.get(dbName);
+ store._initialized = true;
+ this.loadStoreDataInBackground(storeName);
+ this.notify(storeName, 'db-init');
+ }
+ });
} catch (error) {
console.error(`Failed to initialize database for store "${name}":`, error);
@@ -317,29 +329,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');
@@ -392,71 +430,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) {
@@ -464,10 +437,46 @@
}
if (!store._initialized) {
- await this.initStoreDB(name);
+ 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);
@@ -509,15 +518,11 @@
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();
@@ -529,26 +534,43 @@
signal: controller.signal
});
- if (response.status === 304 && cached) {
+ if (response.status === 304) {
+ // 304 means "Not Modified" - use cached data if available
+ if (cached) {
+ this.notify(name, 'data-loaded', {
+ cached: true,
+ notModified: true,
+ items: cached.items || []
+ });
+ return cached;
+ }
+
+ // No cached data but server says not modified - return empty result
this.notify(name, 'data-loaded', {
- cached: true,
+ cached: false,
notModified: true,
- items: cached.items || []
+ items: []
});
- return cached;
+
+ // Initialize empty lastResponse
+ store.lastResponse = {
+ has_more: false,
+ total: 0,
+ pages: 1,
+ queue_stats: {}
+ };
+
+ return { items: [] };
}
+ // Now check for other non-OK responses
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
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,
@@ -592,171 +614,214 @@
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 changes = []; // Track all 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')
};
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 || {}
+ };
+
+ // 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 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);
- processed = this.stripDOMReferences(processed);
+ // Write to IndexedDB immediately for single saves
+ await this.withTransaction(name, store.config.storeName, 'readwrite', (objectStore) => {
+ objectStore.put(changeInfo.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}`);
- }
- }
+ // Always emit for explicit saves
+ this.notify(name, 'item-saved', {
+ item: changeInfo.item,
+ key: changeInfo.key,
+ previousItem: changeInfo.previousItem
+ });
- const key = this.getItemKey(processed, store.config.keyPath);
-
- // Store in memory
- store.data.set(key, item);
-
- // Store 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.
- */
- validateSerializable(obj, path = 'root') {
- // Primitives are fine
- if (obj === null || obj === undefined) {
- return { valid: true };
- }
+ processForStorage(obj, validate = true, path = 'root') {
+ if (obj === null || obj === undefined) return { valid: true, data: obj };
const type = typeof obj;
- if (type === 'string' || type === 'number' || type === 'boolean') {
- return { valid: true };
+
+ // Handle primitives
+ if (['string', 'number', 'boolean'].includes(type)) {
+ return { valid: true, data: obj };
}
- // Functions cannot be serialized
+ // Reject functions
if (type === 'function') {
- return {
- valid: false,
- error: `Function at ${path}`
- };
+ if (validate) return { valid: false, error: `Function at ${path}` };
+ console.debug(`[DataStore] Stripped 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}` };
+ console.debug(`[DataStore] Stripped DOM element at ${path}`);
+ return { valid: true, data: undefined };
}
- // 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.`
- };
+ if (validate) return { valid: false, error: `FormData at ${path}` };
+ console.debug(`[DataStore] Converted FormData at ${path}`);
+ 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}`);
+ const result = this.processForStorage(value, validate, `${path}.${key}`);
if (!result.valid) return result;
+ if (result.data !== undefined) 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}` };
+ 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);
- 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 });
}
@@ -770,6 +835,64 @@
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);
@@ -792,35 +915,48 @@
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];
+ if (clearAll) {
+ store.filters = { ...store.config.filters };
} else {
- store.filters[key] = value;
+ // 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,
- changed: { key, oldValue, newValue: value }
+ updates
});
if (store.config.endpoint) {
- this.fetch(name);
+ await this.fetch(name);
}
}
+ setFilter(name, key, value) {
+ return this.updateFilters(name, { [key]: value });
+ }
+
async setFilters(name, filters) {
const store = this.stores.get(name);
@@ -830,88 +966,58 @@
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());
+ }
const subscribers = this.subscribers.get(name);
subscribers.add(callback);
return () => subscribers.delete(callback);
@@ -930,50 +1036,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') {
@@ -1015,6 +1080,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