@effect-app/vue 4.0.0-beta.282 → 4.0.0-beta.284

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/atomQuery.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * abstraction (mirrors AtomRpc's recipe; see docs/atom-query-plan.md).
15
15
  */
16
16
  import { defaultRegistry } from "@effect/atom-vue"
17
- import { makeQueryKey } from "effect-app/client"
17
+ import { DataDependencies, makeQueryKey } from "effect-app/client"
18
18
  import type { ClientForOptions } from "effect-app/client/clientFor"
19
19
  import { ServiceUnavailableError } from "effect-app/client/errors"
20
20
  import * as Effect from "effect-app/Effect"
@@ -25,12 +25,14 @@ import * as Duration from "effect/Duration"
25
25
  import * as Equal from "effect/Equal"
26
26
  import * as Hash from "effect/Hash"
27
27
  import type * as Layer from "effect/Layer"
28
+ import * as Ref from "effect/Ref"
28
29
  import * as Stream from "effect/Stream"
29
30
  import { isHttpClientError } from "effect/unstable/http/HttpClientError"
30
31
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
31
32
  import * as Atom from "effect/unstable/reactivity/Atom"
32
33
  import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
33
34
  import * as Reactivity from "effect/unstable/reactivity/Reactivity"
35
+ import { clearQueryReadDependencies, setQueryReadDependencies } from "./dependencyMetadata.ts"
34
36
  import { reportRuntimeError } from "./lib.ts"
35
37
 
36
38
  /** All non-empty prefixes of a key, longest last. `[a,b,c]` -> `[[a],[a,b],[a,b,c]]`. */
@@ -83,6 +85,26 @@ const trackWritableByKeys =
83
85
  )
84
86
  }
85
87
 
