diff --git a/.changeset/rpc-stream-support.md b/.changeset/rpc-stream-support.md new file mode 100644 index 000000000..dda463c45 --- /dev/null +++ b/.changeset/rpc-stream-support.md @@ -0,0 +1,11 @@ +--- +"effect-app": minor +"@effect-app/infra": minor +--- + +Add Effect RPC `Stream` support to the wrapper. + +- New `Stream` request constructor on `TaggedRequestFor` parallel to `Query`/`Command`. Emits resources with `type: "stream"`. +- Server router (`@effect-app/infra` `routing.ts`) accepts stream resources whose handlers return a `Stream.Stream` (or a function from input to one). Forwards `stream: true` to `Rpc.make` so `RpcSchema.Stream` wrapping is applied. Streams bypass `applyRequestTypeInterruptibility` and the `Effect.withSpan` wrapping (the RPC server adds its own span). +- Client (`apiClientFactory.ts`) detects stream resources, forwards `stream: true` when constructing `RpcGroup`, and exposes the per-request `handler` as a `Stream.Stream` (via `Stream.unwrap` over the `ManagedRuntime` context) instead of an `Effect`. `Invalidation.CommandResponseWithMetaData` continues to apply only to commands. +- New `RequestStreamHandler` / `RequestStreamHandlerWithInput` shapes in `clientFor.ts`; `RequestHandlers` dispatches on `type: "stream"`. diff --git a/packages/effect-app/src/client/apiClientFactory.ts b/packages/effect-app/src/client/apiClientFactory.ts index 57108c254..3b8b64096 100644 --- a/packages/effect-app/src/client/apiClientFactory.ts +++ b/packages/effect-app/src/client/apiClientFactory.ts @@ -5,6 +5,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime" import * as Option from "effect/Option" import * as Predicate from "effect/Predicate" import * as Schema from "effect/Schema" +import * as Stream from "effect/Stream" import * as Struct from "effect/Struct" import { Rpc, RpcClient, RpcGroup, RpcSerialization } from "effect/unstable/rpc" import * as Config from "../Config.js" @@ -41,7 +42,7 @@ export type Req = S.Top & { config?: Record readonly id: string readonly moduleName: string - readonly type: "command" | "query" + readonly type: "command" | "query" | "stream" readonly "~decodingServices"?: unknown } @@ -122,12 +123,15 @@ export const makeRpcGroupFromRequestsAndModuleName = { - return Rpc.make((_ as any)._tag, { - payload: _ as any, - success: (_ as any).type === "command" - ? Invalidation.CommandResponseWithMetaData((_ as any).success) - : (_ as any).success, - error: (_ as any).error + const r = _ as any + const isStream = r.type === "stream" + return Rpc.make(r._tag, { + payload: r, + success: r.type === "command" + ? Invalidation.CommandResponseWithMetaData(r.success) + : r.success, + error: r.error, + ...isStream ? { stream: true as const } : {} }) }) ) @@ -224,40 +228,52 @@ const makeApiClientFactory = Effect const fields = Struct.omit(Request.fields, ["_tag"] as const) const requestAttr = `${meta.moduleName}.${h._tag}` const isCommand = h.type === "command" + const isStream = h.type === "stream" + + const buildEffect = (input: any) => + mr.contextEffect.pipe( + Effect.flatMap((svcs) => { + const rpcEffect = TheClient + .use((client) => + (client as any)[requestAttr]!(Request.make(input)) as Effect.Effect + ) + .pipe( + Effect.provide(layers), + Effect.provide(svcs) + ) + return isCommand ? unwrapCommand(rpcEffect) : rpcEffect + }) + ) + + const buildStream = (input: any) => + Stream.unwrap( + mr.contextEffect.pipe( + Effect.flatMap((svcs) => + TheClient + .useSync((client) => { + const rpcStream = (client as any)[requestAttr]!( + Request.make(input) + ) as Stream.Stream + return rpcStream.pipe( + Stream.provide(layers), + Stream.provide(svcs) + ) + }) + .pipe(Effect.provide(svcs)) + ) + ) + ) + // @ts-expect-error doc prev[cur] = Object.keys(fields).length === 0 ? { - handler: mr.contextEffect.pipe( - Effect.flatMap((svcs) => { - const rpcEffect = TheClient - .use((client) => - (client as any)[requestAttr]!(Request.make({})) as Effect.Effect - ) - .pipe( - Effect.provide(layers), - Effect.provide(svcs) - ) - return isCommand ? unwrapCommand(rpcEffect) : rpcEffect - }) - ), + handler: isStream ? buildStream({}) : buildEffect({}), ...requestMeta } : { - handler: (req: any) => - mr.contextEffect.pipe( - Effect.flatMap((svcs) => { - const rpcEffect = TheClient - .use((client) => - (client as any)[requestAttr]!(Request.make(req)) as Effect.Effect - ) - .pipe( - Effect.provide(layers), - Effect.provide(svcs) - ) - return isCommand ? unwrapCommand(rpcEffect) : rpcEffect - }) - ), - + handler: isStream + ? (req: any) => buildStream(req) + : (req: any) => buildEffect(req), ...requestMeta } diff --git a/packages/effect-app/src/client/clientFor.ts b/packages/effect-app/src/client/clientFor.ts index fa42fe975..8f1797921 100644 --- a/packages/effect-app/src/client/clientFor.ts +++ b/packages/effect-app/src/client/clientFor.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as Record from "effect/Record" +import type * as Stream from "effect/Stream" import type { Path } from "path-parser" import qs from "query-string" import type * as Effect from "../Effect.js" @@ -98,6 +99,20 @@ export interface RequestHandlerWithInput { + handler: Stream.Stream + id: Id + options?: ClientForOptions + Request: Request +} + +export interface RequestStreamHandlerWithInput { + handler: (i: I) => Stream.Stream + id: Id + options?: ClientForOptions + Request: Request +} + // make sure this is exported or d.ts of apiClientFactory breaks?! type ReqDecodingServices = M extends { readonly "~decodingServices": infer DS } ? DS : never @@ -126,20 +141,40 @@ type RequestInput any }> = Normal RequestInputFromMake > -export type RequestHandlers = { - [K in keyof M as M[K] extends Req ? K : never]: IsTagOnly> extends true ? RequestHandler< - S.Schema.Type, - S.Schema.Type | E, - R | ReqDecodingServices, - M[K], - `${ModuleName}.${K & string}` +type RequestHandlerFor = T["type"] extends "stream" + ? IsTagOnly> extends true ? RequestStreamHandler< + S.Schema.Type, + S.Schema.Type | E, + R | ReqDecodingServices, + T, + Id > - : RequestHandlerWithInput< - RequestInput, - S.Schema.Type, - S.Schema.Type | E, - R | ReqDecodingServices, - M[K], - `${ModuleName}.${K & string}` + : RequestStreamHandlerWithInput< + RequestInput, + S.Schema.Type, + S.Schema.Type | E, + R | ReqDecodingServices, + T, + Id + > + : IsTagOnly> extends true ? RequestHandler< + S.Schema.Type, + S.Schema.Type | E, + R | ReqDecodingServices, + T, + Id > + : RequestHandlerWithInput< + RequestInput, + S.Schema.Type, + S.Schema.Type | E, + R | ReqDecodingServices, + T, + Id + > + +export type RequestHandlers = { + [K in keyof M as M[K] extends Req ? K : never]: Extract extends infer T extends Req + ? RequestHandlerFor + : never } diff --git a/packages/effect-app/src/client/makeClient.ts b/packages/effect-app/src/client/makeClient.ts index c83a940cc..b13e94c51 100644 --- a/packages/effect-app/src/client/makeClient.ts +++ b/packages/effect-app/src/client/makeClient.ts @@ -87,7 +87,7 @@ type TaggedRequestForResult< Error extends S.Top, Config, ModuleName extends string, - Type extends "command" | "query", + Type extends "command" | "query" | "stream", Resources = never > = & S.EnhancedClass, {}> @@ -150,7 +150,7 @@ export const makeRpcClient = < return RequestClass } - function makeTaggedRequestWithMeta( + function makeTaggedRequestWithMeta( moduleName: ModuleName, type: Type ) { @@ -344,6 +344,7 @@ export const makeRpcClient = < function TaggedRequestFor(moduleName: ModuleName) { const Query = makeTaggedRequestWithMeta(moduleName, "query") const Command = makeTaggedRequestWithMeta(moduleName, "command") + const Stream = makeTaggedRequestWithMeta(moduleName, "stream") return { moduleName, @@ -356,7 +357,13 @@ export const makeRpcClient = < * Create command request classes for this module. * Commands mutate state and should avoid returning complex read models. */ - Command + Command, + /** + * Create stream request classes for this module. + * Streams produce a Stream of `success` values, may also fail with `error`. + * Handlers must return an `Effect`-compatible Stream rather than an Effect. + */ + Stream } as const } diff --git a/packages/infra/src/api/routing.ts b/packages/infra/src/api/routing.ts index 351f697cf..bb7a2f181 100644 --- a/packages/infra/src/api/routing.ts +++ b/packages/infra/src/api/routing.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-empty-object-type */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Config, Effect, Layer, type NonEmptyReadonlyArray, Predicate, S, type Scope } from "effect-app" +import { Config, Effect, Layer, type NonEmptyReadonlyArray, Predicate, S, type Scope, Stream } from "effect-app" import { getMeta } from "effect-app/client" import { type HttpHeaders } from "effect-app/http" import { Invalidation } from "effect-app/rpc" @@ -25,7 +25,7 @@ export const applyRequestTypeInterruptibility = ( // it's a schema plus some metadata export type AnyRequestModule = S.Top & { _tag: string // unique identifier for the request module - type: "command" | "query" + type: "command" | "query" | "stream" config: any // ? success: S.Top // validates the success response error: S.Top // validates the failure response @@ -73,7 +73,10 @@ interface HandlerBase, headers: HttpHeaders.Headers) => Effect.Effect + handler: ( + req: S.Schema.Type, + headers: HttpHeaders.Headers + ) => Effect.Effect | Stream.Stream } export interface Handler extends @@ -134,6 +137,28 @@ type Match< Scope.Scope > > + + , R2 = never, E = never>( + f: Stream.Stream + ): Handler< + Resource[Key], + RT, + Exclude< + Exclude>, + Scope.Scope + > + > + + , R2 = never, E = never>( + f: (req: S.Schema.Type) => Stream.Stream + ): Handler< + Resource[Key], + RT, + Exclude< + Exclude>, + Scope.Scope + > + > } export type RouteMatcher< @@ -237,10 +262,32 @@ export const makeRouter = < GetEffectContext | ContextProviderA > + type HandlerWithInputStream< + Action extends AnyRequestModule, + RT extends RequestType + > = ( + req: S.Schema.Type + ) => Stream.Stream< + GetSuccessShape, + S.Schema.Type> | S.SchemaError, + GetEffectContext | ContextProviderA + > + + type HandlerStream< + Action extends AnyRequestModule, + RT extends RequestType + > = Stream.Stream< + GetSuccessShape, + S.Schema.Type> | S.SchemaError, + GetEffectContext | ContextProviderA + > + type Handlers = | HandlerWithInputGen | HandlerWithInputEff | HandlerEff + | HandlerWithInputStream + | HandlerStream type HandlersDecoded = Handlers @@ -248,6 +295,8 @@ export const makeRouter = < | { raw: HandlerWithInputGen } | { raw: HandlerWithInputEff } | { raw: HandlerEff } + | { raw: HandlerWithInputStream } + | { raw: HandlerStream } type AnyHandlers = HandlersRaw | HandlersDecoded @@ -267,7 +316,8 @@ export const makeRouter = < // handlerImpl is the actual handler implementation if (handlerImpl[Symbol.toStringTag] === "GeneratorFunction") handlerImpl = Effect.fnUntraced(handlerImpl) const stack = new Error().stack?.split("\n").slice(2).join("\n") - return Effect.isEffect(handlerImpl) + const isValueShape = Effect.isEffect(handlerImpl) || Stream.isStream(handlerImpl) + return isValueShape // oxlint-disable-next-line typescript/no-extraneous-class ? class { static request = rsc[cur] @@ -292,7 +342,8 @@ export const makeRouter = < (handlerImpl: any) => { if (handlerImpl[Symbol.toStringTag] === "GeneratorFunction") handlerImpl = Effect.fnUntraced(handlerImpl) const stack = new Error().stack?.split("\n").slice(2).join("\n") - return Effect.isEffect(handlerImpl) + const isValueShape = Effect.isEffect(handlerImpl) || Stream.isStream(handlerImpl) + return isValueShape // oxlint-disable-next-line typescript/no-extraneous-class ? class { static request = rsc[cur] @@ -330,6 +381,8 @@ export const makeRouter = < Impl[K] extends { raw: any } ? Impl[K]["raw"] extends (...args: any[]) => Effect.Effect ? R : Impl[K]["raw"] extends Effect.Effect ? R + : Impl[K]["raw"] extends (...args: any[]) => Stream.Stream ? R + : Impl[K]["raw"] extends Stream.Stream ? R : Impl[K]["raw"] extends (...args: any[]) => Generator< Yieldable, any, @@ -338,6 +391,8 @@ export const makeRouter = < : never : Impl[K] extends (...args: any[]) => Effect.Effect ? R : Impl[K] extends Effect.Effect ? R + : Impl[K] extends (...args: any[]) => Stream.Stream ? R + : Impl[K] extends Stream.Stream ? R : Impl[K] extends (...args: any[]) => Generator< Yieldable, any, @@ -398,7 +453,12 @@ export const makeRouter = < } as any : resource, (payload: any, headers: any) => { - const effect = (handler.handler(payload, headers) as Effect.Effect).pipe( + const result = handler.handler(payload, headers) + if (Stream.isStream(result)) { + // RpcServer adds its own span via spanPrefix; pass the Stream through. + return result + } + const effect = (result as Effect.Effect).pipe( Effect.withSpan(`Request.${meta.moduleName}.${resource._tag}`, {}, { captureStackTrace: () => handler.stack // capturing the handler stack is the main reason why we are doing the span here }) @@ -429,13 +489,15 @@ export const makeRouter = < const rpcs = RpcGroup .make( ...typedValuesOf(mapped).map(([resource]) => { + const isStream = resource.type === "stream" return Rpc .make(resource._tag, { payload: resource, success: resource.type === "command" ? Invalidation.CommandResponseWithMetaData(resource.success) : resource.success, - error: resource.error + error: resource.error, + ...isStream ? { stream: true as const } : {} }) .annotate(middleware.requestContext, resource.config ?? {}) .annotate(RequestTypeAnnotation, resource.type) diff --git a/packages/infra/test/rpc-e2e-invalidation.test.ts b/packages/infra/test/rpc-e2e-invalidation.test.ts index 6411d849e..70912547c 100644 --- a/packages/infra/test/rpc-e2e-invalidation.test.ts +++ b/packages/infra/test/rpc-e2e-invalidation.test.ts @@ -10,7 +10,7 @@ * Transport is in-memory via `RpcTest.makeClient`, so no HTTP server is needed. */ import { expect, it } from "@effect/vitest" -import { Effect, Layer, Ref } from "effect" +import { Effect, Layer, Ref, Stream } from "effect" import { S } from "effect-app" import { makeInvalidationKeysService } from "effect-app/client" import { InvalidationMiddleware } from "effect-app/middleware" @@ -67,6 +67,23 @@ const E2eRpcs = RpcGroup.make( .make("doWithBothKeys", { success: S.Number }) .annotate(RequestType, "command") .annotate(Invalidation.Invalidates, [StaticKey]) + .middleware(InvalidationMiddleware), + // Stream — no input + Rpc + .make("streamTicks", { + success: S.Number, + stream: true + }) + .annotate(RequestType, "query") + .middleware(InvalidationMiddleware), + // Stream — with input payload + Rpc + .make("streamCountTo", { + payload: S.Struct({ to: S.Number }), + success: S.Number, + stream: true + }) + .annotate(RequestType, "query") .middleware(InvalidationMiddleware) ) @@ -89,7 +106,9 @@ const E2eImplLayer = E2eRpcs.toLayer({ doWithBothKeys: Effect.fnUntraced(function*() { yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey)) return 99 - }) + }), + streamTicks: () => Stream.fromIterable([1, 2, 3]), + streamCountTo: ({ to }) => Stream.range(1, to) }) const E2eTestLayer = Layer.merge(E2eImplLayer, InvalidationMiddlewareLive) @@ -176,6 +195,24 @@ it.live( }, Effect.provide(E2eTestLayer)) ) +it.live( + "stream RPC without input emits all values", + Effect.fnUntraced(function*() { + const client = yield* RpcTest.makeClient(E2eRpcs) + const values = yield* Stream.runCollect(client.streamTicks()) + expect(values).toStrictEqual([1, 2, 3]) + }, Effect.provide(E2eTestLayer)) +) + +it.live( + "stream RPC with input emits values driven by payload", + Effect.fnUntraced(function*() { + const client = yield* RpcTest.makeClient(E2eRpcs) + const values = yield* Stream.runCollect(client.streamCountTo({ to: 4 })) + expect(values).toStrictEqual([1, 2, 3, 4]) + }, Effect.provide(E2eTestLayer)) +) + it.live( "per-request isolation: each command call has a fresh InvalidationSet", Effect.fnUntraced(function*() { diff --git a/packages/infra/test/rpc-stream-fullstack.test.ts b/packages/infra/test/rpc-stream-fullstack.test.ts new file mode 100644 index 000000000..3dba97532 --- /dev/null +++ b/packages/infra/test/rpc-stream-fullstack.test.ts @@ -0,0 +1,181 @@ +/** + * Full-stack stream test exercising the entire wrapper: + * resources (TaggedRequestFor) + * → controllers (Router(...)({ effect })) + * → router (makeRouter / matchAll) + * → api ClientFactory (ApiClientFactory.makeFor) + * + * Server runs over real HTTP (NodeHttpServer on a loopback port). Client uses + * FetchHttpClient through ApiClientFactory. This covers the wrapper-level + * `Stream` request constructor end-to-end. + */ +import { NodeHttpServer } from "@effect/platform-node" +import { expect, it } from "@effect/vitest" +import { Effect, Layer, Option, Stream } from "effect" +import { S } from "effect-app" +import { ApiClientFactory, makeRpcClient } from "effect-app/client" +import { HttpRouter, HttpServer } from "effect-app/http" +import { DefaultGenericMiddlewares } from "effect-app/middleware" +import { MiddlewareMaker } from "effect-app/rpc" +import { FetchHttpClient } from "effect/unstable/http" +import { RpcSerialization } from "effect/unstable/rpc" +import { createServer } from "http" +import { makeRouter } from "../src/api/routing.js" +import { DefaultGenericMiddlewaresLive } from "../src/api/routing/middleware.js" +import { AllowAnonymous, AllowAnonymousLive, RequestContextMap, RequireRoles, RequireRolesLive, SomeElseMiddleware, SomeElseMiddlewareLive, SomeService, Test, TestLive } from "./fixtures.js" + +// --------------------------------------------------------------------------- +// Middleware (mirrors the boilerplate AppMiddleware shape). +// --------------------------------------------------------------------------- + +class AppMiddleware extends MiddlewareMaker + .Tag()("AppMiddleware", RequestContextMap) + .middleware(RequireRoles, Test) + .middleware(AllowAnonymous) + .middleware(SomeElseMiddleware) + .middleware(...DefaultGenericMiddlewares) +{ + static Default = this.layer.pipe( + Layer.provide([ + RequireRolesLive.pipe(Layer.provide(SomeService.Default)), + AllowAnonymousLive, + TestLive, + SomeElseMiddlewareLive, + DefaultGenericMiddlewaresLive + ]) + ) +} + +const { Router, matchAll } = makeRouter(AppMiddleware) + +// --------------------------------------------------------------------------- +// Resources — Stream with and without payload. +// --------------------------------------------------------------------------- + +const { TaggedRequestFor } = makeRpcClient(RequestContextMap) +const Req = TaggedRequestFor("Streamy") + +class StreamTicks extends Req.Stream()("StreamTicks", {}, { + allowAnonymous: true, + success: S.Number +}) {} + +class StreamCountTo extends Req.Stream()("StreamCountTo", { + to: S.Number +}, { + allowAnonymous: true, + success: S.Number +}) {} + +class StreamRealtime extends Req.Stream()("StreamRealtime", {}, { + allowAnonymous: true, + success: S.Number +}) {} + +const StreamyRsc = { StreamTicks, StreamCountTo, StreamRealtime } + +// --------------------------------------------------------------------------- +// Controllers / router — Stream impls returned from the match callback. +// --------------------------------------------------------------------------- + +const router = Router(StreamyRsc)({ + *effect(match) { + return match({ + StreamTicks: Stream.fromIterable([10, 20, 30]), + StreamCountTo: ({ to }: { readonly to: number }) => + Effect + .gen(function*() { + return Stream.range(1, to) + }) + .pipe(Stream.unwrap), + // emits 3 values 100ms apart so the test can prove element-by-element + // delivery rather than a single batched response + StreamRealtime: Stream.fromIterable([1, 2, 3]).pipe( + Stream.mapEffect((n) => Effect.sleep("100 millis").pipe(Effect.as(n))) + ) + }) + } +}) + +const RpcRouterLayer = matchAll({ router }) + +// --------------------------------------------------------------------------- +// HTTP wiring — real server on a loopback port + FetchHttpClient on the client. +// --------------------------------------------------------------------------- + +// Server binds an ephemeral port (port: 0). The actual URL is read from the +// `HttpServer` service after binding, then fed into `ApiClientFactory.layer` so +// each `it.live` scope gets a fresh server without colliding on a fixed port. +const NodeServerLayer = NodeHttpServer.layer(() => createServer(), { port: 0 }) + +const ServerLayer = HttpRouter + .serve(RpcRouterLayer) + .pipe( + Layer.provide(NodeServerLayer), + Layer.provide(RpcSerialization.layerNdjson) + ) + +const ClientLayer = Layer + .unwrap( + Effect.gen(function*() { + const server = yield* HttpServer.HttpServer + const addr = server.address + if (addr._tag !== "TcpAddress") return yield* Effect.die(new Error("expected TcpAddress")) + const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname + const url = `http://${host}:${addr.port}` + return ApiClientFactory + .layer({ url, headers: Option.none() }) + .pipe(Layer.provide(FetchHttpClient.layer)) + }) + ) + .pipe(Layer.provide(NodeServerLayer)) + +const TestLayer = Layer.mergeAll(ServerLayer, ClientLayer) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +it.live( + "stream resource without input: ApiClientFactory client emits all values", + Effect.fnUntraced(function*() { + const client = yield* ApiClientFactory.makeFor(Layer.empty)(StreamyRsc) + const values = yield* Stream.runCollect(client.StreamTicks.handler) + expect(values).toStrictEqual([10, 20, 30]) + }, Effect.provide(TestLayer)), + { timeout: 10_000 } +) + +it.live( + "stream resource with input: payload drives the emitted values", + Effect.fnUntraced(function*() { + const client = yield* ApiClientFactory.makeFor(Layer.empty)(StreamyRsc) + const values = yield* Stream.runCollect(client.StreamCountTo.handler({ to: 4 })) + expect(values).toStrictEqual([1, 2, 3, 4]) + }, Effect.provide(TestLayer)), + { timeout: 10_000 } +) + +it.live( + "stream resource is delivered element-by-element in real time (not batched)", + Effect.fnUntraced(function*() { + const client = yield* ApiClientFactory.makeFor(Layer.empty)(StreamyRsc) + const start = Date.now() + const arrivals = yield* Stream.runCollect( + client.StreamRealtime.handler.pipe( + Stream.map((n) => ({ n, at: Date.now() - start })) + ) + ) + expect(arrivals.map((_) => _.n)).toStrictEqual([1, 2, 3]) + // server emits each value 100ms after the previous one. If the response + // were batched, deltas would be ~0ms. Allow generous slack for CI jitter + // but require clear separation between consecutive arrivals. + const delta1 = arrivals[1]!.at - arrivals[0]!.at + const delta2 = arrivals[2]!.at - arrivals[1]!.at + expect(delta1).toBeGreaterThan(50) + expect(delta2).toBeGreaterThan(50) + // first element should not be withheld until the whole stream completes + expect(arrivals[0]!.at).toBeLessThan(arrivals[2]!.at - 50) + }, Effect.provide(TestLayer)), + { timeout: 10_000 } +) diff --git a/packages/vue/src/makeClient.ts b/packages/vue/src/makeClient.ts index 787f1617c..c3720f4ea 100644 --- a/packages/vue/src/makeClient.ts +++ b/packages/vue/src/makeClient.ts @@ -697,7 +697,7 @@ export const makeClient = ( if (client[key].Request.type !== "command") { return acc } - const fromRequestConfig = client[key].Request.config?.invalidatesQueries as + const fromRequestConfig = client[key].Request.config?.["invalidatesQueries"] as | InvalidationCallback> | undefined const fromRequest = fromRequestConfig @@ -788,7 +788,7 @@ export const makeClient = ( } : { mutate: ((handler: any) => { - const fromRequestConfig = client[key].Request.config?.invalidatesQueries as + const fromRequestConfig = client[key].Request.config?.["invalidatesQueries"] as | InvalidationCallback> | undefined const fromRequest = fromRequestConfig