diff --git a/.changeset/tagged-request-command-query-split.md b/.changeset/tagged-request-command-query-split.md new file mode 100644 index 0000000000..385aa6bc5d --- /dev/null +++ b/.changeset/tagged-request-command-query-split.md @@ -0,0 +1,8 @@ +--- +"effect-app": patch +"@effect-app/vue": patch +--- + +Split `TaggedRequestFor` into `Query` and `Command` factories, and mark generated request classes with `type: "query" | "command"`. + +Vue client helpers now expose query-only helpers (`query`, `suspense`, `fetch`) for query requests and mutation-only helpers (`mutate`, `fetch`) for command requests. diff --git a/packages/effect-app/src/client/apiClientFactory.ts b/packages/effect-app/src/client/apiClientFactory.ts index ecab7a708b..6452efe33b 100644 --- a/packages/effect-app/src/client/apiClientFactory.ts +++ b/packages/effect-app/src/client/apiClientFactory.ts @@ -39,6 +39,7 @@ export type Req = S.Top & { config?: Record readonly id: string readonly moduleName: string + readonly type: "command" | "query" readonly "~decodingServices"?: unknown } diff --git a/packages/effect-app/src/client/makeClient.ts b/packages/effect-app/src/client/makeClient.ts index 586789590a..36b39ba540 100644 --- a/packages/effect-app/src/client/makeClient.ts +++ b/packages/effect-app/src/client/makeClient.ts @@ -24,7 +24,8 @@ type TaggedRequestForResult< Success extends S.Top, Error extends S.Top, Config, - ModuleName extends string + ModuleName extends string, + Type extends "command" | "query" > = & S.EnhancedClass, {}> & { @@ -36,6 +37,7 @@ type TaggedRequestForResult< readonly "~encodingServices": S.Codec.EncodingServices | S.Codec.EncodingServices readonly id: `${ModuleName}.${Tag}` readonly moduleName: ModuleName + readonly type: Type } export const makeRpcClient = < @@ -84,7 +86,10 @@ export const makeRpcClient = < return RequestClass } - function TaggedRequestFor(moduleName: ModuleName) { + function makeTaggedRequestWithMeta( + moduleName: ModuleName, + type: Type + ) { function TaggedRequestWithMeta(): { ( tag: Tag, @@ -97,7 +102,8 @@ export const makeRpcClient = < SchemaOrFields, ErrorResult, Omit, - ModuleName + ModuleName, + Type > >( tag: Tag, @@ -110,7 +116,8 @@ export const makeRpcClient = < SchemaOrFields, ErrorResult, Omit, - ModuleName + ModuleName, + Type > >( tag: Tag, @@ -123,7 +130,8 @@ export const makeRpcClient = < typeof ForceVoid, ErrorResult, Omit, - ModuleName + ModuleName, + Type > >( tag: Tag, @@ -136,7 +144,8 @@ export const makeRpcClient = < typeof ForceVoid, ErrorResult, Omit, - ModuleName + ModuleName, + Type > ( tag: Tag, @@ -148,7 +157,8 @@ export const makeRpcClient = < typeof ForceVoid, ErrorResult<{}>, Record, - ModuleName + ModuleName, + Type > } { return (( @@ -157,11 +167,30 @@ export const makeRpcClient = < config?: C ) => { const cls = makeRequestClass(tag, fields, config) - Object.assign(cls, { id: `${moduleName}.${tag}`, moduleName }) + Object.assign(cls, { id: `${moduleName}.${tag}`, moduleName, type }) return cls }) as any } - return Object.assign(TaggedRequestWithMeta, { moduleName } as const) + 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. + */ + Query, + /** + * Create command request classes for this module. + * Commands mutate state and should avoid returning complex read models. + */ + Command + } as const } return { diff --git a/packages/effect-app/test/rpc.test.ts b/packages/effect-app/test/rpc.test.ts index c60529419a..bb5f75f199 100644 --- a/packages/effect-app/test/rpc.test.ts +++ b/packages/effect-app/test/rpc.test.ts @@ -11,7 +11,7 @@ export class RequestContextMap extends RpcContextMap.makeMap({ }) {} const { TaggedRequestFor } = makeRpcClient(RequestContextMap) -const TaggedRequest = TaggedRequestFor("Test") +const TaggedRequest = TaggedRequestFor("Test").Query export class Stats extends TaggedRequest()("Stats", {}, { allowedRoles: ["manager"], @@ -26,6 +26,7 @@ export class Stats extends TaggedRequest()("Stats", {}, { declare const _stats: typeof Stats.Type declare const _statsSuccess: typeof Stats.success.Type declare const _statsError: typeof Stats.error.Type +declare const _statsRequestType: typeof Stats.type test("ForceVoid decodes and encodes as void", () => { expect(S.decodeUnknownSync(ForceVoid)(undefined)).toBe(undefined) @@ -42,4 +43,5 @@ test("ForceVoid decodes and encodes as void", () => { readonly newUsersLastWeek: number }>() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<"query">() }) diff --git a/packages/infra/test/controller.test.ts b/packages/infra/test/controller.test.ts index 6116216557..fc9b7d47cf 100644 --- a/packages/infra/test/controller.test.ts +++ b/packages/infra/test/controller.test.ts @@ -204,13 +204,15 @@ export const middleware3 = MiddlewareMaker export const { TaggedRequestFor } = makeRpcClient(RequestContextMap) const Req = TaggedRequestFor("Something") +const Command = Req.Command +const Query = Req.Query -export class Eff extends Req()("Eff", {}, { success: S.Void }) {} -export class Gen extends Req()("Gen", {}) {} +export class Eff extends Command()("Eff", {}, { success: S.Void }) {} +export class Gen extends Command()("Gen", {}) {} expectTypeOf(Eff.error).toEqualTypeOf() -export class DoSomething extends Req()("DoSomething", { +export class DoSomething extends Command()("DoSomething", { id: S.String }, { success: S.Void }) {} @@ -228,11 +230,11 @@ export class DoSomething extends Req()("DoSomething", { // ) // ) -export class GetSomething extends Req()("GetSomething", { +export class GetSomething extends Query()("GetSomething", { id: S.String }, { success: S.String }) {} -export class GetSomething2 extends Req()("GetSomething2", { +export class GetSomething2 extends Query()("GetSomething2", { id: S.String }, { success: S.FiniteFromString }) {} diff --git a/packages/vue/CHANGELOG.md b/packages/vue/CHANGELOG.md index 4c1149bc65..745250fadf 100644 --- a/packages/vue/CHANGELOG.md +++ b/packages/vue/CHANGELOG.md @@ -4,9 +4,9 @@ ### Minor Changes -- 6a3d364: Client entries are now plain objects; use `.fetch` to invoke the request. +- 6a3d364: Client entries are now plain objects; use `.request` to invoke the request. - `client.Xxx` no longer is callable or an `Effect` itself. Call `client.Xxx.fetch(input)` (or `client.Xxx.fetch` for input-less requests) instead. `.mutate`, `.query`, `.suspense`, `.wrap`, and `.fn` are unchanged. + `client.Xxx` no longer is callable or an `Effect` itself. Call `client.Xxx.request(input)` (or `client.Xxx.request` for input-less requests) instead. `.mutate`, `.query`, `.suspense`, `.wrap`, and `.fn` are unchanged. ### Patch Changes diff --git a/packages/vue/src/makeClient.ts b/packages/vue/src/makeClient.ts index 0126ccbff6..414d75b7ef 100644 --- a/packages/vue/src/makeClient.ts +++ b/packages/vue/src/makeClient.ts @@ -21,7 +21,7 @@ const mapHandler = ( map: (self: Effect.Effect, i: I) => Effect.Effect ) => Effect.isEffect(handler) ? map(handler, undefined as any) : (i: I) => map(handler(i), i) -export interface RequestExtensions { +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 */ @@ -39,11 +39,12 @@ export interface RequestExtWithInput< A, E, R -> extends Commander.CommandContextLocal, RequestExtensions { +> extends Commander.CommandContextLocal, CommandRequestExtensions { /** - * Request the endpoint with input + * Send the request to the endpoint and return the raw Effect response. + * This does not perform query cache invalidation. */ - fetch: (i: I) => Effect.Effect + request: (i: I) => Effect.Effect } export interface RequestExt< @@ -55,20 +56,57 @@ export interface RequestExt< > extends Commander.CommandContextLocal, Commander.CommanderWrap, - RequestExtensions + CommandRequestExtensions { /** - * Request the endpoint + * Send the request to the endpoint and return the raw Effect response. + * This does not perform query cache invalidation. */ - fetch: Effect.Effect + request: Effect.Effect } -export type RequestWithExtensions = Req extends +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. @@ -86,17 +124,21 @@ export interface MutationExtWithInput< R > extends MutationExtensions { /** - * Call the endpoint with input - * Invalidate queries based on namespace of this mutation. - * Do not use for queries. + * 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 } /** - * Call the endpoint - * Invalidate queries based on namespace of this mutation. - * Do not use for queries. + * 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, @@ -121,27 +163,29 @@ declare const useSuspenseQuery_: QueryImpl["useSuspenseQuery"] export interface QueriesWithInput { /** - * Effect results are passed to the caller, including errors. + * 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? /** - * 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. + * Like `.query`, but returns a Promise for setup-time awaiting. + * Use this when integrating with Vue Suspense / error boundaries. */ suspense: ReturnType> } export interface QueriesWithoutInput { /** - * Effect results are passed to the caller, including errors. + * 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? /** - * 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. + * Like `.query`, but returns a Promise for setup-time awaiting. + * Use this when integrating with Vue Suspense / error boundaries. */ suspense: ReturnType> } @@ -153,14 +197,16 @@ export type MissingDependencies = { export type Queries = Req extends RequestHandlerWithInput - ? Exclude extends never ? QueriesWithInput - : { - query: MissingDependencies & {} - suspense: MissingDependencies & {} - } + ? Request["type"] extends "query" ? Exclude extends never ? QueriesWithInput + : { + query: MissingDependencies & {} + suspense: MissingDependencies & {} + } + : never : Req extends RequestHandler - ? Exclude extends never ? QueriesWithoutInput - : { query: MissingDependencies & {}; suspense: MissingDependencies & {} } + ? Request["type"] extends "query" ? Exclude extends never ? QueriesWithoutInput + : { query: MissingDependencies & {}; suspense: MissingDependencies & {} } + : never : never const _useMutation = makeMutation() @@ -377,6 +423,9 @@ export const makeClient = ( ) => { 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 }) @@ -388,14 +437,20 @@ export const makeClient = ( {} as & { // apparently can't get JSDoc in here.. - [Key in keyof typeof client as `${ToCamel}Query`]: Queries["query"] + [ + 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 `${ToCamel}SuspenseQuery`]: Queries< + [ + Key in keyof typeof client as QueryHandler extends never ? never + : `${ToCamel}SuspenseQuery` + ]: Queries< RT, - typeof client[Key] + QueryHandler >["suspense"] } ) @@ -408,6 +463,9 @@ export const makeClient = ( 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 }) @@ -419,9 +477,12 @@ export const makeClient = ( return acc }, {} as { - [Key in keyof typeof client as `${ToCamel}Request`]: RequestWithExtensions< + [ + Key in keyof typeof client as CommandHandler extends never ? never + : `${ToCamel}Request` + ]: CommandRequestWithExtensions< RT | RTHooks, - typeof client[Key] + CommandHandler > } ) @@ -435,15 +496,21 @@ export const makeClient = ( const mutation = useMutation() const mutations = Struct.keys(client).reduce( (acc, key) => { + if (client[key].Request.type !== "command") { + return acc + } const mut: any = mutation(client[key] as any) const wrap = Command.wrap({ mutate: Effect.isEffect(mut) ? () => mut : mut, id: client[key].id }) ;(acc as any)[camelCase(key) + "Mutation"] = Object.assign(mut, { wrap }) return acc }, {} as { - [Key in keyof typeof client as `${ToCamel}Mutation`]: MutationWithExtensions< + [ + Key in keyof typeof client as CommandHandler extends never ? never + : `${ToCamel}Mutation` + ]: MutationWithExtensions< RT | RTHooks, - typeof client[Key] + CommandHandler > } ) @@ -463,50 +530,58 @@ export const makeClient = ( const invalidation = queryInvalidation?.(client) const extended = Struct.keys(client).reduce( (acc, key) => { + const requestType = client[key].Request.type const fn = Command.fn(client[key].id) - const mutate = extendM( - mutation( - client[key] as any, - invalidation?.[key] ? { queryInvalidation: invalidation[key] } : undefined - ), - (mutate) => - Object.assign( - mutate, - { - wrap: Command.wrap({ mutate: Effect.isEffect(mutate) ? () => mutate : mutate, id: client[key].id }) - } - ) - ) - const h_ = client[key].handler const wrapInput = Effect.isEffect(h_) ? () => h_ : (...args: [any]) => h_(...args) - const fetch = Effect.isEffect(h_) ? h_ : wrapInput + const request = Effect.isEffect(h_) ? h_ : wrapInput ;(acc as any)[key] = Object.assign( - {}, - client[key], - fn, // to get the i18n key etc. - { - fetch, - mutate, - query: useQuery(client[key] as any), - suspense: useSuspenseQuery(client[key] as any), - wrap: Command.wrap({ mutate: wrapInput, id: client[key].id }), - fn - } + requestType === "query" + ? { + ...client[key], + request, + query: useQuery(client[key] as any), + suspense: useSuspenseQuery(client[key] as any) + } + : { + mutate: extendM( + mutation( + client[key] as any, + invalidation?.[key] ? { queryInvalidation: invalidation[key] } : undefined + ), + (mutate) => + Object.assign( + mutate, + { + wrap: Command.wrap({ + mutate: Effect.isEffect(mutate) ? () => mutate : mutate, + id: client[key].id + }) + } + ) + ), + ...client[key], + ...fn, // to get the i18n key etc. + request, + wrap: Command.wrap({ mutate: wrapInput, id: client[key].id }) + } ) return acc }, {} as { [Key in keyof typeof client]: & typeof client[Key] - & RequestWithExtensions - & { - mutate: MutationWithExtensions - Input: typeof client[Key] extends RequestHandlerWithInput ? I : never - } - & Queries + & (QueryHandler extends never ? {} + : + & QueryRequestWithExtensions> + & Queries>) + & (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), ...mapQuery(client) } }) diff --git a/packages/vue/src/query.ts b/packages/vue/src/query.ts index 9829953a89..e4e97cc976 100644 --- a/packages/vue/src/query.ts +++ b/packages/vue/src/query.ts @@ -127,13 +127,13 @@ export const makeQuery = (getRuntime: () => Context.Context) => { const runPromise = makeRunPromise(getRuntime()) const arr = arg const req: { value: I } = !arg - ? undefined + ? undefined as any : typeof arr === "function" ? ({ get value() { return (arr as any)() } - } as any) + }) : ref(arg) const queryKey = makeQueryKey(q) const handler = q.handler diff --git a/packages/vue/test/makeClient.test.ts b/packages/vue/test/makeClient.test.ts index b4034f4b27..dd7d5af083 100644 --- a/packages/vue/test/makeClient.test.ts +++ b/packages/vue/test/makeClient.test.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { type Effect } from "effect-app" import { makeQueryKey } from "../src/lib.js" import { Something, SomethingElse, SomethingElseReq, SomethingReq, useClient, useExperimental } from "./stubs.js" @@ -9,6 +8,8 @@ it("TaggedRequestFor .moduleName and request .id / .moduleName", () => { expectTypeOf(Something.GetSomething2.moduleName).toEqualTypeOf<"Something">() expectTypeOf(Something.GetSomething2.id).toEqualTypeOf<"Something.GetSomething2">() + expectTypeOf(Something.GetSomething2.type).toEqualTypeOf<"query">() + expectTypeOf(Something.DoSomething.type).toEqualTypeOf<"command">() expectTypeOf(SomethingElse.GetSomething2.moduleName).toEqualTypeOf<"SomethingElse">() expectTypeOf(SomethingElse.GetSomething2.id).toEqualTypeOf<"SomethingElse.GetSomething2">() @@ -63,32 +64,31 @@ it.skip("works", () => { const Command = useExperimental() // just for jsdoc / type testing. - const a0 = client.GetSomething2.fetch(null as any) - const a00 = client.GetSomething2.mutate(null as any) + const a0 = client.GetSomething2.request(null as any) + const a00 = client.DoSomething.mutate(null as any) const a = client.GetSomething2.suspense(null as any) const b = client.GetSomething2.query(null as any) + // @ts-expect-error query requests no longer expose command helpers const e = client.GetSomething2.wrap(null as any) + // @ts-expect-error query requests no longer expose command helpers const f = client.GetSomething2.fn(null as any) - // @ts-expect-error dependencies required that are not provided - const e0 = client.GetSomething2WithDependencies.wrap().handle // not available as we require dependencies not provided by the runtime - // @ts-expect-error dependencies required that are not provided - const e000 = Command.wrap(client.GetSomething2WithDependencies)().handle // not available as we require dependencies not provided by the runtime - const e00 = client.GetSomething2WithDependencies.wrap((_) => _ as Effect.Effect).handle( - null as any - ) - const e0000 = - Command.wrap(client.GetSomething2WithDependencies)((_) => _ as Effect.Effect).handle + // @ts-expect-error query requests no longer expose command helpers + const e0 = client.GetSomething2WithDependencies.wrap + // @ts-expect-error query request does not match Command.wrap mutation signature + const e000 = Command.wrap(client.GetSomething2WithDependencies) // @ts-expect-error dependencies required that are not provided const e1 = client.GetSomething2WithDependencies.suspense(null as any) // @ts-expect-error dependencies required that are not provided const e2 = client.GetSomething2WithDependencies.query(null as any) + // @ts-expect-error query requests no longer expose command helpers const f0 = client.GetSomething2WithDependencies.fn(null as any) - const g = client.GetSomething2.mutate.wrap(null as any) - // @ts-expect-error mutate no longer exposes fn, use client.GetSomething2.fn - const h = client.GetSomething2.mutate.fn(null as any) + const g0 = client.DoSomething.wrap(null as any) + const g = client.DoSomething.mutate.wrap(null as any) + // @ts-expect-error mutate no longer exposes fn, use client.DoSomething.fn + const h = client.DoSomething.mutate.fn(null as any) expect(true).toBe(true) console.log({ @@ -98,13 +98,12 @@ it.skip("works", () => { b, e, e0, - e00, e000, - e0000, e1, e2, f, f0, + g0, g, h }) diff --git a/packages/vue/test/stubs.ts b/packages/vue/test/stubs.ts index be739fd614..166664ec2b 100644 --- a/packages/vue/test/stubs.ts +++ b/packages/vue/test/stubs.ts @@ -95,13 +95,15 @@ export class RequestContextMap extends RpcContextMap.makeMap({}) {} export const { TaggedRequestFor } = makeRpcClient(RequestContextMap) export const SomethingReq = TaggedRequestFor("Something") +const SomethingQuery = SomethingReq.Query +const SomethingCommand = SomethingReq.Command -class SomethingGetSomething2 extends SomethingReq()("GetSomething2", { +class SomethingGetSomething2 extends SomethingQuery()("GetSomething2", { id: S.String }, { success: S.FiniteFromString }) {} class SomethingGetSomething2WithDependencies - extends SomethingReq()("GetSomething2", { + extends SomethingQuery()("GetSomething2", { id: S.String }, { // this is intentilally fake, to simulate a codec that requires a dependency @@ -110,19 +112,25 @@ class SomethingGetSomething2WithDependencies }) {} +class SomethingDoSomething extends SomethingCommand()("DoSomething", { + id: S.String +}, { success: S.FiniteFromString }) {} + export const Something = { GetSomething2: SomethingGetSomething2, - GetSomething2WithDependencies: SomethingGetSomething2WithDependencies + GetSomething2WithDependencies: SomethingGetSomething2WithDependencies, + DoSomething: SomethingDoSomething } export const SomethingElseReq = TaggedRequestFor("SomethingElse") +const SomethingElseQuery = SomethingElseReq.Query -class SomethingElseGetSomething2 extends SomethingElseReq()("GetSomething2", { +class SomethingElseGetSomething2 extends SomethingElseQuery()("GetSomething2", { id: S.String }, { success: S.FiniteFromString }) {} class SomethingElseGetSomething2WithDependencies - extends SomethingElseReq()("GetSomething2", { + extends SomethingElseQuery()("GetSomething2", { id: S.String }, { success: S.FiniteFromString as S.Codec,