88
+ /**
89
+ * Keep a query's recorded read-dependencies registered for as long as it is alive, and drop them when
90
+ * its atom is GC'd (mirrors `trackByKeys`).
91
+ *
92
+ * `recordReads` only stores the deps on an actual fetch. A query that unmounts and later remounts from
93
+ * cache (within idle-ttl) does NOT re-run the handler, so its registry entry — cleared by the previous
94
+ * teardown's finalizer — would never be restored, and a subsequent mutation could not derive it as an
95
+ * invalidation target. Re-asserting the last-known reads on every (re)subscribe closes that gap, so the
96
+ * registry mirrors the live query even across cache-hit remounts.
97
+ */
98
+ const trackReadDependencies =
99
+ (key: ReadonlyArray<unknown>, getReads: () => DataDependencies.DataDependencies) =>
100
+ <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
101
+ Atom.transform(atom, (get) => {
102
+ const reads = getReads()
103
+ if (reads.length > 0) setQueryReadDependencies(key, reads)
104
+ get.addFinalizer(() => clearQueryReadDependencies(key))
105
+ return get(atom)
106
+ }, { initialValueTarget: atom })
107
+
86
108
  const atomsForKeys = (keys: ReadonlyArray<unknown>): ReadonlyArray<Atom.Atom<AsyncResult.AsyncResult<any, any>>> => {
87
109
  const atoms = new Set<Atom.Atom<AsyncResult.AsyncResult<any, any>>>()
88
110
  for (const key of keys) {
@@ -319,9 +341,25 @@ export const buildQueryFamily = <I, A, E>(
319
341
  const baseKey = makeQueryKey(self) // hierarchical, input-independent
320
342
 
321
343
  return Atom.family((input: I) => {
322
- let atom: Atom.Atom<AsyncResult.AsyncResult<A, E>> = rt.runtime.atom(
323
- self
344
+ const fullKey = [...baseKey, input]
345
+ // Record the repository/server read-dependencies seen while fetching, keyed by `fullKey`, so a
346
+ // later mutation whose writes intersect them can derive this query as an invalidation target.
347
+ // The last recorded reads are retained on the (memoized) family atom so `trackReadDependencies`
348
+ // can re-assert them on a cache-hit remount that never re-runs the handler.
349
+ let lastReads: DataDependencies.DataDependencies = []
350
+ const recordReads = Effect.gen(function*() {
351
+ const readsRef = yield* Ref.make<DataDependencies.DataDependencies>([])
352
+ const writesRef = yield* Ref.make<DataDependencies.DataDependencies>([])
353
+ const recorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef)
354
+ const result = yield* self
324
355
  .handler(input)
356
+ .pipe(Effect.provideService(DataDependencies.DataDependencyRecorder, recorder))
357
+ lastReads = yield* Ref.get(readsRef)
358
+ setQueryReadDependencies(fullKey, lastReads)
359
+ return result
360
+ })
361
+ let atom: Atom.Atom<AsyncResult.AsyncResult<A, E>> = rt.runtime.atom(
362
+ recordReads
325
363
  .pipe(
326
364
  Effect.retry({ times: 5, while: isRetryable }),
327
365
  Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)),
@@ -331,13 +369,13 @@ export const buildQueryFamily = <I, A, E>(
331
369
  // Register under every prefix of the full key => hierarchical (prefix) invalidation. Two roles:
332
370
  // - withReactivity: `Reactivity.invalidate(key)` refreshes this atom (the actual refetch).
333
371
  // - trackByKeys: records the atom in `keyAtoms` so the mutation can AWAIT its settle.
334
- const fullKey = [...baseKey, input]
335
372
  const projectedFullKey = self.queryKeyProjectionHash === undefined
336
373
  ? fullKey
337
374
  : [...baseKey, self.queryKeyProjectionHash, input]
338
375
  const reactivityKeys = uniqueKeys([...prefixesOf(fullKey), ...prefixesOf(projectedFullKey)])
339
376
  atom = rt.factory.withReactivity(reactivityKeys)(atom)
340
377
  atom = trackByKeys(reactivityKeys)(atom)
378
+ atom = trackReadDependencies(fullKey, () => lastReads)(atom)
341
379
  // gcTime LAST so the whole chain (incl. the registration + tracking) stays alive through the
342
380
  // idle window, letting invalidation reach a cached-but-unmounted query.
343
381
  atom = Atom.setIdleTTL(atom, defaults.gcTime)
@@ -0,0 +1,38 @@
1
+ import { DataDependencies } from "effect-app/client"
2
+ import * as Hash from "effect/Hash"
3
+
4
+ // Maps a live query's reactivity key (its full `[...queryKey, input]` key) to the
5
+ // data dependencies it read while fetching. Populated when the query handler resolves and
6
+ // cleared when the query atom is GC'd, so the set mirrors the live query atoms (mounted or
7
+ // cached-within-ttl) — the atom equivalent of the former tanstack query cache.
8
+ type Entry = { readonly key: ReadonlyArray<unknown>; readonly reads: DataDependencies.DataDependencies }
9
+ const readDependencies = new Map<number, Entry>()
10
+
11
+ export const setQueryReadDependencies = (
12
+ key: ReadonlyArray<unknown>,
13
+ reads: DataDependencies.DataDependencies
14
+ ) => {
15
+ const h = Hash.hash(key)
16
+ if (reads.length === 0) readDependencies.delete(h)
17
+ else readDependencies.set(h, { key, reads })
18
+ }
19
+
20
+ export const clearQueryReadDependencies = (key: ReadonlyArray<unknown>) => {
21
+ readDependencies.delete(Hash.hash(key))
22
+ }
23
+
24
+ /**
25
+ * Reactivity keys of every live query whose recorded read-dependencies intersect this
26
+ * mutation's `writeDependencies`. Returned keys are passed to `invalidateAndAwait`, refreshing
27
+ * exactly those queries.
28
+ */
29
+ export const getDerivedInvalidationKeys = (
30
+ writeDependencies: ReadonlyArray<DataDependencies.DataDependency>
31
+ ): ReadonlyArray<ReadonlyArray<unknown>> => {
32
+ if (writeDependencies.length === 0) return []
33
+ const keys: Array<ReadonlyArray<unknown>> = []
34
+ for (const { key, reads } of readDependencies.values()) {
35
+ if (DataDependencies.intersects(reads, writeDependencies)) keys.push(key)
36
+ }
37
+ return keys
38
+ }
@@ -1,6 +1,6 @@
1
1
  import { injectRegistry } from "@effect/atom-vue"
2
2
  import { QueryClient, useQuery as useTanstackQuery } from "@tanstack/vue-query"
3
- import { makeQueryKey, type Req } from "effect-app/client"
3
+ import { DataDependencies, makeQueryKey, type Req } from "effect-app/client"
4
4
  import type { RequestHandlerWithInput } from "effect-app/client/clientFor"
5
5
  import { CauseException, ServiceUnavailableError } from "effect-app/client/errors"
6
6
  import type * as Context from "effect-app/Context"
@@ -8,11 +8,13 @@ import * as Effect from "effect-app/Effect"
8
8
  import * as Option from "effect-app/Option"
9
9
  import * as S from "effect-app/Schema"
10
10
  import * as Cause from "effect/Cause"
11
+ import * as Ref from "effect/Ref"
11
12
  import { isHttpClientError } from "effect/unstable/http/HttpClientError"
12
13
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
13
14
  import * as Atom from "effect/unstable/reactivity/Atom"
14
15
  import { computed, type MaybeRefOrGetter, shallowRef, toValue, watch, type WatchSource } from "vue"
15
16
  import { replaceEqualDeep } from "../atomQuery.ts"
17
+ import { clearQueryReadDependencies, setQueryReadDependencies } from "../dependencyMetadata.ts"
16
18
  import { reportRuntimeError } from "../lib.ts"
17
19
  import type { QueryInvalidator } from "../mutate.ts"
18
20
  import type { CustomDefinedInitialQueryOptions, CustomDefinedPlaceholderQueryOptions, CustomUndefinedInitialQueryOptions, CustomUseQueryOptions, MakeQuery2, QueryCacheUpdater, QueryHandle, QueryObserverResult, RefetchOptions } from "../query.ts"
@@ -131,6 +133,12 @@ export const makeTanstackQuery = <R>(
131
133
  getRuntime: () => Context.Context<R>,
132
134
  queryClient: QueryClient
133
135
  ): MakeQuery2<R> => {
136
+ // Drop a query's recorded read-dependencies when tanstack evicts it from the cache, so the
137
+ // registry mirrors the live queries (mirrors the atom engine's `trackReadDependencies` finalizer).
138
+ queryClient.getQueryCache().subscribe((event) => {
139
+ if (event.type === "removed") clearQueryReadDependencies(event.query.queryKey)
140
+ })
141
+
134
142
  const useQuery: MakeQuery2<R> = <I, A, E, Request extends Req, Name extends string>(
135
143
  q: RequestHandlerWithInput<I, A, E, R, Request, Name>
136
144
  ) =>
@@ -161,12 +169,24 @@ export const makeTanstackQuery = <R>(
161
169
  }),
162
170
  queryFn: ({ signal }: { readonly signal: AbortSignal }) =>
163
171
  runPromise(
164
- q
165
- .handler(resolveInput(arg, options?.mode)!)
166
- .pipe(
167
- Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)),
168
- Effect.withSpan(`query ${q.id}`, {}, { captureStackTrace: false })
169
- ),
172
+ Effect.gen(function*() {
173
+ const input = resolveInput(arg, options?.mode)!
174
+ // Record the repository/server read-dependencies seen while fetching, keyed by the
175
+ // tanstack queryKey, so a later mutation whose writes intersect them derives this query
176
+ // as an invalidation target (the tanstack invalidator invalidates by this same key).
177
+ const readsRef = yield* Ref.make<DataDependencies.DataDependencies>([])
178
+ const writesRef = yield* Ref.make<DataDependencies.DataDependencies>([])
179
+ const recorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef)
180
+ const result = yield* q
181
+ .handler(input)
182
+ .pipe(
183
+ Effect.provideService(DataDependencies.DataDependencyRecorder, recorder),
184
+ Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)),
185
+ Effect.withSpan(`query ${q.id}`, {}, { captureStackTrace: false })
186
+ )
187
+ setQueryReadDependencies(fullQueryKey(q, queryKey, input), yield* Ref.get(readsRef))
188
+ return result
189
+ }),
170
190
  { signal }
171
191
  )
172
192
  }, queryClient)
