322 lines
21 KiB
Markdown
322 lines
21 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: storage-plugin-cache
|
|
|
|
**Package**: `@suaveplan/storage-plugin-cache`
|
|
|
|
> Every requirement carries a `**Verified by:**` line.
|
|
|
|
## Requirements
|
|
|
|
### Requirement: `CachedStorage extends AbstractStorage` and `cachedStorage(inner, options)` factory
|
|
|
|
The package SHALL export a `CachedStorage` class that extends `AbstractStorage` from `@suaveplan/storage` and a `cachedStorage(inner, options): CachedStorage` factory. The class SHALL implement every abstract method on the base — `get`, `set`, `delete`, `exists`, `list`, `clear`, `getWithMetadata`, `getMetadata`, `getSize`, `getItemSize`, `getPartial`, `setPartial`, `createReadStream`, `createWriteStream` — and SHALL expose `readonly cache: AbstractCache<Uint8Array>`, `readonly inner: AbstractStorage`, and `getCacheStats(): CachedStorageStats`.
|
|
|
|
#### Scenario: instance is structurally an AbstractStorage
|
|
|
|
- **WHEN** `cachedStorage(innerStorage, { cache })` is called against a real `AbstractStorage` subclass
|
|
- **THEN** the returned instance is `instanceof AbstractStorage` AND `instanceof CachedStorage` AND every abstract method on `AbstractStorage` is callable
|
|
|
|
#### Scenario: cache and inner are exposed as readonly properties
|
|
|
|
- **WHEN** `cachedStorage(innerStorage, { cache })` returns a `CachedStorage`
|
|
- **THEN** `wrapper.cache === cache` (same reference) AND `wrapper.inner === innerStorage` (same reference)
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.test.ts::"instance is structurally an AbstractStorage and a CachedStorage"` AND `src/cached-storage/cached-storage.test.ts::"cache and inner are exposed as readonly properties"`
|
|
|
|
---
|
|
|
|
### Requirement: Construction-time validation of required options
|
|
|
|
The `cachedStorage(inner, options)` factory and the `new CachedStorage(inner, options)` constructor SHALL throw `STORAGE_CACHE.INNER_MISSING` synchronously when `inner` is nullish, and `STORAGE_CACHE.INVALID_OPTIONS` synchronously when `options.cache` is missing or is not a function-typed `get`/`set`/`delete` carrier. The validation MUST NOT be deferred to first use.
|
|
|
|
#### Scenario: nullish inner throws INNER_MISSING
|
|
|
|
- **WHEN** `cachedStorage(null as unknown as AbstractStorage, { cache })` is called
|
|
- **THEN** the call throws `CachedStorageError` with `code === "STORAGE_CACHE.INNER_MISSING"`
|
|
|
|
#### Scenario: missing cache throws INVALID_OPTIONS
|
|
|
|
- **WHEN** `cachedStorage(innerStorage, {} as unknown as CachedStorageOptions)` is called
|
|
- **THEN** the call throws `CachedStorageError` with `code === "STORAGE_CACHE.INVALID_OPTIONS"`
|
|
|
|
#### Scenario: cache without get/set/delete methods throws INVALID_OPTIONS
|
|
|
|
- **WHEN** `cachedStorage(innerStorage, { cache: { name: "x" } as unknown as AbstractCache<Uint8Array> })` is called
|
|
- **THEN** the call throws `CachedStorageError` with `code === "STORAGE_CACHE.INVALID_OPTIONS"`
|
|
|
|
**Verified by:** `src/factory/factory.test.ts::"throws INNER_MISSING when inner is nullish"` AND `src/factory/factory.test.ts::"throws INVALID_OPTIONS when cache is missing"` AND `src/factory/factory.test.ts::"throws INVALID_OPTIONS when cache lacks get/set/delete"`
|
|
|
|
---
|
|
|
|
### Requirement: `get` returns cached bytes on hit without consulting inner
|
|
|
|
When `get(key, signal?)` is called and `cache.get(cacheKey)` resolves to a `Uint8Array`, the wrapper SHALL return those bytes WITHOUT invoking `inner.get`. The cache key SHALL be `${namespace ?? "storage"}::${key}`.
|
|
|
|
#### Scenario: cache hit short-circuits inner
|
|
|
|
- **WHEN** `wrapper.get("clip.mov")` is called and `cache.get("storage::clip.mov")` resolves to bytes
|
|
- **THEN** the returned value equals those bytes AND `inner.get` is NOT invoked AND `getCacheStats().hits === 1`
|
|
|
|
#### Scenario: custom namespace appears in cache key
|
|
|
|
- **WHEN** the wrapper is constructed with `{ cache, namespace: "media" }` and `wrapper.get("clip.mov")` is called
|
|
- **THEN** `cache.get("media::clip.mov")` is invoked exactly once
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.get.test.ts::"hit short-circuits inner and returns cached bytes"` AND `src/cached-storage/cached-storage.get.test.ts::"uses the configured namespace in the cache key"`
|
|
|
|
---
|
|
|
|
### Requirement: `get` populates the cache on miss
|
|
|
|
When `cache.get(cacheKey)` returns `undefined`, the wrapper SHALL invoke `inner.get(key, signal)`, and when the inner returns a `Uint8Array` AND `shouldCache(key, bytes)` returns true, SHALL call `cache.set(cacheKey, bytes)`. The default `shouldCache` SHALL return true when `bytes.byteLength <= 16 * 1024 * 1024` (16 MiB). When the inner returns `undefined`, the wrapper SHALL NOT cache anything (no negative caching).
|
|
|
|
#### Scenario: miss invokes inner and populates the cache
|
|
|
|
- **WHEN** `wrapper.get("x.bin")` is called, `cache.get` returns `undefined`, and `inner.get` returns `bytes`
|
|
- **THEN** `inner.get("x.bin", signal)` is invoked exactly once AND `cache.set("storage::x.bin", bytes)` is invoked exactly once AND `getCacheStats().misses === 1` AND `getCacheStats().populations === 1`
|
|
|
|
#### Scenario: shouldCache predicate gates population
|
|
|
|
- **WHEN** the wrapper is constructed with `{ cache, shouldCache: () => false }` and `wrapper.get("x.bin")` is called on a miss
|
|
- **THEN** `inner.get` is invoked exactly once AND `cache.set` is NOT invoked AND `getCacheStats().populations === 0`
|
|
|
|
#### Scenario: default maxEntryBytes is 16 MiB
|
|
|
|
- **WHEN** the wrapper is constructed without `shouldCache` or `maxEntryBytes`, and `inner.get` resolves to a `Uint8Array` of `16 * 1024 * 1024 + 1` bytes
|
|
- **THEN** `cache.set` is NOT invoked AND `getCacheStats().populations === 0`
|
|
|
|
#### Scenario: undefined from inner is not cached
|
|
|
|
- **WHEN** `wrapper.get("missing.bin")` is called and `inner.get` returns `undefined`
|
|
- **THEN** `cache.set` is NOT invoked AND the wrapper returns `undefined`
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.get.test.ts::"miss invokes inner and populates the cache"` AND `src/cached-storage/cached-storage.get.test.ts::"shouldCache predicate gates population"` AND `src/cached-storage/cached-storage.get.test.ts::"default maxEntryBytes is 16 MiB"` AND `src/cached-storage/cached-storage.get.test.ts::"undefined from inner is not cached"`
|
|
|
|
---
|
|
|
|
### Requirement: Cache failures never propagate
|
|
|
|
When `cache.get`, `cache.set`, or `cache.delete` throws or rejects, the wrapper SHALL increment `getCacheStats().errors`, SHALL record the failure via the OTel `storage_cache.errors_total{namespace, op}` counter, and SHALL NOT propagate the failure to the consumer. On `cache.get` failure during a `get(key)` call, the wrapper SHALL fall through to `inner.get` and return its result. On `cache.set` failure during population, the wrapper SHALL still return the bytes from `inner.get`. On `cache.delete` failure during invalidation, the wrapper SHALL still return the inner's result.
|
|
|
|
#### Scenario: cache.get rejection falls through to inner
|
|
|
|
- **WHEN** `wrapper.get("x")` is called and `cache.get` rejects with `new Error("redis down")`
|
|
- **THEN** `inner.get` is invoked AND the returned bytes match `inner.get`'s result AND the consumer sees no error AND `getCacheStats().errors === 1`
|
|
|
|
#### Scenario: cache.set rejection does not propagate
|
|
|
|
- **WHEN** `wrapper.get("x")` is called and `cache.set` rejects after `inner.get` succeeds
|
|
- **THEN** `wrapper.get` resolves with `inner.get`'s bytes AND `getCacheStats().errors === 1` AND `getCacheStats().populations === 0`
|
|
|
|
#### Scenario: cache.delete rejection on invalidation does not propagate
|
|
|
|
- **WHEN** `wrapper.set("x", bytes)` succeeds and `cache.delete` rejects
|
|
- **THEN** `wrapper.set` resolves successfully AND `getCacheStats().errors === 1` AND `getCacheStats().invalidations === 0`
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.get.test.ts::"cache.get rejection falls through to inner"` AND `src/cached-storage/cached-storage.get.test.ts::"cache.set rejection does not propagate"` AND `src/cached-storage/cached-storage.write.test.ts::"cache.delete rejection on invalidation does not propagate"`
|
|
|
|
---
|
|
|
|
### Requirement: `set` invalidates the cache entry post-success
|
|
|
|
When `set(key, value, signal?)` is called and `invalidateOnWrite !== false`, the wrapper SHALL invoke `inner.set(key, value, signal)` first; on successful resolution, SHALL call `cache.delete(cacheKey)`. When `inner.set` rejects, the wrapper SHALL propagate the rejection WITHOUT invalidating the cache.
|
|
|
|
#### Scenario: successful set invalidates the cache entry
|
|
|
|
- **WHEN** `wrapper.set("x.bin", bytes)` succeeds
|
|
- **THEN** `cache.delete("storage::x.bin")` is invoked exactly once AND `getCacheStats().invalidations === 1`
|
|
|
|
#### Scenario: failed set does not invalidate
|
|
|
|
- **WHEN** `wrapper.set("x.bin", bytes)` rejects because `inner.set` threw
|
|
- **THEN** `cache.delete` is NOT invoked AND `getCacheStats().invalidations === 0`
|
|
|
|
#### Scenario: invalidateOnWrite:false skips invalidation
|
|
|
|
- **WHEN** the wrapper is constructed with `{ cache, invalidateOnWrite: false }` and `wrapper.set("x.bin", bytes)` succeeds
|
|
- **THEN** `cache.delete` is NOT invoked
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.write.test.ts::"successful set invalidates the cache entry"` AND `src/cached-storage/cached-storage.write.test.ts::"failed set does not invalidate"` AND `src/cached-storage/cached-storage.write.test.ts::"invalidateOnWrite:false skips invalidation"`
|
|
|
|
---
|
|
|
|
### Requirement: `setPartial` invalidates the whole entry post-success
|
|
|
|
When `setPartial(key, offset, value, signal?)` is called, the wrapper SHALL invoke `inner.setPartial(...)` first; on success, when `invalidateOnWrite !== false`, SHALL call `cache.delete(cacheKey)` for the whole `key` (the cache entry for that key is now stale regardless of the partial write's range). When `inner.setPartial` rejects, the wrapper SHALL propagate the rejection WITHOUT invalidating.
|
|
|
|
#### Scenario: setPartial invalidates the whole cache entry
|
|
|
|
- **WHEN** `wrapper.setPartial("x.bin", 100, bytes)` succeeds
|
|
- **THEN** `cache.delete("storage::x.bin")` is invoked exactly once AND `getCacheStats().invalidations === 1`
|
|
|
|
#### Scenario: failed setPartial does not invalidate
|
|
|
|
- **WHEN** `wrapper.setPartial("x.bin", 100, bytes)` rejects because `inner.setPartial` threw
|
|
- **THEN** `cache.delete` is NOT invoked
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.write.test.ts::"setPartial invalidates the whole cache entry"` AND `src/cached-storage/cached-storage.write.test.ts::"failed setPartial does not invalidate"`
|
|
|
|
---
|
|
|
|
### Requirement: `delete` invalidates the cache entry post-success
|
|
|
|
When `delete(key, signal?)` is called and `invalidateOnDelete !== false`, the wrapper SHALL invoke `inner.delete(key, signal)` first; on successful resolution (regardless of whether `inner.delete` returned `true` or `false`), SHALL call `cache.delete(cacheKey)` to clear any stale entry. When `inner.delete` rejects, the wrapper SHALL propagate the rejection WITHOUT invalidating.
|
|
|
|
#### Scenario: successful delete invalidates the cache entry
|
|
|
|
- **WHEN** `wrapper.delete("x.bin")` resolves
|
|
- **THEN** `cache.delete("storage::x.bin")` is invoked exactly once AND `getCacheStats().invalidations === 1`
|
|
|
|
#### Scenario: failed delete does not invalidate
|
|
|
|
- **WHEN** `wrapper.delete("x.bin")` rejects because `inner.delete` threw
|
|
- **THEN** `cache.delete` is NOT invoked AND the rejection propagates
|
|
|
|
#### Scenario: invalidateOnDelete:false skips invalidation
|
|
|
|
- **WHEN** the wrapper is constructed with `{ cache, invalidateOnDelete: false }` and `wrapper.delete("x.bin")` resolves to `true`
|
|
- **THEN** `cache.delete` is NOT invoked
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.delete.test.ts::"successful delete invalidates the cache entry"` AND `src/cached-storage/cached-storage.delete.test.ts::"failed delete does not invalidate"` AND `src/cached-storage/cached-storage.delete.test.ts::"invalidateOnDelete:false skips invalidation"`
|
|
|
|
---
|
|
|
|
### Requirement: `clear` invalidates only the namespaced subset of the cache
|
|
|
|
When `clear(signal?)` is called and `invalidateOnClear !== false`, the wrapper SHALL invoke `inner.clear(signal)` first; on success, SHALL iterate the cache's keys (when `cache.keys()` is available) and delete every key starting with `${namespace}::`. When the underlying `AbstractCache` does NOT expose `keys()`, the wrapper SHALL log a warning via `options.logger?.warn?.(...)` and SHALL NOT invoke `cache.clear()` (doing so would obliterate every other wrapper's entries sharing the cache).
|
|
|
|
#### Scenario: clear evicts namespaced entries only when cache supports keys()
|
|
|
|
- **WHEN** the underlying cache has both `"storage::a"` and `"other::b"` and `wrapper.clear()` is called with namespace `"storage"`
|
|
- **THEN** `cache.delete("storage::a")` is invoked AND `cache.delete("other::b")` is NOT invoked
|
|
|
|
#### Scenario: clear logs a warning when cache lacks keys()
|
|
|
|
- **WHEN** the underlying `AbstractCache` does not implement `keys()` and `wrapper.clear()` is called
|
|
- **THEN** `options.logger.warn` is invoked at least once with a message naming `[storage-cache]` AND `cache.clear()` is NOT invoked AND `getCacheStats().invalidations === 0`
|
|
|
|
#### Scenario: invalidateOnClear:false skips namespaced cleanup
|
|
|
|
- **WHEN** the wrapper is constructed with `{ cache, invalidateOnClear: false }` and `wrapper.clear()` is called
|
|
- **THEN** `cache.delete`/`cache.clear`/`cache.keys` are NOT invoked
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.clear.test.ts::"clear evicts namespaced entries only when cache supports keys()"` AND `src/cached-storage/cached-storage.clear.test.ts::"clear logs a warning when cache lacks keys()"` AND `src/cached-storage/cached-storage.clear.test.ts::"invalidateOnClear:false skips namespaced cleanup"`
|
|
|
|
---
|
|
|
|
### Requirement: Passthrough surface — `exists`, `list`, metadata, partial, streams
|
|
|
|
The wrapper SHALL implement `exists`, `list`, `getWithMetadata`, `getMetadata`, `getSize`, `getItemSize`, `getPartial`, `createReadStream`, and `createWriteStream` as pure delegates to the inner storage. None of these SHALL consult or mutate the cache (this iteration intentionally caches whole-object reads only).
|
|
|
|
#### Scenario: exists delegates to inner without touching the cache
|
|
|
|
- **WHEN** `wrapper.exists("x")` is called and `inner.exists("x")` returns `true`
|
|
- **THEN** the wrapper returns `true` AND `cache.get` / `cache.set` / `cache.delete` are NOT invoked
|
|
|
|
#### Scenario: list delegates to inner without touching the cache
|
|
|
|
- **WHEN** `wrapper.list({ prefix: "a/" })` is called
|
|
- **THEN** the returned value equals `inner.list({ prefix: "a/" })`'s result AND `cache.get` / `cache.set` are NOT invoked
|
|
|
|
#### Scenario: getMetadata delegates to inner without touching the cache
|
|
|
|
- **WHEN** `wrapper.getMetadata("x")` is called
|
|
- **THEN** the returned value equals `inner.getMetadata("x")`'s result AND `cache.get` is NOT invoked
|
|
|
|
#### Scenario: getPartial delegates to inner without touching the cache
|
|
|
|
- **WHEN** `wrapper.getPartial("x", 100, 200)` is called
|
|
- **THEN** the returned slice equals `inner.getPartial("x", 100, 200)`'s result AND `cache.get` is NOT invoked
|
|
|
|
#### Scenario: createReadStream delegates to inner without touching the cache
|
|
|
|
- **WHEN** `wrapper.createReadStream("x")` is called
|
|
- **THEN** the returned stream is the same reference returned by `inner.createReadStream("x")` AND `cache.get` is NOT invoked
|
|
|
|
#### Scenario: createWriteStream delegates to inner without touching the cache
|
|
|
|
- **WHEN** `wrapper.createWriteStream("x")` is called
|
|
- **THEN** the returned writer is the same reference returned by `inner.createWriteStream("x")` AND `cache.delete` is NOT invoked at construction time (the cache is invalidated only when the writer's underlying write commits via `set`, which is out of scope here — see Implementation Notes in design.md)
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.passthrough.test.ts::"exists delegates to inner without touching the cache"` AND `src/cached-storage/cached-storage.passthrough.test.ts::"list delegates to inner without touching the cache"` AND `src/cached-storage/cached-storage.passthrough.test.ts::"getMetadata delegates to inner without touching the cache"` AND `src/cached-storage/cached-storage.passthrough.test.ts::"getPartial delegates to inner without touching the cache"` AND `src/cached-storage/cached-storage.passthrough.test.ts::"createReadStream delegates to inner without touching the cache"` AND `src/cached-storage/cached-storage.passthrough.test.ts::"createWriteStream delegates to inner without touching the cache"`
|
|
|
|
---
|
|
|
|
### Requirement: AbortSignal short-circuit on pre-aborted signal
|
|
|
|
When `signal?.aborted === true` at the time `get`, `set`, `setPartial`, `delete`, or `clear` is invoked, the wrapper SHALL skip its own cache work (no `cache.get` / `cache.set` / `cache.delete` calls) and SHALL invoke the inner method immediately so the inner storage raises the canonical `STORAGE.*` aborted error. Live signals SHALL be forwarded into `cache.get` / `cache.set` / `cache.delete` calls when the underlying `AbstractCache` accepts a signal argument.
|
|
|
|
#### Scenario: pre-aborted signal skips cache work in get
|
|
|
|
- **WHEN** `wrapper.get("x", AbortSignal.abort())` is called
|
|
- **THEN** `cache.get` is NOT invoked AND `inner.get` is invoked exactly once with the same aborted signal
|
|
|
|
#### Scenario: pre-aborted signal skips invalidation in set
|
|
|
|
- **WHEN** `wrapper.set("x", bytes, AbortSignal.abort())` is called and `inner.set` rejects with abort
|
|
- **THEN** `cache.delete` is NOT invoked AND the rejection propagates
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.signal.test.ts::"pre-aborted signal skips cache work in get"` AND `src/cached-storage/cached-storage.signal.test.ts::"pre-aborted signal skips invalidation in set"`
|
|
|
|
---
|
|
|
|
### Requirement: `getCacheStats()` reports hits, misses, populations, invalidations, errors
|
|
|
|
The class SHALL expose `getCacheStats(): CachedStorageStats` returning a snapshot of the wrapper's counters: `{ hits, misses, populations, invalidations, errors }`. Counters SHALL be monotonically non-decreasing. The method SHALL NOT mutate any state.
|
|
|
|
#### Scenario: counters reflect a hit, a miss, a population, and an invalidation
|
|
|
|
- **WHEN** one hit, one miss + population, and one set (invalidation) are exercised in sequence against a fresh wrapper
|
|
- **THEN** `getCacheStats()` returns `{ hits: 1, misses: 1, populations: 1, invalidations: 1, errors: 0 }`
|
|
|
|
#### Scenario: getCacheStats does not mutate state
|
|
|
|
- **WHEN** `getCacheStats()` is called twice without any other operation
|
|
- **THEN** both snapshots return the same object content AND no `cache.*` method is invoked
|
|
|
|
**Verified by:** `src/cached-storage/cached-storage.test.ts::"getCacheStats reflects hit, miss, population, and invalidation"` AND `src/cached-storage/cached-storage.test.ts::"getCacheStats does not mutate state"`
|
|
|
|
---
|
|
|
|
### Requirement: OpenTelemetry instrumentation
|
|
|
|
Every cache interaction SHALL emit a span on the `@suaveplan/storage-cache` tracer with `storage.cache.namespace` and `storage.cache.key` attributes (the key is `*` for `clear` operations) and a `storage.cache.outcome` attribute drawn from `{"hit", "miss", "populated", "invalidated", "error"}`. The wrapper SHALL emit counters `storage_cache.{hits, misses, populations, invalidations, errors}_total{namespace}` and a `storage_cache.entry_bytes{namespace}` histogram observing each `populations_total` increment with the cached entry's byte length. The `errors_total` counter SHALL include an `op` label drawn from `{"get", "set", "delete", "clear"}`.
|
|
|
|
#### Scenario: cache hit increments hits_total and records a hit-outcome span
|
|
|
|
- **WHEN** a cache hit is exercised
|
|
- **THEN** `storage_cache.hits_total{namespace:"storage"}` increments by 1 AND the emitted span has `storage.cache.outcome === "hit"`
|
|
|
|
#### Scenario: population records entry_bytes histogram
|
|
|
|
- **WHEN** a 4096-byte payload is cached on a miss
|
|
- **THEN** `storage_cache.entry_bytes` records one observation of `4096`
|
|
|
|
#### Scenario: cache error increments errors_total with op label
|
|
|
|
- **WHEN** `cache.set` rejects during population
|
|
- **THEN** `storage_cache.errors_total{namespace:"storage", op:"set"}` increments by 1
|
|
|
|
**Verified by:** `src/otel/otel.test.ts::"hit increments hits_total and records hit-outcome span"` AND `src/otel/otel.test.ts::"population records entry_bytes histogram"` AND `src/otel/otel.test.ts::"cache error increments errors_total with op label"`
|
|
|
|
---
|
|
|
|
### Requirement: Multi-namespace isolation on a shared cache
|
|
|
|
Two `CachedStorage` wrappers constructed over DISTINCT inner storages with the SAME `AbstractCache` reference but DISTINCT `namespace` strings SHALL NOT observe each other's entries on `get`, AND `wrapper.clear()` on one SHALL NOT evict entries belonging to the other.
|
|
|
|
#### Scenario: distinct namespaces isolate entries on a shared cache
|
|
|
|
- **WHEN** wrapper A (namespace `"a"`) populates `"clip.mov"` and wrapper B (namespace `"b"`) then reads `"clip.mov"`
|
|
- **THEN** wrapper B observes a miss AND `cache.get("a::clip.mov")` and `cache.get("b::clip.mov")` are distinct calls
|
|
|
|
#### Scenario: clear on one wrapper does not evict the other's entries
|
|
|
|
- **WHEN** wrapper A populates entries under `"a::"` and wrapper B populates entries under `"b::"`, then `wrapperA.clear()` is called against a cache that exposes `keys()`
|
|
- **THEN** every `"a::*"` key is deleted AND every `"b::*"` key is preserved
|
|
|
|
**Verified by:** `tests/integration/namespaced-isolation.test.ts::"distinct namespaces isolate entries on a shared cache"` AND `tests/integration/namespaced-isolation.test.ts::"clear on one wrapper does not evict the other's entries"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|