field|false). * * @var array */ private static array $field_cache = array(); /** * Register all metadata hooks. * * Write hooks (update/add/delete) are always registered because Sync Bridge * handles zone-registered keys that may not be tracked by the Entity Bridge. * The per-call is_owned_by_entity_bridge() guard (cached) prevents double-writes * for keys that the Hook Bus owns when post mode is dual_write or higher. */ 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 ( TMDO_Routing_Predicate::entity_bridge_owns( $meta_key ) ) { return $value; } if ( ! TMDO_Routing_Predicate::should_read_from_zone( $post_type, $field['zone'] ) ) { return $value; } try { $zone_value = TMDO_Zone_Router::read( $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( TMDO_Zone_Router::module_name( $field['zone'], $post_type ), '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 — see ADR-001. Hook Bus owns this key when post // mode is dual_write or higher AND the key is in Entity Registry. if ( TMDO_Routing_Predicate::entity_bridge_owns( $meta_key ) ) { return $check; } $field = $this->get_field_cached( $post_type, $meta_key ); if ( ! $field ) { return $check; } if ( ! TMDO_Routing_Predicate::should_write_to_zone( $post_type, $field['zone'] ) ) { return $check; } // Write to zone table (non-fatal on failure). try { TMDO_Zone_Router::write( $field, $post_id, $post_type, $meta_key, $meta_value ); } catch ( \Throwable $e ) { TMDO_Logger::error( TMDO_Zone_Router::module_name( $field['zone'], $post_type ), '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; } /** * 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; } // Entity Bridge guard — see ADR-001. if ( TMDO_Routing_Predicate::entity_bridge_owns( $meta_key ) ) { return $check; } $field = $this->get_field_cached( $post_type, $meta_key ); if ( ! $field ) { return $check; } if ( ! TMDO_Routing_Predicate::should_write_to_zone( $post_type, $field['zone'] ) ) { return $check; } try { TMDO_Zone_Router::write( $field, $post_id, $post_type, $meta_key, $meta_value ); } catch ( \Throwable $e ) { TMDO_Logger::error( TMDO_Zone_Router::module_name( $field['zone'], $post_type ), '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 ( TMDO_Routing_Predicate::entity_bridge_owns( $meta_key ) ) { return; } if ( ! TMDO_Routing_Predicate::should_write_to_zone( $post_type, $field['zone'] ) ) { return; } try { TMDO_Zone_Router::delete_field( $field, $post_id, $post_type, $meta_key ); } catch ( \Throwable $e ) { TMDO_Logger::error( TMDO_Zone_Router::module_name( $field['zone'], $post_type ), '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; } TMDO_Zone_Router::delete_post( $post_id, $post_type ); } /** * 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 ]; } }