strata-storage
Version:
Zero-dependency universal storage plugin providing a unified API for all storage operations across web, Android, and iOS platforms
225 lines (224 loc) • 7.01 kB
JavaScript
/**
* Base adapter implementation with common functionality
*/
import { NotSupportedError } from "../utils/errors.js";
import { EventEmitter, matchGlob, getObjectSize } from "../utils/index.js";
import { QueryEngine } from "../features/query.js";
/**
* Abstract base adapter that implements common functionality
*/
export class BaseAdapter {
eventEmitter = new EventEmitter();
queryEngine = new QueryEngine();
ttlCleanupInterval;
ttlCheckInterval = 60000; // Check every minute
/**
* Initialize TTL cleanup if needed
*/
startTTLCleanup() {
if (this.ttlCleanupInterval)
return;
this.ttlCleanupInterval = setInterval(async () => {
try {
await this.cleanupExpired();
}
catch (error) {
console.error(`TTL cleanup error in ${this.name}:`, error);
}
}, this.ttlCheckInterval);
}
/**
* Stop TTL cleanup
*/
stopTTLCleanup() {
if (this.ttlCleanupInterval) {
clearInterval(this.ttlCleanupInterval);
this.ttlCleanupInterval = undefined;
}
}
/**
* Clean up expired items
*/
async cleanupExpired() {
const now = Date.now();
const keys = await this.keys();
for (const key of keys) {
const item = await this.get(key);
if (item?.expires && item.expires <= now) {
await this.remove(key);
}
}
}
/**
* Check if value is expired
*/
isExpired(value) {
if (!value.expires)
return false;
return Date.now() > value.expires;
}
/**
* Filter keys by pattern
*/
filterKeys(keys, pattern) {
if (!pattern)
return keys;
if (pattern instanceof RegExp) {
return keys.filter((key) => pattern.test(key));
}
// If pattern doesn't contain glob characters, treat it as a prefix
if (!pattern.includes('*') && !pattern.includes('?')) {
return keys.filter((key) => key.startsWith(pattern));
}
return keys.filter((key) => matchGlob(pattern, key));
}
/**
* Calculate size of storage value
*/
calculateSize(value) {
return getObjectSize(value);
}
/**
* Default has implementation using get
*/
async has(key) {
const value = await this.get(key);
return value !== null && !this.isExpired(value);
}
/**
* Default clear implementation
*/
async clear(options) {
const keys = await this.keys();
for (const key of keys) {
let shouldDelete = true;
// Support both pattern and prefix options
const pattern = options?.pattern || options?.prefix;
if (pattern) {
shouldDelete = this.filterKeys([key], pattern).length > 0;
}
if (shouldDelete && options?.tags) {
const value = await this.get(key);
if (!value?.tags || !options.tags.some((tag) => value.tags?.includes(tag))) {
shouldDelete = false;
}
}
if (shouldDelete && options?.expiredOnly) {
const value = await this.get(key);
if (!value || !this.isExpired(value)) {
shouldDelete = false;
}
}
if (shouldDelete) {
await this.remove(key);
}
}
}
/**
* Default size implementation
*/
async size(detailed) {
const keys = await this.keys();
let total = 0;
let keySize = 0;
let valueSize = 0;
let metadataSize = 0;
const byKey = {};
for (const key of keys) {
const keyLength = key.length * 2; // UTF-16
keySize += keyLength;
const item = await this.get(key);
if (item) {
const size = this.calculateSize(item);
valueSize += getObjectSize(item.value);
metadataSize += size - getObjectSize(item.value);
total += size;
if (detailed) {
byKey[key] = size + keyLength;
}
}
}
const result = {
total: total + keySize,
count: keys.length,
};
if (detailed) {
result.byKey = byKey;
result.detailed = {
keys: keySize,
values: valueSize,
metadata: metadataSize,
};
}
return result;
}
/**
* Subscribe to changes (if supported)
*/
subscribe(callback) {
if (!this.capabilities.observable) {
throw new NotSupportedError('subscribe', this.name);
}
const handler = (...args) => {
const change = args[0];
callback(change);
};
this.eventEmitter.on('change', handler);
return () => {
this.eventEmitter.off('change', handler);
};
}
/**
* Emit change event
*/
emitChange(key, oldValue, newValue, source = 'local') {
this.eventEmitter.emit('change', {
key,
oldValue,
newValue,
source,
storage: this.name,
timestamp: Date.now(),
});
}
/**
* Query implementation (override in adapters that support it)
*/
async query(condition) {
if (!this.capabilities.queryable) {
throw new NotSupportedError('query', this.name);
}
// Basic implementation for adapters that don't have native query support
const results = [];
const keys = await this.keys();
for (const key of keys) {
const item = await this.get(key);
if (item && !this.isExpired(item)) {
// Check if querying storage metadata (tags, metadata, etc) or the actual value
let matches = false;
// Check for storage-level properties
const storageProps = ['tags', 'metadata', 'created', 'updated', 'expires'];
const isStorageQuery = Object.keys(condition).some((k) => storageProps.includes(k));
if (isStorageQuery) {
// Query against the storage wrapper
matches = this.queryEngine.matches(item, condition);
}
else {
// Query against the stored value
matches = this.queryEngine.matches(item.value, condition);
}
if (matches) {
results.push({ key, value: item.value });
}
}
}
return results;
}
/**
* Close adapter (cleanup)
*/
async close() {
this.stopTTLCleanup();
this.eventEmitter.removeAllListeners();
}
}