Jake Vanderwerf
2026-01-01 5b5f37de365ff84fc231e414a719d1b2ff4ceff6
inc/managers/CacheManager.php
@@ -13,14 +13,15 @@
 */
class CacheManager
{
   private const CONNECTIONS_OPTION = BASE.'cache_connections';
   private static ?array $connections_cache = null; // Cache in memory
   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;
   private static ?CacheManager $singleton = null;
   /**
    * Private constructor - use for() factory method instead
@@ -33,6 +34,76 @@
      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);
   }
   /**
@@ -127,21 +198,21 @@
   }
   /**
    * Invalidate cache for a content type with automatic cascade
    * Invalidate cache for a content type
    *
    * @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
    * @param bool $flush_connections Whether to flush connected caches
    * @return void
    */
   public static function invalidateAll(string $type, $context = null, $specific_keys = null): 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 (don't flush whole group)
      // If specific keys provided, only delete those
      if ($specific_keys !== null) {
         $instance = self::for($type);
         if (is_array($specific_keys)) {
@@ -152,21 +223,20 @@
            $instance->delete($specific_keys);
         }
      } else {
         // No specific keys - flush the entire group
         // 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);
      // Flush connected caches
      if ($flush_connections) {
         self::for($type)->connections();
      }
      do_action('jvb_cache_invalidated', $type, $context);
      do_action('jvb_cache_invalidated', $type);
   }
   /**
@@ -192,15 +262,14 @@
   /**
    * 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)
    * @param bool $flush_connections Whether to flush connected caches
    * @return self For chaining
    */
   public function invalidate($context = null, $specific_keys = null): self
   public function invalidate($specific_keys = null, bool $flush_connections = true): self
   {
      self::invalidateAll($this->group, $context, $specific_keys);
      self::invalidateAll($this->group, $specific_keys, $flush_connections);
      return $this;
   }
@@ -237,7 +306,14 @@
      $key = $this->normalizeKey($key);
      $cache_key = $this->buildKey($key);
      return wp_cache_get($cache_key, $group);
      $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;
   }
   /**
@@ -255,12 +331,18 @@
      $key = $this->normalizeKey($key);
      $cache_key = $this->buildKey($key);
      // Update timestamp when setting new data
      self::updateTimestamp($this->group);
      return wp_cache_set($cache_key, $value, $group, $ttl);
   }
      // 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)
@@ -273,9 +355,17 @@
      $key = $this->normalizeKey($key);
      $cache_key = $this->buildKey($key);
      return wp_cache_delete($cache_key, $group);
      $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
@@ -285,16 +375,40 @@
      try {
         if (function_exists('wp_cache_flush_group')) {
            wp_cache_flush_group($this->group);
            self::updateTimestamp($this->group);
            return true;
         }
         return false;
         // 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
@@ -360,308 +474,383 @@
      return $this->group;
   }
   // ===== RELATIONSHIP MANAGEMENT =====
   /***************************************************************************
    * 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 = ??
   ***************************************************************************/
   /**
    * Register cache relationship
    * When $type is invalidated, these related types are also invalidated
    * Define a connection between cache groups
    * Connected caches will have their ID-based keys deleted when this cache invalidates
    *
    * @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
    * @param string $type Grand overview ('post', 'taxonomy', 'user')
    * @param string $scope Type-specific constant, user role, or 'id'
    * @return self For chaining
    */
   public static function registerRelationship(string $type, array $config): void
   public function connectTo(string $type, string $scope = 'id'): self
   {
      $type = jvbNoBase($type);
      //TODO: Handle connect to where $type === 'all'
      $connections = self::getConnections();
      // Merge with existing relationships
      self::$relationships[$type] = array_merge(
         self::$relationships[$type] ?? [],
         $config
      );
      if (!isset($connections[$this->group])) {
         $connections[$this->group] = [];
      }
      // Build reverse relationships for bidirectional linking
      self::buildReverseRelationships($type, $config);
   }
      $new_connection = [
         'parent' => $type,
         'scope' => $scope
      ];
   /**
    * 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]
               ));
      // Check if already exists
      foreach ($connections[$this->group] as $existing) {
         if ($existing === $new_connection) {
            return $this;
         }
      }
      // 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]
               ));
      $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;
      }
   }
   /**
    * Load relationships from JVB_CONTENT and JVB_TAXONOMY
    * Handle post save/update
    */
   private static function loadRelationships(): void
   public static function onPostSave(int $post_id, \WP_Post $post): void
   {
      if (self::$relationships_loaded) {
      // Skip revisions and autosaves
      if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
         return;
      }
      // Load post type relationships
      if (defined('JVB_CONTENT')) {
         foreach (JVB_CONTENT as $slug => $config) {
            $relationships = [];
      $post_type = jvbNoBase($post->post_type);
            // Author relationship
            if (!($config['no_author'] ?? false)) {
               $relationships['author'] = true;
            }
      // Invalidate post type cache
      self::invalidateAll($post_type);
            // 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);
      // Invalidate specific post cache
      self::invalidateAll($post_id);
      // Clear WordPress core post object cache
      clean_post_cache($post_id);
   }
   /**
    * Get relationships for a type (for debugging)
    *
    * @param string|null $type Specific type or null for all
    * @return array Relationships
    * Handle post deletion
    */
   public static function getRelationships(?string $type = null): array
   public static function onPostDelete(int $post_id): void
   {
      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)) {
      $post = get_post($post_id);
      if (!$post) {
         return;
      }
      $data = self::extractContext($context);
      $post_type = jvbNoBase($post->post_type);
      // 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);
      }
      self::invalidateAll($post_type);
      self::invalidateAll($post_id);
      // Clear WordPress core post object cache
      clean_post_cache($post_id);
   }
   /**
    * 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
    * Handle term save/update
    */
   private static function extractUserIds(array $data, $config): array
   public static function onTermSave(int $term_id, int $tt_id, string $taxonomy): void
   {
      $user_ids = [];
      // Clear WordPress core term cache
      clean_term_cache($term_id, $taxonomy);
      $taxonomy = jvbNoBase($taxonomy);
      // Simple case: 'author' => true
      if ($config === true) {
         if (!empty($data['post_author'])) {
            $user_ids[] = $data['post_author'];
         }
         return array_filter($user_ids);
      }
      // Invalidate taxonomy cache
      self::invalidateAll($taxonomy);
      // 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)));
      // Invalidate specific term cache
      self::invalidateAll($term_id);
   }
   /**
    * 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
    * Handle term deletion
    */
   private static function extractContext($context): array
   public static function onTermDelete(int $term_id, int $tt_id, string $taxonomy): void
   {
      if (is_array($context)) {
         return $context;
      }
      // Clear WordPress core term cache
      clean_term_cache($term_id, $taxonomy);
      $taxonomy = jvbNoBase($taxonomy);
      if ($context instanceof \WP_Post) {
         return [
            'ID' => $context->ID,
            'post_author' => $context->post_author,
            'post_type' => $context->post_type,
            'post_status' => $context->post_status,
         ];
      }
      self::invalidateAll($taxonomy);
      self::invalidateAll($term_id);
   }
      if ($context instanceof \WP_Term) {
         return [
            'term_id' => $context->term_id,
            'taxonomy' => $context->taxonomy,
            'parent' => $context->parent,
         ];
      }
   /**
    * 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);
      if (is_numeric($context)) {
         $post = get_post($context);
         if ($post) {
            return self::extractContext($post);
      // 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);
   }
      return [];
   /**
    * 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);
   }
}