strata-storage
Version:
Zero-dependency universal storage plugin providing a unified API for all storage operations across web, Android, and iOS platforms
237 lines (236 loc) • 7.74 kB
JavaScript
/**
* LocalStorage Adapter - Browser localStorage implementation
* Provides persistent storage with 5-10MB limit
*/
import { BaseAdapter } from "../../core/BaseAdapter.js";
import { serialize, deserialize, getObjectSize } from "../../utils/index.js";
import { QuotaExceededError, SerializationError } from "../../utils/errors.js";
/**
* Browser localStorage adapter
*/
export class LocalStorageAdapter extends BaseAdapter {
name = 'localStorage';
capabilities = {
persistent: true,
synchronous: false, // We use async for consistency
observable: true, // Via storage events
transactional: false,
queryable: true,
maxSize: 10 * 1024 * 1024, // Typically 5-10MB
binary: false, // Only strings
encrypted: false,
crossTab: true, // Storage events work across tabs
};
prefix;
listeners = new Map();
constructor(prefix = 'strata:') {
super();
this.prefix = prefix;
}
/**
* Check if localStorage is available
*/
async isAvailable() {
try {
if (typeof window === 'undefined' || !window.localStorage) {
return false;
}
// Test if we can actually use it
const testKey = `${this.prefix}__test__`;
window.localStorage.setItem(testKey, 'test');
window.localStorage.removeItem(testKey);
return true;
}
catch {
return false;
}
}
/**
* Initialize the adapter
*/
async initialize(config) {
if (config?.prefix) {
this.prefix = config.prefix;
}
this.startTTLCleanup();
}
/**
* Get a value from localStorage
*/
async get(key) {
try {
const item = window.localStorage.getItem(this.prefix + key);
if (!item)
return null;
const value = deserialize(item);
// Check TTL
if (this.isExpired(value)) {
await this.remove(key);
return null;
}
return value;
}
catch (error) {
console.error(`Failed to get key ${key} from localStorage:`, error);
return null;
}
}
/**
* Set a value in localStorage
*/
async set(key, value) {
const fullKey = this.prefix + key;
const oldValue = await this.get(key);
try {
const serialized = serialize(value);
window.localStorage.setItem(fullKey, serialized);
}
catch (error) {
if (this.isQuotaError(error)) {
throw new QuotaExceededError('LocalStorage quota exceeded', {
key,
size: getObjectSize(value),
});
}
throw new SerializationError(`Failed to store key ${key} in localStorage`, error);
}
// Emit change event (storage events don't fire in same window)
this.emitChange(key, oldValue?.value, value.value, 'local');
}
/**
* Remove a value from localStorage
*/
async remove(key) {
const oldValue = await this.get(key);
window.localStorage.removeItem(this.prefix + key);
if (oldValue) {
this.emitChange(key, oldValue.value, undefined, 'local');
}
}
/**
* Clear localStorage
*/
async clear(options) {
if (!options || (!options.pattern && !options.tags && !options.expiredOnly)) {
// Clear all with our prefix
const keysToRemove = [];
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key?.startsWith(this.prefix)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => window.localStorage.removeItem(key));
this.emitChange('*', undefined, undefined, 'local');
return;
}
// Use base implementation for filtered clear
await super.clear(options);
}
/**
* Get all keys
*/
async keys(pattern) {
const keys = [];
for (let i = 0; i < window.localStorage.length; i++) {
const fullKey = window.localStorage.key(i);
if (fullKey?.startsWith(this.prefix)) {
const key = fullKey.substring(this.prefix.length);
// Check if not expired
const value = await this.get(key);
if (value) {
keys.push(key);
}
}
}
return this.filterKeys(keys, pattern);
}
/**
* Get storage size
*/
async size(detailed) {
let total = 0;
let count = 0;
let keySize = 0;
let valueSize = 0;
for (let i = 0; i < window.localStorage.length; i++) {
const fullKey = window.localStorage.key(i);
if (fullKey?.startsWith(this.prefix)) {
const item = window.localStorage.getItem(fullKey);
if (item) {
count++;
const itemSize = (fullKey.length + item.length) * 2; // UTF-16
total += itemSize;
if (detailed) {
keySize += fullKey.length * 2;
valueSize += item.length * 2;
}
}
}
}
const result = { total, count };
if (detailed) {
result.detailed = {
keys: keySize,
values: valueSize,
metadata: 0, // Metadata is included in values for localStorage
};
}
return result;
}
/**
* Subscribe to storage changes
*/
subscribe(callback) {
const listener = (event) => {
// Only process events from other windows/tabs
if (event.storageArea !== window.localStorage)
return;
// Check if the key belongs to us
if (!event.key || !event.key.startsWith(this.prefix))
return;
const key = event.key.substring(this.prefix.length);
const oldValue = event.oldValue ? deserialize(event.oldValue) : null;
const newValue = event.newValue ? deserialize(event.newValue) : null;
callback({
key,
oldValue: oldValue?.value ?? undefined,
newValue: newValue?.value ?? undefined,
source: 'remote',
storage: this.name,
timestamp: Date.now(),
});
};
window.addEventListener('storage', listener);
this.listeners.set(callback, listener);
return () => {
const storedListener = this.listeners.get(callback);
if (storedListener) {
window.removeEventListener('storage', storedListener);
this.listeners.delete(callback);
}
};
}
/**
* Close the adapter
*/
async close() {
// Remove all storage event listeners
this.listeners.forEach((listener) => {
window.removeEventListener('storage', listener);
});
this.listeners.clear();
await super.close();
}
/**
* Check if error is quota exceeded
*/
isQuotaError(error) {
if (error instanceof Error) {
return (error.name === 'QuotaExceededError' ||
error.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
error.message.toLowerCase().includes('quota'));
}
return false;
}
}