Files
automaker/apps/ui/src/lib/query-persist.ts

134 lines
4.4 KiB
TypeScript

/**
* React Query Cache Persistence
*
* Persists the React Query cache to IndexedDB so that after a tab discard
* or page reload, the user sees cached data instantly while fresh data
* loads in the background.
*
* Uses @tanstack/react-query-persist-client with idb-keyval for IndexedDB storage.
* Cached data is treated as stale on restore and silently refetched.
*/
import { get, set, del } from 'idb-keyval';
import type { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('QueryPersist');
const IDB_KEY = 'automaker-react-query-cache';
/**
* Maximum age of persisted cache before it's discarded (24 hours).
* After this time, the cache is considered too old and will be removed.
*/
export const PERSIST_MAX_AGE_MS = 24 * 60 * 60 * 1000;
/**
* Throttle time for persisting cache to IndexedDB.
* Prevents excessive writes during rapid query updates.
*/
export const PERSIST_THROTTLE_MS = 2000;
/**
* Query key prefixes that should NOT be persisted.
* Auth-related and volatile data should always be fetched fresh.
*/
const EXCLUDED_QUERY_KEY_PREFIXES = ['auth', 'health', 'wsToken', 'sandbox'];
/**
* Check if a query key should be excluded from persistence
*/
function shouldExcludeQuery(queryKey: readonly unknown[]): boolean {
if (queryKey.length === 0) return false;
const firstKey = String(queryKey[0]);
return EXCLUDED_QUERY_KEY_PREFIXES.some((prefix) => firstKey.startsWith(prefix));
}
/**
* Check whether there is a recent enough React Query cache in IndexedDB
* to consider the app "warm" (i.e., safe to skip blocking on the server
* health check and show the UI immediately).
*
* Returns true only if:
* 1. The cache exists and is recent (within maxAgeMs)
* 2. The cache buster matches the current build hash
*
* If the buster doesn't match, PersistQueryClientProvider will wipe the
* cache on restore — so we must NOT skip the server wait in that case,
* otherwise the board renders with empty queries and no data.
*
* This is a read-only probe — it does not restore the cache (that is
* handled by PersistQueryClientProvider automatically).
*/
export async function hasWarmIDBCache(
currentBuster: string,
maxAgeMs = PERSIST_MAX_AGE_MS
): Promise<boolean> {
try {
const client = await get<PersistedClient>(IDB_KEY);
if (!client) return false;
// PersistedClient stores a `timestamp` (ms) when it was last persisted
const age = Date.now() - (client.timestamp ?? 0);
if (age >= maxAgeMs) return false;
// If the buster doesn't match, PersistQueryClientProvider will wipe the cache.
// Treat this as a cold start — we need fresh data from the server.
if (currentBuster && client.buster !== currentBuster) return false;
return true;
} catch {
return false;
}
}
/**
* Create an IndexedDB-based persister for React Query.
*
* This persister:
* - Stores the full query cache in IndexedDB under a single key
* - Filters out auth/health queries that shouldn't be persisted
* - Handles errors gracefully (cache persistence is best-effort)
*/
export function createIDBPersister(): Persister {
return {
persistClient: async (client: PersistedClient) => {
try {
// Filter out excluded queries before persisting
const filteredClient: PersistedClient = {
...client,
clientState: {
...client.clientState,
queries: client.clientState.queries.filter(
(query) => !shouldExcludeQuery(query.queryKey)
),
// Don't persist mutations (they should be re-triggered, not replayed)
mutations: [],
},
};
await set(IDB_KEY, filteredClient);
} catch (error) {
logger.warn('Failed to persist query cache to IndexedDB:', error);
}
},
restoreClient: async () => {
try {
const client = await get<PersistedClient>(IDB_KEY);
if (client) {
logger.info('Restored React Query cache from IndexedDB');
}
return client ?? undefined;
} catch (error) {
logger.warn('Failed to restore query cache from IndexedDB:', error);
return undefined;
}
},
removeClient: async () => {
try {
await del(IDB_KEY);
} catch (error) {
logger.warn('Failed to remove query cache from IndexedDB:', error);
}
},
};
}