import AsyncStorage from "@react-native-async-storage/async-storage"; import { dehydrate, hydrate, type DehydratedState, type QueryClient, } from "@tanstack/react-query"; import SuperJSON from "superjson"; const CACHE_PREFIX = "beenvoice:query-cache:v1"; const CACHE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7; const SAVE_DEBOUNCE_MS = 1000; type PersistedQueryCache = { timestamp: number; state: DehydratedState; }; const SKIPPED_QUERY_KEY_PARTS = ["previewPdf"]; function cacheScopeKey(accountId: string | null, apiUrl: string) { const scope = `${accountId ?? "guest"}:${apiUrl}`; return encodeURIComponent(scope).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, ); } export function offlineQueryCacheKey(accountId: string | null, apiUrl: string) { return `${CACHE_PREFIX}:${cacheScopeKey(accountId, apiUrl)}`; } export async function restoreOfflineQueryCache({ queryClient, accountId, apiUrl, }: { queryClient: QueryClient; accountId: string | null; apiUrl: string; }) { const key = offlineQueryCacheKey(accountId, apiUrl); const cached = await AsyncStorage.getItem(key); if (!cached) return; try { const persisted = SuperJSON.parse(cached); if (Date.now() - persisted.timestamp > CACHE_MAX_AGE_MS) { await AsyncStorage.removeItem(key); return; } hydrate(queryClient, persisted.state); } catch { await AsyncStorage.removeItem(key); } } export function persistOfflineQueryCache({ queryClient, accountId, apiUrl, }: { queryClient: QueryClient; accountId: string | null; apiUrl: string; }) { const key = offlineQueryCacheKey(accountId, apiUrl); let timeout: ReturnType | null = null; const save = async () => { const state = dehydrate(queryClient, { shouldDehydrateQuery: (query) => query.state.status === "success" && query.state.fetchStatus === "idle" && query.state.fetchFailureCount === 0 && !SKIPPED_QUERY_KEY_PARTS.some((part) => JSON.stringify(query.queryKey).includes(part), ), }); if (state.queries.length === 0) return; const existing = await AsyncStorage.getItem(key).then((cached) => { if (!cached) return null; try { return SuperJSON.parse(cached); } catch { return null; } }); const queriesByHash = new Map( existing?.state.queries.map((query) => [query.queryHash, query]), ); for (const query of state.queries) { queriesByHash.set(query.queryHash, query); } const persisted: PersistedQueryCache = { timestamp: Date.now(), state: { mutations: state.mutations, queries: Array.from(queriesByHash.values()), }, }; await AsyncStorage.setItem(key, SuperJSON.stringify(persisted)); }; const scheduleSave = () => { if (timeout) clearTimeout(timeout); timeout = setTimeout(save, SAVE_DEBOUNCE_MS); }; const unsubscribe = queryClient.getQueryCache().subscribe(scheduleSave); return () => { unsubscribe(); if (timeout) { clearTimeout(timeout); void save(); } }; } export async function clearOfflineQueryCacheForAccount(accountId: string) { const keys = await AsyncStorage.getAllKeys(); const accountPrefix = `${CACHE_PREFIX}:${encodeURIComponent(`${accountId}:`)}`; const matchingKeys = keys.filter((key) => key.startsWith(accountPrefix)); if (matchingKeys.length > 0) { await AsyncStorage.multiRemove(matchingKeys); } }