package/src/mutate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { type InvalidationKey, InvalidationKeysFromServer, makeInvalidationKeysService, makeQueryKey, type Req } from "effect-app/client"
2
+ import { DataDependencies, type InvalidationKey, InvalidationKeysFromServer, makeInvalidationKeysService, makeQueryKey, type Req } from "effect-app/client"
3
3
  import type { ClientForOptions, RequestHandlerWithInput } from "effect-app/client/clientFor"
4
4
  import type { InvalidateQueryInstruction } from "effect-app/client/makeClient"
5
5
  import * as Effect from "effect-app/Effect"
@@ -14,6 +14,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
14
14
  import type * as Reactivity from "effect/unstable/reactivity/Reactivity"
15
15
  import { computed, type ComputedRef, shallowRef } from "vue"
16
16
  import { invalidateAndAwait } from "./atomQuery.ts"
17
+ import { getDerivedInvalidationKeys } from "./dependencyMetadata.ts"
17
18
 
18
19
  export type GetQueryKey = (h: { id: string; options?: ClientForOptions }) => string[]
19
20
 
@@ -296,25 +297,32 @@ const buildInvalidateCache = <RInvalidator>(
296
297
  return keys
297
298
  }
298
299
 
299
- return queryKey ? [queryKey] : []
300
+ // No manual `invalidatesQueries`: contribute nothing. Invalidation rides entirely on the
301
+ // repository-derived write-dependencies (plus any server-provided keys) — there is no default
302
+ // namespace invalidation of the command's own resource.
303
+ return []
300
304
  }
301
305
 
