import { assert, describe, it } from "@effect/vitest" import { Clock, Context, Data, Deferred, Duration, Effect, Exit, Fiber, Option, ScopedCache } from "effect" import { TestClock } from "effect/testing" describe("ScopedCache", () => { describe("constructors", () => { describe("make", () => { it.effect("creates cache with fixed capacity", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) assert.strictEqual(cache.capacity, 10) })) it.effect("creates cache with default infinite TTL", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) // Add value and verify it doesn't expire yield* ScopedCache.get(cache, "test") yield* TestClock.adjust(Duration.hours(1000)) assert.isTrue(yield* ScopedCache.has(cache, "test")) })) it.effect("creates cache with custom TTL", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "test") yield* TestClock.adjust("30 minutes") assert.isTrue(yield* ScopedCache.has(cache, "test")) yield* TestClock.adjust("31 minutes") assert.isFalse(yield* ScopedCache.has(cache, "test")) })) it.effect("lookup function context is preserved", () => Effect.gen(function*() { class TestService extends Context.Service()("TestService") {} const program = Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.map(TestService, (service) => service.value) }) return yield* ScopedCache.get(cache, "test") }) const result = yield* Effect.scoped( program.pipe( Effect.provideService(TestService, TestService.of({ value: 42 })) ) ) assert.strictEqual(result, 42) })) it.effect("cache resources are released and future gets interrupt when scope closes", () => Effect.gen(function*() { const cache = yield* Effect.scoped( Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "test") return cache }) ) assert.strictEqual(cache.state._tag, "Closed") assert.isTrue(Exit.hasInterrupts(yield* Effect.exit(ScopedCache.get(cache, "test")))) })) }) describe("makeWith", () => { it.effect("creates cache with function-based TTL", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length), timeToLive: (exit, key) => { if (Exit.isFailure(exit)) return "1 second" return key === "short" ? "1 minute" : "1 hour" } }) // Success with long TTL yield* ScopedCache.get(cache, "long") yield* TestClock.adjust("30 minutes") assert.isTrue(yield* ScopedCache.has(cache, "long")) // Success with short TTL yield* ScopedCache.get(cache, "short") assert.isTrue(yield* ScopedCache.has(cache, "short")) yield* TestClock.adjust(Duration.sum(Duration.minutes(1), Duration.seconds(1))) assert.isFalse(yield* ScopedCache.has(cache, "short")) assert.isTrue(yield* ScopedCache.has(cache, "long")) // Failure with very short TTL yield* Effect.exit(ScopedCache.get(cache, "fail")) assert.isTrue(yield* ScopedCache.has(cache, "fail")) yield* TestClock.adjust("2 seconds") assert.isFalse(yield* ScopedCache.has(cache, "fail")) })) it.effect("TTL function receives correct parameters", () => Effect.gen(function*() { const receivedParams: Array<{ exit: Exit.Exit; key: string }> = [] const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length), timeToLive: (exit, key) => { receivedParams.push({ exit, key }) return Duration.infinity } }) yield* ScopedCache.get(cache, "test") yield* Effect.exit(ScopedCache.get(cache, "fail")) assert.strictEqual(receivedParams.length, 2) assert.strictEqual(receivedParams[0].key, "test") assert.isTrue(Exit.isSuccess(receivedParams[0].exit)) assert(Exit.isSuccess(receivedParams[0].exit)) assert.strictEqual(receivedParams[0].exit.value, 4) assert.strictEqual(receivedParams[1].key, "fail") assert.isTrue(Exit.isFailure(receivedParams[1].exit)) assert.deepStrictEqual(receivedParams[1].exit, Exit.fail("error")) })) it.effect("different TTL for success vs failure", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length), timeToLive: (exit) => Exit.isSuccess(exit) ? "1 hour" : "1 minute" }) yield* ScopedCache.get(cache, "success") yield* Effect.exit(ScopedCache.get(cache, "fail")) yield* TestClock.adjust("30 minutes") assert.isTrue(yield* ScopedCache.has(cache, "success")) assert.isFalse(yield* ScopedCache.has(cache, "fail")) })) it.effect("TTL based on key values", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: (_exit, key) => key === "short" ? "1 minute" : "1 hour" }) yield* ScopedCache.get(cache, "short") yield* ScopedCache.get(cache, "long") yield* TestClock.adjust("30 minutes") assert.isFalse(yield* ScopedCache.has(cache, "short")) assert.isTrue(yield* ScopedCache.has(cache, "long")) })) it.effect("TTL based on result values", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: (exit) => { const value = Exit.isSuccess(exit) ? exit.value : 0 return value > 3 ? "1 hour" : "1 minute" } }) yield* ScopedCache.get(cache, "ab") // length 2 yield* ScopedCache.get(cache, "abcd") // length 4 yield* TestClock.adjust("30 minutes") assert.isFalse(yield* ScopedCache.has(cache, "ab")) assert.isTrue(yield* ScopedCache.has(cache, "abcd")) })) it.effect("infinite TTL handling", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: () => Duration.infinity }) yield* ScopedCache.get(cache, "test") yield* TestClock.adjust(Duration.hours(1000)) assert.isTrue(yield* ScopedCache.has(cache, "test")) })) it.effect("zero duration TTL", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: () => Duration.zero }) yield* ScopedCache.get(cache, "test") // Entry should expire immediately assert.isFalse(yield* ScopedCache.has(cache, "test")) })) }) }) describe("basic operations", () => { describe("get", () => { it.effect("cache hit - multiple different keys", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) const result1 = yield* ScopedCache.get(cache, "a") const result2 = yield* ScopedCache.get(cache, "ab") const result3 = yield* ScopedCache.get(cache, "abc") assert.strictEqual(result1, 1) assert.strictEqual(result2, 2) assert.strictEqual(result3, 3) })) it.effect("cache hit - same key multiple times doesn't invoke lookup again", () => Effect.gen(function*() { const { cache, lookupCount, setLookupResult } = yield* makeScopedTestCache(10) setLookupResult("test", Effect.succeed(4)) const results = yield* Effect.all([ ScopedCache.get(cache, "test"), ScopedCache.get(cache, "test"), ScopedCache.get(cache, "test") ]) assert.deepStrictEqual(results, [4, 4, 4]) assert.strictEqual(lookupCount(), 1) })) it.effect("cache miss - invokes lookup for non-existent key", () => Effect.gen(function*() { const { cache, lookupCount, setLookupResult } = yield* makeScopedTestCache(10) setLookupResult("test", Effect.succeed(42)) const result = yield* ScopedCache.get(cache, "test") assert.strictEqual(result, 42) assert.strictEqual(lookupCount(), 1) })) it.effect("cache miss - invokes lookup again after TTL expiration", () => Effect.gen(function*() { let counter = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.sync(() => ++counter), timeToLive: "1 hour" }) const result1 = yield* ScopedCache.get(cache, "test") assert.strictEqual(result1, 1) yield* TestClock.adjust("30 minutes") const result2 = yield* ScopedCache.get(cache, "test") assert.strictEqual(result2, 1) yield* TestClock.adjust("31 minutes") const result3 = yield* ScopedCache.get(cache, "test") assert.strictEqual(result3, 2) })) it.effect("error handling - lookup function fails", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.fail("lookup error") }) const result = yield* Effect.exit(ScopedCache.get(cache, "test")) assert.deepStrictEqual(result, Exit.fail("lookup error")) })) it.effect("concurrent access - multiple fibers getting same key only invoke lookup once", () => Effect.gen(function*() { let lookupCount = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: () => Effect.delay(Duration.millis(100))(Effect.sync(() => ++lookupCount)) }) const effects = [ScopedCache.get(cache, "key"), ScopedCache.get(cache, "key"), ScopedCache.get(cache, "key")] const resultsFiber = yield* Effect.all(effects, { concurrency: "unbounded" }).pipe(Effect.forkChild) yield* TestClock.adjust(Duration.millis(150)) const results = yield* Fiber.join(resultsFiber) assert.strictEqual(lookupCount, 1) assert.deepStrictEqual(results, [1, 1, 1]) })) it.effect("concurrent access - race between get and invalidate", () => Effect.gen(function*() { let lookupCount = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.delay(Duration.millis(50))(Effect.sync(() => ++lookupCount)) }) // First get starts const fiber1 = yield* ScopedCache.get(cache, "test").pipe(Effect.forkChild) yield* TestClock.adjust(Duration.millis(25)) // Invalidate while first get is in progress yield* ScopedCache.invalidate(cache, "test") // Second get starts after invalidation const fiber2 = yield* ScopedCache.get(cache, "test").pipe(Effect.forkChild) yield* TestClock.adjust(Duration.millis(100)) const result1 = yield* Fiber.join(fiber1) const result2 = yield* Fiber.join(fiber2) assert.strictEqual(result1, 1) assert.strictEqual(result2, 2) assert.strictEqual(lookupCount, 2) })) it.effect("resource cleanup when entry is replaced", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* makeManagedResourceCache(2) // Fill cache to capacity yield* ScopedCache.get(cache, "key1") yield* ScopedCache.get(cache, "key2") assert.strictEqual(cleanupTracker.cleanedUp.length, 0) // Add third entry, should evict first yield* ScopedCache.get(cache, "key3") assert.strictEqual(cleanupTracker.cleanedUp.length, 1) assert.deepStrictEqual(cleanupTracker.cleanedUp, ["key1"]) })) it.effect("scope is properly provided to lookup function", () => Effect.gen(function*() { const scopeIds: Array = [] const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { // Create a resource in the provided scope yield* Effect.acquireRelease( Effect.sync(() => scopeIds.push(`acquired-${key}`)), () => Effect.sync(() => scopeIds.push(`released-${key}`)) ) return `value-${key}` }) }) yield* ScopedCache.get(cache, "test") assert.deepStrictEqual(scopeIds, ["acquired-test"]) // Invalidate should trigger resource cleanup yield* ScopedCache.invalidate(cache, "test") assert.deepStrictEqual(scopeIds, ["acquired-test", "released-test"]) })) it.effect("cache closed state - get returns interrupt", () => Effect.gen(function*() { const cache = yield* Effect.scoped( ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) ) // Cache should be closed now const result = yield* Effect.exit(ScopedCache.get(cache!, "test")) assert.isTrue(Exit.hasInterrupts(result)) })) }) describe("getOption", () => { it.effect("returns Some for existing cached value", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "test") const result = yield* ScopedCache.getOption(cache, "test") assert.deepStrictEqual(result, Option.some(4)) })) it.effect("returns None for non-existent key", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) const result = yield* ScopedCache.getOption(cache, "test") assert.deepStrictEqual(result, Option.none()) })) it.effect("returns None for expired value", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "test") yield* TestClock.adjust("2 hours") const result = yield* ScopedCache.getOption(cache, "test") assert.deepStrictEqual(result, Option.none()) })) it.effect("waits for value being computed and returns result", () => Effect.gen(function*() { const deferred = yield* Deferred.make() const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Deferred.await(deferred).pipe(Effect.as(42)) }) const getFiber = yield* ScopedCache.get(cache, "test").pipe(Effect.forkChild) const optionFiber = yield* ScopedCache.getOption(cache, "test").pipe(Effect.forkChild) yield* Deferred.succeed(deferred, void 0) const getResult = yield* Fiber.join(getFiber) const optionResult = yield* Fiber.join(optionFiber) assert.strictEqual(getResult, 42) assert.deepStrictEqual(optionResult, Option.some(42)) })) it.effect("affects LRU order", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 2, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.getOption(cache, "a") yield* ScopedCache.get(cache, "c") assert.isTrue(yield* ScopedCache.has(cache, "a")) assert.isFalse(yield* ScopedCache.has(cache, "b")) assert.isTrue(yield* ScopedCache.has(cache, "c")) })) }) describe("getSuccess", () => { it.effect("returns Some for successfully cached value", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "test") const result = yield* ScopedCache.getSuccess(cache, "test") assert.deepStrictEqual(result, Option.some(4)) })) it.effect("returns None for failed cached value", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.fail("error") }) yield* Effect.exit(ScopedCache.get(cache, "test")) const result = yield* ScopedCache.getSuccess(cache, "test") assert.deepStrictEqual(result, Option.none()) })) it.effect("returns None for non-existent key", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) const result = yield* ScopedCache.getSuccess(cache, "test") assert.deepStrictEqual(result, Option.none()) })) it.effect("returns None for expired value", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "test") yield* TestClock.adjust("2 hours") const result = yield* ScopedCache.getSuccess(cache, "test") assert.deepStrictEqual(result, Option.none()) })) }) }) describe("modification operations", () => { describe("set", () => { it.effect("sets new key-value pair", () => Effect.gen(function*() { const { cache, lookupCount } = yield* makeScopedTestCache(10) yield* ScopedCache.set(cache, "test", 42) const result = yield* ScopedCache.get(cache, "test") assert.strictEqual(result, 42) assert.strictEqual(lookupCount(), 0) })) it.effect("overwrites existing key", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "test") yield* ScopedCache.set(cache, "test", 100) const result = yield* ScopedCache.get(cache, "test") assert.strictEqual(result, 100) })) it.effect("set with TTL - value expires", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.set(cache, "test", 42) assert.isTrue(yield* ScopedCache.has(cache, "test")) yield* TestClock.adjust("2 hours") assert.isFalse(yield* ScopedCache.has(cache, "test")) })) it.effect("set doesn't invoke lookup function", () => Effect.gen(function*() { const { cache, lookupCount } = yield* makeScopedTestCache(10) yield* ScopedCache.set(cache, "test", 42) yield* ScopedCache.set(cache, "test2", 43) yield* ScopedCache.set(cache, "test3", 44) assert.strictEqual(lookupCount(), 0) })) it.effect("set enforces capacity constraints", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 2, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.set(cache, "a", 1) yield* ScopedCache.set(cache, "b", 2) const sizeBefore = yield* ScopedCache.size(cache) assert.strictEqual(sizeBefore, 2) yield* ScopedCache.set(cache, "c", 3) const sizeAfter = yield* ScopedCache.size(cache) assert.strictEqual(sizeAfter, 2) })) it.effect("resource cleanup when overwriting existing entry", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* makeManagedResourceCache(10) yield* ScopedCache.get(cache, "test") assert.strictEqual(cleanupTracker.cleanedUp.length, 0) // Overwrite should clean up old resource yield* ScopedCache.set(cache, "test", "new-value") assert.strictEqual(cleanupTracker.cleanedUp.length, 1) assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"]) })) }) describe("has", () => { it.effect("returns true for existing key", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "test") const result = yield* ScopedCache.has(cache, "test") assert.isTrue(result) })) it.effect("returns false for non-existent key", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) const result = yield* ScopedCache.has(cache, "test") assert.isFalse(result) })) it.effect("returns false for expired key", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "test") yield* TestClock.adjust("2 hours") const result = yield* ScopedCache.has(cache, "test") assert.isFalse(result) })) it.effect("does not affect LRU order", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 2, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") // Check "a" exists without affecting LRU order assert.isTrue(yield* ScopedCache.has(cache, "a")) // Add "c" - should still evict "a" (oldest) not "b" yield* ScopedCache.get(cache, "c") assert.isFalse(yield* ScopedCache.has(cache, "a")) assert.isTrue(yield* ScopedCache.has(cache, "b")) assert.isTrue(yield* ScopedCache.has(cache, "c")) })) }) describe("invalidate", () => { it.effect("invalidates existing key", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "test") yield* ScopedCache.invalidate(cache, "test") const result = yield* ScopedCache.has(cache, "test") assert.isFalse(result) })) it.effect("invalidating non-existent key doesn't error", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.invalidate(cache, "test") const result = yield* ScopedCache.has(cache, "test") assert.isFalse(result) })) it.effect("get after invalidate invokes lookup again", () => Effect.gen(function*() { let counter = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.sync(() => ++counter) }) const result1 = yield* ScopedCache.get(cache, "test") yield* ScopedCache.invalidate(cache, "test") const result2 = yield* ScopedCache.get(cache, "test") assert.strictEqual(result1, 1) assert.strictEqual(result2, 2) })) it.effect("resource cleanup when invalidating entry", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* makeManagedResourceCache(10) yield* ScopedCache.get(cache, "test") assert.strictEqual(cleanupTracker.cleanedUp.length, 0) yield* ScopedCache.invalidate(cache, "test") assert.strictEqual(cleanupTracker.cleanedUp.length, 1) assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"]) })) }) describe("invalidateWhen", () => { it.effect("invalidates when predicate matches", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "test") const result = yield* ScopedCache.invalidateWhen(cache, "test", (value) => value === 4) assert.isTrue(result) assert.isFalse(yield* ScopedCache.has(cache, "test")) })) it.effect("doesn't invalidate when predicate doesn't match", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "test") const result = yield* ScopedCache.invalidateWhen(cache, "test", (value) => value === 5) assert.isFalse(result) assert.isTrue(yield* ScopedCache.has(cache, "test")) })) it.effect("returns false for non-existent key", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) const result = yield* ScopedCache.invalidateWhen(cache, "test", () => true) assert.isFalse(result) })) it.effect("returns false for failed cached value", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.fail("error") }) yield* Effect.exit(ScopedCache.get(cache, "test")) const result = yield* ScopedCache.invalidateWhen(cache, "test", () => true) assert.isFalse(result) })) it.effect("resource cleanup when predicate matches", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* makeManagedResourceCache(10) yield* ScopedCache.get(cache, "test") assert.strictEqual(cleanupTracker.cleanedUp.length, 0) const result = yield* ScopedCache.invalidateWhen(cache, "test", (value) => value.includes("resource")) assert.isTrue(result) assert.strictEqual(cleanupTracker.cleanedUp.length, 1) assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"]) })) }) describe("refresh", () => { it.effect("refresh existing key invokes lookup again", () => Effect.gen(function*() { let counter = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.sync(() => ++counter) }) yield* ScopedCache.get(cache, "test") const result = yield* ScopedCache.refresh(cache, "test") assert.strictEqual(result, 2) })) it.effect("refresh non-existent key invokes lookup", () => Effect.gen(function*() { let counter = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.sync(() => ++counter) }) const result = yield* ScopedCache.refresh(cache, "test") assert.strictEqual(result, 1) })) it.effect("refresh updates TTL", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "test") yield* TestClock.adjust("30 minutes") yield* ScopedCache.refresh(cache, "test") yield* TestClock.adjust("40 minutes") assert.isTrue(yield* ScopedCache.has(cache, "test")) })) it.effect("concurrent refresh calls each invoke lookup independently", () => Effect.gen(function*() { let lookupCount = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: () => Effect.sync(() => ++lookupCount) }) yield* ScopedCache.get(cache, "test") lookupCount = 0 const results = yield* Effect.all([ ScopedCache.refresh(cache, "test"), ScopedCache.refresh(cache, "test"), ScopedCache.refresh(cache, "test") ], { concurrency: "unbounded" }) // Each refresh calls lookup independently assert.strictEqual(lookupCount, 3) assert.deepStrictEqual(results, [1, 2, 3]) })) it.effect("resource cleanup for old entry after refresh", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* makeManagedResourceCache(10) yield* ScopedCache.get(cache, "test") assert.strictEqual(cleanupTracker.cleanedUp.length, 0) yield* ScopedCache.refresh(cache, "test") assert.strictEqual(cleanupTracker.cleanedUp.length, 1) assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"]) })) }) describe("invalidateAll", () => { it.effect("clears all entries", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.get(cache, "c") yield* ScopedCache.invalidateAll(cache) assert.isFalse(yield* ScopedCache.has(cache, "a")) assert.isFalse(yield* ScopedCache.has(cache, "b")) assert.isFalse(yield* ScopedCache.has(cache, "c")) })) it.effect("size becomes 0 after invalidateAll", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.get(cache, "c") yield* ScopedCache.invalidateAll(cache) const size = yield* ScopedCache.size(cache) assert.strictEqual(size, 0) })) it.effect("all keys return None after invalidateAll", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.invalidateAll(cache) const result1 = yield* ScopedCache.getOption(cache, "a") const result2 = yield* ScopedCache.getOption(cache, "b") assert.deepStrictEqual(result1, Option.none()) assert.deepStrictEqual(result2, Option.none()) })) it.effect("resource cleanup for all entries", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* makeManagedResourceCache(10) yield* ScopedCache.get(cache, "test1") yield* ScopedCache.get(cache, "test2") yield* ScopedCache.get(cache, "test3") assert.strictEqual(cleanupTracker.cleanedUp.length, 0) yield* ScopedCache.invalidateAll(cache) assert.strictEqual(cleanupTracker.cleanedUp.length, 3) assert.deepStrictEqual(cleanupTracker.cleanedUp.sort(), ["test1", "test2", "test3"]) })) }) }) describe("utility operations", () => { describe("size", () => { it.effect("empty cache returns 0", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) const size = yield* ScopedCache.size(cache) assert.strictEqual(size, 0) })) it.effect("returns correct count after adding entries", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.get(cache, "c") const size = yield* ScopedCache.size(cache) assert.strictEqual(size, 3) })) it.effect("decreases after invalidation", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.get(cache, "c") yield* ScopedCache.invalidate(cache, "b") const size = yield* ScopedCache.size(cache) assert.strictEqual(size, 2) })) it.effect("expired entries are counted until accessed", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* TestClock.adjust("2 hours") // Size still includes expired entries const sizeBefore = yield* ScopedCache.size(cache) assert.strictEqual(sizeBefore, 2) // Accessing expired entries removes them yield* ScopedCache.has(cache, "a") const sizeAfter = yield* ScopedCache.size(cache) assert.strictEqual(sizeAfter, 1) })) }) describe("keys", () => { it.effect("returns all active keys", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.get(cache, "c") const keys = yield* ScopedCache.keys(cache) assert.deepStrictEqual(keys.sort(), ["a", "b", "c"]) })) it.effect("excludes keys for expired entries", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* TestClock.adjust("2 hours") const keys = yield* ScopedCache.keys(cache) assert.deepStrictEqual(keys.sort(), []) })) it.effect("empty after invalidateAll", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.invalidateAll(cache) const keys = yield* ScopedCache.keys(cache) assert.deepStrictEqual(keys, []) })) it.effect("resource cleanup for expired entries as side effect", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* Effect.gen(function*() { const cleanupTracker: CleanupTracker = { cleanedUp: [], acquired: [] } const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => cleanupTracker.acquired.push(key)), () => Effect.sync(() => cleanupTracker.cleanedUp.push(key)) ) return `resource-${key}` }), timeToLive: "1 hour" }) return { cache, cleanupTracker } }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") assert.strictEqual(cleanupTracker.cleanedUp.length, 0) yield* TestClock.adjust("2 hours") // keys() should clean up expired entries as side effect const keys = yield* ScopedCache.keys(cache) assert.deepStrictEqual(keys, []) assert.strictEqual(cleanupTracker.cleanedUp.length, 2) assert.deepStrictEqual(cleanupTracker.cleanedUp.sort(), ["a", "b"]) })) }) describe("values", () => { it.effect("returns all successful cached values", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "ab") yield* ScopedCache.get(cache, "abc") const values = yield* ScopedCache.values(cache) const valuesArray = values.sort() assert.deepStrictEqual(valuesArray, [1, 2, 3]) })) it.effect("doesn't include failed lookups", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "a") yield* Effect.exit(ScopedCache.get(cache, "fail")) yield* ScopedCache.get(cache, "ab") const values = yield* ScopedCache.values(cache) const valuesArray = values.sort() assert.deepStrictEqual(valuesArray, [1, 2]) })) it.effect("doesn't include expired values", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* TestClock.adjust("2 hours") const values = yield* ScopedCache.values(cache) assert.deepStrictEqual(values, []) })) it.effect("resource cleanup for expired entries as side effect", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* Effect.gen(function*() { const cleanupTracker: CleanupTracker = { cleanedUp: [], acquired: [] } const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => cleanupTracker.acquired.push(key)), () => Effect.sync(() => cleanupTracker.cleanedUp.push(key)) ) return `resource-${key}` }), timeToLive: "1 hour" }) return { cache, cleanupTracker } }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") assert.strictEqual(cleanupTracker.cleanedUp.length, 0) yield* TestClock.adjust("2 hours") // values() should clean up expired entries as side effect const values = yield* ScopedCache.values(cache) assert.deepStrictEqual(values, []) assert.strictEqual(cleanupTracker.cleanedUp.length, 2) assert.deepStrictEqual(cleanupTracker.cleanedUp.sort(), ["a", "b"]) })) }) describe("entries", () => { it.effect("returns all key-value pairs", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "ab") yield* ScopedCache.get(cache, "abc") const entries = yield* ScopedCache.entries(cache) const entriesArray = entries.sort(([a], [b]) => a.localeCompare(b)) assert.deepStrictEqual(entriesArray, [["a", 1], ["ab", 2], ["abc", 3]]) })) it.effect("filters out failed lookups", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "a") yield* Effect.exit(ScopedCache.get(cache, "fail")) yield* ScopedCache.get(cache, "ab") const entries = yield* ScopedCache.entries(cache) const entriesArray = entries.sort(([a], [b]) => a.localeCompare(b)) assert.deepStrictEqual(entriesArray, [["a", 1], ["ab", 2]]) })) it.effect("filters out expired entries", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "a") yield* TestClock.adjust("1 hour") yield* ScopedCache.get(cache, "b") const entries = yield* ScopedCache.entries(cache) assert.deepStrictEqual(entries, [["b", 1]]) })) it.effect("removes expired entries as side effect with resource cleanup", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* Effect.gen(function*() { const cleanupTracker: CleanupTracker = { cleanedUp: [], acquired: [] } const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => cleanupTracker.acquired.push(key)), () => Effect.sync(() => cleanupTracker.cleanedUp.push(key)) ) return `resource-${key}` }), timeToLive: "1 hour" }) return { cache, cleanupTracker } }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* TestClock.adjust("2 hours") const sizeBefore = yield* ScopedCache.size(cache) assert.strictEqual(sizeBefore, 2) const entriesResult = yield* ScopedCache.entries(cache) const sizeAfter = yield* ScopedCache.size(cache) // entries() should remove expired entries as side effect assert.strictEqual(sizeAfter, 0) assert.strictEqual(entriesResult.length, 0) assert.strictEqual(cleanupTracker.cleanedUp.length, 2) assert.deepStrictEqual(cleanupTracker.cleanedUp.sort(), ["a", "b"]) })) }) }) describe("capacity management", () => { describe("LRU Eviction", () => { it.effect("oldest entries removed when capacity exceeded", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 3, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "first") yield* ScopedCache.get(cache, "second") yield* ScopedCache.get(cache, "third") // Check all entries are present assert.isTrue(yield* ScopedCache.has(cache, "first")) assert.isTrue(yield* ScopedCache.has(cache, "second")) assert.isTrue(yield* ScopedCache.has(cache, "third")) // Add fourth entry, should evict first (oldest) yield* ScopedCache.get(cache, "fourth") assert.isFalse(yield* ScopedCache.has(cache, "first")) assert.isTrue(yield* ScopedCache.has(cache, "second")) assert.isTrue(yield* ScopedCache.has(cache, "third")) assert.isTrue(yield* ScopedCache.has(cache, "fourth")) })) it.effect("access order determines eviction", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 3, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.get(cache, "c") // Access "a" to make it most recently used yield* ScopedCache.get(cache, "a") // Add "d" - should evict "b" (now oldest) yield* ScopedCache.get(cache, "d") assert.isTrue(yield* ScopedCache.has(cache, "a")) assert.isFalse(yield* ScopedCache.has(cache, "b")) assert.isTrue(yield* ScopedCache.has(cache, "c")) assert.isTrue(yield* ScopedCache.has(cache, "d")) })) it.effect("capacity of 1", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 1, lookup: (key: string) => Effect.succeed(key.length) }) yield* ScopedCache.get(cache, "a") assert.isTrue(yield* ScopedCache.has(cache, "a")) yield* ScopedCache.get(cache, "b") assert.isFalse(yield* ScopedCache.has(cache, "a")) assert.isTrue(yield* ScopedCache.has(cache, "b")) yield* ScopedCache.get(cache, "c") assert.isFalse(yield* ScopedCache.has(cache, "b")) assert.isTrue(yield* ScopedCache.has(cache, "c")) })) it.effect("very large capacity", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10000, lookup: (key: string) => Effect.succeed(key.length) }) // Add many entries for (let i = 0; i < 100; i++) { yield* ScopedCache.get(cache, `key-${i}`) } // All should still be present for (let i = 0; i < 100; i++) { assert.isTrue(yield* ScopedCache.has(cache, `key-${i}`)) } const size = yield* ScopedCache.size(cache) assert.strictEqual(size, 100) })) it.effect("resource cleanup for evicted entries", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* makeManagedResourceCache(2) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") assert.deepStrictEqual(cleanupTracker.cleanedUp, []) // Adding third entry should evict first and clean up its resource yield* ScopedCache.get(cache, "c") assert.deepStrictEqual(cleanupTracker.cleanedUp, ["a"]) // Adding fourth entry should evict second yield* ScopedCache.get(cache, "d") assert.deepStrictEqual(cleanupTracker.cleanedUp, ["a", "b"]) })) }) }) describe("TTL (Time To Live) Testing", () => { describe("Fixed TTL", () => { it.effect("entry expires after specified duration", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "2 hours" }) yield* ScopedCache.get(cache, "test") yield* TestClock.adjust("1 hour") assert.isTrue(yield* ScopedCache.has(cache, "test")) yield* TestClock.adjust(Duration.sum(Duration.hours(1), Duration.seconds(1))) assert.isFalse(yield* ScopedCache.has(cache, "test")) })) it.effect("get after expiration invokes lookup", () => Effect.gen(function*() { let lookupCount = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.sync(() => ++lookupCount), timeToLive: "1 hour" }) const result1 = yield* ScopedCache.get(cache, "test") assert.strictEqual(result1, 1) assert.strictEqual(lookupCount, 1) yield* TestClock.adjust("2 hours") const result2 = yield* ScopedCache.get(cache, "test") assert.strictEqual(result2, 2) assert.strictEqual(lookupCount, 2) })) it.effect("has returns false for expired entries", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "test") assert.isTrue(yield* ScopedCache.has(cache, "test")) yield* TestClock.adjust("2 hours") assert.isFalse(yield* ScopedCache.has(cache, "test")) })) it.effect("resource cleanup for expired entries", () => Effect.gen(function*() { const { cache, cleanupTracker } = yield* Effect.gen(function*() { const cleanupTracker: CleanupTracker = { cleanedUp: [], acquired: [] } const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => cleanupTracker.acquired.push(key)), () => Effect.sync(() => cleanupTracker.cleanedUp.push(key)) ) return `resource-${key}` }), timeToLive: "1 hour" }) return { cache, cleanupTracker } }) yield* ScopedCache.get(cache, "test") assert.deepStrictEqual(cleanupTracker.cleanedUp, []) yield* TestClock.adjust("2 hours") // Accessing expired entry should trigger cleanup yield* ScopedCache.has(cache, "test") assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"]) })) }) describe("Function-based TTL", () => { it.effect("different TTL for success vs failure", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length), timeToLive: (exit) => Exit.isSuccess(exit) ? "2 hours" : "30 minutes" }) yield* ScopedCache.get(cache, "success") yield* Effect.exit(ScopedCache.get(cache, "fail")) yield* TestClock.adjust("1 hour") assert.isTrue(yield* ScopedCache.has(cache, "success")) assert.isFalse(yield* ScopedCache.has(cache, "fail")) yield* TestClock.adjust("2 hours") assert.isFalse(yield* ScopedCache.has(cache, "success")) })) it.effect("TTL based on key", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: (_exit, key) => { if (key.startsWith("short-")) return "1 minute" if (key.startsWith("medium-")) return "1 hour" return "1 day" } }) yield* ScopedCache.get(cache, "short-item") yield* ScopedCache.get(cache, "medium-item") yield* ScopedCache.get(cache, "long-item") yield* TestClock.adjust("2 minutes") assert.isFalse(yield* ScopedCache.has(cache, "short-item")) assert.isTrue(yield* ScopedCache.has(cache, "medium-item")) assert.isTrue(yield* ScopedCache.has(cache, "long-item")) yield* TestClock.adjust("2 hours") assert.isFalse(yield* ScopedCache.has(cache, "medium-item")) assert.isTrue(yield* ScopedCache.has(cache, "long-item")) })) it.effect("TTL based on value", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: (exit) => { if (Exit.isSuccess(exit)) { const value = exit.value return value > 5 ? "1 hour" : "1 minute" } return Duration.infinity } }) yield* ScopedCache.get(cache, "abc") // length 3 yield* ScopedCache.get(cache, "longkey") // length 7 yield* TestClock.adjust("2 minutes") assert.isFalse(yield* ScopedCache.has(cache, "abc")) assert.isTrue(yield* ScopedCache.has(cache, "longkey")) })) it.effect("infinite TTL", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: () => Duration.infinity }) yield* ScopedCache.get(cache, "test") yield* TestClock.adjust(Duration.days(365)) assert.isTrue(yield* ScopedCache.has(cache, "test")) })) it.effect("zero duration TTL", () => Effect.gen(function*() { const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: () => Duration.zero }) yield* ScopedCache.get(cache, "test") // Should expire immediately assert.isFalse(yield* ScopedCache.has(cache, "test")) })) it.effect("TTL updates on refresh", () => Effect.gen(function*() { let refreshCount = 0 const cache = yield* ScopedCache.makeWith({ capacity: 10, lookup: (_key: string) => Effect.sync(() => ++refreshCount), timeToLive: (exit) => { if (Exit.isSuccess(exit)) { // First lookup gets 1 hour, refresh gets 2 hours return refreshCount === 1 ? "1 hour" : "2 hours" } return Duration.infinity } }) yield* ScopedCache.get(cache, "test") yield* TestClock.adjust("30 minutes") assert.isTrue(yield* ScopedCache.has(cache, "test")) // Refresh should update TTL yield* ScopedCache.refresh(cache, "test") yield* TestClock.adjust("1 hour") assert.isTrue(yield* ScopedCache.has(cache, "test")) // Still alive due to 2 hour TTL yield* TestClock.adjust("1 hours") assert.isFalse(yield* ScopedCache.has(cache, "test")) })) }) }) describe("error scenarios", () => { describe("Failed Lookups", () => { it.effect("failed lookup caches the failure", () => Effect.gen(function*() { let lookupCount = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.sync(() => ++lookupCount).pipe(Effect.flatMap(() => Effect.fail("error"))) }) const result1 = yield* Effect.exit(ScopedCache.get(cache, "test")) const result2 = yield* Effect.exit(ScopedCache.get(cache, "test")) assert.deepStrictEqual(result1, Exit.fail("error")) assert.deepStrictEqual(result2, Exit.fail("error")) assert.strictEqual(lookupCount, 1) // Only called once })) it.effect("subsequent gets return same failure", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.fail("lookup failed") }) const results = yield* Effect.all([ Effect.exit(ScopedCache.get(cache, "test")), Effect.exit(ScopedCache.get(cache, "test")), Effect.exit(ScopedCache.get(cache, "test")) ]) assert.deepStrictEqual(results, [ Exit.fail("lookup failed"), Exit.fail("lookup failed"), Exit.fail("lookup failed") ]) })) it.effect("can refresh after failure", () => Effect.gen(function*() { let shouldFail = true const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => shouldFail ? Effect.fail("error") : Effect.succeed(42) }) const failResult = yield* Effect.exit(ScopedCache.get(cache, "test")) assert.isTrue(Exit.isFailure(failResult)) shouldFail = false const successResult = yield* ScopedCache.refresh(cache, "test") assert.strictEqual(successResult, 42) })) it.effect("multiple fibers encountering same error", () => Effect.gen(function*() { let lookupCount = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: () => Effect.delay(Duration.millis(50))( Effect.sync(() => ++lookupCount).pipe(Effect.flatMap(() => Effect.fail("error"))) ) }) const fibers = yield* Effect.all([ ScopedCache.get(cache, "test").pipe(Effect.exit, Effect.forkChild), ScopedCache.get(cache, "test").pipe(Effect.exit, Effect.forkChild), ScopedCache.get(cache, "test").pipe(Effect.exit, Effect.forkChild) ]) yield* TestClock.adjust(Duration.millis(100)) const results = yield* Effect.all(fibers.map(Fiber.join)) assert.strictEqual(lookupCount, 1) assert.deepStrictEqual(results, [ Exit.fail("error"), Exit.fail("error"), Exit.fail("error") ]) })) it.effect("resource cleanup for failed entries", () => Effect.gen(function*() { const resourceTracker: Array = [] const cache = yield* ScopedCache.make({ capacity: 2, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => resourceTracker.push(`acquired-${key}`)), () => Effect.sync(() => resourceTracker.push(`released-${key}`)) ) return yield* Effect.fail(`error-${key}`) }) }) yield* Effect.exit(ScopedCache.get(cache, "fail1")) yield* Effect.exit(ScopedCache.get(cache, "fail2")) assert.deepStrictEqual(resourceTracker, ["acquired-fail1", "acquired-fail2"]) // Adding third failed entry should evict first yield* Effect.exit(ScopedCache.get(cache, "fail3")) assert.deepStrictEqual(resourceTracker, [ "acquired-fail1", "acquired-fail2", "released-fail1", "acquired-fail3" ]) })) }) }) describe("scope integration", () => { describe("resource management", () => { it.effect("resources are properly scoped to cache entries", () => Effect.gen(function*() { const resourceTracker: Array = [] const cache = yield* ScopedCache.make({ capacity: 10, lookup: Effect.fnUntraced(function*(key: string) { yield* Effect.acquireRelease( Effect.sync(() => resourceTracker.push(`acquired-${key}`)), () => Effect.sync(() => resourceTracker.push(`released-${key}`)) ) return `value-${key}` }) }) yield* ScopedCache.get(cache, "test") assert.deepStrictEqual(resourceTracker, ["acquired-test"]) // Resource is still alive while cached yield* ScopedCache.get(cache, "test") assert.deepStrictEqual(resourceTracker, ["acquired-test"]) })) it.effect("resources are cleaned up when entries are evicted", () => Effect.gen(function*() { const resourceTracker: Array = [] const cache = yield* ScopedCache.make({ capacity: 2, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => resourceTracker.push(`acquired-${key}`)), () => Effect.sync(() => resourceTracker.push(`released-${key}`)) ) return `value-${key}` }) }) yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") assert.deepStrictEqual(resourceTracker, ["acquired-a", "acquired-b"]) // Adding third entry should evict first and clean up its resource yield* ScopedCache.get(cache, "c") assert.deepStrictEqual(resourceTracker, ["acquired-a", "acquired-b", "released-a", "acquired-c"]) })) it.effect("resources are cleaned up when cache is closed", () => Effect.gen(function*() { const resourceTracker: Array = [] const cache = yield* Effect.scoped( Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => resourceTracker.push(`acquired-${key}`)), () => Effect.sync(() => resourceTracker.push(`released-${key}`)) ) return `value-${key}` }) }) yield* ScopedCache.get(cache, "test1") yield* ScopedCache.get(cache, "test2") assert.deepStrictEqual(resourceTracker, ["acquired-test1", "acquired-test2"]) return cache }) ) // After scope closes, all resources should be cleaned up assert.strictEqual(cache.state._tag, "Closed") assert.deepStrictEqual(resourceTracker.sort(), [ "acquired-test1", "acquired-test2", "released-test1", "released-test2" ]) })) it.effect("resources are cleaned up when entries expire", () => Effect.gen(function*() { const resourceTracker: Array = [] const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => resourceTracker.push(`acquired-${key}`)), () => Effect.sync(() => resourceTracker.push(`released-${key}`)) ) return `value-${key}` }), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "test") assert.deepStrictEqual(resourceTracker, ["acquired-test"]) yield* TestClock.adjust("2 hours") // Accessing expired entry should clean up resource yield* ScopedCache.has(cache, "test") assert.deepStrictEqual(resourceTracker, ["acquired-test", "released-test"]) })) it.effect("resources are cleaned up when entries are invalidated", () => Effect.gen(function*() { const resourceTracker: Array = [] const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => resourceTracker.push(`acquired-${key}`)), () => Effect.sync(() => resourceTracker.push(`released-${key}`)) ) return `value-${key}` }) }) yield* ScopedCache.get(cache, "test") assert.deepStrictEqual(resourceTracker, ["acquired-test"]) yield* ScopedCache.invalidate(cache, "test") assert.deepStrictEqual(resourceTracker, ["acquired-test", "released-test"]) })) it.effect("resource cleanup is atomic and doesn't leak", () => Effect.gen(function*() { const resourceTracker: Array = [] const cache = yield* ScopedCache.make({ capacity: 3, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => resourceTracker.push(`acquired-${key}`)), () => Effect.sync(() => resourceTracker.push(`released-${key}`)) ) return `value-${key}` }) }) // Fill cache yield* ScopedCache.get(cache, "a") yield* ScopedCache.get(cache, "b") yield* ScopedCache.get(cache, "c") // Multiple operations that should trigger cleanup yield* Effect.all([ ScopedCache.invalidate(cache, "a"), ScopedCache.refresh(cache, "b"), ScopedCache.set(cache, "c", "new-value"), ScopedCache.get(cache, "d") ], { concurrency: "unbounded" }) // Verify all resources were properly acquired and released const acquired = resourceTracker.filter((r) => r.startsWith("acquired")).length const released = resourceTracker.filter((r) => r.startsWith("released")).length assert.strictEqual(acquired, 5) assert.strictEqual(released, 3) assert.strictEqual(yield* ScopedCache.size(cache), 3) })) }) describe("scope lifecycle", () => { it.effect("cache becomes closed when parent scope is closed", () => Effect.gen(function*() { const cache = yield* Effect.scoped( Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length) }) assert.strictEqual(cache.state._tag, "Open") yield* ScopedCache.get(cache, "test") return cache }) ) // After scope closure, cache should be closed assert.strictEqual(cache.state._tag, "Closed") })) it.effect("resource cleanup happens in correct order during cache closure", () => Effect.gen(function*() { const cleanupOrder: Array = [] yield* Effect.scoped( Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => cleanupOrder.push(`acquired-${key}`)), () => Effect.sync(() => cleanupOrder.push(`released-${key}`)) ) return `value-${key}` }) }) yield* ScopedCache.get(cache, "first") yield* ScopedCache.get(cache, "second") yield* ScopedCache.get(cache, "third") assert.deepStrictEqual(cleanupOrder, ["acquired-first", "acquired-second", "acquired-third"]) }) ) // All resources should be cleaned up after scope closure const releases = cleanupOrder.filter((item) => item.startsWith("released")) assert.strictEqual(releases.length, 3) assert.isTrue(releases.includes("released-first")) assert.isTrue(releases.includes("released-second")) assert.isTrue(releases.includes("released-third")) })) }) }) // TODO: Service context tests are commented out due to TypeScript issues with Context // These tests are failing due to complex service dependency injection features // that may not be fully implemented in ScopedCache describe("service context", () => { describe("Service Dependency Injection", () => { it.effect("services are available in lookup functions", () => Effect.gen(function*() { class ConfigService extends Context.Service()("ConfigService") {} const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { const config = yield* ConfigService return key.length * config.multiplier }) }).pipe( Effect.provideService(ConfigService, ConfigService.of({ multiplier: 10 })) ) const result = yield* ScopedCache.get(cache, "test") assert.strictEqual(result, 40) // "test".length * 10 })) it.effect("requireServicesAt: 'lookup' provides services at lookup time", () => Effect.gen(function*() { class CounterService extends Context.Service()("CounterService") {} const cache = yield* ScopedCache.make({ capacity: 10, lookup: (_key: string) => Effect.map(CounterService, (service) => service.value), requireServicesAt: "lookup" }) const result1 = yield* ScopedCache.get(cache, "test").pipe( Effect.provideService(CounterService, CounterService.of({ value: 42 })) ) const result2 = yield* ScopedCache.get(cache, "another").pipe( Effect.provideService(CounterService, CounterService.of({ value: 100 })) ) // Same key with different service context const result3 = yield* ScopedCache.get(cache, "test").pipe( Effect.provideService(CounterService, CounterService.of({ value: 200 })) ) assert.strictEqual(result1, 42) assert.strictEqual(result2, 100) assert.strictEqual(result3, 42) // Cached value from first lookup })) }) }) describe("integration tests", () => { describe("TestClock Integration", () => { it.effect("multiple time advances work correctly", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.length), timeToLive: "1 hour" }) yield* ScopedCache.get(cache, "test") // Multiple small advances for (let i = 0; i < 10; i++) { yield* TestClock.adjust("5 minutes") assert.isTrue(yield* ScopedCache.has(cache, "test")) } // Final advance to expire yield* TestClock.adjust("15 minutes") assert.isFalse(yield* ScopedCache.has(cache, "test")) })) it.effect("resource cleanup timing is correct", () => Effect.gen(function*() { const cleanupTimes: Array<{ key: string; time: number }> = [] const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.void, () => Effect.gen(function*() { const time = yield* Clock.currentTimeMillis cleanupTimes.push({ key, time }) }) ) return key.length }), timeToLive: "1 hour" }) const startTime = yield* Clock.currentTimeMillis yield* ScopedCache.get(cache, "a") yield* TestClock.adjust("30 minutes") yield* ScopedCache.get(cache, "b") yield* TestClock.adjust("35 minutes") // "a" should expire yield* ScopedCache.has(cache, "a") // Trigger cleanup yield* TestClock.adjust("30 minutes") // "b" should expire yield* ScopedCache.has(cache, "b") // Trigger cleanup assert.strictEqual(cleanupTimes.length, 2) assert.strictEqual(cleanupTimes[0].key, "a") assert.strictEqual(cleanupTimes[0].time, startTime + 65 * 60 * 1000) assert.strictEqual(cleanupTimes[1].key, "b") assert.strictEqual(cleanupTimes[1].time, startTime + 95 * 60 * 1000) })) }) describe("Different Key Types", () => { it.effect("string keys work correctly", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.succeed(key.toUpperCase()) }) yield* ScopedCache.set(cache, "hello", "HELLO!") const result1 = yield* ScopedCache.get(cache, "hello") const result2 = yield* ScopedCache.get(cache, "world") assert.strictEqual(result1, "HELLO!") assert.strictEqual(result2, "WORLD") })) it.effect("number keys work correctly", () => Effect.gen(function*() { const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: number) => Effect.succeed(key * key) }) yield* ScopedCache.set(cache, 5, 25) const result1 = yield* ScopedCache.get(cache, 5) const result2 = yield* ScopedCache.get(cache, 7) assert.strictEqual(result1, 25) assert.strictEqual(result2, 49) })) it.effect("complex object keys work correctly", () => Effect.gen(function*() { class CacheKey extends Data.Class<{ userId: string productId: number }> {} const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: CacheKey) => Effect.succeed(`${key.userId}-${key.productId}`) }) const key1 = new CacheKey({ userId: "user1", productId: 100 }) const key2 = new CacheKey({ userId: "user2", productId: 200 }) const result1 = yield* ScopedCache.get(cache, key1) const result2 = yield* ScopedCache.get(cache, key2) assert.strictEqual(result1, "user1-100") assert.strictEqual(result2, "user2-200") const key1Copy = new CacheKey({ userId: "user1", productId: 100 }) const hasCopy = yield* ScopedCache.has(cache, key1Copy) assert.isTrue(hasCopy) })) }) }) describe("concurrency tests", () => { describe("Concurrent Resource Access", () => { it.effect("concurrent gets share same resource", () => Effect.gen(function*() { let resourceCreationCount = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.delay(Duration.millis(100))( Effect.sync(() => { resourceCreationCount++ return `resource-${key}-${resourceCreationCount}` }) ) }) const fibers = yield* Effect.all([ ScopedCache.get(cache, "shared").pipe(Effect.forkChild), ScopedCache.get(cache, "shared").pipe(Effect.forkChild), ScopedCache.get(cache, "shared").pipe(Effect.forkChild), ScopedCache.get(cache, "shared").pipe(Effect.forkChild) ]) yield* TestClock.adjust(Duration.millis(150)) const results = yield* Effect.all(fibers.map(Fiber.join)) assert.strictEqual(resourceCreationCount, 1) assert.isTrue(results.every((r) => r === "resource-shared-1")) })) it.effect("concurrent invalidations don't cause resource leaks", () => Effect.gen(function*() { const activeResources: Set = new Set() const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.gen(function*() { const resourceId = `${key}-${Date.now()}-${Math.random()}` yield* Effect.acquireRelease( Effect.sync(() => activeResources.add(resourceId)), () => Effect.sync(() => activeResources.delete(resourceId)) ) return resourceId }) }) // Create initial resources yield* ScopedCache.get(cache, "key1") yield* ScopedCache.get(cache, "key2") yield* ScopedCache.get(cache, "key3") assert.strictEqual(activeResources.size, 3) // Concurrent invalidations yield* Effect.all([ ScopedCache.invalidate(cache, "key1"), ScopedCache.invalidate(cache, "key2"), ScopedCache.invalidate(cache, "key3"), ScopedCache.invalidate(cache, "key1"), // Duplicate ScopedCache.invalidate(cache, "key2") // Duplicate ], { concurrency: "unbounded" }) assert.strictEqual(activeResources.size, 0) })) it.effect("concurrent refresh operations don't interfere", () => Effect.gen(function*() { let counter = 0 const cache = yield* ScopedCache.make({ capacity: 10, lookup: (key: string) => Effect.delay(Duration.millis(50))( Effect.sync(() => `${key}-${++counter}`) ) }) // Initial population yield* ScopedCache.get(cache, "a").pipe(Effect.forkChild) yield* ScopedCache.get(cache, "b").pipe(Effect.forkChild) yield* TestClock.adjust(50) counter = 0 // Reset counter // Concurrent refreshes const fibers = yield* Effect.all([ ScopedCache.refresh(cache, "a").pipe(Effect.forkChild), ScopedCache.refresh(cache, "b").pipe(Effect.forkChild), ScopedCache.refresh(cache, "a").pipe(Effect.forkChild), ScopedCache.refresh(cache, "b").pipe(Effect.forkChild) ]) yield* TestClock.adjust(Duration.millis(100)) const results = yield* Effect.all(fibers.map(Fiber.join)) // Each refresh should get a unique value assert.deepStrictEqual(results, [ "a-1", // First refresh "b-2", // Second refresh "a-3", // Third refresh "b-4" // Fourth refresh ]) assert.strictEqual(counter, 4) })) }) }) }) // Helper functions for testing const makeScopedTestCache = (capacity: number, ttl?: Duration.Input) => Effect.gen(function*() { let lookupCount = 0 const lookupResults = new Map>() const cache = yield* ScopedCache.make({ capacity, lookup: (key: string) => { lookupCount++ return lookupResults.get(key) ?? Effect.fail(`Key not found: ${key}`) }, timeToLive: ttl }) return { cache, lookupCount: () => lookupCount, resetLookupCount: () => { lookupCount = 0 }, setLookupResult: (key: string, result: Effect.Effect) => { lookupResults.set(key, result) } } }) // Helper for tracking resource cleanup interface CleanupTracker { cleanedUp: Array acquired: Array } const makeManagedResourceCache = (capacity: number) => Effect.gen(function*() { const cleanupTracker: CleanupTracker = { cleanedUp: [], acquired: [] } const cache = yield* ScopedCache.make({ capacity, lookup: (key: string) => Effect.gen(function*() { yield* Effect.acquireRelease( Effect.sync(() => cleanupTracker.acquired.push(key)), () => Effect.sync(() => cleanupTracker.cleanedUp.push(key)) ) return `resource-${key}` }) }) return { cache, cleanupTracker } })