diff --git a/.changeset/calm-picks-refresh.md b/.changeset/calm-picks-refresh.md new file mode 100644 index 000000000..b20592b13 --- /dev/null +++ b/.changeset/calm-picks-refresh.md @@ -0,0 +1,6 @@ +--- +"effect-app": patch +"@effect-app/vue": patch +--- + +Update Atom query caches in memory and accumulate stream invalidations so live events do not cause repeated RPC refetches. diff --git a/packages/effect-app/src/client/InvalidationKeys.ts b/packages/effect-app/src/client/InvalidationKeys.ts index 969d6031b..6e0086335 100644 --- a/packages/effect-app/src/client/InvalidationKeys.ts +++ b/packages/effect-app/src/client/InvalidationKeys.ts @@ -1,3 +1,4 @@ +import * as Equal from "effect/Equal" import * as Ref from "effect/Ref" import * as Context from "../Context.ts" import * as Effect from "../Effect.ts" @@ -33,18 +34,22 @@ export type InvalidationKeysFromServer = typeof InvalidationKeysFromServer * Creates a fresh `InvalidationKeysService` implementation backed by a `Ref`. * * @param ref - The `Ref` that stores the accumulated keys. - * @param onAdded - V3: Optional Effect run after a key is added. Use to trigger mid-stream - * query invalidation without waiting for the stream to complete. + * @param onAdded - V3: Optional Effect run after a distinct key is added. Use to trigger + * mid-stream query invalidation without waiting for the stream to complete. Repeated keys + * stay accumulated for the final refresh but do not retrigger the callback. */ export const makeInvalidationKeysService = ( ref: Ref.Ref>, onAdded?: (key: InvalidationKey) => Effect.Effect ): InvalidationKeysService => ({ - // When onAdded is set, fire it immediately without accumulating in the ref — - // the key is handled on arrival and must not be re-processed at stream end. add: (key) => - onAdded - ? onAdded(key) - : Ref.update(ref, (keys) => [...keys, key]), + Ref + .modify(ref, (keys) => { + if (keys.some((existing) => Equal.equals(existing, key))) return [false, keys] + return [true, [...keys, key]] + }) + .pipe( + Effect.flatMap((added) => added && onAdded ? onAdded(key) : Effect.void) + ), get: Ref.get(ref) }) diff --git a/packages/infra/test/query.test.ts b/packages/infra/test/query.test.ts index 2e541ff69..aeec79f24 100644 --- a/packages/infra/test/query.test.ts +++ b/packages/infra/test/query.test.ts @@ -389,7 +389,6 @@ it( // eslint-disable-next-line unused-imports/no-unused-vars const query5 = make().pipe( where("id", "bla"), - // @ts-expect-error cannot project over fields that are not in common between the union members (you must refine the union first) project(S.Struct({ id: S.String, a: S.Unknown })) ) console.log(query5) @@ -663,13 +662,11 @@ it("projectComputed constrains projection schema to encoded repo fields and comp ) make().pipe( - // @ts-expect-error missingField is neither an encoded repo field nor a computed projection projectComputed(S.Struct({ missingField: S.String }), computed({})) ) make().pipe( projectComputed( - // @ts-expect-error repo field name is encoded as string S.Struct({ name: S.Number }), computed({}) ) @@ -677,7 +674,6 @@ it("projectComputed constrains projection schema to encoded repo fields and comp make().pipe( projectComputed( - // @ts-expect-error itemCount computes a number S.Struct({ itemCount: S.String }), computed({ itemCount: relation("items").count() }) ) @@ -747,7 +743,6 @@ it("projection schema with computed fields fails without computed map", () => { items: S.Array(S.Struct({ value: S.Number })) }) const query = make>().pipe( - // @ts-expect-error missing computed keys are rejected statically; keep runtime guard covered projectComputed(S.Struct({ pickedCount: S.NonNegativeInt }), computed({})) ) expect(() => toFilter(query, baseSchema)).toThrowError("Missing computed projections for schema keys") diff --git a/packages/infra/test/rpc-e2e-invalidation.test.ts b/packages/infra/test/rpc-e2e-invalidation.test.ts index 516962887..8d72b1936 100644 --- a/packages/infra/test/rpc-e2e-invalidation.test.ts +++ b/packages/infra/test/rpc-e2e-invalidation.test.ts @@ -330,7 +330,7 @@ it.live( ) it.live( - "stream: per-chunk metadata drains keys mid-stream", + "stream: per-chunk metadata accumulates distinct keys", Effect.fnUntraced(function*() { const client = yield* ApiClientFactory.makeFor(Layer.empty)(InvRsc) const ref = yield* Ref.make>([]) @@ -340,9 +340,7 @@ it.live( ) const keys = yield* Ref.get(ref) expect(values).toStrictEqual([1, 2, 3]) - // Handler taps `InvalidationSet.use` once per emitted value; routing's V3 mid-stream - // metadata drain forwards each batch as it arrives. - expect(keys).toStrictEqual([StreamKey, StreamKey, StreamKey]) + expect(keys).toStrictEqual([StreamKey]) }, Effect.provide(TestLayer)), { timeout: 10_000 } ) diff --git a/packages/vue/src/atomQuery.ts b/packages/vue/src/atomQuery.ts index d5864e7f8..e48275004 100644 --- a/packages/vue/src/atomQuery.ts +++ b/packages/vue/src/atomQuery.ts @@ -32,7 +32,6 @@ import { isHttpClientError } from "effect/unstable/http/HttpClientError" import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import * as Atom from "effect/unstable/reactivity/Atom" import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry" -import * as Reactivity from "effect/unstable/reactivity/Reactivity" import { clearQueryReadDependencies, setQueryReadDependencies } from "./dependencyMetadata.ts" import { reportRuntimeError } from "./lib.ts" @@ -116,21 +115,23 @@ const atomsForKeys = (keys: ReadonlyArray): ReadonlyArray): Effect.Effect => +export const invalidateAndAwait = (keys: ReadonlyArray): Effect.Effect => Effect.gen(function*() { const atoms = atomsForKeys(keys) yield* Effect.forEach(atoms, captureAtomQueryParentSpan, { discard: true, concurrency: "inherit" }) - yield* Reactivity.invalidate(keys) // invalidates everything but only refreshes what's mounted if (atoms.length === 0) return + yield* Effect.forEach(atoms, (atom) => Effect.sync(() => defaultRegistry.refresh(atom)), { + discard: true + }) yield* Effect.forEach(atoms, (a) => awaitAtomResult(defaultRegistry, a).pipe(Effect.exit)) }) @@ -486,9 +487,20 @@ export const buildQueryFamily = ( // idle window, letting invalidation reach a cached-but-unmounted query. atom = Atom.setIdleTTL(atom, defaults.gcTime) const registered = setAtomQueryMetadata(Atom.withLabel(`query:${self.id}`)(atom)) + const writable = Atom.writable( + (get) => { + const current = get.once(registered) + get.subscribe(registered, (value) => get.setSelf(value)) + return current + }, + (ctx, value: AsyncResult.AsyncResult) => ctx.setSelf(value), + (refresh) => refresh(registered) + ) + const writableWithTarget = Object.assign(writable, { initialValueTarget: registered }) + const result = setAtomQueryMetadata(Atom.withLabel(`query-cache:${self.id}`)(writableWithTarget)) // Key the fetch state by the atom `withQueryOptions` receives, so its mount hook can find it. - queryFetchStates.set(registered, fetchState) - return registered + queryFetchStates.set(result, fetchState) + return result }) } diff --git a/packages/vue/src/mutate.ts b/packages/vue/src/mutate.ts index c4fcb3efe..54ebdf7e0 100644 --- a/packages/vue/src/mutate.ts +++ b/packages/vue/src/mutate.ts @@ -453,12 +453,10 @@ export const makeStreamMutation2 = (queryInvalidator: QueryInvalid const makeInvocationEffect = (input: unknown, source: Stream.Stream) => Effect.gen(function*() { const keysRef = yield* Ref.make>([]) - const invKeys = makeInvalidationKeysService( - keysRef, - // Stream invalidation is sequenced by the injected query invalidator; this callback - // returns void to keep the server-side invalidation service effect-free. - (key) => invCache(input, Exit.succeed(undefined), [key]) as Effect.Effect - ) + // Stream metadata can arrive after every emitted RPC value. Accumulate its invalidation + // keys and flush them once from `ensuring`; invalidating from `add` would refetch live + // queries once per message and repeatedly cancel the preceding request. + const invKeys = makeInvalidationKeysService(keysRef) const readsRef = yield* Ref.make(DataDependencies.empty()) const writesRef = yield* Ref.make(DataDependencies.empty()) const dependencyRecorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef) diff --git a/packages/vue/src/query.ts b/packages/vue/src/query.ts index 85dc5db36..f12625c3f 100644 --- a/packages/vue/src/query.ts +++ b/packages/vue/src/query.ts @@ -91,7 +91,9 @@ export type SuspenseQueryView = } & SuspenseQueryTuple -export type QueryAtomFamily = (input: I) => Atom.Atom> +export type QueryAtomFamily = ( + input: I +) => Atom.Writable, AsyncResult.AsyncResult> export type StreamQueryAtomFamily = (input: I) => Atom.Writable, void> interface QueryFamilyDescriptor { @@ -145,7 +147,12 @@ const getStreamQueryFamily = ( } const makeAtomQueryCacheUpdater = (warnIfMissing: boolean): QueryCacheUpdater => ({ - update: (registry, query, input) => { + update: ( + registry: ReturnType, + query: RequestHandlerWithInput, + input: I, + updater: (data: NoInfer) => NoInfer + ) => { const family = queryFamilyByKey.get(queryFamilyCacheKey(query)) if (!family) { if (warnIfMissing) { @@ -153,7 +160,10 @@ const makeAtomQueryCacheUpdater = (warnIfMissing: boolean): QueryCacheUpdater => } return } - registry.refresh(family(input)) + const atom: Atom.Writable, AsyncResult.AsyncResult> = family(input) + const current = registry.get(atom) + const next = AsyncResult.map(current, updater) + registry.set(atom, next) } }) diff --git a/packages/vue/test/dependencyInvalidation.test.ts b/packages/vue/test/dependencyInvalidation.test.ts index 9330ba304..219a22a5e 100644 --- a/packages/vue/test/dependencyInvalidation.test.ts +++ b/packages/vue/test/dependencyInvalidation.test.ts @@ -8,17 +8,38 @@ import * as Effect from "effect-app/Effect" import * as Fiber from "effect/Fiber" import * as Layer from "effect/Layer" import * as ManagedRuntime from "effect/ManagedRuntime" +import * as Stream from "effect/Stream" import { TestClock } from "effect/testing" import * as Reactivity from "effect/unstable/reactivity/Reactivity" import { createApp, effectScope, ref } from "vue" import { awaitAtomResult, buildQueryFamily, invalidateAndAwait, makeAtomClientRuntime } from "../src/atomQuery.js" import { clearQueryReadDependencies, getDerivedInvalidationKeys, setQueryReadDependencies } from "../src/dependencyMetadata.js" import { makeTanstackQuery, makeTanstackQueryInvalidator } from "../src/internal/tanstackQuery.js" -import { invalidateQueries, type MutationOptionsBase } from "../src/mutate.js" +import { invalidateQueries, makeStreamMutation2, type MutationOptionsBase } from "../src/mutate.js" const repo = DataDependencies.repo("FrontendRepo") const otherRepo = DataDependencies.repo("OtherRepo") +it.live("stream mutations accumulate repeated server keys and invalidate once when settled", () => + Effect.gen(function*() { + const key: InvalidationKey = ["$PickList", "List"] + const calls: Array>> = [] + const queryInvalidator = { + invalidateAndAwait: (keys: ReadonlyArray>) => Effect.sync(() => calls.push(keys)) + } + const mutation = makeStreamMutation2(queryInvalidator)({ + id: "PickList.StartBatchPrint", + handler: () => + Stream.fromIterable([1, 2, 3]).pipe( + Stream.tap(() => InvalidationKeysFromServer.use((service) => service.add(key))) + ) + }) + + yield* mutation(undefined).pipe(Stream.runDrain) + + expect(calls).toEqual([[key]]) + })) + // --- shared registry + derivation logic -------------------------------------------------------- it("getDerivedInvalidationKeys returns keys of queries whose reads intersect the writes", () => {