UNPKG

strata-storage

Version:

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

225 lines (224 loc) 7.01 kB
/** * Base adapter implementation with common functionality */ import { NotSupportedError } from "../utils/errors.js"; import { EventEmitter, matchGlob, getObjectSize } from "../utils/index.js"; import { QueryEngine } from "../features/query.js"; /** * Abstract base adapter that implements common functionality */ export class BaseAdapter { eventEmitter = new EventEmitter(); queryEngine = new QueryEngine(); ttlCleanupInterval; ttlCheckInterval = 60000; // Check every minute /** * Initialize TTL cleanup if needed */ startTTLCleanup() { if (this.ttlCleanupInterval) return; this.ttlCleanupInterval = setInterval(async () => { try { await this.cleanupExpired(); } catch (error) { console.error(`TTL cleanup error in ${this.name}:`, error); } }, this.ttlCheckInterval); } /** * Stop TTL cleanup */ stopTTLCleanup() { if (this.ttlCleanupInterval) { clearInterval(this.ttlCleanupInterval); this.ttlCleanupInterval = undefined; } } /** * Clean up expired items */ async cleanupExpired() { const now = Date.now(); const keys = await this.keys(); for (const key of keys) { const item = await this.get(key); if (item?.expires && item.expires <= now) { await this.remove(key); } } } /** * Check if value is expired */ isExpired(value) { if (!value.expires) return false; return Date.now() > value.expires; } /** * Filter keys by pattern */ filterKeys(keys, pattern) { if (!pattern) return keys; if (pattern instanceof RegExp) { return keys.filter((key) => pattern.test(key)); } // If pattern doesn't contain glob characters, treat it as a prefix if (!pattern.includes('*') && !pattern.includes('?')) { return keys.filter((key) => key.startsWith(pattern)); } return keys.filter((key) => matchGlob(pattern, key)); } /** * Calculate size of storage value */ calculateSize(value) { return getObjectSize(value); } /** * Default has implementation using get */ async has(key) { const value = await this.get(key); return value !== null && !this.isExpired(value); } /** * Default clear implementation */ async clear(options) { const keys = await this.keys(); for (const key of keys) { let shouldDelete = true; // Support both pattern and prefix options const pattern = options?.pattern || options?.prefix; if (pattern) { shouldDelete = this.filterKeys([key], pattern).length > 0; } if (shouldDelete && options?.tags) { const value = await this.get(key); if (!value?.tags || !options.tags.some((tag) => value.tags?.includes(tag))) { shouldDelete = false; } } if (shouldDelete && options?.expiredOnly) { const value = await this.get(key); if (!value || !this.isExpired(value)) { shouldDelete = false; } } if (shouldDelete) { await this.remove(key); } } } /** * Default size implementation */ async size(detailed) { const keys = await this.keys(); let total = 0; let keySize = 0; let valueSize = 0; let metadataSize = 0; const byKey = {}; for (const key of keys) { const keyLength = key.length * 2; // UTF-16 keySize += keyLength; const item = await this.get(key); if (item) { const size = this.calculateSize(item); valueSize += getObjectSize(item.value); metadataSize += size - getObjectSize(item.value); total += size; if (detailed) { byKey[key] = size + keyLength; } } } const result = { total: total + keySize, count: keys.length, }; if (detailed) { result.byKey = byKey; result.detailed = { keys: keySize, values: valueSize, metadata: metadataSize, }; } return result; } /** * Subscribe to changes (if supported) */ subscribe(callback) { if (!this.capabilities.observable) { throw new NotSupportedError('subscribe', this.name); } const handler = (...args) => { const change = args[0]; callback(change); }; this.eventEmitter.on('change', handler); return () => { this.eventEmitter.off('change', handler); }; } /** * Emit change event */ emitChange(key, oldValue, newValue, source = 'local') { this.eventEmitter.emit('change', { key, oldValue, newValue, source, storage: this.name, timestamp: Date.now(), }); } /** * Query implementation (override in adapters that support it) */ async query(condition) { if (!this.capabilities.queryable) { throw new NotSupportedError('query', this.name); } // Basic implementation for adapters that don't have native query support const results = []; const keys = await this.keys(); for (const key of keys) { const item = await this.get(key); if (item && !this.isExpired(item)) { // Check if querying storage metadata (tags, metadata, etc) or the actual value let matches = false; // Check for storage-level properties const storageProps = ['tags', 'metadata', 'created', 'updated', 'expires']; const isStorageQuery = Object.keys(condition).some((k) => storageProps.includes(k)); if (isStorageQuery) { // Query against the storage wrapper matches = this.queryEngine.matches(item, condition); } else { // Query against the stored value matches = this.queryEngine.matches(item.value, condition); } if (matches) { results.push({ key, value: item.value }); } } } return results; } /** * Close adapter (cleanup) */ async close() { this.stopTTLCleanup(); this.eventEmitter.removeAllListeners(); } }