import { type Cause, Context, DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, Semaphore, SubscriptionRef } from "effect" import type { AsyncResult } from "effect/unstable/reactivity" import * as Lens from "./Lens.js" import type * as Subscribable from "./Subscribable.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> readonly cacheGcTime: Duration.Duration readonly defaultStaleTime: Duration.Duration readonly defaultRefreshOnWindowFocus: boolean readonly run: Effect.Effect getCacheEntry(key: QueryClientCacheKey): Effect.Effect> setCacheEntry( key: QueryClientCacheKey, result: AsyncResult.Success, staleTime: Duration.Duration, ): Effect.Effect invalidateCacheEntries(f: (key: unknown) => Effect.Effect): Effect.Effect invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect } export class QueryClient extends Context.Service()( "@effect-fc/QueryClient/QueryClient" ) {} export class QueryClientServiceImpl extends Pipeable.Class implements QueryClientService { readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId constructor( readonly cache: Lens.Lens>, readonly cacheGcTime: Duration.Duration, readonly defaultStaleTime: Duration.Duration, readonly defaultRefreshOnWindowFocus: boolean, readonly runSemaphore: Semaphore.Semaphore, ) { super() } get run(): Effect.Effect { return this.runSemaphore.withPermits(1)(Effect.repeat( Effect.flatMap( DateTime.now, now => Lens.update(this.cache, HashMap.filter(entry => Duration.isLessThan( DateTime.distance(entry.lastAccessedAt, now), Duration.sum(entry.staleTime, this.cacheGcTime), ) )), ), Schedule.spaced("30 second"), )) } getCacheEntry(key: QueryClientCacheKey): Effect.Effect> { return Effect.all([ DateTime.now, Effect.flatMap( Effect.map(Lens.get(this.cache), HashMap.get(key)), Effect.fromOption, ), ]).pipe( Effect.map(([now, entry]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)), Effect.tap(entry => Lens.update(this.cache, HashMap.set(key, entry))), Effect.option, ) } setCacheEntry( key: QueryClientCacheKey, result: AsyncResult.Success, staleTime: Duration.Duration, ): Effect.Effect { return DateTime.now.pipe( Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)), Effect.tap(entry => Lens.update(this.cache, HashMap.set(key, entry))), ) } invalidateCacheEntries(f: (key: unknown) => Effect.Effect): Effect.Effect { return Lens.update(this.cache, HashMap.filter((_, key) => !Equivalence.strictEqual()(key.f, f))) } invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect { return Lens.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 cacheGcTime?: Duration.Input readonly defaultStaleTime?: Duration.Input readonly defaultRefreshOnWindowFocus?: boolean } } export const make = Effect.fnUntraced(function* ( options: make.Options = {} ): Effect.fn.Return { return new QueryClientServiceImpl( Lens.fromSubscriptionRef(yield* SubscriptionRef.make(HashMap.empty())), yield* Effect.fromOption(Duration.fromInput(options.cacheGcTime ?? "5 minutes")), yield* Effect.fromOption(Duration.fromInput(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 => Effect.tap( make(options), client => 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 Pipeable.Pipeable, Equal.Equal { readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId constructor( readonly key: unknown, readonly f: (key: unknown) => Effect.Effect, ) { super() } [Equal.symbol](that: Equal.Equal) { return isQueryClientCacheKey(that) && Equal.equals(this.key, that.key) && Equivalence.strictEqual()(this.f, that.f) } [Hash.symbol]() { return Hash.combine(Hash.hash(this.f))(Hash.hash(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 implements Pipeable.Pipeable { readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId constructor( readonly result: AsyncResult.Success, readonly staleTime: Duration.Duration, 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 => Effect.map( DateTime.now, now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), self.staleTime), )