@effect-app/vue 4.0.0-beta.181 → 4.0.0-beta.183

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/makeClient.ts CHANGED
@@ -7,16 +7,19 @@ import type { ExtractModuleName, RequestHandler, RequestHandlers, RequestHandler
7
7
  import type { InvalidationCallback } from "effect-app/client/makeClient"
8
8
  import type * as ExitResult from "effect/Exit"
9
9
  import { type Fiber } from "effect/Fiber"
10
+ import * as Stream from "effect/Stream"
10
11
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
11
12
  import { computed, type ComputedRef, onBeforeUnmount, ref, type WatchSource } from "vue"
12
13
  import { type Commander, CommanderStatic, type Progress } from "./commander.js"
13
14
  import { type I18n } from "./intl.js"
14
15
  import { type CommanderResolved, makeUseCommand } from "./makeUseCommand.js"
15
- import { makeMutation, makeStreamMutation, type MutationOptionsBase, useMakeMutation } from "./mutate.js"
16
- import { type CustomUndefinedInitialQueryOptions, makeQuery } from "./query.js"
16
+ import { makeMutation, makeStreamMutation, makeStreamMutation2, type MutationOptionsBase, useMakeMutation } from "./mutate.js"
17
+ import { type CustomUndefinedInitialQueryOptions, makeQuery, makeStreamQuery } from "./query.js"
17
18
  import { makeRunPromise } from "./runtime.js"
18
19
  import { type Toast } from "./toast.js"
19
20
 
21
+ export type { Progress }
22
+
20
23
  const mapHandler = <A, E, R, I = void, A2 = A, E2 = E, R2 = R>(
21
24
  handler: Effect.Effect<A, E, R> | ((i: I) => Effect.Effect<A, E, R>),
22
25
  map: (self: Effect.Effect<A, E, R>, i: I) => Effect.Effect<A2, E2, R2>
@@ -237,33 +240,36 @@ export type MutateStreamCallOptions<A, E> = {
237
240
 
238
241
  /**
239
242
  * The `mutateStream` factory for a stream-type request handler. Always invoke
240
- * (optionally with `{ progress }`) to get a fresh `[resultRef, execute]` tuple
241
- * each call produces a new `ComputedRef` + execute pair so independent invocations
242
- * don't share state. `execute` updates the ref live with each emitted value.
243
- * When the request declares a `final` schema, `execute` resolves with the last
244
- * emitted value typed as `Final`; otherwise it resolves with the success type.
245
- * The factory itself carries the request `id` so it can be passed to
246
- * `Command.fn` / `Command.wrapStream` directly.
243
+ * (optionally with `{ progress }`) to get a fresh callable `execute` each call
244
+ * produces a new state + execute pair so independent invocations don't share
245
+ * state. The callable updates its underlying ref live with each emitted value
246
+ * and carries `id`, plus `running` and `progress` when the factory was called
247
+ * with a `progress` formatter. When the request declares a `final` schema,
248
+ * the callable resolves with the last emitted value typed as `Final`; otherwise
249
+ * it resolves with the success type. The factory itself carries the request
250
+ * `id` so it can be passed to `Command.fn` / `Command.wrapStream` directly.
247
251
  */
248
252
  export type StreamMutationWithExtensions<Req> = Req extends
249
253
  RequestStreamHandlerWithInput<infer I, infer A, infer E, infer R, infer _Request, infer Id, infer Final> ?
250
254
  & ((options?: MutateStreamCallOptions<A, E>) =>
251
- & readonly [ComputedRef<AsyncResult.AsyncResult<A, E>>, (input: I) => Effect.Effect<Final, never, R>]
255
+ & ((input: I) => Effect.Effect<Final, E, R>)
252
256
  & {
253
257
  readonly id: Id
258
+ readonly _streamCallable: true
254
259
  readonly running?: ComputedRef<AsyncResult.AsyncResult<A, E>>
255
260
  readonly progress?: ComputedRef<Progress | undefined>
256
261
  })
257
- & { readonly id: Id }
262
+ & { readonly id: Id; readonly _streamFactory: true }
258
263
  : Req extends RequestStreamHandler<infer A, infer E, infer R, infer _Request, infer Id, infer Final> ?
259
264
  & ((options?: MutateStreamCallOptions<A, E>) =>
260
- & readonly [ComputedRef<AsyncResult.AsyncResult<A, E>>, Effect.Effect<Final, never, R>]
265
+ & Effect.Effect<Final, E, R>
261
266
  & {
262
267
  readonly id: Id
268
+ readonly _streamCallable: true
263
269
  readonly running?: ComputedRef<AsyncResult.AsyncResult<A, E>>
264
270
  readonly progress?: ComputedRef<Progress | undefined>
265
271
  })
266
- & { readonly id: Id }
272
+ & { readonly id: Id; readonly _streamFactory: true }
267
273
  : never
268
274
 
269
275
  /**
@@ -289,11 +295,42 @@ export type StreamFnExtension<RT, Req> = Req extends
289
295
  ? Commander.CommanderFn<RT, Id, Id, undefined>
290
296
  : never
291
297
 
298
+ /**
299
+ * The `streamFn` builder for a stream-type request handler, using the stream-specific overloads.
300
+ */
301
+ export type StreamFnStreamExtension<RT, Req> = Req extends
302
+ RequestStreamHandlerWithInput<infer _I, infer _A, infer _E, infer _R, infer _Request, infer Id, infer _Final>
303
+ ? Commander.StreamGen<RT, Id, Id, undefined> & Commander.NonGenStream<RT, Id, Id, undefined>
304
+ : Req extends RequestStreamHandler<infer _A, infer _E, infer _R, infer _Request, infer Id, infer _Final>
305
+ ? Commander.StreamGen<RT, Id, Id, undefined> & Commander.NonGenStream<RT, Id, Id, undefined>
306
+ : never
307
+
308
+ /**
309
+ * `mutateStream2` factory — like `mutateStream` but returns `Effect<Stream>` per invocation
310
+ * for use with `streamFn` combinators. Handles invalidation via `Stream.ensuring`.
311
+ */
312
+ export type StreamMutation2WithExtensions<RT, Req> = Req extends
313
+ RequestStreamHandlerWithInput<infer I, infer A, infer E, infer R, infer _Request, infer Id, infer _Final> ?
314
+ & ((input: I) => Effect.Effect<Stream.Stream<A, E, R>>)
315
+ & {
316
+ readonly id: Id
317
+ readonly wrapStream: Commander.StreamGen<RT, Id, Id, undefined> & Commander.NonGenStream<RT, Id, Id, undefined>
318
+ }
319
+ : Req extends RequestStreamHandler<infer A, infer E, infer R, infer _Request, infer Id, infer _Final> ?
320
+ & Effect.Effect<Stream.Stream<A, E, R>>
321
+ & {
322
+ readonly id: Id
323
+ readonly wrapStream: Commander.StreamGen<RT, Id, Id, undefined> & Commander.NonGenStream<RT, Id, Id, undefined>
324
+ }
325
+ : never
326
+
292
327
  // we don't really care about the RT, as we are in charge of ensuring runtime safety anyway
293
328
  // eslint-disable-next-line unused-imports/no-unused-vars
294
329
  declare const useQuery_: QueryImpl<any>["useQuery"]
295
330
  // eslint-disable-next-line unused-imports/no-unused-vars
296
331
  declare const useSuspenseQuery_: QueryImpl<any>["useSuspenseQuery"]
332
+ // eslint-disable-next-line unused-imports/no-unused-vars
333
+ declare const useStreamQuery_: QueryImpl<any>["useStreamQuery"]
297
334
 
298
335
  export interface ProjectResult<RT, I, B, E, R, Request extends Req, Id extends string> {
299
336
  request: (i: I) => Effect.Effect<B, E, R>
@@ -384,6 +421,32 @@ export type Queries<RT, Req> = Req extends
384
421
  : never
385
422
  : never
386
423
 
424
+ export interface StreamQueriesWithInput<Request extends Req, Id extends string, I, A, E> {
425
+ /**
426
+ * Stream helper for stream requests.
427
+ * Runs as a tracked Vue Query and returns reactive state with accumulated chunks.
428
+ * Data is an array of all chunks received so far.
429
+ */
430
+ streamQuery: ReturnType<typeof useStreamQuery_<I, E, A, Request, Id>>
431
+ }
432
+ export interface StreamQueriesWithoutInput<Request extends Req, Id extends string, A, E> {
433
+ /**
434
+ * Stream helper for stream requests.
435
+ * Runs as a tracked Vue Query and returns reactive state with accumulated chunks.
436
+ * Data is an array of all chunks received so far.
437
+ */
438
+ streamQuery: ReturnType<typeof useStreamQuery_<E, A, Request, Id>>
439
+ }
440
+
441
+ export type StreamQueries<RT, HandlerReq> = HandlerReq extends
442
+ RequestStreamHandlerWithInput<infer I, infer A, infer E, infer R, infer Request, infer Id, infer _Final>
443
+ ? Exclude<R, RT> extends never ? StreamQueriesWithInput<Request, Id, I, A, E>
444
+ : { streamQuery: MissingDependencies<RT, R> & {} }
445
+ : HandlerReq extends RequestStreamHandler<infer A, infer E, infer R, infer Request, infer Id, infer _Final>
446
+ ? Exclude<R, RT> extends never ? StreamQueriesWithoutInput<Request, Id, A, E>
447
+ : { streamQuery: MissingDependencies<RT, R> & {} }
448
+ : never
449
+
387
450
  const _useMutation = makeMutation()
388
451
 
389
452
  const wrapWithSpan = (self: { id: string; handler: any }, mut: any) => {
@@ -442,6 +505,7 @@ export type ClientFrom<M extends RequestsAny> = RequestHandlers<never, never, M,
442
505
  export class QueryImpl<R> {
443
506
  constructor(readonly getRuntime: () => Context.Context<R>) {
444
507
  this.useQuery = makeQuery(this.getRuntime)
508
+ this.useStreamQuery = makeStreamQuery(this.getRuntime)
445
509
  }
446
510
  /**
447
511
  * Effect results are passed to the caller, including errors.
@@ -449,6 +513,12 @@ export class QueryImpl<R> {
449
513
  */
450
514
  readonly useQuery: ReturnType<typeof makeQuery<R>>
451
515
 
516
+ /**
517
+ * Stream results are accumulated as an array of chunks and returned as reactive state.
518
+ * @deprecated use client helpers instead (.streamQuery())
519
+ */
520
+ readonly useStreamQuery: ReturnType<typeof makeStreamQuery<R>>
521
+
452
522
  /**
453
523
  * The difference with useQuery is that this function will return a Promise you can await in the Setup,
454
524
  * which ensures that either there always is a latest value, or an error occurs on load.
@@ -646,9 +716,13 @@ export const makeClient = <RT_, RTHooks>(
646
716
  let sm: ReturnType<typeof makeStreamMutation>
647
717
  const useStreamMutation = () => sm ??= makeStreamMutation()
648
718
 
719
+ let sm2: ReturnType<typeof makeStreamMutation2>
720
+ const useStreamMutation2 = () => sm2 ??= makeStreamMutation2()
721
+
649
722
  const query = new QueryImpl(getBaseRt)
650
723
  const useQuery = query.useQuery
651
724
  const useSuspenseQuery = query.useSuspenseQuery
725
+ const useStreamQuery = query.useStreamQuery
652
726
 
653
727
  const mergeInvalidation = (
654
728
  a?: MutationOptionsBase["queryInvalidation"],
@@ -692,15 +766,19 @@ export const makeClient = <RT_, RTHooks>(
692
766
  ) => {
693
767
  const queries = Struct.keys(client).reduce(
694
768
  (acc, key) => {
695
- if (client[key].Request.type !== "query") {
696
- return acc
769
+ const requestType = client[key].Request.type
770
+ if (requestType === "query") {
771
+ ;(acc as any)[camelCase(key) + "Query"] = Object.assign(useQuery(client[key] as any), {
772
+ id: client[key].id
773
+ })
774
+ ;(acc as any)[camelCase(key) + "SuspenseQuery"] = Object.assign(useSuspenseQuery(client[key] as any), {
775
+ id: client[key].id
776
+ })
777
+ } else if (requestType === "stream") {
778
+ ;(acc as any)[camelCase(key) + "StreamQuery"] = Object.assign(useStreamQuery(client[key] as any), {
779
+ id: client[key].id
780
+ })
697
781
  }
698
- ;(acc as any)[camelCase(key) + "Query"] = Object.assign(useQuery(client[key] as any), {
699
- id: client[key].id
700
- })
701
- ;(acc as any)[camelCase(key) + "SuspenseQuery"] = Object.assign(useSuspenseQuery(client[key] as any), {
702
- id: client[key].id
703
- })
704
782
  return acc
705
783
  },
706
784
  {} as
@@ -722,6 +800,12 @@ export const makeClient = <RT_, RTHooks>(
722
800
  QueryHandler<typeof client[Key]>
723
801
  >["suspense"]
724
802
  }
803
+ & {
804
+ [
805
+ Key in keyof typeof client as StreamHandler<typeof client[Key]> extends never ? never
806
+ : `${ToCamel<string & Key>}StreamQuery`
807
+ ]: StreamQueries<RT, StreamHandler<typeof client[Key]>>["streamQuery"]
808
+ }
725
809
  )
726
810
  return queries
727
811
  }
@@ -841,20 +925,21 @@ export const makeClient = <RT_, RTHooks>(
841
925
  const mergedInvalidation = mergeInvalidation(fromRequest, invalidation?.[key])
842
926
  const smFactory = Object.assign(
843
927
  (opts?: { progress?: (result: AsyncResult.AsyncResult<any, any>) => Progress | undefined }) => {
844
- const tuple = streamMutation(client[key] as any, mergedInvalidation)
928
+ const [resultRef, execute] = streamMutation(client[key] as any, mergedInvalidation)
845
929
  const extras: {
846
930
  id: string
931
+ _streamCallable: true
847
932
  running?: ComputedRef<AsyncResult.AsyncResult<any, any>>
848
933
  progress?: ComputedRef<Progress | undefined>
849
- } = { id: client[key].id }
934
+ } = { id: client[key].id, _streamCallable: true }
850
935
  if (opts?.progress) {
851
936
  const fmt = opts.progress
852
- extras.running = tuple[0]
853
- extras.progress = computed(() => fmt(tuple[0].value))
937
+ extras.running = resultRef
938
+ extras.progress = computed(() => fmt(resultRef.value))
854
939
  }
855
- return Object.assign(tuple, extras)
940
+ return Object.assign(execute, extras)
856
941
  },
857
- { id: client[key].id }
942
+ { id: client[key].id, _streamFactory: true as const }
858
943
  )
859
944
  ;(acc as any)[camelCase(key) + "Stream"] = Object.assign(smFactory, {
860
945
  fn: Command.fn(client[key].id)
@@ -938,27 +1023,44 @@ export const makeClient = <RT_, RTHooks>(
938
1023
  const mergedInvalidation = mergeInvalidation(fromRequest, invalidation?.[key])
939
1024
  const streamMutFactory = Object.assign(
940
1025
  (opts?: { progress?: (result: AsyncResult.AsyncResult<any, any>) => Progress | undefined }) => {
941
- const tuple = streamMutation(client[key] as any, mergedInvalidation)
1026
+ const [resultRef, execute] = streamMutation(client[key] as any, mergedInvalidation)
942
1027
  const extras: {
943
1028
  id: string
1029
+ _streamCallable: true
944
1030
  running?: ComputedRef<AsyncResult.AsyncResult<any, any>>
945
1031
  progress?: ComputedRef<Progress | undefined>
946
- } = { id: client[key].id }
1032
+ } = { id: client[key].id, _streamCallable: true }
947
1033
  if (opts?.progress) {
948
1034
  const fmt = opts.progress
949
- extras.running = tuple[0]
950
- extras.progress = computed(() => fmt(tuple[0].value))
1035
+ extras.running = resultRef
1036
+ extras.progress = computed(() => fmt(resultRef.value))
951
1037
  }
952
- return Object.assign(tuple, extras)
1038
+ return Object.assign(execute, extras)
953
1039
  },
954
- { id: client[key].id }
1040
+ { id: client[key].id, _streamFactory: true as const }
955
1041
  )
956
1042
  return {
957
1043
  ...client[key],
958
1044
  request: h_,
1045
+ streamQuery: useStreamQuery(client[key] as any),
959
1046
  mutateStream: streamMutFactory,
960
1047
  wrapStream: Command.wrapStream(streamMutFactory),
961
- fn: Command.fn(client[key].id)
1048
+ fn: Command.fn(client[key].id),
1049
+ streamFn: useCommand().streamFn(client[key].id as any) as any,
1050
+ mutateStream2: (() => {
1051
+ const sm2Act = useStreamMutation2()(client[key] as any, mergedInvalidation)
1052
+ const originalHandler = (client[key] as any).handler
1053
+ const sm2Handler = Stream.isStream(originalHandler)
1054
+ ? (_input: any, _ctx: any) => sm2Act
1055
+ : (input: any, _ctx: any) => (sm2Act as (i: any) => any)(input)
1056
+ return Object.assign(sm2Act, {
1057
+ id: client[key].id,
1058
+ wrapStream: (...combinators: any[]) => {
1059
+ const sfn = useCommand().streamFn(client[key].id as any) as any
1060
+ return sfn(sm2Handler, ...combinators)
1061
+ }
1062
+ })
1063
+ })()
962
1064
  }
963
1065
  })()
964
1066
  : {
@@ -1015,6 +1117,8 @@ export const makeClient = <RT_, RTHooks>(
1015
1117
  & QueryRequestWithExtensions<QueryHandler<typeof client[Key]>>
1016
1118
  & Queries<RT, QueryHandler<typeof client[Key]>>
1017
1119
  & QueryProjection<RT, QueryHandler<typeof client[Key]>>)
1120
+ & (StreamHandler<typeof client[Key]> extends never ? {}
1121
+ : StreamQueries<RT, StreamHandler<typeof client[Key]>>)
1018
1122
  & (CommandHandler<typeof client[Key]> extends never ? {}
1019
1123
  : CommandRequestWithExtensions<RT | RTHooks, CommandHandler<typeof client[Key]>>)
1020
1124
  & (CommandHandler<typeof client[Key]> extends never ? {}
@@ -1024,6 +1128,8 @@ export const makeClient = <RT_, RTHooks>(
1024
1128
  mutateStream: StreamMutationWithExtensions<StreamHandler<typeof client[Key]>>
1025
1129
  wrapStream: StreamCommandWithExtensions<RT | RTHooks, StreamHandler<typeof client[Key]>>
1026
1130
  fn: StreamFnExtension<RT | RTHooks, StreamHandler<typeof client[Key]>>
1131
+ streamFn: StreamFnStreamExtension<RT | RTHooks, StreamHandler<typeof client[Key]>>
1132
+ mutateStream2: StreamMutation2WithExtensions<RT | RTHooks, StreamHandler<typeof client[Key]>>
1027
1133
  })
1028
1134
  & { Input: typeof client[Key] extends RequestHandlerWithInput<infer I, any, any, any, any, any> ? I : never }
1029
1135
  }
@@ -1086,6 +1192,7 @@ export const makeClient = <RT_, RTHooks>(
1086
1192
  fn: (...args: [any]) => useCommand().fn(...args),
1087
1193
  wrap: (...args: [any]) => useCommand().wrap(...args),
1088
1194
  wrapStream: (...args: [any]) => useCommand().wrapStream(...args),
1195
+ streamFn: (...args: [any]) => useCommand().streamFn(...args),
1089
1196
  alt: (...args: [any]) => useCommand().alt(...args),
1090
1197
  alt2: (...args: [any]) => useCommand().alt2(...args)
1091
1198
  } as ReturnType<typeof useCommand>,
@@ -1125,6 +1232,8 @@ export interface CommandBase<I = void, A = void> {
1125
1232
  label: string
1126
1233
  /** formatted progress info for current `running` state, when `progress` was supplied */
1127
1234
  progress?: Progress | undefined
1235
+ /** reactive result state, available on stream-backed commands */
1236
+ result?: AsyncResult.AsyncResult<any, any>
1128
1237
  }
1129
1238
 
1130
1239
  export interface EffectCommand<I = void, A = unknown, E = unknown> extends CommandBase<I, Fiber<A, E>> {}
@@ -5,7 +5,9 @@ type X<X> = X
5
5
 
6
6
  // helps retain JSDoc
7
7
  export interface CommanderResolved<RT, RTHooks>
8
- extends X<typeof CommanderStatic>, Pick<CommanderImpl<RT, RTHooks>, "fn" | "wrap" | "wrapStream" | "alt" | "alt2">
8
+ extends
9
+ X<typeof CommanderStatic>,
10
+ Pick<CommanderImpl<RT, RTHooks>, "fn" | "wrap" | "wrapStream" | "streamFn" | "alt" | "alt2">
9
11
  {
10
12
  }
11
13
 
package/src/mutate.ts CHANGED
@@ -414,16 +414,56 @@ export const useMakeMutation = () => {
414
414
  }
415
415
 
416
416
  /**
417
- * Like `makeMutation`, but for stream-type request handlers.
418
- * Returns a `[ref, execute]` tuple where `ref` is a reactive `AsyncResult` updated per
419
- * stream element. Queries are invalidated once when the stream finishes, regardless of
420
- * success or failure.
417
+ * Like `makeStreamMutation`, but returns an `Effect<Stream>` per invocation instead of a
418
+ * `[ref, execute]` tuple. The outer Effect sets up per-invocation invalidation scaffolding
419
+ * and returns a stream that triggers query invalidation via `Stream.ensuring` when it completes.
421
420
  *
422
- * When the request declares a `final` schema, `execute` resolves with the last emitted value
423
- * typed as `Final`; otherwise it resolves with the last emitted value typed as the success type.
421
+ * Use this with `streamFn` / `Command.streamFn(id)(mutateStream2Handler, ...combinators)` so that
422
+ * the command manages its own reactive state internally. Unlike `makeStreamMutation`, no external
423
+ * reactive result ref is created.
424
424
  *
425
425
  * Must be called inside a Vue setup context (uses `useQueryClient` internally).
426
426
  */
427
+ export const makeStreamMutation2 = () => {
428
+ const queryClient = useQueryClient()
429
+
430
+ return (
431
+ self: {
432
+ id: string
433
+ options?: ClientForOptions
434
+ handler: Stream.Stream<any, any, any> | ((i: any) => Stream.Stream<any, any, any>)
435
+ },
436
+ mergedInvalidation?: MutationOptionsBase["queryInvalidation"]
437
+ ) => {
438
+ const invCache = buildInvalidateCache(queryClient, self, mergedInvalidation)
439
+
440
+ const makeInvocationEffect = (input: unknown, source: Stream.Stream<any, any, any>) =>
441
+ Effect.gen(function*() {
442
+ const keysRef = yield* Ref.make<ReadonlyArray<InvalidationKey>>([])
443
+ const invKeys = makeInvalidationKeysService(keysRef, (key) => invCache(input, Exit.succeed(undefined), [key]))
444
+ const lastRef = yield* Ref.make<any>(undefined)
445
+ return source.pipe(
446
+ Stream.provideService(InvalidationKeysFromServer, invKeys),
447
+ Stream.tap((v) => Ref.set(lastRef, v)),
448
+ Stream.ensuring(
449
+ Effect.gen(function*() {
450
+ const lastValue = yield* Ref.get(lastRef)
451
+ const serverKeys = yield* Ref.get(keysRef)
452
+ yield* invCache(input, Exit.succeed(lastValue), serverKeys)
453
+ })
454
+ )
455
+ )
456
+ })
457
+
458
+ const handler = self.handler
459
+ const act = Stream.isStream(handler)
460
+ ? makeInvocationEffect(undefined, handler)
461
+ : (i: any) => makeInvocationEffect(i, (handler as (i: any) => Stream.Stream<any, any, any>)(i))
462
+
463
+ return act
464
+ }
465
+ }
466
+
427
467
  export const makeStreamMutation = () => {
428
468
  const queryClient = useQueryClient()
429
469
 
@@ -437,7 +477,7 @@ export const makeStreamMutation = () => {
437
477
  ) => {
438
478
  const state = shallowRef<AsyncResult.AsyncResult<any, any>>(AsyncResult.initial())
439
479
 
440
- const runStream = (stream: Stream.Stream<any, any, any>, input?: unknown): Effect.Effect<any, never, any> => {
480
+ const runStream = (stream: Stream.Stream<any, any, any>, input?: unknown): Effect.Effect<any, any, any> => {
441
481
  const invCache = buildInvalidateCache(queryClient, self, mergedInvalidation)
442
482
  const keysRef = Ref.makeUnsafe<ReadonlyArray<InvalidationKey>>([])
443
483
  // V3: pass onAdded so each mid-stream metadata chunk triggers query
@@ -476,11 +516,14 @@ export const makeStreamMutation = () => {
476
516
  const lastValue = AsyncResult.isSuccess(current) ? current.value : undefined
477
517
  const invExit = exit._tag === "Success" ? Exit.succeed(lastValue) : exit
478
518
  const serverKeys = Ref.getUnsafe(keysRef)
479
- // Note: when the stream fails, `lastValue` is undefined. The failure is
480
- // communicated via the reactive `state` ref (AsyncResult.failure). The
481
- // execute effect always resolves successfully; callers should inspect the
482
- // ref to distinguish success from failure.
483
- return invCache(input, invExit, serverKeys).pipe(Effect.as(lastValue))
519
+ // Stream failures bubble through the execute effect's typed error
520
+ // channel. The reactive `state` ref still mirrors the failure as
521
+ // `AsyncResult.failure` for live progress UI.
522
+ return invCache(input, invExit, serverKeys).pipe(
523
+ Effect.flatMap(() =>
524
+ exit._tag === "Success" ? Effect.succeed(lastValue) : Effect.failCause(exit.cause)
525
+ )
526
+ )
484
527
  })
485
528
  )
486
529
  )
package/src/query.ts CHANGED
@@ -2,11 +2,16 @@
2
2
  /* eslint-disable @typescript-eslint/no-unsafe-call */
3
3
  /* eslint-disable @typescript-eslint/no-unsafe-return */
4
4
  /* eslint-disable @typescript-eslint/no-unsafe-assignment */
5
- import { type DefaultError, type Enabled, type InitialDataFunction, type NonUndefinedGuard, type PlaceholderDataFunction, type QueryKey, type QueryObserverOptions, type QueryObserverResult, type RefetchOptions, useQuery as useTanstackQuery, useQueryClient, type UseQueryDefinedReturnType, type UseQueryReturnType } from "@tanstack/vue-query"
6
- import { Array, Cause, type Context, Effect, Option, S } from "effect-app"
5
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
6
+ import { type DefaultError, type Enabled, experimental_streamedQuery as streamedQuery, type InitialDataFunction, type NonUndefinedGuard, type PlaceholderDataFunction, type QueryKey, type QueryObserverOptions, type QueryObserverResult, type RefetchOptions, useQuery as useTanstackQuery, useQueryClient, type UseQueryDefinedReturnType, type UseQueryReturnType } from "@tanstack/vue-query"
7
+ import { Array, Cause, type Context, Effect, Exit, Option, S } from "effect-app"
7
8
  import { makeQueryKey, type Req } from "effect-app/client"
8
- import type { RequestHandler, RequestHandlerWithInput } from "effect-app/client/clientFor"
9
+ import type { RequestHandler, RequestHandlerWithInput, RequestStreamHandler, RequestStreamHandlerWithInput } from "effect-app/client/clientFor"
9
10
  import { CauseException, ServiceUnavailableError } from "effect-app/client/errors"
11
+ import * as Channel from "effect/Channel"
12
+ import * as Pull from "effect/Pull"
13
+ import * as Scope from "effect/Scope"
14
+ import type * as Stream from "effect/Stream"
10
15
  import { type Span } from "effect/Tracer"
11
16
  import { isHttpClientError } from "effect/unstable/http/HttpClientError"
12
17
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
@@ -75,6 +80,64 @@ export interface CustomDefinedPlaceholderQueryOptions<
75
80
  | PlaceholderDataFunction<NonFunctionGuard<TQueryData>, TError, NonFunctionGuard<TQueryData>, TQueryKey>
76
81
  }
77
82
 
83
+ function swrToQuery<E, A>(r: {
84
+ error: CauseException<E> | undefined
85
+ data: A | undefined
86
+ isValidating: boolean
87
+ }): AsyncResult.AsyncResult<A, E> {
88
+ if (r.error !== undefined) {
89
+ return AsyncResult.failureWithPrevious(
90
+ r.error.originalCause,
91
+ {
92
+ previous: r.data === undefined ? Option.none() : Option.some(AsyncResult.success(r.data)),
93
+ waiting: r.isValidating
94
+ }
95
+ )
96
+ }
97
+ if (r.data !== undefined) {
98
+ return AsyncResult.success<A, E>(r.data, { waiting: r.isValidating })
99
+ }
100
+
101
+ return AsyncResult.initial(r.isValidating)
102
+ }
103
+
104
+ function streamToAsyncIterableWithCauseException<A, E, R>(
105
+ self: Stream.Stream<A, E, R>,
106
+ context: Context.Context<R>,
107
+ id: string
108
+ ): AsyncIterable<A> {
109
+ return {
110
+ [Symbol.asyncIterator]() {
111
+ const runPromise = Effect.runPromiseWith(context)
112
+ const runPromiseExit = Effect.runPromiseExitWith(context)
113
+ const scope = Scope.makeUnsafe()
114
+ let pull: any
115
+ let currentIter: Iterator<A> | undefined
116
+ return {
117
+ async next(): Promise<IteratorResult<A>> {
118
+ if (currentIter) {
119
+ const next = currentIter.next()
120
+ if (!next.done) return next
121
+ currentIter = undefined
122
+ }
123
+ pull ??= await runPromise(Channel.toPullScoped((self as any).channel, scope))
124
+ const exit = await runPromiseExit(pull)
125
+ if (Exit.isSuccess(exit)) {
126
+ currentIter = (exit.value as any)[Symbol.iterator]()
127
+ return currentIter!.next()
128
+ } else if (Pull.isDoneCause((exit as any).cause)) {
129
+ return { done: true, value: undefined }
130
+ }
131
+ throw new CauseException((exit as any).cause, id)
132
+ },
133
+ return(_) {
134
+ return runPromise(Effect.as(Scope.close(scope, Exit.void), { done: true, value: undefined }) as any)
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+
78
141
  export const makeQuery = <R>(getRuntime: () => Context.Context<R>) => {
79
142
  const useQuery_: {
80
143
  <I, A, E, Request extends Req, Name extends string>(
@@ -228,27 +291,6 @@ export const makeQuery = <R>(getRuntime: () => Context.Context<R>) => {
228
291
  ] as any
229
292
  }
230
293
 
231
- function swrToQuery<E, A>(r: {
232
- error: CauseException<E> | undefined
233
- data: A | undefined
234
- isValidating: boolean
235
- }): AsyncResult.AsyncResult<A, E> {
236
- if (r.error !== undefined) {
237
- return AsyncResult.failureWithPrevious(
238
- r.error.originalCause,
239
- {
240
- previous: r.data === undefined ? Option.none() : Option.some(AsyncResult.success(r.data)),
241
- waiting: r.isValidating
242
- }
243
- )
244
- }
245
- if (r.data !== undefined) {
246
- return AsyncResult.success<A, E>(r.data, { waiting: r.isValidating })
247
- }
248
-
249
- return AsyncResult.initial(r.isValidating)
250
- }
251
-
252
294
  const useQuery: {
253
295
  /**
254
296
  * Effect results are passed to the caller, including errors.
@@ -351,6 +393,88 @@ export const makeQuery = <R>(getRuntime: () => Context.Context<R>) => {
351
393
  // eslint-disable-next-line @typescript-eslint/no-empty-object-type
352
394
  export interface MakeQuery2<R> extends ReturnType<typeof makeQuery<R>> {}
353
395
 
396
+ type StreamQueryResult<A, E> = readonly [
397
+ ComputedRef<AsyncResult.AsyncResult<A[], E>>,
398
+ ComputedRef<A[] | undefined>,
399
+ (options?: RefetchOptions) => Effect.Effect<QueryObserverResult<A[], CauseException<E>>, never, never>,
400
+ UseQueryReturnType<any, any>
401
+ ]
402
+
403
+ export const makeStreamQuery = <R>(getRuntime: () => Context.Context<R>) => {
404
+ const streamQuery_: {
405
+ <E, A, Request extends Req, Name extends string>(
406
+ q: RequestStreamHandler<A, E, R, Request, Name>
407
+ ): () => StreamQueryResult<A, E>
408
+ <Arg, E, A, Request extends Req, Name extends string>(
409
+ q: RequestStreamHandlerWithInput<Arg, A, E, R, Request, Name>
410
+ ): (arg: Arg | WatchSource<Arg>) => StreamQueryResult<A, E>
411
+ } = (q: any) => (arg?: any) => {
412
+ const context = getRuntime()
413
+ const arr = arg
414
+ const req: { value: any } = !arg
415
+ ? undefined as any
416
+ : typeof arr === "function"
417
+ ? ({
418
+ get value() {
419
+ return arr()
420
+ }
421
+ })
422
+ : ref(arg)
423
+ const queryKey = makeQueryKey(q)
424
+ const handler = q.handler
425
+ const isWithInput = typeof handler === "function"
426
+
427
+ const r = useTanstackQuery<any[], CauseException<any>, any[]>(
428
+ {
429
+ throwOnError: false,
430
+ retry: (retryCount: number, error: unknown) => {
431
+ if (error instanceof CauseException) {
432
+ if (!isHttpClientError(error.cause) && !S.is(ServiceUnavailableError)(error.cause)) {
433
+ return false
434
+ }
435
+ }
436
+ return retryCount < 5
437
+ },
438
+ queryKey: isWithInput ? [...queryKey, req] : queryKey,
439
+ queryFn: streamedQuery({
440
+ streamFn: () => {
441
+ const stream = isWithInput
442
+ ? handler(req.value)
443
+ : handler
444
+ return streamToAsyncIterableWithCauseException(stream, context, q.id)
445
+ }
446
+ })
447
+ }
448
+ )
449
+
450
+ const latestSuccess = shallowRef<any[]>()
451
+ const result = computed((): AsyncResult.AsyncResult<any[], any> =>
452
+ swrToQuery({
453
+ error: r.error.value ?? undefined,
454
+ data: r.data.value === undefined ? latestSuccess.value : r.data.value,
455
+ isValidating: r.isFetching.value
456
+ })
457
+ )
458
+ watch(result, (value) => latestSuccess.value = Option.getOrUndefined(AsyncResult.value(value)), { immediate: true })
459
+
460
+ return [
461
+ result,
462
+ computed(() => latestSuccess.value),
463
+ (options?: RefetchOptions) =>
464
+ Effect.currentSpan.pipe(
465
+ Effect.orElseSucceed(() => null),
466
+ Effect.flatMap((span) => Effect.promise(() => r.refetch({ ...options, updateMeta: { span } })))
467
+ ),
468
+ r
469
+ ] as any
470
+ }
471
+
472
+ return streamQuery_
473
+ }
474
+
475
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
476
+ export interface MakeStreamQuery2<R> extends ReturnType<typeof makeStreamQuery<R>> {}
477
+
354
478
  function orPrevious<E, A>(result: AsyncResult.AsyncResult<A, E>) {
355
479
  return AsyncResult.isFailure(result) && Option.isSome(result.previousSuccess)
356
480
  ? AsyncResult.success(result.previousSuccess.value, { waiting: result.waiting })