@adventurelabs/scout-core
Version:
Core utilities and helpers for Adventure Labs Scout applications
337 lines (336 loc) • 19 kB
JavaScript
import { useEffect, useCallback, useRef, useMemo } from "react";
import { useAppDispatch } from "../store/hooks";
import { useStore } from "react-redux";
import { EnumScoutStateStatus, setHerdModules, setStatus, setHerdModulesLoadingState, setHerdModulesLoadedInMs, setHerdModulesApiServerProcessingDuration, setHerdModulesApiTotalRequestDuration, setUserApiDuration, setDataProcessingDuration, setCacheLoadDuration, setUser, setDataSource, setDataSourceInfo, } from "../store/scout";
import { EnumHerdModulesLoadingState } from "../types/herd_module";
import { server_load_herd_modules } from "../helpers/herds";
import { scoutCache } from "../helpers/cache";
import { EnumDataSource } from "../types/data_source";
import { createBrowserClient } from "@supabase/ssr";
/**
* Hook for refreshing scout data with detailed timing measurements and cache-first loading
*
* @param options - Configuration options for the refresh behavior
* @param options.autoRefresh - Whether to automatically refresh on mount (default: true)
* @param options.onRefreshComplete - Callback function called when refresh completes
* @param options.cacheFirst - Whether to load from cache first, then refresh (default: true)
* @param options.cacheTtlMs - Cache time-to-live in milliseconds (default: 24 hours)
*
* @returns Object containing:
* - handleRefresh: Function to manually trigger a refresh
* - clearCache: Function to clear the cache
*
* @example
* ```tsx
* const { handleRefresh, clearCache } = useScoutRefresh({
* cacheFirst: true,
* cacheTtlMs: 10 * 60 * 1000 // 10 minutes
* });
*
* // Timing stats are available in Redux store via selectors
* ```
*/
export function useScoutRefresh(options = {}) {
const { autoRefresh = true, onRefreshComplete, cacheFirst = true, cacheTtlMs = 24 * 60 * 60 * 1000, // 24 hours default (1 day)
} = options;
const dispatch = useAppDispatch();
const store = useStore();
const refreshInProgressRef = useRef(false);
// Create Supabase client directly to avoid circular dependency
const supabase = useMemo(() => {
return createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "");
}, []);
// Refs to store timing measurements
const timingRefs = useRef({
startTime: 0,
herdModulesDuration: 0,
userApiDuration: 0,
dataProcessingDuration: 0,
cacheLoadDuration: 0,
cacheSaveDuration: 0,
});
// Helper function for deep comparison of objects
// Helper function to handle IndexedDB errors - memoized for stability
const handleIndexedDbError = useCallback(async (error, operation, retryFn) => {
if (error instanceof Error &&
(error.message.includes("object store") ||
error.message.includes("NotFoundError"))) {
console.log(`[useScoutRefresh] Attempting database reset due to ${operation} error...`);
try {
await scoutCache.resetDatabase();
console.log("[useScoutRefresh] Database reset successful");
if (retryFn) {
await retryFn();
console.log(`[useScoutRefresh] ${operation} successful after database reset`);
}
}
catch (resetError) {
console.error(`[useScoutRefresh] Database reset and retry failed:`, resetError);
}
}
}, []);
const handleRefresh = useCallback(async () => {
// Prevent concurrent refresh calls
if (refreshInProgressRef.current) {
console.warn("[useScoutRefresh] Refresh already in progress, skipping");
return;
}
refreshInProgressRef.current = true;
const startTime = Date.now();
timingRefs.current.startTime = startTime;
try {
dispatch(setStatus(EnumScoutStateStatus.LOADING));
dispatch(setHerdModulesLoadingState(EnumHerdModulesLoadingState.LOADING));
let cachedHerdModules = null;
let cacheLoadDuration = 0;
// Step 1: Load from cache first if enabled
if (cacheFirst) {
const cacheStartTime = Date.now();
try {
const cacheResult = await scoutCache.getHerdModules();
cacheLoadDuration = Date.now() - cacheStartTime;
timingRefs.current.cacheLoadDuration = cacheLoadDuration;
dispatch(setCacheLoadDuration(cacheLoadDuration));
if (cacheResult.data && cacheResult.data.length > 0) {
cachedHerdModules = cacheResult.data;
console.log(`[useScoutRefresh] Loaded ${cachedHerdModules.length} herd modules from cache in ${cacheLoadDuration}ms (age: ${Math.round(cacheResult.age / 1000)}s, stale: ${cacheResult.isStale})`);
// Set data source to CACHE initially
dispatch(setDataSource(EnumDataSource.CACHE));
dispatch(setDataSourceInfo({
source: EnumDataSource.CACHE,
timestamp: Date.now(),
cacheAge: cacheResult.age,
isStale: cacheResult.isStale,
}));
// Update the store with cached data
console.log(`[useScoutRefresh] Updating store with cached herd modules`);
dispatch(setHerdModules(cachedHerdModules));
dispatch(setHerdModulesLoadingState(EnumHerdModulesLoadingState.SUCCESSFULLY_LOADED));
// If cache is fresh, we still background fetch but don't wait
if (!cacheResult.isStale) {
// Background fetch fresh data without blocking
(async () => {
try {
const backgroundStartTime = Date.now();
const [backgroundHerdModulesResult, backgroundUserResult] = await Promise.all([
(async () => {
const start = Date.now();
const result = await server_load_herd_modules();
const totalDuration = Date.now() - start;
const serverDuration = result.server_processing_time_ms || totalDuration;
const clientOverhead = totalDuration - serverDuration;
console.log(`[useScoutRefresh] Background API timing breakdown:`);
console.log(` - Server processing: ${serverDuration}ms`);
console.log(` - Client overhead: ${clientOverhead}ms`);
console.log(` - Total request: ${totalDuration}ms`);
timingRefs.current.herdModulesDuration = serverDuration;
dispatch(setHerdModulesApiServerProcessingDuration(serverDuration));
dispatch(setHerdModulesApiTotalRequestDuration(totalDuration));
return result;
})(),
(async () => {
const start = Date.now();
const { data } = await supabase.auth.getUser();
const duration = Date.now() - start;
timingRefs.current.userApiDuration = duration;
dispatch(setUserApiDuration(duration));
return { data: data.user, status: "success" };
})(),
]);
const backgroundDuration = Date.now() - backgroundStartTime;
// Validate background responses
if (backgroundHerdModulesResult.data &&
Array.isArray(backgroundHerdModulesResult.data) &&
backgroundUserResult &&
backgroundUserResult.data) {
// Update cache with fresh data
try {
await scoutCache.setHerdModules(backgroundHerdModulesResult.data, cacheTtlMs);
}
catch (cacheError) {
console.warn("[useScoutRefresh] Background cache save failed:", cacheError);
await handleIndexedDbError(cacheError, "background cache save", async () => {
if (backgroundHerdModulesResult.data) {
await scoutCache.setHerdModules(backgroundHerdModulesResult.data, cacheTtlMs);
}
});
}
// Update store with fresh data from background request
console.log(`[useScoutRefresh] Updating store with background herd modules`);
dispatch(setHerdModules(backgroundHerdModulesResult.data));
if (backgroundUserResult && backgroundUserResult.data) {
console.log(`[useScoutRefresh] Updating store with background user data`);
dispatch(setUser(backgroundUserResult.data));
}
// Update data source to DATABASE
dispatch(setDataSource(EnumDataSource.DATABASE));
dispatch(setDataSourceInfo({
source: EnumDataSource.DATABASE,
timestamp: Date.now(),
}));
}
else {
console.warn("[useScoutRefresh] Background fetch returned invalid data");
}
}
catch (backgroundError) {
console.warn("[useScoutRefresh] Background fetch failed:", backgroundError);
}
})();
const totalDuration = Date.now() - startTime;
dispatch(setHerdModulesLoadedInMs(totalDuration));
dispatch(setStatus(EnumScoutStateStatus.DONE_LOADING));
onRefreshComplete?.();
return;
}
}
else {
}
}
catch (cacheError) {
console.warn("[useScoutRefresh] Cache load failed:", cacheError);
await handleIndexedDbError(cacheError, "cache load");
// Continue with API call
}
}
// Step 2: Load fresh data from API
const parallelStartTime = Date.now();
const [herdModulesResult, userResult] = await Promise.all([
(async () => {
const start = Date.now();
const result = await server_load_herd_modules();
const totalDuration = Date.now() - start;
const serverDuration = result.server_processing_time_ms || totalDuration;
return { result, totalDuration, serverDuration, start };
})(),
(async () => {
const start = Date.now();
const { data } = await supabase.auth.getUser();
const duration = Date.now() - start;
return {
result: { data: data.user, status: "success" },
duration,
start,
};
})(),
]);
const parallelDuration = Date.now() - parallelStartTime;
console.log(`[useScoutRefresh] Parallel API requests completed in ${parallelDuration}ms`);
// Extract results and timing
const herdModulesResponse = herdModulesResult.result;
const res_new_user = userResult.result;
const herdModulesServerDuration = herdModulesResult.serverDuration;
const herdModulesTotalDuration = herdModulesResult.totalDuration;
const userApiDuration = userResult.duration;
const clientOverhead = herdModulesTotalDuration - herdModulesServerDuration;
console.log(`[useScoutRefresh] Fresh API timing breakdown:`);
console.log(` - Server processing: ${herdModulesServerDuration}ms`);
console.log(` - Client overhead: ${clientOverhead}ms`);
console.log(` - Total request: ${herdModulesTotalDuration}ms`);
// Store timing values
timingRefs.current.herdModulesDuration = herdModulesServerDuration;
timingRefs.current.userApiDuration = userApiDuration;
// Dispatch timing actions
dispatch(setHerdModulesApiServerProcessingDuration(herdModulesServerDuration));
dispatch(setHerdModulesApiTotalRequestDuration(herdModulesTotalDuration));
dispatch(setUserApiDuration(userApiDuration));
// Validate API responses
const validationStartTime = Date.now();
if (!herdModulesResponse.data ||
!Array.isArray(herdModulesResponse.data)) {
throw new Error("Invalid herd modules response");
}
if (!res_new_user || !res_new_user.data) {
throw new Error("Invalid user response");
}
const validationDuration = Date.now() - validationStartTime;
console.log(`[useScoutRefresh] Data validation took: ${validationDuration}ms`);
// Use the validated data
const compatible_new_herd_modules = herdModulesResponse.data;
// Set data source to DATABASE
dispatch(setDataSource(EnumDataSource.DATABASE));
dispatch(setDataSourceInfo({
source: EnumDataSource.DATABASE,
timestamp: Date.now(),
}));
// Step 3: Update cache with fresh data
const cacheSaveStartTime = Date.now();
try {
await scoutCache.setHerdModules(compatible_new_herd_modules, cacheTtlMs);
const cacheSaveDuration = Date.now() - cacheSaveStartTime;
timingRefs.current.cacheSaveDuration = cacheSaveDuration;
console.log(`[useScoutRefresh] Cache updated in ${cacheSaveDuration}ms with TTL: ${Math.round(cacheTtlMs / 1000)}s`);
}
catch (cacheError) {
console.warn("[useScoutRefresh] Cache save failed:", cacheError);
await handleIndexedDbError(cacheError, "cache save", async () => {
await scoutCache.setHerdModules(compatible_new_herd_modules, cacheTtlMs);
});
}
// Step 4: Conditionally update store with fresh data, skip timestamp-only changes
const dataProcessingStartTime = Date.now();
// Update store with new data
console.log(`[useScoutRefresh] Updating store with fresh herd modules`);
dispatch(setHerdModules(compatible_new_herd_modules));
console.log(`[useScoutRefresh] Updating store with fresh user data`);
dispatch(setUser(res_new_user.data));
dispatch(setHerdModulesLoadingState(EnumHerdModulesLoadingState.SUCCESSFULLY_LOADED));
const dataProcessingDuration = Date.now() - dataProcessingStartTime;
timingRefs.current.dataProcessingDuration = dataProcessingDuration;
dispatch(setDataProcessingDuration(dataProcessingDuration));
const loadingDuration = Date.now() - startTime;
dispatch(setHerdModulesLoadedInMs(loadingDuration));
dispatch(setStatus(EnumScoutStateStatus.DONE_LOADING));
// Log concise completion summary
console.log(`[useScoutRefresh] Refresh completed in ${loadingDuration}ms (Server: ${herdModulesServerDuration}ms, Total API: ${herdModulesTotalDuration}ms)`);
onRefreshComplete?.();
}
catch (error) {
const loadingDuration = Date.now() - startTime;
console.error("Error refreshing scout data:", error);
// Set data source to UNKNOWN on error
dispatch(setDataSource(EnumDataSource.UNKNOWN));
dispatch(setDataSourceInfo({
source: EnumDataSource.UNKNOWN,
timestamp: Date.now(),
}));
// Ensure consistent state updates on error
dispatch(setHerdModulesLoadingState(EnumHerdModulesLoadingState.UNSUCCESSFULLY_LOADED));
dispatch(setHerdModulesLoadedInMs(loadingDuration));
dispatch(setStatus(EnumScoutStateStatus.DONE_LOADING));
// Log essential error metrics
console.log(`[useScoutRefresh] Refresh failed after ${loadingDuration}ms`);
// Call completion callback even on error for consistency
onRefreshComplete?.();
}
finally {
refreshInProgressRef.current = false;
}
}, [
dispatch,
store,
supabase,
onRefreshComplete,
cacheFirst,
cacheTtlMs,
handleIndexedDbError,
]);
useEffect(() => {
if (autoRefresh) {
handleRefresh();
}
}, [autoRefresh, handleRefresh]);
// Utility function to clear cache
const clearCache = useCallback(async () => {
try {
await scoutCache.clearHerdModules();
}
catch (error) {
console.error("[useScoutRefresh] Failed to clear cache:", error);
}
}, []);
return {
handleRefresh,
clearCache,
};
}