import { type MaybeRefOrGetter, toValue } from "@vueuse/core"; import { computed, del, type Ref, ref, set, unref } from "vue"; import { LastQueue } from "@/utils/lastQueue"; import { isRetryableApiError, MAX_RETRIES } from "@/utils/simple-error"; /** * Parameters for fetching an item from the server. * * Minimally, this should include an id for indexing the item. */ export interface FetchParams { id: string; } /** * A function that fetches an item from the server. */ type FetchHandler = (params: FetchParams) => Promise; /** * A function that returns true if the item should be fetched. * Provides fine-grained control over when to fetch an item. */ type ShouldFetchHandler = (item?: T) => boolean; /** * Returns true if the item is not defined. * @param item The item to check. */ const fetchIfAbsent = (item?: T) => item === undefined; /** * A composable that provides a simple key-value cache for items fetched from the server. * * Useful for storing items that are fetched by id. * * @param fetchItemHandler Fetches an item from the server. * @param shouldFetchHandler Returns true if the item should be fetched. * Provides fine-grained control over when to fetch an item. * If not provided, by default, the item will be fetched if it is not already stored. */ export function useKeyedCache( fetchItemHandler: Ref> | FetchHandler, shouldFetchHandler?: MaybeRefOrGetter>, ) { const storedItems = ref<{ [key: string]: T }>({}); const loadingErrors = ref<{ [key: string]: Error }>({}); const loadingRequests = ref<{ [key: string]: Promise }>({}); const retryCounts: { [key: string]: number } = {}; const fetchQueue = new LastQueue>(); const getItemById = computed(() => { return (id: string) => { const item = storedItems.value[id]; const existingError = loadingErrors.value[id]; const canRetry = existingError && isRetryableApiError(existingError) && (retryCounts[id] ?? 0) <= MAX_RETRIES; if (shouldFetch(item) && (!existingError || canRetry)) { fetchItemById({ id: id }); } return item ?? null; }; }); function shouldFetch(item?: T) { if (shouldFetchHandler == undefined) { return fetchIfAbsent(item); } return toValue(shouldFetchHandler)(item); } const isLoadingItem = computed(() => { return (id: string) => { return Boolean(loadingRequests.value[id]); }; }); const getItemLoadError = computed(() => { return (id: string) => { return loadingErrors.value[id] ?? null; }; }); async function fetchItemById(params: FetchParams): Promise { const itemId = params.id; if (loadingRequests.value[itemId]) { return loadingRequests.value[itemId]; } const fetchPromise = (async () => { try { const fetchItem = unref(fetchItemHandler); const item = await fetchQueue.enqueue(fetchItem, { id: itemId }, itemId); set(storedItems.value, itemId, item); del(loadingErrors.value, itemId); delete retryCounts[itemId]; return item; } catch (error) { retryCounts[itemId] = (retryCounts[itemId] ?? 0) + 1; set(loadingErrors.value, itemId, error as Error); } finally { del(loadingRequests.value, itemId); } })(); set(loadingRequests.value, itemId, fetchPromise); return fetchPromise; } return { /** * The stored items as a reactive object. */ storedItems, /** * A computed function that returns the item with the given id. * If the item is not already stored, it will be fetched from the server. * And reactively updated when the fetch completes. */ getItemById, /** * A computed function holding errors */ getItemLoadError, /** * A computed function that returns true if the item with the given id is currently being fetched. */ isLoadingItem, /** * Fetches the item with the given id from the server. * And reactively updates the stored item when the fetch completes. */ fetchItemById, }; }