UNPKG

@adventurelabs/scout-core

Version:

Core utilities and helpers for Adventure Labs Scout applications

373 lines (372 loc) 16 kB
const DB_NAME = "ScoutCache"; const DB_VERSION = 5; // Increment to invalidate old cache versions const HERD_MODULES_STORE = "herd_modules"; const CACHE_METADATA_STORE = "cache_metadata"; // Default TTL: 24 hours (1 day) const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; export class ScoutCache { constructor() { this.db = null; this.initPromise = null; this.stats = { hits: 0, misses: 0, }; } async init() { if (this.db) return; if (this.initPromise) return this.initPromise; this.initPromise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => { console.error("[ScoutCache] Failed to open IndexedDB:", request.error); this.db = null; this.initPromise = null; reject(request.error); }; request.onsuccess = () => { this.db = request.result; console.log("[ScoutCache] IndexedDB initialized successfully"); // Add error handler for runtime database errors this.db.onerror = (event) => { console.error("[ScoutCache] Database error:", event); }; resolve(); }; request.onupgradeneeded = (event) => { const db = event.target.result; try { console.log(`[ScoutCache] Upgrading database to version ${DB_VERSION}`); // Remove all existing object stores to ensure clean slate const existingStores = Array.from(db.objectStoreNames); for (const storeName of existingStores) { console.log(`[ScoutCache] Removing existing object store: ${storeName}`); db.deleteObjectStore(storeName); } // Create herd modules store (unified storage for all herd data) const herdModulesStore = db.createObjectStore(HERD_MODULES_STORE, { keyPath: "herdId", }); herdModulesStore.createIndex("timestamp", "timestamp", { unique: false, }); herdModulesStore.createIndex("dbVersion", "dbVersion", { unique: false, }); console.log("[ScoutCache] Created herd_modules object store"); // Create cache metadata store const metadataStore = db.createObjectStore(CACHE_METADATA_STORE, { keyPath: "key", }); console.log("[ScoutCache] Created cache_metadata object store"); console.log(`[ScoutCache] Database schema upgrade to version ${DB_VERSION} completed`); } catch (error) { console.error("[ScoutCache] Error during database upgrade:", error); reject(error); } }; request.onblocked = () => { console.warn("[ScoutCache] Database upgrade blocked - other connections may need to be closed"); }; }); return this.initPromise; } validateDatabaseSchema() { if (!this.db) return false; const hasHerdModulesStore = this.db.objectStoreNames.contains(HERD_MODULES_STORE); const hasMetadataStore = this.db.objectStoreNames.contains(CACHE_METADATA_STORE); if (!hasHerdModulesStore) { console.error("[ScoutCache] Missing herd_modules object store"); } if (!hasMetadataStore) { console.error("[ScoutCache] Missing cache_metadata object store"); } return hasHerdModulesStore && hasMetadataStore; } async setHerdModules(herdModules, ttlMs = DEFAULT_TTL_MS, etag) { await this.init(); if (!this.db) throw new Error("Database not initialized"); if (!this.validateDatabaseSchema()) { throw new Error("Database schema validation failed - required object stores not found"); } const transaction = this.db.transaction([HERD_MODULES_STORE, CACHE_METADATA_STORE], "readwrite"); return new Promise((resolve, reject) => { transaction.onerror = () => reject(transaction.error); transaction.oncomplete = () => resolve(); const herdModulesStore = transaction.objectStore(HERD_MODULES_STORE); const metadataStore = transaction.objectStore(CACHE_METADATA_STORE); const timestamp = Date.now(); const version = "2.0.0"; // Store each herd module (contains all nested data - devices, events, zones, etc.) herdModules.forEach((herdModule) => { const cacheEntry = { herdId: herdModule.herd.id.toString(), data: herdModule, timestamp, dbVersion: DB_VERSION, }; herdModulesStore.put(cacheEntry); }); // Store cache metadata const metadata = { key: "herd_modules", timestamp, ttl: ttlMs, version, dbVersion: DB_VERSION, etag, lastModified: timestamp, }; metadataStore.put(metadata); }); } async getHerdModules() { await this.init(); if (!this.db) throw new Error("Database not initialized"); if (!this.validateDatabaseSchema()) { throw new Error("Database schema validation failed - required object stores not found"); } const transaction = this.db.transaction([HERD_MODULES_STORE, CACHE_METADATA_STORE], "readonly"); return new Promise((resolve, reject) => { transaction.onerror = () => reject(transaction.error); const herdModulesStore = transaction.objectStore(HERD_MODULES_STORE); const metadataStore = transaction.objectStore(CACHE_METADATA_STORE); // Get metadata first const metadataRequest = metadataStore.get("herd_modules"); metadataRequest.onsuccess = () => { const metadata = metadataRequest.result; const now = Date.now(); if (!metadata) { this.stats.misses++; resolve({ data: null, isStale: true, age: 0, metadata: null }); return; } // Check if cache is from an incompatible DB version if (!metadata.dbVersion || metadata.dbVersion !== DB_VERSION) { console.log(`[ScoutCache] Cache from incompatible DB version (${metadata.dbVersion || "unknown"} !== ${DB_VERSION}), invalidating`); this.stats.misses++; // Clear old cache asynchronously this.clearHerdModules().catch((error) => { console.warn("[ScoutCache] Failed to clear old cache:", error); }); resolve({ data: null, isStale: true, age: 0, metadata: null }); return; } const age = now - metadata.timestamp; const isStale = age > metadata.ttl; // Get all herd modules const getAllRequest = herdModulesStore.getAll(); getAllRequest.onsuccess = () => { const cacheEntries = getAllRequest.result; const herdModules = cacheEntries .filter((entry) => entry.data && entry.data.herd && entry.data.herd.slug && entry.dbVersion === DB_VERSION) .map((entry) => entry.data) .sort((a, b) => (a.herd?.slug || "").localeCompare(b.herd?.slug || "")); // Update stats if (herdModules.length > 0) { this.stats.hits++; } else { this.stats.misses++; } resolve({ data: herdModules, isStale, age, metadata, }); }; }; }); } async clearHerdModules() { await this.init(); if (!this.db) throw new Error("Database not initialized"); if (!this.validateDatabaseSchema()) { throw new Error("Database schema validation failed - required object stores not found"); } const transaction = this.db.transaction([HERD_MODULES_STORE, CACHE_METADATA_STORE], "readwrite"); return new Promise((resolve, reject) => { transaction.onerror = () => reject(transaction.error); transaction.oncomplete = () => resolve(); const herdModulesStore = transaction.objectStore(HERD_MODULES_STORE); const metadataStore = transaction.objectStore(CACHE_METADATA_STORE); herdModulesStore.clear(); metadataStore.delete("herd_modules"); }); } async invalidateHerdModules() { await this.init(); if (!this.db) throw new Error("Database not initialized"); if (!this.validateDatabaseSchema()) { throw new Error("Database schema validation failed - required object stores not found"); } const transaction = this.db.transaction([CACHE_METADATA_STORE], "readwrite"); return new Promise((resolve, reject) => { transaction.onerror = () => reject(transaction.error); transaction.oncomplete = () => resolve(); const metadataStore = transaction.objectStore(CACHE_METADATA_STORE); metadataStore.delete("herd_modules"); metadataStore.delete("providers"); }); } async getCacheStats() { const result = await this.getHerdModules(); const totalRequests = this.stats.hits + this.stats.misses; const hitRate = totalRequests > 0 ? this.stats.hits / totalRequests : 0; // Calculate size based on herd modules count (no longer including events/sessions/artifacts arrays) const size = result.data?.length || 0; return { size, lastUpdated: result.data ? Date.now() - result.age : 0, isStale: result.isStale, hitRate: Math.round(hitRate * 100) / 100, totalHits: this.stats.hits, totalMisses: this.stats.misses, }; } async isCacheValid(ttlMs) { const result = await this.getHerdModules(); if (!result.data || !result.metadata) return false; const effectiveTtl = ttlMs || result.metadata.ttl; return !result.isStale && result.age < effectiveTtl; } async getCacheAge() { const result = await this.getHerdModules(); return result.age; } async shouldRefresh(maxAgeMs, forceRefresh) { if (forceRefresh) { return { shouldRefresh: true, reason: "Force refresh requested" }; } const result = await this.getHerdModules(); if (!result.data || result.data.length === 0) { return { shouldRefresh: true, reason: "No cached data" }; } // Check for DB version mismatch if (!result.metadata || !result.metadata.dbVersion || result.metadata.dbVersion !== DB_VERSION) { return { shouldRefresh: true, reason: `Cache from incompatible DB version (${result.metadata?.dbVersion || "unknown"} !== ${DB_VERSION})`, }; } if (result.isStale) { return { shouldRefresh: true, reason: "Cache is stale" }; } if (maxAgeMs && result.age > maxAgeMs) { return { shouldRefresh: true, reason: `Cache age (${Math.round(result.age / 1000)}s) exceeds max age (${Math.round(maxAgeMs / 1000)}s)`, }; } return { shouldRefresh: false, reason: "Cache is valid and fresh" }; } async preloadCache(loadFunction, ttlMs = DEFAULT_TTL_MS) { try { console.log("[ScoutCache] Starting background cache preload..."); const startTime = Date.now(); const herdModules = await loadFunction(); await this.setHerdModules(herdModules, ttlMs); const duration = Date.now() - startTime; console.log(`[ScoutCache] Background preload completed in ${duration}ms`); } catch (error) { console.warn("[ScoutCache] Background preload failed:", error); } } getDefaultTtl() { return DEFAULT_TTL_MS; } getCurrentDbVersion() { return DB_VERSION; } async isCacheVersionCompatible() { try { const result = await this.getHerdModules(); if (!result.metadata) return false; return (result.metadata.dbVersion !== undefined && result.metadata.dbVersion === DB_VERSION); } catch (error) { console.warn("[ScoutCache] Version compatibility check failed:", error); return false; } } async resetDatabase() { console.log("[ScoutCache] Resetting database..."); // Close existing connection if (this.db) { this.db.close(); this.db = null; } this.initPromise = null; // Delete the database return new Promise((resolve, reject) => { const deleteRequest = indexedDB.deleteDatabase(DB_NAME); deleteRequest.onsuccess = () => { console.log("[ScoutCache] Database reset successfully"); resolve(); }; deleteRequest.onerror = () => { console.error("[ScoutCache] Failed to reset database:", deleteRequest.error); reject(deleteRequest.error); }; deleteRequest.onblocked = () => { console.warn("[ScoutCache] Database reset blocked - close all other tabs"); // Continue anyway, it will resolve when unblocked }; }); } async checkDatabaseHealth() { const issues = []; try { await this.init(); if (!this.db) { issues.push("Database connection not established"); return { healthy: false, issues }; } if (!this.validateDatabaseSchema()) { issues.push("Database schema validation failed"); } // Check version compatibility const isVersionCompatible = await this.isCacheVersionCompatible(); if (!isVersionCompatible) { issues.push(`Cache version incompatible (current: ${DB_VERSION})`); } // Try a simple read operation try { const result = await this.getHerdModules(); if (result.data === null && result.age === 0) { // This is expected for empty cache, not an error } } catch (error) { issues.push(`Read operation failed: ${error}`); } } catch (error) { issues.push(`Database initialization failed: ${error}`); } return { healthy: issues.length === 0, issues, }; } } // Singleton instance export const scoutCache = new ScoutCache();