Add Effect v4 version
Lint / lint (push) Successful in 48s

This commit is contained in:
Julien Valverdé
2026-06-22 02:04:20 +02:00
parent b7ea35006d
commit 091e102b23
41 changed files with 3832 additions and 2 deletions
+191
View File
@@ -0,0 +1,191 @@
import {
Context,
DateTime,
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 Result from "./Result.js"
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.Input
readonly defaultStaleTime: Duration.Input
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.Input): 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 Context.Service<QueryClient, QueryClientService>()(
"@effect-fc/QueryClient/QueryClient",
) {
static get Default(): Layer.Layer<QueryClient> {
return Layer.effect(QueryClient)(service())
}
}
export class QueryClientServiceImpl extends Pipeable.Class implements QueryClientService {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
constructor(
readonly cacheRef: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly cacheGcTime: Duration.Input,
readonly defaultStaleTime: Duration.Input,
readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.cache = Subscribable.make({
get: SubscriptionRef.get(cacheRef),
changes: SubscriptionRef.changes(cacheRef),
})
}
get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Effect.repeat(
Effect.flatMap(DateTime.now, now => SubscriptionRef.update(this.cacheRef, HashMap.filter(entry =>
Duration.isLessThan(
DateTime.distance(entry.lastAccessedAt, now),
Duration.sum(Duration.fromInputUnsafe(entry.staleTime), Duration.fromInputUnsafe(this.cacheGcTime)),
)
))),
Schedule.spaced("30 seconds"),
))
}
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
const self = this
return Effect.gen(function*() {
const entry = HashMap.get(yield* SubscriptionRef.get(self.cacheRef), key)
if (Option.isNone(entry)) return Option.none()
const now = yield* DateTime.now
const accessed = new QueryClientCacheEntry(
entry.value.result,
entry.value.staleTime,
entry.value.createdAt,
now,
)
yield* SubscriptionRef.update(self.cacheRef, HashMap.set(key, accessed))
return Option.some(accessed)
})
}
setCacheEntry(
key: QueryClientCacheKey,
result: Result.Success<unknown>,
staleTime: Duration.Input,
): Effect.Effect<QueryClientCacheEntry> {
return Effect.flatMap(DateTime.now, now => {
const entry = new QueryClientCacheEntry(result, staleTime, now, now)
return Effect.as(SubscriptionRef.update(this.cacheRef, HashMap.set(key, entry)), entry)
})
}
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)))
}
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
return SubscriptionRef.update(this.cacheRef, HashMap.remove(key))
}
}
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
export declare namespace make {
export interface Options {
readonly cacheGcTime?: Duration.Input
readonly defaultStaleTime?: Duration.Input
readonly defaultRefreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
return new QueryClientServiceImpl(
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
options.cacheGcTime ?? "5 minutes",
options.defaultStaleTime ?? "0 minutes",
options.defaultRefreshOnWindowFocus ?? true,
yield* Semaphore.make(1),
)
})
export declare namespace service {
export interface Options extends make.Options {}
}
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
make(options),
client => Effect.asVoid(Effect.forkScoped(client.run)),
)
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
export class QueryClientCacheKey extends Pipeable.Class implements Equal.Equal {
readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId
constructor(
readonly key: Query.Query.AnyKey,
readonly f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>,
) {
super()
}
[Equal.symbol](that: Equal.Equal): boolean {
return isQueryClientCacheKey(that)
&& Equivalence.Array(Equal.asEquivalence())(this.key, that.key)
&& Equivalence.strictEqual()(this.f, that.f)
}
[Hash.symbol](): number {
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 QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry")
export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId
export class QueryClientCacheEntry extends Pipeable.Class {
readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId
constructor(
readonly result: Result.Success<unknown>,
readonly staleTime: Duration.Input,
readonly createdAt: DateTime.DateTime,
readonly lastAccessedAt: DateTime.DateTime,
) {
super()
}
}
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
export const isQueryClientCacheEntryStale = (self: QueryClientCacheEntry): Effect.Effect<boolean> => Effect.map(
DateTime.now,
now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), Duration.fromInputUnsafe(self.staleTime)),
)