Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
inc/managers/CacheManager.php
@@ -2,33 +2,230 @@
namespace JVBase\managers;
if (!defined('ABSPATH')) {
   exit; // Exit if accessed directly
   exit;
}
/**
 * Manages HTTP cache timestamps and relationship-based invalidation
 *
 * Data caching: Use wrapper methods or wp_cache_get/set directly
 * HTTP caching: This class manages timestamps for ETag/Last-Modified headers
 */
class CacheManager
{
   private string $prefix = 'jvb_';
   private string $prefix = BASE;
   private string $group;
   private int $cache_ttl;
   private static ?bool $use_object_cache = null;
   private static array $instances = []; // Cache instances per type
   private static array $http_timestamps = []; // Request-level memory cache
   private static array $relationships = []; // Type => [related types]
   private static bool $relationships_loaded = false;
   /**
    * @param string|null $group The group name for this cache instance
    * @param int|null $ttl The default ttl for this instance
    * Private constructor - use for() factory method instead
    */
   public function __construct(?string $group = null, ?int $ttl = null)
   private function __construct(string $group, ?int $ttl = null)
   {
      $this->group = $group ?: 'jvb_default';
      $this->group = jvbNoBase($group);
      $this->cache_ttl = $ttl ?: 3600;
      // Check if Redis/Memcached is available
      if (is_null(static::$use_object_cache)) {
         static::$use_object_cache = !is_null(wp_using_ext_object_cache());
//       error_log((static::$use_object_cache) ? 'Using Object Cache' : 'Not using Object Cache');
         static::$use_object_cache = wp_using_ext_object_cache();
      }
   }
   /**
    * Get or create a cache manager instance for a content type
    *
    * @param string $type Content type (tattoo, style, etc.)
    * @param int|null $ttl Optional TTL override
    * @return self Fluent interface
    */
   public static function for(string $type, ?int $ttl = null): self
   {
      $type = jvbNoBase($type);
      $key = $type . ($ttl ? "_{$ttl}" : '');
      if (!isset(self::$instances[$key])) {
         self::$instances[$key] = new self($type, $ttl);
      }
      return self::$instances[$key];
   }
   /**
    * Get cache manager for a specific user
    * Each user gets their own cache group for complete isolation
    *
    * @param int $user_id User ID
    * @param int|null $ttl Optional TTL
    * @return self
    */
   public static function forUser(int $user_id, ?int $ttl = null): self
   {
      return self::for("user_{$user_id}", $ttl);
   }
   /**
    * Get HTTP cache timestamp for content type(s)
    * Used for ETag and Last-Modified header generation
    *
    * @param string|array $types Single type or array of types
    * @return int Latest timestamp (Unix time)
    */
   public static function getTimestamp(string|array $types): int
   {
      // Multiple types - return latest
      if (is_array($types)) {
         $latest = 0;
         foreach ($types as $type) {
            $timestamp = self::getTimestamp($type);
            if ($timestamp > $latest) {
               $latest = $timestamp;
            }
         }
         return $latest ?: time();
      }
      $type = jvbNoBase($types);
      // Check request-level cache
      if (isset(self::$http_timestamps[$type])) {
         return self::$http_timestamps[$type];
      }
      // Load from cache (Redis or transient - wp_cache handles it)
      $timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time();
      // Cache in memory for this request
      self::$http_timestamps[$type] = $timestamp;
      return $timestamp;
   }
   /**
    * Update HTTP cache timestamp (marks content as modified)
    *
    * @param string $type Content type
    * @return int The new timestamp
    */
   public static function updateTimestamp(string $type): int
   {
      $type = jvbNoBase($type);
      $timestamp = time();
      // Store (Redis or transient - wp_cache handles it)
      wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS);
      // Update request cache
      self::$http_timestamps[$type] = $timestamp;
      do_action('jvb_http_timestamp_updated', $type, $timestamp);
      return $timestamp;
   }
   /**
    * Invalidate cache for a content type with automatic cascade
    *
    * @param string $type Content type to invalidate
    * @param mixed $context Post/Term object or array with relationship data (for cascade)
    * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
    * @return void
    */
   public static function invalidateAll(string $type, $context = null, $specific_keys = null): void
   {
      $type = jvbNoBase($type);
      // Update HTTP timestamp
      self::updateTimestamp($type);
      // If specific keys provided, only delete those (don't flush whole group)
      if ($specific_keys !== null) {
         $instance = self::for($type);
         if (is_array($specific_keys)) {
            foreach ($specific_keys as $key) {
               $instance->delete($key);
            }
         } else {
            $instance->delete($specific_keys);
         }
      } else {
         // No specific keys - flush the entire group
         if (function_exists('wp_cache_flush_group')) {
            wp_cache_flush_group($type);
         } else {
            // Fallback for older WP
            wp_cache_flush();
         }
      }
      // Cascade to related types if context provided
      if ($context !== null) {
         self::cascadeInvalidation($type, $context);
      }
      do_action('jvb_cache_invalidated', $type, $context);
   }
   /**
    * Invalidate only specific keys for a type (doesn't flush group or update timestamp)
    * Use this when you want surgical cache invalidation
    *
    * @param string $type Content type
    * @param string|array $keys Key(s) to delete
    * @return void
    */
   public static function invalidateKeys(string $type, string|array $keys): void
   {
      $instance = self::for($type);
      if (is_array($keys)) {
         foreach ($keys as $key) {
            $instance->delete($key);
         }
      } else {
         $instance->delete($keys);
      }
   }
   /**
    * Fluent instance method to invalidate this cache type
    * Allows chaining: CacheManager::for('tattoo')->invalidate()->clear()
    *
    * @param mixed $context Optional context for cascade
    * @param string|array|null $specific_keys Optional specific key(s)
    * @return self For chaining
    */
   public function invalidate($context = null, $specific_keys = null): self
   {
      self::invalidateAll($this->group, $context, $specific_keys);
      return $this;
   }
   /**
    * Get the HTTP timestamp for this instance's type
    *
    * @return int
    */
   public function timestamp(): int
   {
      return self::getTimestamp($this->group);
   }
   /**
    * Update the HTTP timestamp for this instance's type
    *
    * @return self For chaining
    */
   public function touch(): self
   {
      self::updateTimestamp($this->group);
      return $this;
   }
   /**
    * Get a value from the cache
    * @param string|array $key The key to look up (auto-generates key from array of key=>values)
    * @param string|null $group The group to get from. Defaults to current group
@@ -37,39 +234,10 @@
   public function get(string|array $key, ?string $group = null): mixed
   {
      $group = $group ?: $this->group;
      $key = $this->normalizeKey($key);
      $cache_key = $this->buildKey($key);
      // Use appropriate cache method
      if (static::$use_object_cache) {
         $value = wp_cache_get($cache_key, $group);
      } else {
         // Fallback to transients for local development
         $value = get_transient($this->getTransientKey($cache_key, $group));
      }
      return (is_array($value) && array_key_exists('data', $value)) ? $value['data'] : $value;
   }
   public function getTimestamp(string|array $key, ?string $group = null): mixed
   {
      $group = $group ?: $this->group;
      $key = $this->normalizeKey($key);
      $cache_key = $this->buildKey($key);
      // Use appropriate cache method
      if (static::$use_object_cache) {
         $value = wp_cache_get($cache_key, $group);
      } else {
         // Fallback to transients for local development
         $value = get_transient($this->getTransientKey($cache_key, $group));
      }
      return (is_array($value) && array_key_exists('last_modified', $value)) ? $value['last_modified'] : false;
      return wp_cache_get($cache_key, $group);
   }
   /**
@@ -84,23 +252,13 @@
   {
      $ttl = $ttl ?: $this->cache_ttl;
      $group = $group ?: $this->group;
      $key = $this->normalizeKey($key);
      $cache_key = $this->buildKey($key);
      $temp = [
         'data' => $value,
         'last_modified' => time(),
      ];
      $value = $temp;
      // Use appropriate cache method
      if (static::$use_object_cache) {
         return wp_cache_set($cache_key, $value, $group, $ttl);
      } else {
         // Fallback to transients
         return set_transient($this->getTransientKey($cache_key, $group), $value, $ttl);
      }
      // Update timestamp when setting new data
      self::updateTimestamp($this->group);
      return wp_cache_set($cache_key, $value, $group, $ttl);
   }
   /**
@@ -112,147 +270,28 @@
   public function delete(string|array $key, ?string $group = null): bool
   {
      $group = $group ?: $this->group;
      $key = $this->normalizeKey($key);
      $cache_key = $this->buildKey($key);
      // Use appropriate cache method
      if (static::$use_object_cache) {
         return wp_cache_delete($cache_key, $group);
      } else {
         return delete_transient($this->getTransientKey($cache_key, $group));
      }
   }
   public function clear():bool
   {
      try {
         if (static::$use_object_cache) {
            // With Redis, this could be implemented with SCAN command
            // but wp_cache_* doesn't expose this, so we'd need direct Redis access
            // For now, just flush the group as a nuclear option
            if (function_exists('wp_cache_flush_group')) {
               wp_cache_flush_group($this->group);
               return true;
            }
            return false;
         } else {
            // For transients, search and delete
            global $wpdb;
            $prefix = self::getTransientPrefix($this->group);
            $sql = "SELECT option_name FROM {$wpdb->options}
                    WHERE option_name LIKE %s
                    AND option_name LIKE %s";
            $keys = $wpdb->get_col($wpdb->prepare(
               $sql,
               '_transient_' . $prefix . '%'
            ));
            foreach ($keys as $key) {
               $transient_key = str_replace('_transient_', '', $key);
               delete_transient($transient_key);
            }
            return true;
         }
      } catch (\Exception $e) {
      } finally {
         return false;
      }
      return wp_cache_delete($cache_key, $group);
   }
   /**
    * Alias for delete() for backwards compatibility
    * @param string $key The key to look up (auto-generates key from array of key=>values)
    * @param string|null $group The group to delete from (defaults to current group))
    * @return void
    */
   public function invalidate(string $key, ?string $group = null): void
   {
      $this->delete($key, $group);
   }
   /**
    * Clear all cache entries for a group
    * @param string $group The group to clear
    * Clear all cache for this group
    * @return bool
    */
   public static function invalidateGroup(string $group): bool
   public function clear(): bool
   {
      $group = jvbNoBase($group);
      if (wp_using_ext_object_cache()) {
         // With Redis/Memcached, use native group flush
         if (function_exists('wp_cache_flush_group')) {
            return wp_cache_flush_group($group);
         } else {
            // Fallback for older WP versions - flush everything (not ideal)
            return wp_cache_flush();
         }
      } else {
         // For transients, we need to delete them from database
         global $wpdb;
         $prefix = self::getTransientPrefix($group);
         // Delete transients and their timeouts
         $sql = "DELETE FROM {$wpdb->options}
                    WHERE option_name LIKE %s
                    OR option_name LIKE %s";
         $result = $wpdb->query($wpdb->prepare(
            $sql,
            '_transient_' . $prefix . '%',
            '_transient_timeout_' . $prefix . '%'
         ));
         return $result !== false;
      }
   }
   /**
    * Clear cache entries by pattern (only works efficiently with Redis)
    * @param string $pattern
    * @return int
    */
   public function clearPattern(string $pattern): int
   {
      $count = 0;
      if (static::$use_object_cache) {
         // With Redis, this could be implemented with SCAN command
         // but wp_cache_* doesn't expose this, so we'd need direct Redis access
         // For now, just flush the group as a nuclear option
      try {
         if (function_exists('wp_cache_flush_group')) {
            wp_cache_flush_group($this->group);
            return $count;
            self::updateTimestamp($this->group);
            return true;
         }
      } else {
         // For transients, search and delete
         global $wpdb;
         $prefix = self::getTransientPrefix($this->group);
         $sql = "SELECT option_name FROM {$wpdb->options}
                    WHERE option_name LIKE %s
                    AND option_name LIKE %s";
         $keys = $wpdb->get_col($wpdb->prepare(
            $sql,
            '_transient_' . $prefix . '%',
            '%' . $pattern . '%'
         ));
         foreach ($keys as $key) {
            $transient_key = str_replace('_transient_', '', $key);
            delete_transient($transient_key);
            $count++;
         }
         return false;
      } catch (\Exception $e) {
         return false;
      }
      return $count;
   }
   /**
@@ -289,22 +328,18 @@
   {
      $group = $group ?: $this->group;
      $ttl = $ttl ?: $this->cache_ttl;
      $key = $this->normalizeKey($key);
      $value = $this->get($key, $group);
      if ($value === false) {
         $value = $callback();
         if ($value !== false) {
            $value = [
               'data' => $value,
               'last_modified' => time(),
            ];
         if ($value !== false && $value !== null) {
            $this->set($key, $value, $ttl, $group);
         }
      }
      return (is_array($value) && array_key_exists('data', $value)) ? $value['data']: $value;
      return $value;
   }
   /**
@@ -318,59 +353,315 @@
   }
   /**
    * Get transient key for fallback mode
    * @param string $key
    * @param string $group
    * @return string
    * Get instance group name (for debugging)
    */
   private function getTransientKey(string $key, string $group): string
   public function getGroup(): string
   {
      // Transients have a 172 character limit
      $full_key = $group . '_' . $key;
      return $this->group;
   }
      if (strlen($full_key) > 160) {
         // Use hash for long keys, but keep group prefix for clearPattern()
         return substr($group, 0, 20) . '_' . md5($full_key);
   // ===== RELATIONSHIP MANAGEMENT =====
   /**
    * Register cache relationship
    * When $type is invalidated, these related types are also invalidated
    *
    * @param string $type Primary type
    * @param array $config Relationship configuration
    *   - 'author' => bool - Invalidate user content caches
    *   - 'taxonomies' => array - List of taxonomy types to invalidate
    *   - 'content_types' => array - List of content types to invalidate
    *   - 'related' => array - Generic related types to invalidate
    *   - 'cascade' => callable - Custom cascade function
    */
   public static function registerRelationship(string $type, array $config): void
   {
      $type = jvbNoBase($type);
      // Merge with existing relationships
      self::$relationships[$type] = array_merge(
         self::$relationships[$type] ?? [],
         $config
      );
      // Build reverse relationships for bidirectional linking
      self::buildReverseRelationships($type, $config);
   }
   /**
    * Build reverse relationships (if A relates to B, B should know about A)
    *
    * @param string $type The type being registered
    * @param array $config Its relationship config
    */
   private static function buildReverseRelationships(string $type, array $config): void
   {
      // If this type relates to taxonomies, those taxonomies should know about this type
      if (!empty($config['taxonomies'])) {
         foreach ($config['taxonomies'] as $taxonomy) {
            $taxonomy = jvbNoBase($taxonomy);
            self::$relationships[$taxonomy]['content_types'] =
               array_unique(array_merge(
                  self::$relationships[$taxonomy]['content_types'] ?? [],
                  [$type]
               ));
         }
      }
      return $full_key;
      // If this type relates to content_types, those types should know about this taxonomy
      if (!empty($config['content_types'])) {
         foreach ($config['content_types'] as $content_type) {
            $content_type = jvbNoBase($content_type);
            self::$relationships[$content_type]['related'] =
               array_unique(array_merge(
                  self::$relationships[$content_type]['related'] ?? [],
                  [$type]
               ));
         }
      }
   }
   /**
    * Get transient prefix for a group
    * Load relationships from JVB_CONTENT and JVB_TAXONOMY
    */
   private static function getTransientPrefix(string $group): string
   private static function loadRelationships(): void
   {
      return $group . '_jvb_';
   }
   /**
    * Check if using object cache
    */
   public function isUsingObjectCache(): bool
   {
      return static::$use_object_cache;
   }
   /**
    * Cleanup expired transients (maintenance method for non-Redis environments)
    */
   public static function cleanupExpiredTransients(): int
   {
      if (wp_using_ext_object_cache()) {
         return 0; // Not needed with Redis
      if (self::$relationships_loaded) {
         return;
      }
      global $wpdb;
      // Load post type relationships
      if (defined('JVB_CONTENT')) {
         foreach (JVB_CONTENT as $slug => $config) {
            $relationships = [];
      // Delete expired transients
      $sql = "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
                WHERE a.option_name LIKE '_transient_%'
                AND a.option_name NOT LIKE '_transient_timeout_%'
                AND b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
                AND b.option_value < %d";
            // Author relationship
            if (!($config['no_author'] ?? false)) {
               $relationships['author'] = true;
            }
      return $wpdb->query($wpdb->prepare($sql, time()));
            // Taxonomy relationships
            if (!empty($config['taxonomies'])) {
               $relationships['taxonomies'] = array_map('jvbNoBase', $config['taxonomies']);
            }
            // Custom relationships from config
            if (!empty($config['cache_relationships'])) {
               $relationships = array_merge($relationships, $config['cache_relationships']);
            }
            if (!empty($relationships)) {
               self::registerRelationship($slug, $relationships);
            }
         }
      }
      // Load taxonomy relationships
      if (defined('JVB_TAXONOMY')) {
         foreach (JVB_TAXONOMY as $slug => $config) {
            $relationships = [];
            // Content type relationships
            if (!empty($config['for_content'])) {
               $relationships['content_types'] = array_map('jvbNoBase', $config['for_content']);
            }
            // Always include generic 'terms' cache
            $relationships['related'] = ['terms'];
            // Custom relationships from config
            if (!empty($config['cache_relationships'])) {
               $relationships = array_merge($relationships, $config['cache_relationships']);
            }
            if (!empty($relationships)) {
               self::registerRelationship($slug, $relationships);
            }
         }
      }
      self::$relationships_loaded = true;
      do_action('jvb_cache_relationships_loaded', self::$relationships);
   }
   /**
    * Get relationships for a type (for debugging)
    *
    * @param string|null $type Specific type or null for all
    * @return array Relationships
    */
   public static function getRelationships(?string $type = null): array
   {
      self::loadRelationships();
      if ($type !== null) {
         return self::$relationships[jvbNoBase($type)] ?? [];
      }
      return self::$relationships;
   }
   /**
    * Cascade invalidation to related types based on relationships
    *
    * @param string $type Primary type being invalidated
    * @param mixed $context Context with relationship data
    */
   /**
    * Cascade invalidation to related types based on relationships
    */
   private static function cascadeInvalidation(string $type, $context): void
   {
      self::loadRelationships();
      $relationships = self::$relationships[$type] ?? [];
      if (empty($relationships)) {
         return;
      }
      $data = self::extractContext($context);
      // Author relationship - SIMPLIFIED
      if (!empty($relationships['author'])) {
         $user_ids = self::extractUserIds($data, $relationships['author']);
         foreach ($user_ids as $user_id) {
            // Single clean call - handles content, profile, everything
            self::invalidateAll("user_{$user_id}");
         }
      }
      // Taxonomy relationships
      if (!empty($relationships['taxonomies']) && !empty($data['ID'])) {
         foreach ($relationships['taxonomies'] as $taxonomy) {
            $taxonomy_full = jvbCheckBase($taxonomy);
            $terms = wp_get_post_terms($data['ID'], $taxonomy_full, ['fields' => 'ids']);
            if (!empty($terms) && !is_wp_error($terms)) {
               self::updateTimestamp($taxonomy);
               wp_cache_flush_group($taxonomy);
            }
         }
      }
      // Content type relationships (for taxonomies)
      if (!empty($relationships['content_types'])) {
         foreach ($relationships['content_types'] as $content_type) {
            self::updateTimestamp($content_type);
            wp_cache_flush_group($content_type);
         }
      }
      // Generic related caches
      if (!empty($relationships['related'])) {
         foreach ($relationships['related'] as $related_type) {
            self::updateTimestamp($related_type);
            wp_cache_flush_group($related_type);
         }
      }
      // Custom cascade function
      if (!empty($relationships['cascade']) && is_callable($relationships['cascade'])) {
         call_user_func($relationships['cascade'], $type, $data);
      }
   }
   /**
    * Extract user IDs from context based on relationship config
    * Supports multiple authors, contributors, etc.
    *
    * @param array $data Context data
    * @param mixed $config Author relationship config (bool or array)
    * @return array User IDs to invalidate
    */
   private static function extractUserIds(array $data, $config): array
   {
      $user_ids = [];
      // Simple case: 'author' => true
      if ($config === true) {
         if (!empty($data['post_author'])) {
            $user_ids[] = $data['post_author'];
         }
         return array_filter($user_ids);
      }
      // Advanced case: 'author' => ['post_author', 'contributors', 'linked_user']
      if (is_array($config)) {
         foreach ($config as $field) {
            // Handle meta fields
            if (str_starts_with($field, 'meta:') && !empty($data['ID'])) {
               $meta_key = substr($field, 5);
               $value = get_post_meta($data['ID'], BASE . $meta_key, true);
               if (is_array($value)) {
                  $user_ids = array_merge($user_ids, $value);
               } elseif ($value) {
                  $user_ids[] = $value;
               }
            }
            // Handle direct data fields
            elseif (!empty($data[$field])) {
               if (is_array($data[$field])) {
                  $user_ids = array_merge($user_ids, $data[$field]);
               } else {
                  $user_ids[] = $data[$field];
               }
            }
         }
      }
      // Callable: 'author' => function($data) { return [...user_ids]; }
      if (is_callable($config)) {
         $result = call_user_func($config, $data);
         if (is_array($result)) {
            $user_ids = array_merge($user_ids, $result);
         } elseif ($result) {
            $user_ids[] = $result;
         }
      }
      return array_unique(array_filter(array_map('intval', $user_ids)));
   }
   /**
    * Extract context data from various formats
    * Converts WP objects to arrays with relevant data
    *
    * @param mixed $context Post/Term object, array, or ID
    * @return array Normalized context data
    */
   private static function extractContext($context): array
   {
      if (is_array($context)) {
         return $context;
      }
      if ($context instanceof \WP_Post) {
         return [
            'ID' => $context->ID,
            'post_author' => $context->post_author,
            'post_type' => $context->post_type,
            'post_status' => $context->post_status,
         ];
      }
      if ($context instanceof \WP_Term) {
         return [
            'term_id' => $context->term_id,
            'taxonomy' => $context->taxonomy,
            'parent' => $context->parent,
         ];
      }
      if (is_numeric($context)) {
         $post = get_post($context);
         if ($post) {
            return self::extractContext($post);
         }
      }
      return [];
   }
}