From bbacee7ad41bb44ee196d51953a02573870695b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sun, 18 Jan 2026 16:08:39 +0100 Subject: [PATCH] Refactor Query --- packages/effect-fc/src/Query.ts | 57 ++++++++------------ packages/effect-fc/src/QueryClient.ts | 77 ++++++++++++++++++++++----- 2 files changed, 88 insertions(+), 46 deletions(-) diff --git a/packages/effect-fc/src/Query.ts b/packages/effect-fc/src/Query.ts index 70b2765..9c306b7 100644 --- a/packages/effect-fc/src/Query.ts +++ b/packages/effect-fc/src/Query.ts @@ -1,4 +1,4 @@ -import { type Cause, type Context, DateTime, type Duration, Effect, Equal, Equivalence, Fiber, HashMap, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect" +import { type Cause, type Context, type Duration, Effect, Equal, Fiber, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect" import * as QueryClient from "./QueryClient.js" import * as Result from "./Result.js" @@ -80,7 +80,7 @@ extends Pipeable.Class() implements Query { ) } - get interrupt(): Effect.Effect { + get interrupt(): Effect.Effect { return Effect.andThen(this.fiber, Option.match({ onSome: Fiber.interrupt, onNone: () => Effect.void, @@ -159,7 +159,7 @@ extends Pipeable.Class() implements Query { > { return Effect.andThen(this.getCacheEntry(key), Option.match({ onSome: entry => Effect.andThen( - QueryClient.isQueryClientCacheEntryStale(entry, this.staleTime), + QueryClient.isQueryClientCacheEntryStale(entry), isStale => isStale ? this.start(key, Result.willRefresh(entry.result) as Result.Final) : Effect.succeed(Subscribable.make({ @@ -212,7 +212,7 @@ extends Pipeable.Class() implements Query { ) as Effect.Effect>), Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))), Effect.tap(result => Result.isSuccess(result) - ? this.updateCacheEntry(key, result) + ? this.setCacheEntry(key, result) : Effect.void ), ) @@ -225,52 +225,41 @@ extends Pipeable.Class() implements Query { getCacheEntry( key: K ): Effect.Effect, never, QueryClient.QueryClient> { - return Effect.all([ - Effect.succeed(this.makeCacheKey(key)), - Effect.map(QueryClient.QueryClient, client => client.cache), - DateTime.now, - ]).pipe( - Effect.andThen(([key, ref, now]) => ref.pipe( - Effect.andThen(HashMap.get(key)), - Effect.map(entry => new QueryClient.QueryClientCacheEntry(entry.result, entry.createdAt, now)), - Effect.tap(entry => SubscriptionRef.update(ref, HashMap.set(key, entry))), - )), - Effect.option, + return Effect.andThen( + Effect.all([ + Effect.succeed(this.makeCacheKey(key)), + QueryClient.QueryClient, + ]), + ([key, client]) => client.getCacheEntry(key), ) } - updateCacheEntry( + setCacheEntry( key: K, result: Result.Success, ): Effect.Effect { - return Effect.Do.pipe( - Effect.bind("client", () => QueryClient.QueryClient), - Effect.bind("now", () => DateTime.now), - Effect.let("entry", ({ now }) => new QueryClient.QueryClientCacheEntry(result, now, now)), - Effect.tap(({ client, entry }) => SubscriptionRef.update( - client.cache, - HashMap.set(this.makeCacheKey(key), entry), - )), - Effect.map(({ entry }) => entry), + return Effect.andThen( + Effect.all([ + Effect.succeed(this.makeCacheKey(key)), + QueryClient.QueryClient, + ]), + ([key, client]) => client.setCacheEntry(key, result, this.staleTime), ) } get invalidateCache(): Effect.Effect { return QueryClient.QueryClient.pipe( - Effect.andThen(client => SubscriptionRef.update( - client.cache, - HashMap.filter((_, key) => !Equivalence.strict()(key.f, this.f)), - )), + Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect)), Effect.provide(this.context), ) } invalidateCacheEntry(key: K): Effect.Effect { - return QueryClient.QueryClient.pipe( - Effect.andThen(client => SubscriptionRef.update( - client.cache, - HashMap.remove(this.makeCacheKey(key)), - )), + return Effect.all([ + Effect.succeed(this.makeCacheKey(key)), + QueryClient.QueryClient, + ]).pipe( + Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)), Effect.provide(this.context), ) } diff --git a/packages/effect-fc/src/QueryClient.ts b/packages/effect-fc/src/QueryClient.ts index 4dcfb0d..f501316 100644 --- a/packages/effect-fc/src/QueryClient.ts +++ b/packages/effect-fc/src/QueryClient.ts @@ -1,4 +1,4 @@ -import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Pipeable, Predicate, type Scope, SubscriptionRef } from "effect" +import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, type Subscribable, SubscriptionRef } from "effect" import type * as Query from "./Query.js" import type * as Result from "./Result.js" @@ -8,10 +8,21 @@ export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId export interface QueryClientService extends Pipeable.Pipeable { readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId - readonly cache: SubscriptionRef.SubscriptionRef> - readonly gcTime: Duration.DurationInput + + readonly cache: Subscribable.Subscribable> + readonly cacheGcTime: Duration.DurationInput readonly defaultStaleTime: Duration.DurationInput readonly defaultRefreshOnWindowFocus: boolean + + readonly run: Effect.Effect + getCacheEntry(key: QueryClientCacheKey): Effect.Effect> + setCacheEntry( + key: QueryClientCacheKey, + result: Result.Success, + staleTime: Duration.DurationInput, + ): Effect.Effect + invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect): Effect.Effect + invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect } export class QueryClient extends Effect.Service()("@effect-fc/QueryClient/QueryClient", { @@ -25,20 +36,64 @@ implements QueryClientService { constructor( readonly cache: SubscriptionRef.SubscriptionRef>, - readonly gcTime: Duration.DurationInput, + readonly cacheGcTime: Duration.DurationInput, readonly defaultStaleTime: Duration.DurationInput, readonly defaultRefreshOnWindowFocus: boolean, readonly runSemaphore: Effect.Semaphore, ) { super() } + + get run(): Effect.Effect { + return Effect.repeat( + Effect.andThen( + DateTime.now, + now => SubscriptionRef.update(this.cache, HashMap.filter(entry => + Duration.greaterThanOrEqualTo( + DateTime.distanceDuration(entry.lastAccessedAt, now), + Duration.sum(entry.staleTime, this.cacheGcTime), + ) + )), + ), + Schedule.spaced("1 second"), + ) + } + + getCacheEntry(key: QueryClientCacheKey): Effect.Effect> { + return Effect.all([ + Effect.andThen(this.cache, HashMap.get(key)), + DateTime.now, + ]).pipe( + Effect.map(([entry, now]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)), + Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))), + Effect.option, + ) + } + + setCacheEntry( + key: QueryClientCacheKey, + result: Result.Success, + staleTime: Duration.DurationInput, + ): Effect.Effect { + return DateTime.now.pipe( + Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)), + Effect.tap(entry => SubscriptionRef.update(HashMap.set(key, entry))), + ) + } + + invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect): Effect.Effect { + return SubscriptionRef.update(this.cache, HashMap.filter((_, key) => !Equivalence.strict()(key.f, f))) + } + invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect { + return SubscriptionRef.update(this.cache, HashMap.remove(key)) + } } export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId) export declare namespace make { export interface Options { - readonly gcTime?: Duration.DurationInput + readonly cacheGcTime?: Duration.DurationInput readonly defaultStaleTime?: Duration.DurationInput readonly defaultRefreshOnWindowFocus?: boolean } @@ -47,22 +102,20 @@ export declare namespace make { export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return { return new QueryClientServiceImpl( yield* SubscriptionRef.make(HashMap.empty()), - options.gcTime ?? "5 minutes", + options.cacheGcTime ?? "5 minutes", options.defaultStaleTime ?? "0 minutes", options.defaultRefreshOnWindowFocus ?? true, yield* Effect.makeSemaphore(1), ) }) -export const run = (_self: QueryClientService): Effect.Effect => Effect.void - export declare namespace service { export interface Options extends make.Options {} } export const service = (options?: service.Options): Effect.Effect => Effect.tap( make(options), - client => Effect.forkScoped(run(client)), + client => Effect.forkScoped(client.run), ) @@ -102,6 +155,7 @@ implements Pipeable.Pipeable { constructor( readonly result: Result.Success, + readonly staleTime: Duration.DurationInput, readonly createdAt: DateTime.DateTime, readonly lastAccessedAt: DateTime.DateTime, ) { @@ -112,9 +166,8 @@ implements Pipeable.Pipeable { export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId) export const isQueryClientCacheEntryStale = ( - self: QueryClientCacheEntry, - staleTime: Duration.DurationInput, + self: QueryClientCacheEntry ): Effect.Effect => Effect.andThen( DateTime.now, - now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), staleTime), + now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), self.staleTime), )