/* 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
}