Files
zopu-code/repos/effect-smol/packages/effect/test/ScopedCache.test.ts

2317 lines
81 KiB
TypeScript

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, { value: number }>()("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<number, string>; 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<string> = []
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<void>()
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<string> = []
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<string> = []
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<string> = []
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<string> = []
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<string> = []
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<string> = []
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<string> = []
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<string> = []
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, { multiplier: number }>()("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, { value: number }>()("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<string> = 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<string, Effect.Effect<number, string>>()
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<number, string>) => {
lookupResults.set(key, result)
}
}
})
// Helper for tracking resource cleanup
interface CleanupTracker {
cleanedUp: Array<string>
acquired: Array<string>
}
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 }
})