Skip to content

feat!: separate expired from evicted entries#1

Merged
esroyo merged 5 commits into
mainfrom
feat/separate-expired-from-evicted
May 16, 2026
Merged

feat!: separate expired from evicted entries#1
esroyo merged 5 commits into
mainfrom
feat/separate-expired-from-evicted

Conversation

@esroyo

@esroyo esroyo commented May 14, 2026

Copy link
Copy Markdown
Owner

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.

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.
@esroyo esroyo self-assigned this May 14, 2026
@codecov

codecov Bot commented May 14, 2026

Copy link
Copy Markdown

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 ☂️

esroyo added 2 commits May 15, 2026 08:01
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.
@esroyo esroyo force-pushed the feat/separate-expired-from-evicted branch from 77d9c3f to 08d3c9c Compare May 15, 2026 06:02
esroyo added 2 commits May 16, 2026 17:56
…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).
@esroyo esroyo force-pushed the feat/separate-expired-from-evicted branch from 83b947c to 6d0cf7f Compare May 16, 2026 18:17
@esroyo esroyo merged commit 1613d9e into main May 16, 2026
2 of 3 checks passed
@esroyo esroyo deleted the feat/separate-expired-from-evicted branch May 16, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant