@effect-app/vue 4.0.0-beta.99 → 4.0.0

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 (128) hide show
  1. package/CHANGELOG.md +2701 -0
  2. package/dist/atomQuery.d.ts +120 -0
  3. package/dist/atomQuery.d.ts.map +1 -0
  4. package/dist/atomQuery.js +434 -0
  5. package/dist/commander.d.ts +318 -43
  6. package/dist/commander.d.ts.map +1 -1
  7. package/dist/commander.js +593 -81
  8. package/dist/confirm.d.ts +5 -3
  9. package/dist/confirm.d.ts.map +1 -1
  10. package/dist/confirm.js +12 -14
  11. package/dist/dependencyMetadata.d.ts +18 -0
  12. package/dist/dependencyMetadata.d.ts.map +1 -0
  13. package/dist/dependencyMetadata.js +67 -0
  14. package/dist/errorReporter.d.ts +6 -4
  15. package/dist/errorReporter.d.ts.map +1 -1
  16. package/dist/errorReporter.js +14 -19
  17. package/dist/form.d.ts +5 -5
  18. package/dist/form.d.ts.map +1 -1
  19. package/dist/form.js +16 -10
  20. package/dist/internal/tanstackQuery.d.ts +9 -0
  21. package/dist/internal/tanstackQuery.d.ts.map +1 -0
  22. package/dist/internal/tanstackQuery.js +204 -0
  23. package/dist/intl.d.ts +4 -4
  24. package/dist/intl.d.ts.map +1 -1
  25. package/dist/intl.js +2 -2
  26. package/dist/lib.d.ts +8 -10
  27. package/dist/lib.d.ts.map +1 -1
  28. package/dist/lib.js +37 -12
  29. package/dist/liveQueryInvalidation.d.ts +16 -0
  30. package/dist/liveQueryInvalidation.d.ts.map +1 -0
  31. package/dist/liveQueryInvalidation.js +115 -0
  32. package/dist/makeClient.d.ts +232 -109
  33. package/dist/makeClient.d.ts.map +1 -1
  34. package/dist/makeClient.js +416 -84
  35. package/dist/makeContext.d.ts.map +1 -1
  36. package/dist/makeIntl.d.ts.map +1 -1
  37. package/dist/makeUseCommand.d.ts +3 -2
  38. package/dist/makeUseCommand.d.ts.map +1 -1
  39. package/dist/makeUseCommand.js +2 -2
  40. package/dist/mutate.d.ts +110 -41
  41. package/dist/mutate.d.ts.map +1 -1
  42. package/dist/mutate.js +236 -59
  43. package/dist/query.d.ts +161 -44
  44. package/dist/query.d.ts.map +1 -1
  45. package/dist/query.js +382 -113
  46. package/dist/queryLifetime.d.ts +25 -0
  47. package/dist/queryLifetime.d.ts.map +1 -0
  48. package/dist/queryLifetime.js +54 -0
  49. package/dist/routeParams.d.ts +4 -4
  50. package/dist/routeParams.d.ts.map +1 -1
  51. package/dist/routeParams.js +4 -3
  52. package/dist/runtime.d.ts +1 -17
  53. package/dist/runtime.d.ts.map +1 -1
  54. package/dist/runtime.js +2 -38
  55. package/dist/suspense.d.ts +19 -0
  56. package/dist/suspense.d.ts.map +1 -0
  57. package/dist/suspense.js +38 -0
  58. package/dist/toast.d.ts +1 -45
  59. package/dist/toast.d.ts.map +1 -1
  60. package/dist/toast.js +2 -32
  61. package/dist/withToast.d.ts +1 -24
  62. package/dist/withToast.d.ts.map +1 -1
  63. package/dist/withToast.js +2 -45
  64. package/docs/atom-query-api-redesign.md +207 -0
  65. package/docs/mutation-command-atoms.md +40 -0
  66. package/docs/query-key-invalidation.md +107 -0
  67. package/examples/streamMutation.ts +72 -0
  68. package/package.json +35 -94
  69. package/src/atomQuery.ts +600 -0
  70. package/src/commander.ts +1064 -116
  71. package/src/confirm.ts +12 -14
  72. package/src/dependencyMetadata.ts +84 -0
  73. package/src/errorReporter.ts +65 -75
  74. package/src/form.ts +22 -15
  75. package/src/index.ts +7 -7
  76. package/src/internal/tanstackQuery.ts +308 -0
  77. package/src/intl.ts +2 -2
  78. package/src/lib.ts +51 -23
  79. package/src/liveQueryInvalidation.ts +138 -0
  80. package/src/makeClient.ts +972 -263
  81. package/src/makeIntl.ts +2 -2
  82. package/src/makeUseCommand.ts +8 -5
  83. package/src/mutate.ts +407 -154
  84. package/src/query.ts +758 -242
  85. package/src/queryLifetime.ts +74 -0
  86. package/src/routeParams.ts +7 -7
  87. package/src/runtime.ts +1 -54
  88. package/src/suspense.ts +43 -0
  89. package/src/toast.ts +1 -52
  90. package/src/withToast.ts +1 -99
  91. package/test/Mutation.test.ts +104 -17
  92. package/test/dependencyInvalidation.test.ts +444 -0
  93. package/test/dist/dependencyInvalidation.test.d.ts.map +1 -0
  94. package/test/dist/form.test.d.ts.map +1 -1
  95. package/test/dist/inactiveInvalidation.test.d.ts.map +1 -0
  96. package/test/dist/interrupt-refetch-repro.test.d.ts.map +1 -0
  97. package/test/dist/lib.test.d.ts.map +1 -0
  98. package/test/dist/liveQueryInvalidation.test.d.ts.map +1 -0
  99. package/test/dist/query-span.test.d.ts.map +1 -0
  100. package/test/dist/queryOptions.test.d.ts.map +1 -0
  101. package/test/dist/streamFinal.test.d.ts.map +1 -0
  102. package/test/dist/streamFn.test.d.ts.map +1 -0
  103. package/test/dist/stubs.d.ts +3681 -256
  104. package/test/dist/stubs.d.ts.map +1 -1
  105. package/test/dist/stubs.js +166 -25
  106. package/test/dist/suspense-regression.test.d.ts.map +1 -0
  107. package/test/dist/suspense.test.d.ts.map +1 -0
  108. package/test/form-validation-errors.test.ts +2 -1
  109. package/test/form.test.ts +2 -1
  110. package/test/inactiveInvalidation.test.ts +172 -0
  111. package/test/interrupt-refetch-repro.test.ts +625 -0
  112. package/test/lib.test.ts +240 -0
  113. package/test/liveQueryInvalidation.test.ts +108 -0
  114. package/test/makeClient.test.ts +414 -32
  115. package/test/query-span.test.ts +92 -0
  116. package/test/queryOptions.test.ts +88 -0
  117. package/test/streamFinal.test.ts +64 -0
  118. package/test/streamFn.test.ts +457 -0
  119. package/test/stubs.ts +194 -36
  120. package/test/suspense-regression.test.ts +168 -0
  121. package/test/suspense.test.ts +160 -0
  122. package/tsconfig.examples.json +20 -0
  123. package/tsconfig.json +9 -1
  124. package/tsconfig.src.json +34 -34
  125. package/tsconfig.test.json +2 -2
  126. package/vitest.config.ts +5 -5
  127. package/eslint.config.mjs +0 -24
  128. package/tsconfig.json.bak +0 -12
