Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
1143 lines
38 KiB
JavaScript
1143 lines
38 KiB
JavaScript
// modules/api-manager/StorageManager.js
|
|
import { Logger } from '../../core/logger.js';
|
|
|
|
/**
|
|
* Storage APIs Manager - IndexedDB, Cache API, Web Locks, Broadcast Channel
|
|
*/
|
|
export class StorageManager {
|
|
constructor(config = {}) {
|
|
this.config = {
|
|
dbName: config.dbName || 'AppDatabase',
|
|
dbVersion: config.dbVersion || 1,
|
|
enableAnalytics: config.enableAnalytics ?? true,
|
|
enableQuotaMonitoring: config.enableQuotaMonitoring ?? true,
|
|
quotaWarningThreshold: config.quotaWarningThreshold || 0.8, // 80%
|
|
...config
|
|
};
|
|
|
|
this.db = null;
|
|
this.cache = null;
|
|
this.channels = new Map();
|
|
|
|
// Analytics
|
|
this.analytics = {
|
|
operations: {
|
|
get: 0,
|
|
set: 0,
|
|
delete: 0,
|
|
clear: 0
|
|
},
|
|
errors: 0,
|
|
quotaWarnings: 0
|
|
};
|
|
|
|
// Check API support
|
|
this.support = {
|
|
indexedDB: 'indexedDB' in window,
|
|
cacheAPI: 'caches' in window,
|
|
webLocks: 'locks' in navigator,
|
|
broadcastChannel: 'BroadcastChannel' in window,
|
|
localStorage: 'localStorage' in window,
|
|
sessionStorage: 'sessionStorage' in window,
|
|
storageEstimate: 'storage' in navigator && 'estimate' in navigator.storage
|
|
};
|
|
|
|
Logger.info('[StorageManager] Initialized with support:', this.support);
|
|
|
|
// Initialize databases
|
|
this.initializeDB();
|
|
this.initializeCache();
|
|
|
|
// Start quota monitoring if enabled
|
|
if (this.config.enableQuotaMonitoring) {
|
|
this.startQuotaMonitoring();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize IndexedDB
|
|
*/
|
|
async initializeDB() {
|
|
if (!this.support.indexedDB) {
|
|
Logger.warn('[StorageManager] IndexedDB not supported');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
|
|
request.onerror = () => {
|
|
Logger.error('[StorageManager] IndexedDB failed to open');
|
|
};
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
|
|
// Create default object stores
|
|
if (!db.objectStoreNames.contains('keyValue')) {
|
|
const store = db.createObjectStore('keyValue', { keyPath: 'key' });
|
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
}
|
|
|
|
if (!db.objectStoreNames.contains('cache')) {
|
|
db.createObjectStore('cache', { keyPath: 'key' });
|
|
}
|
|
|
|
if (!db.objectStoreNames.contains('files')) {
|
|
const fileStore = db.createObjectStore('files', { keyPath: 'id', autoIncrement: true });
|
|
fileStore.createIndex('name', 'name', { unique: false });
|
|
fileStore.createIndex('type', 'type', { unique: false });
|
|
}
|
|
|
|
Logger.info('[StorageManager] IndexedDB schema updated');
|
|
};
|
|
|
|
request.onsuccess = (event) => {
|
|
this.db = event.target.result;
|
|
Logger.info('[StorageManager] IndexedDB connected');
|
|
};
|
|
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] IndexedDB initialization failed:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize Cache API
|
|
*/
|
|
async initializeCache() {
|
|
if (!this.support.cacheAPI) {
|
|
Logger.warn('[StorageManager] Cache API not supported');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.cache = await caches.open(this.config.cacheName || 'app-cache-v1');
|
|
Logger.info('[StorageManager] Cache API initialized');
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Cache API initialization failed:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* IndexedDB Operations
|
|
*/
|
|
db = {
|
|
// Set key-value data
|
|
set: async (key, value, expiration = null) => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.db) {
|
|
reject(new Error('IndexedDB not available'));
|
|
return;
|
|
}
|
|
|
|
const transaction = this.db.transaction(['keyValue'], 'readwrite');
|
|
const store = transaction.objectStore('keyValue');
|
|
|
|
const data = {
|
|
key,
|
|
value,
|
|
timestamp: Date.now(),
|
|
expiration: expiration ? Date.now() + expiration : null
|
|
};
|
|
|
|
const request = store.put(data);
|
|
|
|
request.onsuccess = () => {
|
|
Logger.info(`[StorageManager] DB set: ${key}`);
|
|
resolve(data);
|
|
};
|
|
|
|
request.onerror = () => {
|
|
Logger.error(`[StorageManager] DB set failed: ${key}`);
|
|
reject(request.error);
|
|
};
|
|
});
|
|
},
|
|
|
|
// Get key-value data
|
|
get: async (key) => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.db) {
|
|
reject(new Error('IndexedDB not available'));
|
|
return;
|
|
}
|
|
|
|
const transaction = this.db.transaction(['keyValue'], 'readonly');
|
|
const store = transaction.objectStore('keyValue');
|
|
const request = store.get(key);
|
|
|
|
request.onsuccess = () => {
|
|
const result = request.result;
|
|
|
|
if (!result) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
// Check expiration
|
|
if (result.expiration && Date.now() > result.expiration) {
|
|
this.db.delete(key); // Auto-cleanup expired data
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
resolve(result.value);
|
|
};
|
|
|
|
request.onerror = () => {
|
|
Logger.error(`[StorageManager] DB get failed: ${key}`);
|
|
reject(request.error);
|
|
};
|
|
});
|
|
},
|
|
|
|
// Delete key
|
|
delete: async (key) => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.db) {
|
|
reject(new Error('IndexedDB not available'));
|
|
return;
|
|
}
|
|
|
|
const transaction = this.db.transaction(['keyValue'], 'readwrite');
|
|
const store = transaction.objectStore('keyValue');
|
|
const request = store.delete(key);
|
|
|
|
request.onsuccess = () => {
|
|
Logger.info(`[StorageManager] DB deleted: ${key}`);
|
|
resolve(true);
|
|
};
|
|
|
|
request.onerror = () => {
|
|
reject(request.error);
|
|
};
|
|
});
|
|
},
|
|
|
|
// Get all keys
|
|
keys: async () => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.db) {
|
|
reject(new Error('IndexedDB not available'));
|
|
return;
|
|
}
|
|
|
|
const transaction = this.db.transaction(['keyValue'], 'readonly');
|
|
const store = transaction.objectStore('keyValue');
|
|
const request = store.getAllKeys();
|
|
|
|
request.onsuccess = () => {
|
|
resolve(request.result);
|
|
};
|
|
|
|
request.onerror = () => {
|
|
reject(request.error);
|
|
};
|
|
});
|
|
},
|
|
|
|
// Clear all data
|
|
clear: async () => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.db) {
|
|
reject(new Error('IndexedDB not available'));
|
|
return;
|
|
}
|
|
|
|
const transaction = this.db.transaction(['keyValue'], 'readwrite');
|
|
const store = transaction.objectStore('keyValue');
|
|
const request = store.clear();
|
|
|
|
request.onsuccess = () => {
|
|
Logger.info('[StorageManager] DB cleared');
|
|
resolve(true);
|
|
};
|
|
|
|
request.onerror = () => {
|
|
reject(request.error);
|
|
};
|
|
});
|
|
},
|
|
|
|
// Store files/blobs
|
|
storeFile: async (name, file, metadata = {}) => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.db) {
|
|
reject(new Error('IndexedDB not available'));
|
|
return;
|
|
}
|
|
|
|
const transaction = this.db.transaction(['files'], 'readwrite');
|
|
const store = transaction.objectStore('files');
|
|
|
|
const fileData = {
|
|
name,
|
|
file,
|
|
type: file.type,
|
|
size: file.size,
|
|
timestamp: Date.now(),
|
|
metadata
|
|
};
|
|
|
|
const request = store.add(fileData);
|
|
|
|
request.onsuccess = () => {
|
|
Logger.info(`[StorageManager] File stored: ${name}`);
|
|
resolve({ id: request.result, ...fileData });
|
|
};
|
|
|
|
request.onerror = () => {
|
|
reject(request.error);
|
|
};
|
|
});
|
|
},
|
|
|
|
// Get file
|
|
getFile: async (id) => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.db) {
|
|
reject(new Error('IndexedDB not available'));
|
|
return;
|
|
}
|
|
|
|
const transaction = this.db.transaction(['files'], 'readonly');
|
|
const store = transaction.objectStore('files');
|
|
const request = store.get(id);
|
|
|
|
request.onsuccess = () => {
|
|
resolve(request.result);
|
|
};
|
|
|
|
request.onerror = () => {
|
|
reject(request.error);
|
|
};
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Cache API Operations
|
|
*/
|
|
cache = {
|
|
// Add request/response to cache
|
|
add: async (request, response = null) => {
|
|
if (!this.cache) {
|
|
throw new Error('Cache API not available');
|
|
}
|
|
|
|
try {
|
|
if (response) {
|
|
await this.cache.put(request, response);
|
|
} else {
|
|
await this.cache.add(request);
|
|
}
|
|
Logger.info(`[StorageManager] Cache add: ${request}`);
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Cache add failed:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Get from cache
|
|
get: async (request) => {
|
|
if (!this.cache) {
|
|
throw new Error('Cache API not available');
|
|
}
|
|
|
|
try {
|
|
const response = await this.cache.match(request);
|
|
if (response) {
|
|
Logger.info(`[StorageManager] Cache hit: ${request}`);
|
|
} else {
|
|
Logger.info(`[StorageManager] Cache miss: ${request}`);
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Cache get failed:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Delete from cache
|
|
delete: async (request) => {
|
|
if (!this.cache) {
|
|
throw new Error('Cache API not available');
|
|
}
|
|
|
|
try {
|
|
const success = await this.cache.delete(request);
|
|
if (success) {
|
|
Logger.info(`[StorageManager] Cache deleted: ${request}`);
|
|
}
|
|
return success;
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Cache delete failed:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Get all cached requests
|
|
keys: async () => {
|
|
if (!this.cache) {
|
|
throw new Error('Cache API not available');
|
|
}
|
|
|
|
try {
|
|
return await this.cache.keys();
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Cache keys failed:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Clear cache
|
|
clear: async () => {
|
|
if (!this.cache) {
|
|
throw new Error('Cache API not available');
|
|
}
|
|
|
|
try {
|
|
const keys = await this.cache.keys();
|
|
await Promise.all(keys.map(key => this.cache.delete(key)));
|
|
Logger.info('[StorageManager] Cache cleared');
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Cache clear failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Broadcast Channel for cross-tab communication
|
|
*/
|
|
channel = {
|
|
// Create or get channel
|
|
create: (channelName, onMessage = null) => {
|
|
if (!this.support.broadcastChannel) {
|
|
Logger.warn('[StorageManager] BroadcastChannel not supported');
|
|
return null;
|
|
}
|
|
|
|
if (this.channels.has(channelName)) {
|
|
return this.channels.get(channelName);
|
|
}
|
|
|
|
const channel = new BroadcastChannel(channelName);
|
|
|
|
if (onMessage) {
|
|
channel.addEventListener('message', onMessage);
|
|
}
|
|
|
|
const channelWrapper = {
|
|
name: channelName,
|
|
channel,
|
|
|
|
send: (data) => {
|
|
channel.postMessage({
|
|
data,
|
|
timestamp: Date.now(),
|
|
sender: 'current-tab'
|
|
});
|
|
Logger.info(`[StorageManager] Broadcast sent to ${channelName}`);
|
|
},
|
|
|
|
onMessage: (callback) => {
|
|
channel.addEventListener('message', (event) => {
|
|
callback(event.data);
|
|
});
|
|
},
|
|
|
|
close: () => {
|
|
channel.close();
|
|
this.channels.delete(channelName);
|
|
Logger.info(`[StorageManager] Channel closed: ${channelName}`);
|
|
}
|
|
};
|
|
|
|
this.channels.set(channelName, channelWrapper);
|
|
Logger.info(`[StorageManager] Channel created: ${channelName}`);
|
|
|
|
return channelWrapper;
|
|
},
|
|
|
|
// Get existing channel
|
|
get: (channelName) => {
|
|
return this.channels.get(channelName) || null;
|
|
},
|
|
|
|
// Close channel
|
|
close: (channelName) => {
|
|
const channel = this.channels.get(channelName);
|
|
if (channel) {
|
|
channel.close();
|
|
}
|
|
},
|
|
|
|
// Close all channels
|
|
closeAll: () => {
|
|
this.channels.forEach(channel => channel.close());
|
|
this.channels.clear();
|
|
Logger.info('[StorageManager] All channels closed');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Web Locks API for resource locking
|
|
*/
|
|
locks = {
|
|
// Acquire lock
|
|
acquire: async (lockName, callback, options = {}) => {
|
|
if (!this.support.webLocks) {
|
|
Logger.warn('[StorageManager] Web Locks not supported, executing without lock');
|
|
return await callback();
|
|
}
|
|
|
|
try {
|
|
return await navigator.locks.request(lockName, options, async (lock) => {
|
|
Logger.info(`[StorageManager] Lock acquired: ${lockName}`);
|
|
const result = await callback(lock);
|
|
Logger.info(`[StorageManager] Lock released: ${lockName}`);
|
|
return result;
|
|
});
|
|
} catch (error) {
|
|
Logger.error(`[StorageManager] Lock failed: ${lockName}`, error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Query locks
|
|
query: async () => {
|
|
if (!this.support.webLocks) {
|
|
return { held: [], pending: [] };
|
|
}
|
|
|
|
try {
|
|
return await navigator.locks.query();
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Lock query failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Local/Session Storage helpers (with JSON support)
|
|
*/
|
|
local = {
|
|
set: (key, value, expiration = null) => {
|
|
if (!this.support.localStorage) return false;
|
|
|
|
try {
|
|
const data = {
|
|
value,
|
|
timestamp: Date.now(),
|
|
expiration: expiration ? Date.now() + expiration : null
|
|
};
|
|
localStorage.setItem(key, JSON.stringify(data));
|
|
return true;
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] localStorage set failed:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
get: (key) => {
|
|
if (!this.support.localStorage) return null;
|
|
|
|
try {
|
|
const item = localStorage.getItem(key);
|
|
if (!item) return null;
|
|
|
|
const data = JSON.parse(item);
|
|
|
|
// Check expiration
|
|
if (data.expiration && Date.now() > data.expiration) {
|
|
localStorage.removeItem(key);
|
|
return null;
|
|
}
|
|
|
|
return data.value;
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] localStorage get failed:', error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
delete: (key) => {
|
|
if (!this.support.localStorage) return false;
|
|
localStorage.removeItem(key);
|
|
return true;
|
|
},
|
|
|
|
clear: () => {
|
|
if (!this.support.localStorage) return false;
|
|
localStorage.clear();
|
|
return true;
|
|
},
|
|
|
|
keys: () => {
|
|
if (!this.support.localStorage) return [];
|
|
return Object.keys(localStorage);
|
|
}
|
|
};
|
|
|
|
session = {
|
|
set: (key, value) => {
|
|
if (!this.support.sessionStorage) return false;
|
|
|
|
try {
|
|
sessionStorage.setItem(key, JSON.stringify(value));
|
|
return true;
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] sessionStorage set failed:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
get: (key) => {
|
|
if (!this.support.sessionStorage) return null;
|
|
|
|
try {
|
|
const item = sessionStorage.getItem(key);
|
|
return item ? JSON.parse(item) : null;
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] sessionStorage get failed:', error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
delete: (key) => {
|
|
if (!this.support.sessionStorage) return false;
|
|
sessionStorage.removeItem(key);
|
|
return true;
|
|
},
|
|
|
|
clear: () => {
|
|
if (!this.support.sessionStorage) return false;
|
|
sessionStorage.clear();
|
|
return true;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Smart caching with automatic expiration
|
|
*/
|
|
smartCache = {
|
|
set: async (key, value, options = {}) => {
|
|
const {
|
|
storage = 'indexedDB',
|
|
expiration = null,
|
|
fallback = true
|
|
} = options;
|
|
|
|
try {
|
|
if (storage === 'indexedDB' && this.db) {
|
|
return await this.db.set(key, value, expiration);
|
|
} else if (fallback) {
|
|
return this.local.set(key, value, expiration);
|
|
}
|
|
} catch (error) {
|
|
if (fallback) {
|
|
return this.local.set(key, value, expiration);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
get: async (key, options = {}) => {
|
|
const { storage = 'indexedDB', fallback = true } = options;
|
|
|
|
try {
|
|
if (storage === 'indexedDB' && this.db) {
|
|
return await this.db.get(key);
|
|
} else if (fallback) {
|
|
return this.local.get(key);
|
|
}
|
|
} catch (error) {
|
|
if (fallback) {
|
|
return this.local.get(key);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
delete: async (key, options = {}) => {
|
|
const { storage = 'indexedDB', fallback = true } = options;
|
|
|
|
try {
|
|
if (storage === 'indexedDB' && this.db) {
|
|
await this.db.delete(key);
|
|
}
|
|
if (fallback) {
|
|
this.local.delete(key);
|
|
}
|
|
} catch (error) {
|
|
if (fallback) {
|
|
this.local.delete(key);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get storage usage statistics
|
|
*/
|
|
async getStorageUsage() {
|
|
const stats = {
|
|
quota: 0,
|
|
usage: 0,
|
|
available: 0,
|
|
percentage: 0
|
|
};
|
|
|
|
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
|
try {
|
|
const estimate = await navigator.storage.estimate();
|
|
stats.quota = estimate.quota || 0;
|
|
stats.usage = estimate.usage || 0;
|
|
stats.available = stats.quota - stats.usage;
|
|
stats.percentage = stats.quota > 0 ? Math.round((stats.usage / stats.quota) * 100) : 0;
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Storage estimate failed:', error);
|
|
}
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Cleanup expired data
|
|
*/
|
|
async cleanup() {
|
|
Logger.info('[StorageManager] Starting cleanup...');
|
|
|
|
try {
|
|
// Cleanup IndexedDB expired entries
|
|
if (this.db) {
|
|
const transaction = this.db.transaction(['keyValue'], 'readwrite');
|
|
const store = transaction.objectStore('keyValue');
|
|
const index = store.index('timestamp');
|
|
|
|
const request = index.openCursor();
|
|
let cleaned = 0;
|
|
|
|
request.onsuccess = (event) => {
|
|
const cursor = event.target.result;
|
|
if (cursor) {
|
|
const data = cursor.value;
|
|
if (data.expiration && Date.now() > data.expiration) {
|
|
cursor.delete();
|
|
cleaned++;
|
|
}
|
|
cursor.continue();
|
|
} else {
|
|
Logger.info(`[StorageManager] Cleanup completed: ${cleaned} expired entries removed`);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Cleanup localStorage expired entries
|
|
if (this.support.localStorage) {
|
|
const keys = Object.keys(localStorage);
|
|
let localCleaned = 0;
|
|
|
|
keys.forEach(key => {
|
|
try {
|
|
const item = localStorage.getItem(key);
|
|
const data = JSON.parse(item);
|
|
|
|
if (data.expiration && Date.now() > data.expiration) {
|
|
localStorage.removeItem(key);
|
|
localCleaned++;
|
|
}
|
|
} catch (error) {
|
|
// Ignore non-JSON items
|
|
}
|
|
});
|
|
|
|
if (localCleaned > 0) {
|
|
Logger.info(`[StorageManager] LocalStorage cleanup: ${localCleaned} expired entries removed`);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Cleanup failed:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get comprehensive storage status
|
|
*/
|
|
async getStatus() {
|
|
const usage = await this.getStorageUsage();
|
|
|
|
return {
|
|
support: this.support,
|
|
usage,
|
|
activeChannels: this.channels.size,
|
|
dbConnected: !!this.db,
|
|
cacheConnected: !!this.cache,
|
|
channelNames: Array.from(this.channels.keys()),
|
|
analytics: this.config.enableAnalytics ? this.getAnalytics() : null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Unified storage API - works with any storage type
|
|
*/
|
|
storage = {
|
|
/**
|
|
* Set value in storage
|
|
*/
|
|
set: async (key, value, options = {}) => {
|
|
const {
|
|
type = 'auto', // 'auto' | 'localStorage' | 'sessionStorage' | 'indexedDB'
|
|
expiration = null,
|
|
...rest
|
|
} = options;
|
|
|
|
try {
|
|
// Auto-select storage type based on size
|
|
let storageType = type;
|
|
if (type === 'auto') {
|
|
const valueSize = JSON.stringify(value).length;
|
|
storageType = valueSize > 5 * 1024 * 1024 ? 'indexedDB' : 'localStorage'; // >5MB use IndexedDB
|
|
}
|
|
|
|
switch (storageType) {
|
|
case 'localStorage':
|
|
return this.local.set(key, value, expiration);
|
|
case 'sessionStorage':
|
|
return this.session.set(key, value);
|
|
case 'indexedDB':
|
|
return await this.db.set(key, value, expiration);
|
|
default:
|
|
throw new Error(`Unknown storage type: ${storageType}`);
|
|
}
|
|
} catch (error) {
|
|
// Fallback to localStorage if IndexedDB fails
|
|
if (type === 'indexedDB' || type === 'auto') {
|
|
Logger.warn('[StorageManager] Falling back to localStorage', error);
|
|
return this.local.set(key, value, expiration);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.operations.set++;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get value from storage
|
|
*/
|
|
get: async (key, options = {}) => {
|
|
const { type = 'auto', ...rest } = options;
|
|
|
|
try {
|
|
// Try different storage types in order
|
|
if (type === 'auto') {
|
|
// Try localStorage first (fastest)
|
|
const localValue = this.local.get(key);
|
|
if (localValue !== null) {
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.operations.get++;
|
|
}
|
|
return localValue;
|
|
}
|
|
|
|
// Try sessionStorage
|
|
const sessionValue = this.session.get(key);
|
|
if (sessionValue !== null) {
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.operations.get++;
|
|
}
|
|
return sessionValue;
|
|
}
|
|
|
|
// Try IndexedDB
|
|
if (this.db) {
|
|
const dbValue = await this.db.get(key);
|
|
if (dbValue !== null) {
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.operations.get++;
|
|
}
|
|
return dbValue;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} else {
|
|
switch (type) {
|
|
case 'localStorage':
|
|
const localValue = this.local.get(key);
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.operations.get++;
|
|
}
|
|
return localValue;
|
|
case 'sessionStorage':
|
|
const sessionValue = this.session.get(key);
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.operations.get++;
|
|
}
|
|
return sessionValue;
|
|
case 'indexedDB':
|
|
const dbValue = await this.db.get(key);
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.operations.get++;
|
|
}
|
|
return dbValue;
|
|
default:
|
|
throw new Error(`Unknown storage type: ${type}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Storage get failed', error);
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.errors++;
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Delete value from storage
|
|
*/
|
|
delete: async (key, options = {}) => {
|
|
const { type = 'all', ...rest } = options;
|
|
|
|
try {
|
|
if (type === 'all') {
|
|
// Delete from all storage types
|
|
this.local.delete(key);
|
|
this.session.delete(key);
|
|
if (this.db) {
|
|
await this.db.delete(key);
|
|
}
|
|
} else {
|
|
switch (type) {
|
|
case 'localStorage':
|
|
this.local.delete(key);
|
|
break;
|
|
case 'sessionStorage':
|
|
this.session.delete(key);
|
|
break;
|
|
case 'indexedDB':
|
|
if (this.db) {
|
|
await this.db.delete(key);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.operations.delete++;
|
|
}
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Storage delete failed', error);
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.errors++;
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clear all storage
|
|
*/
|
|
clear: async (options = {}) => {
|
|
const { type = 'all', ...rest } = options;
|
|
|
|
try {
|
|
if (type === 'all') {
|
|
this.local.clear();
|
|
this.session.clear();
|
|
if (this.db) {
|
|
await this.db.clear();
|
|
}
|
|
if (this.cache) {
|
|
await this.cache.clear();
|
|
}
|
|
} else {
|
|
switch (type) {
|
|
case 'localStorage':
|
|
this.local.clear();
|
|
break;
|
|
case 'sessionStorage':
|
|
this.session.clear();
|
|
break;
|
|
case 'indexedDB':
|
|
if (this.db) {
|
|
await this.db.clear();
|
|
}
|
|
break;
|
|
case 'cache':
|
|
if (this.cache) {
|
|
await this.cache.clear();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.operations.clear++;
|
|
}
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Storage clear failed', error);
|
|
if (this.config.enableAnalytics) {
|
|
this.analytics.errors++;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Start quota monitoring
|
|
*/
|
|
startQuotaMonitoring() {
|
|
// Check quota periodically
|
|
setInterval(async () => {
|
|
try {
|
|
const usage = await this.getStorageUsage();
|
|
if (usage.percentage >= this.config.quotaWarningThreshold * 100) {
|
|
this.analytics.quotaWarnings++;
|
|
Logger.warn('[StorageManager] Storage quota warning', {
|
|
usage: usage.percentage + '%',
|
|
available: this.formatBytes(usage.available)
|
|
});
|
|
|
|
// Trigger event
|
|
const event = new CustomEvent('storage:quota-warning', {
|
|
detail: usage,
|
|
bubbles: true
|
|
});
|
|
window.dispatchEvent(event);
|
|
}
|
|
} catch (error) {
|
|
Logger.error('[StorageManager] Quota monitoring error', error);
|
|
}
|
|
}, 60000); // Check every minute
|
|
}
|
|
|
|
/**
|
|
* Migrate data between storage types
|
|
*/
|
|
async migrate(fromType, toType, keys = null) {
|
|
Logger.info('[StorageManager] Starting migration', { fromType, toType });
|
|
|
|
let sourceKeys = keys;
|
|
|
|
// Get keys from source
|
|
if (!sourceKeys) {
|
|
switch (fromType) {
|
|
case 'localStorage':
|
|
sourceKeys = this.local.keys();
|
|
break;
|
|
case 'sessionStorage':
|
|
sourceKeys = Object.keys(sessionStorage);
|
|
break;
|
|
case 'indexedDB':
|
|
if (this.db) {
|
|
sourceKeys = await this.db.keys();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!sourceKeys || sourceKeys.length === 0) {
|
|
Logger.info('[StorageManager] No keys to migrate');
|
|
return;
|
|
}
|
|
|
|
let migrated = 0;
|
|
let failed = 0;
|
|
|
|
for (const key of sourceKeys) {
|
|
try {
|
|
// Get value from source
|
|
let value = null;
|
|
switch (fromType) {
|
|
case 'localStorage':
|
|
value = this.local.get(key);
|
|
break;
|
|
case 'sessionStorage':
|
|
value = this.session.get(key);
|
|
break;
|
|
case 'indexedDB':
|
|
if (this.db) {
|
|
value = await this.db.get(key);
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (value === null) {
|
|
continue;
|
|
}
|
|
|
|
// Set value in destination
|
|
switch (toType) {
|
|
case 'localStorage':
|
|
this.local.set(key, value);
|
|
break;
|
|
case 'sessionStorage':
|
|
this.session.set(key, value);
|
|
break;
|
|
case 'indexedDB':
|
|
if (this.db) {
|
|
await this.db.set(key, value);
|
|
}
|
|
break;
|
|
}
|
|
|
|
migrated++;
|
|
} catch (error) {
|
|
Logger.error(`[StorageManager] Failed to migrate key: ${key}`, error);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
Logger.info('[StorageManager] Migration completed', { migrated, failed });
|
|
return { migrated, failed };
|
|
}
|
|
|
|
/**
|
|
* Get storage analytics
|
|
*/
|
|
getAnalytics() {
|
|
return {
|
|
...this.analytics,
|
|
totalOperations: Object.values(this.analytics.operations).reduce((sum, count) => sum + count, 0),
|
|
errorRate: this.analytics.errors / (this.analytics.operations.get + this.analytics.operations.set || 1)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reset analytics
|
|
*/
|
|
resetAnalytics() {
|
|
this.analytics = {
|
|
operations: {
|
|
get: 0,
|
|
set: 0,
|
|
delete: 0,
|
|
clear: 0
|
|
},
|
|
errors: 0,
|
|
quotaWarnings: 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format bytes for human reading
|
|
*/
|
|
formatBytes(bytes) {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
} |