/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { type DefaultError, type Enabled, type InitialDataFunction, type NonUndefinedGuard, type PlaceholderDataFunction, type QueryKey, type QueryObserverOptions, type QueryObserverResult, type RefetchOptions, useQuery as useTanstackQuery, useQueryClient, type UseQueryDefinedReturnType, type UseQueryReturnType } from "@tanstack/vue-query" import { Array, Cause, type Context, Effect, Option, S } from "effect-app" import { makeQueryKey, type Req } from "effect-app/client" import type { RequestHandler, RequestHandlerWithInput } from "effect-app/client/clientFor" import { CauseException, ServiceUnavailableError } from "effect-app/client/errors" import { type Span } from "effect/Tracer" import { isHttpClientError } from "effect/unstable/http/HttpClientError" import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import { computed, type ComputedRef, type MaybeRefOrGetter, ref, shallowRef, watch, type WatchSource } from "vue" import { reportRuntimeError } from "./lib.js" import { makeRunPromise } from "./runtime.js" // we must use interface extends, or we get the dreaded typescript error of isn't portable blabla @tanstack/vue-query/build/modern/types.js // but because how they are dealing with some extends clause, we loose all properties except initialData // so we actually reconstruct the interfaces here from the ground up :/ export interface CustomUseQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey > extends Omit< QueryObserverOptions, "queryKey" | "queryFn" | "initialData" | "enabled" | "placeholderData" > { enabled?: MaybeRefOrGetter | (() => Enabled) } // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type type NonFunctionGuard = T extends Function ? never : T export interface CustomUndefinedInitialQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey > extends CustomUseQueryOptions { initialData?: undefined | InitialDataFunction> | NonUndefinedGuard placeholderData?: | undefined | NonFunctionGuard | PlaceholderDataFunction, TError, NonFunctionGuard, TQueryKey> } export interface CustomDefinedInitialQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey > extends CustomUseQueryOptions { initialData: NonUndefinedGuard | (() => NonUndefinedGuard) placeholderData?: | undefined | NonFunctionGuard | PlaceholderDataFunction, TError, NonFunctionGuard, TQueryKey> } export interface CustomDefinedPlaceholderQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey > extends CustomUseQueryOptions { initialData?: NonUndefinedGuard | (() => NonUndefinedGuard) | undefined placeholderData: | NonFunctionGuard | PlaceholderDataFunction, TError, NonFunctionGuard, TQueryKey> } export const makeQuery = (getRuntime: () => Context.Context) => { const useQuery_: { ( q: | RequestHandlerWithInput | RequestHandler ): { ( arg: I | WatchSource | undefined, options?: CustomUndefinedInitialQueryOptions ): readonly [ ComputedRef>, ComputedRef, (options?: RefetchOptions) => Effect.Effect>, never, never>, UseQueryDefinedReturnType> ] ( arg: I | WatchSource | undefined, options: CustomDefinedInitialQueryOptions ): readonly [ ComputedRef>, ComputedRef, (options?: RefetchOptions) => Effect.Effect>, never, never>, UseQueryDefinedReturnType> ] ( arg: I | WatchSource | undefined, options: CustomDefinedPlaceholderQueryOptions ): readonly [ ComputedRef>, ComputedRef, (options?: RefetchOptions) => Effect.Effect>, never, never>, UseQueryDefinedReturnType> ] } } = ( q: | RequestHandlerWithInput | RequestHandler ) => ( arg: I | WatchSource | undefined, // todo QueryKey type would be [string, ...string[]], but with I it would be [string, ...string[], I] options?: any // TODO ) => { // we wrap into CauseException because we want to keep the full cause of the failure. const runPromise = makeRunPromise(getRuntime()) const arr = arg const req: { value: I } = !arg ? undefined as any : typeof arr === "function" ? ({ get value() { return (arr as any)() } }) : ref(arg) const queryKey = makeQueryKey(q) const projectionHash = (q as { queryKeyProjectionHash?: string }).queryKeyProjectionHash const baseQueryKey = projectionHash === undefined ? queryKey : [...queryKey, projectionHash] const handler = q.handler const defaultOptions = { // we do not want to throw errors, because we turn the success and error responses into a Result type // why don't we turn the error/success response into a Result type before returning to tanstack query? because we want to leverage tanstack query's retry and caching mechanism, which relies on throwing errors to trigger retries, and we don't want to interfere with that by catching the errors too early. // but if we allow tanstack query to throw, it will trigger the error boundary in Vue - via a "watcher callback" error - which we currently report and log, which is not what we want. // TODO: we might want to rethink the strategy of how to handle errors that happen after the initial load. // For suspense, the initial load is captured by the suspense boundary. // For subsequent loads (or non suspense use) we currently are required to use the QueryResult component to conditionally render error/loading/etc. throwOnError: false } const r = useTanstackQuery, TData>( Effect.isEffect(handler) ? { ...defaultOptions, ...options, retry: (retryCount, error) => { if (error instanceof CauseException) { if (!isHttpClientError(error.cause) && !S.is(ServiceUnavailableError)(error.cause)) { return false } } return retryCount < 5 }, queryKey: baseQueryKey, queryFn: ({ meta, signal }) => runPromise( handler .pipe( Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)), Effect.withSpan(`query ${q.id}`, {}, { captureStackTrace: false }), meta?.["span"] ? Effect.withParentSpan(meta["span"] as Span) : (_) => _ ), { signal } ) } : { ...defaultOptions, ...options, retry: (retryCount, error) => { if (error instanceof CauseException) { if (!isHttpClientError(error.cause) && !S.is(ServiceUnavailableError)(error.cause)) { return false } } return retryCount < 5 }, queryKey: projectionHash === undefined ? [...queryKey, req] : [...queryKey, req, projectionHash], queryFn: ({ meta, signal }) => runPromise( handler(req.value) .pipe( Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)), Effect.withSpan(`query ${q.id}`, {}, { captureStackTrace: false }), meta?.["span"] ? Effect.withParentSpan(meta["span"] as Span) : (_) => _ ), { signal } ) } ) const latestSuccess = shallowRef() const result = computed((): AsyncResult.AsyncResult => swrToQuery({ error: r.error.value ?? undefined, data: r.data.value === undefined ? latestSuccess.value : r.data.value, // we fall back to existing data, as tanstack query might loose it when the key changes isValidating: r.isFetching.value }) ) // not using `computed` here as we have a circular dependency watch(result, (value) => latestSuccess.value = Option.getOrUndefined(AsyncResult.value(value)), { immediate: true }) return [ result, computed(() => latestSuccess.value), // one thing to keep in mind is that span will be disconnected as Context does not pass from outside. // TODO: consider how we should handle the Result here which is `QueryObserverResult` // and always ends up in the success channel, even when error.. (options?: RefetchOptions) => Effect.currentSpan.pipe( Effect.orElseSucceed(() => null), Effect.flatMap((span) => Effect.promise(() => r.refetch({ ...options, updateMeta: { span } }))) ), r ] as any } function swrToQuery(r: { error: CauseException | undefined data: A | undefined isValidating: boolean }): AsyncResult.AsyncResult { if (r.error !== undefined) { return AsyncResult.failureWithPrevious( r.error.originalCause, { previous: r.data === undefined ? Option.none() : Option.some(AsyncResult.success(r.data)), waiting: r.isValidating } ) } if (r.data !== undefined) { return AsyncResult.success(r.data, { waiting: r.isValidating }) } return AsyncResult.initial(r.isValidating) } const useQuery: { /** * Effect results are passed to the caller, including errors. * @deprecated use client helpers instead (.query()) */ ( self: RequestHandler ): { // required options, with initialData /** * Effect results are passed to the caller, including errors. */ ( options: CustomDefinedInitialQueryOptions, TData> ): readonly [ ComputedRef>, ComputedRef, (options?: RefetchOptions) => Effect.Effect>>, UseQueryReturnType ] ( options: CustomDefinedPlaceholderQueryOptions ): readonly [ ComputedRef>, ComputedRef, (options?: RefetchOptions) => Effect.Effect>, never, never>, UseQueryDefinedReturnType> ] // optional options, optional A /** * Effect results are passed to the caller, including errors. */ (options?: CustomUndefinedInitialQueryOptions, TData>): readonly [ ComputedRef>, ComputedRef, (options?: RefetchOptions) => Effect.Effect>>, UseQueryReturnType ] } /** * Effect results are passed to the caller, including errors. * @deprecated use client helpers instead (.query()) */ ( self: RequestHandlerWithInput ): { // required options, with initialData /** * Effect results are passed to the caller, including errors. */ ( arg: Arg | WatchSource, options: CustomDefinedInitialQueryOptions, TData> ): readonly [ ComputedRef>, ComputedRef, (options?: RefetchOptions) => Effect.Effect>>, UseQueryReturnType ] // required options, with placeholderData /** * Effect results are passed to the caller, including errors. */ ( arg: Arg | WatchSource, options: CustomDefinedPlaceholderQueryOptions, TData> ): readonly [ ComputedRef>, ComputedRef, (options?: RefetchOptions) => Effect.Effect>>, UseQueryReturnType ] // optional options, optional A /** * Effect results are passed to the caller, including errors. */ ( arg: Arg | WatchSource, options?: CustomUndefinedInitialQueryOptions, TData> ): readonly [ ComputedRef>, ComputedRef, (options?: RefetchOptions) => Effect.Effect>>, UseQueryReturnType ] } } = ( self: any ) => { const q = useQuery_(self) return (argOrOptions?: any, options?: any) => Effect.isEffect(self.handler) ? q(undefined, argOrOptions) : q(argOrOptions, options) } return useQuery } // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface MakeQuery2 extends ReturnType> {} function orPrevious(result: AsyncResult.AsyncResult) { return AsyncResult.isFailure(result) && Option.isSome(result.previousSuccess) ? AsyncResult.success(result.previousSuccess.value, { waiting: result.waiting }) : result } export function composeQueries< R extends Record> >( results: R, renderPreviousOnFailure?: boolean ): AsyncResult.AsyncResult< { [Property in keyof R]: R[Property] extends AsyncResult.AsyncResult ? A : never }, { [Property in keyof R]: R[Property] extends AsyncResult.AsyncResult ? E : never }[keyof R] > { const values = renderPreviousOnFailure ? Object.values(results).map(orPrevious) : Object.values(results) const error = values.find(AsyncResult.isFailure) if (error) { return error } const initial = Array.findFirst(values, (x) => x._tag === "Initial" ? Option.some(x) : Option.none()) if (initial.value !== undefined) { return initial.value } const loading = Array.findFirst(values, (x) => AsyncResult.isInitial(x) && x.waiting ? Option.some(x) : Option.none()) if (loading.value !== undefined) { return loading.value } const isRefreshing = values.some((x) => x.waiting) const r = Object.entries(results).reduce((prev, [key, value]) => { prev[key] = AsyncResult.value(value).value return prev }, {} as any) return AsyncResult.success(r, { waiting: isRefreshing }) } export const useUpdateQuery = () => { const queryClient = useQueryClient() const f: { ( query: RequestHandler, updater: (data: NoInfer) => NoInfer ): void ( query: RequestHandlerWithInput, input: I, updater: (data: NoInfer) => NoInfer ): void } = (query: any, updateOrInput: any, updaterMaybe?: any) => { const updater = updaterMaybe !== undefined ? updaterMaybe : updateOrInput const key = updaterMaybe !== undefined ? [...makeQueryKey(query), updateOrInput] : makeQueryKey(query) const data = queryClient.getQueryData(key) if (data) { queryClient.setQueryData(key, updater) } else { console.warn(`Query data for key ${key} not found`, key) } } return f }