@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/CHANGELOG.md +19 -0
- package/dist/atomQuery.d.ts.map +1 -1
- package/dist/atomQuery.js +40 -5
- package/dist/dependencyMetadata.d.ts +10 -0
- package/dist/dependencyMetadata.d.ts.map +1 -0
- package/dist/dependencyMetadata.js +29 -0
- package/dist/internal/tanstackQuery.d.ts.map +1 -1
- package/dist/internal/tanstackQuery.js +24 -5
- package/dist/mutate.d.ts +1 -1
- package/dist/mutate.d.ts.map +1 -1
- package/dist/mutate.js +27 -11
- package/package.json +2 -2
- package/src/atomQuery.ts +42 -4
- package/src/dependencyMetadata.ts +38 -0
- package/src/internal/tanstackQuery.ts +27 -7
- package/src/mutate.ts +27 -8
- package/test/dependencyInvalidation.test.ts +352 -0
- package/test/dist/dependencyInvalidation.test.d.ts.map +1 -0
- package/test/dist/stubs.d.ts +82 -82
- package/test/dist/patchableQueryAtom.test.d.ts.map +0 -1
- package/test/dist/queryOptions.test.d.ts.map +0 -1
- package/test/dist/retrySchedule.test.d.ts.map +0 -1
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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*
|
|
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*
|
|
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*
|
|
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":""}
|