UNPKG

@wisemen/vue-core-api-utils

Version:
773 lines (755 loc) 23.9 kB
import { err, ok } from "neverthrow"; import { QueryClient as QueryClient$1, VueQueryPlugin, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; import { computed } from "vue"; //#region src/async-result/asyncResult.ts /** * Base class for AsyncResult - internal use only. * Use AsyncResult<T, E> as the public type. */ var AsyncResultBase = class { _error; _status; _value; constructor(status, value, error) { this._status = status; this._value = value; this._error = error; } /** * Check if the result is an error (type predicate for narrowing) */ isErr() { return this._status === "err"; } /** * Check if the result is in loading state (type predicate for narrowing) */ isLoading() { return this._status === "loading"; } /** * Check if the result is a success (type predicate for narrowing) */ isOk() { return this._status === "ok"; } /** * Map the success value to a new value */ map(fn) { if (this._status === "loading") return AsyncResult.loading(); if (this._status === "ok") return AsyncResult.ok(fn(this._value)); return AsyncResult.err(this._error); } /** * Map the error to a new error */ mapErr(fn) { if (this._status === "loading") return AsyncResult.loading(); if (this._status === "err") return AsyncResult.err(fn(this._error)); return AsyncResult.ok(this._value); } /** * Pattern match on all three states */ match(handlers) { if (this._status === "loading") return handlers.loading(); if (this._status === "ok") return handlers.ok(this._value); return handlers.err(this._error); } unwrapOr(defaultValue) { if (this._status === "ok") return this._value; return defaultValue; } }; /** * AsyncResult representing an error state */ var AsyncResultErr = class AsyncResultErr extends AsyncResultBase { constructor(error) { super("err", void 0, error); } /** @internal */ static _create(error) { return new AsyncResultErr(error); } /** Get the error value - only available after isErr() check */ getError() { return this._error; } getResult() { return err(this._error); } }; /** * AsyncResult representing a loading state */ var AsyncResultLoading = class AsyncResultLoading extends AsyncResultBase { constructor() { super("loading"); } /** @internal */ static _create() { return new AsyncResultLoading(); } getResult() { return null; } }; /** * AsyncResult representing a success state */ var AsyncResultOk = class AsyncResultOk extends AsyncResultBase { constructor(value) { super("ok", value); } /** @internal */ static _create(value) { return new AsyncResultOk(value); } getResult() { return ok(this._value); } /** Get the success value - only available after isOk() check */ getValue() { return this._value; } }; /** * Static factory methods for creating AsyncResult instances. * This pattern (same name for type and value) is intentional for ergonomic API. */ const AsyncResult = { err(error) { return AsyncResultErr._create(error); }, fromResult(result) { if (result.isOk()) return AsyncResult.ok(result.value); return AsyncResult.err(result.error); }, loading() { return AsyncResultLoading._create(); }, ok(value) { return AsyncResultOk._create(value); } }; function isAsyncResult(value) { return value instanceof AsyncResultErr || value instanceof AsyncResultLoading || value instanceof AsyncResultOk; } //#endregion //#region src/config/config.ts const DEFAULT_LIMIT$3 = 20; const DEFAULT_PREFETCH_STALE_TIME = 60; const QUERY_CONFIG = { prefetchStaleTime: DEFAULT_PREFETCH_STALE_TIME, limit: DEFAULT_LIMIT$3 }; let globalQueryClient = null; /** * Initialize the API utilities with a QueryClient. * Call this once during app setup (e.g. in a plugin or main.ts). * * After calling this, `createApiUtils()` can be called without options. * * @example * ```typescript * import { initializeApiUtils } from '@wisemen/vue-core-api-utils' * * const queryClient = new QueryClient() * initializeApiUtils(queryClient) * * // Then in your api lib: * export const { useQuery, useMutation, ... } = createApiUtils<MyQueryKeys>() * ``` */ function initializeApiUtils(queryClient) { globalQueryClient = queryClient; } /** * @internal */ function getQueryClient() { if (globalQueryClient == null) throw new Error("[api-utils] QueryClient not available. Call initializeApiUtils(queryClient) before using createApiUtils()."); return globalQueryClient; } function setQueryConfig(config) { if (config.limit != null && config.limit > 0) QUERY_CONFIG.limit = config.limit; if (config.prefetchStaleTime != null && config.prefetchStaleTime > 0) QUERY_CONFIG.prefetchStaleTime = config.prefetchStaleTime; } //#endregion //#region src/composables/query/keysetInfiniteQuery.composable.ts const DEFAULT_LIMIT$2 = QUERY_CONFIG.limit; function useKeysetInfiniteQuery(options) { function getQueryKey() { const [queryKey, params] = Object.entries(options.queryKey)[0]; return [queryKey, params]; } const infiniteQuery = useInfiniteQuery({ staleTime: options.staleTime, enabled: options.isEnabled, getNextPageParam: (lastPage) => { if (lastPage.isErr() || lastPage.isLoading()) return null; return lastPage.getValue().meta.next ?? null; }, initialPageParam: void 0, placeholderData: (data) => data, queryFn: async ({ pageParam }) => { return AsyncResult.fromResult(await options.queryFn({ key: pageParam, limit: options.limit ?? DEFAULT_LIMIT$2 })); }, queryKey: getQueryKey() }); const hasError = computed(() => { return Boolean(infiniteQuery.data.value?.pages.find((page) => page.isErr())); }); const result = computed(() => { if (infiniteQuery.isLoading.value) return AsyncResult.loading(); const firstError = infiniteQuery.data.value?.pages.find((page) => page.isErr()); if (firstError) return AsyncResult.err(firstError.getError()); const data = infiniteQuery.data.value?.pages.filter((page) => page.isOk()).flatMap((page) => page.getValue().data) ?? []; const firstPage = infiniteQuery.data.value?.pages[0]; const meta = firstPage?.isOk() ? firstPage.getValue().meta : { next: null }; const response = { data, meta: { next: infiniteQuery.hasNextPage.value ? meta.next : null } }; return AsyncResult.ok(response); }); function fetchNextPage() { if (!infiniteQuery.hasNextPage.value || infiniteQuery.isFetchingNextPage.value) return; return infiniteQuery.fetchNextPage(); } return { hasNextPage: computed(() => infiniteQuery.hasNextPage.value), isError: computed(() => hasError.value), isFetching: computed(() => infiniteQuery.isFetching.value), isFetchingNextPage: computed(() => infiniteQuery.isFetchingNextPage.value), isLoading: computed(() => infiniteQuery.isLoading.value), isSuccess: computed(() => !hasError.value), fetchNextPage: async () => { await fetchNextPage(); }, refetch: async () => { await infiniteQuery.refetch(); }, result }; } //#endregion //#region src/composables/query/offsetInfiniteQuery.composable.ts const DEFAULT_LIMIT$1 = QUERY_CONFIG.limit; function useOffsetInfiniteQuery(options) { function getQueryKey() { const [first] = Object.entries(options.queryKey); if (!first) return []; const [queryKey, params] = first; return [queryKey, params]; } const infiniteQuery = useInfiniteQuery({ staleTime: options.staleTime, enabled: options.isEnabled, getNextPageParam: (lastPage) => { if (lastPage.isErr() || lastPage.isLoading()) return null; const total = lastPage.getValue().meta.offset + lastPage.getValue().meta.limit; if (total >= lastPage.getValue().meta.total) return null; return total; }, initialPageParam: 0, placeholderData: (data) => data, queryFn: async ({ pageParam }) => AsyncResult.fromResult(await options.queryFn({ limit: options.limit ?? DEFAULT_LIMIT$1, offset: pageParam ?? 0 })), queryKey: getQueryKey() }); const hasError = computed(() => { return Boolean(infiniteQuery.data.value?.pages.find((page) => page.isErr())); }); const result = computed(() => { if (infiniteQuery.isLoading.value) return AsyncResult.loading(); const firstError = infiniteQuery.data.value?.pages.find((page) => page.isErr()); if (firstError) return AsyncResult.err(firstError.getError()); const data = infiniteQuery.data.value?.pages.filter((page) => page.isOk()).flatMap((page) => page.getValue().data) ?? []; const firstPage = infiniteQuery.data.value?.pages[0]; const meta = firstPage?.isOk() ? firstPage.getValue().meta : null; const response = { data, meta: { limit: meta?.limit ?? 0, offset: meta?.offset ?? 0, total: meta?.total ?? data.length } }; return AsyncResult.ok(response); }); function fetchNextPage() { if (!infiniteQuery.hasNextPage.value || infiniteQuery.isFetchingNextPage.value) return; return infiniteQuery.fetchNextPage(); } return { hasNextPage: computed(() => infiniteQuery.hasNextPage.value), isError: computed(() => hasError.value), isFetching: computed(() => infiniteQuery.isFetching.value), isFetchingNextPage: computed(() => infiniteQuery.isFetchingNextPage.value), isLoading: computed(() => infiniteQuery.isLoading.value), isSuccess: computed(() => !hasError.value), fetchNextPage: async () => { await fetchNextPage(); }, refetch: async () => { await infiniteQuery.refetch(); }, result }; } //#endregion //#region src/factory/createApiInfiniteQueryUtils.ts function createApiInfiniteQueryUtils() { function useOffsetInfiniteQuery$1(key, queryOptions) { const params = queryOptions.params ?? {}; const queryKey = { [key]: params }; return useOffsetInfiniteQuery({ staleTime: queryOptions.staleTime, isEnabled: queryOptions.isEnabled, limit: queryOptions.limit, queryFn: queryOptions.queryFn, queryKey }); } function useKeysetInfiniteQuery$1(key, queryOptions) { const params = queryOptions.params ?? {}; const queryKey = { [key]: params }; return useKeysetInfiniteQuery({ staleTime: queryOptions.staleTime, isEnabled: queryOptions.isEnabled, limit: queryOptions.limit, queryFn: queryOptions.queryFn, queryKey }); } return { useKeysetInfiniteQuery: useKeysetInfiniteQuery$1, useOffsetInfiniteQuery: useOffsetInfiniteQuery$1 }; } //#endregion //#region src/composables/mutation/mutation.composable.ts function useMutation$1(options) { const isDebug = options.isDebug ?? false; const queryClient = useQueryClient(); async function onSuccess(responseData, params) { if (!options.queryKeysToInvalidate) return; await Promise.all(Object.entries(options.queryKeysToInvalidate).map(async ([queryKey, queryKeyParams]) => { if (!queryKeyParams) { if (isDebug) console.log(`[MUTATION] Invalidating ${queryKey}`); await queryClient.invalidateQueries({ queryKey: [queryKey] }); return; } const qkp = queryKeyParams; const paramEntries = Object.entries(qkp); if (paramEntries.length === 0) { if (isDebug) console.log(`[MUTATION] Invalidating ${queryKey}`); await queryClient.invalidateQueries({ queryKey: [queryKey] }); return; } const paramsWithValues = paramEntries.reduce((acc, [key, value]) => { acc[key] = value(params, responseData); return acc; }, {}); if (isDebug) console.log(`[MUTATION] Invalidating ${queryKey}`, paramsWithValues); await queryClient.invalidateQueries({ exact: false, queryKey: [queryKey, paramsWithValues] }); })); } const mutation = useMutation({ mutationFn: options.queryFn, onSuccess: async (result$1, variables) => { if (!result$1.isOk()) return; const data = result$1.value; if (variables !== void 0 && "params" in variables) { await onSuccess(data, variables.params); return; } await onSuccess(data, {}); } }); async function execute(data) { return await mutation.mutateAsync(data); } const result = computed(() => { if (mutation.isPending.value) return AsyncResult.loading(); if (mutation.isError.value) return AsyncResult.err(mutation.error.value); if (mutation.isSuccess.value && mutation.data.value !== void 0) { const apiResult = mutation.data.value; if (apiResult.isOk()) return AsyncResult.ok(apiResult.value); if (apiResult.isErr()) return AsyncResult.err(apiResult.error); } return AsyncResult.loading(); }); return { isLoading: computed(() => mutation.isPending.value), data: computed(() => { if (mutation.data.value?.isOk()) return mutation.data.value.value; return null; }), execute, result }; } //#endregion //#region src/factory/createApiMutationUtils.ts function createApiMutationUtils() { function useMutation$2(options) { return useMutation$1({ isDebug: options.isDebug, queryFn: options.queryFn, queryKeysToInvalidate: options.queryKeysToInvalidate ?? {} }); } return { useMutation: useMutation$2 }; } //#endregion //#region src/factory/createApiPrefetchInfiniteQueryUtils.ts const DEFAULT_LIMIT = QUERY_CONFIG.limit; function createApiPrefetchInfiniteQueryUtils() { function usePrefetchOffsetInfiniteQuery(key, queryOptions) { const queryKey = [key, queryOptions.params ?? {}]; const queryClient = getQueryClient(); async function execute() { await queryClient.prefetchInfiniteQuery({ staleTime: queryOptions.staleTime ?? QUERY_CONFIG.prefetchStaleTime, getNextPageParam: (lastPage) => { if (lastPage.isErr()) return null; const total = lastPage.value.meta.offset + lastPage.value.meta.limit; if (total >= lastPage.value.meta.total) return null; return total; }, initialPageParam: 0, queryFn: ({ pageParam }) => queryOptions.queryFn({ limit: queryOptions.limit ?? DEFAULT_LIMIT, offset: pageParam ?? 0 }), queryKey }); } return { execute }; } function usePrefetchKeysetInfiniteQuery(key, queryOptions) { const queryKey = [key, queryOptions.params ?? {}]; async function execute() { await getQueryClient().prefetchInfiniteQuery({ staleTime: queryOptions.staleTime ?? QUERY_CONFIG.prefetchStaleTime, getNextPageParam: (lastPage) => { if (lastPage.isErr()) return null; const next = lastPage.value.meta.next; if (next === null || next === void 0) return null; return next; }, initialPageParam: void 0, queryFn: ({ pageParam }) => queryOptions.queryFn({ key: pageParam, limit: queryOptions.limit ?? DEFAULT_LIMIT }), queryKey }); } return { execute }; } return { usePrefetchKeysetInfiniteQuery, usePrefetchOffsetInfiniteQuery }; } //#endregion //#region src/factory/createApiPrefetchQueryUtils.ts function createApiPrefetchQueryUtils() { function usePrefetchQuery(key, queryOptions) { const queryClient = getQueryClient(); const queryKey = [key, queryOptions.params ?? {}]; async function execute() { await queryClient.prefetchQuery({ staleTime: queryOptions.staleTime ?? QUERY_CONFIG.prefetchStaleTime, queryFn: async () => { return AsyncResult.fromResult(await queryOptions.queryFn()); }, queryKey }); } return { execute }; } return { usePrefetchQuery }; } //#endregion //#region src/utils/query-client/queryClient.ts /** * QueryClient utility class for type-safe query operations */ var QueryClient = class { constructor(queryClient) { this.queryClient = queryClient; } /** * Extract the raw entity from AsyncResult data */ extractEntityFromAsyncResult(data) { if (data.isOk()) return data.getValue(); return null; } hasDataArray(value) { return Boolean(value && typeof value === "object" && Array.isArray(value.data)); } isInfiniteDataLike(data) { return Boolean(data && typeof data === "object" && "pages" in data && Array.isArray(data.pages)); } /** * Determine if an item should be updated */ shouldUpdateItem(by, item) { return by(item); } /** * Internal method to update entity based on the "by" option */ updateEntity(by, currentData, value) { if (Array.isArray(currentData)) return currentData.map((item) => { return this.shouldUpdateItem(by, item) ? value(item) : item; }); if (this.shouldUpdateItem(by, currentData)) return value(currentData); return currentData; } updateInfinitePageValue(by, value, pageValue) { if (!this.hasDataArray(pageValue)) return pageValue; return { ...pageValue, data: this.updateEntity(by, pageValue.data, value) }; } /** * Wrap a raw entity in an AsyncResult (preserving ok state) */ wrapEntityInAsyncResult(entity) { return AsyncResult.ok(entity); } get(queryKey, options) { if (Array.isArray(queryKey)) { const data = this.queryClient.getQueryData(queryKey); if (data == null) return null; return this.extractEntityFromAsyncResult(data); } if (options?.isExact ?? false) { const normalizedKey = [queryKey]; const data = this.queryClient.getQueryData(normalizedKey); if (data == null) return null; return this.extractEntityFromAsyncResult(data); } const allQueries = this.queryClient.getQueryCache().findAll({ predicate: (query) => { return query.queryKey[0] === queryKey; } }); const results = []; for (const query of allQueries) { const data = query.state.data; const entity = this.extractEntityFromAsyncResult(data); if (entity !== null) results.push(entity); } return results; } async invalidate(keyOrTuple) { const isSpecific = Array.isArray(keyOrTuple); const key = isSpecific ? keyOrTuple[0] : keyOrTuple; const params = isSpecific ? keyOrTuple[1] : null; await this.queryClient.invalidateQueries({ predicate: (query) => { const queryKey = query.queryKey; if (queryKey[0] !== key) return false; if (isSpecific && params && queryKey[1]) return Object.entries(params).every(([paramKey, paramValue]) => { return queryKey[1][paramKey] === paramValue; }); return !isSpecific; } }); } set(queryKey, entity) { const wrappedData = this.wrapEntityInAsyncResult(entity); const normalizedKey = Array.isArray(queryKey) ? queryKey : [queryKey]; this.queryClient.setQueryData(normalizedKey, wrappedData); } update(keyOrTuple, options) { const by = options.by; const value = options.value; const isSpecific = Array.isArray(keyOrTuple); const key = isSpecific ? keyOrTuple[0] : keyOrTuple; const params = isSpecific ? keyOrTuple[1] : null; const queries = this.queryClient.getQueryCache().findAll({ predicate: (query) => { const queryKey = query.queryKey; if (queryKey[0] !== key) return false; if (isSpecific && params && queryKey[1]) return Object.entries(params).every(([paramKey, paramValue]) => { return queryKey[1][paramKey] === paramValue; }); return !isSpecific; } }); const snapshots = /* @__PURE__ */ new Map(); for (const query of queries) { const currentData = query.state.data; if (this.isInfiniteDataLike(currentData)) { snapshots.set(query.queryKey, currentData); const updatedInfiniteData = { ...currentData, pages: currentData.pages.map((page) => { if (!isAsyncResult(page)) return page; return page.map((pageValue) => this.updateInfinitePageValue(by, value, pageValue)); }) }; this.queryClient.setQueryData(query.queryKey, updatedInfiniteData); continue; } if (!isAsyncResult(currentData)) continue; const rawEntity = this.extractEntityFromAsyncResult(currentData); if (rawEntity === null) continue; snapshots.set(query.queryKey, currentData); const updatedEntity = this.updateEntity(by, rawEntity, value); const wrappedData = this.wrapEntityInAsyncResult(updatedEntity); this.queryClient.setQueryData(query.queryKey, wrappedData); } let rolledBack = false; return { rollback: () => { if (rolledBack) return; rolledBack = true; for (const [queryKey, data] of snapshots) this.queryClient.setQueryData(queryKey, data); } }; } }; //#endregion //#region src/factory/createApiQueryClientUtils.ts function createApiQueryClientUtils() { function useQueryClient$1() { return new QueryClient(getQueryClient()); } return { useQueryClient: useQueryClient$1 }; } //#endregion //#region src/composables/query/query.composable.ts function useQuery$1(options) { const isDebug = options.isDebug ?? false; const query = useQuery({ staleTime: options.staleTime, enabled: options.isEnabled, placeholderData: (data) => data, queryFn: async () => { return AsyncResult.fromResult(await options.queryFn()); }, queryKey: getQueryKey() }); function getQueryKey() { const [queryKey, params] = Object.entries(options.queryKey)[0]; if (isDebug) console.debug(`Create query with key ${queryKey}`, params); return [queryKey, params]; } async function refetch() { await query.refetch(); } return { isError: computed(() => query.data.value?.isErr() ?? false), isFetching: computed(() => query.isFetching.value), isLoading: computed(() => query.isLoading.value), isSuccess: computed(() => query.data.value?.isOk() ?? false), refetch, result: computed(() => { if (query.isLoading.value) return AsyncResult.loading(); if (query.data.value?.isOk()) return AsyncResult.ok(query.data.value.getValue()); if (query.data.value?.isErr()) return AsyncResult.err(query.data.value.getError()); return AsyncResult.loading(); }) }; } //#endregion //#region src/factory/createApiQueryUtils.ts function createApiQueryUtils() { function useQuery$2(key, queryOptions) { const params = queryOptions.params ?? {}; const queryKey = { [key]: params }; return useQuery$1({ staleTime: queryOptions.staleTime, isDebug: queryOptions.isDebug, isEnabled: queryOptions.isEnabled, queryFn: queryOptions.queryFn, queryKey }); } return { useQuery: useQuery$2 }; } //#endregion //#region src/factory/createApiUtils.ts /** * Factory that creates typed composables based on a user-provided query-keys config. * * Requires `initializeApiUtils(queryClient)` to be called first. * * @example * ```typescript * // In app setup (plugin or main.ts): * initializeApiUtils(queryClient) * * // In your api lib: * export const { useQuery, useMutation, useQueryClient } = createApiUtils<MyQueryKeys>() * ``` */ function createApiUtils() { return { ...createApiQueryUtils(), ...createApiPrefetchQueryUtils(), ...createApiPrefetchInfiniteQueryUtils(), ...createApiInfiniteQueryUtils(), ...createApiMutationUtils(), ...createApiQueryClientUtils() }; } //#endregion //#region src/plugin/apiUtilsPlugin.ts /** * Create a Vue plugin that sets up TanStack Query and initializes API utilities. * * This plugin handles: * - Creating a QueryClient with the provided config * - Installing VueQueryPlugin on the app * - Initializing the global QueryClient for api-utils * * @example * ```typescript * import { apiUtilsPlugin } from '@wisemen/vue-core-api-utils' * import { vueQueryClientConfig } from '@wisemen/vue-core-configs' * * app.use(apiUtilsPlugin(vueQueryClientConfig())) * ``` * * @param config - QueryClient configuration * @returns A Vue plugin that can be used with app.use() */ function apiUtilsPlugin(config) { const queryClient = new QueryClient$1(config); return { install: (app) => { app.use(VueQueryPlugin, { queryClient }); initializeApiUtils(queryClient); } }; } //#endregion //#region src/types/sort.type.ts let SortDirection = /* @__PURE__ */ function(SortDirection$1) { SortDirection$1["ASC"] = "asc"; SortDirection$1["DESC"] = "desc"; return SortDirection$1; }({}); //#endregion //#region src/utils/sort/sort.utils.ts var SortUtil = class { static toDto(sort, sortKeyMap) { return sort.filter((s) => s.direction !== null).map((s) => ({ key: sortKeyMap[s.key], order: s.direction === SortDirection.ASC ? SortDirection.ASC : SortDirection.DESC })); } }; //#endregion export { AsyncResult, AsyncResultErr, AsyncResultLoading, AsyncResultOk, QueryClient, SortDirection, SortUtil, apiUtilsPlugin, createApiUtils, getQueryClient as getTanstackQueryClient, initializeApiUtils, setQueryConfig };