Refactor QueryClient
Lint / lint (push) Failing after 16s

This commit is contained in:
Julien Valverdé
2026-06-24 00:54:10 +02:00
parent 65fcfb395e
commit 5945953555
+79 -88
View File
@@ -1,24 +1,8 @@
import { import { type Cause, Context, DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, Semaphore, SubscriptionRef } from "effect"
Context, import type { AsyncResult } from "effect/unstable/reactivity"
DateTime, import * as Lens from "./Lens.js"
Duration,
Effect,
Equal,
Equivalence,
Hash,
HashMap,
Layer,
Option,
Pipeable,
Predicate,
Schedule,
Scope,
Semaphore,
SubscriptionRef,
} from "effect"
import { Subscribable } from "effect-lens"
import type * as Query from "./Query.js" import type * as Query from "./Query.js"
import type * as Result from "./Result.js" import type * as Subscribable from "./Subscribable.js"
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService") export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
@@ -26,89 +10,87 @@ 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: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>> readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.Input readonly cacheGcTime: Duration.Duration
readonly defaultStaleTime: Duration.Input readonly defaultStaleTime: Duration.Duration
readonly defaultRefreshOnWindowFocus: boolean readonly defaultRefreshOnWindowFocus: boolean
readonly run: Effect.Effect<void>
readonly run: Effect.Effect<void, Cause.NoSuchElementError>
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
setCacheEntry(key: QueryClientCacheKey, result: Result.Success<unknown>, staleTime: Duration.Input): Effect.Effect<QueryClientCacheEntry> setCacheEntry(
key: QueryClientCacheKey,
result: AsyncResult.Success<unknown, unknown>,
staleTime: Duration.Duration,
): Effect.Effect<QueryClientCacheEntry>
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
} }
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()( export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
"@effect-fc/QueryClient/QueryClient", "@effect-fc/QueryClient/QueryClient"
) { ) {}
static get Default(): Layer.Layer<QueryClient> {
return Layer.effect(QueryClient)(service())
}
}
export class QueryClientServiceImpl extends Pipeable.Class implements QueryClientService { export class QueryClientServiceImpl
extends Pipeable.Class
implements QueryClientService {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
constructor( constructor(
readonly cacheRef: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>, readonly cache: Lens.Lens<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly cacheGcTime: Duration.Input, readonly cacheGcTime: Duration.Duration,
readonly defaultStaleTime: Duration.Input, readonly defaultStaleTime: Duration.Duration,
readonly defaultRefreshOnWindowFocus: boolean, readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Semaphore.Semaphore, readonly runSemaphore: Semaphore.Semaphore,
) { ) {
super() super()
this.cache = Subscribable.make({
get: SubscriptionRef.get(cacheRef),
changes: SubscriptionRef.changes(cacheRef),
})
} }
get run(): Effect.Effect<void> { get run(): Effect.Effect<void, Cause.NoSuchElementError> {
return this.runSemaphore.withPermits(1)(Effect.repeat( return this.runSemaphore.withPermits(1)(Effect.repeat(
Effect.flatMap(DateTime.now, now => SubscriptionRef.update(this.cacheRef, HashMap.filter(entry => Effect.flatMap(
Duration.isLessThan( DateTime.now,
DateTime.distance(entry.lastAccessedAt, now), now => Lens.update(this.cache, HashMap.filter(entry =>
Duration.sum(Duration.fromInputUnsafe(entry.staleTime), Duration.fromInputUnsafe(this.cacheGcTime)), Duration.isLessThan(
) DateTime.distance(entry.lastAccessedAt, now),
))), Duration.sum(entry.staleTime, this.cacheGcTime),
Schedule.spaced("30 seconds"), )
)),
),
Schedule.spaced("30 second"),
)) ))
} }
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> { getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
const self = this return Effect.all([
return Effect.gen(function*() { DateTime.now,
const entry = HashMap.get(yield* SubscriptionRef.get(self.cacheRef), key) Effect.flatMap(
if (Option.isNone(entry)) return Option.none() Effect.map(Lens.get(this.cache), HashMap.get(key)),
const now = yield* DateTime.now Effect.fromOption,
const accessed = new QueryClientCacheEntry( ),
entry.value.result, ]).pipe(
entry.value.staleTime, Effect.map(([now, entry]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)),
entry.value.createdAt, Effect.tap(entry => Lens.update(this.cache, HashMap.set(key, entry))),
now, Effect.option,
) )
yield* SubscriptionRef.update(self.cacheRef, HashMap.set(key, accessed))
return Option.some(accessed)
})
} }
setCacheEntry( setCacheEntry(
key: QueryClientCacheKey, key: QueryClientCacheKey,
result: Result.Success<unknown>, result: AsyncResult.Success<unknown, unknown>,
staleTime: Duration.Input, staleTime: Duration.Duration,
): Effect.Effect<QueryClientCacheEntry> { ): Effect.Effect<QueryClientCacheEntry> {
return Effect.flatMap(DateTime.now, now => { return DateTime.now.pipe(
const entry = new QueryClientCacheEntry(result, staleTime, now, now) Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)),
return Effect.as(SubscriptionRef.update(this.cacheRef, HashMap.set(key, entry)), entry) Effect.tap(entry => Lens.update(this.cache, HashMap.set(key, entry))),
}) )
} }
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> { invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
return SubscriptionRef.update(this.cacheRef, HashMap.filter((_, key) => !Equivalence.strictEqual()(key.f, f))) return Lens.update(this.cache, HashMap.filter((_, key) => !Equivalence.strictEqual()(key.f, f)))
} }
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> { invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
return SubscriptionRef.update(this.cacheRef, HashMap.remove(key)) return Lens.update(this.cache, HashMap.remove(key))
} }
} }
@@ -122,11 +104,13 @@ 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, Cause.NoSuchElementError, never> {
return new QueryClientServiceImpl( return new QueryClientServiceImpl(
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()), Lens.fromSubscriptionRef(yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>())),
options.cacheGcTime ?? "5 minutes", yield* Effect.fromOption(Duration.fromInput(options.cacheGcTime ?? "5 minutes")),
options.defaultStaleTime ?? "0 minutes", yield* Effect.fromOption(Duration.fromInput(options.defaultStaleTime ?? "0 minutes")),
options.defaultRefreshOnWindowFocus ?? true, options.defaultRefreshOnWindowFocus ?? true,
yield* Semaphore.make(1), yield* Semaphore.make(1),
) )
@@ -136,15 +120,20 @@ 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, Cause.NoSuchElementError, Scope.Scope> => Effect.tap(
make(options), make(options),
client => Effect.asVoid(Effect.forkScoped(client.run)), client => Effect.forkScoped(client.run),
) )
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey") export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
export class QueryClientCacheKey extends Pipeable.Class implements Equal.Equal { export class QueryClientCacheKey
extends Pipeable.Class
implements Pipeable.Pipeable, Equal.Equal {
readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId
constructor( constructor(
@@ -154,28 +143,28 @@ export class QueryClientCacheKey extends Pipeable.Class implements Equal.Equal {
super() super()
} }
[Equal.symbol](that: Equal.Equal): boolean { [Equal.symbol](that: Equal.Equal) {
return isQueryClientCacheKey(that) return isQueryClientCacheKey(that) && Equivalence.Array(Equal.asEquivalence())(this.key, that.key) && Equivalence.strictEqual()(this.f, that.f)
&& Equivalence.Array(Equal.asEquivalence())(this.key, that.key)
&& Equivalence.strictEqual()(this.f, that.f)
} }
[Hash.symbol]() {
[Hash.symbol](): number {
return Hash.combine(Hash.hash(this.f))(Hash.array(this.key)) return Hash.combine(Hash.hash(this.f))(Hash.array(this.key))
} }
} }
export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId) export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId)
export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry") export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry")
export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId
export class QueryClientCacheEntry extends Pipeable.Class { export class QueryClientCacheEntry
extends Pipeable.Class
implements Pipeable.Pipeable {
readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId
constructor( constructor(
readonly result: Result.Success<unknown>, readonly result: AsyncResult.Success<unknown, unknown>,
readonly staleTime: Duration.Input, readonly staleTime: Duration.Duration,
readonly createdAt: DateTime.DateTime, readonly createdAt: DateTime.DateTime,
readonly lastAccessedAt: DateTime.DateTime, readonly lastAccessedAt: DateTime.DateTime,
) { ) {
@@ -185,7 +174,9 @@ export class QueryClientCacheEntry extends Pipeable.Class {
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 = (self: QueryClientCacheEntry): Effect.Effect<boolean> => Effect.map( export const isQueryClientCacheEntryStale = (
self: QueryClientCacheEntry
): Effect.Effect<boolean, Cause.NoSuchElementError> => Effect.map(
DateTime.now, DateTime.now,
now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), Duration.fromInputUnsafe(self.staleTime)), now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), self.staleTime),
) )