@effect-app/vue 4.0.0-beta.271 → 4.0.0-beta.273

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/mutate.ts CHANGED
@@ -1,18 +1,19 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { matchQuery } from "@tanstack/query-core"
3
- import { type InvalidateOptions, type InvalidateQueryFilters, type QueryClient, useQueryClient } from "@tanstack/vue-query"
4
2
  import { type InvalidationKey, InvalidationKeysFromServer, makeInvalidationKeysService, makeQueryKey, type Req } from "effect-app/client"
5
3
  import type { ClientForOptions, RequestHandlerWithInput } from "effect-app/client/clientFor"
6
4
  import type { InvalidateQueryInstruction } from "effect-app/client/makeClient"
7
5
  import * as Effect from "effect-app/Effect"
8
6
  import { tuple } from "effect-app/Function"
9
7
  import * as Option from "effect-app/Option"
8
+ import { isReadonlyArrayNonEmpty } from "effect/Array"
10
9
  import type * as Cause from "effect/Cause"
11
10
  import * as Exit from "effect/Exit"
12
11
  import * as Ref from "effect/Ref"
13
12
  import * as Stream from "effect/Stream"
14
13
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
14
+ import type * as Reactivity from "effect/unstable/reactivity/Reactivity"
15
15
  import { computed, type ComputedRef, shallowRef } from "vue"
16
+ import { invalidateAndAwait } from "./atomQuery.ts"
16
17
 
17
18
  export type GetQueryKey = (h: { id: string; options?: ClientForOptions }) => string[]
18
19
 
@@ -103,11 +104,51 @@ export function make<A, E, R>(self: Effect.Effect<A, E, R>) {
103
104
  }
104
105
 
105
106
  /**
106
- * An entry for `queryInvalidation`. Narrowed alias of effect-app's
107
- * `InvalidateQueryInstruction` with tanstack-query's `InvalidateQueryFilters`
108
- * and `InvalidateOptions` substituted for the structural defaults.
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.
109
111
  */
110
- export type InvalidationEntry = InvalidateQueryInstruction<InvalidateQueryFilters, InvalidateOptions>
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
+ }
111
152
 
