133 lines
3.5 KiB
TypeScript
133 lines
3.5 KiB
TypeScript
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<PersistedQueryCache>(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<typeof setTimeout> | 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<PersistedQueryCache>(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);
|
|
}
|
|
}
|