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
JavaScript
/**
* 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(', ')}`);
}
}