112
153
  export interface MutationOptionsBase<A = unknown, B = A, E2 = never, R2 = never> {
113
154
  /**
@@ -220,56 +261,42 @@ export const asStreamResult = <Args extends readonly any[], A, E, R>(
220
261
  return tuple(computed(() => state.value), act) as any
221
262
  }
222
263
 
223
- const buildInvalidateCache = (
224
- queryClient: QueryClient,
264
+ const buildInvalidateCache = <RInvalidator>(
225
265
  self: { id: string; options?: ClientForOptions },
226
- queryInvalidation?: MutationOptionsBase["queryInvalidation"]
266
+ queryInvalidation: MutationOptionsBase["queryInvalidation"] | undefined,
267
+ queryInvalidator: QueryInvalidator<RInvalidator>
227
268
  ) => {
228
- type InvalidationTarget = {
229
- readonly filters: InvalidateQueryFilters | undefined
230
- readonly options: InvalidateOptions | undefined
231
- }
232
-
233
- const invalidateQueriesFn = (
234
- filters?: InvalidateQueryFilters,
235
- options?: InvalidateOptions
236
- ) =>
237
- Effect.currentSpan.pipe(
238
- Effect.orElseSucceed(() => null),
239
- Effect.flatMap((span) =>
240
- Effect.promise(() => queryClient.invalidateQueries(filters, { ...options, updateMeta: { span } }))
241
- )
242
- )
243
-
244
- const getClientInvalidationTargets = (
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 = (
245
273
  input: unknown,
246
274
  output: Exit.Exit<unknown, unknown>
247
- ): ReadonlyArray<InvalidationTarget> => {
275
+ ): ReadonlyArray<ReadonlyArray<unknown>> => {
248
276
  const queryKey = getQueryKey(self)
249
277
 
250
278
  if (queryInvalidation) {
251
- return queryInvalidation(queryKey, self.id, input, output).map((entry): InvalidationTarget => {
252
- if (Array.isArray(entry)) {
253
- return { filters: { queryKey: entry }, options: undefined }
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
254
284
  }
255
- const obj = entry as Exclude<InvalidationEntry, ReadonlyArray<string>>
256
- if ("id" in obj) {
257
- return {
258
- filters: {
259
- queryKey: makeQueryKey(obj.options ? { id: obj.id, options: obj.options } : { id: obj.id })
260
- },
261
- options: undefined
262
- }
285
+ if ("id" in entry) {
286
+ keys.push(makeQueryKey(entry.options ? { id: entry.id, options: entry.options } : { id: entry.id }))
287
+ continue
263
288
  }
264
- return { filters: obj.filters, options: obj.options }
265
- })
266
- }
267
-
268
- if (!queryKey) {
269
- return []
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")
295
+ }
296
+ return keys
270
297
  }
271
298
 
272
- return [{ filters: { queryKey }, options: undefined }]
299
+ return queryKey ? [queryKey] : []
273
300
  }
274
301
 
275
302
  const invalidateCache = (
@@ -278,57 +305,22 @@ const buildInvalidateCache = (
278
305
  serverKeys: ReadonlyArray<InvalidationKey>
279
306
  ) =>
280
307
  Effect.suspend(() => {
281
- const clientTargets = getClientInvalidationTargets(input, output)
282
- const serverTargets: ReadonlyArray<InvalidationTarget> = serverKeys.map((queryKey) => ({
283
- filters: { queryKey },
284
- options: undefined
285
- }))
286
- const allTargets: ReadonlyArray<InvalidationTarget> = [...clientTargets, ...serverTargets]
287
-
288
- if (!allTargets.length) return Effect.void
289
-
290
- // Group targets by refetchType + options so each group can be merged into a single
291
- // invalidateQueries call using a predicate, reducing N calls to 1 in the common case.
292
- type Group = {
293
- targets: Array<InvalidationTarget>
294
- refetchType: InvalidateQueryFilters["refetchType"]
295
- options: InvalidateOptions | undefined
296
- }
297
- const groups = new Map<string, Group>()
298
- for (const target of allTargets) {
299
- const key = `${target.filters?.refetchType ?? ""}|${target.options?.cancelRefetch ?? ""}|${
300
- target.options?.throwOnError?.toString() ?? ""
301
- }`
302
- const existing = groups.get(key)
303
- if (existing) {
304
- existing.targets.push(target)
305
- } else {
306
- groups.set(key, { targets: [target], refetchType: target.filters?.refetchType, options: target.options })
307
- }
308
- }
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
309
314
 
310
315
  return Effect
311
316
  .andThen(
312
- Effect.annotateCurrentSpan({ clientTargets, serverKeys }),
313
- Effect.forEach(
314
- groups.values(),
315
- ({ options, refetchType, targets }) =>
316
- invalidateQueriesFn(
317
- {
318
- ...(refetchType !== undefined ? { refetchType } : {}),
319
- predicate: (query) => targets.some((t) => t.filters ? matchQuery(t.filters, query) : true)
320
- },
321
- options
322
- ),
323
- { discard: true, concurrency: "inherit" }
324
- )
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)
325
321
  )
326
322
  .pipe(
327
- Effect.tap(
328
- // hand over control back to the event loop so that state can be updated..
329
- // TODO: should we do this in general on any mutation, regardless of invalidation?
330
- Effect.sleep(0)
331
- ),
323
+ Effect.tap(Effect.sleep(0.1)), // allow for refs to update etc
332
324
  Effect.withSpan("client.query.invalidation", {}, { captureStackTrace: false })
333
325
  )
334
326
  })
@@ -336,12 +328,12 @@ const buildInvalidateCache = (
336
328
  return invalidateCache
337
329
  }
338
330
 
339
- export const invalidateQueries = (
340
- queryClient: QueryClient,
331
+ export const invalidateQueries = <RInvalidator>(
341
332
  self: { id: string; options?: ClientForOptions },
342
- options?: MutationOptionsBase
333
+ options: MutationOptionsBase | undefined,
334
+ queryInvalidator: QueryInvalidator<RInvalidator>
343
335
  ) => {
344
- const invalidateCache = buildInvalidateCache(queryClient, self, options?.queryInvalidation)
336
+ const invalidateCache = buildInvalidateCache(self, options?.queryInvalidation, queryInvalidator)
345
337
 
346
338
  const select = options?.select
347
339
 
@@ -384,7 +376,7 @@ export interface MutationFn<I, A, E, R, Id extends string> {
384
376
  readonly id: Id
385
377
  }
386
378
 
387
- export const makeMutation = () => {
379
+ export const makeMutation = <RInvalidator>(queryInvalidator: QueryInvalidator<RInvalidator>) => {
388
380
  /**
389
381
  * Pass a function that returns an Effect, e.g from a client action.
390
382
  * Executes query cache invalidation based on default rules or provided option.
@@ -393,17 +385,14 @@ export const makeMutation = () => {
393
385
  const useMutation = <I, E, A, R, Request extends Req, Id extends string>(
394
386
  self: RequestHandlerWithInput<I, A, E, R, Request, Id>
395
387
  ): MutationFn<I, A, E, R, Id> => {
396
- const queryClient = useQueryClient()
397
- const r = (i: I, options?: MutationOptionsBase) => invalidateQueries(queryClient, self, options)(self.handler(i), i)
388
+ const r = (i: I, options?: MutationOptionsBase) =>
389
+ invalidateQueries(self, options, queryInvalidator)(self.handler(i), i)
398
390
  return Object.assign(r, { id: self.id }) as any
399
391
  }
400
392
  return useMutation
401
393
  }
402
394
 
403
- // calling hooks in the body
404
- export const useMakeMutation = () => {
405
- const queryClient = useQueryClient()
406
-
395
+ export const useMakeMutation = <RInvalidator>(queryInvalidator: QueryInvalidator<RInvalidator>) => {
407
396
  /**
408
397
  * Pass a function that returns an Effect, e.g from a client action.
409
398
  * Executes query cache invalidation based on default rules or provided option.
@@ -412,7 +401,8 @@ export const useMakeMutation = () => {
412
401
  const useMutation = <I, E, A, R, Request extends Req, Id extends string>(
413
402
  self: RequestHandlerWithInput<I, A, E, R, Request, Id>
414
403
  ): MutationFn<I, A, E, R, Id> => {
415
- const r = (i: I, options?: MutationOptionsBase) => invalidateQueries(queryClient, self, options)(self.handler(i), i)
404
+ const r = (i: I, options?: MutationOptionsBase) =>
405
+ invalidateQueries(self, options, queryInvalidator)(self.handler(i), i)
416
406
  return Object.assign(r, { id: self.id }) as any
417
407
  }
418
408
  return useMutation
@@ -425,12 +415,8 @@ export const useMakeMutation = () => {
425
415
  *
426
416
  * Use with `streamFn` / `Command.streamFn(id)(mutateHandler, ...combinators)` so that
427
417
  * the command manages its own reactive state internally.
428
- *
429
- * Must be called inside a Vue setup context (uses `useQueryClient` internally).
430
418
  */
431
- export const makeStreamMutation2 = () => {
432
- const queryClient = useQueryClient()
433
-
419
+ export const makeStreamMutation2 = <RInvalidator>(queryInvalidator: QueryInvalidator<RInvalidator>) => {
434
420
  return (
435
421
  self: {
436
422
  id: string
@@ -439,12 +425,17 @@ export const makeStreamMutation2 = () => {
439
425
  },
440
426
  mergedInvalidation?: MutationOptionsBase["queryInvalidation"]
441
427
  ) => {
442
- const invCache = buildInvalidateCache(queryClient, self, mergedInvalidation)
428
+ const invCache = buildInvalidateCache(self, mergedInvalidation, queryInvalidator)
443
429
 
444
430
  const makeInvocationEffect = (input: unknown, source: Stream.Stream<any, any, any>) =>
445
431
  Effect.gen(function*() {
446
432
  const keysRef = yield* Ref.make<ReadonlyArray<InvalidationKey>>([])
447
- const invKeys = makeInvalidationKeysService(keysRef, (key) => invCache(input, Exit.succeed(undefined), [key]))
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
+ )
448
439
  const lastRef = yield* Ref.make<any>(undefined)
449
440
  return source.pipe(
450
441
  Stream.provideService(InvalidationKeysFromServer, invKeys),