302
306
  const invalidateCache = (
303
307
  input: unknown,
304
308
  output: Exit.Exit<unknown, unknown>,
305
- serverKeys: ReadonlyArray<InvalidationKey>
309
+ serverKeys: ReadonlyArray<InvalidationKey>,
310
+ writeDependencies: ReadonlyArray<DataDependencies.DataDependency> = []
306
311
  ) =>
307
312
  Effect.suspend(() => {
308
313
  const clientKeys = getClientInvalidationKeys(input, output)
314
+ // Derive extra reactivity keys from repository write-dependencies: every live query whose
315
+ // recorded read-dependencies intersect this mutation's writes must be refreshed.
316
+ const derivedKeys = getDerivedInvalidationKeys(writeDependencies)
309
317
  // Invalidate exact reactivity keys (= the prefixes query atoms register under). Each key
310
318
  // array is hashed structurally, matching the query-side registration.
311
- const keys: ReadonlyArray<ReadonlyArray<unknown>> = [...clientKeys, ...serverKeys]
319
+ const keys: ReadonlyArray<ReadonlyArray<unknown>> = [...clientKeys, ...serverKeys, ...derivedKeys]
312
320
 
313
321
  if (!isReadonlyArrayNonEmpty(keys)) return Effect.void
314
322
 
315
323
  return Effect
316
324
  .andThen(
317
- Effect.annotateCurrentSpan({ clientKeys, serverKeys }),
325
+ Effect.annotateCurrentSpan({ clientKeys, serverKeys, writeDependencies }),
318
326
  // refetch + AWAIT every live query registered under these keys, so by the time the
319
327
  // mutation resolves the affected queries are fresh.
320
328
  queryInvalidator.invalidateAndAwait(keys)
@@ -340,12 +348,17 @@ export const invalidateQueries = <RInvalidator>(
340
348
  const handle = <A, E, R>(eff: Effect.Effect<A, E, R>, input?: unknown) =>
341
349
  Effect.gen(function*() {
342
350
  const keysRef = yield* Ref.make<ReadonlyArray<InvalidationKey>>([])
351
+ const readsRef = yield* Ref.make<DataDependencies.DataDependencies>([])
352
+ const writesRef = yield* Ref.make<DataDependencies.DataDependencies>([])
353
+ const dependencyRecorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef)
343
354
  const result = yield* eff.pipe(
344
355
  Effect.provideService(InvalidationKeysFromServer, makeInvalidationKeysService(keysRef)),
356
+ Effect.provideService(DataDependencies.DataDependencyRecorder, dependencyRecorder),
345
357
  Effect.onExit((exit) =>
346
358
  Effect.gen(function*() {
347
359
  const serverKeys = yield* Ref.get(keysRef)
348
- yield* invalidateCache(input, exit, serverKeys)
360
+ const writeDependencies = yield* Ref.get(writesRef)
361
+ yield* invalidateCache(input, exit, serverKeys, writeDependencies)
349
362
  })
350
363
  )
351
364
  )
@@ -354,7 +367,8 @@ export const invalidateQueries = <RInvalidator>(
354
367
  Effect.onExit((exit) =>
355
368
  Effect.gen(function*() {
356
369
  const serverKeys = yield* Ref.get(keysRef)
357
- yield* invalidateCache(input, exit, serverKeys)
370
+ const writeDependencies = yield* Ref.get(writesRef)
371
+ yield* invalidateCache(input, exit, serverKeys, writeDependencies)
358
372
  })
359
373
  )
360
374
  )
@@ -436,15 +450,20 @@ export const makeStreamMutation2 = <RInvalidator>(queryInvalidator: QueryInvalid
436
450
  // returns void to keep the server-side invalidation service effect-free.
437
451
  (key) => invCache(input, Exit.succeed(undefined), [key]) as Effect.Effect<void>
438
452
  )
453
+ const readsRef = yield* Ref.make<DataDependencies.DataDependencies>([])
454
+ const writesRef = yield* Ref.make<DataDependencies.DataDependencies>([])
455
+ const dependencyRecorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef)
439
456
  const lastRef = yield* Ref.make<any>(undefined)
440
457
  return source.pipe(
441
458
  Stream.provideService(InvalidationKeysFromServer, invKeys),
459
+ Stream.provideService(DataDependencies.DataDependencyRecorder, dependencyRecorder),
442
460
  Stream.tap((v) => Ref.set(lastRef, v)),
443
461
  Stream.ensuring(
444
462
  Effect.gen(function*() {
445
463
  const lastValue = yield* Ref.get(lastRef)
446
464
  const serverKeys = yield* Ref.get(keysRef)
447
- yield* invCache(input, Exit.succeed(lastValue), serverKeys)
465
+ const writeDependencies = yield* Ref.get(writesRef)
466
+ yield* invCache(input, Exit.succeed(lastValue), serverKeys, writeDependencies)
448
467
  })
449
468
  )
450
469
  )
@@ -0,0 +1,352 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { defaultRegistry } from "@effect/atom-vue"
3
+ import { expect, it } from "@effect/vitest"
4
+ import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query"
5
+ import { DataDependencies, type InvalidationKey, InvalidationKeysFromServer, makeQueryKey } from "effect-app/client"
6
+ import * as Context from "effect-app/Context"
7
+ import * as Effect from "effect-app/Effect"
8
+ import * as Fiber from "effect/Fiber"
9
+ import * as Layer from "effect/Layer"
10
+ import * as ManagedRuntime from "effect/ManagedRuntime"
11
+ import { TestClock } from "effect/testing"
12
+ import * as Reactivity from "effect/unstable/reactivity/Reactivity"
13
+ import { createApp, effectScope, ref } from "vue"
14
+ import { awaitAtomResult, buildQueryFamily, invalidateAndAwait, makeAtomClientRuntime } from "../src/atomQuery.js"
15
+ import { clearQueryReadDependencies, getDerivedInvalidationKeys, setQueryReadDependencies } from "../src/dependencyMetadata.js"
16
+ import { makeTanstackQuery, makeTanstackQueryInvalidator } from "../src/internal/tanstackQuery.js"
17
+ import { invalidateQueries, type MutationOptionsBase } from "../src/mutate.js"
18
+
19
+ const repo = DataDependencies.repo("FrontendRepo")
20
+ const otherRepo = DataDependencies.repo("OtherRepo")
21
+
22
+ // --- shared registry + derivation logic --------------------------------------------------------
23
+
24
+ it("getDerivedInvalidationKeys returns keys of queries whose reads intersect the writes", () => {
25
+ const inventoryKey = ["$Inventory", "List", undefined]
26
+ const ordersKey = ["$Orders", "List", undefined]
27
+ setQueryReadDependencies(inventoryKey, [repo])
28
+ setQueryReadDependencies(ordersKey, [otherRepo])
29
+
30
+ try {
31
+ expect(getDerivedInvalidationKeys([repo])).toEqual([inventoryKey])
32
+ expect(getDerivedInvalidationKeys([repo, otherRepo])).toEqual([inventoryKey, ordersKey])
33
+ expect(getDerivedInvalidationKeys([])).toEqual([])
34
+ } finally {
35
+ clearQueryReadDependencies(inventoryKey)
36
+ clearQueryReadDependencies(ordersKey)
37
+ }
38
+ })
39
+
40
+ it("clearing read dependencies drops the query from derivation", () => {
41
+ const inventoryKey = ["$Inventory", "List", undefined]
42
+ setQueryReadDependencies(inventoryKey, [repo])
43
+ clearQueryReadDependencies(inventoryKey)
44
+ expect(getDerivedInvalidationKeys([repo])).toEqual([])
45
+ })
46
+
47
+ it("derivation matches on both dependency type and name (repo vs signal)", () => {
48
+ const key = ["$Live", "Feed", undefined]
49
+ setQueryReadDependencies(key, [DataDependencies.signal("Feed")])
50
+ try {
51
+ // Same name but different type (repo "Feed") must NOT intersect a signal read.
52
+ expect(getDerivedInvalidationKeys([DataDependencies.repo("Feed")])).toEqual([])
53
+ // Same type and name does intersect.
54
+ expect(getDerivedInvalidationKeys([DataDependencies.signal("Feed")])).toEqual([key])
55
+ } finally {
56
+ clearQueryReadDependencies(key)
57
+ }
58
+ })
59
+
60
+ it("derivation intersects when any one of multiple reads matches a write", () => {
61
+ const key = ["$Mixed", "List", undefined]
62
+ setQueryReadDependencies(key, [DataDependencies.repo("A"), DataDependencies.repo("B")])
63
+ try {
64
+ expect(getDerivedInvalidationKeys([DataDependencies.repo("B")])).toEqual([key])
65
+ expect(getDerivedInvalidationKeys([DataDependencies.repo("C")])).toEqual([])
66
+ } finally {
67
+ clearQueryReadDependencies(key)
68
+ }
69
+ })
70
+
71
+ it.effect("a command's write deps invalidate active queries whose recorded reads intersect", () =>
72
+ Effect.gen(function*() {
73
+ const inventoryKey = ["$Inventory", "List", undefined]
74
+ setQueryReadDependencies(inventoryKey, [repo])
75
+
76
+ const recorded: Array<ReadonlyArray<unknown>> = []
77
+ const queryInvalidator = {
78
+ invalidateAndAwait: (keys: ReadonlyArray<ReadonlyArray<unknown>>) =>
79
+ Effect.sync(() => keys.forEach((key) => recorded.push(key)))
80
+ }
81
+
82
+ // The command handler records a write to the same repo the query read from.
83
+ const mutate = invalidateQueries({ id: "Admin.Save" }, undefined, queryInvalidator)
84
+ const command = DataDependencies.write(repo).pipe(Effect.as(123))
85
+
86
+ const fiber = yield* Effect.forkChild(mutate(command, { id: "abc" }))
87
+ yield* TestClock.adjust("1 millis")
88
+ const result = yield* Fiber.join(fiber)
89
+
90
+ clearQueryReadDependencies(inventoryKey)
91
+
92
+ expect(result).toBe(123)
93
+ expect(recorded).toContainEqual(inventoryKey)
94
+ }))
95
+
96
+ // --- atom engine: recording wires through buildQueryFamily ---------------------------------------
97
+
98
+ it("atom engine: a query records its read deps so a command's writes derive it", async () => {
99
+ const atomRepo = DataDependencies.repo("AtomRepo")
100
+ const mrt = ManagedRuntime.make(Layer.empty)
101
+ const baseContext = mrt.runSync(Effect.context<never>())
102
+ const rt = makeAtomClientRuntime(
103
+ () => Layer.succeedContext(baseContext) as Layer.Layer<any, never, never>,
104
+ mrt.memoMap
105
+ )
106
+
107
+ let runs = 0
108
+ const self = {
109
+ id: "AtomInv.List",
110
+ handler: () => DataDependencies.read(atomRepo).pipe(Effect.as(++runs))
111
+ }
112
+ const family = buildQueryFamily(rt as any, self as any)
113
+ const atom = family(undefined)
114
+
115
+ const unmount = defaultRegistry.mount(atom)
116
+ try {
117
+ await Effect.runPromise(awaitAtomResult(defaultRegistry, atom) as any)
118
+ expect(runs).toBe(1)
119
+
120
+ const fullKey = [...makeQueryKey(self), undefined]
121
+ expect(getDerivedInvalidationKeys([atomRepo])).toContainEqual(fullKey)
122
+ expect(getDerivedInvalidationKeys([repo])).not.toContainEqual(fullKey)
123
+ } finally {
124
+ unmount()
125
+ clearQueryReadDependencies([...makeQueryKey(self), undefined])
126
+ }
127
+ })
128
+
129
+ // --- legacy tanstack engine: recording + cache-removal cleanup -----------------------------------
130
+
131
+ const makeTanstackContext = (queryClient: QueryClient) => {
132
+ const app = createApp({ render: () => null })
133
+ app.use(VueQueryPlugin, { queryClient })
134
+ const scope = effectScope(true)
135
+ const run = <T>(fn: () => T): T => {
136
+ let out!: T
137
+ app.runWithContext(() => {
138
+ scope.run(() => {
139
+ out = fn()
140
+ })
141
+ })
142
+ return out
143
+ }
144
+ return { run, dispose: () => scope.stop() }
145
+ }
146
+
147
+ it("tanstack engine: a query records reads, and a command's writes refetch it", async () => {
148
+ const tsRepo = DataDependencies.repo("TanstackRepo")
149
+ const queryClient = new QueryClient({
150
+ defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: 0 } }
151
+ })
152
+ const useQuery = makeTanstackQuery(() => Context.empty(), queryClient)
153
+
154
+ let runs = 0
155
+ const self = { id: "TanstackInv.List", handler: () => DataDependencies.read(tsRepo).pipe(Effect.as(++runs)) }
156
+ const ctx = makeTanstackContext(queryClient)
157
+ const [, , , handle] = ctx.run(() => useQuery(self as any)(ref(undefined) as any, {} as any)) as any
158
+
159
+ try {
160
+ await Effect.runPromise(handle.refetch())
161
+ expect(runs).toBe(1)
162
+
163
+ const fullKey = [...makeQueryKey(self), undefined]
164
+ const derived = getDerivedInvalidationKeys([tsRepo])
165
+ expect(derived).toContainEqual(fullKey)
166
+
167
+ // Invalidating those derived keys via the tanstack invalidator refetches the active query.
168
+ await Effect.runPromise(makeTanstackQueryInvalidator(queryClient).invalidateAndAwait(derived))
169
+ expect(runs).toBe(2)
170
+ } finally {
171
+ ctx.dispose()
172
+ }
173
+ })
174
+
175
+ it("tanstack engine: evicting a query from the cache clears its recorded reads", () => {
176
+ const tsRepo = DataDependencies.repo("TanstackEvictRepo")
177
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
178
+ // Installs the cache-removal subscription that clears the registry on eviction.
179
+ makeTanstackQuery(() => Context.empty(), queryClient)
180
+
181
+ const key = ["$TanstackEvict", "List", undefined]
182
+ const cache = queryClient.getQueryCache()
183
+ cache.build(queryClient, { queryKey: key, queryFn: () => Promise.resolve(1) })
184
+ setQueryReadDependencies(key, [tsRepo])
185
+ expect(getDerivedInvalidationKeys([tsRepo])).toContainEqual(key)
186
+
187
+ cache.clear()
188
+ expect(getDerivedInvalidationKeys([tsRepo])).not.toContainEqual(key)
189
+ })
190
+
191
+ // --- atom engine: GC finalizer clears recorded reads --------------------------------------------
192
+
193
+ it("atom engine: disposing the query atom clears its recorded reads", async () => {
194
+ const atomRepo = DataDependencies.repo("AtomGcRepo")
195
+ const mrt = ManagedRuntime.make(Layer.empty)
196
+ const baseContext = mrt.runSync(Effect.context<never>())
197
+ const rt = makeAtomClientRuntime(
198
+ () => Layer.succeedContext(baseContext) as Layer.Layer<any, never, never>,
199
+ mrt.memoMap
200
+ )
201
+ const self = { id: "AtomGc.List", handler: () => DataDependencies.read(atomRepo).pipe(Effect.as(1)) }
202
+ const atom = buildQueryFamily(rt as any, self as any)(undefined)
203
+ const fullKey = [...makeQueryKey(self), undefined]
204
+
205
+ const unmount = defaultRegistry.mount(atom)
206
+ await Effect.runPromise(awaitAtomResult(defaultRegistry, atom) as any)
207
+ expect(getDerivedInvalidationKeys([atomRepo])).toContainEqual(fullKey)
208
+
209
+ // Disposing the registry runs the atom's finalizers, including `trackReadDependencies`.
210
+ unmount()
211
+ defaultRegistry.reset()
212
+ expect(getDerivedInvalidationKeys([atomRepo])).not.toContainEqual(fullKey)
213
+ })
214
+
215
+ // --- full engine e2e matrix: every invalidation source, on each engine ---------------------------
216
+ //
217
+ // For each engine, a live query records a repo read; then commands sharing the query's namespace are
218
+ // run through the real mutate path, covering every invalidation source. The command namespace matches
219
+ // the query's, so the "no refetch" cases also confirm there is NO implicit namespace invalidation.
220
+
221
+ interface EngineHarness {
222
+ readonly queryFullKey: ReadonlyArray<unknown>
223
+ readonly serverInvalidationKey: InvalidationKey
224
+ readonly fetchInitial: () => Promise<unknown>
225
+ readonly runs: () => number
226
+ readonly runCommand: (
227
+ options: MutationOptionsBase | undefined,
228
+ command: Effect.Effect<unknown, never>
229
+ ) => Promise<unknown>
230
+ readonly dispose: () => void
231
+ }
232
+
233
+ const makeTanstackHarness = (queryRepo: DataDependencies.DataDependency): EngineHarness => {
234
+ const queryClient = new QueryClient({
235
+ defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: 0 } }
236
+ })
237
+ const useQuery = makeTanstackQuery(() => Context.empty(), queryClient)
238
+ const invalidator = makeTanstackQueryInvalidator(queryClient)
239
+ let runs = 0
240
+ const self = { id: "MatrixTs.List", handler: () => DataDependencies.read(queryRepo).pipe(Effect.as(++runs)) }
241
+ const ctx = makeTanstackContext(queryClient)
242
+ const [, , , handle] = ctx.run(() => useQuery(self as any)(ref(undefined) as any, {} as any)) as any
243
+ return {
244
+ queryFullKey: [...makeQueryKey(self), undefined],
245
+ serverInvalidationKey: makeQueryKey(self),
246
+ fetchInitial: () => Effect.runPromise(handle.refetch()),
247
+ runs: () => runs,
248
+ runCommand: (options, command) =>
249
+ Effect.runPromise(invalidateQueries({ id: "MatrixTs.Save" }, options, invalidator)(command, { id: "x" })),
250
+ dispose: () => ctx.dispose()
251
+ }
252
+ }
253
+
254
+ const makeAtomHarness = (queryRepo: DataDependencies.DataDependency): EngineHarness => {
255
+ const mrt = ManagedRuntime.make(Reactivity.layer)
256
+ const baseContext = mrt.runSync(Effect.context<Reactivity.Reactivity>())
257
+ const reactivity = Context.get(baseContext, Reactivity.Reactivity)
258
+ const rt = makeAtomClientRuntime(
259
+ () => Layer.succeedContext(baseContext) as Layer.Layer<any, never, never>,
260
+ mrt.memoMap
261
+ )
262
+ const invalidator = {
263
+ invalidateAndAwait: (keys: ReadonlyArray<ReadonlyArray<unknown>>) =>
264
+ invalidateAndAwait(keys).pipe(Effect.provideService(Reactivity.Reactivity, reactivity))
265
+ }
266
+ let runs = 0
267
+ const self = { id: "MatrixAtom.List", handler: () => DataDependencies.read(queryRepo).pipe(Effect.as(++runs)) }
268
+ const atom = buildQueryFamily(rt as any, self as any)(undefined)
269
+ const unmount = defaultRegistry.mount(atom)
270
+ return {
271
+ queryFullKey: [...makeQueryKey(self), undefined],
272
+ serverInvalidationKey: makeQueryKey(self),
273
+ fetchInitial: () => Effect.runPromise(awaitAtomResult(defaultRegistry, atom) as any),
274
+ runs: () => runs,
275
+ runCommand: (options, command) =>
276
+ Effect.runPromise(
277
+ invalidateQueries({ id: "MatrixAtom.Save" }, options, invalidator)(command, { id: "x" })
278
+ .pipe(Effect.andThen(awaitAtomResult(defaultRegistry, atom).pipe(Effect.exit))) as any
279
+ ),
280
+ dispose: () => {
281
+ unmount()
282
+ clearQueryReadDependencies([...makeQueryKey(self), undefined])
283
+ }
284
+ }
285
+ }
286
+
287
+ const runInvalidationMatrix = (engine: string, makeHarness: (repo: DataDependencies.DataDependency) => EngineHarness) =>
288
+ it(`${engine} engine e2e: invalidation source matrix`, async () => {
289
+ const queryRepo = DataDependencies.repo(`${engine}MatrixRepo`)
290
+ const otherRepo = DataDependencies.repo(`${engine}MatrixOther`)
291
+ const h = makeHarness(queryRepo)
292
+ const noop = Effect.succeed(undefined)
293
+
294
+ const expectRefetch = async (
295
+ label: string,
296
+ options: MutationOptionsBase | undefined,
297
+ command: Effect.Effect<unknown, never>
298
+ ) => {
299
+ const before = h.runs()
300
+ await h.runCommand(options, command)
301
+ expect(h.runs(), `${label} should refetch`).toBeGreaterThan(before)
302
+ }
303
+ const expectNoRefetch = async (
304
+ label: string,
305
+ options: MutationOptionsBase | undefined,
306
+ command: Effect.Effect<unknown, never>
307
+ ) => {
308
+ const before = h.runs()
309
+ await h.runCommand(options, command)
310
+ expect(h.runs(), `${label} must not refetch`).toBe(before)
311
+ }
312
+
313
+ try {
314
+ await h.fetchInitial()
315
+ expect(h.runs()).toBeGreaterThanOrEqual(1)
316
+
317
+ // 1. write-dependency derivation: command writes the repo the query read.
318
+ await expectRefetch(
319
+ "intersecting write dep",
320
+ undefined,
321
+ DataDependencies.write(queryRepo).pipe(Effect.as(undefined))
322
+ )
323
+
324
+ // 2. write-dependency derivation: command writes an UNRELATED repo (same namespace).
325
+ await expectNoRefetch(
326
+ "unrelated write dep",
327
+ undefined,
328
+ DataDependencies.write(otherRepo).pipe(Effect.as(undefined))
329
+ )
330
+
331
+ // 3. explicit `queryInvalidation` config targeting the query.
332
+ await expectRefetch("explicit config", { queryInvalidation: () => [h.queryFullKey as any] }, noop)
333
+
334
+ // 4. explicit config targeting an UNRELATED key.
335
+ await expectNoRefetch("unrelated explicit config", { queryInvalidation: () => [["$NotAQuery"]] }, noop)
336
+
337
+ // 5. server-sent invalidation key targeting the query.
338
+ await expectRefetch(
339
+ "server key",
340
+ undefined,
341
+ Effect.flatMap(InvalidationKeysFromServer, (svc) => svc.add(h.serverInvalidationKey)).pipe(Effect.as(undefined))
342
+ )
343
+
344
+ // 6. nothing — no config, no deps, no server keys, same namespace (no implicit invalidation).
345
+ await expectNoRefetch("empty command", undefined, noop)
346
+ } finally {
347
+ h.dispose()
348
+ }
349
+ })
350
+
351
+ runInvalidationMatrix("tanstack", makeTanstackHarness)
352
+ runInvalidationMatrix("atom", makeAtomHarness)
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dependencyInvalidation.test.d.ts","sourceRoot":"","sources":["../dependencyInvalidation.test.ts"],"names":[],"mappings":""}