strata-storage
Version:
Zero-dependency universal storage plugin providing a unified API for all storage operations across web, Android, and iOS platforms
290 lines (289 loc) • 9.36 kB
JavaScript
/**
* Cache Adapter - Service Worker Cache API implementation
* Provides network-aware storage for offline support
*/
import { BaseAdapter } from "../../core/BaseAdapter.js";
import { getObjectSize } from "../../utils/index.js";
import { StorageError, QuotaExceededError, NotSupportedError } from "../../utils/errors.js";
/**
* Cache API adapter for Service Worker environments
*/
export class CacheAdapter extends BaseAdapter {
name = 'cache';
capabilities = {
persistent: true,
synchronous: false,
observable: false, // No native change events
transactional: false,
queryable: true,
maxSize: -1, // Browser dependent, typically GBs
binary: true, // Supports binary data via Response
encrypted: false,
crossTab: true, // Shared across tabs via Service Worker
};
cacheName;
baseUrl;
cache;
constructor(cacheName = 'strata-storage-v1', baseUrl = 'https://strata.local/') {
super();
this.cacheName = cacheName;
this.baseUrl = baseUrl;
}
/**
* Check if Cache API is available
*/
async isAvailable() {
return typeof window !== 'undefined' && 'caches' in window && typeof caches.open === 'function';
}
/**
* Initialize the adapter
*/
async initialize(config) {
if (config?.cacheName)
this.cacheName = config.cacheName;
if (config?.baseUrl)
this.baseUrl = config.baseUrl;
await this.openCache();
this.startTTLCleanup();
}
/**
* Open cache
*/
async openCache() {
if (this.cache)
return this.cache;
if (!('caches' in window)) {
throw new NotSupportedError('Cache API not available');
}
this.cache = await caches.open(this.cacheName);
return this.cache;
}
/**
* Create URL for key
*/
keyToUrl(key) {
return new URL(encodeURIComponent(key), this.baseUrl).href;
}
/**
* Extract key from URL
*/
urlToKey(url) {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const lastSegment = pathname.split('/').pop() || '';
return decodeURIComponent(lastSegment);
}
/**
* Get a value from cache
*/
async get(key) {
const cache = await this.openCache();
const url = this.keyToUrl(key);
try {
const response = await cache.match(url);
if (!response)
return null;
const data = (await response.json());
// Check TTL
if (this.isExpired(data)) {
await this.remove(key);
return null;
}
return data;
}
catch (error) {
console.error(`Failed to get key ${key} from cache:`, error);
return null;
}
}
/**
* Set a value in cache
*/
async set(key, value) {
const cache = await this.openCache();
const url = this.keyToUrl(key);
const oldValue = await this.get(key);
try {
// Create Response with the data
const response = new Response(JSON.stringify(value), {
headers: {
'Content-Type': 'application/json',
'X-Strata-Created': value.created.toString(),
'X-Strata-Updated': value.updated.toString(),
'X-Strata-Expires': value.expires?.toString() || '',
},
});
await cache.put(url, response);
this.emitChange(key, oldValue?.value, value.value, 'local');
}
catch (error) {
if (this.isQuotaError(error)) {
throw new QuotaExceededError('Cache quota exceeded', { key, size: getObjectSize(value) });
}
throw new StorageError(`Failed to set key ${key} in cache: ${error}`);
}
}
/**
* Remove a value from cache
*/
async remove(key) {
const cache = await this.openCache();
const url = this.keyToUrl(key);
const oldValue = await this.get(key);
const deleted = await cache.delete(url);
if (deleted && oldValue) {
this.emitChange(key, oldValue.value, undefined, 'local');
}
}
/**
* Clear cache
*/
async clear(options) {
if (!options || (!options.pattern && !options.tags && !options.expiredOnly)) {
// Delete and recreate cache
await caches.delete(this.cacheName);
this.cache = await caches.open(this.cacheName);
this.emitChange('*', undefined, undefined, 'local');
return;
}
// Use base implementation for filtered clear
await super.clear(options);
}
/**
* Get all keys
*/
async keys(pattern) {
const cache = await this.openCache();
const requests = await cache.keys();
const keys = [];
for (const request of requests) {
const key = this.urlToKey(request.url);
// Check if not expired
const value = await this.get(key);
if (value) {
keys.push(key);
}
}
return this.filterKeys(keys, pattern);
}
/**
* Query cache with conditions
*/
async query(condition) {
const cache = await this.openCache();
const requests = await cache.keys();
const results = [];
for (const request of requests) {
const key = this.urlToKey(request.url);
const value = await this.get(key);
if (value && this.queryEngine.matches(value.value, condition)) {
results.push({ key, value: value.value });
}
}
return results;
}
/**
* Get storage size
*/
async size(detailed) {
const cache = await this.openCache();
const requests = await cache.keys();
let total = 0;
let count = 0;
let keySize = 0;
let valueSize = 0;
let metadataSize = 0;
for (const request of requests) {
const response = await cache.match(request);
if (response) {
count++;
const key = this.urlToKey(request.url);
const blob = await response.blob();
const contentSize = blob.size;
total += key.length * 2 + contentSize;
if (detailed) {
keySize += key.length * 2;
valueSize += contentSize;
// Headers contribute to metadata
const headers = response.headers;
headers.forEach((value, key) => {
metadataSize += (key.length + value.length) * 2;
});
}
}
}
const result = { total, count };
if (detailed) {
result.detailed = {
keys: keySize,
values: valueSize,
metadata: metadataSize,
};
}
return result;
}
/**
* Store binary data
*/
async setBinary(key, data, metadata) {
const cache = await this.openCache();
const url = this.keyToUrl(key);
const now = Date.now();
// Create storage value for metadata
const storageMetadata = {
value: metadata || {},
created: now,
updated: now,
metadata: { binary: true, size: data instanceof ArrayBuffer ? data.byteLength : data.size },
};
// Create Response with binary data
const response = new Response(data, {
headers: {
'Content-Type': 'application/octet-stream',
'X-Strata-Metadata': JSON.stringify(storageMetadata),
},
});
await cache.put(url, response);
this.emitChange(key, undefined, metadata || {}, 'local');
}
/**
* Get binary data
*/
async getBinary(key) {
const cache = await this.openCache();
const url = this.keyToUrl(key);
try {
const response = await cache.match(url);
if (!response)
return null;
const metadataHeader = response.headers.get('X-Strata-Metadata');
const metadata = metadataHeader ? JSON.parse(metadataHeader) : null;
// Check if it's binary data
if (response.headers.get('Content-Type') !== 'application/octet-stream') {
return null;
}
const data = await response.arrayBuffer();
return { data, metadata: metadata?.value };
}
catch (error) {
console.error(`Failed to get binary data for key ${key}:`, error);
return null;
}
}
/**
* Close the adapter
*/
async close() {
this.cache = 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;
}
}