feat!: separate expired from evicted entries#1
Merged
Conversation
Add OpenSpec change introducing two orthogonal options on bundled
CachePersistence implementations:
- staleRetention ('evict' | 'retain', default 'evict') controls whether
HTTP-expired entries are deleted or retained as stale (marked via the
x-cachestorage-stale header) for revalidation / stale-if-error use cases.
- maxPersistenceTtlMs (number, default 30 days) is a universal upper bound
on entry storage lifetime, applied in every mode. Exposes the previously
hidden _maxExpireIn safety net.
Also cleans up _expiresIn() to represent pure HTTP freshness semantics
(no internal clamping; returns 0 for responses without Cache-Control or
Expires, per RFC 9111 §4.2.1) and moves storage-policy clamping to a new
_evictionDelay helper.
The standard W3C Cache interface is unchanged; no companion methods are
added. Under 'retain', match() returns whatever is stored (spec-compliant);
under 'evict' the bundled implementations preserve current behavior.
Welcome to Codecov 🎉Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests. Thanks for integrating Codecov - We've got you covered ☂️ |
Decouple HTTP expiration (per Cache-Control / Expires) from storage eviction. Two new construction-time options on the bundled persistence implementations (Memory, Deno KV, Redis, NoOp): - `staleRetention: 'evict' | 'retain'` (default `'evict'`) — `'retain'` yields W3C-spec-compliant behavior: entries past their HTTP expiration remain retrievable as stale (signalled via `x-cachestorage-stale: 1` header) until `maxPersistenceTtlMs` elapses. Unblocks revalidation, stale-while-revalidate, and stale-if-error use cases. - `maxPersistenceTtlMs: number` (default `2_592_000_000` ms / 30 days) — universal upper bound on entry storage lifetime, applied in both modes. Promotes the previously-hidden `_maxExpireIn` field to a documented option, renamed to `_maxPersistenceTtlMs` to telegraph "storage lifetime, not HTTP freshness" and avoid the seconds-vs-milliseconds footgun. The eviction primitive (Memory `setTimeout`, Deno KV `setBlob`'s `expireIn`, Redis `PEXPIRE`) is ALWAYS invoked in both modes — only the delay value differs: `min(httpExpiresIn, maxPersistenceTtlMs)` under `'evict'`, `maxPersistenceTtlMs` under `'retain'`. Routed through a new `_evictionDelay(httpExpiresIn)` helper on `CachePersistenceBase`. BREAKING: responses with neither `Cache-Control` nor `Expires` are no longer silently cached for 30 days. The previous behavior was an undocumented heuristic-freshness policy that violated RFC 9111 §4.2.1 (no explicit freshness directive ⇒ no explicit freshness lifetime). After this change, `_expiresIn` returns `0` for such responses; under default `'evict'` `_pairToPlain` declines to store them, under `'retain'` they are stored and immediately stale on first read. Migration: set explicit `Cache-Control: max-age=N`, or use `staleRetention: 'retain'` and consult `x-cachestorage-stale`. `_expiresIn()` is now pure HTTP freshness semantics — no storage-policy clamping, no fallback. All storage clamping lives in `_evictionDelay`. Interface signatures unchanged: no new method on `CacheLike`; no signature change on `CachePersistenceLike.get` / `[Symbol.asyncIterator]`. Only construction-time options grow.
Move the completed change into openspec/changes/archive/2026-05-14-... and sync its delta specs into the main capability specs: - cache-freshness-policy (6 requirements) - cache-persistence-storage (8 requirements) Archived with 3 known-incomplete tasks (benchmarks, git-history audit, unused-code assertion); the underlying behavior changes have already landed in e94c350.
77d9c3f to
08d3c9c
Compare
…acheStorage
Public surface
--------------
- Add `createCacheStorage({ persistence, headerNormalizer?, Cache? })` helper
+ `CreateCacheStorageOptions` type, exported from `mod.ts`. Object-bag sugar
over `new CacheStorage(...)`; same accepted-input union, no new mental model.
- Add four sub-path exports under JSR:
/memory, /noop, /deno-kv, /deno-redis
Each is the canonical home for its backend and exports a default factory
(e.g. `denoRedis`), a same-identity named factory, the persistence class,
and the options type.
- BREAKING: `mod.ts` no longer re-exports backend persistence classes. Imports
of `CachePersistenceMemory`, `CachePersistenceDenoKv`, `CachePersistenceNoop`,
and `CachePersistenceRedis` from the package root no longer resolve; consumers
must update to the matching sub-path. Keeping the root re-exports would have
defeated the lazy-resolution property motivating the change.
- BREAKING (softened): `CachePersistenceRedis` is renamed to
`CachePersistenceDenoRedis` (same for the options interface). The old names
remain available as `@deprecated` aliases from the `/deno-redis` sub-path,
pointing at the same class identity (so `instanceof` keeps working).
Removal targeted for the next major release.
Source layout
-------------
Adopt a pseudo-monorepo shape under `src/`:
- `src/core/` - layered foundation (Cache, CacheStorage,
cache-persistence-base, types, webidl, test-utils,
create-cache-storage.test.ts).
- `src/<backend>/`- one directory per backend (memory, noop, deno-kv,
deno-redis), each with a `mod.ts` public entry and
colocated `mod.test.ts`. The Redis-specific
OpenTelemetry instrumentation lives inside
`src/deno-redis/`.
- `src/_shared/` - test infrastructure shared across backends. Houses
the parameterised-by-backend `CacheStorage` conformance
suite (`cache-storage.test.ts`), re-imported by each
backend's `mod.test.ts` after setting `globalThis.caches`.
- `bench/` - cross-backend benchmark, sibling of `src/` (not under
it, because importing every backend from `src/core/`
would invert the dependency direction). Excluded from
JSR via `deno.json`'s `publish.exclude`.
`deno.json`
-----------
- `exports` becomes an object map (`.` + four sub-paths).
- Add `publish.exclude: ["bench/"]`.
- Update `bench`, `test:ci`, and `coverage[:ci]` task paths.
Docs
----
- README: primary example switched to `createCacheStorage` + factory; added
Backends and Migrating-from-0.3.x sections; legacy direct-class usage
retained as low-level API.
- CHANGELOG: BREAKING / Features / Moved / Deprecated entries.
OpenSpec
--------
- Archive `add-adapter-subpath-exports` as
`2026-05-16-add-adapter-subpath-exports`.
- New active spec: `cache-adapter-exports` (canonical home contract, sub-path
declaration contract, mod.ts boundaries, layout invariant, Deno Redis rename,
bench placement, src/_shared placement).
- `cache-persistence-storage` and `cache-freshness-policy` updated for the
Redis-name ripple (semantic content unchanged).
83b947c to
6d0cf7f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Decouple HTTP expiration (per Cache-Control / Expires) from storage
eviction. Two new construction-time options on the bundled persistence
implementations (Memory, Deno KV, Redis, NoOp):
staleRetention: 'evict' | 'retain'(default'evict') —'retain'yields W3C-spec-compliant behavior: entries past their HTTP expiration
remain retrievable as stale (signalled via
x-cachestorage-stale: 1header) until
maxPersistenceTtlMselapses. Unblocks revalidation,stale-while-revalidate, and stale-if-error use cases.
maxPersistenceTtlMs: number(default2_592_000_000ms / 30 days) —universal upper bound on entry storage lifetime, applied in both
modes. Promotes the previously-hidden
_maxExpireInfield to adocumented option, renamed to
_maxPersistenceTtlMsto telegraph"storage lifetime, not HTTP freshness" and avoid the
seconds-vs-milliseconds footgun.
The eviction primitive (Memory
setTimeout, Deno KVsetBlob'sexpireIn, RedisPEXPIRE) is ALWAYS invoked in both modes — only thedelay value differs:
min(httpExpiresIn, maxPersistenceTtlMs)under'evict',maxPersistenceTtlMsunder'retain'. Routed through a new_evictionDelay(httpExpiresIn)helper onCachePersistenceBase.BREAKING: responses with neither
Cache-ControlnorExpiresare nolonger silently cached for 30 days. The previous behavior was an
undocumented heuristic-freshness policy that violated RFC 9111 §4.2.1
(no explicit freshness directive ⇒ no explicit freshness lifetime).
After this change,
_expiresInreturns0for such responses; underdefault
'evict'_pairToPlaindeclines to store them, under'retain'they are stored and immediately stale on first read.Migration: set explicit
Cache-Control: max-age=N, or usestaleRetention: 'retain'and consultx-cachestorage-stale._expiresIn()is now pure HTTP freshness semantics — no storage-policyclamping, no fallback. All storage clamping lives in
_evictionDelay.