UNPKG

@wisemen/vue-core-api-utils

Version:
708 lines (693 loc) 22.5 kB
import { ResultAsync, err, ok } from "neverthrow"; import { QueryClient as QueryClient$1, VueQueryPlugin, useInfiniteQuery, useMutation as useMutation$1, useQuery as useQuery$1, useQueryClient } from "@tanstack/vue-query"; import { computed } from "vue"; import z from "zod"; //#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/composables/mutation/mutation.composable.ts function useMutation(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$1({ 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/config/config.ts const DEFAULT_LIMIT$2 = 20; const DEFAULT_PREFETCH_STALE_TIME = 60; const QUERY_CONFIG = { prefetchStaleTime: DEFAULT_PREFETCH_STALE_TIME, limit: DEFAULT_LIMIT$2 }; 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$1 = QUERY_CONFIG.limit; function useKeysetInfiniteQuery(key, options) { const queryKey = [key, options.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$1 })); }, queryKey }); 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 = QUERY_CONFIG.limit; function useOffsetInfiniteQuery(key, options) { const queryKey = [key, options.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, offset: pageParam ?? 0 })), queryKey }); 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/composables/query/prefetchKeysetInfiniteQuery.composable.ts function usePrefetchKeysetInfiniteQuery(key, options) { const queryClient = useQueryClient(); const params = options.params; async function execute() { await queryClient.prefetchInfiniteQuery({ staleTime: options.staleTime ?? QUERY_CONFIG.prefetchStaleTime, getNextPageParam: (lastPage) => { if (!lastPage.isOk()) return null; return lastPage.getValue().meta.next ?? null; }, initialPageParam: void 0, queryFn: async ({ pageParam }) => AsyncResult.fromResult(await options.queryFn({ key: pageParam, limit: options.limit ?? QUERY_CONFIG.limit })), queryKey: [key, params] }); } return { execute }; } //#endregion //#region src/composables/query/prefetchOffsetInfiniteQuery.composable.ts function usePrefetchOffsetInfiniteQuery(key, options) { const queryClient = useQueryClient(); const params = options.params; async function execute() { await queryClient.prefetchInfiniteQuery({ staleTime: options.staleTime ?? QUERY_CONFIG.prefetchStaleTime, getNextPageParam: (lastPage) => { if (!lastPage.isOk()) return null; const total = lastPage.getValue().meta.offset + lastPage.getValue().meta.limit; if (total >= lastPage.getValue().meta.total) return null; return total; }, initialPageParam: 0, queryFn: async ({ pageParam }) => AsyncResult.fromResult(await options.queryFn({ limit: options.limit ?? QUERY_CONFIG.limit, offset: pageParam ?? 0 })), queryKey: [key, params] }); } return { execute }; } //#endregion //#region src/composables/query/prefetchQuery.composable.ts function usePrefetchQuery(key, options) { const queryClient = useQueryClient(); const params = options.params; async function execute() { await queryClient.prefetchQuery({ staleTime: options.staleTime ?? QUERY_CONFIG.prefetchStaleTime, queryFn: async () => AsyncResult.fromResult(await options.queryFn()), queryKey: [key, params] }); } return { execute }; } //#endregion //#region src/composables/query/query.composable.ts function useQuery(key, options) { const isDebug = options.isDebug ?? false; const params = options.params; const query = useQuery$1({ staleTime: options.staleTime, enabled: options.isEnabled, placeholderData: (data) => data, queryFn: async () => { return AsyncResult.fromResult(await options.queryFn()); }, queryKey: [key, params] }); if (isDebug) console.debug(`Create query with key ${String(key)}`, 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/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/utils/api-error/apiError.util.ts var ApiErrorUtil = class ApiErrorUtil { static getApiErrorCode(error) { return error.errors?.[0]?.code ?? null; } static getApiErrorMessage(error) { return error.errors?.[0]?.detail ?? null; } static getMessage(error) { return error.errors?.[0]?.detail ?? null; } static handleApiError({ error, message }) { if (ApiErrorUtil.isExpectedApiError(error)) return error; console.error(`Unexpected API error: ${error}`); if (error instanceof Error) return error; return new Error(message ?? "An unknown error occurred"); } static isExpectedApiError(error) { return error?.errors !== void 0; } static isZodError(error) { return error instanceof z.ZodError; } }; //#endregion //#region src/utils/api/api.util.ts var ApiUtil = class ApiUtil { static async fromPromise(promise, message) { return await ResultAsync.fromPromise(promise, (error) => { return ApiErrorUtil.handleApiError({ error, message }); }); } static getKeysetPaginationNextOffset(keysetPaginationMeta) { return keysetPaginationMeta.next?.offset ?? null; } static getResultError(result) { if (result === null) return null; if (!result.isErr()) return null; if (ApiUtil.isAsyncResult(result)) return result.getError(); return result.error; } static isAsyncResult(value) { return value.getResult !== void 0; } static void(result) { if (result.isErr()) return err(result.error); return ok(); } }; //#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/utils/sort/sort.utils.ts var SortDirectionDto = /* @__PURE__ */ function(SortDirectionDto$1) { SortDirectionDto$1["ASC"] = "asc"; SortDirectionDto$1["DESC"] = "desc"; return SortDirectionDto$1; }(SortDirectionDto || {}); var SortUtil = class { static toDto(sort, sortKeyMap) { return sort.filter((s) => s.direction !== null).map((s) => ({ key: sortKeyMap[s.key], order: s.direction === "asc" ? SortDirectionDto.ASC : SortDirectionDto.DESC })); } }; //#endregion export { ApiErrorUtil, ApiUtil, AsyncResult, AsyncResultErr, AsyncResultLoading, AsyncResultOk, QueryClient, SortUtil, apiUtilsPlugin, getQueryClient as getTanstackQueryClient, initializeApiUtils, setQueryConfig, useKeysetInfiniteQuery, useMutation, useOffsetInfiniteQuery, usePrefetchKeysetInfiniteQuery, usePrefetchOffsetInfiniteQuery, usePrefetchQuery, useQuery };