@effect-app/vue 4.0.0-beta.28 → 4.0.0-beta.281

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +2073 -0
  2. package/dist/atomQuery.d.ts +105 -0
  3. package/dist/atomQuery.d.ts.map +1 -0
  4. package/dist/atomQuery.js +283 -0
  5. package/dist/commander.d.ts +634 -0
  6. package/dist/commander.d.ts.map +1 -0
  7. package/dist/commander.js +1070 -0
  8. package/dist/{experimental/confirm.d.ts → confirm.d.ts} +6 -4
  9. package/dist/confirm.d.ts.map +1 -0
  10. package/dist/confirm.js +26 -0
  11. package/dist/errorReporter.d.ts +6 -4
  12. package/dist/errorReporter.d.ts.map +1 -1
  13. package/dist/errorReporter.js +14 -19
  14. package/dist/form.d.ts +14 -5
  15. package/dist/form.d.ts.map +1 -1
  16. package/dist/form.js +46 -13
  17. package/dist/internal/tanstackQuery.d.ts +9 -0
  18. package/dist/internal/tanstackQuery.d.ts.map +1 -0
  19. package/dist/internal/tanstackQuery.js +163 -0
  20. package/dist/intl.d.ts +15 -0
  21. package/dist/intl.d.ts.map +1 -0
  22. package/dist/intl.js +9 -0
  23. package/dist/lib.d.ts +7 -9
  24. package/dist/lib.d.ts.map +1 -1
  25. package/dist/lib.js +35 -10
  26. package/dist/makeClient.d.ts +212 -336
  27. package/dist/makeClient.d.ts.map +1 -1
  28. package/dist/makeClient.js +399 -411
  29. package/dist/makeContext.d.ts.map +1 -1
  30. package/dist/makeIntl.d.ts.map +1 -1
  31. package/dist/{experimental/makeUseCommand.d.ts → makeUseCommand.d.ts} +4 -3
  32. package/dist/makeUseCommand.d.ts.map +1 -0
  33. package/dist/makeUseCommand.js +13 -0
  34. package/dist/mutate.d.ts +106 -40
  35. package/dist/mutate.d.ts.map +1 -1
  36. package/dist/mutate.js +187 -58
  37. package/dist/query.d.ts +139 -45
  38. package/dist/query.d.ts.map +1 -1
  39. package/dist/query.js +330 -122
  40. package/dist/routeParams.d.ts +4 -4
  41. package/dist/routeParams.d.ts.map +1 -1
  42. package/dist/routeParams.js +4 -3
  43. package/dist/runtime.d.ts +1 -14
  44. package/dist/runtime.d.ts.map +1 -1
  45. package/dist/runtime.js +2 -26
  46. package/dist/suspense.d.ts +5 -0
  47. package/dist/suspense.d.ts.map +1 -0
  48. package/dist/suspense.js +12 -0
  49. package/dist/toast.d.ts +2 -0
  50. package/dist/toast.d.ts.map +1 -0
  51. package/dist/toast.js +2 -0
  52. package/dist/withToast.d.ts +2 -0
  53. package/dist/withToast.d.ts.map +1 -0
  54. package/dist/withToast.js +2 -0
  55. package/docs/atom-query-api-redesign.md +207 -0
  56. package/examples/streamMutation.ts +72 -0
  57. package/package.json +30 -94
  58. package/src/atomQuery.ts +381 -0
  59. package/src/commander.ts +3413 -0
  60. package/src/{experimental/confirm.ts → confirm.ts} +13 -15
  61. package/src/errorReporter.ts +65 -75
  62. package/src/form.ts +61 -18
  63. package/src/index.ts +7 -7
  64. package/src/internal/tanstackQuery.ts +250 -0
  65. package/src/intl.ts +12 -0
  66. package/src/lib.ts +49 -21
  67. package/src/makeClient.ts +941 -1159
  68. package/src/makeIntl.ts +2 -2
  69. package/src/{experimental/makeUseCommand.ts → makeUseCommand.ts} +9 -6
  70. package/src/mutate.ts +343 -151
  71. package/src/query.ts +688 -260
  72. package/src/routeParams.ts +7 -7
  73. package/src/runtime.ts +1 -31
  74. package/src/suspense.ts +17 -0
  75. package/src/toast.ts +1 -0
  76. package/src/withToast.ts +1 -0
  77. package/test/Mutation.test.ts +181 -24
  78. package/test/dist/form.test.d.ts.map +1 -1
  79. package/test/dist/lib.test.d.ts.map +1 -0
  80. package/test/dist/patchableQueryAtom.test.d.ts.map +1 -0
  81. package/test/dist/queryOptions.test.d.ts.map +1 -0
  82. package/test/dist/retrySchedule.test.d.ts.map +1 -0
  83. package/test/dist/streamFinal.test.d.ts.map +1 -0
  84. package/test/dist/streamFn.test.d.ts.map +1 -0
  85. package/test/dist/stubs.d.ts +3697 -122
  86. package/test/dist/stubs.d.ts.map +1 -1
  87. package/test/dist/stubs.js +192 -32
  88. package/test/dist/suspense-regression.test.d.ts.map +1 -0
  89. package/test/dist/suspense.test.d.ts.map +1 -0
  90. package/test/form-validation-errors.test.ts +25 -20
  91. package/test/form.test.ts +22 -3
  92. package/test/lib.test.ts +240 -0
  93. package/test/makeClient.test.ts +437 -38
  94. package/test/streamFinal.test.ts +64 -0
  95. package/test/streamFn.test.ts +457 -0
  96. package/test/stubs.ts +228 -43
  97. package/test/suspense-regression.test.ts +115 -0
  98. package/test/suspense.test.ts +160 -0
  99. package/tsconfig.examples.json +20 -0
  100. package/tsconfig.json +3 -1
  101. package/tsconfig.src.json +34 -34
  102. package/tsconfig.test.json +2 -2
  103. package/vitest.config.ts +5 -5
  104. package/dist/experimental/commander.d.ts +0 -359
  105. package/dist/experimental/commander.d.ts.map +0 -1
  106. package/dist/experimental/commander.js +0 -557
  107. package/dist/experimental/confirm.d.ts.map +0 -1
  108. package/dist/experimental/confirm.js +0 -28
  109. package/dist/experimental/intl.d.ts +0 -16
  110. package/dist/experimental/intl.d.ts.map +0 -1
  111. package/dist/experimental/intl.js +0 -5
  112. package/dist/experimental/makeUseCommand.d.ts.map +0 -1
  113. package/dist/experimental/makeUseCommand.js +0 -13
  114. package/dist/experimental/toast.d.ts +0 -47
  115. package/dist/experimental/toast.d.ts.map +0 -1
  116. package/dist/experimental/toast.js +0 -41
  117. package/dist/experimental/withToast.d.ts +0 -25
  118. package/dist/experimental/withToast.d.ts.map +0 -1
  119. package/dist/experimental/withToast.js +0 -45
  120. package/eslint.config.mjs +0 -24
  121. package/src/experimental/commander.ts +0 -1835
  122. package/src/experimental/intl.ts +0 -9
  123. package/src/experimental/toast.ts +0 -66
  124. package/src/experimental/withToast.ts +0 -99
  125. package/tsconfig.json.bak +0 -12
