Refactor Query
All checks were successful
Lint / lint (push) Successful in 13s

This commit is contained in:
Julien Valverdé
2026-01-18 16:08:39 +01:00
parent 636beedd13
commit bbacee7ad4
2 changed files with 88 additions and 46 deletions

View File

@@ -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 QueryClient from "./QueryClient.js"
import * as Result from "./Result.js" import * as Result from "./Result.js"
@@ -80,7 +80,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
) )
} }
get interrupt(): Effect.Effect<void, never, never> { get interrupt(): Effect.Effect<void> {
return Effect.andThen(this.fiber, Option.match({ return Effect.andThen(this.fiber, Option.match({
onSome: Fiber.interrupt, onSome: Fiber.interrupt,
onNone: () => Effect.void, onNone: () => Effect.void,
@@ -159,7 +159,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
> { > {
return Effect.andThen(this.getCacheEntry(key), Option.match({ return Effect.andThen(this.getCacheEntry(key), Option.match({
onSome: entry => Effect.andThen( onSome: entry => Effect.andThen(
QueryClient.isQueryClientCacheEntryStale(entry, this.staleTime), QueryClient.isQueryClientCacheEntryStale(entry),
isStale => isStale isStale => isStale
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>) ? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
: Effect.succeed(Subscribable.make({ : Effect.succeed(Subscribable.make({
@@ -212,7 +212,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
) as Effect.Effect<Result.Final<A, E, P>>), ) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))), Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
Effect.tap(result => Result.isSuccess(result) Effect.tap(result => Result.isSuccess(result)
? this.updateCacheEntry(key, result) ? this.setCacheEntry(key, result)
: Effect.void : Effect.void
), ),
) )
@@ -225,52 +225,41 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
getCacheEntry( getCacheEntry(
key: K key: K
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> { ): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
return Effect.all([ return Effect.andThen(
Effect.succeed(this.makeCacheKey(key)), Effect.all([
Effect.map(QueryClient.QueryClient, client => client.cache), Effect.succeed(this.makeCacheKey(key)),
DateTime.now, QueryClient.QueryClient,
]).pipe( ]),
Effect.andThen(([key, ref, now]) => ref.pipe( ([key, client]) => client.getCacheEntry(key),
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,
) )
} }
updateCacheEntry( setCacheEntry(
key: K, key: K,
result: Result.Success<A>, result: Result.Success<A>,
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> { ): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
return Effect.Do.pipe( return Effect.andThen(
Effect.bind("client", () => QueryClient.QueryClient), Effect.all([
Effect.bind("now", () => DateTime.now), Effect.succeed(this.makeCacheKey(key)),
Effect.let("entry", ({ now }) => new QueryClient.QueryClientCacheEntry(result, now, now)), QueryClient.QueryClient,
Effect.tap(({ client, entry }) => SubscriptionRef.update( ]),
client.cache, ([key, client]) => client.setCacheEntry(key, result, this.staleTime),
HashMap.set(this.makeCacheKey(key), entry),
)),
Effect.map(({ entry }) => entry),
) )
} }
get invalidateCache(): Effect.Effect<void> { get invalidateCache(): Effect.Effect<void> {
return QueryClient.QueryClient.pipe( return QueryClient.QueryClient.pipe(
Effect.andThen(client => SubscriptionRef.update( Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)),
client.cache,
HashMap.filter((_, key) => !Equivalence.strict()(key.f, this.f)),
)),
Effect.provide(this.context), Effect.provide(this.context),
) )
} }
invalidateCacheEntry(key: K): Effect.Effect<void> { invalidateCacheEntry(key: K): Effect.Effect<void> {
return QueryClient.QueryClient.pipe( return Effect.all([
Effect.andThen(client => SubscriptionRef.update( Effect.succeed(this.makeCacheKey(key)),
client.cache, QueryClient.QueryClient,
HashMap.remove(this.makeCacheKey(key)), ]).pipe(
)), Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)),
Effect.provide(this.context), Effect.provide(this.context),
) )
} }

View File

@@ -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 Query from "./Query.js"
import type * as Result from "./Result.js" import type * as Result from "./Result.js"
@@ -8,10 +8,21 @@ export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable { export interface QueryClientService extends Pipeable.Pipeable {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly gcTime: Duration.DurationInput readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.DurationInput
readonly defaultStaleTime: Duration.DurationInput readonly defaultStaleTime: Duration.DurationInput
readonly defaultRefreshOnWindowFocus: boolean readonly defaultRefreshOnWindowFocus: boolean
readonly run: Effect.Effect<void>
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
setCacheEntry(
key: QueryClientCacheKey,
result: Result.Success<unknown>,
staleTime: Duration.DurationInput,
): Effect.Effect<QueryClientCacheEntry>
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
} }
export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", { export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", {
@@ -25,20 +36,64 @@ implements QueryClientService {
constructor( constructor(
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>, readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly gcTime: Duration.DurationInput, readonly cacheGcTime: Duration.DurationInput,
readonly defaultStaleTime: Duration.DurationInput, readonly defaultStaleTime: Duration.DurationInput,
readonly defaultRefreshOnWindowFocus: boolean, readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Effect.Semaphore, readonly runSemaphore: Effect.Semaphore,
) { ) {
super() super()
} }
get run(): Effect.Effect<void> {
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<Option.Option<QueryClientCacheEntry>> {
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<unknown>,
staleTime: Duration.DurationInput,
): Effect.Effect<QueryClientCacheEntry> {
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<unknown, unknown, unknown>): Effect.Effect<void> {
return SubscriptionRef.update(this.cache, HashMap.filter((_, key) => !Equivalence.strict()(key.f, f)))
}
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
return SubscriptionRef.update(this.cache, HashMap.remove(key))
}
} }
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId) export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
export declare namespace make { export declare namespace make {
export interface Options { export interface Options {
readonly gcTime?: Duration.DurationInput readonly cacheGcTime?: Duration.DurationInput
readonly defaultStaleTime?: Duration.DurationInput readonly defaultStaleTime?: Duration.DurationInput
readonly defaultRefreshOnWindowFocus?: boolean readonly defaultRefreshOnWindowFocus?: boolean
} }
@@ -47,22 +102,20 @@ export declare namespace make {
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> { export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
return new QueryClientServiceImpl( return new QueryClientServiceImpl(
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()), yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
options.gcTime ?? "5 minutes", options.cacheGcTime ?? "5 minutes",
options.defaultStaleTime ?? "0 minutes", options.defaultStaleTime ?? "0 minutes",
options.defaultRefreshOnWindowFocus ?? true, options.defaultRefreshOnWindowFocus ?? true,
yield* Effect.makeSemaphore(1), yield* Effect.makeSemaphore(1),
) )
}) })
export const run = (_self: QueryClientService): Effect.Effect<void> => Effect.void
export declare namespace service { export declare namespace service {
export interface Options extends make.Options {} export interface Options extends make.Options {}
} }
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap( export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
make(options), make(options),
client => Effect.forkScoped(run(client)), client => Effect.forkScoped(client.run),
) )
@@ -102,6 +155,7 @@ implements Pipeable.Pipeable {
constructor( constructor(
readonly result: Result.Success<unknown>, readonly result: Result.Success<unknown>,
readonly staleTime: Duration.DurationInput,
readonly createdAt: DateTime.DateTime, readonly createdAt: DateTime.DateTime,
readonly lastAccessedAt: 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 isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
export const isQueryClientCacheEntryStale = ( export const isQueryClientCacheEntryStale = (
self: QueryClientCacheEntry, self: QueryClientCacheEntry
staleTime: Duration.DurationInput,
): Effect.Effect<boolean> => Effect.andThen( ): Effect.Effect<boolean> => Effect.andThen(
DateTime.now, DateTime.now,
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), staleTime), now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), self.staleTime),
) )