UNPKG

strata-storage

Version:

Zero-dependency universal storage plugin providing a unified API for all storage operations across web, Android, and iOS platforms

290 lines (289 loc) 9.36 kB
/** * Cache Adapter - Service Worker Cache API implementation * Provides network-aware storage for offline support */ import { BaseAdapter } from "../../core/BaseAdapter.js"; import { getObjectSize } from "../../utils/index.js"; import { StorageError, QuotaExceededError, NotSupportedError } from "../../utils/errors.js"; /** * Cache API adapter for Service Worker environments */ export class CacheAdapter extends BaseAdapter { name = 'cache'; capabilities = { persistent: true, synchronous: false, observable: false, // No native change events transactional: false, queryable: true, maxSize: -1, // Browser dependent, typically GBs binary: true, // Supports binary data via Response encrypted: false, crossTab: true, // Shared across tabs via Service Worker }; cacheName; baseUrl; cache; constructor(cacheName = 'strata-storage-v1', baseUrl = 'https://strata.local/') { super(); this.cacheName = cacheName; this.baseUrl = baseUrl; } /** * Check if Cache API is available */ async isAvailable() { return typeof window !== 'undefined' && 'caches' in window && typeof caches.open === 'function'; } /** * Initialize the adapter */ async initialize(config) { if (config?.cacheName) this.cacheName = config.cacheName; if (config?.baseUrl) this.baseUrl = config.baseUrl; await this.openCache(); this.startTTLCleanup(); } /** * Open cache */ async openCache() { if (this.cache) return this.cache; if (!('caches' in window)) { throw new NotSupportedError('Cache API not available'); } this.cache = await caches.open(this.cacheName); return this.cache; } /** * Create URL for key */ keyToUrl(key) { return new URL(encodeURIComponent(key), this.baseUrl).href; } /** * Extract key from URL */ urlToKey(url) { const urlObj = new URL(url); const pathname = urlObj.pathname; const lastSegment = pathname.split('/').pop() || ''; return decodeURIComponent(lastSegment); } /** * Get a value from cache */ async get(key) { const cache = await this.openCache(); const url = this.keyToUrl(key); try { const response = await cache.match(url); if (!response) return null; const data = (await response.json()); // Check TTL if (this.isExpired(data)) { await this.remove(key); return null; } return data; } catch (error) { console.error(`Failed to get key ${key} from cache:`, error); return null; } } /** * Set a value in cache */ async set(key, value) { const cache = await this.openCache(); const url = this.keyToUrl(key); const oldValue = await this.get(key); try { // Create Response with the data const response = new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json', 'X-Strata-Created': value.created.toString(), 'X-Strata-Updated': value.updated.toString(), 'X-Strata-Expires': value.expires?.toString() || '', }, }); await cache.put(url, response); this.emitChange(key, oldValue?.value, value.value, 'local'); } catch (error) { if (this.isQuotaError(error)) { throw new QuotaExceededError('Cache quota exceeded', { key, size: getObjectSize(value) }); } throw new StorageError(`Failed to set key ${key} in cache: ${error}`); } } /** * Remove a value from cache */ async remove(key) { const cache = await this.openCache(); const url = this.keyToUrl(key); const oldValue = await this.get(key); const deleted = await cache.delete(url); if (deleted && oldValue) { this.emitChange(key, oldValue.value, undefined, 'local'); } } /** * Clear cache */ async clear(options) { if (!options || (!options.pattern && !options.tags && !options.expiredOnly)) { // Delete and recreate cache await caches.delete(this.cacheName); this.cache = await caches.open(this.cacheName); this.emitChange('*', undefined, undefined, 'local'); return; } // Use base implementation for filtered clear await super.clear(options); } /** * Get all keys */ async keys(pattern) { const cache = await this.openCache(); const requests = await cache.keys(); const keys = []; for (const request of requests) { const key = this.urlToKey(request.url); // Check if not expired const value = await this.get(key); if (value) { keys.push(key); } } return this.filterKeys(keys, pattern); } /** * Query cache with conditions */ async query(condition) { const cache = await this.openCache(); const requests = await cache.keys(); const results = []; for (const request of requests) { const key = this.urlToKey(request.url); const value = await this.get(key); if (value && this.queryEngine.matches(value.value, condition)) { results.push({ key, value: value.value }); } } return results; } /** * Get storage size */ async size(detailed) { const cache = await this.openCache(); const requests = await cache.keys(); let total = 0; let count = 0; let keySize = 0; let valueSize = 0; let metadataSize = 0; for (const request of requests) { const response = await cache.match(request); if (response) { count++; const key = this.urlToKey(request.url); const blob = await response.blob(); const contentSize = blob.size; total += key.length * 2 + contentSize; if (detailed) { keySize += key.length * 2; valueSize += contentSize; // Headers contribute to metadata const headers = response.headers; headers.forEach((value, key) => { metadataSize += (key.length + value.length) * 2; }); } } } const result = { total, count }; if (detailed) { result.detailed = { keys: keySize, values: valueSize, metadata: metadataSize, }; } return result; } /** * Store binary data */ async setBinary(key, data, metadata) { const cache = await this.openCache(); const url = this.keyToUrl(key); const now = Date.now(); // Create storage value for metadata const storageMetadata = { value: metadata || {}, created: now, updated: now, metadata: { binary: true, size: data instanceof ArrayBuffer ? data.byteLength : data.size }, }; // Create Response with binary data const response = new Response(data, { headers: { 'Content-Type': 'application/octet-stream', 'X-Strata-Metadata': JSON.stringify(storageMetadata), }, }); await cache.put(url, response); this.emitChange(key, undefined, metadata || {}, 'local'); } /** * Get binary data */ async getBinary(key) { const cache = await this.openCache(); const url = this.keyToUrl(key); try { const response = await cache.match(url); if (!response) return null; const metadataHeader = response.headers.get('X-Strata-Metadata'); const metadata = metadataHeader ? JSON.parse(metadataHeader) : null; // Check if it's binary data if (response.headers.get('Content-Type') !== 'application/octet-stream') { return null; } const data = await response.arrayBuffer(); return { data, metadata: metadata?.value }; } catch (error) { console.error(`Failed to get binary data for key ${key}:`, error); return null; } } /** * Close the adapter */ async close() { this.cache = undefined; await super.close(); } /** * Check if error is quota exceeded */ isQuotaError(error) { if (error instanceof Error || error instanceof DOMException) { return error.name === 'QuotaExceededError' || error.message.toLowerCase().includes('quota'); } return false; } }