UNPKG

strata-storage

Version:

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

576 lines (575 loc) 20 kB
/** * Strata Storage - Main entry point * Zero-dependency universal storage solution */ import { AdapterRegistry } from "./AdapterRegistry.js"; import { isBrowser, isNode } from "../utils/index.js"; import { StorageError, EncryptionError } from "../utils/errors.js"; import { EncryptionManager } from "../features/encryption.js"; import { CompressionManager } from "../features/compression.js"; import { SyncManager } from "../features/sync.js"; import { TTLManager } from "../features/ttl.js"; /** * Main Strata class - unified storage interface */ export class Strata { config; registry; defaultAdapter; adapters = new Map(); _platform; encryptionManager; compressionManager; syncManager; ttlManager; _initialized = false; constructor(config = {}) { this.config = this.normalizeConfig(config); this._platform = this.detectPlatform(); this.registry = new AdapterRegistry(); } /** * Check if Strata has been initialized */ get isInitialized() { return this._initialized; } /** * Get the detected platform */ get platform() { return this._platform; } /** * Initialize Strata with available adapters */ async initialize() { // No automatic adapter registration - adapters should be registered before initialize() // This allows for zero-dependency operation and explicit opt-in for features // Find and set default adapter await this.selectDefaultAdapter(); // Initialize configured adapters await this.initializeAdapters(); // Initialize encryption if enabled if (this.config.encryption?.enabled) { this.encryptionManager = new EncryptionManager(this.config.encryption); if (!this.encryptionManager.isAvailable()) { console.warn('Encryption enabled but Web Crypto API not available'); } } // Initialize compression if enabled if (this.config.compression?.enabled) { this.compressionManager = new CompressionManager(this.config.compression); } // Initialize sync if enabled if (this.config.sync?.enabled) { this.syncManager = new SyncManager(this.config.sync); await this.syncManager.initialize(); // Subscribe to sync events this.syncManager.subscribe((_change) => { // Forward sync events to subscribers // The adapters will handle their own change events }); } // Initialize TTL manager this.ttlManager = new TTLManager(this.config.ttl); // Set up TTL cleanup for default adapter if (this.defaultAdapter && this.config.ttl?.autoCleanup !== false) { this.ttlManager.startAutoCleanup(() => this.defaultAdapter.keys(), (key) => this.defaultAdapter.get(key), (key) => this.defaultAdapter.remove(key)); } // Mark as initialized this._initialized = true; } /** * Get a value from storage */ async get(key, options) { const adapter = await this.selectAdapter(options?.storage); const value = await adapter.get(key); if (!value) return null; // Handle TTL if (this.ttlManager && this.ttlManager.isExpired(value)) { await adapter.remove(key); return null; } // Update sliding TTL if configured if (options?.sliding && value.expires && this.ttlManager) { const updatedValue = this.ttlManager.updateExpiration(value, options); if (updatedValue !== value) { await adapter.set(key, updatedValue); } } // Handle decryption if needed if (value.encrypted && this.encryptionManager) { try { if (!options?.skipDecryption) { const password = options?.encryptionPassword || this.config.encryption?.password; if (!password) { throw new EncryptionError('Encrypted value requires password for decryption'); } const decrypted = await this.encryptionManager.decrypt(value.value, password); return decrypted; } } catch (error) { if (options?.ignoreDecryptionErrors) { console.warn(`Failed to decrypt key ${key}:`, error); return null; } throw error; } } // Handle decompression if needed if (value.compressed && this.compressionManager) { try { const decompressed = await this.compressionManager.decompress(value.value); return decompressed; } catch (error) { console.warn(`Failed to decompress key ${key}:`, error); return value.value; } } return value.value; } /** * Set a value in storage */ async set(key, value, options) { const adapter = await this.selectAdapter(options?.storage); const now = Date.now(); let processedValue = value; let compressed = false; // Handle compression if needed const shouldCompress = options?.compress ?? this.config.compression?.enabled; if (shouldCompress && this.compressionManager) { const compressedResult = await this.compressionManager.compress(value); if (this.compressionManager.isCompressedData(compressedResult)) { processedValue = compressedResult; compressed = true; } } // Handle encryption if needed const shouldEncrypt = options?.encrypt ?? this.config.encryption?.enabled; let encrypted = false; if (shouldEncrypt && this.encryptionManager) { const password = options?.encryptionPassword || this.config.encryption?.password; if (!password) { throw new EncryptionError('Encryption enabled but no password provided'); } processedValue = await this.encryptionManager.encrypt(value, password); encrypted = true; } const storageValue = { value: processedValue, created: now, updated: now, expires: this.ttlManager ? this.ttlManager.calculateExpiration(options) : undefined, tags: options?.tags, metadata: options?.metadata, encrypted: encrypted, compressed: compressed, }; await adapter.set(key, storageValue); // Broadcast change for sync if (this.syncManager) { this.syncManager.broadcast({ type: 'set', key, value: storageValue, storage: adapter.name, timestamp: now, }); } } /** * Remove a value from storage */ async remove(key, options) { const adapter = await this.selectAdapter(options?.storage); await adapter.remove(key); // Broadcast removal for sync if (this.syncManager) { this.syncManager.broadcast({ type: 'remove', key, storage: adapter.name, timestamp: Date.now(), }); } } /** * Check if a key exists */ async has(key, options) { const adapter = await this.selectAdapter(options?.storage); return adapter.has(key); } /** * Clear storage */ async clear(options) { if (options?.storage) { const adapter = await this.selectAdapter(options.storage); await adapter.clear(options); } else { // Clear all adapters for (const adapter of this.adapters.values()) { await adapter.clear(options); } } } /** * Get all keys */ async keys(pattern, options) { if (options?.storage) { const adapter = await this.selectAdapter(options.storage); return adapter.keys(pattern); } // Get keys from all adapters and deduplicate const allKeys = new Set(); for (const adapter of this.adapters.values()) { const keys = await adapter.keys(pattern); keys.forEach((key) => allKeys.add(key)); } return Array.from(allKeys); } /** * Get storage size information */ async size(detailed) { let total = 0; let count = 0; const byStorage = {}; for (const [type, adapter] of this.adapters.entries()) { const sizeInfo = await adapter.size(detailed); total += sizeInfo.total; count += sizeInfo.count; byStorage[type] = sizeInfo.total; } return { total, count, byStorage: byStorage, }; } /** * Subscribe to storage changes */ subscribe(callback, options) { const unsubscribers = []; if (options?.storage) { const adapter = this.adapters.get(options.storage); if (adapter?.subscribe) { unsubscribers.push(adapter.subscribe(callback)); } } else { // Subscribe to all adapters that support it for (const adapter of this.adapters.values()) { if (adapter.subscribe) { unsubscribers.push(adapter.subscribe(callback)); } } } // Return function to unsubscribe from all return () => { unsubscribers.forEach((unsub) => unsub()); }; } /** * Query storage (if supported) */ async query(condition, options) { const adapter = await this.selectAdapter(options?.storage); if (!adapter.query) { throw new StorageError(`Adapter ${adapter.name} does not support queries`); } return adapter.query(condition); } /** * Export storage data */ async export(options) { const data = {}; const keys = options?.keys || (await this.keys()); for (const key of keys) { const value = await this.get(key); if (value !== null) { if (options?.includeMetadata) { const adapter = await this.selectAdapter(); const storageValue = await adapter.get(key); data[key] = storageValue; } else { data[key] = value; } } } const format = options?.format || 'json'; if (format === 'json') { return JSON.stringify(data, null, options?.pretty ? 2 : 0); } throw new StorageError(`Export format ${format} not supported`); } /** * Import storage data */ async import(data, options) { const format = options?.format || 'json'; if (format !== 'json') { throw new StorageError(`Import format ${format} not supported`); } const parsed = JSON.parse(data); for (const [key, value] of Object.entries(parsed)) { const exists = await this.has(key); if (!exists || options?.overwrite) { await this.set(key, value); } else if (options?.merge) { const existing = await this.get(key); if (options.merge === 'deep' && typeof existing === 'object' && typeof value === 'object') { // Deep merge will be implemented with utils await this.set(key, { ...existing, ...value, }); } else { await this.set(key, value); } } } } /** * Get available storage types */ getAvailableStorageTypes() { return Array.from(this.adapters.keys()); } /** * Get adapter capabilities */ getCapabilities(storage) { if (storage) { const adapter = this.adapters.get(storage); return adapter ? adapter.capabilities : {}; } // Return capabilities of all adapters const capabilities = {}; for (const [type, adapter] of this.adapters.entries()) { capabilities[type] = adapter.capabilities; } return capabilities; } /** * Generate a secure password for encryption */ generatePassword(length) { if (!this.encryptionManager) { throw new EncryptionError('Encryption not initialized'); } return this.encryptionManager.generatePassword(length); } /** * Hash data using SHA-256 */ async hash(data) { if (!this.encryptionManager) { throw new EncryptionError('Encryption not initialized'); } return this.encryptionManager.hash(data); } /** * Get TTL (time to live) for a key */ async getTTL(key, options) { if (!this.ttlManager) return null; const adapter = await this.selectAdapter(options?.storage); const value = await adapter.get(key); if (!value) return null; return this.ttlManager.getTimeToLive(value); } /** * Extend TTL for a key */ async extendTTL(key, extension, options) { if (!this.ttlManager) { throw new StorageError('TTL manager not initialized'); } const adapter = await this.selectAdapter(options?.storage); const value = await adapter.get(key); if (!value) { throw new StorageError(`Key ${key} not found`); } const updated = this.ttlManager.extendTTL(value, extension); await adapter.set(key, updated); } /** * Make a key persistent (remove TTL) */ async persist(key, options) { if (!this.ttlManager) { throw new StorageError('TTL manager not initialized'); } const adapter = await this.selectAdapter(options?.storage); const value = await adapter.get(key); if (!value) { throw new StorageError(`Key ${key} not found`); } const persisted = this.ttlManager.persist(value); await adapter.set(key, persisted); } /** * Get items expiring within a time window */ async getExpiring(timeWindow, options) { if (!this.ttlManager) return []; const adapter = await this.selectAdapter(options?.storage); return this.ttlManager.getExpiring(timeWindow, () => adapter.keys(), (key) => adapter.get(key)); } /** * Manually trigger TTL cleanup */ async cleanupExpired(options) { if (!this.ttlManager) return 0; const adapter = await this.selectAdapter(options?.storage); const expired = await this.ttlManager.cleanup(() => adapter.keys(), (key) => adapter.get(key), (key) => adapter.remove(key)); return expired.length; } /** * Register a custom storage adapter * This allows external adapters to be registered after initialization * * @example * ```typescript * import { MyCustomAdapter } from "./my-adapter.js"; * storage.registerAdapter(new MyCustomAdapter()); * ``` */ registerAdapter(adapter) { this.registry.register(adapter); } /** * Get the adapter registry (for advanced use cases) * @internal */ getRegistry() { return this.registry; } /** * Close all adapters */ async close() { for (const adapter of this.adapters.values()) { if (adapter.close) { await adapter.close(); } } this.adapters.clear(); // Clear encryption cache if (this.encryptionManager) { this.encryptionManager.clearCache(); } // Close sync manager if (this.syncManager) { this.syncManager.close(); } // Clear TTL manager if (this.ttlManager) { this.ttlManager.clear(); } } // Private methods normalizeConfig(config) { return { platform: config.platform || this.detectPlatform(), defaultStorages: config.defaultStorages || ['memory'], // Default to memory adapter ...config, }; } detectPlatform() { if (isBrowser()) return 'web'; if (isNode()) return 'node'; return 'web'; // Default to web } getDefaultStorages() { // Only return adapters that are actually registered const registered = Array.from(this.registry.getAll().keys()).map((key) => String(key)); // Prefer these storages in order if available const preferredOrder = ['indexedDB', 'localStorage', 'sessionStorage', 'memory']; const available = preferredOrder.filter((storage) => registered.includes(storage)); // Always include memory as fallback if registered if (available.length === 0 && registered.includes('memory')) { return ['memory']; } return (available.length > 0 ? available : registered); } async selectDefaultAdapter() { const storages = this.config.defaultStorages || this.getDefaultStorages(); if (storages.length === 0) { throw new StorageError('No storage adapters registered or configured'); } for (const storage of storages) { try { const adapter = this.registry.get(storage); if (!adapter) { continue; } const isAvailable = await adapter.isAvailable(); if (!isAvailable) { continue; } // Initialize adapter with config if provided const config = this.config.adapters?.[storage]; await adapter.initialize(config); this.defaultAdapter = adapter; this.adapters.set(storage, adapter); return; } catch (error) { console.warn(`Failed to initialize ${storage} adapter:`, error); // Continue to next adapter } } throw new StorageError(`No available storage adapters found. Tried: ${storages.join(', ')}. ` + `Registered adapters: ${Array.from(this.registry.getAll().keys()).join(', ')}`); } async initializeAdapters() { // Adapters are already initialized in selectDefaultAdapter // This method is kept for compatibility but doesn't re-initialize } async selectAdapter(storage) { if (!storage) { if (!this.defaultAdapter) { throw new StorageError('No default adapter available'); } return this.defaultAdapter; } const storages = Array.isArray(storage) ? storage : [storage]; for (const s of storages) { const adapter = this.adapters.get(s); if (adapter) return adapter; } // Try to load adapter if not already loaded for (const s of storages) { const adapter = this.registry.get(s); if (adapter && (await adapter.isAvailable())) { await adapter.initialize(); this.adapters.set(s, adapter); return adapter; } } throw new StorageError(`No available adapter found for storage types: ${storages.join(', ')}`); } }