diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php
index ec92a55..b1707cd 100644
--- a/2meet-data-optimizer.php
+++ b/2meet-data-optimizer.php
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/2meet-data-optimizer
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
- * Version: 1.4.1
+ * Version: 1.4.2
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// ── Constants ──────────────────────────────────────────────────────────────
-define( 'TMDO_VERSION', '1.4.1' );
+define( 'TMDO_VERSION', '1.4.2' );
define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc5f5cf..22ae452 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,44 @@ Versioning follows [Semantic Versioning](https://semver.org/).
---
+## [1.4.2] — 2026-09-28 — 手冊已知問題修正(hardening 2.4.2)
+
+**性質**:修正。無 schema 版本變更;archive 表新增索引(見「升級後」)。來源:`2meet-do-manual.md`
+附錄 F(F-2、F-4〜F-8、F-11)。
+
+### Fixed
+
+- **設定頁會說明模式為何沒有變更**:完整表單與 AJAX 區塊儲存都經 `TMDO_Mode_Manager::set()`,
+ 過去忽略它回傳的 `WP_Error`:不安全的跳階或 Promotion Guard 拒絕時,頁面仍顯示「已儲存」、
+ 模式卻沒變。現在完整表單在重新導向後顯示拒絕原因(每位使用者一次),AJAX 儲存以錯誤訊息回傳。(PR-135,F-2)
+- **archive 不再重複歸檔**:`wpdo_archive_sweep` 略過已在 archive 表的 meta(依 `original_meta_id`),
+ 過去同一筆 meta 每次 sweep 都再寫一列(dev20:300 列只有 150 筆不同)。archive 表新增
+ `idx_original_meta` 索引;`wp tmdo install` 會補上索引並刪除重複列(保留最早一列)。(PR-136,F-6)
+- **entity 健康面板看得到執行中的 backfill**:批次以 `(entity, group)` 參數排程,面板過去只查
+ `(entity)`,永遠看不到、一直提示可開始。(PR-137,F-11)
+- **停用與解除安裝清掉所有排程**:改用 `wp_unschedule_hook()`,連帶參數排程的事件
+ (`tmdo_drift_resync`、backfill 批次)也清除;修正錯誤的 `wpdo_user_stress_test_batch` 名稱,
+ 補上 `wpdo_migration_tick`、`wpdo_entity_backfill_batch`、`wpdo_auto_promote_check` 與各壓測批次。
+ 解除安裝另外刪除 `tmdo_*` option 及 `tmdo_*` transient。(PR-138,F-4、F-5)
+- 解除安裝被拒絕時的提示改為指向 `wp tmdo bridge-restore-native`(過去說沒有寫回工具);post
+ 壓測工具不再使用已退役的 `wc_product` 表。(PR-139,F-7、F-8)
+
+### 升級後
+
+- 每個站台執行一次 `wp tmdo install`:為 archive 表加上 `idx_original_meta` 並刪除重複的 archive
+ 列(刪除重複只在 CLI 執行,web 啟用外掛時不做)。表很大時請在離峰時段執行。
+- **未執行前 archive sweep 會暫停**(沒有索引時 NOT EXISTS 會對每筆候選掃整張 archive 表)。
+ sweep 只複製、不刪 postmeta,暫停不會遺失資料。
+- 解除安裝的 option 清理保留 LatePoint AddOn 的 `tmdo_latepoint_db_version`;設定頁的 AJAX 錯誤訊息
+ 不再 2.5 秒後自動消失。(PR-141,驗收修正)
+
+### 已知、未修改
+
+- 已歸檔的 meta 以 `meta_id` 判斷:文章還原、修改後再次丟到回收桶,archive 保留的是第一次的值。
+- post 壓測工具預設仍是 `product`,但 product 已沒有 flat 表,量測會回報 `flat_table_missing`。
+
+---
+
## [1.4.1] — 2026-09-28 — 2.4 驗收修正
**性質**:修正。無 schema 變更。來源:2.4 全面驗收的 security review 與 correctness review。
diff --git a/admin/assets/wpdo-settings.js b/admin/assets/wpdo-settings.js
index 8cf7cc2..bab4d08 100644
--- a/admin/assets/wpdo-settings.js
+++ b/admin/assets/wpdo-settings.js
@@ -45,7 +45,7 @@
}
/**
- * Show a transient status message, then fade it out after 2.5s.
+ * Show a status message; success fades out after 2.5s, errors stay.
*
* @param {HTMLElement} statusEl
* @param {string} message
@@ -56,6 +56,9 @@
statusEl.style.color = isError ? 'var(--color-error, #b03d3d)' : 'var(--color-success, #5a8c5a)';
statusEl.classList.add( 'visible' );
clearTimeout( statusEl._wpdo_timer );
+ if ( isError ) {
+ return;
+ }
statusEl._wpdo_timer = setTimeout( () => {
statusEl.classList.remove( 'visible' );
}, 2500 );
@@ -92,7 +95,7 @@
if ( data.success ) {
showStatus( statusEl, i18n.saved, false );
} else {
- showStatus( statusEl, i18n.error, true );
+ showStatus( statusEl, ( data.data && data.data.message ) || i18n.error, true );
}
} )
.catch( () => {
diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php
index 27827ac..eeca602 100644
--- a/admin/class-tmdo-admin.php
+++ b/admin/class-tmdo-admin.php
@@ -398,17 +398,7 @@ class TMDO_Admin {
// v2.5.4: Entity Bridge settings (v2.11.0: post added).
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
- if ( class_exists( 'TMDO_Mode_Manager' ) ) {
- foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
- $new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
- ? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
- : '';
- if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
- TMDO_Mode_Manager::set( $entity_type, $new_mode );
- }
- }
- TMDO_Mode_Manager::reset_cache();
- }
+ self::remember_bridge_mode_refusals( self::apply_bridge_modes( self::posted_bridge_modes() ) );
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache();
}
@@ -3144,6 +3134,9 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
if ( isset( $_GET['wpdo_msg'] ) && 'settings_saved' === sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '
' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '
';
}
+ foreach ( self::pull_bridge_mode_refusals() as $refused ) {
+ echo '' . esc_html__( '模式未變更:', '2meet-data-optimizer' ) . esc_html( $refused ) . '
';
+ }
$enabled = class_exists( 'TMDO_Email_Notifier' ) && TMDO_Email_Notifier::is_enabled();
$email = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::recipient() : '';
$throttle = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::throttle_hours() : 24;
@@ -3743,22 +3736,33 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
}
}
- private static function save_section_entity_bridge(): void {
+ /**
+ * Save the Entity Bridge section (Hook Bus switch and bridge modes).
+ *
+ * @return string[] Refused changes, ":".
+ */
+ private static function save_section_entity_bridge(): array {
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
- if ( class_exists( 'TMDO_Mode_Manager' ) ) {
- foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
- $new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
- ? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
- : '';
- if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
- TMDO_Mode_Manager::set( $entity_type, $new_mode );
- }
- }
- TMDO_Mode_Manager::reset_cache();
- }
+ $refused = self::apply_bridge_modes( self::posted_bridge_modes() );
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache();
}
+ return $refused;
+ }
+
+ /**
+ * Bridge modes posted by the settings form, keyed by entity type ('' when absent).
+ *
+ * @return array
+ */
+ private static function posted_bridge_modes(): array {
+ $posted = array();
+ foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
+ $posted[ $entity_type ] = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
+ ? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
+ : '';
+ }
+ return $posted;
}
private static function save_section_hp_transient(): void {
@@ -3786,6 +3790,56 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
}
// phpcs:enable WordPress.Security.NonceVerification.Missing,Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.FunctionComment.WrongStyle
+ /**
+ * Apply posted bridge modes through TMDO_Mode_Manager::set(). Empty or
+ * unknown modes are skipped; refused changes (unsafe jump, Promotion Guard)
+ * are returned instead of being dropped silently (2.4.2 PR-135).
+ *
+ * @param array $posted Mode per entity type.
+ * @return string[] Refused changes, ":".
+ */
+ public static function apply_bridge_modes( array $posted ): array {
+ if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
+ return array();
+ }
+ $refused = array();
+ foreach ( $posted as $entity_type => $new_mode ) {
+ if ( ! TMDO_Mode_Manager::is_valid_mode( (string) $new_mode ) ) {
+ continue;
+ }
+ $result = TMDO_Mode_Manager::set( (string) $entity_type, (string) $new_mode );
+ if ( is_wp_error( $result ) ) {
+ $refused[] = $entity_type . ':' . $result->get_error_message();
+ }
+ }
+ TMDO_Mode_Manager::reset_cache();
+ return $refused;
+ }
+
+ /**
+ * Keep refused bridge-mode changes for the page the settings form redirects to.
+ *
+ * @param string[] $refused Refused changes.
+ * @return void
+ */
+ public static function remember_bridge_mode_refusals( array $refused ): void {
+ if ( $refused ) {
+ set_transient( 'wpdo_mode_refused_' . get_current_user_id(), $refused, 5 * MINUTE_IN_SECONDS );
+ }
+ }
+
+ /**
+ * Refused bridge-mode changes remembered for this user, once.
+ *
+ * @return string[]
+ */
+ public static function pull_bridge_mode_refusals(): array {
+ $key = 'wpdo_mode_refused_' . get_current_user_id();
+ $refused = get_transient( $key );
+ delete_transient( $key );
+ return is_array( $refused ) ? $refused : array();
+ }
+
/**
* AJAX: save a single settings section without a full page reload.
*/
@@ -3807,7 +3861,10 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
if ( ! isset( $handlers[ $section ] ) ) {
wp_send_json_error( 'invalid_section', 400 );
}
- self::{$handlers[ $section ]}();
+ $refused = self::{$handlers[ $section ]}();
+ if ( is_array( $refused ) && $refused ) {
+ wp_send_json_error( array( 'message' => __( '部分模式未變更:', '2meet-data-optimizer' ) . implode( ';', $refused ) ) );
+ }
wp_send_json_success( array( 'message' => __( '設定已儲存。', '2meet-data-optimizer' ) ) );
}
}
diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php
index da1244a..c5a01bd 100644
--- a/includes/class-tmdo-installer.php
+++ b/includes/class-tmdo-installer.php
@@ -217,6 +217,16 @@ class TMDO_Installer {
// Idempotent — only creates tables that don't already exist.
self::install_v2_tables();
+ // 1.4.2: archive sweeps before this release copied trashed meta daily.
+ // One unbounded DELETE: CLI only, never in a web activation.
+ if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'TMDO_Zone_Archive' ) ) {
+ global $wpdb;
+ $archive = TMDO_Zone_Archive::table();
+ if ( $archive === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $archive ) ) ) ) {
+ TMDO_Zone_Archive::remove_duplicates();
+ }
+ }
+
update_option( 'wpdo_db_version', self::SCHEMA_VERSION );
// Register wpdo_features with autoload=no so it doesn't inflate every page load.
@@ -771,31 +781,46 @@ class TMDO_Installer {
return $status;
}
+ /**
+ * Every cron hook the plugin schedules. Cleared with wp_unschedule_hook(),
+ * which also removes events scheduled with args (tmdo_drift_resync( 'post' ),
+ * backfill batches) that wp_clear_scheduled_hook( $hook ) leaves behind
+ * (2.4.2 PR-138).
+ */
+ private const CRON_HOOKS = array(
+ 'wpdo_warm_cleanup',
+ 'wpdo_archive_sweep',
+ 'wpdo_errors_gc',
+ 'wpdo_flush_views',
+ 'wpdo_remove_uae_plugin_dir',
+ 'wpdo_daily_health_check',
+ 'wpdo_snapshot_prune_daily',
+ 'wpdo_fsm_automator_run',
+ 'wpdo_collect_site_metrics',
+ 'wpdo_health_snapshot_monthly',
+ 'wpdo_post_shadow_verify',
+ 'wpdo_term_comment_shadow_verify',
+ 'tmdo_drift_resync',
+ 'tmdo_ensure_group_indexes',
+ 'wpdo_entity_backfill_batch',
+ 'wpdo_migration_tick',
+ 'wpdo_auto_promote_check',
+ 'wpdo_post_stress_test_batch',
+ 'wpdo_stress_test_batch',
+ 'wpdo_term_stress_test_batch',
+ 'wpdo_comment_stress_test_batch',
+ );
+
/**
* Called on plugin deactivation. Non-destructive.
*/
public static function deactivate(): void {
- wp_clear_scheduled_hook( 'wpdo_warm_cleanup' );
- wp_clear_scheduled_hook( 'wpdo_archive_sweep' );
- // v2.14.0: clear all plugin cron hooks (was incomplete pre-v2.14.0).
- wp_clear_scheduled_hook( 'wpdo_errors_gc' );
- wp_clear_scheduled_hook( 'wpdo_flush_views' );
- wp_clear_scheduled_hook( 'wpdo_remove_uae_plugin_dir' );
- wp_clear_scheduled_hook( 'wpdo_daily_health_check' );
- wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' );
- wp_clear_scheduled_hook( 'wpdo_fsm_automator_run' );
- wp_clear_scheduled_hook( 'wpdo_collect_site_metrics' );
- wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
- wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
- wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
- wp_clear_scheduled_hook( 'tmdo_drift_resync' );
- wp_clear_scheduled_hook( 'tmdo_ensure_group_indexes' );
- wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
- wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
- wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
- wp_clear_scheduled_hook( 'wpdo_comment_stress_test_batch' );
+ foreach ( self::CRON_HOOKS as $hook ) {
+ wp_unschedule_hook( $hook );
+ }
}
+
// ── v2.14.0: Multisite-aware site cleanup ─────────────────────────────────
/**
@@ -967,42 +992,26 @@ class TMDO_Installer {
// Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0
// or by 3rd-party hooks). Validates name pattern before delete to avoid
// accidental option removal.
- $residual = $wpdb->get_col(
- $wpdb->prepare(
- "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
- $wpdb->esc_like( 'wpdo_' ) . '%'
- )
- );
- foreach ( (array) $residual as $opt ) {
- if ( preg_match( '/^wpdo_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
- ++$options_deleted;
+ // 1.4.2: tmdo_* options and their transients too (tmdo_schema_deferred,
+ // tmdo_entity_indexes, tmdo_cov_* …) — a reinstall saw stale ones.
+ // tmdo_latepoint_* belongs to the LatePoint add-on.
+ foreach ( array( 'wpdo_', 'tmdo_', '_transient_tmdo_', '_transient_timeout_tmdo_' ) as $prefix ) {
+ $residual = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
+ $wpdb->esc_like( $prefix ) . '%'
+ )
+ );
+ foreach ( (array) $residual as $opt ) {
+ if ( preg_match( '/^(_transient_(timeout_)?)?(wpdo|tmdo)_(?!latepoint_)[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
+ ++$options_deleted;
+ }
}
}
// ── 5) Clear cron events ──────────────────────────────────────────────
- $crons = array(
- 'wpdo_warm_cleanup',
- 'wpdo_archive_sweep',
- 'wpdo_errors_gc',
- 'wpdo_flush_views',
- 'wpdo_remove_uae_plugin_dir',
- 'wpdo_daily_health_check',
- 'wpdo_snapshot_prune_daily',
- 'wpdo_fsm_automator_run',
- 'wpdo_collect_site_metrics',
- 'wpdo_health_snapshot_monthly',
- 'wpdo_post_shadow_verify',
- 'wpdo_term_comment_shadow_verify',
- 'tmdo_drift_resync',
- 'tmdo_ensure_group_indexes',
- 'wpdo_post_stress_test_batch',
- 'wpdo_user_stress_test_batch',
- 'wpdo_term_stress_test_batch',
- 'wpdo_comment_stress_test_batch',
- );
- foreach ( $crons as $hook ) {
- if ( false !== wp_next_scheduled( $hook ) ) {
- wp_clear_scheduled_hook( $hook );
+ foreach ( self::CRON_HOOKS as $hook ) {
+ if ( wp_unschedule_hook( $hook ) ) {
++$crons_cleared;
}
}
@@ -1440,7 +1449,8 @@ class TMDO_Installer {
original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_post_id (post_id),
- KEY idx_post_type_date (post_type, archived_at)
+ KEY idx_post_type_date (post_type, archived_at),
+ KEY idx_original_meta (original_meta_id)
) {$charset};";
foreach ( $sqls as $sql ) {
diff --git a/includes/class-tmdo-post-stress-tester.php b/includes/class-tmdo-post-stress-tester.php
index e483656..3e20750 100644
--- a/includes/class-tmdo-post-stress-tester.php
+++ b/includes/class-tmdo-post-stress-tester.php
@@ -872,7 +872,6 @@ final class TMDO_Post_Stress_Tester {
global $wpdb;
$prefix = $wpdb->prefix . 'wpdo_post_';
$map = array(
- 'product' => $prefix . 'wc_product',
'hp_listing' => $prefix . 'hp_listing_core',
'hp_request' => $prefix . 'hp_request_core',
'hp_vendor' => $prefix . 'hp_vendor_core',
@@ -1036,7 +1035,6 @@ final class TMDO_Post_Stress_Tester {
return array(
$prefix . 'wp_core',
$prefix . 'attachment',
- $prefix . 'wc_product',
$prefix . 'hp_listing_core',
$prefix . 'hp_request_core',
$prefix . 'hp_vendor_core',
diff --git a/includes/engine/class-tmdo-entity-health.php b/includes/engine/class-tmdo-entity-health.php
index e470ef7..4a5278a 100644
--- a/includes/engine/class-tmdo-entity-health.php
+++ b/includes/engine/class-tmdo-entity-health.php
@@ -325,7 +325,20 @@ final class TMDO_Entity_Health {
return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ];
}
+ /**
+ * Whether a backfill batch is scheduled for any group of the entity. Batches
+ * are scheduled with (entity, group) args, so looking for (entity) alone
+ * never found one (2.4.2 PR-137).
+ *
+ * @param string $entity_type Entity type.
+ * @return bool
+ */
private static function is_backfill_active( string $entity_type ): bool {
- return (bool) wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type ) );
+ foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
+ if ( wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type, $group ) ) ) {
+ return true;
+ }
+ }
+ return false;
}
}
diff --git a/includes/zones/class-tmdo-zone-archive.php b/includes/zones/class-tmdo-zone-archive.php
index ccce528..21a67f0 100644
--- a/includes/zones/class-tmdo-zone-archive.php
+++ b/includes/zones/class-tmdo-zone-archive.php
@@ -210,7 +210,15 @@ class TMDO_Zone_Archive {
global $wpdb;
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
+ // Rows already archived are skipped: the sweep used to copy the same
+ // trashed meta again every day (2.4.2 PR-136).
+ $table = self::table();
+ // Without the index each candidate scans the archive: wait for `wp tmdo install`.
+ if ( ! TMDO_IS_SQLITE && ! $wpdb->get_var( "SHOW INDEX FROM `{$table}` WHERE Key_name = 'idx_original_meta'" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- internal table name
+ return 0;
+ }
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
@@ -218,12 +226,14 @@ class TMDO_Zone_Archive {
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s
+ AND NOT EXISTS ( SELECT 1 FROM `{$table}` a WHERE a.original_meta_id = pm.meta_id )
LIMIT %d",
$cutoff,
$limit
),
ARRAY_A
);
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! $rows ) {
return 0;
@@ -245,6 +255,21 @@ class TMDO_Zone_Archive {
return count( $entries );
}
+ /**
+ * Delete archive rows that copy the same original meta row again, keeping
+ * the first copy (sweeps before 1.4.2 archived trashed meta every day).
+ * Rows without an original meta id are left alone.
+ *
+ * @return int Rows deleted.
+ */
+ public static function remove_duplicates(): int {
+ global $wpdb;
+ $table = self::table();
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
+ $deleted = $wpdb->query( "DELETE FROM `{$table}` WHERE original_meta_id > 0 AND id NOT IN ( SELECT keep_id FROM ( SELECT MIN(id) AS keep_id FROM `{$table}` WHERE original_meta_id > 0 GROUP BY original_meta_id ) AS k )" );
+ return is_int( $deleted ) ? $deleted : 0;
+ }
+
/**
* Get archive statistics.
*
diff --git a/readme.txt b/readme.txt
index f8df125..2086be0 100644
--- a/readme.txt
+++ b/readme.txt
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 8.1
-Stable tag: 1.4.1
+Stable tag: 1.4.2
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -107,6 +107,13 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
== Changelog ==
+= 1.4.2 =
+* The settings page shows why a bridge-mode change was refused (unsafe jump, Promotion Guard) instead of reporting it saved
+* Archive sweep skips meta already archived; `wp tmdo install` adds an index on original_meta_id and removes duplicate archive rows (the sweep waits until the index exists)
+* Entity health sees a running backfill (batches are scheduled with entity and group args)
+* Deactivate and uninstall clear every scheduled event, including ones scheduled with args; uninstall also removes tmdo_* options and transients
+* The refused-uninstall notice points to `wp tmdo bridge-restore-native`; the post stress tester no longer uses the retired wc_product table
+
= 1.4.1 =
* A query ordered by its meta_key among several orderings (or by the key's name) keeps meta_key native; meta_compare_key other than '=' is not routed; a sort group stays when orderby may use it as the primary clause
* Only standalone posts.ID terms in ORDER BY become flat post_id tie-breaks (FIELD()/IN expressions from other plugins are left alone); PCRE failures keep the original SQL
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index c1fbd05..447b53f 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -180,7 +180,7 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
}
}
if ( ! function_exists( 'wp_next_scheduled' ) ) {
- function wp_next_scheduled( string $hook ): int|false { return false; }
+ function wp_next_scheduled( string $hook, array $args = array() ): int|false { return $GLOBALS['_wp_next_scheduled'][ $hook ][ md5( serialize( $args ) ) ] ?? false; }
}
if ( ! function_exists( 'wp_schedule_event' ) ) {
function wp_schedule_event( int $t, string $r, string $h ): bool { return true; }
@@ -188,6 +188,7 @@ if ( ! function_exists( 'wp_schedule_event' ) ) {
if ( ! function_exists( 'wp_schedule_single_event' ) ) {
function wp_schedule_single_event( int $ts, string $hook ): bool { return true; }
}
+if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) {
function wp_clear_scheduled_hook( string $hook ): int|false { return 0; }
}
diff --git a/tests/integration/DriftCheckTest.php b/tests/integration/DriftCheckTest.php
index c646b4c..1f416d1 100644
--- a/tests/integration/DriftCheckTest.php
+++ b/tests/integration/DriftCheckTest.php
@@ -303,7 +303,8 @@ class DriftCheckTest extends TestCase {
}
// Literal: uninstall.php does not load TMDO_Drift_Check.
$installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' );
- $this->assertSame( 2, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'cleared on deactivate and uninstall' );
+ // One CRON_HOOKS list serves deactivate and uninstall (InstallerCleanupTest covers both).
+ $this->assertSame( 1, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'listed in CRON_HOOKS' );
}
public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void {
diff --git a/tests/integration/InstallerCleanupTest.php b/tests/integration/InstallerCleanupTest.php
index b95224b..be73e63 100644
--- a/tests/integration/InstallerCleanupTest.php
+++ b/tests/integration/InstallerCleanupTest.php
@@ -68,6 +68,50 @@ class InstallerCleanupTest extends TestCase {
$GLOBALS['_wp_options'] = array();
}
+ /**
+ * Events scheduled with args (tmdo_drift_resync( 'post' ), backfill
+ * batches) are not cleared by wp_clear_scheduled_hook( $hook ); the user
+ * stress hook was misnamed and three hooks were missing (2.4.2 PR-138, F-4).
+ */
+ private const ARG_AND_MISSED_HOOKS = array( 'tmdo_drift_resync', 'wpdo_entity_backfill_batch', 'wpdo_stress_test_batch', 'wpdo_migration_tick', 'wpdo_auto_promote_check' );
+
+ public function test_deactivate_unschedules_every_hook_whatever_its_args(): void {
+ $GLOBALS['_wp_unscheduled'] = array();
+
+ WPDO_Installer::deactivate();
+
+ foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
+ $this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
+ }
+ }
+
+ public function test_uninstall_unschedules_every_hook_whatever_its_args(): void {
+ $GLOBALS['_wp_unscheduled'] = array();
+
+ WPDO_Installer::drop_all_tables_for_current_blog();
+
+ foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
+ $this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
+ }
+ }
+
+ /** tmdo_* options and transients were left behind; a reinstall then saw stale deferrals and indexes (F-5). */
+ public function test_uninstall_removes_tmdo_options_and_transients(): void {
+ global $wpdb;
+ foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x', 'hivepress_settings', 'tmdo_latepoint_db_version' ) as $name ) {
+ $wpdb->insert( $wpdb->options, array( 'option_name' => $name, 'option_value' => '1' ) );
+ $GLOBALS['_wp_options'][ $name ] = '1';
+ }
+
+ WPDO_Installer::drop_all_tables_for_current_blog();
+
+ foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x' ) as $name ) {
+ $this->assertArrayNotHasKey( $name, $GLOBALS['_wp_options'], $name );
+ }
+ $this->assertArrayHasKey( 'hivepress_settings', $GLOBALS['_wp_options'], 'other plugins untouched' );
+ $this->assertArrayHasKey( 'tmdo_latepoint_db_version', $GLOBALS['_wp_options'], 'the LatePoint add-on owns it' );
+ }
+
public function test_drops_static_tables(): void {
global $wpdb;
diff --git a/tests/integration/ZoneArchiveIntegrationTest.php b/tests/integration/ZoneArchiveIntegrationTest.php
index 1c12e21..b932eb9 100644
--- a/tests/integration/ZoneArchiveIntegrationTest.php
+++ b/tests/integration/ZoneArchiveIntegrationTest.php
@@ -29,7 +29,8 @@ class ZoneArchiveIntegrationTest extends TestCase {
`original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
- KEY `archived_at` (`archived_at`)
+ KEY `archived_at` (`archived_at`),
+ KEY `idx_original_meta` (`original_meta_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
@@ -45,6 +46,69 @@ class ZoneArchiveIntegrationTest extends TestCase {
$GLOBALS['_wp_postmeta'] = [];
}
+ // ── sweep / duplicates (2.4.2 PR-136, manual F-6) ─────────────────────
+
+ /** Posts and postmeta for sweep(): post 10 trashed 200 days ago, post 11 published. */
+ private function seed_trashed_post(): void {
+ global $wpdb;
+ $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
+ $wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_modified_gmt DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
+ $wpdb->query( "CREATE TABLE `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
+ $old = gmdate( 'Y-m-d H:i:s', time() - 200 * DAY_IN_SECONDS );
+ $wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (10, 'hp_listing', 'trash', '{$old}'), (11, 'hp_listing', 'publish', '{$old}')" );
+ $wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (10, 'hp_price', '5'), (10, 'hp_rating', '4'), (11, 'hp_price', '9')" );
+ }
+
+ private function drop_posts(): void {
+ global $wpdb;
+ $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
+ }
+
+ private function archive_rows(): int {
+ global $wpdb;
+ return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' );
+ }
+
+ public function test_sweep_copies_each_trashed_meta_row_once(): void {
+ $this->seed_trashed_post();
+ try {
+ $this->assertSame( 2, WPDO_Zone_Archive::sweep( 90, false ) );
+ $this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ), 'already archived' );
+ $this->assertSame( 2, $this->archive_rows() );
+ } finally {
+ $this->drop_posts();
+ }
+ }
+
+ /** Until `wp tmdo install` adds the index, NOT EXISTS would scan the archive per candidate. */
+ public function test_sweep_waits_for_the_original_meta_index(): void {
+ global $wpdb;
+ $this->seed_trashed_post();
+ $wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `idx_original_meta`' );
+ try {
+ $this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ) );
+ $this->assertSame( 0, $this->archive_rows() );
+ } finally {
+ $wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD KEY `idx_original_meta` (`original_meta_id`)' );
+ $this->drop_posts();
+ }
+ }
+
+ public function test_remove_duplicates_keeps_the_first_copy_of_each_meta_row(): void {
+ WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
+ WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
+ WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
+ WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
+ WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' ); // original_meta_id 0: no identity, kept.
+ WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' );
+
+ $this->assertSame( 2, WPDO_Zone_Archive::remove_duplicates() );
+ $this->assertSame( 4, $this->archive_rows() );
+ $this->assertSame( 0, WPDO_Zone_Archive::remove_duplicates() );
+ }
+
// ── archive / get ─────────────────────────────────────────────────────
public function test_archive_stores_plain_entry(): void {
diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php
index 76fcb96..e7d2d4d 100644
--- a/tests/integration/bootstrap.php
+++ b/tests/integration/bootstrap.php
@@ -315,6 +315,7 @@ if ( ! function_exists( 'get_metadata' ) ) {
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
+if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
diff --git a/tests/unit/AdminBridgeModeSaveTest.php b/tests/unit/AdminBridgeModeSaveTest.php
new file mode 100644
index 0000000..ef7b8a4
--- /dev/null
+++ b/tests/unit/AdminBridgeModeSaveTest.php
@@ -0,0 +1,78 @@
+ true );
+ $GLOBALS['_wp_current_user_id'] = 7;
+ 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_an_accepted_change_is_applied_without_refusals(): void {
+ $this->modes( array( 'user' => 'disabled' ) );
+
+ $refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'dual_write' ) );
+
+ $this->assertSame( array(), $refused );
+ $this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
+ }
+
+ public function test_an_unsafe_jump_is_reported_and_the_mode_kept(): void {
+ $this->modes( array( 'post' => 'disabled' ) );
+
+ $refused = TMDO_Admin::apply_bridge_modes( array( 'post' => 'shadow_read' ) );
+
+ $this->assertCount( 1, $refused );
+ $this->assertStringStartsWith( 'post', $refused[0] );
+ $this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'post' ) );
+ }
+
+ public function test_empty_or_unknown_modes_are_ignored(): void {
+ $this->modes( array( 'term' => 'disabled' ) );
+
+ $this->assertSame( array(), TMDO_Admin::apply_bridge_modes( array( 'post' => '', 'term' => 'bogus' ) ) );
+ $this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'term' ) );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_a_promotion_refused_by_the_guard_is_reported(): void {
+ eval( 'namespace HivePress; class Core {}' );
+ $this->modes( array( 'user' => 'shadow_read' ) );
+
+ $refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'aeav_only' ) );
+
+ $this->assertCount( 1, $refused );
+ $this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
+ }
+
+ /** The full-page form redirects after saving: refusals wait in a per-user transient, shown once. */
+ public function test_refusals_are_kept_for_the_next_page_view_only(): void {
+ TMDO_Admin::remember_bridge_mode_refusals( array( 'post:refused' ) );
+
+ $this->assertSame( array( 'post:refused' ), TMDO_Admin::pull_bridge_mode_refusals() );
+ $this->assertSame( array(), TMDO_Admin::pull_bridge_mode_refusals() );
+ }
+}
diff --git a/tests/unit/EntityHealthBackfillTest.php b/tests/unit/EntityHealthBackfillTest.php
new file mode 100644
index 0000000..bf071d2
--- /dev/null
+++ b/tests/unit/EntityHealthBackfillTest.php
@@ -0,0 +1,44 @@
+ 'eh_a', 'type' => 'text' ) ) );
+ TMDO_Entity_Registry::register_group( 'user', 'eh_two', array( array( 'key' => 'eh_b', 'type' => 'text' ) ) );
+ }
+
+ protected function tearDown(): void {
+ $GLOBALS['_wp_next_scheduled'] = array();
+ TMDO_Entity_Registry::init();
+ }
+
+ private function backfill_active( string $entity_type ): bool {
+ $m = new ReflectionMethod( TMDO_Entity_Health::class, 'is_backfill_active' );
+ $m->setAccessible( true );
+ return (bool) $m->invoke( null, $entity_type );
+ }
+
+ public function test_a_backfill_scheduled_for_one_group_is_seen(): void {
+ $GLOBALS['_wp_next_scheduled']['wpdo_entity_backfill_batch'][ md5( serialize( array( 'user', 'eh_two' ) ) ) ] = time() + 5;
+
+ $this->assertTrue( $this->backfill_active( 'user' ) );
+ $this->assertFalse( $this->backfill_active( 'post' ), 'other entities are not affected' );
+ }
+
+ public function test_no_scheduled_batch_means_no_backfill(): void {
+ $this->assertFalse( $this->backfill_active( 'user' ) );
+ }
+}
diff --git a/uninstall.php b/uninstall.php
index c6e0a40..f1fe960 100644
--- a/uninstall.php
+++ b/uninstall.php
@@ -94,7 +94,7 @@ if ( $wpdo_blocked ) {
wp_die(
'' . esc_html( '2meet Data Optimizer 已停止解除安裝:以下資料只存在 wpdo_* 表,刪除外掛會一併刪除這些表。' ) . '
'
. '- ' . implode( '
- ', array_map( 'esc_html', $wpdo_blocked ) ) . '
'
- . '' . esc_html( '目前沒有把這些資料寫回 WordPress 原生表的自動工具。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '
'
+ . '' . esc_html( '請先以 wp tmdo bridge-restore-native 把資料寫回 WordPress 原生表,再解除安裝。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '
'
);
}