package/src/mutate.ts CHANGED
@@ -1,23 +1,61 @@
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 { DataDependencies, 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 AsyncResult from "effect/unstable/reactivity/AsyncResult"
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 AsyncResult from "effect/reactivity/AsyncResult"
12
+ import type * as Reactivity from "effect/reactivity/Reactivity"
13
+ import * as Ref from "effect/Ref"
14
+ import * as Stream from "effect/Stream"
8
15
  import { computed, type ComputedRef, shallowRef } from "vue"
9
- import { makeQueryKey } from "./lib.js"
16
+ import { invalidateAndAwait, invalidateSoft } from "./atomQuery.ts"
17
+ import { getDerivedInvalidationKeys, partitionInvalidationKeys } from "./dependencyMetadata.ts"
18
+ import { reportRuntimeError } from "./lib.ts"
10
19
 
11
- export const getQueryKey = (h: { id: string; options?: ClientForOptions }) => {
20
+ export type GetQueryKey = (h: { id: string; options?: ClientForOptions }) => string[]
21
+
22
+ /**
23
+ * Default heuristic: invalidate the parent namespace of the action.
24
+ * e.g. `$project/$configuration.get` -> `["$project"]`
25
+ * e.g. `$project/$configuration/$something.get` -> `["$project","$configuration"]`
26
+ */
27
+ export const defaultGetQueryKey: GetQueryKey = (h) => {
12
28
  const key = makeQueryKey(h)
13
29
  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
30
  const k = ns.length ? ns.length > 1 ? ns.slice(0, ns.length - 1) : ns : undefined
17
31
  if (!k) throw new Error("empty query key for: " + h.id)
18
32
  return k
19
33
  }
20
34
 
35
+ let activeGetQueryKey: GetQueryKey = defaultGetQueryKey
36
+
37
+ /**
38
+ * Override the default query-key heuristic used by mutations for cache
39
+ * invalidation. Call once at app bootstrap. Pass `undefined` to restore the
40
+ * built-in default.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * // invalidate the full namespace of the action (no parent collapse)
45
+ * setDefaultGetQueryKey((h) => {
46
+ * const key = makeQueryKey(h)
47
+ * const ns = key.filter((_) => _.startsWith("$"))
48
+ * if (!ns.length) throw new Error("empty query key for: " + h.id)
49
+ * return ns
50
+ * })
51
+ * ```
52
+ */
53
+ export const setDefaultGetQueryKey = (fn: GetQueryKey | undefined) => {
54
+ activeGetQueryKey = fn ?? defaultGetQueryKey
55
+ }
56
+
57
+ export const getQueryKey: GetQueryKey = (h) => activeGetQueryKey(h)
58
+
21
59
  export function mutationResultToVue<A, E>(
22
60
  mutationResult: AsyncResult.AsyncResult<A, E>
23
61
  ): Res<A, E> {
@@ -67,197 +105,412 @@ export function make<A, E, R>(self: Effect.Effect<A, E, R>) {
67
105
  return tuple(result, latestSuccess, execute)
68
106
  }
69
107
 
70
- export interface MutationOptionsBase {
108
+ /**
109
+ * An entry for `queryInvalidation`: a raw query key, an `{ id }` handler ref, or a
110
+ * `{ filters }` shape. The atom engine acts on entries that carry a concrete query
111
+ * key: raw arrays, handler refs, or `{ filters: { queryKey } }`. Predicate-only
112
+ * filters have no exact-key reactivity equivalent and fail fast.
113
+ */
114
+ export type QueryKeyInvalidationFilters = {
115
+ readonly queryKey: ReadonlyArray<unknown>
116
+ }
117
+ export type InvalidationEntry = InvalidateQueryInstruction<QueryKeyInvalidationFilters>
118
+ export type QueryInvalidationEffect<R = never> = (
119
+ keys: ReadonlyArray<ReadonlyArray<unknown>>
120
+ ) => Effect.Effect<void, never, R>
121
+ export interface QueryInvalidator<R = never> {
122
+ readonly invalidateAndAwait: QueryInvalidationEffect<R>
123
+ readonly invalidateSoft?: QueryInvalidationEffect<R>
124
+ }
125
+
126
+ export const atomQueryInvalidator: QueryInvalidator<Reactivity.Reactivity> = {
127
+ invalidateAndAwait,
128
+ invalidateSoft
129
+ }
130
+
131
+ export const combineQueryInvalidators = <R>(
132
+ ...invalidators: ReadonlyArray<QueryInvalidator<R>>
133
+ ): QueryInvalidator<R> => ({
134
+ invalidateAndAwait: (keys) =>
135
+ Effect.forEach(
136
+ invalidators,
137
+ (invalidator) => invalidator.invalidateAndAwait(keys),
138
+ { discard: true, concurrency: "unbounded" }
139
+ ),
140
+ invalidateSoft: (keys) =>
141
+ Effect.forEach(
142
+ invalidators,
143
+ (invalidator) => invalidator.invalidateSoft?.(keys) ?? Effect.void,
144
+ { discard: true, concurrency: "unbounded" }
145
+ )
146
+ })
147
+
148
+ const isRecord = (value: unknown): value is { readonly [key: string]: unknown } =>
149
+ typeof value === "object" && value !== null
150
+
151
+ const isQueryKey = (entry: InvalidationEntry): entry is ReadonlyArray<string> => Array.isArray(entry)
152
+
153
+ const queryKeyFromFilters = (
154
+ entry: Exclude<InvalidationEntry, ReadonlyArray<string>>
155
+ ): ReadonlyArray<unknown> | undefined => {
156
+ if (!("filters" in entry)) return undefined
157
+ const filters = entry.filters
158
+ if (!isRecord(filters)) return undefined
159
+ const queryKey = filters["queryKey"]
160
+ return Array.isArray(queryKey) ? queryKey : undefined
161
+ }
162
+
163
+ export interface MutationOptionsBase<A = unknown, B = A, E2 = never, R2 = never> {
71
164
  /**
72
165
  * 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.
166
+ * This can be overridden by providing a function that returns an array of filters and options,
167
+ * or RPC handlers directly (their query keys are derived automatically).
168
+ *
169
+ * @example
170
+ * ```ts
171
+ * queryInvalidation: (queryKey) => [
172
+ * { filters: { queryKey } },
173
+ * GetMe,
174
+ * PackListIndex
175
+ * ]
176
+ * ```
74
177
  */
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 {
178
+ queryInvalidation?: (
179
+ defaultKey: string[],
180
+ name: string,
181
+ input?: unknown,
182
+ output?: Exit.Exit<unknown, unknown>
183
+ ) => InvalidationEntry[]
83
184
  /**
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.
185
+ * Run an additional Effect after the mutation succeeds. Its output becomes the
186
+ * final result returned to the caller. Query cache is invalidated once on
187
+ * mutation exit and again after this Effect completes. Useful for long-running
188
+ * operations (e.g. polling a background job) where you want the caller to
189
+ * receive the downstream result and the cache to refresh once it is ready.
86
190
  *
87
- * @deprecated use `Command.fn` instead of `useMutation*` with `mapHandler` option.
191
+ * @example
192
+ * ```ts
193
+ * useMutation(startExportCommand, {
194
+ * select: (result) => pollUntilDone(result.jobId)
195
+ * // caller receives the pollUntilDone output, not the original result
196
+ * })
197
+ * ```
88
198
  */
89
- mapHandler?: (handler: Effect.Effect<A, E, R>, input: I) => Effect.Effect<A2, E2, R2>
199
+ select?: (result: A) => Effect.Effect<B, E2, R2>
90
200
  }
91
201
 
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
- ) => {
202
+ export const asResult = <Args extends readonly any[], A, E, R>(
203
+ handler: (...args: Args) => Effect.Effect<A, E, R>
204
+ ): readonly [
205
+ ComputedRef<AsyncResult.AsyncResult<A, E>>,
206
+ (...args: Args) => Effect.Effect<Exit.Exit<A, E>, never, R>
207
+ ] => {
113
208
  const state = shallowRef<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())
114
209
 
115
- const act = Effect.isEffect(handler)
116
- ? Effect
210
+ const act = (...args: Args) =>
211
+ Effect
117
212
  .sync(() => {
118
213
  state.value = AsyncResult.initial(true)
119
214
  })
120
215
  .pipe(
121
216
  Effect.andThen(Effect.suspend(() =>
122
- handler.pipe(
217
+ handler(...args).pipe(
123
218
  Effect.exit,
124
219
  Effect.tap((exit) => Effect.sync(() => (state.value = AsyncResult.fromExit(exit))))
125
220
  )
126
221
  ))
127
222
  )
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))))
223
+
224
+ return tuple(computed(() => state.value), act)
225
+ }
226
+
227
+ /**
228
+ * Like `asResult`, but for streams. The ref is updated with each emitted value
229
+ * (keeping `waiting: true`) and is finalised (with `waiting: false`) once the
230
+ * stream terminates successfully. Errors are surfaced as `AsyncResult.failure`.
231
+ */
232
+ export const asStreamResult = <Args extends readonly any[], A, E, R>(
233
+ handler: (...args: Args) => Stream.Stream<A, E, R>
234
+ ): readonly [ComputedRef<AsyncResult.AsyncResult<A, E>>, (...args: Args) => Effect.Effect<void, never, R>] => {
235
+ const state = shallowRef<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())
236
+
237
+ const runStream = (stream: Stream.Stream<A, E, R>): Effect.Effect<void, never, R> =>
238
+ Effect
239
+ .sync(() => {
240
+ state.value = AsyncResult.initial(true)
241
+ })
242
+ .pipe(
243
+ Effect.andThen(
244
+ stream.pipe(
245
+ Stream.runForEach((value) =>
246
+ Effect.sync(() => {
247
+ state.value = AsyncResult.success(value, { waiting: true })
248
+ })
249
+ ),
250
+ Effect.exit,
251
+ Effect.flatMap((exit) =>
252
+ Effect.sync(() => {
253
+ if (exit._tag === "Success") {
254
+ const current = state.value
255
+ if (AsyncResult.isSuccess(current)) {
256
+ state.value = AsyncResult.success(current.value, { waiting: false })
257
+ } else {
258
+ state.value = AsyncResult.initial(false)
259
+ }
260
+ } else {
261
+ state.value = AsyncResult.failure(exit.cause)
262
+ }
263
+ })
138
264
  )
139
- ))
265
+ )
140
266
  )
