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:
@@ -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' );
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user