*/ private array $entries = array(); /** * Clock used to apply TTL eviction at read time and to stamp * `storedAt` / `expiresAt` at write time. */ private Clock $clock; /** * Construct the store. * * @param Clock $clock Clock supplying "now" for read-time * eviction and write-time stamping. */ public function __construct( Clock $clock ) { $this->clock = $clock; } /** * Read the entry stored under `$key`. * * Returns null when the key is unset or when the entry is * expired relative to the clock's current instant. Expired * entries are removed eagerly so a long-lived store does not * accumulate dead entries between explicit flush calls. * * @param string $key Cache key. */ public function getRaw( string $key ): ?CachedEntry { if ( ! array_key_exists( $key, $this->entries ) ) { return null; } $entry = $this->entries[ $key ]; if ( $entry->isExpired( $this->clock->now() ) ) { unset( $this->entries[ $key ] ); return null; } return $entry; } /** * Write the value under `$key` with the given TTL in seconds. * * The entry's `storedAt` is stamped from the clock and its * `expiresAt` is `storedAt + ttlSeconds`. A non-positive TTL * is rejected as a programming error per each cache port's * `put()` contract. * * @param string $key Cache key. * @param mixed $value Cached value (opaque to the store). * @param int $ttlSeconds TTL in seconds; must be > 0. * * @throws InvalidArgumentException When `$ttlSeconds` is non-positive. */ public function putRaw( string $key, $value, int $ttlSeconds ): void { if ( $ttlSeconds <= 0 ) { throw new InvalidArgumentException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. 'InMemoryCacheStore: ttlSeconds must be greater than zero; received ' . $ttlSeconds . '.' ); } $storedAt = $this->clock->now(); $expiresAt = $storedAt->modify( '+' . $ttlSeconds . ' seconds' ); // `\DateTimeImmutable::modify()` returns false on failure; // a positive integer offset cannot fail in practice but we // guard explicitly so the static analyzer can prove the // `\DateTimeImmutable` type that CachedEntry requires. if ( false === $expiresAt ) { throw new InvalidArgumentException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. 'InMemoryCacheStore: failed to compute expiresAt from ttlSeconds=' . $ttlSeconds . '.' ); } $this->entries[ $key ] = new CachedEntry( $value, $storedAt, $expiresAt ); } /** * Remove the entry stored under `$key`. Idempotent. * * @param string $key Cache key. */ public function forget( string $key ): void { unset( $this->entries[ $key ] ); } /** * Drop every stored entry. */ public function flush(): void { $this->entries = array(); } /** * Drop every entry whose key begins with `$prefix` and return * the count of removed entries. * * Rejects an empty prefix as a defensive guard against * accidental layer-wide flushes; callers that genuinely want * a layer-wide flush call {@see self::flush()} explicitly. * * @param string $prefix Cache-key prefix. * * @throws InvalidArgumentException When `$prefix` is empty. */ public function forgetByPrefix( string $prefix ): int { if ( '' === $prefix ) { throw new InvalidArgumentException( // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- Developer-facing message. 'InMemoryCacheStore: forgetByPrefix() requires a non-empty prefix; call flush() to drop every entry.' ); } $removed = 0; foreach ( array_keys( $this->entries ) as $key ) { if ( str_starts_with( $key, $prefix ) ) { unset( $this->entries[ $key ] ); ++$removed; } } return $removed; } /** * Count of currently stored entries. * * Exposed as a testing helper; production code MUST NOT rely * on this surface for cache-policy decisions. */ public function count(): int { return count( $this->entries ); } }