@wisemen/vue-core-api-utils
Version:
773 lines (755 loc) • 23.9 kB
JavaScript
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 };