field|false). * * @var array */ private static array $field_cache = array(); /** * Register all metadata hooks. */ public function register_hooks(): void { add_filter( 'get_post_metadata', array( $this, 'intercept_get' ), 10, 5 ); add_filter( 'update_post_metadata', array( $this, 'intercept_update' ), 10, 5 ); add_filter( 'add_post_metadata', array( $this, 'intercept_add' ), 10, 5 ); add_action( 'deleted_post_meta', array( $this, 'intercept_delete' ), 10, 4 ); add_action( 'before_delete_post', array( $this, 'cleanup_post' ), 10, 1 ); } /** * Intercept get_post_meta — read from zone table when module is in read-custom state. * * @param mixed $value Existing filtered value (null by default). * @param int $post_id Post ID. * @param string $meta_key Meta key (empty = get all). * @param bool $single Whether to return single value. * @param string $meta_type Meta type (always 'post'). * @return mixed */ public function intercept_get( $value, int $post_id, string $meta_key, bool $single, string $meta_type = 'post' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by get_post_metadata filter signature. if ( self::$bypassing || empty( $meta_key ) || $post_id <= 0 ) { return $value; } $post_type = get_post_type( $post_id ); if ( ! $post_type ) { return $value; } $field = $this->get_field_cached( $post_type, $meta_key ); if ( ! $field ) { return $value; } // P1-24: defer to Hook Bus for reads when post entity is dual_write or higher. if ( self::is_owned_by_entity_bridge( $meta_key ) ) { return $value; } $module = $this->get_zone_module( $field['zone'], $post_type ); if ( ! TMDO_Feature_Flags::is_read_custom( $module ) ) { return $value; } try { $zone_value = $this->read_from_zone( $field, $post_id, $post_type, $meta_key ); if ( null === $zone_value ) { return $value; } // phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- This is an explanatory comment, not commented-out code. // Wrap in array: WP unwraps $check[0] for $single=true, casts (array)$check for $single=false. return array( $zone_value ); } catch ( \Throwable $e ) { TMDO_Logger::error( $module, 'get_post_metadata', $e->getMessage() ); return $value; } } /** * Intercept update_post_meta — dual-write to zone table. * * @param null|bool $check Whether to short-circuit (null = proceed). * @param int $post_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * @param mixed $prev_value Previous value. * @return null|bool */ public function intercept_update( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed if ( self::$bypassing || $post_id <= 0 ) { return $check; } $post_type = get_post_type( $post_id ); if ( ! $post_type ) { return $check; } // v2.9.2 Entity Bridge guard: when post mode is dual_write or higher // AND the key is registered in the new Entity Registry, the unified // Hook Bus is the source of truth — Sync_Bridge must not also write // to the legacy zone table to avoid duplicate flat writes. if ( self::is_owned_by_entity_bridge( $meta_key ) ) { return $check; } $field = $this->get_field_cached( $post_type, $meta_key ); if ( ! $field ) { return $check; } $module = $this->get_zone_module( $field['zone'], $post_type ); if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) { return $check; } // Write to zone table (non-fatal on failure). try { $this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value ); } catch ( \Throwable $e ) { TMDO_Logger::error( $module, 'update_post_metadata', $e->getMessage() ); } // Return null — let WordPress proceed with native postmeta write. // In cleanup/complete states, we could skip native write, but for safety // we always allow it during zone migration lifecycle. return $check; } /** * Whether the given post meta_key is now owned by the Entity Bridge, * meaning Sync_Bridge should defer to the unified Hook Bus and skip its * zone write to avoid duplicate flat writes. * * Returns true only when ALL of: * - TMDO_Mode_Manager and TMDO_Entity_Registry classes exist * - post mode is dual_write or higher (writes_to_flat returns true) * - the key is registered for entity_type=post * * Default post mode is `disabled`, so this returns false in all * environments that have not opted in to Entity Bridge — keeping the * legacy Sync_Bridge → zone path unchanged. * * @param string $meta_key Meta key being written. * @return bool * @since 2.9.2 */ private static function is_owned_by_entity_bridge( string $meta_key ): bool { if ( ! class_exists( 'TMDO_Mode_Manager' ) || ! class_exists( 'TMDO_Entity_Registry' ) ) { return false; } if ( ! TMDO_Mode_Manager::writes_to_flat( 'post' ) ) { return false; } return null !== TMDO_Entity_Registry::get_field( 'post', $meta_key ); } /** * Intercept add_post_meta — dual-write to zone table. * * @param mixed $check Whether to short-circuit. * @param int $post_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * @param mixed $unique Whether the meta key should be unique. Not used directly. * @return mixed Filtered check value. */ public function intercept_add( $check, int $post_id, string $meta_key, $meta_value, $unique ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by add_post_metadata filter signature. if ( self::$bypassing || $post_id <= 0 ) { return $check; } $post_type = get_post_type( $post_id ); if ( ! $post_type ) { return $check; } // v2.9.2 Entity Bridge guard — see is_owned_by_entity_bridge() docblock. if ( self::is_owned_by_entity_bridge( $meta_key ) ) { return $check; } $field = $this->get_field_cached( $post_type, $meta_key ); if ( ! $field ) { return $check; } $module = $this->get_zone_module( $field['zone'], $post_type ); if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) { return $check; } try { $this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value ); } catch ( \Throwable $e ) { TMDO_Logger::error( $module, 'add_post_metadata', $e->getMessage() ); } return $check; } /** * After a postmeta is deleted, remove from zone table too. * * Hooked to 'deleted_post_meta' (fires after native delete completes). * * @param int[] $meta_ids Array of deleted meta IDs. * @param int $post_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. Not used directly. * @return void */ public function intercept_delete( $meta_ids, int $post_id, string $meta_key, $meta_value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by deleted_post_meta action signature. if ( self::$bypassing || $post_id <= 0 ) { return; } $post_type = get_post_type( $post_id ); if ( ! $post_type ) { return; } $field = $this->get_field_cached( $post_type, $meta_key ); if ( ! $field ) { return; } // P1-24: skip zone cleanup when Hook Bus owns this key (aeav_only blocks native delete). if ( self::is_owned_by_entity_bridge( $meta_key ) ) { return; } $module = $this->get_zone_module( $field['zone'], $post_type ); if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) { return; } try { $this->delete_from_zone( $field, $post_id, $post_type, $meta_key ); } catch ( \Throwable $e ) { TMDO_Logger::error( $module, 'deleted_post_meta', $e->getMessage() ); } } /** * When a post is permanently deleted, clean up all zone data. * * @param int $post_id Post ID being deleted. * @return void */ public function cleanup_post( int $post_id ): void { $post_type = get_post_type( $post_id ); if ( ! $post_type ) { return; } $registry = TMDO_Schema_Registry::instance(); // Clean Hot zone. if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) { try { TMDO_Zone_Hot::delete( $post_id, $post_type ); } catch ( \Throwable $e ) { TMDO_Logger::error( 'hot_' . $post_type, 'before_delete_post', $e->getMessage() ); } } // Clean Cold zone. if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) { try { TMDO_Zone_Cold::delete( $post_id, $post_type ); } catch ( \Throwable $e ) { TMDO_Logger::error( 'cold_' . $post_type, 'before_delete_post', $e->getMessage() ); } } // Clean Warm zone. try { TMDO_Zone_Warm::delete_all( $post_id ); } catch ( \Throwable $e ) { TMDO_Logger::error( 'warm', 'before_delete_post', $e->getMessage() ); } // Clean Archive zone. try { TMDO_Zone_Archive::delete( $post_id ); } catch ( \Throwable $e ) { TMDO_Logger::error( 'archive', 'before_delete_post', $e->getMessage() ); } } // ── Private helpers ─────────────────────────────────────────────────── /** * Get a registered field with a request-level static cache. * * @param string $post_type Post type. * @param string $meta_key Meta key. * @return array|null Field definition, or null if not registered. */ private function get_field_cached( string $post_type, string $meta_key ): ?array { $cache_key = $post_type . ':' . $meta_key; if ( ! array_key_exists( $cache_key, self::$field_cache ) ) { self::$field_cache[ $cache_key ] = TMDO_Schema_Registry::instance()->get_field( $post_type, $meta_key ); } return self::$field_cache[ $cache_key ]; } /** * Derive module name from zone + post_type for feature flag lookups. * * @param string $zone Zone identifier (hot, cold, warm, archive). * @param string $post_type Post type. * @return string Module name. */ private function get_zone_module( string $zone, string $post_type ): string { return match ( $zone ) { 'hot' => 'hot_' . sanitize_key( $post_type ), 'cold' => 'cold_' . sanitize_key( $post_type ), 'warm' => 'warm', 'archive' => 'archive', default => 'unknown', }; } /** * Read a value from the appropriate zone. * * @param array $field Field definition from Schema Registry. * @param int $post_id Post ID. * @param string $post_type Post type. * @param string $meta_key Meta key. * @return mixed Value from zone or null. */ private function read_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): mixed { return match ( $field['zone'] ) { 'hot' => TMDO_Zone_Hot::get( $post_id, $post_type, $field['column'] ), 'cold' => TMDO_Zone_Cold::get( $post_id, $post_type, $meta_key ), 'warm' => TMDO_Zone_Warm::get( $post_id, $meta_key ), default => null, }; } /** * Write a value to the appropriate zone. * * @param array $field Field definition from Schema Registry. * @param int $post_id Post ID. * @param string $post_type Post type. * @param string $meta_key Meta key. * @param mixed $value Value to write. * @return void */ private function write_to_zone( array $field, int $post_id, string $post_type, string $meta_key, mixed $value ): void { match ( $field['zone'] ) { 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], $value ), 'cold' => TMDO_Zone_Cold::set( $post_id, $post_type, $meta_key, $value ), 'warm' => TMDO_Zone_Warm::set( $post_id, $meta_key, is_string( $value ) ? $value : wp_json_encode( $value ), $field['ttl'] ?? null ), default => null, }; } /** * Delete a value from the appropriate zone. * * @param array $field Field definition from Schema Registry. * @param int $post_id Post ID. * @param string $post_type Post type. * @param string $meta_key Meta key. * @return void */ private function delete_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): void { match ( $field['zone'] ) { 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], null ), 'cold' => TMDO_Zone_Cold::remove( $post_id, $post_type, $meta_key ), 'warm' => TMDO_Zone_Warm::delete( $post_id, $meta_key ), default => null, }; } }