267
+ )
141
268
 
142
- return tuple(computed(() => state.value), act) as any
269
+ const act = (...args: Args) => runStream(handler(...args))
270
+
271
+ return tuple(computed(() => state.value), act)
143
272
  }
144
273
 
145
- export const invalidateQueries = (
146
- queryClient: QueryClient,
147
- self: { id: string; options?: ClientForOptions },
148
- options?: MutationOptionsBase["queryInvalidation"]
274
+ const buildInvalidateCache = <RInvalidator>(
275
+ self: { id: string; options?: ClientForOptions; disableQueryInvalidation?: boolean },
276
+ queryInvalidation: MutationOptionsBase["queryInvalidation"] | undefined,
277
+ queryInvalidator: QueryInvalidator<RInvalidator>
149
278
  ) => {
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(() => {
279
+ // Concrete reactivity keys to invalidate: a raw query key, one derived from an `{ id }`
280
+ // entry, or a compatibility `{ filters: { queryKey } }` entry.
281
+ // Predicate-only `{ filters }` entries have no exact-key reactivity equivalent and throw.
282
+ const getClientInvalidationKeys = (
283
+ input: unknown,
284
+ output: Exit.Exit<unknown, unknown>
285
+ ): ReadonlyArray<ReadonlyArray<unknown>> => {
162
286
  const queryKey = getQueryKey(self)
163
287
 
164
- if (options) {
165
- const opts = options(queryKey, self.id)
166
- if (!opts.length) {
167
- return Effect.void
288
+ if (queryInvalidation) {
289
+ const keys: Array<ReadonlyArray<unknown>> = []
290
+ for (const entry of queryInvalidation(queryKey, self.id, input, output)) {
291
+ if (isQueryKey(entry)) {
292
+ keys.push(entry)
293
+ continue
294
+ }
295
+ if ("id" in entry) {
296
+ keys.push(makeQueryKey(entry.options ? { id: entry.id, options: entry.options } : { id: entry.id }))
297
+ continue
298
+ }
299
+ const filterQueryKey = queryKeyFromFilters(entry)
300
+ if (filterQueryKey !== undefined) {
301
+ keys.push(filterQueryKey)
302
+ continue
303
+ }
304
+ throw new Error("Unsupported query invalidation filter: only filters.queryKey is supported")
168
305
  }
306
+ return keys
307
+ }
308
+
309
+ // No manual `invalidatesQueries`: contribute nothing. Invalidation rides entirely on the
310
+ // repository-derived write-dependencies (plus any server-provided keys) — there is no default
311
+ // namespace invalidation of the command's own resource.
312
+ return []
313
+ }
314
+
315
+ const invalidateCache = (
316
+ input: unknown,
317
+ output: Exit.Exit<unknown, unknown>,
318
+ serverKeys: ReadonlyArray<InvalidationKey>,
319
+ writeDependencies: DataDependencies.DataDependencies = DataDependencies.empty()
320
+ ) =>
321
+ Effect.suspend(() => {
322
+ if (self.disableQueryInvalidation) return Effect.void
323
+
324
+ const clientKeys = getClientInvalidationKeys(input, output)
325
+ // Derive extra reactivity keys from repository write-dependencies: every live query whose
326
+ // recorded read-dependencies intersect this mutation's writes must be refreshed.
327
+ const derivedKeys = getDerivedInvalidationKeys(writeDependencies)
328
+ // Invalidate exact reactivity keys (= the prefixes query atoms register under). Each key
329
+ // array is hashed structurally, matching the query-side registration.
330
+ const keys: ReadonlyArray<ReadonlyArray<unknown>> = [...clientKeys, ...serverKeys, ...derivedKeys]
331
+
332
+ if (!isReadonlyArrayNonEmpty(keys)) return Effect.void
333
+
334
+ const { awaitKeys, softKeys } = partitionInvalidationKeys(keys)
335
+
169
336
  return Effect
170
337
  .andThen(
171
- Effect.annotateCurrentSpan({ queryKey, opts }),
172
- Effect.forEach(opts, (_) => invalidateQueries(_.filters, _.options), { concurrency: "inherit" })
338
+ Effect.annotateCurrentSpan({
339
+ keys,
340
+ awaitKeys,
341
+ softKeys,
342
+ clientKeys,
343
+ serverKeys,
344
+ derivedKeys,
345
+ writeDependencies: [...writeDependencies]
346
+ }),
347
+ // refetch + AWAIT every live query registered under these keys, so by the time the
348
+ // mutation resolves the affected queries are fresh.
349
+ Effect.gen(function*() {
350
+ if (softKeys.length > 0) {
351
+ yield* (queryInvalidator.invalidateSoft?.(softKeys) ?? Effect.void).pipe(
352
+ Effect.catchCause((cause) =>
353
+ reportRuntimeError(cause, { invalidation: "soft", keys: softKeys }).pipe(Effect.asVoid)
354
+ ),
355
+ Effect.forkDetach({ startImmediately: true })
356
+ )
357
+ }
358
+ if (awaitKeys.length > 0) {
359
+ yield* queryInvalidator.invalidateAndAwait(awaitKeys)
360
+ }
361
+ })
173
362
  )
174
- .pipe(Effect.withSpan("client.query.invalidation", {}, { captureStackTrace: false }))
175
- }
363
+ .pipe(
364
+ Effect.tap(Effect.sleep(0.1)), // allow for refs to update etc
365
+ Effect.withSpan("client.query.invalidation", {}, { captureStackTrace: false })
366
+ )
367
+ })
176
368
 
177
- if (!queryKey) return Effect.void
369
+ return invalidateCache
370
+ }
178
371
 
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
- })
372
+ export const invalidateQueries = <RInvalidator>(
373
+ self: { id: string; options?: ClientForOptions; disableQueryInvalidation?: boolean },
374
+ options: MutationOptionsBase | undefined,
375
+ queryInvalidator: QueryInvalidator<RInvalidator>
376
+ ) => {
377
+ const invalidateCache = buildInvalidateCache(self, options?.queryInvalidation, queryInvalidator)
193
378
 
194
- const handle = <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.ensuring(self, invalidateCache)
379
+ const select = options?.select
380
+
381
+ const handle = <A, E, R>(eff: Effect.Effect<A, E, R>, input?: unknown) =>
382
+ Effect.gen(function*() {
383
+ const keysRef = yield* Ref.make<ReadonlyArray<InvalidationKey>>([])
384
+ const readsRef = yield* Ref.make(DataDependencies.empty())
385
+ const writesRef = yield* Ref.make(DataDependencies.empty())
386
+ const dependencyRecorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef)
387
+ const result = yield* eff.pipe(
388
+ Effect.provideService(InvalidationKeysFromServer, makeInvalidationKeysService(keysRef)),
389
+ Effect.provideService(DataDependencies.DataDependencyRecorder, dependencyRecorder),
390
+ Effect.onExit((exit) =>
391
+ Effect.gen(function*() {
392
+ const serverKeys = yield* Ref.get(keysRef)
393
+ const writeDependencies = yield* Ref.get(writesRef)
394
+ yield* invalidateCache(input, exit, serverKeys, writeDependencies)
395
+ })
396
+ )
397
+ )
398
+ if (select) {
399
+ return yield* select(result).pipe(
400
+ Effect.onExit((exit) =>
401
+ Effect.gen(function*() {
402
+ const serverKeys = yield* Ref.get(keysRef)
403
+ const writeDependencies = yield* Ref.get(writesRef)
404
+ yield* invalidateCache(input, exit, serverKeys, writeDependencies)
405
+ })
406
+ )
407
+ )
408
+ }
409
+ return result
410
+ })
195
411
 
196
412
  return handle
197
413
  }
198
414
 
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))
415
+ /**
416
+ * A callable mutation result. When `I = void` the input argument may be omitted.
417
+ */
418
+ export interface MutationFn<I, A, E, R, Id extends string> {
419
+ <B = A, E2 = never, R2 = never>(
420
+ input: I,
421
+ options?: MutationOptionsBase<A, B, E2, R2>
422
+ ): Effect.Effect<B, E | E2, R | R2>
423
+ readonly id: Id
424
+ }
225
425
 
