fix(2meet-data-optimizer): refuse aeav_only and cleanup on HivePress / WooCommerce sites

- Add TMDO_Promotion_Guard: HivePress models read every meta key with an
  empty-key get_*_meta() and WooCommerce does the same for products (and
  writes stock/sales with direct SQL), so on those sites native meta must
  stay authoritative. Promotion to aeav_only is refused in
  TMDO_Mode_Manager::set() (covers REST, CLI, phases, auto-promoter) for
  post/user/term/comment with HivePress and post/user with WooCommerce;
  TMDO_Post_Migration::cleanup() and the cleanup phase refuse too.
  Demotion stays open; filter tmdo_promotion_guard_allow overrides.
- Admin notice lists guarded entities already in aeav_only with the
  restore-native command (no forced demotion on read).
- Verify: PromotionGuardTest 8 OK; unit 668 OK, integration 480 OK,
  phpcs (phpcs.xml) 0, stan OK; dev36 live: 4 promotions + post-cleanup
  refused, wpdo_bridge_modes unchanged
- PR: PR-053
This commit is contained in:
2026-09-26 18:29:04 +08:00
parent f72a315986
commit 79a6c4d724
9 changed files with 384 additions and 0 deletions
+3
View File
@@ -234,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// ── v2.0.0 Engine + Adapters + Modules ───────────────────────────────────
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -292,6 +293,8 @@ if ( is_admin() ) {
TMDO_Deprecated_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
TMDO_Missing_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
TMDO_Promotion_Guard_Notice::register();
}
// ── WP-CLI ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,63 @@
<?php
/**
* Admin notice when an entity is in aeav_only on a HivePress / WooCommerce site.
*
* Those plugins read native meta directly, so on an aeav_only entity they read
* stale (or, after cleanup, missing) values. The Promotion Guard refuses new
* promotions; sites promoted before it existed are only warned — demoting on
* read would leave them reading whatever native meta is left.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Warns administrators about guarded entities that are already in aeav_only.
*/
final class TMDO_Promotion_Guard_Notice {
/**
* Hook the notice.
*
* @return void
*/
public static function register(): void {
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
}
/**
* Print the notice for administrators when a guarded entity is in aeav_only.
*
* @return void
*/
public static function render(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$violations = TMDO_Promotion_Guard::violations();
if ( empty( $violations ) ) {
return;
}
$lines = array();
foreach ( $violations as $entity_type => $integrations ) {
$lines[] = sprintf( '%s(%s):wp tmdo bridge-restore-native %s --demote', $entity_type, implode( ' / ', $integrations ), $entity_type );
}
printf(
'<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
esc_html(
sprintf(
/* translators: %s: "entity (integrations): command" list */
__( '以下實體處於 aeav_only,但這些外掛會直接讀取原生 meta,可能讀到過期或遺失的資料。請先備份,再把扁平資料寫回原生並降級:%s', '2meet-data-optimizer' ),
implode( ';', $lines )
)
)
);
}
}
@@ -140,6 +140,11 @@ final class TMDO_Mode_Manager {
);
}
$guarded = TMDO_Promotion_Guard::check_promotion( $entity_type, $new_mode );
if ( $guarded ) {
return $guarded;
}
$all = self::all();
$all[ $entity_type ] = $new_mode;
@@ -0,0 +1,140 @@
<?php
/**
* TMDO_Promotion_Guard — keeps native meta authoritative on HivePress /
* WooCommerce sites (ADR-004 "search-index mode").
*
* Both plugins read native meta without going through a keyed
* get_*_meta() call: HivePress models load every key with get_*_meta( $id ),
* WooCommerce does the same for products and writes stock / sales with direct
* SQL. The Hook Bus cannot serve those reads from flat tables, so on these
* sites an entity must never reach aeav_only and native rows must never be
* cleaned up. Demotion always stays open.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Refuses aeav_only promotion and native-meta cleanup where an integration
* reads native meta directly.
*/
final class TMDO_Promotion_Guard {
/**
* Entity types each integration reads natively.
*
* HivePress models (Post, User, Term, Comment) all load meta with an empty
* key; WooCommerce does so for products and customers.
*/
private const GUARDED = array(
'HivePress' => array( 'post', 'user', 'term', 'comment' ),
'WooCommerce' => array( 'post', 'user' ),
);
/**
* Check a mode change. Only promotion to aeav_only is guarded.
*
* @param string $entity_type Entity type.
* @param string $new_mode Target mode.
* @return WP_Error|null Error when refused.
*/
public static function check_promotion( string $entity_type, string $new_mode ): ?WP_Error {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $new_mode ) {
return null;
}
return self::refuse( $entity_type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
}
/**
* Check a cleanup of the entity's native managed meta rows.
*
* @param string $entity_type Entity type.
* @return WP_Error|null Error when refused.
*/
public static function check_cleanup( string $entity_type ): ?WP_Error {
return self::refuse( $entity_type, 'cleanup' );
}
/**
* Guarded entity types that are already in aeav_only (promoted before the
* guard existed). Native meta may be stale there.
*
* @return array<string, string[]> Entity type → integration names.
*/
public static function violations(): array {
$out = array();
foreach ( TMDO_Mode_Manager::all() as $type => $mode ) {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
continue;
}
$integrations = self::integrations_for( $type );
if ( ! empty( $integrations ) ) {
$out[ $type ] = $integrations;
}
}
return $out;
}
/**
* Active integrations that read this entity type natively.
*
* @param string $entity_type Entity type.
* @return string[]
*/
private static function integrations_for( string $entity_type ): array {
$active = array(
'HivePress' => TMDO_Compatibility::is_hivepress_active(),
'WooCommerce' => TMDO_Compatibility::is_woocommerce_active(),
);
$names = array();
foreach ( self::GUARDED as $name => $types ) {
if ( $active[ $name ] && in_array( $entity_type, $types, true ) ) {
$names[] = $name;
}
}
return $names;
}
/**
* Build the refusal, unless no integration applies or a filter allows it.
*
* @param string $entity_type Entity type.
* @param string $action 'aeav_only' or 'cleanup'.
* @return WP_Error|null
*/
private static function refuse( string $entity_type, string $action ): ?WP_Error {
$integrations = self::integrations_for( $entity_type );
if ( empty( $integrations ) ) {
return null;
}
/**
* Allow a guarded aeav_only promotion or cleanup anyway.
*
* @param bool $allow Default false.
* @param string $entity_type Entity type.
* @param string $action 'aeav_only' or 'cleanup'.
* @param string[] $integrations Integrations that read this entity natively.
*/
if ( apply_filters( 'tmdo_promotion_guard_allow', false, $entity_type, $action, $integrations ) ) {
return null;
}
return new WP_Error(
'promotion_guarded',
sprintf(
/* translators: 1: integration names, 2: entity type, 3: aeav_only or cleanup */
__( '%1$s 直接讀取原生 meta,%2$s 不能執行 %3$s:這類站台以原生 meta 為唯一資料來源(搜尋索引模式)。', '2meet-data-optimizer' ),
implode( ' / ', $integrations ),
$entity_type,
$action
)
);
}
}
@@ -228,6 +228,10 @@ final class TMDO_Post_Migration {
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
);
}
$guarded = TMDO_Promotion_Guard::check_cleanup( self::ENTITY_TYPE );
if ( $guarded ) {
throw new RuntimeException( esc_html( $guarded->get_error_message() ) );
}
global $wpdb;
$keys = self::get_managed_keys();
@@ -46,6 +46,10 @@ class TMDO_Phase_Cleanup extends TMDO_Migration_Phase_Base {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" );
}
$guarded = TMDO_Promotion_Guard::check_cleanup( $this->entity_type );
if ( $guarded ) {
throw new \RuntimeException( $guarded->get_error_message() );
}
if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) {
throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' );
}
+1
View File
@@ -613,6 +613,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// Engine (v2.0.0).
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
+1
View File
@@ -576,6 +576,7 @@ require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Promotion Guard: HivePress / WooCommerce read native meta directly
* (empty-key get_*_meta(), direct SQL), so on those sites native meta must
* stay authoritative — no aeav_only, no cleanup of native rows.
*
* Tests that declare HivePress / WooCommerce stand-in classes run in their own
* process so those classes do not leak into other tests.
*/
class PromotionGuardTest extends TestCase {
protected function setUp(): void {
if ( ! class_exists( 'TMDO_Promotion_Guard_Notice' ) ) {
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
}
// Mode changes log through error_log(); in a separate process PHPUnit
// treats that stderr output as an error.
ini_set( 'error_log', '/dev/null' );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_filter_callbacks'] = array();
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
TMDO_Mode_Manager::reset_cache();
}
private function modes( array $modes ): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
TMDO_Mode_Manager::reset_cache();
}
public function test_without_integrations_aeav_only_is_allowed(): void {
$this->modes( array( 'post' => 'shadow_read' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
$this->assertNull( TMDO_Promotion_Guard::check_cleanup( 'post' ) );
$this->assertSame( array(), TMDO_Promotion_Guard::violations() );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_hivepress_refuses_aeav_only_for_every_entity(): void {
eval( 'namespace HivePress; class Core {}' );
foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
$this->modes( array( $type => 'shadow_read' ) );
$result = TMDO_Mode_Manager::set( $type, 'aeav_only' );
$this->assertInstanceOf( WP_Error::class, $result, $type );
$this->assertSame( 'promotion_guarded', $result->get_error_code(), $type );
$this->assertStringContainsString( 'HivePress', $result->get_error_message(), $type );
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( $type ), "{$type} mode must not change" );
$this->assertSame( array( $type => 'shadow_read' ), $GLOBALS['_wp_options']['wpdo_bridge_modes'], "{$type} option must not change" );
}
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_woocommerce_guards_post_and_user_only(): void {
eval( 'class WooCommerce {}' );
$this->modes( array( 'post' => 'shadow_read', 'user' => 'shadow_read', 'term' => 'shadow_read' ) );
$this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
$this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'user', 'aeav_only' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'term', 'aeav_only' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_guard_allows_lower_modes_and_demotion(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'post' => 'disabled' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'shadow_read' ) );
$this->modes( array( 'post' => 'aeav_only' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ), 'the way out of aeav_only stays open' );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_filter_can_allow_a_guarded_promotion(): void {
eval( 'namespace HivePress; class Core {}' );
add_filter(
'tmdo_promotion_guard_allow',
static function ( $allow, $entity_type, $action, $integrations ) {
return 'user' === $entity_type && 'aeav_only' === $action && array( 'HivePress' ) === $integrations;
},
10,
4
);
$this->modes( array( 'user' => 'shadow_read', 'post' => 'shadow_read' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'user', 'aeav_only' ) );
$this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_cleanup_is_refused_even_in_aeav_only(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'post' => 'aeav_only', 'user' => 'aeav_only' ) );
$error = TMDO_Promotion_Guard::check_cleanup( 'post' );
$this->assertInstanceOf( WP_Error::class, $error );
try {
TMDO_Post_Migration::cleanup();
$this->fail( 'post cleanup must be refused' );
} catch ( RuntimeException $e ) {
$this->assertStringContainsString( 'HivePress', $e->getMessage() );
}
$job = array( 'log' => array(), 'options' => array() );
try {
( new TMDO_Phase_Cleanup( 'user' ) )->execute( $job );
$this->fail( 'user cleanup phase must be refused' );
} catch ( RuntimeException $e ) {
$this->assertStringContainsString( 'HivePress', $e->getMessage() );
}
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_sites_already_in_aeav_only_are_reported(): void {
eval( 'class WooCommerce {}' );
$this->modes( array( 'post' => 'aeav_only', 'term' => 'aeav_only' ) );
$this->assertSame( array( 'post' => array( 'WooCommerce' ) ), TMDO_Promotion_Guard::violations() );
ob_start();
TMDO_Promotion_Guard_Notice::render();
$html = (string) ob_get_clean();
$this->assertStringContainsString( 'post', $html );
$this->assertStringContainsString( 'bridge-restore-native post', $html );
}
public function test_notice_is_silent_without_violations(): void {
$this->modes( array( 'post' => 'aeav_only' ) );
ob_start();
TMDO_Promotion_Guard_Notice::render();
$this->assertSame( '', ob_get_clean() );
}
}