/* eslint-disable @typescript-eslint/no-explicit-any */ import { type InvalidateOptions, type InvalidateQueryFilters, type QueryClient, useQueryClient } from "@tanstack/vue-query" import { type Cause, Effect, type Exit, Option } from "effect-app" import { makeQueryKey, type Req } from "effect-app/client" import type { ClientForOptions, RequestHandler, RequestHandlerWithInput } from "effect-app/client/clientFor" import { tuple } from "effect-app/Function" import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import { computed, type ComputedRef, shallowRef } from "vue" export const getQueryKey = (h: { id: string; options?: ClientForOptions }) => { const key = makeQueryKey(h) const ns = key.filter((_) => _.startsWith("$")) // we invalidate the parent namespace e.g $project/$configuration.get, we invalidate $project // for $project/$configuration/$something.get, we invalidate $project/$configuration const k = ns.length ? ns.length > 1 ? ns.slice(0, ns.length - 1) : ns : undefined if (!k) throw new Error("empty query key for: " + h.id) return k } export function mutationResultToVue( mutationResult: AsyncResult.AsyncResult ): Res { switch (mutationResult._tag) { case "Initial": { return { loading: mutationResult.waiting, data: undefined, error: undefined } } case "Success": { return { loading: false, data: mutationResult.value, error: undefined } } case "Failure": { return { loading: false, data: undefined, error: mutationResult.cause } } } } export interface Res { readonly loading: boolean readonly data: A | undefined readonly error: Cause.Cause | undefined } export function make(self: Effect.Effect) { const result = shallowRef(AsyncResult.initial() as AsyncResult.AsyncResult) const execute = Effect .sync(() => { result.value = AsyncResult.waiting(result.value) }) .pipe( Effect.andThen(self), Effect.exit, Effect.map(AsyncResult.fromExit), Effect.flatMap((r) => Effect.sync(() => result.value = r)) ) const latestSuccess = computed(() => Option.getOrUndefined(AsyncResult.value(result.value))) return tuple(result, latestSuccess, execute) } export interface MutationOptionsBase { /** * By default we invalidate one level of the query key, e.g $project/$configuration.get, we invalidate $project. * This can be overridden by providing a function that returns an array of filters and options. */ queryInvalidation?: (defaultKey: string[], name: string, input?: unknown, output?: Exit.Exit) => { filters?: InvalidateQueryFilters | undefined options?: InvalidateOptions | undefined }[] } /** @deprecated prefer more basic @see MutationOptionsBase and separate useMutation from Command.fn */ export interface MutationOptions extends MutationOptionsBase { /** * Map the handler; cache invalidation is already done in this handler. * This is useful for e.g navigating, as you know caches have already updated. * * @deprecated use `Command.fn` instead of `useMutation*` with `mapHandler` option. */ mapHandler?: (handler: Effect.Effect, input: I) => Effect.Effect } // TODO: more efficient invalidation, including args etc // return Effect.promise(() => queryClient.invalidateQueries({ // predicate: (_) => nses.includes(_.queryKey.filter((_) => _.startsWith("$")).join("/")) // })) /* // const nses: string[] = []` // for (let i = 0; i < ns.length; i++) { // nses.push(ns.slice(0, i + 1).join("/")) // } */ export const asResult: { ( handler: Effect.Effect ): readonly [ComputedRef>, Effect.Effect, never, R>] ( handler: (...args: Args) => Effect.Effect ): readonly [ComputedRef>, (...args: Args) => Effect.Effect, never, R>] } = ( handler: Effect.Effect | ((...args: Args) => Effect.Effect) ) => { const state = shallowRef>(AsyncResult.initial()) const act = Effect.isEffect(handler) ? Effect .sync(() => { state.value = AsyncResult.initial(true) }) .pipe( Effect.andThen(Effect.suspend(() => handler.pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => (state.value = AsyncResult.fromExit(exit)))) ) )) ) : (...args: Args) => Effect .sync(() => { state.value = AsyncResult.initial(true) }) .pipe( Effect.andThen(Effect.suspend(() => handler(...args).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => (state.value = AsyncResult.fromExit(exit)))) ) )) ) return tuple(computed(() => state.value), act) as any } export const invalidateQueries = ( queryClient: QueryClient, self: { id: string; options?: ClientForOptions }, options?: MutationOptionsBase["queryInvalidation"] ) => { const invalidateQueries = ( filters?: InvalidateQueryFilters, options?: InvalidateOptions ) => Effect.currentSpan.pipe( Effect.orElseSucceed(() => null), Effect.flatMap((span) => Effect.promise(() => queryClient.invalidateQueries(filters, { ...options, updateMeta: { span } })) ) ) const invalidateCache = (input: unknown, output: Exit.Exit) => Effect.suspend(() => { const queryKey = getQueryKey(self) if (options) { const opts = options(queryKey, self.id, input, output) if (!opts.length) { return Effect.void } return Effect .andThen( Effect.annotateCurrentSpan({ queryKey, opts }), Effect.forEach(opts, (_) => invalidateQueries(_.filters, _.options), { concurrency: "inherit" }) ) .pipe(Effect.withSpan("client.query.invalidation", {}, { captureStackTrace: false })) } if (!queryKey) return Effect.void return Effect .andThen( Effect.annotateCurrentSpan({ queryKey }), invalidateQueries({ queryKey }) ) .pipe( Effect.tap( // hand over control back to the event loop so that state can be updated.. // TODO: should we do this in general on any mutation, regardless of invalidation? Effect.sleep(0) ), Effect.withSpan("client.query.invalidation", {}, { captureStackTrace: false }) ) }) const handle = (self: Effect.Effect, input?: unknown) => self.pipe( Effect.onExit((exit) => invalidateCache( input, exit ) ) ) return handle } export const makeMutation = () => { const useMutation: { /** * Pass a function that returns an Effect, e.g from a client action * Executes query cache invalidation based on default rules or provided option. */ ( self: RequestHandlerWithInput, options?: MutationOptionsBase ): ((i: I) => Effect.Effect) & { readonly id: Id } /** * Pass an Effect, e.g from a client action * Executes query cache invalidation based on default rules or provided option. */ ( self: RequestHandler, options?: MutationOptionsBase ): Effect.Effect & { readonly id: Id } } = ( self: RequestHandlerWithInput | RequestHandler, options?: MutationOptionsBase ) => { const queryClient = useQueryClient() const handle = invalidateQueries(queryClient, self, options?.queryInvalidation) const handler = self.handler const r = Effect.isEffect(handler) ? handle(handler) : (i: I) => handle(handler(i), i) return Object.assign(r, { id: self.id }) as any } return useMutation } // calling hooks in the body export const useMakeMutation = () => { const queryClient = useQueryClient() const useMutation: { /** * Pass a function that returns an Effect, e.g from a client action * Executes query cache invalidation based on default rules or provided option. */ ( self: RequestHandlerWithInput, options?: MutationOptionsBase ): ((i: I) => Effect.Effect) & { readonly id: Id } /** * Pass an Effect, e.g from a client action * Executes query cache invalidation based on default rules or provided option. */ ( self: RequestHandler, options?: MutationOptionsBase ): Effect.Effect & { readonly id: Id } } = ( self: RequestHandlerWithInput | RequestHandler, options?: MutationOptionsBase ) => { const handle = invalidateQueries(queryClient, self, options?.queryInvalidation) const handler = self.handler const r = Effect.isEffect(handler) ? handle(handler) : (i: I) => handle(handler(i), i) return Object.assign(r, { id: self.id }) as any } return useMutation }