@effect-app/vue 4.0.0-beta.281 → 4.0.0-beta.283
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 +22 -0
- package/dist/atomQuery.d.ts.map +1 -1
- package/dist/atomQuery.js +24 -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 +6 -6
- package/src/atomQuery.ts +27 -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/query-span.test.d.ts.map +1 -0
- package/test/dist/stubs.d.ts +82 -82
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,15 @@ const trackWritableByKeys =
|
|
|
83
85
|
)
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
/** Drop a query's recorded read-dependencies when its atom is GC'd (mirrors `trackByKeys`). */
|
|
89
|
+
const trackReadDependencies =
|
|
90
|
+
(key: ReadonlyArray<unknown>) =>
|
|
91
|
+
<A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
|
|
92
|
+
Atom.transform(atom, (get) => {
|
|
93
|
+
get.addFinalizer(() => clearQueryReadDependencies(key))
|
|
94
|
+
return get(atom)
|
|
95
|
+
}, { initialValueTarget: atom })
|
|
96
|
+
|
|
86
97
|
const atomsForKeys = (keys: ReadonlyArray<unknown>): ReadonlyArray<Atom.Atom<AsyncResult.AsyncResult<any, any>>> => {
|
|
87
98
|
const atoms = new Set<Atom.Atom<AsyncResult.AsyncResult<any, any>>>()
|
|
88
99
|
for (const key of keys) {
|
|
@@ -319,9 +330,21 @@ export const buildQueryFamily = <I, A, E>(
|
|
|
319
330
|
const baseKey = makeQueryKey(self) // hierarchical, input-independent
|
|
320
331
|
|
|
321
332
|
return Atom.family((input: I) => {
|
|
322
|
-
|
|
323
|
-
|
|
333
|
+
const fullKey = [...baseKey, input]
|
|
334
|
+
// Record the repository/server read-dependencies seen while fetching, keyed by `fullKey`, so a
|
|
335
|
+
// later mutation whose writes intersect them can derive this query as an invalidation target.
|
|
336
|
+
const recordReads = Effect.gen(function*() {
|
|
337
|
+
const readsRef = yield* Ref.make<DataDependencies.DataDependencies>([])
|
|
338
|
+
const writesRef = yield* Ref.make<DataDependencies.DataDependencies>([])
|
|
339
|
+
const recorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef)
|
|
340
|
+
const result = yield* self
|
|
324
341
|
.handler(input)
|
|
342
|
+
.pipe(Effect.provideService(DataDependencies.DataDependencyRecorder, recorder))
|
|
343
|
+
setQueryReadDependencies(fullKey, yield* Ref.get(readsRef))
|
|
344
|
+
return result
|
|
345
|
+
})
|
|
346
|
+
let atom: Atom.Atom<AsyncResult.AsyncResult<A, E>> = rt.runtime.atom(
|
|
347
|
+
recordReads
|
|
325
348
|
.pipe(
|
|
326
349
|
Effect.retry({ times: 5, while: isRetryable }),
|
|
327
350
|
Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)),
|
|
@@ -331,13 +354,13 @@ export const buildQueryFamily = <I, A, E>(
|
|
|
331
354
|
// Register under every prefix of the full key => hierarchical (prefix) invalidation. Two roles:
|
|
332
355
|
// - withReactivity: `Reactivity.invalidate(key)` refreshes this atom (the actual refetch).
|
|
333
356
|
// - trackByKeys: records the atom in `keyAtoms` so the mutation can AWAIT its settle.
|
|
334
|
-
const fullKey = [...baseKey, input]
|
|
335
357
|
const projectedFullKey = self.queryKeyProjectionHash === undefined
|
|
336
358
|
? fullKey
|
|
337
359
|
: [...baseKey, self.queryKeyProjectionHash, input]
|
|
338
360
|
const reactivityKeys = uniqueKeys([...prefixesOf(fullKey), ...prefixesOf(projectedFullKey)])
|
|
339
361
|
atom = rt.factory.withReactivity(reactivityKeys)(atom)
|
|
340
362
|
atom = trackByKeys(reactivityKeys)(atom)
|
|
363
|
+
atom = trackReadDependencies(fullKey)(atom)
|
|
341
364
|
// gcTime LAST so the whole chain (incl. the registration + tracking) stays alive through the
|
|
342
365
|
// idle window, letting invalidation reach a cached-but-unmounted query.
|
|
343
366
|
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 implicit namespace invalidation: without an explicit `queryInvalidation` config, client
|
|
301
|
+
// keys are empty and invalidation is driven solely by server keys + repository write-dependency
|
|
302
|
+
// derivation. (`getQueryKey` is still offered to the config callback as a convenience base.)
|
|
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":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-span.test.d.ts","sourceRoot":"","sources":["../query-span.test.ts"],"names":[],"mappings":""}
|