flush()`) so the * surrounding cache wiring stays operational on bootstrap paths * that have not yet bound an {@see EmbedItemRepository}. * * @var EmbedItemRepository|null */ private ?EmbedItemRepository $embedItems; /** * Wire the four §4 cache layers, the §6.3 key factory, and the * optional Phase 2 KI-002 embed-item reverse-index repository. * * @param SearchCache $searchCache §4 search layer. * @param LookupCache $lookupCache §4 lookup layer. * @param RenderCache $renderCache §4 render layer. * @param ProviderHealthCache $providerHealthCache §4 provider-health layer. * @param CacheKeyFactory $keys §6.3 key factory. * @param EmbedItemRepository|null $embedItems KI-002 reverse-index * repository; when * null, the * product-sync * invalidation falls * back on the * Phase 9 broad * render-cache flush. */ public function __construct( SearchCache $searchCache, LookupCache $lookupCache, RenderCache $renderCache, ProviderHealthCache $providerHealthCache, CacheKeyFactory $keys, ?EmbedItemRepository $embedItems = null ) { $this->searchCache = $searchCache; $this->lookupCache = $lookupCache; $this->renderCache = $renderCache; $this->providerHealthCache = $providerHealthCache; $this->keys = $keys; $this->embedItems = $embedItems; } /** * §14.2 商品更新時 — invalidate caches affected by a product * snapshot change. * * Targeted: * - lookup cache entry for the (provider, providerProductId) pair * produced by {@see CacheKeyFactory::lookupKey()}. * - render cache (embed surface): when a Phase 2 KI-002 * {@see EmbedItemRepository} is wired, every embed id * referencing `$productId` is enumerated through * {@see EmbedItemRepository::findEmbedIdsByProductId()} and * the matching `affilun:render:single:embed:N:` / * `affilun:render:grid:embed:N:` prefixes are forgotten one by * one. * - render cache (adhoc surface, KI-002 完全解消): the * post-body Gutenberg block path materializes render keys * under the `affilun:render:single:adhoc:` / * `affilun:render:grid:adhoc:` prefix family keyed by * `productsHash`. Because `productsHash` is a sha256 of * the canonical product-id list, individual products * cannot be reversed out of the key without an additional * post→productId reverse index. The KI-002 完全解消 path * therefore evicts the entire adhoc family on a product * sync — still a surgical improvement over the Phase 9 * MVP `renderCache->flush()` overshoot because search / * lookup / provider-health caches remain untouched, and * embed-keyed entries for unrelated products survive. * * Broad (legacy fallback — when no {@see EmbedItemRepository} * is injected): * - render cache: flushed in full, matching the Phase 9 MVP * overshoot. Bootstrap paths that omit the embed-item * binding (typically pure-unit tests that exercise the * invalidator without standing up the persistence stack) * observe the legacy behavior so existing tests continue * to pass. * * Untouched: * - search cache (§14.3 / §14.5 do not require it on a * per-product change; provider-search results are scoped * by query, not by individual product). * - provider health cache (§7.10 status is independent of * individual product snapshots). * * @param string $provider Provider key (e.g. `amazon`). * @param string $providerProductId Provider-side product id * (e.g. ASIN, item code). * @param int $productId Local `affilun_products.id`. Held * for forward compatibility with * the Phase 10 / 11 reverse-index * lookup; not used directly here. * * @throws InvalidArgumentException When `$provider` / * `$providerProductId` are * rejected by * {@see CacheKeyFactory::lookupKey()} * or `$productId < 1`. */ public function invalidateProductSync( string $provider, string $providerProductId, int $productId ): void { if ( $productId < 1 ) { $message = sprintf( 'CacheInvalidator: productId must be >= 1; got %d.', $productId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->lookupCache->forget( $this->keys->lookupKey( $provider, $providerProductId ) ); if ( $this->embedItems === null ) { // Legacy Phase 9 fallback: no reverse-index repository // wired, so flush the whole render cache rather than // leak stale data. $this->renderCache->flush(); return; } // KI-002 embed-side targeted sweep: enumerate every embed // that references this product through the §7.3 indexed // reverse lookup, then forget the per-embed render keys // for both single and grid display modes. Returns an // empty list when no embed references the product — that // is the normal case for products only embedded directly // in post bodies via the Gutenberg block. $embedIds = $this->embedItems->findEmbedIdsByProductId( $productId ); foreach ( $embedIds as $embedId ) { $this->renderCache->forgetByPrefix( 'affilun:render:single:embed:' . $embedId . ':' ); $this->renderCache->forgetByPrefix( 'affilun:render:grid:embed:' . $embedId . ':' ); } // KI-002 完全解消 — adhoc-side sweep: the post-body // Gutenberg block path stores render entries under the // `affilun:render:*:adhoc:*` family keyed by the productsHash // (a sha256 of the canonical id list), so a single // product cannot be reversed out of the key. Drop the // entire adhoc family per kind so a snapshot change // cannot be served from a stale post-body render. Search // / lookup / provider-health caches remain untouched and // embed-keyed entries for unrelated products survive, so // this is still a surgical improvement over the Phase 9 // renderCache->flush() overshoot. $this->renderCache->forgetByPrefix( 'affilun:render:single:adhoc:' ); $this->renderCache->forgetByPrefix( 'affilun:render:grid:adhoc:' ); } /** * §14.3 埋め込み設定更新時 — invalidate render entries for a * specific embed. * * Targeted: drops every render key under the `single` and * `grid` prefixes for the given `$embedId` produced by * {@see CacheKeyFactory::renderEmbedKey()}. All other embeds, * the search cache, the lookup cache, and the provider health * cache are left intact (per §14.3 「無効化不要: 商品 snapshot / * provider search cache」). * * @param int $embedId Positive embed id. * * @throws InvalidArgumentException When `$embedId < 1`. */ public function invalidateEmbedConfig( int $embedId ): void { if ( $embedId < 1 ) { $message = sprintf( 'CacheInvalidator: embedId must be >= 1; got %d.', $embedId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->renderCache->forgetByPrefix( 'affilun:render:single:embed:' . $embedId . ':' ); $this->renderCache->forgetByPrefix( 'affilun:render:grid:embed:' . $embedId . ':' ); } /** * §14.4 テンプレート変更時 — invalidate render entries that * resolve to a given template. * * Broad (MVP overshoot — see file-level docblock): the * template segment is positioned in the middle of the render * key (after the embed/adhoc-id segment), so a prefix sweep * on the template value alone is not safe. Until a * template → render-key reverse index is introduced in * Phase 10 / 11, this method falls back on * {@see RenderCache::flush()}. The §14.4 「場合によっては * 全 render cache を version bump で無効化」 escape hatch * is the spec-sanctioned justification. * * Untouched: search cache, lookup cache, provider health * cache. A template change does not affect provider data. * * No template-resolver cache port exists in Phase 9; once it * lands (rendering spec §22 follow-up) this method will also * forget the resolver entry for `$templateKey`. * * @param string $templateKey Non-empty trimmed template key. * * @throws InvalidArgumentException When `$templateKey` is * empty after trimming. */ public function invalidateTemplate( string $templateKey ): void { if ( trim( $templateKey ) === '' ) { $message = 'CacheInvalidator: templateKey must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } // MVP overshoot: see file-level docblock and §14.4 escape // hatch. Phase 10 / 11 will replace this with a targeted // sweep keyed by a template→render-key reverse index, and // will additionally forget the future template-resolver // cache entry for `$templateKey`. $this->renderCache->flush(); } /** * §14.5 provider 設定変更時 — invalidate caches affected by a * provider configuration change (affiliate id, link policy, * credentials, etc.). * * Targeted: * - provider health cache entry for `$provider` produced by * {@see CacheKeyFactory::providerHealthKey()}. * * Broad (MVP overshoot — see file-level docblock): * - search cache: flushed in full because {@see SearchCache} * does not expose `forgetByPrefix()` and the Phase 9 slice * deliberately does not expand the port contract. The * practical impact is bounded: the search layer's TTL is * short (§4 / §6.3), and provider-config changes are rare. * - lookup cache: same rationale as search cache. * - render cache: flushed in full because affiliate-id and * link-policy changes affect every rendered URL produced * under the given provider, and we lack a provider → * render-key reverse index in Phase 9. * * §14.5 also lists 「必要に応じて product snapshot の再同期候補化」 * — that is a separate concern handled by `SyncPolicy` / * `StaleProductFinder`, not by this invalidator. * * @param string $provider Provider key (e.g. `amazon`). * * @throws InvalidArgumentException When `$provider` is rejected * by * {@see CacheKeyFactory::providerHealthKey()}. */ public function invalidateProviderConfig( string $provider ): void { $this->providerHealthCache->forget( $this->keys->providerHealthKey( $provider ) ); // MVP overshoot: see file-level docblock. Phase 10 / 11 // will replace these flushes with provider-scoped sweeps // once SearchCache / LookupCache gain a `forgetByPrefix()` // hook and a provider→render-key reverse index lands. $this->searchCache->flush(); $this->lookupCache->flush(); $this->renderCache->flush(); } /** * §14.6 テーマ切替時 — invalidate render entries because * theme overrides change template resolution. * * Targeted: render cache (§14.6 「無効化対象: template * resolver cache, render cache」). * * Search / lookup / provider health caches are untouched — * theme changes do not affect upstream provider data. * * No template-resolver cache port exists in Phase 9; once it * lands (rendering spec §22 follow-up) this method will also * flush the resolver layer. */ public function invalidateThemeChange(): void { $this->renderCache->flush(); } /** * §14.7 プラグインアップデート時 — invalidate every cache * layer to guard against shape drift across an upgrade. * * §14.7 lists two equivalent strategies: (a) flush each * layer, or (b) bump the cache version segment so old keys * become unreachable. Phase 9 implements (a) because the * `VERSION` constant on {@see CacheKeyFactory} is currently * set at compile time and a dynamic bump would require a * persisted version counter that is not in scope for this * slice. The (b) strategy ("cache version bump") is tracked * as a Phase 10 / 11 follow-up. * * Flushed: render, search, lookup, provider health. */ public function invalidatePluginUpgrade(): void { $this->renderCache->flush(); $this->searchCache->flush(); $this->lookupCache->flush(); $this->providerHealthCache->flush(); } }