/** * Strata Smart Fetch * Deduplication, caching, and no re-render for unchanged data */ import type { Strata } from '../core/strata.sts'; interface FetchOptions { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; headers?: Record; body?: any; cache?: 'none' | 'smart' | 'permanent' | string; // string for TTL like '5m', '1h' stale?: string; // stale-while-revalidate TTL dedupe?: boolean; // default true transform?: (data: any) => any; } interface QueryState { data: T | null; loading: boolean; error: Error | null; isStale: boolean; } type QuerySubscriber = (state: QueryState) => void; export class StrataFetch { private strata: any; // Strata instance private queryCache: Map> = new Map(); private subscribers: Map>> = new Map(); constructor(strata: any) { this.strata = strata; } /** * Basic fetch with deduplication and caching */ async fetch(url: string, options: FetchOptions = {}): Promise { const fullUrl = this.resolveUrl(url); const data = await this.strata.fetchViaWorker(fullUrl, { method: options.method || 'GET', headers: options.headers, body: options.body, cache: options.cache || 'smart', }); return options.transform ? options.transform(data) : data; } /** * GET request */ async get(url: string, options?: Omit): Promise { return this.fetch(url, { ...options, method: 'GET' }); } /** * POST request */ async post(url: string, body?: any, options?: Omit): Promise { return this.fetch(url, { ...options, method: 'POST', body }); } /** * PUT request */ async put(url: string, body?: any, options?: Omit): Promise { return this.fetch(url, { ...options, method: 'PUT', body }); } /** * DELETE request */ async delete(url: string, options?: Omit): Promise { return this.fetch(url, { ...options, method: 'DELETE' }); } /** * Reactive query hook - only re-renders when data changes */ useQuery( url: string, options: FetchOptions = {} ): { subscribe: (subscriber: QuerySubscriber) => () => void; refetch: () => Promise; getData: () => QueryState; } { const cacheKey = this.getCacheKey(url, options); // Initialize state if not exists if (!this.queryCache.has(cacheKey)) { this.queryCache.set(cacheKey, { data: null, loading: true, error: null, isStale: false, }); // Start initial fetch this.executeQuery(url, options, cacheKey); } return { subscribe: (subscriber: QuerySubscriber) => { if (!this.subscribers.has(cacheKey)) { this.subscribers.set(cacheKey, new Set()); } this.subscribers.get(cacheKey)!.add(subscriber); // Immediately call with current state subscriber(this.queryCache.get(cacheKey)!); // Return unsubscribe return () => { this.subscribers.get(cacheKey)?.delete(subscriber); }; }, refetch: () => this.executeQuery(url, options, cacheKey), getData: () => this.queryCache.get(cacheKey)!, }; } private async executeQuery(url: string, options: FetchOptions, cacheKey: string): Promise { const currentState = this.queryCache.get(cacheKey)!; // Set loading (but keep old data for stale-while-revalidate) this.updateQueryState(cacheKey, { ...currentState, loading: true, isStale: currentState.data !== null, }); try { const newData = await this.fetch(url, options); // Deep compare with existing data const hasChanged = !this.deepEqual(currentState.data, newData); if (hasChanged) { this.updateQueryState(cacheKey, { data: newData, loading: false, error: null, isStale: false, }); } else { // Data unchanged - just update loading state, no re-render trigger const state = this.queryCache.get(cacheKey)!; state.loading = false; state.isStale = false; // Don't notify subscribers since data didn't change } } catch (error) { this.updateQueryState(cacheKey, { ...currentState, loading: false, error: error instanceof Error ? error : new Error('Unknown error'), isStale: false, }); } } private updateQueryState(cacheKey: string, state: QueryState): void { this.queryCache.set(cacheKey, state); // Notify subscribers const subs = this.subscribers.get(cacheKey); if (subs) { for (const subscriber of subs) { subscriber(state); } } } private getCacheKey(url: string, options: FetchOptions): string { const method = options.method || 'GET'; const body = options.body ? JSON.stringify(options.body) : ''; return `${method}:${url}:${body}`; } private resolveUrl(url: string): string { if (url.startsWith('http://') || url.startsWith('https://')) { return url; } // Use base URL from config if available const baseUrl = (window as any).__STRATA_CONFIG__?.apiBaseUrl || ''; return baseUrl + url; } private deepEqual(a: any, b: any): boolean { if (a === b) return true; if (a === null || b === null) return false; if (typeof a !== typeof b) return false; if (typeof a === 'object') { if (Array.isArray(a) !== Array.isArray(b)) return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (!this.deepEqual(a[key], b[key])) return false; } return true; } return false; } /** * Invalidate cache for specific URLs */ invalidate(pattern?: string): void { this.strata.invalidateCache(pattern || '*'); // Also clear local query cache if (!pattern || pattern === '*') { this.queryCache.clear(); } else { for (const key of this.queryCache.keys()) { if (key.includes(pattern)) { this.queryCache.delete(key); } } } } }