426
+ export const makeMutation = <RInvalidator>(queryInvalidator: QueryInvalidator<RInvalidator>) => {
427
+ /**
428
+ * Pass a function that returns an Effect, e.g from a client action.
429
+ * Executes query cache invalidation based on default rules or provided option.
430
+ * When `I = void` the input argument may be omitted.
431
+ */
432
+ const useMutation = <I, E, A, R, Request extends Req, Id extends string>(
433
+ self: RequestHandlerWithInput<I, A, E, R, Request, Id>
434
+ ): MutationFn<I, A, E, R, Id> => {
435
+ const r = (i: I, options?: MutationOptionsBase) =>
436
+ invalidateQueries(self, options, queryInvalidator)(self.handler(i), i)
226
437
  return Object.assign(r, { id: self.id }) as any
227
438
  }
228
439
  return useMutation
229
440
  }
230
441
 
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
-
442
+ export const useMakeMutation = <RInvalidator>(queryInvalidator: QueryInvalidator<RInvalidator>) => {
443
+ /**
444
+ * Pass a function that returns an Effect, e.g from a client action.
445
+ * Executes query cache invalidation based on default rules or provided option.
446
+ * When `I = void` the input argument may be omitted.
447
+ */
448
+ const useMutation = <I, E, A, R, Request extends Req, Id extends string>(
449
+ self: RequestHandlerWithInput<I, A, E, R, Request, Id>
450
+ ): MutationFn<I, A, E, R, Id> => {
451
+ const r = (i: I, options?: MutationOptionsBase) =>
452
+ invalidateQueries(self, options, queryInvalidator)(self.handler(i), i)
260
453
  return Object.assign(r, { id: self.id }) as any
261
454
  }
262
455
  return useMutation
263
456
  }
