import type * as Exit from "effect/Exit" import * as SchemaTransformation from "effect/SchemaTransformation" import { type GetContextConfig, type RpcContextMap } from "../rpc/RpcContextMap.js" import * as S from "../Schema.js" import { AST } from "../Schema.js" import type { ClientForOptions } from "./clientFor.js" /** * Minimal structural shape for an rpc-client middleware tag. * Captures only what `makeRpcClient` and routing/client factory consume from it * (no `Default` layer required — that is provided to `makeRouter`). */ export interface ClientMiddleware> { readonly requestContextMap: RequestContextMap readonly requestContext: unknown readonly provides?: unknown readonly requires?: unknown } const merge = (a: any, b: Array) => a !== undefined && b.length ? S.Union([a, ...b]) : a !== undefined ? a : b.length ? S.Union(b) : S.Never /** * Whatever the input, we will only decode or encode to void */ export const ForceVoid = S .declare((_: unknown): _ is unknown => true) .pipe( S.decodeTo(S.Any, SchemaTransformation.transform({ decode: () => void 0, encode: () => void 0 })) ) type SchemaOrFields = T extends S.Top ? T : T extends S.Struct.Fields ? S.Struct : S.Void type TaggedRequestSchema = S.Struct< { readonly _tag: S.tag } & Payload > type QueryOnlyRequests = { [K in keyof Resource as Resource[K] extends { readonly type: "query" } ? K : never]: Resource[K] } type QueryOnlyResources = { [K in keyof Resources]: QueryOnlyRequests } type InputFromPayload = keyof Payload extends never ? void : S.Schema.Type> type OutputFromSuccess = Success extends typeof ForceVoid ? void : S.Schema.Type type InvalidationResources = Record> /** * An invalidation instruction returned from `invalidatesQueries`. One of: * - a raw query key (`ReadonlyArray`) * - an RPC handler object (`{ id, options? }`) — query key derived from `id` * - the raw `{ filters, options }` tanstack-query shape * * `Filters` / `Options` are widened to `Record` by default so * the effect-app core has no dependency on `@tanstack/vue-query`. The vue * adapter narrows them via the `InvalidationEntry` alias. */ export type InvalidateQueryInstruction< Filters = Record, Options = Record > = | ReadonlyArray | { readonly id: string readonly options?: ClientForOptions } | { readonly filters?: Filters readonly options?: Options } export type InvalidationCallback = ( queryKey: readonly string[], resources: QueryOnlyResources, ...args: [Input] extends [void] ? [exit: Exit.Exit] : [input: Input, exit: Exit.Exit] ) => ReadonlyArray export type InvalidationConfig = { readonly invalidatesQueries: InvalidationCallback readonly invalidationResources?: Resources } type InvalidationConfigForCommand< Resources, Payload extends S.Struct.Fields, Success extends S.Top, Error extends S.Top > = InvalidationConfig< Resources, InputFromPayload, OutputFromSuccess, S.Schema.Type > export const configureInvalidation = () => ( invalidatesQueries: InvalidationCallback, NoInfer, NoInfer> ): InvalidationConfig => ({ invalidatesQueries }) export const configureInvalidationCallback = () => ( invalidatesQueries: InvalidationCallback, NoInfer, NoInfer> ): InvalidationCallback => invalidatesQueries export const configureInvalidationResources = () => ({}) as Pick, "invalidationResources"> type TaggedRequestForResult< Self, Tag extends string, Payload extends S.Struct.Fields, Success extends S.Top, Error extends S.Top, Config, ModuleName extends string, Type extends "command" | "query", Stream extends boolean, Resources = never, Final extends S.Top = never, Middleware = unknown > = & S.Opaque, {}> & { readonly fields: TaggedRequestSchema["fields"] readonly _tag: Tag readonly success: Success readonly error: Error readonly config: Config readonly id: `${ModuleName}.${Tag}` readonly moduleName: ModuleName readonly type: Type readonly stream: Stream readonly middleware?: Middleware readonly "~invalidationResources"?: Resources } & ([Final] extends [never] ? {} : { readonly final: Final }) export const makeRpcClient = < Middleware extends ClientMiddleware>, GeneralErrors extends S.Top = never >(middleware: Middleware, generalErrors?: GeneralErrors) => { // Long way around Context/C extends etc to support actual jsdoc from passed in RequestConfig etc... (??) type ServiceMap = { success: S.Top | S.Struct.Fields // SchemaOrFields will make a Schema type out of Struct.Fields error: S.Top | S.Struct.Fields // SchemaOrFields will make a Schema type out of Struct.Fields final?: S.Top | S.Struct.Fields // optional final-value schema for stream requests stream?: boolean // request metadata — stripped from stored config } type RequestConfig = GetContextConfig // Errors raised by RPC middleware (e.g. `NotLoggedInError` from auth) used to // be merged into `resource.error` here so they would surface in the wire // failure schema. That merging is gone — the canonical source for middleware // errors is now the middleware tag attached to the rpc on both server and // client (see `ApiClientFactory.makeFor({ middleware })`). `Rpc.exitSchema` // unions in `rpc.middlewares[*].error` automatically. type MergeError = [GeneralErrors] extends [never] ? SchemaOrFields : S.Union<[SchemaOrFields, GeneralErrors]> type ErrorResult = C extends { error: infer E } ? MergeError : [GeneralErrors] extends [never] ? typeof S.Never : GeneralErrors function makeRequestClass>( tag: Tag, fields: Fields, config?: C ) { const failureSchema = merge( config?.error ? S.isSchema(config.error) ? config.error : S.Struct(config.error) : undefined, [generalErrors].filter(Boolean) ) const successSchema = config?.success ? S.isSchema(config.success) ? AST.isVoid(config.success.ast) ? ForceVoid : config.success : S.Struct(config.success) : ForceVoid const finalConfig = (config as any)?.final const finalSchema = finalConfig && S.isSchema(finalConfig) ? finalConfig : undefined // Strip stream from the stored config — it's request metadata, not handler config const { stream: _stream, ...restConfig } = config ?? ({} as C) const RequestClass = S.Opaque()(S.TaggedStruct(tag, fields)) Object.assign(RequestClass, { _tag: tag, success: successSchema, error: failureSchema, ...(finalSchema !== undefined ? { final: finalSchema } : {}), config: restConfig }) return RequestClass } function makeTaggedRequestWithMeta< ModuleName extends string, Type extends "command" | "query" >( moduleName: ModuleName, type: Type ) { function TaggedRequestWithMeta(): { // ─── stream: true overloads (must come before non-stream so TypeScript picks them) ─────────── < Tag extends string, Payload extends S.Struct.Fields, Success extends S.Top | S.Struct.Fields, Error extends S.Top | S.Struct.Fields, Final extends S.Top | S.Struct.Fields = never, C extends RequestConfig & Record = RequestConfig & Record >( tag: Tag, fields: Payload, config: & Omit & { stream: true; success: Success; error: Error; final?: Final }, invalidatesQueries?: InvalidationCallback< Resources, InputFromPayload, OutputFromSuccess>, S.Schema.Type> > ): TaggedRequestForResult< Self, Tag, Payload, SchemaOrFields, ErrorResult, Omit< & Omit & { success: Success error: Error } & Partial< InvalidationConfigForCommand< Resources, Payload, SchemaOrFields, ErrorResult > >, "success" | "error" | "stream" >, ModuleName, Type, true, Resources, [Final] extends [never] ? never : SchemaOrFields, Middleware > < Tag extends string, Payload extends S.Struct.Fields, Success extends S.Top | S.Struct.Fields, Final extends S.Top | S.Struct.Fields = never, C extends RequestConfig & Record & { error?: never } = & RequestConfig & Record & { error?: never } >( tag: Tag, fields: Payload, config: & Omit & { stream: true; success: Success; final?: Final }, invalidatesQueries?: InvalidationCallback< Resources, InputFromPayload, OutputFromSuccess>, S.Schema.Type> > ): TaggedRequestForResult< Self, Tag, Payload, SchemaOrFields, ErrorResult, Omit< & Omit & { success: Success } & Partial< InvalidationConfigForCommand< Resources, Payload, SchemaOrFields, ErrorResult > >, "success" | "error" | "stream" >, ModuleName, Type, true, Resources, [Final] extends [never] ? never : SchemaOrFields, Middleware > // ─── stream: true without `success` overloads ──────────────────────────────────────────────── < Tag extends string, Payload extends S.Struct.Fields, Error extends S.Top | S.Struct.Fields, C extends RequestConfig & Record & { success?: never } = & RequestConfig & Record & { success?: never } >( tag: Tag, fields: Payload, config: & Omit & { stream: true; error: Error }, invalidatesQueries?: InvalidationCallback< Resources, InputFromPayload, void, S.Schema.Type> > ): TaggedRequestForResult< Self, Tag, Payload, typeof ForceVoid, ErrorResult, Omit< & Omit & { error: Error } & Partial< InvalidationConfigForCommand< Resources, Payload, typeof ForceVoid, ErrorResult > >, "success" | "error" | "stream" >, ModuleName, Type, true, Resources, never, Middleware > < Tag extends string, Payload extends S.Struct.Fields, C extends RequestConfig & Record & { success?: never; error?: never } = & RequestConfig & Record & { success?: never; error?: never } >( tag: Tag, fields: Payload, config: & Omit & { stream: true }, invalidatesQueries?: InvalidationCallback< Resources, InputFromPayload, void, S.Schema.Type> > ): TaggedRequestForResult< Self, Tag, Payload, typeof ForceVoid, ErrorResult, Omit< & Omit & Partial>>, "success" | "error" | "stream" >, ModuleName, Type, true, Resources, never, Middleware > // ─── non-stream overloads ──────────────────────────────────────────────────────────────────── < Tag extends string, Payload extends S.Struct.Fields, Success extends S.Top | S.Struct.Fields, Error extends S.Top | S.Struct.Fields, Final extends S.Top | S.Struct.Fields = never, C extends RequestConfig & Record = RequestConfig & Record >( tag: Tag, fields: Payload, config: & Omit & { success: Success; error: Error; final?: Final }, invalidatesQueries?: InvalidationCallback< Resources, InputFromPayload, OutputFromSuccess>, S.Schema.Type> > ): TaggedRequestForResult< Self, Tag, Payload, SchemaOrFields, ErrorResult, Omit< & Omit & { success: Success error: Error } & Partial< InvalidationConfigForCommand< Resources, Payload, SchemaOrFields, ErrorResult > >, "success" | "error" | "stream" >, ModuleName, Type, false, Resources, [Final] extends [never] ? never : SchemaOrFields, Middleware > < Tag extends string, Payload extends S.Struct.Fields, Success extends S.Top | S.Struct.Fields, Final extends S.Top | S.Struct.Fields = never, C extends RequestConfig & Record & { error?: never } = & RequestConfig & Record & { error?: never } >( tag: Tag, fields: Payload, config: & Omit & { success: Success; final?: Final }, invalidatesQueries?: InvalidationCallback< Resources, InputFromPayload, OutputFromSuccess>, S.Schema.Type> > ): TaggedRequestForResult< Self, Tag, Payload, SchemaOrFields, ErrorResult, Omit< & Omit & { success: Success } & Partial< InvalidationConfigForCommand< Resources, Payload, SchemaOrFields, ErrorResult > >, "success" | "error" | "stream" >, ModuleName, Type, false, Resources, [Final] extends [never] ? never : SchemaOrFields, Middleware > < Tag extends string, Payload extends S.Struct.Fields, Error extends S.Top | S.Struct.Fields, C extends RequestConfig & Record & { success?: never } >( tag: Tag, fields: Payload, config: & Omit & { error: Error }, invalidatesQueries?: InvalidationCallback< Resources, InputFromPayload, void, S.Schema.Type> > ): TaggedRequestForResult< Self, Tag, Payload, typeof ForceVoid, ErrorResult, Omit< & Omit & { error: Error } & Partial< InvalidationConfigForCommand< Resources, Payload, typeof ForceVoid, ErrorResult > >, "success" | "error" | "stream" >, ModuleName, Type, false, Resources, never, Middleware > < Tag extends string, Payload extends S.Struct.Fields, C extends RequestConfig & Record & { success?: never; error?: never } >( tag: Tag, fields: Payload, config: Omit, invalidatesQueries?: InvalidationCallback< Resources, InputFromPayload, void, S.Schema.Type> > ): TaggedRequestForResult< Self, Tag, Payload, typeof ForceVoid, ErrorResult, Omit< & Omit & Partial>>, "success" | "error" | "stream" >, ModuleName, Type, false, Resources, never, Middleware > ( tag: Tag, fields: Payload ): TaggedRequestForResult< Self, Tag, Payload, typeof ForceVoid, ErrorResult<{}>, Record, ModuleName, Type, false, never, never, Middleware > } { return (( tag: Tag, fields: Fields, config?: C, invalidatesQueries?: InvalidationCallback ) => { const isStream = (config as any)?.stream === true const requestConfig = invalidatesQueries === undefined ? config : { ...config, invalidatesQueries } const cls = makeRequestClass(tag, fields, requestConfig) Object.assign(cls, { id: `${moduleName}.${tag}`, moduleName, type, stream: isStream, middleware }) return cls }) as any } return Object.assign(TaggedRequestWithMeta, { moduleName, type } as const) } function TaggedRequestFor(moduleName: ModuleName) { const Query = makeTaggedRequestWithMeta(moduleName, "query") const Command = makeTaggedRequestWithMeta(moduleName, "command") return { moduleName, /** * Create query request classes for this module. * Queries read state and should not mutate server state. * Pass `stream: true` in the config to produce a Stream of `success` values (QueryStream behaviour). */ Query, /** * Create command request classes for this module. * Commands mutate state and should avoid returning complex read models. * Pass `stream: true` in the config to produce a Stream of `success` values (CommandStream behaviour). */ Command } as const } return { TaggedRequestFor } }