From c2fbce55740a1f8bd841c2613e3ceef7edd7ae1d Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 28 Jun 2026 10:29:47 -0500 Subject: [PATCH] fix: honor a Miss returned from the go() callback instead of wrapping it in a Hit go() honored a returned Hit but wrapped every other return value, including a Miss, in a new Hit. A callback returning new Cacheism.Miss(...) to signal failure on an empty cache therefore produced a Hit whose data was a Miss-shaped object (isHit true, data.isMiss true). Add a `result instanceof Miss` branch so a returned Miss is used as-is, symmetric with the existing returned-Hit handling. The Miss is persisted as a real Miss, preserving consecutiveErrors across the store round-trip. A returned Miss is authoritative and is not routed through the cacheOnFail/preferCache fallback (that remains exclusive to thrown errors). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 20 ++++++++++++++++- src/index.ts | 2 ++ test/memory.test.cjs | 52 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d6213f..4357226 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,22 @@ if (result.error) { } ``` +### Callback return value + +What your callback does determines the response `cache.go` returns: + +- **Return raw data** — it is wrapped in a `Hit` and stored. +- **Return a `Hit`** — it is used as-is (lets you set a custom `etag`, for example). +- **Return a `Miss`** — it is used as-is: an _authoritative_ miss. It is stored as a + real `Miss` (preserving its `consecutiveErrors`) and is **not** treated as a thrown + error, so it does **not** trigger the `cacheOnFail`/`preferCache` fallback to a stale + cached `Hit`. When constructing the `Miss`, use the cache name handed to your callback + so it persists under the right key: `return new Cacheism.Miss(existing.cacheName, message, n)`. +- **Throw** — `cache.go` decides the response based on the status (for example, + `cacheOnFail` falls back to a cached `Hit` if one exists). + +In short: a returned value is authoritative, while a throw delegates to the status policy. + ## Statuses ### Only Fresh @@ -89,7 +105,9 @@ want to fetch the fresh data and store it in the cache for other requests. ### Cache on Fail The cacheOnFail status is for times where we want to try to fetch fresh data, -but if an error is thrown, use the cache if present. +but if an error is thrown, use the cache if present. Note this fallback only +applies when the callback _throws_ — explicitly returning a `Miss` is honored +as-is (see [Callback return value](#callback-return-value)). ### Prefer Cache diff --git a/src/index.ts b/src/index.ts index d2b8994..c148f01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,8 @@ export class Cacheism { const result = await callback(existing); if (result instanceof Hit) { response = result; + } else if (result instanceof Miss) { + response = result; } else { response = new Hit(name, result); } diff --git a/test/memory.test.cjs b/test/memory.test.cjs index db24c0b..e366d0d 100644 --- a/test/memory.test.cjs +++ b/test/memory.test.cjs @@ -35,6 +35,58 @@ describe('memory', function() { }); + describe('when callback returns a Miss instance', function() { + + it('should use the returned Miss directly', async function() { + const customMiss = new Cacheism.Miss('-internal/cache', 'custom error', 2); + + const c = await cache.go('-internal', 'cache', Cacheism.Status.onlyFresh, async () => { + return customMiss; + }); + + helpers.expectCacheMiss(c, false, null); + helpers.expectCacheErrors(c, 'custom error', 2); + }); + + it('should persist the returned Miss and preserve consecutiveErrors', async function() { + const customMiss = new Cacheism.Miss('-internal/cache', 'custom error', 2); + + await cache.go('-internal', 'cache', Cacheism.Status.onlyFresh, async () => { + return customMiss; + }); + + assert.strictEqual(await cache.store.isset('-internal/cache'), true); + + const d = await cache.store.get('-internal/cache'); + helpers.expectDataMiss(d, null, null); + helpers.expectDataErrors(d, 'custom error', 2); + + const back = d.response(); + assert.ok(back instanceof Cacheism.Miss); + assert.strictEqual(back.consecutiveErrors, 2); + }); + + it('should honor a returned Miss over a cached Hit under cacheOnFail', async function() { + mockdate.set('2000-11-22'); + await cache.store.set(Cacheism.Data.fromResponse(new Cacheism.Hit('-internal/cache', 'cached'))); + mockdate.reset(); + + const customMiss = new Cacheism.Miss('-internal/cache', 'deliberate miss', 1); + + const c = await cache.go('-internal', 'cache', Cacheism.Status.cacheOnFail, async () => { + return customMiss; + }); + + helpers.expectCacheMiss(c, false, null); + helpers.expectCacheErrors(c, 'deliberate miss', 1); + + const d = await cache.store.get('-internal/cache'); + helpers.expectDataMiss(d, null, null); + helpers.expectDataErrors(d, 'deliberate miss', 1); + }); + + }); + describe('when status=onlyFresh', async function () { describe('and no existing cache', async function () {