457
+
458
+ /**
459
+ * Returns a stream-based mutation factory for use with `streamFn`.
460
+ * The outer Effect sets up per-invocation invalidation scaffolding
461
+ * and returns a stream that triggers query invalidation via `Stream.ensuring` when it completes.
462
+ *
463
+ * Use with `streamFn` / `Command.streamFn(id)(mutateHandler, ...combinators)` so that
464
+ * the command manages its own reactive state internally.
465
+ */
466
+ export const makeStreamMutation2 = <RInvalidator>(queryInvalidator: QueryInvalidator<RInvalidator>) => {
467
+ return (
468
+ self: {
469
+ id: string
470
+ options?: ClientForOptions
471
+ disableQueryInvalidation?: boolean
472
+ handler: (i: any) => Stream.Stream<any, any, any>
473
+ },
474
+ mergedInvalidation?: MutationOptionsBase["queryInvalidation"]
475
+ ) => {
476
+ const invCache = buildInvalidateCache(self, mergedInvalidation, queryInvalidator)
477
+
478
+ const makeInvocationEffect = (input: unknown, source: Stream.Stream<any, any, any>) =>
479
+ Effect.gen(function*() {
480
+ const keysRef = yield* Ref.make<ReadonlyArray<InvalidationKey>>([])
481
+ // Server invalidation keys stay settlement-only: flushing them from `add` refetched
482
+ // live queries once per chunk (One-Pick List storms). Write-deps flush once when the
483
+ // first write arrives (job create → GetActiveJob) and again from `ensuring`.
484
+ const invKeys = makeInvalidationKeysService(keysRef)
485
+ const readsRef = yield* Ref.make(DataDependencies.empty())
486
+ const writesRef = yield* Ref.make(DataDependencies.empty())
487
+ const dependencyRecorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef)
488
+ const lastRef = yield* Ref.make<any>(undefined)
489
+ let flushedFirstWrites = false
490
+ const flushFirstWrites = Effect.gen(function*() {
491
+ if (flushedFirstWrites) return
492
+ const writeDependencies = yield* Ref.get(writesRef)
493
+ if (!DataDependencies.isNonEmpty(writeDependencies)) return
494
+ flushedFirstWrites = true
495
+ const lastValue = yield* Ref.get(lastRef)
496
+ yield* invCache(input, Exit.succeed(lastValue), [], writeDependencies)
497
+ })
498
+ return source.pipe(
499
+ Stream.provideService(InvalidationKeysFromServer, invKeys),
500
+ Stream.provideService(DataDependencies.DataDependencyRecorder, dependencyRecorder),
501
+ Stream.tap((v) => Ref.set(lastRef, v)),
502
+ Stream.tap(() => flushFirstWrites),
503
+ Stream.ensuring(
504
+ Effect.gen(function*() {
505
+ const lastValue = yield* Ref.get(lastRef)
506
+ const serverKeys = yield* Ref.get(keysRef)
507
+ const writeDependencies = yield* Ref.get(writesRef)
508
+ yield* invCache(input, Exit.succeed(lastValue), serverKeys, writeDependencies)
509
+ })
510
+ )
511
+ )
512
+ })
513
+
514
+ return (i: any) => Stream.unwrap(makeInvocationEffect(i, self.handler(i)))
515
+ }
516
+ }