@statezero/core
Version:
The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate
342 lines (341 loc) • 12.7 kB
JavaScript
const dbConnections = new Map();
/**
* Simple IndexedDB key-value store with batched operations
*/
export class IndexedDBStore {
constructor(dbName, options = {}) {
this.dbName = dbName;
this.storeName = options.storeName || "keyval-store";
this.batchDelay = options.batchDelay || 50;
this.version = options.version || 5;
// List of error names or messages that should trigger database deletion
this.resetOnErrors = options.resetOnErrors || [
"VersionError",
"InvalidStateError",
"Failed to open database"
];
// Queue of pending operations
this.pendingOps = [];
this.activeCommit = null;
// Whether destroy() has been called
this._destroyed = false;
// Open the database once
this.dbPromise = this._openDatabaseWithRecovery();
}
/**
* Attempt to open the database, with automatic recovery if it fails
*/
async _openDatabaseWithRecovery() {
try {
return await this._openDatabase();
}
catch (error) {
// Check if this is an error type that should trigger reset
const shouldReset = this.resetOnErrors.some(errorPattern => error.name === errorPattern ||
error.message.includes(errorPattern));
if (shouldReset) {
console.warn(`[IndexedDBStore] Database error detected, attempting to reset database "${this.dbName}":`, error);
try {
// Delete the database
await this._deleteDatabase();
console.log(`[IndexedDBStore] Successfully deleted database "${this.dbName}", attempting to reopen...`);
// Try to open it again at the desired version
return await this._openDatabase();
}
catch (resetError) {
console.error(`[IndexedDBStore] Failed to recover database "${this.dbName}":`, resetError);
throw resetError;
}
}
else {
// Not a recoverable error
throw error;
}
}
}
/**
* Open the database
*/
_openDatabase() {
return new Promise((resolve, reject) => {
const openRequest = indexedDB.open(this.dbName, this.version);
openRequest.onupgradeneeded = () => {
try {
// Create the object store if it doesn't exist
if (!openRequest.result.objectStoreNames.contains(this.storeName)) {
openRequest.result.createObjectStore(this.storeName);
}
}
catch (error) {
console.error(`[IndexedDBStore] Error during upgrade:`, error);
// Error during upgrade should reject the promise
reject(error);
}
};
openRequest.onsuccess = () => resolve(openRequest.result);
openRequest.onerror = () => reject(new Error(`Failed to open database: ${openRequest.error}`));
});
}
/**
* Delete the database
*/
_deleteDatabase() {
return new Promise((resolve, reject) => {
const deleteRequest = indexedDB.deleteDatabase(this.dbName);
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(new Error(`Failed to delete database: ${deleteRequest.error}`));
});
}
/**
* Retrieve a value by key
*/
async get(key) {
if (this._destroyed) {
throw new Error("Database has been destroyed");
}
// Schedule the get in the next batch
const getPromise = new Promise((resolve, reject) => {
this.pendingOps.push({ operation: "get", key, resolve, reject });
});
// Wait for that batch to run (or reject if destroyed)
await this._startCommitIfNeeded();
return getPromise;
}
/**
* Store a value with the given key
*/
async set(key, value) {
if (this._destroyed) {
throw new Error("Database has been destroyed");
}
// Schedule the set in the next batch with a promise
const setPromise = new Promise((resolve, reject) => {
this.pendingOps.push({ operation: "set", key, value, resolve, reject });
});
// Wait for that batch to run (or reject if destroyed)
await this._startCommitIfNeeded();
return setPromise;
}
/**
* Remove a key-value pair
*/
async delete(key) {
if (this._destroyed) {
throw new Error("Database has been destroyed");
}
// Schedule the delete in the next batch with a promise
const deletePromise = new Promise((resolve, reject) => {
this.pendingOps.push({ operation: "delete", key, resolve, reject });
});
// Wait for that batch to run (or reject if destroyed)
await this._startCommitIfNeeded();
return deletePromise;
}
/**
* Delete the entire database
*/
async destroy() {
// Prevent any future actions
this._destroyed = true;
// Reject all pending operations
const error = new Error("Database has been destroyed");
for (const op of this.pendingOps) {
if (op.reject) {
op.reject(error);
}
}
this.pendingOps = [];
try {
// Close current connection if we have one
const db = await this.dbPromise;
db.close();
// Drop the whole database
await new Promise((resolve, reject) => {
const deleteRequest = indexedDB.deleteDatabase(this.dbName);
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(deleteRequest.error);
});
}
catch (error) {
// If database opening failed, we can safely ignore this
// as there's no active connection to close
}
}
/**
* Kick off a batch if none is running
*/
_startCommitIfNeeded() {
if (this._destroyed) {
return Promise.reject(new Error("Database has been destroyed"));
}
if (!this.activeCommit) {
this.activeCommit = this._executeBatch();
}
return this.activeCommit;
}
/**
* Add this method to the IndexedDBStore class
*/
async getAllKeys() {
if (this._destroyed) {
throw new Error("Database has been destroyed");
}
const db = await this.dbPromise;
const tx = db.transaction(this.storeName, "readonly");
const store = tx.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.getAllKeys();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Get all entries from the store
* @returns {Promise<Array<[any, any]>>} Array of [key, value] pairs
*/
async getAll() {
if (this._destroyed) {
throw new Error("Database has been destroyed");
}
// Use a direct transaction for better performance with large datasets
const db = await this.dbPromise;
const tx = db.transaction(this.storeName, "readonly");
const store = tx.objectStore(this.storeName);
// Get all keys and values
const keysRequest = store.getAllKeys();
const valuesRequest = store.getAll();
// Wait for both requests to complete
const [keys, values] = await Promise.all([
new Promise((resolve, reject) => {
keysRequest.onsuccess = () => resolve(keysRequest.result);
keysRequest.onerror = () => reject(keysRequest.error);
}),
new Promise((resolve, reject) => {
valuesRequest.onsuccess = () => resolve(valuesRequest.result);
valuesRequest.onerror = () => reject(valuesRequest.error);
})
]);
// Combine keys and values into entries
return keys.map((key, index) => [key, values[index]]);
}
/**
* Execute all pending operations in one transaction
*/
async _executeBatch() {
// Brief pause so multiple ops can be coalesced
await new Promise((r) => setTimeout(r, this.batchDelay));
try {
// If we were destroyed in the meantime, abort and reject all pending operations
if (this._destroyed) {
const error = new Error("Database has been destroyed");
// Reject all pending operations
for (const op of this.pendingOps) {
if (op.reject) {
op.reject(error);
}
}
this.pendingOps = [];
return;
}
const db = await this.dbPromise;
const tx = db.transaction(this.storeName, "readwrite");
const store = tx.objectStore(this.storeName);
// Drain the queue
for (const op of this.pendingOps) {
switch (op.operation) {
case "get": {
const req = store.get(op.key);
req.onsuccess = () => op.resolve(req.result);
req.onerror = () => op.reject(req.error);
break;
}
case "set": {
const req = store.put(op.value, op.key);
req.onsuccess = () => op.resolve();
req.onerror = () => op.reject(req.error);
break;
}
case "delete": {
const req = store.delete(op.key);
req.onsuccess = () => op.resolve();
req.onerror = () => op.reject(req.error);
break;
}
}
}
this.pendingOps = [];
// Wait for the transaction to finish or fail
await new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = (e) => reject(e.target.error);
});
}
catch (error) {
// If there's an error, make sure to reject all pending operations that have a reject function
for (const op of this.pendingOps) {
if (op.reject) {
op.reject(error);
}
}
this.pendingOps = [];
throw error; // Re-throw to propagate the error
}
finally {
// Allow a new batch to start
this.activeCommit = null;
}
}
}
export class Cache {
constructor(dbName, options = {}, onHydrated = null) {
this.store = new IndexedDBStore(dbName, options);
this.localMap = new Map();
// don't await - will hydrate during app setup
this.hydrate()
.then(result => {
if (typeof onHydrated === 'function') {
onHydrated(result);
}
})
.catch(err => {
console.error(`Cache hydration failed for "${dbName}":`, err);
});
}
async hydrate() {
this.localMap = new Map(await this.store.getAll());
}
get(key) {
return this.localMap.get(key);
}
set(key, value) {
this.localMap.set(key, value);
this.store.set(key, value);
}
delete(key) {
this.localMap.delete(key);
this.store.delete(key);
}
async getAllKeys() {
return await this.store.getAllKeys();
}
/**
* Clear all entries from the cache
* Clears the in-memory map and schedules deletion of all keys in IndexedDB
*/
clear() {
// Clear the in-memory map
this.localMap.clear();
// Get all keys from the store and delete them
this.store.getAll()
.then(entries => {
// Delete each key from the store
entries.forEach(([key]) => {
this.store.delete(key);
});
})
.catch(err => {
console.error('Error clearing cache:', err);
});
}
}