package/src/makeIntl.ts CHANGED
@@ -3,8 +3,8 @@ import { createIntl, createIntlCache, type Formatters, type IntlFormatters, type
3
3
  import { typedKeysOf } from "effect-app/utils"
4
4
  import { type FormatXMLElementFn, type Options as IntlMessageFormatOptions, type PrimitiveType } from "intl-messageformat"
5
5
  import { type Ref, watch } from "vue"
6
- import { translate } from "./form.js"
7
- import { makeContext } from "./makeContext.js"
6
+ import { translate } from "./form.ts"
7
+ import { makeContext } from "./makeContext.ts"
8
8
 
9
9
  export interface MakeIntlReturn<Locale extends string> extends ReturnType<typeof makeIntl<Locale>> {}
10
10
 
@@ -1,26 +1,29 @@
1
- import { Effect, type Layer } from "effect-app"
2
- import { Commander, type CommanderImpl, CommanderStatic } from "./commander.js"
1
+ import * as Effect from "effect-app/Effect"
2
+ import type * as Layer from "effect-app/Layer"
3
+ import { Commander, type CommanderImpl, CommanderStatic } from "./commander.ts"
3
4
 
4
5
  type X<X> = X
5
6
 
6
7
  // helps retain JSDoc
7
8
  export interface CommanderResolved<RT, RTHooks>
8
- extends X<typeof CommanderStatic>, Pick<CommanderImpl<RT, RTHooks>, "fn" | "wrap" | "alt" | "alt2">
9
+ extends
10
+ X<typeof CommanderStatic>,
11
+ Pick<CommanderImpl<RT, RTHooks>, "fn" | "wrap" | "streamWrap" | "streamFn" | "alt" | "alt2">
9
12
  {
10
13
  }
11
14
 
12
15
  export const makeUseCommand = Effect.fnUntraced(
13
16
  function*<R = never, RTHooks = never>(rtHooks: Layer.Layer<RTHooks, never, R>) {
14
17
  const cmndr = yield* Commander
15
- const runtime = yield* Effect.services<R>()
18
+ const runtime = yield* Effect.context<R>()
16
19
 
17
20
  const comm = cmndr(runtime, rtHooks)
18
21
 
19
- const command = {
22
+ const command: CommanderResolved<R, RTHooks> = {
20
23
  ...comm,
21
24
  ...CommanderStatic
22
25
  }
23
26
 
24
- return command as CommanderResolved<R, RTHooks>
27
+ return command
25
28
  }
26
29
  )
package/src/mutate.ts CHANGED
@@ -1,23 +1,59 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { type InvalidateOptions, type InvalidateQueryFilters, type QueryClient, useQueryClient } from "@tanstack/vue-query"
3
- import { type Cause, Effect, type Exit, Option } from "effect-app"
4
- import { type Req } from "effect-app/client"
5
- import type { ClientForOptions, RequestHandler, RequestHandlerWithInput } from "effect-app/client/clientFor"
2
+ import { type InvalidationKey, InvalidationKeysFromServer, makeInvalidationKeysService, makeQueryKey, type Req } from "effect-app/client"
3
+ import type { ClientForOptions, RequestHandlerWithInput } from "effect-app/client/clientFor"
4
+ import type { InvalidateQueryInstruction } from "effect-app/client/makeClient"
5
+ import * as Effect from "effect-app/Effect"
6
6
  import { tuple } from "effect-app/Function"
7
+ import * as Option from "effect-app/Option"
8
+ import { isReadonlyArrayNonEmpty } from "effect/Array"
9
+ import type * as Cause from "effect/Cause"
10
+ import * as Exit from "effect/Exit"
11
+ import * as Ref from "effect/Ref"
12
+ import * as Stream from "effect/Stream"
7
13
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
14
+ import type * as Reactivity from "effect/unstable/reactivity/Reactivity"
8
15
  import { computed, type ComputedRef, shallowRef } from "vue"
9
- import { makeQueryKey } from "./lib.js"
16
+ import { invalidateAndAwait } from "./atomQuery.ts"
10
17
 
11
- export const getQueryKey = (h: { id: string; options?: ClientForOptions }) => {
18
+ export type GetQueryKey = (h: { id: string; options?: ClientForOptions }) => string[]
19
+
20
+ /**
21
+ * Default heuristic: invalidate the parent namespace of the action.
22
+ * e.g. `$project/$configuration.get` -> `["$project"]`
23
+ * e.g. `$project/$configuration/$something.get` -> `["$project","$configuration"]`
24
+ */
25
+ export const defaultGetQueryKey: GetQueryKey = (h) => {
12
26
  const key = makeQueryKey(h)
13
27
  const ns = key.filter((_) => _.startsWith("$"))
14
- // we invalidate the parent namespace e.g $project/$configuration.get, we invalidate $project
15
- // for $project/$configuration/$something.get, we invalidate $project/$configuration
16
28
  const k = ns.length ? ns.length > 1 ? ns.slice(0, ns.length - 1) : ns : undefined
17
29
  if (!k) throw new Error("empty query key for: " + h.id)
18
30
  return k
19
31
  }
20
32
 
33
+ let activeGetQueryKey: GetQueryKey = defaultGetQueryKey
34
+
35
+ /**
36
+ * Override the default query-key heuristic used by mutations for cache
37
+ * invalidation. Call once at app bootstrap. Pass `undefined` to restore the
38
+ * built-in default.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * // invalidate the full namespace of the action (no parent collapse)
43
+ * setDefaultGetQueryKey((h) => {
44
+ * const key = makeQueryKey(h)
45
+ * const ns = key.filter((_) => _.startsWith("$"))
46
+ * if (!ns.length) throw new Error("empty query key for: " + h.id)
47
+ * return ns
48
+ * })
49
+ * ```
50
+ */
51
+ export const setDefaultGetQueryKey = (fn: GetQueryKey | undefined) => {
52
+ activeGetQueryKey = fn ?? defaultGetQueryKey
53
+ }
54
+
55
+ export const getQueryKey: GetQueryKey = (h) => activeGetQueryKey(h)
56
+
21
57
  export function mutationResultToVue<A, E>(
22
58
  mutationResult: AsyncResult.AsyncResult<A, E>
23
59
  ): Res<A, E> {
@@ -67,197 +103,353 @@ export function make<A, E, R>(self: Effect.Effect<A, E, R>) {
67
103
  return tuple(result, latestSuccess, execute)
68
104
  }
69
105
 
70
- export interface MutationOptionsBase {
106
+ /**
107
+ * An entry for `queryInvalidation`: a raw query key, an `{ id }` handler ref, or a
108
+ * `{ filters }` shape. The atom engine acts on entries that carry a concrete query
109
+ * key: raw arrays, handler refs, or `{ filters: { queryKey } }`. Predicate-only
110
+ * filters have no exact-key reactivity equivalent and fail fast.
111
+ */
112
+ export type QueryKeyInvalidationFilters = {
113
+ readonly queryKey: ReadonlyArray<unknown>
114
+ }
115
+ export type InvalidationEntry = InvalidateQueryInstruction<QueryKeyInvalidationFilters>
116
+ export type QueryInvalidationEffect<R = never> = (
117
+ keys: ReadonlyArray<ReadonlyArray<unknown>>
118
+ ) => Effect.Effect<void, never, R>
119
+ export interface QueryInvalidator<R = never> {
120
+ readonly invalidateAndAwait: QueryInvalidationEffect<R>
121
+ }
122
+
123
+ export const atomQueryInvalidator: QueryInvalidator<Reactivity.Reactivity> = {
124
+ invalidateAndAwait
125
+ }
126
+
127
+ export const combineQueryInvalidators = <R>(
128
+ ...invalidators: ReadonlyArray<QueryInvalidator<R>>
129
+ ): QueryInvalidator<R> => ({
130
+ invalidateAndAwait: (keys) =>
131
+ Effect.forEach(
132
+ invalidators,
133
+ (invalidator) => invalidator.invalidateAndAwait(keys),
134
+ { discard: true, concurrency: "inherit" }
135
+ )
136
+ })
137
+
138
+ const isRecord = (value: unknown): value is { readonly [key: string]: unknown } =>
139
+ typeof value === "object" && value !== null
140
+
141
+ const isQueryKey = (entry: InvalidationEntry): entry is ReadonlyArray<string> => Array.isArray(entry)
142
+
143
+ const queryKeyFromFilters = (
144
+ entry: Exclude<InvalidationEntry, ReadonlyArray<string>>
145
+ ): ReadonlyArray<unknown> | undefined => {
146
+ if (!("filters" in entry)) return undefined
147
+ const filters = entry.filters
148
+ if (!isRecord(filters)) return undefined
149
+ const queryKey = filters["queryKey"]
150
+ return Array.isArray(queryKey) ? queryKey : undefined
151
+ }
152
+
153
+ export interface MutationOptionsBase<A = unknown, B = A, E2 = never, R2 = never> {
71
154
  /**
72
155
  * By default we invalidate one level of the query key, e.g $project/$configuration.get, we invalidate $project.
73
- * This can be overridden by providing a function that returns an array of filters and options.
156
+ * This can be overridden by providing a function that returns an array of filters and options,
157
+ * or RPC handlers directly (their query keys are derived automatically).
158
+ *
159
+ * @example
160
+ * ```ts
161
+ * queryInvalidation: (queryKey) => [
162
+ * { filters: { queryKey } },
163
+ * GetMe,
164
+ * PackListIndex
165
+ * ]
166
+ * ```
74
167
  */
75
- queryInvalidation?: (defaultKey: string[], name: string) => {
76
- filters?: InvalidateQueryFilters | undefined
77
- options?: InvalidateOptions | undefined
78
- }[]
79
- }
80
-
81
- /** @deprecated prefer more basic @see MutationOptionsBase and separate useMutation from Command.fn */
82
- export interface MutationOptions<A, E, R, A2 = A, E2 = E, R2 = R, I = void> extends MutationOptionsBase {
168
+ queryInvalidation?: (
169
+ defaultKey: string[],
170
+ name: string,
171
+ input?: unknown,
172
+ output?: Exit.Exit<unknown, unknown>
173
+ ) => InvalidationEntry[]
83
174
  /**
84
- * Map the handler; cache invalidation is already done in this handler.
85
- * This is useful for e.g navigating, as you know caches have already updated.
175
+ * Run an additional Effect after the mutation succeeds. Its output becomes the
176
+ * final result returned to the caller. Query cache is invalidated once on
177
+ * mutation exit and again after this Effect completes. Useful for long-running
178
+ * operations (e.g. polling a background job) where you want the caller to
179
+ * receive the downstream result and the cache to refresh once it is ready.
86
180
  *
87
- * @deprecated use `Command.fn` instead of `useMutation*` with `mapHandler` option.
181
+ * @example
182
+ * ```ts
183
+ * useMutation(startExportCommand, {
184
+ * select: (result) => pollUntilDone(result.jobId)
185
+ * // caller receives the pollUntilDone output, not the original result
186
+ * })
187
+ * ```
88
188
  */
89
- mapHandler?: (handler: Effect.Effect<A, E, R>, input: I) => Effect.Effect<A2, E2, R2>
189
+ select?: (result: A) => Effect.Effect<B, E2, R2>
90
190
  }
91
191
 
92
- // TODO: more efficient invalidation, including args etc
93
- // return Effect.promise(() => queryClient.invalidateQueries({
94
- // predicate: (_) => nses.includes(_.queryKey.filter((_) => _.startsWith("$")).join("/"))
95
- // }))
96
- /*
97
- // const nses: string[] = []`
98
- // for (let i = 0; i < ns.length; i++) {
99
- // nses.push(ns.slice(0, i + 1).join("/"))
100
- // }
101
- */
102
-
103
- export const asResult: {
104
- <A, E, R>(
105
- handler: Effect.Effect<A, E, R>
106
- ): readonly [ComputedRef<AsyncResult.AsyncResult<A, E>>, Effect.Effect<Exit.Exit<A, E>, never, R>]
107
- <Args extends readonly any[], A, E, R>(
108
- handler: (...args: Args) => Effect.Effect<A, E, R>
109
- ): readonly [ComputedRef<AsyncResult.AsyncResult<A, E>>, (...args: Args) => Effect.Effect<Exit.Exit<A, E>, never, R>]
110
- } = <Args extends readonly any[], A, E, R>(
111
- handler: Effect.Effect<A, E, R> | ((...args: Args) => Effect.Effect<A, E, R>)
112
- ) => {
192
+ export const asResult = <Args extends readonly any[], A, E, R>(
193
+ handler: (...args: Args) => Effect.Effect<A, E, R>
194
+ ): readonly [
195
+ ComputedRef<AsyncResult.AsyncResult<A, E>>,
196
+ (...args: Args) => Effect.Effect<Exit.Exit<A, E>, never, R>
197
+ ] => {
113
198
  const state = shallowRef<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())
114
199
 
115
- const act = Effect.isEffect(handler)
116
- ? Effect
200
+ const act = (...args: Args) =>
201
+ Effect
117
202
  .sync(() => {
118
203
  state.value = AsyncResult.initial(true)
119
204
  })
120
205
  .pipe(
121
206
  Effect.andThen(Effect.suspend(() =>
122
- handler.pipe(
207
+ handler(...args).pipe(
123
208
  Effect.exit,
124
209
  Effect.tap((exit) => Effect.sync(() => (state.value = AsyncResult.fromExit(exit))))
125
210
  )
126
211
  ))
127
212
  )
128
- : (...args: Args) =>
129
- Effect
130
- .sync(() => {
131
- state.value = AsyncResult.initial(true)
132
- })
133
- .pipe(
134
- Effect.andThen(Effect.suspend(() =>
135
- handler(...args).pipe(
136
- Effect.exit,
137
- Effect.tap((exit) => Effect.sync(() => (state.value = AsyncResult.fromExit(exit))))
213
+
214
+ return tuple(computed(() => state.value), act) as any
215
+ }
216
+
217
+ /**
218
+ * Like `asResult`, but for streams. The ref is updated with each emitted value
219
+ * (keeping `waiting: true`) and is finalised (with `waiting: false`) once the
220
+ * stream terminates successfully. Errors are surfaced as `AsyncResult.failure`.
221
+ */
222
+ export const asStreamResult = <Args extends readonly any[], A, E, R>(
223
+ handler: (...args: Args) => Stream.Stream<A, E, R>
224
+ ): readonly [ComputedRef<AsyncResult.AsyncResult<A, E>>, (...args: Args) => Effect.Effect<void, never, R>] => {
225
+ const state = shallowRef<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())
226
+
227
+ const runStream = (stream: Stream.Stream<A, E, R>): Effect.Effect<void, never, R> =>
228
+ Effect
229
+ .sync(() => {
230
+ state.value = AsyncResult.initial(true)
231
+ })
232
+ .pipe(
233
+ Effect.andThen(
234
+ stream.pipe(
235
+ Stream.runForEach((value) =>
236
+ Effect.sync(() => {
237
+ state.value = AsyncResult.success(value, { waiting: true })
238
+ })
239
+ ),
240
+ Effect.exit,
241
+ Effect.flatMap((exit) =>
242
+ Effect.sync(() => {
243
+ if (exit._tag === "Success") {
244
+ const current = state.value
245
+ if (AsyncResult.isSuccess(current)) {
246
+ state.value = AsyncResult.success(current.value, { waiting: false })
247
+ } else {
248
+ state.value = AsyncResult.initial(false)
249
+ }
250
+ } else {
251
+ state.value = AsyncResult.failure(exit.cause)
252
+ }
253
+ })
138
254
  )
139
- ))
255
+ )
140
256
  )
257
+ )
258
+
259
+ const act = (...args: Args) => runStream(handler(...args))
141
260
 
142
261
  return tuple(computed(() => state.value), act) as any
143
262
  }
144
263
 
145
- export const invalidateQueries = (
146
- queryClient: QueryClient,
264
+ const buildInvalidateCache = <RInvalidator>(
147
265
  self: { id: string; options?: ClientForOptions },
148
- options?: MutationOptionsBase["queryInvalidation"]
266
+ queryInvalidation: MutationOptionsBase["queryInvalidation"] | undefined,
267
+ queryInvalidator: QueryInvalidator<RInvalidator>
149
268
  ) => {
150
- const invalidateQueries = (
151
- filters?: InvalidateQueryFilters,
152
- options?: InvalidateOptions
153
- ) =>
154
- Effect.currentSpan.pipe(
155
- Effect.orElseSucceed(() => null),
156
- Effect.flatMap((span) =>
157
- Effect.promise(() => queryClient.invalidateQueries(filters, { ...options, updateMeta: { span } }))
158
- )
159
- )
160
-
161
- const invalidateCache = Effect.suspend(() => {
269
+ // Concrete reactivity keys to invalidate: a raw query key, one derived from an `{ id }`
270
+ // entry, or a compatibility `{ filters: { queryKey } }` entry.
271
+ // Predicate-only `{ filters }` entries have no exact-key reactivity equivalent and throw.
272
+ const getClientInvalidationKeys = (
273
+ input: unknown,
274
+ output: Exit.Exit<unknown, unknown>
275
+ ): ReadonlyArray<ReadonlyArray<unknown>> => {
162
276
  const queryKey = getQueryKey(self)
163
277
 
164
- if (options) {
165
- const opts = options(queryKey, self.id)
166
- if (!opts.length) {
167
- return Effect.void
278
+ if (queryInvalidation) {
279
+ const keys: Array<ReadonlyArray<unknown>> = []
280
+ for (const entry of queryInvalidation(queryKey, self.id, input, output)) {
281
+ if (isQueryKey(entry)) {
282
+ keys.push(entry)
283
+ continue
284
+ }
285
+ if ("id" in entry) {
286
+ keys.push(makeQueryKey(entry.options ? { id: entry.id, options: entry.options } : { id: entry.id }))
287
+ continue
288
+ }
289
+ const filterQueryKey = queryKeyFromFilters(entry)
290
+ if (filterQueryKey !== undefined) {
291
+ keys.push(filterQueryKey)
292
+ continue
293
+ }
294
+ throw new Error("Unsupported query invalidation filter: only filters.queryKey is supported")
168
295
  }
296
+ return keys
297
+ }
298
+
299
+ return queryKey ? [queryKey] : []
300
+ }
301
+
302
+ const invalidateCache = (
303
+ input: unknown,
304
+ output: Exit.Exit<unknown, unknown>,
305
+ serverKeys: ReadonlyArray<InvalidationKey>
306
+ ) =>
307
+ Effect.suspend(() => {
308
+ const clientKeys = getClientInvalidationKeys(input, output)
309
+ // Invalidate exact reactivity keys (= the prefixes query atoms register under). Each key
310
+ // array is hashed structurally, matching the query-side registration.
311
+ const keys: ReadonlyArray<ReadonlyArray<unknown>> = [...clientKeys, ...serverKeys]
312
+
313
+ if (!isReadonlyArrayNonEmpty(keys)) return Effect.void
314
+
169
315
  return Effect
170
316
  .andThen(
171
- Effect.annotateCurrentSpan({ queryKey, opts }),
172
- Effect.forEach(opts, (_) => invalidateQueries(_.filters, _.options), { concurrency: "inherit" })
317
+ Effect.annotateCurrentSpan({ clientKeys, serverKeys }),
318
+ // refetch + AWAIT every live query registered under these keys, so by the time the
319
+ // mutation resolves the affected queries are fresh.
320
+ queryInvalidator.invalidateAndAwait(keys)
173
321
  )
174
- .pipe(Effect.withSpan("client.query.invalidation", {}, { captureStackTrace: false }))
175
- }
322
+ .pipe(
323
+ Effect.tap(Effect.sleep(0.1)), // allow for refs to update etc
324
+ Effect.withSpan("client.query.invalidation", {}, { captureStackTrace: false })
325
+ )
326
+ })
327
+
328
+ return invalidateCache
329
+ }
176
330
 
177
- if (!queryKey) return Effect.void
331
+ export const invalidateQueries = <RInvalidator>(
332
+ self: { id: string; options?: ClientForOptions },
333
+ options: MutationOptionsBase | undefined,
334
+ queryInvalidator: QueryInvalidator<RInvalidator>
335
+ ) => {
336
+ const invalidateCache = buildInvalidateCache(self, options?.queryInvalidation, queryInvalidator)
178
337
 
179
- return Effect
180
- .andThen(
181
- Effect.annotateCurrentSpan({ queryKey }),
182
- invalidateQueries({ queryKey })
183
- )
184
- .pipe(
185
- Effect.tap(
186
- // hand over control back to the event loop so that state can be updated..
187
- // TODO: should we do this in general on any mutation, regardless of invalidation?
188
- Effect.sleep(0)
189
- ),
190
- Effect.withSpan("client.query.invalidation", {}, { captureStackTrace: false })
191
- )
192
- })
338
+ const select = options?.select
193
339
 
194
- const handle = <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.ensuring(self, invalidateCache)
340
+ const handle = <A, E, R>(eff: Effect.Effect<A, E, R>, input?: unknown) =>
341
+ Effect.gen(function*() {
342
+ const keysRef = yield* Ref.make<ReadonlyArray<InvalidationKey>>([])
343
+ const result = yield* eff.pipe(
344
+ Effect.provideService(InvalidationKeysFromServer, makeInvalidationKeysService(keysRef)),
345
+ Effect.onExit((exit) =>
346
+ Effect.gen(function*() {
347
+ const serverKeys = yield* Ref.get(keysRef)
348
+ yield* invalidateCache(input, exit, serverKeys)
349
+ })
350
+ )
351
+ )
352
+ if (select) {
353
+ return yield* select(result).pipe(
354
+ Effect.onExit((exit) =>
355
+ Effect.gen(function*() {
356
+ const serverKeys = yield* Ref.get(keysRef)
357
+ yield* invalidateCache(input, exit, serverKeys)
358
+ })
359
+ )
360
+ )
361
+ }
362
+ return result
363
+ })
195
364
 
196
365
  return handle
197
366
  }
198
367
 
199
- export const makeMutation = () => {
200
- const useMutation: {
201
- /**
202
- * Pass a function that returns an Effect, e.g from a client action
203
- * Executes query cache invalidation based on default rules or provided option.
204
- */
205
- <I, E, A, R, Request extends Req, Id extends string>(
206
- self: RequestHandlerWithInput<I, A, E, R, Request, Id>,
207
- options?: MutationOptionsBase
208
- ): ((i: I) => Effect.Effect<A, E, R>) & { readonly id: Id }
209
- /**
210
- * Pass an Effect, e.g from a client action
211
- * Executes query cache invalidation based on default rules or provided option.
212
- */
213
- <E, A, R, Request extends Req, Id extends string>(
214
- self: RequestHandler<A, E, R, Request, Id>,
215
- options?: MutationOptionsBase
216
- ): Effect.Effect<A, E, R> & { readonly id: Id }
217
- } = <I, E, A, R, Request extends Req, Id extends string>(
218
- self: RequestHandlerWithInput<I, A, E, R, Request, Id> | RequestHandler<A, E, R, Request, Id>,
219
- options?: MutationOptionsBase
220
- ) => {
221
- const queryClient = useQueryClient()
222
- const handle = invalidateQueries(queryClient, self, options?.queryInvalidation)
223
- const handler = self.handler
224
- const r = Effect.isEffect(handler) ? handle(handler) : (i: I) => handle(handler(i))
368
+ /**
369
+ * A callable mutation result. When `I = void` the input argument may be omitted.
370
+ */
371
+ export interface MutationFn<I, A, E, R, Id extends string> {
372
+ <B = A, E2 = never, R2 = never>(
373
+ input: I,
374
+ options?: MutationOptionsBase<A, B, E2, R2>
375
+ ): Effect.Effect<B, E | E2, R | R2>
376
+ readonly id: Id
377
+ }
225
378
 
379
+ export const makeMutation = <RInvalidator>(queryInvalidator: QueryInvalidator<RInvalidator>) => {
380
+ /**
381
+ * Pass a function that returns an Effect, e.g from a client action.
382
+ * Executes query cache invalidation based on default rules or provided option.
383
+ * When `I = void` the input argument may be omitted.
384
+ */
385
+ const useMutation = <I, E, A, R, Request extends Req, Id extends string>(
386
+ self: RequestHandlerWithInput<I, A, E, R, Request, Id>
387
+ ): MutationFn<I, A, E, R, Id> => {
388
+ const r = (i: I, options?: MutationOptionsBase) =>
389
+ invalidateQueries(self, options, queryInvalidator)(self.handler(i), i)
226
390
  return Object.assign(r, { id: self.id }) as any
227
391
  }
228
392
  return useMutation
229
393
  }
230
394
 
231
- // calling hooks in the body
232
- export const useMakeMutation = () => {
233
- const queryClient = useQueryClient()
234
-
235
- const useMutation: {
236
- /**
237
- * Pass a function that returns an Effect, e.g from a client action
238
- * Executes query cache invalidation based on default rules or provided option.
239
- */
240
- <I, E, A, R, Request extends Req, Id extends string>(
241
- self: RequestHandlerWithInput<I, A, E, R, Request, Id>,
242
- options?: MutationOptionsBase
243
- ): ((i: I) => Effect.Effect<A, E, R>) & { readonly id: Id }
244
- /**
245
- * Pass an Effect, e.g from a client action
246
- * Executes query cache invalidation based on default rules or provided option.
247
- */
248
- <E, A, R, Request extends Req, Id extends string>(
249
- self: RequestHandler<A, E, R, Request, Id>,
250
- options?: MutationOptionsBase
251
- ): Effect.Effect<A, E, R> & { readonly id: Id }
252
- } = <I, E, A, R, Request extends Req, Id extends string>(
253
- self: RequestHandlerWithInput<I, A, E, R, Request, Id> | RequestHandler<A, E, R, Request, Id>,
254
- options?: MutationOptionsBase
255
- ) => {
256
- const handle = invalidateQueries(queryClient, self, options?.queryInvalidation)
257
- const handler = self.handler
258
- const r = Effect.isEffect(handler) ? handle(handler) : (i: I) => handle(handler(i))
259
-
395
+ export const useMakeMutation = <RInvalidator>(queryInvalidator: QueryInvalidator<RInvalidator>) => {
396
+ /**
397
+ * Pass a function that returns an Effect, e.g from a client action.
398
+ * Executes query cache invalidation based on default rules or provided option.
399
+ * When `I = void` the input argument may be omitted.
400
+ */
401
+ const useMutation = <I, E, A, R, Request extends Req, Id extends string>(
402
+ self: RequestHandlerWithInput<I, A, E, R, Request, Id>
403
+ ): MutationFn<I, A, E, R, Id> => {
404
+ const r = (i: I, options?: MutationOptionsBase) =>
405
+ invalidateQueries(self, options, queryInvalidator)(self.handler(i), i)
260
406
  return Object.assign(r, { id: self.id }) as any
261
407
  }
262
408
  return useMutation
263
409
  }
410
+
411
+ /**
412
+ * Returns a stream-based mutation factory for use with `streamFn`.
413
+ * The outer Effect sets up per-invocation invalidation scaffolding
414
+ * and returns a stream that triggers query invalidation via `Stream.ensuring` when it completes.
415
+ *
416
+ * Use with `streamFn` / `Command.streamFn(id)(mutateHandler, ...combinators)` so that
417
+ * the command manages its own reactive state internally.
418
+ */
419
+ export const makeStreamMutation2 = <RInvalidator>(queryInvalidator: QueryInvalidator<RInvalidator>) => {
420
+ return (
421
+ self: {
422
+ id: string
423
+ options?: ClientForOptions
424
+ handler: (i: any) => Stream.Stream<any, any, any>
425
+ },
426
+ mergedInvalidation?: MutationOptionsBase["queryInvalidation"]
427
+ ) => {
428
+ const invCache = buildInvalidateCache(self, mergedInvalidation, queryInvalidator)
429
+
430
+ const makeInvocationEffect = (input: unknown, source: Stream.Stream<any, any, any>) =>
431
+ Effect.gen(function*() {
432
+ const keysRef = yield* Ref.make<ReadonlyArray<InvalidationKey>>([])
433
+ const invKeys = makeInvalidationKeysService(
434
+ keysRef,
435
+ // Stream invalidation is sequenced by the injected query invalidator; this callback
436
+ // returns void to keep the server-side invalidation service effect-free.
437
+ (key) => invCache(input, Exit.succeed(undefined), [key]) as Effect.Effect<void>
438
+ )
439
+ const lastRef = yield* Ref.make<any>(undefined)
440
+ return source.pipe(
441
+ Stream.provideService(InvalidationKeysFromServer, invKeys),
442
+ Stream.tap((v) => Ref.set(lastRef, v)),
443
+ Stream.ensuring(
444
+ Effect.gen(function*() {
445
+ const lastValue = yield* Ref.get(lastRef)
446
+ const serverKeys = yield* Ref.get(keysRef)
447
+ yield* invCache(input, Exit.succeed(lastValue), serverKeys)
448
+ })
449
+ )
450
+ )
451
+ })
452
+
453
+ return (i: any) => Stream.unwrap(makeInvocationEffect(i, self.handler(i)))
454
+ }
455
+ }