From 7a7783e7ded89409cc5db4ed89e78fc9e38f4f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 7 Jul 2026 21:22:05 +0200 Subject: [PATCH] Refactor Query --- packages/effect-fc-next/src/Query.ts | 161 +++++++++++++++++---------- 1 file changed, 100 insertions(+), 61 deletions(-) diff --git a/packages/effect-fc-next/src/Query.ts b/packages/effect-fc-next/src/Query.ts index ca169be..9b5007b 100644 --- a/packages/effect-fc-next/src/Query.ts +++ b/packages/effect-fc-next/src/Query.ts @@ -13,13 +13,13 @@ extends Pipeable.Pipeable { readonly [QueryTypeId]: QueryTypeId readonly context: Context.Context - readonly f: (key: K) => Effect.Effect + readonly key: Subscribable.Subscribable readonly keyEquivalence: Equivalence.Equivalence + readonly f: (key: K) => Effect.Effect readonly staleTime: Duration.Duration readonly refreshOnWindowFocus: boolean - readonly latestKey: Subscribable.Subscribable> readonly fiber: Subscribable.Subscribable>> readonly state: Subscribable.Subscribable> readonly latestFinalState: Subscribable.Subscribable>> @@ -53,13 +53,13 @@ extends Pipeable.Class implements Query { constructor( readonly context: Context.Context, - readonly f: (key: K) => Effect.Effect, + readonly key: Lens.Lens, readonly keyEquivalence: Equivalence.Equivalence, + readonly f: (key: K) => Effect.Effect, readonly staleTime: Duration.Duration, readonly refreshOnWindowFocus: boolean, - readonly latestKey: Lens.Lens>, readonly fiber: Lens.Lens>>, readonly state: Lens.Lens>, readonly latestFinalState: Lens.Lens>>, @@ -70,15 +70,29 @@ extends Pipeable.Class implements Query { } get run(): Effect.Effect { - return Effect.promise(() => import("@effect/platform-browser")).pipe( - Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus - ? Stream.runForEach( - BrowserStream.fromEventListenerWindow("focus"), - () => this.refreshSubscribable, - ) - : Effect.void + return Effect.all([ + Stream.runFoldEffect( + this.key.changes, + () => Option.none(), + (previous, key) => Effect.as( + Option.isSome(previous) && this.keyEquivalence(key, previous.value) + ? this.refreshSubscribable + : this.fetchSubscribable(key), + Option.some(key), + ), ), - Effect.catchDefect(() => Effect.void), + + Effect.promise(() => import("@effect/platform-browser")).pipe( + Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus + ? Stream.runForEach( + BrowserStream.fromEventListenerWindow("focus"), + () => this.refreshSubscribable, + ) + : Effect.void + ), + Effect.catchDefect(() => Effect.void), + ), + ], { concurrency: "unbounded" }).pipe( Effect.ignore, this.runSemaphore.withPermits(1), Effect.provide(this.context), @@ -93,13 +107,16 @@ extends Pipeable.Class implements Query { } fetch(key: K): Effect.Effect, Cause.NoSuchElementError> { - return this.interrupt.pipe( - Effect.andThen(Lens.set(this.latestKey, Option.some(key))), - Effect.andThen(this.startCached({ + return Effect.gen({ self: this }, function*() { + yield* this.interrupt + yield* Lens.set(this.key, key) + + const state = yield* this.startCached({ key, - result: AsyncResult.initial(false) - })), - Effect.flatMap(state => this.watch(state)), + result: AsyncResult.initial(false), + }) + return yield* this.watch(state) + }).pipe( Effect.provide(this.context), ) } @@ -108,29 +125,39 @@ extends Pipeable.Class implements Query { Subscribable.Subscribable>, Cause.NoSuchElementError > { - return this.interrupt.pipe( - Effect.andThen(Lens.set(this.latestKey, Option.some(key))), - Effect.andThen(this.startCached({ + return Effect.gen({ self: this }, function*() { + yield* this.interrupt + yield* Lens.set(this.key, key) + + const state = yield* this.startCached({ key, - result: AsyncResult.initial(false) - })), - Effect.tap(state => Effect.forkScoped(this.watch(state))), + result: AsyncResult.initial(false), + }) + + yield* Effect.forkScoped(this.watch(state)) + return state + }).pipe( Effect.provide(this.context), ) } get refresh(): Effect.Effect, Cause.NoSuchElementError> { - return this.interrupt.pipe( - Effect.andThen(Effect.Do), - Effect.bind("latestKey", () => Effect.flatMap(Lens.get(this.latestKey), Effect.fromOption)), - Effect.bind("latestFinalState", () => Lens.get(this.latestFinalState)), - Effect.bind("subscribable", ({ latestKey, latestFinalState }) => - this.startCached(Option.getOrElse(latestFinalState, () => ({ - key: latestKey, - result: AsyncResult.initial(true), - }))) - ), - Effect.flatMap(({ subscribable }) => this.watch(subscribable)), + return Effect.gen({ self: this }, function*() { + yield* this.interrupt + const latestKey = yield* Lens.get(this.key) + const latestFinalState = yield* Lens.get(this.latestFinalState) + + const state = yield* this.startCached( + Option.isSome(latestFinalState) && this.keyEquivalence(latestKey, latestFinalState.value.key) + ? latestFinalState.value + : { + key: latestKey, + result: AsyncResult.initial(false), + } + ) + + return yield* this.watch(state) + }).pipe( Effect.provide(this.context), ) } @@ -139,18 +166,23 @@ extends Pipeable.Class implements Query { Subscribable.Subscribable>, Cause.NoSuchElementError > { - return this.interrupt.pipe( - Effect.andThen(Effect.Do), - Effect.bind("latestKey", () => Effect.flatMap(Lens.get(this.latestKey), Effect.fromOption)), - Effect.bind("latestFinalState", () => Lens.get(this.latestFinalState)), - Effect.bind("subscribable", ({ latestKey, latestFinalState }) => - this.startCached(Option.getOrElse(latestFinalState, () => ({ - key: latestKey, - result: AsyncResult.initial(true), - }))) - ), - Effect.tap(({ subscribable }) => Effect.forkScoped(this.watch(subscribable))), - Effect.map(({ subscribable }) => subscribable), + return Effect.gen({ self: this }, function*() { + yield* this.interrupt + const latestKey = yield* Lens.get(this.key) + const latestFinalState = yield* Lens.get(this.latestFinalState) + + const state = yield* this.startCached( + Option.isSome(latestFinalState) && this.keyEquivalence(latestKey, latestFinalState.value.key) + ? latestFinalState.value + : { + key: latestKey, + result: AsyncResult.initial(false), + } + ) + + yield* Effect.forkScoped(this.watch(state)) + return state + }).pipe( Effect.provide(this.context), ) } @@ -266,18 +298,20 @@ extends Pipeable.Class implements Query { watch( subscribable: Subscribable.Subscribable> ): Effect.Effect, never, QueryClient.QueryClient> { - return subscribable.get.pipe( - Effect.flatMap(initial => Stream.runFoldEffect( + return Effect.gen({ self: this }, function*() { + const initial = yield* subscribable.get + const final = yield* Stream.runFoldEffect( subscribable.changes, () => initial, (_, state) => Effect.as(Lens.set(this.state, state), state), - ) as Effect.Effect>), - Effect.tap(state => Lens.set(this.latestFinalState, Option.some(state))), - Effect.tap(state => AsyncResult.isSuccess(state.result) - ? this.setCacheEntry(state.key, state.result) - : Effect.void - ), - ) + ) as Effect.Effect> + + yield* Lens.set(this.latestFinalState, Option.some(final)) + if (AsyncResult.isSuccess(final.result)) + yield* this.setCacheEntry(final.key, final.result) + + return final + }) } makeCacheKey(key: K): QueryClient.QueryClientCacheKey { @@ -329,8 +363,10 @@ extends Pipeable.Class implements Query { export declare namespace make { export interface Options { - readonly f: (key: K) => Effect.Effect + readonly key: Lens.Lens, readonly keyEquivalence?: Equivalence.Equivalence, + readonly f: (key: K) => Effect.Effect + readonly staleTime?: Duration.Input readonly refreshOnWindowFocus?: boolean } @@ -347,16 +383,19 @@ export const make = Effect.fnUntraced(function* ( return new QueryImpl( yield* Effect.context(), - options.f, + options.key, options.keyEquivalence ?? Equal.asEquivalence(), + options.f, options.staleTime ? yield* Effect.fromOption(Duration.fromInput(options.staleTime)) : client.defaultStaleTime, options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus, - Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())), Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), - Lens.fromSubscriptionRef(yield* SubscriptionRef.make>(AsyncResult.initial())), - Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none | AsyncResult.Failure>())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make>({ + key: yield* Lens.get(options.key), + result: AsyncResult.initial(false), + })), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), yield* Semaphore.make(1), )