/* eslint-disable @typescript-eslint/no-explicit-any */ import { type InvalidateOptions, type InvalidateQueryFilters, isCancelledError, type QueryObserverResult, type RefetchOptions, type UseQueryReturnType } from "@tanstack/vue-query" import { camelCase } from "change-case" import { type Context, Effect, Exit, Hash, type Layer, type ManagedRuntime, S, Struct } from "effect-app" import { type ApiClientFactory, type Req } from "effect-app/client" import type { ExtractModuleName, RequestHandler, RequestHandlers, RequestHandlerWithInput, RequestInputFromMake, RequestsAny } from "effect-app/client/clientFor" import type { InvalidationCallback } from "effect-app/client/makeClient" import type * as ExitResult from "effect/Exit" import { type Fiber } from "effect/Fiber" import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import { type ComputedRef, onBeforeUnmount, ref, type WatchSource } from "vue" import { type Commander, CommanderStatic } from "./commander.js" import { type I18n } from "./intl.js" import { type CommanderResolved, makeUseCommand } from "./makeUseCommand.js" import { makeMutation, type MutationOptionsBase, useMakeMutation } from "./mutate.js" import { type CustomUndefinedInitialQueryOptions, makeQuery } from "./query.js" import { makeRunPromise } from "./runtime.js" import { type Toast } from "./toast.js" const mapHandler = ( handler: Effect.Effect | ((i: I) => Effect.Effect), map: (self: Effect.Effect, i: I) => Effect.Effect ) => Effect.isEffect(handler) ? map(handler, undefined as any) : (i: I) => map(handler(i), i) // TODO: optimize - work from encoded shape directly const projectHandler = ( handler: Effect.Effect | ((i: any) => Effect.Effect), successSchema: S.Top, projectionSchema: S.Top ) => { const encode = S.encodeEffect(successSchema) const decode = S.decodeEffectConcurrently(projectionSchema) return mapHandler(handler, (self) => self.pipe( Effect.flatMap(encode), Effect.flatMap(decode) )) } const projectionSchemaHash = (schema: S.Top) => String(Hash.hash(schema.ast)) export interface CommandRequestExtensions { /** Defines a Command based on this call, taking the `id` of the call as the `id` of the Command. * The Request function will be taken as the first member of the Command, the Command required input will be the Request input. * see Command.wrap for details */ wrap: Commander.CommanderWrap /** Defines a Command based on this call, taking the `id` of the call as the `id` of the Command. * see Command.fn for details */ fn: Commander.CommanderFn } /** my other doc */ export interface RequestExtWithInput< RT, Id extends string, I, A, E, R > extends Commander.CommandContextLocal, CommandRequestExtensions { /** * Send the request to the endpoint and return the raw Effect response. * This does not perform query cache invalidation. */ request: (i: I) => Effect.Effect } export interface RequestExt< RT, Id extends string, A, E, R > extends Commander.CommandContextLocal, Commander.CommanderWrap, CommandRequestExtensions { /** * Send the request to the endpoint and return the raw Effect response. * This does not perform query cache invalidation. */ request: Effect.Effect } export type CommandRequestWithExtensions = Req extends RequestHandlerWithInput ? RequestExtWithInput : Req extends RequestHandler ? RequestExt : never export interface QueryExtensionsWithInput { /** * Send the request to the endpoint and return the raw Effect response. * This does not set up query state tracking. */ request: (i: I) => Effect.Effect } export interface QueryExtensions { /** * Send the request to the endpoint and return the raw Effect response. * This does not set up query state tracking. */ request: Effect.Effect } export type QueryRequestWithExtensions = Req extends RequestHandlerWithInput ? QueryExtensionsWithInput : Req extends RequestHandler ? QueryExtensions : never type QueryHandler = Req extends RequestHandlerWithInput ? Request["type"] extends "query" ? RequestHandlerWithInput : never : Req extends RequestHandler ? Request["type"] extends "query" ? RequestHandler : never : never type CommandHandler = Req extends RequestHandlerWithInput ? Request["type"] extends "command" ? RequestHandlerWithInput : never : Req extends RequestHandler ? Request["type"] extends "command" ? RequestHandler : never : never export interface MutationExtensions { /** Defines a Command based on this mutation, taking the `id` of the mutation as the `id` of the Command. * The Mutation function will be taken as the first member of the Command, the Command required input will be the Mutation input. * see Command.wrap for details */ wrap: Commander.CommanderWrap } /** my other doc */ export interface MutationExtWithInput< RT, Id extends string, I, A, E, R, EA = unknown > extends MutationExtensions { /** * Send the request to the endpoint and return the raw Effect response. * Also invalidates query caches using the request namespace by default. * Namespace invalidation targets parent namespace keys * (for example `$project/$configuration.get` invalidates `$project`). * Override invalidation in client options via `queryInvalidation`. */ (i: I): Effect.Effect project: ( schema: EA extends ProjSchema["Encoded"] ? ProjSchema : never ) => MutationExtWithInput< RT, Id, I, S.Schema.Type, E | S.SchemaError, R | S.Codec.DecodingServices, S.Codec.Encoded > } /** * Send the request to the endpoint and return the raw Effect response. * Also invalidates query caches using the request namespace by default. * Namespace invalidation targets parent namespace keys * (for example `$project/$configuration.get` invalidates `$project`). * Override invalidation in client options via `queryInvalidation`. */ export interface MutationExt< RT, Id extends string, A, E, R, EA = unknown > extends MutationExtensions, Effect.Effect { project: ( schema: EA extends ProjSchema["Encoded"] ? ProjSchema : never ) => MutationExt< RT, Id, S.Schema.Type, E | S.SchemaError, R | S.Codec.DecodingServices, S.Codec.Encoded > } export type MutationWithExtensions = Req extends RequestHandlerWithInput ? MutationExtWithInput> : Req extends RequestHandler ? MutationExt> : never // we don't really care about the RT, as we are in charge of ensuring runtime safety anyway // eslint-disable-next-line unused-imports/no-unused-vars declare const useQuery_: QueryImpl["useQuery"] // eslint-disable-next-line unused-imports/no-unused-vars declare const useSuspenseQuery_: QueryImpl["useSuspenseQuery"] export interface ProjectResult { request: (i: I) => Effect.Effect query: Exclude extends never ? ReturnType> : MissingDependencies & {} suspense: Exclude extends never ? ReturnType> : MissingDependencies & {} } export type QueryProjection = HandlerReq extends RequestHandlerWithInput ? Request["type"] extends "query" ? { project: ( schema: S.Codec.Encoded extends ProjSchema["Encoded"] ? ProjSchema : never ) => ProjectResult< RT, I, S.Schema.Type, E | S.SchemaError, R | S.Codec.DecodingServices, Request, Id > } : {} : HandlerReq extends RequestHandler ? Request["type"] extends "query" ? { project: ( schema: S.Codec.Encoded extends ProjSchema["Encoded"] ? ProjSchema : never ) => ProjectResult< RT, void, S.Schema.Type, E | S.SchemaError, R | S.Codec.DecodingServices, Request, Id > } : {} : {} export interface QueriesWithInput { /** * Read helper for query requests. * Runs as a tracked Vue Query and returns reactive state. * Queries read state and should not be used to mutate it. */ query: ReturnType> // TODO or suspense as Option? /** * Like `.query`, but returns a Promise for setup-time awaiting. * Use this when integrating with Vue Suspense / error boundaries. */ suspense: ReturnType> } export interface QueriesWithoutInput { /** * Read helper for query requests. * Runs as a tracked Vue Query and returns reactive state. * Queries read state and should not be used to mutate it. */ query: ReturnType> // TODO or suspense as Option? /** * Like `.query`, but returns a Promise for setup-time awaiting. * Use this when integrating with Vue Suspense / error boundaries. */ suspense: ReturnType> } export type MissingDependencies = { message: "Dependencies required that are not provided by the runtime" dependencies: Exclude } export type Queries = Req extends RequestHandlerWithInput ? Request["type"] extends "query" ? Exclude extends never ? QueriesWithInput : { query: MissingDependencies & {} suspense: MissingDependencies & {} } : never : Req extends RequestHandler ? Request["type"] extends "query" ? Exclude extends never ? QueriesWithoutInput : { query: MissingDependencies & {}; suspense: MissingDependencies & {} } : never : never const _useMutation = makeMutation() /** * Pass an Effect or a function that returns an Effect, e.g from a client action * Executes query cache invalidation based on default rules or provided option. * adds a span with the mutation id */ export const useMutation: typeof _useMutation = < I, E, A, R, Request extends Req, Name extends string >( self: RequestHandlerWithInput | RequestHandler, options?: MutationOptionsBase ) => Object.assign( mapHandler( _useMutation(self as any, options), Effect.withSpan(`mutation ${self.id}`, {}, { captureStackTrace: false }) ) as any, { id: self.id } ) /** * Pass an Effect or a function that returns an Effect, e.g from a client action * Executes query cache invalidation based on default rules or provided option. * adds a span with the mutation id */ export const useMutationInt = (): typeof _useMutation => { const _useMutation = useMakeMutation() return < I, E, A, R, Request extends Req, Name extends string >( self: RequestHandlerWithInput | RequestHandler, options?: MutationOptionsBase ) => Object.assign( mapHandler( _useMutation(self as any, options), Effect.withSpan(`mutation ${self.id}`, {}, { captureStackTrace: false }) ) as any, { id: self.id } ) } export type ClientFrom = RequestHandlers> export class QueryImpl { constructor(readonly getRuntime: () => Context.Context) { this.useQuery = makeQuery(this.getRuntime) } /** * Effect results are passed to the caller, including errors. * @deprecated use client helpers instead (.query()) */ readonly useQuery: ReturnType> /** * The difference with useQuery is that this function will return a Promise you can await in the Setup, * which ensures that either there always is a latest value, or an error occurs on load. * So that Suspense and error boundaries can be used. * @deprecated use client helpers instead (.suspense()) */ readonly useSuspenseQuery: { /** * The difference with useQuery is that this function will return a Promise you can await in the Setup, * which ensures that either there always is a latest value, or an error occurs on load. * So that Suspense and error boundaries can be used. * @deprecated use client helpers instead (.suspense()) */ < E, A, Request extends Req, Name extends string >( self: RequestHandler ): { /** * The difference with useQuery is that this function will return a Promise you can await in the Setup, * which ensures that either there always is a latest value, or an error occurs on load. * So that Suspense and error boundaries can be used. */ (options?: CustomUndefinedInitialQueryOptions): Promise< readonly [ ComputedRef>, ComputedRef, ( options?: RefetchOptions ) => Effect.Effect>, UseQueryReturnType ] > } /** * The difference with useQuery is that this function will return a Promise you can await in the Setup, * which ensures that either there always is a latest value, or an error occurs on load. * So that Suspense and error boundaries can be used. */ < Arg, E, A, Request extends Req, Name extends string >( self: RequestHandlerWithInput ): { /** * The difference with useQuery is that this function will return a Promise you can await in the Setup, * which ensures that either there always is a latest value, or an error occurs on load. * So that Suspense and error boundaries can be used. */ (arg: Arg | WatchSource, options?: CustomUndefinedInitialQueryOptions): Promise< readonly [ ComputedRef>, ComputedRef, ( options?: RefetchOptions ) => Effect.Effect>, UseQueryReturnType ] > } } = ( self: RequestHandlerWithInput | RequestHandler ) => { const runPromise = makeRunPromise(this.getRuntime()) const q = this.useQuery(self as any) as any return (argOrOptions?: any, options?: any) => { const [resultRef, latestRef, fetch, uqrt] = q(argOrOptions, { ...options, suspense: true } // experimental_prefetchInRender: true } ) const isMounted = ref(true) onBeforeUnmount(() => { isMounted.value = false }) // @effect-diagnostics effect/missingEffectError:off const eff = Effect.gen(function*() { // we want to throw on error so that we can catch cancelled error and skip handling it // what's the difference with just calling `fetch` ? // we will receive a CancelledError which we will have to ignore in our ErrorBoundary, otherwise the user ends up on an error page even if the user e.g cancelled a navigation const r = yield* Effect.tryPromise(() => uqrt.suspense()).pipe( Effect.catchTag("UnknownError", (err) => isCancelledError(err.cause) ? Effect.interrupt : Effect.die(err.cause)) ) if (!isMounted.value) { return yield* Effect.interrupt } const result = resultRef.value if (AsyncResult.isInitial(result)) { console.error("Internal Error: Promise should be resolved already", { self, argOrOptions, options, r, resultRef }) return yield* Effect.die( "Internal Error: Promise should be resolved already" ) } if (AsyncResult.isFailure(result)) { return yield* Exit.failCause(result.cause) } return [resultRef, latestRef, fetch, uqrt] as const }) return runPromise(eff) } } } // somehow mrt.runtimeEffect doesnt work sync, but this workaround works fine? not sure why though as the layers are generally only sync const managedRuntimeRt = (mrt: ManagedRuntime.ManagedRuntime) => mrt.runSync(Effect.context()) type Base = I18n | Toast type Mix = ApiClientFactory | Commander | Base type InvalidationResources = Record> type UnionToIntersection = (U extends unknown ? (arg: U) => void : never) extends ((arg: infer I) => void) ? I : never type CommandInvalidationResources = Req extends { readonly type: "command" readonly "~invalidationResources"?: infer Resources } ? NonNullable extends InvalidationResources ? NonNullable : never : Req extends { readonly type: "command" readonly config?: infer Config } ? Config extends { readonly invalidationResources?: infer LegacyResources } ? NonNullable extends InvalidationResources ? NonNullable : never : Config extends { readonly invalidatesQueries?: InvalidationCallback } ? NonNullable extends InvalidationResources ? NonNullable : never : never : never type InvalidationResourcesForUnion = { [K in keyof M]: CommandInvalidationResources }[keyof M] type InvalidationResourcesFor = [InvalidationResourcesForUnion] extends [never] ? never : UnionToIntersection> extends infer R ? R extends InvalidationResources ? R : never : never type QueryInvalidationFactory = (client: ClientFrom) => QueryInvalidation type StrictResourcesArg = & Actual & Record, never> type ClientForArgs< M extends RequestsAny, Resources extends InvalidationResourcesFor = InvalidationResourcesFor > = [InvalidationResourcesFor] extends [never] ? [ queryInvalidation?: QueryInvalidationFactory, invalidationResources?: StrictResourcesArg< InvalidationResourcesFor, Resources > ] : [ queryInvalidation: QueryInvalidationFactory | undefined, invalidationResources: StrictResourcesArg< InvalidationResourcesFor, Resources > ] export const makeClient = ( // global, but only accessible after startup has completed getBaseMrt: () => ManagedRuntime.ManagedRuntime, clientFor_: ReturnType, rtHooks: Layer.Layer ) => { type RT = RT_ | Mix const getBaseRt = () => managedRuntimeRt(getBaseMrt()) const makeCommand = makeUseCommand(rtHooks) let cmd: Effect.Success const useCommand = () => cmd ??= getBaseMrt().runSync(makeCommand) let m: ReturnType const useMutation = () => m ??= useMutationInt() const query = new QueryImpl(getBaseRt) const useQuery = query.useQuery const useSuspenseQuery = query.useSuspenseQuery const mergeInvalidation = ( a?: MutationOptionsBase["queryInvalidation"], b?: MutationOptionsBase["queryInvalidation"] ): MutationOptionsBase["queryInvalidation"] | undefined => { if (!a && !b) { return undefined } return (defaultKey, name, input, output) => [ ...(a?.(defaultKey, name, input, output) ?? []), ...(b?.(defaultKey, name, input, output) ?? []) ] } const makeQueryResources = (resources: Resources | undefined) => { if (!resources) { return {} as Record> } return resources as Record> } const mapQuery = ( client: ClientFrom ) => { const queries = Struct.keys(client).reduce( (acc, key) => { if (client[key].Request.type !== "query") { return acc } ;(acc as any)[camelCase(key) + "Query"] = Object.assign(useQuery(client[key] as any), { id: client[key].id }) ;(acc as any)[camelCase(key) + "SuspenseQuery"] = Object.assign(useSuspenseQuery(client[key] as any), { id: client[key].id }) return acc }, {} as & { // apparently can't get JSDoc in here.. [ Key in keyof typeof client as QueryHandler extends never ? never : `${ToCamel}Query` ]: Queries>["query"] } // todo: or suspense as an Option? & { // apparently can't get JSDoc in here.. [ Key in keyof typeof client as QueryHandler extends never ? never : `${ToCamel}SuspenseQuery` ]: Queries< RT, QueryHandler >["suspense"] } ) return queries } const mapRequest = ( client: ClientFrom ) => { const Command = useCommand() const mutations = Struct.keys(client).reduce( (acc, key) => { if (client[key].Request.type !== "command") { return acc } const mut = client[key].handler const fn = Command.fn(client[key].id) const wrap = Command.wrap({ mutate: Effect.isEffect(mut) ? () => mut : mut, id: client[key].id }) ;(acc as any)[camelCase(key) + "Request"] = Object.assign( mut, fn, // to get the i18n key etc. { wrap, fn } ) return acc }, {} as { [ Key in keyof typeof client as CommandHandler extends never ? never : `${ToCamel}Request` ]: CommandRequestWithExtensions< RT | RTHooks, CommandHandler > } ) return mutations } const mapMutation = ( client: ClientFrom, queryInvalidation?: (client: ClientFrom) => QueryInvalidation, invalidationResources?: InvalidationResourcesFor ) => { const Command = useCommand() const mutation = useMutation() const invalidation = queryInvalidation?.(client) const queryResources = makeQueryResources(invalidationResources) const mutations = Struct.keys(client).reduce( (acc, key) => { if (client[key].Request.type !== "command") { return acc } const fromRequestConfig = client[key].Request.config?.invalidatesQueries as | InvalidationCallback> | undefined const fromRequest = fromRequestConfig ? ((defaultKey: string[], _name: string, input?: unknown, output?: unknown) => fromRequestConfig(defaultKey, queryResources as never, input as never, output as never).map((entry) => ({ filters: entry.filters as InvalidateQueryFilters | undefined, options: entry.options as InvalidateOptions | undefined }))) : undefined const mergedInvalidation = mergeInvalidation(fromRequest, invalidation?.[key]) const makeProjectedMutation = (handler: any): any => { const mut: any = mutation( handler, mergedInvalidation ? { queryInvalidation: mergedInvalidation } : undefined ) const wrap = Command.wrap({ mutate: Effect.isEffect(mut) ? () => mut : mut, id: client[key].id }) return Object.assign(mut, { wrap, project: (projectionSchema: any) => { const projected = { ...handler, handler: projectHandler(handler.handler, client[key].Request.success, projectionSchema) } return makeProjectedMutation(projected) } }) } ;(acc as any)[camelCase(key) + "Mutation"] = makeProjectedMutation(client[key] as any) return acc }, {} as { [ Key in keyof typeof client as CommandHandler extends never ? never : `${ToCamel}Mutation` ]: MutationWithExtensions< RT | RTHooks, CommandHandler > } ) return mutations } // make available .query, .suspense and .mutate for each operation // and a .helpers with all mutations and queries const mapClient = ( queryInvalidation?: (client: ClientFrom) => QueryInvalidation, invalidationResources?: InvalidationResourcesFor ) => ( client: ClientFrom ) => { const Command = useCommand() const mutation = useMutation() const invalidation = queryInvalidation?.(client) const queryResources = makeQueryResources(invalidationResources) const extended = Struct.keys(client).reduce( (acc, key) => { const requestType = client[key].Request.type const fn = Command.fn(client[key].id) const h_ = client[key].handler const wrapInput = Effect.isEffect(h_) ? () => h_ : (...args: [any]) => h_(...args) const request = Effect.isEffect(h_) ? h_ : wrapInput ;(acc as any)[key] = Object.assign( requestType === "query" ? { ...client[key], request, query: useQuery(client[key] as any), suspense: useSuspenseQuery(client[key] as any), project: (projectionSchema: any) => { const successSchema = client[key].Request.success const projectionHash = projectionSchemaHash(projectionSchema) const projected = projectHandler(h_ as any, successSchema, projectionSchema) const fakeHandler = { handler: projected, id: client[key].id, Request: client[key].Request, options: client[key].options, queryKeyProjectionHash: projectionHash } return { request: projected, query: useQuery(fakeHandler as any), suspense: useSuspenseQuery(fakeHandler as any) } } } : { mutate: ((handler: any) => { const fromRequestConfig = client[key].Request.config?.invalidatesQueries as | InvalidationCallback> | undefined const fromRequest = fromRequestConfig ? ((defaultKey: string[], _name: string, input?: unknown, output?: unknown) => fromRequestConfig(defaultKey, queryResources as never, input as never, output as never).map(( entry ) => ({ filters: entry.filters as InvalidateQueryFilters | undefined, options: entry.options as InvalidateOptions | undefined }))) : undefined const mergedInvalidation = mergeInvalidation(fromRequest, invalidation?.[key]) const makeProjectedMutation = (h: any): any => { const mutate = mutation( h, mergedInvalidation ? { queryInvalidation: mergedInvalidation } : undefined ) as any return Object.assign( mutate, { wrap: Command.wrap({ mutate: Effect.isEffect(mutate) ? () => mutate : mutate, id: client[key].id }), project: (projectionSchema: any) => { const projected = { ...h, handler: projectHandler(h.handler, client[key].Request.success, projectionSchema) } return makeProjectedMutation(projected) } } ) } return makeProjectedMutation(handler) })(client[key] as any), ...client[key], ...fn, // to get the i18n key etc. request, fn, wrap: Command.wrap({ mutate: wrapInput, id: client[key].id }) } ) return acc }, {} as { [Key in keyof typeof client]: & typeof client[Key] & (QueryHandler extends never ? {} : & QueryRequestWithExtensions> & Queries> & QueryProjection>) & (CommandHandler extends never ? {} : CommandRequestWithExtensions>) & (CommandHandler extends never ? {} : { mutate: MutationWithExtensions> }) & { Input: typeof client[Key] extends RequestHandlerWithInput ? I : never } } ) return Object.assign(extended, { helpers: { ...mapRequest(client), ...mapMutation(client, queryInvalidation, invalidationResources), ...mapQuery(client) } }) } // TODO: Clean up this delay initialisation messs // TODO; invalidateQueries should perhaps be configured in the Request impl themselves? const clientFor__ = ( m: M, queryInvalidation?: QueryInvalidationFactory, invalidationResources?: InvalidationResourcesFor ) => getBaseMrt().runSync(clientFor_(m).pipe(Effect.map(mapClient(queryInvalidation, invalidationResources)))) // delay client creation until first access // the idea is that we don't need the useNuxtApp().$runtime (only available at later initialisation stage) // until we are at a place where it is available.. const clientFor = < M extends RequestsAny, Resources extends InvalidationResourcesFor = InvalidationResourcesFor >( m: M, ...args: ClientForArgs ) => { const [queryInvalidation, invalidationResources] = args as [ QueryInvalidationFactory | undefined, InvalidationResourcesFor | undefined ] type Client = ReturnType> let client: Client | undefined = undefined const getOrMakeClient = () => (client ??= clientFor__(m, queryInvalidation, invalidationResources)) // initialize on first use.. const proxy = Struct.keys(m).concat(["helpers"]).reduce((acc, key) => { Object.defineProperty(acc, key, { configurable: true, get() { const v = (getOrMakeClient() as any)[key as any] // cache on first use. Object.defineProperty(acc, key, { value: v }) return v } }) return acc }, {} as Client) return proxy } const Command: CommanderResolved = { ...{ // delay initialisation until first use... fn: (...args: [any]) => useCommand().fn(...args), wrap: (...args: [any]) => useCommand().wrap(...args), alt: (...args: [any]) => useCommand().alt(...args), alt2: (...args: [any]) => useCommand().alt2(...args) } as ReturnType, ...CommanderStatic } return { Command, useCommand, clientFor } } export type QueryInvalidation = { [K in keyof M]?: ( defaultKey: string[], name: string, input?: unknown, output?: ExitResult.Exit ) => { filters?: InvalidateQueryFilters | undefined options?: InvalidateOptions | undefined }[] } export type ToCamel = S extends string ? S extends `${infer Head}_${infer Tail}` ? `${Uncapitalize}${Capitalize>}` : Uncapitalize : never export interface CommandBase { handle: (input: I) => A waiting: boolean blocked: boolean allowed: boolean action: string label: string } export interface EffectCommand extends CommandBase> {} export interface CommandFromRequest any }, A = unknown, E = unknown> extends EffectCommand, A, E> {}