From dd9d9868201b4f5fd82523294f238fdca0ba1b21 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:26:18 +0000 Subject: [PATCH 01/28] feat(cache): add NullSentinel primitive for nullable cache methods Array-constant sentinel used by the nullable cache methods to distinguish "key absent" from "key present with null value". Array (not object) for immunity to unserialize allowed_classes restrictions. Includes a static unwrap() helper for use at boundaries where the cache layer returns to public API callers. --- src/cache/src/NullSentinel.php | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/cache/src/NullSentinel.php diff --git a/src/cache/src/NullSentinel.php b/src/cache/src/NullSentinel.php new file mode 100644 index 000000000..543f07c9d --- /dev/null +++ b/src/cache/src/NullSentinel.php @@ -0,0 +1,48 @@ + true]. The same approach is used by + * EloquentUserProvider::NULL_SENTINEL for the same reason. + */ +final class NullSentinel +{ + /** + * Sentinel value stored in place of null by the nullable cache methods. + */ + public const VALUE = ['__hypervel_cache_null_sentinel' => true]; + + /** + * Unwrap a value read from the cache — sentinel becomes null; anything else passes through. + * + * Used at the boundaries where the cache layer returns to public API callers, + * so the sentinel never leaks through get/many/remember/flexible/etc. + */ + public static function unwrap(mixed $value): mixed + { + return $value === self::VALUE ? null : $value; + } +} From 058049921bf32e50c506f40dfb49797bb2c5fb2a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:26:26 +0000 Subject: [PATCH 02/28] feat(cache): add RawReadable capability interface for sentinel-preserving reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capability interface for cache layers that need to expose raw reads (values returned as stored, including NullSentinel::VALUE). Plain stores don't need to implement it — Repository::getRaw()/manyRaw() fall back to get()/many(). Exists for wrapper stores (MemoizedStore, FailoverStore) whose internal bounce-through-a-Repository path would otherwise unwrap sentinels prematurely. --- src/contracts/src/Cache/RawReadable.php | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/contracts/src/Cache/RawReadable.php diff --git a/src/contracts/src/Cache/RawReadable.php b/src/contracts/src/Cache/RawReadable.php new file mode 100644 index 000000000..9cd973097 --- /dev/null +++ b/src/contracts/src/Cache/RawReadable.php @@ -0,0 +1,47 @@ + $keys + * @return array keyed by the input keys; values are raw + * (may include null, NullSentinel::VALUE, or real values) + */ + public function manyRaw(array $keys): array; +} From 1c90901bec1457c7a30c458f74e428d53474f4dc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:26:32 +0000 Subject: [PATCH 03/28] feat(cache): add nullable method declarations to Repository contract rememberNullable / searNullable / rememberForeverNullable join the existing remember family on the contract. flexibleNullable is concrete-only, matching how flexible() is concrete-only (not on the contract). --- src/contracts/src/Cache/Repository.php | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/contracts/src/Cache/Repository.php b/src/contracts/src/Cache/Repository.php index d440d4022..ebb7e4022 100644 --- a/src/contracts/src/Cache/Repository.php +++ b/src/contracts/src/Cache/Repository.php @@ -77,6 +77,45 @@ public function sear(UnitEnum|string $key, Closure $callback): mixed; */ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed; + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * Unlike remember(), a null return from $callback is stored and returned on + * subsequent calls rather than triggering re-execution. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function rememberNullable(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed; + + /** + * Get an item from the cache, or execute the given Closure and store the result forever. + * + * Unlike rememberForever(), a null return from $callback is stored and returned + * on subsequent calls rather than triggering re-execution. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function searNullable(UnitEnum|string $key, Closure $callback): mixed; + + /** + * Get an item from the cache, or execute the given Closure and store the result forever. + * + * Unlike rememberForever(), a null return from $callback is stored and returned + * on subsequent calls rather than triggering re-execution. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function rememberForeverNullable(UnitEnum|string $key, Closure $callback): mixed; + /** * Set the expiration of a cached item; null TTL will retain the item forever. */ From 586ee95c233131cb8fcbd97958e9615332a8eed5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:26:46 +0000 Subject: [PATCH 04/28] feat(cache): implement nullable cache methods and sentinel-aware read path Repository implements RawReadable via public @internal getRaw()/manyRaw() helpers. get()/many() route through them so sentinels are recognized as hits internally and unwrapped to null at the public boundary. remember(), rememberForever(), and flexible() use the raw path too, so a cached sentinel from a prior rememberNullable call is a hit (no callback re-run) while plain callers writing null still get Laravel's polling behavior. The four nullable wrappers (rememberNullable, searNullable, rememberForeverNullable, flexibleNullable) substitute null with NullSentinel::VALUE at the callback boundary and delegate to their non-nullable counterparts. --- src/cache/src/Repository.php | 279 +++++++++++++++++++++++++++-------- 1 file changed, 217 insertions(+), 62 deletions(-) diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 8aa82d473..793a7caed 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -28,6 +28,7 @@ use Hypervel\Cache\Events\WritingManyKeys; use Hypervel\Contracts\Cache\CanFlushLocks; use Hypervel\Contracts\Cache\LockTimeoutException; +use Hypervel\Contracts\Cache\RawReadable; use Hypervel\Contracts\Cache\Repository as CacheContract; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Events\Dispatcher; @@ -43,7 +44,7 @@ /** * @mixin \Hypervel\Contracts\Cache\Store */ -class Repository implements ArrayAccess, CacheContract +class Repository implements ArrayAccess, CacheContract, RawReadable { use InteractsWithTime; use Macroable { @@ -110,24 +111,10 @@ public function get(array|UnitEnum|string $key, mixed $default = null): mixed return $this->many($key); } - $key = enum_value($key); - - $this->event(RetrievingKey::class, fn (): RetrievingKey => new RetrievingKey($this->getName(), $key)); - - $value = $this->store->get($this->itemKey($key)); - - // If we could not find the cache value, we will fire the missed event and get - // the default value for this cache value. This default could be a callback - // so we will execute the value function which will resolve it if needed. - if (is_null($value)) { - $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key)); - - $value = value($default); - } else { - $this->event(CacheHit::class, fn (): CacheHit => new CacheHit($this->getName(), $key, $value)); - } - - return $value; + // NullSentinel::unwrap collapses a cached sentinel to null, so a sentinel + // and a genuine miss both resolve to the default — matching Laravel's + // convention that `put('k', null)` + `get('k', 'default')` returns 'default'. + return NullSentinel::unwrap($this->getRaw($key)) ?? value($default); } /** @@ -140,12 +127,10 @@ public function many(array $keys): array return is_string($key) ? $key : (string) enum_value($value); })->values()->all(); - $this->event( - RetrievingManyKeys::class, - fn (): RetrievingManyKeys => new RetrievingManyKeys($this->getName(), $resolvedKeys) - ); - - $values = $this->store->many($resolvedKeys); + // manyRaw() fires RetrievingManyKeys + per-key CacheHit/CacheMissed events and + // routes through the RawReadable raw-read path for wrapper stores — so a cached + // sentinel is correctly classified as CacheHit rather than CacheMissed. + $values = $this->manyRaw($resolvedKeys); return collect($values)->map(function ($value, $key) use ($keys) { return $this->handleManyResult($keys, (string) $key, $value); @@ -477,20 +462,20 @@ public function forever(UnitEnum|string $key, mixed $value): bool public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed { $remember = function () use ($key, $ttl, $callback) { - $value = $this->get($key); + $value = $this->getRaw($key); - // If the item exists in the cache we will just return this immediately and if - // not we will execute the given Closure and cache the result of that for a - // given number of seconds so it's available for all subsequent requests. + // Hit — including cached sentinels. Unwrap before returning. if (! is_null($value)) { - return $value; + return NullSentinel::unwrap($value); } + // Miss — run callback and store the raw result (may be a sentinel if + // the caller is rememberNullable(), which wraps the callback). $value = $callback(); $this->put($key, $value, value($ttl, $value)); - return $value; + return NullSentinel::unwrap($value); }; return method_exists($this->store, 'withPinnedConnection') @@ -498,6 +483,25 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in : $remember(); } + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * Unlike remember(), a null return from $callback is stored (as the internal + * NullSentinel::VALUE marker) and returned as null on subsequent calls rather + * than triggering re-execution. Public accessors (get, many, pull, has, etc.) + * unwrap the sentinel automatically — callers never see it. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * + * @return TCacheValue + */ + public function rememberNullable(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed + { + return $this->remember($key, $ttl, fn () => $callback() ?? NullSentinel::VALUE); + } + /** * Get an item from the cache, or execute the given Closure and store the result forever. * @@ -512,6 +516,22 @@ public function sear(UnitEnum|string $key, Closure $callback): mixed return $this->rememberForever($key, $callback); } + /** + * Get an item from the cache, or execute the given Closure and store the result forever. + * + * Alias for rememberForeverNullable(). + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * + * @return TCacheValue + */ + public function searNullable(UnitEnum|string $key, Closure $callback): mixed + { + return $this->rememberForeverNullable($key, $callback); + } + /** * Get an item from the cache, or execute the given Closure and store the result forever. * @@ -524,18 +544,15 @@ public function sear(UnitEnum|string $key, Closure $callback): mixed public function rememberForever(UnitEnum|string $key, Closure $callback): mixed { $remember = function () use ($key, $callback) { - $value = $this->get($key); + $value = $this->getRaw($key); - // If the item exists in the cache we will just return this immediately - // and if not we will execute the given Closure and cache the result - // of that forever so it is available for all subsequent requests. if (! is_null($value)) { - return $value; + return NullSentinel::unwrap($value); } $this->forever($key, $value = $callback()); - return $value; + return NullSentinel::unwrap($value); }; return method_exists($this->store, 'withPinnedConnection') @@ -543,6 +560,25 @@ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed : $remember(); } + /** + * Get an item from the cache, or execute the given Closure and store the result forever. + * + * Unlike rememberForever(), a null return from $callback is stored (as the + * internal NullSentinel::VALUE marker) and returned as null on subsequent + * calls rather than triggering re-execution. Public accessors unwrap the + * sentinel automatically. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * + * @return TCacheValue + */ + public function rememberForeverNullable(UnitEnum|string $key, Closure $callback): mixed + { + return $this->rememberForever($key, fn () => $callback() ?? NullSentinel::VALUE); + } + /** * Retrieve an item from the cache by key, refreshing it in the background if it is stale. * @@ -556,43 +592,73 @@ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed public function flexible(UnitEnum|string $key, array $ttl, mixed $callback, ?array $lock = null, bool $alwaysDefer = false): mixed { $key = enum_value($key); + $markerKey = "hypervel:cache:flexible:created:{$key}"; - [ - $key => $value, - "hypervel:cache:flexible:created:{$key}" => $created, - ] = $this->many([$key, "hypervel:cache:flexible:created:{$key}"]); + [$key => $value, $markerKey => $created] = $this->manyRaw([$key, $markerKey]); if (in_array(null, [$value, $created], true)) { - return tap(value($callback), fn ($value) => $this->putMany([ - $key => $value, - "hypervel:cache:flexible:created:{$key}" => Carbon::now()->getTimestamp(), - ], $ttl[1])); + $stored = value($callback); + + $this->putMany([ + $key => $stored, + $markerKey => Carbon::now()->getTimestamp(), + ], $ttl[1]); + + return NullSentinel::unwrap($stored); } if (($created + $this->getSeconds($ttl[0])) > Carbon::now()->getTimestamp()) { - return $value; + return NullSentinel::unwrap($value); } - $refresh = function () use ($key, $ttl, $callback, $lock, $created) { + $refresh = function () use ($key, $markerKey, $ttl, $callback, $lock, $created) { $this->store->lock( // @phpstan-ignore method.notFound (lock() is on LockProvider, not Store contract) "hypervel:cache:flexible:lock:{$key}", $lock['seconds'] ?? 0, $lock['owner'] ?? null, - )->get(function () use ($key, $callback, $created, $ttl) { - if ($created !== $this->get("hypervel:cache:flexible:created:{$key}")) { + )->get(function () use ($key, $markerKey, $callback, $created, $ttl) { + // Re-check the marker inside the lock. Single key, so getRaw is the + // right tool here — no need to batch. + if ($created !== $this->getRaw($markerKey)) { return; } $this->putMany([ $key => value($callback), - "hypervel:cache:flexible:created:{$key}" => Carbon::now()->getTimestamp(), + $markerKey => Carbon::now()->getTimestamp(), ], $ttl[1]); }); }; defer($refresh, "hypervel:cache:flexible:{$key}", $alwaysDefer); - return $value; + return NullSentinel::unwrap($value); + } + + /** + * Retrieve an item from the cache by key, refreshing it in the background if it is stale. + * + * Unlike flexible(), a null return from $callback is stored (as the internal + * NullSentinel::VALUE marker) and returned as null on subsequent calls rather + * than triggering re-execution. Public accessors unwrap the sentinel + * automatically. + * + * Inherits flexible()'s support matrix: unsupported on any-mode tagged caches + * (tags()->flexibleNullable() on a TagMode::Any store throws the same + * BadMethodCallException that tags()->flexible() does, because flexible() + * internally reads via manyRaw() (initial batched read) and getRaw() (refresh + * closure), both of which AnyTaggedCache overrides to throw in any-mode). + * + * @template TCacheValue + * + * @param array{ 0: DateInterval|DateTimeInterface|int, 1: DateInterval|DateTimeInterface|int } $ttl + * @param callable(): TCacheValue $callback + * @param null|array{ seconds?: int, owner?: string } $lock + * @return TCacheValue + */ + public function flexibleNullable(UnitEnum|string $key, array $ttl, mixed $callback, ?array $lock = null, bool $alwaysDefer = false): mixed + { + return $this->flexible($key, $ttl, fn () => value($callback) ?? NullSentinel::VALUE, $lock, $alwaysDefer); } /** @@ -863,20 +929,13 @@ public function offsetUnset($key): void */ protected function handleManyResult(array $keys, string $key, mixed $value): mixed { - // If we could not find the cache value, we will fire the missed event and get - // the default value for this cache value. This default could be a callback - // so we will execute the value function which will resolve it if needed. - if (is_null($value)) { - $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key)); - + // Events are fired by manyRaw(). This method is a pure default resolver: + // genuine miss (null) and cached-null (sentinel) both resolve to the + // per-key default, matching get()'s convention. + if (is_null($value) || $value === NullSentinel::VALUE) { return (isset($keys[$key]) && ! array_is_list($keys)) ? value($keys[$key]) : null; } - // If we found a valid value we will fire the "hit" event and return the value - // back from this function. The "hit" event gives developers an opportunity - // to listen for every possible cache "hit" throughout this applications. - $this->event(CacheHit::class, fn (): CacheHit => new CacheHit($this->getName(), $key, $value)); - return $value; } @@ -932,6 +991,102 @@ protected function event(string $eventClass, Closure $event): void $this->events->dispatch($event()); } + /** + * Retrieve an item from the cache by key without unwrapping sentinels. + * + * @internal For cache-layer internal use (sentinel-aware hit detection in + * remember/rememberForever/flexible, plus the RawReadable seam that + * wrapper stores like MemoizedStore / FailoverStore use). App code should + * use get(), which unwraps NullSentinel::VALUE to null. + * + * Fires the same RetrievingKey / CacheHit / CacheMissed events as get(), + * so observability is unchanged — listeners observing CacheHit may see + * NullSentinel::VALUE as the event's value field on cached-null entries. + * + * Delegates to $this->store->getRaw() when the underlying store implements + * RawReadable (wrapper stores that need to preserve sentinels across their + * own internal indirection). Otherwise calls $this->store->get(), which + * plain stores already implement as a raw read. + */ + public function getRaw(UnitEnum|string $key): mixed + { + $key = enum_value($key); + + $this->event(RetrievingKey::class, fn (): RetrievingKey => new RetrievingKey($this->getName(), $key)); + + $value = $this->store instanceof RawReadable + ? $this->store->getRaw($this->itemKey($key)) + : $this->store->get($this->itemKey($key)); + + if (is_null($value)) { + $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key)); + } else { + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit($this->getName(), $key, $value)); + } + + return $value; + } + + /** + * Retrieve multiple items from the cache by key without unwrapping sentinels. + * + * @internal For cache-layer internal use. App code should use many(), which + * unwraps sentinels via handleManyResult(). + * + * Batched raw-read counterpart to getRaw(). Used by flexible() to preserve + * its single batched store read (avoiding a hot-path regression from + * splitting into sequential get() calls), while still returning raw + * sentinels so the caller can distinguish "cached sentinel = hit" from + * "genuinely absent = miss". + * + * Applies itemKey() to each key so tag-namespacing works correctly on + * TaggedCache / AllTaggedCache (which prepend sha1($tagNamespace) . ':'). + * AnyTaggedCache overrides this method to throw, preserving the any-mode + * invariant that reads through tags are rejected. + * + * Fires RetrievingManyKeys + per-key CacheHit/CacheMissed events, matching + * the event shape of public many() calls. + * + * Delegates to $this->store->manyRaw() when the underlying store implements + * RawReadable (MemoizedStore / FailoverStore). Otherwise calls + * $this->store->many(), which plain stores already implement as a raw read. + * + * @param list $keys + * @return array keyed by the input keys (not itemKey-prefixed); + * value may be null (miss), NullSentinel::VALUE (cached-null), or a real value + */ + public function manyRaw(array $keys): array + { + if ($keys === []) { + return []; + } + + $this->event( + RetrievingManyKeys::class, + fn (): RetrievingManyKeys => new RetrievingManyKeys($this->getName(), $keys) + ); + + $itemKeys = array_map(fn (string $key): string => $this->itemKey($key), $keys); + + $storeValues = $this->store instanceof RawReadable + ? $this->store->manyRaw($itemKeys) + : $this->store->many($itemKeys); + + $result = []; + foreach ($keys as $i => $key) { + $value = $storeValues[$itemKeys[$i]] ?? null; + $result[$key] = $value; + + if (is_null($value)) { + $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key)); + } else { + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit($this->getName(), $key, $value)); + } + } + + return $result; + } + /** * Flush the cache repository's global state. */ From d95f116dbf4b304919944d66b4ea422f072a56ff Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:26:53 +0000 Subject: [PATCH 05/28] feat(cache): unwrap NullSentinel in AllTaggedCache remember/rememberForever The Redis tagged-cache subclass returns raw values from allTagOps(), so tags()->rememberNullable() would leak the sentinel through tags()->remember() (plain) on a subsequent call. Single-line unwrap on return keeps the sentinel fully internal, matching Repository's boundary behavior. --- src/cache/src/Redis/AllTaggedCache.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php index 875c81210..b94957a58 100644 --- a/src/cache/src/Redis/AllTaggedCache.php +++ b/src/cache/src/Redis/AllTaggedCache.php @@ -12,6 +12,7 @@ use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Cache\NullSentinel; use Hypervel\Cache\RedisStore; use Hypervel\Cache\TaggedCache; use Hypervel\Contracts\Cache\Store; @@ -248,7 +249,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds)); } - return $value; + return NullSentinel::unwrap($value); } /** @@ -277,7 +278,7 @@ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value)); } - return $value; + return NullSentinel::unwrap($value); } /** From 20851e8121e335200c725af67ee3f6727a16017b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:27:03 +0000 Subject: [PATCH 06/28] feat(cache): unwrap NullSentinel and reject getRaw/manyRaw in AnyTaggedCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same remember/rememberForever unwrap as AllTaggedCache. Additionally, getRaw() and manyRaw() are overridden to throw BadMethodCallException — preserving the existing any-mode invariant that tagged reads are rejected, which must now cover the new raw-read path too (otherwise tags()->flexible() and tags()->flexibleNullable() would accidentally work on any-mode stores). --- src/cache/src/Redis/AnyTaggedCache.php | 27 ++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/cache/src/Redis/AnyTaggedCache.php b/src/cache/src/Redis/AnyTaggedCache.php index e8f256110..aa6ffc8e4 100644 --- a/src/cache/src/Redis/AnyTaggedCache.php +++ b/src/cache/src/Redis/AnyTaggedCache.php @@ -14,6 +14,7 @@ use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Cache\NullSentinel; use Hypervel\Cache\RedisStore; use Hypervel\Cache\TaggedCache; use Hypervel\Contracts\Cache\Store; @@ -69,6 +70,17 @@ public function get(array|UnitEnum|string $key, mixed $default = null): mixed ); } + /** + * @throws BadMethodCallException Always - tags are for writing/flushing only + */ + public function getRaw(UnitEnum|string $key): mixed + { + throw new BadMethodCallException( + 'Cannot get items via tags in any mode. Tags are for writing and flushing only. ' + . 'Use Cache::get() directly with the full key.' + ); + } + /** * Retrieve multiple items from the cache by key. * @@ -82,6 +94,17 @@ public function many(array $keys): array ); } + /** + * @throws BadMethodCallException Always - tags are for writing/flushing only + */ + public function manyRaw(array $keys): array + { + throw new BadMethodCallException( + 'Cannot get items via tags in any mode. Tags are for writing and flushing only. ' + . 'Use Cache::get() directly.' + ); + } + /** * Determine if an item exists in the cache. * @@ -295,7 +318,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds)); } - return $value; + return NullSentinel::unwrap($value); } /** @@ -324,7 +347,7 @@ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value)); } - return $value; + return NullSentinel::unwrap($value); } /** From 92cb3b37c794e811b0236f568bb8618700fa948d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:27:12 +0000 Subject: [PATCH 07/28] feat(cache): implement RawReadable in MemoizedStore to preserve sentinels across memo MemoizedStore wraps an inner Repository; its pre-refactor get()/many() bounced through the inner Repository::get/many which would unwrap sentinels before the memo layer saw them, breaking sentinel-aware hit detection on memoized stacks. Now memoizes raw values; getRaw()/manyRaw() delegate to the inner repository's raw path; get()/many() keep their Store-contract unwrap behavior for direct callers by layering on top of the raw helpers. --- src/cache/src/MemoizedStore.php | 51 ++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/cache/src/MemoizedStore.php b/src/cache/src/MemoizedStore.php index 396702078..0ffaaa193 100644 --- a/src/cache/src/MemoizedStore.php +++ b/src/cache/src/MemoizedStore.php @@ -7,9 +7,13 @@ use BadMethodCallException; use Hypervel\Contracts\Cache\Lock as LockContract; use Hypervel\Contracts\Cache\LockProvider; +use Hypervel\Contracts\Cache\RawReadable; use Hypervel\Contracts\Cache\Store; +use UnitEnum; -class MemoizedStore implements LockProvider, Store +use function Hypervel\Support\enum_value; + +class MemoizedStore implements LockProvider, RawReadable, Store { /** * The memoized cache values. @@ -29,16 +33,27 @@ public function __construct( /** * Retrieve an item from the cache by key. + * + * Store contract method — returns the value with sentinels unwrapped to null, + * matching the pre-refactor behavior (which returned whatever the inner + * Repository's get() returned, i.e., unwrapped). Memoizes the raw value so + * subsequent getRaw() calls see the sentinel. */ public function get(string $key): mixed { - $prefixedKey = $this->prefix($key); + return NullSentinel::unwrap($this->getRaw($key)); + } + + public function getRaw(UnitEnum|string $key): mixed + { + $stringKey = (string) (is_object($key) ? enum_value($key) : $key); + $prefixedKey = $this->prefix($stringKey); if (array_key_exists($prefixedKey, $this->cache)) { return $this->cache[$prefixedKey]; } - return $this->cache[$prefixedKey] = $this->repository->get($key); + return $this->cache[$prefixedKey] = $this->repository->getRaw($stringKey); } /** @@ -48,35 +63,39 @@ public function get(string $key): mixed */ public function many(array $keys): array { - [$memoized, $retrieved, $missing] = [[], [], []]; + return array_map( + NullSentinel::unwrap(...), + $this->manyRaw(array_map(fn ($k) => (string) $k, $keys)) + ); + } + + public function manyRaw(array $keys): array + { + [$memoized, $missing] = [[], []]; foreach ($keys as $key) { $stringKey = (string) $key; $prefixedKey = $this->prefix($stringKey); if (array_key_exists($prefixedKey, $this->cache)) { - $memoized[$key] = $this->cache[$prefixedKey]; + $memoized[$stringKey] = $this->cache[$prefixedKey]; } else { $missing[] = $stringKey; } } + $retrieved = []; if (count($missing) > 0) { - $retrieved = tap($this->repository->many($missing), function ($values) { - foreach ($values as $key => $value) { - $this->cache[$this->prefix((string) $key)] = $value; - } - }); + $retrieved = $this->repository->manyRaw($missing); + foreach ($retrieved as $key => $value) { + $this->cache[$this->prefix((string) $key)] = $value; + } } $result = []; - foreach ($keys as $key) { - if (array_key_exists($key, $memoized)) { - $result[$key] = $memoized[$key]; - } else { - $result[$key] = $retrieved[$key]; - } + $stringKey = (string) $key; + $result[$stringKey] = $memoized[$stringKey] ?? $retrieved[$stringKey] ?? null; } return $result; From 7469ad76c681b3042f76063d821a318b3bbb8f50 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:27:20 +0000 Subject: [PATCH 08/28] feat(cache): implement RawReadable in FailoverStore to preserve sentinels across failover Same motivation as MemoizedStore: FailoverStore's attemptOnAllStores() bounced through per-layer Repositories, so sentinels were unwrapped before the outer Repository could see them. New getRaw()/manyRaw() reuse the existing attemptOnAllStores() machinery by dispatching to the raw methods on each layer's Repository (which now implements RawReadable). get()/many() keep the pre-refactor Store-contract unwrap behavior for direct callers. --- src/cache/src/FailoverStore.php | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/cache/src/FailoverStore.php b/src/cache/src/FailoverStore.php index 2974953f6..c01a2dd10 100644 --- a/src/cache/src/FailoverStore.php +++ b/src/cache/src/FailoverStore.php @@ -8,12 +8,14 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Cache\Lock as LockContract; use Hypervel\Contracts\Cache\LockProvider; +use Hypervel\Contracts\Cache\RawReadable; use Hypervel\Contracts\Cache\Repository as RepositoryContract; use Hypervel\Contracts\Events\Dispatcher; use RuntimeException; use Throwable; +use UnitEnum; -class FailoverStore extends TaggableStore implements LockProvider +class FailoverStore extends TaggableStore implements LockProvider, RawReadable { /** * Context key prefix for the caches which failed on the last action. @@ -41,10 +43,18 @@ public function __construct( /** * Retrieve an item from the cache by key. + * + * Store contract method — unwraps sentinels to null, matching the + * pre-refactor behavior (inner Repository's get() also unwrapped). */ public function get(string $key): mixed { - return $this->attemptOnAllStores(__FUNCTION__, func_get_args()); + return NullSentinel::unwrap($this->getRaw($key)); + } + + public function getRaw(UnitEnum|string $key): mixed + { + return $this->attemptOnAllStores('getRaw', [$key]); } /** @@ -54,7 +64,15 @@ public function get(string $key): mixed */ public function many(array $keys): array { - return $this->attemptOnAllStores(__FUNCTION__, func_get_args()); + return array_map( + NullSentinel::unwrap(...), + $this->manyRaw(array_map(fn ($k) => (string) $k, $keys)) + ); + } + + public function manyRaw(array $keys): array + { + return $this->attemptOnAllStores('manyRaw', [$keys]); } /** From d92e72f0d52036cb8c8258d05329c790559d6fd4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:27:26 +0000 Subject: [PATCH 09/28] docs(support): advertise nullable cache methods and getTagMode on Cache facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Facade method annotations for rememberNullable, searNullable, rememberForeverNullable, flexibleNullable, and getTagMode — so IDE auto-complete and phpstan both see the new public surface. --- src/support/src/Facades/Cache.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/support/src/Facades/Cache.php b/src/support/src/Facades/Cache.php index d96d86fb9..33f4907c9 100644 --- a/src/support/src/Facades/Cache.php +++ b/src/support/src/Facades/Cache.php @@ -29,6 +29,9 @@ * @method static mixed remember(\UnitEnum|string $key, \DateInterval|\DateTimeInterface|int|null $ttl, \Closure $callback) * @method static mixed sear(\UnitEnum|string $key, \Closure $callback) * @method static mixed rememberForever(\UnitEnum|string $key, \Closure $callback) + * @method static mixed rememberNullable(\UnitEnum|string $key, \DateInterval|\DateTimeInterface|int|null $ttl, \Closure $callback) + * @method static mixed searNullable(\UnitEnum|string $key, \Closure $callback) + * @method static mixed rememberForeverNullable(\UnitEnum|string $key, \Closure $callback) * @method static bool touch(\UnitEnum|string $key, \DateInterval|\DateTimeInterface|int|null $ttl = null) * @method static bool forget(\UnitEnum|string $key) * @method static \Hypervel\Contracts\Cache\Store getStore() @@ -43,6 +46,7 @@ * @method static \Hypervel\Contracts\Cache\Lock lock(string $name, int $seconds = 0, string|null $owner = null) * @method static \Hypervel\Contracts\Cache\Lock restoreLock(string $name, string $owner) * @method static \Hypervel\Cache\TaggedCache tags(mixed $names) + * @method static \Hypervel\Cache\TagMode getTagMode() * @method static array many(array $keys) * @method static bool putMany(array $values, int $seconds) * @method static bool flush() @@ -54,6 +58,7 @@ * @method static bool boolean(\UnitEnum|string $key, null|bool|\Closure $default = null) * @method static array array(\UnitEnum|string $key, null|array|\Closure $default = null) * @method static mixed flexible(\UnitEnum|string $key, array $ttl, callable $callback, null|array $lock = null, bool $alwaysDefer = false) + * @method static mixed flexibleNullable(\UnitEnum|string $key, array $ttl, callable $callback, null|array $lock = null, bool $alwaysDefer = false) * @method static mixed withoutOverlapping(\UnitEnum|string $key, callable $callback, int $lockFor = 0, int $waitFor = 10, string|null $owner = null) * @method static bool flushLocks() * @method static bool supportsTags() From 6be7a3ba75e53926143d7e72ecbe5b6e91a68d96 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:27:37 +0000 Subject: [PATCH 10/28] fix(collections): bind generic type on IteratorIterator in makeIterator fallback With treatPhpDocTypesAsCertain disabled, the Traversable-but-not-Iterator fallback branch in LazyCollection::makeIterator is no longer dead code in phpstan's view. Phpstan can't infer generic parameters on a bare Traversable to fill in IteratorIterator, so supply them explicitly via @var using the method's existing @template types. Also drops two now-obsolete @phpstan-ignore comments that were suppressing the old dead-code verdicts. --- src/collections/src/LazyCollection.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 9a7285918..d15bc0156 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -1771,13 +1771,12 @@ protected function makeIterator(IteratorAggregate|array|callable $source): Itera // Only callable remains at this point $maybeTraversable = $source(); - // @phpstan-ignore instanceof.alwaysTrue (PHPDoc says Generator but runtime callable could return anything) if ($maybeTraversable instanceof Iterator) { return $maybeTraversable; } - // @phpstan-ignore deadCode.unreachable (defensive - handles non-Iterator Traversables) if ($maybeTraversable instanceof Traversable) { + /** @var Traversable $maybeTraversable */ return new IteratorIterator($maybeTraversable); } From df0018a4cc33e2fbfd39054da43dd56349091923 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:27:47 +0000 Subject: [PATCH 11/28] chore(phpstan): flip treatPhpDocTypesAsCertain to false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHPDocs aren't enforceable at runtime — callers can pass anything matching the native type. Flagging defensive runtime checks against phpdoc-declared types as "always false" pushes developers to delete those checks, leaving library code brittle to real-world misuse. Setting this to false matches Laravel's default stance and keeps legitimate defensive code green. Surfaced one latent issue in LazyCollection (fixed separately). --- phpstan.neon.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a5df9bce3..27cff3da2 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,7 +11,7 @@ parameters: maximumNumberOfProcesses: 32 minimumNumberOfJobsPerProcess: 2 inferPrivatePropertyTypeFromConstructor: true - treatPhpDocTypesAsCertain: true + treatPhpDocTypesAsCertain: false reportUnmatchedIgnoredErrors: false paths: - src From 46e0cea0a1e1ff5617ce6840a6b53743b2c7ca1a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:27:57 +0000 Subject: [PATCH 12/28] test(cache): add Repository nullable method + sentinel coverage Covers plain-Repository nullable hit/miss with mocked Store, mixed-usage semantics via real ArrayStore (get/has/many/pull/plain-remember/plain-flexible all resolve cached-null correctly), events-with-sentinel-payload, store-level round-trip under default and restrictive serializable_classes configs, edge cases (TTL expiry, put-overwrite, forget), plus the many() regression test asserting CacheHit (not CacheMissed) fires for a cached-null entry. --- tests/Cache/CacheRepositoryTest.php | 439 ++++++++++++++++++++++++++++ 1 file changed, 439 insertions(+) diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index 009a90851..ba367a992 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -10,8 +10,14 @@ use DateTime; use DateTimeImmutable; use Hypervel\Cache\ArrayStore; +use Hypervel\Cache\Events\CacheHit; +use Hypervel\Cache\Events\CacheMissed; +use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Cache\Events\RetrievingManyKeys; +use Hypervel\Cache\Events\WritingKey; use Hypervel\Cache\FileStore; use Hypervel\Cache\Lock; +use Hypervel\Cache\NullSentinel; use Hypervel\Cache\NullStore; use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; @@ -144,6 +150,341 @@ public function testRememberForeverMethodCallsForeverAndReturnsDefault() $this->assertSame('bar', $result); } + public function testRememberNullableStoresAndReturnsNonNullValue() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); + $repo->getStore()->shouldReceive('put')->once()->with('foo', 'bar', 10); + + $result = $repo->rememberNullable('foo', 10, fn () => 'bar'); + + $this->assertSame('bar', $result); + } + + public function testRememberNullableStoresSentinelWhenCallbackReturnsNull() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); + $repo->getStore()->shouldReceive('put')->once()->with('foo', NullSentinel::VALUE, 10); + + $result = $repo->rememberNullable('foo', 10, fn () => null); + + $this->assertNull($result); + } + + public function testRememberNullableReturnsNullOnSentinelHitWithoutInvokingCallback() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(NullSentinel::VALUE); + $repo->getStore()->shouldNotReceive('put'); + + $invoked = false; + $result = $repo->rememberNullable('foo', 10, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testRememberNullableReturnsCachedValueOnHit() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('cached'); + + $result = $repo->rememberNullable('foo', 10, fn () => 'new'); + + $this->assertSame('cached', $result); + } + + public function testRememberForeverNullableStoresAndReturnsNonNullValue() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->andReturn(null); + $repo->getStore()->shouldReceive('forever')->once()->with('foo', 'bar'); + + $result = $repo->rememberForeverNullable('foo', fn () => 'bar'); + + $this->assertSame('bar', $result); + } + + public function testRememberForeverNullableStoresSentinelWhenCallbackReturnsNull() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->andReturn(null); + $repo->getStore()->shouldReceive('forever')->once()->with('foo', NullSentinel::VALUE); + + $result = $repo->rememberForeverNullable('foo', fn () => null); + + $this->assertNull($result); + } + + public function testRememberForeverNullableReturnsNullOnSentinelHit() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->andReturn(NullSentinel::VALUE); + $repo->getStore()->shouldNotReceive('forever'); + + $result = $repo->rememberForeverNullable('foo', fn () => 'should-not-run'); + + $this->assertNull($result); + } + + public function testSearNullableDelegatesToRememberForeverNullable() + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->andReturn(null); + $repo->getStore()->shouldReceive('forever')->once()->with('foo', NullSentinel::VALUE); + + $result = $repo->searNullable('foo', fn () => null); + + $this->assertNull($result); + } + + public function testFlexibleNullableStoresAndReturnsNonNullValue() + { + $repo = $this->getRepository(); + $repo->getStore() + ->shouldReceive('many') + ->once() + ->with(['foo', 'hypervel:cache:flexible:created:foo']) + ->andReturn(['foo' => null, 'hypervel:cache:flexible:created:foo' => null]); + $repo->getStore() + ->shouldReceive('putMany') + ->once() + ->with(m::on(fn ($values) => $values['foo'] === 'bar'), 20); + + $result = $repo->flexibleNullable('foo', [10, 20], fn () => 'bar'); + + $this->assertSame('bar', $result); + } + + public function testFlexibleNullableStoresSentinelWhenCallbackReturnsNull() + { + $repo = $this->getRepository(); + $repo->getStore() + ->shouldReceive('many') + ->once() + ->with(['foo', 'hypervel:cache:flexible:created:foo']) + ->andReturn(['foo' => null, 'hypervel:cache:flexible:created:foo' => null]); + $repo->getStore() + ->shouldReceive('putMany') + ->once() + ->with(m::on(fn ($values) => $values['foo'] === NullSentinel::VALUE), 20); + + $result = $repo->flexibleNullable('foo', [10, 20], fn () => null); + + $this->assertNull($result); + } + + public function testFlexibleNullableReturnsNullOnFreshSentinelHit() + { + $repo = $this->getRepository(); + $now = Carbon::now()->getTimestamp(); + + $repo->getStore() + ->shouldReceive('many') + ->once() + ->with(['foo', 'hypervel:cache:flexible:created:foo']) + ->andReturn([ + 'foo' => NullSentinel::VALUE, + 'hypervel:cache:flexible:created:foo' => $now, + ]); + + $result = $repo->flexibleNullable('foo', [10, 20], fn () => 'should-not-run'); + + $this->assertNull($result); + } + + public function testFlexibleNullableReturnsValueOnFreshValueHit() + { + $repo = $this->getRepository(); + $now = Carbon::now()->getTimestamp(); + + $repo->getStore() + ->shouldReceive('many') + ->once() + ->with(['foo', 'hypervel:cache:flexible:created:foo']) + ->andReturn([ + 'foo' => 'cached', + 'hypervel:cache:flexible:created:foo' => $now, + ]); + + $result = $repo->flexibleNullable('foo', [10, 20], fn () => 'new'); + + $this->assertSame('cached', $result); + } + + public function testMixedUsageGetReturnsNullForCachedNullEntry() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + $repo->rememberNullable('k', 60, fn () => null); + + $this->assertNull($repo->get('k')); + } + + public function testMixedUsageGetAppliesDefaultForCachedNullEntry() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + $repo->rememberNullable('k', 60, fn () => null); + + $this->assertSame('default', $repo->get('k', 'default')); + } + + public function testMixedUsageManyReturnsNullForCachedNullEntry() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + $repo->rememberNullable('k', 60, fn () => null); + + $this->assertSame(['k' => null], $repo->many(['k'])); + } + + public function testMixedUsageHasReturnsFalseForCachedNullEntry() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + $repo->rememberNullable('k', 60, fn () => null); + + $this->assertFalse($repo->has('k')); + $this->assertTrue($repo->missing('k')); + } + + public function testMixedUsageHasConsistencyBetweenPutNullAndRememberNullable() + { + $repoA = new Repository(new ArrayStore(serializesValues: true)); + $repoA->put('k', null, 60); + + $repoB = new Repository(new ArrayStore(serializesValues: true)); + $repoB->rememberNullable('k', 60, fn () => null); + + $this->assertSame($repoA->has('k'), $repoB->has('k')); + $this->assertFalse($repoA->has('k')); + $this->assertFalse($repoB->has('k')); + } + + public function testMixedUsagePlainRememberTreatsCachedSentinelAsHit() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + $repo->rememberNullable('k', 60, fn () => null); + + $invoked = false; + $result = $repo->remember('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testMixedUsagePlainFlexibleTreatsCachedSentinelAsHit() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + $repo->flexibleNullable('k', [60, 120], fn () => null); + + $invoked = false; + $result = $repo->flexible('k', [60, 120], function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testNullSentinelRoundTripsThroughStoreWithDefaultSerializableClasses() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + + $repo->rememberNullable('k', 60, fn () => null); + + $this->assertSame(NullSentinel::VALUE, $repo->getStore()->get('k')); + $this->assertNull($repo->get('k')); + + $invoked = false; + $result = $repo->rememberNullable('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testNullSentinelRoundTripsThroughStoreWithNoAllowedClasses() + { + $repo = new Repository(new ArrayStore(serializesValues: true, serializableClasses: false)); + + $repo->rememberNullable('k', 60, fn () => null); + + $this->assertSame(NullSentinel::VALUE, $repo->getStore()->get('k')); + $this->assertNull($repo->get('k')); + + $invoked = false; + $result = $repo->rememberNullable('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testRememberNullableReRunsCallbackAfterTtlExpiry() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + + $repo->rememberNullable('k', 60, fn () => null); + + Carbon::setTestNow(Carbon::now()->addSeconds(61)); + + $invoked = false; + $result = $repo->rememberNullable('k', 60, function () use (&$invoked) { + $invoked = true; + return 'fresh'; + }); + + $this->assertSame('fresh', $result); + $this->assertTrue($invoked); + } + + public function testPutOverwritesCachedNullSentinelWithRealValue() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + $repo->rememberNullable('k', 60, fn () => null); + + $repo->put('k', 'real', 60); + + $this->assertSame('real', $repo->get('k')); + $this->assertTrue($repo->has('k')); + + $invoked = false; + $result = $repo->rememberNullable('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertSame('real', $result); + $this->assertFalse($invoked); + } + + public function testForgetClearsSentinelAndNextRememberNullableReRunsCallback() + { + $repo = new Repository(new ArrayStore(serializesValues: true)); + $repo->rememberNullable('k', 60, fn () => null); + + $repo->forget('k'); + + $invoked = false; + $result = $repo->rememberNullable('k', 60, function () use (&$invoked) { + $invoked = true; + return 'fresh'; + }); + + $this->assertSame('fresh', $result); + $this->assertTrue($invoked); + } + public function testPuttingMultipleItemsInCache() { $repo = $this->getRepository(); @@ -880,6 +1221,104 @@ public function testRememberSkipsDispatchWhenCacheEventsHaveNoListeners() $this->assertSame('bar', $result); } + public function testRememberNullableFiresEventsWithSentinelPayloadOnCacheMiss() + { + $store = m::mock(RedisStore::class); + $store->shouldReceive('withPinnedConnection') + ->once() + ->andReturnUsing(fn (callable $callback) => $callback()); + $store->shouldReceive('get')->once()->with('foo')->andReturn(null); + $store->shouldReceive('put')->once()->with('foo', NullSentinel::VALUE, 10)->andReturn(true); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + + $repository = new Repository($store); + $repository->setEventDispatcher($events); + + $result = $repository->rememberNullable('foo', 10, fn () => null); + + $this->assertNull($result); + + // Four events fire on miss: RetrievingKey, CacheMissed, WritingKey, KeyWritten. + $this->assertCount(4, $captured); + + $writingKey = array_values(array_filter($captured, fn ($e) => $e instanceof WritingKey))[0] ?? null; + $keyWritten = array_values(array_filter($captured, fn ($e) => $e instanceof KeyWritten))[0] ?? null; + + $this->assertNotNull($writingKey); + $this->assertNotNull($keyWritten); + $this->assertSame(NullSentinel::VALUE, $writingKey->value); + $this->assertSame(NullSentinel::VALUE, $keyWritten->value); + } + + public function testRememberNullableFiresHitEventWithSentinelPayloadOnSentinelRetrieval() + { + $store = m::mock(RedisStore::class); + $store->shouldReceive('withPinnedConnection') + ->once() + ->andReturnUsing(fn (callable $callback) => $callback()); + $store->shouldReceive('get')->once()->with('foo')->andReturn(NullSentinel::VALUE); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + + $repository = new Repository($store); + $repository->setEventDispatcher($events); + + $result = $repository->rememberNullable('foo', 10, fn () => 'should-not-run'); + + $this->assertNull($result); + + // Two events fire on hit: RetrievingKey, CacheHit. + $this->assertCount(2, $captured); + + $cacheHit = array_values(array_filter($captured, fn ($e) => $e instanceof CacheHit))[0] ?? null; + $this->assertNotNull($cacheHit); + $this->assertSame(NullSentinel::VALUE, $cacheHit->value); + } + + public function testManyFiresCacheHitWithSentinelPayloadForCachedNullEntry() + { + // Real ArrayStore so the sentinel genuinely round-trips through a store. + $repo = new Repository(new ArrayStore(serializesValues: true)); + $repo->rememberNullable('k', 60, fn () => null); + + // Install the capturing dispatcher AFTER the write so we only capture the + // many() read path's events (not the write-phase events from rememberNullable). + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + $repo->setEventDispatcher($events); + + $result = $repo->many(['k']); + + $this->assertSame(['k' => null], $result); + + // Two events fire: RetrievingManyKeys + CacheHit (sentinel is present — not a miss). + $this->assertCount(2, $captured); + $this->assertInstanceOf(RetrievingManyKeys::class, $captured[0]); + $this->assertInstanceOf(CacheHit::class, $captured[1]); + $this->assertSame(NullSentinel::VALUE, $captured[1]->value); + + // Must NOT fire CacheMissed — the key IS present (as a cached sentinel). + $this->assertEmpty(array_filter($captured, fn ($e) => $e instanceof CacheMissed)); + } + public function testRememberWorksWithoutPinnableStore() { $store = m::mock(ArrayStore::class); From 3a4bb1e1fd128f2224d217c7412230e7847aa591 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:28:04 +0000 Subject: [PATCH 13/28] test(cache): add AllTaggedCache nullable coverage Nullable methods on all-mode tags: rememberNullable value/sentinel storage, sentinel-hit callback suppression, rememberForeverNullable, searNullable delegation, plus plain-remember and plain-rememberForever unwrap-on-hit regressions. Also covers flexibleNullable fresh-sentinel-hit via the batched mget read path. --- tests/Cache/Redis/AllTaggedCacheTest.php | 186 +++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php index 11274eebb..1ee9da43e 100644 --- a/tests/Cache/Redis/AllTaggedCacheTest.php +++ b/tests/Cache/Redis/AllTaggedCacheTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Cache\Redis; +use Hypervel\Cache\NullSentinel; +use Mockery as m; use RuntimeException; /** @@ -418,6 +420,95 @@ public function testRememberDoesNotCallCallbackOnCacheHit(): void $this->assertSame(0, $callCount, 'Callback should not be called on cache hit'); } + public function testRememberNullableStoresAndReturnsNonNullValue(): void + { + $connection = $this->mockConnection(); + $key = sha1('_all:tag:users:entries') . ':profile'; + $expectedScore = now()->timestamp + 60; + + $connection->shouldReceive('get')->once()->with("prefix:{$key}")->andReturnNull(); + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zadd')->once()->with('prefix:_all:tag:users:entries', $expectedScore, $key)->andReturn($connection); + $connection->shouldReceive('setex')->once()->with("prefix:{$key}", 60, serialize('computed'))->andReturn($connection); + $connection->shouldReceive('exec')->once()->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->rememberNullable('profile', 60, fn () => 'computed'); + + $this->assertSame('computed', $result); + } + + public function testRememberNullableStoresSentinelWhenCallbackReturnsNull(): void + { + $connection = $this->mockConnection(); + $key = sha1('_all:tag:users:entries') . ':profile'; + $expectedScore = now()->timestamp + 60; + + $connection->shouldReceive('get')->once()->with("prefix:{$key}")->andReturnNull(); + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zadd')->once()->with('prefix:_all:tag:users:entries', $expectedScore, $key)->andReturn($connection); + $connection->shouldReceive('setex') + ->once() + ->with( + "prefix:{$key}", + 60, + m::on(fn (string $serialized): bool => unserialize($serialized) === NullSentinel::VALUE) + ) + ->andReturn($connection); + $connection->shouldReceive('exec')->once()->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->rememberNullable('profile', 60, fn () => null); + + $this->assertNull($result); + } + + public function testRememberNullableReturnsNullOnSentinelHitWithoutInvokingCallback(): void + { + $connection = $this->mockConnection(); + $key = sha1('_all:tag:users:entries') . ':profile'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize(NullSentinel::VALUE)); + + $invoked = false; + $store = $this->createStore($connection); + $result = $store->tags(['users'])->rememberNullable('profile', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + /** + * Proves tags()->remember() (plain, non-nullable) unwraps the sentinel on return + * — the sentinel never leaks through the public non-nullable tagged-cache API. + */ + public function testPlainRememberUnwrapsSentinelOnCachedNullHit(): void + { + $connection = $this->mockConnection(); + $key = sha1('_all:tag:users:entries') . ':profile'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize(NullSentinel::VALUE)); + + $invoked = false; + $store = $this->createStore($connection); + $result = $store->tags(['users'])->remember('profile', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + /** * @test */ @@ -465,6 +556,76 @@ public function testRememberForeverCallsCallbackAndStoresValueOnMiss(): void $this->assertSame('computed_settings', $result); } + public function testRememberForeverNullableStoresSentinelWhenCallbackReturnsNull(): void + { + $connection = $this->mockConnection(); + $key = sha1('_all:tag:config:entries') . ':settings'; + + $connection->shouldReceive('get')->once()->with("prefix:{$key}")->andReturnNull(); + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zadd')->once()->with('prefix:_all:tag:config:entries', -1, $key)->andReturn($connection); + $connection->shouldReceive('set') + ->once() + ->with( + "prefix:{$key}", + m::on(fn (string $serialized): bool => unserialize($serialized) === NullSentinel::VALUE) + ) + ->andReturn($connection); + $connection->shouldReceive('exec')->once()->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['config'])->rememberForeverNullable('settings', fn () => null); + + $this->assertNull($result); + } + + public function testSearNullableDelegatesToRememberForeverNullable(): void + { + $connection = $this->mockConnection(); + $key = sha1('_all:tag:config:entries') . ':settings'; + + $connection->shouldReceive('get')->once()->with("prefix:{$key}")->andReturnNull(); + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zadd')->once()->with('prefix:_all:tag:config:entries', -1, $key)->andReturn($connection); + $connection->shouldReceive('set') + ->once() + ->with( + "prefix:{$key}", + m::on(fn (string $serialized): bool => unserialize($serialized) === NullSentinel::VALUE) + ) + ->andReturn($connection); + $connection->shouldReceive('exec')->once()->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['config'])->searNullable('settings', fn () => null); + + $this->assertNull($result); + } + + /** + * Same unwrap behavior on the forever variant. + */ + public function testPlainRememberForeverUnwrapsSentinelOnCachedNullHit(): void + { + $connection = $this->mockConnection(); + $key = sha1('_all:tag:config:entries') . ':settings'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize(NullSentinel::VALUE)); + + $invoked = false; + $store = $this->createStore($connection); + $result = $store->tags(['config'])->rememberForever('settings', function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + /** * @test */ @@ -539,4 +700,29 @@ public function testRememberWithMultipleTags(): void $this->assertSame('activity_data', $result); } + + public function testFlexibleNullableReturnsNullOnFreshSentinelHit(): void + { + $connection = $this->mockConnection(); + $valueKey = sha1('_all:tag:posts:entries') . ':digest'; + $markerKey = sha1('_all:tag:posts:entries') . ':hypervel:cache:flexible:created:digest'; + $now = now()->timestamp; + + // flexible() reads both keys via a single manyRaw() → store->many() → MGET. + // phpredis's mget() returns a numeric array in input order. + $connection->shouldReceive('mget') + ->once() + ->with(["prefix:{$valueKey}", "prefix:{$markerKey}"]) + ->andReturn([serialize(NullSentinel::VALUE), serialize($now)]); + + $invoked = false; + $store = $this->createStore($connection); + $result = $store->tags(['posts'])->flexibleNullable('digest', [60, 120], function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } } From 73b1123c3ca05d522cee1776b875a3b5ad86b918 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:28:11 +0000 Subject: [PATCH 14/28] test(cache): add AnyTaggedCache nullable coverage and flexibleNullable throw Nullable methods on any-mode tags: rememberNullable value/sentinel via evalWithShaCache, sentinel-hit suppression, rememberForeverNullable, searNullable, plain-remember/rememberForever unwrap regressions, plus flexibleNullable BadMethodCallException assertion (tags+flexible rejected on any-mode via the new getRaw/manyRaw throwing overrides). --- tests/Cache/Redis/AnyTaggedCacheTest.php | 153 +++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php index 10c1a20f6..603a26f9f 100644 --- a/tests/Cache/Redis/AnyTaggedCacheTest.php +++ b/tests/Cache/Redis/AnyTaggedCacheTest.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Generator; +use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Redis\AnyTaggedCache; use Hypervel\Cache\Redis\AnyTagSet; use Hypervel\Cache\TaggedCache; @@ -461,6 +462,88 @@ public function testRememberCallsCallbackAndStoresValueWhenMiss(): void $this->assertSame(1, $callCount); } + public function testRememberNullableStoresAndReturnsNonNullValue(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get')->once()->with('prefix:mykey')->andReturnNull(); + $connection->shouldReceive('evalWithShaCache')->once()->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->rememberNullable('mykey', 60, fn () => 'computed'); + + $this->assertSame('computed', $result); + } + + public function testRememberNullableStoresSentinelWhenCallbackReturnsNull(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get')->once()->with('prefix:mykey')->andReturnNull(); + + $connection->shouldReceive('evalWithShaCache') + ->once() + ->withArgs(function ($script, $keys, $args): bool { + foreach (array_merge((array) $keys, (array) $args) as $arg) { + if (is_string($arg) && @unserialize($arg) === NullSentinel::VALUE) { + return true; + } + } + return false; + }) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->rememberNullable('mykey', 60, fn () => null); + + $this->assertNull($result); + } + + public function testRememberNullableReturnsNullOnSentinelHitWithoutInvokingCallback(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturn(serialize(NullSentinel::VALUE)); + + $invoked = false; + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->rememberNullable('mykey', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + /** + * Proves tags()->remember() (plain, non-nullable) unwraps the sentinel on return + * on an any-mode tagged cache — the sentinel never leaks through the public + * non-nullable API. + */ + public function testPlainRememberUnwrapsSentinelOnCachedNullHit(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturn(serialize(NullSentinel::VALUE)); + + $invoked = false; + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->remember('mykey', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + /** * @test */ @@ -504,6 +587,64 @@ public function testRememberForeverCallsCallbackAndStoresValueWhenMiss(): void $this->assertSame('computed_value', $result); } + public function testRememberForeverNullableStoresSentinelWhenCallbackReturnsNull(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get')->once()->with('prefix:mykey')->andReturnNull(); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->withArgs(function ($script, $keys, $args): bool { + foreach (array_merge((array) $keys, (array) $args) as $arg) { + if (is_string($arg) && @unserialize($arg) === NullSentinel::VALUE) { + return true; + } + } + return false; + }) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->rememberForeverNullable('mykey', fn () => null); + + $this->assertNull($result); + } + + public function testSearNullableDelegatesToRememberForeverNullable(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturn(serialize(NullSentinel::VALUE)); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->searNullable('mykey', fn () => 'should-not-run'); + + $this->assertNull($result); + } + + public function testPlainRememberForeverUnwrapsSentinelOnCachedNullHit(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturn(serialize(NullSentinel::VALUE)); + + $invoked = false; + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->rememberForever('mykey', function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + /** * @test */ @@ -667,4 +808,16 @@ public function testItemsReturnsGenerator(): void $items = iterator_to_array($result); $this->assertCount(2, $items); } + + public function testFlexibleNullableThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot get items via tags in any mode'); + + $cache->flexibleNullable('mykey', [60, 120], fn () => 'v'); + } } From 50758dd17495b529b436eb3fd19191d77d7a5e51 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:28:21 +0000 Subject: [PATCH 15/28] test(cache): assert tag flush invalidates sentinel and re-runs callback Verifies that after rememberNullable stores a sentinel under a tag, flushing the tag removes the entry and the next rememberNullable call invokes the callback again. --- tests/Cache/CacheTaggedCacheTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Cache/CacheTaggedCacheTest.php b/tests/Cache/CacheTaggedCacheTest.php index 4c79195d6..b5283156d 100644 --- a/tests/Cache/CacheTaggedCacheTest.php +++ b/tests/Cache/CacheTaggedCacheTest.php @@ -7,6 +7,7 @@ use DateInterval; use DateTime; use Hypervel\Cache\ArrayStore; +use Hypervel\Cache\Repository; use Hypervel\Tests\TestCase; use TypeError; @@ -60,6 +61,25 @@ public function testCacheSavedWithMultipleTagsCanBeFlushed() $this->assertSame('bar', $store->tags($tags2)->get('foo')); } + public function testTagFlushRemovesSentinelAndReRunsCallbackOnRememberNullable() + { + $store = new ArrayStore(serializesValues: true); + $repo = new Repository($store); + + $repo->tags(['users'])->rememberNullable('profile', 60, fn () => null); + + $repo->tags(['users'])->flush(); + + $invoked = false; + $result = $repo->tags(['users'])->rememberNullable('profile', 60, function () use (&$invoked) { + $invoked = true; + return 'fresh'; + }); + + $this->assertSame('fresh', $result); + $this->assertTrue($invoked); + } + public function testTagsWithStringArgument() { $store = new ArrayStore; From 9d734a01d1a30a8950295fa7669d994dfb8dd7e0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:28:29 +0000 Subject: [PATCH 16/28] test(cache): RawReadable regression coverage through the memo layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests proving the RawReadable seam works: sentinel survives the memo and raw-store inspection shows it intact; plain remember on a sentinel-stored key returns null without re-running its callback; plain flexible likewise treats the sentinel as a fresh hit. Plus a many() event-classification regression — CacheHit (not CacheMissed) fires on a cached-null entry read through the memoized stack. --- tests/Cache/CacheMemoizedStoreTest.php | 94 ++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/Cache/CacheMemoizedStoreTest.php b/tests/Cache/CacheMemoizedStoreTest.php index 221ab0a00..43103c431 100644 --- a/tests/Cache/CacheMemoizedStoreTest.php +++ b/tests/Cache/CacheMemoizedStoreTest.php @@ -5,10 +5,16 @@ namespace Hypervel\Tests\Cache; use Hypervel\Cache\ArrayStore; +use Hypervel\Cache\Events\CacheHit; +use Hypervel\Cache\Events\CacheMissed; +use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\MemoizedStore; +use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Repository; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Support\Carbon; use Hypervel\Tests\TestCase; +use Mockery as m; class CacheMemoizedStoreTest extends TestCase { @@ -25,4 +31,92 @@ public function testTouchExtendsTtl() $this->assertSame('bar', $store->get('foo')); } + + public function testNullSentinelRoundTripsThroughMemoizedStore(): void + { + $innerRepo = new Repository(new ArrayStore(serializesValues: true)); + $memoized = new MemoizedStore('memoized', $innerRepo); + $outerRepo = new Repository($memoized); + + $result1 = $outerRepo->rememberNullable('k', 60, fn () => null); + $this->assertNull($result1); + + $this->assertSame(NullSentinel::VALUE, $memoized->getRaw('k')); + + $invoked = false; + $result2 = $outerRepo->rememberNullable('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + $this->assertNull($result2); + $this->assertFalse($invoked, 'Callback must not re-run — proves the RawReadable seam works across the memo layer'); + } + + public function testPlainRememberTreatsCachedSentinelAsHitThroughMemoizedStore(): void + { + $innerRepo = new Repository(new ArrayStore(serializesValues: true)); + $outerRepo = new Repository(new MemoizedStore('memoized', $innerRepo)); + + $outerRepo->rememberNullable('k', 60, fn () => null); + + $invoked = false; + $result = $outerRepo->remember('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testPlainFlexibleTreatsCachedSentinelAsHitThroughMemoizedStore(): void + { + $innerRepo = new Repository(new ArrayStore(serializesValues: true)); + $outerRepo = new Repository(new MemoizedStore('memoized', $innerRepo)); + + $outerRepo->flexibleNullable('k', [60, 120], fn () => null); + + $invoked = false; + $result = $outerRepo->flexible('k', [60, 120], function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testManyFiresCacheHitNotCacheMissedForSentinelThroughMemoizedStack(): void + { + // Build outer Repository → MemoizedStore → inner Repository → ArrayStore. + $innerRepo = new Repository(new ArrayStore(serializesValues: true)); + $outerRepo = new Repository(new MemoizedStore('memoized', $innerRepo)); + + $outerRepo->rememberNullable('k', 60, fn () => null); + + // Install the capturing dispatcher AFTER the write so we only observe the + // many() read-path events. + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + $outerRepo->setEventDispatcher($events); + + $result = $outerRepo->many(['k']); + + $this->assertSame(['k' => null], $result); + + // Before the Repository::many() → manyRaw() refactor, MemoizedStore::many() + // pre-unwrapped the sentinel, Repository saw null, and fired CacheMissed — + // incorrect, because the key IS present. After the refactor, many() routes + // through manyRaw() which sees the raw sentinel and fires CacheHit correctly. + $this->assertCount(2, $captured); + $this->assertInstanceOf(RetrievingManyKeys::class, $captured[0]); + $this->assertInstanceOf(CacheHit::class, $captured[1]); + $this->assertSame(NullSentinel::VALUE, $captured[1]->value); + $this->assertEmpty(array_filter($captured, fn ($e) => $e instanceof CacheMissed)); + } } From 9a81baa596df111568c3a2e1f28d8a48a009a054 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:28:35 +0000 Subject: [PATCH 17/28] test(cache): rememberNullable always re-runs the callback on NullStore NullStore never caches anything, so rememberNullable callbacks must run on every invocation with no error. --- tests/Cache/CacheNullStoreTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Cache/CacheNullStoreTest.php b/tests/Cache/CacheNullStoreTest.php index fed4f9af0..ff847a6e3 100644 --- a/tests/Cache/CacheNullStoreTest.php +++ b/tests/Cache/CacheNullStoreTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Cache; use Hypervel\Cache\NullStore; +use Hypervel\Cache\Repository; use Hypervel\Tests\TestCase; class CacheNullStoreTest extends TestCase @@ -40,4 +41,21 @@ public function testTouchReturnsFalse() { $this->assertFalse((new NullStore)->touch('foo', 30)); } + + public function testRememberNullableAlwaysReRunsCallbackOnNullStore(): void + { + $repo = new Repository(new NullStore); + + $count = 0; + $repo->rememberNullable('k', 60, function () use (&$count) { + ++$count; + return null; + }); + $repo->rememberNullable('k', 60, function () use (&$count) { + ++$count; + return null; + }); + + $this->assertSame(2, $count); + } } From a194a8d244307dcc4ae17d8ac2cad0b864f740ef Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:28:41 +0000 Subject: [PATCH 18/28] test(cache): sentinel round-trips through SwooleStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwooleStore uses its own Swoole Table storage with serialize/unserialize — covered explicitly to prove the array sentinel survives this driver's unique storage path. --- tests/Cache/CacheSwooleStoreTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Cache/CacheSwooleStoreTest.php b/tests/Cache/CacheSwooleStoreTest.php index dc64383c2..707c2dce4 100644 --- a/tests/Cache/CacheSwooleStoreTest.php +++ b/tests/Cache/CacheSwooleStoreTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Cache; use Carbon\Carbon; +use Hypervel\Cache\NullSentinel; +use Hypervel\Cache\Repository; use Hypervel\Cache\SwooleStore; use Hypervel\Cache\SwooleTableManager; use Hypervel\Contracts\Container\Container; @@ -90,6 +92,27 @@ public function testPutStoresValueInTable() $this->assertEquals('bar', $store->get('foo')); } + public function testNullSentinelRoundTripsThroughSwooleStore() + { + $store = $this->createStore($this->createSwooleTable()); + $repo = new Repository($store); + + $repo->rememberNullable('k', 60, fn () => null); + + // Raw store-level access: the sentinel survives Swoole Table serialize/unserialize. + $this->assertSame(NullSentinel::VALUE, $store->get('k')); + $this->assertNull($repo->get('k')); + + $invoked = false; + $result = $repo->rememberNullable('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + public function testPutManyStoresValueInTable() { $table = $this->createSwooleTable(); From 97db659126a300c38b77d851dcdc30513006b0bc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:28:48 +0000 Subject: [PATCH 19/28] test(cache): sentinel propagates through stacked stores StackStore wraps values in a record envelope at each layer; assert the sentinel survives that envelope on write, and is served as a hit on read without re-running the callback. --- tests/Cache/CacheStackStoreTest.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Cache/CacheStackStoreTest.php b/tests/Cache/CacheStackStoreTest.php index 2ce378d23..8424b0684 100644 --- a/tests/Cache/CacheStackStoreTest.php +++ b/tests/Cache/CacheStackStoreTest.php @@ -6,7 +6,9 @@ use Carbon\Carbon; use Hypervel\Cache\ArrayStore; +use Hypervel\Cache\NullSentinel; use Hypervel\Cache\RedisStore; +use Hypervel\Cache\Repository; use Hypervel\Cache\StackStore; use Hypervel\Cache\StackStoreProxy; use Hypervel\Cache\SwooleStore; @@ -94,6 +96,31 @@ public function testMissingItemsReturnNull() $this->assertNull($this->store->get($key)); } + public function testNullSentinelPropagatesThroughStackedStores() + { + $stack = new StackStore([ + new ArrayStore(serializesValues: true), + new ArrayStore(serializesValues: true), + ]); + $repo = new Repository($stack); + + $result1 = $repo->rememberNullable('k', 60, fn () => null); + $this->assertNull($result1); + + // Stack-level get unwraps the record and returns the stored sentinel. + $this->assertSame(NullSentinel::VALUE, $stack->get('k')); + + // Second remember call: callback must not re-run — sentinel recognized as hit. + $invoked = false; + $result2 = $repo->rememberNullable('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result2); + $this->assertFalse($invoked); + } + public function testPutItemToStoreStacked() { $this->createStores(); From 3ec01087ad4df3a1c73020582effc5ef5397abda Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:28:54 +0000 Subject: [PATCH 20/28] test(cache): Redis integration coverage for nullable cache methods All-mode and any-mode integration tests against real Redis: rememberNullable store-and-return-null, tag flush invalidation, plain remember on a sentinel-stored key, plus a sanity check that tagged get() returns null for a cached sentinel. --- .../Cache/Redis/RememberIntegrationTest.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/tests/Integration/Cache/Redis/RememberIntegrationTest.php b/tests/Integration/Cache/Redis/RememberIntegrationTest.php index dcf642166..407a4b32a 100644 --- a/tests/Integration/Cache/Redis/RememberIntegrationTest.php +++ b/tests/Integration/Cache/Redis/RememberIntegrationTest.php @@ -413,4 +413,130 @@ public function testNonTaggedRememberForeverInAnyMode(): void $this->assertSame('forever_untagged', $result); $this->assertSame('forever_untagged', Cache::get('untagged_forever')); } + + // ========================================================================= + // NULLABLE OPERATIONS (rememberNullable / rememberForeverNullable) + // ========================================================================= + + public function testAllModeRememberNullableStoresSentinelAndReturnsNull(): void + { + $this->setTagMode(TagMode::All); + + $count = 0; + $result1 = Cache::tags(['users'])->rememberNullable('profile', 60, function () use (&$count) { + ++$count; + return null; + }); + $result2 = Cache::tags(['users'])->rememberNullable('profile', 60, function () use (&$count) { + ++$count; + return null; + }); + + $this->assertNull($result1); + $this->assertNull($result2); + $this->assertSame(1, $count, 'Callback should only run once — sentinel is recognized as a hit on the second call'); + } + + public function testAllModeTagFlushRemovesSentinelAndAllowsCallbackToReRun(): void + { + $this->setTagMode(TagMode::All); + + $count = 0; + Cache::tags(['users'])->rememberNullable('profile', 60, function () use (&$count) { + ++$count; + return null; + }); + + Cache::tags(['users'])->flush(); + + Cache::tags(['users'])->rememberNullable('profile', 60, function () use (&$count) { + ++$count; + return null; + }); + + $this->assertSame(2, $count, 'Callback runs again after tag flush — sentinel was invalidated with the tag'); + } + + public function testAnyModeRememberNullableStoresSentinelAndReturnsNull(): void + { + $this->setTagMode(TagMode::Any); + + $count = 0; + $result1 = Cache::tags(['users'])->rememberNullable('profile', 60, function () use (&$count) { + ++$count; + return null; + }); + $result2 = Cache::tags(['users'])->rememberNullable('profile', 60, function () use (&$count) { + ++$count; + return null; + }); + + $this->assertNull($result1); + $this->assertNull($result2); + $this->assertSame(1, $count); + } + + public function testAnyModeTagFlushRemovesSentinelAndAllowsCallbackToReRun(): void + { + $this->setTagMode(TagMode::Any); + + $count = 0; + Cache::tags(['users'])->rememberNullable('profile', 60, function () use (&$count) { + ++$count; + return null; + }); + + Cache::tags(['users'])->flush(); + + Cache::tags(['users'])->rememberNullable('profile', 60, function () use (&$count) { + ++$count; + return null; + }); + + $this->assertSame(2, $count); + } + + public function testAllModePlainRememberOnSentinelStoredKeyReturnsNullWithoutReRunning(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['users'])->rememberNullable('profile', 60, fn () => null); + + // Plain remember() on the sentinel-stored key: Repository::remember() uses getRaw() + // internally, sees the sentinel as a hit, unwraps to null, and does NOT re-run the callback. + $invoked = false; + $result = Cache::tags(['users'])->remember('profile', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testAnyModePlainRememberOnSentinelStoredKeyReturnsNullWithoutReRunning(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['users'])->rememberNullable('profile', 60, fn () => null); + + $invoked = false; + $result = Cache::tags(['users'])->remember('profile', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testAllModeSentinelIsStoredAsExpected(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['users'])->rememberNullable('profile', 60, fn () => null); + + // Reading the tagged key back via get() on the tagged cache still unwraps to null. + $this->assertNull(Cache::tags(['users'])->get('profile')); + } } From dfdfde31ecaff113416e76958ffddb7d89aeb78a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:29:01 +0000 Subject: [PATCH 21/28] test(cache): Repository-level integration coverage for nullable methods End-to-end: rememberNullable round-trip through the default driver, has-returns-false convention, put-overwrite, TTL expiry triggering callback re-run, and a flexibleNullable stale-hit test that pins the deferred-refresh behavior end-to-end (invokes the deferred callback, asserts the sentinel stays stored). --- tests/Integration/Cache/RepositoryTest.php | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/Integration/Cache/RepositoryTest.php b/tests/Integration/Cache/RepositoryTest.php index 9714a3512..8d1448e04 100644 --- a/tests/Integration/Cache/RepositoryTest.php +++ b/tests/Integration/Cache/RepositoryTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Integration\Cache; use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Cache\NullSentinel; use Hypervel\Foundation\Testing\LazilyRefreshDatabase; use Hypervel\Support\Carbon; use Hypervel\Support\Facades\Cache; @@ -343,6 +344,99 @@ public function testWorksWithEnumKey() $this->assertSame(['foo' => null, 'bar' => null, 'baz' => null], $cache->many([TestCacheKey::Foo, TestCacheKey::Bar, TestCacheKey::Baz])); $this->assertSame(['foo' => 'default', 'qux' => 'default'], $cache->getMultiple([TestCacheKey::Foo, TestCacheKey::Qux], 'default')); } + + public function testRememberNullableRoundTripsThroughDefaultStore() + { + $cache = Cache::driver('array'); + + $count = 0; + $result1 = $cache->rememberNullable('k', 60, function () use (&$count) { + ++$count; + return null; + }); + $result2 = $cache->rememberNullable('k', 60, function () use (&$count) { + ++$count; + return null; + }); + + $this->assertNull($result1); + $this->assertNull($result2); + $this->assertSame(1, $count); + } + + public function testHasReturnsFalseForCachedNullSentinelViaRealStore() + { + $cache = Cache::driver('array'); + + $cache->rememberNullable('k', 60, fn () => null); + + // Laravel null-as-absence convention: has() returns false for a stored null. + $this->assertFalse($cache->has('k')); + $this->assertTrue($cache->missing('k')); + } + + public function testPutOverwritesCachedNullSentinelEndToEnd() + { + $cache = Cache::driver('array'); + + $cache->rememberNullable('k', 60, fn () => null); + $cache->put('k', 'real', 60); + + $this->assertSame('real', $cache->get('k')); + $this->assertTrue($cache->has('k')); + } + + public function testTtlExpiryOnSentinelStoredKeyReRunsCallback() + { + $this->freezeTime(); + $cache = Cache::driver('array'); + + $cache->rememberNullable('k', 60, fn () => null); + + $this->travel(61)->seconds(); + + $invoked = false; + $result = $cache->rememberNullable('k', 60, function () use (&$invoked) { + $invoked = true; + return 'fresh'; + }); + + $this->assertSame('fresh', $result); + $this->assertTrue($invoked); + } + + public function testFlexibleNullableStaleHitUnwrapsAndTriggersRefresh() + { + Carbon::setTestNow('2000-01-01 00:00:00'); + $cache = Cache::driver('array'); + + $count = 0; + + // First call: miss, callback returns null → sentinel stored via flexible's putMany. + $value = $cache->flexibleNullable('foo', [10, 20], function () use (&$count) { + ++$count; + return null; + }); + $this->assertNull($value); + $this->assertSame(1, $count); + $this->assertSame(NullSentinel::VALUE, $cache->getStore()->get('foo')); + + // Advance past the fresh TTL. Next call returns the stale sentinel (unwrapped) + // and registers a deferred refresh. + Carbon::setTestNow(now()->addSeconds(11)); + $value = $cache->flexibleNullable('foo', [10, 20], function () use (&$count) { + ++$count; + return null; + }); + $this->assertNull($value); + $this->assertSame(1, $count, 'Callback must not run inline on stale hit — refresh is deferred'); + $this->assertCount(1, defer()); + + // Invoke the deferred refresh. Callback runs; sentinel stays stored. + defer()->invoke(); + $this->assertSame(2, $count); + $this->assertSame(NullSentinel::VALUE, $cache->getStore()->get('foo')); + } } enum TestCacheKey: string From 40e710881623a236117c4081a151e1a48c6d6eeb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:29:14 +0000 Subject: [PATCH 22/28] test(cache): failover-stack nullable coverage and getRaw mock update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new regression tests: sentinel round-trip through primary, plain remember and plain flexible both treating a sentinel as a hit through the failover stack (RawReadable seam regressions), and a many() event-classification check (CacheHit fires, not CacheMissed). Also updates the existing shared-failure test to mock getRaw() instead of get() — FailoverStore::get() now delegates to getRaw() internally for sentinel-aware reads. --- tests/Integration/Cache/FailoverStoreTest.php | 130 +++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Cache/FailoverStoreTest.php b/tests/Integration/Cache/FailoverStoreTest.php index 6503e19f4..461743995 100644 --- a/tests/Integration/Cache/FailoverStoreTest.php +++ b/tests/Integration/Cache/FailoverStoreTest.php @@ -5,9 +5,15 @@ namespace Hypervel\Tests\Integration\Cache; use Exception; +use Hypervel\Cache\ArrayStore; use Hypervel\Cache\CacheManager; use Hypervel\Cache\Events\CacheFailedOver; +use Hypervel\Cache\Events\CacheHit; +use Hypervel\Cache\Events\CacheMissed; +use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\FailoverStore; +use Hypervel\Cache\NullSentinel; +use Hypervel\Cache\Repository; use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Support\Facades\Cache; @@ -62,14 +68,17 @@ public function testSeparateFailoverStoresDoNotShareFailureEventsForTheSameFaili ->twice() ->with(m::on(fn (object $event) => $event instanceof CacheFailedOver && $event->storeName === 'failing')); + // FailoverStore::get() now delegates to getRaw() internally (for sentinel-aware + // reads via RawReadable). The mocks target getRaw() accordingly — the contract + // is the same, just routed through the raw-read path. $failingRepository = m::mock(CacheRepository::class); - $failingRepository->shouldReceive('get') + $failingRepository->shouldReceive('getRaw') ->twice() ->with(m::type('string')) ->andThrow(new Exception('The primary store failed.')); $fallbackRepository = m::mock(CacheRepository::class); - $fallbackRepository->shouldReceive('get') + $fallbackRepository->shouldReceive('getRaw') ->twice() ->with(m::type('string')) ->andReturn('fallback-a', 'fallback-b'); @@ -90,6 +99,123 @@ public function testSeparateFailoverStoresDoNotShareFailureEventsForTheSameFaili $this->assertSame('fallback-a', $storeA->get('test-a')); $this->assertSame('fallback-b', $storeB->get('test-b')); } + + public function testNullSentinelRoundTripsThroughFailoverStorePrimary() + { + $primaryRepo = new Repository(new ArrayStore(serializesValues: true)); + $fallbackRepo = new Repository(new ArrayStore(serializesValues: true)); + + $outerRepo = $this->buildFailoverRepository($primaryRepo, $fallbackRepo); + + $count = 0; + $result1 = $outerRepo->rememberNullable('k', 60, function () use (&$count) { + ++$count; + return null; + }); + $result2 = $outerRepo->rememberNullable('k', 60, function () use (&$count) { + ++$count; + return null; + }); + + $this->assertNull($result1); + $this->assertNull($result2); + $this->assertSame(1, $count, 'Sentinel round-trips through the primary without re-running the callback'); + + // Primary's inner store holds the raw sentinel; fallback untouched. + $this->assertSame(NullSentinel::VALUE, $primaryRepo->getStore()->get('k')); + $this->assertNull($fallbackRepo->getStore()->get('k')); + } + + public function testPlainRememberTreatsCachedSentinelAsHitThroughFailoverStack() + { + $primaryRepo = new Repository(new ArrayStore(serializesValues: true)); + $fallbackRepo = new Repository(new ArrayStore(serializesValues: true)); + + $outerRepo = $this->buildFailoverRepository($primaryRepo, $fallbackRepo); + + $outerRepo->rememberNullable('k', 60, fn () => null); + + // Plain remember on the sentinel-stored key. Without RawReadable on FailoverStore, + // the inner Repository would unwrap the sentinel and remember() would re-run the + // callback on every call. + $invoked = false; + $result = $outerRepo->remember('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testPlainFlexibleTreatsCachedSentinelAsHitThroughFailoverStack() + { + $primaryRepo = new Repository(new ArrayStore(serializesValues: true)); + $fallbackRepo = new Repository(new ArrayStore(serializesValues: true)); + + $outerRepo = $this->buildFailoverRepository($primaryRepo, $fallbackRepo); + + $outerRepo->flexibleNullable('k', [60, 120], fn () => null); + + // Regression test for manyRaw() across FailoverStore. Plain flexible()'s batched + // read must see the sentinel as a hit via FailoverStore::manyRaw(), not re-run + // the callback or trigger a background refresh. + $invoked = false; + $result = $outerRepo->flexible('k', [60, 120], function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testManyFiresCacheHitNotCacheMissedForSentinelThroughFailoverStack() + { + $primaryRepo = new Repository(new ArrayStore(serializesValues: true)); + $fallbackRepo = new Repository(new ArrayStore(serializesValues: true)); + + $outerRepo = $this->buildFailoverRepository($primaryRepo, $fallbackRepo); + + $outerRepo->rememberNullable('k', 60, fn () => null); + + // Install the capturing dispatcher AFTER the write so we only observe the + // many() read-path events. + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + $outerRepo->setEventDispatcher($events); + + $result = $outerRepo->many(['k']); + + $this->assertSame(['k' => null], $result); + + // Regression: before Repository::many() → manyRaw() refactor, FailoverStore::many() + // unwrapped the sentinel before the outer Repository saw it — so many() fired + // CacheMissed on a key that was genuinely present. The refactor routes many() + // through manyRaw(), which sees the raw sentinel and correctly fires CacheHit. + $this->assertCount(2, $captured); + $this->assertInstanceOf(RetrievingManyKeys::class, $captured[0]); + $this->assertInstanceOf(CacheHit::class, $captured[1]); + $this->assertSame(NullSentinel::VALUE, $captured[1]->value); + $this->assertEmpty(array_filter($captured, fn ($e) => $e instanceof CacheMissed)); + } + + private function buildFailoverRepository(Repository $primary, Repository $fallback): Repository + { + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store')->with('primary')->andReturn($primary); + $cacheManager->shouldReceive('store')->with('fallback')->andReturn($fallback); + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('dispatch')->withAnyArgs()->andReturnNull(); + + return new Repository(new FailoverStore($cacheManager, $events, ['primary', 'fallback'])); + } } class CantSerialize From f336f5ada09ddf81b0d42b458b4b4130f52aedb7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:29:20 +0000 Subject: [PATCH 23/28] test(cache): memoized-stack nullable coverage End-to-end through Cache::memo(): sentinel round-trips through the memo + inner store, plain remember and plain flexible both treat a sentinel as a hit via the RawReadable seam, callbacks don't re-run on the second call. --- tests/Integration/Cache/MemoizedStoreTest.php | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/Integration/Cache/MemoizedStoreTest.php b/tests/Integration/Cache/MemoizedStoreTest.php index 4c0bf298c..5c29865db 100644 --- a/tests/Integration/Cache/MemoizedStoreTest.php +++ b/tests/Integration/Cache/MemoizedStoreTest.php @@ -13,6 +13,7 @@ use Hypervel\Cache\Events\RetrievingKey; use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; +use Hypervel\Cache\NullSentinel; use Hypervel\Contracts\Cache\Store; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Support\Facades\Cache; @@ -512,4 +513,57 @@ public function testItSupportsRestoreLock() $this->assertSame($owner, $restoredLock->owner()); } + + public function testNullSentinelRoundTripsThroughMemoizedStoreIntegration() + { + $count = 0; + $result1 = Cache::memo()->rememberNullable('k', 60, function () use (&$count) { + ++$count; + return null; + }); + $result2 = Cache::memo()->rememberNullable('k', 60, function () use (&$count) { + ++$count; + return null; + }); + + $this->assertNull($result1); + $this->assertNull($result2); + $this->assertSame(1, $count, 'Callback runs once — sentinel is served from memo on the second call'); + + // Raw inner-store access confirms the sentinel landed in the underlying store. + $this->assertSame(NullSentinel::VALUE, Cache::getStore()->get('k')); + } + + public function testPlainRememberTreatsCachedSentinelAsHitThroughRealMemoizedStack() + { + Cache::memo()->rememberNullable('k', 60, fn () => null); + + // Plain remember on the sentinel-stored key via the memoized stack: without + // RawReadable on MemoizedStore, the inner Repository would unwrap the sentinel + // early and this remember() would loop forever. + $invoked = false; + $result = Cache::memo()->remember('k', 60, function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } + + public function testPlainFlexibleTreatsCachedSentinelAsHitThroughRealMemoizedStack() + { + Cache::memo()->flexibleNullable('k', [60, 120], fn () => null); + + // Plain flexible() reads via manyRaw() — must see the sentinel across the memo + // layer and return null without re-running or triggering a background refresh. + $invoked = false; + $result = Cache::memo()->flexible('k', [60, 120], function () use (&$invoked) { + $invoked = true; + return 'should-not-run'; + }); + + $this->assertNull($result); + $this->assertFalse($invoked); + } } From 8205fe9c7313c4e8a969aed2167894d1fcb9e372 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:26:21 +0000 Subject: [PATCH 24/28] docs(cache): drop stale EloquentUserProvider reference from NullSentinel docblock The auth provider will migrate to rememberNullable in a follow-up and its NULL_SENTINEL constant goes away with it, so pointing at it as precedent would read oddly once that lands. The collision-risk explanation is self-contained without the cross-reference. --- src/cache/src/NullSentinel.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cache/src/NullSentinel.php b/src/cache/src/NullSentinel.php index 543f07c9d..2a66c8863 100644 --- a/src/cache/src/NullSentinel.php +++ b/src/cache/src/NullSentinel.php @@ -25,8 +25,7 @@ * is safe because PHP's array equality compares keys and values recursively. * Collision risk is effectively zero: a caller would have to independently * cache a value whose structure is exactly - * ['__hypervel_cache_null_sentinel' => true]. The same approach is used by - * EloquentUserProvider::NULL_SENTINEL for the same reason. + * ['__hypervel_cache_null_sentinel' => true]. */ final class NullSentinel { From 64ff688749d8f81541b4dd8e8d593bf1448ec057 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:24:21 +0000 Subject: [PATCH 25/28] docs(cache): lowercase 'array' in NullSentinel docblock No reason to shout the word for emphasis. --- src/cache/src/NullSentinel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cache/src/NullSentinel.php b/src/cache/src/NullSentinel.php index 2a66c8863..67a1bddfb 100644 --- a/src/cache/src/NullSentinel.php +++ b/src/cache/src/NullSentinel.php @@ -13,7 +13,7 @@ * "key present with null value" — the cache contract itself can't express * that distinction via get() alone. * - * The sentinel is an ARRAY constant, not an object. This is deliberate: + * The sentinel is an array constant, not an object. This is deliberate: * PHP's unserialize() allowed_classes option (used by stores when the * cache.serializable_classes config is set) only restricts object * deserialization. Scalars and arrays round-trip unchanged regardless of From 5c009b7c4dda4e6f8154040a3db9ae00cfb98681 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:16:30 +0000 Subject: [PATCH 26/28] fix(cache): unwrap NullSentinel in Repository event payloads CacheHit, WritingKey, KeyWritten, KeyWriteFailed, and WritingManyKeys events fired from Repository now carry null in their value field instead of the raw NullSentinel::VALUE marker when the entry is a cached-null. Listeners no longer have to know the sentinel exists; they just see the unwrapped value. Per Albert's review on PR #372 - the sentinel was supposed to be fully internal, and event payloads were the one surface still leaking it. --- src/cache/src/Repository.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 793a7caed..ee3d5c434 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -297,19 +297,19 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT $this->event( WritingKey::class, - fn (): WritingKey => new WritingKey($this->getName(), $key, $value, $seconds) + fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value), $seconds) ); $result = $this->store->put($this->itemKey($key), $value, $seconds); if ($result) { $this->event( KeyWritten::class, - fn (): KeyWritten => new KeyWritten($this->getName(), $key, $value, $seconds) + fn (): KeyWritten => new KeyWritten($this->getName(), $key, NullSentinel::unwrap($value), $seconds) ); } else { $this->event( KeyWriteFailed::class, - fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, $value, $seconds) + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value), $seconds) ); } @@ -344,7 +344,7 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ fn (): WritingManyKeys => new WritingManyKeys( $this->getName(), array_map(static fn ($key) => (string) $key, array_keys($values)), - array_values($values), + array_map(NullSentinel::unwrap(...), array_values($values)), $seconds ) ); @@ -355,12 +355,12 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ if ($result) { $this->event( KeyWritten::class, - fn (): KeyWritten => new KeyWritten($this->getName(), (string) $key, $value, $seconds) + fn (): KeyWritten => new KeyWritten($this->getName(), (string) $key, NullSentinel::unwrap($value), $seconds) ); } else { $this->event( KeyWriteFailed::class, - fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), (string) $key, $value, $seconds) + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), (string) $key, NullSentinel::unwrap($value), $seconds) ); } } @@ -434,16 +434,16 @@ public function forever(UnitEnum|string $key, mixed $value): bool { $key = enum_value($key); - $this->event(WritingKey::class, fn (): WritingKey => new WritingKey($this->getName(), $key, $value)); + $this->event(WritingKey::class, fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value))); $result = $this->store->forever($this->itemKey($key), $value); if ($result) { - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten($this->getName(), $key, $value)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten($this->getName(), $key, NullSentinel::unwrap($value))); } else { $this->event( KeyWriteFailed::class, - fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, $value) + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value)) ); } @@ -1021,7 +1021,7 @@ public function getRaw(UnitEnum|string $key): mixed if (is_null($value)) { $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key)); } else { - $this->event(CacheHit::class, fn (): CacheHit => new CacheHit($this->getName(), $key, $value)); + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit($this->getName(), $key, NullSentinel::unwrap($value))); } return $value; @@ -1080,7 +1080,7 @@ public function manyRaw(array $keys): array if (is_null($value)) { $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key)); } else { - $this->event(CacheHit::class, fn (): CacheHit => new CacheHit($this->getName(), $key, $value)); + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit($this->getName(), $key, NullSentinel::unwrap($value))); } } From 5108a75288b2752be1d98265ae7a4967a69a626b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:16:37 +0000 Subject: [PATCH 27/28] fix(cache): unwrap NullSentinel in Redis tagged-cache event payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same change as Repository, applied to AllTaggedCache and AnyTaggedCache — the tagged-cache subclasses fire their own CacheHit/KeyWritten events inline (bypassing Repository::put for tagged writes). Each value-bearing event constructor now passes the unwrapped payload. --- src/cache/src/Redis/AllTaggedCache.php | 16 ++++++++-------- src/cache/src/Redis/AnyTaggedCache.php | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php index b94957a58..09cb6abf5 100644 --- a/src/cache/src/Redis/AllTaggedCache.php +++ b/src/cache/src/Redis/AllTaggedCache.php @@ -67,7 +67,7 @@ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInt ); if ($result) { - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value))); } return $result; @@ -105,7 +105,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT ); if ($result) { - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds)); } return $result; @@ -135,7 +135,7 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ if ($result) { foreach ($values as $key => $value) { - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, $value, $seconds)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, NullSentinel::unwrap($value), $seconds)); } } @@ -180,7 +180,7 @@ public function forever(UnitEnum|string $key, mixed $value): bool ); if ($result) { - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value))); } return $result; @@ -243,10 +243,10 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in ); if ($wasHit) { - $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, $value)); + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value))); } else { $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key)); - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds)); } return NullSentinel::unwrap($value); @@ -272,10 +272,10 @@ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed ); if ($wasHit) { - $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, $value)); + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value))); } else { $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key)); - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value))); } return NullSentinel::unwrap($value); diff --git a/src/cache/src/Redis/AnyTaggedCache.php b/src/cache/src/Redis/AnyTaggedCache.php index aa6ffc8e4..6abe37b97 100644 --- a/src/cache/src/Redis/AnyTaggedCache.php +++ b/src/cache/src/Redis/AnyTaggedCache.php @@ -169,7 +169,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT $result = $this->store->anyTagOps()->put()->execute($key, $value, $seconds, $this->tags->getNames()); if ($result) { - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds)); } return $result; @@ -194,7 +194,7 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ if ($result) { foreach ($values as $key => $value) { - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, $value, $seconds)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, NullSentinel::unwrap($value), $seconds)); } } @@ -232,7 +232,7 @@ public function forever(UnitEnum|string $key, mixed $value): bool $result = $this->store->anyTagOps()->forever()->execute($key, $value, $this->tags->getNames()); if ($result) { - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value))); } return $result; @@ -312,10 +312,10 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in ); if ($wasHit) { - $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, $value)); + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value))); } else { $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key)); - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds)); } return NullSentinel::unwrap($value); @@ -341,10 +341,10 @@ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed ); if ($wasHit) { - $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, $value)); + $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value))); } else { $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key)); - $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value)); + $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value))); } return NullSentinel::unwrap($value); From 35a976d312fe45cca14683456db103baed52726c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:16:46 +0000 Subject: [PATCH 28/28] test(cache): event payloads carry null, not the sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips five existing assertions from `=== NullSentinel::VALUE` to `=== null` to match the new contract, and adds six regression tests covering the paths that didn't have explicit event-payload coverage: Repository::forever() (via rememberForeverNullable), Repository::putMany() (via flexibleNullable), AllTaggedCache hit + miss, AnyTaggedCache hit + miss. Also tidies up the existing tests' comments — drops the historical "before the manyRaw refactor" prose (rotted by this round of changes) and adds a short marker at each event-payload assertion so the intent reads clearly: null, not the sentinel value. --- tests/Cache/CacheMemoizedStoreTest.php | 13 +- tests/Cache/CacheRepositoryTest.php | 115 +++++++++++++++--- tests/Cache/Redis/AllTaggedCacheTest.php | 65 ++++++++++ tests/Cache/Redis/AnyTaggedCacheTest.php | 60 +++++++++ tests/Integration/Cache/FailoverStoreTest.php | 12 +- 5 files changed, 232 insertions(+), 33 deletions(-) diff --git a/tests/Cache/CacheMemoizedStoreTest.php b/tests/Cache/CacheMemoizedStoreTest.php index 43103c431..c82634dc3 100644 --- a/tests/Cache/CacheMemoizedStoreTest.php +++ b/tests/Cache/CacheMemoizedStoreTest.php @@ -88,14 +88,13 @@ public function testPlainFlexibleTreatsCachedSentinelAsHitThroughMemoizedStore() public function testManyFiresCacheHitNotCacheMissedForSentinelThroughMemoizedStack(): void { - // Build outer Repository → MemoizedStore → inner Repository → ArrayStore. $innerRepo = new Repository(new ArrayStore(serializesValues: true)); $outerRepo = new Repository(new MemoizedStore('memoized', $innerRepo)); $outerRepo->rememberNullable('k', 60, fn () => null); - // Install the capturing dispatcher AFTER the write so we only observe the - // many() read-path events. + // Capture only the many() read-path events by attaching the dispatcher + // after the write. $captured = []; $events = m::mock(Dispatcher::class); $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); @@ -108,15 +107,11 @@ public function testManyFiresCacheHitNotCacheMissedForSentinelThroughMemoizedSta $result = $outerRepo->many(['k']); $this->assertSame(['k' => null], $result); - - // Before the Repository::many() → manyRaw() refactor, MemoizedStore::many() - // pre-unwrapped the sentinel, Repository saw null, and fired CacheMissed — - // incorrect, because the key IS present. After the refactor, many() routes - // through manyRaw() which sees the raw sentinel and fires CacheHit correctly. $this->assertCount(2, $captured); $this->assertInstanceOf(RetrievingManyKeys::class, $captured[0]); $this->assertInstanceOf(CacheHit::class, $captured[1]); - $this->assertSame(NullSentinel::VALUE, $captured[1]->value); + // Null, not the sentinel value. + $this->assertNull($captured[1]->value); $this->assertEmpty(array_filter($captured, fn ($e) => $e instanceof CacheMissed)); } } diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index ba367a992..08dd8ea92 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -15,6 +15,7 @@ use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; +use Hypervel\Cache\Events\WritingManyKeys; use Hypervel\Cache\FileStore; use Hypervel\Cache\Lock; use Hypervel\Cache\NullSentinel; @@ -1221,7 +1222,7 @@ public function testRememberSkipsDispatchWhenCacheEventsHaveNoListeners() $this->assertSame('bar', $result); } - public function testRememberNullableFiresEventsWithSentinelPayloadOnCacheMiss() + public function testRememberNullableFiresEventsWithNullPayloadOnCacheMiss() { $store = m::mock(RedisStore::class); $store->shouldReceive('withPinnedConnection') @@ -1245,7 +1246,7 @@ public function testRememberNullableFiresEventsWithSentinelPayloadOnCacheMiss() $this->assertNull($result); - // Four events fire on miss: RetrievingKey, CacheMissed, WritingKey, KeyWritten. + // Miss path fires four events: RetrievingKey, CacheMissed, WritingKey, KeyWritten. $this->assertCount(4, $captured); $writingKey = array_values(array_filter($captured, fn ($e) => $e instanceof WritingKey))[0] ?? null; @@ -1253,11 +1254,95 @@ public function testRememberNullableFiresEventsWithSentinelPayloadOnCacheMiss() $this->assertNotNull($writingKey); $this->assertNotNull($keyWritten); - $this->assertSame(NullSentinel::VALUE, $writingKey->value); - $this->assertSame(NullSentinel::VALUE, $keyWritten->value); + // Null, not the sentinel value. + $this->assertNull($writingKey->value); + $this->assertNull($keyWritten->value); } - public function testRememberNullableFiresHitEventWithSentinelPayloadOnSentinelRetrieval() + public function testRememberForeverNullableFiresEventsWithNullPayloadOnCacheMiss() + { + $store = m::mock(RedisStore::class); + $store->shouldReceive('withPinnedConnection') + ->once() + ->andReturnUsing(fn (callable $callback) => $callback()); + $store->shouldReceive('get')->once()->with('foo')->andReturn(null); + $store->shouldReceive('forever')->once()->with('foo', NullSentinel::VALUE)->andReturn(true); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + + $repository = new Repository($store); + $repository->setEventDispatcher($events); + + $result = $repository->rememberForeverNullable('foo', fn () => null); + + $this->assertNull($result); + + // Miss path fires four events: RetrievingKey, CacheMissed, WritingKey, KeyWritten. + $this->assertCount(4, $captured); + + $writingKey = array_values(array_filter($captured, fn ($e) => $e instanceof WritingKey))[0] ?? null; + $keyWritten = array_values(array_filter($captured, fn ($e) => $e instanceof KeyWritten))[0] ?? null; + + $this->assertNotNull($writingKey); + $this->assertNotNull($keyWritten); + // Null, not the sentinel value. + $this->assertNull($writingKey->value); + $this->assertNull($keyWritten->value); + } + + public function testFlexibleNullableFiresEventsWithNullPayloadOnCacheMiss() + { + $repo = $this->getRepository(); + $repo->getStore() + ->shouldReceive('many') + ->once() + ->with(['foo', 'hypervel:cache:flexible:created:foo']) + ->andReturn(['foo' => null, 'hypervel:cache:flexible:created:foo' => null]); + $repo->getStore() + ->shouldReceive('putMany') + ->once() + ->andReturn(true); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + $repo->setEventDispatcher($events); + + $result = $repo->flexibleNullable('foo', [10, 20], fn () => null); + + $this->assertNull($result); + + // WritingManyKeys carries an aligned keys/values pair, so we look up the + // value-key's entry by its index in the keys array. + $writingMany = array_values(array_filter($captured, fn ($e) => $e instanceof WritingManyKeys))[0] ?? null; + $this->assertNotNull($writingMany); + $valueKeyIndex = array_search('foo', $writingMany->keys, true); + $this->assertNotFalse($valueKeyIndex); + // Null, not the sentinel value. + $this->assertNull($writingMany->values[$valueKeyIndex]); + + // The marker key's KeyWritten carries a real timestamp, so filter to the + // value key's KeyWritten specifically. + $valueKeyWritten = array_values(array_filter( + $captured, + fn ($e) => $e instanceof KeyWritten && $e->key === 'foo' + ))[0] ?? null; + $this->assertNotNull($valueKeyWritten); + // Null, not the sentinel value. + $this->assertNull($valueKeyWritten->value); + } + + public function testRememberNullableFiresHitEventWithNullPayloadOnSentinelRetrieval() { $store = m::mock(RedisStore::class); $store->shouldReceive('withPinnedConnection') @@ -1280,22 +1365,23 @@ public function testRememberNullableFiresHitEventWithSentinelPayloadOnSentinelRe $this->assertNull($result); - // Two events fire on hit: RetrievingKey, CacheHit. + // Hit path fires two events: RetrievingKey, CacheHit. $this->assertCount(2, $captured); $cacheHit = array_values(array_filter($captured, fn ($e) => $e instanceof CacheHit))[0] ?? null; $this->assertNotNull($cacheHit); - $this->assertSame(NullSentinel::VALUE, $cacheHit->value); + // Null, not the sentinel value. + $this->assertNull($cacheHit->value); } - public function testManyFiresCacheHitWithSentinelPayloadForCachedNullEntry() + public function testManyFiresCacheHitWithNullPayloadForCachedNullEntry() { - // Real ArrayStore so the sentinel genuinely round-trips through a store. + // Real ArrayStore so the sentinel genuinely round-trips through serialize. $repo = new Repository(new ArrayStore(serializesValues: true)); $repo->rememberNullable('k', 60, fn () => null); - // Install the capturing dispatcher AFTER the write so we only capture the - // many() read path's events (not the write-phase events from rememberNullable). + // Capture only the many() read-path events by attaching the dispatcher + // after the write. $captured = []; $events = m::mock(Dispatcher::class); $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); @@ -1308,14 +1394,11 @@ public function testManyFiresCacheHitWithSentinelPayloadForCachedNullEntry() $result = $repo->many(['k']); $this->assertSame(['k' => null], $result); - - // Two events fire: RetrievingManyKeys + CacheHit (sentinel is present — not a miss). $this->assertCount(2, $captured); $this->assertInstanceOf(RetrievingManyKeys::class, $captured[0]); $this->assertInstanceOf(CacheHit::class, $captured[1]); - $this->assertSame(NullSentinel::VALUE, $captured[1]->value); - - // Must NOT fire CacheMissed — the key IS present (as a cached sentinel). + // Null, not the sentinel value. + $this->assertNull($captured[1]->value); $this->assertEmpty(array_filter($captured, fn ($e) => $e instanceof CacheMissed)); } diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php index 1ee9da43e..20a0f03aa 100644 --- a/tests/Cache/Redis/AllTaggedCacheTest.php +++ b/tests/Cache/Redis/AllTaggedCacheTest.php @@ -4,7 +4,10 @@ namespace Hypervel\Tests\Cache\Redis; +use Hypervel\Cache\Events\CacheHit; +use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\NullSentinel; +use Hypervel\Contracts\Events\Dispatcher; use Mockery as m; use RuntimeException; @@ -484,6 +487,68 @@ public function testRememberNullableReturnsNullOnSentinelHitWithoutInvokingCallb $this->assertFalse($invoked); } + public function testRememberNullableFiresCacheHitWithNullPayloadOnSentinelHit(): void + { + $connection = $this->mockConnection(); + $key = sha1('_all:tag:users:entries') . ':profile'; + + $connection->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize(NullSentinel::VALUE)); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + + $store = $this->createStore($connection); + $tagged = $store->tags(['users']); + $tagged->setEventDispatcher($events); + + $tagged->rememberNullable('profile', 60, fn () => 'should-not-run'); + + $cacheHit = array_values(array_filter($captured, fn ($e) => $e instanceof CacheHit))[0] ?? null; + $this->assertNotNull($cacheHit); + // Null, not the sentinel value. + $this->assertNull($cacheHit->value); + } + + public function testRememberNullableFiresKeyWrittenWithNullPayloadOnCacheMiss(): void + { + $connection = $this->mockConnection(); + $key = sha1('_all:tag:users:entries') . ':profile'; + $expectedScore = now()->timestamp + 60; + + $connection->shouldReceive('get')->once()->with("prefix:{$key}")->andReturnNull(); + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zadd')->once()->with('prefix:_all:tag:users:entries', $expectedScore, $key)->andReturn($connection); + $connection->shouldReceive('setex')->once()->andReturn($connection); + $connection->shouldReceive('exec')->once()->andReturn([1, true]); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + + $store = $this->createStore($connection); + $tagged = $store->tags(['users']); + $tagged->setEventDispatcher($events); + + $tagged->rememberNullable('profile', 60, fn () => null); + + $keyWritten = array_values(array_filter($captured, fn ($e) => $e instanceof KeyWritten))[0] ?? null; + $this->assertNotNull($keyWritten); + // Null, not the sentinel value. + $this->assertNull($keyWritten->value); + } + /** * Proves tags()->remember() (plain, non-nullable) unwraps the sentinel on return * — the sentinel never leaks through the public non-nullable tagged-cache API. diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php index 603a26f9f..420aa3d73 100644 --- a/tests/Cache/Redis/AnyTaggedCacheTest.php +++ b/tests/Cache/Redis/AnyTaggedCacheTest.php @@ -6,11 +6,15 @@ use BadMethodCallException; use Generator; +use Hypervel\Cache\Events\CacheHit; +use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Redis\AnyTaggedCache; use Hypervel\Cache\Redis\AnyTagSet; use Hypervel\Cache\TaggedCache; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Redis\Exceptions\LuaScriptException; +use Mockery as m; use RuntimeException; /** @@ -519,6 +523,62 @@ public function testRememberNullableReturnsNullOnSentinelHitWithoutInvokingCallb $this->assertFalse($invoked); } + public function testRememberNullableFiresCacheHitWithNullPayloadOnSentinelHit(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturn(serialize(NullSentinel::VALUE)); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + + $store = $this->createStore($connection); + $tagged = $store->setTagMode('any')->tags(['users']); + $tagged->setEventDispatcher($events); + + $tagged->rememberNullable('mykey', 60, fn () => 'should-not-run'); + + $cacheHit = array_values(array_filter($captured, fn ($e) => $e instanceof CacheHit))[0] ?? null; + $this->assertNotNull($cacheHit); + // Null, not the sentinel value. + $this->assertNull($cacheHit->value); + } + + public function testRememberNullableFiresKeyWrittenWithNullPayloadOnCacheMiss(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get')->once()->with('prefix:mykey')->andReturnNull(); + $connection->shouldReceive('evalWithShaCache')->once()->andReturn(true); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch') + ->andReturnUsing(function ($event) use (&$captured) { + $captured[] = $event; + }); + + $store = $this->createStore($connection); + $tagged = $store->setTagMode('any')->tags(['users']); + $tagged->setEventDispatcher($events); + + $tagged->rememberNullable('mykey', 60, fn () => null); + + $keyWritten = array_values(array_filter($captured, fn ($e) => $e instanceof KeyWritten))[0] ?? null; + $this->assertNotNull($keyWritten); + // Null, not the sentinel value. + $this->assertNull($keyWritten->value); + } + /** * Proves tags()->remember() (plain, non-nullable) unwraps the sentinel on return * on an any-mode tagged cache — the sentinel never leaks through the public diff --git a/tests/Integration/Cache/FailoverStoreTest.php b/tests/Integration/Cache/FailoverStoreTest.php index 461743995..19c945dff 100644 --- a/tests/Integration/Cache/FailoverStoreTest.php +++ b/tests/Integration/Cache/FailoverStoreTest.php @@ -179,8 +179,8 @@ public function testManyFiresCacheHitNotCacheMissedForSentinelThroughFailoverSta $outerRepo->rememberNullable('k', 60, fn () => null); - // Install the capturing dispatcher AFTER the write so we only observe the - // many() read-path events. + // Capture only the many() read-path events by attaching the dispatcher + // after the write. $captured = []; $events = m::mock(Dispatcher::class); $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); @@ -193,15 +193,11 @@ public function testManyFiresCacheHitNotCacheMissedForSentinelThroughFailoverSta $result = $outerRepo->many(['k']); $this->assertSame(['k' => null], $result); - - // Regression: before Repository::many() → manyRaw() refactor, FailoverStore::many() - // unwrapped the sentinel before the outer Repository saw it — so many() fired - // CacheMissed on a key that was genuinely present. The refactor routes many() - // through manyRaw(), which sees the raw sentinel and correctly fires CacheHit. $this->assertCount(2, $captured); $this->assertInstanceOf(RetrievingManyKeys::class, $captured[0]); $this->assertInstanceOf(CacheHit::class, $captured[1]); - $this->assertSame(NullSentinel::VALUE, $captured[1]->value); + // Null, not the sentinel value. + $this->assertNull($captured[1]->value); $this->assertEmpty(array_filter($captured, fn ($e) => $e instanceof CacheMissed)); }