_%` and * `_transient_timeout_affilun__%` rows from * `wp_options` so the layer's persisted transients and their * timeout siblings are removed atomically. When `$wpdb` is * null (pure-unit test path), {@see self::flush()} remains a * no-op for backward compatibility — callers that need flush * semantics in tests inject the {@see InMemoryCacheStore} * family instead. * * - {@see self::forgetByPrefix()} performs a $wpdb-backed scan * over the layer's transient names when a `$wpdb` collaborator * is supplied at construction time (Phase 2 KI-001 follow-up). * The persisted transient name itself is a sha256 hash of the * original cache key and therefore opaque to a prefix match, * but the envelope written by {@see self::putRaw()} preserves * the original (un-hashed) key under the {@see self::ENVELOPE_KEY} * slot, so the scan can read each envelope back, inspect * `envelope['key']`, and `delete_transient()` only the rows * whose key starts with `$prefix`. When `$wpdb` is null * (pure-unit test path), {@see self::forgetByPrefix()} remains * a no-op returning 0 for backward compatibility — callers that * need prefix-sweep semantics in tests inject the * {@see InMemoryCacheStore} family instead. * * Layer namespace: * * The store accepts an optional `$namespace` string at * construction time so each cache layer (search, lookup, * render, provider-health) gets its own physical option-name * prefix (`affilun_search_`, `affilun_lookup_`, `affilun_render_`, * `affilun_phealth_`). Per-layer namespacing keeps * {@see self::flush()} narrow — a render-layer flush MUST NOT * sweep search-layer transients — and keeps the diagnostics * surface readable when an operator inspects `wp_options` * directly. * * @package Affilun\Infrastructure\Cache */ declare(strict_types=1); // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL values are prepared and identifiers come from WordPress-owned table names. namespace Affilun\Infrastructure\Cache; use Affilun\Application\Cache\CachedEntry; use Affilun\Domain\Clock; use DateTimeImmutable; use InvalidArgumentException; use LogicException; /** * Transient-backed generic cache store with TTL-driven eviction. * * See the file-level docblock for the transient-name hashing * policy and the documented Phase-9-MVP no-op semantics for * {@see self::flush()} / {@see self::forgetByPrefix()}. */ final class TransientsCacheStore { /** * Default name-prefix used when no `$namespace` is supplied. * * Kept for backward compatibility with the Phase 9 wiring that * constructed every layer's store without a namespace; new * call sites SHOULD supply an explicit namespace so * {@see self::flush()} can scope its DB scan. */ public const DEFAULT_NAME_PREFIX = 'affilun_'; /** * Envelope key carrying the original (un-hashed) cache key so * diagnostics can recover the human-readable form. */ private const ENVELOPE_KEY = 'key'; /** * Envelope key carrying the cached payload. */ private const ENVELOPE_VALUE = 'value'; /** * Envelope key carrying the write instant (ISO-8601, UTC). */ private const ENVELOPE_STORED_AT = 'storedAt'; /** * Envelope key carrying the expiry instant (ISO-8601, UTC). */ private const ENVELOPE_EXPIRES_AT = 'expiresAt'; /** * Clock used to stamp `storedAt` / `expiresAt` and to apply * read-time TTL eviction defensively (Transients are * auto-expiring on the WordPress side, but the clock-based * check keeps semantics aligned with the in-memory store). */ private Clock $clock; /** * Callable matching `get_transient( string $name ): mixed`. * * @var callable(string):mixed */ private $getTransientFn; /** * Callable matching `set_transient( string, mixed, int ): bool`. * * @var callable(string,mixed,int):bool */ private $setTransientFn; /** * Callable matching `delete_transient( string ): bool`. * * @var callable(string):bool */ private $deleteTransientFn; /** * Layer namespace component used in the transient name * prefix. Empty string means "no namespace" (legacy MVP * wiring); a non-empty namespace becomes part of the * persisted option name so {@see self::flush()} can scope its * DB scan to a single layer. */ private string $layerNamespace; /** * Cached transient-name prefix derived from {@see self::$namespace}. * * Computed once at construction time so {@see self::transientName()} * does not have to re-derive it per call. */ private string $namePrefix; /** * Optional `$wpdb` collaborator used by {@see self::flush()}. * * Typed as `object` rather than the concrete `\wpdb` class so * this file stays trivially loadable in unit tests that run * outside a WordPress environment. When null, {@see self::flush()} * is a no-op for backward compatibility with the Phase 9 wiring. */ private ?object $wpdb; /** * Construct the store. * * The three callable parameters default to thin wrappers * around the global `get_transient()`, `set_transient()`, and * `delete_transient()` functions when WordPress is loaded. * Tests inject stub callables that record arguments and * return canned responses, exercising this class without a * live WordPress. * * @param Clock $clock Clock supplying "now". * @param callable(string):mixed|null $getTransientFn Optional override for `get_transient`. * @param callable(string,mixed,int):bool|null $setTransientFn Optional override for `set_transient`. * @param callable(string):bool|null $deleteTransientFn Optional override for `delete_transient`. * @param string $layerNamespace Layer namespace component * inserted into the transient * name prefix; empty string * preserves the legacy * `affilun_` prefix. * @param object|null $wpdb Optional `$wpdb` collaborator * used by {@see self::flush()} * to issue the targeted DB scan. * When null, {@see self::flush()} * remains a no-op. * * @throws InvalidArgumentException When `$layerNamespace` contains * characters outside the * `[a-z0-9_]` grammar that * the option-name prefix * expects. */ public function __construct( Clock $clock, ?callable $getTransientFn = null, ?callable $setTransientFn = null, ?callable $deleteTransientFn = null, string $layerNamespace = '', ?object $wpdb = null ) { if ( $layerNamespace !== '' && preg_match( '/^[a-z0-9_]+$/', $layerNamespace ) !== 1 ) { $message = sprintf( 'TransientsCacheStore: layerNamespace must match ^[a-z0-9_]+$; got "%s".', $layerNamespace ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } $this->clock = $clock; $this->layerNamespace = $layerNamespace; $this->namePrefix = $layerNamespace === '' ? self::DEFAULT_NAME_PREFIX : self::DEFAULT_NAME_PREFIX . $layerNamespace . '_'; $this->wpdb = $wpdb; $this->getTransientFn = $getTransientFn ?? self::defaultGetTransient(); $this->setTransientFn = $setTransientFn ?? self::defaultSetTransient(); $this->deleteTransientFn = $deleteTransientFn ?? self::defaultDeleteTransient(); } /** * Layer namespace component supplied at construction time, or * the empty string when no namespace was supplied. */ public function layerNamespace(): string { return $this->layerNamespace; } /** * Transient-name prefix this store writes under * (e.g. `affilun_render_` for the render layer). * * Exposed so diagnostics surfaces and tests can probe the * exact prefix without re-deriving it. */ public function namePrefix(): string { return $this->namePrefix; } /** * Read the entry stored under `$key`. * * Returns null on miss (the WordPress `get_transient()` * convention is to return `false` on miss), on a malformed * stored envelope, or once the entry's clock-based TTL has * elapsed. * * @param string $key Cache key. */ public function getRaw( string $key ): ?CachedEntry { $name = $this->localTransientName( $key ); /** @var mixed $raw */ $raw = ( $this->getTransientFn )( $name ); if ( false === $raw || ! is_array( $raw ) ) { return null; } if ( ! array_key_exists( self::ENVELOPE_VALUE, $raw ) || ! array_key_exists( self::ENVELOPE_STORED_AT, $raw ) || ! array_key_exists( self::ENVELOPE_EXPIRES_AT, $raw ) ) { return null; } $storedAtRaw = $raw[ self::ENVELOPE_STORED_AT ]; $expiresAtRaw = $raw[ self::ENVELOPE_EXPIRES_AT ]; if ( ! is_string( $storedAtRaw ) || ! is_string( $expiresAtRaw ) ) { return null; } $storedAt = self::parseInstant( $storedAtRaw ); $expiresAt = self::parseInstant( $expiresAtRaw ); if ( null === $storedAt || null === $expiresAt ) { return null; } $entry = new CachedEntry( $raw[ self::ENVELOPE_VALUE ], $storedAt, $expiresAt ); if ( $entry->isExpired( $this->clock->now() ) ) { ( $this->deleteTransientFn )( $name ); return null; } return $entry; } /** * Write the value under `$key` with the given TTL in seconds. * * The stored envelope carries the original key plus the * `storedAt` / `expiresAt` timestamps so a subsequent * {@see self::getRaw()} can reconstruct a {@see CachedEntry} * without re-deriving the temporal boundary. * * @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. 'TransientsCacheStore: ttlSeconds must be greater than zero; received ' . $ttlSeconds . '.' ); } $storedAt = $this->clock->now(); $expiresAt = $storedAt->modify( '+' . $ttlSeconds . ' seconds' ); if ( false === $expiresAt ) { throw new InvalidArgumentException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. 'TransientsCacheStore: failed to compute expiresAt from ttlSeconds=' . $ttlSeconds . '.' ); } $envelope = array( self::ENVELOPE_KEY => $key, self::ENVELOPE_VALUE => $value, self::ENVELOPE_STORED_AT => $storedAt->format( DateTimeImmutable::ATOM ), self::ENVELOPE_EXPIRES_AT => $expiresAt->format( DateTimeImmutable::ATOM ), ); ( $this->setTransientFn )( $this->localTransientName( $key ), $envelope, $ttlSeconds ); } /** * Remove the entry stored under `$key`. Idempotent. * * @param string $key Cache key. */ public function forget( string $key ): void { ( $this->deleteTransientFn )( $this->localTransientName( $key ) ); } /** * Remove every transient this store wrote, scoped to the * layer's namespace. * * Behavior: * * - When the store was constructed without a `$wpdb` * collaborator (typical pure-unit test path), this method * is a no-op for backward compatibility with the Phase 9 * MVP wiring. * * - When `$wpdb` is available, the method issues a single * `DELETE FROM {$wpdb->options} WHERE option_name LIKE * '_transient_%' OR option_name LIKE * '_transient_timeout_%'` query so both the * persisted transient and its timeout sibling are removed * atomically. The `` is the * {@see self::namePrefix()} value, ensuring the sweep * never strays outside the layer's namespace. * * - The method delegates the LIKE-escape to * `$wpdb->esc_like()` so a future namespace value that * contains a `_` character cannot accidentally widen the * match scope. */ public function flush(): void { if ( $this->wpdb === null ) { // No DB scan available — preserve legacy no-op behavior. return; } $wpdb = $this->wpdb; if ( ! property_exists( $wpdb, 'options' ) ) { return; } $optionsTable = (string) $wpdb->options; if ( $optionsTable === '' ) { return; } if ( ! method_exists( $wpdb, 'esc_like' ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'query' ) ) { return; } $prefix = $this->namePrefix; $escapedLike = $wpdb->esc_like( $prefix ) . '%'; $transientLike = '_transient_' . $escapedLike; $transientTimeoutLike = '_transient_timeout_' . $escapedLike; $deleteSql = 'DELETE FROM ' . $optionsTable . ' WHERE option_name LIKE %s OR option_name LIKE %s'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name resolved from $wpdb->options; values bound via prepare(). $sql = $wpdb->prepare( $deleteSql, $transientLike, $transientTimeoutLike ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- KI-001 targeted transient sweep; SQL prepared above. $wpdb->query( $sql ); } /** * Drop every entry whose original (un-hashed) cache key begins * with `$prefix`, returning the count of removed entries. * * Behavior: * * - When the store was constructed without a `$wpdb` * collaborator (typical pure-unit test path), this method * is a no-op returning 0 for backward compatibility with * the Phase 9 MVP wiring. * * - When `$wpdb` is available, the method issues a single * `SELECT option_name, option_value FROM {$wpdb->options} * WHERE option_name LIKE '_transient_%'` query to * enumerate every transient row this layer wrote, decodes * each envelope, and `delete_transient()`s the rows whose * `envelope['key']` starts with `$prefix`. The persisted * transient name itself is a sha256 hash of the original * key and therefore opaque to the LIKE clause; the * envelope's preserved `key` slot is what the prefix * compare consults. * * - Empty `$prefix` is rejected as a defensive guard so an * accidental layer-wide flush does not slip through this * surface; callers that want a layer-wide flush should * invoke {@see self::flush()} explicitly. * * - The returned count reflects the number of envelopes * whose key matched the prefix, regardless of whether the * subsequent `delete_transient()` reported success: a * transient that was already auto-expired by WordPress * between the SELECT and the DELETE counts as removed * because it is no longer accessible from this layer. * * @param string $prefix Cache-key prefix; rejected when empty. * * @throws InvalidArgumentException When `$prefix` is empty. */ public function forgetByPrefix( string $prefix ): int { if ( $prefix === '' ) { throw new InvalidArgumentException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. 'TransientsCacheStore: forgetByPrefix() rejects empty prefix; use flush() for a layer-wide sweep.' ); } if ( $this->wpdb === null ) { // No DB scan available — preserve legacy no-op behavior. return 0; } $wpdb = $this->wpdb; if ( ! property_exists( $wpdb, 'options' ) ) { return 0; } $optionsTable = (string) $wpdb->options; if ( $optionsTable === '' ) { return 0; } if ( ! method_exists( $wpdb, 'esc_like' ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_results' ) ) { return 0; } $escapedNamespace = $wpdb->esc_like( $this->namePrefix ) . '%'; $transientLike = '_transient_' . $escapedNamespace; $selectSql = 'SELECT option_name, option_value FROM ' . $optionsTable . ' WHERE option_name LIKE %s'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- KI-001 prefix-sweep enumeration; rows consumed immediately and never cached. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name resolved from $wpdb->options; value bound immediately here. $wpdb->prepare( $selectSql, $transientLike ), 'ARRAY_A' ); if ( ! is_array( $rows ) || $rows === array() ) { return 0; } $prefixLength = strlen( $this->namePrefix ); $removed = 0; foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } if ( ! array_key_exists( 'option_name', $row ) || ! array_key_exists( 'option_value', $row ) ) { continue; } $optionName = (string) $row['option_name']; if ( strpos( $optionName, '_transient_' ) !== 0 ) { continue; } $rawValue = $row['option_value']; if ( ! is_string( $rawValue ) ) { continue; } // WordPress stores transient values serialized via // maybe_serialize() at the options-table boundary; the // SELECT returns the raw serialized payload, which we // must unserialize back into the envelope array. Object // instantiation is explicitly disabled. The AFFILUN envelope is // always serialized as an array, so non-array serialized // shapes and malformed values can be skipped before decoding. if ( ! str_starts_with( $rawValue, 'a:' ) ) { continue; } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Decoding WordPress-owned transient payload; object injection is blocked via allowed_classes=false. $envelope = unserialize( $rawValue, array( 'allowed_classes' => false ) ); if ( ! is_array( $envelope ) ) { continue; } if ( ! array_key_exists( self::ENVELOPE_KEY, $envelope ) ) { continue; } $originalKey = $envelope[ self::ENVELOPE_KEY ]; if ( ! is_string( $originalKey ) ) { continue; } if ( strpos( $originalKey, $prefix ) !== 0 ) { continue; } // Strip the leading `_transient_` segment so the value // matches the canonical transient name expected by // delete_transient(). $transientName = substr( $optionName, strlen( '_transient_' ) ); if ( $transientName === '' ) { continue; } ( $this->deleteTransientFn )( $transientName ); ++$removed; } return $removed; } /** * Compute the deterministic transient name for `$key` under * the supplied namespace. * * Returns `affilun__` when `$namespace` is * non-empty, or `affilun_` (the legacy Phase 9 shape) * when `$namespace` is the empty string. The total length * stays well below the 172-char transient-name limit. The * hash is stable across requests so a write from one request * can be read from another. * * Kept static for backward compatibility with the Phase 9 * call sites and tests that expect a one-argument signature; * the default empty `$namespace` preserves their behavior. * * @param string $key Cache key. * @param string $layerNamespace Optional layer namespace. */ public static function transientName( string $key, string $layerNamespace = '' ): string { $prefix = $layerNamespace === '' ? self::DEFAULT_NAME_PREFIX : self::DEFAULT_NAME_PREFIX . $layerNamespace . '_'; return $prefix . substr( hash( 'sha256', $key ), 0, 40 ); } /** * Instance-scoped helper that derives the transient name for * `$key` using this store's pre-computed * {@see self::$namePrefix}. * * Equivalent to {@see self::transientName()} with the store's * namespace, but avoids re-deriving the prefix per call. * * @param string $key Cache key. */ private function localTransientName( string $key ): string { return $this->namePrefix . substr( hash( 'sha256', $key ), 0, 40 ); } /** * Parse an ISO-8601 timestamp produced by * {@see DateTimeImmutable::ATOM}, returning null on failure. * * @param string $stamp ISO-8601 timestamp string. */ private static function parseInstant( string $stamp ): ?DateTimeImmutable { $parsed = DateTimeImmutable::createFromFormat( DateTimeImmutable::ATOM, $stamp ); return $parsed instanceof DateTimeImmutable ? $parsed : null; } /** * Default `get_transient` wrapper. * * @return callable(string):mixed */ private static function defaultGetTransient(): callable { return static function ( string $name ) { if ( ! function_exists( 'get_transient' ) ) { throw new LogicException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. 'TransientsCacheStore: get_transient() is not available; inject a callable override.' ); } /** @var mixed $value */ $value = get_transient( $name ); return $value; }; } /** * Default `set_transient` wrapper. * * @return callable(string,mixed,int):bool */ private static function defaultSetTransient(): callable { return static function ( string $name, $value, int $ttl ): bool { if ( ! function_exists( 'set_transient' ) ) { throw new LogicException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. 'TransientsCacheStore: set_transient() is not available; inject a callable override.' ); } return (bool) set_transient( $name, $value, $ttl ); }; } /** * Default `delete_transient` wrapper. * * @return callable(string):bool */ private static function defaultDeleteTransient(): callable { return static function ( string $name ): bool { if ( ! function_exists( 'delete_transient' ) ) { throw new LogicException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. 'TransientsCacheStore: delete_transient() is not available; inject a callable override.' ); } return (bool) delete_transient( $name ); }; } }