group = jvbNoBase($group); $this->cache_ttl = $ttl ?: 3600; if (is_null(static::$use_object_cache)) { static::$use_object_cache = wp_using_ext_object_cache(); } add_action('init', [$this, 'registerHooks']); } /** * Get singleton instance (for general cache operations) * For type-specific operations, use for() or forUser() instead */ public static function getInstance(): self { if (self::$singleton === null) { self::$singleton = new self('global', HOUR_IN_SECONDS); } return self::$singleton; } /** * Get all cache connections (public accessor) * * @return array Array of cache group connections */ public static function getAllConnections(): array { return self::getConnections(); } /** * Get all registered cache groups * * @return array List of cache group names */ public static function getAllGroups(): array { $connections = self::getConnections(); return array_keys($connections); } /** * Register WordPress hooks for automatic cache invalidation * Call this once during plugin initialization */ public static function registerHooks(): void { // Post updates (all post types including core) add_action('save_post', [self::class, 'onPostSave'], 10, 2); add_action('delete_post', [self::class, 'onPostDelete']); // Meta updates (will catch MetaManager updates) add_action('updated_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4); add_action('added_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4); add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 4); // transition_post_status? // Term updates (all taxonomies) add_action('edited_term', [self::class, 'onTermSave'], 10, 3); add_action('create_term', [self::class, 'onTermSave'], 10, 3); add_action('delete_term', [self::class, 'onTermDelete'], 10, 3); // Term meta updates add_action('updated_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4); add_action('added_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4); add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 4); // User updates add_action('profile_update', [self::class, 'onUserUpdate'], 10, 2); add_action('user_register', [self::class, 'onUserUpdate'], 10, 1); add_action('deleted_user', [self::class, 'onUserDelete']); // User meta updates add_action('updated_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4); add_action('added_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4); add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 4); } /** * 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 * * @param string $type Content type to invalidate * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group * @param bool $flush_connections Whether to flush connected caches * @return void */ public static function invalidateAll(string $type, $specific_keys = null, bool $flush_connections = true): void { $type = jvbNoBase($type); // Update HTTP timestamp self::updateTimestamp($type); // If specific keys provided, only delete those 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 { // Flush the entire group if (function_exists('wp_cache_flush_group')) { wp_cache_flush_group($type); } else { wp_cache_flush(); } } // Flush connected caches if ($flush_connections) { self::for($type)->connections(); } do_action('jvb_cache_invalidated', $type); } /** * 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 * * @param string|array|null $specific_keys Optional specific key(s) * @param bool $flush_connections Whether to flush connected caches * @return self For chaining */ public function invalidate($specific_keys = null, bool $flush_connections = true): self { self::invalidateAll($this->group, $specific_keys, $flush_connections); 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 * @return mixed */ public function get(string|array $key, ?string $group = null): mixed { $group = $group ?: $this->group; $key = $this->normalizeKey($key); $cache_key = $this->buildKey($key); $value = wp_cache_get($cache_key, $group); // Fallback to transient if no external object cache if ($value === false && !wp_using_ext_object_cache()) { $value = get_transient($group . '_' . $cache_key); } return $value; } /** * Store a value in cache * @param string|array $key The key to look up (auto-generates key from array of key=>values) * @param mixed $value The Value to set * @param int|null $ttl The ttl (defaults to current set ttl) * @param string|null $group The group to add cache to (defaults to current group)) * @return bool */ public function set(string|array $key, mixed $value, ?int $ttl = null, ?string $group = null): bool { $ttl = $ttl ?: $this->cache_ttl; $group = $group ?: $this->group; $key = $this->normalizeKey($key); $cache_key = $this->buildKey($key); self::updateTimestamp($this->group); // Try object cache first $result = wp_cache_set($cache_key, $value, $group, $ttl); // If no external object cache, also store in transient for persistence if (!wp_using_ext_object_cache()) { set_transient($group . '_' . $cache_key, $value, $ttl); } return $result; } /** * Delete a cached value * @param string|array $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 bool */ public function delete(string|array $key, ?string $group = null): bool { $group = $group ?: $this->group; $key = $this->normalizeKey($key); $cache_key = $this->buildKey($key); $result = wp_cache_delete($cache_key, $group); // Also delete transient if no external object cache if (!wp_using_ext_object_cache()) { delete_transient($group . '_' . $cache_key); } return $result; } /** * Clear all cache for this group * @return bool */ public function clear(): bool { try { if (function_exists('wp_cache_flush_group')) { wp_cache_flush_group($this->group); } // Clear transients for this group if no external object cache if (!wp_using_ext_object_cache()) { $this->clearGroupTransients(); } self::updateTimestamp($this->group); return true; } catch (\Exception $e) { return false; } } /** * Clear all transients for this cache group */ private function clearGroupTransients(): void { global $wpdb; $pattern = '_transient_' . $this->group . '_' . $this->prefix . '%'; $timeout_pattern = '_transient_timeout_' . $this->group . '_' . $this->prefix . '%'; $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s", $pattern, $timeout_pattern ) ); } /** * Helper to generateKey from array if applicable * @param string|array $key * @return string */ private function normalizeKey(string|array $key): string { return is_array($key) ? $this->generateKey($key) : $key; } /** * Generate a cache key from parameters * @param array $params An array of key/values that differentiates this cache item from others * @return string */ public function generateKey(array $params): string { // Sort params for consistent key generation ksort($params); return md5(serialize($params)); } /** * The workhorse shorthand of CacheManager. Tests the cache, and calls the callback if nothing is found. * @param string|array $key The key to look up (auto-generates key from array of key=>values) * @param callable $callback The callback to generate the value for this key * @param int|null $ttl The time-to-live for the cache. Defaults to constructor * @param string|null $group The group to save cache to. Defaults to constructor * @return mixed */ public function remember(string|array $key, callable $callback, ?int $ttl = null, ?string $group = null): mixed { $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 !== null) { $this->set($key, $value, $ttl, $group); } } return $value; } /** * Build the cache key * @param string $key * @return string */ private function buildKey(string $key): string { return $this->prefix . $key; } /** * Get instance group name (for debugging) */ public function getGroup(): string { return $this->group; } /*************************************************************************** * CONNECTIONS * Connect to other caches by instantiating and defining connection * Ex: CacheManager::for('usernames')->connectTo($type, $scope = 'all', $keyPattern) * Where: $type = content / taxonomy / user * $scope = either 'id' for specific item, or the entire group (registered post type, taxonomy, or user role) * $keyPattern = ?? ***************************************************************************/ /** * Define a connection between cache groups * Connected caches will have their ID-based keys deleted when this cache invalidates * * @param string $type Grand overview ('post', 'taxonomy', 'user') * @param string $scope Type-specific constant, user role, or 'id' * @return self For chaining */ public function connectTo(string $type, string $scope = 'id'): self { //TODO: Handle connect to where $type === 'all' $connections = self::getConnections(); if (!isset($connections[$this->group])) { $connections[$this->group] = []; } $new_connection = [ 'parent' => $type, 'scope' => $scope ]; // Check if already exists foreach ($connections[$this->group] as $existing) { if ($existing === $new_connection) { return $this; } } $connections[$this->group][] = $new_connection; update_option(self::CONNECTIONS_OPTION, $connections, false); self::$connections_cache = $connections; return $this; } /** * Get all registered connections (cached for performance) * * @param bool $refresh Force refresh from database * @return array */ private static function getConnections(bool $refresh = false): array { if (self::$connections_cache === null || $refresh) { self::$connections_cache = get_option(self::CONNECTIONS_OPTION, []); } return self::$connections_cache; } /** * Flush all caches connected to this one * * @return self For chaining */ public function connections(): self { $all_connections = self::getConnections(); foreach ($all_connections as $cache_group => $connections) { foreach ($connections as $conn) { if ($this->matchesConnection($conn)) { $this->flushConnection($cache_group, $conn); } } } return $this; } /** * Check if this cache group matches a connection definition */ private function matchesConnection(array $connection): bool { $parent = $connection['parent'] ?? ''; $scope = $connection['scope'] ?? 'id'; // Grand overview match if ($this->group === $parent) { return true; } // Type-specific match if ($scope !== 'id') { if ($this->group === jvbNoBase($scope)) { return true; } // Check constants if ($parent === 'post' && defined('JVB_CONTENT')) { return isset(JVB_CONTENT[$scope]) && jvbNoBase($scope) === $this->group; } if ($parent === 'taxonomy' && defined('JVB_TAXONOMY')) { return isset(JVB_TAXONOMY[$scope]) && jvbNoBase($scope) === $this->group; } } // ID-specific match: 'user_123' matches 'user' + 'id' if ($scope === 'id' && str_starts_with($this->group, $parent . '_')) { return true; } return false; } /** * Flush a connected cache group * For ID-specific connections, deletes the specific ID key * For type/overview connections, flushes entire group */ private function flushConnection(string $cache_group, array $connection): void { $scope = $connection['scope'] ?? 'id'; // ID-specific: delete specific key if ($scope === 'id') { $id = $this->extractIdFromGroup(); if ($id !== null) { self::invalidateKeys($cache_group, $id); return; } } // Type/overview: flush entire group self::invalidateAll($cache_group, specific_keys: null, flush_connections: false); } /** * Extract ID from group name like 'user_123' -> '123' * * @return string|null */ private function extractIdFromGroup(): ?string { if (preg_match('/^[a-z]+_(\d+)$/', $this->group, $matches)) { return $matches[1]; } return null; } /** * Register multiple connections at once */ public static function registerConnections(array $connections): void { $existing = self::getConnections(); $changed = false; foreach ($connections as $cache_group => $configs) { if (!isset($existing[$cache_group])) { $existing[$cache_group] = []; } foreach ($configs as $config) { $duplicate = false; foreach ($existing[$cache_group] as $existing_config) { if ($existing_config === $config) { $duplicate = true; break; } } if (!$duplicate) { $existing[$cache_group][] = $config; $changed = true; } } } if ($changed) { update_option(self::CONNECTIONS_OPTION, $existing, false); self::$connections_cache = $existing; } } /** * Handle post save/update */ public static function onPostSave(int $post_id, \WP_Post $post): void { // Skip revisions and autosaves if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) { return; } $post_type = jvbNoBase($post->post_type); // Invalidate post type cache self::invalidateAll($post_type); // Invalidate specific post cache self::invalidateAll($post_id); // Clear WordPress core post object cache clean_post_cache($post_id); } /** * Handle post deletion */ public static function onPostDelete(int $post_id): void { $post = get_post($post_id); if (!$post) { return; } $post_type = jvbNoBase($post->post_type); self::invalidateAll($post_type); self::invalidateAll($post_id); // Clear WordPress core post object cache clean_post_cache($post_id); } /** * Handle term save/update */ public static function onTermSave(int $term_id, int $tt_id, string $taxonomy): void { // Clear WordPress core term cache clean_term_cache($term_id, $taxonomy); $taxonomy = jvbNoBase($taxonomy); // Invalidate taxonomy cache self::invalidateAll($taxonomy); // Invalidate specific term cache self::invalidateAll($term_id); } /** * Handle term deletion */ public static function onTermDelete(int $term_id, int $tt_id, string $taxonomy): void { // Clear WordPress core term cache clean_term_cache($term_id, $taxonomy); $taxonomy = jvbNoBase($taxonomy); self::invalidateAll($taxonomy); self::invalidateAll($term_id); } /** * Handle user update */ public static function onUserUpdate(int $user_id, ?\WP_User $old_user_data = null): void { // Invalidate user-specific cache self::invalidateAll($user_id); // Invalidate user role caches if roles changed if ($old_user_data) { $user = get_userdata($user_id); if ($user && $user->roles !== $old_user_data->roles) { foreach (array_merge($user->roles, $old_user_data->roles) as $role) { self::invalidateAll($role); } } } // Clear WordPress core user cache clean_user_cache($user_id); } /** * Handle user deletion */ public static function onUserDelete(int $user_id): void { self::invalidateAll($user_id); // Clear WordPress core user cache clean_user_cache($user_id); } /** * Handle post meta updates */ public static function onPostMetaUpdate(int $meta_id, int $post_id, string $meta_key, mixed $meta_value): void { if (!str_starts_with($meta_key, BASE)) { return; } $post = get_post($post_id); if (!$post) { return; } self::onPostSave($post_id, $post); } public static function onPostMetaDelete(array $meta_ids, int $post_id, string $meta_key, mixed $meta_value):void { if (!str_starts_with($meta_key, BASE)) { return; } $post = get_post($post_id); if (!$post) { return; } self::onPostSave($post_id, $post); } /** * Handle term meta updates */ public static function onTermMetaUpdate(int $meta_id, int $term_id, string $meta_key, mixed $meta_value): void { if (!str_starts_with($meta_key, BASE)) { return; } $term = get_term($term_id); if (!$term || is_wp_error($term)) { return; } self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy); } public static function onTermMetaDelete(array $meta_ids, int $term_id, string $meta_key, mixed $meta_value):void { if (!str_starts_with($meta_key, BASE)) { return; } $term = get_term($term_id); if (!$term || is_wp_error($term)) { return; } self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy); } /** * Handle user meta updates */ public static function onUserMetaUpdate(int $meta_id, int $user_id, string $meta_key, mixed $meta_value): void { if (!str_starts_with($meta_key, BASE)) { return; } $user = get_userdata($user_id); if (!$user) { return; } self::onUserUpdate($user_id, null); } public static function onUserMetaDelete(array $meta_ids, int $user_id, string $meta_key, mixed $meta_value):void { if (!str_starts_with($meta_key, BASE)) { return; } $user = get_userdata($user_id); if (!$user) { return; } self::onUserUpdate($user_id, null); } }