strata-storage
Version:
Zero-dependency universal storage plugin providing a unified API for all storage operations across web, Android, and iOS platforms
370 lines (369 loc) • 13.4 kB
JavaScript
/**
* IndexedDB Adapter - Browser IndexedDB implementation
* Provides large-scale persistent storage with advanced features
*/
import { BaseAdapter } from "../../core/BaseAdapter.js";
import { createDeferred, getObjectSize } from "../../utils/index.js";
import { StorageError, QuotaExceededError, TransactionError } from "../../utils/errors.js";
/**
* Browser IndexedDB adapter
*/
export class IndexedDBAdapter extends BaseAdapter {
name = 'indexedDB';
capabilities = {
persistent: true,
synchronous: false,
observable: false, // No native change events
transactional: true,
queryable: true,
maxSize: -1, // Browser dependent, typically GBs
binary: true,
encrypted: false,
crossTab: false, // No built-in cross-tab sync
};
dbName;
storeName;
version;
db;
constructor(dbName = 'StrataStorage', storeName = 'storage', version = 1) {
super();
this.dbName = dbName;
this.storeName = storeName;
this.version = version;
}
/**
* Check if IndexedDB is available
*/
async isAvailable() {
return typeof window !== 'undefined' && 'indexedDB' in window && window.indexedDB !== null;
}
/**
* Initialize the adapter
*/
async initialize(config) {
if (config?.dbName)
this.dbName = config.dbName;
if (config?.storeName)
this.storeName = config.storeName;
if (config?.version)
this.version = config.version;
await this.openDatabase();
this.startTTLCleanup();
}
/**
* Open IndexedDB database
*/
async openDatabase() {
if (this.db)
return this.db;
const { promise, resolve, reject } = createDeferred();
const request = window.indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(new StorageError(`Failed to open IndexedDB: ${request.error}`));
request.onsuccess = () => {
this.db = request.result;
resolve(request.result);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(this.storeName)) {
const store = db.createObjectStore(this.storeName, { keyPath: 'key' });
// Create indexes for efficient querying
store.createIndex('expires', 'expires', { unique: false });
store.createIndex('tags', 'tags', { unique: false, multiEntry: true });
store.createIndex('created', 'created', { unique: false });
store.createIndex('updated', 'updated', { unique: false });
}
};
return promise;
}
/**
* Get a value from IndexedDB
*/
async get(key) {
const db = await this.openDatabase();
const { promise, resolve, reject } = createDeferred();
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
if (!result) {
resolve(null);
return;
}
// Remove the key property as it's not part of StorageValue
const { key: _, ...value } = result;
// Check TTL
if (this.isExpired(value)) {
// Don't wait for removal
this.remove(key).catch(console.error);
resolve(null);
}
else {
resolve(value);
}
};
request.onerror = () => reject(new StorageError(`Failed to get key ${key}: ${request.error}`));
return promise;
}
/**
* Set a value in IndexedDB
*/
async set(key, value) {
const db = await this.openDatabase();
const oldValue = await this.get(key);
const { promise, resolve, reject } = createDeferred();
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
// Add key to the value for IndexedDB storage
const record = { key, ...value };
const request = store.put(record);
request.onsuccess = () => {
resolve();
this.emitChange(key, oldValue?.value, value.value, 'local');
};
request.onerror = () => {
if (this.isQuotaError(request.error)) {
reject(new QuotaExceededError('IndexedDB quota exceeded', { key, size: getObjectSize(value) }));
}
else {
reject(new StorageError(`Failed to set key ${key}: ${request.error}`));
}
};
return promise;
}
/**
* Remove a value from IndexedDB
*/
async remove(key) {
const db = await this.openDatabase();
const oldValue = await this.get(key);
const { promise, resolve, reject } = createDeferred();
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(key);
request.onsuccess = () => {
resolve();
if (oldValue) {
this.emitChange(key, oldValue.value, undefined, 'local');
}
};
request.onerror = () => reject(new StorageError(`Failed to remove key ${key}: ${request.error}`));
return promise;
}
/**
* Clear IndexedDB
*/
async clear(options) {
if (!options || (!options.pattern && !options.tags && !options.expiredOnly)) {
// Clear everything
const db = await this.openDatabase();
const { promise, resolve, reject } = createDeferred();
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onsuccess = () => {
resolve();
this.emitChange('*', undefined, undefined, 'local');
};
request.onerror = () => reject(new StorageError(`Failed to clear store: ${request.error}`));
return promise;
}
// Use base implementation for filtered clear
await super.clear(options);
}
/**
* Get all keys
*/
async keys(pattern) {
const db = await this.openDatabase();
const { promise, resolve, reject } = createDeferred();
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAllKeys();
request.onsuccess = async () => {
const allKeys = request.result;
// Filter expired keys
const validKeys = [];
for (const key of allKeys) {
const value = await this.get(key);
if (value) {
validKeys.push(key);
}
}
resolve(this.filterKeys(validKeys, pattern));
};
request.onerror = () => reject(new StorageError(`Failed to get keys: ${request.error}`));
return promise;
}
/**
* Query IndexedDB with conditions
*/
async query(condition) {
const db = await this.openDatabase();
const { promise, resolve, reject } = createDeferred();
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const results = [];
// For now, we'll do a full scan and filter
// In the future, we can optimize using indexes
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const record = cursor.value;
const { key, ...value } = record;
if (!this.isExpired(value) && this.queryEngine.matches(value.value, condition)) {
results.push({ key, value: value.value });
}
cursor.continue();
}
else {
resolve(results);
}
};
request.onerror = () => reject(new StorageError(`Query failed: ${request.error}`));
return promise;
}
/**
* Get storage size
*/
async size(detailed) {
const db = await this.openDatabase();
const { promise, resolve, reject } = createDeferred();
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
let total = 0;
let count = 0;
let keySize = 0;
let valueSize = 0;
let metadataSize = 0;
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const record = cursor.value;
const { key, value, ...metadata } = record;
count++;
const recordKeySize = key.length * 2;
const recordValueSize = getObjectSize(value);
const recordMetadataSize = getObjectSize(metadata);
total += recordKeySize + recordValueSize + recordMetadataSize;
if (detailed) {
keySize += recordKeySize;
valueSize += recordValueSize;
metadataSize += recordMetadataSize;
}
cursor.continue();
}
else {
const result = { total, count };
if (detailed) {
result.detailed = {
keys: keySize,
values: valueSize,
metadata: metadataSize,
};
}
resolve(result);
}
};
request.onerror = () => reject(new StorageError(`Failed to calculate size: ${request.error}`));
return promise;
}
/**
* Begin a transaction
*/
async transaction() {
const db = await this.openDatabase();
const txn = db.transaction([this.storeName], 'readwrite');
const store = txn.objectStore(this.storeName);
return new IndexedDBTransaction(store, txn);
}
/**
* Close the adapter
*/
async close() {
if (this.db) {
this.db.close();
this.db = 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;
}
}
/**
* IndexedDB transaction implementation
*/
class IndexedDBTransaction {
store;
txn;
committed = false;
aborted = false;
constructor(store, txn) {
this.store = store;
this.txn = txn;
}
async get(key) {
if (this.aborted)
throw new TransactionError('Transaction already aborted');
const { promise, resolve, reject } = createDeferred();
const request = this.store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => reject(new StorageError(`Transaction get failed: ${request.error}`));
return promise;
}
async set(key, value) {
if (this.aborted)
throw new TransactionError('Transaction already aborted');
const { promise, resolve, reject } = createDeferred();
const now = Date.now();
const record = {
key,
value,
created: now,
updated: now,
};
const request = this.store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(new StorageError(`Transaction set failed: ${request.error}`));
return promise;
}
async remove(key) {
if (this.aborted)
throw new TransactionError('Transaction already aborted');
const { promise, resolve, reject } = createDeferred();
const request = this.store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(new StorageError(`Transaction remove failed: ${request.error}`));
return promise;
}
async commit() {
if (this.aborted)
throw new TransactionError('Cannot commit aborted transaction');
if (this.committed)
throw new TransactionError('Transaction already committed');
this.committed = true;
// IndexedDB auto-commits when transaction completes
}
async rollback() {
if (this.committed)
throw new TransactionError('Cannot rollback committed transaction');
if (this.aborted)
return;
this.aborted = true;
this.txn.abort();
}
}