1/**
2 * Caches values that need scoped resource management.
3 *
4 * Each cached entry owns its own `Scope`, so resources opened while creating a
5 * value stay alive while that entry is cached and are released when the entry is
6 * removed. A `ScopedCache` also belongs to an outer scope, which closes all
7 * remaining entries when the cache is closed. Lookups for the same missing key
8 * share one in-progress effect, and entries can expire, be refreshed, be
9 * invalidated, or be evicted by capacity limits.
10 *
11 * @since 4.0.0
12 */
13import * as Arr from "./Array.ts"
14import * as Cause from "./Cause.ts"
15import * as Context from "./Context.ts"
16import * as Deferred from "./Deferred.ts"
17import * as Duration from "./Duration.ts"
18import type * as Effect from "./Effect.ts"
19import * as Exit from "./Exit.ts"
20import * as Fiber from "./Fiber.ts"
21import { dual, identity } from "./Function.ts"
22import * as core from "./internal/core.ts"
23import { PipeInspectableProto } from "./internal/core.ts"
24import * as effect from "./internal/effect.ts"
25import * as MutableHashMap from "./MutableHashMap.ts"
26import * as Option from "./Option.ts"
27import type { Pipeable } from "./Pipeable.ts"
28import * as Predicate from "./Predicate.ts"
29import * as Scope from "./Scope.ts"
30
31const TypeId = "~effect/ScopedCache"
32
33/**
34 * A scoped cache whose values are acquired by a lookup effect and stored in
35 * per-entry scopes.
36 *
37 * **When to use**
38 *
39 * Use to cache values that acquire scoped resources and must release those
40 * resources when entries expire, are evicted, or are invalidated.
41 *
42 * **Details**
43 *
44 * Concurrent requests for the same key share the same in-flight lookup.
45 * Entries can expire based on the lookup exit, are evicted when capacity is
46 * exceeded, and release their entry scopes when invalidated, evicted, expired,
47 * or when the cache's owning scope closes.
48 *
49 * @see {@link make} for creating a scoped cache with a fixed time-to-live
50 * @see {@link makeWith} for creating a scoped cache with dynamic time-to-live
51 *
52 * @category models
53 * @since 2.0.0
54 */
55export interface ScopedCache<in out Key, in out A, in out E = never, out R = never> extends Pipeable {
56 readonly [TypeId]: typeof TypeId
57 state: State<Key, A, E>
58 readonly capacity: number
59 readonly lookup: (key: Key) => Effect.Effect<A, E, R | Scope.Scope>
60 readonly timeToLive: (exit: Exit.Exit<A, E>, key: Key) => Duration.Duration
61}
62
63/**
64 * Represents whether a `ScopedCache` is open or closed.
65 *
66 * **When to use**
67 *
68 * Use when inspecting the low-level lifecycle state of a scoped cache.
69 *
70 * **Details**
71 *
72 * `Open` stores cached entries in access order for reuse and eviction.
73 * `Closed` means the owning scope has closed and the cache can no longer
74 * perform lookup operations.
75 *
76 * @category models
77 * @since 4.0.0
78 */
79export type State<K, A, E> = {
80 readonly _tag: "Open"
81 readonly map: MutableHashMap.MutableHashMap<K, Entry<A, E>>
82} | {
83 readonly _tag: "Closed"
84}
85
86/**
87 * A single scoped cache entry.
88 *
89 * **When to use**
90 *
91 * Use when inspecting the open state of a `ScopedCache` and you need the stored
92 * deferred result, entry scope, or expiration timestamp for a key.
93 *
94 * **Details**
95 *
96 * The entry contains the deferred lookup result shared by readers, the scope
97 * that owns resources acquired while computing the value, and an optional
98 * expiration time in milliseconds. Removing the entry closes its scope.
99 *
100 * @see {@link State} for the open/closed cache state that stores entries by key
101 *
102 * @category models
103 * @since 4.0.0
104 */
105export interface Entry<A, E> {
106 expiresAt: number | undefined
107 readonly deferred: Deferred.Deferred<A, E>
108 readonly scope: Scope.Closeable
109 awaiters: number
110 fiber?: Fiber.Fiber<A, E>
111}
112
113/**
114 * Creates a `ScopedCache` from a lookup function, maximum capacity, and a
115 * time-to-live function computed from each lookup exit and key.
116 *
117 * **When to use**
118 *
119 * Use when you need a scoped cache whose entry lifetime depends on each lookup
120 * result or key.
121 *
122 * **Details**
123 *
124 * The cache must be constructed in a `Scope`. Each lookup runs in its own entry
125 * scope, and that scope is closed when the entry expires, is invalidated, is
126 * evicted by capacity, or when the cache's owning scope closes.
127 * `requireServicesAt` controls whether lookup services are captured at
128 * construction time or required when lookup operations run.
129 *
130 * @see {@link make} for creating a scoped cache with one fixed time-to-live
131 *
132 * @category constructors
133 * @since 2.0.0
134 */
135export const makeWith = <
136 Key,
137 A,
138 E = never,
139 R = never,
140 ServiceMode extends "lookup" | "construction" = never
141>(options: {
142 readonly lookup: (key: Key) => Effect.Effect<A, E, R | Scope.Scope>
143 readonly capacity: number
144 readonly timeToLive?: ((exit: Exit.Exit<A, E>, key: Key) => Duration.Input) | undefined
145 readonly requireServicesAt?: ServiceMode | undefined
146}): Effect.Effect<
147 ScopedCache<Key, A, E, "lookup" extends ServiceMode ? Exclude<R, Scope.Scope> : never>,
148 never,
149 ("lookup" extends ServiceMode ? never : R) | Scope.Scope
150> =>
151 effect.contextWith((context: Context.Context<any>) => {
152 const scope = Context.get(context, Scope.Scope)
153 const self = Object.create(Proto)
154 self.lookup = (key: Key): Effect.Effect<A, E> =>
155 effect.updateContext(
156 options.lookup(key),
157 (input) => Context.merge(context, input)
158 )
159 const map = MutableHashMap.empty<Key, Entry<A, E>>()
160 self.state = { _tag: "Open", map }
161 self.capacity = options.capacity
162 self.timeToLive = options.timeToLive
163 ? (exit: Exit.Exit<A, E>, key: Key) => Duration.fromInputUnsafe(options.timeToLive!(exit, key))
164 : defaultTimeToLive
165 return effect.as(
166 Scope.addFinalizer(
167 scope,
168 core.withFiber((fiber) => {
169 self.state = { _tag: "Closed" }
170 return invalidateAllImpl(fiber, map)
171 })
172 ),
173 self
174 )
175 })
176
177/**
178 * Creates a `ScopedCache` with a fixed time-to-live for every lookup result.
179 *
180 * **When to use**
181 *
182 * Use to create a scoped cache when every cached lookup result should share the
183 * same lifetime.
184 *
185 * **Details**
186 *
187 * This is the constant-TTL variant of `makeWith`: values are acquired by the
188 * lookup effect in per-entry scopes, capacity can evict older entries, and
189 * entry scopes are closed when entries expire, are invalidated, are evicted, or
190 * when the cache's owning scope closes.
191 *
192 * @see {@link makeWith} for computing time-to-live from each lookup result and key
193 *
194 * @category constructors
195 * @since 2.0.0
196 */
197export const make = <
198 Key,
199 A,
200 E = never,
201 R = never,
202 ServiceMode extends "lookup" | "construction" = never
203>(
204 options: {
205 readonly lookup: (key: Key) => Effect.Effect<A, E, R | Scope.Scope>
206 readonly capacity: number
207 readonly timeToLive?: Duration.Input | undefined
208 readonly requireServicesAt?: ServiceMode | undefined
209 }
210): Effect.Effect<
211 ScopedCache<Key, A, E, "lookup" extends ServiceMode ? Exclude<R, Scope.Scope> : never>,
212 never,
213 ("lookup" extends ServiceMode ? never : R) | Scope.Scope
214> =>
215 makeWith<Key, A, E, R, ServiceMode>({
216 ...options,
217 timeToLive: options.timeToLive !== undefined ? () => options.timeToLive! : defaultTimeToLive
218 })
219
220const Proto = {
221 ...PipeInspectableProto,
222 [TypeId]: TypeId,
223 toJSON(this: ScopedCache<any, any, any>) {
224 return {
225 _id: "ScopedCache",
226 capacity: this.capacity,
227 state: this.state
228 }
229 }
230}
231
232const defaultTimeToLive = <A, E>(_: Exit.Exit<A, E>, _key: unknown): Duration.Duration => Duration.infinity
233
234/**
235 * Gets the value for a key, running the cache lookup when no unexpired entry is
236 * present.
237 *
238 * **When to use**
239 *
240 * Use to retrieve a scoped cached value by key when a missing or expired entry
241 * should run the cache lookup and share the in-flight lookup with concurrent
242 * callers.
243 *
244 * **Details**
245 *
246 * Concurrent `get` calls for the same key share the same in-flight lookup.
247 * Successful and failed lookup exits are cached according to the configured
248 * TTL. If the cache is closed, the effect is interrupted.
249 *
250 * @see {@link getOption} for reading only when an unexpired entry is already cached
251 * @see {@link getSuccess} for inspecting an already-completed successful entry
252 * @see {@link refresh} for forcing a new lookup
253 *
254 * @category combinators
255 * @since 4.0.0
256 */
257export const get: {
258 <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<A, E, R>
259 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R>
260} = dual(
261 2,
262 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R> =>
263 effect.uninterruptibleMask((restore) =>
264 core.withFiber((fiber) => {
265 const state = self.state
266 if (state._tag === "Closed") {
267 return effect.interrupt
268 }
269 const oentry = MutableHashMap.get(state.map, key)
270 if (Option.isSome(oentry) && !hasExpired(oentry.value, fiber)) {
271 // Move the entry to the end of the map to keep it fresh
272 MutableHashMap.remove(state.map, key)
273 MutableHashMap.set(state.map, key, oentry.value)
274 return awaitEntry(self, key, oentry.value, restore)
275 }
276 const scope = Scope.makeUnsafe()
277 const deferred = Deferred.makeUnsafe<A, E>()
278 const entry: Entry<A, E> = {
279 expiresAt: undefined,
280 deferred,
281 scope,
282 awaiters: 0
283 }
284 MutableHashMap.set(state.map, key, entry)
285 return checkCapacity(fiber, state.map, self.capacity).pipe(
286 Option.isSome(oentry) ? effect.flatMap(() => Scope.close(oentry.value.scope, effect.exitVoid)) : identity,
287 effect.flatMap(() => {
288 entry.fiber = effect.forkUnsafe(
289 fiber,
290 effect.onExit(effect.suspend(() => Scope.provide(self.lookup(key), scope)), (exit) => {
291 if (effect.exitHasInterrupts(exit)) {
292 removeEntry(self, key, entry)
293 Deferred.doneUnsafe(deferred, exit)
294 return Scope.close(scope, exit)
295 }
296 try {
297 const ttl = self.timeToLive(exit, key)
298 if (Duration.isFinite(ttl)) {
299 entry.expiresAt = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl)
300 }
301 } finally {
302 Deferred.doneUnsafe(deferred, exit)
303 }
304 return effect.void
305 }),
306 true,
307 true
308 )
309 return awaitEntry(self, key, entry, restore)
310 })
311 )
312 })
313 )
314)
315
316const awaitEntry = <Key, A, E, R>(
317 self: ScopedCache<Key, A, E, R>,
318 key: Key,
319 entry: Entry<A, E>,
320 restore: <X, Y, Z>(effect: Effect.Effect<X, Y, Z>) => Effect.Effect<X, Y, Z>
321): Effect.Effect<A, E> => {
322 if (Deferred.isDoneUnsafe(entry.deferred)) return restore(Deferred.await(entry.deferred))
323 entry.awaiters++
324 // Install cleanup before restore so a pending interrupt cannot skip the decrement.
325 return effect.onExit(restore(Deferred.await(entry.deferred)), () => {
326 entry.awaiters--
327 const fiber = entry.fiber
328 if (entry.awaiters > 0 || fiber === undefined || Deferred.isDoneUnsafe(entry.deferred)) return effect.void
329 // Detach before interruption so callers arriving during finalization start fresh.
330 removeEntry(self, key, entry)
331 return effect.flatMap(effect.fiberInterrupt(fiber), () => {
332 const exit = fiber.pollUnsafe()!
333 return Exit.isFailure(exit) && Cause.hasDies(exit.cause) ? effect.failCause(exit.cause) : effect.void
334 })
335 })
336}
337
338const removeEntry = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, entry: Entry<A, E>): void => {
339 if (self.state._tag !== "Open") return
340 const current = MutableHashMap.get(self.state.map, key)
341 if (Option.isSome(current) && current.value === entry) MutableHashMap.remove(self.state.map, key)
342}
343
344const hasExpired = <A, E>(entry: Entry<A, E>, fiber: Fiber.Fiber<unknown, unknown>): boolean => {
345 if (entry.expiresAt === undefined) {
346 return false
347 }
348 return fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() >= entry.expiresAt
349}
350
351const checkCapacity = <K, A, E>(
352 parent: Fiber.Fiber<unknown, unknown>,
353 map: MutableHashMap.MutableHashMap<K, Entry<A, E>>,
354 capacity: number
355): Effect.Effect<void> => {
356 if (!Number.isFinite(capacity)) return effect.void
357 let diff = MutableHashMap.size(map) - capacity
358 if (diff <= 0) return effect.void
359 // MutableHashMap has insertion order, so we can remove the oldest entries
360 const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>()
361 for (const [key, entry] of map) {
362 MutableHashMap.remove(map, key)
363 fibers.push(effect.forkUnsafe(parent as any, Scope.close(entry.scope, effect.exitVoid), true))
364 diff--
365 if (diff === 0) break
366 }
367 return effect.fiberAwaitAll(fibers)
368}
369
370/**
371 * Reads an existing unexpired cache entry without running the lookup function.
372 *
373 * **When to use**
374 *
375 * Use to read a scoped value only when it is already cached, without starting
376 * the lookup for missing or expired keys.
377 *
378 * **Details**
379 *
380 * Returns `Option.none` when the key is absent or expired. If an entry exists,
381 * the effect waits for its cached result and returns `Option.some(value)` on
382 * success, or fails with the cached lookup error.
383 *
384 * @see {@link get} for running the lookup on missing or expired keys
385 * @see {@link getSuccess} for inspecting only already-completed successful entries
386 *
387 * @category combinators
388 * @since 4.0.0
389 */
390export const getOption: {
391 <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<Option.Option<A>, E>
392 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>, E>
393} = dual(
394 2,
395 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>, E> =>
396 effect.uninterruptibleMask((restore) =>
397 core.withFiber((fiber) =>
398 effect.flatMap(
399 getImpl(self, key, fiber),
400 (entry) => entry ? effect.asSome(awaitEntry(self, key, entry, restore)) : effect.succeedNone
401 )
402 )
403 )
404)
405
406const getImpl = <Key, A, E, R>(
407 self: ScopedCache<Key, A, E, R>,
408 key: Key,
409 fiber: Fiber.Fiber<any, any>,
410 isRead = true
411): Effect.Effect<Entry<A, E> | undefined> => {
412 if (self.state._tag === "Closed") {
413 return effect.interrupt
414 }
415 const state = self.state
416 const oentry = MutableHashMap.get(state.map, key)
417 if (Option.isNone(oentry)) {
418 return effect.undefined
419 } else if (hasExpired(oentry.value, fiber)) {
420 MutableHashMap.remove(state.map, key)
421 return effect.as(
422 Scope.close(oentry.value.scope, effect.exitVoid),
423 undefined
424 )
425 } else if (isRead) {
426 MutableHashMap.remove(state.map, key)
427 MutableHashMap.set(state.map, key, oentry.value)
428 }
429 return effect.succeed(oentry.value)
430}
431
432/**
433 * Retrieves the value associated with the specified key from the cache, only if
434 * it contains a resolved successful value.
435 *
436 * **When to use**
437 *
438 * Use to inspect an already-completed successful scoped cache entry without
439 * running or awaiting the lookup effect.
440 *
441 * **Details**
442 *
443 * Returns `Option.some` for a resolved successful entry. Returns `Option.none`
444 * for missing, expired, failed, or still-pending entries.
445 *
446 * @see {@link get} for awaiting or starting the lookup effect
447 * @see {@link getOption} for awaiting an already-cached entry without starting a lookup
448 *
449 * @category combinators
450 * @since 4.0.0
451 */
452export const getSuccess: {
453 <Key, A, R>(key: Key): <E>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<Option.Option<A>>
454 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>>
455} = dual(
456 2,
457 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>> =>
458 effect.uninterruptible(
459 core.withFiber((fiber) =>
460 effect.map(
461 getImpl(self, key, fiber),
462 (entry) => {
463 const exit = entry?.deferred.effect as Exit.Exit<A, E> | undefined
464 if (exit && effect.exitIsSuccess(exit)) {
465 return Option.some(exit.value)
466 }
467 return Option.none()
468 }
469 )
470 )
471 )
472)
473
474/**
475 * Sets a successful value for a key without running the lookup function.
476 *
477 * **When to use**
478 *
479 * Use to seed or overwrite a scoped cache entry with an already available
480 * successful value.
481 *
482 * **Details**
483 *
484 * This replaces and closes any existing entry scope for the key, applies the
485 * cache's TTL using a successful exit for the value, and may evict older
486 * entries if the cache capacity is exceeded.
487 *
488 * @see {@link get} for reading or computing a cached value
489 * @see {@link refresh} for replacing an entry by running the lookup function
490 *
491 * @category combinators
492 * @since 4.0.0
493 */
494export const set: {
495 <Key, A>(key: Key, value: A): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<void>
496 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, value: A): Effect.Effect<void>
497} = dual(
498 3,
499 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, value: A): Effect.Effect<void> =>
500 effect.uninterruptible(
501 core.withFiber((fiber) => {
502 if (self.state._tag === "Closed") {
503 return effect.interrupt
504 }
505 const oentry = MutableHashMap.get(self.state.map, key)
506 const state = self.state
507 const exit = core.exitSucceed(value)
508 const deferred = Deferred.makeUnsafe<A, E>()
509 Deferred.doneUnsafe(deferred, exit)
510 const ttl = self.timeToLive(exit, key)
511 MutableHashMap.set(state.map, key, {
512 scope: Scope.makeUnsafe(),
513 deferred,
514 awaiters: 0,
515 expiresAt: Duration.isFinite(ttl)
516 ? fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl)
517 : undefined
518 })
519 const check = checkCapacity(fiber, state.map, self.capacity)
520 return Option.isSome(oentry)
521 ? effect.flatMap(Scope.close(oentry.value.scope, effect.exitVoid), () => check)
522 : check
523 })
524 )
525)
526
527/**
528 * Checks whether the cache contains an entry for the specified key.
529 *
530 * **When to use**
531 *
532 * Use to test whether an unexpired entry exists for a key without running the
533 * cache lookup.
534 *
535 * **Details**
536 *
537 * This does not start lookups and does not refresh access order. Expired
538 * entries are treated as absent and their scopes are closed while checking. If
539 * the cache is closed, the effect is interrupted.
540 *
541 * @see {@link getOption} for reading an existing cached entry
542 * @see {@link get} for running the lookup on missing or expired keys
543 *
544 * @category combinators
545 * @since 4.0.0
546 */
547export const has: {
548 <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<boolean>
549 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<boolean>
550} = dual(
551 2,
552 <Key, A, E>(self: ScopedCache<Key, A, E>, key: Key): Effect.Effect<boolean> =>
553 effect.uninterruptible(
554 core.withFiber((fiber) => effect.map(getImpl(self, key, fiber, false), Predicate.isNotUndefined))
555 )
556)
557
558/**
559 * Removes the entry associated with a key and closes its entry scope.
560 *
561 * **When to use**
562 *
563 * Use to remove a single key from a scoped cache and release any resources owned
564 * by that entry before a later lookup computes it again.
565 *
566 * **Details**
567 *
568 * If the key is absent, this is a no-op.
569 *
570 * **Gotchas**
571 *
572 * If the cache is closed, the effect is interrupted.
573 *
574 * @see {@link refresh} for replacing a key by running a new lookup immediately
575 * @see {@link invalidateWhen} for invalidating only when a cached value matches a predicate
576 * @see {@link invalidateAll} for removing every cached entry
577 *
578 * @category combinators
579 * @since 4.0.0
580 */
581export const invalidate: {
582 <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<void>
583 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<void>
584} = dual(2, <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<void> =>
585 effect.uninterruptible(
586 effect.suspend(() => {
587 if (self.state._tag === "Closed") {
588 return effect.interrupt
589 }
590 const oentry = MutableHashMap.get(self.state.map, key)
591 if (Option.isNone(oentry)) {
592 return effect.void
593 }
594 MutableHashMap.remove(self.state.map, key)
595 return Scope.close(oentry.value.scope, effect.exitVoid)
596 })
597 ))
598
599/**
600 * Invalidates the entry associated with the specified key in the cache when the
601 * predicate returns true for the cached value.
602 *
603 * **When to use**
604 *
605 * Use to remove an already-cached scoped value only when the successful cached
606 * value satisfies a predicate.
607 *
608 * **Details**
609 *
610 * Returns `true` only when a successful cached value matches and is removed. It
611 * returns `false` for absent, expired, failed, or non-matching entries.
612 *
613 * **Gotchas**
614 *
615 * A matching invalidation closes the entry scope and releases its resources.
616 *
617 * @see {@link invalidate} for unconditional removal by key
618 *
619 * @category combinators
620 * @since 4.0.0
621 */
622export const invalidateWhen: {
623 <Key, A>(key: Key, f: Predicate.Predicate<A>): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<boolean>
624 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, f: Predicate.Predicate<A>): Effect.Effect<boolean>
625} = dual(
626 3,
627 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, f: Predicate.Predicate<A>): Effect.Effect<boolean> =>
628 effect.uninterruptibleMask((restore) =>
629 core.withFiber((fiber) =>
630 effect.flatMap(getImpl(self, key, fiber, false), (entry) => {
631 if (entry === undefined) {
632 return effect.succeed(false)
633 }
634 return awaitEntry(self, key, entry, restore).pipe(
635 effect.flatMap((value) => {
636 if (self.state._tag === "Closed") {
637 return effect.succeed(false)
638 } else if (f(value)) {
639 const current = MutableHashMap.get(self.state.map, key)
640 if (Option.isNone(current) || current.value !== entry) {
641 return effect.succeed(false)
642 }
643 MutableHashMap.remove(self.state.map, key)
644 return effect.as(Scope.close(entry.scope, effect.exitVoid), true)
645 }
646 return effect.succeed(false)
647 }),
648 effect.catch_(() => effect.succeed(false))
649 )
650 })
651 )
652 )
653)
654
655/**
656 * Forces a refresh of the value associated with the specified key in the cache.
657 *
658 * **When to use**
659 *
660 * Use to recompute a scoped cache entry immediately, even when an unexpired
661 * value is already cached.
662 *
663 * **Details**
664 *
665 * It will always invoke the lookup function to construct a new value,
666 * overwriting any existing value for that key.
667 *
668 * @see {@link get} for reusing an unexpired entry before running the lookup
669 * @see {@link invalidate} for removing an entry without recomputing it
670 *
671 * @category combinators
672 * @since 4.0.0
673 */
674export const refresh: {
675 <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<A, E, R>
676 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R>
677} = dual(
678 2,
679 <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R> =>
680 effect.uninterruptibleMask(effect.fnUntraced(function*(restore) {
681 if (self.state._tag === "Closed") return yield* effect.interrupt
682 if (!MutableHashMap.has(self.state.map, key)) return yield* restore(get(self, key))
683 const fiber = Fiber.getCurrent()!
684 const scope = Scope.makeUnsafe()
685 const deferred = Deferred.makeUnsafe<A, E>()
686 const exit = yield* effect.exit(effect.suspend(() => restore(Scope.provide(self.lookup(key), scope))))
687 Deferred.doneUnsafe(deferred, exit)
688 if (effect.exitHasInterrupts(exit)) {
689 yield* Scope.close(scope, exit)
690 return yield* exit
691 }
692 // @ts-ignore async gap
693 if (self.state._tag === "Closed") {
694 yield* Scope.close(scope, effect.exitVoid)
695 return yield* effect.interrupt
696 }
697 const ttl = self.timeToLive(exit, key)
698 const entry: Entry<A, E> = {
699 scope,
700 deferred,
701 awaiters: 0,
702 expiresAt: Duration.isFinite(ttl)
703 ? fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl)
704 : undefined
705 }
706 const oentry = MutableHashMap.get(self.state.map, key)
707 MutableHashMap.set(self.state.map, key, entry)
708 yield* checkCapacity(fiber, self.state.map, self.capacity)
709 if (Option.isSome(oentry)) {
710 yield* Scope.close(oentry.value.scope, effect.exitVoid)
711 }
712 return yield* exit
713 }))
714)
715
716/**
717 * Removes every entry from the cache and closes each entry scope.
718 *
719 * **When to use**
720 *
721 * Use to clear a scoped cache and release resources owned by all cached entries.
722 *
723 * **Details**
724 *
725 * If the cache is closed, the effect is interrupted.
726 *
727 * @see {@link invalidate} for removing one cached entry
728 *
729 * @category combinators
730 * @since 4.0.0
731 */
732export const invalidateAll = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<void> =>
733 core.withFiber((parent) => {
734 if (self.state._tag === "Closed") {
735 return effect.interrupt
736 }
737 return invalidateAllImpl(parent, self.state.map)
738 })
739
740const invalidateAllImpl = <Key, A, E>(
741 parent: Fiber.Fiber<unknown, unknown>,
742 map: MutableHashMap.MutableHashMap<Key, Entry<A, E>>
743): Effect.Effect<void> => {
744 // Detach the batch before finalizers can reenter the cache.
745 const entries = Array.from(MutableHashMap.values(map))
746 MutableHashMap.clear(map)
747 const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>()
748 for (const entry of entries) {
749 fibers.push(effect.forkUnsafe(parent as any, Scope.close(entry.scope, effect.exitVoid), true, true))
750 }
751 return effect.fiberAwaitAll(fibers)
752}
753
754/**
755 * Retrieves the approximate number of entries in the cache.
756 *
757 * **When to use**
758 *
759 * Use to inspect how many entries are currently stored in the scoped cache.
760 *
761 * **Gotchas**
762 *
763 * Note that expired entries are counted until they are accessed and removed.
764 * The size reflects the current number of entries stored, not the number
765 * of valid entries.
766 *
767 * @category combinators
768 * @since 4.0.0
769 */
770export const size = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<number> =>
771 effect.sync(() => self.state._tag === "Closed" ? 0 : MutableHashMap.size(self.state.map))
772
773/**
774 * Retrieves all active keys from the cache, automatically filtering out expired entries.
775 *
776 * **When to use**
777 *
778 * Use to inspect currently cached unexpired keys without running cache lookups.
779 *
780 * **Gotchas**
781 *
782 * Expired entries are removed and their scopes are closed while filtering.
783 *
784 * @see {@link entries} for retrieving successful cached key-value pairs
785 * @see {@link values} for retrieving only successfully cached values
786 *
787 * @category combinators
788 * @since 4.0.0
789 */
790export const keys = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<Array<Key>> =>
791 core.withFiber((fiber) => {
792 if (self.state._tag === "Closed") return effect.succeed([])
793 const state = self.state
794 const now = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe()
795 const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>()
796 const keys: Array<Key> = []
797 for (const [key, entry] of state.map) {
798 if (entry.expiresAt === undefined || entry.expiresAt > now) {
799 keys.push(key)
800 } else {
801 MutableHashMap.remove(state.map, key)
802 fibers.push(effect.forkUnsafe(fiber, Scope.close(entry.scope, effect.exitVoid), true, true))
803 }
804 }
805 return fibers.length === 0 ? effect.succeed(keys) : effect.as(effect.fiberAwaitAll(fibers), keys)
806 })
807
808/**
809 * Retrieves all successfully cached values from the cache, excluding failed
810 * lookups and expired entries.
811 *
812 * **When to use**
813 *
814 * Use to inspect currently successful cached values without running cache
815 * lookups.
816 *
817 * **Gotchas**
818 *
819 * Expired entries are removed and their scopes are closed while filtering.
820 *
821 * @see {@link entries} for retrieving successful cached key-value pairs
822 * @see {@link keys} for retrieving only cached keys
823 *
824 * @category combinators
825 * @since 4.0.0
826 */
827export const values = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<Array<A>> =>
828 effect.map(entries(self), Arr.map(([, value]) => value))
829
830/**
831 * Retrieves all key-value pairs from the cache as an array. This function
832 * only returns entries with successfully resolved values, filtering out any
833 * failed lookups or expired entries.
834 *
835 * **When to use**
836 *
837 * Use to inspect the currently successful cached key-value pairs without
838 * running cache lookups.
839 *
840 * **Gotchas**
841 *
842 * Expired entries are removed and their scopes are closed while filtering.
843 *
844 * @see {@link keys} for retrieving only cached keys
845 * @see {@link values} for retrieving only cached values
846 *
847 * @category combinators
848 * @since 4.0.0
849 */
850export const entries = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<Array<[Key, A]>> =>
851 core.withFiber((fiber) => {
852 if (self.state._tag === "Closed") return effect.succeed([])
853 const state = self.state
854 const now = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe()
855 const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>()
856 const arr: Array<[Key, A]> = []
857 for (const [key, entry] of state.map) {
858 if (entry.expiresAt === undefined || entry.expiresAt > now) {
859 const exit = entry.deferred.effect
860 if (core.isExit(exit) && !effect.exitIsFailure(exit)) {
861 arr.push([key, exit.value as A])
862 }
863 } else {
864 MutableHashMap.remove(state.map, key)
865 fibers.push(effect.forkUnsafe(fiber, Scope.close(entry.scope, effect.exitVoid), true, true))
866 }
867 }
868 return fibers.length === 0
869 ? effect.succeed(arr)
870 : effect.as(effect.fiberAwaitAll(fibers), arr)
871 })