chore: initial snapshot of 2meet-data-optimizer v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
vendor/
node_modules/
.phpunit.result.cache
.phpcs-cache
*.log
*.bak
*.swp
.DS_Store
Thumbs.db
.idea/
.vscode/
.full-review/
.gstack/
.playwright-mcp/
.claude/
dist/
+331
View File
@@ -0,0 +1,331 @@
<?php
/**
* 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: 0.1.0
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
* Author: 2meet
* Author URI: https://2meet.io
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: 2meet-data-optimizer
* Domain Path: /languages
*
* TablePrefix: ^wpdo_(hot_|cold_|warm$|archive$|migrations$|errors$|benchmarks$|audit$|shadow_diffs$|site_metrics$|uni_options$|snapshots$|registry_meta$|migration_status$|user_points_ledger$|user_membership$|user_activity$|user_profile$|user_sso$|user_core_profile$|user_social$|user_commerce$|user_hp_user$|user_admin_prefs$|user_hot$|user_cold$|post_wp_core$|post_attachment$|post_nav_menu_item$|term_misc$|comment_misc$|demo_entity_counters$)
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '0.1.0' );
define( 'TMDO_DB_VERSION', '2.0.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
define( 'TMDO_FILE', __FILE__ );
define( 'TMDO_MIN_PHP', '8.1' );
define( 'TMDO_MIN_WP', '6.0' );
// 沿用既有 wp_wpdo_* schema(不 rename,與 wp-data-optimizer v2.16.0 共存相容)。
if ( ! defined( 'TMDO_TABLE_PREFIX' ) ) {
define( 'TMDO_TABLE_PREFIX', 'wpdo_' );
}
if ( ! defined( 'TMDO_CACHE_GROUP' ) ) {
define( 'TMDO_CACHE_GROUP', 'wpdo' );
}
/** True when running on the SQLite drop-in. */
define(
'TMDO_IS_SQLITE',
class_exists( 'WP_SQLite_Driver' ) ||
class_exists( 'WP_SQLite_DB' ) ||
class_exists( 'WP_SQLite_Translator' )
);
define( 'TMDO_IS_MYSQL', ! TMDO_IS_SQLITE );
// ── 跨外掛偵測訊號(dual-fire 對外保留 wp-data-optimizer 訊號)────────────
const TWO_MEET_DATA_OPTIMIZER_VERSION = '0.1.0';
const TWO_MEET_DATA_OPTIMIZER_FILE = __FILE__;
const TWO_MEET_DATA_OPTIMIZER_DB_VERSION = '1.2.0';
// 向後相容:對外 sister plugins 仍以 WP_DATA_OPTIMIZER_VERSION 偵測。
if ( ! defined( 'WP_DATA_OPTIMIZER_VERSION' ) ) {
define( 'WP_DATA_OPTIMIZER_VERSION', '0.1.0' );
}
if ( ! defined( 'WP_DATA_OPTIMIZER_FILE' ) ) {
define( 'WP_DATA_OPTIMIZER_FILE', __FILE__ );
}
if ( ! defined( 'WP_DATA_OPTIMIZER_DB_VERSION' ) ) {
define( 'WP_DATA_OPTIMIZER_DB_VERSION', '1.2.0' );
}
// PHP 版本守門。
if ( version_compare( PHP_VERSION, TMDO_MIN_PHP, '<' ) ) {
add_action(
'admin_notices',
static function () {
echo '<div class="notice notice-error"><p>';
printf(
/* translators: 1: required PHP version, 2: current PHP version */
esc_html__( '2meet Data Optimizer 需要 PHP %1$s 或更高版本(目前 %2$s)。', '2meet-data-optimizer' ),
esc_html( TMDO_MIN_PHP ),
esc_html( PHP_VERSION )
);
echo '</p></div>';
}
);
return;
}
// 互斥檢查:偵測既有 wp-data-optimizer v2.x 仍啟用時警告(avoid hook double-fire)。
add_action(
'admin_notices',
static function () {
if ( defined( 'WPDO_VERSION' ) && WPDO_VERSION !== TMDO_VERSION ) {
echo '<div class="notice notice-error"><p>';
esc_html_e( '⚠ 偵測到舊版 wp-data-optimizer 已啟用。請停用舊外掛以避免 hook 雙觸發。', '2meet-data-optimizer' );
echo '</p></div>';
}
}
);
// ── i18n ──────────────────────────────────────────────────────────────────
add_action( 'init', 'tmdo_load_textdomain' );
/**
* Load plugin text domain for translations.
*/
function tmdo_load_textdomain(): void {
load_plugin_textdomain(
'2meet-data-optimizer',
false,
dirname( plugin_basename( TMDO_FILE ) ) . '/languages'
);
}
// ── Core includes ─────────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/class-tmdo-capability.php';
require_once TMDO_PATH . 'includes/class-tmdo-crypto.php';
require_once TMDO_PATH . 'includes/class-tmdo-safe-unserialize.php';
require_once TMDO_PATH . 'includes/class-tmdo-db.php';
require_once TMDO_PATH . 'includes/class-tmdo-logger.php';
require_once TMDO_PATH . 'includes/class-tmdo-feature-flags.php';
require_once TMDO_PATH . 'includes/class-tmdo-sqlite-compat.php';
require_once TMDO_PATH . 'includes/class-tmdo-installer.php';
require_once TMDO_PATH . 'includes/class-tmdo-schema-registry.php';
require_once TMDO_PATH . 'includes/class-tmdo-custom-table-registry.php';
require_once TMDO_PATH . 'includes/trait-tmdo-anti-eav-aware.php';
require_once TMDO_PATH . 'includes/class-tmdo-hook-bus-bridge.php';
require_once TMDO_PATH . 'includes/class-tmdo-conflict-monitor.php';
require_once TMDO_PATH . 'includes/class-tmdo-compatibility.php';
require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-commentmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-comment-shadow-verifier.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-comment-backfill.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-comment-stress-tester.php';
// ── Interceptor base + Sync Bridge(核心唯一保留 interceptor)─────────────
require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php';
require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php';
// ── Zone handlers ─────────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-archive.php';
// ── Query interceptors(核心通用部分)─────────────────────────────────────
require_once TMDO_PATH . 'includes/query/class-tmdo-query-interceptor-base.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-query-router.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-post-query-router.php';
// ── Migration engine ──────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-base.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-engine.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-hot-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-warm-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-cold-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-archive-migration.php';
// ── Integrations(核心保留 6 個通用整合,無 HP/WC/LP/2meet 依存)────────
require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-garbage-filter.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-misc-bucket.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-member-fields.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-post-fields.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-points-manager.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-demo-entity-counter.php';
// ── Cache + Classifier ───────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/class-tmdo-cache-layer.php';
require_once TMDO_PATH . 'includes/class-tmdo-zone-classifier.php';
// ── Public API facade ─────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/class-tmdo-api.php';
// ── v2 Upgrader ───────────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/class-tmdo-v2-upgrader.php';
// ── Snapshot system ──────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-manager.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-writer.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-reader.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-pruner.php';
// ── FSM transition guard ─────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
// ── Site Health integration ──────────────────────────────────────────────
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-health.php';
add_action( 'init', array( 'TMDO_Site_Health', 'register' ) );
// ── Daily health probe cron ─────────────────────────────────────────────
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-health-cron.php';
// ── Notifiers ─────────────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/notifications/abstract-class-tmdo-notifier.php';
require_once TMDO_PATH . 'includes/notifications/class-tmdo-email-notifier.php';
require_once TMDO_PATH . 'includes/notifications/class-tmdo-slack-notifier.php';
require_once TMDO_PATH . 'includes/notifications/class-tmdo-discord-notifier.php';
require_once TMDO_PATH . 'includes/notifications/class-tmdo-telegram-notifier.php';
TMDO_Email_Notifier::register();
TMDO_Slack_Notifier::register();
TMDO_Discord_Notifier::register();
TMDO_Telegram_Notifier::register();
// ── Monthly executive summary ───────────────────────────────────────────
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-monthly-summary.php';
TMDO_Monthly_Summary::register();
// ── Daily site-wide EAV health metrics ──────────────────────────────────
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-metrics-collector.php';
// ── FSM advisor ─────────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/advisor/class-tmdo-fsm-advisor.php';
require_once TMDO_PATH . 'includes/advisor/class-tmdo-module-rules.php';
require_once TMDO_PATH . 'includes/advisor/class-tmdo-module-detector.php';
require_once TMDO_PATH . 'includes/advisor/class-tmdo-fsm-automator.php';
// ── Report export ──────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/export/class-tmdo-csv-writer.php';
if ( is_admin() ) {
require_once TMDO_PATH . 'admin/class-tmdo-export.php';
TMDO_Export::register();
}
// ── REST API ──────────────────────────────────────────────────────────────
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-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';
require_once TMDO_PATH . 'includes/engine/class-tmdo-cache-orchestrator.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) );
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-term.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-comment.php';
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
// ── Main loader ───────────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/class-tmdo-core.php';
// ── Admin UI ───────────────────────────────────────────────────────────────
if ( is_admin() ) {
require_once TMDO_PATH . 'admin/class-tmdo-admin.php';
require_once TMDO_PATH . 'admin/class-tmdo-help-tabs.php';
TMDO_Help_Tabs::register();
require_once TMDO_PATH . 'admin/class-tmdo-setup-wizard.php';
TMDO_Setup_Wizard::register();
require_once TMDO_PATH . 'admin/class-tmdo-dashboard-widget.php';
TMDO_Dashboard_Widget::register();
}
// ── WP-CLI ─────────────────────────────────────────────────────────────────
if ( defined( 'WP_CLI' ) && WP_CLI ) {
require_once TMDO_PATH . 'cli/class-tmdo-cli.php';
WP_CLI::add_command( 'tmdo', 'TMDO_CLI' );
// 向後相容:保留 `wp wpdo` 指令命名空間。
WP_CLI::add_command( 'wpdo', 'TMDO_CLI' );
require_once TMDO_PATH . 'cli/class-tmdo-cli-v2.php';
require_once TMDO_PATH . 'cli/class-tmdo-cli-member.php';
require_once TMDO_PATH . 'cli/class-tmdo-cli-post.php';
require_once TMDO_PATH . 'cli/class-tmdo-cli-term-comment.php';
}
// ── 對外向後相容:WPDO_* 類別 / 常數 alias ─────────────────────────────
require_once TMDO_PATH . 'includes/class-tmdo-back-compat.php';
// ── Boot plugin ────────────────────────────────────────────────────────────
/**
* Plugin boot — fires at plugins_loaded:4 (early enough for HPCT-era priority 5).
*/
function tmdo_run(): void {
$core = new TMDO_Core();
$core->run();
add_action(
'rest_api_init',
static function () {
( new TMDO_REST_API() )->register_routes();
}
);
}
add_action( 'plugins_loaded', 'tmdo_run', 4 );
// ── Late-bind safety net ──────────────────────────────────────────────────
// Re-fire registration hooks at priority 30 to catch AddOn / partner plugin
// listeners that bootstrap on plugins_loaded:6+ (after core but before
// init).
add_action(
'plugins_loaded',
static function () {
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
TMDO_Custom_Table_Registry::instance()->fire_registration();
}
// Re-fire entity field registration so AddOns hooked at plugins_loaded:6 catch up.
if ( class_exists( 'TMDO_Entity_Registry' ) ) {
do_action( 'wpdo_register_entity_fields', 'TMDO_Entity_Registry' );
do_action( 'tmdo_register_entity_fields', 'TMDO_Entity_Registry' );
}
if ( class_exists( 'TMDO_Schema_Registry' ) ) {
do_action( 'wpdo_register_fields', TMDO_Schema_Registry::instance() );
do_action( 'tmdo_register_fields', TMDO_Schema_Registry::instance() );
}
},
30
);
// ── Lifecycle hooks ────────────────────────────────────────────────────────
register_activation_hook( __FILE__, array( 'TMDO_Installer', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'TMDO_Installer', 'deactivate' ) );
// Multisite
if ( is_multisite() ) {
add_action( 'wp_initialize_site', array( 'TMDO_Installer', 'on_new_site' ), 999, 1 );
add_action( 'wp_uninitialize_site', array( 'TMDO_Installer', 'on_site_delete' ), 1, 1 );
}
+114
View File
@@ -0,0 +1,114 @@
# Changelog
All notable changes to this plugin will be documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning follows [Semantic Versioning](https://semver.org/).
---
## [0.1.0] — 2026-05-15 (initial release, Phase 0-5 完成)
### Phase 5 hotfix(同日完成)
-`TMDO_WooCommerce::doctor_check()` callback signature mismatchcli/class-tmdo-cli.php:264 改 call_user_func 傳 3 參數)
-`includes/back-compat/trait-wpdo-anti-eav-aware-alias.php` — trait + interface 反向相容(PHP class_alias 不支援 trait/interface
- `WPDO_Anti_EAV_Aware` trait wrapper 可讓 2meet-brandcards 等 sister plugin 直接 `use`
- `WPDO_Entity_Adapter_Interface` extends `TMDO_Entity_Adapter_Interface`
- 完整 `uninstall.php`(核心 244 行從 wp-data-optimizer 提煉,34 張表 DROP TABLE 對齊)
- HP AddOn + infocards AddOn `uninstall.php` 補 schema drift check 對齊
- **12 plugin ZIP 全通過 10 終檢**
### 12 ZIP 交付(dist/
| Plugin | Size | MD5 |
|---|---:|---|
| 2meet-data-optimizer | 521 KB | a10bb191e662cb5fb53a3d23165c71e5 |
| -hivepress-addon | 117 KB | 24bd8c3211421fed86dc551658d7064a |
| -woocommerce-addon | 34 KB | fbd6f60368fc93310f2e90e58c95b53c |
| -spoke-addon | 24 KB | c1e92b755fb1d108a141086ccf1fe519 |
| -hub-addon | 22 KB | 4e010c63ed8ad1dd24f06b8741e3a1b0 |
| -infocards-addon | 22 KB | 7f095fdc4748d3ff4cd4e05f1bf3d417 |
| -latepoint-addon | 21 KB | 60eeb4ea67cdc996cb60b6ad7126f2df |
| -bookings-addon | 21 KB | ac32b85d84e912d3b5a1cdfb56f9fa96 |
| -mobile-bridge-addon | 20 KB | 59905719cfee4bd31967736517391a43 |
| -collab-addon | 20 KB | 1ed0a2983a692a94b6c42f21b62af2c2 |
| -quotation-addon | 20 KB | 5e693a4df3ce48de447cf6277deaece5 |
| -playlist-addon | 20 KB | d52552782f383abbe8f278a5b85cafef |
### CLI smoke test12 指令 全 PASS
- `wp tmdo status / doctor / analyze / install / cleanup / health-snapshot / site-metrics / benchmark`
- `wp wpdo status / doctor` (alias 路徑)
- `wp tmdo spoke-sync --dry-run` / `wp tmdo spoke-force-logout --user-id=1 --dry-run`
---
## [0.1.0] — 2026-05-15 (initial release, Phase 0-4 完成)
### Added — 核心反 EAV 引擎(從 wp-data-optimizer v2.16.0 提煉)
- 4 Zone 分流(Hot / Warm / Cold / Archive+ Sync Bridge 雙寫
- 7 態 FSM 模組生命週期(idle → dual_write → backfill → verify → cutover → cleanup → complete
- 4 entity 通用引擎(post / user / term / comment+ Hook Bus + Mode Manager
- Query Router (`pre_get_posts` 改寫) + Query Compiler
- Migration Engine + Orchestrator + Backfill + Stress Tester + Shadow Verifier
- Snapshot / Safety / Diagnostic / Notifications (Email / Slack / Discord / Telegram)
- Crypto AES-256-GCM AEAD + Safe Unserialize
- WP-CLI 18+ subcommand
- Admin Dashboard + KPI Hero + Tab Groups + Setup Wizard + Help Tabs
### Added — 對外向後相容
- `TMDO_*` 類別 + `class_alias` 80+ 個 → 對外保留 `WPDO_*` 類別名
- 8 個常數 alias`WPDO_VERSION` / `WPDO_PLUGIN_DIR` / `WPDO_TABLE_PREFIX` 等)
- 9 個 hook dual-fire 橋接(`tmdo_register_fields``wpdo_register_fields` 等)
- WP-CLI 雙命名空間:`wp tmdo` (主) + `wp wpdo` (alias)
### Removed — 整合層搬出
- HivePress + 12 擴充整合(13 adapter + 7 interceptor + 5 query interceptor + bootstrap + admin + CLI)→ 搬到 `2meet-data-optimizer-hivepress-addon`
- WooCommerce 整合 → `2meet-data-optimizer-woocommerce-addon`
- LatePoint 整合 → `2meet-data-optimizer-latepoint-addon`
- 2meet-infocards / bookings / quotation / mobile-bridge / collab / playlist 整合 → 各自 6 個 AddOn
- Hub-core SSO group 與自訂表註冊 → `2meet-data-optimizer-hub-addon`
- Spoke-sso SSO group 與 CLI → `2meet-data-optimizer-spoke-addon`
- `TMDO_Core::register_hivepress_defaults()` (193 行) → HP AddOn
### Migration from `wp-data-optimizer v2.16.0`
- `wp_wpdo_*` 表結構完全相容(不 rename,新外掛沿用既有 schema
- 舊外掛先停用即可改用新外掛接管
- 兩外掛**互斥**:本外掛偵測舊外掛仍 active 時會發 admin noticeplan §8.1
### Limitations / Known issues (Phase 5 follow-up)
- `tests/` 目錄空殼,PHPUnit unit + integration tests 需後續搬遷
- `phpcs.xml.dist` 缺;`composer install --dev` 後可手動跑
- WP 6.5+ `Requires Plugins:` 強制 Plugin Name 比對;hub-core / spoke-sso 仍寫死 `wp-data-optimizer`,需更新或本 plugin 加偽 `Plugin Name: WP Data Optimizer` alias headerPhase 5
- `TMDO_WooCommerce::doctor_check()` signature 與 Custom_Table_Registry callback 不對齊(warning level
- Trait `Trait_TMDO_Anti_EAV_Aware` 暫無 alias wrapperclass_alias 不適用 trait
### Phase 0-4 統計
- **PHP files**: 203(核心 111 + Hub 5 + Spoke 7 + HP 47 + WC 8 + LP 5 + 6 family 各 5
- **PHP lint**: 全 PASS
- **Plugin active**: 12/12
- **Custom tables registered**: 14Hub 12 + Spoke 2,未含 6 family no-op
- **SSO group fields**: 7
- **Schema fields**: 23 (Hot 20 + Cold 3HP 未安裝 → HP fields no-op)
---
## 來源外掛累計重要 release(供考古)
詳見 `/var/www/Studio/wp-local-dev30/wp-content/plugins/wp-data-optimizer/CHANGELOG.md``CLAUDE.md`v2.0.0 → v2.16.0 共 47 次發版的累計改動。
亮點:
- v2.6.4 H-4 加密 webhook 密鑰
- v2.13.3 audit 7/7 finding 全清零
- v2.15.0 AES-256-CBC → AES-256-GCM AEAD
- v2.16.0 UI/UX KPI Hero + Grouped Tab Navigation
---
## [Unreleased]
### Phase 5 候補
- Tests 套件搬遷
- PHPCS 套件 + 0/0 達成
- 打包腳本 10 終檢驗收
- WPDO_ → TMDO_ migration CLI
+108
View File
@@ -0,0 +1,108 @@
# CLAUDE.md — 2meet Data Optimizer (核心)
> 父環境:`/var/www/Studio/wp-local-dev/CLAUDE.md` 與 `/var/www/Studio/wp-local-dev30/CLAUDE.md`
> 全域:`~/.claude/CLAUDE.md`(含 Karpathy Guidelines
---
## Reset recovery
1. 讀本檔(CLAUDE.md)取得 plugin 上下文
2. 讀 [PLAN.md](PLAN.md) 看當前 phase 進度
3. 接續 PLAN.md 的下一個未完成步驟
---
## Plugin 概述
**2meet-data-optimizer** 是純通用 WordPress 反 EAV 引擎,從 `wp-data-optimizer v2.16.0` 提煉。
- 不依存 HivePress / WooCommerce / LatePoint / 2meet-*
- 4 entity 通用(post/user/term/comment
- 整合層全部交給 11 個獨立 AddOn
完整拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md`
---
## 開發紀律
### 命名前綴
- 類別:`TMDO_*`
- 函式:`tmdo_*`
- 常數:`TMDO_VERSION` / `TMDO_PATH` / `TMDO_URL` / `TMDO_FILE`
- Hook`tmdo_*`(同時 dual-fire `wpdo_*` 過渡)
- Text-domain`2meet-data-optimizer`
### 對外相容
- 主要 API 公開:`TMDO_API``TMDO_Schema_Registry``TMDO_Entity_Registry``TMDO_Custom_Table_Registry``TMDO_Feature_Flags``TMDO_DB``TMDO_Crypto``TMDO_Logger`
- **必加 `class_alias( 'TMDO_*', 'WPDO_*' )`** 對應全部上述 8 個類別
- **必 dual-fire** `tmdo_*``wpdo_*` 共 9 個 hook(見 plan § 11.2
### 反 EAV 紅線(CI gate 守門)
-`SELECT FROM wp_postmeta / wp_usermeta / wp_termmeta / wp_commentmeta`(除一次性 migration / cron
- ❌ 硬編碼表名 `wp_postmeta` / `wp_options`,必走 `TMDO_DB::table()`
- ❌ 主動 `add_action('updated_postmeta', ...)`(與 Hook Bus 衝突)
- ❌ 單外掛 `autoload=yes``wp_options` > 30 條
- ❌ 直接 `unserialize()`,必走 `TMDO_Safe_Unserialize::run()`
### 安全
- 所有 SQL 走 `$wpdb->prepare()`
- 動態 IN 子句用 `array_fill('%d')` 動態佔位符
- DROP TABLE 前白名單 + prefix 雙重驗證
- `manage_options` capability check + nonce
- 輸出 `esc_html()` / `esc_attr()` / `esc_url()`
### Crypto
- `TMDO_Crypto`AES-256-GCM AEAD`enc:v2:` 格式),向後相容讀取 `enc:v1:` (CBC)
- 加密 wp_options 內 webhook 密鑰
---
## 任務管理
- 開始任務前更新 `PLAN.md`
- 每完成一步驟,標記 ✅
- 重要決策 / 踩坑記錄在 PLAN.md「Lessons learned」
---
## 常用指令
```bash
# Lint
find . -name "*.php" | grep -v vendor | grep -v tests | xargs php -l
# Unit tests
./vendor/bin/phpunit --configuration phpunit.xml
# Integration tests(需本機 MariaDB
./vendor/bin/phpunit --configuration phpunit-integration.xml
# WP-CLI
wp --path=/var/www/Studio/wp-local-dev30 tmdo status
wp --path=/var/www/Studio/wp-local-dev30 tmdo doctor
wp --path=/var/www/Studio/wp-local-dev30 tmdo benchmark hot_post --samples=200
# 打包(必經 10 終檢)
/var/www/Studio/wp-local-dev/scripts/package-plugin.sh 2meet-data-optimizer
```
---
## 邊界
- ✅ 本外掛觸碰:4-entity 反 EAV 引擎、zone 表、migration、wizard、admin、CLI、REST、snapshot、diagnostic、notifications
- ❌ 本外掛**不**觸碰:HP / WC / LP / 2meet-* 邏輯(全部交給 AddOn
- ❌ 本外掛**不**註冊:HP listing 欄位、WC product 欄位、SSO sso group、infocards postmeta(全部交給 AddOn
---
## Phase 進度
```
Phase 0 ✅ 骨架建立 (2026-05-15)
Phase 1 ⬜ Core v0.1.0 (W1-W2)
```
詳見 `PLAN.md`
+121
View File
@@ -0,0 +1,121 @@
# 部署流程
## Prerequisites
- WordPress ≥ 6.0
- PHP ≥ 8.1
- MySQL ≥ 5.7 / MariaDB ≥ 10.3
- 站台目前已停用既有 `wp-data-optimizer`(若有)
> ⚠️ 兩個外掛**互斥**:本外掛啟動時偵測到 `WPDO_Core` 已存在會發 admin notice 要求停用舊外掛。
---
## 全新站台部署
```bash
# 上傳 + 啟用
wp plugin install /path/to/2meet-data-optimizer-v0.1.0.zip --activate
# 驗證
wp tmdo doctor
wp tmdo status
# 第一次健診
wp tmdo benchmark --samples=100
```
完成。系統表會自動 dbDelta 建立,無需手動操作。
---
## 從 wp-data-optimizer v2.16.0 接管
兩種策略:
### 策略 A — 退場式接管(推薦)
```bash
# 1. 停用舊外掛
wp plugin deactivate wp-data-optimizer
# 2. 上傳新外掛 + 啟用
wp plugin install /path/to/2meet-data-optimizer-v0.1.0.zip --activate
# 3. 新外掛偵測既有 wp_wpdo_* 表,自動 dbDelta 對齊(無資料變動)
wp tmdo doctor
# 4. 驗證 4-entity migration mode 仍存在
wp tmdo user-mode-status
wp tmdo post-mode-status
# 5. 移除舊外掛資料夾(保留 git 紀錄)
wp plugin delete wp-data-optimizer
```
### 策略 B — 並行驗證(謹慎環境)
```bash
# 1. 新外掛安裝但暫不啟用
wp plugin install /path/to/2meet-data-optimizer-v0.1.0.zip
# 2. 在 staging 跑 verify
wp --path=/srv/staging tmdo doctor
wp --path=/srv/staging tmdo benchmark --samples=200
# 3. staging 過 24h watch 後切回 production,按策略 A 接管
```
---
## 安裝整合 AddOn
整合 AddOn 採 `Requires Plugins:` 強制相依,需先啟用本核心:
```bash
# 例:安裝 HivePress AddOn
wp plugin install /path/to/2meet-data-optimizer-hivepress-addon-v0.1.0.zip --activate
# 驗證 HP 13 adapter 已 bound
wp tmdo hivepress detect
```
---
## Rollback
```bash
# 1. 停用新外掛
wp plugin deactivate 2meet-data-optimizer
# 2. 重啟舊外掛(資料完全相容)
wp plugin activate wp-data-optimizer
# 3. 驗證
wp wpdo doctor
```
每個 AddOn 卸載時**僅清自己的 schema**(系統表保留給 core / 其他 AddOn)。
---
## 監控
```bash
# 每日健康檢查 cron
wp tmdo health-cron status
wp tmdo health-cron run
# 衝突偵測
wp tmdo conflict-scan
# Hook Bus 健診
wp tmdo bridge-status
```
production 站台建議啟用通知:
```bash
wp option update wpdo_slack_webhook 'https://hooks.slack.com/services/...'
wp option update wpdo_alert_threshold 'warning'
```
+130
View File
@@ -0,0 +1,130 @@
# DESIGN — 2meet Data Optimizer 架構決策
## 設計目標(pillars
1. **Zero-side-effect**:未啟用任何 zone 的站台應感受不到此外掛存在
2. **Non-destructive**:所有操作可 rollbacksnapshot + 7-state FSM
3. **Schema-first**:所有欄位需明確 register,禁止隱式 mapping
4. **Hook-bus only**:所有寫入必經 Hook Bus;禁止繞過 interceptor 直 SQL
5. **Anti-EAV strict**:紅線禁止 `SELECT FROM wp_*meta`(除一次性 migration / cron
---
## 七態 FSM 模組生命週期
```
┌──────────────────────────────────────────────┐
│ │
v │
idle ──→ dual_write ──→ backfill ──→ verify ──→ cutover ──→ cleanup ──→ complete
│ │
└─ rollback ─┘
```
| 態 | 寫 | 讀 | 可逆 |
|---|---|---|---|
| idle | wp_*meta | wp_*meta | ✓ |
| dual_write | wp_*meta + zone | wp_*meta | ✓ |
| backfill | wp_*meta + zone | wp_*meta | ✓ |
| verify | wp_*meta + zone | wp_*meta | ✓ |
| cutover | wp_*meta + zone | zone | ✓ (rollback to verify) |
| cleanup | zone only | zone | ✗ (postmeta 已 DROP) |
| complete | zone only | zone | ✗ |
---
## 四象限分配原則
| Zone | 條件 | 範例 |
|---|---|---|
| Hot | 同欄位被 meta_query 過濾或 ORDER BY 排序 | `_price`, `hp_featured` |
| Warm | 高頻寫入 / 短 TTL / 弱一致性 | view counter, hourly flag |
| Cold | 長文本 / 不被搜尋 / 顯示用 | `_description`, social links JSON |
| Archive | 過期資料 / gzip 壓縮 | expired listings 歷史欄位 |
`TMDO_Zone_Classifier` 自動分析(transient 1h),但人工 register 永遠優先。
---
## Hook Bus 架構
Hook Bus 是寫入唯一真理之源。任何外掛要參與反 EAV 必須:
```php
// 註冊
add_action( 'tmdo_register_entity_fields', function ( $registry_class ) {
$registry_class::register_group( 'user', 'my_group', [...] );
} );
// 訂閱事件
add_action( 'tmdo_after_write', function ( $type, $id, $key, $value, $result ) {
// ... cross-domain logic
} );
```
禁止直接 hook `update_user_meta` / `add_post_meta`(會與 Hook Bus 衝突,CI gate 偵測)。
---
## Schema Registry 雙軌
- **`TMDO_Schema_Registry`**post entity 的 zone 欄位(HP listing 模式)
- **`TMDO_Entity_Registry`**4 entity 通用 groupuser/post/term/comment
兩者並存原因:post zone 模式較成熟,user/term/comment 較新;最終 v0.3.0 會收斂為單一 registry。
---
## Custom Table Registry
第三方外掛的自訂表可註冊到 `TMDO_Custom_Table_Registry` 享有:
- `wp tmdo doctor` 表結構檢查
- Schema drift 偵測(CREATE 與 DROP 對齊)
- Benchmark 整合(可選 `benchmark_callback`
- 衝突偵測
僅限 schema 監控,不會被 Hook Bus 攔截寫入。
---
## 為什麼資料表沿用 `wp_wpdo_*` 不 rename 為 `wp_tmdo_*`
- 避免 v2.16.0 → v0.1.0 資料遷移的成本與風險
- 兩外掛同時存在時 schema 完全相容(互斥啟動)
- v0.2.0 後若決定 rename 再做(屆時提供 `wp tmdo migrate-from-wpdo`
---
## 為什麼公開 API 同時暴露 `TMDO_API` + `WPDO_API` (alias)
- `class_alias( 'TMDO_API', 'WPDO_API' )` 確保 hub-core / spoke-sso / 其他 consumer 零修改可用
- `wpdo_*` hook 與 `tmdo_*` hook 並存 dual-fire
- 過渡期 ≥ 2 個 minor 版本後加 `_doing_it_wrong` deprecation notice
---
## CSS 設計系統
承襲父環境奶油莫蘭迪設計系統(見 `wp-local-dev/CLAUDE.md` § 設計系統):
- 顏色:`var(--color-*)`,禁止 HEX 硬編碼
- 間距:`var(--space-*)` 8px 倍數
- 圓角:`var(--radius-*)`
- 按鈕最小高度 44px
- 過渡:`var(--ease-default) + var(--duration-normal)`
詳見 `admin/assets/wpdo-admin.css`(從 wp-data-optimizer v2.16.0 搬入)。
---
## 反 EAV 8 維評分(自評)
承襲 wp-data-optimizer 8 維 anti-EAV 評分標準,目標 v0.1.0 達 9.5+/10
1. ✅ Schema-first registration
2. ✅ Hook Bus single source of truth
3. ✅ No direct `wp_*meta` SELECT in app code
4. ✅ Custom table registry 全覆蓋
5. ✅ Sync Bridge dual-write + cutover
6. ✅ Query Router pre_get_posts 改寫
7. ✅ Snapshot + rollback 機制
8. ✅ Conflict detection + CI gate
+201
View File
@@ -0,0 +1,201 @@
# PLAN — 2meet Data Optimizer (核心)
> 完整四階段拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md`
---
## Current state
- **Version**: 0.1.0 (scaffold + 4 phase 完成 2026-05-15)
- **Phase**: 全 4 phase 已完成
- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉
---
## Phase 0 ✅ — 骨架建立 (2026-05-15)
- [x] 12 plugin 目錄結構(核心 + 11 AddOn
- [x] 主檔 `2meet-data-optimizer.php` + Plugin Header
- [x] `includes/class-tmdo-bootstrap.php` stub
- [x] `uninstall.php` stub
- [x] `composer.json`
- [x] `.gitignore`
- [x] 7 件 MDREADME / PLAN / CHANGELOG / DEPLOY / SECURITY / DESIGN / CLAUDE
---
## Phase 1 ✅ — Core v0.1.0 (2026-05-15)
- [x] rsync 從 wp-data-optimizer 複製 ~110 個核心檔案,排除 HP/WC/LP/2meet 整合
- [x] 檔名 `class-wpdo-*``class-tmdo-*`(含 abstract / interface / trait
- [x] sed 內容批次 rename`WPDO_``TMDO_`class + 常數,hook/table/option 前綴 `wpdo_` 保留共用)
- [x] 常數 `TMDO_PLUGIN_DIR``TMDO_PATH` 等對齊
- [x] Text-domain `'wp-data-optimizer'``'2meet-data-optimizer'`
- [x] 主檔重寫成完整 loaderrequire chain ~150 行)
- [x]`tmdo_run()` boot 函式 + `plugins_loaded:4` action
- [x]`late-bind safety net` (priority 30) 確保 AddOn listener 收到 hook
- [x]`includes/class-tmdo-back-compat.php` — 80+ class_alias + 8 常數 alias + 9 hook dual-fire
- [x] 移除 `register_hivepress_defaults()` (193 行) → 搬到 HP AddOn
- [x] 把 core 內所有 HP/WC/LP 整合 class 引用加 `class_exists` 守門
- [x] PHP lint111 files PASS
- [x] Live test`wp tmdo doctor` 全 PASS、`wp wpdo` (alias) 通
**Phase 1 DoD:✅ 全達標**(除舊 unit/integration test 套件需後續 phase 1 follow-up
---
## Phase 2 ✅ — Hub + Spoke AddOn v0.1.0 (2026-05-15)
### Hub AddOn (`2meet-data-optimizer-hub-addon`)
- [x] `TMDO_Hub_Bootstrap` 主啟動類別
- [x] `TMDO_Hub_Vendor_Field` — 註冊 `_2meet_global_vendor_id` (hp_vendor / cold zone / indexed)
- [x] `TMDO_Hub_Custom_Tables` — 註冊 12 張 `2mhc_*` 自訂表(fallback 列表 + delegate to hub-core helper if available
- [x] dual-emit 支援 (`tmdo_*` + `wpdo_*` 兩個 hook 路徑)
- [x] PHP lint PASS
### Spoke AddOn (`2meet-data-optimizer-spoke-addon`)
- [x] `TMDO_Spoke_Bootstrap` 主啟動類別
- [x] `TMDO_Spoke_SSO_Group` — 註冊 user entity `sso` group7 欄位 → wp_wpdo_user_sso
- [x] `TMDO_Spoke_Custom_Tables` — 註冊 `2mso_sso_config` / `2mso_user_mapping`
- [x] `TMDO_Spoke_CLI::sync()` — 從舊 `_tmso_*` usermeta 遷移
- [x] `TMDO_Spoke_CLI::force_logout()` — 設置 token_expires_at = 過去
- [x] CLI 雙命名空間:`wp tmdo spoke-*` + `wp wpdo spoke-*`
- [x] PHP lint PASS
**Phase 2 Live test 結果:**
- ✅ Hub: 12 hub tables 全部 OK
- ✅ Spoke: SSO group 7 fields registered, 2 tables registered
- ✅ CLI: `wp tmdo spoke-sync --dry-run` 跑通,`spoke-force-logout --user-id=1 --dry-run` 跑通
---
## Phase 3 ✅ — HP + WC + LP AddOn v0.1.0 (2026-05-15)
### HivePress AddOn (`2meet-data-optimizer-hivepress-addon`, ~7,400 行)
- [x] 複製 13 adaptercore / reviews / bookings / messages / memberships / requests / favorites / statistics / tags / seo / social-links / blocks / marketplace
- [x] 複製 7 HPCT interceptorreviews / messages / favorites / memberships / statistics / requests / listing-meta
- [x] 複製 5 query interceptorreviews / messages / memberships / requests / listing-meta
- [x] 複製 補助組件(bootstrap framework / detector / conflict-guard / comment-router / cron-optimizer / attribute-bridge / suitability-scorer / benchmark / rest
- [x] 複製 admin tab + CLI namespace
- [x] 複製 HP transient filter / HPCT import / Listing Stats / term-comment-fields
- [x] `TMDO_HP_Bootstrap` outer bootstrap(避免與 inner family bootstrap class 衝突)
- [x] PHP lint46 files PASS
### WooCommerce AddOn (`2meet-data-optimizer-woocommerce-addon`, ~1,316 行)
- [x] 複製 4 個 WC 整合檔
- [x] `TMDO_Woocommerce_Bootstrap` 載入 + register
- [x] PHP lint PASS
### LatePoint AddOn (`2meet-data-optimizer-latepoint-addon`, ~151 行)
- [x] 複製 LatePoint interceptor
- [x] `TMDO_Latepoint_Bootstrap` 載入 + register_hooks
- [x] PHP lint PASS
**Phase 3 Live test 結果:**
- ✅ Plugin 全 active
- ✅ Hot zone 20 fields registeredmostly WC product/order
- ✅ Cold zone 3 fields
- ✅ Total 23 fields
- ✅ HP no-op when HivePress not installeddetector check
---
## Phase 4 ✅ — 6 個 2meet 家族 AddOn v0.1.0 (2026-05-15)
| AddOn | Source | Status |
|---|---|---|
| infocards | class-tmdo-infocards.php | ✅ |
| bookings | class-tmdo-bookings.php | ✅ |
| quotation | class-tmdo-quotation.php | ✅ |
| mobile-bridge | class-tmdo-mobile-bridge.php | ✅ |
| collab | class-tmdo-collab.php | ✅ |
| playlist | class-tmdo-playlist.php | ✅ |
每個 AddOn 都有:
- `TMDO_{Module}_Bootstrap` 啟動類別
- 核心整合 class(從 wp-data-optimizer 搬入)
- partner detection (early return if target plugin not installed)
- `register()``wpdo_register_fields` / `wpdo_register_custom_tables`
**Phase 4 Live test 結果:**
- ✅ 6 AddOn 全 active
- ✅ Target plugin 未安裝時正確 no-op (designed behavior)
- ✅ 0 fatal、0 lint error
---
## 整體驗收(2026-05-15 末)
| 指標 | 結果 |
|---|---|
| 12 plugin active | ✅ |
| PHP lint 全 files | ✅ **203 files PASS** |
| `wp tmdo` CLI | ✅ 18 個 subcommand 全 listed |
| `wp wpdo` alias CLI | ✅ 通 |
| `wp tmdo doctor` | ✅ system tables 全綠 + 14 partner tables 註冊 |
| `wp tmdo status` | ✅ 23 fields registered |
| SSO group 7 欄位 | ✅ 全 7 欄位(含 hub_global_user_id searchable |
| Hub-addon 12 表 | ✅ 全 12 表存在 + doctor PASS |
| Spoke-addon 2 表 | ✅ 註冊 OKDB 表 missing 是 spoke-sso 從未啟用之故) |
| WC 20 表 | ✅ 註冊 OKDB 表 missing 是 WC 未安裝) |
| 6 family AddOn no-op | ✅ target 未安裝時正確 early return |
---
## Lessons learned
### Phase 1
- rsync 的 `--exclude``tests/``tools/` top-level 不生效(因為 source 路徑沒這個前綴),需後續 rm -rf 清理
- sed 全檔大寫 `WPDO_``TMDO_` 不影響 hook/table/option 小寫前綴 `wpdo_`,確保資料相容
- 主檔的 require chain 無法以 sed 自動產出(需手動寫,因為 require 路徑與檔名變動)
- TMDO_Core::run() 內有 8 處對「整合層 class」的硬引用,需逐個加 `class_exists` 守門
- `class_alias()` 對 trait 與 interface 不適用(需 wrapping wrapper,留 Phase 1 follow-up
- WP 6.5+ `Requires Plugins:` 強制檢查 Plugin Name 而非 slughub-core / spoke-sso 仍寫死 `Requires Plugins: wp-data-optimizer` 故無法直接啟用 → Phase 5 計畫請求 hub-core / spoke-sso 端更新或加 `or 2meet-data-optimizer` 邏輯
### Phase 2
- inner Hub class `TMDO_Hub_Bootstrap` 需與 outer AddOn bootstrap 區分名稱(衝突避免)
- `TMDO_Entity_Registry::register_group()` 是公開 API;其他 read methods (`get_group_fields` not `get_fields`) 命名不直觀
### Phase 3
- HP family 內部已有 `TMDO_HivePress_Bootstrap` classv3.0.0 family bootstrap),故 outer AddOn bootstrap 改名 `TMDO_HP_Bootstrap` 避免衝突
### Phase 4
- 所有 family integration class 皆有 partner detection (e.g. `class_exists('TMEETIC_Plugin')`) → AddOn 無需自己重複偵測
---
## Phase 5 follow-up 執行狀態(2026-05-15
| # | 項目 | 狀態 |
|---|---|---|
| 1 | **Tests 套件搬遷**`tests/unit/``tests/integration/` 重命名 + path 修正 | ✅ **完成** |
| 2 | PHPCS 套件:補 phpcs.xml.dist + WordPress coding standards | ⬜ 待執行 |
| 3 | **PHPUnit bootstrap**:完整重建 unit/integration test 環境 | ✅ **完成** |
| 4 | DB version migration`TMDO_DB_VERSION` 對齊 `SCHEMA_VERSION = '2.0.0'` | ✅ **完成** |
| 5 | `Requires Plugins: 2meet-data-optimizer` — 12 個 AddOn + brandcards header 全部更新 | ✅ **完成** |
| 6 | Trait/interface alias wrappers`Trait_TMDO_Anti_EAV_Aware` 等 | ✅ **完成** |
| 7 | Doctor callback signature 修正 | ✅ **完成** |
| 8 | 打包驗收:`scripts/package-plugin.sh` 12 次 10 終檢 PASS | ⬜ 待執行 |
### #1 + #3 完成摘要(2026-05-15
**Unit tests373/373 GREEN771 assertions**
- `phpunit.xml` 已建立,bootstrap `tests/bootstrap.php` 完整重建
- 32 個 unit test 檔從 `wp-data-optimizer` 搬遷,全部 `class-wpdo-``class-tmdo-` 修正
- bootstrap 新增:minimal filter registry`$GLOBALS['_wp_filter_callbacks']`)、全域 FSM bypass filter、`TMDO_Listing_Stats` stub、`esc_sql``addslashes()`
- 跳過 HP/WC/LP 專屬 testHivePress/*、WooCommerceIntegrationTest、ListingStatsTest
**Integration tests398/398 GREEN1139 assertions**
- `phpunit-integration.xml` 已建立,bootstrap `tests/integration/bootstrap.php` 完整重建
- 35 個 integration test 檔搬遷(跳過 HivepressIntegrationTest × 3、WCTermCountFilterTest、ListingMetaInterceptorTest、TermCommentBackfillTest
- bootstrap 新增:`wp_upload_dir()``wp_mkdir_p()``TMDO_Listing_Stats` stub
- 移除 `WarmArchiveIntegrationTest` 的 listing_stats 2 個方法(需真實 HP AddOn
- 移除 `RestApiIntegrationTest` 的 post_view increment + rate-limit 2 個方法(同上)
---
## Git tag plan
- `v0.1.0` — 本 releasePhase 0-4 完成)
- `v0.1.1` — Phase 5 follow-uptests / PHPCS / packaging
- `v0.2.0` — WPDO_ deprecation notice + wpdo → tmdo migration CLI
+127
View File
@@ -0,0 +1,127 @@
# 2meet Data Optimizer
> 通用 WordPress 反 EAV 引擎 — 零依賴,零 HivePress / WooCommerce / 2meet-* 綁定
`wp_postmeta` / `wp_usermeta` / `wp_termmeta` / `wp_commentmeta` 的高頻欄位自動分流到四象限扁平表(Hot / Warm / Cold / Archive),帶來 3-30× 的查詢加速。
從 [`wp-data-optimizer v2.16.0`](https://github.com/2meet/wp-data-optimizer) 提煉的純核心,整合層全部移到獨立 AddOn。
---
## 為什麼
WordPress 預設用 EAV (Entity-Attribute-Value) 模式儲存所有 meta:每筆 meta 都是 `wp_*meta` 表的一列。對少量欄位無傷,但站台一長大就出現:
- 單一 listing 可能對應數十條 postmeta → 列表頁查詢爆炸
- meta_query 複雜過濾必須 N 次 JOIN
- `autoload=yes` 的 wp_options 拖慢每頁載入
- `_transient_*` 滲入 wp_options 造成持續性脹
`2meet-data-optimizer` 為這些 meta 建立**扁平欄位表**(每 post_type 一張),由 Sync Bridge 雙寫,由 Query Router 改寫 `meta_query` 為直接 JOIN,達到接近原生欄位的效能。
---
## 四象限
| Zone | 表名 | 用途 | 加速場景 |
|---|---|---|---|
| **A Hot** | `wp_wpdo_hot_{type}` | 搜尋 / 篩選欄位 | meta_query 過濾、ORDER BY 排序 |
| **B Warm** | `wp_wpdo_warm` | TTL 計數 / 短期快取 | 瀏覽計數、暫時旗標 |
| **C Cold** | `wp_wpdo_cold_{type}` | 展示 / 描述欄位 | 詳情頁讀取 |
| **D Archive** | `wp_wpdo_archive` | 過期 gzip 歸檔 | 歷史資料保留 |
---
## 系統需求
- WordPress ≥ 6.0
- PHP ≥ 8.1
- MySQL ≥ 5.7 / MariaDB ≥ 10.3 (SQLite 亦支援)
---
## 安裝
1. 從 GitHub Release 下載 `2meet-data-optimizer-v0.1.0.zip`
2. 在 WordPress 後台 → 外掛 → 安裝外掛 → 上傳外掛
3. 啟用後執行 `wp tmdo doctor` 驗證
---
## 整合 AddOn(選用)
主外掛只覆蓋 WordPress 原生 4 entity meta。若使用 HivePress / WooCommerce / LatePoint / 2meet-* 系列等外掛並希望也享有反 EAV 加速,請額外安裝對應 AddOn:
| AddOn | 對應外掛 |
|---|---|
| `2meet-data-optimizer-hub-addon` | 2meet-hub-core |
| `2meet-data-optimizer-spoke-addon` | 2meet-spoke-sso |
| `2meet-data-optimizer-hivepress-addon` | hivepress + 12 HP 擴充 |
| `2meet-data-optimizer-woocommerce-addon` | woocommerce |
| `2meet-data-optimizer-latepoint-addon` | latepoint |
| `2meet-data-optimizer-infocards-addon` | 2meet-infocards |
| `2meet-data-optimizer-bookings-addon` | 2meet-bookings |
| `2meet-data-optimizer-quotation-addon` | 2meet-quotation |
| `2meet-data-optimizer-mobile-bridge-addon` | 2meet-mobile-bridge |
| `2meet-data-optimizer-collab-addon` | 2meet-collab |
| `2meet-data-optimizer-playlist-addon` | 2meet-playlist |
---
## WP-CLI 速查
```bash
wp tmdo status # 整體狀態
wp tmdo doctor # 健診(schema drift / 表對齊 / index
wp tmdo benchmark hot_post --samples=200
wp tmdo migrate hot_post # 執行 backfill
wp tmdo verify hot_post # 比對 postmeta 與 zone 表
wp tmdo cutover hot_post # 讀寫切到 zone 表
wp tmdo rollback hot_post # 退回 postmeta
wp tmdo cleanup --archive-expired
```
---
## 公開 API(給其他外掛)
```php
// 通用 4-entity 讀寫
TMDO_API::set_entity( 'user', $user_id, 'membership_level', 'gold' );
$level = TMDO_API::get_entity( 'user', $user_id, 'membership_level' );
// Schema 註冊
add_action( 'tmdo_register_entity_fields', function ( $registry_class ) {
$registry_class::register_group( 'user', 'my_group', [
[ 'key' => 'my_field', 'type' => 'text', 'searchable' => true ],
] );
} );
// 自訂表註冊(給其他外掛的表加入 doctor 監控)
add_action( 'tmdo_register_custom_tables', function ( $registry ) {
$registry->register( 'my-plugin', [
'table_name' => 'my_table',
'primary_key' => 'id',
'expected_columns' => [ ... ],
] );
} );
```
向後相容:`WPDO_API` / `WPDO_Schema_Registry` 等舊類別名透過 `class_alias()` 仍可使用(`v0.2.0` 起加 deprecation notice)。
---
## 授權
GPL-2.0-or-later
---
## 文件
- [PLAN.md](PLAN.md) — 開發任務追蹤
- [CHANGELOG.md](CHANGELOG.md) — 版本歷史
- [DEPLOY.md](DEPLOY.md) — 部署流程
- [SECURITY.md](SECURITY.md) — 安全聯絡
- [DESIGN.md](DESIGN.md) — 架構決策
- [CLAUDE.md](CLAUDE.md) — AI 協作指引
+76
View File
@@ -0,0 +1,76 @@
# Security Policy
## Supported versions
| Version | Supported |
|---|:-:|
| 0.1.x | ✅ |
> 來源外掛 `wp-data-optimizer v2.x` 仍接受安全修補,但新功能不再回 port。
---
## Reporting a vulnerability
請勿在 public GitHub issue 揭露安全問題。透過以下管道私下回報:
- Email: security@2meet.io
- PGP key: TBD(首次回報時索取)
我們承諾:
- 24h 內初步回應
- 7 天內提供修補時程
- 90 天內出 patch releaseCVSS ≥ 7 立即出版)
---
## 安全設計重點
### Crypto
- AES-256-GCM AEADv2 格式 `enc:v2:`
- v1 (AES-256-CBC `enc:v1:`) 向後相容讀取
- Auth tag 16B + tamper detection
- Key 衍生自 `AUTH_KEY` + `SECURE_AUTH_SALT`,每站獨一
### SQL injection
- 100% `$wpdb->prepare()`,含動態 IN 子句用 `array_fill('%d')` 動態佔位符
- 欄位名走 `sanitize_key()`
- DROP TABLE 前白名單 + prefix 雙重驗證
### Authorization
- Capability 檢查:`manage_options`admin tab/ `edit_posts`(部分 REST
- Nonce 驗證所有 AJAX + 表單
### Output / Input
- 輸出:`esc_html()` / `esc_attr()` / `esc_url()`
- 輸入:`sanitize_text_field()` / `absint()` / `wp_unslash()`
- Unserialize`WPDO_Safe_Unserialize::run()``['allowed_classes' => false]`
### File upload
- 不接受 file upload。
### Rate limiting
- REST endpoints 採 transient-based limiter(見 `class-tmdo-rest-api.php`
---
## 已知 Risk
- (本版本暫無已知安全 riskPhase 1 ship 時補)
---
## 來源外掛安全沿革
- v2.6.4 H-4:加密 Slack/Discord/Telegram 密鑰
- v2.6.5 A-4Spoke 明文 JWT 持久化完全移除
- v2.13.3 audit 7/7 finding 全清零(M-AUTH-1 / M-LOGIC-1 / L-AUTH-1 / L-CFG-1 / L-DESER-1 / L-SSRF-1 / L-CRYPTO-1
- v2.15.0 AES-256-CBC → AES-256-GCM AEAD 升級
詳見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md` § 1.1。
+515
View File
@@ -0,0 +1,515 @@
/**
* WP Data Optimizer — Admin Styles (Morandi Design System)
*
* Morandi tokens are provided via the 'morandi-design-system' CSS dependency
* registered in WPDO_Admin::enqueue_assets(). No @import needed.
*/
/* ── KPI Hero Strip (v2.16.0) ────────────────────────────────────────── */
.wpdo-kpi-hero {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-4);
margin: var(--space-5) 0;
}
.wpdo-kpi-card {
background: var(--color-bg-secondary, #FAF9F6);
border: 1px solid var(--color-bg-divider, #E8E3D9);
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-5);
box-shadow: var(--shadow-sm, 0 1px 3px rgba(58,53,48,0.08));
position: relative;
overflow: hidden;
transition: transform var(--duration-normal, 200ms) var(--ease-default, ease-out),
box-shadow var(--duration-normal, 200ms) var(--ease-default, ease-out);
}
.wpdo-kpi-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg,
var(--color-accent-warm, #c4a986),
var(--color-accent-warm, #c4a986));
opacity: 0.6;
}
.wpdo-kpi-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md, 0 4px 12px rgba(58,53,48,0.10));
}
.wpdo-kpi-label {
font-family: var(--font-family-body);
font-size: var(--text-sm, 13px);
color: var(--color-text-secondary, #6b6256);
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
margin-bottom: var(--space-2);
}
.wpdo-kpi-value {
font-family: var(--font-family-display);
font-size: 2.25rem;
line-height: 1.1;
font-weight: 700;
color: var(--color-text-primary, #3a3530);
margin-bottom: var(--space-2);
letter-spacing: -0.02em;
}
.wpdo-kpi-unit {
font-size: 1.1rem;
color: var(--color-text-secondary, #6b6256);
font-weight: 500;
margin-left: 2px;
}
.wpdo-kpi-empty {
color: var(--color-text-tertiary, #9a8e7e);
font-weight: 400;
}
.wpdo-kpi-sub {
font-family: var(--font-family-body);
font-size: var(--text-xs, 12px);
color: var(--color-text-secondary, #6b6256);
line-height: 1.4;
}
/* Per-KPI accent stripe color */
.wpdo-kpi--ratio::before { background: linear-gradient(90deg, #c4a986, #d4b89a); }
.wpdo-kpi--speedup::before { background: linear-gradient(90deg, #5a9a85, #7eb39e); }
.wpdo-kpi--coverage::before { background: linear-gradient(90deg, #8b7eb3, #a397c9); }
.wpdo-kpi--health-excellent::before { background: linear-gradient(90deg, #5a9a85, #7eb39e); }
.wpdo-kpi--health-good::before { background: linear-gradient(90deg, #c4a986, #d4b89a); }
.wpdo-kpi--health-warn::before { background: linear-gradient(90deg, #d4a574, #e0b888); }
.wpdo-kpi--health-crit::before { background: linear-gradient(90deg, #c47a7a, #d49090); }
.wpdo-kpi--health-excellent .wpdo-kpi-value { color: #4a8270; }
.wpdo-kpi--health-good .wpdo-kpi-value { color: #a8906f; }
.wpdo-kpi--health-warn .wpdo-kpi-value { color: #b88955; }
.wpdo-kpi--health-crit .wpdo-kpi-value { color: #a86060; }
@media (max-width: 600px) {
.wpdo-kpi-hero {
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.wpdo-kpi-value {
font-size: 1.75rem;
}
}
/* ── Grouped Tab Navigation (v2.16.0) ────────────────────────────────── */
.wpdo-tab-nav {
display: flex;
flex-wrap: wrap;
gap: var(--space-3) var(--space-4);
align-items: flex-end;
padding-bottom: 0;
border-bottom: 1px solid var(--color-bg-divider, #E8E3D9);
margin-bottom: var(--space-4);
}
.wpdo-tab-group {
display: inline-flex;
flex-direction: column;
gap: 4px;
margin-right: var(--space-2);
padding-right: var(--space-3);
position: relative;
}
.wpdo-tab-group:not(:last-child)::after {
content: "";
position: absolute;
right: 0;
top: 24px;
bottom: 4px;
width: 1px;
background: var(--color-bg-divider, #E8E3D9);
}
.wpdo-tab-group-label {
font-family: var(--font-family-body);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-tertiary, #9a8e7e);
font-weight: 500;
padding-left: var(--space-2);
user-select: none;
pointer-events: none;
}
.wpdo-tab-group .nav-tab {
margin: 0 2px -1px 0;
transition: background-color var(--duration-fast, 120ms) ease,
color var(--duration-fast, 120ms) ease;
}
.wpdo-tab-group .nav-tab:hover {
background-color: var(--color-bg-tertiary, #F2EEE8);
color: var(--color-text-primary, #3a3530);
}
@media (max-width: 768px) {
.wpdo-tab-nav {
gap: var(--space-2);
}
.wpdo-tab-group {
margin-right: 0;
padding-right: var(--space-2);
}
.wpdo-tab-group-label {
font-size: 10px;
}
}
/* ── Layout ──────────────────────────────────────────────────────────── */
.wpdo-wrap .wpdo-tab-content {
margin-top: var(--space-5);
}
.wpdo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-5);
margin-bottom: var(--space-5);
}
.wpdo-card {
background: var(--color-bg-secondary, #FAF9F6);
border: 1px solid var(--color-bg-divider, #E8E3D9);
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-5);
box-shadow: var(--shadow-sm, 0 1px 3px rgba(58,53,48,0.08));
overflow-x: auto; /* WCAG 1.4.10 Reflow: tables scroll within card, not page */
}
.wpdo-card h2 {
margin-top: 0;
padding-top: 0;
border-bottom: 1px solid var(--color-bg-divider, #E8E3D9);
padding-bottom: var(--space-3);
font-family: var(--font-family-display);
}
.wpdo-card h3,
.wpdo-card h4 {
margin-top: var(--space-3);
font-family: var(--font-family-display);
}
.wpdo-card-wide {
grid-column: 1 / -1;
}
/* Utility spacers replacing inline style="margin-top:N" */
.wpdo-mt-1 { margin-top: var(--space-2); }
.wpdo-mt-2 { margin-top: var(--space-3); }
.wpdo-mt-3 { margin-top: var(--space-5); }
.wpdo-mb-2 { margin-bottom: var(--space-3); }
.wpdo-table--narrow { max-width: 480px; }
.wpdo-table--medium { max-width: 700px; }
.wpdo-block--wide { max-width: 900px; }
.wpdo-col-param { width: 180px; }
/* Inline form row */
.wpdo-form-row {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
margin: var(--space-3) 0;
}
/* ── Tables ──────────────────────────────────────────────────────────── */
.wpdo-table {
font-size: var(--text-sm);
font-family: var(--font-family-body);
}
.wpdo-table code,
.wpdo-wrap code {
font-size: var(--text-xs);
background: var(--color-bg-tertiary, #F2EEE8);
padding: 2px var(--space-1);
border-radius: var(--radius-sm);
font-family: var(--font-family-mono);
}
.wpdo-log-msg {
max-width: 400px;
word-break: break-word;
}
/* ── Touch targets (CLAUDE.md: 44px minimum) ─────────────────────────── */
.wpdo-wrap .button,
.wpdo-wrap .button-secondary,
.wpdo-wrap .button-primary,
.wpdo-wrap input[type="number"],
.wpdo-wrap input[type="text"],
.wpdo-wrap input[type="search"],
.wpdo-wrap select {
min-height: 44px;
line-height: 1.4;
}
.wpdo-wrap .button-small {
min-height: 40px; /* WCAG 2.2 AA: 24px min; 40px exceeds 2.5.8 while remaining compact */
padding: 0 var(--space-2);
}
.wpdo-wrap input[type="number"] {
padding: 6px var(--space-2);
}
.wpdo-wrap input[type="number"].wpdo-input--xs {
width: 72px; /* was inline 60px — bumped to fit 44px height without clipping digits */
}
.wpdo-wrap .button:focus-visible,
.wpdo-wrap input:focus-visible,
.wpdo-wrap select:focus-visible {
outline: 2px solid var(--color-terra-primary, #C4897A);
outline-offset: 2px;
}
/* ── Method pill for REST API tab (was inline #0073aa) ───────────────── */
.wpdo-method-pill {
background: var(--color-blue-primary, #9EB3C2);
color: var(--color-text-primary, #3A3530);
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-mono);
}
/* Code block for curl examples (was inline #EFEFEF) */
.wpdo-wrap .wpdo-code-block {
background: var(--color-bg-tertiary, #F2EEE8);
color: var(--color-text-primary, #3A3530);
padding: var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-family: var(--font-family-mono);
overflow: auto;
white-space: pre;
border: 1px solid var(--color-bg-divider, #E8E3D9);
}
/* ── Zone badges (icon + text, not colour alone — WCAG 1.4.1) ────────── */
.wpdo-zone {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary, #3A3530);
line-height: 1.4;
}
.wpdo-zone::before {
font-family: var(--font-family-mono);
font-weight: var(--font-weight-bold);
}
.wpdo-zone-hot {
background: var(--color-terra-primary, #C4897A);
}
.wpdo-zone-hot::before { content: "🔥"; }
.wpdo-zone-warm {
background: var(--color-apricot-medium, #C8B090);
}
.wpdo-zone-warm::before { content: "◐"; }
.wpdo-zone-cold {
background: var(--color-blue-medium, #8AA0B2);
}
.wpdo-zone-cold::before { content: "❄"; }
.wpdo-zone-archive {
background: var(--color-gray-medium, #AFA8A0);
}
.wpdo-zone-archive::before { content: "▦"; }
/* ── State badges (shape-prefixed for colour-blind legibility) ───────── */
.wpdo-state {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
line-height: 1.4;
}
.wpdo-state::before {
font-family: var(--font-family-mono);
}
.wpdo-state-idle {
background: var(--color-bg-tertiary, #F2EEE8);
color: var(--color-text-primary, #3A3530);
}
.wpdo-state-idle::before { content: "○"; }
.wpdo-state-dual_write {
background: var(--color-apricot-lightest, #EDD9C0);
color: var(--color-text-primary, #3A3530);
}
.wpdo-state-dual_write::before { content: "⇄"; }
.wpdo-state-backfill {
background: var(--color-blue-lightest, #C8D6DE);
color: var(--color-text-primary, #3A3530);
}
.wpdo-state-backfill::before { content: "⟲"; }
.wpdo-state-verify {
background: var(--color-apricot-lightest, #EDD9C0);
color: var(--color-text-primary, #3A3530);
}
.wpdo-state-verify::before { content: "⚠"; }
.wpdo-state-cutover {
background: var(--color-green-lightest, #C8D5C4);
color: var(--color-text-primary, #3A3530);
}
.wpdo-state-cutover::before { content: "✓"; }
.wpdo-state-cleanup {
background: var(--color-blue-lightest, #C8D6DE);
color: var(--color-text-primary, #3A3530);
}
.wpdo-state-cleanup::before { content: "✂"; }
.wpdo-state-complete {
background: var(--color-green-primary, #A8B9A4);
color: var(--color-text-primary, #3A3530);
}
.wpdo-state-complete::before { content: "✔"; }
/* ── Generic badges ──────────────────────────────────────────────────── */
.wpdo-badge {
display: inline-block;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
background: var(--color-bg-tertiary, #F2EEE8);
color: var(--color-text-primary, #3A3530);
}
.wpdo-badge-ok {
background: var(--color-green-lightest, #C8D5C4);
color: var(--color-text-primary, #3A3530);
}
.wpdo-badge-warn {
background: var(--color-apricot-lightest, #EDD9C0);
color: var(--color-text-primary, #3A3530);
}
/* ── Progress bars (transform-based — no layout-thrashing) ───────────── */
.wpdo-progress {
display: inline-block;
width: 120px;
height: 14px;
background: var(--color-bg-tertiary, #F2EEE8);
border-radius: var(--radius-full);
overflow: hidden;
vertical-align: middle;
margin-right: var(--space-2);
}
.wpdo-progress-bar {
height: 100%;
width: 100%;
background: var(--color-blue-primary, #9EB3C2);
border-radius: var(--radius-full);
transform-origin: left center;
transform: scaleX(var(--wpdo-progress, 0));
transition: transform var(--duration-slow, 0.4s) var(--ease-default, ease);
}
.wpdo-progress-text {
font-size: var(--text-xs);
color: var(--color-text-primary, #3A3530);
}
/* ── Confidence bar ──────────────────────────────────────────────────── */
.wpdo-confidence {
display: inline-block;
width: 60px;
height: 10px;
background: var(--color-bg-tertiary, #F2EEE8);
border-radius: var(--radius-full);
overflow: hidden;
vertical-align: middle;
margin-right: var(--space-1);
}
.wpdo-confidence-bar {
height: 100%;
width: 100%;
background: var(--color-green-primary, #A8B9A4);
border-radius: var(--radius-full);
transform-origin: left center;
transform: scaleX(var(--wpdo-confidence, 0));
}
/* ── Screen reader only ──────────────────────────────────────────────── */
.wpdo-sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
border: 0;
}
/* ── Tab red-dot badge (v2.8.1) ──────────────────────────────────────── */
.nav-tab .wpdo-tab-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #dc3232;
margin-left: 6px;
vertical-align: middle;
box-shadow: 0 0 6px rgba(220, 50, 50, 0.6);
animation: wpdo-tab-dot-pulse 2s ease infinite;
}
@keyframes wpdo-tab-dot-pulse {
0% { box-shadow: 0 0 0 0 rgba(220, 50, 50, 0.7); }
70% { box-shadow: 0 0 0 6px rgba(220, 50, 50, 0); }
100% { box-shadow: 0 0 0 0 rgba(220, 50, 50, 0); }
}
/* ── Reduced motion ──────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.wpdo-progress-bar {
transition-duration: 0.01ms;
}
.nav-tab .wpdo-tab-dot {
animation: none;
}
}
+246
View File
@@ -0,0 +1,246 @@
/**
* WP Data Optimizer — Admin JS (native fetch, no jQuery)
*
* @package WP_Data_Optimizer
*/
( function () {
'use strict';
var WPDO = window.wpdoAdmin || {};
var i18n = WPDO.i18n || {};
document.addEventListener(
'DOMContentLoaded',
function () {
bindFlushCacheButtons();
bindStatusRefresh();
}
);
/**
* Announce a message via the shared aria-live region.
* Falls back to creating the region on first call.
*/
function announce( message ) {
var region = document.getElementById( 'wpdo-aria-live' );
if ( ! region ) {
region = document.createElement( 'div' );
region.id = 'wpdo-aria-live';
region.className = 'wpdo-sr-only';
region.setAttribute( 'role', 'status' );
region.setAttribute( 'aria-live', 'polite' );
region.setAttribute( 'aria-atomic', 'true' );
document.body.appendChild( region );
}
region.textContent = '';
setTimeout(
function () {
region.textContent = message; },
50
);
}
/**
* Render an inline admin notice at the top of .wpdo-wrap.
* Dismissable by user. Replaces legacy alert() for errors.
*/
function showNotice( message, type ) {
type = type || 'error';
var wrap = document.querySelector( '.wpdo-wrap' );
if ( ! wrap ) {
return;
}
var notice = document.createElement( 'div' );
notice.className = 'notice notice-' + type + ' is-dismissible';
notice.setAttribute( 'role', type === 'error' ? 'alert' : 'status' );
var p = document.createElement( 'p' );
p.textContent = message;
notice.appendChild( p );
var dismiss = document.createElement( 'button' );
dismiss.type = 'button';
dismiss.className = 'notice-dismiss';
dismiss.setAttribute( 'aria-label', i18n.dismiss || 'Dismiss' );
dismiss.addEventListener(
'click',
function () {
notice.remove();
}
);
notice.appendChild( dismiss );
// Insert after the H1 so it appears at the top of the content area.
var h1 = wrap.querySelector( 'h1' );
if ( h1 && h1.nextSibling ) {
wrap.insertBefore( notice, h1.nextSibling );
} else {
wrap.prepend( notice );
}
announce( message );
}
/**
* POST to admin-ajax.php using native fetch + FormData.
* Returns a Promise resolving to the parsed JSON response.
*/
function wpdoAjax( actionType, data ) {
var body = new FormData();
body.append( 'action', 'wpdo_admin_action' );
body.append( 'nonce', WPDO.nonce || '' );
body.append( 'action_type', actionType );
Object.keys( data || {} ).forEach(
function ( key ) {
body.append( key, data[ key ] );
}
);
return fetch(
WPDO.ajaxUrl,
{
method: 'POST',
credentials: 'same-origin',
body: body,
}
)
.then(
function ( res ) {
if ( ! res.ok ) {
throw new Error( 'HTTP ' + res.status );
}
return res.json();
}
);
}
/**
* Flush cache button handler — delegated click listener.
*/
function bindFlushCacheButtons() {
document.addEventListener(
'click',
function ( e ) {
var btn = e.target.closest( '.wpdo-flush-cache' );
if ( ! btn ) {
return;
}
e.preventDefault();
var postType = btn.getAttribute( 'data-post-type' );
if ( ! postType ) {
return;
}
var originalLabel = btn.textContent;
btn.disabled = true;
btn.textContent = i18n.flushing || 'Flushing…';
announce( btn.textContent );
wpdoAjax( 'flush_cache', { post_type: postType } )
.then(
function ( response ) {
if ( response && response.success ) {
btn.textContent = i18n.flushed || 'Flushed!';
announce( btn.textContent );
setTimeout(
function () {
btn.disabled = false;
btn.textContent = i18n.flushLabel || originalLabel;
},
2000
);
} else {
btn.disabled = false;
btn.textContent = i18n.flushLabel || originalLabel;
var msg = ( response && response.data ) ? String( response.data ) : ( i18n.error || 'Unknown error' );
showNotice( msg, 'error' );
}
}
)
.catch(
function () {
btn.disabled = false;
btn.textContent = i18n.flushLabel || originalLabel;
showNotice( i18n.error || 'Request failed.', 'error' );
}
);
}
);
}
/**
* Auto-refresh module status on the Dashboard tab (every 30s).
*/
function bindStatusRefresh() {
var isDashboard = document.querySelector( '.wpdo-wrap' )
&& window.location.search.indexOf( 'tab=dashboard' ) !== -1;
if ( ! isDashboard ) {
return;
}
var timer = setInterval(
function () {
if ( ! document.hidden ) {
refreshStatus();
}
},
30000
);
window.addEventListener(
'beforeunload',
function () {
clearInterval( timer );
}
);
}
function refreshStatus() {
wpdoAjax( 'module_status', {} )
.then(
function ( response ) {
if ( ! response || ! response.success || ! response.data ) {
return;
}
var data = response.data;
var changed = 0;
var changedSummaries = [];
document.querySelectorAll( '.wpdo-state' ).forEach(
function ( el ) {
var row = el.closest( 'tr' );
var codeEl = row ? row.querySelector( 'code' ) : null;
if ( ! codeEl ) {
return;
}
var module = codeEl.textContent;
if ( ! module || ! ( module in data ) ) {
return;
}
var newState = data[ module ];
if ( el.textContent === newState ) {
return;
}
el.textContent = newState;
el.className = 'wpdo-state wpdo-state-' + newState;
changed++;
changedSummaries.push( module + ' → ' + newState );
}
);
if ( changed > 0 ) {
announce( changedSummaries.join( '、' ) );
}
}
)
.catch(
function () {
// Fail silently on polling — user will see the stale state; not worth a notice.
}
);
}
} )();
+368
View File
@@ -0,0 +1,368 @@
/**
* WPDO Comment Stress Test — admin tab JS (v2.13.1)
*
* Mirrors wpdo-term-stress-test.js (v2.13.0) but:
* - Talks to /wpdo/v1/comment-stress-test/* endpoints
* - Sends post_id in start payload (not taxonomy)
* - DOM IDs prefixed wpdo-cst-* (comment stress test)
*
* @since 2.13.1
*/
( function () {
'use strict';
const cfg = window.wpdoCommentStressTest;
if ( ! cfg || ! cfg.restUrl ) {
return;
}
if ( ! document.querySelector( '.wpdo-comment-stress-test-tab' ) ) {
return;
}
const $ = ( sel ) => document.querySelector( sel );
const restUrl = cfg.restUrl.replace( /\/$/, '' );
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
let pollTimer = null;
// ── API helpers ─────────────────────────────────────────────────────────
async function apiCall( path, method = 'GET', body = null ) {
const opts = { method, headers, credentials: 'same-origin' };
if ( body ) {
opts.body = JSON.stringify( body );
}
const resp = await fetch( restUrl + path, opts );
const text = await resp.text();
try {
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
} catch ( e ) {
return { ok: false, status: resp.status, data: { error: text } };
}
}
// ── Rendering ───────────────────────────────────────────────────────────
function fmt( n ) {
if ( n === null || n === undefined ) {
return '—';
}
return Number( n ).toLocaleString();
}
function setText( sel, text ) {
const el = $( sel );
if ( el ) {
el.textContent = String( text );
}
}
function esc( s ) {
if ( s === null || s === undefined ) {
return '';
}
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ c ] ) );
}
function renderProgress( state ) {
const isRunning = state.status === 'running' || state.status === 'benchmarking';
const card = $( '#wpdo-cst-progress-card' );
if ( card ) {
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
}
setText( '#wpdo-cst-pg-status', state.status || 'idle' );
setText( '#wpdo-cst-pg-post-id', state.post_id ? ( 'post #' + state.post_id ) : '' );
setText( '#wpdo-cst-pg-mode', state.mode || '' );
setText( '#wpdo-cst-pg-pct', ( state.pct || 0 ) + '%' );
setText( '#wpdo-cst-pg-processed', fmt( state.processed || 0 ) );
setText( '#wpdo-cst-pg-target', fmt( state.target || 0 ) );
setText( '#wpdo-cst-pg-rate', state.rate_per_sec || 0 );
setText( '#wpdo-cst-pg-elapsed', state.elapsed_sec || 0 );
setText( '#wpdo-cst-pg-eta', state.eta_sec || 0 );
setText( '#wpdo-cst-pg-batches', state.batches_done || 0 );
setText( '#wpdo-cst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
const bar = $( '#wpdo-cst-pg-bar' );
if ( bar ) {
bar.style.width = ( state.pct || 0 ) + '%';
}
const count = state.test_comment_count || 0;
setText( '#wpdo-cst-count', fmt( count ) );
setText( '#wpdo-cst-count-mirror', fmt( count ) );
const startBtn = $( '#wpdo-cst-start' );
const cancelBtn = $( '#wpdo-cst-cancel' );
const cleanupBtn = $( '#wpdo-cst-cleanup' );
const benchBtn = $( '#wpdo-cst-rerun-bench' );
if ( startBtn ) {
startBtn.disabled = isRunning;
}
if ( cancelBtn ) {
cancelBtn.disabled = ! isRunning;
}
if ( cleanupBtn ) {
cleanupBtn.disabled = isRunning || count === 0;
}
if ( benchBtn ) {
benchBtn.disabled = isRunning || count === 0;
}
}
function renderBenchmark( bench ) {
if ( ! bench ) {
return;
}
const card = $( '#wpdo-cst-bench-card' );
const content = $( '#wpdo-cst-bench-content' );
if ( ! card || ! content ) {
return;
}
card.style.display = '';
const w = bench.write || {};
const dbSizes = bench.db_sizes || [];
const q = bench.query || {};
const labels = {
point_rating_5: 'Point lookup (hp_rating = 5)',
range_rating_top: 'Range scan (hp_rating >= 4 ORDER BY DESC)',
eav_baseline: 'EAV baseline (wp_commentmeta 直查 hp_rating)',
};
let html = '';
// Write metrics
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
html += `<tr><td>Post ID</td><td><code>#${ esc( w.post_id ) }</code></td></tr>`;
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> comments/sec</td></tr>`;
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
html += '</tbody></table>';
// DB sizes
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(comment 相關表)</h4>';
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
html += '</tr></thead><tbody>';
dbSizes.forEach( ( r ) => {
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
} );
html += '</tbody></table>';
// Query perf — 3 probes with EAV baseline last for visual speedup comparison
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
html += '<th>測試項目</th><th>耗時 (ms)</th>';
html += '</tr></thead><tbody>';
const baselineMs = q.eav_baseline?.duration_ms ?? null;
Object.keys( q ).forEach( ( key ) => {
const v = q[ key ];
if ( ! v || typeof v.duration_ms !== 'number' ) {
return;
}
const label = labels[ key ] || key;
let speedup = '';
if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) {
const ratio = baselineMs / v.duration_ms;
if ( ratio >= 1 ) {
speedup = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
}
}
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
} );
html += '</tbody></table>';
html += '<p class="description">EAV baseline 走 wp_commentmetaflat probes 走 wpdo_comment_hp_review。倍率即此規模下反 EAV 的查詢加速。</p>';
content.innerHTML = html;
}
// ── Polling ─────────────────────────────────────────────────────────────
async function poll() {
const r = await apiCall( '/comment-stress-test/status' );
if ( ! r.ok || ! r.data ) {
return;
}
renderProgress( r.data );
if ( r.data.benchmark ) {
renderBenchmark( r.data.benchmark );
}
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
stopPolling();
}
}
function startPolling() {
stopPolling();
poll();
pollTimer = setInterval( poll, 2000 );
}
function stopPolling() {
if ( pollTimer ) {
clearInterval( pollTimer );
pollTimer = null;
}
}
// ── Event handlers ──────────────────────────────────────────────────────
async function handleStart() {
const post_id = parseInt( $( '#wpdo-cst-post-id' ).value, 10 );
const target = parseInt( $( '#wpdo-cst-target' ).value, 10 );
const batch = parseInt( $( '#wpdo-cst-batch' ).value, 10 );
const mode = document.querySelector( 'input[name="wpdo-cst-mode"]:checked' ).value;
if ( ! post_id || post_id < 1 ) {
alert( '請選擇目標 post' );
return;
}
if ( ! target || target < 1 ) {
alert( '請輸入有效的 comment 數量' );
return;
}
if ( mode === 'realistic' && batch > 50 ) {
if ( ! confirm( `Realistic 模式每個 comment 約需 30-100 mswp_insert_comment + update_comment_meta),batch_size=${ batch } 可能超過後端 8s deadline。建議 batch=10-30。要繼續嗎?` ) ) {
return;
}
}
// Soft warn for combinations that won't demonstrate反 EAV 優化效果
const commentMode = String( cfg.commentMode || 'disabled' );
if ( mode === 'fast' && commentMode === 'aeav_only' ) {
if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 comment mode=aeav_only 也會寫滿 wp_commentmeta。\n\n要驗證反 EAV 優化效果(wp_commentmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) {
return;
}
} else if ( mode === 'realistic' && commentMode !== 'aeav_only' ) {
if ( ! confirm( `⚠️ 目前 comment mode = ${ commentMode }Realistic 寫入仍會雙寫 wp_commentmeta(不展示優化效果)。\n\n要看 wp_commentmeta 完全短路請先升 mode 至 aeav_only(設定 tab)。\n\n仍以 ${ commentMode } 模式繼續嗎?` ) ) {
return;
}
}
const big = target >= 5000;
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 commentattached to post #${ post_id }${ big ? '(規模較大)' : '' }\n\n所有 comment 的 author email 會以 @wpdo-stress.local 結尾,可一鍵清除。\n\n確定要開始嗎?`;
if ( ! confirm( msg ) ) {
return;
}
const r = await apiCall( '/comment-stress-test/start', 'POST', {
post_id,
target,
mode,
batch_size: batch,
} );
if ( ! r.ok ) {
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
return;
}
const card = $( '#wpdo-cst-progress-card' );
if ( card ) {
card.style.display = '';
}
const benchCard = $( '#wpdo-cst-bench-card' );
if ( benchCard ) {
benchCard.style.display = 'none';
}
setText( '#wpdo-cst-pg-status', 'running' );
setText( '#wpdo-cst-pg-post-id', 'post #' + post_id );
setText( '#wpdo-cst-pg-mode', mode );
setText( '#wpdo-cst-pg-target', target.toLocaleString() );
startPolling();
}
async function handleCancel() {
if ( ! confirm( '確定要取消當前測試?已建立的 comment 不會被刪除。' ) ) {
return;
}
const cancelBtn = $( '#wpdo-cst-cancel' );
if ( cancelBtn ) {
cancelBtn.disabled = true;
cancelBtn.textContent = '⏹ 取消中…';
}
setText( '#wpdo-cst-pg-status', 'cancelling' );
const r = await apiCall( '/comment-stress-test/cancel', 'POST' );
if ( ! r.ok ) {
alert( '取消失敗:' + ( r.data?.error || r.status ) );
if ( cancelBtn ) {
cancelBtn.disabled = false;
cancelBtn.textContent = '⏹ 取消';
}
return;
}
poll();
if ( cancelBtn ) {
cancelBtn.textContent = '⏹ 取消';
}
}
async function handleCleanup() {
if ( ! confirm( '確定要清除所有 stress test comments\n\n此動作會:\n- DELETE 所有 email 後綴 @wpdo-stress.local 的 comment\n- DELETE 對應 wp_commentmeta\n- DELETE flat 表中對應 comment_id 的列\n\n不可復原!' ) ) {
return;
}
const r = await apiCall( '/comment-stress-test/cleanup', 'DELETE' );
if ( ! r.ok ) {
alert( '清除失敗:' + ( r.data?.error || r.status ) );
return;
}
alert( `已清除 ${ r.data.deleted } 筆測試 comment。` );
const card = $( '#wpdo-cst-progress-card' );
const benchCard = $( '#wpdo-cst-bench-card' );
if ( card ) card.style.display = 'none';
if ( benchCard ) benchCard.style.display = 'none';
poll();
}
async function handleRerunBench() {
const btn = $( '#wpdo-cst-rerun-bench' );
if ( btn ) {
btn.disabled = true;
btn.textContent = '⏳ 執行中...';
}
const r = await apiCall( '/comment-stress-test/benchmark', 'POST' );
if ( btn ) {
btn.disabled = false;
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
}
if ( ! r.ok ) {
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
return;
}
renderBenchmark( r.data.benchmark );
}
// ── Init ────────────────────────────────────────────────────────────────
document.addEventListener( 'DOMContentLoaded', () => {
const startBtn = $( '#wpdo-cst-start' );
const cancelBtn = $( '#wpdo-cst-cancel' );
const cleanupBtn = $( '#wpdo-cst-cleanup' );
const benchBtn = $( '#wpdo-cst-rerun-bench' );
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
poll().then( () => {
const status = ( $( '#wpdo-cst-pg-status' )?.textContent || '' ).trim();
if ( status === 'running' || status === 'benchmarking' ) {
startPolling();
}
} );
} );
} )();
+295
View File
@@ -0,0 +1,295 @@
/**
* WPDO Entity Bridge — Admin UI controller
*
* Features:
* - Polls /wp-json/wpdo/v1/entity-bridge/health every 5 s during active backfill
* - Updates coverage progress bars and migration badges in real time
* - Handles Backfill / Promote / Demote button clicks with confirmation
*
* Depends on: wpdoEntityBridge (wp_localize_script data)
*/
/* global wpdoEntityBridge */
( function () {
'use strict';
var cfg = window.wpdoEntityBridge || {};
var restUrl = cfg.restUrl || '';
var nonce = cfg.nonce || '';
var pollInterval = 5000; // ms between polls
var timer = null;
var activeBackfills = {}; // {entity_type: {group_name: true}} — track in-progress
// ─── Helpers ─────────────────────────────────────────────────────────────
function apiFetch( method, path, body ) {
var url = restUrl + path;
var opts = {
method: method,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
};
if ( body ) {
opts.body = JSON.stringify( body );
}
return fetch( url, opts ).then( function ( res ) {
if ( ! res.ok ) {
return res.json().then( function ( e ) { throw new Error( e.message || e.error || res.status ); } );
}
return res.json();
} );
}
function modeBadgeClass( mode ) {
return {
disabled: 'wpdo-mode-disabled',
dual_write: 'wpdo-mode-dual-write',
shadow_read: 'wpdo-mode-shadow-read',
aeav_only: 'wpdo-mode-aeav-only',
}[ mode ] || 'wpdo-mode-disabled';
}
function modeLabel( mode ) {
return {
disabled: 'disabled',
dual_write: 'dual_write',
shadow_read: 'shadow_read',
aeav_only: 'aeav_only',
}[ mode ] || mode;
}
function pct( n ) {
return Math.min( 100, Math.max( 0, parseFloat( n ) || 0 ) ).toFixed( 1 );
}
// ─── Update UI from health data ──────────────────────────────────────────
function updateAll( data ) {
var hasActive = false;
Object.keys( data ).forEach( function ( type ) {
var card = document.querySelector( '[data-entity-type="' + type + '"]' );
if ( ! card ) return;
var info = data[ type ];
// Mode badge
var badge = card.querySelector( '.wpdo-mode-badge' );
if ( badge ) {
badge.textContent = modeLabel( info.mode );
badge.className = 'wpdo-mode-badge ' + modeBadgeClass( info.mode );
}
// Pipeline dots
var pipeline = card.querySelector( '.wpdo-pipeline' );
if ( pipeline ) {
pipeline.innerHTML = buildPipeline( info.mode );
}
// Mode days
var daysEl = card.querySelector( '.wpdo-mode-days' );
if ( daysEl ) {
daysEl.textContent = info.mode_days + ' 天';
}
// Groups
( info.groups || [] ).forEach( function ( g ) {
var groupEl = card.querySelector( '[data-group="' + g.name + '"]' );
if ( ! groupEl ) return;
// Progress bar
var bar = groupEl.querySelector( '.wpdo-cov-bar-fill' );
if ( bar ) bar.style.width = pct( g.coverage_pct ) + '%';
var pctEl = groupEl.querySelector( '.wpdo-cov-pct' );
if ( pctEl ) pctEl.textContent = pct( g.coverage_pct ) + '%';
var rowsEl = groupEl.querySelector( '.wpdo-cov-rows' );
if ( rowsEl ) rowsEl.textContent = g.flat_rows + ' / ' + g.eav_rows;
// Migration badge
var migBadge = groupEl.querySelector( '.wpdo-mig-status' );
if ( migBadge ) {
migBadge.textContent = g.migration_status;
migBadge.className = 'wpdo-mig-status wpdo-mig-' + g.migration_status.replace( /_/g, '-' );
}
// Is this group's backfill active?
if ( g.migration_status === 'running' ) {
hasActive = true;
}
} );
// Shadow diffs
var diffsEl = card.querySelector( '.wpdo-shadow-diffs' );
if ( diffsEl ) diffsEl.textContent = info.shadow_diffs;
// Recommendation
var recEl = card.querySelector( '.wpdo-recommendation' );
if ( recEl ) recEl.textContent = info.recommendation;
// Auto-promote eligible badge
var apEl = card.querySelector( '.wpdo-auto-promote-eligible' );
if ( apEl ) {
if ( info.auto_promote && info.auto_promote.eligible ) {
apEl.textContent = '✓ 可升級';
apEl.style.color = '#155724';
} else {
apEl.textContent = '— ' + ( ( info.auto_promote || {} ).reason || '' );
apEl.style.color = '#856404';
}
}
// Button states — update promote/demote targets
var promoteBtn = card.querySelector( '.wpdo-btn-promote' );
if ( promoteBtn ) {
promoteBtn.disabled = ! info.next_mode;
promoteBtn.dataset.nextMode = info.next_mode || '';
promoteBtn.title = info.next_mode ? '升級到 ' + info.next_mode : '已在最高模式';
}
var demoteBtn = card.querySelector( '.wpdo-btn-demote' );
if ( demoteBtn ) {
demoteBtn.disabled = ! info.prev_mode;
demoteBtn.dataset.prevMode = info.prev_mode || '';
demoteBtn.title = info.prev_mode ? '降級到 ' + info.prev_mode : '已在最低模式';
}
// Backfill active indicator
if ( info.backfill_active ) hasActive = true;
} );
// Manage poll timer
if ( hasActive ) {
startPolling();
}
}
function buildPipeline( currentMode ) {
var modes = [ 'disabled', 'dual_write', 'shadow_read', 'aeav_only' ];
var labels = { disabled: 'disabled', dual_write: 'dual_write', shadow_read: 'shadow_read', aeav_only: 'aeav_only' };
return modes.map( function ( m ) {
var active = m === currentMode ? ' wpdo-pipeline-active' : '';
return '<span class="wpdo-pipeline-dot' + active + '" title="' + labels[ m ] + '">' +
'<span class="wpdo-dot"></span>' +
'<span class="wpdo-dot-label">' + labels[ m ] + '</span>' +
'</span>';
} ).join( '<span class="wpdo-pipeline-arrow">→</span>' );
}
// ─── Polling ─────────────────────────────────────────────────────────────
function poll() {
apiFetch( 'GET', '/entity-bridge/health' )
.then( updateAll )
.catch( function ( e ) { console.warn( '[WPDO] Health poll error:', e ); } );
}
function startPolling() {
if ( timer ) return;
timer = setInterval( poll, pollInterval );
}
function stopPolling() {
if ( timer ) { clearInterval( timer ); timer = null; }
}
// ─── Button handlers ─────────────────────────────────────────────────────
function handleBackfill( btn ) {
var entityType = btn.dataset.entityType;
var groupName = btn.dataset.groupName;
if ( ! confirm( '確定要啟動 ' + entityType + '/' + groupName + ' 的 Backfill 遷移嗎?這將清除現有進度並重新開始。' ) ) return;
btn.disabled = true;
btn.textContent = '排程中…';
apiFetch( 'POST', '/entity-bridge/backfill', { entity_type: entityType, group_name: groupName } )
.then( function () {
btn.textContent = '已排程 ✓';
startPolling();
// Refresh once immediately
setTimeout( poll, 1000 );
} )
.catch( function ( e ) {
alert( '啟動 Backfill 失敗:' + e.message );
btn.disabled = false;
btn.textContent = '啟動 Backfill';
} );
}
function handlePromote( btn ) {
var entityType = btn.dataset.entityType;
var nextMode = btn.dataset.nextMode;
if ( ! nextMode ) return;
if ( ! confirm( '確定要將 ' + entityType + ' 升級到 ' + nextMode + ' 嗎?' ) ) return;
btn.disabled = true;
btn.textContent = '更新中…';
apiFetch( 'POST', '/entity-bridge/promote', { entity_type: entityType } )
.then( function () {
poll(); // immediate refresh
} )
.catch( function ( e ) {
alert( '升級失敗:' + e.message );
btn.disabled = false;
btn.textContent = '升級模式';
} );
}
function handleDemote( btn ) {
var entityType = btn.dataset.entityType;
var prevMode = btn.dataset.prevMode;
if ( ! prevMode ) return;
if ( ! confirm( '確定要將 ' + entityType + ' 降級到 ' + prevMode + ' 嗎?降級後讀取會切回 EAV。' ) ) return;
btn.disabled = true;
btn.textContent = '更新中…';
apiFetch( 'POST', '/entity-bridge/demote', { entity_type: entityType } )
.then( function () {
poll();
} )
.catch( function ( e ) {
alert( '降級失敗:' + e.message );
btn.disabled = false;
btn.textContent = '降級模式';
} );
}
// ─── Event delegation ────────────────────────────────────────────────────
document.addEventListener( 'click', function ( e ) {
var btn = e.target.closest( 'button[data-wpdo-action]' );
if ( ! btn ) return;
var action = btn.dataset.wpdoAction;
if ( action === 'backfill' ) {
handleBackfill( btn );
} else if ( action === 'promote' ) {
handlePromote( btn );
} else if ( action === 'demote' ) {
handleDemote( btn );
}
} );
// ─── Init ────────────────────────────────────────────────────────────────
// Initial poll on page load if we're on the entity-bridge tab.
if ( document.querySelector( '.wpdo-entity-bridge-tab' ) ) {
poll();
// Auto-start polling if any backfill is already running (check server state).
// A subsequent poll() response will call startPolling() if needed.
}
// Stop polling when navigating away.
window.addEventListener( 'beforeunload', stopPolling );
} )();
+288
View File
@@ -0,0 +1,288 @@
/**
* WP Data Optimizer — Migration Wizard styling.
*
* Consumes the Morandi design system tokens (var(--color-*), var(--space-*),
* var(--radius-*), var(--shadow-*), var(--ease-default), var(--duration-normal),
* var(--font-family-display), var(--font-family-body)).
*
* @since 2.8.0
*/
.wpdo-migration-wizard {
max-width: 1100px;
font-family: var(--font-family-body, system-ui);
color: var(--color-ink, #2c2618);
}
.wpdo-mw-header h2 {
font-family: var(--font-family-display, serif);
font-size: 1.6rem;
margin-bottom: var(--space-2, 8px);
}
.wpdo-mw-panel {
background: var(--color-surface-card, #fbf7f0);
border: 1px solid var(--color-border-soft, #e5dccd);
border-radius: var(--radius-md, 12px);
padding: var(--space-6, 24px);
margin-bottom: var(--space-5, 20px);
box-shadow: var(--shadow-sm, 0 1px 3px rgba(60, 40, 20, 0.06));
}
.wpdo-mw-panel h3 {
margin-top: 0;
font-family: var(--font-family-display, serif);
}
/* ── Pre-flight metrics grid ─────────────────────────────────── */
.wpdo-mw-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-3, 12px);
margin-bottom: var(--space-4, 16px);
}
.wpdo-mw-metric {
background: var(--color-surface, #fff);
border-radius: var(--radius-sm, 8px);
padding: var(--space-3, 12px) var(--space-4, 16px);
border: 1px solid var(--color-border-soft, #e5dccd);
}
.wpdo-mw-metric-primary {
background: var(--color-accent-soft, #f4e9d4);
border-color: var(--color-accent, #c9a55a);
}
.wpdo-mw-metric-label {
display: block;
font-size: 0.78rem;
color: var(--color-ink-soft, #6b5d44);
margin-bottom: var(--space-1, 4px);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.wpdo-mw-metric-value {
display: block;
font-size: 1.4rem;
font-weight: 600;
font-family: var(--font-family-display, serif);
}
.wpdo-mw-mode {
font-size: 0.95rem;
font-family: var(--font-family-body, monospace);
padding: var(--space-1, 4px) var(--space-2, 8px);
border-radius: var(--radius-pill, 999px);
}
.wpdo-mw-mode-aeav_only { background: #d8efd8; color: #2c5a2c; }
.wpdo-mw-mode-shadow_read { background: #fff3cd; color: #7c5e1e; }
.wpdo-mw-mode-dual_write { background: #d8e4f0; color: #2c4a6a; }
.wpdo-mw-mode-disabled { background: #eaeaea; color: #555; }
.wpdo-mw-zero { color: var(--color-ink-soft, #aaa); }
.wpdo-mw-groups summary {
cursor: pointer;
margin-bottom: var(--space-2, 8px);
color: var(--color-ink-soft, #6b5d44);
}
.wpdo-mw-groups table {
margin-top: var(--space-2, 8px);
}
/* ── Run panel ───────────────────────────────────────────────── */
.wpdo-mw-nothing-todo {
text-align: center;
padding: var(--space-8, 32px);
color: var(--color-ink-soft, #6b5d44);
}
.wpdo-mw-nothing-todo h3 {
font-size: 1.4rem;
margin-bottom: var(--space-3, 12px);
}
.wpdo-mw-estimate {
background: var(--color-surface, #fff);
border-left: 3px solid var(--color-accent, #c9a55a);
padding: var(--space-3, 12px) var(--space-4, 16px);
margin: var(--space-4, 16px) 0;
border-radius: var(--radius-sm, 8px);
}
.wpdo-mw-options {
border: 1px solid var(--color-border-soft, #e5dccd);
border-radius: var(--radius-sm, 8px);
padding: var(--space-4, 16px);
margin: var(--space-4, 16px) 0;
}
.wpdo-mw-options legend {
padding: 0 var(--space-2, 8px);
font-weight: 600;
}
.wpdo-mw-options label {
display: block;
margin: var(--space-2, 8px) 0;
cursor: pointer;
transition: color var(--duration-normal, 200ms) var(--ease-default, ease);
}
.wpdo-mw-options label:hover {
color: var(--color-accent, #c9a55a);
}
.wpdo-mw-confirm {
background: var(--color-warn-soft, #fff3cd);
border: 1px solid var(--color-warn, #d4a04f);
border-radius: var(--radius-sm, 8px);
padding: var(--space-3, 12px) var(--space-4, 16px);
margin: var(--space-4, 16px) 0;
}
.wpdo-mw-confirm label {
cursor: pointer;
}
#wpdo-mw-start {
min-height: 44px;
font-size: 1.05rem;
padding: var(--space-3, 12px) var(--space-6, 24px);
border-radius: var(--radius-md, 12px);
transition: transform var(--duration-normal, 200ms) var(--ease-default, ease);
}
#wpdo-mw-start:not([disabled]):hover {
transform: translateY(-1px);
}
/* ── Progress panel ──────────────────────────────────────────── */
.wpdo-mw-progress-bar {
position: relative;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border-soft, #e5dccd);
border-radius: var(--radius-pill, 999px);
height: 28px;
overflow: hidden;
margin: var(--space-4, 16px) 0;
}
.wpdo-mw-progress-fill {
background: linear-gradient(
90deg,
var(--color-accent, #c9a55a),
var(--color-accent-strong, #b08c3a)
);
height: 100%;
transition: width var(--duration-normal, 300ms) var(--ease-default, ease);
}
.wpdo-mw-progress-pct {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-weight: 600;
font-family: var(--font-family-display, serif);
mix-blend-mode: difference;
color: #fff;
}
.wpdo-mw-current {
display: flex;
justify-content: space-between;
align-items: center;
margin: var(--space-3, 12px) 0;
}
.wpdo-mw-current code {
background: var(--color-surface, #fff);
padding: var(--space-1, 4px) var(--space-2, 8px);
border-radius: var(--radius-sm, 8px);
font-family: var(--font-family-body, monospace);
}
.wpdo-mw-elapsed {
font-family: var(--font-family-display, serif);
font-size: 1.1rem;
color: var(--color-ink-soft, #6b5d44);
min-width: 70px;
text-align: right;
}
.wpdo-mw-live-ratio {
background: var(--color-accent-soft, #f4e9d4);
padding: var(--space-2, 8px) var(--space-4, 16px);
border-radius: var(--radius-sm, 8px);
margin: var(--space-3, 12px) 0;
font-family: var(--font-family-display, serif);
}
.wpdo-mw-log-wrap h4 {
margin: var(--space-4, 16px) 0 var(--space-2, 8px);
font-family: var(--font-family-display, serif);
}
.wpdo-mw-log {
background: var(--color-ink, #2c2618);
color: var(--color-paper, #f4ecdb);
padding: var(--space-4, 16px);
border-radius: var(--radius-sm, 8px);
font-family: var(--font-family-body, monospace);
font-size: 0.85rem;
line-height: 1.6;
max-height: 320px;
overflow-y: auto;
white-space: pre-wrap;
margin: 0;
}
.wpdo-mw-log-flash {
animation: wpdo-mw-log-pulse var(--duration-normal, 350ms) var(--ease-default, ease);
}
@keyframes wpdo-mw-log-pulse {
0% { box-shadow: inset 0 0 0 0 var(--color-accent, #c9a55a); }
30% { box-shadow: inset 0 0 30px -8px var(--color-accent, #c9a55a); }
100% { box-shadow: inset 0 0 0 0 transparent; }
}
.wpdo-mw-actions {
display: flex;
gap: var(--space-2, 8px);
margin-top: var(--space-4, 16px);
}
.wpdo-mw-failed {
color: var(--color-error, #b03d3d);
}
/* ── Done panel ──────────────────────────────────────────────── */
.wpdo-mw-done {
background: var(--color-success-soft, #d8efd8);
border-color: var(--color-success, #5a8c5a);
}
.wpdo-mw-done-summary {
margin: var(--space-4, 16px) 0;
}
.wpdo-mw-done-line {
padding: var(--space-2, 8px) 0;
border-bottom: 1px dashed var(--color-border-soft, #e5dccd);
}
.wpdo-mw-done-line:last-child {
border-bottom: 0;
}
.wpdo-mw-done-line span {
display: inline-block;
min-width: 100px;
color: var(--color-ink-soft, #6b5d44);
}
+314
View File
@@ -0,0 +1,314 @@
/**
* WP Data Optimizer — One-click User Migration Wizard frontend.
*
* - Confirms options + checkbox before starting.
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
* - Polls /status every 500ms while job is active.
* - Streams log lines with fade-in; live-updates ratio + progress bar.
*
* @since 2.8.0
*/
(function () {
'use strict';
const POLL_INTERVAL_MS = 500;
const config = window.wpdoMigrationWizard || {};
const i18n = config.i18n || {};
const restUrl = (config.restUrl || '').replace(/\/$/, '');
const nonce = config.nonce || '';
const $ = (id) => document.getElementById(id);
const root = document.querySelector('.wpdo-migration-wizard');
if (!root) return;
const els = {
runPanel: $('wpdo-mw-run-panel'),
progressPanel: $('wpdo-mw-progress-panel'),
donePanel: $('wpdo-mw-done-panel'),
startBtn: $('wpdo-mw-start'),
cancelBtn: $('wpdo-mw-cancel'),
resumeBtn: $('wpdo-mw-resume'),
resetBtn: $('wpdo-mw-reset'),
confirmBox: $('wpdo-mw-confirm-backup'),
optBackup: $('wpdo-mw-opt-backup'),
optStrict: $('wpdo-mw-opt-strict'),
opt24h: $('wpdo-mw-opt-24h'),
optAsync: $('wpdo-mw-opt-async'),
optDryRun: $('wpdo-mw-opt-dryrun'),
progressFill: $('wpdo-mw-progress-fill'),
progressPct: $('wpdo-mw-progress-pct'),
progressTitle: $('wpdo-mw-progress-title'),
phaseName: $('wpdo-mw-current-phase-name'),
elapsed: $('wpdo-mw-elapsed'),
liveRatio: $('wpdo-mw-live-ratio-text'),
log: $('wpdo-mw-log'),
ratio: $('wpdo-mw-ratio'),
residue: $('wpdo-mw-residue'),
usermeta: $('wpdo-mw-usermeta'),
doneSummary: $('wpdo-mw-done-summary'),
};
let pollTimer = null;
let startedAt = 0;
let elapsedTimer = null;
function show(panel) {
[els.runPanel, els.progressPanel, els.donePanel].forEach((p) => {
if (!p) return;
if (p === panel) {
p.removeAttribute('hidden');
} else {
p.setAttribute('hidden', '');
}
});
}
function fetchJson(path, options = {}) {
const url = restUrl + path;
const opts = Object.assign(
{
method: 'GET',
credentials: 'same-origin',
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
},
options
);
return fetch(url, opts).then(async (r) => {
let body;
try {
body = await r.json();
} catch (e) {
body = null;
}
return { ok: r.ok, status: r.status, body };
});
}
// ── Start handling ───────────────────────────────────────────────────
if (els.confirmBox && els.startBtn) {
els.confirmBox.addEventListener('change', () => {
els.startBtn.disabled = !els.confirmBox.checked;
});
}
if (els.startBtn) {
els.startBtn.addEventListener('click', () => {
if (!confirm(i18n.confirmStart || 'Start migration?')) return;
els.startBtn.disabled = true;
els.startBtn.textContent = '…';
const opts = {
auto_backup: els.optBackup ? !!els.optBackup.checked : true,
verify_strict: els.optStrict ? !!els.optStrict.checked : true,
verify_24h: els.opt24h ? !!els.opt24h.checked : false,
force_async: els.optAsync ? !!els.optAsync.checked : false,
dry_run: els.optDryRun ? !!els.optDryRun.checked : false,
};
fetchJson('/migration/start', {
method: 'POST',
body: JSON.stringify(opts),
}).then((res) => {
if (!res.ok) {
if (res.body && res.body.reason === 'nothing_to_do') {
alert(i18n.nothingToDo);
els.startBtn.disabled = false;
els.startBtn.textContent = '🚀';
return;
}
alert((res.body && res.body.error) || 'Start failed');
els.startBtn.disabled = false;
els.startBtn.textContent = '🚀';
return;
}
startedAt = Date.now();
show(els.progressPanel);
startElapsedTimer();
startPolling();
});
});
}
// ── Cancel & Resume ──────────────────────────────────────────────────
if (els.cancelBtn) {
els.cancelBtn.addEventListener('click', () => {
if (!confirm(i18n.confirmCancel || 'Cancel?')) return;
els.cancelBtn.disabled = true;
fetchJson('/migration/cancel', { method: 'POST' }).then(() => {
stopPolling();
stopElapsedTimer();
setTimeout(() => location.reload(), 600);
});
});
}
if (els.resumeBtn) {
els.resumeBtn.addEventListener('click', () => {
els.resumeBtn.disabled = true;
fetchJson('/migration/resume', { method: 'POST' }).then((res) => {
if (res.ok) {
els.resumeBtn.setAttribute('hidden', '');
els.cancelBtn.disabled = false;
startPolling();
} else {
alert((res.body && res.body.error) || 'Resume failed');
els.resumeBtn.disabled = false;
}
});
});
}
if (els.resetBtn) {
els.resetBtn.addEventListener('click', () => location.reload());
}
// ── Polling ──────────────────────────────────────────────────────────
function startPolling() {
if (pollTimer) return;
const tick = () => {
fetchJson('/migration/status').then((res) => {
if (!res.ok || !res.body) {
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
return;
}
renderStatus(res.body);
const state = res.body.state;
if (state === 'running') {
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
} else if (state === 'completed') {
stopPolling();
stopElapsedTimer();
renderDone(res.body);
} else if (state === 'failed') {
stopPolling();
stopElapsedTimer();
renderFailed(res.body);
} else if (state === 'cancelled' || state === 'idle') {
stopPolling();
stopElapsedTimer();
}
});
};
tick();
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
}
function startElapsedTimer() {
if (elapsedTimer) return;
elapsedTimer = setInterval(() => {
if (!els.elapsed) return;
const sec = (Date.now() - startedAt) / 1000;
els.elapsed.textContent = sec.toFixed(1) + 's';
}, 100);
}
function stopElapsedTimer() {
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
}
}
// ── Render ───────────────────────────────────────────────────────────
function renderStatus(s) {
if (els.progressFill) {
els.progressFill.style.width = (s.overall_progress || 0) + '%';
}
if (els.progressPct) {
els.progressPct.textContent = (s.overall_progress || 0) + '%';
}
if (els.phaseName) {
els.phaseName.textContent = s.phase || '';
}
if (els.liveRatio && s.metrics) {
els.liveRatio.textContent =
'1:' + (s.metrics.ratio_start || '?') +
' → 1:' + (s.metrics.ratio_now || '?');
}
// Update top metrics live too
if (els.ratio && s.metrics && s.metrics.ratio_now != null) {
els.ratio.textContent = '1:' + s.metrics.ratio_now;
}
if (els.residue && s.metrics && s.metrics.eav_rows_now != null) {
els.residue.textContent = String(s.metrics.eav_rows_now);
}
updateLog(s.log || []);
}
let lastLogLength = 0;
function updateLog(lines) {
if (!els.log) return;
if (lines.length === lastLogLength) return;
els.log.textContent = lines.join('\n');
els.log.scrollTop = els.log.scrollHeight;
lastLogLength = lines.length;
// Apply a subtle highlight on the last line via CSS animation
els.log.classList.remove('wpdo-mw-log-flash');
// Force reflow so re-adding the class re-triggers the animation
// eslint-disable-next-line no-unused-expressions
void els.log.offsetWidth;
els.log.classList.add('wpdo-mw-log-flash');
}
function renderDone(s) {
show(els.donePanel);
if (!els.doneSummary) return;
const m = s.metrics || {};
const elapsed = ((s.completed_at || 0) - (s.started_at || 0));
els.doneSummary.innerHTML = '';
const lines = [
['ratio', '1:' + (m.ratio_start || '?') + ' → 1:' + (m.ratio_now || '?')],
['EAV 殘留', (m.eav_rows_start || 0) + ' → ' + (m.eav_rows_now || 0)],
['mode', (m.mode_start || '?') + ' → aeav_only'],
['耗時', elapsed + ' 秒'],
['備份', s.backup_path || '(無)'],
];
lines.forEach(([label, value]) => {
const div = document.createElement('div');
div.className = 'wpdo-mw-done-line';
const lab = document.createElement('span'); lab.textContent = label + '';
const val = document.createElement('strong'); val.textContent = value;
div.append(lab, val);
els.doneSummary.appendChild(div);
});
}
function renderFailed(s) {
els.progressTitle.textContent = '✗ 失敗 — ' + (i18n.failedRetry || '請 Resume 或 Cancel');
els.progressTitle.classList.add('wpdo-mw-failed');
if (els.resumeBtn) {
els.resumeBtn.removeAttribute('hidden');
els.resumeBtn.disabled = false;
}
}
// ── On-load: if a job is already running, hop into progress mode ────
const initialState = root.getAttribute('data-state');
if (initialState === 'running' || initialState === 'paused') {
startedAt = Date.now();
show(els.progressPanel);
startElapsedTimer();
startPolling();
} else if (initialState === 'failed') {
show(els.progressPanel);
renderFailed({ state: 'failed' });
} else if (initialState === 'completed') {
// Already shown via PHP hidden flag — but populate summary if available
fetchJson('/migration/status').then((res) => {
if (res.ok && res.body && res.body.state === 'completed') {
renderDone(res.body);
}
});
}
})();
+383
View File
@@ -0,0 +1,383 @@
/**
* WPDO Post Stress Test — admin tab JS (v2.11.4)
*
* Mirrors wpdo-stress-test.js (user side) but:
* - Talks to /wpdo/v1/post-stress-test/* endpoints
* - Sends post_type in start payload (user side has no entity selector)
* - DOM IDs prefixed wpdo-pst-* to coexist on the same admin page
* - Renders a smaller benchmark report (3 probes per post_type vs 6 for user)
*
* @since 2.11.4
*/
( function () {
'use strict';
const cfg = window.wpdoPostStressTest;
if ( ! cfg || ! cfg.restUrl ) {
return;
}
if ( ! document.querySelector( '.wpdo-post-stress-test-tab' ) ) {
return;
}
const $ = ( sel ) => document.querySelector( sel );
const restUrl = cfg.restUrl.replace( /\/$/, '' );
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
let pollTimer = null;
// ── API helpers ─────────────────────────────────────────────────────────
async function apiCall( path, method = 'GET', body = null ) {
const opts = { method, headers, credentials: 'same-origin' };
if ( body ) {
opts.body = JSON.stringify( body );
}
const resp = await fetch( restUrl + path, opts );
const text = await resp.text();
try {
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
} catch ( e ) {
return { ok: false, status: resp.status, data: { error: text } };
}
}
// ── Rendering ───────────────────────────────────────────────────────────
function fmt( n ) {
if ( n === null || n === undefined ) {
return '—';
}
return Number( n ).toLocaleString();
}
function setText( sel, text ) {
const el = $( sel );
if ( el ) {
el.textContent = String( text );
}
}
function esc( s ) {
if ( s === null || s === undefined ) {
return '';
}
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ c ] ) );
}
function renderProgress( state ) {
const isRunning = state.status === 'running' || state.status === 'benchmarking';
const card = $( '#wpdo-pst-progress-card' );
if ( card ) {
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
}
setText( '#wpdo-pst-pg-status', state.status || 'idle' );
setText( '#wpdo-pst-pg-post-type', state.post_type || '' );
setText( '#wpdo-pst-pg-mode', state.mode || '' );
setText( '#wpdo-pst-pg-pct', ( state.pct || 0 ) + '%' );
setText( '#wpdo-pst-pg-processed', fmt( state.processed || 0 ) );
setText( '#wpdo-pst-pg-target', fmt( state.target || 0 ) );
setText( '#wpdo-pst-pg-rate', state.rate_per_sec || 0 );
setText( '#wpdo-pst-pg-elapsed', state.elapsed_sec || 0 );
setText( '#wpdo-pst-pg-eta', state.eta_sec || 0 );
setText( '#wpdo-pst-pg-batches', state.batches_done || 0 );
setText( '#wpdo-pst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
const bar = $( '#wpdo-pst-pg-bar' );
if ( bar ) {
bar.style.width = ( state.pct || 0 ) + '%';
}
const count = state.test_post_count || 0;
setText( '#wpdo-pst-count', fmt( count ) );
setText( '#wpdo-pst-count-mirror', fmt( count ) );
const startBtn = $( '#wpdo-pst-start' );
const cancelBtn = $( '#wpdo-pst-cancel' );
const cleanupBtn = $( '#wpdo-pst-cleanup' );
const benchBtn = $( '#wpdo-pst-rerun-bench' );
if ( startBtn ) {
startBtn.disabled = isRunning;
}
if ( cancelBtn ) {
cancelBtn.disabled = ! isRunning;
}
if ( cleanupBtn ) {
cleanupBtn.disabled = isRunning || count === 0;
}
if ( benchBtn ) {
benchBtn.disabled = isRunning || count === 0;
}
}
function renderBenchmark( bench ) {
if ( ! bench ) {
return;
}
const card = $( '#wpdo-pst-bench-card' );
const content = $( '#wpdo-pst-bench-content' );
if ( ! card || ! content ) {
return;
}
card.style.display = '';
const w = bench.write || {};
const dbSizes = bench.db_sizes || [];
const q = bench.query || {};
// Friendly labels for the per-post_type query probes
const labels = {
point_stock_status: 'Point lookup (_stock_status)',
point_status: 'Point lookup (hp_status)',
point_verified: 'Point lookup (hp_verified)',
point_alt_present: 'Point lookup (alt 文字)',
point_type: 'Point lookup (_menu_item_type)',
point_thumbnail: 'Point lookup (_thumbnail_id)',
range_price_above: 'Range scan (price > 100)',
range_budget_above: 'Range scan (budget > 100)',
range_rate_above: 'Range scan (hourly_rate > 50)',
range_id_above: 'Range scan (post_id 排序)',
eav_baseline: 'EAV baseline (wp_postmeta 直查)',
};
let html = '';
// Write metrics
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
html += `<tr><td>Post Type</td><td><code>${ esc( w.post_type ) }</code></td></tr>`;
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> posts/sec</td></tr>`;
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
html += '</tbody></table>';
// DB sizes (wp_posts + wp_postmeta + flat table for this run)
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(post 相關表)</h4>';
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
html += '</tr></thead><tbody>';
dbSizes.forEach( ( r ) => {
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
} );
html += '</tbody></table>';
// Query perf — 3 probes per post_type, with EAV baseline last for visual comparison
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
html += '<th>測試項目</th><th>耗時 (ms)</th>';
html += '</tr></thead><tbody>';
const baselineMs = q.eav_baseline?.duration_ms ?? null;
Object.keys( q ).forEach( ( key ) => {
const v = q[ key ];
if ( ! v || typeof v.duration_ms !== 'number' ) {
return;
}
const label = labels[ key ] || key;
let speedup = '';
if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) {
const ratio = baselineMs / v.duration_ms;
if ( ratio >= 1 ) {
speedup = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
}
}
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
} );
html += '</tbody></table>';
html += '<p class="description">EAV baseline 走 wp_postmetaflat probes 走專屬 group 表。倍率即此規模下反 EAV 的查詢加速。</p>';
content.innerHTML = html;
}
// ── Polling ─────────────────────────────────────────────────────────────
async function poll() {
const r = await apiCall( '/post-stress-test/status' );
if ( ! r.ok || ! r.data ) {
return;
}
renderProgress( r.data );
if ( r.data.benchmark ) {
renderBenchmark( r.data.benchmark );
}
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
stopPolling();
}
}
function startPolling() {
stopPolling();
poll();
pollTimer = setInterval( poll, 2000 );
}
function stopPolling() {
if ( pollTimer ) {
clearInterval( pollTimer );
pollTimer = null;
}
}
// ── Event handlers ──────────────────────────────────────────────────────
async function handleStart() {
const postType = $( '#wpdo-pst-post-type' ).value;
const target = parseInt( $( '#wpdo-pst-target' ).value, 10 );
const batch = parseInt( $( '#wpdo-pst-batch' ).value, 10 );
const mode = document.querySelector( 'input[name="wpdo-pst-mode"]:checked' ).value;
if ( ! postType ) {
alert( '請選擇 post_type' );
return;
}
if ( ! target || target < 1 ) {
alert( '請輸入有效的 post 數量' );
return;
}
// Realistic 模式每 post 100-300msbatch 太大會撞 nginx 60s timeout 之前的 8s deadline
if ( mode === 'realistic' && batch > 30 ) {
if ( ! confirm( `Realistic 模式每個 post 約需 100-300 msbatch_size=${ batch } 可能超過後端 8s deadline 上限。建議使用 batch=10-20。要繼續嗎?` ) ) {
return;
}
}
// Soft warn for combinations that won't demonstrate反 EAV 優化效果
const postMode = String( cfg.postMode || 'disabled' );
if ( mode === 'fast' && postMode === 'aeav_only' ) {
if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 post mode=aeav_only 也會寫滿 wp_postmetafixture 用途,非優化驗證)。\n\n如果你想驗證反 EAV 優化效果(wp_postmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) {
return;
}
} else if ( mode === 'realistic' && postMode !== 'aeav_only' ) {
if ( ! confirm( `⚠️ 目前 post mode = ${ postMode },此模式下 Realistic 寫入仍會雙寫 wp_postmeta(不會展示優化效果,ratio 不變)。\n\n要看 wp_postmeta 完全短路(0 寫入)需要先把 mode 升到 aeav_only(設定 tab)。\n\n仍以 ${ postMode } 模式繼續測試嗎?` ) ) {
return;
}
}
const big = target >= 5000;
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() }${ postType } 測試 post${ big ? '(規模較大,可能耗時數分鐘)' : '' }\n\n所有 post 的 post_title 會以 WPDO_STRESS_TEST_ 開頭,可一鍵清除。\n\n確定要開始嗎?`;
if ( ! confirm( msg ) ) {
return;
}
const r = await apiCall( '/post-stress-test/start', 'POST', {
post_type: postType,
target,
mode,
batch_size: batch,
} );
if ( ! r.ok ) {
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
return;
}
// 啟動後立即顯示進度卡片
const card = $( '#wpdo-pst-progress-card' );
if ( card ) {
card.style.display = '';
}
// Hide the previous benchmark card (a fresh run will replace it)
const benchCard = $( '#wpdo-pst-bench-card' );
if ( benchCard ) {
benchCard.style.display = 'none';
}
setText( '#wpdo-pst-pg-status', 'running' );
setText( '#wpdo-pst-pg-post-type', postType );
setText( '#wpdo-pst-pg-mode', mode );
setText( '#wpdo-pst-pg-target', target.toLocaleString() );
startPolling();
}
async function handleCancel() {
if ( ! confirm( '確定要取消當前測試?已建立的 post 不會被刪除。' ) ) {
return;
}
const cancelBtn = $( '#wpdo-pst-cancel' );
if ( cancelBtn ) {
cancelBtn.disabled = true;
cancelBtn.textContent = '⏹ 取消中…';
}
setText( '#wpdo-pst-pg-status', 'cancelling' );
const r = await apiCall( '/post-stress-test/cancel', 'POST' );
if ( ! r.ok ) {
alert( '取消失敗:' + ( r.data?.error || r.status ) );
if ( cancelBtn ) {
cancelBtn.disabled = false;
cancelBtn.textContent = '⏹ 取消';
}
return;
}
// realistic 模式 in-flight batch 可能還要 ~1 秒才會真正中止
poll();
if ( cancelBtn ) {
cancelBtn.textContent = '⏹ 取消';
}
}
async function handleCleanup() {
if ( ! confirm( '確定要清除所有 stress test posts\n\n此動作會:\n- DELETE 所有 post_title 前綴 WPDO_STRESS_TEST_ 的 post\n- DELETE 對應 wp_postmeta\n- DELETE 7 張 wp_wpdo_post_* flat tables 中對應 post_id 的列\n\n不可復原!' ) ) {
return;
}
const r = await apiCall( '/post-stress-test/cleanup', 'DELETE' );
if ( ! r.ok ) {
alert( '清除失敗:' + ( r.data?.error || r.status ) );
return;
}
alert( `已清除 ${ r.data.deleted } 筆測試 post。` );
const card = $( '#wpdo-pst-progress-card' );
const benchCard = $( '#wpdo-pst-bench-card' );
if ( card ) card.style.display = 'none';
if ( benchCard ) benchCard.style.display = 'none';
poll();
}
async function handleRerunBench() {
const btn = $( '#wpdo-pst-rerun-bench' );
if ( btn ) {
btn.disabled = true;
btn.textContent = '⏳ 執行中...';
}
const r = await apiCall( '/post-stress-test/benchmark', 'POST' );
if ( btn ) {
btn.disabled = false;
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
}
if ( ! r.ok ) {
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
return;
}
renderBenchmark( r.data.benchmark );
}
// ── Init ────────────────────────────────────────────────────────────────
document.addEventListener( 'DOMContentLoaded', () => {
const startBtn = $( '#wpdo-pst-start' );
const cancelBtn = $( '#wpdo-pst-cancel' );
const cleanupBtn = $( '#wpdo-pst-cleanup' );
const benchBtn = $( '#wpdo-pst-rerun-bench' );
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
// 初始 poll:若 status==running 自動接手 polling;若 completed 渲染 benchmark
poll().then( () => {
const status = ( $( '#wpdo-pst-pg-status' )?.textContent || '' ).trim();
if ( status === 'running' || status === 'benchmarking' ) {
startPolling();
}
} );
} );
} )();
+247
View File
@@ -0,0 +1,247 @@
/**
* WP Data Optimizer — REST API JavaScript SDK
*
* Lightweight client for /wp-json/wpdo/v1/ endpoints.
* No dependencies required.
*
* Usage:
* const wpdo = new WpdoClient();
*
* // Fetch listings (Zone A)
* const { items, total } = await wpdo.getListings({ hp_price_min: 100, per_page: 20 });
*
* // Single listing (Zone A + Zone C)
* const listing = await wpdo.getListing(123);
*
* // View count (Zone B)
* const { view_count } = await wpdo.getStats(123);
*
* // Increment view count (Zone B, requires WP REST nonce)
* const { view_count: updated } = await wpdo.incrementView(123);
*
* // Iterate all pages
* for await (const page of wpdo.paginateListings({ hp_featured: 1 })) {
* console.log(page.items);
* }
*
* @package WP_Data_Optimizer
*/
/* global wpdo_sdk_config */
( function ( global ) {
'use strict';
/**
* WpdoClient — REST API wrapper for WP Data Optimizer.
*
* @param {object} [options]
* @param {string} [options.baseUrl] REST base URL (default: auto-detected from wpdo_sdk_config or /wp-json)
* @param {string} [options.nonce] WP REST nonce for authenticated requests
* @param {string} [options.postType] Default post type (default: 'hp_listing')
*/
function WpdoClient( options ) {
options = options || {};
var config = ( typeof wpdo_sdk_config !== 'undefined' ) ? wpdo_sdk_config : {};
this._base = options.baseUrl || config.rest_url || '/wp-json/wpdo/v1';
this._nonce = options.nonce || config.nonce || '';
this._postType = options.postType || config.post_type || 'hp_listing';
}
// ── Core fetch ────────────────────────────────────────────────────────────
/**
* Internal GET fetch helper.
*
* @param {string} path Relative path (e.g. '/listings')
* @param {object} params Query parameters
* @return {Promise<{data: *, headers: Headers, status: number}>}
*/
WpdoClient.prototype._fetch = function ( path, params ) {
var url = this._base + path;
if ( params && Object.keys( params ).length ) {
var qs = Object.keys( params )
.filter(
function ( k ) {
return params[ k ] !== null && params[ k ] !== undefined && params[ k ] !== ''; }
)
.map(
function ( k ) {
return encodeURIComponent( k ) + '=' + encodeURIComponent( params[ k ] ); }
)
.join( '&' );
if ( qs ) {
url += '?' + qs;
}
}
var headers = { 'Content-Type': 'application/json' };
if ( this._nonce ) {
headers[ 'X-WP-Nonce' ] = this._nonce;
}
return fetch( url, { headers: headers } ).then(
function ( res ) {
return res.json().then(
function ( data ) {
return { data: data, headers: res.headers, status: res.status };
}
);
}
);
};
/**
* Internal POST fetch helper.
*
* @param {string} path Relative path
* @param {object} body JSON body (optional)
* @return {Promise<{data: *, status: number}>}
*/
WpdoClient.prototype._post = function ( path, body ) {
var headers = { 'Content-Type': 'application/json' };
if ( this._nonce ) {
headers[ 'X-WP-Nonce' ] = this._nonce;
}
return fetch(
this._base + path,
{
method: 'POST',
headers: headers,
body: body ? JSON.stringify( body ) : null,
}
).then(
function ( res ) {
return res.json().then(
function ( data ) {
return { data: data, status: res.status };
}
);
}
);
};
// ── Public API ────────────────────────────────────────────────────────────
/**
* Fetch a page of listings from Zone A.
*
* @param {object} [params]
* @param {string} [params.post_type] Default: configured post type
* @param {number} [params.per_page] 1100, default 20
* @param {number} [params.page] Default 1
* @param {string} [params.orderby] Column name, default 'post_id'
* @param {string} [params.order] 'ASC' | 'DESC', default 'DESC'
* @param {number} [params.*_min] Numeric range filter (e.g. hp_price_min)
* @param {number} [params.*_max] Numeric range filter (e.g. hp_price_max)
* @param {*} [params.*] Exact match filter (e.g. hp_featured: 1)
* @return {Promise<{items: Array, total: number, totalPages: number}>}
*/
WpdoClient.prototype.getListings = function ( params ) {
var merged = Object.assign( { post_type: this._postType }, params || {} );
return this._fetch( '/listings', merged ).then(
function ( res ) {
return {
items: res.data,
total: parseInt( res.headers.get( 'X-WP-Total' ) || '0', 10 ),
totalPages: parseInt( res.headers.get( 'X-WP-TotalPages' ) || '0', 10 ),
status: res.status,
};
}
);
};
/**
* Fetch a single listing (Zone A + Zone C merged).
*
* @param {number} id Post ID
* @return {Promise<object>}
*/
WpdoClient.prototype.getListing = function ( id ) {
return this._fetch( '/listings/' + id, null ).then(
function ( res ) {
return res.data;
}
);
};
/**
* Fetch Zone B view count for a post.
*
* @param {number} id Post ID
* @return {Promise<{post_id: number, view_count: number}>}
*/
WpdoClient.prototype.getStats = function ( id ) {
return this._fetch( '/stats/' + id, null ).then(
function ( res ) {
return res.data;
}
);
};
/**
* Increment Zone B view count for a post (requires WP REST nonce).
*
* The nonce must be passed via the `nonce` constructor option or
* via `wpdo_sdk_config.nonce` (wp_create_nonce('wp_rest')).
*
* @param {number} id Post ID
* @return {Promise<{post_id: number, view_count: number}>}
*
* Example:
* // Auto-tracks a view when a listing page loads:
* document.addEventListener('DOMContentLoaded', () => {
* const postId = parseInt(document.body.dataset.postId);
* if (postId) wpdo.incrementView(postId);
* });
*/
WpdoClient.prototype.incrementView = function ( id ) {
return this._post( '/listings/' + id + '/view', null ).then(
function ( res ) {
return res.data;
}
);
};
/**
* Async generator — iterates every page of listings.
*
* @param {object} [params] Same params as getListings (page is managed internally)
* @yields {{items: Array, total: number, page: number, totalPages: number}}
*
* Example:
* for await (const page of wpdo.paginateListings({ hp_featured: 1 })) {
* page.items.forEach(item => console.log(item));
* }
*/
WpdoClient.prototype.paginateListings = async function * ( params ) {
var page = 1;
var total = Infinity;
var perPage = ( params && params.per_page ) ? params.per_page : 20;
while ( ( page - 1 ) * perPage < total ) {
var merged = Object.assign( {}, params || {}, { page: page } );
var result = await this.getListings( merged );
total = result.total;
yield { items: result.items, total: total, page: page, totalPages: result.totalPages };
if ( page >= result.totalPages ) {
break;
}
page++;
}
};
// ── Export ────────────────────────────────────────────────────────────────
global.WpdoClient = WpdoClient;
// Auto-instantiate as window.wpdo if config is present.
if ( typeof wpdo_sdk_config !== 'undefined' ) {
global.wpdo = new WpdoClient();
}
}( window ) );
+339
View File
@@ -0,0 +1,339 @@
/**
* WPDO User Stress Test — admin tab JS
*
* - 2s polling while running/benchmarking
* - Start / Cancel / Cleanup / Re-run benchmark
* - Renders progress + benchmark report
*
* @since 2.6.7
*/
( function () {
'use strict';
const cfg = window.wpdoStressTest;
if ( ! cfg || ! cfg.restUrl ) {
return;
}
if ( ! document.querySelector( '.wpdo-stress-test-tab' ) ) {
return;
}
const $ = ( sel ) => document.querySelector( sel );
const restUrl = cfg.restUrl.replace( /\/$/, '' );
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
let pollTimer = null;
// ── API helpers ─────────────────────────────────────────────────────────
async function apiCall( path, method = 'GET', body = null ) {
const opts = { method, headers, credentials: 'same-origin' };
if ( body ) {
opts.body = JSON.stringify( body );
}
const resp = await fetch( restUrl + path, opts );
const text = await resp.text();
try {
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
} catch ( e ) {
return { ok: false, status: resp.status, data: { error: text } };
}
}
// ── Rendering ───────────────────────────────────────────────────────────
function fmt( n ) {
if ( n === null || n === undefined ) {
return '—';
}
return Number( n ).toLocaleString();
}
function renderProgress( state ) {
const isRunning = state.status === 'running' || state.status === 'benchmarking';
const card = $( '#wpdo-st-progress-card' );
if ( card ) {
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
}
setText( '#wpdo-st-pg-status', state.status || 'idle' );
setText( '#wpdo-st-pg-mode', state.mode || '' );
setText( '#wpdo-st-pg-pct', ( state.pct || 0 ) + '%' );
setText( '#wpdo-st-pg-processed', fmt( state.processed || 0 ) );
setText( '#wpdo-st-pg-target', fmt( state.target || 0 ) );
setText( '#wpdo-st-pg-rate', state.rate_per_sec || 0 );
setText( '#wpdo-st-pg-elapsed', state.elapsed_sec || 0 );
setText( '#wpdo-st-pg-eta', state.eta_sec || 0 );
setText( '#wpdo-st-pg-batches', state.batches_done || 0 );
setText( '#wpdo-st-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
const bar = $( '#wpdo-st-pg-bar' );
if ( bar ) {
bar.style.width = ( state.pct || 0 ) + '%';
}
setText( '#wpdo-st-count', fmt( state.test_user_count || 0 ) );
const startBtn = $( '#wpdo-st-start' );
const cancelBtn = $( '#wpdo-st-cancel' );
const cleanupBtn = $( '#wpdo-st-cleanup' );
const benchBtn = $( '#wpdo-st-rerun-bench' );
if ( startBtn ) {
startBtn.disabled = isRunning;
}
if ( cancelBtn ) {
cancelBtn.disabled = ! isRunning;
}
if ( cleanupBtn ) {
cleanupBtn.disabled = isRunning || ( state.test_user_count || 0 ) === 0;
}
if ( benchBtn ) {
benchBtn.disabled = isRunning || ( state.test_user_count || 0 ) === 0;
}
}
function renderBenchmark( bench ) {
if ( ! bench ) {
return;
}
const card = $( '#wpdo-st-bench-card' );
const content = $( '#wpdo-st-bench-content' );
if ( ! card || ! content ) {
return;
}
card.style.display = '';
const w = bench.write || {};
const dbSizes = bench.db_sizes || [];
const q = bench.query || {};
const labels = {
get_field_membership_level: 'WPDO_API::get_field (membership_level) ×100',
get_entity_full: 'WPDO_API::get_entity (整筆) ×100',
range_gold_high_points: '索引範圍:gold + points>5000',
sort_recent_active_100: '排序:last_active_at DESC LIMIT 100',
join_top_gold_active: 'JOINtop gold + active LIMIT 100',
eav_range_baseline: '原生 EAV 等價查詢(baseline',
};
let html = '';
// Write metrics
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> users/sec</td></tr>`;
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
html += '</tbody></table>';
// DB sizes
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(user 相關表)</h4>';
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
html += '</tr></thead><tbody>';
dbSizes.forEach( ( r ) => {
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
} );
html += '</tbody></table>';
// Query perf
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
html += '<th>測試項目</th><th>樣本</th><th>總時間 (ms)</th><th>平均 (ms)</th><th>QPS</th>';
html += '</tr></thead><tbody>';
Object.keys( labels ).forEach( ( key ) => {
const v = q[ key ];
if ( ! v ) {
return;
}
const total = v.total_ms ?? v.duration_ms ?? '—';
html += `<tr><td>${ esc( labels[ key ] ) }</td><td>${ v.n || 1 }</td>`;
html += `<td><strong>${ total }</strong></td><td>${ v.avg_ms ?? '—' }</td><td>${ v.qps ?? '—' }</td></tr>`;
} );
html += '</tbody></table>';
html += '<p class="description">原生 EAV baseline 與 flat table 範圍查詢的時間差,即代表此規模下反 EAV 帶來的查詢加速倍數。</p>';
content.innerHTML = html;
}
function setText( sel, text ) {
const el = $( sel );
if ( el ) {
el.textContent = String( text );
}
}
function esc( s ) {
if ( s === null || s === undefined ) {
return '';
}
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ c ] ) );
}
// ── Polling ─────────────────────────────────────────────────────────────
async function poll() {
const r = await apiCall( '/stress-test/status' );
if ( ! r.ok || ! r.data ) {
return;
}
renderProgress( r.data );
if ( r.data.benchmark ) {
renderBenchmark( r.data.benchmark );
}
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
stopPolling();
}
}
function startPolling() {
stopPolling();
poll();
pollTimer = setInterval( poll, 2000 );
}
function stopPolling() {
if ( pollTimer ) {
clearInterval( pollTimer );
pollTimer = null;
}
}
// ── Event handlers ──────────────────────────────────────────────────────
async function handleStart() {
const target = parseInt( $( '#wpdo-st-target' ).value, 10 );
let batch = parseInt( $( '#wpdo-st-batch' ).value, 10 );
const mode = document.querySelector( 'input[name="wpdo-st-mode"]:checked' ).value;
if ( ! target || target < 1 ) {
alert( '請輸入有效的使用者數量' );
return;
}
// Realistic 模式每 user ~3.5 秒,batch_size 太大會撞 nginx 60s timeout
// 後端有 25s wall-clock deadline 保護,但前端先給友善提示
if ( mode === 'realistic' && batch > 10 ) {
if ( ! confirm( `Realistic 模式每個 user 約需 3 秒,batch_size=${ batch } 會超過後端 25s deadline 上限。建議使用 batch=10。要繼續嗎?` ) ) {
return;
}
}
const big = target >= 10000;
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆測試使用者${ big ? '(規模較大,可能耗時數分鐘)' : '' }\n\n密碼一律為 PassWord2026!user_login 為 test{n}。\n\n確定要開始嗎?`;
if ( ! confirm( msg ) ) {
return;
}
const r = await apiCall( '/stress-test/start', 'POST', { target, mode, batch_size: batch } );
if ( ! r.ok ) {
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
return;
}
// 啟動後立即顯示進度卡片,不必等第一次 poll 才出現
const card = $( '#wpdo-st-progress-card' );
if ( card ) {
card.style.display = '';
}
setText( '#wpdo-st-pg-status', 'running' );
setText( '#wpdo-st-pg-mode', mode );
setText( '#wpdo-st-pg-target', target.toLocaleString() );
startPolling();
}
async function handleCancel() {
if ( ! confirm( '確定要取消當前測試?已建立的使用者不會被刪除。' ) ) {
return;
}
// 立刻 UI 反饋:避免使用者再點一次
const cancelBtn = $( '#wpdo-st-cancel' );
if ( cancelBtn ) {
cancelBtn.disabled = true;
cancelBtn.textContent = '⏹ 取消中…';
}
setText( '#wpdo-st-pg-status', 'cancelling' );
const r = await apiCall( '/stress-test/cancel', 'POST' );
if ( ! r.ok ) {
alert( '取消失敗:' + ( r.data?.error || r.status ) );
if ( cancelBtn ) {
cancelBtn.disabled = false;
cancelBtn.textContent = '⏹ 取消';
}
return;
}
// realistic 模式 in-flight batch 可能還要 ~3 秒才會真正中止;polling 會看到 cancelled
poll();
if ( cancelBtn ) {
cancelBtn.textContent = '⏹ 取消';
}
}
async function handleCleanup() {
if ( ! confirm( '確定要清除所有 test_* 使用者?\n\n此動作會:\n- DELETE 所有 user_login LIKE "test%" 的使用者\n- DELETE 對應 wp_usermeta\n- DELETE 所有 wp_wpdo_user_* flat tables 中對應 user_id 的列\n\n不可復原!' ) ) {
return;
}
const r = await apiCall( '/stress-test/cleanup', 'DELETE' );
if ( ! r.ok ) {
alert( '清除失敗:' + ( r.data?.error || r.status ) );
return;
}
alert( `已清除 ${ r.data.deleted } 筆測試使用者。` );
const card = $( '#wpdo-st-progress-card' );
const benchCard = $( '#wpdo-st-bench-card' );
if ( card ) card.style.display = 'none';
if ( benchCard ) benchCard.style.display = 'none';
poll();
}
async function handleRerunBench() {
const btn = $( '#wpdo-st-rerun-bench' );
if ( btn ) {
btn.disabled = true;
btn.textContent = '⏳ 執行中...';
}
const r = await apiCall( '/stress-test/benchmark', 'POST' );
if ( btn ) {
btn.disabled = false;
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
}
if ( ! r.ok ) {
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
return;
}
renderBenchmark( r.data.benchmark );
}
// ── Init ────────────────────────────────────────────────────────────────
document.addEventListener( 'DOMContentLoaded', () => {
const startBtn = $( '#wpdo-st-start' );
const cancelBtn = $( '#wpdo-st-cancel' );
const cleanupBtn = $( '#wpdo-st-cleanup' );
const benchBtn = $( '#wpdo-st-rerun-bench' );
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
// 初始 poll,如果正在執行就會自動開始 polling
poll().then( () => {
const card = $( '#wpdo-st-progress-card' );
if ( card && card.style.display !== 'none' ) {
const status = ( $( '#wpdo-st-pg-status' )?.textContent || '' ).trim();
if ( status === 'running' || status === 'benchmarking' ) {
startPolling();
}
}
} );
} );
} )();
+368
View File
@@ -0,0 +1,368 @@
/**
* WPDO Term Stress Test — admin tab JS (v2.13.0)
*
* Mirrors wpdo-post-stress-test.js (v2.11.4) but:
* - Talks to /wpdo/v1/term-stress-test/* endpoints
* - Sends taxonomy in start payload (not post_type)
* - DOM IDs prefixed wpdo-tst-* (term stress test)
*
* @since 2.13.0
*/
( function () {
'use strict';
const cfg = window.wpdoTermStressTest;
if ( ! cfg || ! cfg.restUrl ) {
return;
}
if ( ! document.querySelector( '.wpdo-term-stress-test-tab' ) ) {
return;
}
const $ = ( sel ) => document.querySelector( sel );
const restUrl = cfg.restUrl.replace( /\/$/, '' );
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce };
let pollTimer = null;
// ── API helpers ─────────────────────────────────────────────────────────
async function apiCall( path, method = 'GET', body = null ) {
const opts = { method, headers, credentials: 'same-origin' };
if ( body ) {
opts.body = JSON.stringify( body );
}
const resp = await fetch( restUrl + path, opts );
const text = await resp.text();
try {
return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null };
} catch ( e ) {
return { ok: false, status: resp.status, data: { error: text } };
}
}
// ── Rendering ───────────────────────────────────────────────────────────
function fmt( n ) {
if ( n === null || n === undefined ) {
return '—';
}
return Number( n ).toLocaleString();
}
function setText( sel, text ) {
const el = $( sel );
if ( el ) {
el.textContent = String( text );
}
}
function esc( s ) {
if ( s === null || s === undefined ) {
return '';
}
return String( s ).replace( /[&<>"']/g, ( c ) => ( {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ c ] ) );
}
function renderProgress( state ) {
const isRunning = state.status === 'running' || state.status === 'benchmarking';
const card = $( '#wpdo-tst-progress-card' );
if ( card ) {
card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none';
}
setText( '#wpdo-tst-pg-status', state.status || 'idle' );
setText( '#wpdo-tst-pg-taxonomy', state.taxonomy || '' );
setText( '#wpdo-tst-pg-mode', state.mode || '' );
setText( '#wpdo-tst-pg-pct', ( state.pct || 0 ) + '%' );
setText( '#wpdo-tst-pg-processed', fmt( state.processed || 0 ) );
setText( '#wpdo-tst-pg-target', fmt( state.target || 0 ) );
setText( '#wpdo-tst-pg-rate', state.rate_per_sec || 0 );
setText( '#wpdo-tst-pg-elapsed', state.elapsed_sec || 0 );
setText( '#wpdo-tst-pg-eta', state.eta_sec || 0 );
setText( '#wpdo-tst-pg-batches', state.batches_done || 0 );
setText( '#wpdo-tst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) );
const bar = $( '#wpdo-tst-pg-bar' );
if ( bar ) {
bar.style.width = ( state.pct || 0 ) + '%';
}
const count = state.test_term_count || 0;
setText( '#wpdo-tst-count', fmt( count ) );
setText( '#wpdo-tst-count-mirror', fmt( count ) );
const startBtn = $( '#wpdo-tst-start' );
const cancelBtn = $( '#wpdo-tst-cancel' );
const cleanupBtn = $( '#wpdo-tst-cleanup' );
const benchBtn = $( '#wpdo-tst-rerun-bench' );
if ( startBtn ) {
startBtn.disabled = isRunning;
}
if ( cancelBtn ) {
cancelBtn.disabled = ! isRunning;
}
if ( cleanupBtn ) {
cleanupBtn.disabled = isRunning || count === 0;
}
if ( benchBtn ) {
benchBtn.disabled = isRunning || count === 0;
}
}
function renderBenchmark( bench ) {
if ( ! bench ) {
return;
}
const card = $( '#wpdo-tst-bench-card' );
const content = $( '#wpdo-tst-bench-content' );
if ( ! card || ! content ) {
return;
}
card.style.display = '';
const w = bench.write || {};
const dbSizes = bench.db_sizes || [];
const q = bench.query || {};
const labels = {
point_default: 'Point lookup (hp_default = 1)',
range_sort_top: 'Range scan (hp_sort_order > 0 ORDER BY DESC)',
eav_baseline: 'EAV baseline (wp_termmeta 直查 hp_default)',
};
let html = '';
// Write metrics
html += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
html += `<tr><td>Taxonomy</td><td><code>${ esc( w.taxonomy ) }</code></td></tr>`;
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> terms/sec</td></tr>`;
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
html += '</tbody></table>';
// DB sizes
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(term 相關表)</h4>';
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
html += '</tr></thead><tbody>';
dbSizes.forEach( ( r ) => {
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
} );
html += '</tbody></table>';
// Query perf — 3 probes with EAV baseline last for visual speedup comparison
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
html += '<th>測試項目</th><th>耗時 (ms)</th>';
html += '</tr></thead><tbody>';
const baselineMs = q.eav_baseline?.duration_ms ?? null;
Object.keys( q ).forEach( ( key ) => {
const v = q[ key ];
if ( ! v || typeof v.duration_ms !== 'number' ) {
return;
}
const label = labels[ key ] || key;
let speedup = '';
if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) {
const ratio = baselineMs / v.duration_ms;
if ( ratio >= 1 ) {
speedup = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
}
}
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
} );
html += '</tbody></table>';
html += '<p class="description">EAV baseline 走 wp_termmetaflat probes 走 wpdo_term_hp_taxonomy。倍率即此規模下反 EAV 的查詢加速。</p>';
content.innerHTML = html;
}
// ── Polling ─────────────────────────────────────────────────────────────
async function poll() {
const r = await apiCall( '/term-stress-test/status' );
if ( ! r.ok || ! r.data ) {
return;
}
renderProgress( r.data );
if ( r.data.benchmark ) {
renderBenchmark( r.data.benchmark );
}
if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) {
stopPolling();
}
}
function startPolling() {
stopPolling();
poll();
pollTimer = setInterval( poll, 2000 );
}
function stopPolling() {
if ( pollTimer ) {
clearInterval( pollTimer );
pollTimer = null;
}
}
// ── Event handlers ──────────────────────────────────────────────────────
async function handleStart() {
const taxonomy = $( '#wpdo-tst-taxonomy' ).value;
const target = parseInt( $( '#wpdo-tst-target' ).value, 10 );
const batch = parseInt( $( '#wpdo-tst-batch' ).value, 10 );
const mode = document.querySelector( 'input[name="wpdo-tst-mode"]:checked' ).value;
if ( ! taxonomy ) {
alert( '請選擇 taxonomy' );
return;
}
if ( ! target || target < 1 ) {
alert( '請輸入有效的 term 數量' );
return;
}
if ( mode === 'realistic' && batch > 50 ) {
if ( ! confirm( `Realistic 模式每個 term 約需 50-150 mswp_insert_term + 3 個 update_term_meta),batch_size=${ batch } 可能超過後端 8s deadline。建議 batch=10-30。要繼續嗎?` ) ) {
return;
}
}
// Soft warn for combinations that won't demonstrate反 EAV 優化效果
const termMode = String( cfg.termMode || 'disabled' );
if ( mode === 'fast' && termMode === 'aeav_only' ) {
if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 term mode=aeav_only 也會寫滿 wp_termmeta。\n\n要驗證反 EAV 優化效果(wp_termmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) {
return;
}
} else if ( mode === 'realistic' && termMode !== 'aeav_only' ) {
if ( ! confirm( `⚠️ 目前 term mode = ${ termMode }Realistic 寫入仍會雙寫 wp_termmeta(不展示優化效果)。\n\n要看 wp_termmeta 完全短路請先升 mode 至 aeav_only(設定 tab)。\n\n仍以 ${ termMode } 模式繼續嗎?` ) ) {
return;
}
}
const big = target >= 5000;
const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() }${ taxonomy } 測試 term${ big ? '(規模較大)' : '' }\n\n所有 term 的 slug 會以 wpdo-stress- 開頭,可一鍵清除。\n\n確定要開始嗎?`;
if ( ! confirm( msg ) ) {
return;
}
const r = await apiCall( '/term-stress-test/start', 'POST', {
taxonomy,
target,
mode,
batch_size: batch,
} );
if ( ! r.ok ) {
alert( '啟動失敗:' + ( r.data?.error || r.status ) );
return;
}
const card = $( '#wpdo-tst-progress-card' );
if ( card ) {
card.style.display = '';
}
const benchCard = $( '#wpdo-tst-bench-card' );
if ( benchCard ) {
benchCard.style.display = 'none';
}
setText( '#wpdo-tst-pg-status', 'running' );
setText( '#wpdo-tst-pg-taxonomy', taxonomy );
setText( '#wpdo-tst-pg-mode', mode );
setText( '#wpdo-tst-pg-target', target.toLocaleString() );
startPolling();
}
async function handleCancel() {
if ( ! confirm( '確定要取消當前測試?已建立的 term 不會被刪除。' ) ) {
return;
}
const cancelBtn = $( '#wpdo-tst-cancel' );
if ( cancelBtn ) {
cancelBtn.disabled = true;
cancelBtn.textContent = '⏹ 取消中…';
}
setText( '#wpdo-tst-pg-status', 'cancelling' );
const r = await apiCall( '/term-stress-test/cancel', 'POST' );
if ( ! r.ok ) {
alert( '取消失敗:' + ( r.data?.error || r.status ) );
if ( cancelBtn ) {
cancelBtn.disabled = false;
cancelBtn.textContent = '⏹ 取消';
}
return;
}
poll();
if ( cancelBtn ) {
cancelBtn.textContent = '⏹ 取消';
}
}
async function handleCleanup() {
if ( ! confirm( '確定要清除所有 stress test terms\n\n此動作會:\n- DELETE 所有 slug 前綴 wpdo-stress- 的 term\n- DELETE 對應 wp_termmeta + wp_term_taxonomy\n- DELETE flat 表中對應 term_id 的列\n\n不可復原!' ) ) {
return;
}
const r = await apiCall( '/term-stress-test/cleanup', 'DELETE' );
if ( ! r.ok ) {
alert( '清除失敗:' + ( r.data?.error || r.status ) );
return;
}
alert( `已清除 ${ r.data.deleted } 筆測試 term。` );
const card = $( '#wpdo-tst-progress-card' );
const benchCard = $( '#wpdo-tst-bench-card' );
if ( card ) card.style.display = 'none';
if ( benchCard ) benchCard.style.display = 'none';
poll();
}
async function handleRerunBench() {
const btn = $( '#wpdo-tst-rerun-bench' );
if ( btn ) {
btn.disabled = true;
btn.textContent = '⏳ 執行中...';
}
const r = await apiCall( '/term-stress-test/benchmark', 'POST' );
if ( btn ) {
btn.disabled = false;
btn.textContent = '📊 重跑 Benchmark(不新增資料)';
}
if ( ! r.ok ) {
alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) );
return;
}
renderBenchmark( r.data.benchmark );
}
// ── Init ────────────────────────────────────────────────────────────────
document.addEventListener( 'DOMContentLoaded', () => {
const startBtn = $( '#wpdo-tst-start' );
const cancelBtn = $( '#wpdo-tst-cancel' );
const cleanupBtn = $( '#wpdo-tst-cleanup' );
const benchBtn = $( '#wpdo-tst-rerun-bench' );
if ( startBtn ) startBtn.addEventListener( 'click', handleStart );
if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel );
if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup );
if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench );
poll().then( () => {
const status = ( $( '#wpdo-tst-pg-status' )?.textContent || '' ).trim();
if ( status === 'running' || status === 'benchmarking' ) {
startPolling();
}
} );
} );
} )();
File diff suppressed because it is too large Load Diff
+443
View File
@@ -0,0 +1,443 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: native postmeta count for dashboard widget
/**
* TMDO_Dashboard_Widget — wp-admin home dashboard widget (v2.4.0 M9).
*
* Adds a "WP Data Optimizer 健康狀態" widget on the WP admin dashboard for
* users with `manage_options`. Surfaces:
* - Today's health check status (green / yellow / red traffic light)
* - Consecutive green days streak
* - 3 quick statslast snapshot age, largest zone table, oldest open conflict
* - Quick links to Run Health Check, Entity Bridge, Create Snapshot
*
* Uses transient cache (5 minutes) to keep the dashboard responsive.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Dashboard widget — stateless static API.
*/
class TMDO_Dashboard_Widget {
public const WIDGET_ID = 'wpdo_health_widget';
public const CACHE_TTL = 300;
/**
* Register the dashboard widget hook.
*
* @return void
*/
public static function register(): void {
add_action( 'wp_dashboard_setup', array( __CLASS__, 'add_widget' ) );
}
/**
* Add the widget if user has manage_options.
*
* @return void
*/
public static function add_widget(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
wp_add_dashboard_widget(
self::WIDGET_ID,
__( 'WP Data Optimizer 健康狀態', '2meet-data-optimizer' ),
array( __CLASS__, 'render' )
);
}
/**
* Render the widget HTML.
*
* @return void
*/
public static function render(): void {
$page_url = admin_url( 'tools.php?page=wp-data-optimizer' );
$run_health_url = wp_nonce_url( add_query_arg( array( 'wpdo_run_health' => '1' ), $page_url ), 'wpdo_run_health' );
$create_snap_url = wp_nonce_url( add_query_arg( array( 'wpdo_create_snapshot' => '1' ), $page_url ), 'wpdo_create_snapshot' );
$bridge_url = add_query_arg( 'tab', 'entity-bridge', $page_url );
$status = self::compute_status();
$light = self::traffic_light( $status['level'] );
?>
<style>
.wpdo-widget-light { display: inline-block; width: 16px; height: 16px; border-radius: 50%; vertical-align: middle; margin-right: 6px; }
.wpdo-widget-light.good { background: #46b450; box-shadow: 0 0 8px rgba(70,180,80,0.5); }
.wpdo-widget-light.warn { background: #dba617; box-shadow: 0 0 8px rgba(219,166,23,0.5); }
.wpdo-widget-light.crit { background: #dc3232; box-shadow: 0 0 8px rgba(220,50,50,0.5); }
.wpdo-widget-light.gray { background: #c3c4c7; }
.wpdo-widget-stat { display: flex; justify-content: space-between; padding: 0.4em 0; border-bottom: 1px solid #f0f0f1; }
.wpdo-widget-stat:last-child { border-bottom: none; }
.wpdo-widget-actions { margin-top: 0.8em; padding-top: 0.8em; border-top: 1px solid #c3c4c7; }
.wpdo-widget-actions a { margin-right: 0.5em; }
</style>
<p style="font-size: 1.1em; margin-bottom: 0.6em;">
<span class="wpdo-widget-light <?php echo esc_attr( $light['class'] ); ?>"></span>
<strong><?php echo esc_html( $light['label'] ); ?></strong>
<?php if ( $status['streak'] > 0 ) : ?>
·
<?php
printf(
/* translators: %d: days */
esc_html__( '連續綠 %d 天', '2meet-data-optimizer' ),
(int) $status['streak']
);
?>
<?php endif; ?>
</p>
<?php if ( '' !== $status['summary_msg'] ) : ?>
<p class="description" style="margin-bottom: 0.8em;"><?php echo esc_html( $status['summary_msg'] ); ?></p>
<?php endif; ?>
<div class="wpdo-widget-stats">
<div class="wpdo-widget-stat">
<span><?php esc_html_e( '最近快照', '2meet-data-optimizer' ); ?></span>
<span><strong><?php echo esc_html( $status['last_snapshot'] ); ?></strong></span>
</div>
<div class="wpdo-widget-stat">
<span><?php esc_html_e( '最大 zone 表', '2meet-data-optimizer' ); ?></span>
<span><strong><?php echo esc_html( $status['largest_zone'] ); ?></strong></span>
</div>
<div class="wpdo-widget-stat">
<span><?php esc_html_e( '未處理衝突', '2meet-data-optimizer' ); ?></span>
<span>
<strong><?php echo esc_html( (string) (int) $status['conflicts'] ); ?></strong>
<?php if ( (int) $status['conflicts'] > 0 ) : ?>
<a href="<?php echo esc_url( add_query_arg( 'tab', 'conflicts', $page_url ) ); ?>"
style="margin-left:0.5em;"><?php esc_html_e( '查看', '2meet-data-optimizer' ); ?></a>
<?php endif; ?>
</span>
</div>
<div class="wpdo-widget-stat">
<span><?php esc_html_e( 'wp_postmeta 行數', '2meet-data-optimizer' ); ?></span>
<span><strong><?php echo esc_html( $status['postmeta_human'] ); ?></strong></span>
</div>
</div>
<?php
// v2.5.0 M16: actionable module suggestions count.
$actionable_count = 0;
if ( class_exists( 'TMDO_Module_Detector' ) ) {
$cached = TMDO_Module_Detector::get_cached();
if ( is_array( $cached ) && isset( $cached['results'] ) ) {
foreach ( (array) $cached['results'] as $r ) {
// v2.5.0 polish: align threshold with admin tab's get_actionable() default (0.5).
if ( ! empty( $r['available'] ) && 'enable' === ( $r['recommendation'] ?? '' ) && (float) ( $r['confidence'] ?? 0 ) >= 0.5 ) {
++$actionable_count;
}
}
}
}
if ( $actionable_count > 0 ) :
$ms_url = add_query_arg( 'tab', 'module-suggestions', $page_url );
?>
<p style="margin: 0.6em 0; padding: 0.5em 0.7em; background: #fff8e5; border-left: 3px solid #dba617; border-radius: 3px;">
🤖
<?php
printf(
/* translators: %d: count */
esc_html__( '偵測到 %d 個建議啟用的 module —', '2meet-data-optimizer' ),
(int) $actionable_count
);
?>
<a href="<?php echo esc_url( $ms_url ); ?>"><?php esc_html_e( '看建議', '2meet-data-optimizer' ); ?></a>
</p>
<?php endif; ?>
<?php
// v2.8.1: surface user-meta migration residue → CTA to one-click wizard.
$attn = class_exists( 'TMDO_Migration_Orchestrator' )
? TMDO_Migration_Orchestrator::needs_attention()
: array( 'needs' => false );
if ( ! empty( $attn['needs'] ) && 'running' !== ( $attn['job_state'] ?? '' ) ) :
$wizard_url = add_query_arg( 'tab', 'migration-wizard', $page_url );
?>
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #fef0f0; border-left: 3px solid #dc3232; border-radius: 3px;">
🔴
<?php
/* translators: 1: EAV row count, 2: group count, 3: ratio. */
$msg = __( '偵測到 <strong>%1$s</strong> 行 wp_usermeta EAV 殘留橫跨 <strong>%2$s</strong> 個 entity group(當前 ratio 1:<strong>%3$s</strong>)—', '2meet-data-optimizer' );
printf(
wp_kses( $msg, array( 'strong' => array() ) ),
esc_html( number_format_i18n( (int) $attn['eav_rows'] ) ),
esc_html( (string) (int) $attn['groups_with_residue'] ),
esc_html( (string) $attn['ratio'] )
);
?>
<a href="<?php echo esc_url( $wizard_url ); ?>"><strong><?php esc_html_e( 'User 遷移精靈 →', '2meet-data-optimizer' ); ?></strong></a>
</p>
<?php elseif ( 'running' === ( $attn['job_state'] ?? '' ) ) : ?>
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #e5f5fa; border-left: 3px solid #00a0d2; border-radius: 3px;">
⏳ <?php esc_html_e( 'User 遷移精靈正在執行中 —', '2meet-data-optimizer' ); ?>
<a href="<?php echo esc_url( add_query_arg( 'tab', 'migration-wizard', $page_url ) ); ?>"><?php esc_html_e( '查看進度', '2meet-data-optimizer' ); ?></a>
</p>
<?php endif; ?>
<?php
// v2.9.0 Phase 0: wp_postmeta garbage CTA (transients/_wp_old_date/stale _edit_lock).
$gc = class_exists( 'TMDO_Postmeta_Cleaner' )
? TMDO_Postmeta_Cleaner::count_garbage( TMDO_Postmeta_Cleaner::TARGET_ALL )
: array( 'total' => 0 );
if ( ! empty( $gc['total'] ) && (int) $gc['total'] > 0 ) :
$cleanup_url = wp_nonce_url(
add_query_arg( array( 'wpdo_postmeta_cleanup' => '1' ), $page_url ),
'wpdo_postmeta_cleanup'
);
$confirm_msg = sprintf(
/* translators: %s: total garbage row count */
esc_html__( '即將從 wp_postmeta 刪除 %s 行垃圾資料(transients + _wp_old_date + 過期 _edit_lock)。確認執行?', '2meet-data-optimizer' ),
number_format_i18n( (int) $gc['total'] )
);
?>
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #f6f7f7; border-left: 3px solid #2271b1; border-radius: 3px;">
🧹
<?php
/* translators: 1: total rows, 2: transients, 3: wp_old_date, 4: edit_locks */
$gc_msg = __( 'wp_postmeta 偵測到 <strong>%1$s</strong> 行可清理垃圾(transients %2$s + _wp_old_date %3$s + 過期 _edit_lock %4$s)—', '2meet-data-optimizer' );
printf(
wp_kses( $gc_msg, array( 'strong' => array() ) ),
esc_html( number_format_i18n( (int) $gc['total'] ) ),
esc_html( number_format_i18n( (int) $gc['transients'] ) ),
esc_html( number_format_i18n( (int) $gc['wp_old_date'] ) ),
esc_html( number_format_i18n( (int) $gc['edit_locks'] ) )
);
?>
<a href="<?php echo esc_url( $cleanup_url ); ?>"
onclick="return confirm(<?php echo wp_json_encode( $confirm_msg ); ?>);">
<strong><?php esc_html_e( '一鍵清理 →', '2meet-data-optimizer' ); ?></strong>
</a>
</p>
<?php endif; ?>
<?php
// v2.9.4: Post Entity diagnostics (independent of user-side needs_attention()).
// Shows wp_posts:wp_postmeta ratio, post mode, and total EAV residue
// across all 7 entity groups. Read-only summary; full wizard ships v2.9.5.
$post_diag = class_exists( 'TMDO_Post_Migration' ) ? TMDO_Post_Migration::diagnose() : null;
if ( null !== $post_diag && $post_diag['posts'] > 0 ) :
$total_eav = 0;
foreach ( $post_diag['groups'] as $g ) {
$total_eav += (int) $g['eav_rows'];
}
$post_color = $total_eav > 0 ? '#dba617' : '#46b450';
$post_bg = $total_eav > 0 ? '#fffbe5' : '#ecf7ed';
$post_label = $total_eav > 0
? sprintf(
/* translators: 1: total EAV rows, 2: ratio */
__( 'Post Entity:偵測到 <strong>%1$s</strong> 行 wp_postmeta EAV 殘留(當前 ratio 1:<strong>%2$s</strong>mode=<strong>%3$s</strong>)—', '2meet-data-optimizer' ),
'%1$s',
'%2$s',
'%3$s'
)
: sprintf(
/* translators: 1: ratio */
__( 'Post Entity:無 EAV 殘留(ratio 1:<strong>%1$s</strong>mode=<strong>%2$s</strong>', '2meet-data-optimizer' ),
'%1$s',
'%2$s'
);
?>
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: <?php echo esc_attr( $post_bg ); ?>; border-left: 3px solid <?php echo esc_attr( $post_color ); ?>; border-radius: 3px;">
📦
<?php
if ( $total_eav > 0 ) {
printf(
wp_kses(
/* translators: 1: total EAV rows, 2: ratio, 3: mode */
__( 'Post Entity:偵測到 <strong>%1$s</strong> 行 wp_postmeta EAV 殘留(當前 ratio 1:<strong>%2$s</strong>mode=<strong>%3$s</strong>)— v2.9.5 將提供一鍵遷移 UI。', '2meet-data-optimizer' ),
array( 'strong' => array() )
),
esc_html( number_format_i18n( $total_eav ) ),
esc_html( (string) $post_diag['ratio'] ),
esc_html( $post_diag['mode'] )
);
} else {
printf(
wp_kses(
/* translators: 1: ratio, 2: mode */
__( 'Post Entity:✓ 無 EAV 殘留(ratio 1:<strong>%1$s</strong>mode=<strong>%2$s</strong>', '2meet-data-optimizer' ),
array( 'strong' => array() )
),
esc_html( (string) $post_diag['ratio'] ),
esc_html( $post_diag['mode'] )
);
}
?>
</p>
<?php endif; ?>
<div class="wpdo-widget-actions">
<a class="button button-small button-primary" href="<?php echo esc_url( $run_health_url ); ?>">
<?php esc_html_e( '跑健康檢查', '2meet-data-optimizer' ); ?>
</a>
<a class="button button-small" href="<?php echo esc_url( $bridge_url ); ?>">
<?php esc_html_e( 'Entity Bridge', '2meet-data-optimizer' ); ?>
</a>
<a class="button button-small" href="<?php echo esc_url( $create_snap_url ); ?>">
<?php esc_html_e( '建立快照', '2meet-data-optimizer' ); ?>
</a>
<a href="<?php echo esc_url( $page_url ); ?>" style="float: right; padding-top: 4px;">
<?php esc_html_e( '完整儀表板 →', '2meet-data-optimizer' ); ?>
</a>
</div>
<?php
}
// ─── private ──────────────────────────────────────────────────────
/**
* Compute widget status. Cached via transient.
*
* @return array {level:string, summary_msg:string, streak:int,
* last_snapshot:string, largest_zone:string,
* conflicts:int, postmeta_human:string}
*/
private static function compute_status(): array {
$cached = get_transient( 'wpdo_dashboard_widget_status' );
if ( is_array( $cached ) ) {
return $cached;
}
global $wpdb;
// Health level from last cron run, falling back to "no data".
$last = class_exists( 'TMDO_Health_Cron' ) ? TMDO_Health_Cron::get_last_run() : null;
$level = 'unknown';
$msg = '';
$streak = 0;
if ( is_array( $last ) ) {
$crit = (int) ( $last['critical_count'] ?? 0 );
$rec = (int) ( $last['recommended_count'] ?? 0 );
if ( $crit > 0 ) {
$level = 'critical';
$msg = sprintf( /* translators: %d: count */ __( '%d 項 critical 警告', '2meet-data-optimizer' ), $crit );
} elseif ( $rec > 0 ) {
$level = 'warn';
$msg = sprintf( /* translators: %d: count */ __( '%d 項 recommended 提示', '2meet-data-optimizer' ), $rec );
} else {
$level = 'good';
$streak = TMDO_Health_Cron::consecutive_green_days();
}
$msg .= ' · ' . sprintf(
/* translators: %s: timestamp */
__( '最後執行:%s', '2meet-data-optimizer' ),
(string) $last['ran_at']
);
} else {
$msg = __( '尚未執行過健康檢查', '2meet-data-optimizer' );
}
// Last snapshot age.
$snap_table = $wpdb->prefix . 'wpdo_snapshots';
$snap_exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$snap_table
)
);
$last_snapshot = __( '從未', '2meet-data-optimizer' );
if ( 1 === $snap_exists ) {
$ts = (string) $wpdb->get_var( "SELECT created_at FROM `{$snap_table}` ORDER BY created_at DESC LIMIT 1" ); // phpcs:ignore WordPress.DB
if ( '' !== $ts ) {
$diff = time() - strtotime( $ts . ' UTC' );
$last_snapshot = $diff < 0 ? $ts : self::human_time_diff( $diff );
}
}
// Largest zone table by row count (rough sample).
$largest = '—';
if ( 1 === $snap_exists ) {
$row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_warm`" ); // phpcs:ignore WordPress.DB
if ( $row_count > 0 ) {
$largest = sprintf( 'wpdo_warm (%s)', number_format_i18n( $row_count ) );
}
}
// Conflict count (cheap — uses cached summary).
$conflicts = 0;
if ( class_exists( 'TMDO_Conflict_Monitor' ) ) {
$summary = TMDO_Conflict_Monitor::get_summary();
$conflicts = (int) ( $summary['total'] ?? 0 );
}
// wp_postmeta size (informational).
$pm_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
$result = array(
'level' => $level,
'summary_msg' => $msg,
'streak' => $streak,
'last_snapshot' => $last_snapshot,
'largest_zone' => $largest,
'conflicts' => $conflicts,
'postmeta_human' => number_format_i18n( $pm_count ),
);
set_transient( 'wpdo_dashboard_widget_status', $result, self::CACHE_TTL );
return $result;
}
/**
* Map level → CSS class + label.
*
* @param string $level Level slug.
* @return array {class:string,label:string}
*/
private static function traffic_light( string $level ): array {
switch ( $level ) {
case 'good':
return array(
'class' => 'good',
'label' => __( '正常', '2meet-data-optimizer' ),
);
case 'warn':
return array(
'class' => 'warn',
'label' => __( '注意', '2meet-data-optimizer' ),
);
case 'critical':
return array(
'class' => 'crit',
'label' => __( '警告', '2meet-data-optimizer' ),
);
default:
return array(
'class' => 'gray',
'label' => __( '未知', '2meet-data-optimizer' ),
);
}
}
/**
* Human-readable time-diff (seconds → "5 minutes ago" style). Uses WP
* `human_time_diff` when available; falls back to simple math otherwise.
*
* @param int $seconds Seconds delta (>=0).
* @return string
*/
private static function human_time_diff( int $seconds ): string {
if ( $seconds < 60 ) {
return $seconds . 's';
}
if ( function_exists( 'human_time_diff' ) ) {
return sprintf(
/* translators: %s: human-readable time difference */
__( '%s 前', '2meet-data-optimizer' ),
human_time_diff( time() - $seconds, time() )
);
}
if ( $seconds < 3600 ) {
return floor( $seconds / 60 ) . 'm';
}
if ( $seconds < 86400 ) {
return floor( $seconds / 3600 ) . 'h';
}
return floor( $seconds / 86400 ) . 'd';
}
}
+272
View File
@@ -0,0 +1,272 @@
<?php
/**
* TMDO_Export — Report export endpoints (v2.5.0 M14).
*
* Three export types
* 1. health — wp_wpdo_audit op='health_check_daily' over last N days
* 2. snapshots — wp_wpdo_snapshots metadata (no inline_blob payload)
* 3. monthly — wpdo_monthly_summary_history (latest + up to 12 archives)
*
* Triggered via admin GETtools.php?page=wp-data-optimizer&wpdo_export=health&format=csv&days=30
* Capability: manage_options + nonce.
*
* Filenameswpdo-{type}-{site_slug}-{YYYYMMDD}.{csv|json}
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Stateless static export handler.
*/
class TMDO_Export {
public const NONCE = 'wpdo_export';
/**
* Hook into admin_init to handle export requests.
*
* @return void
*/
public static function register(): void {
add_action( 'admin_init', array( __CLASS__, 'maybe_handle' ), 1 );
}
/**
* Detect + serve an export request.
*
* @return void
*/
public static function maybe_handle(): void {
if ( ! isset( $_GET['wpdo_export'] ) ) {
return;
}
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
if ( ! check_admin_referer( self::NONCE ) ) {
return;
}
$type = sanitize_key( wp_unslash( (string) $_GET['wpdo_export'] ) );
$format = sanitize_key( wp_unslash( (string) ( $_GET['format'] ?? 'csv' ) ) );
$days = isset( $_GET['days'] ) ? max( 1, min( 365, (int) $_GET['days'] ) ) : 30;
if ( ! in_array( $format, array( 'csv', 'json' ), true ) ) {
$format = 'csv';
}
switch ( $type ) {
case 'health':
self::send_health( $format, $days );
break;
case 'snapshots':
self::send_snapshots( $format );
break;
case 'monthly':
self::send_monthly( $format );
break;
}
}
/**
* Build a download URL with nonce.
*
* @param string $type health|snapshots|monthly.
* @param string $format csv|json.
* @param int $days For health only.
* @return string
*/
public static function url( string $type, string $format = 'csv', int $days = 30 ): string {
$args = array(
'page' => '2meet-data-optimizer',
'wpdo_export' => $type,
'format' => $format,
);
if ( 'health' === $type ) {
$args['days'] = $days;
}
return wp_nonce_url( add_query_arg( $args, admin_url( 'tools.php' ) ), self::NONCE );
}
// ─── handlers ──────────────────────────────────────────────────────
/**
* Send health export.
*
* @param string $format csv|json.
* @param int $days Number of days to include.
* @return void
*/
private static function send_health( string $format, int $days ): void {
global $wpdb;
$audit = $wpdb->prefix . 'wpdo_audit';
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$audit
)
);
$rows = array();
if ( 1 === $exists ) {
$raw = (array) $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$audit} is a trusted table name via $wpdb->prefix
"SELECT ts, op, value_after FROM `{$audit}` WHERE op = 'health_check_daily' AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY) ORDER BY ts DESC",
$days
),
ARRAY_A
);
foreach ( $raw as $r ) {
$ctx = isset( $r['value_after'] ) ? json_decode( (string) $r['value_after'], true ) : array();
if ( ! is_array( $ctx ) ) {
$ctx = array();
}
$rows[] = array(
'ts' => (string) $r['ts'],
'critical' => (int) ( $ctx['critical'] ?? 0 ),
'recommended' => (int) ( $ctx['recommended'] ?? 0 ),
'duration_ms' => (int) ( $ctx['duration_ms'] ?? 0 ),
'autoload_kb' => (int) ( $ctx['autoload_kb'] ?? 0 ),
'conflicts' => (int) ( $ctx['conflicts'] ?? 0 ),
'module_suggestions_count' => (int) ( $ctx['module_suggestions_count'] ?? 0 ),
);
}
}
$headers = array( 'ts', 'critical', 'recommended', 'duration_ms', 'autoload_kb', 'conflicts', 'module_suggestions_count' );
self::respond( $format, 'health', $headers, $rows );
}
/**
* Send snapshots export.
*
* @param string $format csv|json.
* @return void
*/
private static function send_snapshots( string $format ): void {
global $wpdb;
$snap = $wpdb->prefix . 'wpdo_snapshots';
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$snap
)
);
$rows = array();
if ( 1 === $exists ) {
$rows = (array) $wpdb->get_results(
"SELECT snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, file_sha256, notes, created_at, expires_at FROM `{$snap}` ORDER BY created_at DESC", // phpcs:ignore WordPress.DB
ARRAY_A
);
}
$headers = array( 'snapshot_id', 'trigger_type', 'size_bytes', 'row_count', 'storage', 'file_path', 'file_sha256', 'notes', 'created_at', 'expires_at' );
self::respond( $format, 'snapshots', $headers, $rows );
}
/**
* Send monthly summary export.
*
* @param string $format csv|json.
* @return void
*/
private static function send_monthly( string $format ): void {
$latest = (array) get_option( 'wpdo_monthly_summary_latest', array() );
$history = (array) get_option( 'wpdo_monthly_summary_history', array() );
$all = array();
if ( ! empty( $latest ) ) {
$all[] = $latest;
}
foreach ( $history as $h ) {
if ( is_array( $h ) ) {
$all[] = $h;
}
}
if ( 'json' === $format ) {
self::respond_json( 'monthly', $all );
return;
}
// Flatten for CSV — top-level metric only.
$rows = array();
foreach ( $all as $row ) {
$health = (array) ( $row['health'] ?? array() );
$snap = (array) ( $row['snapshots'] ?? array() );
$rows[] = array(
'period_start' => (string) ( $row['period_start'] ?? '' ),
'period_end' => (string) ( $row['period_end'] ?? '' ),
'generated_at' => (string) ( $row['generated_at'] ?? '' ),
'health_total' => (int) ( $health['total'] ?? 0 ),
'health_success' => (int) ( $health['success'] ?? 0 ),
'health_critical' => (int) ( $health['critical'] ?? 0 ),
'snapshots_total' => (int) ( $snap['total'] ?? 0 ),
'snapshots_bytes' => (int) ( $snap['total_bytes'] ?? 0 ),
'autoload_size' => (int) ( $row['autoload_size'] ?? 0 ),
);
}
$headers = array( 'period_start', 'period_end', 'generated_at', 'health_total', 'health_success', 'health_critical', 'snapshots_total', 'snapshots_bytes', 'autoload_size' );
self::respond_csv( 'monthly', $headers, $rows );
}
// ─── output helpers ────────────────────────────────────────────────
/**
* Dispatch to csv or json responder.
*
* @param string $format csv|json.
* @param string $type Export type slug.
* @param array $headers Column headers.
* @param array $rows Data rows.
* @return void
*/
private static function respond( string $format, string $type, array $headers, array $rows ): void {
if ( 'json' === $format ) {
self::respond_json( $type, $rows );
return;
}
self::respond_csv( $type, $headers, $rows );
}
/**
* Send CSV response.
*
* @param string $type Export type slug.
* @param array $headers Column headers.
* @param array $rows Data rows.
* @return void
*/
private static function respond_csv( string $type, array $headers, array $rows ): void {
$body = TMDO_CSV_Writer::build( $headers, $rows );
self::send_attachment( $type, 'csv', 'text/csv; charset=UTF-8', $body );
}
/**
* Send JSON response.
*
* @param string $type Export type slug.
* @param array $rows Data rows.
* @return void
*/
private static function respond_json( string $type, array $rows ): void {
$body = wp_json_encode( $rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
self::send_attachment( $type, 'json', 'application/json; charset=UTF-8', (string) $body );
}
/**
* Send file attachment response.
*
* @param string $type Export type slug.
* @param string $ext File extension.
* @param string $mime MIME type.
* @param string $body File body content.
* @return void
*/
private static function send_attachment( string $type, string $ext, string $mime, string $body ): void {
$site_slug = sanitize_title( (string) get_option( 'blogname', 'site' ) ) ?: 'site';
$filename = sprintf( 'wpdo-%s-%s-%s.%s', $type, $site_slug, gmdate( 'Ymd' ), $ext );
nocache_headers();
header( 'Content-Type: ' . $mime );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Content-Length: ' . strlen( $body ) );
echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
exit;
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
/**
* TMDO_Help_Tabs — Contextual help tabs (v2.3.0 M8).
*
* Registers `add_help_tab()` content on the WPDO admin page. Each tab gets a
* dedicated help panel that explains:
* - what this tab is for
* - what to do here as a first-time user
* - links to Entity Bridge, Snapshots, Doctor when relevant
*
* Hooked on `load-tools_page_wp-data-optimizer` so help tabs only appear on
* our admin page (not site-wide).
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Help tab registrar — stateless static API.
*/
class TMDO_Help_Tabs {
/**
* Hook into admin page load.
*
* @return void
*/
public static function register(): void {
add_action( 'load-tools_page_wp-data-optimizer', array( __CLASS__, 'add_tabs' ) );
}
/**
* Register help tabs based on current `tab` GET param.
*
* @return void
*/
public static function add_tabs(): void {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( null === $screen ) {
return;
}
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) : 'dashboard'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// Always-on overview tab.
$screen->add_help_tab(
array(
'id' => 'wpdo-help-overview',
'title' => __( '什麼是 WPDO', '2meet-data-optimizer' ),
'content' => self::content_overview(),
)
);
// Per-tab help (matches whichever tab is active).
$tab_help = array(
'dashboard' => array( __( '儀表板閱讀指南', '2meet-data-optimizer' ), 'content_dashboard' ),
'zones' => array( __( '4 個 Zone 是什麼', '2meet-data-optimizer' ), 'content_zones' ),
'classifier' => array( __( 'Classifier 解讀', '2meet-data-optimizer' ), 'content_classifier' ),
'snapshots' => array( __( '備份策略', '2meet-data-optimizer' ), 'content_snapshots' ),
'conflicts' => array( __( '衝突處理', '2meet-data-optimizer' ), 'content_conflicts' ),
'doctor' => array( __( '健康檢查解讀', '2meet-data-optimizer' ), 'content_doctor' ),
'logs' => array( __( '日誌使用', '2meet-data-optimizer' ), 'content_logs' ),
'rest-api' => array( __( 'REST API', '2meet-data-optimizer' ), 'content_rest_api' ),
);
if ( isset( $tab_help[ $tab ] ) ) {
[ $title, $cb ] = $tab_help[ $tab ];
$screen->add_help_tab(
array(
'id' => 'wpdo-help-' . $tab,
'title' => $title,
'content' => call_user_func( array( __CLASS__, $cb ) ),
)
);
}
// Sidebar with persistent links.
$screen->set_help_sidebar(
'<p><strong>' . esc_html__( '更多資源', '2meet-data-optimizer' ) . '</strong></p>'
. '<p><a href="' . esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=entity-bridge' ) ) . '">' . esc_html__( 'Entity Bridge — 主維運入口', '2meet-data-optimizer' ) . '</a></p>'
. '<p><a href="' . esc_url( admin_url( 'site-health.php' ) ) . '">' . esc_html__( 'WP Site Health', '2meet-data-optimizer' ) . '</a></p>'
. '<p><code>wp wpdo doctor</code><br/><code>wp wpdo mode-audit</code><br/><code>wp wpdo snapshot create</code></p>'
);
}
// ─── content templates ─────────────────────────────────────────────
/**
* Overview help content.
*
* @return string
*/
private static function content_overview(): string {
return '<p>' . esc_html__( 'WP Data Optimizer 是反 EAVmeta 爆炸)的解方。', '2meet-data-optimizer' ) . '</p>'
. '<p>' . esc_html__( '核心概念:把 wp_postmeta 的高頻欄位(Hot)、TTL 暫存(Warm)、低頻欄位(Cold)、歷史資料(Archive)拆到 4 種專用表,讀寫快很多、autoload 不再爆。', '2meet-data-optimizer' ) . '</p>'
. '<p><strong>' . esc_html__( '建議第一步:', '2meet-data-optimizer' ) . '</strong> ' . esc_html__( '逛一遍儀表板了解現況 → 看 Entity Bridge tab 各 entity 健康卡片 → 從 1 個 entity 開始用 Migration Wizard 漸進升級 mode。', '2meet-data-optimizer' ) . '</p>';
}
/**
* Dashboard help content.
*
* @return string
*/
private static function content_dashboard(): string {
return '<p>' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '</p>'
. '<ul style="list-style: disc; padding-left: 1.5em;">'
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、HPCT、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'Archive 統計 — 壓縮率、依 post_type 拆分', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'REST API rate limit — 429 事件 + Top 10 受限 post', '2meet-data-optimizer' ) . '</li>'
. '</ul>';
}
/**
* Zones help content.
*
* @return string
*/
private static function content_zones(): string {
return '<p>' . esc_html__( '4 個 Zone 對應不同存取頻率與保留需求:', '2meet-data-optimizer' ) . '</p>'
. '<ul style="list-style: disc; padding-left: 1.5em;">'
. '<li><strong>Hot</strong> — ' . esc_html__( '高頻索引欄位,如 listing 的 price / location。獨立 column + indexWP_Query 可 JOIN。', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>Warm</strong> — ' . esc_html__( 'TTL 暫存(如 view count、cache stats)。固定表 wp_wpdo_warm 含 expires_at。', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>Cold</strong> — ' . esc_html__( '低頻 metasettings / preferences)。讀寫透過 interceptor 攔截後保持 EAV 形式。', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>Archive</strong> — ' . esc_html__( 'Trashed / 90+ 天舊資料。可 gzip 壓縮。', '2meet-data-optimizer' ) . '</li>'
. '</ul>'
. '<p>' . esc_html__( '不確定要哪種 → 用 Classifier,它會看 access pattern 給建議。', '2meet-data-optimizer' ) . '</p>';
}
/**
* Classifier help content.
*
* @return string
*/
private static function content_classifier(): string {
return '<p>' . esc_html__( 'Classifier 分析 wp_postmeta 給每個 meta_key 一個 zone 建議:', '2meet-data-optimizer' ) . '</p>'
. '<ul style="list-style: disc; padding-left: 1.5em;">'
. '<li><strong>Confidence</strong> — ' . esc_html__( '0.0~1.0,越高代表分類越確定。≥ 0.8 可放心採納,< 0.5 建議 manual review。', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>Reasons</strong> — ' . esc_html__( '說明為什麼建議這個 zoneaccess frequency / row count / TTL hints)。', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>Already-assigned</strong> — ' . esc_html__( '已透過 Schema_Registry 註冊的 meta_key 數量。', '2meet-data-optimizer' ) . '</li>'
. '</ul>';
}
/**
* Snapshots help content.
*
* @return string
*/
private static function content_snapshots(): string {
return '<p>' . esc_html__( '快照保留政策(v2.2.0):', '2meet-data-optimizer' ) . '</p>'
. '<ul style="list-style: disc; padding-left: 1.5em;">'
. '<li>' . esc_html__( '預設 30 天 TTL,可在 wp wpdo snapshot create 時用 --retention-days 覆蓋。', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'pre_uninstall / pre_v2_upgrade triggers 受 size-cap 保護(不會被自動 evict)。', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( '檔案存於 wp-content/uploads/wpdo-backups/,含 .htaccess deny all + 每個檔 sha256 校驗。', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( '小於 5MB 自動 inline 到 wp_wpdo_snapshots.inline_blob,方便 wp db export 時跟著走。', '2meet-data-optimizer' ) . '</li>'
. '</ul>'
. '<p><strong>' . esc_html__( '災難還原 drill', '2meet-data-optimizer' ) . '</strong>'
. esc_html__( '建議每月做一次 dry-run 還原驗證 — wp wpdo snapshot restore <id>(不加 --apply)即可預覽會還原什麼。', '2meet-data-optimizer' ) . '</p>';
}
/**
* Conflicts help content.
*
* @return string
*/
private static function content_conflicts(): string {
return '<p>' . esc_html__( 'Hook 衝突偵測:當多個 plugin 在同一 WordPress 的 metadata filter 上掛 callback 時,可能造成資料寫入順序不確定 / 重複處理。', '2meet-data-optimizer' ) . '</p>'
. '<p>' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '</p>'
. '<ul style="list-style: disc; padding-left: 1.5em;">'
. '<li>' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'HPCT (HP Custom Tables) plugin 還沒移除 — 與 WPDO 同時攔截', '2meet-data-optimizer' ) . '</li>'
. '</ul>'
. '<p>' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '</p>';
}
/**
* Doctor help content.
*
* @return string
*/
private static function content_doctor(): string {
return '<p>' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
. '<ol style="padding-left: 1.5em;">'
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>hook_conflicts</strong> — ' . esc_html__( '同上 conflicts tab', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>autoload_bloat</strong> — ' . esc_html__( 'wp_options autoload 大小 > 5MB 警告', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>postmeta_explosion</strong> — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
. '</ol>'
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
}
/**
* Logs help content.
*
* @return string
*/
private static function content_logs(): string {
return '<p>' . esc_html__( '日誌讀取:', '2meet-data-optimizer' ) . '</p>'
. '<ul style="list-style: disc; padding-left: 1.5em;">'
. '<li>' . esc_html__( '每筆對應 wp_wpdo_errors 一行:module / zone / hook / message / timestamp。', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( '預設保留 90 天(wpdo_errors_gc daily cron 自動清)。', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( '看 message 開頭 [WARN] 是 warning level(不影響運作但需注意)。', '2meet-data-optimizer' ) . '</li>'
. '</ul>';
}
/**
* REST API help content.
*
* @return string
*/
private static function content_rest_api(): string {
return '<p>' . esc_html__( 'REST API 提供 zone 操作 + diagnostics endpoints。', '2meet-data-optimizer' ) . '</p>'
. '<p>' . esc_html__( '所有 endpoint 用 X-WP-Nonce 認證;rate limit 預設 30/min。', '2meet-data-optimizer' ) . '</p>';
}
}
+474
View File
@@ -0,0 +1,474 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: postmeta inventory for setup wizard
/**
* TMDO_Setup_Wizard — First-run onboarding wizard (v2.3.0 M5).
*
* 5 steps:
* 1. Welcome + anti-EAV concept (4-zone diagram, when needed, when not).
* 2. Baseline diagnostic — auto-runs Site Health, postmeta count, top
* meta_key consumers, predicted optimization upside.
* 3. Recommendation — checks Classifier suggestions, presents pre-checked
* modules with "OK" / "skip" choice (no auto-enable; explicit consent).
* 4. First snapshot + daily health check (already scheduled by Core, just
* confirms here).
* 5. Done — sets wpdo_setup_wizard_completed=1, wpdo_first_run_at timestamp.
*
* Trigger: admin loads any wp-admin page AND wpdo_setup_wizard_completed != '1'.
* Existing-user shielding: if any module is not idle (= already migrating),
* we mark wizard completed immediately to avoid ambushing experienced admins.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Setup wizard — stateless static API.
*/
class TMDO_Setup_Wizard {
public const OPT_COMPLETED = 'wpdo_setup_wizard_completed';
public const OPT_FIRST_RUN = 'wpdo_first_run_at';
public const QUERY_PARAM = 'wpdo_wizard';
public const NONCE_NAME = 'wpdo_wizard_nonce';
public const TOTAL_STEPS = 5;
/**
* Bootstrap hooks.
*
* @return void
*/
public static function register(): void {
add_action( 'admin_init', array( __CLASS__, 'maybe_redirect_to_wizard' ) );
add_action( 'admin_menu', array( __CLASS__, 'register_menu_page' ) );
add_action( 'admin_post_wpdo_wizard_step', array( __CLASS__, 'handle_step_submit' ) );
add_action( 'admin_post_wpdo_wizard_dismiss', array( __CLASS__, 'handle_dismiss' ) );
add_action( 'admin_post_wpdo_wizard_reset', array( __CLASS__, 'handle_reset' ) );
}
/**
* On first activation, decide: run wizard, or shield (already configured).
*
* @return void
*/
public static function maybe_redirect_to_wizard(): void {
// Don't redirect on AJAX / cron / auto-saves / wp-cli.
if ( wp_doing_ajax() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
return;
}
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
if ( '1' === get_option( self::OPT_COMPLETED ) ) {
return;
}
// Shield existing users: if any module is not idle, the admin already knows
// what they're doing. Mark wizard completed silently.
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
$flags = TMDO_Feature_Flags::all();
foreach ( $flags as $state ) {
if ( 'idle' !== $state ) {
update_option( self::OPT_COMPLETED, '1', false );
update_option( self::OPT_FIRST_RUN, gmdate( 'Y-m-d H:i:s' ), false );
return;
}
}
}
// Don't redirect if already on the wizard page or another WPDO admin page.
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( in_array( $page, array( 'wpdo-setup-wizard', '2meet-data-optimizer' ), true ) ) {
return;
}
// Only redirect from the WordPress dashboard top page (avoid plugin install + mu-plugins flow).
global $pagenow;
if ( 'index.php' !== $pagenow ) {
return;
}
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=1' ) );
exit;
}
/**
* Register the wizard as a visible submenu item under Tools > WP Data Optimizer.
*
* Appears in the Tools section so admins can re-run the wizard at any time.
* The wizard itself is idempotent: re-running it never auto-enables modules.
*
* @return void
*/
public static function register_menu_page(): void {
add_submenu_page(
'tools.php',
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
'manage_options',
'wpdo-setup-wizard',
array( __CLASS__, 'render_page' )
);
}
/**
* Render the wizard.
*
* @return void
*/
public static function render_page(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
wp_die( esc_html__( 'Insufficient permissions.', '2meet-data-optimizer' ) );
}
$step = max( 1, min( self::TOTAL_STEPS, absint( wp_unslash( $_GET['step'] ?? 1 ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$completed = '1' === get_option( self::OPT_COMPLETED );
?>
<div class="wrap" style="max-width: 800px;">
<p>
<a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
&larr; <?php esc_html_e( '返回 WP Data Optimizer', '2meet-data-optimizer' ); ?>
</a>
</p>
<h1><?php esc_html_e( 'WP Data Optimizer — 設定嚮導', '2meet-data-optimizer' ); ?></h1>
<?php if ( $completed ) : ?>
<div style="background:#f0f6fc; border-left:4px solid #2271b1; padding:0.75em 1em; margin-bottom:1em; display:flex; align-items:center; gap:1em;">
<span>✅ <?php esc_html_e( '嚮導已完成。你可以重新瀏覽任何步驟,或重設為全新執行。', '2meet-data-optimizer' ); ?></span>
<a class="button button-secondary"
href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_reset' ), self::NONCE_NAME ) ); ?>"
style="white-space:nowrap;">
<?php esc_html_e( '重新執行精靈', '2meet-data-optimizer' ); ?>
</a>
</div>
<?php endif; ?>
<p class="description">
<?php esc_html_e( '第', '2meet-data-optimizer' ); ?>
<strong><?php echo (int) $step; ?></strong> /
<strong><?php echo (int) self::TOTAL_STEPS; ?></strong>
<?php esc_html_e( '步', '2meet-data-optimizer' ); ?>
</p>
<div style="background: #f0f0f1; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 2em;">
<div style="background: #2271b1; height: 100%; width: <?php echo (int) ( $step / self::TOTAL_STEPS * 100 ); ?>%; transition: width 0.3s;"></div>
</div>
<div class="card" style="max-width: none; padding: 2em;">
<?php call_user_func( array( __CLASS__, 'render_step_' . $step ) ); ?>
</div>
<p style="margin-top: 2em;">
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_dismiss' ), self::NONCE_NAME ) ); ?>"
style="color: #888; font-size: 0.9em;">
<?php esc_html_e( '我熟悉了,跳過嚮導', '2meet-data-optimizer' ); ?>
</a>
</p>
</div>
<?php
}
// ─── steps ─────────────────────────────────────────────────────────
/**
* Render wizard step 1: Welcome.
*
* @return void
*/
private static function render_step_1(): void {
?>
<h2><?php esc_html_e( '👋 歡迎使用 WP Data Optimizer', '2meet-data-optimizer' ); ?></h2>
<p><?php esc_html_e( 'WPDO 解決的是 wp_postmeta 表「meta 爆炸」問題:當 postmeta 累積到數百萬行時,autoload 變大、JOIN 變慢、整站變慢。', '2meet-data-optimizer' ); ?></p>
<h3><?php esc_html_e( '4 個 Zone 是什麼?', '2meet-data-optimizer' ); ?></h3>
<table class="widefat" style="margin-bottom: 1em;">
<thead>
<tr><th>Zone</th><th><?php esc_html_e( '用途', '2meet-data-optimizer' ); ?></th><th><?php esc_html_e( '舉例', '2meet-data-optimizer' ); ?></th></tr>
</thead>
<tbody>
<tr><td><strong>Hot</strong></td><td><?php esc_html_e( '高頻 + 索引欄位', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( 'price, location, vendor_id', '2meet-data-optimizer' ); ?></td></tr>
<tr><td><strong>Warm</strong></td><td><?php esc_html_e( 'TTL 暫存(會過期)', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( 'view_count, last_seen', '2meet-data-optimizer' ); ?></td></tr>
<tr><td><strong>Cold</strong></td><td><?php esc_html_e( '低頻設定型欄位', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( 'preferences, settings', '2meet-data-optimizer' ); ?></td></tr>
<tr><td><strong>Archive</strong></td><td><?php esc_html_e( '舊資料 / 已 trashed', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( '90+ 天前的記錄', '2meet-data-optimizer' ); ?></td></tr>
</tbody>
</table>
<h3><?php esc_html_e( '何時需要 WPDO', '2meet-data-optimizer' ); ?></h3>
<ul style="list-style: disc; padding-left: 1.5em;">
<li><?php esc_html_e( '✅ wp_postmeta 行數 > 100k 開始考慮', '2meet-data-optimizer' ); ?></li>
<li><?php esc_html_e( '✅ 行數 > 1M 強烈建議啟用', '2meet-data-optimizer' ); ?></li>
<li><?php esc_html_e( '⚠️ 小站台(< 10k 行)可以裝著但別啟用 module', '2meet-data-optimizer' ); ?></li>
</ul>
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
<input type="hidden" name="page" value="wpdo-setup-wizard">
<input type="hidden" name="step" value="2">
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:跑健診 →', '2meet-data-optimizer' ); ?></button>
</form>
<?php
}
/**
* Render wizard step 2: Baseline diagnostic.
*
* @return void
*/
private static function render_step_2(): void {
global $wpdb;
$pm_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
$autoload_bytes = (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
$top_keys = $wpdb->get_results( "SELECT meta_key, COUNT(*) AS c FROM `{$wpdb->postmeta}` GROUP BY meta_key ORDER BY c DESC LIMIT 10", ARRAY_A ); // phpcs:ignore WordPress.DB
?>
<h2><?php esc_html_e( '🔬 Baseline 健診', '2meet-data-optimizer' ); ?></h2>
<table class="widefat striped" style="max-width: 600px; margin-bottom: 1em;">
<tbody>
<tr><th><?php esc_html_e( 'wp_postmeta 行數', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( number_format_i18n( $pm_count ) ); ?></strong></td></tr>
<tr><th><?php esc_html_e( 'autoload 大小', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( size_format( $autoload_bytes, 1 ) ); ?></strong></td></tr>
<tr><th><?php esc_html_e( '建議啟用 WPDO', '2meet-data-optimizer' ); ?></th>
<td>
<?php if ( $pm_count > 1_000_000 ) : ?>
<span style="color: #46b450; font-weight: bold;">✅ <?php esc_html_e( '強烈建議', '2meet-data-optimizer' ); ?></span>
<?php elseif ( $pm_count > 100_000 ) : ?>
<span style="color: #dba617; font-weight: bold;">🟡 <?php esc_html_e( '可以考慮', '2meet-data-optimizer' ); ?></span>
<?php else : ?>
<span style="color: #8c8f94;">⏸ <?php esc_html_e( '尚不必', '2meet-data-optimizer' ); ?></span>
<?php endif; ?>
</td>
</tr>
</tbody>
</table>
<h3><?php esc_html_e( 'Top 10 meta_key(依行數)', '2meet-data-optimizer' ); ?></h3>
<table class="widefat striped">
<thead><tr><th>meta_key</th><th><?php esc_html_e( '行數', '2meet-data-optimizer' ); ?></th></tr></thead>
<tbody>
<?php foreach ( (array) $top_keys as $k ) : ?>
<tr><td><code><?php echo esc_html( (string) $k['meta_key'] ); ?></code></td><td><?php echo esc_html( number_format_i18n( (int) $k['c'] ) ); ?></td></tr>
<?php endforeach; ?>
</tbody>
</table>
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
<input type="hidden" name="page" value="wpdo-setup-wizard">
<input type="hidden" name="step" value="3">
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:看建議 →', '2meet-data-optimizer' ); ?></button>
</form>
<?php
}
/**
* Render wizard step 3: Recommendations.
*
* @return void
*/
private static function render_step_3(): void {
?>
<h2><?php esc_html_e( '🧭 智能推薦', '2meet-data-optimizer' ); ?></h2>
<p><?php esc_html_e( '系統依你的環境(plugin / post_type / 行數)自動偵測哪些 module 適合啟用。每筆建議含 confidence + reasons。', '2meet-data-optimizer' ); ?></p>
<?php
$actionable = array();
if ( class_exists( 'TMDO_Module_Detector' ) ) {
$actionable = TMDO_Module_Detector::get_actionable( 0.5 );
}
?>
<?php if ( empty( $actionable ) ) : ?>
<div class="notice notice-info inline" style="padding: 1em;">
<p><?php esc_html_e( '目前環境暫無高 confidence 的 module 建議。可隨時前往「模組建議」tab 重新檢查。', '2meet-data-optimizer' ); ?></p>
</div>
<?php else : ?>
<h3>
<?php
printf(
/* translators: %d: count */
esc_html__( '✅ 偵測到 %d 個建議啟用的 module', '2meet-data-optimizer' ),
count( $actionable )
);
?>
</h3>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Module', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'Confidence', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( '說明', '2meet-data-optimizer' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $actionable as $module => $r ) : ?>
<tr>
<td><strong><code><?php echo esc_html( (string) $module ); ?></code></strong></td>
<td><?php echo esc_html( sprintf( '%.2f', (float) $r['confidence'] ) ); ?></td>
<td>
<em><?php echo esc_html( (string) $r['description'] ); ?></em><br/>
<small><?php echo esc_html( implode( ' · ', (array) $r['reasons'] ) ); ?></small>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="description"><?php esc_html_e( '完成 wizard 後,到「模組建議」tab 一鍵啟用(推進到 dual_writeFSM Guard 確保不越級)。', '2meet-data-optimizer' ); ?></p>
<?php endif; ?>
<h3><?php esc_html_e( '黃金法則', '2meet-data-optimizer' ); ?></h3>
<ul style="list-style: disc; padding-left: 1.5em;">
<li><?php esc_html_e( '一次只推進 1 個 module,每階段觀察至少 24-48 小時。', '2meet-data-optimizer' ); ?></li>
<li><?php esc_html_e( '進 cutover 前一定要有 snapshotFSM Guard 自動觸發)。', '2meet-data-optimizer' ); ?></li>
<li><?php esc_html_e( '出事第一件事:rewind 該 module 到 idleemergency 流程)。', '2meet-data-optimizer' ); ?></li>
</ul>
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
<input type="hidden" name="page" value="wpdo-setup-wizard">
<input type="hidden" name="step" value="4">
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:建第一個 snapshot →', '2meet-data-optimizer' ); ?></button>
</form>
<?php
}
/**
* Render wizard step 4: Snapshot + health check.
*
* @return void
*/
private static function render_step_4(): void {
// On entry, take the first snapshot if not already done in this wizard run.
$snapshot_taken = isset( $_GET['snap_done'] ) ? '1' === $_GET['snap_done'] : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
?>
<h2><?php esc_html_e( '📸 第一個 Snapshot + Daily Health Check', '2meet-data-optimizer' ); ?></h2>
<h3><?php esc_html_e( '📦 Snapshot', '2meet-data-optimizer' ); ?></h3>
<?php if ( $snapshot_taken ) : ?>
<p style="color: #46b450; font-weight: bold;">✅ <?php esc_html_e( '快照已建立。可隨時於「備份快照」tab 查看與管理。', '2meet-data-optimizer' ); ?></p>
<?php else : ?>
<p><?php esc_html_e( '我們會建立一個 baseline snapshot,命名為 wizard_baseline,保留 365 天。即使你日後沒做任何 destructive 操作,這也是一個 known-good 還原點。', '2meet-data-optimizer' ); ?></p>
<p>
<a class="button button-secondary"
href="<?php echo esc_url( wp_nonce_url( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&action=take_snapshot' ), self::NONCE_NAME ) ); ?>">
<?php esc_html_e( '建立 baseline snapshot', '2meet-data-optimizer' ); ?>
</a>
</p>
<?php
// Take snapshot if action requested.
if ( isset( $_GET['action'] ) && 'take_snapshot' === $_GET['action'] && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) {
$result = TMDO_Snapshot_Manager::create(
'manual',
array(),
array(
'notes' => 'wizard_baseline',
'retention_days' => 365,
)
);
if ( ! empty( $result['ok'] ) ) {
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) );
exit;
}
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
}
?>
<?php endif; ?>
<h3><?php esc_html_e( '🔄 Daily Health Check', '2meet-data-optimizer' ); ?></h3>
<p>
<?php
$next = wp_next_scheduled( 'wpdo_daily_health_check' );
if ( $next ) {
printf(
/* translators: %s: human-readable timestamp */
esc_html__( 'Daily cron 已排程,下次執行:%sUTC', '2meet-data-optimizer' ),
'<code>' . esc_html( gmdate( 'Y-m-d H:i:s', $next ) ) . '</code>'
);
} else {
esc_html_e( '⚠️ Daily cron 未排程;請重新啟用外掛。', '2meet-data-optimizer' );
}
?>
</p>
<p class="description"><?php esc_html_e( 'Cron 會跑 7 項 Site Health 檢查;critical 警告寫入 admin notice + audit log。', '2meet-data-optimizer' ); ?></p>
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
<input type="hidden" name="page" value="wpdo-setup-wizard">
<input type="hidden" name="step" value="5">
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:完成 →', '2meet-data-optimizer' ); ?></button>
</form>
<?php
}
/**
* Render wizard step 5: Done.
*
* @return void
*/
private static function render_step_5(): void {
// Mark complete on render of step 5.
update_option( self::OPT_COMPLETED, '1', false );
if ( ! get_option( self::OPT_FIRST_RUN ) ) {
update_option( self::OPT_FIRST_RUN, gmdate( 'Y-m-d H:i:s' ), false );
}
?>
<h2><?php esc_html_e( '🎉 完成!', '2meet-data-optimizer' ); ?></h2>
<p><?php esc_html_e( 'Setup wizard 已結束。下一步建議:', '2meet-data-optimizer' ); ?></p>
<ul style="list-style: disc; padding-left: 1.5em; line-height: 1.8;">
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=entity-bridge' ) ); ?>"><?php esc_html_e( '🌉 看 Entity Bridge 健康卡片', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( 'user / post / term / comment 4 entity 即時健康+模式狀態', '2meet-data-optimizer' ); ?></li>
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=migration-wizard' ) ); ?>"><?php esc_html_e( '🪄 開 User / Post 遷移精靈', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '一鍵推進 disabled → dual_write → shadow_read → aeav_only', '2meet-data-optimizer' ); ?></li>
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=classifier' ) ); ?>"><?php esc_html_e( '🔍 跑 Classifier', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '看推薦 zone 配置', '2meet-data-optimizer' ); ?></li>
<li><a href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>"><?php esc_html_e( '🏥 WP Site Health', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '7 個 WPDO 健康檢查', '2meet-data-optimizer' ); ?></li>
</ul>
<p style="margin-top: 2em;">
<a class="button button-primary button-hero" href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
<?php esc_html_e( '前往 WPDO 儀表板', '2meet-data-optimizer' ); ?>
</a>
</p>
<?php
}
// ─── form / dismiss handlers ──────────────────────────────────────
/**
* Handle wizard step form submit.
*
* @return void
*/
public static function handle_step_submit(): void {
// v2.13.3: capability check first (matches handle_dismiss / handle_reset
// pattern; fixes L-AUTH-1). Reserved for future POST-based steps —
// currently steps use GET nav so body is a no-op redirect.
if ( ! TMDO_Capability::current_user_can_admin() ) {
wp_die( esc_html__( 'Insufficient permissions.', '2meet-data-optimizer' ) );
}
check_admin_referer( self::NONCE_NAME );
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=1' ) );
exit;
}
/**
* Handle wizard dismiss action.
*
* @return void
*/
public static function handle_dismiss(): void {
check_admin_referer( self::NONCE_NAME );
if ( TMDO_Capability::current_user_can_admin() ) {
update_option( self::OPT_COMPLETED, '1', false );
update_option( self::OPT_FIRST_RUN, gmdate( 'Y-m-d H:i:s' ), false );
}
wp_safe_redirect( admin_url( 'tools.php?page=wp-data-optimizer' ) );
exit;
}
/**
* Handle wizard reset — clears OPT_COMPLETED so wizard reruns from step 1.
*
* @return void
*/
public static function handle_reset(): void {
check_admin_referer( self::NONCE_NAME );
if ( TMDO_Capability::current_user_can_admin() ) {
update_option( self::OPT_COMPLETED, '0', false );
}
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=1' ) );
exit;
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
/**
* Comment Stress Test template (v2.13.1).
*
* Full async + polling UI mirroring Term / Post / User Stress Test:
* 1. 設定並啟動測試(form: post_id / target / mode / batch_size + Start/Cancel
* 2. 即時進度(progress bar + processed/target/rate/ETA/peak memory
* 3. Benchmark 報告(write metrics + DB sizes + query performance
*
* Variables in scope from render_comment_stress_test():
* $state — TMDO_Comment_Stress_Tester::get_progress(false) output
* $test_comment_count — int, comments matching @wpdo-stress.local email
* $posts — array<post_id, label> of available posts (top by comment_count)
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$status_str = (string) ( $state['status'] ?? 'idle' );
$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str );
$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'comment' ) : 'disabled';
$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' );
$mode_optimal = 'aeav_only' === $current_mode;
$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
global $wpdb;
$flat_hp_review = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_comment_hp_review" );
$flat_misc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_comment_misc" );
$total_comments = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comments}" );
$total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->commentmeta}" );
?>
<div class="wpdo-comment-stress-test-tab">
<h2><?php esc_html_e( 'Comment Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
<?php esc_html_e( '會建立大量測試 comment 並 seed 對應 hp_review 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
</div>
<p class="description">
<?php esc_html_e( '透過自動產生大量測試 comment,評估 comment entity 反 EAV 系統在不同規模下的寫入吞吐與查詢效能。所有測試 comment 均以 @wpdo-stress.local 為 email 後綴,可一鍵清除。', '2meet-data-optimizer' ); ?>
</p>
<!-- Diagnose snapshot -->
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Comment Entity 狀態', '2meet-data-optimizer' ); ?></h3>
<table class="widefat striped">
<tr>
<td><?php esc_html_e( 'wp_comments', '2meet-data-optimizer' ); ?></td>
<td><strong><?php echo esc_html( number_format_i18n( $total_comments ) ); ?></strong></td>
<td><?php esc_html_e( 'wp_commentmeta', '2meet-data-optimizer' ); ?></td>
<td><strong><?php echo esc_html( number_format_i18n( $total_commentmeta ) ); ?></strong></td>
</tr>
<tr>
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_mode ); ?></strong></td>
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
<td><strong>1:<?php echo esc_html( (string) ( $total_comments > 0 ? round( $total_commentmeta / $total_comments, 2 ) : 0 ) ); ?></strong></td>
</tr>
<tr>
<td><code>wpdo_comment_hp_review</code></td>
<td><strong><?php echo esc_html( number_format_i18n( $flat_hp_review ) ); ?></strong> rows</td>
<td><code>wpdo_comment_misc</code></td>
<td><strong><?php echo esc_html( number_format_i18n( $flat_misc ) ); ?></strong> rows</td>
</tr>
<tr>
<td colspan="2"><?php esc_html_e( 'Stress 測試 comment 數', '2meet-data-optimizer' ); ?></td>
<td colspan="2"><strong id="wpdo-cst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?></strong></td>
</tr>
</table>
</div>
<!-- 驗證反 EAV 優化指南 -->
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
<h3 style="margin-top:0;font-size:14px;">
<?php if ( $mode_optimal ) : ?>
✅ <?php esc_html_e( 'Comment Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
<?php else : ?>
⚠️
<?php
printf(
/* translators: %s: current mode */
esc_html__( 'Comment Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_mode ) . '</code>'
);
?>
<?php endif; ?>
</h3>
<p style="margin:8px 0 0 0;">
<strong><?php esc_html_e( '想看 wp_commentmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
</p>
<ol style="margin:6px 0 8px 22px;padding:0;">
<li>
<?php
printf(
/* translators: 1: open code, 2: close code */
esc_html__( 'Comment mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Comment entity', '2meet-data-optimizer' ),
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
'</code>'
);
?>
<?php if ( ! $mode_optimal ) : ?>
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
<?php endif; ?>
</li>
<li>
<?php esc_html_e( '寫入模式選 🐢 Realistic(走 wp_insert_comment + Hook Bus,會被攔截短路 wp_commentmeta', '2meet-data-optimizer' ); ?>
</li>
</ol>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
<!-- Left: Configure & Start -->
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
<table class="form-table" style="margin-top:0;">
<tr>
<th style="width:35%;"><label for="wpdo-cst-post-id"><?php esc_html_e( '目標 Post', '2meet-data-optimizer' ); ?></label></th>
<td>
<select id="wpdo-cst-post-id" class="regular-text">
<?php if ( empty( $posts ) ) : ?>
<option value=""><?php esc_html_e( '— 沒有可用的 publish post —', '2meet-data-optimizer' ); ?></option>
<?php else : ?>
<?php foreach ( $posts as $pid => $label ) : ?>
<option value="<?php echo esc_attr( (string) $pid ); ?>">
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p class="description"><?php esc_html_e( '所有測試 comment 會 attach 到此 post。建議用 hp_listing post — seed 後 hp_rating 走 hp_review 群組。', '2meet-data-optimizer' ); ?></p>
</td>
</tr>
<tr>
<th><label for="wpdo-cst-target"><?php esc_html_e( '要建立的 comment 數', '2meet-data-optimizer' ); ?></label></th>
<td>
<input type="number" id="wpdo-cst-target" min="1" max="100000" value="500" class="regular-text" />
<p class="description"><?php esc_html_e( '常用:100 / 500 / 1,000 / 10,000(上限 100,000', '2meet-data-optimizer' ); ?></p>
</td>
</tr>
<tr>
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
<td>
<label style="display:block;margin-bottom:6px;">
<input type="radio" name="wpdo-cst-mode" value="fast" checked />
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus', '2meet-data-optimizer' ); ?>
</label>
<label style="display:block;">
<input type="radio" name="wpdo-cst-mode" value="realistic" />
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_comment + update_comment_meta(較慢,模擬生產路徑 + 觸發 Hook Bus', '2meet-data-optimizer' ); ?>
</label>
</td>
</tr>
<tr>
<th><label for="wpdo-cst-batch"><?php esc_html_e( '批次大小', '2meet-data-optimizer' ); ?></label></th>
<td>
<input type="number" id="wpdo-cst-batch" min="1" max="1000" value="200" class="small-text" />
<p class="description"><?php esc_html_e( '每批 8 秒 wall-clock 上限。Fast 建議 200-1000Realistic 建議 10-30。', '2meet-data-optimizer' ); ?></p>
</td>
</tr>
</table>
<p>
<button type="button" class="button button-primary button-large" id="wpdo-cst-start" <?php disabled( $is_running || empty( $posts ) ); ?>>
<?php esc_html_e( '🚀 啟動壓力測試', '2meet-data-optimizer' ); ?>
</button>
<button type="button" class="button" id="wpdo-cst-cancel" <?php disabled( ! $is_running ); ?>>
<?php esc_html_e( '⏹ 取消', '2meet-data-optimizer' ); ?>
</button>
</p>
</div>
<!-- Right: Cleanup + Re-run benchmark -->
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
<p>
<?php esc_html_e( '目前 stress test comment 數:', '2meet-data-optimizer' ); ?>
<strong id="wpdo-cst-count-mirror" style="font-size:18px;color:#dc3545;">
<?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?>
</strong>
</p>
<p class="description">
<?php esc_html_e( '一鍵清除所有 email 後綴 @wpdo-stress.local 的 comment,連同 wp_commentmeta + flat 表的對應 row。', '2meet-data-optimizer' ); ?>
</p>
<p>
<button type="button" class="button button-secondary" id="wpdo-cst-cleanup" <?php disabled( $is_running || 0 === $test_comment_count ); ?>>
<?php esc_html_e( '🗑 清除全部測試 comment', '2meet-data-optimizer' ); ?>
</button>
</p>
<hr style="margin:18px 0;" />
<p>
<button type="button" class="button" id="wpdo-cst-rerun-bench" <?php disabled( $is_running || 0 === $test_comment_count ); ?>>
<?php esc_html_e( '📊 重跑 Benchmark(不新增資料)', '2meet-data-optimizer' ); ?>
</button>
</p>
</div>
</div>
<!-- Progress card (live) -->
<div id="wpdo-cst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
<span>
<strong id="wpdo-cst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
· <code id="wpdo-cst-pg-post-id">post #<?php echo esc_html( (string) ( $state['post_id'] ?? '' ) ); ?></code>
· <span id="wpdo-cst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
</span>
<span id="wpdo-cst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
</div>
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
<div id="wpdo-cst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
</div>
</div>
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
<tr>
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;font-weight:600;"><span id="wpdo-cst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-cst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;font-weight:600;"><span id="wpdo-cst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> comments/sec</td>
</tr>
<tr>
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-cst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-cst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
</tr>
<tr>
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-cst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-cst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
</tr>
</table>
</div>
<!-- Benchmark report -->
<div id="wpdo-cst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-cst-bench-content"></div>
</div>
</div>
+217
View File
@@ -0,0 +1,217 @@
<?php
/**
* One-click User Entity Migration Wizard.
*
* Variables in scope (from class-tmdo-admin.php::render_migration_wizard):
* $preflight array TMDO_Migration_Orchestrator::preflight() snapshot.
* $status array TMDO_Migration_Orchestrator::get_status() snapshot.
*
* @package WP_Data_Optimizer
* @since 2.8.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$ratio = $preflight['ratio'] ?? 0;
$users_count = $preflight['users'] ?? 0;
$usermeta_count = $preflight['usermeta'] ?? 0;
$eav_residue = $preflight['eav_rows'] ?? 0;
$current_mode = $preflight['mode'] ?? 'unknown';
$strategy = $preflight['estimated_strategy'] ?? 'sync';
$est_sec = $preflight['estimated_sec'] ?? 0;
$groups = $preflight['groups'] ?? array();
$state = $status['state'] ?? 'idle';
$active = in_array( $state, array( 'running', 'paused' ), true );
$completed = 'completed' === $state;
$failed = 'failed' === $state;
?>
<div class="wpdo-migration-wizard" data-state="<?php echo esc_attr( $state ); ?>">
<header class="wpdo-mw-header">
<h2><?php esc_html_e( 'User Entity 遷移精靈', '2meet-data-optimizer' ); ?></h2>
<p class="description">
<?php esc_html_e( '把 wp_usermeta 中已註冊為 entity field 的 legacy keys 一次遷移到 flat tables。流程包含自動備份、demote、bulk SQL backfill、shadow_read 驗證、aeav_only cutover、以及最終的 EAV 殘留清除。', '2meet-data-optimizer' ); ?>
</p>
</header>
<!-- Pre-flight diagnostic panel ─────────────────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-preflight" id="wpdo-mw-preflight">
<h3><?php esc_html_e( '當前狀態', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-mw-metrics">
<div class="wpdo-mw-metric">
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'wp_users', '2meet-data-optimizer' ); ?></span>
<span class="wpdo-mw-metric-value" id="wpdo-mw-users"><?php echo esc_html( number_format_i18n( $users_count ) ); ?></span>
</div>
<div class="wpdo-mw-metric">
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'wp_usermeta', '2meet-data-optimizer' ); ?></span>
<span class="wpdo-mw-metric-value" id="wpdo-mw-usermeta"><?php echo esc_html( number_format_i18n( $usermeta_count ) ); ?></span>
</div>
<div class="wpdo-mw-metric wpdo-mw-metric-primary">
<span class="wpdo-mw-metric-label"><?php esc_html_e( '當前 ratio', '2meet-data-optimizer' ); ?></span>
<span class="wpdo-mw-metric-value" id="wpdo-mw-ratio">1:<?php echo esc_html( (string) $ratio ); ?></span>
</div>
<div class="wpdo-mw-metric">
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'EAV 殘留', '2meet-data-optimizer' ); ?></span>
<span class="wpdo-mw-metric-value" id="wpdo-mw-residue"><?php echo esc_html( number_format_i18n( $eav_residue ) ); ?></span>
</div>
<div class="wpdo-mw-metric">
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'user mode', '2meet-data-optimizer' ); ?></span>
<span class="wpdo-mw-metric-value wpdo-mw-mode wpdo-mw-mode-<?php echo esc_attr( $current_mode ); ?>"><?php echo esc_html( $current_mode ); ?></span>
</div>
</div>
<details class="wpdo-mw-groups">
<summary><?php esc_html_e( '各 entity group 詳細狀態', '2meet-data-optimizer' ); ?></summary>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Group', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'Keys', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'Flat rows', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'EAV 殘留', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'Backfill 策略', '2meet-data-optimizer' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $groups as $name => $g ) : ?>
<tr>
<td><code><?php echo esc_html( $name ); ?></code></td>
<td><?php echo esc_html( (string) count( $g['keys'] ) ); ?></td>
<td><?php echo esc_html( (string) $g['flat_rows'] ); ?></td>
<td>
<?php if ( $g['residue'] > 0 ) : ?>
<strong><?php echo esc_html( (string) $g['residue'] ); ?></strong>
<?php else : ?>
<span class="wpdo-mw-zero">0</span>
<?php endif; ?>
</td>
<td><code><?php echo esc_html( $g['strategy'] ); ?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</details>
</section>
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
<?php echo $active || $completed ? 'hidden' : ''; ?>>
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
<div class="wpdo-mw-nothing-todo">
<h3>✅ <?php esc_html_e( '無事可做', '2meet-data-optimizer' ); ?></h3>
<p><?php esc_html_e( '所有 entity group 已 aeav_only / 0 EAV 殘留。', '2meet-data-optimizer' ); ?></p>
<p>
<?php
echo esc_html(
sprintf(
/* translators: %s: ratio */
__( '當前 ratio1:%s', '2meet-data-optimizer' ),
(string) $ratio
)
);
?>
</p>
</div>
<?php else : ?>
<h3><?php esc_html_e( '執行一鍵遷移', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-mw-estimate">
<?php
printf(
/* translators: 1: rows count, 2: strategy, 3: estimated seconds */
esc_html__( '將遷移 %1$s 行 EAV 殘留,預估策略:%2$s(約 %3$s 秒)。', '2meet-data-optimizer' ),
'<strong>' . esc_html( number_format_i18n( $eav_residue ) ) . '</strong>',
'<code>' . esc_html( $strategy ) . '</code>',
'<strong>' . esc_html( (string) $est_sec ) . '</strong>'
);
?>
</div>
<fieldset class="wpdo-mw-options">
<legend><?php esc_html_e( '進階選項', '2meet-data-optimizer' ); ?></legend>
<label>
<input type="checkbox" id="wpdo-mw-opt-backup" checked>
<?php esc_html_e( '自動備份 wp_usermeta 至 uploads/wpdo-backups/(推薦)', '2meet-data-optimizer' ); ?>
</label>
<label>
<input type="checkbox" id="wpdo-mw-opt-strict" checked>
<?php esc_html_e( '嚴格驗證(500 users / 10% 抽樣)', '2meet-data-optimizer' ); ?>
</label>
<label>
<input type="checkbox" id="wpdo-mw-opt-24h">
<?php esc_html_e( '24 小時 shadow_read 觀察視窗(生產環境用,會暫停 24h 才繼續)', '2meet-data-optimizer' ); ?>
</label>
<label>
<input type="checkbox" id="wpdo-mw-opt-async">
<?php esc_html_e( '強制 asynccron 推進,不阻塞 HTTP 請求)', '2meet-data-optimizer' ); ?>
</label>
<label>
<input type="checkbox" id="wpdo-mw-opt-dryrun">
<?php esc_html_e( 'Dry-run(不寫入、不刪除,僅模擬流程)', '2meet-data-optimizer' ); ?>
</label>
</fieldset>
<div class="wpdo-mw-confirm">
<label>
<input type="checkbox" id="wpdo-mw-confirm-backup" required>
<?php esc_html_e( '我已了解:流程將從 wp_usermeta 刪除已註冊欄位的 EAV 行,過程不可逆(自動備份提供救援路徑)。', '2meet-data-optimizer' ); ?>
</label>
</div>
<button type="button" class="button button-primary button-hero" id="wpdo-mw-start" disabled>
🚀 <?php esc_html_e( '開始一鍵遷移', '2meet-data-optimizer' ); ?>
</button>
<?php endif; ?>
</section>
<!-- Live progress panel (running state) ───────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
<?php echo $active || $failed ? '' : 'hidden'; ?>>
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-mw-progress-bar">
<div class="wpdo-mw-progress-fill" id="wpdo-mw-progress-fill"
style="width:<?php echo esc_attr( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%"></div>
<span class="wpdo-mw-progress-pct" id="wpdo-mw-progress-pct"><?php echo esc_html( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%</span>
</div>
<div class="wpdo-mw-current">
<span class="wpdo-mw-current-phase">
<?php esc_html_e( '當前階段:', '2meet-data-optimizer' ); ?>
<code id="wpdo-mw-current-phase-name"><?php echo esc_html( $status['phase'] ?? 'idle' ); ?></code>
</span>
<span class="wpdo-mw-elapsed" id="wpdo-mw-elapsed">0.0s</span>
</div>
<div class="wpdo-mw-live-ratio">
<span><?php esc_html_e( 'Live ratio', '2meet-data-optimizer' ); ?></span>
<span id="wpdo-mw-live-ratio-text">
1:<?php echo esc_html( (string) ( $status['metrics']['ratio_start'] ?? $ratio ) ); ?>
1:<?php echo esc_html( (string) ( $status['metrics']['ratio_now'] ?? $ratio ) ); ?>
</span>
</div>
<div class="wpdo-mw-log-wrap">
<h4><?php esc_html_e( 'Log', '2meet-data-optimizer' ); ?></h4>
<pre class="wpdo-mw-log" id="wpdo-mw-log"><?php echo esc_html( implode( "\n", (array) ( $status['log'] ?? array() ) ) ); ?></pre>
</div>
<div class="wpdo-mw-actions">
<button type="button" class="button" id="wpdo-mw-cancel"><?php esc_html_e( '取消', '2meet-data-optimizer' ); ?></button>
<button type="button" class="button button-primary" id="wpdo-mw-resume" hidden><?php esc_html_e( 'Resume', '2meet-data-optimizer' ); ?></button>
</div>
</section>
<!-- Completed state ───────────────────────────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
<?php echo $completed ? '' : 'hidden'; ?>>
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
</section>
</div>
+298
View File
@@ -0,0 +1,298 @@
<?php
/**
* Post Migration Wizard template (v2.10.0).
*
* Variables in scope from render_post_migration_wizard():
* $diagnose — TMDO_Post_Migration::diagnose() output
* $garbage — TMDO_Postmeta_Cleaner::count_garbage('all') output
*
* Sync execution model — each action button POSTs back to this page with a
* query arg + nonce, the action runs to completion in one request, then
* redirects with `wpdo_msg=*` for the success/failure banner.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$page_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=post-migration-wizard' );
$total_eav = 0;
foreach ( $diagnose['groups'] as $g ) {
$total_eav += (int) $g['eav_rows'];
}
$total_flat = 0;
foreach ( $diagnose['groups'] as $g ) {
$total_flat += (int) $g['flat_rows'];
}
$mode_label = array(
'disabled' => __( '🔵 disabled — wp_postmeta 仍是 source-of-truth', '2meet-data-optimizer' ),
'dual_write' => __( '🟡 dual_write — 同時寫 wp_postmeta + flat 表', '2meet-data-optimizer' ),
'shadow_read' => __( '🟠 shadow_read — 寫雙路徑,讀比對', '2meet-data-optimizer' ),
'aeav_only' => __( '🟢 aeav_only — flat 表為 source-of-truth', '2meet-data-optimizer' ),
);
// Action URLs (each carries nonce).
$cleanup_garbage_url = wp_nonce_url(
add_query_arg( array( 'wpdo_postmeta_cleanup' => '1' ), $page_url ),
'wpdo_postmeta_cleanup'
);
$backfill_all_url = wp_nonce_url(
add_query_arg( array( 'wpdo_post_backfill_all' => '1' ), $page_url ),
'wpdo_post_backfill_all'
);
$cutover_legacy_url = wp_nonce_url(
add_query_arg( array( 'wpdo_post_cutover_legacy' => '1' ), $page_url ),
'wpdo_post_cutover_legacy'
);
$promote_dual_write_url = wp_nonce_url(
add_query_arg( array( 'wpdo_post_promote_dual_write' => '1' ), $page_url ),
'wpdo_post_promote_dual_write'
);
$promote_aeav_url = wp_nonce_url(
add_query_arg( array( 'wpdo_post_promote_aeav' => '1' ), $page_url ),
'wpdo_post_promote_aeav'
);
// Status banner from prior action redirect.
// Read-only display banner — server-set redirect message, no form processing.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$msg_raw = isset( $_GET['wpdo_msg'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['wpdo_msg'] ) ) : '';
/**
* Map redirect message code → user-facing string.
*
* @param string $code Message code from wpdo_msg query arg.
* @return string
*/
$wpdo_format_msg = static function ( string $code ): string {
$prefix = strtok( $code, '_' );
$rest = substr( $code, strlen( (string) $prefix ) + 1 );
switch ( $prefix ) {
case 'backfill':
return sprintf(
/* translators: %s: total flat rows */
__( 'Backfill 完成:7 個 group 共 %s 個 post 已寫入 flat 表。', '2meet-data-optimizer' ),
$rest
);
case 'cutover':
return sprintf(
/* translators: %s: rows */
__( 'Legacy hot table cutover 完成:%s 行已 copy 至 flat 表。', '2meet-data-optimizer' ),
$rest
);
case 'promote':
return sprintf(
/* translators: %s: target mode */
__( 'Post mode 已升級至 %s。', '2meet-data-optimizer' ),
$rest
);
case 'postmetacleanupdone':
return sprintf(
/* translators: %s: deleted rows */
__( '已從 wp_postmeta 刪除 %s 行垃圾。', '2meet-data-optimizer' ),
$rest
);
case 'err':
return sprintf(
/* translators: %s: error code */
__( '錯誤:%s(請查看 wpdo_errors 日誌)。', '2meet-data-optimizer' ),
str_replace( '_', ' ', $rest )
);
default:
return $code;
}
};
?>
<style>
.wpdo-post-wizard h2 { margin-top: 1.4em; }
.wpdo-post-wizard .wpdo-card {
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
padding: 1em 1.4em;
margin: 0.8em 0;
}
.wpdo-post-wizard .wpdo-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1em;
margin: 1em 0;
}
.wpdo-post-wizard .wpdo-stat {
background: #f6f7f7;
border-left: 4px solid #2271b1;
padding: 0.8em 1em;
border-radius: 3px;
}
.wpdo-post-wizard .wpdo-stat-label { font-size: 0.85em; color: #50575e; }
.wpdo-post-wizard .wpdo-stat-value { font-size: 1.6em; font-weight: 600; }
.wpdo-post-wizard .wpdo-mode-banner {
padding: 0.6em 1em;
border-radius: 3px;
margin: 0.8em 0;
}
.wpdo-post-wizard .wpdo-mode-disabled { background: #e5f5fa; border-left: 4px solid #00a0d2; }
.wpdo-post-wizard .wpdo-mode-dual_write { background: #fffbe5; border-left: 4px solid #dba617; }
.wpdo-post-wizard .wpdo-mode-shadow_read { background: #fff4e5; border-left: 4px solid #d54e21; }
.wpdo-post-wizard .wpdo-mode-aeav_only { background: #ecf7ed; border-left: 4px solid #46b450; }
.wpdo-post-wizard table.widefat td.right { text-align: right; }
.wpdo-post-wizard .actions {
margin-top: 1.6em;
padding-top: 1em;
border-top: 1px solid #dcdcde;
}
.wpdo-post-wizard .actions .button { margin-right: 0.4em; margin-bottom: 0.4em; }
</style>
<div class="wpdo-post-wizard">
<?php if ( $msg_raw ) : ?>
<?php
$msg_class = str_starts_with( $msg_raw, 'err' ) ? 'notice-error' : 'notice-success';
$msg_text = $wpdo_format_msg( $msg_raw );
?>
<div class="notice <?php echo esc_attr( $msg_class ); ?> is-dismissible">
<p><?php echo esc_html( $msg_text ); ?></p>
</div>
<?php endif; ?>
<h2><?php esc_html_e( 'Post Entity 遷移精靈', '2meet-data-optimizer' ); ?></h2>
<p class="description">
<?php esc_html_e( '把 wp_postmeta 中的 47 個 managed keys 遷移到 7 張 flat 表(wp_wpdo_post_*)。每個按鈕只執行單一階段;按順序執行可達成完整 EAV → flat 切換。', '2meet-data-optimizer' ); ?>
</p>
<!-- Mode banner -->
<div class="wpdo-mode-banner wpdo-mode-<?php echo esc_attr( $diagnose['mode'] ); ?>">
<strong><?php esc_html_e( '當前 post mode', '2meet-data-optimizer' ); ?></strong>
<?php echo esc_html( $mode_label[ $diagnose['mode'] ] ?? $diagnose['mode'] ); ?>
</div>
<!-- Stats -->
<div class="wpdo-stats">
<div class="wpdo-stat">
<div class="wpdo-stat-label"><?php esc_html_e( 'Posts', '2meet-data-optimizer' ); ?></div>
<div class="wpdo-stat-value"><?php echo esc_html( number_format_i18n( $diagnose['posts'] ) ); ?></div>
</div>
<div class="wpdo-stat">
<div class="wpdo-stat-label"><?php esc_html_e( 'Postmeta', '2meet-data-optimizer' ); ?></div>
<div class="wpdo-stat-value"><?php echo esc_html( number_format_i18n( $diagnose['postmeta'] ) ); ?></div>
</div>
<div class="wpdo-stat">
<div class="wpdo-stat-label"><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></div>
<div class="wpdo-stat-value">1:<?php echo esc_html( (string) $diagnose['ratio'] ); ?></div>
</div>
<div class="wpdo-stat">
<div class="wpdo-stat-label"><?php esc_html_e( 'Total flat rows', '2meet-data-optimizer' ); ?></div>
<div class="wpdo-stat-value"><?php echo esc_html( number_format_i18n( $total_flat ) ); ?></div>
</div>
</div>
<!-- Group breakdown -->
<h3><?php esc_html_e( '7 個 group 詳細狀態', '2meet-data-optimizer' ); ?></h3>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Group', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'post_type', '2meet-data-optimizer' ); ?></th>
<th class="right"><?php esc_html_e( 'Keys', '2meet-data-optimizer' ); ?></th>
<th class="right"><?php esc_html_e( 'EAV rows', '2meet-data-optimizer' ); ?></th>
<th class="right"><?php esc_html_e( 'Flat rows', '2meet-data-optimizer' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $diagnose['groups'] as $name => $g ) : ?>
<tr>
<td><code><?php echo esc_html( $name ); ?></code></td>
<td><?php echo esc_html( $g['post_type'] ?: '(any)' ); ?></td>
<td class="right"><?php echo esc_html( (string) count( $g['keys'] ) ); ?></td>
<td class="right"><?php echo esc_html( number_format_i18n( (int) $g['eav_rows'] ) ); ?></td>
<td class="right"><?php echo esc_html( number_format_i18n( (int) $g['flat_rows'] ) ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<!-- Phase actions -->
<div class="wpdo-card">
<h3><?php esc_html_e( '步驟 1:清 wp_postmeta 垃圾(v2.9.0 phase 0', '2meet-data-optimizer' ); ?></h3>
<p>
<?php
printf(
/* translators: 1: total garbage rows, 2: transients, 3: wp_old_date, 4: edit_locks */
esc_html__( '偵測到 %1$s 行可清理垃圾(transients %2$s + _wp_old_date %3$s + 過期 _edit_lock %4$s)。', '2meet-data-optimizer' ),
esc_html( number_format_i18n( (int) $garbage['total'] ) ),
esc_html( number_format_i18n( (int) $garbage['transients'] ) ),
esc_html( number_format_i18n( (int) $garbage['wp_old_date'] ) ),
esc_html( number_format_i18n( (int) $garbage['edit_locks'] ) )
);
?>
</p>
<div class="actions">
<a href="<?php echo esc_url( $cleanup_garbage_url ); ?>"
class="button button-primary <?php echo (int) $garbage['total'] > 0 ? '' : 'disabled'; ?>"
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
</a>
</div>
</div>
<div class="wpdo-card">
<h3><?php esc_html_e( '步驟 2:把 wp_postmeta 既有資料 backfill 至 flat 表(v2.9.3', '2meet-data-optimizer' ); ?></h3>
<p><?php esc_html_e( 'Idempotent — 重跑安全。每個 group 跑一次 bulk SQL pivot。', '2meet-data-optimizer' ); ?></p>
<div class="actions">
<a href="<?php echo esc_url( $backfill_all_url ); ?>" class="button button-primary"
onclick="return confirm(<?php echo wp_json_encode( __( '確定要對 7 個 group 跑 backfill?此動作 idempotent,可重跑。', '2meet-data-optimizer' ) ); ?>);">
<?php esc_html_e( '把 wp_postmeta backfill 到 7 張 flat 表', '2meet-data-optimizer' ); ?>
</a>
</div>
</div>
<div class="wpdo-card">
<h3><?php esc_html_e( '步驟 3:把 legacy wpdo_hot_hp_listing 抄到 flat 表(v2.9.5', '2meet-data-optimizer' ); ?></h3>
<p><?php esc_html_e( '非破壞 — legacy hot 表保留作為 v3.0.0 rollback safety net。', '2meet-data-optimizer' ); ?></p>
<div class="actions">
<a href="<?php echo esc_url( $cutover_legacy_url ); ?>" class="button"
onclick="return confirm(<?php echo wp_json_encode( __( '確定要 copy wpdo_hot_hp_listing 到 wp_wpdo_post_hp_listing_core', '2meet-data-optimizer' ) ); ?>);">
<?php esc_html_e( 'Copy legacy hot table', '2meet-data-optimizer' ); ?>
</a>
</div>
</div>
<div class="wpdo-card">
<h3><?php esc_html_e( '步驟 4:升級 mode 至 dual_write', '2meet-data-optimizer' ); ?></h3>
<p><?php esc_html_e( 'wp_postmeta 與 flat 表同時寫入。讀仍走 wp_postmeta(生產 safe)。建議至少觀察 24h 後再升級下一階。', '2meet-data-optimizer' ); ?></p>
<div class="actions">
<a href="<?php echo esc_url( $promote_dual_write_url ); ?>" class="button"
onclick="return confirm(<?php echo wp_json_encode( __( '確定要升級 post mode 至 dual_write?此後 update_post_meta() 會雙寫。', '2meet-data-optimizer' ) ); ?>);">
<?php esc_html_e( '升級 mode → dual_write', '2meet-data-optimizer' ); ?>
</a>
</div>
</div>
<div class="wpdo-card">
<h3><?php esc_html_e( '步驟 5:升級 mode 至 aeav_only(最終 cutover', '2meet-data-optimizer' ); ?></h3>
<p><?php esc_html_e( 'flat 表成為 source-of-truth,讀寫都走 flat。完成後可 wp wpdo post-cleanup --confirm 清掉 wp_postmeta 已遷移 keys。', '2meet-data-optimizer' ); ?></p>
<div class="actions">
<a href="<?php echo esc_url( $promote_aeav_url ); ?>" class="button"
onclick="return confirm(<?php echo wp_json_encode( __( '確定要升級 post mode 至 aeav_only?此後讀路徑切換到 flat 表,wp_postmeta 變成 backup。', '2meet-data-optimizer' ) ); ?>);">
<?php esc_html_e( '升級 mode → aeav_only', '2meet-data-optimizer' ); ?>
</a>
</div>
</div>
<div class="wpdo-card" style="background: #f6f7f7;">
<h3><?php esc_html_e( '回退路徑(rollback', '2meet-data-optimizer' ); ?></h3>
<p>
<?php esc_html_e( '若任一步驟出問題,可下降 mode', '2meet-data-optimizer' ); ?>
<code>wp option update wpdo_bridge_modes '{"post":"disabled",...}'</code>
<?php esc_html_e( '。Legacy wpdo_hot_* 表完整保留 — 直到 v3.0.0 才考慮 DROP。', '2meet-data-optimizer' ); ?>
</p>
</div>
</div>
+393
View File
@@ -0,0 +1,393 @@
<?php
/**
* Post Stress Test template (v2.11.4).
*
* Full async + polling UI mirroring User Stress Test:
* 1. 設定並啟動測試(form: post_type / target / mode / batch_size + Start/Cancel
* 2. 即時進度(progress bar + processed/target/rate/ETA/peak memory
* 3. Benchmark 報告(write metrics + DB sizes + query performance
*
* Backward-compat: legacy GET ?wpdo_post_stress_create / cleanup / bench
* handlers in admin still work for bookmarked URLs; the new UI uses REST.
*
* Variables in scope from render_post_stress_test():
* $test_post_count — int, posts matching TMDO_STRESS_TEST_ prefix
* $diagnose — TMDO_Post_Migration::diagnose() output
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$page_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=post-stress-test' );
$cleanup_url = wp_nonce_url(
add_query_arg( array( 'wpdo_post_stress_cleanup' => '1' ), $page_url ),
'wpdo_post_stress_cleanup'
);
// Read-only display banner — server-set redirect message, no form processing.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$msg_raw = isset( $_GET['wpdo_msg'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['wpdo_msg'] ) ) : '';
$msg_text = '';
$msg_class = '';
if ( '' !== $msg_raw ) {
if ( str_starts_with( $msg_raw, 'stress_cleanup_' ) ) {
$n = (int) substr( $msg_raw, strlen( 'stress_cleanup_' ) );
$msg_text = sprintf(
/* translators: %s: count */
__( '已清除 %s 個 stress test posts(連同 wp_postmeta + 7 張 flat 表的對應 row)。', '2meet-data-optimizer' ),
number_format_i18n( $n )
);
$msg_class = 'notice-success';
} elseif ( str_starts_with( $msg_raw, 'err_' ) ) {
$msg_text = __( '錯誤:請查看 wpdo_errors 日誌。', '2meet-data-optimizer' );
$msg_class = 'notice-error';
}
}
// Live state from the new state machine — drives initial render of cards.
$state = class_exists( 'TMDO_Post_Stress_Tester' ) ? TMDO_Post_Stress_Tester::get_progress( false ) : array();
$wpdo_pst_stat = (string) ( $state['status'] ?? 'idle' );
$is_running = ( 'running' === $wpdo_pst_stat || 'benchmarking' === $wpdo_pst_stat );
// Map of post_type → seed key count, surfaced inline next to the dropdown.
$post_type_options = array(
'product' => array( 'product (WC)', 5 ),
'hp_listing' => array( 'hp_listing (HivePress)', 7 ),
'hp_request' => array( 'hp_request', 5 ),
'hp_vendor' => array( 'hp_vendor', 5 ),
'attachment' => array( 'attachment', 2 ),
'nav_menu_item' => array( 'nav_menu_item', 5 ),
'post' => array( 'post', 2 ),
);
?>
<div class="wpdo-post-stress-test-tab">
<h2><?php esc_html_e( 'Post Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
<?php esc_html_e( '會建立大量測試 post 並填滿對應 wp_postmeta + flat 表。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
</div>
<p class="description">
<?php esc_html_e( '透過自動產生大量測試 post,評估 post entity 反 EAV 系統在不同規模下的寫入吞吐與查詢效能。所有測試 post 均以 TMDO_STRESS_TEST_ 為 post_title 前綴,可一鍵清除。', '2meet-data-optimizer' ); ?>
</p>
<?php if ( '' !== $msg_text ) : ?>
<div class="notice <?php echo esc_attr( $msg_class ); ?> is-dismissible">
<p><?php echo esc_html( $msg_text ); ?></p>
</div>
<?php endif; ?>
<!-- Diagnose snapshot -->
<?php
$current_post_mode = (string) ( $diagnose['mode'] ?? 'disabled' );
$mode_color = 'aeav_only' === $current_post_mode ? '#28a745' : '#dc3545';
$mode_optimal = 'aeav_only' === $current_post_mode;
$settings_tab_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
?>
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Post Entity 狀態', '2meet-data-optimizer' ); ?></h3>
<table class="widefat striped">
<tr>
<td><?php esc_html_e( 'Posts', '2meet-data-optimizer' ); ?></td>
<td><strong><?php echo esc_html( number_format_i18n( $diagnose['posts'] ) ); ?></strong></td>
<td><?php esc_html_e( 'Postmeta', '2meet-data-optimizer' ); ?></td>
<td><strong><?php echo esc_html( number_format_i18n( $diagnose['postmeta'] ) ); ?></strong></td>
</tr>
<tr>
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
<td><strong>1:<?php echo esc_html( (string) $diagnose['ratio'] ); ?></strong></td>
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_post_mode ); ?></strong></td>
</tr>
<tr>
<td colspan="2"><?php esc_html_e( 'Stress 測試 post 數', '2meet-data-optimizer' ); ?></td>
<td colspan="2"><strong id="wpdo-pst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_post_count ) ); ?></strong></td>
</tr>
</table>
</div>
<!-- 驗證反 EAV 優化指南 -->
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
<h3 style="margin-top:0;font-size:14px;">
<?php if ( $mode_optimal ) : ?>
✅ <?php esc_html_e( 'Post Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
<?php else : ?>
⚠️
<?php
printf(
/* translators: %s: current mode */
esc_html__( 'Post Mode = %s — 此模式下壓力測試結果不會展示反 EAV 優化效果', '2meet-data-optimizer' ),
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_post_mode ) . '</code>'
);
?>
<?php endif; ?>
</h3>
<p style="margin:8px 0 0 0;">
<strong><?php esc_html_e( '想看 wp_postmeta 真實減量?必須同時滿足兩個條件:', '2meet-data-optimizer' ); ?></strong>
</p>
<ol style="margin:6px 0 8px 22px;padding:0;">
<li>
<?php
printf(
/* translators: 1: settings tab anchor open, 2: settings tab anchor close */
esc_html__( 'Post mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Post entity', '2meet-data-optimizer' ),
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
'</code>'
);
?>
<?php if ( ! $mode_optimal ) : ?>
<a href="<?php echo esc_url( $settings_tab_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
<?php endif; ?>
</li>
<li>
<?php esc_html_e( '寫入模式選 🐢 Realistic(走 wp_insert_post + Hook Bus,會被攔截短路 wp_postmeta', '2meet-data-optimizer' ); ?>
</li>
</ol>
<details style="margin-top:8px;">
<summary style="cursor:pointer;color:#0073aa;font-weight:600;"><?php esc_html_e( '📐 模式 × 寫入路徑 → 預期結果矩陣', '2meet-data-optimizer' ); ?></summary>
<table class="widefat" style="margin-top:8px;background:#fff;font-size:12px;">
<thead>
<tr style="background:#f0f0f0;">
<th><?php esc_html_e( 'Post Mode', '2meet-data-optimizer' ); ?></th>
<th>⚡ Fast</th>
<th>🐢 Realistic</th>
</tr>
</thead>
<tbody>
<tr<?php echo 'disabled' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
<td><code>disabled</code></td>
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
</tr>
<tr<?php echo 'dual_write' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
<td><code>dual_write</code></td>
<td>wp_postmeta 5 / flat 0 → 1:5</td>
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
</tr>
<tr<?php echo 'shadow_read' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
<td><code>shadow_read</code></td>
<td>wp_postmeta 5 / flat 0 → 1:5</td>
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
</tr>
<tr<?php echo 'aeav_only' === $current_post_mode ? ' style="background:#e8f5e9;font-weight:600;"' : ''; ?>>
<td><code>aeav_only</code></td>
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus/ flat 0 → 1:5 ⚠️</td>
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
</tr>
</tbody>
</table>
<p class="description" style="margin-top:6px;">
<?php esc_html_e( '範例以 nav_menu_item5 keys/post)為基準。Fast 模式直接 $wpdb->insert,故意繞過 Hook Bus → 即使 mode=aeav_only 也會寫滿 wp_postmeta(用途:快速灌 fixture 給 Query Router benchmark)。驗證反 EAV 優化效果一律用 Realistic。', '2meet-data-optimizer' ); ?>
</p>
</details>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
<!-- Left: Configure & Start -->
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
<table class="form-table" style="margin-top:0;">
<tr>
<th style="width:35%;"><label for="wpdo-pst-post-type"><?php esc_html_e( 'Post Type', '2meet-data-optimizer' ); ?></label></th>
<td>
<select id="wpdo-pst-post-type" class="regular-text">
<?php foreach ( $post_type_options as $pt => $info ) : ?>
<?php
list( $label, $key_count ) = $info;
?>
<option value="<?php echo esc_attr( $pt ); ?>" data-keys="<?php echo esc_attr( (string) $key_count ); ?>">
<?php
printf(
/* translators: 1: post_type slug, 2: human label, 3: number of seeded keys */
esc_html__( '%1$s — %2$s · %3$d keys/post', '2meet-data-optimizer' ),
esc_html( $pt ),
esc_html( $label ),
(int) $key_count
);
?>
</option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e( '每筆 post 會 seed 該 post_type 對應 entity group 的標準 meta keysv2.9.1 entity registry)。', '2meet-data-optimizer' ); ?></p>
</td>
</tr>
<tr>
<th><label for="wpdo-pst-target"><?php esc_html_e( '要建立的 post 數', '2meet-data-optimizer' ); ?></label></th>
<td>
<input type="number" id="wpdo-pst-target" min="1" max="100000" value="500" class="regular-text" />
<p class="description"><?php esc_html_e( '常用:100 / 500 / 1,000 / 10,000(上限 100,000', '2meet-data-optimizer' ); ?></p>
</td>
</tr>
<tr>
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
<td>
<label style="display:block;margin-bottom:6px;">
<input type="radio" name="wpdo-pst-mode" value="fast" checked />
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus', '2meet-data-optimizer' ); ?>
</label>
<label style="display:block;">
<input type="radio" name="wpdo-pst-mode" value="realistic" />
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_post + update_post_meta(較慢,模擬生產路徑 + 觸發 Hook Bus → mode=dual_write+ 時 flat 表自動填入)', '2meet-data-optimizer' ); ?>
</label>
</td>
</tr>
<tr>
<th><label for="wpdo-pst-batch"><?php esc_html_e( '批次大小', '2meet-data-optimizer' ); ?></label></th>
<td>
<input type="number" id="wpdo-pst-batch" min="1" max="1000" value="200" class="small-text" />
<p class="description"><?php esc_html_e( '每批執行有 8 秒 wall-clock 上限(避免 nginx 504)。Fast 建議 200-1000Realistic 建議 5-20(每 post ~100-300ms)。', '2meet-data-optimizer' ); ?></p>
</td>
</tr>
</table>
<p>
<button type="button" class="button button-primary button-large" id="wpdo-pst-start" <?php disabled( $is_running ); ?>>
<?php esc_html_e( '🚀 啟動壓力測試', '2meet-data-optimizer' ); ?>
</button>
<button type="button" class="button" id="wpdo-pst-cancel" <?php disabled( ! $is_running ); ?>>
<?php esc_html_e( '⏹ 取消', '2meet-data-optimizer' ); ?>
</button>
</p>
</div>
<!-- Right: Cleanup + Re-run benchmark -->
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
<p>
<?php esc_html_e( '目前 stress test post 數:', '2meet-data-optimizer' ); ?>
<strong id="wpdo-pst-count-mirror" style="font-size:18px;color:#dc3545;">
<?php echo esc_html( number_format_i18n( $test_post_count ) ); ?>
</strong>
</p>
<p class="description">
<?php esc_html_e( '一鍵清除所有 post_title 前綴 TMDO_STRESS_TEST_ 的 post,連同 wp_postmeta + 7 張 flat 表的對應 row。', '2meet-data-optimizer' ); ?>
</p>
<p>
<button type="button" class="button button-secondary" id="wpdo-pst-cleanup" <?php disabled( $is_running || 0 === $test_post_count ); ?>>
<?php esc_html_e( '🗑 清除全部測試 post', '2meet-data-optimizer' ); ?>
</button>
</p>
<hr style="margin:18px 0;" />
<p>
<button type="button" class="button" id="wpdo-pst-rerun-bench" <?php disabled( $is_running || 0 === $test_post_count ); ?>>
<?php esc_html_e( '📊 重跑 Benchmark(不新增資料)', '2meet-data-optimizer' ); ?>
</button>
</p>
<p class="description">
<?php esc_html_e( '針對最後一次啟動所選的 post_type 重新量測寫入指標(保留)+ DB 容量 + 查詢效能。', '2meet-data-optimizer' ); ?>
</p>
</div>
</div>
<!-- Progress section (live) -->
<div id="wpdo-pst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
<span>
<strong id="wpdo-pst-pg-status"><?php echo esc_html( $wpdo_pst_stat ); ?></strong>
· <code id="wpdo-pst-pg-post-type"><?php echo esc_html( (string) ( $state['post_type'] ?? '' ) ); ?></code>
· <span id="wpdo-pst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
</span>
<span id="wpdo-pst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
</div>
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
<div id="wpdo-pst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
</div>
</div>
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
<tr>
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;font-weight:600;"><span id="wpdo-pst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-pst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;font-weight:600;"><span id="wpdo-pst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> posts/sec</td>
</tr>
<tr>
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-pst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-pst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
</tr>
<tr>
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-pst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-pst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
</tr>
</table>
</div>
<!-- Benchmark report -->
<div id="wpdo-pst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-pst-bench-content"></div>
</div>
<!-- 7-group seed map info -->
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
<h3 style="margin-top:0;"><?php esc_html_e( '4. TMDO_Post_Stress_Tester seed map7 個 post_type', '2meet-data-optimizer' ); ?></h3>
<p class="description">
<?php esc_html_e( '每個 stress post 自動 seed 對應 group 的 canonical meta keysv2.9.1 entity registry 定義)。', '2meet-data-optimizer' ); ?>
</p>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'post_type', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'Seeded keys', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'Flat 表', '2meet-data-optimizer' ); ?></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>product</code></td>
<td>_price, _regular_price, _stock, _stock_status, _sku</td>
<td><code>wp_wpdo_post_wc_product</code></td>
</tr>
<tr>
<td><code>hp_listing</code></td>
<td>hp_price, hp_status, hp_featured, hp_verified, hp_vendor, hp_view_count, hp_expired_time</td>
<td><code>wp_wpdo_post_hp_listing_core</code></td>
</tr>
<tr>
<td><code>hp_request</code></td>
<td>hp_status, hp_user, hp_budget, hp_view_count, hp_expired_time</td>
<td><code>wp_wpdo_post_hp_request_core</code></td>
</tr>
<tr>
<td><code>hp_vendor</code></td>
<td>hp_user, hp_verified, hp_hourly_rate, hp_rating_count, hp_rating</td>
<td><code>wp_wpdo_post_hp_vendor_core</code></td>
</tr>
<tr>
<td><code>attachment</code></td>
<td>_wp_attached_file, _wp_attachment_image_alt</td>
<td><code>wp_wpdo_post_attachment</code></td>
</tr>
<tr>
<td><code>nav_menu_item</code></td>
<td>_menu_item_type, _menu_item_object_id, _menu_item_object, _menu_item_target, _menu_item_url</td>
<td><code>wp_wpdo_post_nav_menu_item</code></td>
</tr>
<tr>
<td><code>post</code></td>
<td>_thumbnail_id, _edit_last</td>
<td><code>wp_wpdo_post_wp_core</code></td>
</tr>
</tbody>
</table>
</div>
</div>
+248
View File
@@ -0,0 +1,248 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
/**
* Term Stress Test template (v2.13.0).
*
* Full async + polling UI mirroring Post / User Stress Test:
* 1. 設定並啟動測試(form: taxonomy / target / mode / batch_size + Start/Cancel
* 2. 即時進度(progress bar + processed/target/rate/ETA/peak memory
* 3. Benchmark 報告(write metrics + DB sizes + query performance
*
* Variables in scope from render_term_stress_test():
* $state — TMDO_Term_Stress_Tester::get_progress(false) output
* $test_term_count — int, terms matching wpdo-stress- slug prefix
* $taxonomies — array<slug,label> of available taxonomies
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$status_str = (string) ( $state['status'] ?? 'idle' );
$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str );
$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'term' ) : 'disabled';
$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' );
$mode_optimal = 'aeav_only' === $current_mode;
$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
global $wpdb;
$flat_hp_taxonomy = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_term_hp_taxonomy" );
$flat_misc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_term_misc" );
$total_terms = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->terms}" );
$total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta}" );
?>
<div class="wpdo-term-stress-test-tab">
<h2><?php esc_html_e( 'Term Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
<?php esc_html_e( '會建立大量測試 term 並 seed 對應 hp_taxonomy 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
</div>
<p class="description">
<?php esc_html_e( '透過自動產生大量測試 term,評估 term entity 反 EAV 系統在不同規模下的寫入吞吐與查詢效能。所有測試 term 均以 wpdo-stress- 為 slug 前綴,可一鍵清除。', '2meet-data-optimizer' ); ?>
</p>
<!-- Diagnose snapshot -->
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Term Entity 狀態', '2meet-data-optimizer' ); ?></h3>
<table class="widefat striped">
<tr>
<td><?php esc_html_e( 'wp_terms', '2meet-data-optimizer' ); ?></td>
<td><strong><?php echo esc_html( number_format_i18n( $total_terms ) ); ?></strong></td>
<td><?php esc_html_e( 'wp_termmeta', '2meet-data-optimizer' ); ?></td>
<td><strong><?php echo esc_html( number_format_i18n( $total_termmeta ) ); ?></strong></td>
</tr>
<tr>
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_mode ); ?></strong></td>
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
<td><strong>1:<?php echo esc_html( (string) ( $total_terms > 0 ? round( $total_termmeta / $total_terms, 2 ) : 0 ) ); ?></strong></td>
</tr>
<tr>
<td><code>wpdo_term_hp_taxonomy</code></td>
<td><strong><?php echo esc_html( number_format_i18n( $flat_hp_taxonomy ) ); ?></strong> rows</td>
<td><code>wpdo_term_misc</code></td>
<td><strong><?php echo esc_html( number_format_i18n( $flat_misc ) ); ?></strong> rows</td>
</tr>
<tr>
<td colspan="2"><?php esc_html_e( 'Stress 測試 term 數', '2meet-data-optimizer' ); ?></td>
<td colspan="2"><strong id="wpdo-tst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_term_count ) ); ?></strong></td>
</tr>
</table>
</div>
<!-- 驗證反 EAV 優化指南 -->
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
<h3 style="margin-top:0;font-size:14px;">
<?php if ( $mode_optimal ) : ?>
✅ <?php esc_html_e( 'Term Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
<?php else : ?>
⚠️
<?php
printf(
/* translators: %s: current mode */
esc_html__( 'Term Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_mode ) . '</code>'
);
?>
<?php endif; ?>
</h3>
<p style="margin:8px 0 0 0;">
<strong><?php esc_html_e( '想看 wp_termmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
</p>
<ol style="margin:6px 0 8px 22px;padding:0;">
<li>
<?php
printf(
/* translators: 1: open code, 2: close code */
esc_html__( 'Term mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Term entity', '2meet-data-optimizer' ),
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
'</code>'
);
?>
<?php if ( ! $mode_optimal ) : ?>
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
<?php endif; ?>
</li>
<li>
<?php esc_html_e( '寫入模式選 🐢 Realistic(走 wp_insert_term + Hook Bus,會被攔截短路 wp_termmeta', '2meet-data-optimizer' ); ?>
</li>
</ol>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
<!-- Left: Configure & Start -->
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
<table class="form-table" style="margin-top:0;">
<tr>
<th style="width:35%;"><label for="wpdo-tst-taxonomy"><?php esc_html_e( 'Taxonomy', '2meet-data-optimizer' ); ?></label></th>
<td>
<select id="wpdo-tst-taxonomy" class="regular-text">
<?php foreach ( $taxonomies as $slug => $label ) : ?>
<option value="<?php echo esc_attr( $slug ); ?>" <?php selected( $slug, 'listing_category' ); ?>>
<?php echo esc_html( $slug . ' — ' . $label ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e( '建議用 HivePress 自訂 taxonomylisting_category / listing_tag 等)— seed 後 hp_sort_order/hp_default/hp_icon 三 keys 走 hp_taxonomy 群組。', '2meet-data-optimizer' ); ?></p>
</td>
</tr>
<tr>
<th><label for="wpdo-tst-target"><?php esc_html_e( '要建立的 term 數', '2meet-data-optimizer' ); ?></label></th>
<td>
<input type="number" id="wpdo-tst-target" min="1" max="100000" value="500" class="regular-text" />
<p class="description"><?php esc_html_e( '常用:100 / 500 / 1,000 / 10,000(上限 100,000', '2meet-data-optimizer' ); ?></p>
</td>
</tr>
<tr>
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
<td>
<label style="display:block;margin-bottom:6px;">
<input type="radio" name="wpdo-tst-mode" value="fast" checked />
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus', '2meet-data-optimizer' ); ?>
</label>
<label style="display:block;">
<input type="radio" name="wpdo-tst-mode" value="realistic" />
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_term + update_term_meta(較慢,模擬生產路徑 + 觸發 Hook Bus', '2meet-data-optimizer' ); ?>
</label>
</td>
</tr>
<tr>
<th><label for="wpdo-tst-batch"><?php esc_html_e( '批次大小', '2meet-data-optimizer' ); ?></label></th>
<td>
<input type="number" id="wpdo-tst-batch" min="1" max="1000" value="200" class="small-text" />
<p class="description"><?php esc_html_e( '每批 8 秒 wall-clock 上限。Fast 建議 200-1000Realistic 建議 10-30。', '2meet-data-optimizer' ); ?></p>
</td>
</tr>
</table>
<p>
<button type="button" class="button button-primary button-large" id="wpdo-tst-start" <?php disabled( $is_running ); ?>>
<?php esc_html_e( '🚀 啟動壓力測試', '2meet-data-optimizer' ); ?>
</button>
<button type="button" class="button" id="wpdo-tst-cancel" <?php disabled( ! $is_running ); ?>>
<?php esc_html_e( '⏹ 取消', '2meet-data-optimizer' ); ?>
</button>
</p>
</div>
<!-- Right: Cleanup + Re-run benchmark -->
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
<p>
<?php esc_html_e( '目前 stress test term 數:', '2meet-data-optimizer' ); ?>
<strong id="wpdo-tst-count-mirror" style="font-size:18px;color:#dc3545;">
<?php echo esc_html( number_format_i18n( $test_term_count ) ); ?>
</strong>
</p>
<p class="description">
<?php esc_html_e( '一鍵清除所有 slug 前綴 wpdo-stress- 的 term,連同 wp_termmeta + wp_term_taxonomy + flat 表的對應 row。', '2meet-data-optimizer' ); ?>
</p>
<p>
<button type="button" class="button button-secondary" id="wpdo-tst-cleanup" <?php disabled( $is_running || 0 === $test_term_count ); ?>>
<?php esc_html_e( '🗑 清除全部測試 term', '2meet-data-optimizer' ); ?>
</button>
</p>
<hr style="margin:18px 0;" />
<p>
<button type="button" class="button" id="wpdo-tst-rerun-bench" <?php disabled( $is_running || 0 === $test_term_count ); ?>>
<?php esc_html_e( '📊 重跑 Benchmark(不新增資料)', '2meet-data-optimizer' ); ?>
</button>
</p>
</div>
</div>
<!-- Progress card (live) -->
<div id="wpdo-tst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
<span>
<strong id="wpdo-tst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
· <code id="wpdo-tst-pg-taxonomy"><?php echo esc_html( (string) ( $state['taxonomy'] ?? '' ) ); ?></code>
· <span id="wpdo-tst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
</span>
<span id="wpdo-tst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
</div>
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
<div id="wpdo-tst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
</div>
</div>
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
<tr>
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;font-weight:600;"><span id="wpdo-tst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-tst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;font-weight:600;"><span id="wpdo-tst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> terms/sec</td>
</tr>
<tr>
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-tst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-tst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
</tr>
<tr>
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-tst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
<td style="padding:6px;"><span id="wpdo-tst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
</tr>
</table>
</div>
<!-- Benchmark report -->
<div id="wpdo-tst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-tst-bench-content"></div>
</div>
</div>
+561
View File
@@ -0,0 +1,561 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
/**
* TMDO_CLI_Member — Member flat-table CLI subcommands.
*
* Adds the following subcommands under `wp wpdo`:
*
* wp wpdo member-audit — Mode, row counts, shadow diff rate.
* wp wpdo member-backfill — Backfill usermeta → flat table.
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
* wp wpdo member-cutover — Switch user mode to aeav_only.
* wp wpdo member-points-check — Show balance + recent ledger for a user.
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table.
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth.
*
* @package WP_Data_Optimizer
* @since 2.5.5
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_CLI callbacks accept ($args, $assoc_args) by contract; many subcommands only need one of them.
if ( ! class_exists( 'WP_CLI' ) ) {
return;
}
/**
* Member flat-table CLI subcommands. Loaded only in WP_CLI context.
*/
class TMDO_CLI_Member {
/**
* Valid group names managed by this CLI.
*
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
* the legacy WP/WC/HP wp_usermeta keys.
*/
private const VALID_GROUPS = array(
'membership',
'activity',
'profile',
'sso',
'core_profile',
'social',
'commerce',
'hp_user',
'admin_prefs',
);
// ── member-audit ─────────────────────────────────────────────────────────
/**
* Show current user bridge mode, flat-table row counts, and shadow diff rate.
*
* ## EXAMPLES
*
* wp wpdo member-audit
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments (unused).
*/
public function member_audit( $args, $assoc_args ): void {
global $wpdb;
$mode = TMDO_Mode_Manager::get( 'user' );
WP_CLI::log( "User bridge mode: {$mode}" );
WP_CLI::log( '' );
$items = array();
foreach ( self::VALID_GROUPS as $group ) {
$table = $wpdb->prefix . 'wpdo_user_' . $group;
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
$rows = $exists
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" )
: -1;
$items[] = array(
'group' => $group,
'table' => $table,
'exists' => $exists ? 'yes' : 'NO',
'rows' => $exists ? $rows : '(table missing)',
);
}
WP_CLI\Utils\format_items( 'table', $items, array( 'group', 'table', 'exists', 'rows' ) );
WP_CLI::log( '' );
// Shadow diff rate.
$shadow_table = $wpdb->prefix . 'wpdo_shadow_diffs';
$shadow_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $shadow_table )
);
if ( $shadow_exists ) {
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
"SELECT COUNT(*) FROM `{$shadow_table}` WHERE entity_type = 'user'" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
WP_CLI::log( "Shadow diffs (entity_type=user): {$total}" );
} else {
WP_CLI::log( 'Shadow diffs table: not present.' );
}
}
// ── member-backfill ───────────────────────────────────────────────────────
/**
* Backfill wp_usermeta rows into the specified user flat-table group.
*
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
*
* ## OPTIONS
*
* [--group=<group>]
* : Which group to backfill: membership, activity, profile, sso. Default: membership.
*
* [--batch-size=<n>]
* : Rows per batch (default 500, max 2000).
*
* [--dry-run]
* : Preview without writing.
*
* [--reset]
* : Clear checkpoint and restart from the beginning.
*
* ## EXAMPLES
*
* wp wpdo member-backfill --group=membership --dry-run
* wp wpdo member-backfill --group=membership
* wp wpdo member-backfill --group=sso --reset
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_backfill( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? 'membership' );
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
$dry_run = isset( $assoc_args['dry-run'] );
$reset = isset( $assoc_args['reset'] );
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) );
}
if ( $reset ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
WP_CLI::log( "Checkpoint cleared for user/{$group}." );
}
WP_CLI::log( $dry_run ? "[dry-run] Backfill user/{$group} ..." : "Backfill user/{$group} ..." );
$result = TMDO_Entity_Migration_Engine::migrate_group(
'user',
$group,
array(
'batch_size' => $batch_size,
'dry_run' => $dry_run,
'resume' => ! $reset,
)
);
// Pre-flight failure (adapter/group/keys/table missing).
if ( ! empty( $result['error'] ) ) {
WP_CLI::error( $result['error'] );
}
$migrated = (int) ( $result['migrated'] ?? 0 );
$errors = (int) ( $result['errors'] ?? 0 );
$skipped = (int) ( $result['skipped'] ?? 0 );
// Row-level failures: migrate_group() catches Throwables per-entity, increments
// $stats['errors'], and continues. The returned $stats has no 'error' key,
// so without this branch the CLI would silently report success.
if ( $errors > 0 && 0 === $migrated ) {
WP_CLI::error(
sprintf(
'All %d row(s) failed during migration — see error_log for details.',
$errors
)
);
}
if ( $errors > 0 ) {
WP_CLI::warning(
sprintf( '%d row(s) failed during migration — see error_log for details.', $errors )
);
}
WP_CLI::success(
sprintf(
'Migrated %d rows, %d errors, %d skipped (%.2f sec)%s.',
$migrated,
$errors,
$skipped,
$result['elapsed_sec'] ?? 0,
$dry_run ? ' [dry-run, no changes written]' : ''
)
);
}
// ── member-shadow-report ──────────────────────────────────────────────────
/**
* Display recent shadow_diffs rows for entity_type=user.
*
* ## OPTIONS
*
* [--limit=<n>]
* : Max rows to display (default 20).
*
* [--format=<format>]
* : Output format: table, json, csv. Default: table.
*
* ## EXAMPLES
*
* wp wpdo member-shadow-report
* wp wpdo member-shadow-report --limit=50 --format=json
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_shadow_report( $args, $assoc_args ): void {
global $wpdb;
$limit = min( 500, max( 1, (int) ( $assoc_args['limit'] ?? 20 ) ) );
$format = (string) ( $assoc_args['format'] ?? 'table' );
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
WP_CLI::warning( 'wpdo_shadow_diffs table not present — no shadow diffs collected yet.' );
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, entity_id, meta_key, postmeta_value, zone_value, diff_hash, ts FROM `{$table}` WHERE entity_type = 'user' ORDER BY ts DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$limit
),
ARRAY_A
);
if ( empty( $rows ) ) {
WP_CLI::success( 'No shadow diffs found for entity_type=user.' );
return;
}
WP_CLI::log( count( $rows ) . " diff(s) found (limit {$limit}):" );
WP_CLI\Utils\format_items(
$format,
$rows,
array( 'id', 'entity_id', 'meta_key', 'diff_hash', 'ts' )
);
}
// ── member-cutover ────────────────────────────────────────────────────────
/**
* Switch the user bridge mode to aeav_only (reads and writes go to flat table only).
*
* Requires --confirm to prevent accidental invocation.
* Recommended only after shadow diff rate drops below 0.1%.
*
* ## OPTIONS
*
* [--confirm]
* : Required confirmation flag.
*
* ## EXAMPLES
*
* wp wpdo member-cutover --confirm
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_cutover( $args, $assoc_args ): void {
if ( ! isset( $assoc_args['confirm'] ) ) {
WP_CLI::error( 'You must pass --confirm to cut over. Check shadow diff rate first: wp wpdo member-shadow-report' );
}
$current = TMDO_Mode_Manager::get( 'user' );
WP_CLI::log( "Current user mode: {$current}" );
if ( 'aeav_only' === $current ) {
WP_CLI::success( 'User mode is already aeav_only. Nothing to do.' );
return;
}
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
if ( isset( $result['error'] ) ) {
WP_CLI::error( 'Cutover failed: ' . $result['error'] );
}
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
}
// ── member-points-check ───────────────────────────────────────────────────
/**
* Show points balance and recent ledger entries for a user.
*
* ## OPTIONS
*
* <user_id>
* : WordPress user ID.
*
* [--limit=<n>]
* : Ledger rows to show (default 10).
*
* ## EXAMPLES
*
* wp wpdo member-points-check 42
* wp wpdo member-points-check 42 --limit=20
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_points_check( $args, $assoc_args ): void {
$user_id = (int) ( $args[0] ?? 0 );
if ( $user_id <= 0 ) {
WP_CLI::error( 'Usage: wp wpdo member-points-check <user_id>' );
}
$limit = min( 100, max( 1, (int) ( $assoc_args['limit'] ?? 10 ) ) );
$balance = TMDO_Points_Manager::get_balance( $user_id );
WP_CLI::log( "User {$user_id} — points balance: {$balance}" );
WP_CLI::log( '' );
$ledger = TMDO_Points_Manager::get_ledger( $user_id, $limit );
if ( empty( $ledger ) ) {
WP_CLI::log( 'No ledger entries found.' );
return;
}
WP_CLI\Utils\format_items(
'table',
$ledger,
array( 'id', 'delta', 'balance_after', 'reason', 'ref_type', 'ref_id', 'created_at' )
);
}
// ── member-sso-sync ───────────────────────────────────────────────────────
/**
* Sync Hub membership claims into the user flat table.
*
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
* from usermeta and writes them into the sso group flat table.
* Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing
* directly to the sso group via the Hook Bus.
*
* ## OPTIONS
*
* [--user-id=<id>]
* : Single user ID to sync.
*
* [--all]
* : Sync all users that have _tmso_refresh_token in usermeta (batch 500).
*
* [--dry-run]
* : Preview without writing.
*
* ## EXAMPLES
*
* wp wpdo member-sso-sync --user-id=42
* wp wpdo member-sso-sync --all --dry-run
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_sso_sync( $args, $assoc_args ): void {
global $wpdb;
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
$all = isset( $assoc_args['all'] );
$dry_run = isset( $assoc_args['dry-run'] );
if ( ! $user_id && ! $all ) {
WP_CLI::error( 'Provide --user-id=<id> or --all.' );
}
$sso_table = $wpdb->prefix . 'wpdo_user_sso';
$table_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $sso_table )
);
if ( ! $table_exists ) {
WP_CLI::error( "SSO flat table {$sso_table} does not exist. Run `wp wpdo doctor` first." );
}
$user_ids = array();
if ( $user_id ) {
$user_ids = array( $user_id );
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$user_ids = $wpdb->get_col(
"SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = '_tmso_refresh_token' LIMIT 5000" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
}
if ( empty( $user_ids ) ) {
WP_CLI::log( 'No users with _tmso_refresh_token found.' );
return;
}
$synced = 0;
$skipped = 0;
$sso_keys = array(
'hub_global_user_id' => '_tmso_global_user_id',
'picture_url' => '_tmso_picture_url',
'refresh_token_enc' => '_tmso_refresh_token',
);
foreach ( $user_ids as $uid ) {
$uid = (int) $uid;
$data = array( 'user_id' => $uid );
foreach ( $sso_keys as $flat_col => $meta_key ) {
$val = get_user_meta( $uid, $meta_key, true );
if ( '' !== $val ) {
// Reject plaintext refresh tokens — must carry the enc_vN: envelope
// written by TMSO_Crypto::encrypt(). Storing cleartext here would
// silently bypass the encryption layer.
if ( 'refresh_token_enc' === $flat_col && ! preg_match( '/^enc_v\d+:/', (string) $val ) ) {
TMDO_Logger::error( 'sso_sync', 'plaintext_token_rejected', "user_id={$uid} meta_key={$meta_key}" );
continue;
}
$data[ $flat_col ] = $val;
}
}
if ( count( $data ) <= 1 ) {
++$skipped;
continue;
}
if ( ! $dry_run ) {
$wpdb->replace( $sso_table, $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
}
++$synced;
}
WP_CLI::success(
sprintf(
'SSO sync complete — synced: %d, skipped (no meta): %d%s.',
$synced,
$skipped,
$dry_run ? ' [dry-run]' : ''
)
);
}
// ── member-force-logout ───────────────────────────────────────────────────
/**
* Force re-authentication by setting token_expires_at to a past datetime.
*
* The Spoke silent-refresh logic detects an expired token_expires_at and
* redirects the user to re-authenticate against the Hub. If the Hub has
* the account blocked, the user is logged out from all Spokes.
*
* ## OPTIONS
*
* [--user-id=<id>]
* : WordPress local user ID to force-logout.
*
* [--global-user-id=<uuid>]
* : Hub global_user_id to force-logout (looks up via hub_global_user_id column).
*
* [--confirm]
* : Required safety flag — confirms intent to invalidate live SSO tokens.
*
* ## EXAMPLES
*
* wp wpdo member-force-logout --user-id=42 --confirm
* wp wpdo member-force-logout --global-user-id=abc-123 --confirm
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_force_logout( $args, $assoc_args ): void {
if ( ! isset( $assoc_args['confirm'] ) ) {
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
}
global $wpdb;
$sso_table = $wpdb->prefix . 'wpdo_user_sso';
$past = '2000-01-01 00:00:00';
if ( isset( $assoc_args['user-id'] ) ) {
$uid = (int) $assoc_args['user-id'];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$sso_table}` SET token_expires_at = %s WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$past,
$uid
)
);
if ( $updated ) {
TMDO_Logger::info(
'member_force_logout',
array(
'user_id' => $uid,
'via' => 'user-id',
)
);
WP_CLI::success( "Force-logout applied to user_id={$uid}. Token invalidated." );
} else {
WP_CLI::warning( "No SSO row found for user_id={$uid}." );
}
return;
}
if ( isset( $assoc_args['global-user-id'] ) ) {
$global_id = sanitize_text_field( (string) $assoc_args['global-user-id'] );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$sso_table}` SET token_expires_at = %s WHERE hub_global_user_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$past,
$global_id
)
);
if ( $updated ) {
TMDO_Logger::info(
'member_force_logout',
array(
'global_user_id' => $global_id,
'affected_rows' => $updated,
'via' => 'global-user-id',
)
);
WP_CLI::success( "Force-logout applied to global_user_id={$global_id}. Affected rows: {$updated}." );
} else {
WP_CLI::warning( "No SSO row found for global_user_id={$global_id}." );
}
return;
}
WP_CLI::error( 'Provide --user-id=<id> or --global-user-id=<uuid>.' );
}
}
// ── Register subcommands ──────────────────────────────────────────────────────
WP_CLI::add_command( 'wpdo member-audit', array( 'TMDO_CLI_Member', 'member_audit' ) );
WP_CLI::add_command( 'wpdo member-backfill', array( 'TMDO_CLI_Member', 'member_backfill' ) );
WP_CLI::add_command( 'wpdo member-shadow-report', array( 'TMDO_CLI_Member', 'member_shadow_report' ) );
WP_CLI::add_command( 'wpdo member-cutover', array( 'TMDO_CLI_Member', 'member_cutover' ) );
WP_CLI::add_command( 'wpdo member-points-check', array( 'TMDO_CLI_Member', 'member_points_check' ) );
WP_CLI::add_command( 'wpdo member-sso-sync', array( 'TMDO_CLI_Member', 'member_sso_sync' ) );
WP_CLI::add_command( 'wpdo member-force-logout', array( 'TMDO_CLI_Member', 'member_force_logout' ) );
+844
View File
@@ -0,0 +1,844 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
/**
* TMDO_CLI_Post — Post entity CLI subcommands (v2.9.0+).
*
* Adds the following subcommands under `wp wpdo`:
*
* wp wpdo postmeta-cleanup — Clean wp_postmeta garbage (transients,
* _wp_old_date, stale _edit_lock).
*
* Mirrors TMDO_CLI_Member's design: thin wrapper around core class
* (TMDO_Postmeta_Cleaner). Future post-entity subcommands (post-audit,
* post-backfill, post-cutover) will live in this same file.
*
* @package WP_Data_Optimizer
* @since 2.9.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital -- WP_CLI callbacks accept ($args, $assoc_args) by contract; many subcommands only need one.
if ( ! class_exists( 'WP_CLI' ) ) {
return;
}
/**
* Post entity CLI subcommands. Loaded only in WP_CLI context.
*/
class TMDO_CLI_Post {
// ── postmeta-cleanup ──────────────────────────────────────────────────────
/**
* Clean wp_postmeta garbage rows (transients, _wp_old_date, stale _edit_lock).
*
* Phase 0 of v2.9.0 Post Entity migration. Runs before any Entity Bridge
* work so subsequent ratio measurements reflect real data, not garbage.
*
* SAFETY: by default this command refuses to run. Pass --dry-run to
* preview row counts without deleting, or --confirm to actually delete.
*
* ## OPTIONS
*
* [--target=<target>]
* : Which garbage class to address. Default: all.
* ---
* default: all
* options:
* - all
* - transients
* - wp_old_date
* - edit_locks
* ---
*
* [--dry-run]
* : Show row counts without deleting.
*
* [--confirm]
* : Required to actually DELETE rows. Mutually exclusive with --dry-run.
*
* ## EXAMPLES
*
* wp wpdo postmeta-cleanup --dry-run
* wp wpdo postmeta-cleanup --target=transients --dry-run
* wp wpdo postmeta-cleanup --confirm
* wp wpdo postmeta-cleanup --target=edit_locks --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function postmeta_cleanup( $args, $assoc_args ): void {
$target = (string) ( $assoc_args['target'] ?? TMDO_Postmeta_Cleaner::TARGET_ALL );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( ! in_array( $target, TMDO_Postmeta_Cleaner::VALID_TARGETS, true ) ) {
WP_CLI::error(
'Invalid --target. Choose: ' . implode( ', ', TMDO_Postmeta_Cleaner::VALID_TARGETS )
);
}
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
// Default safety: refuse to run without an explicit choice.
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
'Example: wp wpdo postmeta-cleanup --dry-run'
);
}
if ( $dry_run ) {
$counts = TMDO_Postmeta_Cleaner::count_garbage( $target );
self::render_table( $counts, 'would delete' );
WP_CLI::success(
sprintf( '[dry-run] %d row(s) would be deleted. Re-run with --confirm to apply.', $counts['total'] )
);
return;
}
// $confirm path.
$deleted = TMDO_Postmeta_Cleaner::delete_garbage( $target );
self::render_table( $deleted, 'deleted' );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'postmeta_cleanup',
array(
'target' => $target,
'transients' => $deleted['transients'],
'wp_old_date' => $deleted['wp_old_date'],
'edit_locks' => $deleted['edit_locks'],
'total' => $deleted['total'],
)
);
}
WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_postmeta.', $deleted['total'] ) );
}
/**
* Render a table of bucket → count.
*
* @param array $counts Output from TMDO_Postmeta_Cleaner::count_garbage() / delete_garbage().
* @param string $verb Column header verb ('would delete' / 'deleted').
*/
private static function render_table( array $counts, string $verb ): void {
$rows = array(
array(
'bucket' => 'transients',
$verb => $counts['transients'],
'rule' => '_transient_% OR _transient_timeout_%',
),
array(
'bucket' => 'wp_old_date',
$verb => $counts['wp_old_date'],
'rule' => "meta_key = '_wp_old_date'",
),
array(
'bucket' => 'edit_locks',
$verb => $counts['edit_locks'],
'rule' => '_edit_lock older than 24h',
),
array(
'bucket' => 'TOTAL',
$verb => $counts['total'],
'rule' => '',
),
);
WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) );
}
// ── post-diagnose (v2.9.3) ────────────────────────────────────────────────
/**
* Show wp_posts:wp_postmeta ratio + per-group EAV residue / flat row count.
*
* Read-only command. Mirrors `wp wpdo member-audit` for the post entity.
*
* ## EXAMPLES
*
* wp wpdo post-diagnose
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments (unused).
*/
public function post_diagnose( $args, $assoc_args ): void {
$result = TMDO_Post_Migration::diagnose();
WP_CLI::log( sprintf( 'Posts: %s', number_format_i18n( $result['posts'] ) ) );
WP_CLI::log( sprintf( 'Postmeta: %s', number_format_i18n( $result['postmeta'] ) ) );
WP_CLI::log( sprintf( 'Ratio: 1:%s', $result['ratio'] ) );
WP_CLI::log( sprintf( 'Mode: %s', $result['mode'] ) );
WP_CLI::log( '' );
$rows = array();
foreach ( $result['groups'] as $name => $g ) {
$rows[] = array(
'group' => $name,
'post_type' => $g['post_type'] ?: '(any)',
'keys' => count( $g['keys'] ),
'eav_rows' => $g['eav_rows'],
'flat_rows' => $g['flat_rows'],
);
}
WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'post_type', 'keys', 'eav_rows', 'flat_rows' ) );
}
// ── post-migrate-group (v2.9.3) ───────────────────────────────────────────
/**
* Backfill one post entity group from wp_postmeta into its flat table
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE).
*
* Idempotent — safe to re-run. Skips JSON-typed fields (handled by the
* row-by-row backfill phase in v2.9.4+).
*
* ## OPTIONS
*
* --group=<group>
* : Entity group name. Required.
* ---
* options:
* - wp_core
* - attachment
* - wc_product
* - hp_listing_core
* - hp_request_core
* - hp_vendor_core
* - nav_menu_item
* ---
*
* ## EXAMPLES
*
* wp wpdo post-migrate-group --group=wc_product
* wp wpdo post-migrate-group --group=hp_listing_core
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_migrate_group( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? '' );
if ( '' === $group ) {
WP_CLI::error( 'Missing required --group=<name>.' );
}
try {
$result = TMDO_Post_Migration::backfill_group( $group );
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'post_migrate_group',
array(
'group' => $result['group'],
'post_type' => $result['post_type'],
'migrated' => $result['migrated'],
)
);
}
WP_CLI::success(
sprintf(
'Backfilled %s (post_type=%s): %d post(s) migrated.',
$result['group'],
$result['post_type'] ?: '(any)',
$result['migrated']
)
);
}
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
/**
* DELETE managed-key wp_postmeta rows after cutover. Requires post mode
* to be aeav_only (the flat table is the authoritative source).
*
* SAFETY: requires --confirm.
*
* ## OPTIONS
*
* [--dry-run]
* : Preview row count only.
*
* [--confirm]
* : Required to actually DELETE rows.
*
* ## EXAMPLES
*
* wp wpdo post-cleanup --dry-run
* wp wpdo post-cleanup --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_cleanup( $args, $assoc_args ): void {
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
'Example: wp wpdo post-cleanup --dry-run'
);
}
$diagnose = TMDO_Post_Migration::diagnose();
$keys = TMDO_Post_Migration::get_managed_keys();
global $wpdb;
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$sql = "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$candidate = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$keys ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
if ( $dry_run ) {
WP_CLI::log( sprintf( 'Mode: %s', $diagnose['mode'] ) );
WP_CLI::log( sprintf( 'Managed keys: %d', count( $keys ) ) );
WP_CLI::success(
sprintf(
'[dry-run] %d wp_postmeta row(s) would be deleted. Re-run with --confirm.',
$candidate
)
);
return;
}
// $confirm path — TMDO_Post_Migration::cleanup() enforces mode=aeav_only.
try {
$result = TMDO_Post_Migration::cleanup();
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'post_cleanup',
array(
'deleted' => $result['deleted'],
)
);
}
WP_CLI::success( sprintf( 'Deleted %d wp_postmeta row(s).', $result['deleted'] ) );
}
// ── cleanup-hp-transients (v2.11.5) ───────────────────────────────────────
/**
* Purge legacy `_transient_hp_*` rows from wp_postmeta.
*
* HivePress (`hivepress/includes/components/class-cache.php`) writes
* per-post TTL caches as `_transient_<name>` / `_transient_timeout_<name>`
* postmeta rows. v2.11.5 ships `TMDO_Hivepress_Transient_Filter` to
* intercept new writes and reroute them to wp_options. This command does
* the one-time historical cleanup — DELETE-ing all such existing rows
* from wp_postmeta. After cleanup, HivePress re-fetches on demand.
*
* Safe to run with the filter enabled; the filter prevents new postmeta
* writes from re-bloating the table.
*
* ## OPTIONS
*
* [--dry-run]
* : Preview row count only.
*
* [--confirm]
* : Required to actually DELETE rows.
*
* ## EXAMPLES
*
* wp wpdo cleanup-hp-transients --dry-run
* wp wpdo cleanup-hp-transients --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function cleanup_hp_transients( $args, $assoc_args ): void {
unset( $args );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without explicit choice. Pass --dry-run or --confirm.\n" .
'Example: wp wpdo cleanup-hp-transients --dry-run'
);
}
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
}
$count = TMDO_Hivepress_Transient_Filter::count_legacy_postmeta_rows();
if ( $dry_run ) {
WP_CLI::success(
sprintf(
'[dry-run] %d HivePress transient row(s) in wp_postmeta would be deleted. Re-run with --confirm.',
$count
)
);
return;
}
$deleted = TMDO_Hivepress_Transient_Filter::purge_legacy_postmeta_rows();
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'cleanup_hp_transients',
array( 'deleted' => $deleted )
);
}
WP_CLI::success(
sprintf(
'Deleted %d HivePress transient row(s) from wp_postmeta. Future writes auto-route to wp_options.',
$deleted
)
);
}
// ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
/**
* Non-destructive copy of a legacy `wpdo_hot_<post_type>` zone table
* into the new `wp_wpdo_post_<group>` flat table.
*
* The legacy table is left UNTOUCHED (safety net for v3.0.0 rollback).
* Idempotent — re-running is safe.
*
* Currently supports hp_listing only (the only post_type with a
* legacy hot table on production deployments). Other post_types skip
* the cutover and rely on direct wp_postmeta backfill.
*
* ## OPTIONS
*
* --post-type=<post_type>
* : Post type to cutover.
* ---
* default: hp_listing
* options:
* - hp_listing
* ---
*
* [--dry-run]
* : Preview row counts only.
*
* [--confirm]
* : Required to actually run the copy.
*
* ## EXAMPLES
*
* wp wpdo post-cutover-legacy --dry-run
* wp wpdo post-cutover-legacy --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_cutover_legacy( $args, $assoc_args ): void {
$post_type = (string) ( $assoc_args['post-type'] ?? 'hp_listing' );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to run.\n" .
'Example: wp wpdo post-cutover-legacy --dry-run'
);
}
// Map post_type → (hot_table, flat_table, group).
$mapping = array(
'hp_listing' => array(
'hot' => 'wpdo_hot_hp_listing',
'flat' => 'wpdo_post_hp_listing_core',
'group' => 'hp_listing_core',
),
);
if ( ! isset( $mapping[ $post_type ] ) ) {
WP_CLI::error( 'Unsupported --post-type: ' . $post_type );
}
global $wpdb;
$hot_table = $wpdb->prefix . $mapping[ $post_type ]['hot'];
$flat_table = $wpdb->prefix . $mapping[ $post_type ]['flat'];
// Pre-flight: verify hot table exists and report current state.
$hot_exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $hot_table )
);
if ( ! $hot_exists ) {
WP_CLI::warning( "Legacy hot table not present: {$hot_table}. Nothing to do." );
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" );
WP_CLI::log( sprintf( 'Hot (%s): %s rows', $hot_table, number_format_i18n( $hot_rows ) ) );
WP_CLI::log( sprintf( 'Flat (%s): %s rows', $flat_table, number_format_i18n( $flat_rows ) ) );
WP_CLI::log( '' );
if ( $dry_run ) {
WP_CLI::success(
sprintf(
'[dry-run] Would copy %s rows from hot → flat. Re-run with --confirm to apply.',
number_format_i18n( $hot_rows )
)
);
return;
}
// $confirm path.
try {
$result = TMDO_Post_Migration::copy_legacy_hot_table( $post_type, $hot_table, $flat_table );
$verify = TMDO_Post_Migration::verify_legacy_cutover( $hot_table, $flat_table );
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'post_cutover_legacy',
array(
'post_type' => $post_type,
'hot_table' => $hot_table,
'flat_table' => $flat_table,
'copied' => $result['copied'],
'common_columns' => $result['common_columns'],
'verify' => $verify,
)
);
}
WP_CLI::log( sprintf( 'Copied %d row(s). Common columns: %s', $result['copied'], implode( ', ', $result['common_columns'] ) ) );
WP_CLI::log(
sprintf(
'Verify: hot=%d flat=%d mismatched=%d ok=%s',
$verify['hot_rows'],
$verify['flat_rows'],
$verify['mismatched_rows'],
$verify['ok'] ? 'yes' : 'NO'
)
);
if ( ! $verify['ok'] ) {
WP_CLI::error( 'Verification failed — flat table missing rows. Legacy hot table left intact for retry.' );
}
WP_CLI::success(
sprintf(
'Legacy cutover complete (%s). Hot table preserved as safety net for v3.0.0 rollback.',
$post_type
)
);
}
// ── post-benchmark (v2.10.2) ──────────────────────────────────────────────
/**
* Compare query latency between wp_postmeta JOIN and flat-table JOIN
* for a single meta_key/value combination.
*
* Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better.
*
* Read-only — does not modify any table or change post mode. Safe to run
* in any post mode (the benchmark always queries both paths regardless).
*
* ## OPTIONS
*
* --post-type=<post_type>
* : Post type to filter by (e.g. product, hp_listing).
*
* --meta-key=<key>
* : Meta key to query (must be registered in entity registry for $post_type).
*
* [--compare=<op>]
* : Comparison operator. Default: =.
* ---
* default: =
* options:
* - "="
* - "!="
* - "<"
* - "<="
* - ">"
* - ">="
* - "LIKE"
* ---
*
* --value=<value>
* : Value to compare against.
*
* [--samples=<n>]
* : Number of times to run each query. Default: 50.
*
* ## EXAMPLES
*
* wp wpdo post-benchmark --post-type=hp_listing --meta-key=hp_price --compare=">=" --value=100
* wp wpdo post-benchmark --post-type=product --meta-key=_price --compare="=" --value=99 --samples=200
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_benchmark( $args, $assoc_args ): void {
$post_type = (string) ( $assoc_args['post-type'] ?? '' );
$meta_key = (string) ( $assoc_args['meta-key'] ?? '' );
$compare = (string) ( $assoc_args['compare'] ?? '=' );
$value = (string) ( $assoc_args['value'] ?? '' );
$samples = max( 1, (int) ( $assoc_args['samples'] ?? 50 ) );
if ( '' === $post_type || '' === $meta_key ) {
WP_CLI::error( 'Both --post-type and --meta-key are required.' );
}
// Look up the entity group for this meta_key to find the flat table.
$field = TMDO_Entity_Registry::get_field( 'post', $meta_key );
if ( ! $field ) {
WP_CLI::error( "Meta key '{$meta_key}' is not registered for entity_type='post'." );
}
global $wpdb;
$flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $field['group'] );
try {
$result = TMDO_Post_Migration::benchmark_query(
$post_type,
$meta_key,
$compare,
$value,
$flat_table,
$samples
);
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
WP_CLI::log(
sprintf(
'Benchmark: %s.%s %s %s (samples=%d)',
$post_type,
$meta_key,
$compare,
$value,
$samples
)
);
WP_CLI::log( '' );
WP_CLI::log(
sprintf(
' postmeta JOIN: %.3f ms avg (matched %d rows)',
$result['postmeta_avg_ms'],
$result['postmeta_rows']
)
);
WP_CLI::log(
sprintf(
' flat JOIN: %.3f ms avg (matched %d rows)',
$result['flat_avg_ms'],
$result['flat_rows']
)
);
WP_CLI::log( '' );
WP_CLI::log( sprintf( ' → Speedup: %.2fx', $result['speedup'] ) );
// Persist to wpdo_benchmarks table.
$bench_table = $wpdb->prefix . 'wpdo_benchmarks';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->insert(
$bench_table,
array(
'module' => 'post_router_' . $post_type,
'zone' => 'flat',
'query_type' => 'meta_query_' . $meta_key,
'native_ms' => $result['postmeta_avg_ms'],
'custom_ms' => $result['flat_avg_ms'],
'sample_size' => $samples,
'created_at' => gmdate( 'Y-m-d H:i:s' ),
),
array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
);
WP_CLI::success( sprintf( 'Benchmark recorded to %s.', $bench_table ) );
}
// ── post-shadow-report (v2.10.3) ──────────────────────────────────────────
/**
* Show shadow_read divergence stats for entity_type='post'.
*
* When post mode is shadow_read, the TMDO_Post_Shadow_Verifier cron job
* runs hourly and writes any flat vs wp_postmeta divergences to
* wpdo_shadow_diffs. This command surfaces a 24h aggregate.
*
* Optionally runs an immediate sample-compare pass via --run-now.
*
* ## OPTIONS
*
* [--hours=<n>]
* : Aggregation window in hours. Default: 24.
*
* [--limit=<n>]
* : Max recent diff rows to display. Default: 10.
*
* [--run-now]
* : Execute one sample-compare tick immediately (alongside the report).
*
* ## EXAMPLES
*
* wp wpdo post-shadow-report
* wp wpdo post-shadow-report --hours=48 --limit=20
* wp wpdo post-shadow-report --run-now
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_shadow_report( $args, $assoc_args ): void {
$hours = max( 1, (int) ( $assoc_args['hours'] ?? 24 ) );
$limit = max( 1, (int) ( $assoc_args['limit'] ?? 10 ) );
$run_now = isset( $assoc_args['run-now'] );
$mode = TMDO_Mode_Manager::get( 'post' );
WP_CLI::log( sprintf( 'Post mode: %s', $mode ) );
WP_CLI::log( sprintf( 'Window: last %d hours', $hours ) );
WP_CLI::log( '' );
if ( $run_now ) {
WP_CLI::log( 'Running sample-compare for all groups...' );
$totals = array(
'sampled' => 0,
'matched' => 0,
'diffs' => 0,
'missing_flat' => 0,
'missing_postmeta' => 0,
);
global $wpdb;
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
$keys = TMDO_Entity_Registry::get_group_keys( 'post', $group );
if ( empty( $keys ) ) {
continue;
}
// Use the verifier's own group→post_type mapping reflectively.
$pt = self::group_post_type_for_cli( $group );
if ( null === $pt ) {
continue;
}
$flat = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
try {
$res = TMDO_Post_Shadow_Verifier::sample_compare( $pt, $group, $flat, $keys, 50 );
foreach ( $totals as $k => $_ ) {
$totals[ $k ] += (int) ( $res[ $k ] ?? 0 );
}
WP_CLI::log(
sprintf(
' %-20s sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d',
$group,
$res['sampled'],
$res['matched'],
$res['diffs'],
$res['missing_flat'],
$res['missing_postmeta']
)
);
} catch ( \Throwable $e ) {
WP_CLI::warning( " {$group}: " . $e->getMessage() );
}
}
WP_CLI::log( '' );
WP_CLI::log(
sprintf(
'Run-now totals: sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d',
$totals['sampled'],
$totals['matched'],
$totals['diffs'],
$totals['missing_flat'],
$totals['missing_postmeta']
)
);
WP_CLI::log( '' );
}
// 24h aggregate from wpdo_shadow_diffs.
$stats = TMDO_Post_Shadow_Verifier::diff_stats( $hours );
WP_CLI::log( sprintf( 'Total diffs (last %dh): %d', $hours, $stats['total'] ) );
if ( ! empty( $stats['by_key'] ) ) {
WP_CLI::log( '' );
$rows = array();
foreach ( $stats['by_key'] as $key => $count ) {
$rows[] = array(
'meta_key' => $key,
'diffs' => $count,
);
}
WP_CLI\Utils\format_items( 'table', $rows, array( 'meta_key', 'diffs' ) );
}
// Recent diff rows.
if ( $stats['total'] > 0 ) {
$recent = TMDO_Post_Shadow_Verifier::recent_diffs( $limit );
if ( ! empty( $recent ) ) {
WP_CLI::log( '' );
WP_CLI::log( sprintf( 'Recent %d diff(s):', $limit ) );
WP_CLI\Utils\format_items( 'table', $recent, array( 'entity_id', 'meta_key', 'postmeta_value', 'zone_value', 'ts' ) );
}
}
WP_CLI::success( 'Shadow report complete.' );
}
/**
* Helper: map entity group to its primary post_type for the run-now CLI path.
* Mirror of TMDO_Post_Shadow_Verifier's private mapping.
*
* @param string $group Entity group name.
* @return string|null
*/
private static function group_post_type_for_cli( string $group ): ?string {
switch ( $group ) {
case 'attachment':
return 'attachment';
case 'wc_product':
return 'product';
case 'hp_listing_core':
return 'hp_listing';
case 'hp_request_core':
return 'hp_request';
case 'hp_vendor_core':
return 'hp_vendor';
case 'nav_menu_item':
return 'nav_menu_item';
case 'wp_core':
default:
return null;
}
}
}
// ── Register subcommands ──────────────────────────────────────────────────────
WP_CLI::add_command( 'wpdo postmeta-cleanup', array( 'TMDO_CLI_Post', 'postmeta_cleanup' ) );
WP_CLI::add_command( 'wpdo post-diagnose', array( 'TMDO_CLI_Post', 'post_diagnose' ) );
WP_CLI::add_command( 'wpdo post-migrate-group', array( 'TMDO_CLI_Post', 'post_migrate_group' ) );
WP_CLI::add_command( 'wpdo post-cleanup', array( 'TMDO_CLI_Post', 'post_cleanup' ) );
WP_CLI::add_command( 'wpdo post-cutover-legacy', array( 'TMDO_CLI_Post', 'post_cutover_legacy' ) );
WP_CLI::add_command( 'wpdo cleanup-hp-transients', array( 'TMDO_CLI_Post', 'cleanup_hp_transients' ) );
WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
File diff suppressed because it is too large Load Diff
+795
View File
@@ -0,0 +1,795 @@
<?php
/**
* TMDO_CLI_V2 — v2.0.0 CLI subcommands.
*
* Adds the following subcommands under `wp wpdo`:
*
* wp wpdo bridge-status — Hook Bus enabled? Conflict snapshot.
* wp wpdo bridge-set <on|off> — Toggle TMDO_Hook_Bus_Bridge.
* wp wpdo mode-audit — Per-module state + shadow flags.
* wp wpdo mode-set <module> <state> — Set FSM state.
* wp wpdo shadow-enable <module> — Enable shadow_read_only sub-flag.
* wp wpdo shadow-disable <module> — Disable shadow_read_only.
* wp wpdo conflict-scan — Run conflict monitor + emit JSON.
* wp wpdo lint --plugin=<path> — Anti-EAV lint (Part C.2).
*
* Kept in a separate class so v1.x CLI commands stay untouched.
*
* @package WP_Data_Optimizer
* @since 2.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_CLI callbacks must accept ($args, $assoc_args) by signature; many subcommands ignore them. file_get_contents() reads local PHP files only — wp_remote_get N/A.
if ( ! class_exists( 'WP_CLI' ) ) {
return;
}
/**
* v2.0.0 CLI subcommands. Loaded only in WP_CLI context.
*/
class TMDO_CLI_V2 {
/**
* Show Hook Bus status, conflict count, and dual_write progress per entity group.
*
* ## OPTIONS
*
* [--format=<format>]
* : Output format for dual_write progress table. Options: table, json. Default: table.
*
* ## EXAMPLES
*
* wp wpdo bridge-status
* wp wpdo bridge-status --format=json
*/
public function bridge_status( $args, $assoc_args ): void {
global $wpdb;
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$enabled = TMDO_Hook_Bus_Bridge::is_enabled();
$summary = TMDO_Conflict_Monitor::get_summary();
$status = $enabled ? 'enabled' : 'disabled';
WP_CLI::log( "Hook Bus: {$status}" );
WP_CLI::log( "Conflicts (total / hook_overlap / uaepg_overlap): {$summary['total']} / {$summary['hook_overlap']} / {$summary['uaepg_overlap']}" );
if ( $summary['total'] > 0 ) {
WP_CLI::warning( 'Run `wp wpdo conflict-scan` for full report.' );
}
// dual_write progress: flat table rows vs EAV source rows.
$modes = TMDO_Mode_Manager::all();
$rows = array();
// EAV source table per entity type.
$eav_tables = array(
'post' => $wpdb->postmeta,
'user' => $wpdb->usermeta,
'term' => $wpdb->termmeta,
'comment' => $wpdb->commentmeta,
);
foreach ( $modes as $entity_type => $mode ) {
if ( 'disabled' === $mode ) {
continue;
}
$groups = TMDO_Entity_Registry::get_groups_for_type( $entity_type );
if ( empty( $groups ) ) {
continue;
}
foreach ( $groups as $group_name ) {
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
// Check flat table existence.
$flat_exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table )
);
$flat_count = $flat_exists
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" )
: 0;
// EAV source: count distinct entity IDs that have at least one managed key.
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
$eav_table = $eav_tables[ $entity_type ] ?? '';
$eav_count = 0;
if ( '' !== $eav_table && ! empty( $managed_keys ) ) {
$id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id";
$placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) );
// $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$sql = "SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$eav_table}` WHERE `meta_key` IN ({$placeholders})";
$eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
$pct = ( $eav_count > 0 ) ? round( $flat_count / $eav_count * 100, 1 ) : ( $flat_count > 0 ? 100.0 : 0.0 );
$rows[] = array(
'entity' => $entity_type,
'group' => $group_name,
'mode' => $mode,
'flat_rows' => $flat_count,
'eav_ids' => $eav_count,
'progress_%' => $pct . '%',
);
}
}
if ( ! empty( $rows ) ) {
WP_CLI::log( '' );
$format = $assoc_args['format'] ?? 'table';
\WP_CLI\Utils\format_items( $format, $rows, array( 'entity', 'group', 'mode', 'flat_rows', 'eav_ids', 'progress_%' ) );
}
}
/**
* Toggle TMDO_Hook_Bus_Bridge feature flag.
*
* ## OPTIONS
*
* <state>
* : on | off
*
* ## EXAMPLES
*
* wp wpdo bridge-set on
*/
public function bridge_set( $args, $assoc_args ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$state = strtolower( (string) ( $args[0] ?? '' ) );
if ( 'on' !== $state && 'off' !== $state ) {
WP_CLI::error( 'Usage: wp wpdo bridge-set <on|off>' );
}
update_option( TMDO_Hook_Bus_Bridge::OPTION, 'on' === $state ? '1' : '0' );
TMDO_Hook_Bus_Bridge::reset_cache();
WP_CLI::success( "Hook Bus: {$state}" );
}
/**
* Audit per-module state + shadow_read flags.
*
* ## EXAMPLES
*
* wp wpdo mode-audit
* wp wpdo mode-audit --format=json
*
* @when after_wp_load
*/
public function mode_audit( $args, $assoc_args ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$states = TMDO_Feature_Flags::all();
$shadow = TMDO_Feature_Flags::all_shadow();
$rows = array();
foreach ( $states as $module => $state ) {
$rows[] = array(
'module' => $module,
'state' => $state,
'shadow_read' => empty( $shadow[ $module ] ) ? 'no' : 'yes',
);
}
$format = $assoc_args['format'] ?? 'table';
\WP_CLI\Utils\format_items( $format, $rows, array( 'module', 'state', 'shadow_read' ) );
}
/**
* Set FSM state for a module.
*
* ## OPTIONS
*
* <module>
* : Module name (e.g. hot_hp_listing)
*
* <state>
* : One of idle, dual_write, backfill, verify, cutover, cleanup, complete
*
* ## EXAMPLES
*
* wp wpdo mode-set hot_hp_listing verify
*/
public function mode_set( $args, $assoc_args ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$module = (string) ( $args[0] ?? '' );
$state = (string) ( $args[1] ?? '' );
if ( '' === $module || ! in_array( $state, TMDO_Feature_Flags::VALID_STATES, true ) ) {
WP_CLI::error( 'Usage: wp wpdo mode-set <module> <state> — state must be one of: ' . implode( ', ', TMDO_Feature_Flags::VALID_STATES ) );
}
$ok = TMDO_Feature_Flags::set( $module, $state );
if ( ! $ok ) {
WP_CLI::error( 'Failed to update state' );
}
WP_CLI::success( "{$module}{$state}" );
}
/**
* Enable shadow_read_only sub-flag (only effective in verify state).
*/
public function shadow_enable( $args, $assoc_args ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$module = (string) ( $args[0] ?? '' );
if ( '' === $module ) {
WP_CLI::error( 'Usage: wp wpdo shadow-enable <module>' );
}
TMDO_Feature_Flags::enable_shadow_read( $module );
$state = TMDO_Feature_Flags::get( $module );
WP_CLI::success( "shadow_read enabled for {$module} (current state: {$state})" );
if ( 'verify' !== $state ) {
WP_CLI::warning( 'shadow_read only takes effect when state == verify' );
}
}
/**
* Disable shadow_read_only sub-flag.
*/
public function shadow_disable( $args, $assoc_args ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$module = (string) ( $args[0] ?? '' );
if ( '' === $module ) {
WP_CLI::error( 'Usage: wp wpdo shadow-disable <module>' );
}
TMDO_Feature_Flags::disable_shadow_read( $module );
WP_CLI::success( "shadow_read disabled for {$module}" );
}
/**
* Run conflict monitor scan + emit findings.
*/
public function conflict_scan( $args, $assoc_args ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
TMDO_Conflict_Monitor::reset_cache();
$conflicts = TMDO_Conflict_Monitor::scan();
if ( empty( $conflicts ) ) {
WP_CLI::success( '0 conflicts detected.' );
return;
}
WP_CLI::warning( count( $conflicts ) . ' conflict(s) detected:' );
// Normalise heterogenous finding shapes to a uniform 6-column row so
// format_items() doesn't blow up on optional keys.
$rows = array_map(
static fn( array $f ) => array(
'type' => $f['type'] ?? '',
'hook' => $f['hook'] ?? '',
'priority' => isset( $f['priority'] ) ? (string) $f['priority'] : '',
'callback' => $f['callback'] ?? '',
'entity_type' => $f['entity_type'] ?? '',
'meta_key' => $f['meta_key'] ?? '',
),
$conflicts
);
\WP_CLI\Utils\format_items(
$assoc_args['format'] ?? 'table',
$rows,
array( 'type', 'hook', 'priority', 'callback', 'entity_type', 'meta_key' )
);
}
/**
* Anti-EAV strict lint for a partner plugin.
*
* Scans the target plugin / theme directory for:
* 1. Direct SELECT FROM wp_postmeta / wp_usermeta / wp_termmeta
* 2. update_post_meta() on fields registered to WPDO
* 3. autoload=yes wp_options exceeding the per-plugin cap (default 30)
* 4. meta_query with ≥3 conditions but no wpdo_register_fields
*
* Returns non-zero exit when --strict is set and findings exist (CI gate).
*
* ## OPTIONS
*
* --plugin=<path>
* : Absolute path to plugin directory.
*
* [--strict]
* : Exit non-zero on any finding.
*
* [--max-autoload=<n>]
* : Soft cap on autoload=yes options per plugin (default 30).
*
* ## EXAMPLES
*
* wp wpdo lint --plugin=/var/www/.../2meet-infocards --strict
*/
public function lint( $args, $assoc_args ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$plugin_path = (string) ( $assoc_args['plugin'] ?? '' );
$strict = ! empty( $assoc_args['strict'] );
$max_autoload = (int) ( $assoc_args['max-autoload'] ?? 30 );
if ( '' === $plugin_path || ! is_dir( $plugin_path ) ) {
WP_CLI::error( '--plugin=<path> required and must be a directory' );
}
$findings = self::lint_directory( $plugin_path, $max_autoload );
if ( empty( $findings ) ) {
WP_CLI::success( "Anti-EAV lint passed for {$plugin_path}" );
return;
}
WP_CLI::warning( count( $findings ) . ' Anti-EAV violation(s) in ' . $plugin_path );
foreach ( $findings as $f ) {
WP_CLI::log( sprintf( ' [%s] %s:%d — %s', $f['rule'], $f['file'], $f['line'], $f['message'] ) );
}
if ( $strict ) {
WP_CLI::error( 'Lint failed — strict mode enabled' );
}
}
/**
* Walk a directory tree and apply Anti-EAV lint rules to every PHP file.
*
* Static so it can be unit-tested without WP_CLI runtime.
*
* @param string $plugin_path Absolute directory path.
* @param int $max_autoload Per-plugin autoload cap.
* @return array<int, array{rule:string, file:string, line:int, message:string}>
*/
public static function lint_directory( string $plugin_path, int $max_autoload = 30 ): array {
$findings = array();
$plugin_path = rtrim( $plugin_path, '/' );
$skip_dirs = array( 'vendor', 'node_modules', 'tests', '.git', '.github' );
$it = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator( $plugin_path, FilesystemIterator::SKIP_DOTS ),
static function ( $current ) use ( $skip_dirs ) {
if ( $current->isDir() && in_array( $current->getFilename(), $skip_dirs, true ) ) {
return false;
}
return true;
}
)
);
// Patterns: regex => { rule, message }.
// Match any 'postmeta' / 'usermeta' / etc. token after SELECT ... FROM,
// regardless of how the table prefix is constructed (literal, $wpdb->prefix
// concatenation, {$wpdb->prefix} interpolation, sprintf, etc.).
$patterns = array(
'/\bSELECT\b[^;]*\bFROM\s+\S*postmeta/i' => array(
'rule' => 'no-direct-postmeta-select',
'message' => 'Direct SELECT FROM postmeta — use TMDO_API::query() or TMDO_API::get_field()',
),
'/\bSELECT\b[^;]*\bFROM\s+\S*usermeta/i' => array(
'rule' => 'no-direct-usermeta-select',
'message' => 'Direct SELECT FROM usermeta — use TMDO_API::get_entity()',
),
'/\bSELECT\b[^;]*\bFROM\s+\S*termmeta/i' => array(
'rule' => 'no-direct-termmeta-select',
'message' => 'Direct SELECT FROM termmeta — use TMDO_API::get_entity()',
),
'/\bSELECT\b[^;]*\bFROM\s+\S*commentmeta/i' => array(
'rule' => 'no-direct-commentmeta-select',
'message' => 'Direct SELECT FROM commentmeta — use TMDO_API::get_entity()',
),
"/'autoload'\s*=>\s*'yes'/" => array(
'rule' => 'autoload-yes',
'message' => 'autoload=yes — keep per-plugin total ≤ ' . $max_autoload,
),
);
foreach ( $it as $file ) {
if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
continue;
}
// Skip files that explicitly opt out.
$source = (string) file_get_contents( $file->getPathname() );
if ( str_contains( $source, 'phpcs:ignore WPDO.AntiEAV' ) ) {
continue;
}
$lines = explode( "\n", $source );
foreach ( $lines as $i => $line ) {
// Skip lines marked with phpcs:ignore WPDO.AntiEAV.<rule> on the same line.
if ( str_contains( $line, 'phpcs:ignore WPDO.AntiEAV' ) ) {
continue;
}
foreach ( $patterns as $regex => $meta ) {
if ( preg_match( $regex, $line ) ) {
$findings[] = array(
'rule' => $meta['rule'],
'file' => $file->getPathname(),
'line' => $i + 1,
'message' => $meta['message'],
);
}
}
}
}
return $findings;
}
// ─── snapshot subcommands (v2.2.0 M1) ─────────────────────────────────
/**
* Create a snapshot.
*
* ## OPTIONS
*
* [--trigger=<trigger>]
* : One of manual|pre_fsm_transition|pre_v2_upgrade|scheduled|pre_uninstall.
* ---
* default: manual
* ---
*
* [--scope-tables=<csv>]
* : Comma-separated table list to dump. Empty = all WPDO tables.
*
* [--scope-entities=<csv>]
* : Comma-separated entity list (post,user,term,comment) — adds wp_*meta to dump.
*
* [--notes=<text>]
* : Free-form note for the catalog.
*
* [--retention-days=<n>]
* : Snapshot expiry. Default 30. 0 disables.
*
* ## EXAMPLES
*
* wp wpdo snapshot create --trigger=manual --notes="before reviews backfill"
* wp wpdo snapshot create --scope-tables=wp_wpdo_warm,wp_wpdo_archive
*/
public function snapshot_create( $args, $assoc_args ): void {
$trigger = (string) ( $assoc_args['trigger'] ?? 'manual' );
$scope = array(
'tables' => self::csv_to_array( (string) ( $assoc_args['scope-tables'] ?? '' ) ),
'entities' => self::csv_to_array( (string) ( $assoc_args['scope-entities'] ?? '' ) ),
);
$opts = array(
'notes' => (string) ( $assoc_args['notes'] ?? '' ),
'retention_days' => isset( $assoc_args['retention-days'] ) ? (int) $assoc_args['retention-days'] : TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS,
);
$result = TMDO_Snapshot_Manager::create( $trigger, $scope, $opts );
if ( empty( $result['ok'] ) ) {
WP_CLI::error( 'snapshot create failed: ' . ( $result['error'] ?? 'unknown' ) . ( isset( $result['message'] ) ? ' — ' . $result['message'] : '' ) );
}
WP_CLI::success(
sprintf(
'snapshot %s created (%d bytes, %d rows, storage=%s)',
$result['snapshot_id'],
$result['size_bytes'],
$result['row_count'],
$result['storage']
)
);
}
/**
* List recent snapshots.
*
* ## OPTIONS
*
* [--trigger=<trigger>]
* : Filter by trigger type.
*
* [--limit=<n>]
* : Default 50.
*
* [--format=<format>]
* : table|json|csv|yaml. Default table.
*
* ## EXAMPLES
*
* wp wpdo snapshot list
* wp wpdo snapshot list --trigger=pre_fsm_transition --format=json
*/
public function snapshot_list( $args, $assoc_args ): void {
$limit = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 50;
$trigger = isset( $assoc_args['trigger'] ) ? (string) $assoc_args['trigger'] : null;
$rows = TMDO_Snapshot_Manager::list_recent( $limit, $trigger );
$format = (string) ( $assoc_args['format'] ?? 'table' );
$display_keys = array( 'snapshot_id', 'trigger_type', 'size_bytes', 'row_count', 'storage', 'created_at', 'expires_at' );
\WP_CLI\Utils\format_items( $format, $rows, $display_keys );
}
/**
* Restore a snapshot. Default is dry-run (preview only).
*
* ## OPTIONS
*
* <snapshot_id>
* : Snapshot ULID (e.g. wpdo_xyz_abc).
*
* [--apply]
* : Actually run the restore (DELETE + INSERT). Without this flag, only preview.
*
* ## EXAMPLES
*
* wp wpdo snapshot restore wpdo_xyz_abc # preview
* wp wpdo snapshot restore wpdo_xyz_abc --apply # destructive!
*/
public function snapshot_restore( $args, $assoc_args ): void {
$snapshot_id = (string) ( $args[0] ?? '' );
if ( '' === $snapshot_id ) {
WP_CLI::error( 'Usage: wp wpdo snapshot restore <snapshot_id> [--apply]' );
}
$apply = isset( $assoc_args['apply'] );
$result = TMDO_Snapshot_Manager::restore( $snapshot_id, ! $apply );
if ( empty( $result['ok'] ) ) {
WP_CLI::error( 'restore failed: ' . ( $result['error'] ?? 'unknown' ) . ( isset( $result['message'] ) ? ' — ' . $result['message'] : '' ) );
}
if ( ! $apply ) {
$preview = $result['preview'];
WP_CLI::log( sprintf( 'PREVIEW (dry-run): %d statements, %d total rows, %d bytes', $preview['statements'], $preview['total_rows'], $preview['sql_bytes'] ) );
foreach ( $preview['tables'] as $t => $n ) {
WP_CLI::log( " {$t}: {$n} rows" );
}
WP_CLI::warning( 'No changes applied. Re-run with --apply to actually restore.' );
return;
}
$applied = $result['restored'];
WP_CLI::success(
sprintf(
'Restored %d statements, %d rows across %d tables',
$applied['statements_run'],
$applied['rows_restored'],
count( $applied['tables'] )
)
);
}
/**
* Prune expired or over-cap snapshots.
*
* ## OPTIONS
*
* [--days=<n>]
* : Older-than threshold (informational; actual TTL stored in row).
* ---
* default: 30
* ---
*
* [--size-cap-mb=<n>]
* : Backup directory cap (MB). 0 disables.
* ---
* default: 1024
* ---
*
* ## EXAMPLES
*
* wp wpdo snapshot prune
* wp wpdo snapshot prune --size-cap-mb=2048
*/
public function snapshot_prune( $args, $assoc_args ): void {
$days = isset( $assoc_args['days'] ) ? (int) $assoc_args['days'] : TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS;
$capmb = isset( $assoc_args['size-cap-mb'] ) ? (int) $assoc_args['size-cap-mb'] : 1024;
$cap = $capmb * 1024 * 1024;
$res = TMDO_Snapshot_Manager::prune( $days, $cap );
WP_CLI::log(
sprintf(
'Pruned %d (TTL=%d, sizecap=%d), freed %s, errors=%d',
$res['pruned'],
$res['ttl_pruned'] ?? 0,
$res['sizecap_pruned'] ?? 0,
size_format( (int) $res['freed_bytes'], 2 ),
count( $res['errors'] )
)
);
if ( ! empty( $res['errors'] ) ) {
foreach ( $res['errors'] as $err ) {
WP_CLI::warning( $err );
}
}
}
/**
* Verify a snapshot's sha256 + readability.
*
* ## OPTIONS
*
* <snapshot_id>
* : Snapshot ULID.
*/
public function snapshot_verify( $args, $assoc_args ): void {
$snapshot_id = (string) ( $args[0] ?? '' );
if ( '' === $snapshot_id ) {
WP_CLI::error( 'Usage: wp wpdo snapshot verify <snapshot_id>' );
}
$result = TMDO_Snapshot_Manager::verify( $snapshot_id );
if ( empty( $result['ok'] ) ) {
WP_CLI::error( 'verify failed: ' . ( $result['error'] ?? 'unknown' ) );
}
WP_CLI::success(
sprintf(
'OK — sha256=%s size=%s storage=%s',
$result['sha256_ok'] ? '✓' : '✗',
$result['size_match'] ? '✓' : '✗',
$result['storage']
)
);
}
/**
* Helper: split comma-separated input into a clean array.
*
* @param string $csv Comma-separated input.
* @return array<int,string>
*/
private static function csv_to_array( string $csv ): array {
if ( '' === $csv ) {
return array();
}
return array_values( array_filter( array_map( 'trim', explode( ',', $csv ) ), 'strlen' ) );
}
// ── crypto-status / crypto-migrate (v2.15.0) ──────────────────────────────
/**
* Show ciphertext format breakdown for `wpdo_*` options.
*
* Counts wp_options entries whose option_name starts with `wpdo_` and
* classifies each by storage format: v2 (AES-256-GCM, current), v1
* (AES-256-CBC, legacy), plaintext, or empty.
*
* Use this to verify the v1→v2 migration ran successfully (expect
* `v1=0, v2>=count(secrets)`).
*
* ## OPTIONS
*
* [--prefix=<prefix>]
* : Option name prefix to scan. Default: wpdo_.
*
* ## EXAMPLES
*
* wp wpdo crypto-status
* wp wpdo crypto-status --prefix=wpdo_
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
* @return void
*/
public function crypto_status( $args, $assoc_args ): void {
unset( $args );
if ( ! class_exists( 'TMDO_Crypto' ) ) {
WP_CLI::error( 'TMDO_Crypto class not loaded.' );
}
global $wpdb;
$prefix = (string) ( $assoc_args['prefix'] ?? 'wpdo_' );
$option_names = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_name ASC",
$wpdb->esc_like( $prefix ) . '%'
)
);
$counts = array(
'v2' => 0,
'v1' => 0,
'plaintext' => 0,
'empty' => 0,
);
$rows = array();
foreach ( (array) $option_names as $name ) {
$fmt = TMDO_Crypto::format_version( $name );
++$counts[ $fmt ];
$rows[] = array(
'option_name' => $name,
'format' => $fmt,
);
}
WP_CLI\Utils\format_items( 'table', $rows, array( 'option_name', 'format' ) );
WP_CLI::log( '' );
WP_CLI::log( sprintf( 'Total scanned: %d', count( $rows ) ) );
WP_CLI::log( sprintf( ' v2 (GCM): %d', $counts['v2'] ) );
WP_CLI::log( sprintf( ' v1 (CBC): %d', $counts['v1'] ) );
WP_CLI::log( sprintf( ' plaintext: %d', $counts['plaintext'] ) );
WP_CLI::log( sprintf( ' empty: %d', $counts['empty'] ) );
if ( $counts['v1'] > 0 ) {
WP_CLI::warning(
sprintf(
'%d v1 ciphertext(s) detected. Run `wp wpdo crypto-migrate` to upgrade to v2 GCM.',
$counts['v1']
)
);
} else {
WP_CLI::success( 'No v1 ciphertext remaining — all encrypted secrets are GCM-authenticated.' );
}
}
/**
* Migrate v1 (AES-256-CBC) ciphertext to v2 (AES-256-GCM) under `wpdo_*` options.
*
* Idempotent: already-v2, plaintext, and empty options are skipped without error.
*
* ## OPTIONS
*
* [--prefix=<prefix>]
* : Option name prefix to scan. Default: wpdo_.
*
* [--dry-run]
* : Show what would change without applying.
*
* ## EXAMPLES
*
* wp wpdo crypto-migrate --dry-run
* wp wpdo crypto-migrate
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
* @return void
*/
public function crypto_migrate( $args, $assoc_args ): void {
unset( $args );
if ( ! class_exists( 'TMDO_Crypto' ) ) {
WP_CLI::error( 'TMDO_Crypto class not loaded.' );
}
$prefix = (string) ( $assoc_args['prefix'] ?? 'wpdo_' );
$dry_run = isset( $assoc_args['dry-run'] );
if ( $dry_run ) {
global $wpdb;
$option_names = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( $prefix ) . '%'
)
);
$v1_count = 0;
foreach ( (array) $option_names as $name ) {
if ( 'v1' === TMDO_Crypto::format_version( $name ) ) {
++$v1_count;
}
}
WP_CLI::success( sprintf( '[dry-run] %d v1 option(s) would be migrated. Re-run without --dry-run to apply.', $v1_count ) );
return;
}
$counts = TMDO_Crypto::migrate_v1_to_v2( $prefix );
WP_CLI::log( sprintf( 'Scanned: %d', $counts['scanned'] ) );
WP_CLI::log( sprintf( 'Migrated: %d', $counts['migrated'] ) );
WP_CLI::log( sprintf( 'Already v2: %d', $counts['already_v2'] ) );
WP_CLI::log( sprintf( 'Plaintext: %d', $counts['plaintext'] ) );
WP_CLI::log( sprintf( 'Empty: %d', $counts['empty'] ) );
WP_CLI::log( sprintf( 'Failed: %d', $counts['failed'] ) );
if ( $counts['failed'] > 0 ) {
foreach ( $counts['errors'] as $option_name => $reason ) {
WP_CLI::warning( sprintf( ' %s → %s', $option_name, $reason ) );
}
WP_CLI::error( sprintf( '%d migration(s) failed; see warnings above.', $counts['failed'] ) );
}
// Set the migrated flag so installer's auto-migration won't re-scan.
update_option( 'wpdo_crypto_migrated_v2', '1', false );
WP_CLI::success( sprintf( 'Migrated %d option(s) from v1 (CBC) to v2 (GCM).', $counts['migrated'] ) );
}
}
WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) );
WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
WP_CLI::add_command( 'wpdo shadow-enable', array( 'TMDO_CLI_V2', 'shadow_enable' ) );
WP_CLI::add_command( 'wpdo shadow-disable', array( 'TMDO_CLI_V2', 'shadow_disable' ) );
WP_CLI::add_command( 'wpdo conflict-scan', array( 'TMDO_CLI_V2', 'conflict_scan' ) );
WP_CLI::add_command( 'wpdo lint', array( 'TMDO_CLI_V2', 'lint' ) );
WP_CLI::add_command( 'wpdo snapshot create', array( 'TMDO_CLI_V2', 'snapshot_create' ) );
WP_CLI::add_command( 'wpdo snapshot list', array( 'TMDO_CLI_V2', 'snapshot_list' ) );
WP_CLI::add_command( 'wpdo snapshot restore', array( 'TMDO_CLI_V2', 'snapshot_restore' ) );
WP_CLI::add_command( 'wpdo snapshot prune', array( 'TMDO_CLI_V2', 'snapshot_prune' ) );
WP_CLI::add_command( 'wpdo snapshot verify', array( 'TMDO_CLI_V2', 'snapshot_verify' ) );
WP_CLI::add_command( 'wpdo crypto-status', array( 'TMDO_CLI_V2', 'crypto_status' ) );
WP_CLI::add_command( 'wpdo crypto-migrate', array( 'TMDO_CLI_V2', 'crypto_migrate' ) );
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "2meet/2meet-data-optimizer",
"description": "通用 WordPress 反 EAV 引擎,將四 entity meta 自動分流至 Hot/Warm/Cold/Archive 扁平表",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"keywords": ["wordpress", "performance", "anti-eav", "postmeta", "flat-table", "query-router"],
"require": {
"php": ">=8.1"
},
"require-dev": {
"brain/monkey": "^2.6",
"dealerdirect/phpcodesniffer-composer-installer": "^1.2",
"php-stubs/wp-cli-stubs": "^2.12",
"phpcompatibility/phpcompatibility-wp": "^2.1",
"phpcsstandards/phpcsutils": "^1.2",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.13",
"szepeviktor/phpstan-wordpress": "^2.0",
"wp-coding-standards/wpcs": "^3.0",
"yoast/phpunit-polyfills": "^2.0"
},
"autoload": {
"classmap": [
"includes/",
"admin/",
"cli/",
"modules/"
]
},
"autoload-dev": {
"psr-4": {
"TMDO\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit --configuration phpunit.xml",
"test:integration": "phpunit --configuration phpunit-integration.xml",
"phpcs": "phpcs --standard=WordPress includes/ admin/ cli/ modules/ 2meet-data-optimizer.php uninstall.php",
"stan": "phpstan analyse --no-progress",
"stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
},
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
}
}
Generated
+2840
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
<?php
/**
* TMDO_Adapter_Comment - Comment 實體適配器
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Adapter_Comment implements TMDO_Entity_Adapter_Interface {
public function get_entity_type(): string {
return 'comment';
}
public function get_native_meta_table(): string {
global $wpdb;
return $wpdb->commentmeta;
}
public function get_entity_id_column(): string {
return 'comment_id';
}
public function get_primary_table(): string {
global $wpdb;
return $wpdb->comments;
}
public function get_primary_id_column(): string {
return 'comment_ID';
}
public function get_cache_group(): string {
return 'wpdo_comment_profile';
}
public function get_delete_hook(): string {
return 'delete_comment';
}
public function extend_native_query( $query_object ): void {
if ( ! ( $query_object instanceof WP_Comment_Query ) ) {
return;
}
$wpdo_query = $query_object->query_vars['wpdo_meta_query'] ?? null;
if ( empty( $wpdo_query ) || ! is_array( $wpdo_query ) ) {
return;
}
// WP_Comment_Query 使用 comments_clauses filter
add_filter(
'comments_clauses',
function ( $clauses ) use ( $wpdo_query ) {
$compiled = TMDO_Query_Compiler::compile( 'comment', $wpdo_query );
if ( ! empty( $compiled['joins'] ) ) {
$clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] );
}
if ( ! empty( $compiled['where'] ) ) {
$clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
}
return $clauses;
},
10,
1
);
}
public function get_entity_ids_after( int $after_id, int $limit ): array {
global $wpdb;
return array_map(
'intval',
$wpdb->get_col(
$wpdb->prepare(
"SELECT comment_ID FROM `{$wpdb->comments}` WHERE comment_ID > %d ORDER BY comment_ID ASC LIMIT %d",
$after_id,
$limit
)
) ?: array()
);
}
}
// 註冊 pre_get_comments hook(unit test 下 add_action 未載入時跳過)
if ( function_exists( 'add_action' ) ) {
add_action(
'pre_get_comments',
function ( $query ) {
$adapter = TMDO_Entity_Registry::get_adapter( 'comment' );
if ( $adapter ) {
$adapter->extend_native_query( $query );
}
},
10,
1
);
}
@@ -0,0 +1,93 @@
<?php
/**
* TMDO_Adapter_Post - Post 實體適配器
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Adapter_Post implements TMDO_Entity_Adapter_Interface {
public function get_entity_type(): string {
return 'post';
}
public function get_native_meta_table(): string {
global $wpdb;
return $wpdb->postmeta;
}
public function get_entity_id_column(): string {
return 'post_id';
}
public function get_primary_table(): string {
global $wpdb;
return $wpdb->posts;
}
public function get_primary_id_column(): string {
return 'ID';
}
public function get_cache_group(): string {
return 'wpdo_post_profile';
}
public function get_delete_hook(): string {
return 'before_delete_post';
}
/**
* 擴充 WP_Query 支援 wpdo_meta_query / wpdo_orderby
*/
public function extend_native_query( $query_object ): void {
if ( ! ( $query_object instanceof WP_Query ) ) {
return;
}
$wpdo_query = $query_object->get( 'wpdo_meta_query' );
if ( empty( $wpdo_query ) || ! is_array( $wpdo_query ) ) {
return;
}
TMDO_Query_Compiler::inject_into_wp_query( $query_object, $wpdo_query );
}
/**
* Cursor-based 取得 post IDs
*/
public function get_entity_ids_after( int $after_id, int $limit ): array {
global $wpdb;
return array_map(
'intval',
$wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM `{$wpdb->posts}` WHERE ID > %d ORDER BY ID ASC LIMIT %d",
$after_id,
$limit
)
) ?: array()
);
}
}
// 註冊 pre_get_posts 以啟用 wpdo_meta_query(unit test 下 add_action 未載入時跳過)
if ( function_exists( 'add_action' ) ) {
add_action(
'pre_get_posts',
function ( $query ) {
$adapter = TMDO_Entity_Registry::get_adapter( 'post' );
if ( $adapter ) {
$adapter->extend_native_query( $query );
}
},
10,
1
);
}
@@ -0,0 +1,89 @@
<?php
/**
* TMDO_Adapter_Term - Term 實體適配器
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Adapter_Term implements TMDO_Entity_Adapter_Interface {
public function get_entity_type(): string {
return 'term';
}
public function get_native_meta_table(): string {
global $wpdb;
return $wpdb->termmeta;
}
public function get_entity_id_column(): string {
return 'term_id';
}
public function get_primary_table(): string {
global $wpdb;
return $wpdb->terms;
}
public function get_primary_id_column(): string {
return 'term_id';
}
public function get_cache_group(): string {
return 'wpdo_term_profile';
}
public function get_delete_hook(): string {
return 'delete_term';
}
public function extend_native_query( $query_object ): void {
// Term 查詢使用 terms_clauses filter,不使用 $query_object
}
/**
* Term 特有:透過 terms_clauses filter 注入
*/
public function extend_term_clauses( array $clauses, array $taxonomies, array $args ): array {
if ( empty( $args['wpdo_meta_query'] ) || ! is_array( $args['wpdo_meta_query'] ) ) {
return $clauses;
}
return TMDO_Query_Compiler::inject_into_terms_clauses( $clauses, $args['wpdo_meta_query'] );
}
public function get_entity_ids_after( int $after_id, int $limit ): array {
global $wpdb;
return array_map(
'intval',
$wpdb->get_col(
$wpdb->prepare(
"SELECT term_id FROM `{$wpdb->terms}` WHERE term_id > %d ORDER BY term_id ASC LIMIT %d",
$after_id,
$limit
)
) ?: array()
);
}
}
// 註冊 terms_clauses filter(unit test 下 add_filter 未載入時跳過)
if ( function_exists( 'add_filter' ) ) {
add_filter(
'terms_clauses',
function ( $clauses, $taxonomies, $args ) {
$adapter = TMDO_Entity_Registry::get_adapter( 'term' );
if ( $adapter instanceof TMDO_Adapter_Term ) {
return $adapter->extend_term_clauses( $clauses, $taxonomies, $args );
}
return $clauses;
},
10,
3
);
}
@@ -0,0 +1,87 @@
<?php
/**
* TMDO_Adapter_User - User 實體適配器
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Adapter_User implements TMDO_Entity_Adapter_Interface {
public function get_entity_type(): string {
return 'user';
}
public function get_native_meta_table(): string {
global $wpdb;
return $wpdb->usermeta;
}
public function get_entity_id_column(): string {
return 'user_id';
}
public function get_primary_table(): string {
global $wpdb;
return $wpdb->users;
}
public function get_primary_id_column(): string {
return 'ID';
}
public function get_cache_group(): string {
return 'wpdo_user_profile';
}
public function get_delete_hook(): string {
return 'delete_user';
}
public function extend_native_query( $query_object ): void {
if ( ! ( $query_object instanceof WP_User_Query ) ) {
return;
}
$wpdo_query = $query_object->get( 'wpdo_meta_query' );
if ( empty( $wpdo_query ) || ! is_array( $wpdo_query ) ) {
return;
}
TMDO_Query_Compiler::inject_into_user_query( $query_object, $wpdo_query );
}
public function get_entity_ids_after( int $after_id, int $limit ): array {
global $wpdb;
return array_map(
'intval',
$wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM `{$wpdb->users}` WHERE ID > %d ORDER BY ID ASC LIMIT %d",
$after_id,
$limit
)
) ?: array()
);
}
}
// 註冊 pre_user_query(unit test 下 add_action 未載入時跳過)
if ( function_exists( 'add_action' ) ) {
add_action(
'pre_user_query',
function ( $query ) {
$adapter = TMDO_Entity_Registry::get_adapter( 'user' );
if ( $adapter ) {
$adapter->extend_native_query( $query );
}
},
10,
1
);
}
@@ -0,0 +1,83 @@
<?php
/**
* Entity Adapter 合約介面
*
* 所有實體適配器(Post/User/Term/Comment)必須實作此介面
* 將不同實體的行為標準化,供 Hook Bus 統一呼叫
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
// Back-compat: define WPDO_ marker first so TMDO_ can extend it.
// PHP class_alias() doesn't work on interfaces; extending is the only option.
// Any class implementing TMDO_Entity_Adapter_Interface automatically satisfies
// `instanceof WPDO_Entity_Adapter_Interface` via interface inheritance.
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface', false ) ) {
interface WPDO_Entity_Adapter_Interface {}
}
interface TMDO_Entity_Adapter_Interface extends WPDO_Entity_Adapter_Interface {
/**
* 實體類型識別子
* 必須回傳 WordPress metadata type 之一:post|user|term|comment
*/
public function get_entity_type(): string;
/**
* 原生 meta 表完整名稱(含 wp_ 前綴)
* 例:wp_postmeta / wp_usermeta
*/
public function get_native_meta_table(): string;
/**
* 原生 meta 表中指向實體的欄位名稱
* 例:post_id / user_id / term_id / comment_id
*/
public function get_entity_id_column(): string;
/**
* 對應的主實體表(用於 JOIN)
* 例:wp_posts / wp_users / wp_terms / wp_comments
*/
public function get_primary_table(): string;
/**
* 主實體表的主鍵欄位
* 例:ID (post/user) / term_id / comment_ID
*/
public function get_primary_id_column(): string;
/**
* 快取群組名稱(供 wp_cache_* 使用)
*/
public function get_cache_group(): string;
/**
* 實體刪除時會觸發的 WordPress hook
* 用於自動清理 UAE 資料表中對應列
*/
public function get_delete_hook(): string;
/**
* 擴充原生查詢物件以支援 UAE meta_query
*
* @param mixed $query_object WP_Query / WP_User_Query / WP_Comment_Query 等
*/
public function extend_native_query( $query_object ): void;
/**
* 取得所有已知的 ID(用於遷移時的迭代)
* 採用 cursor-based 分頁
*
* @param int $after_id 取大於此 ID 的筆
* @param int $limit 取多少筆
* @return array<int>
*/
public function get_entity_ids_after( int $after_id, int $limit ): array;
}
+266
View File
@@ -0,0 +1,266 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for FSM state advisor
/**
* TMDO_FSM_Advisor — Recommends the next FSM state per module (v2.4.0 M12).
*
* Reads
* - Current state via TMDO_Feature_Flags::get( $module )
* - Time-in-current-state via wp_options.wpdo_fsm_state_entered (set by
* TMDO_FSM_Guard::record_entry() since v2.2.0 M2)
* - shadow_diffs ratio per module from wp_wpdo_shadow_diffs (last 24h)
*
* Emits one of
* - PROMOTE — current state has cooked long enough + low divergence; safe to advance
* - WAIT — needs more soak time, returns days remaining
* - REVIEW — divergence too high; manual investigation needed before promoting
* - ROLLBACK — error budget exceeded; suggest rewind to idle
* - HOLD — module already idle or complete; nothing to do
*
* Designed to be advice — never auto-acts. Surfaces in Migration tab as a
* panel beside each module row.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* FSM advisor — stateless static API with structured `advise()` return.
*/
class TMDO_FSM_Advisor {
/** Minimum soak days per state before advisor allows promotion. */
public const MIN_SOAK_DAYS = array(
'idle' => 0,
'dual_write' => 1,
'backfill' => 1,
'verify' => 7,
'cutover' => 1,
'cleanup' => 3,
'complete' => 0, // terminal — never promote.
);
/** Max divergence ratio in `verify` state before advisor refuses to promote. */
public const VERIFY_MAX_DIVERGENCE_RATIO = 0.001; // 0.1%
/**
* Build advice for a single module.
*
* @param string $module Module slug.
* @return array {action:string, next_state?:string, days_remaining?:int,
* reason:string, level:'info'|'warn'|'critical', metrics:array}
*/
public static function advise( string $module ): array {
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
return self::stub( 'unavailable', 'Feature_Flags 未載入' );
}
$state = TMDO_Feature_Flags::get( $module );
// Terminal cases.
if ( 'idle' === $state ) {
return array(
'action' => 'HOLD',
'level' => 'info',
'reason' => __( 'Module 處於 idle,無需建議。如要啟用請先讀 SOP。', '2meet-data-optimizer' ),
'metrics' => array( 'state' => $state ),
);
}
if ( 'complete' === $state ) {
return array(
'action' => 'HOLD',
'level' => 'info',
'reason' => __( 'Module 已 complete,反 EAV 完成。', '2meet-data-optimizer' ),
'metrics' => array( 'state' => $state ),
);
}
$days_in_state = self::days_in_state( $module );
$min_soak = self::MIN_SOAK_DAYS[ $state ] ?? 1;
$metrics = array(
'state' => $state,
'days_in_state' => $days_in_state,
'min_soak_days' => $min_soak,
);
// Verify state requires divergence ratio check.
if ( 'verify' === $state ) {
$div = self::shadow_diff_ratio( $module );
$metrics['shadow_diff_ratio_24h'] = $div['ratio'];
$metrics['shadow_diff_count_24h'] = $div['count'];
$metrics['shadow_diff_total_24h'] = $div['total'];
if ( $div['count'] > 0 && $div['ratio'] > self::VERIFY_MAX_DIVERGENCE_RATIO ) {
return array(
'action' => 'REVIEW',
'next_state' => null,
'level' => 'warn',
'reason' => sprintf(
/* translators: 1: ratio, 2: max allowed */
__( 'Verify 期間 shadow_diffs 比率 %1$.2f%% 超過上限 %2$.2f%%;建議手動 review wp_wpdo_shadow_diffs 確認分歧來源後再決定。', '2meet-data-optimizer' ),
$div['ratio'] * 100,
self::VERIFY_MAX_DIVERGENCE_RATIO * 100
),
'metrics' => $metrics,
);
}
}
// Soak time check.
if ( null !== $days_in_state && $days_in_state < $min_soak ) {
$days_remaining = $min_soak - $days_in_state;
return array(
'action' => 'WAIT',
'days_remaining' => $days_remaining,
'level' => 'info',
'reason' => sprintf(
/* translators: 1: days left, 2: state name, 3: min days */
__( '再等 %1$d 天即可推進。當前 %2$s 狀態需 ≥ %3$d 天 soak。', '2meet-data-optimizer' ),
$days_remaining,
$state,
$min_soak
),
'metrics' => $metrics,
);
}
// Promotion candidate.
$next = self::next_state( $state );
if ( null === $next ) {
return array(
'action' => 'HOLD',
'level' => 'info',
'reason' => __( '當前狀態為 terminal — 無下一步建議。', '2meet-data-optimizer' ),
'metrics' => $metrics,
);
}
return array(
'action' => 'PROMOTE',
'next_state' => $next,
'level' => 'info',
'reason' => sprintf(
/* translators: 1: from, 2: to, 3: days */
__( '可推進:%1$s → %2$s(已 soak %3$d 天)。', '2meet-data-optimizer' ),
$state,
$next,
$days_in_state
),
'metrics' => $metrics,
);
}
/**
* Build advice for every known module. Returns map module → advice.
*
* @return array<string,array>
*/
public static function advise_all(): array {
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
return array();
}
$out = array();
foreach ( TMDO_Feature_Flags::all() as $module => $_state ) {
$out[ $module ] = self::advise( $module );
}
return $out;
}
// ─── private ──────────────────────────────────────────────────────
/**
* Return days since the module entered its current state.
*
* @param string $module Module slug.
* @return int|null Days since entering current state, or null if unknown.
*/
private static function days_in_state( string $module ): ?int {
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
if ( ! isset( $entered[ $module ]['entered_at'] ) ) {
return null;
}
$ts = strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' );
if ( false === $ts || $ts <= 0 ) {
return null;
}
return (int) floor( ( time() - $ts ) / DAY_IN_SECONDS );
}
/**
* Return the shadow-diff divergence ratio for a module over the last 24 hours.
*
* @param string $module Module slug.
* @return array {ratio:float, count:int, total:int}
*/
private static function shadow_diff_ratio( string $module ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- $module reserved for per-module entity_type routing (future)
global $wpdb;
$out = array(
'ratio' => 0.0,
'count' => 0,
'total' => 0,
);
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$table
)
);
if ( 0 === $exists ) {
return $out;
}
// Module → entity_type mapping is loose — most HPCT modules map to 'post'.
// Use entity_type='post' as default proxy; downstream `level=warn` is
// honest about uncertainty.
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = %s AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is $wpdb->prefix . 'wpdo_shadow_diffs' (no user input)
'post'
)
);
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no variables; table name is a WP core property
"SELECT COUNT(*) FROM `{$wpdb->postmeta}` WHERE meta_id > 0"
);
$out['count'] = $count;
$out['total'] = max( 1, $total );
$out['ratio'] = $count / $out['total'];
return $out;
}
/**
* Forward graph (mirrors TMDO_FSM_Guard::FORWARD_GRAPH but without the
* cycle check — advisor only suggests the canonical next step).
*
* @param string $state Current state.
* @return string|null Next state, or null when terminal.
*/
private static function next_state( string $state ): ?string {
$map = array(
'idle' => 'dual_write',
'dual_write' => 'backfill',
'backfill' => 'verify',
'verify' => 'cutover',
'cutover' => 'cleanup',
'cleanup' => 'complete',
'complete' => null,
);
return $map[ $state ] ?? null;
}
/**
* Stub advice when prerequisites are missing.
*
* @param string $action Reason code.
* @param string $reason Human reason.
* @return array
*/
private static function stub( string $action, string $reason ): array {
return array(
'action' => strtoupper( $action ),
'level' => 'info',
'reason' => $reason,
'metrics' => array(),
);
}
}
@@ -0,0 +1,234 @@
<?php
/**
* TMDO_FSM_Automator — Opt-in automatic execution of FSM_Advisor PROMOTE
* recommendations (v2.5.0 M13).
*
* Default OFF. When admin enables, runs daily at 04:30 UTC. For each module
* - Calls TMDO_FSM_Advisor::advise()
* - Only auto-promotes when ALL 4 conditions true:
* 1. wpdo_automator_enabled = 1
* 2. module not in wpdo_automator_blacklist
* 3. TMDO_Health_Cron::get_last_run() critical_count = 0 in last 7 days
* (cool-off — system is healthy)
* 4. ≥ 24h since this module's last automated promotion
* - Destructive transitions (cutover→cleanup, cleanup→complete) are
* NEVER automated — admin must manually invoke.
*
* Audit trail: every automator action writes wp_wpdo_audit op='automator_promoted'
* + fires action `wpdo/automator_promoted` for downstream subscribers.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Automator — stateless static API.
*/
class TMDO_FSM_Automator {
public const OPT_ENABLED = 'wpdo_automator_enabled';
public const OPT_BLACKLIST = 'wpdo_automator_blacklist';
public const OPT_LAST_ACTION = 'wpdo_automator_last_action';
/** Cool-off window after critical health alert. */
private const COOL_OFF_DAYS = 7;
/** Minimum interval between automated promotions of the same module. */
private const MIN_INTERVAL_HOURS = 24;
/** Transitions that are never auto-executed (destructive). */
private const FORBIDDEN_TRANSITIONS = array(
array( 'verify', 'cutover' ), // cutover starts reading from custom — needs manual sign-off.
array( 'cutover', 'cleanup' ), // cleanup purges wp_*meta — irreversible without snapshot.
array( 'cleanup', 'complete' ), // complete = no fallback path.
);
/**
* Cron handler.
*
* @return array {ok:bool, executed:int, skipped:int, errors:array}
*/
public static function run(): array {
$result = array(
'ok' => true,
'executed' => 0,
'skipped' => 0,
'errors' => array(),
'actions' => array(),
);
// Pre-flight: enabled?
if ( ! self::is_enabled() ) {
$result['ok'] = false;
$result['errors'][] = 'automator disabled';
return $result;
}
// Pre-flight: cool-off?
if ( ! self::cool_off_clear() ) {
$result['ok'] = false;
$result['errors'][] = 'cool-off active (critical health in last ' . self::COOL_OFF_DAYS . ' days)';
return $result;
}
// Iterate modules.
if ( ! class_exists( 'TMDO_Feature_Flags' ) || ! class_exists( 'TMDO_FSM_Advisor' ) ) {
$result['ok'] = false;
$result['errors'][] = 'dependencies missing';
return $result;
}
$blacklist = (array) get_option( self::OPT_BLACKLIST, array() );
$last_actions = (array) get_option( self::OPT_LAST_ACTION, array() );
foreach ( TMDO_Feature_Flags::all() as $module => $current_state ) {
// Blacklisted?
if ( in_array( $module, $blacklist, true ) ) {
++$result['skipped'];
continue;
}
// Get advisor recommendation.
$advice = TMDO_FSM_Advisor::advise( $module );
if ( 'PROMOTE' !== ( $advice['action'] ?? '' ) || 'info' !== ( $advice['level'] ?? '' ) ) {
++$result['skipped'];
continue;
}
$next_state = (string) ( $advice['next_state'] ?? '' );
if ( '' === $next_state ) {
++$result['skipped'];
continue;
}
// Forbidden destructive transition?
if ( self::is_forbidden_transition( $current_state, $next_state ) ) {
++$result['skipped'];
continue;
}
// Recent action?
if ( ! self::interval_clear( $module, $last_actions ) ) {
++$result['skipped'];
continue;
}
// All checks passed — execute.
$set_result = TMDO_Feature_Flags::set( $module, $next_state );
if ( true === $set_result ) {
++$result['executed'];
$result['actions'][] = array(
'module' => $module,
'from' => $current_state,
'to' => $next_state,
);
$last_actions[ $module ] = gmdate( 'Y-m-d H:i:s' );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'automator_promoted',
array(
'module' => $module,
'from' => $current_state,
'to' => $next_state,
)
);
}
do_action( 'wpdo/automator_promoted', $module, $current_state, $next_state );
} else {
$result['errors'][] = "{$module}: " . ( $set_result instanceof \WP_Error ? $set_result->get_error_code() : 'unknown' );
}
}
update_option( self::OPT_LAST_ACTION, $last_actions, false );
return $result;
}
// ─── settings accessors ─────────────────────────────────────────────
/**
* Return whether the automator is enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return '1' === (string) get_option( self::OPT_ENABLED, '0' );
}
/**
* Return the list of blacklisted module slugs.
*
* @return array
*/
public static function blacklist(): array {
return (array) get_option( self::OPT_BLACKLIST, array() );
}
/**
* Return the map of module → last automated action timestamp.
*
* @return array
*/
public static function last_actions(): array {
return (array) get_option( self::OPT_LAST_ACTION, array() );
}
// ─── private helpers ───────────────────────────────────────────────
/**
* Cool-off: false when there's been a critical health alert in last N days.
*
* @return bool true = OK to act, false = blocked.
*/
private static function cool_off_clear(): bool {
if ( ! class_exists( 'TMDO_Health_Cron' ) ) {
return true; // No data — assume OK.
}
$last = TMDO_Health_Cron::get_last_run();
if ( ! is_array( $last ) ) {
return true;
}
$crit = (int) ( $last['critical_count'] ?? 0 );
if ( 0 === $crit ) {
return true;
}
$ts = isset( $last['ran_at'] ) ? strtotime( (string) $last['ran_at'] . ' UTC' ) : 0;
if ( $ts <= 0 ) {
return true;
}
// Critical alert exists; cool-off if within window.
return ( time() - $ts ) > ( self::COOL_OFF_DAYS * DAY_IN_SECONDS );
}
/**
* Per-module interval: ≥ 24h since last automated action on this module.
*
* @param string $module Module slug.
* @param array $last_actions Map of module → timestamp.
* @return bool true = OK to act.
*/
private static function interval_clear( string $module, array $last_actions ): bool {
$last = (string) ( $last_actions[ $module ] ?? '' );
if ( '' === $last ) {
return true;
}
$ts = strtotime( $last . ' UTC' );
if ( $ts <= 0 ) {
return true;
}
return ( time() - $ts ) >= ( self::MIN_INTERVAL_HOURS * HOUR_IN_SECONDS );
}
/**
* Check if (from, to) is in FORBIDDEN_TRANSITIONS.
*
* @param string $from Current state.
* @param string $to Target state.
* @return bool
*/
private static function is_forbidden_transition( string $from, string $to ): bool {
foreach ( self::FORBIDDEN_TRANSITIONS as $pair ) {
if ( $pair[0] === $from && $pair[1] === $to ) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,381 @@
<?php
/**
* TMDO_Module_Detector — Smart module availability detection (v2.5.0 M16).
*
* For each registered module (see TMDO_Module_Rules), evaluates
* 1. Are required plugins active (Compatibility::is_*_active)?
* 2. Does the required post_type exist + meet min_post_count?
* 3. (zone modules) Does Classifier confidence exceed min_classifier_confidence?
* 4. Is the module already in a non-idle state (no point recommending)?
*
* Output per module: {available, confidence, recommendation, reasons,
* blockers, current_state, suggested_action}.
*
* Caching: detect_all() is heavy (queries postmeta, runs classifier).
* Daily health-cron writes to wp_options.wpdo_module_suggestions; admin UI
* reads from there for fast widget rendering.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Detection engine — stateless static API.
*/
class TMDO_Module_Detector {
/** WP options key holding cached results. */
public const OPTION_CACHE = 'wpdo_module_suggestions';
/** Transient key for short-term cache (1 hour). */
private const TRANSIENT = 'wpdo_module_detector_results';
/** TTL for transient cache. */
private const TRANSIENT_TTL = 3600;
/**
* Detect every registered module. Cached for 1 hour via transient.
*
* @param bool $force_refresh Bypass transient cache.
* @return array<string,array> Module slug → result.
*/
public static function detect_all( bool $force_refresh = false ): array {
if ( ! $force_refresh ) {
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return $cached;
}
}
$out = array();
foreach ( TMDO_Module_Rules::known_modules() as $module ) {
$out[ $module ] = self::detect_one( $module );
}
set_transient( self::TRANSIENT, $out, self::TRANSIENT_TTL );
// Persist to wp_options for the dashboard widget (no autoload).
update_option(
self::OPTION_CACHE,
array(
'results' => $out,
'generated_at' => gmdate( 'Y-m-d H:i:s' ),
),
false
);
return $out;
}
/**
* Detect a single module.
*
* @param string $module Module slug.
* @return array See class doc for shape.
*/
public static function detect_one( string $module ): array {
$rule = TMDO_Module_Rules::for_module( $module );
if ( null === $rule ) {
return self::stub( $module, 'no_rule' );
}
$reasons = array();
$blockers = array();
$confidence = 0.0;
$current_state = class_exists( 'TMDO_Feature_Flags' ) ? TMDO_Feature_Flags::get( $module ) : 'idle';
// 0. Already non-idle? Block recommendation.
if ( 'idle' !== $current_state ) {
$blockers[] = sprintf( '⚠️ Module 已在 %s 狀態(不需重複推薦)', $current_state );
return self::build(
$module,
false,
0.0,
'skip',
$reasons,
$blockers,
$current_state
);
}
// 1. Plugin compatibility check.
$compat_required = (array) ( $rule['compat_required'] ?? array() );
if ( ! empty( $compat_required ) ) {
$missing = self::check_compat( $compat_required );
if ( ! empty( $missing ) ) {
$blockers[] = '⚠️ 需要 plugin 啟用:' . implode( ', ', $missing );
return self::build( $module, false, 0.0, 'skip', $reasons, $blockers, $current_state );
}
$reasons[] = '✅ 必要 plugin 已啟用:' . implode( ', ', $compat_required );
$confidence += 0.3;
}
// 2. Required post_type + row count.
$post_type = (string) ( $rule['post_type_required'] ?? '' );
$min_count = (int) ( $rule['min_post_count'] ?? 0 );
if ( '' !== $post_type ) {
$count = self::post_type_count( $post_type );
if ( $count < $min_count ) {
$blockers[] = sprintf(
'⚠️ %s post_type 只有 %s 行(需要 ≥ %s',
$post_type,
number_format_i18n( $count ),
number_format_i18n( $min_count )
);
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
}
$reasons[] = sprintf(
'✅ %s post_type 有 %s 行(門檻 %s',
$post_type,
number_format_i18n( $count ),
number_format_i18n( $min_count )
);
$confidence += 0.4;
}
// 3. Archive-specific: trashed count.
if ( 'archive' === $module ) {
$min_trash = (int) ( $rule['min_trash_count'] ?? 0 );
$trash_count = self::trashed_post_count();
if ( $trash_count < $min_trash ) {
$blockers[] = sprintf( '⚠️ trashed posts 只有 %d 個(需要 ≥ %d', $trash_count, $min_trash );
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
}
$reasons[] = sprintf( '✅ trashed posts 有 %d 個', $trash_count );
$confidence += 0.3;
}
// 4. Classifier consultation (zone modules).
if ( ! empty( $rule['consult_classifier'] ) && '' !== $post_type && class_exists( 'TMDO_Zone_Classifier' ) ) {
$min_conf = (float) ( $rule['min_classifier_confidence'] ?? 0.6 );
$cls_score = self::classifier_score( $post_type, $module );
if ( $cls_score < $min_conf ) {
$blockers[] = sprintf(
'⚠️ Classifier 對 %s 的 %s zone confidence 僅 %.2f(需 ≥ %.2f',
$post_type,
self::module_to_zone( $module ),
$cls_score,
$min_conf
);
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
}
$reasons[] = sprintf( '✅ Classifier confidence %.2f(門檻 %.2f', $cls_score, $min_conf );
$confidence = min( 1.0, $confidence + $cls_score * 0.3 );
}
// 5. Priority bonus: warm/archive recommended_first.
// v2.5.0 M16 polish: bump from +0.2 → +0.5 so a low-risk module like
// `warm` (no compat / no post_type gate) crosses the actionable
// threshold (0.5) on a fresh install — Setup Wizard / Dashboard widget
// can surface it without admin chasing config.
if ( 'recommended_first' === ( $rule['priority'] ?? '' ) ) {
$confidence = min( 1.0, $confidence + 0.5 );
$reasons[] = '⭐ 入門首選(低風險)';
}
// Cap confidence.
$confidence = min( 1.0, max( 0.0, $confidence ) );
return self::build( $module, true, $confidence, 'enable', $reasons, $blockers, $current_state );
}
/**
* Filter detect_all() down to actionable enable-recommendations
* above a confidence threshold.
*
* @param float $min_confidence Threshold (0..1, default 0.5).
* @return array<string,array>
*/
public static function get_actionable( float $min_confidence = 0.5 ): array {
$all = self::detect_all();
$out = array();
foreach ( $all as $module => $r ) {
if ( ! empty( $r['available'] )
&& 'enable' === ( $r['recommendation'] ?? '' )
&& (float) ( $r['confidence'] ?? 0 ) >= $min_confidence ) {
$out[ $module ] = $r;
}
}
// Sort by confidence desc.
uasort( $out, static fn( $a, $b ) => (float) $b['confidence'] <=> (float) $a['confidence'] );
return $out;
}
/**
* Get the cached results from wp_options (for dashboard widget — no live query).
*
* @return array {results:array, generated_at:string}|null
*/
public static function get_cached(): ?array {
$v = get_option( self::OPTION_CACHE );
return is_array( $v ) ? $v : null;
}
// ─── private helpers ─────────────────────────────────────────────────
/**
* Build a result envelope.
*
* @param string $module Module slug.
* @param bool $available Whether module passes all checks.
* @param float $confidence 0..1.
* @param string $recommendation 'enable'|'wait'|'skip'.
* @param array $reasons Positive findings.
* @param array $blockers Negative findings.
* @param string $current_state Current FSM state.
* @return array
*/
private static function build( string $module, bool $available, float $confidence, string $recommendation, array $reasons, array $blockers, string $current_state ): array {
$rule = TMDO_Module_Rules::for_module( $module ) ?? array();
return array(
'module' => $module,
'available' => $available,
'confidence' => round( $confidence, 2 ),
'recommendation' => $recommendation,
'reasons' => $reasons,
'blockers' => $blockers,
'description' => (string) ( $rule['description'] ?? '' ),
'current_state' => $current_state,
'suggested_action' => $available && 'enable' === $recommendation
? array(
'type' => 'set_state',
'module' => $module,
'to_state' => 'dual_write',
'cli' => "wp wpdo mode-set {$module} dual_write",
)
: null,
);
}
/**
* Build a stub result envelope for modules that cannot be evaluated.
*
* @param string $module Module slug.
* @param string $reason Reason code or human message.
* @return array
*/
private static function stub( string $module, string $reason ): array {
return array(
'module' => $module,
'available' => false,
'confidence' => 0.0,
'recommendation' => 'skip',
'reasons' => array(),
'blockers' => array( $reason ),
'description' => '',
'current_state' => 'idle',
'suggested_action' => null,
);
}
/**
* Find which required plugins are missing.
*
* @param array $required Plugins required.
* @return array<int,string> Missing plugin slugs.
*/
private static function check_compat( array $required ): array {
if ( ! class_exists( 'TMDO_Compatibility' ) ) {
return $required;
}
$missing = array();
foreach ( $required as $plugin ) {
$active = match ( $plugin ) {
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
'hpct' => TMDO_Compatibility::is_hpct_active(),
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
default => false,
};
if ( ! $active ) {
$missing[] = $plugin;
}
}
return $missing;
}
/**
* Return the published post count for a given post type.
*
* @param string $post_type Post type slug.
* @return int Row count.
*/
private static function post_type_count( string $post_type ): int {
global $wpdb;
$cached_key = 'wpdo_pt_count_' . md5( $post_type );
$cached = get_transient( $cached_key );
if ( false !== $cached ) {
return (int) $cached;
}
$count = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_type = %s AND post_status NOT IN ('trash','auto-draft')",
$post_type
)
);
set_transient( $cached_key, $count, HOUR_IN_SECONDS );
return $count;
}
/**
* Total trashed posts (all post types).
*
* @return int
*/
private static function trashed_post_count(): int {
global $wpdb;
$cached = get_transient( 'wpdo_trash_count' );
if ( false !== $cached ) {
return (int) $cached;
}
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_status = 'trash'" ); // phpcs:ignore WordPress.DB
set_transient( 'wpdo_trash_count', $count, HOUR_IN_SECONDS );
return $count;
}
/**
* Map module → target zone for Classifier consultation.
*
* @param string $module Module slug (e.g. hot_hp_listing).
* @return string Zone (hot|cold|warm|archive).
*/
private static function module_to_zone( string $module ): string {
if ( str_starts_with( $module, 'hot_' ) ) {
return 'hot';
}
if ( str_starts_with( $module, 'cold_' ) ) {
return 'cold';
}
return $module; // 'warm' / 'archive'.
}
/**
* Classifier confidence aggregate for a (post_type, target_zone).
*
* @param string $post_type Post type.
* @param string $module Module slug → mapped to zone.
* @return float 0..1 average confidence of meta_keys whose suggested_zone matches.
*/
private static function classifier_score( string $post_type, string $module ): float {
if ( ! class_exists( 'TMDO_Zone_Classifier' ) ) {
return 0.0;
}
$zone = self::module_to_zone( $module );
try {
$results = TMDO_Zone_Classifier::analyze( $post_type, 50 );
} catch ( Throwable $e ) {
return 0.0;
}
if ( ! is_array( $results ) || empty( $results ) ) {
return 0.0;
}
$total = 0.0;
$count = 0;
foreach ( $results as $field ) {
if ( ( $field['suggested_zone'] ?? '' ) === $zone ) {
$total += (float) ( $field['confidence'] ?? 0 );
++$count;
}
}
return $count > 0 ? $total / $count : 0.0;
}
}
@@ -0,0 +1,171 @@
<?php
/**
* TMDO_Module_Rules — Declarative trigger conditions per module (v2.5.0 M16).
*
* Separates "what makes this module worth enabling" from the detection
* machinery (Module_Detector consumes these rules). Each rule declares
* - compat_requiredlist of plugins that must be active (any of)
* - post_type_requiredpost_type must exist + have rows
* - min_post_countthreshold for post_type row count
* - min_trash_count(archive only) trashed post count threshold
* - consult_classifier + min_classifier_confidence(zone modules only)
* - descriptionhuman reason shown in UI
*
* Extensible via filter `wpdo/module_rules` — third-party plugins can register
* their own modules and rules without forking this file.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Static rules registry.
*/
class TMDO_Module_Rules {
/**
* Default rules for the 15 known modules. Use `get()` to apply filter.
*/
private const DEFAULT_RULES = array(
// ─── HPCT modulesHivePress + HPCT)────────────────────────────
'reviews' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_review',
'min_post_count' => 100,
'description' => '若使用 HivePress reviews 且累積評論 ≥ 100 條,啟用後可顯著降低 listing 查詢的 JOIN 成本。',
),
'messages' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_message_thread',
'min_post_count' => 50,
'description' => 'HivePress messages 累積對話數 ≥ 50 時,啟用 module 可加速 inbox / unread count 查詢。',
),
'favorites' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_favorite',
'min_post_count' => 200,
'description' => '使用者 favorite 累積 ≥ 200 條時,啟用 module 把 favorite meta 從 wp_postmeta 搬出。',
),
'memberships' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_membership',
'min_post_count' => 50,
'description' => 'HivePress memberships 啟用且 ≥ 50 條訂閱時,membership 過期檢查會更快。',
),
'statistics' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_listing',
'min_post_count' => 500,
'description' => 'Listing ≥ 500 條時,view_count / favorite_count 等高頻統計搬到 stats module 可大幅減少 wp_postmeta 寫入。',
),
'requests' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_request',
'min_post_count' => 50,
'description' => 'HivePress requests / quotes 累積 ≥ 50 條時建議啟用。',
),
'listing_meta' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_listing',
'min_post_count' => 100,
'description' => '所有 listing meta 集中管理;listing ≥ 100 時可省下大量 postmeta JOIN。',
),
'wc_orders' => array(
'compat_required' => array( 'woocommerce' ),
'post_type_required' => 'shop_order',
'min_post_count' => 100,
'description' => 'WooCommerce 訂單 ≥ 100 筆且未啟用 HPOS 時,建議啟用 module 把 vendor commission 搬到 wp_wpdo_wc_commissions。',
),
'latepoint' => array(
'compat_required' => array( 'latepoint' ),
'post_type_required' => null,
'description' => 'LatePoint 預約系統啟用時建議開啟,把 booking meta 從 wp_postmeta 搬出。',
),
// ─── Zone modules(任何站皆可,但仍依環境推薦)────────────────────
'warm' => array(
'compat_required' => array(),
'post_type_required' => null,
'min_post_count' => 0,
'priority' => 'recommended_first',
'description' => 'Warm zone 處理 view counts / TTL 暫存;任何站都可啟用,幾乎零風險。',
),
'archive' => array(
'compat_required' => array(),
'post_type_required' => null,
'min_trash_count' => 50,
'description' => '已 trashed posts ≥ 50 個時,啟用 archive module 可釋放 wp_postmeta 空間(自動 gzip 壓縮)。',
),
'hot_hp_listing' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_listing',
'min_post_count' => 100,
'consult_classifier' => true,
'min_classifier_confidence' => 0.6,
'description' => '使用 HivePress + listing ≥ 100 條 + Classifier confidence ≥ 0.6 時,把高頻欄位搬到 wp_wpdo_hot_hp_listing 可大幅加速 WP_Query。',
),
'cold_hp_listing' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_listing',
'min_post_count' => 100,
'consult_classifier' => true,
'min_classifier_confidence' => 0.5,
'description' => '低頻 listing meta(如 settings / preferences)搬到 cold zone,減少 hot path 的 postmeta JOIN。',
),
'hot_hp_vendor' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_vendor',
'min_post_count' => 50,
'consult_classifier' => true,
'min_classifier_confidence' => 0.6,
'description' => 'HivePress 商家 ≥ 50 個時,把高頻 vendor meta 搬到 hot zone 可加速 vendor 列表頁。',
),
'cold_hp_vendor' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_vendor',
'min_post_count' => 50,
'consult_classifier' => true,
'min_classifier_confidence' => 0.5,
'description' => '低頻 vendor meta 搬到 cold zone。',
),
);
/**
* Return all rules with filter applied.
*
* @return array<string,array>
*/
public static function all(): array {
$rules = self::DEFAULT_RULES;
if ( function_exists( 'apply_filters' ) ) {
$filtered = apply_filters( 'wpdo/module_rules', $rules );
if ( is_array( $filtered ) ) {
return $filtered;
}
}
return $rules;
}
/**
* Return rule for one module.
*
* @param string $module Module slug.
* @return array|null Rule or null when no rule registered.
*/
public static function for_module( string $module ): ?array {
$rules = self::all();
return $rules[ $module ] ?? null;
}
/**
* List all module slugs that have rules registered.
*
* @return array<int,string>
*/
public static function known_modules(): array {
return array_keys( self::all() );
}
}
@@ -0,0 +1,30 @@
<?php
/**
* Backward-compat interface alias.
*
* PHP class_alias() does NOT support interfaces. This file declares
* `WPDO_Entity_Adapter_Interface` as a child of `TMDO_Entity_Adapter_Interface`
* (no new methods), which allows sister plugins that type-hint against the old
* name to keep working: the four TMDO adapters implement this interface, so
* `instanceof WPDO_Entity_Adapter_Interface` returns true for all of them.
*
* Loaded by `class-tmdo-back-compat.php` after the real interface is loaded.
*
* @package TMDO
* @since 0.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface', false ) && interface_exists( 'TMDO_Entity_Adapter_Interface', false ) ) {
/**
* Backward-compat alias interface. Any class implementing
* `WPDO_Entity_Adapter_Interface` automatically satisfies
* `TMDO_Entity_Adapter_Interface` and vice-versa.
*
* Will emit `_doing_it_wrong()` notice in v0.2.0+.
*/
interface WPDO_Entity_Adapter_Interface extends TMDO_Entity_Adapter_Interface {}
}
@@ -0,0 +1,40 @@
<?php
/**
* Backward-compat trait alias.
*
* PHP class_alias() does NOT support traits or interfaces. This file declares
* a thin wrapper trait `WPDO_Anti_EAV_Aware` that simply `use`s the new
* `TMDO_Anti_EAV_Aware` trait, preserving binary compatibility for sister
* plugins (2meet-brandcards, hub-core extensions, etc.) that still reference
* the old trait name.
*
* Loaded by `class-tmdo-back-compat.php` after the new trait is loaded.
*
* @package TMDO
* @since 0.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! trait_exists( 'WPDO_Anti_EAV_Aware', false ) && trait_exists( 'TMDO_Anti_EAV_Aware', false ) ) {
/**
* Backward-compat alias trait. Sister plugins using `use WPDO_Anti_EAV_Aware;`
* inherit all methods from `TMDO_Anti_EAV_Aware` transparently.
*
* Will emit `_doing_it_wrong()` notice in v0.2.0+.
*/
trait WPDO_Anti_EAV_Aware {
use TMDO_Anti_EAV_Aware;
}
}
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface', false ) && interface_exists( 'TMDO_Entity_Adapter_Interface', false ) ) {
/**
* Backward-compat alias interface. PHP allows interface inheritance, so
* extending the new interface gives implementing classes the same
* obligations under the old name.
*/
interface WPDO_Entity_Adapter_Interface extends TMDO_Entity_Adapter_Interface {}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
/**
* TMDO_API — public facade for partner plugins.
*
* Provides the canonical, stable API contract for reading/writing entity
* fields managed by WP Data Optimizer. Partner plugins MUST use this API
* instead of direct `get_post_meta()`, `get_user_meta()`, etc., when the
* field is registered to WPDO.
*
* Part A.4 of the Anti-EAV Playbook (plan file).
*
* Usage:
* TMDO_API::get_field( $post_id, 'hp_price' ); // post entity
* TMDO_API::get_entity( 'user', $uid, 'points' ); // any entity
* TMDO_API::set_field( $post_id, 'hp_price', '100.00' );
* TMDO_API::query( [ 'post_type' => 'hp_listing', 'meta_query' => [...] ] );
*
* @package WP_Data_Optimizer
* @since 2.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Public read/write facade. All methods are static and side-effect free
* outside of the underlying meta API.
*
* Backwards compatibility: this class is the long-lived contract for
* partner plugins. Internal implementation may change between versions
* (e.g. switch from postmeta passthrough to Hook Bus dispatch); the
* signatures here are guaranteed stable across the 2.x line.
*/
final class TMDO_API {
/**
* Get a single meta field for a post.
*
* Internally:
* - When the field is registered to a Zone, reads from the zone table
* once cutover.
* - When the field is unregistered, falls back to native get_post_meta()
* transparently.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param bool $single Whether to return a single scalar.
* @return mixed Meta value (or empty string when single=true and absent).
*/
public static function get_field( int $post_id, string $meta_key, bool $single = true ) {
// Defer to standard WordPress metadata API. The Hook Bus / Sync_Bridge
// transparently routes the read to the appropriate zone table when
// the module is in a read-custom state (cutover/cleanup/complete).
// This single line of indirection is the contract that lets us swap
// internal storage without breaking callers.
return get_post_meta( $post_id, $meta_key, $single );
}
/**
* Get a single meta field for any entity (post/user/term/comment).
*
* @param string $entity_type One of: post, user, term, comment.
* @param int $entity_id Entity ID.
* @param string $meta_key Meta key.
* @param bool $single Whether to return a single scalar.
* @return mixed
*/
public static function get_entity( string $entity_type, int $entity_id, string $meta_key, bool $single = true ) {
return match ( $entity_type ) {
'post' => get_post_meta( $entity_id, $meta_key, $single ),
'user' => get_user_meta( $entity_id, $meta_key, $single ),
'term' => get_term_meta( $entity_id, $meta_key, $single ),
'comment' => get_comment_meta( $entity_id, $meta_key, $single ),
default => null,
};
}
/**
* Set a single meta field for a post.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value New value.
* @return bool|int Result of update_post_meta.
*/
public static function set_field( int $post_id, string $meta_key, $meta_value ) {
return update_post_meta( $post_id, $meta_key, $meta_value );
}
/**
* Set a meta field for any entity.
*
* @param string $entity_type One of: post, user, term, comment.
* @param int $entity_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value New value.
* @return bool|int
*/
public static function set_entity( string $entity_type, int $entity_id, string $meta_key, $meta_value ) {
return match ( $entity_type ) {
'post' => update_post_meta( $entity_id, $meta_key, $meta_value ),
'user' => update_user_meta( $entity_id, $meta_key, $meta_value ),
'term' => update_term_meta( $entity_id, $meta_key, $meta_value ),
'comment' => update_comment_meta( $entity_id, $meta_key, $meta_value ),
default => false,
};
}
/**
* Run a meta_query — currently a thin wrapper around WP_Query.
*
* Future: when v2.0.0 entity adapters are fully wired, this method will
* compile cross-entity queries via TMDO_Query_Compiler.
*
* @param array $args WP_Query-compatible arguments.
* @return WP_Query
*/
public static function query( array $args ): WP_Query {
return new WP_Query( $args );
}
/**
* Check whether a field is registered to WPDO (zone or entity).
*
* Useful for partner plugins to choose between TMDO_API and native APIs.
*
* @param string $entity_type One of: post, user, term, comment.
* @param string $meta_key Meta key.
* @return bool True when the field is registered.
*/
public static function is_field_registered( string $entity_type, string $meta_key ): bool {
// Zone path (post entity only): TMDO_Schema_Registry.
if ( 'post' === $entity_type && class_exists( 'TMDO_Schema_Registry' ) ) {
$registry = TMDO_Schema_Registry::instance();
// We can't filter without a post_type — search across all hot/cold/warm.
foreach ( $registry->all() as $field ) {
if ( ( $field['meta_key'] ?? '' ) === $meta_key ) {
return true;
}
}
}
// Entity path: TMDO_Entity_Registry (PR-1 ported).
if ( class_exists( 'TMDO_Entity_Registry' ) ) {
return TMDO_Entity_Registry::is_managed( $entity_type, $meta_key );
}
return false;
}
/**
* Trace the storage backend currently serving a field.
*
* Returns one of: 'postmeta' (native fallback), 'zone_hot', 'zone_warm',
* 'zone_cold', 'zone_archive', 'entity_table', 'unregistered'.
*
* Useful for `wp wpdo doctor` and Conflict Detector reports.
*
* @param string $entity_type Entity type.
* @param string $meta_key Meta key.
* @param string $post_type Post type, for zone routing (post entity only).
* @return string Storage label.
*/
public static function trace_storage( string $entity_type, string $meta_key, string $post_type = '' ): string {
if ( 'post' === $entity_type && class_exists( 'TMDO_Schema_Registry' ) && '' !== $post_type ) {
$field = TMDO_Schema_Registry::instance()->get_field( $post_type, $meta_key );
if ( $field ) {
$module = ( $field['zone'] ?? 'hot' ) . '_' . sanitize_key( $post_type );
if ( class_exists( 'TMDO_Feature_Flags' ) && TMDO_Feature_Flags::is_read_custom( $module ) ) {
return 'zone_' . $field['zone'];
}
return 'postmeta';
}
}
if ( class_exists( 'TMDO_Entity_Registry' ) && TMDO_Entity_Registry::is_managed( $entity_type, $meta_key ) ) {
return 'entity_table';
}
return 'unregistered';
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* Back-compat aliases — 對外保留 WPDO_* 類別 / 常數,讓 hub-core / spoke-sso /
* 其他 sister plugins 在升級到 2meet-data-optimizer 後不需修改任何 class_exists()
* / new WPDO_X() / WPDO_API::xxx() 呼叫。
*
* Phase 1 起穩定。v0.2.0 後可加 _doing_it_wrong() deprecation notice。
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// ── 公開合約類別(Phase 1 凍結,不可移除)─────────────────────────────
$tmdo_class_aliases = array(
'TMDO_API' => 'WPDO_API',
'TMDO_Schema_Registry' => 'WPDO_Schema_Registry',
'TMDO_Entity_Registry' => 'WPDO_Entity_Registry',
'TMDO_Custom_Table_Registry' => 'WPDO_Custom_Table_Registry',
'TMDO_Feature_Flags' => 'WPDO_Feature_Flags',
'TMDO_DB' => 'WPDO_DB',
'TMDO_Crypto' => 'WPDO_Crypto',
'TMDO_Logger' => 'WPDO_Logger',
'TMDO_Capability' => 'WPDO_Capability',
'TMDO_Safe_Unserialize' => 'WPDO_Safe_Unserialize',
// Engine / Migration / Adapterssecond-tier,部分 sister plugin 已直接呼叫)
'TMDO_Hook_Bus' => 'WPDO_Hook_Bus',
'TMDO_Mode_Manager' => 'WPDO_Mode_Manager',
'TMDO_Migration_Engine' => 'WPDO_Migration_Engine',
'TMDO_Migration_Orchestrator' => 'WPDO_Migration_Orchestrator',
'TMDO_Entity_Migration_Engine' => 'WPDO_Entity_Migration_Engine',
'TMDO_Entity_Health' => 'WPDO_Entity_Health',
'TMDO_Schema_Manager' => 'WPDO_Schema_Manager',
'TMDO_Sync_Bridge' => 'WPDO_Sync_Bridge',
'TMDO_Query_Router' => 'WPDO_Query_Router',
'TMDO_Post_Query_Router' => 'WPDO_Post_Query_Router',
'TMDO_Hook_Bus_Bridge' => 'WPDO_Hook_Bus_Bridge',
'TMDO_Conflict_Monitor' => 'WPDO_Conflict_Monitor',
'TMDO_Compatibility' => 'WPDO_Compatibility',
'TMDO_Installer' => 'WPDO_Installer',
'TMDO_Cache_Layer' => 'WPDO_Cache_Layer',
'TMDO_Zone_Classifier' => 'WPDO_Zone_Classifier',
'TMDO_Core' => 'WPDO_Core',
'TMDO_REST_API' => 'WPDO_REST_API',
'TMDO_Snapshot_Manager' => 'WPDO_Snapshot_Manager',
'TMDO_Snapshot_Writer' => 'WPDO_Snapshot_Writer',
'TMDO_Snapshot_Reader' => 'WPDO_Snapshot_Reader',
'TMDO_Snapshot_Pruner' => 'WPDO_Snapshot_Pruner',
'TMDO_FSM_Guard' => 'WPDO_FSM_Guard',
'TMDO_Site_Health' => 'WPDO_Site_Health',
'TMDO_Health_Cron' => 'WPDO_Health_Cron',
'TMDO_Email_Notifier' => 'WPDO_Email_Notifier',
'TMDO_Slack_Notifier' => 'WPDO_Slack_Notifier',
'TMDO_Discord_Notifier' => 'WPDO_Discord_Notifier',
'TMDO_Telegram_Notifier' => 'WPDO_Telegram_Notifier',
'TMDO_Monthly_Summary' => 'WPDO_Monthly_Summary',
'TMDO_Site_Metrics_Collector' => 'WPDO_Site_Metrics_Collector',
'TMDO_FSM_Advisor' => 'WPDO_FSM_Advisor',
'TMDO_Module_Detector' => 'WPDO_Module_Detector',
'TMDO_FSM_Automator' => 'WPDO_FSM_Automator',
'TMDO_Module_Rules' => 'WPDO_Module_Rules',
'TMDO_CSV_Writer' => 'WPDO_CSV_Writer',
'TMDO_Export' => 'WPDO_Export',
'TMDO_CLI' => 'WPDO_CLI',
'TMDO_CLI_V2' => 'WPDO_CLI_V2',
'TMDO_CLI_Member' => 'WPDO_CLI_Member',
'TMDO_CLI_Post' => 'WPDO_CLI_Post',
'TMDO_CLI_Term_Comment' => 'WPDO_CLI_Term_Comment',
'TMDO_Member_Fields' => 'WPDO_Member_Fields',
'TMDO_Post_Fields' => 'WPDO_Post_Fields',
'TMDO_Points_Manager' => 'WPDO_Points_Manager',
'TMDO_Demo_Entity_Counter' => 'WPDO_Demo_Entity_Counter',
'TMDO_Postmeta_Cleaner' => 'WPDO_Postmeta_Cleaner',
'TMDO_Termmeta_Cleaner' => 'WPDO_Termmeta_Cleaner',
'TMDO_Commentmeta_Cleaner' => 'WPDO_Commentmeta_Cleaner',
'TMDO_Post_Stress_Tester' => 'WPDO_Post_Stress_Tester',
'TMDO_User_Stress_Tester' => 'WPDO_User_Stress_Tester',
'TMDO_Term_Stress_Tester' => 'WPDO_Term_Stress_Tester',
'TMDO_Comment_Stress_Tester' => 'WPDO_Comment_Stress_Tester',
'TMDO_Post_Shadow_Verifier' => 'WPDO_Post_Shadow_Verifier',
'TMDO_Term_Comment_Shadow_Verifier' => 'WPDO_Term_Comment_Shadow_Verifier',
'TMDO_Term_Comment_Backfill' => 'WPDO_Term_Comment_Backfill',
'TMDO_Term_Comment_Misc_Bucket' => 'WPDO_Term_Comment_Misc_Bucket',
'TMDO_Term_Comment_Garbage_Filter' => 'WPDO_Term_Comment_Garbage_Filter',
'TMDO_Setup_Wizard' => 'WPDO_Setup_Wizard',
'TMDO_Dashboard_Widget' => 'WPDO_Dashboard_Widget',
'TMDO_Help_Tabs' => 'WPDO_Help_Tabs',
'TMDO_Admin' => 'WPDO_Admin',
'TMDO_V2_Upgrader' => 'WPDO_V2_Upgrader',
'TMDO_SQLite_Compat' => 'WPDO_SQLite_Compat',
'TMDO_Type_Caster' => 'WPDO_Type_Caster',
'TMDO_Audit_Logger' => 'WPDO_Audit_Logger',
'TMDO_Shadow_Diff_Logger' => 'WPDO_Shadow_Diff_Logger',
'TMDO_Conflict_Detector' => 'WPDO_Conflict_Detector',
'TMDO_Cache_Orchestrator' => 'WPDO_Cache_Orchestrator',
'TMDO_Query_Compiler' => 'WPDO_Query_Compiler',
'TMDO_Auto_Promoter' => 'WPDO_Auto_Promoter',
'TMDO_Adapter_Post' => 'WPDO_Adapter_Post',
'TMDO_Adapter_User' => 'WPDO_Adapter_User',
'TMDO_Adapter_Term' => 'WPDO_Adapter_Term',
'TMDO_Adapter_Comment' => 'WPDO_Adapter_Comment',
'TMDO_Options_Manager' => 'WPDO_Options_Manager',
'TMDO_Hot_Migration' => 'WPDO_Hot_Migration',
'TMDO_Warm_Migration' => 'WPDO_Warm_Migration',
'TMDO_Cold_Migration' => 'WPDO_Cold_Migration',
'TMDO_Archive_Migration' => 'WPDO_Archive_Migration',
'TMDO_Migration_Base' => 'WPDO_Migration_Base',
'TMDO_Post_Migration' => 'WPDO_Post_Migration',
'TMDO_Zone_Hot' => 'WPDO_Zone_Hot',
'TMDO_Zone_Warm' => 'WPDO_Zone_Warm',
'TMDO_Zone_Cold' => 'WPDO_Zone_Cold',
'TMDO_Zone_Archive' => 'WPDO_Zone_Archive',
'TMDO_Interceptor_Base' => 'WPDO_Interceptor_Base',
'TMDO_Query_Interceptor_Base' => 'WPDO_Query_Interceptor_Base',
'TMDO_Notifier' => 'WPDO_Notifier',
);
foreach ( $tmdo_class_aliases as $tmdo_class => $wpdo_alias ) {
if ( class_exists( $tmdo_class, false ) && ! class_exists( $wpdo_alias, false ) ) {
class_alias( $tmdo_class, $wpdo_alias );
}
}
unset( $tmdo_class_aliases, $tmdo_class, $wpdo_alias );
// trait + interface back-compatPHP class_alias 不支援 trait/interface,用 wrapper 解決)。
require_once __DIR__ . '/back-compat/trait-wpdo-anti-eav-aware-alias.php';
require_once __DIR__ . '/back-compat/interface-wpdo-entity-adapter-alias.php';
// ── 常數別名(部分 sister plugin 直接讀取常數)─────────────────────────
if ( ! defined( 'WPDO_VERSION' ) ) {
define( 'WPDO_VERSION', TMDO_VERSION );
}
if ( ! defined( 'WPDO_DB_VERSION' ) ) {
define( 'WPDO_DB_VERSION', TMDO_DB_VERSION );
}
if ( ! defined( 'WPDO_PLUGIN_DIR' ) ) {
define( 'WPDO_PLUGIN_DIR', TMDO_PATH );
}
if ( ! defined( 'WPDO_PLUGIN_URL' ) ) {
define( 'WPDO_PLUGIN_URL', TMDO_URL );
}
if ( ! defined( 'WPDO_PLUGIN_FILE' ) ) {
define( 'WPDO_PLUGIN_FILE', TMDO_FILE );
}
if ( ! defined( 'WPDO_TABLE_PREFIX' ) ) {
define( 'WPDO_TABLE_PREFIX', TMDO_TABLE_PREFIX );
}
if ( ! defined( 'WPDO_CACHE_GROUP' ) ) {
define( 'WPDO_CACHE_GROUP', TMDO_CACHE_GROUP );
}
if ( ! defined( 'WPDO_IS_SQLITE' ) ) {
define( 'WPDO_IS_SQLITE', TMDO_IS_SQLITE );
}
if ( ! defined( 'WPDO_IS_MYSQL' ) ) {
define( 'WPDO_IS_MYSQL', TMDO_IS_MYSQL );
}
// ── Hook dual-fire bridge ─────────────────────────────────────────────────
// Phase 1 過渡期:tmdo_* 與 wpdo_* hook 雙向轉發,讓既有 listener 仍能接收事件。
foreach ( array(
'wpdo_register_fields',
'wpdo_register_entity_fields',
'wpdo_register_custom_tables',
'wpdo_after_write',
'wpdo_after_delete',
'wpdo_schema_updated',
'wpdo_bridge_mode_changed',
'wpdo_bridge_emergency_disabled',
'wpdo_auto_promoted',
) as $tmdo_back_compat_hook ) {
$tmdo_new_hook = preg_replace( '/^wpdo_/', 'tmdo_', $tmdo_back_compat_hook );
add_action(
$tmdo_new_hook,
static function ( ...$args ) use ( $tmdo_back_compat_hook ) {
do_action_ref_array( $tmdo_back_compat_hook, $args );
},
1
);
}
unset( $tmdo_back_compat_hook, $tmdo_new_hook );
+236
View File
@@ -0,0 +1,236 @@
<?php
/**
* Object Cache integration layer for Zone C (Cold) data.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Object Cache integration layer for Zone C (Cold) data.
*
* Provides a unified API for cache operations across all cold post types,
* with bulk prefetch support for reducing DB queries on archive pages.
*
* This class sits on top of TMDO_Zone_Cold and adds:
* - Bulk prefetch for multiple posts (archive/search result pages)
* - Cache warming on post save
* - Cache statistics for admin dashboard
* - Group-level cache flush
*/
class TMDO_Cache_Layer {
/**
* Prefetch cold data for multiple posts into Object Cache.
*
* Call this early on archive/search pages to batch-load cold blobs
* in a single query instead of N+1 queries per post.
*
* @param int[] $post_ids Array of post IDs to prefetch.
* @param string $post_type Post type.
*/
public static function prefetch( array $post_ids, string $post_type ): void {
if ( empty( $post_ids ) ) {
return;
}
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
if ( empty( $cold_keys ) ) {
return;
}
$group = 'wpdo_cold_' . sanitize_key( $post_type );
// Check which IDs are already cached.
$uncached = array();
foreach ( $post_ids as $pid ) {
$cached = wp_cache_get( "cold_{$pid}", $group );
if ( false === $cached ) {
$uncached[] = (int) $pid;
}
}
if ( empty( $uncached ) ) {
return;
}
// Bulk fetch from cold table.
global $wpdb;
$table = TMDO_Zone_Cold::table( $post_type );
$placeholders = implode( ',', array_fill( 0, count( $uncached ), '%d' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table from TMDO_Zone_Cold::table(); $placeholders built from array_fill with %d.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, data FROM `{$table}` WHERE post_id IN ({$placeholders})",
...$uncached
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// Get cache TTL from registry.
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'cold', $post_type );
$ttl = HOUR_IN_SECONDS;
foreach ( $fields as $field ) {
if ( ! empty( $field['cache_ttl'] ) ) {
$ttl = (int) $field['cache_ttl'];
break;
}
}
// Index fetched rows by post_id.
$fetched = array();
foreach ( $rows ?: array() as $row ) {
$data = json_decode( $row['data'], true );
$fetched[ (int) $row['post_id'] ] = is_array( $data ) ? $data : array();
}
// Cache all results (including empty arrays for posts with no cold data).
foreach ( $uncached as $pid ) {
$data = $fetched[ $pid ] ?? array();
wp_cache_set( "cold_{$pid}", $data, $group, $ttl );
}
}
/**
* Warm the cache for a single post after it's saved/updated.
*
* Hooked to 'save_post' to ensure fresh data is in cache.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
*/
public static function warm_post( int $post_id, string $post_type ): void {
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
if ( empty( $cold_keys ) ) {
return;
}
// Force a fresh read from DB (bypasses cache).
$group = 'wpdo_cold_' . sanitize_key( $post_type );
wp_cache_delete( "cold_{$post_id}", $group );
// Re-read will populate cache.
TMDO_Zone_Cold::get_blob( $post_id, $post_type );
}
/**
* Flush all cold cache for a post type.
*
* Useful after bulk imports or schema changes.
*
* @param string $post_type Post type to flush.
*/
public static function flush_group( string $post_type ): void {
$group = 'wpdo_cold_' . sanitize_key( $post_type );
if ( function_exists( 'wp_cache_flush_group' ) ) {
wp_cache_flush_group( $group );
}
// If wp_cache_flush_group is not available (older WP or no object cache),
// individual entries will expire via TTL.
}
/**
* Get cache statistics for the admin dashboard.
*
* @return array{groups: array, prefetch_support: bool}
*/
public static function get_stats(): array {
$registry = TMDO_Schema_Registry::instance();
$cold_types = $registry->get_cold_post_types();
$groups = array();
foreach ( $cold_types as $pt ) {
$group = 'wpdo_cold_' . sanitize_key( $pt );
$keys = $registry->get_cold_meta_keys( $pt );
$fields = $registry->get_zone_fields_for_type( 'cold', $pt );
$ttl = HOUR_IN_SECONDS;
foreach ( $fields as $field ) {
if ( ! empty( $field['cache_ttl'] ) ) {
$ttl = (int) $field['cache_ttl'];
break;
}
}
$groups[] = array(
'post_type' => $pt,
'group' => $group,
'meta_keys' => $keys,
'ttl' => $ttl,
);
}
return array(
'groups' => $groups,
'prefetch_support' => true,
'flush_support' => function_exists( 'wp_cache_flush_group' ),
);
}
/**
* Register WordPress hooks for cache warming.
*/
public static function register_hooks(): void {
add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 99, 2 );
// Prefetch on HivePress archive pages.
add_action( 'loop_start', array( __CLASS__, 'on_loop_start' ), 10, 1 );
}
/**
* Warm cache after post save.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public static function on_save_post( int $post_id, \WP_Post $post ): void {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post->post_type );
if ( ! empty( $cold_keys ) ) {
self::warm_post( $post_id, $post->post_type );
}
}
/**
* Prefetch cold data at the start of a main query loop.
*
* @param \WP_Query $query The current WP_Query object.
* @return void
*/
public static function on_loop_start( \WP_Query $query ): void {
if ( ! $query->is_main_query() || is_admin() ) {
return;
}
$posts = $query->posts;
if ( empty( $posts ) ) {
return;
}
$post_type = $query->get( 'post_type' );
if ( is_array( $post_type ) ) {
$post_type = reset( $post_type );
}
if ( ! $post_type ) {
return;
}
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
if ( empty( $cold_keys ) ) {
return;
}
$post_ids = wp_list_pluck( $posts, 'ID' );
self::prefetch( $post_ids, $post_type );
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
/**
* TMDO_Capability — Multisite-aware admin capability check (v2.14.0).
*
* Centralizes the "can this user manage WPDO?" decision. Behaviour:
*
* - Single-site: require `manage_options` (site admin)
* - Multisite per-site: require `manage_options` (site admin on that site)
* - Multisite network: super admin always passes; site admin still passes
* on their own site if they hold `manage_options`
*
* Pre-v2.14.0 the codebase called `current_user_can('manage_options')` in 17
* places. On a network-admin context page, super admins do NOT automatically
* have `manage_options` — WordPress maps it to `do_not_allow` for non-super
* admins and lets `is_super_admin()` carry the decision separately. By
* routing through this helper, the plugin works identically on single-site
* and multisite without per-call-site if/else.
*
* @package WP_Data_Optimizer
* @since 2.14.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Static helper class — drop-in replacement for `current_user_can('manage_options')`.
*/
final class TMDO_Capability {
/**
* Whether the current user can manage WP Data Optimizer.
*
* Super admins always pass on multisite; site admins pass on their site.
* On single-site WordPress this is functionally identical to
* `current_user_can('manage_options')`.
*
* @return bool
*/
public static function current_user_can_admin(): bool {
if ( function_exists( 'is_multisite' ) && is_multisite()
&& function_exists( 'is_super_admin' ) && is_super_admin()
) {
return true;
}
return current_user_can( 'manage_options' );
}
}
@@ -0,0 +1,864 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
/**
* TMDO_Comment_Stress_Tester — Async stress fixture generator for comment entity (v2.13.1).
*
* Mirrors TMDO_Term_Stress_Tester (v2.13.0) but adapted for comment entity:
*
* - Source: $wpdb->comments (comment_ID, comment_post_ID, comment_author, ...)
* - Meta: $wpdb->commentmeta
* - Flat: wpdo_comment_hp_review (single hp_rating key) + wpdo_comment_misc
* - Realistic mode: wp_insert_comment() + update_comment_meta()
* - Fast mode: bulk INSERT to wp_comments + wp_commentmeta
*
* Test comments are identified by `comment_author_email LIKE '%@wpdo-stress.local'`.
* Each comment is attached to a caller-specified `comment_post_ID` (must exist).
*
* 🔒 v2.13.x frozen contract: never touches user / post / term entities or
* their flat tables.
*
* @package WP_Data_Optimizer
* @since 2.13.1
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Internal stress fixture: $wpdb->comments / wp_commentmeta WP-managed; meta_key strings static class constants; user-controlled values use prepare() placeholders.
/**
* Async bulk fixture generator for comment entity stress tests.
*/
final class TMDO_Comment_Stress_Tester {
/** Email domain marker — comments matching this are stress test fixtures. */
public const TEST_EMAIL_DOMAIN = 'wpdo-stress.local';
/** Hard cap to prevent runaway calls. */
public const MAX_COUNT = 100000;
// State machine constants.
public const OPT_STATE = 'wpdo_comment_stress_test_state';
public const CRON_HOOK = 'wpdo_comment_stress_test_batch';
public const CANCEL_FLAG = 'wpdo_comment_stress_cancel_flag';
public const DEFAULT_BATCH_SIZE = 200;
public const MAX_BATCH_SIZE = 1000;
public const MIN_BATCH_DELAY_SEC = 1;
public const BATCH_DEADLINE_SEC = 8;
public const MODE_FAST = 'fast';
public const MODE_REALISTIC = 'realistic';
/**
* Per-key seed map (hp_review group: just hp_rating).
*
* @var array<string,mixed>|null
*/
private static ?array $seed_map_cache = null;
// ─────────────────────────────────────────────────────────────────────────
// Public API — sync helpers (also used internally by state machine batches)
// ─────────────────────────────────────────────────────────────────────────
/**
* Bulk-create N stress test comments on the given post via direct SQL
* (Fast mode — bypasses WP filter chain).
*
* @param int $post_id WP post ID to attach comments to.
* @param int $count Number of comments to insert. Capped at MAX_COUNT.
* @return array{created:int,post_id:int,first_id:int|null,last_id:int|null}
* @throws InvalidArgumentException When inputs invalid.
*/
public static function create( int $post_id, int $count ): array {
self::validate_inputs( $post_id, $count );
global $wpdb;
$first_id = null;
$last_id = null;
$created = 0;
$seed_map = self::seed_map();
$now = current_time( 'mysql' );
$now_gmt = current_time( 'mysql', true );
for ( $i = 0; $i < $count; $i++ ) {
$suffix = wp_generate_password( 8, false );
$ok = $wpdb->insert(
$wpdb->comments,
array(
'comment_post_ID' => $post_id,
'comment_author' => 'WPDO Stress ' . $suffix,
'comment_author_email' => 'wpdo+' . $suffix . '@' . self::TEST_EMAIL_DOMAIN,
'comment_author_url' => '',
'comment_author_IP' => '127.0.0.1',
'comment_date' => $now,
'comment_date_gmt' => $now_gmt,
'comment_content' => 'Stress test comment ' . $suffix,
'comment_karma' => 0,
'comment_approved' => '1',
'comment_agent' => 'wpdo-stress-tester',
'comment_type' => 'comment',
'comment_parent' => 0,
'user_id' => 0,
)
);
if ( ! $ok ) {
continue;
}
$comment_id = (int) $wpdb->insert_id;
if ( null === $first_id ) {
$first_id = $comment_id;
}
$last_id = $comment_id;
++$created;
// Direct INSERT to wp_commentmeta (bypassing Hook Bus). For aeav_only
// mode validation use create_realistic() instead.
foreach ( $seed_map as $meta_key => $value_spec ) {
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
$wpdb->insert(
$wpdb->commentmeta,
array(
'comment_id' => $comment_id,
'meta_key' => $meta_key,
'meta_value' => (string) $value,
)
);
}
}
return array(
'created' => $created,
'post_id' => $post_id,
'first_id' => $first_id,
'last_id' => $last_id,
);
}
/**
* Realistic-mode counterpart — uses wp_insert_comment() +
* update_comment_meta() so Hook Bus / entity registry / mode_manager all
* engage on the write path.
*
* @param int $post_id WP post ID.
* @param int $count Number of comments.
* @return array{created:int,post_id:int,mode:string,first_id:int|null,last_id:int|null}
* @throws InvalidArgumentException When inputs invalid.
*/
public static function create_realistic( int $post_id, int $count ): array {
self::validate_inputs( $post_id, $count );
$first_id = null;
$last_id = null;
$created = 0;
$seed_map = self::seed_map();
for ( $i = 0; $i < $count; $i++ ) {
$suffix = wp_generate_password( 8, false );
$comment_id = wp_insert_comment(
array(
'comment_post_ID' => $post_id,
'comment_author' => 'WPDO Stress ' . $suffix,
'comment_author_email' => 'wpdo+' . $suffix . '@' . self::TEST_EMAIL_DOMAIN,
'comment_content' => 'Stress test comment ' . $suffix,
'comment_approved' => 1,
'comment_type' => 'comment',
)
);
if ( ! $comment_id ) {
continue;
}
$comment_id = (int) $comment_id;
if ( null === $first_id ) {
$first_id = $comment_id;
}
$last_id = $comment_id;
++$created;
foreach ( $seed_map as $meta_key => $value_spec ) {
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
update_comment_meta( $comment_id, $meta_key, $value );
}
}
return array(
'created' => $created,
'post_id' => $post_id,
'mode' => 'realistic',
'first_id' => $first_id,
'last_id' => $last_id,
);
}
/**
* Count comments whose author email ends with TEST_EMAIL_DOMAIN.
*
* @return int
*/
public static function count_test_comments(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_author_email LIKE %s",
'%@' . $wpdb->esc_like( self::TEST_EMAIL_DOMAIN )
)
);
}
/**
* Delete every stress-test comment + its commentmeta + flat table rows.
*
* @return array{deleted_comments:int,deleted_meta:int,deleted_flat:int}
*/
public static function cleanup(): array {
global $wpdb;
set_transient( self::CANCEL_FLAG, 1, 600 );
wp_clear_scheduled_hook( self::CRON_HOOK );
$comment_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT comment_ID FROM {$wpdb->comments} WHERE comment_author_email LIKE %s",
'%@' . $wpdb->esc_like( self::TEST_EMAIL_DOMAIN )
)
);
if ( empty( $comment_ids ) ) {
return array(
'deleted_comments' => 0,
'deleted_meta' => 0,
'deleted_flat' => 0,
);
}
$id_list = implode( ',', array_map( 'absint', $comment_ids ) );
$flat_deleted = 0;
foreach ( self::get_comment_flat_tables() as $tbl ) {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $tbl ) );
if ( ! $exists ) {
continue;
}
$rows = (int) $wpdb->query( "DELETE FROM `{$tbl}` WHERE comment_id IN ({$id_list})" );
$flat_deleted += $rows;
}
$meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->commentmeta} WHERE comment_id IN ({$id_list})" );
$comment_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->comments} WHERE comment_ID IN ({$id_list})" );
delete_option( self::OPT_STATE );
delete_transient( self::CANCEL_FLAG );
return array(
'deleted_comments' => $comment_deleted,
'deleted_meta' => $meta_deleted,
'deleted_flat' => $flat_deleted,
);
}
// ─────────────────────────────────────────────────────────────────────────
// State machine
// ─────────────────────────────────────────────────────────────────────────
/**
* Start an async stress run.
*
* @param int $post_id Post ID to attach comments to.
* @param int $target Total comments to create.
* @param string $mode MODE_FAST | MODE_REALISTIC.
* @param int $batch_size Per-batch insert count.
* @return array{ok:bool,error?:string,state?:array}
*/
public static function start( int $post_id, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array {
if ( $post_id < 1 || ! self::post_exists( $post_id ) ) {
return array(
'ok' => false,
'error' => 'unknown_post: ' . $post_id,
);
}
if ( $target < 1 ) {
return array(
'ok' => false,
'error' => 'target must be >= 1',
);
}
if ( $target > self::MAX_COUNT ) {
return array(
'ok' => false,
'error' => 'target too large (max ' . self::MAX_COUNT . ')',
);
}
if ( ! in_array( $mode, array( self::MODE_FAST, self::MODE_REALISTIC ), true ) ) {
return array(
'ok' => false,
'error' => 'invalid mode',
);
}
$batch_size = max( 1, min( self::MAX_BATCH_SIZE, $batch_size ) );
$current = self::get_state();
if ( ! empty( $current['status'] ) && 'running' === $current['status'] ) {
return array(
'ok' => false,
'error' => 'already_running',
'state' => $current,
);
}
$state = array(
'job_id' => uniqid( 'cstress_', true ),
'status' => 'running',
'mode' => $mode,
'post_id' => $post_id,
'target' => $target,
'batch_size' => $batch_size,
'started_at' => time(),
'processed' => 0,
'batches_done' => 0,
'batches_log' => array(),
'errors' => array(),
'peak_memory' => 0,
'completed_at' => null,
'last_pushed_at' => 0,
'benchmark' => null,
);
update_option( self::OPT_STATE, $state, false );
delete_transient( self::CANCEL_FLAG );
wp_clear_scheduled_hook( self::CRON_HOOK );
wp_schedule_single_event( time(), self::CRON_HOOK );
return array(
'ok' => true,
'state' => $state,
);
}
/**
* Cancel a running stress test.
*
* @return array{ok:bool,state?:array,message?:string}
*/
public static function cancel(): array {
$state = self::get_state();
if ( empty( $state ) ) {
return array(
'ok' => true,
'message' => 'no_active_job',
);
}
set_transient( self::CANCEL_FLAG, 1, 600 );
wp_clear_scheduled_hook( self::CRON_HOOK );
$state = self::get_state();
$state['status'] = 'cancelled';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
return array(
'ok' => true,
'state' => $state,
);
}
/**
* Read raw state.
*
* @return array
*/
public static function get_state(): array {
$state = get_option( self::OPT_STATE, array() );
return is_array( $state ) ? $state : array();
}
/**
* Get progress with computed pct/rate/ETA.
*
* @param bool $pump When true, opportunistically pump.
* @return array
*/
public static function get_progress( bool $pump = true ): array {
if ( $pump ) {
self::pump_if_due();
}
$state = self::get_state();
if ( empty( $state ) ) {
return array(
'status' => 'idle',
'processed' => 0,
'target' => 0,
'pct' => 0,
);
}
$processed = (int) ( $state['processed'] ?? 0 );
$target = (int) ( $state['target'] ?? 0 );
$started = (int) ( $state['started_at'] ?? 0 );
$ended = (int) ( $state['completed_at'] ?? 0 );
$now = $ended > 0 ? $ended : time();
$elapsed = max( 1, $now - $started );
$rate = $processed > 0 ? round( $processed / $elapsed, 1 ) : 0;
$eta_sec = ( $rate > 0 && $processed < $target ) ? (int) ceil( ( $target - $processed ) / $rate ) : 0;
$pct = $target > 0 ? round( ( $processed / $target ) * 100, 1 ) : 0;
return array_merge(
$state,
array(
'pct' => $pct,
'rate_per_sec' => $rate,
'elapsed_sec' => $elapsed,
'eta_sec' => $eta_sec,
'test_comment_count' => self::count_test_comments(),
)
);
}
/**
* Opportunistic batch pump (transient-locked).
*
* @return void
*/
public static function pump_if_due(): void {
$state = self::get_state();
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
return;
}
$last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 );
if ( time() - $last_pushed_at < 1 ) {
return;
}
$lock_key = 'wpdo_comment_stress_pump_lock';
if ( false !== get_transient( $lock_key ) ) {
return;
}
set_transient( $lock_key, 1, 30 );
if ( function_exists( 'set_time_limit' ) ) {
@set_time_limit( self::BATCH_DEADLINE_SEC + 10 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
}
try {
self::run_batch();
} finally {
delete_transient( $lock_key );
}
}
/**
* Cron entrypoint.
*
* @return void
*/
public static function run_batch(): void {
$state = self::get_state();
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
return;
}
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
return;
}
$post_id = (int) ( $state['post_id'] ?? 0 );
$target = (int) ( $state['target'] ?? 0 );
$processed = (int) ( $state['processed'] ?? 0 );
$batch_size = (int) ( $state['batch_size'] ?? self::DEFAULT_BATCH_SIZE );
$mode = (string) ( $state['mode'] ?? self::MODE_FAST );
$remaining = $target - $processed;
if ( $remaining <= 0 ) {
self::finalize( $state );
return;
}
$this_batch_size = min( $batch_size, $remaining );
$batch_started = microtime( true );
try {
if ( self::MODE_FAST === $mode ) {
$inserted = self::run_batch_fast( $post_id, $this_batch_size );
} else {
$inserted = self::run_batch_realistic( $post_id, $this_batch_size );
}
} catch ( \Throwable $e ) {
$state['errors'][] = array(
'time' => time(),
'message' => $e->getMessage(),
);
$state['status'] = 'failed';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::error( 'comment_stress_test_batch_failed', array( 'message' => $e->getMessage() ) );
}
return;
}
$batch_elapsed = microtime( true ) - $batch_started;
$latest = self::get_state();
if ( empty( $latest ) ) {
return;
}
$is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG );
$latest['processed'] = ( (int) ( $latest['processed'] ?? 0 ) ) + $inserted;
$latest['batches_done'] = ( (int) ( $latest['batches_done'] ?? 0 ) ) + 1;
$latest['batches_log'][] = array(
'n' => $inserted,
'duration_ms' => (int) round( $batch_elapsed * 1000 ),
);
if ( count( $latest['batches_log'] ) > 200 ) {
$latest['batches_log'] = array_slice( $latest['batches_log'], -200 );
}
$latest['peak_memory'] = max( (int) ( $latest['peak_memory'] ?? 0 ), (int) memory_get_peak_usage( true ) );
$latest['last_pushed_at'] = time();
if ( $is_cancelled ) {
update_option( self::OPT_STATE, $latest, false );
return;
}
update_option( self::OPT_STATE, $latest, false );
if ( $latest['processed'] >= $target ) {
self::finalize( $latest );
return;
}
wp_schedule_single_event( time() + self::MIN_BATCH_DELAY_SEC, self::CRON_HOOK );
}
/**
* Run one fast-mode batch.
*
* @param int $post_id Post ID.
* @param int $count Batch size.
* @return int Inserted count.
*/
private static function run_batch_fast( int $post_id, int $count ): int {
$result = self::create( $post_id, $count );
return (int) ( $result['created'] ?? 0 );
}
/**
* Run one realistic-mode batch with deadline + cancel check per comment.
*
* @param int $post_id Post ID.
* @param int $count Batch size.
* @return int Inserted count.
*/
private static function run_batch_realistic( int $post_id, int $count ): int {
$deadline = microtime( true ) + self::BATCH_DEADLINE_SEC;
$inserted = 0;
$seed_map = self::seed_map();
for ( $i = 0; $i < $count; $i++ ) {
if ( microtime( true ) > $deadline ) {
break;
}
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
break;
}
$suffix = wp_generate_password( 8, false );
$comment_id = wp_insert_comment(
array(
'comment_post_ID' => $post_id,
'comment_author' => 'WPDO Stress ' . $suffix,
'comment_author_email' => 'wpdo+' . $suffix . '@' . self::TEST_EMAIL_DOMAIN,
'comment_content' => 'Stress test comment ' . $suffix,
'comment_approved' => 1,
'comment_type' => 'comment',
)
);
if ( ! $comment_id ) {
continue;
}
++$inserted;
foreach ( $seed_map as $meta_key => $value_spec ) {
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
update_comment_meta( (int) $comment_id, $meta_key, $value );
}
}
return $inserted;
}
/**
* Finalize: clear cron, run benchmark, persist completed state.
*
* @param array $state Pre-finalize state.
* @return void
*/
private static function finalize( array $state ): void {
wp_clear_scheduled_hook( self::CRON_HOOK );
$state['status'] = 'benchmarking';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
$report = self::run_benchmark( $state );
$state['benchmark'] = $report;
$state['status'] = 'completed';
update_option( self::OPT_STATE, $state, false );
}
/**
* Run benchmark.
*
* @param array|null $state Optional state snapshot.
* @return array
*/
public static function run_benchmark( ?array $state = null ): array {
$state = $state ?? self::get_state();
$post_id = (int) ( $state['post_id'] ?? 0 );
return array(
'generated_at' => time(),
'post_id' => $post_id,
'write' => self::compute_write_metrics( $state ),
'db_sizes' => self::measure_db_sizes(),
'query' => self::measure_query_performance(),
);
}
/**
* Write throughput metrics from state.
*
* @param array $state State snapshot.
* @return array
*/
private static function compute_write_metrics( array $state ): array {
$started = (int) ( $state['started_at'] ?? 0 );
$ended = (int) ( $state['completed_at'] ?? time() );
$processed = (int) ( $state['processed'] ?? 0 );
$elapsed = max( 1, $ended - $started );
$batches = $state['batches_log'] ?? array();
$durations = array_column( $batches, 'duration_ms' );
$min_ms = ! empty( $durations ) ? min( $durations ) : 0;
$max_ms = ! empty( $durations ) ? max( $durations ) : 0;
$avg_ms = ! empty( $durations ) ? (int) ( array_sum( $durations ) / count( $durations ) ) : 0;
return array(
'mode' => $state['mode'] ?? '',
'post_id' => (int) ( $state['post_id'] ?? 0 ),
'target' => (int) ( $state['target'] ?? 0 ),
'processed' => $processed,
'elapsed_sec' => $elapsed,
'rate_per_sec' => round( $processed / $elapsed, 2 ),
'batches_done' => (int) ( $state['batches_done'] ?? 0 ),
'batch_min_ms' => $min_ms,
'batch_max_ms' => $max_ms,
'batch_avg_ms' => $avg_ms,
'peak_memory_mb' => round( (int) ( $state['peak_memory'] ?? 0 ) / 1048576, 1 ),
);
}
/**
* Measure DB sizes for comment-related tables.
*
* @return array
*/
private static function measure_db_sizes(): array {
global $wpdb;
$tables = array( $wpdb->comments, $wpdb->commentmeta );
foreach ( self::get_comment_flat_tables() as $tbl ) {
if ( self::table_exists( $tbl ) ) {
$tables[] = $tbl;
}
}
if ( ! self::is_mysql() ) {
return array_map(
static fn( $t ) => array(
'table' => $t,
'rows' => self::table_row_count( $t ),
),
$tables
);
}
$placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT TABLE_NAME AS t, TABLE_ROWS AS rows_count, DATA_LENGTH AS dl, INDEX_LENGTH AS il
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})",
...$tables
),
ARRAY_A
);
$out = array();
foreach ( (array) $rows as $r ) {
$dl = (int) $r['dl'];
$il = (int) $r['il'];
$total = $dl + $il;
$out[] = array(
'table' => $r['t'],
'rows' => (int) $r['rows_count'],
'data_mb' => round( $dl / 1048576, 2 ),
'index_mb' => round( $il / 1048576, 2 ),
'total_mb' => round( $total / 1048576, 2 ),
'avg_bytes' => $r['rows_count'] > 0 ? (int) ( $total / (int) $r['rows_count'] ) : 0,
);
}
return $out;
}
/**
* Measure 3 representative queries:
* - point: hp_rating = 5 lookup on flat
* - range: hp_rating > 3 ORDER BY DESC on flat
* - EAV baseline: same point query on wp_commentmeta
*
* @return array
*/
private static function measure_query_performance(): array {
global $wpdb;
$flat = $wpdb->prefix . 'wpdo_comment_hp_review';
if ( ! self::table_exists( $flat ) ) {
return array( 'note' => 'flat_table_missing' );
}
return array(
'point_rating_5' => self::time_query(
"SELECT comment_id FROM `{$flat}` WHERE hp_rating = 5 LIMIT 100"
),
'range_rating_top' => self::time_query(
"SELECT comment_id FROM `{$flat}` WHERE hp_rating > 3 ORDER BY hp_rating DESC LIMIT 100"
),
'eav_baseline' => self::time_query(
"SELECT comment_id FROM {$wpdb->commentmeta} WHERE meta_key = 'hp_rating' AND meta_value = '5' LIMIT 100"
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Validate inputs for create() / create_realistic().
*
* @param int $post_id Post ID.
* @param int $count Count.
* @return void
* @throws InvalidArgumentException When invalid.
*/
private static function validate_inputs( int $post_id, int $count ): void {
if ( $post_id < 1 ) {
throw new InvalidArgumentException( 'post_id must be >= 1.' );
}
if ( $count <= 0 ) {
throw new InvalidArgumentException( 'Count must be > 0.' );
}
if ( $count > self::MAX_COUNT ) {
$msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . ').';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
}
/**
* Check whether $post_id exists in wp_posts.
*
* @param int $post_id Post ID.
* @return bool
*/
private static function post_exists( int $post_id ): bool {
global $wpdb;
return (bool) $wpdb->get_var(
$wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} WHERE ID = %d LIMIT 1", $post_id )
);
}
/**
* Lazy-built seed map for hp_review group.
*
* @return array<string,mixed>
*/
private static function seed_map(): array {
if ( null !== self::$seed_map_cache ) {
return self::$seed_map_cache;
}
self::$seed_map_cache = array(
'hp_rating' => static fn( int $i ) => (string) ( ( $i % 5 ) + 1 ),
);
return self::$seed_map_cache;
}
/**
* Names of all wp_wpdo_comment_* flat tables.
*
* @return string[]
*/
private static function get_comment_flat_tables(): array {
global $wpdb;
$prefix = $wpdb->prefix . 'wpdo_comment_';
return array(
$prefix . 'hp_review',
$prefix . 'misc',
);
}
/**
* Memoized table-exists probe.
*
* @param string $table Table name.
* @return bool
*/
private static function table_exists( string $table ): bool {
global $wpdb;
static $cache = array();
if ( isset( $cache[ $table ] ) ) {
return $cache[ $table ];
}
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$cache[ $table ] = ( $found === $table );
return $cache[ $table ];
}
/**
* Cheap row count helper (SQLite fallback).
*
* @param string $table Table name.
* @return int
*/
private static function table_row_count( string $table ): int {
global $wpdb;
if ( ! self::table_exists( $table ) ) {
return 0;
}
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
}
/**
* Time a SQL query.
*
* @param string $sql Query.
* @return array{duration_ms:float}
*/
private static function time_query( string $sql ): array {
global $wpdb;
$start = microtime( true );
$wpdb->get_results( $sql );
$elapsed_ms = ( microtime( true ) - $start ) * 1000;
return array( 'duration_ms' => round( $elapsed_ms, 2 ) );
}
/**
* Detect MySQL vs SQLite.
*
* @return bool
*/
private static function is_mysql(): bool {
return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) );
}
}
+277
View File
@@ -0,0 +1,277 @@
<?php
/**
* TMDO_Commentmeta_Cleaner — wp_commentmeta garbage cleanup (v2.12.0 Phase 0).
*
* Identifies and removes four classes of low-value rows from wp_commentmeta
* that bloat the table without serving any business purpose:
*
* - wxr_import — `_wxr_import_*` rows from WP importer (pure tracking)
* On dev10 alone: 211 rows = 75% of all commentmeta
* - demo_data — `_2meet_demo_*` rows used to mark demo content
* - transients — `_transient_*` and `_transient_timeout_*` rows
* - orphan_post_meta — Stray post-meta keys mistakenly written to
* commentmeta (e.g., `_hp_price`, `_hp_status`,
* `_thumbnail_id`). These are bugs / typos / earlier
* plugin defects — never read from commentmeta.
*
* Mirrors TMDO_Postmeta_Cleaner / TMDO_Termmeta_Cleaner pattern.
*
* @package WP_Data_Optimizer
* @since 2.12.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Wp_commentmeta garbage cleanup (v2.12.0 Phase 0).
*/
class TMDO_Commentmeta_Cleaner {
public const TARGET_WXR_IMPORT = 'wxr_import';
public const TARGET_DEMO_DATA = 'demo_data';
public const TARGET_TRANSIENTS = 'transients';
public const TARGET_ORPHAN_POST_META = 'orphan_post_meta';
public const TARGET_ALL = 'all';
public const VALID_TARGETS = array(
self::TARGET_WXR_IMPORT,
self::TARGET_DEMO_DATA,
self::TARGET_TRANSIENTS,
self::TARGET_ORPHAN_POST_META,
self::TARGET_ALL,
);
/**
* Known post-meta keys that have been observed mis-written to commentmeta.
* Conservative list — only includes keys that:
* 1. Are clearly post-domain (start with `_hp_*` / `_thumbnail_id` / etc.)
* 2. Have NO known consumer reading from get_comment_meta() with that key
* 3. Were observed in real production data (dev10) as orphan rows
*/
private const ORPHAN_POST_META_KEYS = array(
'_hp_price',
'_hp_status',
'_hp_featured',
'_hp_verified',
'_hp_view_count',
'_thumbnail_id',
'_edit_lock',
'_edit_last',
);
/**
* Count rows that would be cleaned for the given target.
*
* @param string $target One of TARGET_* constants.
* @return array{wxr_import:int, demo_data:int, transients:int, orphan_post_meta:int, total:int}
* @throws InvalidArgumentException When $target is not a valid target.
*/
public static function count_garbage( string $target = self::TARGET_ALL ): array {
self::assert_valid_target( $target );
$counts = array(
'wxr_import' => 0,
'demo_data' => 0,
'transients' => 0,
'orphan_post_meta' => 0,
'total' => 0,
);
if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) {
$counts['wxr_import'] = self::count_wxr_import();
}
if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) {
$counts['demo_data'] = self::count_demo_data();
}
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
$counts['transients'] = self::count_transients();
}
if ( self::target_includes( $target, self::TARGET_ORPHAN_POST_META ) ) {
$counts['orphan_post_meta'] = self::count_orphan_post_meta();
}
$counts['total'] = $counts['wxr_import'] + $counts['demo_data']
+ $counts['transients'] + $counts['orphan_post_meta'];
return $counts;
}
/**
* Delete garbage rows for the given target.
*
* @param string $target One of TARGET_* constants.
* @return array{wxr_import:int, demo_data:int, transients:int, orphan_post_meta:int, total:int}
* @throws InvalidArgumentException When $target is not a valid target.
*/
public static function delete_garbage( string $target = self::TARGET_ALL ): array {
self::assert_valid_target( $target );
$deleted = array(
'wxr_import' => 0,
'demo_data' => 0,
'transients' => 0,
'orphan_post_meta' => 0,
'total' => 0,
);
if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) {
$deleted['wxr_import'] = self::delete_wxr_import();
}
if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) {
$deleted['demo_data'] = self::delete_demo_data();
}
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
$deleted['transients'] = self::delete_transients();
}
if ( self::target_includes( $target, self::TARGET_ORPHAN_POST_META ) ) {
$deleted['orphan_post_meta'] = self::delete_orphan_post_meta();
}
$deleted['total'] = $deleted['wxr_import'] + $deleted['demo_data']
+ $deleted['transients'] + $deleted['orphan_post_meta'];
return $deleted;
}
/**
* Whether $target selects $bucket (i.e. target=all or target=bucket).
*
* @param string $target Selected target.
* @param string $bucket Bucket constant.
* @return bool
*/
private static function target_includes( string $target, string $bucket ): bool {
return self::TARGET_ALL === $target || $bucket === $target;
}
/**
* Validate target parameter.
*
* @param string $target Target to validate.
* @return void
* @throws InvalidArgumentException When $target is not in VALID_TARGETS.
*/
private static function assert_valid_target( string $target ): void {
if ( in_array( $target, self::VALID_TARGETS, true ) ) {
return;
}
$msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) );
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->commentmeta is WP-managed; meta_key patterns are static class constants.
/**
* Count `_wxr_import_*` rows in wp_commentmeta.
*
* @return int
*/
private static function count_wxr_import(): int {
global $wpdb;
$table = $wpdb->commentmeta;
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'"
);
}
/**
* Delete `_wxr_import_*` rows from wp_commentmeta.
*
* @return int Affected row count.
*/
private static function delete_wxr_import(): int {
global $wpdb;
$table = $wpdb->commentmeta;
return (int) $wpdb->query(
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'"
);
}
/**
* Count `_2meet_demo_*` rows in wp_commentmeta.
*
* @return int
*/
private static function count_demo_data(): int {
global $wpdb;
$table = $wpdb->commentmeta;
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'"
);
}
/**
* Delete `_2meet_demo_*` rows from wp_commentmeta.
*
* @return int Affected row count.
*/
private static function delete_demo_data(): int {
global $wpdb;
$table = $wpdb->commentmeta;
return (int) $wpdb->query(
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'"
);
}
/**
* Count rows matching transient meta_key patterns in wp_commentmeta.
*
* @return int
*/
private static function count_transients(): int {
global $wpdb;
$table = $wpdb->commentmeta;
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
);
}
/**
* Delete rows matching transient meta_key patterns from wp_commentmeta.
*
* @return int Affected row count.
*/
private static function delete_transients(): int {
global $wpdb;
$table = $wpdb->commentmeta;
return (int) $wpdb->query(
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
);
}
/**
* Count orphan post-meta keys in wp_commentmeta.
*
* @return int
*/
private static function count_orphan_post_meta(): int {
global $wpdb;
$table = $wpdb->commentmeta;
$placeholders = implode( ',', array_fill( 0, count( self::ORPHAN_POST_META_KEYS ), '%s' ) );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key IN ({$placeholders})",
...self::ORPHAN_POST_META_KEYS
)
);
}
/**
* Delete orphan post-meta keys from wp_commentmeta.
*
* @return int Affected row count.
*/
private static function delete_orphan_post_meta(): int {
global $wpdb;
$table = $wpdb->commentmeta;
$placeholders = implode( ',', array_fill( 0, count( self::ORPHAN_POST_META_KEYS ), '%s' ) );
return (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` WHERE meta_key IN ({$placeholders})",
...self::ORPHAN_POST_META_KEYS
)
);
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
+133
View File
@@ -0,0 +1,133 @@
<?php
/**
* Plugin coexistence manager for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Plugin coexistence manager.
*
* Detects active plugins and determines whether WPDO should register
* its interceptors or defer to existing plugins.
*/
class TMDO_Compatibility {
/**
* Cached detection results.
*
* @var array
*/
private static array $detected = array();
/**
* Run all compatibility checks. Called once during TMDO_Core::run().
*
* @return array Detection results.
*/
public static function check(): array {
if ( ! empty( self::$detected ) ) {
return self::$detected;
}
self::$detected = array(
'hpct_active' => self::is_hpct_active(),
'hpct_imported' => self::is_hpct_imported(),
'hivepress' => self::is_hivepress_active(),
'woocommerce' => self::is_woocommerce_active(),
);
return self::$detected;
}
/**
* Can WPDO register its HPCT-inherited module interceptors?
*
* True when:
* - hp-custom-tables is NOT active, OR
* - hp-custom-tables IS active but already imported into WPDO.
*/
public static function can_register_hpct_hooks(): bool {
$compat = self::check();
// HPCT not active → WPDO can freely register.
if ( ! $compat['hpct_active'] ) {
return true;
}
// HPCT is active but already imported → WPDO takes over.
if ( $compat['hpct_imported'] ) {
return true;
}
// HPCT is active and NOT imported → defer to HPCT.
return false;
}
/**
* Can WPDO register zone-specific interceptors (hot/warm/cold/archive)?
*
* Zone interceptors are always safe to register — they operate on
* WPDO's own tables and do not conflict with HPCT.
*/
public static function can_register_zone_hooks(): bool {
return true;
}
/**
* Should WPDO show an admin notice about HPCT import?
*/
public static function should_show_hpct_notice(): bool {
$compat = self::check();
return $compat['hpct_active'] && ! $compat['hpct_imported'];
}
/**
* Is HivePress core active?
*/
public static function is_hivepress_active(): bool {
return class_exists( 'HivePress\Core' );
}
/**
* Is WooCommerce active?
*/
public static function is_woocommerce_active(): bool {
return class_exists( 'WooCommerce' );
}
/**
* Is LatePoint plugin active? (v2.5.0 M16 polish — for module detector.)
*
* LatePoint exposes the OsBookingHelper class and `latepoint_*` action
* hooks. Detect via either the helper class or the version constant.
*/
public static function is_latepoint_active(): bool {
return class_exists( 'OsBookingHelper' )
|| defined( 'LATEPOINT_VERSION' )
|| function_exists( 'latepoint_get_settings' );
}
// ── Detection Methods ────────────────────────────────────────────────
/**
* Is HP Custom Tables plugin active (loaded)?
*
* Checks for the HPCT_Loader class, which is always loaded
* when hp-custom-tables is active.
*/
public static function is_hpct_active(): bool {
return class_exists( 'HPCT_Loader' ) || defined( 'HPCT_VERSION' );
}
/**
* Has HPCT been imported into WPDO?
*/
private static function is_hpct_imported(): bool {
return (bool) get_option( 'wpdo_hpct_imported', false );
}
}
+245
View File
@@ -0,0 +1,245 @@
<?php
/**
* TMDO_Conflict_Monitor — production-time conflict detection for WPDO.
*
* Wraps TMDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts() and the UAE-port
* TMDO_Conflict_Detector (UAEPG cross-plugin scan) into a single facade with:
*
* - admin_init scan + admin_notices warning when conflicts detected
* - admin_bar warning chip when conflicts > 0 (Part C.1 enforcer requirement)
* - Persistent log to wpdo_audit table for ops review
* - Single-source-of-truth `get_all_conflicts()` for CLI / REST surfaces
*
* This wrapper is the live monitor; the engine/class-tmdo-conflict-detector.php
* is the deeper UAEPG-aware scanner. The split keeps WPDO callable without
* UAEPG present.
*
* @package WP_Data_Optimizer
* @since 2.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.InlineComment.InvalidEndChar,Generic.Commenting.DocComment.MissingShort,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.PHP.YodaConditions,Generic.CodeAnalysis.EmptyStatement -- v2.0.0 partner integrations: pure registration helpers + intentional silent catches.
/**
* Production conflict monitor with admin surface integration.
*/
final class TMDO_Conflict_Monitor {
/**
* Cached conflict list per request.
*
* @var array<int, array{type:string, hook?:string, priority?:int, callback?:string, entity_type?:string, meta_key?:string}>|null
*/
private static ?array $cache = null;
/**
* Register all hooks for production monitor surface.
*
* Called from TMDO_Core::run() after all interceptors register.
*
* @return void
*/
public static function register_hooks(): void {
// init:30 — runs after both wpdo_register_fields (init:20) and the
// UAE-port Conflict_Detector (init:25), so we observe a complete picture.
add_action( 'init', array( self::class, 'scan' ), 30 );
if ( is_admin() ) {
add_action( 'admin_notices', array( self::class, 'maybe_render_admin_notice' ) );
add_action( 'admin_bar_menu', array( self::class, 'maybe_render_admin_bar' ), 999 );
}
}
/**
* Run a full scan and cache the result for the request.
*
* Aggregates findings from:
* 1. TMDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts() — same-hook callback overlap
* 2. TMDO_Conflict_Detector::scan() — UAEPG cross-plugin field overlap (if present)
*
* @return array<int, array>
*/
public static function scan(): array {
if ( null !== self::$cache ) {
return self::$cache;
}
$conflicts = array();
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
foreach ( TMDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts() as $finding ) {
$conflicts[] = array_merge( array( 'type' => 'hook_overlap' ), $finding );
}
}
if ( class_exists( 'TMDO_Conflict_Detector' ) ) {
foreach ( TMDO_Conflict_Detector::scan() as $finding ) {
$conflicts[] = array_merge( array( 'type' => 'uaepg_overlap' ), $finding );
}
}
self::$cache = $conflicts;
// Persist a single summary row to wpdo_audit when conflicts exist.
if ( $conflicts ) {
self::persist_audit_summary( $conflicts );
}
return $conflicts;
}
/**
* Get the cached conflict list, scanning lazily if needed.
*
* @return array<int, array>
*/
public static function get_all_conflicts(): array {
return self::$cache ?? self::scan();
}
/**
* Conflict count by type.
*
* @return array{total:int, hook_overlap:int, uaepg_overlap:int}
*/
public static function get_summary(): array {
$conflicts = self::get_all_conflicts();
$summary = array(
'total' => count( $conflicts ),
'hook_overlap' => 0,
'uaepg_overlap' => 0,
);
foreach ( $conflicts as $c ) {
$type = $c['type'] ?? 'unknown';
if ( isset( $summary[ $type ] ) ) {
++$summary[ $type ];
}
}
return $summary;
}
/**
* Reset request cache. Test helper.
*
* @internal
*/
public static function reset_cache(): void {
self::$cache = null;
}
/**
* Render an admin notice when conflicts are present.
*
* @return void
*/
public static function maybe_render_admin_notice(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
$summary = self::get_summary();
if ( $summary['total'] === 0 ) {
return;
}
$message = sprintf(
/* translators: %d: conflict count */
esc_html__( 'WPDO Conflict Detector:偵測到 %d 個欄位 / hook 衝突 — 可能造成資料靜默遺失。', '2meet-data-optimizer' ),
$summary['total']
);
$link = esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=conflicts' ) );
printf(
'<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
esc_html( $message ),
esc_url( $link ),
esc_html__( '查看詳情', '2meet-data-optimizer' )
);
}
/**
* Render a warning chip in the admin bar.
*
* @param mixed $wp_admin_bar WP_Admin_Bar instance.
* @return void
*/
public static function maybe_render_admin_bar( $wp_admin_bar ): void {
if ( ! is_object( $wp_admin_bar ) || ! method_exists( $wp_admin_bar, 'add_node' ) ) {
return;
}
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
$summary = self::get_summary();
if ( $summary['total'] === 0 ) {
return;
}
$wp_admin_bar->add_node(
array(
'id' => 'wpdo-conflicts',
'title' => sprintf(
/* translators: %d: conflict count */
'⚠️ ' . esc_html__( 'Anti-EAV: %d conflicts', '2meet-data-optimizer' ),
$summary['total']
),
'href' => admin_url( 'tools.php?page=wp-data-optimizer&tab=conflicts' ),
'meta' => array( 'class' => 'wpdo-conflict-warning' ),
)
);
}
/**
* Persist a summary row to wpdo_audit (silently swallows errors when table missing).
*
* @param array<int,array> $conflicts All findings.
* @return void
*/
private static function persist_audit_summary( array $conflicts ): void {
try {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_audit';
$wpdb->insert(
$table,
array(
'ts' => current_time( 'mysql' ),
'user_id' => 0,
'entity_type' => 'system',
'entity_id' => 0,
'meta_key' => '',
'op' => 'conflict_scan',
'value_before' => null,
'value_after' => wp_json_encode( $conflicts ),
'source' => 'monitor',
'trace_id' => self::generate_trace_id(),
),
array( '%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
);
} catch ( \Throwable $e ) {
// wpdo_audit not yet installed (pre-v2 upgrade) — skip silently.
}
}
/**
* Generate a UUID-like trace id (v4-ish, no external deps).
*
* @return string
*/
private static function generate_trace_id(): string {
// PHP 8.1+ random_bytes is always available.
try {
$bytes = random_bytes( 16 );
} catch ( \Throwable $e ) {
$bytes = pack( 'H*', md5( (string) microtime( true ) . wp_generate_password( 16, false ) ) );
}
$bytes[6] = chr( ( ord( $bytes[6] ) & 0x0f ) | 0x40 );
$bytes[8] = chr( ( ord( $bytes[8] ) & 0x3f ) | 0x80 );
return vsprintf(
'%s%s-%s-%s-%s-%s%s%s',
str_split( bin2hex( $bytes ), 4 )
);
}
}
+840
View File
@@ -0,0 +1,840 @@
<?php
/**
* Main loader for WP Data Optimizer components.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Main loader — bootstraps all WPDO components.
*
* Loaded at plugins_loaded priority 4 (before HPCT at priority 5).
*/
class TMDO_Core {
/**
* Boot the plugin.
*/
public function run(): void {
// Schema upgrade check.
TMDO_Installer::maybe_upgrade();
// Run compatibility checks.
$compat = TMDO_Compatibility::check();
// Fire the schema registry hook so integrations can register their fields.
$registry = TMDO_Schema_Registry::instance();
// v2.0.x: Pre-register the 7 partner plugin integrations BEFORE firing the
// `wpdo_register_fields` and `wpdo_register_custom_tables` hooks. Each
// `register()` is a thin `add_action()` wrapper guarded by a partner-plugin
// `class_exists()` probe (TMDO_Infocards / Bookings / Quotation / Events /
// Mobile_Bridge / Collab / Playlist). Without this, partner add_actions
// arrive too late and the do_action below misses them — observed via
// 2meet-infocards 9 hp_vendor meta keys never landing in Schema Registry.
// register() is side-effect-free aside from add_action(), so pre-registering
// is provably safe (no hidden dependency on later-state global init).
// v2.1.3 R1 (B step): TMDO_Events removed — 2meet-events now self-registers
// via its own bridge class. Other partners still pre-registered here pending
// their own decentralized migrations (R1 continuation in v2.2.x).
$partner_integrations = array(
'TMDO_Infocards',
'TMDO_Bookings',
'TMDO_Quotation',
'TMDO_Mobile_Bridge',
'TMDO_Collab',
'TMDO_Playlist',
'TMDO_WooCommerce',
'TMDO_Member_Fields',
'TMDO_Post_Fields',
'TMDO_Hivepress_Term_Comment_Fields',
);
foreach ( $partner_integrations as $cls ) {
if ( class_exists( $cls ) ) {
call_user_func( array( $cls, 'register' ) );
}
}
/**
* Action: wpdo_register_fields
*
* Plugins register their meta_key → zone mappings here.
*
* @param TMDO_Schema_Registry $registry The schema registry instance.
*/
do_action( 'wpdo_register_fields', $registry );
// v2.1.3 R2 safety net: re-fire on init:1 to catch partners that registered
// AFTER plugins_loaded:4 (e.g., partners loaded by HP modules at plugins_loaded:5+,
// or lazy-loaded modules booted on init:0). Schema_Registry::register() has
// internal dedup, so re-firing is idempotent — same partner calls won't double-register.
// This deprecates the need for the TMDO_Core hardcoded partner pre-registration list above.
//
// v2.1.6: also re-fire `wpdo_register_entity_fields` so partner plugins booting on
// plugins_loaded:20 (e.g. 2meet-spoke-sso) can register entity-based user/term/comment
// fields. TMDO_Entity_Registry::register_group() has internal dedup. After all
// registrations land, kick `TMDO_Schema_Manager::process_pending_migrations()` once
// per request to materialize any newly-defined entity flat tables (idempotent via
// schema_hash compare — no DDL when unchanged).
if ( ! did_action( 'wpdo_late_bind_setup' ) ) {
add_action(
'init',
static function () use ( $registry ) {
do_action( 'wpdo_register_fields', $registry );
if ( class_exists( 'TMDO_Entity_Registry' ) ) {
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
}
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
TMDO_Custom_Table_Registry::instance()->fire_registration();
}
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
$pending = TMDO_Entity_Registry::get_pending_schemas();
if ( ! empty( $pending ) ) {
TMDO_Schema_Manager::process_pending_migrations();
}
}
do_action( 'wpdo_late_bind_setup' );
},
1
);
}
// HivePress 整合已移至 2meet-data-optimizer-hivepress-addon。
// AddOn bootstrap 自行偵測 + register。Core 完全 HP-agnostic。
// Cache Layer hooks (Zone C prefetch + save_post warming).
TMDO_Cache_Layer::register_hooks();
// Register HPCT-inherited module interceptors if allowed.
if ( TMDO_Compatibility::can_register_hpct_hooks() ) {
$this->register_hpct_interceptors();
}
// v2.1.2 fix: WC commission interceptor is independent of HPCT — writes
// to its own wp_wpdo_wc_commissions table. Register whenever WC is active.
if ( class_exists( 'TMDO_WooCommerce' ) && TMDO_WooCommerce::is_active() && class_exists( 'TMDO_WC_Orders_Interceptor' ) ) {
( new TMDO_WC_Orders_Interceptor() )->register_hooks();
}
// Zone interceptors are always safe to register.
if ( TMDO_Compatibility::can_register_zone_hooks() ) {
$this->register_zone_hooks();
}
// v2.0.0: register entity adapters into Entity Registry. Adapters are
// REGISTERED unconditionally (so TMDO_API::get_entity / Hook Bus can
// find them), but Hook Bus only initializes when feature flag is on
// (default off — preserves v1.3.x behaviour).
$this->register_entity_adapters();
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
// v2.0.0: fire the Custom Table Registry hook so partner plugins can register.
TMDO_Custom_Table_Registry::instance()->fire_registration();
// v2.0.0: production-time conflict monitor (admin notice + admin bar).
if ( class_exists( 'TMDO_Conflict_Monitor' ) ) {
TMDO_Conflict_Monitor::register_hooks();
}
// v2.0.x: partner plugin integrations have been pre-registered earlier
// (see top of run() before the wpdo_register_fields do_action). Removing
// the duplicate late-binding block — see commit log for time-ordering bug.
// Schedule cron events.
$this->schedule_cron();
// Admin notice for HPCT import.
if ( is_admin() && TMDO_Compatibility::should_show_hpct_notice() ) {
add_action( 'admin_notices', array( $this, 'render_hpct_notice' ) );
}
// v2.6.6: async entity backfill cron handler.
add_action( 'wpdo_entity_backfill_batch', array( $this, 'run_entity_backfill_batch' ), 10, 2 );
// v2.6.7: user stress-test batch cron handler.
add_action( TMDO_User_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_stress_test_batch' ) );
// v2.11.4: post stress-test batch cron handler.
if ( class_exists( 'TMDO_Post_Stress_Tester' ) ) {
add_action( TMDO_Post_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_post_stress_test_batch' ) );
}
// v2.13.0: term stress-test batch cron handler.
if ( class_exists( 'TMDO_Term_Stress_Tester' ) ) {
add_action( TMDO_Term_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_term_stress_test_batch' ) );
}
// v2.13.1: comment stress-test batch cron handler.
if ( class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
add_action( TMDO_Comment_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_comment_stress_test_batch' ) );
}
// v2.11.5: HivePress per-post transient cache → wp_options reroute.
// Filters registered on `init` so HivePress's own bootstrap (plugins_loaded:5)
// runs first and our filter chain catches the actual cache writes happening
// during save_post and term-cache rebuilds.
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 );
}
// v2.12.1: Term + Comment garbage write-time filter
// (silent-drop _wxr_import_* / _2meet_demo_* / orphan post-meta keys).
if ( class_exists( 'TMDO_Term_Comment_Garbage_Filter' ) ) {
add_action( 'init', array( 'TMDO_Term_Comment_Garbage_Filter', 'init' ), 5 );
}
// v2.12.3: WooCommerce term count cache → wp_options reroute
// (product_count_<taxonomy> rows out of wp_termmeta).
if ( class_exists( 'TMDO_WC_Term_Count_Filter' ) ) {
add_action( 'init', array( 'TMDO_WC_Term_Count_Filter', 'init' ), 5 );
}
// v2.12.4: Term + Comment misc bucket (catch-all flat for unregistered keys).
// Filters at priority 99 (LAST in chain) so all other v2.12.x filters
// + Hook Bus get first crack; only truly unhandled keys land here.
if ( class_exists( 'TMDO_Term_Comment_Misc_Bucket' ) ) {
add_action( 'init', array( 'TMDO_Term_Comment_Misc_Bucket', 'init' ), 5 );
}
}
/**
* Cron handler: 執行壓力測試的單一批次(v2.6.7)。
*/
public static function run_stress_test_batch(): void {
if ( class_exists( 'TMDO_User_Stress_Tester' ) ) {
TMDO_User_Stress_Tester::run_batch();
}
}
/**
* Cron handler: post entity 壓力測試單一批次(v2.11.4)。
*
* @return void
*/
public static function run_post_stress_test_batch(): void {
if ( class_exists( 'TMDO_Post_Stress_Tester' ) ) {
TMDO_Post_Stress_Tester::run_batch();
}
}
/**
* Cron handler: term entity 壓力測試單一批次(v2.13.0)。
*
* @return void
*/
public static function run_term_stress_test_batch(): void {
if ( class_exists( 'TMDO_Term_Stress_Tester' ) ) {
TMDO_Term_Stress_Tester::run_batch();
}
}
/**
* Cron handler: comment entity 壓力測試單一批次(v2.13.1)。
*
* @return void
*/
public static function run_comment_stress_test_batch(): void {
if ( class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
TMDO_Comment_Stress_Tester::run_batch();
}
}
/**
* Register the four entity adapters into TMDO_Entity_Registry.
*
* Registration is unconditional but write/read paths only activate when
* the corresponding module is in a write-active or read-custom state
* (feature flag controlled).
*
* @return void
*/
private function register_entity_adapters(): void {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
TMDO_Entity_Registry::register_adapter( 'post', new TMDO_Adapter_Post() );
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
TMDO_Entity_Registry::register_adapter( 'term', new TMDO_Adapter_Term() );
TMDO_Entity_Registry::register_adapter( 'comment', new TMDO_Adapter_Comment() );
/**
* Action: wpdo_register_entity_fields
*
* Partner plugins register their entity meta_key → adapter mappings here.
* Mirrors `wpdo_register_fields` but for non-postmeta entities.
*
* @since 2.0.0
*/
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
}
/**
* Register default HivePress field mappings.
*
* These are the known HivePress meta_keys and their zone classifications.
*
* @param TMDO_Schema_Registry $registry The schema registry instance.
* @return void
*/
private function register_hivepress_defaults( TMDO_Schema_Registry $registry ): void {
// Zone A (Hot) — Listing search/filter fields.
$registry->register_many(
'hivepress',
array(
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_price',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_featured',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_featured',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_verified',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_verified',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_expired_time',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_expired_time',
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_featured_time',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_featured_time',
),
// Vendor hot fields.
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_verified',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_verified',
'indexed' => true,
),
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_hourly_rate',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_hourly_rate',
'indexed' => true,
),
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_rating_count',
'zone' => 'hot',
'data_type' => 'int(11) NOT NULL DEFAULT 0',
'column' => 'hp_rating_count',
'indexed' => false,
),
)
);
// Zone C (Cold) — Profile/display fields.
$registry->register_many(
'hivepress',
array(
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_description',
'zone' => 'cold',
'cache_group' => 'wpdo_cold_hp_vendor',
'cache_ttl' => HOUR_IN_SECONDS,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_description',
'zone' => 'cold',
'cache_group' => 'wpdo_cold_hp_listing',
'cache_ttl' => HOUR_IN_SECONDS,
),
)
);
// ── HivePress extension fields (filled the 47% → 94% coverage gap) ──
// Reviews extension (hp_review post type).
$registry->register_many(
'hivepress-reviews',
array(
array(
'post_type' => 'hp_review',
'meta_key' => 'hp_rating',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_rating',
'indexed' => true,
),
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_rating',
'zone' => 'hot',
'data_type' => 'decimal(3,2) NOT NULL DEFAULT 0',
'column' => 'hp_rating',
'indexed' => true,
),
array(
'post_type' => 'hp_review',
'meta_key' => 'hp_text',
'zone' => 'cold',
'cache_group' => 'wpdo_cold_hp_review',
'cache_ttl' => HOUR_IN_SECONDS,
),
)
);
// Bookings extension (hp_booking post type).
$registry->register_many(
'hivepress-bookings',
array(
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_start_time',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_start_time',
'indexed' => true,
),
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_end_time',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_end_time',
'indexed' => true,
),
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_status',
'zone' => 'hot',
'data_type' => "varchar(20) NOT NULL DEFAULT ''",
'column' => 'hp_status',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_booking_enabled',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_booking_enabled',
'indexed' => true,
),
)
);
// Marketplace / Requests extension.
$registry->register_many(
'hivepress-marketplace',
array(
array(
'post_type' => 'hp_request',
'meta_key' => 'hp_request_category',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_request_category',
'indexed' => true,
),
array(
'post_type' => 'hp_offer',
'meta_key' => 'hp_amount',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_amount',
'indexed' => true,
),
array(
'post_type' => 'hp_offer',
'meta_key' => 'hp_request',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_request',
'indexed' => true,
),
)
);
}
/**
* Register HPCT-inherited module interceptors and query interceptors.
*/
private function register_hpct_interceptors(): void {
// 7 HPCT-era meta interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
// 1 LatePoint interceptor 已搬到 2meet-data-optimizer-latepoint-addon。
// 5 query interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
// 此方法保留為空殼以保持 ABI 相容;AddOn 各自於自身 bootstrap 內 register_hooks。
}
/**
* Register zone-specific hooks.
*/
private function register_zone_hooks(): void {
// Sync Bridge: Zone-aware dual-write + read routing for all zone fields.
$sync_bridge = new TMDO_Sync_Bridge();
$sync_bridge->register_hooks();
// Query Router: Zone A flat-column JOIN rewrite for WP_Query.
$query_router = new TMDO_Query_Router();
$query_router->register_hooks();
// v2.10.1 Post Entity Query Router — only registers when post mode
// is shadow_read or aeav_only (zero overhead in disabled/dual_write).
// is_router_active() guards against early-bind issues; once mode flips
// to reads_from_flat the hooks fire on subsequent requests.
if ( class_exists( 'TMDO_Post_Query_Router' ) && TMDO_Post_Query_Router::is_router_active() ) {
$post_router = new TMDO_Post_Query_Router();
$post_router->register_hooks();
}
// Zone B: Warm cleanup cron handler.
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
// v2.10.3: Post shadow_read verifier cron handler. Only fires when
// post mode is shadow_read (verifier::cron_tick() is internally gated).
if ( class_exists( 'TMDO_Post_Shadow_Verifier' ) ) {
add_action( TMDO_Post_Shadow_Verifier::CRON_HOOK, array( 'TMDO_Post_Shadow_Verifier', 'cron_tick' ) );
}
// v2.12.5: Term + Comment shadow verifier cron handler. Only fires when
// either term or comment mode is shadow_read (gated internally).
if ( class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) ) {
add_action( TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK, array( 'TMDO_Term_Comment_Shadow_Verifier', 'cron_tick' ) );
}
// v2.10.3: react to bridge mode changes — register/unregister verifier
// cron when post mode crosses the shadow_read threshold.
add_action( 'wpdo_bridge_mode_changed', array( $this, 'maybe_toggle_post_verifier_cron' ), 10, 3 );
// v2.12.5: same listener for term/comment.
add_action( 'wpdo_bridge_mode_changed', array( $this, 'maybe_toggle_term_comment_verifier_cron' ), 10, 3 );
// Zone D: Archive sweep cron handler.
add_action( 'wpdo_archive_sweep', array( $this, 'sweep_archive_zone' ) );
// Errors GC cron handler — keeps wp_wpdo_errors bounded (default 90 days).
add_action( 'wpdo_errors_gc', array( $this, 'gc_errors' ) );
// Monthly health-snapshot cron handler.
add_action( 'wpdo_health_snapshot_monthly', array( $this, 'monthly_health_snapshot' ) );
// v2.3.0 M6: daily health probe cron.
add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) );
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
// v2.5.0 M13: daily FSM automator cron (opt-in).
add_action( 'wpdo_fsm_automator_run', array( 'TMDO_FSM_Automator', 'run' ) );
// v2.6.2: daily site-wide EAV health metrics snapshot.
TMDO_Site_Metrics_Collector::register();
// Add 'monthly' cron interval (WP only ships hourly/twicedaily/daily).
add_filter(
'cron_schedules',
static function ( $schedules ) {
if ( ! isset( $schedules['monthly'] ) ) {
$schedules['monthly'] = array(
'interval' => 30 * DAY_IN_SECONDS,
'display' => __( 'Once Monthly', '2meet-data-optimizer' ),
);
}
return $schedules;
}
);
// Admin notice when DB grows > 10% MoM.
if ( is_admin() ) {
add_action( 'admin_notices', array( $this, 'render_health_alert_notice' ) );
}
// Zone B/D: Listing stats integration(已搬到 2meet-data-optimizer-hivepress-addon)。
if ( class_exists( 'TMDO_Listing_Stats' ) ) {
TMDO_Listing_Stats::register_hooks();
}
}
/**
* Schedule cron events if not already scheduled.
*/
private function schedule_cron(): void {
if ( ! wp_next_scheduled( 'wpdo_warm_cleanup' ) ) {
wp_schedule_event( time(), 'hourly', 'wpdo_warm_cleanup' );
}
// v2.10.3: Post shadow_read verifier — only schedule when post mode
// is shadow_read (auto-cleared in mode_changed listener below).
if ( class_exists( 'TMDO_Post_Shadow_Verifier' )
&& class_exists( 'TMDO_Mode_Manager' )
&& 'shadow_read' === TMDO_Mode_Manager::get( 'post' )
&& ! wp_next_scheduled( TMDO_Post_Shadow_Verifier::CRON_HOOK )
) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', TMDO_Post_Shadow_Verifier::CRON_HOOK );
}
// v2.12.5: Term + Comment shadow verifier — schedule when EITHER term
// or comment mode is shadow_read.
if ( class_exists( 'TMDO_Term_Comment_Shadow_Verifier' )
&& class_exists( 'TMDO_Mode_Manager' )
&& ( 'shadow_read' === TMDO_Mode_Manager::get( 'term' )
|| 'shadow_read' === TMDO_Mode_Manager::get( 'comment' ) )
&& ! wp_next_scheduled( TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK )
) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK );
}
if ( ! wp_next_scheduled( 'wpdo_archive_sweep' ) ) {
wp_schedule_event( time(), 'daily', 'wpdo_archive_sweep' );
}
// Daily errors GC — keep 90 days, prevents wpdo_errors growing unbounded.
if ( ! wp_next_scheduled( 'wpdo_errors_gc' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpdo_errors_gc' );
}
// Monthly health snapshot — auto-runs first of each month.
if ( ! wp_next_scheduled( 'wpdo_health_snapshot_monthly' ) ) {
// Schedule at 03:00 on the 1st of next month.
$next = strtotime( 'first day of next month 03:00' );
wp_schedule_event( $next ?: ( time() + 30 * DAY_IN_SECONDS ), 'monthly', 'wpdo_health_snapshot_monthly' );
}
// v2.3.0 M6: daily health probe at 03:30 UTC (avoids hourly warm cleanup).
if ( ! wp_next_scheduled( 'wpdo_daily_health_check' ) ) {
$tomorrow_330 = strtotime( 'tomorrow 03:30 UTC' );
wp_schedule_event( $tomorrow_330 ?: ( time() + DAY_IN_SECONDS ), 'daily', 'wpdo_daily_health_check' );
}
// v2.3.0 M6: daily snapshot prune at 04:00 UTC.
if ( ! wp_next_scheduled( 'wpdo_snapshot_prune_daily' ) ) {
$tomorrow_400 = strtotime( 'tomorrow 04:00 UTC' );
wp_schedule_event( $tomorrow_400 ?: ( time() + DAY_IN_SECONDS ), 'daily', 'wpdo_snapshot_prune_daily' );
}
// v2.5.0 M13: FSM Automator daily cron at 04:30 UTC (after prune).
if ( ! wp_next_scheduled( 'wpdo_fsm_automator_run' ) ) {
$tomorrow_430 = strtotime( 'tomorrow 04:30 UTC' );
wp_schedule_event( $tomorrow_430 ?: ( time() + DAY_IN_SECONDS ), 'daily', 'wpdo_fsm_automator_run' );
}
// v2.6.2: daily site metrics collection at 05:00 UTC.
if ( ! wp_next_scheduled( TMDO_Site_Metrics_Collector::CRON_HOOK ) ) {
$tomorrow_500 = strtotime( 'tomorrow 05:00 UTC' );
wp_schedule_event( $tomorrow_500 ?: ( time() + DAY_IN_SECONDS ), 'daily', TMDO_Site_Metrics_Collector::CRON_HOOK );
}
// Zone B: Flush view counts to postmeta (hourly)(已搬到 hivepress addon)。
if ( class_exists( 'TMDO_Listing_Stats' ) ) {
TMDO_Listing_Stats::schedule_cron();
}
}
/**
* Cron handler: prune wpdo_errors > 90 days.
*
* @return void
*/
public function gc_errors(): void {
TMDO_Logger::purge( 90 );
}
/**
* Cron handler: monthly health snapshot via WP_CLI subprocess.
*
* @return void
*/
public function monthly_health_snapshot(): void {
// Only do anything when WP-CLI is invocable in the current request — the
// cron runs via `wp cron event run` typically. Otherwise, dispatch via
// shell.
if ( ! class_exists( 'TMDO_CLI' ) || ! defined( 'WP_CLI' ) || ! WP_CLI ) {
return;
}
$cli = new TMDO_CLI();
$cli->health_snapshot( array(), array() );
}
/**
* Render admin notice when DB has grown > 10% since last snapshot.
*
* Reads `wpdo_health_alert` option set by `health_snapshot` CLI when
* threshold breached. Auto-dismissed after fix or next snapshot.
*
* @return void
*/
public function render_health_alert_notice(): void {
$alert = (string) get_option( 'wpdo_health_alert', '' );
if ( '' === $alert ) {
return;
}
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
// Allow user to dismiss until next snapshot.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display.
if ( ! empty( $_GET['wpdo_dismiss_health_alert'] ) ) {
$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['_wpnonce'] ) ) : '';
if ( wp_verify_nonce( $nonce, 'wpdo_dismiss_health_alert' ) ) {
delete_option( 'wpdo_health_alert' );
return;
}
}
$dismiss_url = wp_nonce_url(
add_query_arg( 'wpdo_dismiss_health_alert', '1' ),
'wpdo_dismiss_health_alert'
);
?>
<div class="notice notice-warning">
<p>
<strong>WP Data Optimizer:</strong>
<?php echo esc_html( $alert ); ?>
&nbsp;<a href="<?php echo esc_url( $dismiss_url ); ?>"><?php esc_html_e( '關閉', '2meet-data-optimizer' ); ?></a>
</p>
</div>
<?php
}
/**
* Cron handler: Clean up expired Zone B (warm) entries.
*/
public function cleanup_warm_zone(): void {
TMDO_Zone_Warm::purge_expired();
}
/**
* Toggle the post shadow verifier cron event when post mode crosses
* the shadow_read threshold (v2.10.3).
*
* Fires on `wpdo_bridge_mode_changed` action emitted by Mode_Manager::set().
*
* @param string $entity_type Entity type that changed.
* @param string $new_mode New mode value.
* @param string $old_mode Previous mode value.
* @return void
*/
public function maybe_toggle_post_verifier_cron( string $entity_type, string $new_mode, string $old_mode ): void {
if ( 'post' !== $entity_type || ! class_exists( 'TMDO_Post_Shadow_Verifier' ) ) {
return;
}
$hook = TMDO_Post_Shadow_Verifier::CRON_HOOK;
if ( 'shadow_read' === $new_mode && ! wp_next_scheduled( $hook ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', $hook );
} elseif ( 'shadow_read' !== $new_mode ) {
wp_clear_scheduled_hook( $hook );
}
}
/**
* Toggle the term/comment shadow verifier cron when EITHER term or
* comment mode crosses the shadow_read threshold (v2.12.5).
*
* @param string $entity_type Entity that changed (term/comment/user/post).
* @param string $new_mode New mode.
* @param string $old_mode Previous mode (unused).
* @return void
*/
public function maybe_toggle_term_comment_verifier_cron( string $entity_type, string $new_mode, string $old_mode ): void {
unset( $old_mode );
if ( ! in_array( $entity_type, array( 'term', 'comment' ), true ) ) {
return;
}
if ( ! class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) || ! class_exists( 'TMDO_Mode_Manager' ) ) {
return;
}
$hook = TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK;
// Recompute "should run" based on BOTH entities' current modes.
$should_run = 'shadow_read' === $new_mode
|| 'shadow_read' === TMDO_Mode_Manager::get( 'term' )
|| 'shadow_read' === TMDO_Mode_Manager::get( 'comment' );
if ( $should_run && ! wp_next_scheduled( $hook ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', $hook );
} elseif ( ! $should_run ) {
wp_clear_scheduled_hook( $hook );
}
}
/**
* Cron handler: Archive stale postmeta for trashed posts.
*/
public function sweep_archive_zone(): void {
TMDO_Zone_Archive::sweep();
}
/**
* Render admin notice when hp-custom-tables is detected but not imported.
*/
public function render_hpct_notice(): void {
$import_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=hpct-import' );
?>
<div class="notice notice-warning is-dismissible">
<p>
<strong>WP Data Optimizer:</strong>
<?php
printf(
/* translators: %s: URL to import page */
esc_html__( 'HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。', '2meet-data-optimizer' ),
'<a href="' . esc_url( $import_url ) . '">' . esc_html__( 'HPCT 匯入頁面', '2meet-data-optimizer' ) . '</a>'
);
?>
</p>
</div>
<?php
}
/**
* WP Cron handler: process one backfill batch for an entity/group pair.
*
* Re-schedules itself (via wp_schedule_single_event) until migration is done.
*
* @param string $entity_type Entity type (user|term|comment).
* @param string $group_name Registered group name.
*/
public function run_entity_backfill_batch( string $entity_type, string $group_name ): void {
if ( ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
return;
}
$result = TMDO_Entity_Migration_Engine::migrate_group_batch( $entity_type, $group_name );
if ( ! $result['done'] ) {
// Re-schedule next batch (5 s delay to avoid overwhelming DB).
wp_schedule_single_event( time() + 5, 'wpdo_entity_backfill_batch', array( $entity_type, $group_name ) );
}
TMDO_Logger::info(
'entity_backfill_batch',
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
'migrated' => $result['migrated'],
'total' => $result['total'],
'done' => $result['done'],
'status' => $result['status'],
)
);
}
}
+370
View File
@@ -0,0 +1,370 @@
<?php
/**
* TMDO_Crypto — AES-256-GCM authenticated encryption for sensitive option values.
*
* Derives a site-specific key from AUTH_KEY + SECURE_AUTH_SALT so ciphertext
* is useless outside this WordPress installation.
*
* Storage formats:
* - "enc:v2:<base64(iv12 . tag16 . ciphertext)>" (current — AES-256-GCM, AEAD)
* - "enc:v1:<base64(iv16 . ciphertext)>" (legacy — AES-256-CBC; read-only)
*
* Backward compatibility:
* - encrypt() always writes v2 GCM
* - decrypt() reads BOTH v1 and v2 (transparent migration)
* - Values without any "enc:" prefix are returned as-is (existing plaintext
* options continue to work until re-saved or migrated explicitly)
*
* The v1→v2 upgrade can be triggered via:
* - WP-CLI: `wp wpdo crypto-migrate`
* - Auto-migration during install/upgrade (idempotent best-effort)
*
* Usage (unchanged from v2.6.4 contract):
* TMDO_Crypto::set_option( 'wpdo_slack_webhook', $url ); // writes v2
* $url = TMDO_Crypto::get_option( 'wpdo_slack_webhook' ); // reads v1 or v2
*
* @package WP_Data_Optimizer
* @since 2.6.4 (v1 CBC)
* @since 2.15.0 (v2 GCM, AEAD authentication)
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Symmetric authenticated encryption helper for sensitive wp_options entries.
*/
class TMDO_Crypto {
/** Ciphertext prefix v2 (AES-256-GCM, current). */
public const PREFIX_V2 = 'enc:v2:';
/** Ciphertext prefix v1 (AES-256-CBC, legacy read-only). */
public const PREFIX_V1 = 'enc:v1:';
/** Cipher suite v2 (AEAD — authenticated, tamper-detectable). */
private const CIPHER_V2 = 'aes-256-gcm';
/** Cipher suite v1 (legacy — no authentication). */
private const CIPHER_V1 = 'AES-256-CBC';
/** GCM IV length (12 bytes is the GCM standard / NIST SP 800-38D recommended). */
private const IV_LEN_V2 = 12;
/** CBC IV length (legacy). */
private const IV_LEN_V1 = 16;
/** GCM authentication tag length (16 bytes = 128 bits, the strongest standard). */
private const TAG_LEN = 16;
/**
* Derive a 32-byte site-specific key from WordPress auth constants.
*
* Stable per site → ciphertext written on this site cannot be decrypted
* elsewhere. Both v1 and v2 use the same derived key (same secret material,
* different cipher) so v1 ciphertext can be read after the v2 upgrade.
*
* Falls back to a sha256 of ABSPATH when constants are not defined
* (unit-test environments). The fallback must be stable per request.
*
* @return string 32 raw bytes.
*/
private static function derived_key(): string {
$salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : '';
$salt .= defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : ABSPATH;
return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 );
}
/**
* Encrypt a plaintext string with AES-256-GCM (v2 format).
*
* @param string $plaintext Value to encrypt.
* @return string Encrypted value with "enc:v2:" prefix, or original on failure.
*/
public static function encrypt( string $plaintext ): string {
if ( '' === $plaintext ) {
return '';
}
if ( ! function_exists( 'openssl_encrypt' ) ) {
return $plaintext;
}
$iv = random_bytes( self::IV_LEN_V2 );
$tag = '';
// phpcs:ignore -- $tag is reference output for GCM auth tag.
$ciphertext = openssl_encrypt(
$plaintext,
self::CIPHER_V2,
self::derived_key(),
OPENSSL_RAW_DATA,
$iv,
$tag,
'',
self::TAG_LEN
);
if ( false === $ciphertext ) {
return $plaintext;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- intentional binary encoding.
return self::PREFIX_V2 . base64_encode( $iv . $tag . $ciphertext );
}
/**
* Decrypt an encrypted value (v2 GCM or v1 CBC).
*
* Dispatches by prefix:
* - "enc:v2:..." → AES-256-GCM (auth-tag verified)
* - "enc:v1:..." → AES-256-CBC (legacy, no auth)
* - other → returned as-is (legacy plaintext)
*
* @param string $stored Stored option value.
* @return string Plaintext, or original value on failure.
*/
public static function decrypt( string $stored ): string {
if ( '' === $stored ) {
return $stored;
}
if ( ! function_exists( 'openssl_decrypt' ) ) {
return $stored;
}
if ( str_starts_with( $stored, self::PREFIX_V2 ) ) {
return self::decrypt_v2( $stored );
}
if ( str_starts_with( $stored, self::PREFIX_V1 ) ) {
return self::decrypt_v1( $stored );
}
return $stored; // plaintext (legacy).
}
/**
* Decrypt v2 GCM blob (private — dispatched by decrypt()).
*
* @param string $stored "enc:v2:..." string.
* @return string Plaintext or original on auth failure / parse error.
*/
private static function decrypt_v2( string $stored ): string {
$encoded = substr( $stored, strlen( self::PREFIX_V2 ) );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own encrypted data.
$raw = base64_decode( $encoded, true );
if ( false === $raw || strlen( $raw ) <= self::IV_LEN_V2 + self::TAG_LEN ) {
return $stored;
}
$iv = substr( $raw, 0, self::IV_LEN_V2 );
$tag = substr( $raw, self::IV_LEN_V2, self::TAG_LEN );
$ciphertext = substr( $raw, self::IV_LEN_V2 + self::TAG_LEN );
$plaintext = openssl_decrypt(
$ciphertext,
self::CIPHER_V2,
self::derived_key(),
OPENSSL_RAW_DATA,
$iv,
$tag
);
return false === $plaintext ? $stored : $plaintext;
}
/**
* Decrypt v1 CBC blob (private — backward compatibility path).
*
* NOTE: CBC has no authentication. A successful decrypt does NOT prove the
* ciphertext is intact. Callers should treat v1 plaintext as "trusted as
* much as the surrounding wp_options column was trusted at write time".
* The v1→v2 migration upgrades these values to authenticated GCM.
*
* @param string $stored "enc:v1:..." string.
* @return string Plaintext or original on parse error.
*/
private static function decrypt_v1( string $stored ): string {
$encoded = substr( $stored, strlen( self::PREFIX_V1 ) );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own encrypted data.
$raw = base64_decode( $encoded, true );
if ( false === $raw || strlen( $raw ) <= self::IV_LEN_V1 ) {
return $stored;
}
$iv = substr( $raw, 0, self::IV_LEN_V1 );
$ciphertext = substr( $raw, self::IV_LEN_V1 );
$plaintext = openssl_decrypt(
$ciphertext,
self::CIPHER_V1,
self::derived_key(),
OPENSSL_RAW_DATA,
$iv
);
return false === $plaintext ? $stored : $plaintext;
}
/**
* Encrypt a value and save it to wp_options.
*
* @param string $option_name Option name.
* @param string $plaintext Value to encrypt and store.
* @param bool $autoload Whether to autoload (default false — secrets should never autoload).
* @return bool Whether the option was updated.
*/
public static function set_option( string $option_name, string $plaintext, bool $autoload = false ): bool {
$encrypted = self::encrypt( $plaintext );
return update_option( $option_name, $encrypted, $autoload );
}
/**
* Read a wp_option and decrypt it.
*
* @param string $option_name Option name.
* @param string $fallback Value returned when option is empty.
* @return string Decrypted plaintext (or legacy plaintext, or fallback).
*/
public static function get_option( string $option_name, string $fallback = '' ): string {
$stored = (string) get_option( $option_name, '' );
if ( '' === $stored ) {
return $fallback;
}
return self::decrypt( $stored );
}
/**
* Whether a stored option is encrypted (v1 or v2).
*
* @param string $option_name Option name.
* @return bool
*/
public static function is_encrypted( string $option_name ): bool {
$stored = get_option( $option_name, '' );
if ( ! is_string( $stored ) ) {
return false;
}
return str_starts_with( $stored, self::PREFIX_V2 )
|| str_starts_with( $stored, self::PREFIX_V1 );
}
/**
* Format version of a stored option ("v2", "v1", "plaintext", or "empty").
*
* Non-string option values (arrays / objects) are classified as
* "plaintext" since they are not in any encrypted format. This avoids the
* "Array to string conversion" warning when wp_options stores serialized
* arrays (e.g. `wpdo_features`, `wpdo_bridge_modes`).
*
* @param string $option_name Option name.
* @return string One of: "v2", "v1", "plaintext", "empty".
* @since 2.15.0
*/
public static function format_version( string $option_name ): string {
$stored = get_option( $option_name, '' );
if ( '' === $stored || null === $stored ) {
return 'empty';
}
if ( ! is_string( $stored ) ) {
// Arrays, objects, ints, etc. — never encrypted.
return 'plaintext';
}
if ( str_starts_with( $stored, self::PREFIX_V2 ) ) {
return 'v2';
}
if ( str_starts_with( $stored, self::PREFIX_V1 ) ) {
return 'v1';
}
return 'plaintext';
}
/**
* Migrate a single option from v1 (CBC) to v2 (GCM).
*
* Idempotent — already-v2 options are skipped. Plaintext options are NOT
* touched (the caller decides whether to encrypt; this method only handles
* format upgrade of already-encrypted values).
*
* @param string $option_name Option name.
* @return string One of: "migrated", "already_v2", "plaintext_skipped",
* "empty", "decrypt_failed", "encrypt_failed", "no_op".
* @since 2.15.0
*/
public static function migrate_option_v1_to_v2( string $option_name ): string {
$stored = get_option( $option_name, '' );
if ( '' === $stored || null === $stored ) {
return 'empty';
}
if ( ! is_string( $stored ) ) {
// Arrays / objects are never v1 ciphertext.
return 'plaintext_skipped';
}
if ( str_starts_with( $stored, self::PREFIX_V2 ) ) {
return 'already_v2';
}
if ( ! str_starts_with( $stored, self::PREFIX_V1 ) ) {
return 'plaintext_skipped';
}
// Decrypt v1.
$plaintext = self::decrypt_v1( $stored );
if ( $plaintext === $stored ) {
// decrypt_v1() returns original on failure.
return 'decrypt_failed';
}
// Re-encrypt as v2.
$reencrypted = self::encrypt( $plaintext );
if ( ! str_starts_with( $reencrypted, self::PREFIX_V2 ) ) {
return 'encrypt_failed';
}
// Preserve current autoload flag (don't accidentally flip).
$autoload = wp_cache_get( 'notoptions', 'options' ); // not used; kept here as reference for the option API contract.
$ok = update_option( $option_name, $reencrypted, false );
return $ok ? 'migrated' : 'no_op';
}
/**
* Bulk-migrate every wp_option whose name starts with `wpdo_` (or the
* supplied prefix) from v1 CBC to v2 GCM.
*
* Idempotent: already-v2 / plaintext / empty options are skipped without
* error. Returns counts so the caller can log / display progress.
*
* @param string $option_prefix Prefix to scan (default 'wpdo_').
* @return array{
* scanned:int, migrated:int, already_v2:int, plaintext:int, empty:int,
* failed:int, errors:array<string,string>
* }
* @since 2.15.0
*/
public static function migrate_v1_to_v2( string $option_prefix = 'wpdo_' ): array {
global $wpdb;
$counts = array(
'scanned' => 0,
'migrated' => 0,
'already_v2' => 0,
'plaintext' => 0,
'empty' => 0,
'failed' => 0,
'errors' => array(),
);
$option_names = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( $option_prefix ) . '%'
)
);
foreach ( (array) $option_names as $option_name ) {
++$counts['scanned'];
$result = self::migrate_option_v1_to_v2( $option_name );
switch ( $result ) {
case 'migrated':
++$counts['migrated'];
break;
case 'already_v2':
++$counts['already_v2'];
break;
case 'plaintext_skipped':
++$counts['plaintext'];
break;
case 'empty':
++$counts['empty'];
break;
default:
++$counts['failed'];
$counts['errors'][ $option_name ] = $result;
}
}
return $counts;
}
}
@@ -0,0 +1,297 @@
<?php
/**
* Custom Table Registry — third-party plugin custom-table awareness.
*
* Allows partner plugins (2meet-courses, 2meet-bookings, 2meet-infocards, etc.)
* to register their own custom tables so WPDO can include them in:
* - `wp wpdo doctor` health checks
* - `wp wpdo benchmark --custom-tables`
* - Backup/cleanup tooling
* - Site monitor metrics
*
* Solves audit finding R-3 (Custom Table Provider missing).
*
* @package WP_Data_Optimizer
* @since 2.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Singleton registry for partner plugin custom tables.
*
* Usage (from a partner plugin):
* add_action( 'wpdo_register_custom_tables', function ( TMDO_Custom_Table_Registry $r ) {
* $r->register( '2meet-courses', [
* 'table_name' => '2mc_courses', // raw, without $wpdb->prefix
* 'primary_key' => 'id',
* 'post_type_link' => null,
* 'doctor_callback' => [ '2meet_Courses', 'doctor_check' ],
* 'benchmark_callback' => [ '2meet_Courses', 'benchmark_run' ],
* ] );
* } );
*/
final class TMDO_Custom_Table_Registry {
/**
* Singleton instance.
*
* @var self|null
*/
private static ?self $instance = null;
/**
* Registered custom tables, keyed by `provider:table_name`.
*
* @var array<string, array{provider:string, table_name:string, primary_key:string, post_type_link:?string, doctor_callback:?callable, benchmark_callback:?callable, expected_columns:array, indexes:array}>
*/
private array $tables = array();
/**
* V2.1.2: Secondary index by provider for O(1) `for_provider()` lookup.
* Avoids array_filter scan over the full registry — matters at 100+ tables.
*
* @var array<string, array<string>> provider => list of full keys ('provider:table_name').
*/
private array $by_provider = array();
/**
* Whether the registration hook has fired.
*
* @var bool
*/
private bool $registration_done = false;
/**
* Private constructor — use instance().
*/
private function __construct() {}
/**
* Singleton accessor.
*/
public static function instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Reset for tests only.
*
* @internal
*/
public static function reset_for_tests(): void {
self::$instance = null;
}
/**
* Fire `wpdo_register_custom_tables` action so partner plugins can register.
*
* Called by TMDO_Core::run() and again on plugins_loaded:30 (after all
* partner plugins have had a chance to attach their listeners). Safe to
* call multiple times — `register()` is idempotent on (provider, table_name).
*
* @return void
*/
public function fire_registration(): void {
$this->registration_done = true;
/**
* Action: wpdo_register_custom_tables
*
* Partner plugins should register their custom tables here.
*
* @param TMDO_Custom_Table_Registry $registry Registry instance.
*/
do_action( 'wpdo_register_custom_tables', $this );
}
/**
* Register a custom table from a partner plugin.
*
* @param string $provider Plugin slug (e.g. '2meet-courses').
* @param array $config Table configuration:
* - table_name (string, required) Raw table name without $wpdb->prefix.
* - primary_key (string, default 'id') Primary key column name.
* - post_type_link (?string) Linked post_type, or null if standalone.
* - doctor_callback (?callable) Returns array of {ok:bool, message:string} for doctor.
* - benchmark_callback (?callable) Returns array of {duration_ms:float, sample_size:int}.
* - expected_columns (array<string,string>) Column => SQL type for schema drift detection.
* - indexes (array<string,string[]>) Index name => column list for index health.
* @return bool True on success, false on validation failure or duplicate.
*/
public function register( string $provider, array $config ): bool {
$config = wp_parse_args(
$config,
array(
'table_name' => '',
'primary_key' => 'id',
'post_type_link' => null,
'doctor_callback' => null,
'benchmark_callback' => null,
'expected_columns' => array(),
'indexes' => array(),
)
);
if ( '' === $config['table_name'] || '' === $provider ) {
return false;
}
// Sanitize table name to a safe identifier.
$config['table_name'] = sanitize_key( $config['table_name'] );
if ( '' === $config['table_name'] ) {
return false;
}
$config['provider'] = $provider;
$key = $provider . ':' . $config['table_name'];
if ( isset( $this->tables[ $key ] ) ) {
return false; // Already registered.
}
$this->tables[ $key ] = $config;
// v2.1.2: maintain secondary index by provider for O(1) lookup.
$this->by_provider[ $provider ][] = $key;
return true;
}
/**
* Unregister a custom table (test or runtime cleanup).
*
* @param string $provider Plugin slug.
* @param string $table_name Raw table name.
*/
public function unregister( string $provider, string $table_name ): bool {
$key = $provider . ':' . sanitize_key( $table_name );
if ( ! isset( $this->tables[ $key ] ) ) {
return false;
}
unset( $this->tables[ $key ] );
// v2.1.2: keep secondary index in sync.
if ( isset( $this->by_provider[ $provider ] ) ) {
$this->by_provider[ $provider ] = array_values( array_diff( $this->by_provider[ $provider ], array( $key ) ) );
if ( empty( $this->by_provider[ $provider ] ) ) {
unset( $this->by_provider[ $provider ] );
}
}
return true;
}
/**
* Get all registered tables.
*
* @return array<string, array>
*/
public function all(): array {
return $this->tables;
}
/**
* Get tables for a specific provider.
*
* @param string $provider Plugin slug.
* @return array<string, array>
*/
public function for_provider( string $provider ): array {
// v2.1.2: O(1) via secondary index instead of O(n) array_filter.
// Drop-in equivalent — same return shape (key=full_key, value=config).
if ( ! isset( $this->by_provider[ $provider ] ) ) {
return array();
}
$out = array();
foreach ( $this->by_provider[ $provider ] as $key ) {
if ( isset( $this->tables[ $key ] ) ) {
$out[ $key ] = $this->tables[ $key ];
}
}
return $out;
}
/**
* Get tables linked to a specific post_type.
*
* @param string $post_type Post type slug.
* @return array<string, array>
*/
public function for_post_type( string $post_type ): array {
return array_filter(
$this->tables,
static fn( $cfg ) => isset( $cfg['post_type_link'] ) && $cfg['post_type_link'] === $post_type
);
}
/**
* Get all unique provider names.
*
* @return string[]
*/
public function providers(): array {
$set = array();
foreach ( $this->tables as $cfg ) {
$set[ $cfg['provider'] ] = true;
}
return array_keys( $set );
}
/**
* Summary stats for admin dashboard.
*
* @return array{tables_count:int, providers_count:int, with_doctor:int, with_benchmark:int}
*/
public function get_stats(): array {
$with_doctor = 0;
$with_benchmark = 0;
foreach ( $this->tables as $cfg ) {
if ( is_callable( $cfg['doctor_callback'] ?? null ) ) {
++$with_doctor;
}
if ( is_callable( $cfg['benchmark_callback'] ?? null ) ) {
++$with_benchmark;
}
}
return array(
'tables_count' => count( $this->tables ),
'providers_count' => count( $this->providers() ),
'with_doctor' => $with_doctor,
'with_benchmark' => $with_benchmark,
);
}
/**
* Run all registered doctor callbacks and aggregate results.
*
* @return array<string, array{provider:string, table:string, ok:bool, message:string}>
*/
public function run_doctor_checks(): array {
$results = array();
foreach ( $this->tables as $key => $cfg ) {
if ( ! is_callable( $cfg['doctor_callback'] ?? null ) ) {
continue;
}
try {
$result = call_user_func( $cfg['doctor_callback'], $cfg['table_name'] );
$results[ $key ] = array(
'provider' => $cfg['provider'],
'table' => $cfg['table_name'],
'ok' => (bool) ( $result['ok'] ?? false ),
'message' => (string) ( $result['message'] ?? '' ),
);
} catch ( \Throwable $e ) {
$results[ $key ] = array(
'provider' => $cfg['provider'],
'table' => $cfg['table_name'],
'ok' => false,
'message' => 'doctor_callback threw: ' . $e->getMessage(),
);
}
}
return $results;
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
/**
* Database abstraction layer for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* TMDO_DB — Database abstraction layer for MySQL / SQLite dual support.
*
* Provides unified transaction interface, SQL dialect helpers, and
* table name resolution. Absorbs and extends the FCB_DB pattern.
*/
class TMDO_DB {
/**
* Check if the current database engine is SQLite.
*/
public static function is_sqlite(): bool {
return TMDO_IS_SQLITE;
}
/**
* Check if the current database engine is MySQL.
*/
public static function is_mysql(): bool {
return TMDO_IS_MYSQL;
}
/**
* Begin a database transaction.
*
* SQLite: BEGIN IMMEDIATE (write-lock, prevents concurrent writes).
* MySQL: START TRANSACTION (row-level locking via InnoDB).
*/
public static function begin(): void {
global $wpdb;
$wpdb->query( self::is_sqlite() ? 'BEGIN IMMEDIATE' : 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static SQL literals; no user input.
}
/**
* Commit the current transaction.
*/
public static function commit(): void {
global $wpdb;
$wpdb->query( 'COMMIT' );
}
/**
* Roll back the current transaction.
*/
public static function rollback(): void {
global $wpdb;
$wpdb->query( 'ROLLBACK' );
}
/**
* Get the current WordPress local time as a MySQL datetime string.
*/
public static function now(): string {
return current_time( 'mysql' );
}
/**
* Get the full table name with wpdb prefix.
*
* @param string $name Table name without prefix (e.g. 'wpdo_warm').
* @return string Fully-prefixed, sanitized table name.
*/
public static function table( string $name ): string {
global $wpdb;
return $wpdb->prefix . sanitize_key( $name );
}
/**
* Execute an INSERT IGNORE statement (MySQL) or INSERT OR IGNORE (SQLite).
*
* @param string $table Full table name.
* @param array $data Column => value pairs.
* @param array $format Optional wpdb format array ('%s', '%d', etc.).
* @return int|false Number of rows affected, or false on error.
*/
public static function insert_ignore( string $table, array $data, array $format = array() ): int|false {
global $wpdb;
$columns = array_keys( $data );
$values = array_values( $data );
$col_list = implode( ', ', array_map( fn( $c ) => '`' . sanitize_key( $c ) . '`', $columns ) );
$placeholder = implode( ', ', $format ?: array_fill( 0, count( $values ), '%s' ) );
$keyword = self::is_sqlite() ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
$sql = "{$keyword} INTO `{$table}` ({$col_list}) VALUES ({$placeholder})";
return $wpdb->query( $wpdb->prepare( $sql, ...$values ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Build a JSON_EXTRACT expression compatible with both MySQL and SQLite.
*
* Both MySQL 5.7+ and SQLite 3.38+ support json_extract().
*
* @param string $column Column name containing JSON data.
* @param string $path JSON path (e.g. '$.social_links').
* @return string SQL expression.
*/
public static function json_extract( string $column, string $path ): string {
$column = sanitize_key( $column );
// JSON path must start with '$' and contain only word chars, dots, brackets, and digits.
if ( ! preg_match( '/^\$[\w.\[\]0-9]*$/', $path ) ) {
$path = '$';
}
return "json_extract(`{$column}`, '{$path}')";
}
/**
* Build an UPSERT statement.
*
* MySQL: INSERT ... ON DUPLICATE KEY UPDATE ...
* SQLite: INSERT ... ON CONFLICT(...) DO UPDATE SET ...
*
* @param string $table Full table name.
* @param array $data Column => value pairs to insert.
* @param array $update_columns Columns to update on conflict.
* @param string|string[] $conflict_key Column name (string) or columns (array)
* for ON CONFLICT (SQLite). On MySQL it
* is informational only — ON DUPLICATE
* KEY UPDATE matches any unique key.
* @param array $format Optional wpdb format array.
* @return int|false
*/
public static function upsert( string $table, array $data, array $update_columns, string|array $conflict_key = 'post_id', array $format = array() ): int|false {
global $wpdb;
$columns = array_keys( $data );
$values = array_values( $data );
// Normalise composite vs single conflict key.
$conflict_keys = is_array( $conflict_key ) ? $conflict_key : array( $conflict_key );
$conflict_keys = array_map( 'sanitize_key', $conflict_keys );
$conflict_list = implode(
', ',
array_map( static fn( $k ) => '`' . $k . '`', $conflict_keys )
);
$col_list = implode( ', ', array_map( fn( $c ) => '`' . sanitize_key( $c ) . '`', $columns ) );
// Build per-value placeholders, using NULL literal for null values.
$prepare_values = array();
$placeholders = array();
$fmt = $format ?: array_fill( 0, count( $values ), '%s' );
foreach ( $values as $i => $v ) {
if ( null === $v ) {
$placeholders[] = 'NULL';
} else {
$placeholders[] = $fmt[ $i ] ?? '%s';
$prepare_values[] = $v;
}
}
$placeholder = implode( ', ', $placeholders );
// ON DUPLICATE KEY UPDATE: null columns use NULL literal, others use VALUES().
$null_cols = array();
foreach ( $update_columns as $c ) {
$col_index = array_search( $c, $columns, true );
if ( false !== $col_index && null === $values[ $col_index ] ) {
$null_cols[] = $c;
}
}
if ( self::is_mysql() ) {
$updates = implode(
', ',
array_map(
function ( $c ) use ( $null_cols ) {
$safe = sanitize_key( $c );
return in_array( $c, $null_cols, true )
? "`{$safe}` = NULL"
: "`{$safe}` = VALUES(`{$safe}`)";
},
$update_columns
)
);
$sql = "INSERT INTO `{$table}` ({$col_list}) VALUES ({$placeholder}) ON DUPLICATE KEY UPDATE {$updates}";
} else {
$updates = implode(
', ',
array_map(
function ( $c ) use ( $null_cols ) {
$safe = sanitize_key( $c );
return in_array( $c, $null_cols, true )
? "`{$safe}` = NULL"
: "`{$safe}` = excluded.`{$safe}`";
},
$update_columns
)
);
$sql = "INSERT INTO `{$table}` ({$col_list}) VALUES ({$placeholder}) ON CONFLICT({$conflict_list}) DO UPDATE SET {$updates}";
}
if ( empty( $prepare_values ) ) {
return $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
return $wpdb->query( $wpdb->prepare( $sql, ...$prepare_values ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
}
+346
View File
@@ -0,0 +1,346 @@
<?php
/**
* Feature flags manager for WPDO module lifecycle states.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Feature flags for each WPDO module.
*
* Option key: wpdo_features (serialized array)
*
* 7-state lifecycle per module (extends HPCT's 4-state model):
* idle → dual_write → backfill → verify → cutover → cleanup → complete
*
* Any state can roll back to idle.
*/
class TMDO_Feature_Flags {
/** Option name storing all module states. */
private const OPTION_KEY = 'wpdo_features';
/**
* All known HPCT-inherited modules.
* These modules operate on existing hpct_* tables.
*/
public const HPCT_MODULES = array(
'reviews',
'messages',
'favorites',
'memberships',
'statistics',
'requests',
'listing_meta',
'wc_orders',
'latepoint',
);
/**
* Zone-based modules (new in WPDO).
* These modules operate on wpdo_* tables.
*/
public const ZONE_MODULES = array(
'hot_hp_listing',
'hot_hp_vendor',
'warm',
'cold_hp_listing',
'cold_hp_vendor',
'archive',
);
/** Valid lifecycle states. */
public const VALID_STATES = array(
'idle',
'dual_write',
'backfill',
'verify',
'cutover',
'cleanup',
'complete',
);
/**
* States in which interceptors should capture writes (dual-write active).
*/
public const WRITE_ACTIVE_STATES = array(
'dual_write',
'backfill',
'verify',
'cutover',
);
/**
* States in which reads come from the custom/zone table.
*/
public const READ_CUSTOM_STATES = array(
'cutover',
'cleanup',
'complete',
);
/**
* States in which query interceptors are active.
*/
public const QUERY_ACTIVE_STATES = array(
'cutover',
'cleanup',
'complete',
);
/**
* Get the state of a single module.
*
* @param string $module Module name.
* @return string One of VALID_STATES, defaults to 'idle'.
*/
public static function get( string $module ): string {
$flags = self::all();
return $flags[ $module ] ?? 'idle';
}
/**
* Set the state of a module.
*
* V2.2.0 M2: gated by TMDO_FSM_Guard — invalid transitions are blocked
* (returns false), destructive transitions automatically snapshot first.
* Filter `wpdo/fsm_guard/bypass` allows CLI/tests to override.
*
* @param string $module Module name.
* @param string $state One of VALID_STATES.
* @return bool|WP_Error true on success, WP_Error on guard rejection,
* false on invalid state name.
*/
public static function set( string $module, string $state ) {
if ( ! in_array( $state, self::VALID_STATES, true ) ) {
return false;
}
// FSM Guard: validate transition + auto-snapshot if destructive.
// Defensive: only invoke when class is loaded AND not explicitly
// disabled (raw-PHP unit harness sets TMDO_FSM_GUARD_DISABLED).
// Dedicated FSMGuardTest unsets the constant before its own assertions.
$guard_active = class_exists( 'TMDO_FSM_Guard' )
&& ! ( defined( 'TMDO_FSM_GUARD_DISABLED' ) && TMDO_FSM_GUARD_DISABLED );
if ( $guard_active ) {
$check = TMDO_FSM_Guard::before_transition( $module, $state );
if ( is_wp_error( $check ) ) {
return $check;
}
}
$flags = self::all();
$flags[ $module ] = $state;
update_option( self::OPTION_KEY, $flags );
self::$cache = null; // Invalidate request-level cache.
// Record entry timestamp (M2) for downstream verify-gate / wash-period checks.
if ( $guard_active ) {
TMDO_FSM_Guard::record_entry( $module, $state );
}
return true;
}
// ── shadow_read_only sub-flag (PR-4) ────────────────────────────────────
// Orthogonal to the main 7-state FSM. When enabled for a module that is in
// the `verify` state, every read fans out to BOTH postmeta and the zone
// table; divergence is logged to wp_wpdo_shadow_diffs without affecting
// the served value. Lets ops bake confidence before cutover.
/** Option key storing per-module shadow_read_only flags. */
private const SHADOW_OPTION_KEY = 'wpdo_features_shadow';
/**
* Request-level cache for shadow flags.
*
* @var array<string,bool>|null
*/
private static ?array $shadow_cache = null;
/**
* Enable shadow_read_only for a module (only meaningful in the verify state).
*
* @param string $module Module identifier.
* @return bool True on success.
*/
public static function enable_shadow_read( string $module ): bool {
$flags = self::all_shadow();
$flags[ $module ] = true;
update_option( self::SHADOW_OPTION_KEY, $flags, false );
self::$shadow_cache = null;
return true;
}
/**
* Disable shadow_read_only for a module.
*
* @param string $module Module identifier.
* @return bool
*/
public static function disable_shadow_read( string $module ): bool {
$flags = self::all_shadow();
unset( $flags[ $module ] );
update_option( self::SHADOW_OPTION_KEY, $flags, false );
self::$shadow_cache = null;
return true;
}
/**
* Whether shadow_read_only is currently active for a module.
*
* Returns true only when:
* - Module is in the `verify` state, AND
* - Shadow flag has been explicitly enabled.
*
* @param string $module Module identifier.
* @return bool
*/
public static function is_shadow_read_active( string $module ): bool {
if ( 'verify' !== self::get( $module ) ) {
return false;
}
$flags = self::all_shadow();
return ! empty( $flags[ $module ] );
}
/**
* Return all shadow flags.
*
* @return array<string,bool>
*/
public static function all_shadow(): array {
if ( null !== self::$shadow_cache ) {
return self::$shadow_cache;
}
$saved = get_option( self::SHADOW_OPTION_KEY, array() );
if ( ! is_array( $saved ) ) {
$saved = array();
}
self::$shadow_cache = $saved;
return $saved;
}
/**
* Check if a module is fully complete (reads and writes on custom table only).
*
* @param string $module Module identifier.
* @return bool True if module state is 'complete'.
*/
public static function is_complete( string $module ): bool {
return 'complete' === self::get( $module );
}
/**
* Check if dual-write is active for a module.
*
* @param string $module Module identifier.
* @return bool True if module is in a write-active state.
*/
public static function is_write_active( string $module ): bool {
return in_array( self::get( $module ), self::WRITE_ACTIVE_STATES, true );
}
/**
* Check if reads should come from the custom/zone table.
*
* @param string $module Module identifier.
* @return bool True if module is in a read-custom state.
*/
public static function is_read_custom( string $module ): bool {
return in_array( self::get( $module ), self::READ_CUSTOM_STATES, true );
}
/**
* Check if query interceptors should be active.
*
* @param string $module Module identifier.
* @return bool True if module is in a query-active state.
*/
public static function is_query_active( string $module ): bool {
return in_array( self::get( $module ), self::QUERY_ACTIVE_STATES, true );
}
/**
* Reset a module to idle (rollback).
*
* @param string $module Module identifier.
* @return void
*/
public static function reset( string $module ): void {
self::set( $module, 'idle' );
self::$cache = null; // Invalidate request-level cache.
}
/**
* Request-level cache for all() to avoid repeated get_option() calls.
*
* @var array|null
*/
private static ?array $cache = null;
/**
* Return all module states.
*
* @return array<string, string>
*/
public static function all(): array {
if ( null !== self::$cache ) {
return self::$cache;
}
$saved = get_option( self::OPTION_KEY, array() );
if ( ! is_array( $saved ) ) {
$saved = array();
}
$all_modules = array_merge( self::HPCT_MODULES, self::ZONE_MODULES );
$defaults = array_fill_keys( $all_modules, 'idle' );
self::$cache = array_merge( $defaults, $saved );
return self::$cache;
}
/**
* Return only HPCT-inherited module states.
*
* @return array<string, string>
*/
public static function hpct_modules(): array {
$all = self::all();
return array_intersect_key( $all, array_flip( self::HPCT_MODULES ) );
}
/**
* Return only zone module states.
*
* @return array<string, string>
*/
public static function zone_modules(): array {
$all = self::all();
return array_intersect_key( $all, array_flip( self::ZONE_MODULES ) );
}
/**
* Map an HPCT feature flag status to a WPDO state.
*
* Used during HPCT import to translate HPCT's 4-state model
* to WPDO's 7-state model.
*
* @param string $hpct_status HPCT status (disabled/migrating/verified/enabled).
* @return string WPDO state.
*/
public static function map_hpct_status( string $hpct_status ): string {
return match ( $hpct_status ) {
'disabled' => 'idle',
'migrating' => 'backfill',
'verified' => 'cutover',
'enabled' => 'complete',
default => 'idle',
};
}
}
+232
View File
@@ -0,0 +1,232 @@
<?php
/**
* TMDO_Hook_Bus_Bridge — feature-flagged Hook Bus integration layer.
*
* PR-3 introduces a unified Hook Bus that will eventually replace the per-
* interceptor `add_filter()` registrations. To avoid breaking production
* during the transition, the unified bus is opt-in via the
* `wpdo_hook_bus_enabled` option (default: false).
*
* When enabled, the Hook Bus takes over `update_post_metadata` /
* `get_post_metadata` at priority 8, dispatching to handlers (via
* TMDO_Adapter_Post + TMDO_Entity_Registry). When disabled, the existing
* 9 interceptors continue to operate at priority 10 untouched.
*
* Use `TMDO_Hook_Bus_Bridge::is_enabled()` to gate any code path that
* should defer to the unified bus.
*
* @package WP_Data_Optimizer
* @since 2.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Bridge / feature-flag layer between the legacy interceptors and the unified
* TMDO_Hook_Bus introduced by the UAE port.
*/
final class TMDO_Hook_Bus_Bridge {
/** Option name for the feature flag. */
public const OPTION = 'wpdo_hook_bus_enabled';
/**
* Request-level cache for is_enabled() checks.
*
* @var bool|null
*/
private static ?bool $cache = null;
/**
* Whether the unified Hook Bus is enabled for this site.
*
* Default: false (legacy interceptors at priority 10 remain active).
* To enable: `update_option( 'wpdo_hook_bus_enabled', '1' )` or
* `wp option update wpdo_hook_bus_enabled 1`.
*
* @return bool
*/
public static function is_enabled(): bool {
if ( null !== self::$cache ) {
return self::$cache;
}
$value = get_option( self::OPTION, '1' ); // Default ON as of v2.5.4.
self::$cache = ( '1' === (string) $value || true === $value );
return self::$cache;
}
/**
* Reset request cache — for tests and runtime mode toggles.
*
* @internal
*/
public static function reset_cache(): void {
self::$cache = null;
}
/**
* Boot the unified Hook Bus when enabled.
*
* Called from TMDO_Core::run() after legacy interceptors have a chance to
* register. The Hook Bus will silently skip startup if disabled.
*
* @return void
*/
public static function maybe_init_hook_bus(): void {
if ( ! self::is_enabled() ) {
return;
}
if ( ! class_exists( 'TMDO_Hook_Bus' ) ) {
return;
}
// Initialize the unified bus. Only Adapter_Post handlers will be
// registered by default (PR-3 scope); other entity adapters land in PR-5.
TMDO_Hook_Bus::init();
}
/**
* Whitelist of WPDO interceptors that are designed to coexist on the same
* hook + priority. They each filter on a disjoint meta_key prefix, so
* "multiple callbacks per hook" does not imply a real conflict.
*
* @var string[]
*/
private const COEXIST_WHITELIST = array(
'TMDO_Reviews_Interceptor',
'TMDO_Messages_Interceptor',
'TMDO_Favorites_Interceptor',
'TMDO_Memberships_Interceptor',
'TMDO_Statistics_Interceptor',
'TMDO_Requests_Interceptor',
'TMDO_Listing_Meta_Interceptor',
'TMDO_WC_Orders_Interceptor',
'TMDO_LatePoint_Interceptor',
'TMDO_Sync_Bridge',
'TMDO_Cache_Layer',
'TMDO_Listing_Stats',
'TMDO_Hook_Bus',
// v2.11.5+: HivePress per-post transient cache rerouter. Filters at
// priority 9 (before Hook Bus at 10) and matches only `_transient_hp_*`
// meta_keys — fully disjoint from any other interceptor.
'TMDO_Hivepress_Transient_Filter',
// v2.12.1+: Term + Comment garbage write-time filter. Filters
// add/update_term_metadata + add/update_comment_metadata at priority 9
// and matches only `_wxr_import_*` / `_2meet_demo_*` / 8 orphan
// post-meta keys — fully disjoint from any other interceptor.
'TMDO_Term_Comment_Garbage_Filter',
// v2.12.3+: WC term count cache rerouter. Filters at priority 9 and
// matches only `product_count_*` term meta keys — fully disjoint
// from any other interceptor.
'TMDO_WC_Term_Count_Filter',
// v2.12.4+: Term + Comment misc bucket (catch-all). Filters at
// priority 99 (LAST), only handles writes where every other filter
// returned null. Disjoint by design.
'TMDO_Term_Comment_Misc_Bucket',
// v2.13.0+: Term Stress Tester. Bulk fixture generator that doesn't
// register any metadata filter — listed here so admin Conflict Detector
// recognizes it as a known WPDO class even though it's filter-disjoint.
'TMDO_Term_Stress_Tester',
// v2.13.1+: Comment Stress Tester. Same role for comment entity —
// bulk fixture generator with no metadata filter registration.
'TMDO_Comment_Stress_Tester',
);
/**
* Detect interceptor priority overlap that risks duplicate writes.
*
* Inspects the metadata filters and reports cases where two non-whitelisted
* WPDO callbacks compete on the same hook. Whitelisted interceptors (the 9
* HPCT-inherited modules + Sync_Bridge) are designed to coexist by
* filtering on disjoint meta_key prefixes — they are NOT real conflicts.
*
* Real conflicts: a third-party plugin registers `TMDO_Custom_*` AND
* collides with an existing whitelisted interceptor on the same priority.
*
* Used by the admin Conflict Detector tab + `wp wpdo conflict-scan`.
*
* @return array<int, array{hook:string, priority:int, callback:string}>
*/
public static function detect_intra_wpdo_conflicts(): array {
global $wp_filter;
$findings = array();
$hooks_to_check = array(
'update_post_metadata',
'add_post_metadata',
'delete_post_metadata',
'get_post_metadata',
);
foreach ( $hooks_to_check as $hook ) {
if ( ! isset( $wp_filter[ $hook ] ) ) {
continue;
}
// Collect TMDO_* callbacks AND identify which are non-whitelisted.
$wpdo_callbacks = array();
$non_whitelist_callbacks = array();
$priorities = $wp_filter[ $hook ]->callbacks ?? array();
foreach ( $priorities as $priority => $callbacks ) {
foreach ( $callbacks as $cb ) {
$cb = $cb['function'] ?? null;
if ( null === $cb ) {
continue;
}
$class_name = self::callable_class_name( $cb );
if ( null === $class_name || ! str_starts_with( $class_name, 'TMDO_' ) ) {
continue;
}
$entry = array(
'class' => $class_name,
'priority' => (int) $priority,
);
$wpdo_callbacks[] = $entry;
if ( ! in_array( $class_name, self::COEXIST_WHITELIST, true ) ) {
$non_whitelist_callbacks[] = $entry;
}
}
}
// Real conflict: any non-whitelisted WPDO callback overlapping with
// other WPDO callbacks on the same hook.
if ( ! empty( $non_whitelist_callbacks ) && count( $wpdo_callbacks ) > 1 ) {
foreach ( $non_whitelist_callbacks as $cb ) {
$findings[] = array(
'hook' => $hook,
'priority' => $cb['priority'],
'callback' => $cb['class'],
);
}
}
}
return $findings;
}
/**
* Extract the class name from a callable, or return null when not class-bound.
*
* @param mixed $cb Any PHP callable.
* @return string|null Fully qualified class name, or null.
*/
private static function callable_class_name( $cb ): ?string {
if ( is_array( $cb ) && isset( $cb[0] ) ) {
$obj = $cb[0];
if ( is_object( $obj ) ) {
return get_class( $obj );
}
if ( is_string( $obj ) ) {
return $obj;
}
}
if ( is_string( $cb ) && str_contains( $cb, '::' ) ) {
return strtok( $cb, ':' );
}
return null;
}
}
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* Error logging for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Lightweight error logger — writes to wpdo_errors table and PHP error_log.
*/
class TMDO_Logger {
/**
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
*
* Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved
* for actual errors). Mode changes / cache flushes / audit prunes call here
* many times per request — keeping them out of the DB error table.
*
* @param string $event Event name (e.g. 'bridge_mode_changed').
* @param array $context Structured context.
* @return void
*/
public static function info( string $event, array $context = array() ): void {
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
}
/**
* Log a NOTICE-level event — alias of info() for callers that want a stronger
* level intent. Same routing (error_log only).
*
* @param string $event Event name.
* @param array $context Structured context.
* @return void
*/
public static function notice( string $event, array $context = array() ): void {
self::info( $event, $context );
}
/**
* Log a WARNING-level event — error_log + wpdo_errors row (for admin visibility).
*
* Use for "anomaly worth attention but not breaking" (e.g. invalid filter
* return, unexpected fallback path). The event_name becomes the `hook` column;
* message gets a `[WARN]` prefix to distinguish from hard errors.
*
* @param string $event Event name (e.g. 'wpdo_route_decision_invalid_return').
* @param array $context Structured context.
* @return void
*/
public static function warning( string $event, array $context = array() ): void {
error_log( sprintf( '[WPDO][WARN][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
self::error( 'engine', $event, '[WARN] ' . $event, $context );
}
/**
* Log a DEBUG-level event — only when WP_DEBUG is true; routes to error_log.
*
* @param string $event Event name.
* @param array $context Structured context.
* @return void
*/
public static function debug( string $event, array $context = array() ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
self::info( $event, $context );
}
}
/**
* Log a module error.
*
* @param string $module Module name (e.g. 'reviews', 'hot_hp_listing').
* @param string $hook The hook or method where the error occurred.
* @param string $message Human-readable error message.
* @param array $context Optional extra context (will be JSON-encoded).
* @param string $zone Optional zone identifier (hot/warm/cold/archive).
*/
public static function error( string $module, string $hook, string $message, array $context = array(), string $zone = '' ): void {
global $wpdb;
error_log( sprintf( '[WPDO][%s][%s] %s', $module, $hook, $message ) );
$table = TMDO_DB::table( 'wpdo_errors' );
$wpdb->insert(
$table,
array(
'module' => sanitize_key( $module ),
'zone' => sanitize_key( $zone ),
'hook' => substr( sanitize_text_field( $hook ), 0, 255 ),
'message' => $message,
'context' => $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : null,
'created_at' => current_time( 'mysql', true ),
),
array( '%s', '%s', '%s', '%s', '%s', '%s' )
);
}
/**
* Return recent errors, optionally filtered by module.
*
* @param string $module Module name (empty = all modules).
* @param int $limit Max rows to return.
* @return array
*/
public static function get_recent( string $module = '', int $limit = 100 ): array {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_errors' );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
if ( $module ) {
return $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$table}` WHERE module = %s ORDER BY id DESC LIMIT %d",
$module,
$limit
),
ARRAY_A
) ?: array();
}
return $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d", $limit ),
ARRAY_A
) ?: array();
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Delete errors older than a given number of days.
*
* @param int $days Keep errors from the last N days.
* @return int Number of rows deleted.
*/
public static function purge( int $days = 30 ): int {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_errors' );
$now = current_time( 'mysql', true );
if ( TMDO_IS_SQLITE ) {
$sql = $wpdb->prepare(
"DELETE FROM `{$table}` WHERE created_at < datetime(%s, %s)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$now,
"-{$days} days"
);
} else {
$sql = $wpdb->prepare(
"DELETE FROM `{$table}` WHERE created_at < DATE_SUB(%s, INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$now,
$days
);
}
return (int) $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
}
@@ -0,0 +1,437 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform shadow verifier: must read raw meta to verify zone correctness
/**
* TMDO_Post_Shadow_Verifier — Sample-and-compare flat vs wp_postmeta (v2.10.3).
*
* Companion to TMDO_Post_Migration. Used during shadow_read mode to verify
* the flat tables stay in sync with wp_postmeta. Each sample picks a random
* post + key, fetches both values (flat row vs wp_postmeta row), and counts
* matches / diffs / missing rows. Divergent values are written to the
* shared `wpdo_shadow_diffs` table via `TMDO_Shadow_Diff_Logger` (which is
* already entity_type-aware — pass 'post' to differentiate from user diffs).
*
* Run pattern:
* - Manual: `wp wpdo post-shadow-report`
* - Cron: wpdo_post_shadow_verify event registered when post mode is
* shadow_read; auto-unregistered when mode changes to anything else
*
* 🔒 Frozen contract: never touches user-side flat tables, never modifies
* wp_postmeta or wp_posts (read-only verifier).
*
* @package WP_Data_Optimizer
* @since 2.10.3
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Internal verifier: $flat_table comes from registry, columns sanitized via Schema_Manager, user values use prepare(). Verifier is read-only.
/**
* Sample-and-compare verifier for post entity flat tables.
*/
final class TMDO_Post_Shadow_Verifier {
/**
* Cron event hook fired hourly when post mode is shadow_read.
*/
public const CRON_HOOK = 'wpdo_post_shadow_verify';
/**
* Default sample size per cron tick (capped to available post count).
*/
public const DEFAULT_SAMPLE_SIZE = 100;
/**
* Run a sample-compare pass.
*
* Picks $sample_size random posts of $post_type, fetches their flat row
* + their wp_postmeta values for each $keys entry, and counts matches.
* Divergences are recorded via TMDO_Shadow_Diff_Logger when the class
* is available.
*
* @param string $post_type WP post_type to sample.
* @param string $group_name Entity group name (e.g. 'wc_product').
* @param string $flat_table Fully qualified flat table name.
* @param string[] $keys Meta keys to compare (each post checks all).
* @param int $sample_size Number of posts to sample (capped to DB count).
* @return array{
* sampled:int,
* matched:int,
* diffs:int,
* missing_flat:int,
* missing_postmeta:int,
* group:string,
* post_type:string,
* }
* @throws InvalidArgumentException When inputs invalid.
*/
public static function sample_compare(
string $post_type,
string $group_name,
string $flat_table,
array $keys,
int $sample_size = self::DEFAULT_SAMPLE_SIZE
): array {
if ( $sample_size <= 0 ) {
$msg = 'Sample size must be > 0';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
if ( empty( $keys ) ) {
$msg = 'Keys array cannot be empty';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
global $wpdb;
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s ORDER BY RAND() LIMIT %d",
$post_type,
$sample_size
)
);
$sampled = count( $post_ids );
$matched = 0;
$diffs = 0;
$missing_flat = 0;
$missing_postmeta = 0;
if ( 0 === $sampled ) {
return array(
'sampled' => 0,
'matched' => 0,
'diffs' => 0,
'missing_flat' => 0,
'missing_postmeta' => 0,
'group' => $group_name,
'post_type' => $post_type,
);
}
foreach ( $post_ids as $post_id ) {
$post_id = (int) $post_id;
foreach ( $keys as $key ) {
$col = self::sanitize_column( $key );
$flat_val = $wpdb->get_var(
$wpdb->prepare(
"SELECT `{$col}` FROM `{$flat_table}` WHERE post_id = %d LIMIT 1",
$post_id
)
);
$pm_val = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
$post_id,
$key
)
);
$flat_present = null !== $flat_val && '' !== $flat_val;
$pm_present = null !== $pm_val && '' !== $pm_val;
if ( ! $flat_present && ! $pm_present ) {
// Both empty — neither side has the value, count as match
// (key truly absent for this post).
++$matched;
continue;
}
if ( ! $flat_present && $pm_present ) {
++$missing_flat;
self::log_diff( $post_id, $key, (string) $pm_val, '(missing)' );
continue;
}
if ( $flat_present && ! $pm_present ) {
++$missing_postmeta;
self::log_diff( $post_id, $key, '(missing)', (string) $flat_val );
continue;
}
// Loose equality — flat may have widened types (e.g. tinyint→bigint)
// or full-precision decimals (10,2 → 18,6).
if ( self::values_loose_equal( $pm_val, $flat_val ) ) {
++$matched;
} else {
++$diffs;
self::log_diff( $post_id, $key, (string) $pm_val, (string) $flat_val );
}
}
}
return array(
'sampled' => $sampled,
'matched' => $matched,
'diffs' => $diffs,
'missing_flat' => $missing_flat,
'missing_postmeta' => $missing_postmeta,
'group' => $group_name,
'post_type' => $post_type,
);
}
/**
* Cron handler: runs sample_compare for each registered group when post
* mode is shadow_read. No-op otherwise.
*
* @return void
*/
public static function cron_tick(): void {
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return;
}
if ( 'shadow_read' !== TMDO_Mode_Manager::get( 'post' ) ) {
return;
}
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
global $wpdb;
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
$keys = TMDO_Entity_Registry::get_group_keys( 'post', $group );
if ( empty( $keys ) ) {
continue;
}
$post_type = self::group_post_type( $group );
if ( null === $post_type ) {
continue; // wp_core spans all types, skip in cron tick.
}
$flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
try {
self::sample_compare( $post_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
} catch ( \Throwable $e ) {
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::error(
'post_shadow_verifier',
'cron_tick',
$e->getMessage()
);
}
}
}
}
/**
* Recent diff records for entity_type='post'.
*
* @param int $limit Max rows.
* @return array
*/
public static function recent_diffs( int $limit = 100 ): array {
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
return array();
}
return TMDO_Shadow_Diff_Logger::recent( $limit, 'post' );
}
/**
* Aggregate diff stats for entity_type='post' over recent N hours.
*
* @param int $hours Window in hours (default 24).
* @return array{total:int,by_key:array<string,int>}
*/
public static function diff_stats( int $hours = 24 ): array {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
return array(
'total' => 0,
'by_key' => array(),
);
}
$since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) );
$total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s",
$since
)
);
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s GROUP BY meta_key ORDER BY n DESC",
$since
),
ARRAY_A
);
$by_key = array();
foreach ( (array) $rows as $row ) {
$by_key[ (string) $row['meta_key'] ] = (int) $row['n'];
}
return array(
'total' => $total,
'by_key' => $by_key,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Sanitize meta_key into a column name (mirrors Schema_Manager rules).
*
* @param string $key Meta key.
* @return string
*/
private static function sanitize_column( string $key ): string {
if ( class_exists( 'TMDO_Schema_Manager' ) ) {
return TMDO_Schema_Manager::sanitize_column_name( $key );
}
return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key );
}
/**
* Map entity group → primary post_type (null = cross-cutting like wp_core).
*
* @param string $group Entity group name.
* @return string|null
*/
private static function group_post_type( string $group ): ?string {
switch ( $group ) {
case 'attachment':
return 'attachment';
case 'wc_product':
return 'product';
case 'hp_listing_core':
return 'hp_listing';
case 'hp_request_core':
return 'hp_request';
case 'hp_vendor_core':
return 'hp_vendor';
case 'nav_menu_item':
return 'nav_menu_item';
case 'wp_core':
default:
return null;
}
}
/**
* Loose equality for verifier — handles widened types (decimal precision,
* tinyint→bigint, etc.) and serialization-format differences without
* false-positive divergence reports.
*
* Layers (fail-fast on first match):
* 1. Identical strings
* 2. Numeric loose match (handles "50" vs "50.000000")
* 3. v2.10.5: Decoded match for serialized vs JSON values
* (postmeta uses PHP serialize, flat tables use wp_json_encode for
* json-typed fields — same logical data, different storage format)
*
* @param mixed $a Side A value.
* @param mixed $b Side B value.
* @return bool
*/
private static function values_loose_equal( $a, $b ): bool {
// Both null/empty already filtered out by caller.
$as = (string) $a;
$bs = (string) $b;
if ( $as === $bs ) {
return true;
}
// Numeric loose match (handles "50" vs "50.000000").
if ( is_numeric( $as ) && is_numeric( $bs ) ) {
return (float) $as === (float) $bs;
}
// v2.10.5: try decoded comparison for serialized/JSON values.
$a_decoded = self::decode_value( $as );
$b_decoded = self::decode_value( $bs );
if ( ( null !== $a_decoded || null !== $b_decoded ) && $a_decoded === $b_decoded ) {
return true;
}
return false;
}
/**
* Best-effort decode of a string value: try PHP unserialize (object-safe),
* then JSON. Returns the decoded structure, or null when neither succeeds
* (so the caller can short-circuit instead of false-positive matching
* against a literal "null" string).
*
* Returns null in three cases:
* - Both decoders failed (input is plain non-encoded string)
* - unserialize returned NULL legitimately (input was 'N;')
* - Empty string
*
* The caller distinguishes these by combining with an identical-string
* fast-path that runs first; non-encoded strings hit equality before this
* decoder runs.
*
* @param string $s Raw string value.
* @return mixed|null Decoded value, or null on failure.
*/
private static function decode_value( string $s ) {
if ( '' === $s ) {
return null;
}
// PHP serialize: 'a:N:{...}' / 'O:N:"...":...' / 'i:N;' / 's:N:"..."' / 'b:0|1;' / 'N;' / 'd:N;'.
if ( preg_match( '/^[aOidsbN]:/', $s ) || 'N;' === $s ) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors,Generic.PHP.NoSilencedErrors,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes=false hardens against object injection; @ swallows malformed-payload notices.
$v = @unserialize( $s, array( 'allowed_classes' => false ) );
if ( false !== $v || 'b:0;' === $s ) {
return $v;
}
}
// JSON: arrays/objects start with [ or {.
$first = $s[0] ?? '';
if ( '[' === $first || '{' === $first ) {
$v = json_decode( $s, true );
if ( null !== $v ) {
return $v;
}
}
return null;
}
/**
* Record a divergence via the shared shadow diff logger.
*
* @param int $post_id Post ID.
* @param string $key Meta key.
* @param string $pm_val Postmeta value (or '(missing)').
* @param string $flat_val Flat value (or '(missing)').
* @return void
*/
private static function log_diff( int $post_id, string $key, string $pm_val, string $flat_val ): void {
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
return;
}
// Use the public record() method if the logger exposes one; otherwise
// fall through silently. v2.5.x exposes compare_and_log() which does
// internal compare; for verifier we already know they diverged so we
// write directly to the table.
global $wpdb;
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
return;
}
$wpdb->insert(
$table,
array(
'entity_type' => 'post',
'entity_id' => $post_id,
'meta_key' => $key,
'postmeta_value' => substr( $pm_val, 0, 1000 ),
'zone_value' => substr( $flat_val, 0, 1000 ),
'diff_hash' => md5( $pm_val . '|' . $flat_val ),
'ts' => gmdate( 'Y-m-d H:i:s' ),
)
);
}
}
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
<?php
/**
* TMDO_Postmeta_Cleaner — wp_postmeta garbage cleanup (v2.9.0 Phase 0).
*
* Identifies and removes three classes of low-value rows from wp_postmeta
* that bloat the table without serving any business purpose:
*
* - transients — stale `_transient_*` and `_transient_timeout_*` rows
* (often left by HivePress model version cache)
* - wp_old_date — WP core internal record of post date changes (no app value)
* - edit_locks — `_edit_lock` rows whose lock timestamp is > 24h old
* (orphaned from interrupted edit sessions)
*
* Runs before any Entity Bridge migration so subsequent ratio measurements
* reflect real data, not garbage. Pure DB layer — no Hook Bus / Entity Bridge
* coupling so cleanup is safe even when post entity bridge is disabled.
*
* @package WP_Data_Optimizer
* @since 2.9.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Wp_postmeta garbage cleanup (v2.9.0 Phase 0).
*/
class TMDO_Postmeta_Cleaner {
public const TARGET_TRANSIENTS = 'transients';
public const TARGET_WP_OLD_DATE = 'wp_old_date';
public const TARGET_EDIT_LOCKS = 'edit_locks';
public const TARGET_ALL = 'all';
public const VALID_TARGETS = array(
self::TARGET_TRANSIENTS,
self::TARGET_WP_OLD_DATE,
self::TARGET_EDIT_LOCKS,
self::TARGET_ALL,
);
/**
* Seconds after which an _edit_lock is considered stale.
* WP refreshes locks on a 15-second heartbeat; 24h is intentionally
* conservative to avoid disturbing any active edit session.
*/
private const EDIT_LOCK_STALE_THRESHOLD = 86400;
/**
* Count rows that would be cleaned for the given target.
*
* @param string $target One of TARGET_* constants.
* @return array{transients:int, wp_old_date:int, edit_locks:int, total:int}
* @throws InvalidArgumentException When $target is not a valid target.
*/
public static function count_garbage( string $target = self::TARGET_ALL ): array {
self::assert_valid_target( $target );
$counts = array(
'transients' => 0,
'wp_old_date' => 0,
'edit_locks' => 0,
'total' => 0,
);
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
$counts['transients'] = self::count_transients();
}
if ( self::target_includes( $target, self::TARGET_WP_OLD_DATE ) ) {
$counts['wp_old_date'] = self::count_wp_old_date();
}
if ( self::target_includes( $target, self::TARGET_EDIT_LOCKS ) ) {
$counts['edit_locks'] = self::count_stale_edit_locks();
}
$counts['total'] = $counts['transients'] + $counts['wp_old_date'] + $counts['edit_locks'];
return $counts;
}
/**
* Delete garbage rows for the given target.
*
* @param string $target One of TARGET_* constants.
* @return array{transients:int, wp_old_date:int, edit_locks:int, total:int}
* @throws InvalidArgumentException When $target is not a valid target.
*/
public static function delete_garbage( string $target = self::TARGET_ALL ): array {
self::assert_valid_target( $target );
$deleted = array(
'transients' => 0,
'wp_old_date' => 0,
'edit_locks' => 0,
'total' => 0,
);
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
$deleted['transients'] = self::delete_transients();
}
if ( self::target_includes( $target, self::TARGET_WP_OLD_DATE ) ) {
$deleted['wp_old_date'] = self::delete_wp_old_date();
}
if ( self::target_includes( $target, self::TARGET_EDIT_LOCKS ) ) {
$deleted['edit_locks'] = self::delete_stale_edit_locks();
}
$deleted['total'] = $deleted['transients'] + $deleted['wp_old_date'] + $deleted['edit_locks'];
return $deleted;
}
/**
* Whether $target selects $bucket (i.e. target=all or target=bucket).
*
* @param string $target Selected target.
* @param string $bucket Bucket constant (TARGET_TRANSIENTS / WP_OLD_DATE / EDIT_LOCKS).
* @return bool
*/
private static function target_includes( string $target, string $bucket ): bool {
return self::TARGET_ALL === $target || $bucket === $target;
}
/**
* Validate target parameter.
*
* @param string $target Target to validate.
* @return void
* @throws InvalidArgumentException When $target is not in VALID_TARGETS.
*/
private static function assert_valid_target( string $target ): void {
if ( in_array( $target, self::VALID_TARGETS, true ) ) {
return;
}
// Exception messages bubble up to PHP's error handler / WP_CLI; not user output.
$msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) );
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->postmeta is WP-managed and never user input. PreparedSQL.InterpolatedNotPrepared fires on multi-line $wpdb->prepare() literals where the only interpolation is the trusted table name; user-controlled values use placeholders.
/**
* Count rows matching transient meta_key patterns in wp_postmeta.
*
* @return int
*/
private static function count_transients(): int {
global $wpdb;
$table = $wpdb->postmeta;
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
);
}
/**
* Delete rows matching transient meta_key patterns from wp_postmeta.
*
* @return int Affected row count.
*/
private static function delete_transients(): int {
global $wpdb;
$table = $wpdb->postmeta;
return (int) $wpdb->query(
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
);
}
/**
* Count _wp_old_date rows in wp_postmeta.
*
* @return int
*/
private static function count_wp_old_date(): int {
global $wpdb;
$table = $wpdb->postmeta;
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key = '_wp_old_date'"
);
}
/**
* Delete _wp_old_date rows from wp_postmeta.
*
* @return int Affected row count.
*/
private static function delete_wp_old_date(): int {
global $wpdb;
$table = $wpdb->postmeta;
return (int) $wpdb->query(
"DELETE FROM `{$table}` WHERE meta_key = '_wp_old_date'"
);
}
/**
* Count stale _edit_lock rows (lock_ts older than 24h) in wp_postmeta.
*
* @return int
*/
private static function count_stale_edit_locks(): int {
global $wpdb;
$table = $wpdb->postmeta;
$cutoff = time() - self::EDIT_LOCK_STALE_THRESHOLD;
// _edit_lock format is "<unix_ts>:<user_id>"; SUBSTRING_INDEX extracts the timestamp.
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key = '_edit_lock' AND CAST(SUBSTRING_INDEX(meta_value, ':', 1) AS UNSIGNED) < %d",
$cutoff
)
);
}
/**
* Delete stale _edit_lock rows (lock_ts older than 24h) from wp_postmeta.
*
* @return int Affected row count.
*/
private static function delete_stale_edit_locks(): int {
global $wpdb;
$table = $wpdb->postmeta;
$cutoff = time() - self::EDIT_LOCK_STALE_THRESHOLD;
return (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` WHERE meta_key = '_edit_lock' AND CAST(SUBSTRING_INDEX(meta_value, ':', 1) AS UNSIGNED) < %d",
$cutoff
)
);
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* TMDO_Safe_Unserialize — Object-injection-safe replacement for
* `maybe_unserialize()` (v2.13.3 — fixes L-DESER-1).
*
* `maybe_unserialize()` calls `unserialize()` with default options, which
* materializes objects and triggers __wakeup / __destruct magic methods.
* Attacker-planted serialized payloads in DB-stored values (postmeta,
* usermeta, options) become RCE vectors when any vendor library exposes
* a usable gadget chain.
*
* This wrapper passes `['allowed_classes' => false]` so PHP returns
* `__PHP_Incomplete_Class` placeholders without ever invoking magic
* methods on the original class. The placeholders are then walked out of
* the result tree before returning, so they cannot leak into a flat-table
* JSON column or downstream consumer.
*
* @package WP_Data_Optimizer
* @since 2.13.3
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Static helper class — drop-in replacement for `maybe_unserialize()`.
*/
final class TMDO_Safe_Unserialize {
/**
* Object-injection-safe `maybe_unserialize()` equivalent.
*
* Same input/output contract as `maybe_unserialize()`:
* - Non-string input is returned as-is.
* - Non-serialized strings are returned as-is.
* - Serialized arrays / scalars are unserialized with allowed_classes=false.
* - Any object placeholders in the result are stripped to null.
*
* @param mixed $value Raw value (typically meta_value or option value).
* @return mixed Unserialized array / scalar, or original string if not serialized.
*/
public static function run( $value ) {
if ( ! is_string( $value ) ) {
return $value;
}
$trimmed = trim( $value );
// Cheap inline detector — does not rely on WP's is_serialized() so this
// helper works in CLI / standalone contexts. PHP serialize tokens:
// a (array), O (object), s (string), i (int), d (float), b (bool),
// N; (null), C (custom class — also handled by allowed_classes=false).
if ( 'N;' !== $trimmed ) {
if ( strlen( $trimmed ) < 4 || ':' !== ( $trimmed[1] ?? '' ) ) {
return $value;
}
if ( ! in_array( $trimmed[0] ?? '', array( 'a', 'O', 's', 'i', 'd', 'b', 'C' ), true ) ) {
return $value;
}
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes=false hardens against object injection.
$result = @unserialize( $trimmed, array( 'allowed_classes' => false ) );
// `unserialize()` returns false on parse error. The literal payload
// `b:0;` legitimately deserializes to (bool) false, so distinguish that.
if ( false === $result && 'b:0;' !== $trimmed ) {
return $value;
}
// Strip __PHP_Incomplete_Class artefacts (allowed_classes=false replaces
// any object marker with this stub). They must never reach a flat-table
// JSON column or a downstream consumer that might try to access props.
if ( is_object( $result ) ) {
return null;
}
if ( is_array( $result ) ) {
array_walk_recursive(
$result,
static function ( &$v ) {
if ( is_object( $v ) ) {
$v = null;
}
}
);
}
return $result;
}
}
+287
View File
@@ -0,0 +1,287 @@
<?php
/**
* Central registry for zone field configurations.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Central registry for zone field configurations.
*
* Plugins register their meta_key → zone mappings here.
* The registry drives table creation, migration, interceptors, and query routing.
*
* Usage:
* $registry = TMDO_Schema_Registry::instance();
* $registry->register( 'hivepress', [
* 'post_type' => 'hp_listing',
* 'meta_key' => 'hp_price',
* 'zone' => 'hot',
* 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
* 'column' => 'hp_price',
* 'indexed' => true,
* ]);
*/
class TMDO_Schema_Registry {
/**
* Singleton instance.
*
* @var self|null
*/
private static ?self $instance = null;
/**
* Registered field mappings.
*
* Structure: [ 'post_type:meta_key' => field_config, ... ]
*
* @var array<string, array>
*/
private array $fields = array();
/**
* Zone → post_type → columns map for Zone A (hot) table creation.
*
* @var array<string, array<string, array>>
*/
private array $hot_columns = array();
/**
* Zone C (cold) fields grouped by post_type.
*
* @var array<string, string[]>
*/
private array $cold_fields = array();
/**
* Zone B (warm) fields with TTL config.
*
* @var array<string, array>
*/
private array $warm_fields = array();
/**
* Private constructor — use instance() to get the singleton.
*/
private function __construct() {}
/**
* Get the singleton instance.
*/
public static function instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register a field mapping.
*
* @param string $provider Provider name (e.g. 'hivepress', 'woocommerce').
* @param array $config Field configuration:
* - post_type (string) WordPress post type.
* - meta_key (string) The wp_postmeta meta_key.
* - zone (string) hot|warm|cold|archive
* - data_type (string) SQL column type for Zone A (e.g. 'decimal(10,2) NOT NULL DEFAULT 0').
* - column (string) Column name in zone table (defaults to sanitized meta_key).
* - indexed (bool) Whether to add an index (Zone A only).
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
* - cache_group (string) Object cache group (Zone C only).
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
*/
public function register( string $provider, array $config ): void {
$config = wp_parse_args(
$config,
array(
'post_type' => '',
'entity_type' => '',
'meta_key' => '',
'zone' => 'hot',
'data_type' => 'longtext',
'column' => '',
'indexed' => false,
'ttl' => null,
'cache_group' => '',
'cache_ttl' => HOUR_IN_SECONDS,
'provider' => $provider,
)
);
// v2.1.2 fix: normalize post_type vs entity_type. Some partner plugins
// (e.g. 2meet-liff) register fields against entities other than posts
// (user / term / comment) using `entity_type` instead of `post_type`.
// Without normalization, those fields end up with empty-string post_type,
// polluting `get_hot_post_types()` output. Use entity_type as bucket key.
if ( empty( $config['post_type'] ) && ! empty( $config['entity_type'] ) ) {
$config['post_type'] = sanitize_key( $config['entity_type'] );
}
if ( empty( $config['column'] ) ) {
$config['column'] = sanitize_key( $config['meta_key'] );
}
$key = $config['post_type'] . ':' . $config['meta_key'];
$config['provider'] = $provider;
$this->fields[ $key ] = $config;
// Index by zone for efficient lookup.
switch ( $config['zone'] ) {
case 'hot':
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
break;
case 'warm':
$this->warm_fields[ $config['meta_key'] ] = $config;
break;
case 'cold':
$this->cold_fields[ $config['post_type'] ][] = $config['meta_key'];
break;
}
}
/**
* Bulk register multiple fields.
*
* @param string $provider Provider name.
* @param array $configs Array of field configs.
*/
public function register_many( string $provider, array $configs ): void {
foreach ( $configs as $config ) {
$this->register( $provider, $config );
}
}
/**
* Get the zone configuration for a specific field.
*
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @return array|null Field config or null if not registered.
*/
public function get_field( string $post_type, string $meta_key ): ?array {
$key = $post_type . ':' . $meta_key;
return $this->fields[ $key ] ?? null;
}
/**
* Get all fields for a specific zone.
*
* @param string $zone Zone identifier (hot/warm/cold/archive).
* @return array Array of field configs.
*/
public function get_zone_fields( string $zone ): array {
return array_filter( $this->fields, fn( $f ) => $f['zone'] === $zone );
}
/**
* Get all fields for a specific zone and post type.
*
* @param string $zone Zone identifier.
* @param string $post_type Post type.
* @return array
*/
public function get_zone_fields_for_type( string $zone, string $post_type ): array {
return array_filter(
$this->fields,
fn( $f ) => $f['zone'] === $zone && $f['post_type'] === $post_type
);
}
/**
* Get hot column definitions for a post type (for table creation).
*
* @param string $post_type Post type.
* @return array<string, string> Column name => SQL type.
*/
public function get_hot_columns( string $post_type ): array {
return $this->hot_columns[ $post_type ] ?? array();
}
/**
* Get all post types that have hot zone fields.
*
* @return string[]
*/
public function get_hot_post_types(): array {
return array_keys( $this->hot_columns );
}
/**
* Get all post types that have cold zone fields.
*
* @return string[]
*/
public function get_cold_post_types(): array {
return array_keys( $this->cold_fields );
}
/**
* Get cold field meta keys for a post type.
*
* @param string $post_type Post type.
* @return string[] Array of meta keys.
*/
public function get_cold_meta_keys( string $post_type ): array {
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
}
/**
* Get warm field configuration.
*
* @param string $meta_key Meta key.
* @return array|null
*/
public function get_warm_field( string $meta_key ): ?array {
return $this->warm_fields[ $meta_key ] ?? null;
}
/**
* Check which zone a meta_key belongs to for a given post type.
*
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @return string|null Zone name or null if not registered.
*/
public function get_field_zone( string $post_type, string $meta_key ): ?string {
$field = $this->get_field( $post_type, $meta_key );
return $field['zone'] ?? null;
}
/**
* Get all registered fields (for admin dashboard / analysis).
*
* @return array<string, array>
*/
public function all(): array {
return $this->fields;
}
/**
* Get summary statistics for the admin dashboard.
*
* @return array{hot: int, warm: int, cold: int, archive: int, total: int}
*/
public function get_stats(): array {
$stats = array(
'hot' => 0,
'warm' => 0,
'cold' => 0,
'archive' => 0,
'total' => 0,
);
foreach ( $this->fields as $field ) {
if ( isset( $stats[ $field['zone'] ] ) ) {
++$stats[ $field['zone'] ];
}
++$stats['total'];
}
return $stats;
}
}
+359
View File
@@ -0,0 +1,359 @@
<?php
/**
* SQLite information schema compatibility for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Patches the SQLite info_schema emulation table so dbDelta does not
* re-run CREATE TABLE on every page load.
*
* The SQLite drop-in reads _wp_sqlite_mysql_information_schema_columns
* to know which columns already exist. We must insert a row for each
* column in each WPDO table immediately after dbDelta().
*
* Columns are inserted via INSERT OR IGNORE so the method is idempotent.
*/
class TMDO_SQLite_Compat {
/**
* Patch all WPDO system tables into the SQLite info_schema.
*/
public static function patch_all(): void {
if ( ! TMDO_IS_SQLITE ) {
return;
}
global $wpdb;
$p = $wpdb->prefix;
$tables = self::get_system_column_definitions( $p );
$schema_table = '_wp_sqlite_mysql_information_schema_columns';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SQLite driver's fixed internal table name.
foreach ( $tables as $table_name => $columns ) {
foreach ( $columns as $pos => $col ) {
$wpdb->query(
$wpdb->prepare(
"INSERT OR IGNORE INTO `{$schema_table}`
(table_name, column_name, data_type, is_nullable, column_default, ordinal_position)
VALUES (%s, %s, %s, %s, %s, %d)",
$table_name,
$col['name'],
$col['type'],
$col['nullable'] ? 'YES' : 'NO',
$col['default'],
$pos + 1
)
);
}
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Patch a dynamic zone table (e.g. wpdo_hot_hp_listing) into the SQLite info_schema.
*
* @param string $table_name Full table name with prefix.
* @param array $columns Array of column definitions.
*/
public static function patch_table( string $table_name, array $columns ): void {
if ( ! TMDO_IS_SQLITE ) {
return;
}
global $wpdb;
$schema_table = '_wp_sqlite_mysql_information_schema_columns';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SQLite driver's fixed internal table name.
foreach ( $columns as $pos => $col ) {
$wpdb->query(
$wpdb->prepare(
"INSERT OR IGNORE INTO `{$schema_table}`
(table_name, column_name, data_type, is_nullable, column_default, ordinal_position)
VALUES (%s, %s, %s, %s, %s, %d)",
$table_name,
$col['name'],
$col['type'],
$col['nullable'] ? 'YES' : 'NO',
$col['default'],
$pos + 1
)
);
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Column definitions for WPDO system tables (migrations, errors, benchmarks)
* and zone infrastructure tables (warm, archive).
*
* @param string $p Table prefix.
* @return array Table column definitions array.
*/
private static function get_system_column_definitions( string $p ): array {
return array(
"{$p}wpdo_migrations" => array(
array(
'name' => 'id',
'type' => 'bigint',
'nullable' => false,
'default' => null,
),
array(
'name' => 'module',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'zone',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'state',
'type' => 'varchar',
'nullable' => false,
'default' => 'idle',
),
array(
'name' => 'total_rows',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'processed_rows',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'last_offset',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'error_count',
'type' => 'int',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'started_at',
'type' => 'datetime',
'nullable' => true,
'default' => null,
),
array(
'name' => 'completed_at',
'type' => 'datetime',
'nullable' => true,
'default' => null,
),
array(
'name' => 'created_at',
'type' => 'datetime',
'nullable' => false,
'default' => '0000-00-00 00:00:00',
),
array(
'name' => 'updated_at',
'type' => 'datetime',
'nullable' => false,
'default' => '0000-00-00 00:00:00',
),
),
"{$p}wpdo_errors" => array(
array(
'name' => 'id',
'type' => 'bigint',
'nullable' => false,
'default' => null,
),
array(
'name' => 'module',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'zone',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'hook',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'message',
'type' => 'longtext',
'nullable' => false,
'default' => null,
),
array(
'name' => 'context',
'type' => 'longtext',
'nullable' => true,
'default' => null,
),
array(
'name' => 'created_at',
'type' => 'datetime',
'nullable' => false,
'default' => '0000-00-00 00:00:00',
),
),
"{$p}wpdo_benchmarks" => array(
array(
'name' => 'id',
'type' => 'bigint',
'nullable' => false,
'default' => null,
),
array(
'name' => 'module',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'zone',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'query_type',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'native_ms',
'type' => 'decimal',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'custom_ms',
'type' => 'decimal',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'sample_size',
'type' => 'int',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'created_at',
'type' => 'datetime',
'nullable' => false,
'default' => '0000-00-00 00:00:00',
),
),
"{$p}wpdo_warm" => array(
array(
'name' => 'id',
'type' => 'bigint',
'nullable' => false,
'default' => null,
),
array(
'name' => 'post_id',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'meta_key',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'meta_value',
'type' => 'longtext',
'nullable' => true,
'default' => null,
),
array(
'name' => 'expires_at',
'type' => 'datetime',
'nullable' => true,
'default' => null,
),
array(
'name' => 'created_at',
'type' => 'datetime',
'nullable' => false,
'default' => '0000-00-00 00:00:00',
),
),
"{$p}wpdo_archive" => array(
array(
'name' => 'id',
'type' => 'bigint',
'nullable' => false,
'default' => null,
),
array(
'name' => 'post_id',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'post_type',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'meta_key',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'meta_value',
'type' => 'longtext',
'nullable' => true,
'default' => null,
),
array(
'name' => 'compressed',
'type' => 'tinyint',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'archived_at',
'type' => 'datetime',
'nullable' => false,
'default' => '0000-00-00 00:00:00',
),
array(
'name' => 'original_meta_id',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
),
);
}
}
@@ -0,0 +1,224 @@
<?php
/**
* TMDO_Term_Comment_Backfill — Pivot-style backfill from wp_*meta to flat
* tables for term + comment entities (v2.12.6 Phase 6).
*
* Background
* ----------
* Phase 15 cover write-time interception (anything written via the
* metadata API after v2.12.x activation goes to flat tables / wp_options /
* misc bucket / drop). But **historical** wp_termmeta / wp_commentmeta rows
* predating v2.12.x are still in those tables — they were never intercepted
* because they were already there.
*
* Phase 5's shadow_read verifier surfaces these as `missing_flat` drift
* (e.g. dev10 baseline: 42 term + 23 comment = 65 missing_flat rows).
* Without backfill, promoting to aeav_only would silently drop reads of
* those historical values (the read path goes flat → empty → fall-through
* to wp_termmeta DOES still work for now, but only because we left the
* fall-through in place; in v3.0.0 we DROP the source tables).
*
* Strategy
* --------
* One-shot SQL pivot per group, mirroring the user-side backfill pattern
* (v2.5.5 user_membership backfill, etc.):
*
* INSERT INTO wp_wpdo_term_hp_taxonomy (term_id, hp_sort_order, hp_default, hp_icon)
* SELECT t.term_id,
* MAX(CASE WHEN tm.meta_key = 'hp_sort_order' THEN tm.meta_value END),
* MAX(CASE WHEN tm.meta_key = 'hp_default' THEN tm.meta_value END),
* MAX(CASE WHEN tm.meta_key = 'hp_icon' THEN tm.meta_value END)
* FROM wp_terms t
* INNER JOIN wp_termmeta tm ON tm.term_id = t.term_id
* WHERE tm.meta_key IN (...registered keys...)
* GROUP BY t.term_id
* ON DUPLICATE KEY UPDATE
* hp_sort_order = COALESCE(VALUES(hp_sort_order), hp_sort_order),
* ...
*
* COALESCE preserves any value already in the flat table when the wp_*meta
* side has NULL for that key — important for partial-coverage backfills
* where some keys are present and others aren't on a given entity.
*
* 🔒 Frozen contract: never touches user / post entities; only term/comment.
*
* @package WP_Data_Optimizer
* @since 2.12.6
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Pivot SQL: keys + table from registry, parameterized via prepare placeholders for keys list. One-shot ops command.
/**
* Pivot-style backfill from wp_termmeta / wp_commentmeta to flat tables.
*/
final class TMDO_Term_Comment_Backfill {
/**
* Group → flat table suffix mapping. Mirror of TMDO_Hivepress_Term_Comment_Fields.
*
* @var array<string, array{entity_type:string, table_suffix:string}>
*/
private const GROUP_MAP = array(
'hp_taxonomy' => array(
'entity_type' => 'term',
'table_suffix' => 'wpdo_term_hp_taxonomy',
),
'hp_review' => array(
'entity_type' => 'comment',
'table_suffix' => 'wpdo_comment_hp_review',
),
);
/**
* Run backfill for every registered term + comment group.
*
* @param bool $dry_run When true, only count rows without writing.
* @return array<string, array{group:string, entity_type:string, candidates:int, written:int, dry_run:bool}>
*/
public static function backfill_all( bool $dry_run = false ): array {
$results = array();
foreach ( self::GROUP_MAP as $group_name => $cfg ) {
$results[ $group_name ] = self::backfill_group( $cfg['entity_type'], $group_name, $dry_run );
}
return $results;
}
/**
* Run backfill for a single group.
*
* @param string $entity_type 'term' or 'comment'.
* @param string $group_name Entity group name.
* @param bool $dry_run When true, only count rows without writing.
* @return array{group:string, entity_type:string, candidates:int, written:int, dry_run:bool, error?:string}
*/
public static function backfill_group( string $entity_type, string $group_name, bool $dry_run = false ): array {
$result = array(
'group' => $group_name,
'entity_type' => $entity_type,
'candidates' => 0,
'written' => 0,
'dry_run' => $dry_run,
);
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
$result['error'] = 'Entity Registry unavailable';
return $result;
}
if ( ! isset( self::GROUP_MAP[ $group_name ] ) ) {
$result['error'] = "Unknown group: {$group_name}";
return $result;
}
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
if ( empty( $keys ) ) {
$result['error'] = "No keys registered for {$entity_type}:{$group_name}";
return $result;
}
global $wpdb;
// Resolve source / meta tables + id columns per entity type.
if ( 'term' === $entity_type ) {
$source_table = $wpdb->terms;
$source_id_col = 'term_id';
$meta_table = $wpdb->termmeta;
$meta_id_col = 'term_id';
$flat_id_col = 'term_id';
} elseif ( 'comment' === $entity_type ) {
$source_table = $wpdb->comments;
$source_id_col = 'comment_ID';
$meta_table = $wpdb->commentmeta;
$meta_id_col = 'comment_id';
$flat_id_col = 'comment_id';
} else {
$result['error'] = "Unsupported entity_type: {$entity_type}";
return $result;
}
$flat_table = $wpdb->prefix . self::GROUP_MAP[ $group_name ]['table_suffix'];
// Count candidate entities — those with at least one of the registered keys in wp_*meta.
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$candidate_query = $wpdb->prepare(
"SELECT COUNT(DISTINCT s.`{$source_id_col}`)
FROM `{$source_table}` s
INNER JOIN `{$meta_table}` m ON m.`{$meta_id_col}` = s.`{$source_id_col}`
WHERE m.meta_key IN ({$placeholders})",
...$keys
);
$result['candidates'] = (int) $wpdb->get_var( $candidate_query );
if ( $dry_run ) {
return $result;
}
if ( 0 === $result['candidates'] ) {
return $result;
}
// Build SELECT clause: id + one MAX(CASE) per registered key (sanitized to flat column).
$select_parts = array( "s.`{$source_id_col}` AS id" );
$update_clauses = array();
$insert_cols = array( "`{$flat_id_col}`" );
foreach ( $keys as $key ) {
$col = self::sanitize_column( $key );
$insert_cols[] = "`{$col}`";
$select_parts[] = $wpdb->prepare(
"MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS `{$col}`",
$key
);
$update_clauses[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
}
$insert_sql = sprintf(
'INSERT INTO `%s` (%s)
SELECT %s
FROM `%s` s
INNER JOIN `%s` m ON m.`%s` = s.`%s`
WHERE m.meta_key IN (%s)
GROUP BY s.`%s`
ON DUPLICATE KEY UPDATE %s',
$flat_table,
implode( ', ', $insert_cols ),
implode( ",\n ", $select_parts ),
$source_table,
$meta_table,
$meta_id_col,
$source_id_col,
$placeholders,
$source_id_col,
implode( ', ', $update_clauses )
);
$prepared = $wpdb->prepare( $insert_sql, ...$keys );
$ok = $wpdb->query( $prepared );
if ( false === $ok ) {
$result['error'] = $wpdb->last_error ?: 'Backfill query failed';
return $result;
}
// MySQL `INSERT ... ON DUPLICATE KEY UPDATE` returns 1 per insert, 2 per
// update — divide by 2 with floor for a meaningful "rows touched" estimate
// only when both sides equal candidates. Easier: just report candidates
// since that's the upper bound and ON DUPLICATE replaces.
$result['written'] = $result['candidates'];
return $result;
}
/**
* Sanitize meta_key into a column name (mirrors Schema_Manager rules).
*
* @param string $key Meta key.
* @return string
*/
private static function sanitize_column( string $key ): string {
if ( class_exists( 'TMDO_Schema_Manager' ) ) {
return TMDO_Schema_Manager::sanitize_column_name( $key );
}
return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key );
}
}
@@ -0,0 +1,428 @@
<?php
/**
* TMDO_Term_Comment_Shadow_Verifier — Sample-and-compare flat vs wp_*meta
* for term + comment entities (v2.12.5 Phase 5).
*
* Mirror of TMDO_Post_Shadow_Verifier (v2.10.3). Used during shadow_read mode
* to verify the flat tables stay in sync with wp_termmeta / wp_commentmeta.
* Each sample picks a random entity + key, fetches both values, counts
* matches / diffs / missing rows. Divergent values are written to
* `wpdo_shadow_diffs` via TMDO_Shadow_Diff_Logger (entity_type-aware).
*
* Run pattern:
* - Manual: `wp wpdo term-comment-shadow-report`
* - Cron: wpdo_term_comment_shadow_verify event registered when EITHER
* term or comment mode is shadow_read; auto-unregistered when
* both modes are not shadow_read
*
* 🔒 Frozen contract: read-only verifier. Never modifies wp_termmeta /
* wp_commentmeta or flat tables.
*
* @package WP_Data_Optimizer
* @since 2.12.5
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Internal verifier: $flat_table comes from registry, columns sanitized via Schema_Manager, user values use prepare(). Read-only.
/**
* Sample-and-compare verifier for term + comment entity flat tables.
*/
final class TMDO_Term_Comment_Shadow_Verifier {
/** Cron event hook fired hourly when at least one of term/comment modes is shadow_read. */
public const CRON_HOOK = 'wpdo_term_comment_shadow_verify';
/** Default sample size per entity per cron tick. */
public const DEFAULT_SAMPLE_SIZE = 100;
/**
* Group → flat table suffix mapping. Keep in sync with
* TMDO_Hivepress_Term_Comment_Fields registrations and the misc bucket.
*
* @var array<string, array{entity_type:string, table_suffix:string, has_misc:bool}>
*/
private const GROUP_MAP = array(
'hp_taxonomy' => array(
'entity_type' => 'term',
'table_suffix' => 'wpdo_term_hp_taxonomy',
),
'hp_review' => array(
'entity_type' => 'comment',
'table_suffix' => 'wpdo_comment_hp_review',
),
);
/**
* Run a sample-compare pass for a single entity group.
*
* @param string $entity_type 'term' or 'comment'.
* @param string $group_name Entity group name.
* @param string $flat_table Fully-qualified flat table name.
* @param string[] $keys Meta keys to compare.
* @param int $sample_size Number of entities to sample.
* @return array{
* sampled:int,
* matched:int,
* diffs:int,
* missing_flat:int,
* missing_meta:int,
* group:string,
* entity_type:string,
* }
* @throws InvalidArgumentException When inputs invalid.
*/
public static function sample_compare(
string $entity_type,
string $group_name,
string $flat_table,
array $keys,
int $sample_size = self::DEFAULT_SAMPLE_SIZE
): array {
if ( ! in_array( $entity_type, array( 'term', 'comment' ), true ) ) {
$msg = 'entity_type must be term or comment';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
if ( $sample_size <= 0 ) {
$msg = 'Sample size must be > 0';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
if ( empty( $keys ) ) {
$msg = 'Keys array cannot be empty';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
global $wpdb;
// Source table + id column + meta table differ per entity type.
if ( 'term' === $entity_type ) {
$source_table = $wpdb->terms;
$source_id_col = 'term_id';
$meta_table = $wpdb->termmeta;
$meta_id_col = 'term_id';
$flat_id_col = 'term_id';
} else {
$source_table = $wpdb->comments;
$source_id_col = 'comment_ID';
$meta_table = $wpdb->commentmeta;
$meta_id_col = 'comment_id';
$flat_id_col = 'comment_id';
}
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT `{$source_id_col}` FROM `{$source_table}` ORDER BY RAND() LIMIT %d",
$sample_size
)
);
$sampled = count( $ids );
$matched = 0;
$diffs = 0;
$missing_flat = 0;
$missing_meta = 0;
if ( 0 === $sampled ) {
return array(
'sampled' => 0,
'matched' => 0,
'diffs' => 0,
'missing_flat' => 0,
'missing_meta' => 0,
'group' => $group_name,
'entity_type' => $entity_type,
);
}
foreach ( $ids as $object_id ) {
$object_id = (int) $object_id;
foreach ( $keys as $key ) {
$col = self::sanitize_column( $key );
$flat_val = $wpdb->get_var(
$wpdb->prepare(
"SELECT `{$col}` FROM `{$flat_table}` WHERE `{$flat_id_col}` = %d LIMIT 1",
$object_id
)
);
$meta_val = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM `{$meta_table}` WHERE `{$meta_id_col}` = %d AND meta_key = %s LIMIT 1",
$object_id,
$key
)
);
$flat_present = null !== $flat_val && '' !== $flat_val;
$meta_present = null !== $meta_val && '' !== $meta_val;
// In-memory counters only. Hook Bus auto-logs diffs to
// wpdo_shadow_diffs on every read in shadow_read mode (via
// TMDO_Shadow_Diff_Logger::compare_and_log) — this verifier
// is a sample-based snapshot, not the persistent log.
if ( ! $flat_present && ! $meta_present ) {
++$matched;
continue;
}
if ( ! $flat_present && $meta_present ) {
++$missing_flat;
continue;
}
if ( $flat_present && ! $meta_present ) {
++$missing_meta;
continue;
}
if ( self::values_loose_equal( $meta_val, $flat_val ) ) {
++$matched;
} else {
++$diffs;
}
}
}
return array(
'sampled' => $sampled,
'matched' => $matched,
'diffs' => $diffs,
'missing_flat' => $missing_flat,
'missing_meta' => $missing_meta,
'group' => $group_name,
'entity_type' => $entity_type,
);
}
/**
* Cron handler: runs sample_compare for every group whose entity is in
* shadow_read. No-op when neither term nor comment is shadow_read.
*
* @return void
*/
public static function cron_tick(): void {
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return;
}
$term_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'term' );
$comment_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'comment' );
if ( ! $term_shadow && ! $comment_shadow ) {
return;
}
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
global $wpdb;
foreach ( self::GROUP_MAP as $group => $cfg ) {
$entity_type = $cfg['entity_type'];
// Only run when the entity is in shadow_read mode.
if ( 'term' === $entity_type && ! $term_shadow ) {
continue;
}
if ( 'comment' === $entity_type && ! $comment_shadow ) {
continue;
}
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
if ( empty( $keys ) ) {
continue;
}
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
try {
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
} catch ( \Throwable $e ) {
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::error(
'term_comment_shadow_verifier',
'cron_tick',
$e->getMessage()
);
}
}
}
}
/**
* Recent diff records for term + comment entity types.
*
* @param int $limit Max rows.
* @return array
*/
public static function recent_diffs( int $limit = 100 ): array {
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
return array();
}
// Logger is entity-type-aware; gather both sides.
$term_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'term' );
$comment_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'comment' );
return array_merge( (array) $term_diffs, (array) $comment_diffs );
}
/**
* Aggregate diff stats for term+comment over recent N hours.
*
* @param int $hours Window (default 24).
* @return array{total:int,term:int,comment:int,by_key:array<string,int>}
*/
public static function diff_stats( int $hours = 24 ): array {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
return array(
'total' => 0,
'term' => 0,
'comment' => 0,
'by_key' => array(),
);
}
$since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) );
$term_total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'term' AND ts >= %s",
$since
)
);
$comment_total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'comment' AND ts >= %s",
$since
)
);
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type IN ('term','comment') AND ts >= %s GROUP BY meta_key ORDER BY n DESC",
$since
),
ARRAY_A
);
$by_key = array();
foreach ( (array) $rows as $row ) {
$by_key[ (string) $row['meta_key'] ] = (int) $row['n'];
}
return array(
'total' => $term_total + $comment_total,
'term' => $term_total,
'comment' => $comment_total,
'by_key' => $by_key,
);
}
/**
* Run sample_compare for ALL groups (regardless of mode). Used by
* `wp wpdo term-comment-shadow-report` so users can see drift even
* before promoting to shadow_read.
*
* @param int $sample_size Per-group sample size.
* @return array<string,array> Map of `group_name` → sample_compare() result.
*/
public static function run_all( int $sample_size = self::DEFAULT_SAMPLE_SIZE ): array {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return array();
}
global $wpdb;
$out = array();
foreach ( self::GROUP_MAP as $group => $cfg ) {
$entity_type = $cfg['entity_type'];
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
if ( empty( $keys ) ) {
continue;
}
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
try {
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
} catch ( \Throwable $e ) {
$out[ $group ] = array(
'error' => $e->getMessage(),
'group' => $group,
);
}
}
return $out;
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Sanitize meta_key into a column name (mirrors Schema_Manager rules).
*
* @param string $key Meta key.
* @return string
*/
private static function sanitize_column( string $key ): string {
if ( class_exists( 'TMDO_Schema_Manager' ) ) {
return TMDO_Schema_Manager::sanitize_column_name( $key );
}
return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key );
}
/**
* Loose equality for verifier — handles type widening and serialization
* format differences. Layers fail-fast: identical → numeric → decoded.
*
* @param mixed $a Side A value.
* @param mixed $b Side B value.
* @return bool
*/
private static function values_loose_equal( $a, $b ): bool {
// 1. Identical strings.
if ( (string) $a === (string) $b ) {
return true;
}
// 2. Numeric loose match.
if ( is_numeric( $a ) && is_numeric( $b ) && (float) $a === (float) $b ) {
return true;
}
// 3. Decoded match (serialized vs JSON).
$decoded_a = self::decode_value( (string) $a );
$decoded_b = self::decode_value( (string) $b );
return $decoded_a === $decoded_b;
}
/**
* Decode a stored value: try unserialize → JSON decode → as-is.
*
* @param string $value Raw stored value.
* @return mixed
*/
private static function decode_value( string $value ) {
// Try unserialize for postmeta-style serialized payloads.
if ( '' !== $value ) {
$first_two = substr( $value, 0, 2 );
if ( 'a:' === $first_two || 'O:' === $first_two || 's:' === $first_two || 'i:' === $first_two || 'b:' === $first_two || 'd:' === $first_two ) {
$unserialized = @unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize,WordPress.PHP.NoSilencedErrors
if ( false !== $unserialized || 'b:0;' === $value ) {
return $unserialized;
}
}
}
// Try JSON for flat-table json-typed columns.
if ( '' !== $value && ( '{' === $value[0] || '[' === $value[0] ) ) {
$json = json_decode( $value, true );
if ( null !== $json ) {
return $json;
}
}
return $value;
}
}
+880
View File
@@ -0,0 +1,880 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
/**
* TMDO_Term_Stress_Tester — Async stress fixture generator for term entity (v2.13.0).
*
* Mirrors TMDO_Post_Stress_Tester (v2.11.4) for the term entity. Provides:
*
* - State machine: start / cancel / get_progress / pump_if_due / run_batch / finalize
* - Fast mode: bulk INSERT to wp_terms + wp_term_taxonomy + wp_termmeta + flat
* - Realistic mode: wp_insert_term + update_term_meta (Hook Bus auto-routes)
* - Cron pump for nginx-safe long runs
* - Per-batch wall-clock deadline (BATCH_DEADLINE_SEC)
* - Benchmark on completion: write metrics + DB sizes + query performance
*
* Test terms use slug prefix `wpdo-stress-` for unambiguous identification —
* cleanup() deletes only those + all matching meta + flat rows.
*
* 🔒 v2.13.x frozen contract: never touches user / post / comment entities or
* their flat tables.
*
* @package WP_Data_Optimizer
* @since 2.13.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Internal stress fixture: $wpdb->terms / wp_term_taxonomy / wp_termmeta are WP-managed; meta_key strings are static class constants; user-controlled values use prepare() placeholders.
/**
* Async bulk fixture generator for term entity stress tests.
*/
final class TMDO_Term_Stress_Tester {
/** Slug prefix for stress test terms — used for cleanup matching. */
public const TEST_TERM_PREFIX = 'wpdo-stress-';
/** Hard cap to prevent runaway calls. */
public const MAX_COUNT = 100000;
// State machine constants (mirror post v2.11.4).
public const OPT_STATE = 'wpdo_term_stress_test_state';
public const CRON_HOOK = 'wpdo_term_stress_test_batch';
public const CANCEL_FLAG = 'wpdo_term_stress_cancel_flag';
public const DEFAULT_BATCH_SIZE = 200;
public const MAX_BATCH_SIZE = 1000;
public const MIN_BATCH_DELAY_SEC = 1;
public const BATCH_DEADLINE_SEC = 8;
public const MODE_FAST = 'fast';
public const MODE_REALISTIC = 'realistic';
/**
* Per-key seed map (single hp_taxonomy group). All keys go to
* `wp_wpdo_term_hp_taxonomy` flat table when term mode is dual_write+.
*
* @var array<string,mixed>|null
*/
private static ?array $seed_map_cache = null;
// ─────────────────────────────────────────────────────────────────────────
// Public API — sync helpers (also used internally by state machine batches)
// ─────────────────────────────────────────────────────────────────────────
/**
* Bulk-create N stress test terms in the given taxonomy via direct SQL
* (Fast mode — bypasses WP filter chain). Use for fixture loading where
* Hook Bus routing is not the goal.
*
* @param string $taxonomy WP taxonomy slug (e.g., listing_category, category).
* @param int $count Number of terms to insert. Capped at MAX_COUNT.
* @return array{created:int,taxonomy:string,first_id:int|null,last_id:int|null}
* @throws InvalidArgumentException When inputs invalid.
*/
public static function create( string $taxonomy, int $count ): array {
self::validate_inputs( $taxonomy, $count );
global $wpdb;
$first_id = null;
$last_id = null;
$created = 0;
$seed_map = self::seed_map();
for ( $i = 0; $i < $count; $i++ ) {
$suffix = wp_generate_password( 8, false );
$name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix;
$slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix );
$ok = $wpdb->insert(
$wpdb->terms,
array(
'name' => $name,
'slug' => $slug,
'term_group' => 0,
)
);
if ( ! $ok ) {
continue;
}
$term_id = (int) $wpdb->insert_id;
$ok2 = $wpdb->insert(
$wpdb->prefix . 'term_taxonomy',
array(
'term_id' => $term_id,
'taxonomy' => $taxonomy,
'description' => '',
'parent' => 0,
'count' => 0,
)
);
if ( ! $ok2 ) {
$wpdb->delete( $wpdb->terms, array( 'term_id' => $term_id ) );
continue;
}
if ( null === $first_id ) {
$first_id = $term_id;
}
$last_id = $term_id;
++$created;
// Direct INSERT to wp_termmeta (bypassing Hook Bus). For aeav_only
// mode validation, use create_realistic() instead.
foreach ( $seed_map as $meta_key => $value_spec ) {
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
$wpdb->insert(
$wpdb->termmeta,
array(
'term_id' => $term_id,
'meta_key' => $meta_key,
'meta_value' => (string) $value,
)
);
}
}
return array(
'created' => $created,
'taxonomy' => $taxonomy,
'first_id' => $first_id,
'last_id' => $last_id,
);
}
/**
* Realistic-mode counterpart — uses wp_insert_term() + update_term_meta()
* so Hook Bus / entity registry / mode_manager all engage on the write
* path. Use this for validating mode=dual_write+ behavior.
*
* @param string $taxonomy WP taxonomy slug.
* @param int $count Number of terms to insert.
* @return array{created:int,taxonomy:string,mode:string,first_id:int|null,last_id:int|null}
* @throws InvalidArgumentException When inputs invalid.
*/
public static function create_realistic( string $taxonomy, int $count ): array {
self::validate_inputs( $taxonomy, $count );
$first_id = null;
$last_id = null;
$created = 0;
$seed_map = self::seed_map();
for ( $i = 0; $i < $count; $i++ ) {
$suffix = wp_generate_password( 8, false );
$name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix;
$slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix );
$result = wp_insert_term( $name, $taxonomy, array( 'slug' => $slug ) );
if ( is_wp_error( $result ) ) {
continue;
}
$term_id = (int) ( $result['term_id'] ?? 0 );
if ( 0 === $term_id ) {
continue;
}
if ( null === $first_id ) {
$first_id = $term_id;
}
$last_id = $term_id;
++$created;
foreach ( $seed_map as $meta_key => $value_spec ) {
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
update_term_meta( $term_id, $meta_key, $value );
}
}
return array(
'created' => $created,
'taxonomy' => $taxonomy,
'mode' => 'realistic',
'first_id' => $first_id,
'last_id' => $last_id,
);
}
/**
* Count terms whose slug begins with TEST_TERM_PREFIX.
*
* @return int
*/
public static function count_test_terms(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->terms} WHERE slug LIKE %s",
$wpdb->esc_like( self::TEST_TERM_PREFIX ) . '%'
)
);
}
/**
* Delete every stress-test term + its term_taxonomy + termmeta rows + flat
* table rows in wpdo_term_*.
*
* @return array{deleted_terms:int,deleted_meta:int,deleted_flat:int}
*/
public static function cleanup(): array {
global $wpdb;
// Cancel any in-flight job + clear pump lock.
set_transient( self::CANCEL_FLAG, 1, 600 );
wp_clear_scheduled_hook( self::CRON_HOOK );
$term_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT term_id FROM {$wpdb->terms} WHERE slug LIKE %s",
$wpdb->esc_like( self::TEST_TERM_PREFIX ) . '%'
)
);
if ( empty( $term_ids ) ) {
return array(
'deleted_terms' => 0,
'deleted_meta' => 0,
'deleted_flat' => 0,
);
}
$id_list = implode( ',', array_map( 'absint', $term_ids ) );
$flat_deleted = 0;
// Cascade flat tables first (best-effort — tables may not exist yet).
foreach ( self::get_term_flat_tables() as $tbl ) {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $tbl ) );
if ( ! $exists ) {
continue;
}
$rows = (int) $wpdb->query( "DELETE FROM `{$tbl}` WHERE term_id IN ({$id_list})" );
$flat_deleted += $rows;
}
// Delete termmeta.
$meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->termmeta} WHERE term_id IN ({$id_list})" );
// Delete term_taxonomy.
$wpdb->query( "DELETE FROM {$wpdb->prefix}term_taxonomy WHERE term_id IN ({$id_list})" );
// Delete terms.
$term_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->terms} WHERE term_id IN ({$id_list})" );
// Reset state.
delete_option( self::OPT_STATE );
delete_transient( self::CANCEL_FLAG );
// Clean WP term caches per term_id (clean_term_cache works on arrays).
clean_term_cache( array_map( 'absint', $term_ids ) );
return array(
'deleted_terms' => $term_deleted,
'deleted_meta' => $meta_deleted,
'deleted_flat' => $flat_deleted,
);
}
// ─────────────────────────────────────────────────────────────────────────
// State machine
// ─────────────────────────────────────────────────────────────────────────
/**
* Start an async stress run. Persists state, schedules the first batch
* via wp_schedule_single_event(). First batch is pushed by cron or by
* pump_if_due() on next polling request.
*
* @param string $taxonomy Taxonomy slug.
* @param int $target Total terms to create.
* @param string $mode MODE_FAST | MODE_REALISTIC.
* @param int $batch_size Per-batch insert count.
* @return array{ok:bool,error?:string,state?:array}
*/
public static function start( string $taxonomy, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array {
if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) ) {
return array(
'ok' => false,
'error' => 'unknown_taxonomy: ' . $taxonomy,
);
}
if ( $target < 1 ) {
return array(
'ok' => false,
'error' => 'target must be >= 1',
);
}
if ( $target > self::MAX_COUNT ) {
return array(
'ok' => false,
'error' => 'target too large (max ' . self::MAX_COUNT . ')',
);
}
if ( ! in_array( $mode, array( self::MODE_FAST, self::MODE_REALISTIC ), true ) ) {
return array(
'ok' => false,
'error' => 'invalid mode',
);
}
$batch_size = max( 1, min( self::MAX_BATCH_SIZE, $batch_size ) );
$current = self::get_state();
if ( ! empty( $current['status'] ) && 'running' === $current['status'] ) {
return array(
'ok' => false,
'error' => 'already_running',
'state' => $current,
);
}
$state = array(
'job_id' => uniqid( 'tstress_', true ),
'status' => 'running',
'mode' => $mode,
'taxonomy' => $taxonomy,
'target' => $target,
'batch_size' => $batch_size,
'started_at' => time(),
'processed' => 0,
'batches_done' => 0,
'batches_log' => array(),
'errors' => array(),
'peak_memory' => 0,
'completed_at' => null,
'last_pushed_at' => 0,
'benchmark' => null,
);
update_option( self::OPT_STATE, $state, false );
delete_transient( self::CANCEL_FLAG );
wp_clear_scheduled_hook( self::CRON_HOOK );
wp_schedule_single_event( time(), self::CRON_HOOK );
return array(
'ok' => true,
'state' => $state,
);
}
/**
* Cancel a running stress test.
*
* @return array{ok:bool,state?:array,message?:string}
*/
public static function cancel(): array {
$state = self::get_state();
if ( empty( $state ) ) {
return array(
'ok' => true,
'message' => 'no_active_job',
);
}
set_transient( self::CANCEL_FLAG, 1, 600 );
wp_clear_scheduled_hook( self::CRON_HOOK );
$state = self::get_state();
$state['status'] = 'cancelled';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
return array(
'ok' => true,
'state' => $state,
);
}
/**
* Read raw state.
*
* @return array
*/
public static function get_state(): array {
$state = get_option( self::OPT_STATE, array() );
return is_array( $state ) ? $state : array();
}
/**
* Get progress with computed pct/rate/ETA. Opportunistically pumps
* if cron is overdue.
*
* @param bool $pump When true, pump_if_due() runs.
* @return array
*/
public static function get_progress( bool $pump = true ): array {
if ( $pump ) {
self::pump_if_due();
}
$state = self::get_state();
if ( empty( $state ) ) {
return array(
'status' => 'idle',
'processed' => 0,
'target' => 0,
'pct' => 0,
);
}
$processed = (int) ( $state['processed'] ?? 0 );
$target = (int) ( $state['target'] ?? 0 );
$started = (int) ( $state['started_at'] ?? 0 );
$ended = (int) ( $state['completed_at'] ?? 0 );
$now = $ended > 0 ? $ended : time();
$elapsed = max( 1, $now - $started );
$rate = $processed > 0 ? round( $processed / $elapsed, 1 ) : 0;
$eta_sec = ( $rate > 0 && $processed < $target ) ? (int) ceil( ( $target - $processed ) / $rate ) : 0;
$pct = $target > 0 ? round( ( $processed / $target ) * 100, 1 ) : 0;
return array_merge(
$state,
array(
'pct' => $pct,
'rate_per_sec' => $rate,
'elapsed_sec' => $elapsed,
'eta_sec' => $eta_sec,
'test_term_count' => self::count_test_terms(),
)
);
}
/**
* Opportunistic batch pump (transient-locked).
*
* @return void
*/
public static function pump_if_due(): void {
$state = self::get_state();
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
return;
}
$last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 );
if ( time() - $last_pushed_at < 1 ) {
return;
}
$lock_key = 'wpdo_term_stress_pump_lock';
if ( false !== get_transient( $lock_key ) ) {
return;
}
set_transient( $lock_key, 1, 30 );
if ( function_exists( 'set_time_limit' ) ) {
@set_time_limit( self::BATCH_DEADLINE_SEC + 10 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
}
try {
self::run_batch();
} finally {
delete_transient( $lock_key );
}
}
/**
* Cron entrypoint. Runs one batch, reschedules or finalizes.
*
* @return void
*/
public static function run_batch(): void {
$state = self::get_state();
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
return;
}
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
return;
}
$taxonomy = (string) ( $state['taxonomy'] ?? '' );
$target = (int) ( $state['target'] ?? 0 );
$processed = (int) ( $state['processed'] ?? 0 );
$batch_size = (int) ( $state['batch_size'] ?? self::DEFAULT_BATCH_SIZE );
$mode = (string) ( $state['mode'] ?? self::MODE_FAST );
$remaining = $target - $processed;
if ( $remaining <= 0 ) {
self::finalize( $state );
return;
}
$this_batch_size = min( $batch_size, $remaining );
$batch_started = microtime( true );
try {
if ( self::MODE_FAST === $mode ) {
$inserted = self::run_batch_fast( $taxonomy, $this_batch_size );
} else {
$inserted = self::run_batch_realistic( $taxonomy, $this_batch_size );
}
} catch ( \Throwable $e ) {
$state['errors'][] = array(
'time' => time(),
'message' => $e->getMessage(),
);
$state['status'] = 'failed';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::error( 'term_stress_test_batch_failed', array( 'message' => $e->getMessage() ) );
}
return;
}
$batch_elapsed = microtime( true ) - $batch_started;
// Re-read state — cancel() may have modified status mid-batch.
$latest = self::get_state();
if ( empty( $latest ) ) {
return;
}
$is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG );
$latest['processed'] = ( (int) ( $latest['processed'] ?? 0 ) ) + $inserted;
$latest['batches_done'] = ( (int) ( $latest['batches_done'] ?? 0 ) ) + 1;
$latest['batches_log'][] = array(
'n' => $inserted,
'duration_ms' => (int) round( $batch_elapsed * 1000 ),
);
if ( count( $latest['batches_log'] ) > 200 ) {
$latest['batches_log'] = array_slice( $latest['batches_log'], -200 );
}
$latest['peak_memory'] = max( (int) ( $latest['peak_memory'] ?? 0 ), (int) memory_get_peak_usage( true ) );
$latest['last_pushed_at'] = time();
if ( $is_cancelled ) {
update_option( self::OPT_STATE, $latest, false );
return;
}
update_option( self::OPT_STATE, $latest, false );
if ( $latest['processed'] >= $target ) {
self::finalize( $latest );
return;
}
wp_schedule_single_event( time() + self::MIN_BATCH_DELAY_SEC, self::CRON_HOOK );
}
/**
* Run one fast-mode batch.
*
* @param string $taxonomy Taxonomy slug.
* @param int $count Batch size.
* @return int Inserted count.
*/
private static function run_batch_fast( string $taxonomy, int $count ): int {
$result = self::create( $taxonomy, $count );
return (int) ( $result['created'] ?? 0 );
}
/**
* Run one realistic-mode batch with deadline + cancel check per term.
*
* @param string $taxonomy Taxonomy slug.
* @param int $count Batch size.
* @return int Inserted count.
*/
private static function run_batch_realistic( string $taxonomy, int $count ): int {
$deadline = microtime( true ) + self::BATCH_DEADLINE_SEC;
$inserted = 0;
$seed_map = self::seed_map();
for ( $i = 0; $i < $count; $i++ ) {
if ( microtime( true ) > $deadline ) {
break;
}
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
break;
}
$suffix = wp_generate_password( 8, false );
$name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix;
$slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix );
$result = wp_insert_term( $name, $taxonomy, array( 'slug' => $slug ) );
if ( is_wp_error( $result ) ) {
continue;
}
$term_id = (int) ( $result['term_id'] ?? 0 );
if ( 0 === $term_id ) {
continue;
}
++$inserted;
foreach ( $seed_map as $meta_key => $value_spec ) {
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
update_term_meta( $term_id, $meta_key, $value );
}
}
return $inserted;
}
/**
* Finalize: clear cron, run benchmark, persist completed state.
*
* @param array $state Pre-finalize state.
* @return void
*/
private static function finalize( array $state ): void {
wp_clear_scheduled_hook( self::CRON_HOOK );
$state['status'] = 'benchmarking';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
$report = self::run_benchmark( $state );
$state['benchmark'] = $report;
$state['status'] = 'completed';
update_option( self::OPT_STATE, $state, false );
}
/**
* Run benchmark on the current dataset.
*
* @param array|null $state Optional state snapshot.
* @return array
*/
public static function run_benchmark( ?array $state = null ): array {
$state = $state ?? self::get_state();
$taxonomy = (string) ( $state['taxonomy'] ?? '' );
return array(
'generated_at' => time(),
'taxonomy' => $taxonomy,
'write' => self::compute_write_metrics( $state ),
'db_sizes' => self::measure_db_sizes(),
'query' => self::measure_query_performance( $taxonomy ),
);
}
/**
* Write throughput metrics from state.
*
* @param array $state State snapshot.
* @return array
*/
private static function compute_write_metrics( array $state ): array {
$started = (int) ( $state['started_at'] ?? 0 );
$ended = (int) ( $state['completed_at'] ?? time() );
$processed = (int) ( $state['processed'] ?? 0 );
$elapsed = max( 1, $ended - $started );
$batches = $state['batches_log'] ?? array();
$durations = array_column( $batches, 'duration_ms' );
$min_ms = ! empty( $durations ) ? min( $durations ) : 0;
$max_ms = ! empty( $durations ) ? max( $durations ) : 0;
$avg_ms = ! empty( $durations ) ? (int) ( array_sum( $durations ) / count( $durations ) ) : 0;
return array(
'mode' => $state['mode'] ?? '',
'taxonomy' => $state['taxonomy'] ?? '',
'target' => (int) ( $state['target'] ?? 0 ),
'processed' => $processed,
'elapsed_sec' => $elapsed,
'rate_per_sec' => round( $processed / $elapsed, 2 ),
'batches_done' => (int) ( $state['batches_done'] ?? 0 ),
'batch_min_ms' => $min_ms,
'batch_max_ms' => $max_ms,
'batch_avg_ms' => $avg_ms,
'peak_memory_mb' => round( (int) ( $state['peak_memory'] ?? 0 ) / 1048576, 1 ),
);
}
/**
* Measure DB sizes for term-related tables.
*
* @return array
*/
private static function measure_db_sizes(): array {
global $wpdb;
$tables = array(
$wpdb->terms,
$wpdb->prefix . 'term_taxonomy',
$wpdb->termmeta,
);
foreach ( self::get_term_flat_tables() as $tbl ) {
if ( self::table_exists( $tbl ) ) {
$tables[] = $tbl;
}
}
if ( ! self::is_mysql() ) {
return array_map(
static fn( $t ) => array(
'table' => $t,
'rows' => self::table_row_count( $t ),
),
$tables
);
}
$placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT TABLE_NAME AS t, TABLE_ROWS AS rows_count, DATA_LENGTH AS dl, INDEX_LENGTH AS il
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})",
...$tables
),
ARRAY_A
);
$out = array();
foreach ( (array) $rows as $r ) {
$dl = (int) $r['dl'];
$il = (int) $r['il'];
$total = $dl + $il;
$out[] = array(
'table' => $r['t'],
'rows' => (int) $r['rows_count'],
'data_mb' => round( $dl / 1048576, 2 ),
'index_mb' => round( $il / 1048576, 2 ),
'total_mb' => round( $total / 1048576, 2 ),
'avg_bytes' => $r['rows_count'] > 0 ? (int) ( $total / (int) $r['rows_count'] ) : 0,
);
}
return $out;
}
/**
* Measure 3 representative queries for term entity:
* - point lookup on hp_default flat
* - range scan on hp_sort_order flat
* - EAV baseline on wp_termmeta
*
* @param string $taxonomy Taxonomy slug.
* @return array
*/
private static function measure_query_performance( string $taxonomy ): array {
global $wpdb;
$flat = $wpdb->prefix . 'wpdo_term_hp_taxonomy';
if ( ! self::table_exists( $flat ) ) {
return array( 'note' => 'flat_table_missing' );
}
return array(
'point_default' => self::time_query(
"SELECT term_id FROM `{$flat}` WHERE hp_default = '1' LIMIT 100"
),
'range_sort_top' => self::time_query(
"SELECT term_id FROM `{$flat}` WHERE hp_sort_order > 0 ORDER BY hp_sort_order DESC LIMIT 100"
),
'eav_baseline' => self::time_query(
"SELECT term_id FROM {$wpdb->termmeta} WHERE meta_key = 'hp_default' AND meta_value = '1' LIMIT 100"
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Validate inputs for create() / create_realistic().
*
* @param string $taxonomy Taxonomy slug.
* @param int $count Count to insert.
* @return void
* @throws InvalidArgumentException When invalid.
*/
private static function validate_inputs( string $taxonomy, int $count ): void {
if ( '' === $taxonomy ) {
throw new InvalidArgumentException( 'Taxonomy required.' );
}
if ( $count <= 0 ) {
throw new InvalidArgumentException( 'Count must be > 0.' );
}
if ( $count > self::MAX_COUNT ) {
$msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . ').';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
}
/**
* Lazy-built seed map for hp_taxonomy group.
*
* @return array<string,mixed>
*/
private static function seed_map(): array {
if ( null !== self::$seed_map_cache ) {
return self::$seed_map_cache;
}
self::$seed_map_cache = array(
'hp_sort_order' => static fn( int $i ) => (string) ( ( $i % 100 ) + 1 ),
'hp_default' => static fn( int $i ) => 0 === $i % 50 ? '1' : '0',
'hp_icon' => static fn( int $i ) => 'fa-icon-' . ( $i % 10 ),
);
return self::$seed_map_cache;
}
/**
* Names of all wp_wpdo_term_* flat tables that cleanup should sweep.
*
* @return string[]
*/
private static function get_term_flat_tables(): array {
global $wpdb;
$prefix = $wpdb->prefix . 'wpdo_term_';
return array(
$prefix . 'hp_taxonomy',
$prefix . 'misc',
);
}
/**
* Memoized table-exists probe.
*
* @param string $table Table name.
* @return bool
*/
private static function table_exists( string $table ): bool {
global $wpdb;
static $cache = array();
if ( isset( $cache[ $table ] ) ) {
return $cache[ $table ];
}
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$cache[ $table ] = ( $found === $table );
return $cache[ $table ];
}
/**
* Cheap row count helper (SQLite fallback).
*
* @param string $table Table name.
* @return int
*/
private static function table_row_count( string $table ): int {
global $wpdb;
if ( ! self::table_exists( $table ) ) {
return 0;
}
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
}
/**
* Time a SQL query.
*
* @param string $sql Query.
* @return array{duration_ms:float}
*/
private static function time_query( string $sql ): array {
global $wpdb;
$start = microtime( true );
$wpdb->get_results( $sql );
$elapsed_ms = ( microtime( true ) - $start ) * 1000;
return array( 'duration_ms' => round( $elapsed_ms, 2 ) );
}
/**
* Detect MySQL vs SQLite.
*
* @return bool
*/
private static function is_mysql(): bool {
return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) );
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
/**
* TMDO_Termmeta_Cleaner — wp_termmeta garbage cleanup (v2.12.0 Phase 0).
*
* Identifies and removes three classes of low-value rows from wp_termmeta
* that bloat the table without serving any business purpose:
*
* - wxr_import — `_wxr_import_*` rows left by WordPress importer (never
* read after import, pure tracking residue)
* - demo_data — `_2meet_demo_*` rows used to mark demo content
* (can be re-seeded; safe to drop)
* - transients — `_transient_*` and `_transient_timeout_*` rows
* (HivePress / WC term cache layer; auto-rebuilt)
*
* Mirrors TMDO_Postmeta_Cleaner pattern. Runs before any term Entity Bridge
* migration so subsequent ratio measurements reflect real data, not garbage.
* Pure DB layer — no Hook Bus / Entity Bridge coupling.
*
* @package WP_Data_Optimizer
* @since 2.12.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Wp_termmeta garbage cleanup (v2.12.0 Phase 0).
*/
class TMDO_Termmeta_Cleaner {
public const TARGET_WXR_IMPORT = 'wxr_import';
public const TARGET_DEMO_DATA = 'demo_data';
public const TARGET_TRANSIENTS = 'transients';
public const TARGET_ALL = 'all';
public const VALID_TARGETS = array(
self::TARGET_WXR_IMPORT,
self::TARGET_DEMO_DATA,
self::TARGET_TRANSIENTS,
self::TARGET_ALL,
);
/**
* Count rows that would be cleaned for the given target.
*
* @param string $target One of TARGET_* constants.
* @return array{wxr_import:int, demo_data:int, transients:int, total:int}
* @throws InvalidArgumentException When $target is not a valid target.
*/
public static function count_garbage( string $target = self::TARGET_ALL ): array {
self::assert_valid_target( $target );
$counts = array(
'wxr_import' => 0,
'demo_data' => 0,
'transients' => 0,
'total' => 0,
);
if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) {
$counts['wxr_import'] = self::count_wxr_import();
}
if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) {
$counts['demo_data'] = self::count_demo_data();
}
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
$counts['transients'] = self::count_transients();
}
$counts['total'] = $counts['wxr_import'] + $counts['demo_data'] + $counts['transients'];
return $counts;
}
/**
* Delete garbage rows for the given target.
*
* @param string $target One of TARGET_* constants.
* @return array{wxr_import:int, demo_data:int, transients:int, total:int}
* @throws InvalidArgumentException When $target is not a valid target.
*/
public static function delete_garbage( string $target = self::TARGET_ALL ): array {
self::assert_valid_target( $target );
$deleted = array(
'wxr_import' => 0,
'demo_data' => 0,
'transients' => 0,
'total' => 0,
);
if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) {
$deleted['wxr_import'] = self::delete_wxr_import();
}
if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) {
$deleted['demo_data'] = self::delete_demo_data();
}
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
$deleted['transients'] = self::delete_transients();
}
$deleted['total'] = $deleted['wxr_import'] + $deleted['demo_data'] + $deleted['transients'];
return $deleted;
}
/**
* Whether $target selects $bucket (i.e. target=all or target=bucket).
*
* @param string $target Selected target.
* @param string $bucket Bucket constant.
* @return bool
*/
private static function target_includes( string $target, string $bucket ): bool {
return self::TARGET_ALL === $target || $bucket === $target;
}
/**
* Validate target parameter.
*
* @param string $target Target to validate.
* @return void
* @throws InvalidArgumentException When $target is not in VALID_TARGETS.
*/
private static function assert_valid_target( string $target ): void {
if ( in_array( $target, self::VALID_TARGETS, true ) ) {
return;
}
$msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) );
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->termmeta is WP-managed; meta_key patterns are static class constants.
/**
* Count `_wxr_import_*` rows in wp_termmeta.
*
* @return int
*/
private static function count_wxr_import(): int {
global $wpdb;
$table = $wpdb->termmeta;
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'"
);
}
/**
* Delete `_wxr_import_*` rows from wp_termmeta.
*
* @return int Affected row count.
*/
private static function delete_wxr_import(): int {
global $wpdb;
$table = $wpdb->termmeta;
return (int) $wpdb->query(
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'"
);
}
/**
* Count `_2meet_demo_*` rows in wp_termmeta.
*
* @return int
*/
private static function count_demo_data(): int {
global $wpdb;
$table = $wpdb->termmeta;
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'"
);
}
/**
* Delete `_2meet_demo_*` rows from wp_termmeta.
*
* @return int Affected row count.
*/
private static function delete_demo_data(): int {
global $wpdb;
$table = $wpdb->termmeta;
return (int) $wpdb->query(
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'"
);
}
/**
* Count rows matching transient meta_key patterns in wp_termmeta.
*
* @return int
*/
private static function count_transients(): int {
global $wpdb;
$table = $wpdb->termmeta;
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
);
}
/**
* Delete rows matching transient meta_key patterns from wp_termmeta.
*
* @return int Affected row count.
*/
private static function delete_transients(): int {
global $wpdb;
$table = $wpdb->termmeta;
return (int) $wpdb->query(
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
);
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
<?php
/**
* TMDO_V2_Upgrader — atomic v1.3.x → v2.0.0 upgrade orchestrator (PR-7).
*
* Implements the Part F upgrade strategy from the master plan:
* 1. Pre-flight: PHP / WP / MySQL versions, free disk, no in-flight migrations
* 2. Backup wpdo_features option (rollback safety net)
* 3. dbDelta v2 tables (idempotent)
* 4. ALTER existing tables (skip if already migrated)
* 5. UAE import (when wp_uae_* present, otherwise no-op)
* 6. migrate_feature_flags_v2 — seed entity module states
* 7. update wpdo_db_version → 2.0.0
* 8. safely_deactivate_uae_plugin (when present)
* 9. Schedule cron to remove the UAE plugin directory (5s)
* 10. fire wpdo_v2_upgraded action
*
* Any throw → catch → write wpdo_v2_upgrade_error → restore features → admin
* notice. New tables are NOT dropped on failure (idempotent retry).
*
* @package WP_Data_Optimizer
* @since 2.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Atomic upgrade orchestrator.
*/
final class TMDO_V2_Upgrader {
/** Target schema version after successful upgrade. */
public const TARGET_VERSION = '2.0.0';
/** Min PHP / WP / MySQL versions enforced by pre-flight. */
public const MIN_PHP_VERSION = '8.1';
public const MIN_WP_VERSION = '6.0';
public const MIN_MYSQL_VERSION = '5.7';
/** Free-disk threshold in MB. */
public const MIN_FREE_DISK_MB = 100;
/**
* Pre-flight check — returns map of check_name => bool.
*
* @param array $opts Optional overrides (mostly for tests).
* @return array<string,bool>
*/
public static function pre_flight_check( array $opts = array() ): array {
global $wpdb;
$wp_version = $opts['wp_version'] ?? ( defined( 'ABSPATH' ) ? ( get_bloginfo( 'version' ) ?: '6.0' ) : '6.0' );
$mysql_version = $opts['mysql_version'] ?? self::detect_mysql_version();
$disk_path = $opts['disk_path'] ?? sys_get_temp_dir();
$checks = array(
'php_version' => version_compare( PHP_VERSION, self::MIN_PHP_VERSION, '>=' ),
'wp_version' => version_compare( $wp_version, self::MIN_WP_VERSION, '>=' ),
'mysql_version' => version_compare( $mysql_version, self::MIN_MYSQL_VERSION, '>=' ),
'free_disk_mb' => self::detect_free_disk_mb( $disk_path ) >= self::MIN_FREE_DISK_MB,
'features_writable' => self::is_features_option_writable(),
'no_active_migration' => self::no_active_migration(),
);
return $checks;
}
/**
* Run the atomic upgrade. Returns true on success, false on failure.
*
* @return bool
* @throws \RuntimeException When UAE importer fails (caught internally and rolled back).
*/
public static function upgrade_to_v2(): bool {
// 1. Pre-flight check.
$checks = self::pre_flight_check();
if ( in_array( false, $checks, true ) ) {
update_option(
'wpdo_v2_upgrade_error',
'Pre-flight check failed: ' . wp_json_encode( $checks ),
false
);
update_option( 'wpdo_v2_upgrade_status', 'preflight_failed', false );
return false;
}
update_option( 'wpdo_v2_upgrade_started_at', time(), false );
update_option( 'wpdo_v2_upgrade_status', 'in_progress', false );
$features_backup = get_option( 'wpdo_features', array() );
update_option( 'wpdo_v2_features_backup', $features_backup, false );
try {
// 4. Install v2 tables.
TMDO_Installer::install_v2_tables();
// 5. UAE import (when applicable).
if ( self::detect_uae_data() ) {
$import_ok = self::run_uae_import();
if ( ! $import_ok ) {
throw new \RuntimeException( 'UAE importer failed' );
}
}
// 6. Migrate feature flags to include entity modules.
self::migrate_feature_flags_v2();
// 7. Bump db_version.
update_option( 'wpdo_db_version', self::TARGET_VERSION, false );
update_option( 'wpdo_v2_upgrade_status', 'complete', false );
update_option( 'wpdo_v2_upgraded_at', time(), false );
// 8. Safely deactivate UAE plugin.
self::safely_deactivate_uae_plugin();
// 9. Schedule plugin directory removal.
self::schedule_uae_dir_removal();
// 10. Fire hook for downstream consumers.
do_action( 'wpdo_v2_upgraded', '1.3.38', self::TARGET_VERSION );
return true;
} catch ( \Throwable $e ) {
// Rollback path: restore features option, leave new tables in place
// (idempotent retry on next attempt).
update_option( 'wpdo_features', $features_backup );
update_option( 'wpdo_v2_upgrade_error', $e->getMessage(), false );
update_option( 'wpdo_v2_upgrade_status', 'failed', false );
return false;
}
}
/**
* Roll back from a partially-completed v2 upgrade.
*
* @param bool $keep_data When true, retain wpdo_uni_* tables (for retry).
* @return bool
*/
public static function rollback_v2( bool $keep_data = true ): bool {
$backup = get_option( 'wpdo_v2_features_backup' );
if ( false !== $backup ) {
update_option( 'wpdo_features', $backup );
}
update_option( 'wpdo_db_version', '1.0.0', false );
update_option( 'wpdo_v2_upgrade_status', 'rolled_back', false );
if ( ! $keep_data ) {
global $wpdb;
foreach ( array( 'wpdo_audit', 'wpdo_shadow_diffs', 'wpdo_site_metrics', 'wpdo_uni_options' ) as $t ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $t from hardcoded array, $wpdb->prefix sanitized by core.
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}{$t}`" );
}
}
return true;
}
// ── Helpers ─────────────────────────────────────────────────────────────
/**
* Detect whether wp_uae_* tables exist (S3 scenario).
*/
public static function detect_uae_data(): bool {
global $wpdb;
$count = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE %s',
$wpdb->prefix . 'uae_%'
)
);
return $count > 0;
}
/**
* Run the UAE → WPDO data importer. Stub for v2.0.0 — full implementation
* lives in includes/migration/class-tmdo-uae-importer.php (PR-7 follow-up).
*
* @return bool
*/
public static function run_uae_import(): bool {
if ( class_exists( 'TMDO_UAE_Importer' ) ) {
return TMDO_UAE_Importer::run(
array(
'auto' => true,
'keep_source' => true,
)
);
}
// No UAE importer yet — return true so dev10 (no UAE data) progresses.
return true;
}
/**
* Migrate the feature_flags option to include entity modules.
*/
public static function migrate_feature_flags_v2(): void {
$flags = get_option( 'wpdo_features', array() );
if ( ! is_array( $flags ) ) {
$flags = array();
}
foreach ( array( 'entity_user', 'entity_term', 'entity_comment', 'entity_options' ) as $module ) {
if ( ! isset( $flags[ $module ] ) ) {
$flags[ $module ] = 'idle';
}
}
update_option( 'wpdo_features', $flags );
}
/**
* Detect installed MySQL / MariaDB version via @@version.
*/
public static function detect_mysql_version(): string {
global $wpdb;
try {
$v = (string) $wpdb->get_var( 'SELECT VERSION()' );
// Strip trailing -MariaDB or similar tags for version_compare.
if ( preg_match( '/^([\d.]+)/', $v, $m ) ) {
return $m[1];
}
} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- intentional: fall through to safe default.
// Fall through to safe default.
}
return '5.7';
}
/**
* Detect free disk space in MB at the given path.
*
* @param string $path Filesystem path (typically sys_get_temp_dir()).
* @return int Free disk space in MB, or 0 when unreadable.
*/
public static function detect_free_disk_mb( string $path ): int {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- @ suppresses E_WARNING when path is on a stat-fail filesystem; explicit false-check follows.
$free = @disk_free_space( $path );
if ( false === $free ) {
return 0;
}
return (int) ( $free / 1024 / 1024 );
}
/**
* Verify wpdo_features option is writable (autoload=no row exists or absent).
*/
private static function is_features_option_writable(): bool {
$probe_key = '_wpdo_v2_writability_probe_' . wp_generate_password( 12, false );
$ok = update_option( $probe_key, time(), false );
delete_option( $probe_key );
return $ok;
}
/**
* Verify no migration is currently in-flight (zero rows in 'in_progress' state).
*/
private static function no_active_migration(): bool {
global $wpdb;
try {
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_migrations` WHERE state = %s",
'in_progress'
)
);
return 0 === $count;
} catch ( \Throwable $e ) {
// Table may not exist on truly fresh installs — treat as "no active migration".
return true;
}
}
/**
* Quietly deactivate wp-universal-anti-eav plugin if it is currently active.
*/
private static function safely_deactivate_uae_plugin(): void {
if ( ! function_exists( 'deactivate_plugins' ) || ! function_exists( 'is_plugin_active' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
if ( ! function_exists( 'is_plugin_active' ) ) {
return;
}
$slug = 'wp-universal-anti-eav/wp-universal-anti-eav.php';
if ( is_plugin_active( $slug ) ) {
deactivate_plugins( $slug, true );
}
}
/**
* Schedule a one-shot cron event to remove the UAE plugin directory.
*
* Filesystem permission failures fall back to admin notice for manual removal.
*/
private static function schedule_uae_dir_removal(): void {
if ( ! function_exists( 'wp_schedule_single_event' ) ) {
return;
}
if ( ! wp_next_scheduled( 'wpdo_remove_uae_plugin_dir' ) ) {
wp_schedule_single_event( time() + 5, 'wpdo_remove_uae_plugin_dir' );
}
}
}
+396
View File
@@ -0,0 +1,396 @@
<?php
/**
* Zone Classifier for wp_postmeta analysis and zone assignment suggestions.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone Classifier — analyzes wp_postmeta and suggests zone assignments.
*
* Examines meta_key usage patterns across the database to recommend
* which zone each key should belong to:
*
* Hot (A) — Used in WP_Query meta_query (search/filter); numeric or short values
* Warm (B) — Transient/computed data; infrequently updated
* Cold (C) — Display data read often but never queried; long text/JSON blobs
* Archive (D) — Belongs to trashed/old posts; rarely if ever accessed
*
* Signals analyzed:
* - Value length distribution (short = hot candidate, long = cold candidate)
* - Post status distribution (trash/draft heavy = archive candidate)
* - Key prefix patterns (hp_, _hp_, _transient_ etc.)
* - Whether the key appears in meta_query (via slow query log or heuristics)
* - Distinct value cardinality (low = likely enum/flag = hot)
*
* Used by Admin UI and WP-CLI `wp wpdo analyze` command.
*/
class TMDO_Zone_Classifier {
/**
* Analyze postmeta for a specific post type and return zone suggestions.
*
* @param string $post_type Post type to analyze.
* @param int $sample_size Number of rows to sample per meta_key.
* @return array Array of suggestions, each with: meta_key, suggested_zone, confidence, reasons.
*/
public static function analyze( string $post_type, int $sample_size = 100 ): array {
global $wpdb;
// Return cached result if available (TTL: 1 hour).
$transient_key = 'wpdo_classifier_' . sanitize_key( $post_type );
$cached = get_transient( $transient_key );
if ( false !== $cached ) {
return $cached;
}
// Get all distinct meta_keys for this post type.
$keys = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.meta_key, COUNT(*) as row_count
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s
GROUP BY pm.meta_key
ORDER BY row_count DESC
LIMIT 200",
$post_type
),
ARRAY_A
);
if ( empty( $keys ) ) {
return array();
}
$suggestions = array();
$registry = TMDO_Schema_Registry::instance();
foreach ( $keys as $key_info ) {
$meta_key = $key_info['meta_key'];
$row_count = (int) $key_info['row_count'];
// Skip WordPress internal keys.
if ( self::is_wp_internal( $meta_key ) ) {
continue;
}
// Check if already registered.
$existing = $registry->get_field( $post_type, $meta_key );
$already_assigned = $existing ? $existing['zone'] : null;
// Collect signals.
$signals = self::collect_signals( $meta_key, $post_type, $row_count, $sample_size );
// Score each zone.
$scores = self::score_zones( $signals );
// Pick the best zone.
arsort( $scores );
$best_zone = array_key_first( $scores );
$confidence = $scores[ $best_zone ];
$suggestions[] = array(
'meta_key' => $meta_key,
'row_count' => $row_count,
'suggested_zone' => $best_zone,
'confidence' => round( $confidence, 2 ),
'already_assigned' => $already_assigned,
'scores' => $scores,
'reasons' => self::build_reasons( $signals, $best_zone ),
);
}
// Sort by confidence descending.
usort( $suggestions, fn( $a, $b ) => $b['confidence'] <=> $a['confidence'] );
set_transient( $transient_key, $suggestions, HOUR_IN_SECONDS );
return $suggestions;
}
/**
* Quick summary: count of meta_keys per suggested zone.
*
* @param string $post_type Post type to analyze.
* @return array{hot: int, warm: int, cold: int, archive: int, already_assigned: int}
*/
public static function summary( string $post_type ): array {
$suggestions = self::analyze( $post_type );
$summary = array(
'hot' => 0,
'warm' => 0,
'cold' => 0,
'archive' => 0,
'already_assigned' => 0,
);
foreach ( $suggestions as $s ) {
if ( $s['already_assigned'] ) {
++$summary['already_assigned'];
} else {
++$summary[ $s['suggested_zone'] ];
}
}
return $summary;
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Collect analytical signals for a meta_key.
*
* @param string $meta_key Meta key to analyze.
* @param string $post_type Post type context.
* @param int $row_count Total rows for this meta key.
* @param int $sample_size Number of rows sampled.
* @return array Signal data for zone scoring.
*/
private static function collect_signals( string $meta_key, string $post_type, int $row_count, int $sample_size ): array {
global $wpdb;
$signals = array(
'meta_key' => $meta_key,
'row_count' => $row_count,
'avg_length' => 0,
'max_length' => 0,
'distinct_values' => 0,
'numeric_ratio' => 0.0,
'trash_ratio' => 0.0,
'is_serialized' => false,
'is_json' => false,
'prefix' => '',
);
// Prefix detection.
if ( str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' ) ) {
$signals['prefix'] = 'hivepress';
} elseif ( str_starts_with( $meta_key, '_transient_' ) || str_starts_with( $meta_key, '_site_transient_' ) ) {
$signals['prefix'] = 'transient';
} elseif ( str_starts_with( $meta_key, '_' ) ) {
$signals['prefix'] = 'internal';
}
// Sample values for analysis.
$samples = $wpdb->get_col(
$wpdb->prepare(
"SELECT pm.meta_value
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key = %s
LIMIT %d",
$post_type,
$meta_key,
$sample_size
)
);
if ( ! empty( $samples ) ) {
$lengths = array_map( 'strlen', $samples );
$signals['avg_length'] = (int) ( array_sum( $lengths ) / count( $lengths ) );
$signals['max_length'] = max( $lengths );
$numeric_count = 0;
foreach ( $samples as $val ) {
if ( is_numeric( $val ) ) {
++$numeric_count;
}
}
$signals['numeric_ratio'] = $numeric_count / count( $samples );
// Check serialized/JSON.
$first = $samples[0] ?? '';
$signals['is_serialized'] = is_serialized( $first );
$signals['is_json'] = ( str_starts_with( $first, '{' ) || str_starts_with( $first, '[' ) )
&& null !== json_decode( $first );
}
// Distinct value count.
$signals['distinct_values'] = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT pm.meta_value)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key = %s",
$post_type,
$meta_key
)
);
// Trash ratio.
if ( $row_count > 0 ) {
$trash_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key = %s AND p.post_status = 'trash'",
$post_type,
$meta_key
)
);
$signals['trash_ratio'] = $trash_count / $row_count;
}
return $signals;
}
/**
* Score each zone based on collected signals.
*
* @param array $signals Signal data from collect_signals().
* @return array{hot: float, warm: float, cold: float, archive: float} Zone scores.
*/
private static function score_zones( array $signals ): array {
$scores = array(
'hot' => 0.0,
'warm' => 0.0,
'cold' => 0.0,
'archive' => 0.0,
);
// --- Hot signals ---
// Short, numeric values are great for indexing.
if ( $signals['avg_length'] < 50 ) {
$scores['hot'] += 0.3;
}
if ( $signals['numeric_ratio'] > 0.8 ) {
$scores['hot'] += 0.3;
}
// Low cardinality = enum/flag = good for filtering.
if ( $signals['distinct_values'] > 0 && $signals['distinct_values'] <= 20 ) {
$scores['hot'] += 0.2;
}
// HivePress prefix = likely a search field.
if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] < 100 ) {
$scores['hot'] += 0.2;
}
// --- Warm signals ---
// Transient prefix is a clear warm signal.
if ( 'transient' === $signals['prefix'] ) {
$scores['warm'] += 0.8;
}
// Internal prefix + short values.
if ( 'internal' === $signals['prefix'] && $signals['avg_length'] < 100 ) {
$scores['warm'] += 0.2;
}
// --- Cold signals ---
// Long text/JSON blobs are cold candidates.
if ( $signals['avg_length'] > 200 ) {
$scores['cold'] += 0.4;
}
if ( $signals['is_json'] || $signals['is_serialized'] ) {
$scores['cold'] += 0.3;
}
// High cardinality + long values = display/profile data.
if ( $signals['distinct_values'] > 50 && $signals['avg_length'] > 100 ) {
$scores['cold'] += 0.2;
}
// HivePress prefix + long values = description field.
if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] > 100 ) {
$scores['cold'] += 0.2;
}
// --- Archive signals ---
// High trash ratio = archive candidate.
if ( $signals['trash_ratio'] > 0.5 ) {
$scores['archive'] += 0.6;
} elseif ( $signals['trash_ratio'] > 0.2 ) {
$scores['archive'] += 0.3;
}
// Normalize: ensure at least one zone has a score.
$max = max( $scores );
if ( 0.0 === $max ) {
// Default to cold for unknown patterns.
$scores['cold'] = 0.1;
}
return $scores;
}
/**
* Build human-readable reasons for the zone suggestion.
*
* @param array $signals Signal data from collect_signals().
* @param string $zone Suggested zone name.
* @return array Array of human-readable reason strings.
*/
private static function build_reasons( array $signals, string $zone ): array {
$reasons = array();
switch ( $zone ) {
case 'hot':
if ( $signals['avg_length'] < 50 ) {
$reasons[] = sprintf( 'Short values (avg %d chars) — efficient for indexing', $signals['avg_length'] );
}
if ( $signals['numeric_ratio'] > 0.8 ) {
$reasons[] = sprintf( '%.0f%% numeric values — ideal for range queries', $signals['numeric_ratio'] * 100 );
}
if ( $signals['distinct_values'] <= 20 ) {
$reasons[] = sprintf( 'Low cardinality (%d distinct values) — good for filtering', $signals['distinct_values'] );
}
break;
case 'warm':
if ( 'transient' === $signals['prefix'] ) {
$reasons[] = 'Transient prefix detected — ephemeral data with natural TTL';
}
break;
case 'cold':
if ( $signals['avg_length'] > 200 ) {
$reasons[] = sprintf( 'Long values (avg %d chars) — display/profile data', $signals['avg_length'] );
}
if ( $signals['is_json'] ) {
$reasons[] = 'JSON structure detected — good for blob storage';
}
if ( $signals['is_serialized'] ) {
$reasons[] = 'Serialized data — good for blob storage';
}
break;
case 'archive':
if ( $signals['trash_ratio'] > 0.2 ) {
$reasons[] = sprintf( '%.0f%% of entries belong to trashed posts', $signals['trash_ratio'] * 100 );
}
break;
}
if ( empty( $reasons ) ) {
$reasons[] = 'Default classification based on overall signal pattern';
}
return $reasons;
}
/**
* Check if a meta_key is a WordPress internal key that should be skipped.
*
* @param string $meta_key Meta key to check.
* @return bool True if the key is a WordPress internal key.
*/
private static function is_wp_internal( string $meta_key ): bool {
$skip = array(
'_edit_lock',
'_edit_last',
'_wp_page_template',
'_wp_old_slug',
'_wp_trash_meta_time',
'_wp_trash_meta_status',
'_wp_desired_post_slug',
'_thumbnail_id',
'_encloseme',
'_pingme',
);
return in_array( $meta_key, $skip, true );
}
}
@@ -0,0 +1,288 @@
<?php
/**
* TMDO_Health_Cron — Daily health probe (v2.3.0 M6).
*
* Runs daily at 03:30 UTC. Aggregates results from:
* - TMDO_Site_Health 7 tests (schema_drift, error_budget, hook_conflicts,
* autoload_bloat, postmeta_explosion, orphan_zone_rows, missing_snapshot)
* - TMDO_Conflict_Monitor::get_summary()
* - shadow_diffs ratio per module in `verify` state
* - autoload size measurement
*
* Outputs:
* 1. Single audit log entry: op='health_check_daily' with full payload
* (so admin can read history via Logs tab + `wp wpdo audit` future CLI).
* 2. wpdo_health_alert option set when any critical found (existing
* TMDO_Core::render_health_alert_notice consumes this).
* 3. wpdo_health_last_run option for SOP runbook "is health green?" question.
* 4. action 'wpdo/health_alert_critical' fired on critical (v2.4.0 email
* notifier subscribes here; consumers get the full result array).
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Daily probe — stateless static API.
*/
class TMDO_Health_Cron {
/** Option key for the most recent run summary. */
public const OPTION_LAST_RUN = 'wpdo_health_last_run';
/** Option key for active critical alert (consumed by Core notice). */
public const OPTION_ALERT = 'wpdo_health_alert';
/**
* Run the daily probe. Idempotent — safe to invoke ad-hoc.
*
* @return array {ok:bool, summary:array, critical_count:int, recommended_count:int, ts:string}
*/
public static function run(): array {
$started_at = microtime( true );
$results = self::run_site_health_tests();
$conflict = self::summarize_conflicts();
$shadow = self::summarize_shadow_diffs();
$autoload = self::measure_autoload_size();
$critical_count = 0;
$recommended_count = 0;
foreach ( $results as $r ) {
$status = (string) ( $r['status'] ?? 'good' );
if ( 'critical' === $status ) {
++$critical_count;
} elseif ( 'recommended' === $status ) {
++$recommended_count;
}
}
$summary = array(
'tests' => $results,
'critical_count' => $critical_count,
'recommended_count' => $recommended_count,
'conflicts' => $conflict,
'shadow_diffs' => $shadow,
'autoload_bytes' => $autoload,
'ran_at' => gmdate( 'Y-m-d H:i:s' ),
'duration_ms' => (int) round( ( microtime( true ) - $started_at ) * 1000 ),
);
// 1. Persist last-run snapshot (autoload=no, lightweight).
update_option( self::OPTION_LAST_RUN, $summary, false );
// 2. Set / clear alert flag.
if ( $critical_count > 0 ) {
$first_critical = self::first_critical_test( $results );
update_option(
self::OPTION_ALERT,
array(
'level' => 'critical',
'count' => $critical_count,
'first' => $first_critical,
'ran_at' => $summary['ran_at'],
),
false
);
} else {
delete_option( self::OPTION_ALERT );
}
// v2.5.0 M16: refresh module suggestions cache (autoload=no).
$module_suggestions_count = 0;
if ( class_exists( 'TMDO_Module_Detector' ) ) {
try {
$detected = TMDO_Module_Detector::detect_all( true );
foreach ( $detected as $r ) {
if ( ! empty( $r['available'] ) && 'enable' === ( $r['recommendation'] ?? '' ) ) {
++$module_suggestions_count;
}
}
} catch ( Throwable $e ) {
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
error_log( '[WPDO] Module detector exception in health cron: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
// 3. Audit log entry.
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'health_check_daily',
array(
'critical' => $critical_count,
'recommended' => $recommended_count,
'duration_ms' => $summary['duration_ms'],
'autoload_kb' => (int) round( $autoload / 1024 ),
'conflicts' => (int) ( $conflict['total'] ?? 0 ),
'module_suggestions_count' => $module_suggestions_count,
)
);
}
// 4. Fire action for downstream subscribers (v2.4.0 email notifier).
if ( $critical_count > 0 ) {
do_action( 'wpdo/health_alert_critical', $summary );
} else {
do_action( 'wpdo/health_check_passed', $summary );
}
return array(
'ok' => true,
'summary' => $summary,
'critical_count' => $critical_count,
'recommended_count' => $recommended_count,
'ts' => $summary['ran_at'],
);
}
/**
* Read most recent run (for SOP runbook + Doctor tab streak counter).
*
* @return array|null
*/
public static function get_last_run(): ?array {
$v = get_option( self::OPTION_LAST_RUN );
return is_array( $v ) ? $v : null;
}
/**
* Compute consecutive green days from audit log (best-effort for SOP UI).
*
* @return int
*/
public static function consecutive_green_days(): int {
$last = self::get_last_run();
if ( null === $last ) {
return 0;
}
// If today's run is critical, streak = 0.
if ( ( $last['critical_count'] ?? 0 ) > 0 ) {
return 0;
}
// Otherwise approximate via TMDO_Logger — count distinct days with health_check_daily and 0 critical.
// Conservative best-effort: just check today's run is green = 1 day.
return 1;
}
// ─── private helpers ────────────────────────────────────────────────
/**
* Run all 7 Site Health tests directly (without the WP Site Health UI loop).
*
* @return array<string,array> Test slug → result array.
*/
private static function run_site_health_tests(): array {
$out = array();
if ( ! class_exists( 'TMDO_Site_Health' ) ) {
return $out;
}
$tests = array(
'wpdo_schema_drift' => 'check_schema_drift',
'wpdo_error_budget' => 'check_error_budget',
'wpdo_hook_conflicts' => 'check_hook_conflicts',
'wpdo_autoload_bloat' => 'check_autoload_bloat',
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
'wpdo_missing_snapshot' => 'check_missing_snapshot',
);
foreach ( $tests as $slug => $cb ) {
try {
$result = call_user_func( array( 'TMDO_Site_Health', $cb ) );
if ( is_array( $result ) ) {
$out[ $slug ] = array(
'status' => (string) ( $result['status'] ?? 'good' ),
'severity' => (string) ( $result['severity'] ?? 'good' ),
'label' => (string) ( $result['label'] ?? $slug ),
'description' => wp_strip_all_tags( (string) ( $result['description'] ?? '' ) ),
);
}
} catch ( Throwable $e ) {
$out[ $slug ] = array(
'status' => 'critical',
'severity' => 'critical',
'label' => $slug,
'description' => 'test threw: ' . $e->getMessage(),
);
}
}
return $out;
}
/**
* Return a conflict summary from TMDO_Conflict_Monitor.
*
* @return array {total:int, hook_overlap:int, uaepg_overlap:int}
*/
private static function summarize_conflicts(): array {
if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) {
return array(
'total' => 0,
'hook_overlap' => 0,
'uaepg_overlap' => 0,
);
}
$summary = TMDO_Conflict_Monitor::get_summary();
return array(
'total' => (int) ( $summary['total'] ?? 0 ),
'hook_overlap' => (int) ( $summary['hook_overlap'] ?? 0 ),
'uaepg_overlap' => (int) ( $summary['uaepg_overlap'] ?? 0 ),
);
}
/**
* Per-module shadow_diffs ratio (only for modules currently in `verify`).
* Reads wp_wpdo_shadow_diffs and bucket-counts by entity_type.
*
* @return array<string,array>
*/
private static function summarize_shadow_diffs(): array {
global $wpdb;
$out = array();
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$table
)
);
if ( 0 === $exists ) {
return $out;
}
$rows = $wpdb->get_results( "SELECT entity_type, COUNT(*) AS cnt FROM `{$table}` WHERE ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR) GROUP BY entity_type", ARRAY_A ); // phpcs:ignore WordPress.DB
if ( is_array( $rows ) ) {
foreach ( $rows as $r ) {
$out[ (string) $r['entity_type'] ] = array(
'diffs_24h' => (int) $r['cnt'],
);
}
}
return $out;
}
/**
* Return the total byte size of autoloaded options.
*
* @return int Bytes in autoloaded options.
*/
private static function measure_autoload_size(): int {
global $wpdb;
return (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
}
/**
* Return the slug of the first critical test result, or null if none.
*
* @param array $results Site Health test results map.
* @return string|null Slug of first critical test, or null.
*/
private static function first_critical_test( array $results ): ?string {
foreach ( $results as $slug => $r ) {
if ( 'critical' === ( $r['status'] ?? '' ) ) {
return $slug;
}
}
return null;
}
}
@@ -0,0 +1,283 @@
<?php
/**
* TMDO_Monthly_Summary — 30-day rollup report (v2.4.0 M11).
*
* Generates a monthly executive summary covering
* - Health success / fail / rate-limit ratios over last 30 days
* - Zone size growth (deltas from 30 days ago snapshot if available)
* - Per-module FSM trajectory (who promoted, who rolled back)
* - Snapshot retention overview (count, oldest, newest)
*
* Hooks the existing `wpdo_health_snapshot_monthly` action (Core line ~489)
* so it runs once per month at 03:00 UTC on the 1st. Output
* 1. Persisted to wp_wpdo_audit (op='monthly_summary').
* 2. Stored as wp_options.wpdo_monthly_summary_latest (autoload=no).
* 3. Rendered on the admin Doctor tab "📅 Monthly Summary" sub-section.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Monthly summary aggregator.
*/
class TMDO_Monthly_Summary {
public const OPTION_LATEST = 'wpdo_monthly_summary_latest';
/**
* Hook into Core's existing monthly cron.
*
* @return void
*/
public static function register(): void {
add_action( 'wpdo_health_snapshot_monthly', array( __CLASS__, 'generate' ), 20 );
}
/**
* Build + persist the monthly summary.
*
* @return array Summary structure.
*/
public static function generate(): array {
$started_at = microtime( true );
$summary = array(
'period_start' => gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) ),
'period_end' => gmdate( 'Y-m-d H:i:s' ),
'health' => self::aggregate_health(),
'fsm_trajectory' => self::aggregate_fsm(),
'snapshots' => self::aggregate_snapshots(),
'zone_growth' => self::aggregate_zone_growth(),
'autoload_size' => self::measure_autoload(),
'duration_ms' => 0,
'generated_at' => gmdate( 'Y-m-d H:i:s' ),
);
$summary['duration_ms'] = (int) round( ( microtime( true ) - $started_at ) * 1000 );
update_option( self::OPTION_LATEST, $summary, false );
// v2.5.0 M14: archive into history (max 12 entries).
$history = (array) get_option( 'wpdo_monthly_summary_history', array() );
array_unshift( $history, $summary );
$history = array_slice( $history, 0, 12 );
update_option( 'wpdo_monthly_summary_history', $history, false );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'monthly_summary',
array(
'health_success' => (int) ( $summary['health']['success'] ?? 0 ),
'health_critical' => (int) ( $summary['health']['critical'] ?? 0 ),
'fsm_promotions' => count( $summary['fsm_trajectory']['promotions'] ?? array() ),
'fsm_rollbacks' => count( $summary['fsm_trajectory']['rollbacks'] ?? array() ),
'snapshots_total' => (int) ( $summary['snapshots']['total'] ?? 0 ),
'autoload_kb' => (int) round( ( $summary['autoload_size'] ?? 0 ) / 1024 ),
)
);
}
do_action( 'wpdo/monthly_summary_generated', $summary );
return $summary;
}
/**
* Read the latest summary (from wp_options).
*
* @return array|null
*/
public static function get_latest(): ?array {
$v = get_option( self::OPTION_LATEST );
return is_array( $v ) ? $v : null;
}
// ─── private ────────────────────────────────────────────────────
/**
* Aggregate health-check audit log entries from last 30 days.
*
* @return array {success:int, recommended:int, critical:int, total:int}
*/
private static function aggregate_health(): array {
global $wpdb;
$out = array(
'success' => 0,
'recommended' => 0,
'critical' => 0,
'total' => 0,
);
// wp_wpdo_audit may not exist on early v1.x installs; check first.
$audit = $wpdb->prefix . 'wpdo_audit';
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$audit
)
);
if ( 0 === $exists ) {
return $out;
}
// Health check audit rows have op='health_check_daily'.
$rows = (array) $wpdb->get_results( "SELECT * FROM `{$audit}` WHERE op = 'health_check_daily' AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)", ARRAY_A ); // phpcs:ignore WordPress.DB
foreach ( $rows as $r ) {
$ctx = isset( $r['value_after'] ) ? json_decode( (string) $r['value_after'], true ) : null;
if ( ! is_array( $ctx ) ) {
continue;
}
$crit = (int) ( $ctx['critical'] ?? 0 );
$rec = (int) ( $ctx['recommended'] ?? 0 );
++$out['total'];
if ( $crit > 0 ) {
++$out['critical'];
} elseif ( $rec > 0 ) {
++$out['recommended'];
} else {
++$out['success'];
}
}
return $out;
}
/**
* Best-effort FSM trajectory: which modules changed state in the last 30
* days. Reads `wpdo_fsm_state_entered` + current state.
*
* @return array {promotions:array, rollbacks:array, stationary:array}
*/
private static function aggregate_fsm(): array {
$out = array(
'promotions' => array(),
'rollbacks' => array(),
'stationary' => array(),
);
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
return $out;
}
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
$now = time();
foreach ( TMDO_Feature_Flags::all() as $module => $state ) {
$ts = isset( $entered[ $module ]['entered_at'] ) ? strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' ) : 0;
$age_days = $ts > 0 ? (int) floor( ( $now - $ts ) / DAY_IN_SECONDS ) : null;
if ( $ts > 0 && ( $now - $ts ) <= ( 30 * DAY_IN_SECONDS ) ) {
// Recently changed — bucket by direction.
if ( 'idle' === $state ) {
$out['rollbacks'][ $module ] = array(
'state' => $state,
'days_in_state' => $age_days,
);
} else {
$out['promotions'][ $module ] = array(
'state' => $state,
'days_in_state' => $age_days,
);
}
} else {
$out['stationary'][ $module ] = array(
'state' => $state,
'days_in_state' => $age_days,
);
}
}
return $out;
}
/**
* Aggregate snapshot retention stats from the snapshots table.
*
* @return array {total:int, oldest:string|null, newest:string|null, total_bytes:int}
*/
private static function aggregate_snapshots(): array {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_snapshots';
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$table
)
);
if ( 0 === $exists ) {
return array(
'total' => 0,
'oldest' => null,
'newest' => null,
'total_bytes' => 0,
);
}
$row = $wpdb->get_row( "SELECT COUNT(*) AS c, MIN(created_at) AS oldest, MAX(created_at) AS newest, COALESCE(SUM(size_bytes),0) AS bytes FROM `{$table}`", ARRAY_A ); // phpcs:ignore WordPress.DB
return array(
'total' => (int) ( $row['c'] ?? 0 ),
'oldest' => isset( $row['oldest'] ) ? (string) $row['oldest'] : null,
'newest' => isset( $row['newest'] ) ? (string) $row['newest'] : null,
'total_bytes' => (int) ( $row['bytes'] ?? 0 ),
);
}
/**
* Per-zone row count snapshot. Compares to previous month's value when
* available (delta_rows positive = growth).
*
* @return array<string,array>
*/
private static function aggregate_zone_growth(): array {
// Use historical site_metrics when available — avoids live COUNT(*) on large tables
// and provides a 30-day delta (current oldest snapshot).
if ( class_exists( 'TMDO_Site_Metrics_Collector' ) ) {
$latest = TMDO_Site_Metrics_Collector::get_latest_snapshot();
if ( ! empty( $latest ) ) {
// Get oldest snapshot within 30 days for delta calculation.
$keys_of_interest = array( 'eav.postmeta_rows', 'flat.hot_rows', 'flat.cold_rows', 'flat.warm_rows', 'custom_tables.total_rows' );
$out = array();
foreach ( $keys_of_interest as $mk ) {
if ( ! isset( $latest[ $mk ] ) ) {
continue;
}
$history = TMDO_Site_Metrics_Collector::get_history( $mk, 30 );
$oldest = ! empty( $history ) ? (int) $history[0]['value'] : null;
$current = (int) $latest[ $mk ];
$out[ $mk ] = array(
'rows' => $current,
'delta_rows' => null !== $oldest ? $current - $oldest : null,
);
}
return $out;
}
}
// Fallback: live COUNT(*) for warm + archive tables (pre-v2.6.2 installs).
global $wpdb;
$out = array();
$keys = array( 'wpdo_warm', 'wpdo_archive' );
foreach ( $keys as $slug ) {
$table = $wpdb->prefix . $slug;
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$table
)
);
if ( 0 === $exists ) {
continue;
}
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
$out[ $slug ] = array(
'rows' => $count,
'delta_rows' => null,
);
}
return $out;
}
/**
* Return the total byte size of autoloaded options.
*
* @return int Bytes in autoloaded options.
*/
private static function measure_autoload(): int {
global $wpdb;
return (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
}
}
@@ -0,0 +1,491 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for site health
/**
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
*
* Registers 7 tests under Tools → Site Health → Status:
* 1. wpdo_schema_drift (critical)
* 2. wpdo_error_budget (recommended)
* 3. wpdo_hook_conflicts (recommended)
* 4. wpdo_autoload_bloat (recommended)
* 5. wpdo_postmeta_explosion (recommended)
* 6. wpdo_orphan_zone_rows (recommended)
* 7. wpdo_missing_snapshot (critical)
*
* Each test result is cached for 5 minutes to keep Site Health responsive.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Site Health test suite. Hook on `init` admin context.
*/
class TMDO_Site_Health {
/** Transient cache TTL for individual checks. */
private const CACHE_TTL = 300;
/** Test name → callable suffix mapping. */
private const TESTS = array(
'wpdo_schema_drift' => 'check_schema_drift',
'wpdo_error_budget' => 'check_error_budget',
'wpdo_hook_conflicts' => 'check_hook_conflicts',
'wpdo_autoload_bloat' => 'check_autoload_bloat',
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
'wpdo_missing_snapshot' => 'check_missing_snapshot',
);
/**
* Hook into Site Health.
*
* @return void
*/
public static function register(): void {
add_filter( 'site_status_tests', array( __CLASS__, 'register_tests' ) );
}
/**
* Register the 7 tests with WP Site Health.
*
* @param array $tests Existing tests.
* @return array
*/
public static function register_tests( array $tests ): array {
foreach ( self::TESTS as $key => $cb_suffix ) {
$tests['direct'][ $key ] = array(
'label' => self::label_for( $key ),
'test' => array( __CLASS__, $cb_suffix ),
);
}
return $tests;
}
/**
* Human-readable label for each test.
*
* @param string $key Test slug.
* @return string
*/
private static function label_for( string $key ): string {
$map = array(
'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ),
'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ),
'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ),
'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ),
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
);
return $map[ $key ] ?? $key;
}
// ─── Test 1: Schema drift ─────────────────────────────────────────────
/**
* Verify all expected v2 tables exist.
*
* @return array Site Health result.
*/
public static function check_schema_drift(): array {
$result = self::cached(
'wpdo_sh_schema_drift',
static function () {
if ( ! class_exists( 'TMDO_Installer' ) ) {
return self::pass( __( 'WPDO installer not loaded.', '2meet-data-optimizer' ) );
}
if ( ! method_exists( 'TMDO_Installer', 'v2_tables_status' ) ) {
return self::pass( __( 'Schema check unavailable on this version.', '2meet-data-optimizer' ) );
}
$status = TMDO_Installer::v2_tables_status();
$missing = array_keys( array_filter( $status, static fn( $exists ) => ! $exists ) );
if ( empty( $missing ) ) {
return self::pass( __( 'All WPDO v2 tables exist.', '2meet-data-optimizer' ) );
}
return self::fail(
__( 'WPDO v2 tables missing', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: comma-separated list of missing table names */
__( 'Missing tables: %s. Run wp wpdo install or re-activate the plugin.', '2meet-data-optimizer' ),
implode( ', ', $missing )
),
'critical'
);
}
);
return self::wrap( 'wpdo_schema_drift', $result );
}
// ─── Test 2: Error budget (last 7 days) ──────────────────────────────
/**
* Count errors in wp_wpdo_errors over the last 7 days.
*
* @return array Site Health result.
*/
public static function check_error_budget(): array {
$result = self::cached(
'wpdo_sh_error_budget',
static function () {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_errors';
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$table
)
);
if ( 0 === $exists ) {
return self::pass( __( 'Error log not present (yet) — clean.', '2meet-data-optimizer' ) );
}
$threshold = (int) apply_filters( 'wpdo/site_health/error_budget_threshold', 100 );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)" ); // phpcs:ignore WordPress.DB
if ( $count <= $threshold ) {
return self::pass(
sprintf(
/* translators: %d: error count */
__( '%d errors in the last 7 days (within budget).', '2meet-data-optimizer' ),
$count
)
);
}
return self::fail(
__( 'WPDO error budget exceeded', '2meet-data-optimizer' ),
sprintf(
/* translators: 1: error count, 2: threshold */
__( '%1$d errors in the last 7 days (threshold %2$d). Inspect under Tools → WP Data Optimizer → Logs.', '2meet-data-optimizer' ),
$count,
$threshold
),
'recommended'
);
}
);
return self::wrap( 'wpdo_error_budget', $result );
}
// ─── Test 3: Hook conflicts ──────────────────────────────────────────
/**
* Check for interceptor or hook conflicts via TMDO_Conflict_Monitor.
*
* @return array Site Health result.
*/
public static function check_hook_conflicts(): array {
$result = self::cached(
'wpdo_sh_hook_conflicts',
static function () {
if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) {
return self::pass( __( 'Conflict monitor not loaded.', '2meet-data-optimizer' ) );
}
$summary = TMDO_Conflict_Monitor::get_summary();
$total = (int) ( $summary['total'] ?? 0 );
if ( 0 === $total ) {
return self::pass( __( 'No interceptor / hook conflicts detected.', '2meet-data-optimizer' ) );
}
return self::fail(
__( 'WPDO hook conflicts detected', '2meet-data-optimizer' ),
sprintf(
/* translators: %d: number of conflicts */
__( '%d interceptor/hook conflicts detected. Run wp wpdo conflict-scan for details.', '2meet-data-optimizer' ),
$total
),
'recommended'
);
}
);
return self::wrap( 'wpdo_hook_conflicts', $result );
}
// ─── Test 4: Autoload bloat ──────────────────────────────────────────
/**
* Check total autoloaded options size against a configurable threshold.
*
* @return array Site Health result.
*/
public static function check_autoload_bloat(): array {
$result = self::cached(
'wpdo_sh_autoload_bloat',
static function () {
global $wpdb;
$threshold_mb = (int) apply_filters( 'wpdo/site_health/autoload_threshold_mb', 5 );
$bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(option_value)) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
$mb = $bytes / 1024 / 1024;
if ( $mb < $threshold_mb ) {
return self::pass(
sprintf(
/* translators: 1: actual size in MB */
__( 'Autoload total %1$0.2f MB (under %2$d MB threshold).', '2meet-data-optimizer' ),
$mb,
$threshold_mb
)
);
}
return self::fail(
__( 'Autoload size large', '2meet-data-optimizer' ),
sprintf(
/* translators: 1: actual size in MB, 2: threshold in MB */
__( 'Autoload total %1$0.2f MB exceeds %2$d MB threshold. Consider migrating large autoloaded options to wp_wpdo_uni_options.', '2meet-data-optimizer' ),
$mb,
$threshold_mb
),
'recommended'
);
}
);
return self::wrap( 'wpdo_autoload_bloat', $result );
}
// ─── Test 5: Postmeta explosion ──────────────────────────────────────
/**
* Check whether wp_postmeta row count exceeds the explosion threshold.
*
* @return array Site Health result.
*/
public static function check_postmeta_explosion(): array {
$result = self::cached(
'wpdo_sh_postmeta_explosion',
static function () {
global $wpdb;
$threshold = (int) apply_filters( 'wpdo/site_health/postmeta_threshold', 5_000_000 );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
if ( $count < $threshold ) {
return self::pass(
sprintf(
/* translators: %s: row count */
__( 'wp_postmeta has %s rows (under threshold).', '2meet-data-optimizer' ),
number_format_i18n( $count )
)
);
}
// Also check if any zone module is active.
$any_active = false;
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
foreach ( TMDO_Feature_Flags::all() as $state ) {
if ( 'idle' !== $state ) {
$any_active = true;
break;
}
}
}
$severity = $any_active ? 'recommended' : 'critical';
return self::fail(
__( 'wp_postmeta is large', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: row count */
__( 'wp_postmeta has %s rows. Run the Classifier to identify candidates for migration into Hot/Warm/Cold/Archive zones.', '2meet-data-optimizer' ),
number_format_i18n( $count )
),
$severity
);
}
);
return self::wrap( 'wpdo_postmeta_explosion', $result );
}
// ─── Test 6: Orphan zone rows ────────────────────────────────────────
/**
* Detect zone table rows that belong to modules currently in idle state.
*
* @return array Site Health result.
*/
public static function check_orphan_zone_rows(): array {
$result = self::cached(
'wpdo_sh_orphan_zone',
static function () {
global $wpdb;
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
return self::pass( __( 'Feature flags not loaded.', '2meet-data-optimizer' ) );
}
$idle_modules = array_keys( array_filter( TMDO_Feature_Flags::all(), static fn( $state ) => 'idle' === $state ) );
$orphans = array();
foreach ( $idle_modules as $module ) {
// Best-effort: zone tables for hot_*/cold_* are dynamically named.
$candidates = array(
$wpdb->prefix . 'wpdo_warm',
$wpdb->prefix . 'wpdo_archive',
);
foreach ( $candidates as $table ) {
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$table
)
);
if ( 0 === $exists ) {
continue;
}
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
if ( $count > 0 ) {
$orphans[ $table ] = $count;
}
}
}
if ( empty( $orphans ) ) {
return self::pass( __( 'No orphan zone rows from idle modules.', '2meet-data-optimizer' ) );
}
$lines = array();
foreach ( $orphans as $t => $n ) {
$lines[] = sprintf( '%s (%s rows)', $t, number_format_i18n( $n ) );
}
return self::fail(
__( 'Orphan zone rows detected', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: list of tables and row counts */
__( 'Modules in idle state but zone tables still hold data: %s. These rows are typically cleanup leftovers — verify before truncating.', '2meet-data-optimizer' ),
implode( ', ', $lines )
),
'recommended'
);
}
);
return self::wrap( 'wpdo_orphan_zone_rows', $result );
}
// ─── Test 7: Missing snapshot ────────────────────────────────────────
/**
* Verify that a recent snapshot exists when modules are in risk states.
*
* @return array Site Health result.
*/
public static function check_missing_snapshot(): array {
$result = self::cached(
'wpdo_sh_missing_snapshot',
static function () {
global $wpdb;
if ( ! class_exists( 'TMDO_Snapshot_Manager' ) || ! class_exists( 'TMDO_Feature_Flags' ) ) {
return self::pass( __( 'Snapshot system not yet available.', '2meet-data-optimizer' ) );
}
// Risk only applies to modules in cutover/cleanup/complete (data is in custom tables).
$risk_modules = array_keys(
array_filter(
TMDO_Feature_Flags::all(),
static fn( $state ) => in_array( $state, array( 'cutover', 'cleanup', 'complete' ), true )
)
);
if ( empty( $risk_modules ) ) {
return self::pass( __( 'No modules in risk state — snapshot not required.', '2meet-data-optimizer' ) );
}
$snap_table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$snap_table
)
);
if ( 0 === $exists ) {
return self::fail(
__( 'Snapshot table missing', '2meet-data-optimizer' ),
__( 'Snapshot system table not present. Run wp wpdo install.', '2meet-data-optimizer' ),
'critical'
);
}
$days = (int) apply_filters( 'wpdo/site_health/snapshot_max_age_days', 7 );
$recent = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$snap_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$snap_table} is $wpdb->prefix + TABLE_SLUG constant (no user input)
$days
)
);
if ( $recent > 0 ) {
return self::pass(
sprintf(
/* translators: 1: count, 2: days */
__( 'Found %1$d snapshot(s) within the last %2$d days.', '2meet-data-optimizer' ),
$recent,
$days
)
);
}
return self::fail(
__( 'No recent WPDO snapshot', '2meet-data-optimizer' ),
sprintf(
/* translators: 1: comma-separated module names, 2: days */
__( 'Modules in risk state (%1$s) but no snapshot in last %2$d days. Run: wp wpdo snapshot create --trigger=manual --notes="catch-up safety net"', '2meet-data-optimizer' ),
implode( ', ', $risk_modules ),
$days
),
'critical'
);
}
);
return self::wrap( 'wpdo_missing_snapshot', $result );
}
// ─── helpers ──────────────────────────────────────────────────────────
/**
* Wrap a raw check result with required Site Health envelope fields.
*
* @param string $key Test slug.
* @param array $result Raw result with keys label, status, description, severity.
* @return array
*/
private static function wrap( string $key, array $result ): array {
$result['test'] = $key;
$result['badge'] = array(
'label' => 'WPDO',
'color' => 'blue',
);
return $result;
}
/**
* Cache a callable's return value via transient.
*
* @param string $key Transient key.
* @param callable $producer Callable returning result array.
* @return array
*/
private static function cached( string $key, callable $producer ): array {
$cached = get_transient( $key );
if ( is_array( $cached ) ) {
return $cached;
}
$result = $producer();
set_transient( $key, $result, self::CACHE_TTL );
return $result;
}
/**
* Build a "pass" result envelope.
*
* @param string $description Body text.
* @return array
*/
private static function pass( string $description ): array {
return array(
'label' => __( 'WPDO check passed', '2meet-data-optimizer' ),
'status' => 'good',
'description' => '<p>' . esc_html( $description ) . '</p>',
'severity' => 'good',
'actions' => '',
);
}
/**
* Build a fail / warning result envelope.
*
* @param string $label Test heading.
* @param string $description Body.
* @param string $severity 'critical' | 'recommended'.
* @return array
*/
private static function fail( string $label, string $description, string $severity ): array {
return array(
'label' => $label,
'status' => 'critical' === $severity ? 'critical' : 'recommended',
'description' => '<p>' . esc_html( $description ) . '</p>',
'severity' => $severity,
'actions' => '<p><a href="' . esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ) . '">' . esc_html__( 'Open WPDO admin', '2meet-data-optimizer' ) . '</a></p>',
);
}
}
@@ -0,0 +1,236 @@
<?php
/**
* TMDO_Site_Metrics_Collector — daily EAV health snapshot writer.
*
* Writes structured rows to `wpdo_site_metrics` once per day (via
* `wpdo_collect_site_metrics` cron action, scheduled at 05:00 UTC).
*
* Metric keys written per run:
* eav.postmeta_rows / eav.usermeta_rows / eav.termmeta_rows / eav.commentmeta_rows
* flat.hot_rows / flat.cold_rows / flat.warm_rows
* custom_tables.total_rows / custom_tables.table_count
* errors.last_24h / shadow_diffs.last_24h
*
* Monthly Summary (`TMDO_Monthly_Summary`) reads these rows for the
* `zone_growth` section instead of querying live tables, so the monthly
* rollup is fast even on large databases.
*
* @package WP_Data_Optimizer
* @since 2.6.2
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Collects site-wide EAV health metrics and writes to wpdo_site_metrics.
*/
class TMDO_Site_Metrics_Collector {
/**
* Cron hook name.
*
* @var string
*/
public const CRON_HOOK = 'wpdo_collect_site_metrics';
/**
* How many days of daily rows to retain before pruning.
*
* @var int
*/
private const RETENTION_DAYS = 90;
/**
* Register the cron handler and return the instance for chaining.
*
* @return void
*/
public static function register(): void {
add_action( self::CRON_HOOK, array( __CLASS__, 'collect' ) );
}
/**
* Collect all site metrics and persist them to wpdo_site_metrics.
*
* Called by the daily cron. Safe to call manually (e.g., via WP-CLI).
*
* @param bool $dry_run When true, collect but do not write to the DB.
* @return array<string,int> Map of metric_key => metric_value collected.
*/
public static function collect( bool $dry_run = false ): array {
global $wpdb;
$now = TMDO_DB::now();
$metrics = array();
// ── EAV row counts ────────────────────────────────────────────────
$eav_tables = array(
'eav.postmeta_rows' => $wpdb->postmeta,
'eav.usermeta_rows' => $wpdb->usermeta,
'eav.termmeta_rows' => $wpdb->termmeta,
'eav.commentmeta_rows' => $wpdb->commentmeta,
);
foreach ( $eav_tables as $key => $table ) {
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
$metrics[ $key ] = $count;
}
// ── Flat-table row counts (hot + cold dynamic tables, warm) ───────
$hot_rows = 0;
$cold_rows = 0;
if ( class_exists( 'TMDO_Schema_Registry' ) ) {
$registry = TMDO_Schema_Registry::instance();
foreach ( $registry->get_hot_post_types() as $pt ) {
$ht = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $pt );
$exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $ht ) ); // phpcs:ignore WordPress.DB
if ( $exists ) {
$hot_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$ht}`" ); // phpcs:ignore WordPress.DB
}
}
foreach ( $registry->get_cold_post_types() as $pt ) {
$ct = $wpdb->prefix . 'wpdo_cold_' . sanitize_key( $pt );
$exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $ct ) ); // phpcs:ignore WordPress.DB
if ( $exists ) {
$cold_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$ct}`" ); // phpcs:ignore WordPress.DB
}
}
}
$warm_table = $wpdb->prefix . 'wpdo_warm';
$metrics['flat.hot_rows'] = $hot_rows;
$metrics['flat.cold_rows'] = $cold_rows;
$metrics['flat.warm_rows'] = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$warm_table}`" ); // phpcs:ignore WordPress.DB
// ── Custom table row counts ───────────────────────────────────────
$custom_rows = 0;
$custom_count = 0;
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
foreach ( TMDO_Custom_Table_Registry::instance()->all() as $cfg ) {
$tbl = $wpdb->prefix . $cfg['table_name'];
$exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $tbl ) ); // phpcs:ignore WordPress.DB
if ( $exists ) {
$custom_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$tbl}`" ); // phpcs:ignore WordPress.DB
++$custom_count;
}
}
}
$metrics['custom_tables.total_rows'] = $custom_rows;
$metrics['custom_tables.table_count'] = $custom_count;
// ── Error / shadow-diff activity (last 24 h) ─────────────────────
$errors_table = $wpdb->prefix . 'wpdo_errors';
$metrics['errors.last_24h'] = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$errors_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)" // phpcs:ignore WordPress.DB
);
$shadow_table = $wpdb->prefix . 'wpdo_shadow_diffs';
$shadow_exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $shadow_table ) ); // phpcs:ignore WordPress.DB
$metrics['shadow_diffs.last_24h'] = $shadow_exists
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$shadow_table}` WHERE ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)" ) // phpcs:ignore WordPress.DB
: 0;
if ( $dry_run ) {
return $metrics;
}
// ── Persist each metric to wpdo_site_metrics ─────────────────────
$dest = TMDO_DB::table( 'wpdo_site_metrics' );
foreach ( $metrics as $key => $value ) {
$wpdb->insert(
$dest,
array(
'collected_at' => $now,
'metric_key' => $key,
'metric_value' => $value,
'context' => null,
),
array( '%s', '%s', '%d', '%s' )
);
}
// ── Prune old rows beyond retention window ────────────────────────
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . self::RETENTION_DAYS . ' days' ) );
$wpdb->query( $wpdb->prepare( "DELETE FROM {$dest} WHERE collected_at < %s", $cutoff ) ); // phpcs:ignore WordPress.DB
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'site_metrics_collected',
array(
'metric_count' => count( $metrics ),
'postmeta_rows' => $metrics['eav.postmeta_rows'] ?? 0,
'hot_rows' => $metrics['flat.hot_rows'] ?? 0,
'custom_rows' => $metrics['custom_tables.total_rows'] ?? 0,
)
);
}
return $metrics;
}
/**
* Get the most recent snapshot (latest collected_at timestamp).
*
* @return array<string,int> metric_key => metric_value, or empty on miss.
*/
public static function get_latest_snapshot(): array {
global $wpdb;
$dest = TMDO_DB::table( 'wpdo_site_metrics' );
$latest_ts = $wpdb->get_var( "SELECT MAX(collected_at) FROM `{$dest}`" ); // phpcs:ignore WordPress.DB
if ( ! $latest_ts ) {
return array();
}
$rows = (array) $wpdb->get_results(
$wpdb->prepare(
"SELECT metric_key, metric_value FROM `{$dest}` WHERE collected_at = %s", // phpcs:ignore WordPress.DB
$latest_ts
),
ARRAY_A
);
$out = array();
foreach ( $rows as $r ) {
$out[ (string) $r['metric_key'] ] = (int) $r['metric_value'];
}
return $out;
}
/**
* Get daily metric history for a single key over the past N days.
*
* @param string $metric_key Metric key (e.g. 'eav.postmeta_rows').
* @param int $days Number of days of history to return (default 30).
* @return array<array{collected_at:string,value:int}> Oldest-first.
*/
public static function get_history( string $metric_key, int $days = 30 ): array {
global $wpdb;
$dest = TMDO_DB::table( 'wpdo_site_metrics' );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $dest is a validated table name from TMDO_DB::table().
$rows = (array) $wpdb->get_results(
$wpdb->prepare(
"SELECT collected_at, metric_value AS value
FROM `{$dest}`
WHERE metric_key = %s
AND collected_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)
ORDER BY collected_at ASC",
$metric_key,
$days
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return array_map(
static fn( $r ) => array(
'collected_at' => (string) $r['collected_at'],
'value' => (int) $r['value'],
),
$rows
);
}
}
+322
View File
@@ -0,0 +1,322 @@
<?php
/**
* TMDO_Audit_Logger — 合規稽核用獨立 audit log 表。
*
* 訂閱 v1.2.0 起新增的 `wpdo_after_write` / `wpdo_after_delete` action,
* 把每次寫入/刪除的 who/when/what/before/after 寫進 `wp_wpdo_uni_audit`,
* 與 shadow_diffs 分離以利長期保留與合規查詢(GDPR / SOX 等)。
*
* 設計決定:
* - 同步寫入(不走 Action Scheduler)以確保 audit 先於下游動作。
* 若效能成為瓶頸,未來可改 fire-and-forget(wp_schedule_single_event)。
* - `value_before` 透過讀取 flat table 取得,若表不存在則留 null。
* - `source` 欄位以 const 列出可能的呼叫來源(rest / cli / fe-editor / internal);
* 由呼叫端透過 filter `wpdo_audit_source` 覆寫。
*
* @package WP_Data_Optimizer
* @since 1.3.0
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Audit_Logger {
public const TABLE_SUFFIX = 'audit';
/** 硬性 row 上限,超過時 maybe_prune 會刪除最舊的資料。 */
public const MAX_ROWS = 500_000;
/** 預設保留天數(可由 `wpdo_audit_retention_days` option 覆寫)。 */
public const DEFAULT_RETENTION_DAYS = 365;
public const OPT_RETENTION_DAYS = 'wpdo_audit_retention_days';
public const SOURCE_REST = 'rest';
public const SOURCE_CLI = 'cli';
public const SOURCE_FE_EDITOR = 'fe-editor';
public const SOURCE_INTERNAL = 'internal';
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . TMDO_TABLE_PREFIX . self::TABLE_SUFFIX;
}
public static function init(): void {
add_action( 'wpdo_after_write', array( self::class, 'on_write' ), 10, 7 );
add_action( 'wpdo_after_delete', array( self::class, 'on_delete' ), 10, 7 );
}
/**
* 寫入後訂閱 — 記錄 value_before / value_after 至 audit log。
*
* @param string $entity_type post|user|term|comment
* @param int $entity_id
* @param string $meta_key
* @param mixed $meta_value 寫入後的新值
* @param mixed $result perform_upsert 回傳值
* @param string $op 'add' | 'update'
* @param mixed $before_value 寫入前的舊值(v1.3.1+ 由 hook_bus 傳入)
*/
public static function on_write(
string $entity_type,
int $entity_id,
string $meta_key,
$meta_value,
$result,
string $op,
$before_value = null
): void {
if ( $result === false ) {
return; // 寫入失敗不記
}
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key );
if ( ! $field_def ) {
return; // 非 UAE 欄位
}
self::write_row(
array(
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'group_name' => (string) ( $field_def['group'] ?? '' ),
'meta_key' => $meta_key,
'action' => 'write',
'op' => $op, // 'add' | 'update'
'value_before' => self::stringify( $before_value ),
'value_after' => self::stringify( $meta_value ),
)
);
}
/**
* 刪除後訂閱。
*
* @param string $entity_type
* @param int $entity_id
* @param string $meta_key
* @param mixed $meta_value WP 傳進 delete_metadata 的值(語義:匹配這個值才刪;不等於實際刪除前的 flat 值)
* @param mixed $result
* @param bool $delete_all
* @param mixed $before_value 刪除前 flat table 實際的值(v1.3.1+)
*/
public static function on_delete(
string $entity_type,
int $entity_id,
string $meta_key,
$meta_value,
$result,
bool $delete_all,
$before_value = null
): void {
if ( $result === false ) {
return;
}
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key );
if ( ! $field_def ) {
return;
}
self::write_row(
array(
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'group_name' => (string) ( $field_def['group'] ?? '' ),
'meta_key' => $meta_key,
'action' => 'delete',
'op' => $delete_all ? 'delete_all' : 'delete',
'value_before' => self::stringify( $before_value ),
'value_after' => null,
)
);
}
private static function write_row( array $row ): void {
global $wpdb;
$source = apply_filters(
'wpdo_audit_source',
defined( 'WP_CLI' ) && WP_CLI
? self::SOURCE_CLI
: ( defined( 'REST_REQUEST' ) && REST_REQUEST ? self::SOURCE_REST : self::SOURCE_INTERNAL )
);
// wpdb::insert 以欄位順序對應 format — 我們用 null 讓 wpdb 自己根據值型別推斷,
// 避免 format array 長度與 row key 數不對稱(v1.5.1 加 op 後更容易出錯)。
$wpdb->insert(
self::table_name(),
array_merge(
$row,
array(
'ts' => current_time( 'mysql', true ),
'user_id' => get_current_user_id(),
'source' => (string) $source,
'trace_id' => TMDO_Logger::trace_id(),
)
)
);
// 1% 機率觸發 prune — 類似 shadow_diff_logger::maybe_trim,
// 避免每次寫都查 count。
if ( wp_rand( 1, 100 ) === 1 ) {
self::maybe_prune();
}
}
/**
* 依 MAX_ROWS 與 retention_days 裁剪 audit 表。
*
* 先按時間刪除過期資料,再看是否超過 MAX_ROWS,超過則刪除最舊。
* 回傳總共刪除的 row 數(供 CLI 呈現)。
*
* @since 1.3.2
*/
public static function maybe_prune(): int {
global $wpdb;
$table = self::table_name();
$total = 0;
$retention_days = (int) get_option( self::OPT_RETENTION_DAYS, self::DEFAULT_RETENTION_DAYS );
if ( $retention_days > 0 ) {
$cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS );
$deleted = (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` WHERE ts < %s",
$cutoff
)
);
$total += $deleted;
if ( $deleted > 0 ) {
TMDO_Logger::info(
'audit_prune_expired',
array(
'deleted' => $deleted,
'cutoff' => $cutoff,
'days' => $retention_days,
)
);
}
}
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
if ( $count > self::MAX_ROWS ) {
$to_delete = $count - self::MAX_ROWS;
$deleted = (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d",
$to_delete
)
);
$total += $deleted;
TMDO_Logger::warning(
'audit_prune_overflow',
array(
'deleted' => $deleted,
'count_before' => $count,
'max_rows' => self::MAX_ROWS,
)
);
}
return $total;
}
private static function stringify( $value ): ?string {
if ( $value === null ) {
return null;
}
if ( is_scalar( $value ) ) {
return (string) $value;
}
return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
}
// ─────────────────────────────────────────────────────────
// Schema
// ─────────────────────────────────────────────────────────
public static function install_table(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
// `action` 在 MySQL 部分版本為 reserved keyword — 用 backtick 保險。
// v1.5.1 新增 `op` 欄位:細分 WordPress 內部觸發路徑(add / update / delete),
// 用來區分「add_metadata 與 update_metadata 被同時觸發」的雙 row 情境。
$sql = "CREATE TABLE {$table} (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`ts` DATETIME NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`entity_type` VARCHAR(20) NOT NULL,
`entity_id` BIGINT UNSIGNED NOT NULL,
`group_name` VARCHAR(64) NOT NULL,
`meta_key` VARCHAR(255) NOT NULL,
`action` VARCHAR(10) NOT NULL,
`op` VARCHAR(10) NOT NULL DEFAULT 'update',
`value_before` LONGTEXT NULL,
`value_after` LONGTEXT NULL,
`source` VARCHAR(20) NULL,
`trace_id` CHAR(36) NULL,
PRIMARY KEY (`id`),
KEY `idx_entity` (`entity_type`, `entity_id`),
KEY `idx_ts` (`ts`),
KEY `idx_user` (`user_id`, `ts`),
KEY `idx_op` (`op`, `ts`)
) {$charset};";
dbDelta( $sql );
}
public static function drop_table(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() );
}
// ─────────────────────────────────────────────────────────
// Read API(供 admin / CLI 查詢)
// ─────────────────────────────────────────────────────────
/**
* 取得最近 N 筆 audit 紀錄。
*
* @return array<int, array<string, mixed>>
*/
public static function recent( int $limit = 100, ?string $entity_type = null ): array {
global $wpdb;
$table = self::table_name();
if ( $entity_type ) {
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d",
$entity_type,
$limit
),
ARRAY_A
);
} else {
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d",
$limit
),
ARRAY_A
);
}
return is_array( $rows ) ? $rows : array();
}
public static function count(): int {
global $wpdb;
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' );
}
}
@@ -0,0 +1,134 @@
<?php
/**
* TMDO_Auto_Promoter — shadow_read 穩定期後自動升級到 aeav_only。
*
* 僅針對 `shadow_read` 做自動升級(升級到 aeav_only),其他 mode 不自動動。
* 判斷條件(皆滿足才觸發):
* 1. 當前 mode = shadow_read
* 2. 進入 shadow_read 距今 ≥ `min_days`
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0
*
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
* 若 diff 出現,規則永不觸發;人工介入即可。
*
* 可由 option 或 filter 調整:
* option `wpdo_auto_promote_enabled` — 全域開關(bool,預設 false)
* option `wpdo_auto_promote_min_days` — 停留天數門檻(int,預設 7)
* filter `wpdo_auto_promote_should_run` — per-entity 覆寫(bool default)
*
* @package WP_Data_Optimizer
* @since 1.5.0
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Auto_Promoter {
public const OPT_ENABLED = 'wpdo_auto_promote_enabled';
public const OPT_MIN_DAYS = 'wpdo_auto_promote_min_days';
public const DEFAULT_MIN_DAYS = 7;
public const CRON_HOOK = 'wpdo_auto_promote_check';
public static function init(): void {
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
}
/**
* 首次啟用時排入 daily cron。Option 關閉時會停掉,開啟時重排。
*/
public static function maybe_schedule(): void {
$enabled = (bool) get_option( self::OPT_ENABLED, false );
$scheduled = wp_next_scheduled( self::CRON_HOOK );
if ( $enabled && ! $scheduled ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
} elseif ( ! $enabled && $scheduled ) {
wp_unschedule_event( $scheduled, self::CRON_HOOK );
}
}
/**
* 遍歷所有 entity,符合條件者升級為 aeav_only。
*
* @return array<string, string> entity_type → 結果('promoted'|'not_ready'|'no_diff_yet'|'disabled_globally')
*/
public static function check_all(): array {
$results = array();
if ( ! (bool) get_option( self::OPT_ENABLED, false ) ) {
foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
$results[ $type ] = 'disabled_globally';
}
return $results;
}
$min_days = (int) get_option( self::OPT_MIN_DAYS, self::DEFAULT_MIN_DAYS );
$entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
$results[ $type ] = self::check_entity( $type, $min_days, $entered_all );
}
return $results;
}
/**
* 評估單一 entity 是否可升級。
*
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed'
*/
public static function check_entity( string $type, int $min_days, array $entered_all ): string {
$current = TMDO_Mode_Manager::get( $type );
if ( $current !== TMDO_Mode_Manager::MODE_SHADOW_READ ) {
return 'not_shadow_read';
}
$entered_at = isset( $entered_all[ $type ] ) ? (int) $entered_all[ $type ] : 0;
if ( $entered_at === 0 || ( time() - $entered_at ) < $min_days * DAY_IN_SECONDS ) {
return 'not_ready';
}
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
$count = (int) ( $diff_counts[ $type ] ?? 0 );
if ( $count > 0 ) {
return 'has_diff';
}
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count );
if ( ! $should ) {
return 'vetoed';
}
$result = TMDO_Mode_Manager::set( $type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
if ( is_wp_error( $result ) ) {
TMDO_Logger::error(
'auto_promote_failed',
array(
'entity_type' => $type,
'error' => $result->get_error_message(),
)
);
return 'error';
}
TMDO_Logger::info(
'auto_promote_success',
array(
'entity_type' => $type,
'entered_at' => gmdate( 'c', $entered_at ),
'days_elapsed' => (int) ( ( time() - $entered_at ) / DAY_IN_SECONDS ),
'min_days' => $min_days,
)
);
do_action( 'wpdo_auto_promoted', $type, $entered_at, $min_days );
return 'promoted';
}
}
@@ -0,0 +1,205 @@
<?php
/**
* TMDO_Cache_Orchestrator - 多層快取協調器
*
* 層級:
* L1: PHP Process Memory(同一請求內,靜態陣列)
* L2: WP Object Cache(搭配 Redis/Memcached 外掛時為跨請求)
* L3: Transientfallback,資料庫持久化)
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Cache_Orchestrator {
/** @var array<string, mixed> L1 記憶體快取 */
private static array $l1_cache = array();
/** @var int L1 快取計數上限(防記憶體爆炸) */
private const L1_MAX_ITEMS = 1000;
/** @var int L2 TTL 秒數 */
private const L2_TTL = HOUR_IN_SECONDS;
public static function init(): void {
// 註冊 L1 清理(避免長命令列任務記憶體膨脹)
add_action( 'shutdown', array( self::class, 'clear_l1' ) );
}
/**
* 產生快取 key
*/
private static function make_key( string $type, int $id, string $group ): string {
return "{$type}:{$id}:{$group}";
}
// ─────────────────────────────────────────────────────────
// 讀取
// ─────────────────────────────────────────────────────────
public static function get_row( string $type, int $id, string $group ) {
$key = self::make_key( $type, $id, $group );
// L1
if ( array_key_exists( $key, self::$l1_cache ) ) {
return self::$l1_cache[ $key ];
}
// L2
$cached = wp_cache_get( $key, TMDO_CACHE_GROUP );
if ( $cached !== false ) {
self::set_l1( $key, $cached );
return $cached;
}
return false;
}
// ─────────────────────────────────────────────────────────
// 寫入
// ─────────────────────────────────────────────────────────
public static function set_row( string $type, int $id, string $group, array $data ): void {
$key = self::make_key( $type, $id, $group );
self::set_l1( $key, $data );
wp_cache_set( $key, $data, TMDO_CACHE_GROUP, self::L2_TTL );
}
private static function set_l1( string $key, $data ): void {
// 防 L1 無限膨脹
if ( count( self::$l1_cache ) >= self::L1_MAX_ITEMS ) {
// 簡易 FIFO:砍掉最舊的 20%
$keep = (int) ( self::L1_MAX_ITEMS * 0.8 );
self::$l1_cache = array_slice( self::$l1_cache, - $keep, null, true );
}
self::$l1_cache[ $key ] = $data;
}
// ─────────────────────────────────────────────────────────
// 失效
// ─────────────────────────────────────────────────────────
public static function invalidate( string $type, int $id, string $group ): void {
$key = self::make_key( $type, $id, $group );
unset( self::$l1_cache[ $key ] );
wp_cache_delete( $key, TMDO_CACHE_GROUP );
}
public static function flush_entity( string $type, ?int $id = null ): void {
// 若沒給 id → flush 整個 entity type(L1 中所有該 type 的 key)
if ( $id === null ) {
$prefix = 'uae:' . $type . ':';
foreach ( array_keys( self::$l1_cache ) as $key ) {
if ( strpos( $key, $prefix ) === 0 ) {
unset( self::$l1_cache[ $key ] );
}
}
// Object cache 沒法做 prefix flush,用版本號 bump
wp_cache_set( 'wpdo_cache_version', time(), TMDO_CACHE_GROUP );
return;
}
$groups = TMDO_Entity_Registry::get_groups_for_type( $type );
foreach ( $groups as $group ) {
self::invalidate( $type, $id, $group );
}
}
public static function flush_all(): void {
self::$l1_cache = array();
// WP Object Cache 無跨群組 flush,改用版本號方式
wp_cache_set( 'wpdo_cache_version', time(), TMDO_CACHE_GROUP );
}
public static function clear_l1(): void {
self::$l1_cache = array();
}
// ─────────────────────────────────────────────────────────
// 批次預熱(核心效能優化:解決清單頁 N+1)
// ─────────────────────────────────────────────────────────
/**
* 批次預載一組 entity IDs 的資料至快取
* 用於清單頁渲染前呼叫,避免每個 item 都去查 DB
*
* @param string $type 實體類型
* @param array $ids entity ID 陣列
* @param string $group 欄位群組
*/
public static function warm_batch( string $type, array $ids, string $group ): void {
if ( empty( $ids ) ) {
return;
}
// 找出未快取的 IDs
$uncached_ids = array_filter(
$ids,
function ( $id ) use ( $type, $group ) {
return self::get_row( $type, (int) $id, $group ) === false;
}
);
if ( empty( $uncached_ids ) ) {
return;
}
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return;
}
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
$id_col = $adapter->get_entity_id_column();
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return;
}
$ids_in = implode( ',', array_map( 'intval', $uncached_ids ) );
$rows = $wpdb->get_results(
"SELECT * FROM `{$table}` WHERE `{$id_col}` IN ({$ids_in})",
ARRAY_A
);
if ( ! is_array( $rows ) ) {
return;
}
// 建立 id → row 索引
$indexed = array();
foreach ( $rows as $row ) {
$indexed[ (int) $row[ $id_col ] ] = $row;
}
// 填入快取(未找到者存空陣列避免重查)
foreach ( $uncached_ids as $id ) {
$id_int = (int) $id;
self::set_row( $type, $id_int, $group, $indexed[ $id_int ] ?? array() );
}
}
// ─────────────────────────────────────────────────────────
// 統計(供後台顯示)
// ─────────────────────────────────────────────────────────
public static function get_l1_stats(): array {
return array(
'count' => count( self::$l1_cache ),
'max' => self::L1_MAX_ITEMS,
'usage_pct' => self::L1_MAX_ITEMS > 0
? round( count( self::$l1_cache ) / self::L1_MAX_ITEMS * 100, 1 )
: 0,
);
}
}
@@ -0,0 +1,148 @@
<?php
/**
* TMDO_Conflict_Detector — 偵測 UAE 與 UAEPG 同欄位衝突
*
* 背景:
* UAEPG(PostgreSQL sidecar)以 metadata filter priority 5 攔截,
* UAE 在 priority 10 攔截。若同一 meta_key 被雙方都登錄,
* UAEPG 會先短路 return,UAE 寫入永遠不執行,資料靜默遺失。
*
* 本類別在欄位登錄階段完成後(init:25)掃描雙方 Registry,
* 找出 overlap 並:
* 1. 寫入 error log(供 CI / 監控系統消化)
* 2. 在管理後台顯示紅色 admin_notices
* 3. 提供 TMDO_Conflict_Detector::get_conflicts() 與 CLI 指令 wp uae conflicts 查詢
*
* 結果 cache 在 static property 內,單 request 內不重複掃描。
*
* @package WP_Data_Optimizer
* @since 1.1.2
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Conflict_Detector {
/**
* @var array<int, array{entity_type:string, meta_key:string, wpdo_group:string, uaepg_group:string}>|null
*/
private static ?array $cache = null;
public static function init(): void {
// init:25 — register_default_fields 於 init:20 觸發 wpdo_register_fields,
// UAEPG 亦在 init:20 觸發 uaepg_register_fields;此時 priority 25 可確保雙邊皆完成登錄。
add_action( 'init', array( self::class, 'scan' ), 25 );
if ( is_admin() ) {
add_action( 'admin_notices', array( self::class, 'maybe_render_admin_notice' ) );
}
}
/**
* 掃描並 cache 衝突清單。
*
* @return array<int, array{entity_type:string, meta_key:string, wpdo_group:string, uaepg_group:string}>
*/
public static function scan(): array {
if ( self::$cache !== null ) {
return self::$cache;
}
if ( ! class_exists( 'UAEPG_Registry' ) ) {
return self::$cache = array();
}
$conflicts = array();
foreach ( TMDO_Entity_Registry::get_all_fields() as $entity_type => $fields_by_key ) {
foreach ( $fields_by_key as $meta_key => $wpdo_field ) {
if ( ! UAEPG_Registry::is_managed( $entity_type, $meta_key ) ) {
continue;
}
$uaepg_field = UAEPG_Registry::get_field( $entity_type, $meta_key );
$conflicts[] = array(
'entity_type' => $entity_type,
'meta_key' => $meta_key,
'wpdo_group' => $wpdo_field['group'] ?? '(unknown)',
'uaepg_group' => $uaepg_field['group'] ?? '(unknown)',
);
}
}
if ( $conflicts ) {
TMDO_Logger::error(
'field_registration_conflict',
array(
'count' => count( $conflicts ),
'fields' => $conflicts,
)
);
}
return self::$cache = $conflicts;
}
/**
* 取得衝突清單(若尚未掃描,觸發掃描)。
*/
public static function get_conflicts(): array {
return self::$cache ?? self::scan();
}
/**
* 測試用:強制清除 cache,下次 scan() 會重跑。
*
* @internal
*/
public static function reset_cache(): void {
self::$cache = null;
}
/**
* 若有衝突則輸出管理後台紅色通知。
*/
public static function maybe_render_admin_notice(): void {
$conflicts = self::get_conflicts();
if ( ! $conflicts ) {
return;
}
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
$count = count( $conflicts );
$lines = array();
foreach ( array_slice( $conflicts, 0, 5 ) as $c ) {
$lines[] = sprintf(
'%s / %s(UAE 群組「%s」 ↔ UAEPG 群組「%s」)',
esc_html( $c['entity_type'] ),
esc_html( $c['meta_key'] ),
esc_html( $c['wpdo_group'] ),
esc_html( $c['uaepg_group'] )
);
}
$extra = $count > 5 ? sprintf( __( '… 另有 %d 個欄位未顯示', 'uae' ), $count - 5 ) : '';
printf(
'<div class="notice notice-error"><p><strong>%s</strong></p><ul style="list-style:disc;margin-left:20px;"><li>%s</li></ul>%s<p>%s <code>wp uae conflicts</code></p></div>',
esc_html(
sprintf(
/* translators: %d: conflict count */
__( 'UAE × UAEPG 欄位衝突偵測:發現 %d 個同 meta_key 被雙方 Registry 重複登錄 — UAEPG 會搶先短路,UAE 寫入將被靜默跳過', 'uae' ),
$count
)
),
// $lines elements were each individually esc_html()'d above (lines 121-124).
// implode joins escaped strings with literal HTML markers — safe.
implode( '</li><li>', $lines ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above.
$extra ? '<p><em>' . esc_html( $extra ) . '</em></p>' : '',
esc_html__( '查看完整清單:', 'uae' )
);
}
}
@@ -0,0 +1,329 @@
<?php
/**
* TMDO_Entity_Health — Entity Bridge 健康狀態聚合器
*
* 收集每個 entity type (user/term/comment) 的即時健康快照:
* - 模式與已停留天數
* - 每個群組的覆蓋率(flat table rows vs EAV rows
* - Shadow diff 計數
* - 遷移進度(斷點位置、已搬移筆數)
* - Auto-promote 資格評估
* - 操作建議
*
* @package WP_Data_Optimizer
* @since 2.6.6
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions -- inherits engine coding standard.
defined( 'ABSPATH' ) || exit;
final class TMDO_Entity_Health {
/** Entity types managed by this class (post is handled by Feature_Flags FSM). */
public const MANAGED_TYPES = array( 'user', 'term', 'comment' );
/**
* 取得全部 entity 的健康快照(批次,供 REST polling 用)。
*
* @return array<string, array>
*/
public static function get_all(): array {
$result = array();
foreach ( self::MANAGED_TYPES as $type ) {
$result[ $type ] = self::get_one( $type );
}
return $result;
}
/**
* 取得單一 entity 的健康快照。
*/
public static function get_one( string $entity_type ): array {
$mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( $entity_type ) : 'disabled';
$entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
$entered_at = isset( $entered_all[ $entity_type ] ) ? (int) $entered_all[ $entity_type ] : 0;
$mode_days = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0;
$groups = self::get_groups_health( $entity_type );
$shadow_diffs = 0;
if ( class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
$shadow_diffs = (int) ( $diff_counts[ $entity_type ] ?? 0 );
}
$auto_promote = self::get_auto_promote_status( $entity_type, $mode, $entered_at, $shadow_diffs );
$backfill_active = self::is_backfill_active( $entity_type );
$native_counts = self::get_native_counts( $entity_type );
return array(
'entity_type' => $entity_type,
'mode' => $mode,
'entered_at' => $entered_at,
'mode_days' => $mode_days,
'groups' => $groups,
'shadow_diffs' => $shadow_diffs,
'auto_promote' => $auto_promote,
'backfill_active' => $backfill_active,
'native_entity_count' => $native_counts['entity_count'],
'native_meta_count' => $native_counts['meta_count'],
'native_entity_table' => $native_counts['entity_table'],
'native_meta_table' => $native_counts['meta_table'],
'recommendation' => self::get_recommendation( $entity_type, $mode, $groups, $shadow_diffs ),
'next_mode' => self::get_next_mode( $mode ),
'prev_mode' => self::get_prev_mode( $mode ),
);
}
/**
* 取得 native EAV 表的總筆數(entity 本體 + meta)。
*/
private static function get_native_counts( string $entity_type ): array {
global $wpdb;
$adapter = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_adapter( $entity_type ) : null;
if ( ! $adapter ) {
return array(
'entity_table' => '',
'meta_table' => '',
'entity_count' => 0,
'meta_count' => 0,
);
}
$meta_table = $adapter->get_native_meta_table();
$entity_table = self::get_native_entity_table( $entity_type );
$entity_count = $entity_table ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$entity_table}`" ) : 0;
$meta_count = $meta_table ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$meta_table}`" ) : 0;
return array(
'entity_table' => $entity_table,
'meta_table' => $meta_table,
'entity_count' => $entity_count,
'meta_count' => $meta_count,
);
}
/**
* 取得 entity 本體資料表名稱(非 meta 表)。
*/
private static function get_native_entity_table( string $entity_type ): string {
global $wpdb;
$map = array(
'user' => $wpdb->users,
'term' => $wpdb->terms,
'comment' => $wpdb->comments,
'post' => $wpdb->posts,
);
return $map[ $entity_type ] ?? '';
}
// ─────────────────────────────────────────────────────────
// 群組覆蓋率
// ─────────────────────────────────────────────────────────
private static function get_groups_health( string $entity_type ): array {
if ( ! class_exists( 'TMDO_Entity_Registry' ) || ! class_exists( 'TMDO_Schema_Manager' ) ) {
return array();
}
global $wpdb;
$groups = TMDO_Entity_Registry::get_groups_for_type( $entity_type );
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
$result = array();
foreach ( $groups as $group_name ) {
$fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
$table_exists = TMDO_Schema_Manager::table_exists( $table );
$flat_rows = 0;
$eav_rows = 0;
if ( $table_exists && $adapter ) {
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
$managed_keys = array_column( $fields, 'key' );
if ( ! empty( $managed_keys ) ) {
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$phs = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
$eav_rows = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$meta_table}` WHERE `meta_key` IN ({$phs})",
...$managed_keys
)
);
}
}
$coverage_pct = 0.0;
if ( $eav_rows > 0 ) {
$coverage_pct = round( min( $flat_rows / $eav_rows, 1.0 ) * 100, 1 );
} elseif ( $flat_rows > 0 ) {
$coverage_pct = 100.0;
}
$migration_info = self::get_migration_status( $entity_type, $group_name );
$result[] = array(
'name' => $group_name,
'fields_count' => count( $fields ),
'table' => $table,
'table_exists' => $table_exists,
'flat_rows' => $flat_rows,
'eav_rows' => $eav_rows,
'coverage_pct' => $coverage_pct,
'migration_status' => $migration_info['status'],
'last_id' => $migration_info['last_id'],
'total_migrated' => $migration_info['total_migrated'],
'started_at' => $migration_info['started_at'],
'completed_at' => $migration_info['completed_at'],
);
}
return $result;
}
private static function get_migration_status( string $entity_type, string $group_name ): array {
global $wpdb;
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT status, last_id, total_migrated, started_at, completed_at FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
),
ARRAY_A
);
return array(
'status' => $row['status'] ?? 'not_started',
'last_id' => (int) ( $row['last_id'] ?? 0 ),
'total_migrated' => (int) ( $row['total_migrated'] ?? 0 ),
'started_at' => $row['started_at'] ?? null,
'completed_at' => $row['completed_at'] ?? null,
);
}
// ─────────────────────────────────────────────────────────
// Auto-promote 資格評估
// ─────────────────────────────────────────────────────────
private static function get_auto_promote_status(
string $entity_type,
string $mode,
int $entered_at,
int $shadow_diffs
): array {
$enabled = (bool) get_option( TMDO_Auto_Promoter::OPT_ENABLED, false );
$min_days = (int) get_option( TMDO_Auto_Promoter::OPT_MIN_DAYS, TMDO_Auto_Promoter::DEFAULT_MIN_DAYS );
$days_elapsed = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0;
if ( $mode !== TMDO_Mode_Manager::MODE_SHADOW_READ ) {
return array(
'enabled' => $enabled,
'min_days' => $min_days,
'eligible' => false,
'reason' => 'not_shadow_read',
);
}
if ( $days_elapsed < $min_days ) {
return array(
'enabled' => $enabled,
'min_days' => $min_days,
'days_elapsed' => $days_elapsed,
'eligible' => false,
'reason' => "need_{$min_days}_days",
);
}
if ( $shadow_diffs > 0 ) {
return array(
'enabled' => $enabled,
'min_days' => $min_days,
'days_elapsed' => $days_elapsed,
'eligible' => false,
'reason' => "has_{$shadow_diffs}_diffs",
);
}
return array(
'enabled' => $enabled,
'min_days' => $min_days,
'days_elapsed' => $days_elapsed,
'eligible' => true,
'reason' => 'ready',
);
}
// ─────────────────────────────────────────────────────────
// 建議與模式轉換
// ─────────────────────────────────────────────────────────
private static function get_recommendation(
string $entity_type,
string $mode,
array $groups,
int $shadow_diffs
): string {
switch ( $mode ) {
case TMDO_Mode_Manager::MODE_DISABLED:
return '啟用 Hook Bus 並切換到 dual_write,讓系統開始對 flat table 雙寫。';
case TMDO_Mode_Manager::MODE_DUAL_WRITE:
foreach ( $groups as $g ) {
if ( $g['coverage_pct'] < 99.0 ) {
return '執行 Backfill 遷移歷史資料,待所有群組覆蓋率達到 100% 後再升級到 shadow_read。';
}
}
return '所有群組覆蓋率已達 100%,可以升級到 shadow_read 進行驗證期觀察。';
case TMDO_Mode_Manager::MODE_SHADOW_READ:
if ( $shadow_diffs > 0 ) {
return "發現 {$shadow_diffs} 筆 shadow diff,請先調查差異原因(Settings → Shadow Diffs)後再考慮升級。";
}
$min_days = (int) get_option( TMDO_Auto_Promoter::OPT_MIN_DAYS, TMDO_Auto_Promoter::DEFAULT_MIN_DAYS );
$entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
$entered_at = isset( $entered_all[ $entity_type ] ) ? (int) $entered_all[ $entity_type ] : 0;
$days = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0;
if ( $days < $min_days ) {
return "繼續觀察中(已 {$days}/{$min_days} 天)。無 diff 且穩定後可升級到 aeav_only。";
}
return '已達穩定期,可以升級到 aeav_only(最終生產模式)。';
case TMDO_Mode_Manager::MODE_AEAV_ONLY:
return '遷移完成。所有讀寫均走 flat table,效能最佳。';
default:
return '';
}
}
private static function get_next_mode( string $mode ): ?string {
$idx = array_search( $mode, TMDO_Mode_Manager::ALL_MODES, true );
if ( $idx === false || $idx >= count( TMDO_Mode_Manager::ALL_MODES ) - 1 ) {
return null;
}
return TMDO_Mode_Manager::ALL_MODES[ $idx + 1 ];
}
private static function get_prev_mode( string $mode ): ?string {
$idx = array_search( $mode, TMDO_Mode_Manager::ALL_MODES, true );
if ( $idx === false || $idx <= 0 ) {
return null;
}
return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ];
}
private static function is_backfill_active( string $entity_type ): bool {
return (bool) wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type ) );
}
}
@@ -0,0 +1,799 @@
<?php
/**
* TMDO_Entity_Migration_Engine - 遷移引擎
*
* 負責將原生 wp_*meta 表中的 EAV 資料搬移至 UAE 扁平化表
*
* 特性:
* - Cursor-based 分頁(避免 OFFSET 效能問題)
* - 斷點續傳(記錄 last_id
* - 批次處理(可配置 batch size
* - Transaction 安全
* - 統計報告
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Entity_Migration_Engine {
/** 預設批次大小 */
public const DEFAULT_BATCH_SIZE = 500;
/** 批次間延遲(毫秒)*/
public const DEFAULT_SLEEP_MS = 100;
/**
* 遷移一個實體類型下的一個群組
*
* @param string $entity_type
* @param string $group_name
* @param array $options {
* @type int $batch_size 每批筆數
* @type int $sleep_ms 批次間延遲毫秒
* @type bool $resume 是否從斷點續傳
* @type bool $dry_run 乾跑(不實際寫入)
* }
* @return array 統計報告
*/
public static function migrate_group(
string $entity_type,
string $group_name,
array $options = array()
): array {
$defaults = array(
'batch_size' => self::DEFAULT_BATCH_SIZE,
'sleep_ms' => self::DEFAULT_SLEEP_MS,
'resume' => true,
'dry_run' => false,
);
$options = wp_parse_args( $options, $defaults );
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return self::error_result( "Adapter not found: {$entity_type}" );
}
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( empty( $group_fields ) ) {
return self::error_result( "Group not registered: {$entity_type}/{$group_name}" );
}
$managed_keys = array_column( $group_fields, 'key' );
if ( empty( $managed_keys ) ) {
return self::error_result( 'No keys to migrate' );
}
$target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
if ( ! TMDO_Schema_Manager::table_exists( $target_table ) && ! $options['dry_run'] ) {
return self::error_result( "Target table does not exist: {$target_table}" );
}
// 斷點
$last_id = $options['resume']
? self::get_checkpoint( $entity_type, $group_name )
: 0;
// 標記開始
if ( ! $options['dry_run'] ) {
self::mark_migration_started( $entity_type, $group_name );
}
$stats = array(
'entity_type' => $entity_type,
'group' => $group_name,
'migrated' => 0,
'errors' => 0,
'skipped' => 0,
'elapsed_sec' => 0,
'dry_run' => $options['dry_run'],
);
$start_time = microtime( true );
global $wpdb;
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$field_map = array_column( $group_fields, null, 'key' );
// 建立 IN 子句佔位符
$keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
do {
// Cursor-based 分頁
$query = $wpdb->prepare(
"SELECT DISTINCT `{$id_col}` FROM `{$meta_table}`
WHERE `meta_key` IN ({$keys_placeholders})
AND `{$id_col}` > %d
ORDER BY `{$id_col}` ASC
LIMIT %d",
...array_merge( $managed_keys, array( $last_id, $options['batch_size'] ) )
);
$entity_ids = $wpdb->get_col( $query );
if ( empty( $entity_ids ) ) {
break;
}
foreach ( $entity_ids as $entity_id ) {
$entity_id = (int) $entity_id;
try {
$migrated = self::migrate_single_entity(
$entity_type,
$group_name,
$entity_id,
$managed_keys,
$field_map,
$adapter,
$options['dry_run']
);
if ( $migrated ) {
++$stats['migrated'];
} else {
++$stats['skipped'];
}
} catch ( \Throwable $e ) {
++$stats['errors'];
error_log(
sprintf(
'[UAE Migration] Error %s/%s ID=%d: %s',
$entity_type,
$group_name,
$entity_id,
$e->getMessage()
)
);
}
$last_id = $entity_id;
}
// 更新斷點
if ( ! $options['dry_run'] ) {
self::update_checkpoint( $entity_type, $group_name, $last_id, $stats['migrated'] );
}
// 釋放記憶體
$wpdb->flush();
// 延遲(降低 DB 負載)
if ( $options['sleep_ms'] > 0 ) {
usleep( $options['sleep_ms'] * 1000 );
}
} while ( count( $entity_ids ) === $options['batch_size'] );
// 標記完成
if ( ! $options['dry_run'] ) {
self::mark_migration_completed( $entity_type, $group_name );
}
$stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 );
return $stats;
}
/**
* 遷移單一 entity 的所有 meta
*/
private static function migrate_single_entity(
string $entity_type,
string $group_name,
int $entity_id,
array $managed_keys,
array $field_map,
TMDO_Entity_Adapter_Interface $adapter,
bool $dry_run
): bool {
global $wpdb;
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
// 取出此 entity 所有相關的 meta
$placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
$metas = $wpdb->get_results(
$wpdb->prepare(
"SELECT `meta_key`, `meta_value` FROM `{$meta_table}`
WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})",
...array_merge( array( $entity_id ), $managed_keys )
),
ARRAY_A
);
if ( empty( $metas ) ) {
return false;
}
// 組裝 row 資料
$data = array( $id_col => $entity_id );
$formats = array( '%d' );
foreach ( $metas as $meta ) {
$key = $meta['meta_key'];
$value = $meta['meta_value'];
if ( ! isset( $field_map[ $key ] ) ) {
continue;
}
$field_def = $field_map[ $key ];
// 安全反序列化 — wp_usermeta 是使用者可寫表,攻擊者可植入序列化物件
// 觸發 __wakeup/__destruct gadget chain。allowed_classes=false 阻擋。
$value = self::safe_unserialize( $value );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$data[ $col ] = TMDO_Type_Caster::to_db( $value, $field_def );
$formats[] = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
}
if ( count( $data ) <= 1 ) {
return false;
}
if ( $dry_run ) {
return true;
}
$target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
// REPLACE 作 Upsert
return $wpdb->replace( $target_table, $data, $formats ) !== false;
}
// ─────────────────────────────────────────────────────────
// 單批次非同步遷移(WP Cron 用)
// ─────────────────────────────────────────────────────────
/**
* 執行一個批次遷移並回傳進度(由 WP Cron 驅動,自動重排直到完成)。
*
* @param string $entity_type
* @param string $group_name
* @param int $batch_size
* @return array{done:bool,migrated:int,last_id:int,total:int,status:string,error?:string}
*/
public static function migrate_group_batch(
string $entity_type,
string $group_name,
int $batch_size = self::DEFAULT_BATCH_SIZE
): array {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return array(
'done' => true,
'migrated' => 0,
'last_id' => 0,
'total' => 0,
'status' => 'error',
'error' => "Adapter not found: {$entity_type}",
);
}
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( empty( $group_fields ) ) {
return array(
'done' => true,
'migrated' => 0,
'last_id' => 0,
'total' => 0,
'status' => 'error',
'error' => "Group not registered: {$entity_type}/{$group_name}",
);
}
$managed_keys = array_column( $group_fields, 'key' );
$target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
if ( ! TMDO_Schema_Manager::table_exists( $target_table ) ) {
return array(
'done' => true,
'migrated' => 0,
'last_id' => 0,
'total' => 0,
'status' => 'error',
'error' => "Target table not found: {$target_table}",
);
}
global $wpdb;
$status_table = self::get_status_table();
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT last_id, total_migrated FROM `{$status_table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
),
ARRAY_A
);
$last_id = (int) ( $row['last_id'] ?? 0 );
$prev_total = (int) ( $row['total_migrated'] ?? 0 );
self::mark_migration_started( $entity_type, $group_name );
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$field_map = array_column( $group_fields, null, 'key' );
$keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
$entity_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT `{$id_col}` FROM `{$meta_table}`
WHERE `meta_key` IN ({$keys_placeholders})
AND `{$id_col}` > %d
ORDER BY `{$id_col}` ASC
LIMIT %d",
...array_merge( $managed_keys, array( $last_id, $batch_size ) )
)
);
$batch_migrated = 0;
foreach ( $entity_ids as $entity_id ) {
$entity_id = (int) $entity_id;
try {
if ( self::migrate_single_entity( $entity_type, $group_name, $entity_id, $managed_keys, $field_map, $adapter, false ) ) {
++$batch_migrated;
}
} catch ( \Throwable $e ) {
error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) );
}
$last_id = $entity_id;
}
$total = $prev_total + $batch_migrated;
$done = count( $entity_ids ) < $batch_size;
if ( $done ) {
self::mark_migration_completed( $entity_type, $group_name );
} else {
self::update_checkpoint( $entity_type, $group_name, $last_id, $total );
}
$wpdb->flush();
return array(
'done' => $done,
'migrated' => $batch_migrated,
'last_id' => $last_id,
'total' => $total,
'status' => $done ? 'completed' : 'running',
);
}
// ─────────────────────────────────────────────────────────
// 斷點管理
// ─────────────────────────────────────────────────────────
private static function get_status_table(): string {
global $wpdb;
return $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
}
public static function get_checkpoint( string $entity_type, string $group_name ): int {
global $wpdb;
$table = self::get_status_table();
$last_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT last_id FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
)
);
return (int) ( $last_id ?? 0 );
}
private static function update_checkpoint( string $entity_type, string $group_name, int $last_id, int $total ): void {
global $wpdb;
$table = self::get_status_table();
$wpdb->replace(
$table,
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
'last_id' => $last_id,
'total_migrated' => $total,
'status' => 'running',
),
array( '%s', '%s', '%d', '%d', '%s' )
);
}
private static function mark_migration_started( string $entity_type, string $group_name ): void {
global $wpdb;
$table = self::get_status_table();
$existing = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
)
);
if ( $existing ) {
$wpdb->update(
$table,
array(
'status' => 'running',
'started_at' => current_time( 'mysql' ),
),
array( 'id' => $existing ),
array( '%s', '%s' ),
array( '%d' )
);
} else {
$wpdb->insert(
$table,
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
'status' => 'running',
'started_at' => current_time( 'mysql' ),
),
array( '%s', '%s', '%s', '%s' )
);
}
}
private static function mark_migration_completed( string $entity_type, string $group_name ): void {
global $wpdb;
$table = self::get_status_table();
$wpdb->update(
$table,
array(
'status' => 'completed',
'completed_at' => current_time( 'mysql' ),
),
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
),
array( '%s', '%s' ),
array( '%s', '%s' )
);
}
public static function reset_checkpoint( string $entity_type, string $group_name ): void {
global $wpdb;
$wpdb->delete(
self::get_status_table(),
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
),
array( '%s', '%s' )
);
}
// ─────────────────────────────────────────────────────────
// 驗證
// ─────────────────────────────────────────────────────────
/**
* 驗證遷移資料完整性(抽樣比對)
*/
public static function verify(
string $entity_type,
string $group_name,
int $sample_size = 100
): array {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return array( 'error' => 'Adapter not found' );
}
$wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
if ( ! TMDO_Schema_Manager::table_exists( $wpdo_table ) ) {
return array( 'error' => "UAE table not found: {$wpdo_table}" );
}
// 隨機抽樣 UAE 表中的 IDs
$sample_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT `{$id_col}` FROM `{$wpdo_table}` ORDER BY RAND() LIMIT %d",
$sample_size
)
);
if ( empty( $sample_ids ) ) {
return array(
'sampled' => 0,
'match' => 0,
'mismatch' => 0,
);
}
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
$field_map = array_column( $group_fields, null, 'key' );
$managed_keys = array_column( $group_fields, 'key' );
$match = 0;
$mismatch = 0;
$details = array();
foreach ( $sample_ids as $entity_id ) {
$entity_id = (int) $entity_id;
// 取 UAE 資料
$wpdo_row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` = %d",
$entity_id
),
ARRAY_A
);
// 取原始 meta
$placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
$raw_metas = $wpdb->get_results(
$wpdb->prepare(
"SELECT `meta_key`, `meta_value` FROM `{$meta_table}`
WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})",
...array_merge( array( $entity_id ), $managed_keys )
),
ARRAY_A
);
$is_match = true;
foreach ( $raw_metas as $raw ) {
$key = $raw['meta_key'];
if ( ! isset( $field_map[ $key ] ) ) {
continue;
}
$raw_value = self::safe_unserialize( $raw['meta_value'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$field_def = $field_map[ $key ];
$wpdo_value = TMDO_Type_Caster::from_db( $wpdo_row[ $col ] ?? null, $field_def );
// v2.1.6: type-aware comparison. The previous `(string)` cast falsely
// flagged numeric-precision differences (`"5678.90" !== "5678.9"`)
// even though both are numerically equal. Aligned with
// TMDO_Shadow_Diff_Logger::values_equal() semantics.
if ( ! self::loose_equal( $raw_value, $wpdo_value, $field_def['type'] ?? 'text' ) ) {
$is_match = false;
$details[] = array(
'entity_id' => $entity_id,
'key' => $key,
'raw' => $raw_value,
'uae' => $wpdo_value,
);
break;
}
}
$is_match ? $match++ : $mismatch++;
}
return array(
'sampled' => count( $sample_ids ),
'match' => $match,
'mismatch' => $mismatch,
'match_rate_pct' => count( $sample_ids ) > 0
? round( $match / count( $sample_ids ) * 100, 2 )
: 0,
'mismatch_details' => array_slice( $details, 0, 10 ),
);
}
// ─────────────────────────────────────────────────────────
// 回溯(UAE → wp_*meta 反向遷移)
// ─────────────────────────────────────────────────────────
/**
* 反向遷移:將 UAE 表資料寫回 wp_*meta
* 用於解除安裝前的資料保留
*/
public static function rollback_group(
string $entity_type,
string $group_name,
array $options = array()
): array {
$defaults = array(
'batch_size' => 500,
'sleep_ms' => 50,
);
$options = wp_parse_args( $options, $defaults );
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return self::error_result( "Adapter not found: {$entity_type}" );
}
$wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
$field_map = array_column( $group_fields, null, 'key' );
$stats = array(
'written' => 0,
'errors' => 0,
);
$last_id = 0;
do {
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` > %d ORDER BY `{$id_col}` ASC LIMIT %d",
$last_id,
$options['batch_size']
),
ARRAY_A
);
if ( empty( $rows ) ) {
break;
}
foreach ( $rows as $row ) {
$entity_id = (int) $row[ $id_col ];
foreach ( $field_map as $key => $field_def ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$value = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field_def );
if ( $value === null ) {
continue;
}
// 直接寫 wp_*meta,繞過 UAE 攔截(因為我們正在反向遷移)
$wpdb->replace(
$meta_table,
array(
$id_col => $entity_id,
'meta_key' => $key,
'meta_value' => maybe_serialize( $value ),
),
array( '%d', '%s', '%s' )
);
++$stats['written'];
}
$last_id = $entity_id;
}
$wpdb->flush();
usleep( $options['sleep_ms'] * 1000 );
} while ( count( $rows ) === $options['batch_size'] );
return $stats;
}
private static function error_result( string $message ): array {
return array(
'error' => $message,
'migrated' => 0,
'errors' => 1,
);
}
/**
* Object-safe replacement for `maybe_unserialize()`.
*
* `wp_usermeta` / `wp_postmeta` rows can contain attacker-planted serialized
* objects. `maybe_unserialize()` calls `unserialize()` with default options,
* which materializes objects and triggers `__wakeup`/`__destruct` gadgets.
* During backfill the migration engine runs in admin context, so any gadget
* chain in vendor/ becomes RCE.
*
* This wrapper passes `allowed_classes => false` so PHP returns
* `__PHP_Incomplete_Class` instances without ever invoking magic methods on
* the original class. We then convert those to null so they cannot leak
* into a flat-table column.
*
* @param mixed $value Raw meta_value from native EAV table.
* @return mixed Unserialized array/scalar, or original string if not serialized.
*/
private static function safe_unserialize( $value ) {
if ( ! is_string( $value ) ) {
return $value;
}
$trimmed = trim( $value );
// Cheap inline detector — does not rely on WP's is_serialized() so this
// function works in CLI / standalone migration contexts. Mirrors the
// shape checks WP does: type-tag at offset 0, ':' at offset 1, plausible
// terminator. PHP serialize tokens are: a (array), O (object),
// s (string), i (int), d (float), b (bool), N; (null).
if ( 'N;' !== $trimmed ) {
if ( strlen( $trimmed ) < 4 || ':' !== ( $trimmed[1] ?? '' ) ) {
return $value;
}
if ( ! in_array( $trimmed[0] ?? '', array( 'a', 'O', 's', 'i', 'd', 'b' ), true ) ) {
return $value;
}
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- explicit allowed_classes=false hardens against object injection.
$result = @unserialize( $trimmed, array( 'allowed_classes' => false ) );
if ( false === $result && 'b:0;' !== $trimmed ) {
return $value;
}
// Strip __PHP_Incomplete_Class artefacts (allowed_classes=false replaces
// any object marker with this stub). They must never reach a flat-table
// JSON column or a downstream consumer.
if ( is_object( $result ) ) {
return null;
}
if ( is_array( $result ) ) {
array_walk_recursive(
$result,
static function ( &$v ) {
if ( is_object( $v ) ) {
$v = null;
}
}
);
}
return $result;
}
/**
* Type-aware loose equality check for verify() sample comparison.
*
* Mirrors TMDO_Shadow_Diff_Logger::values_equal() so the two divergence
* detection paths (cron verify + live shadow_compare) agree on what
* constitutes a real mismatch vs a representation difference.
*
* @param mixed $eav Native wp_*meta value (after maybe_unserialize).
* @param mixed $flat Value from the WPDO flat table (after type cast).
* @param string $type Field type from registry (text/integer/decimal/...).
* @return bool
*/
private static function loose_equal( $eav, $flat, string $type ): bool {
if ( $eav === $flat ) {
return true;
}
if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) {
return is_numeric( $eav ) && is_numeric( $flat ) && (float) $eav === (float) $flat;
}
if ( 'boolean' === $type ) {
return (bool) $eav === (bool) $flat;
}
if ( 'json' === $type || is_array( $eav ) || is_array( $flat ) ) {
return wp_json_encode( $eav ) === wp_json_encode( $flat );
}
return (string) $eav === (string) $flat;
}
/**
* 取得所有遷移狀態
*/
public static function get_all_statuses(): array {
global $wpdb;
$table = self::get_status_table();
return $wpdb->get_results( "SELECT * FROM `{$table}` ORDER BY entity_type, group_name", ARRAY_A ) ?: array();
}
}
@@ -0,0 +1,226 @@
<?php
/**
* TMDO_Entity_Registry - 全域欄位與適配器登錄中心
*
* 提供:
* - 適配器註冊(post/user/term/comment
* - 欄位群組註冊(Schema 定義)
* - 欄位查詢 APImeta_key → field_def
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Entity_Registry {
/** @var array<string, TMDO_Entity_Adapter_Interface> 實體類型 → 適配器實例 */
private static array $adapters = array();
/** @var array<string, array<string, array>> 實體類型 → 群組名 → 欄位定義陣列 */
private static array $groups = array();
/** @var array<string, array<string, array>> 實體類型 → meta_key → 欄位定義(含 group 資訊)*/
private static array $field_index = array();
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
private static array $pending_schemas = array();
/** 合法的欄位型別 */
public const VALID_TYPES = array(
'text',
'textarea',
'integer',
'decimal',
'boolean',
'date',
'datetime',
'timestamp',
'json',
'enum',
'binary',
);
/** 合法的實體類型 */
public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' );
public static function init(): void {
self::$adapters = array();
self::$groups = array();
self::$field_index = array();
self::$pending_schemas = array();
}
// ─────────────────────────────────────────────────────────
// 適配器管理
// ─────────────────────────────────────────────────────────
public static function register_adapter( string $entity_type, TMDO_Entity_Adapter_Interface $adapter ): void {
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
return;
}
self::$adapters[ $entity_type ] = $adapter;
}
public static function get_adapter( string $entity_type ): ?TMDO_Entity_Adapter_Interface {
return self::$adapters[ $entity_type ] ?? null;
}
public static function get_all_adapters(): array {
return self::$adapters;
}
// ─────────────────────────────────────────────────────────
// 欄位群組登錄
// ─────────────────────────────────────────────────────────
/**
* 登錄一組 UAE 管理的欄位
*
* @param string $entity_type post|user|term|comment
* @param string $group_name 群組名稱(會成為表名的一部分)
* @param array $fields 欄位定義陣列,每項需有 key、type,選填:
* - required (bool)
* - default (mixed)
* - searchable (bool) 加索引
* - fulltext (bool) 全文索引(僅 text/textarea
* - unique (bool) 唯一索引
* - options (array) enum 選項
* - label (string) 顯示名稱
*
* @return bool
*/
public static function register_group( string $entity_type, string $group_name, array $fields ): bool {
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
return false;
}
if ( ! isset( self::$adapters[ $entity_type ] ) ) {
return false;
}
// 防重複登錄
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
return false;
}
// 驗證並正規化欄位定義
$normalized = array();
foreach ( $fields as $field ) {
if ( empty( $field['key'] ) || empty( $field['type'] ) ) {
continue;
}
if ( ! in_array( $field['type'], self::VALID_TYPES, true ) ) {
continue;
}
$normalized_field = wp_parse_args(
$field,
array(
'key' => '',
'type' => 'text',
'required' => false,
'default' => null,
'searchable' => false,
'fulltext' => false,
'unique' => false,
'options' => array(),
'label' => '',
)
);
$normalized_field['group'] = $group_name;
$normalized_field['entity_type'] = $entity_type;
$normalized[] = $normalized_field;
// 建立索引以供快速查詢
self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field;
}
if ( empty( $normalized ) ) {
return false;
}
self::$groups[ $entity_type ][ $group_name ] = $normalized;
// 加入待建表佇列
self::$pending_schemas[] = array(
'type' => $entity_type,
'group' => $group_name,
'fields' => $normalized,
);
return true;
}
// ─────────────────────────────────────────────────────────
// 查詢 API
// ─────────────────────────────────────────────────────────
/**
* 依 meta_key 查詢欄位定義
*
* @return array|null 欄位定義,或 null(非 UAE 管理欄位)
*/
public static function get_field( string $entity_type, string $meta_key ): ?array {
return self::$field_index[ $entity_type ][ $meta_key ] ?? null;
}
/**
* 取得實體類型下所有群組名稱
*
* @return array<string>
*/
public static function get_groups_for_type( string $entity_type ): array {
return array_keys( self::$groups[ $entity_type ] ?? array() );
}
/**
* 取得特定群組的完整欄位定義
*/
public static function get_group_fields( string $entity_type, string $group_name ): array {
return self::$groups[ $entity_type ][ $group_name ] ?? array();
}
/**
* 取得一個群組中所有 meta_key
*
* @return array<string>
*/
public static function get_group_keys( string $entity_type, string $group_name ): array {
$fields = self::get_group_fields( $entity_type, $group_name );
return array_column( $fields, 'key' );
}
/**
* 取得所有已登錄的欄位(跨實體、跨群組)
*/
public static function get_all_fields(): array {
return self::$field_index;
}
/**
* 取得待建表清單(供 Schema_Manager 處理)
*/
public static function get_pending_schemas(): array {
return self::$pending_schemas;
}
public static function clear_pending_schemas(): void {
self::$pending_schemas = array();
}
/**
* 判斷某個 meta_key 是否由 UAE 管理
*/
public static function is_managed( string $entity_type, string $meta_key ): bool {
return isset( self::$field_index[ $entity_type ][ $meta_key ] );
}
}
+658
View File
@@ -0,0 +1,658 @@
<?php
/**
* TMDO_Hook_Bus - 統一 Hook 攔截匯流排
*
* 所有 WordPress 的 meta 操作皆通過此匯流排:
* - {type}_metadata 系列 filteradd/get/update/delete
* - 實體刪除 action
* - 原生查詢擴充 hook
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Hook_Bus {
/** 標記:防止在內部 UPSERT 時遞迴觸發 filter */
private static array $internal_ops = array();
public static function init(): void {
// 取得所有已註冊的適配器
$adapters = TMDO_Entity_Registry::get_all_adapters();
foreach ( $adapters as $type => $adapter ) {
self::register_hooks_for_type( $type, $adapter );
}
}
private static function register_hooks_for_type( string $type, TMDO_Entity_Adapter_Interface $adapter ): void {
// ── 寫入攔截 ──────────────────────────────────────────
add_filter( "update_{$type}_metadata", array( self::class, 'intercept_update' ), 10, 5 );
add_filter( "add_{$type}_metadata", array( self::class, 'intercept_add' ), 10, 5 );
// ── 讀取攔截 ──────────────────────────────────────────
add_filter( "get_{$type}_metadata", array( self::class, 'intercept_get' ), 10, 5 );
// ── 刪除攔截 ──────────────────────────────────────────
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
// ── 實體刪除時自動清理 ───────────────────────────────
add_action(
$adapter->get_delete_hook(),
function ( $entity_id ) use ( $type, $adapter ) {
self::cleanup_entity( $type, (int) $entity_id );
},
10,
1
);
}
// ─────────────────────────────────────────────────────────
// 寫入:update_{type}_metadata filter
// ─────────────────────────────────────────────────────────
/**
* 短路 WordPress 原生 update_metadata() 流程
*
* @param null|bool $check 若回傳 null 則 WP 繼續原生流程
* @param int $object_id
* @param string $meta_key
* @param mixed $meta_value
* @param mixed $prev_value
*/
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( ! $field_def ) {
return $check; // 非管理欄位,放行
}
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check; // disabled
}
// Route decision (v1.2.0):讓 UAEPG 等外掛正式訂閱 routing,不必搶 priority 5。
$route = self::decide_route( 'update', $type, (int) $object_id, $meta_key, $meta_value );
if ( $route === 'pg' ) {
// 讓其他 listener(例如 UAEPG)接手;原生 EAV 也放行。
return null;
}
if ( $route === 'skip' ) {
// 不寫 flat、不寫 EAV,但告訴 WP 已處理。
return true;
}
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
$flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def );
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
// 若 mode 也要寫 EAV → return null 讓 WP 繼續
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null; // dual_write / shadow_read
}
return $flat_result; // aeav_only
}
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( ! $field_def ) {
return $check;
}
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check;
}
$route = self::decide_route( 'add', $type, (int) $object_id, $meta_key, $meta_value );
if ( $route === 'pg' ) {
return null;
}
if ( $route === 'skip' ) {
return true;
}
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
$flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def );
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
return $flat_result;
}
/**
* 如 filter `wpdo_capture_before_value` 回 true 才讀 flat table 的 before value,
* 否則直接回 null — 讓沒在用 audit / 其他 listener 的站台省一次 DB read。
*
* filter 參數:(bool $default_true, string $type, string $meta_key, string $op)
* $op ∈ { 'add', 'update', 'delete' }
*
* 使用範例(關閉 audit 的站台):
* add_filter( 'wpdo_capture_before_value', '__return_false' );
*
* @since 1.3.2
*/
private static function maybe_read_before_value(
string $type,
int $entity_id,
string $meta_key,
array $field_def,
string $op
) {
$capture = apply_filters(
'wpdo_capture_before_value',
true,
$type,
$meta_key,
$op
);
if ( ! $capture ) {
return null;
}
return self::read_flat_value( $type, $entity_id, $meta_key, $field_def );
}
/**
* 讀取 flat table 中當前值(before value,供 audit / after_write listener 使用)。
*
* 此方法**不經 cache 加熱**,直接查 DB,以避免快取污染與遞迴。表不存在回 null。
*
* @since 1.3.1
*/
private static function read_flat_value(
string $type,
int $entity_id,
string $meta_key,
array $field_def
) {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$group = $field_def['group'] ?? '';
if ( $group === '' ) {
return null;
}
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return null;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$id_col = $adapter->get_entity_id_column();
$raw = $wpdb->get_var(
$wpdb->prepare(
"SELECT `{$col}` FROM `{$table}` WHERE `{$id_col}` = %d LIMIT 1",
$entity_id
)
);
return $raw === null ? null : TMDO_Type_Caster::from_db( $raw, $field_def );
}
/**
* 讓外部 listener(例如 UAEPG)透過 `wpdo_route_decision` filter 指定路由。
*
* 回傳值:
* 'flat' (預設) — UAE 寫入 MySQL flat table
* 'pg' — 放行,由其他 listener 接手;UAE 不寫 flat,原生 EAV 依 mode 決定
* 'skip' — 都不寫(用於軟刪除之類特殊情境),但告訴 WP 已處理
*
* 其他非預期值會被 fallback 到 'flat' 以維持安全預設。
*
* @since 1.2.0
*/
private static function decide_route(
string $op,
string $type,
int $object_id,
string $meta_key,
$meta_value
): string {
$route = apply_filters(
'wpdo_route_decision',
'flat',
$type,
$object_id,
$meta_key,
$meta_value,
$op
);
if ( in_array( $route, array( 'flat', 'pg', 'skip' ), true ) ) {
return $route;
}
TMDO_Logger::warning(
'wpdo_route_decision_invalid_return',
array(
'returned' => is_scalar( $route ) ? (string) $route : gettype( $route ),
'op' => $op,
'type' => $type,
'key' => $meta_key,
)
);
return 'flat';
}
/**
* 執行 Upsert 操作
*/
private static function perform_upsert(
string $type,
int $entity_id,
string $meta_key,
$meta_value,
array $field_def
) {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$group = $field_def['group'];
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
$id_col = $adapter->get_entity_id_column();
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
// 表不存在則讓 WP 走原生流程(降級處理)
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return null;
}
// 型別轉換
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
// 鎖防遞迴
$lock_key = $entity_id . ':' . $meta_key;
self::$internal_ops[ $lock_key ] = true;
try {
// 檢查列是否存在
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM `{$table}` WHERE `{$id_col}` = %d",
$entity_id
)
);
if ( $exists ) {
// UPDATE
$result = $wpdb->update(
$table,
array( $col => $db_value ),
array( $id_col => $entity_id ),
array( $format ),
array( '%d' )
);
} else {
// INSERT
$result = $wpdb->insert(
$table,
array(
$id_col => $entity_id,
$col => $db_value,
),
array( '%d', $format )
);
}
// 清除快取
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
// 短路回傳 true(WP 認為寫入成功)
return $result !== false;
} finally {
unset( self::$internal_ops[ $lock_key ] );
}
}
// ─────────────────────────────────────────────────────────
// 讀取:get_{type}_metadata filter
// ─────────────────────────────────────────────────────────
/**
* 攔截 get_metadata() 呼叫
*
* @param null|mixed $check 若回傳 null 則 WP 繼續原生流程
* @param int $object_id
* @param string $meta_key 空字串表示取所有 meta
* @param bool $single
* @param string $meta_type 5.5+ 額外參數
*/
public static function intercept_get( $check, $object_id, $meta_key, $single, $meta_type = '' ) {
$type = $meta_type ?: self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
// 空 keyWP 要求所有 meta,UAE 不攔截(維持相容性)
if ( $meta_key === '' ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( ! $field_def ) {
return $check;
}
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
// aeav_only : 讀取走 UAE flat,不讀 EAV
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
return $check; // disabled / dual_write
}
$group = $field_def['group'];
$row = self::get_or_load_row( $type, (int) $object_id, $group );
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$has_value = is_array( $row ) && array_key_exists( $col, $row );
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
// Shadow-read:與 EAV 比對,記錄差異
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
try {
TMDO_Shadow_Diff_Logger::compare_and_log(
$type,
(int) $object_id,
$meta_key,
$value,
$field_def
);
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error(
'shadow_compare_exception',
array(
'error' => $e->getMessage(),
)
);
}
}
// 值為空 → 回 WP 原生慣例
if ( empty( $row ) || $value === null || $value === '' ) {
return $single ? '' : array();
}
// WP 的慣例:get_metadata() 即使 $single=true 也回傳陣列包裝
return $single ? array( $value ) : array( $value );
}
/**
* 取得(或載入)完整列資料,並快取
*/
private static function get_or_load_row( string $type, int $entity_id, string $group ): array {
// L1 快取
$cached = TMDO_Cache_Orchestrator::get_row( $type, $entity_id, $group );
if ( is_array( $cached ) ) {
return $cached;
}
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return array();
}
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
$id_col = $adapter->get_entity_id_column();
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return array();
}
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM `{$table}` WHERE `{$id_col}` = %d LIMIT 1",
$entity_id
),
ARRAY_A
);
$row = $row ?: array();
TMDO_Cache_Orchestrator::set_row( $type, $entity_id, $group, $row );
return $row;
}
// ─────────────────────────────────────────────────────────
// 刪除:delete_{type}_metadata filter
// ─────────────────────────────────────────────────────────
public static function intercept_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( ! $field_def ) {
return $check;
}
// Mode-aware:disabled 完全不攔截
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check;
}
$route = self::decide_route( 'delete', $type, (int) $object_id, $meta_key, $meta_value );
if ( $route === 'pg' ) {
return null;
}
if ( $route === 'skip' ) {
return true;
}
// 截取 before value(v1.3.1)
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
$group = $field_def['group'];
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
$id_col = $adapter->get_entity_id_column();
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return $check;
}
// UAE 的邏輯:刪除 meta = 設該欄位為 NULL
// 因為一個 entity 只對應一列,完整刪除列會丟失其他欄位
$default = $field_def['default'] ?? null;
if ( $delete_all ) {
// Safety cap: refuse mass-null if affected row count exceeds threshold.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
if ( $row_count > 500 ) {
TMDO_Logger::warning(
'intercept_delete_mass_blocked',
array(
'table' => $table,
'col' => $col,
'rows' => $row_count,
)
);
return false;
}
TMDO_Logger::info(
'intercept_delete_all',
array(
'table' => $table,
'col' => $col,
'rows' => $row_count,
)
);
// 刪除所有 entity 的該欄位
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` SET `{$col}` = %s",
$default
)
);
} else {
$result = $wpdb->update(
$table,
array( $col => $default ),
array( $id_col => $object_id ),
array( TMDO_Type_Caster::get_wpdb_format( $field_def['type'] ) ),
array( '%d' )
);
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
}
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
return $result !== false;
}
// ─────────────────────────────────────────────────────────
// 實體刪除清理
// ─────────────────────────────────────────────────────────
public static function cleanup_entity( string $type, int $entity_id ): void {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return;
}
$id_col = $adapter->get_entity_id_column();
$groups = TMDO_Entity_Registry::get_groups_for_type( $type );
foreach ( $groups as $group ) {
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
$wpdb->delete( $table, array( $id_col => $entity_id ), array( '%d' ) );
}
}
TMDO_Cache_Orchestrator::flush_entity( $type, $entity_id );
}
// ─────────────────────────────────────────────────────────
// 工具方法
// ─────────────────────────────────────────────────────────
/**
* 從當前 filter 名稱推斷實體類型
* 例:update_user_metadata → user
*/
private static function resolve_type_from_current_filter(): ?string {
$current = current_filter();
if ( preg_match( '/^(?:add|get|update|delete)_(post|user|term|comment)_metadata$/', $current, $matches ) ) {
return $matches[1];
}
return null;
}
/**
* 直接讀取(繞過 WP filter 系統)
* 供 wpdo_get_meta() 便利函式使用,性能更好
*
* @param string $type
* @param int $entity_id
* @param string $key 若為空字串則回傳整列
* @return mixed
*/
public static function direct_read( string $type, int $entity_id, string $key = '' ) {
if ( $key === '' ) {
// 回傳所有群組的所有欄位
$result = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
$row = self::get_or_load_row( $type, $entity_id, $group );
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
$result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
}
}
return $result;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $key );
if ( ! $field_def ) {
return null;
}
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
return TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field_def );
}
}
+380
View File
@@ -0,0 +1,380 @@
<?php
/**
* TMDO_Mode_Manager - Per-entity-type bridge 模式管理
*
* 4 種運作模式(每個 entity type 可獨立設定):
*
* disabled — 完全停用 UAE hook,回到原生 wp_*meta EAV。
* Kill-switch,緊急關閉用。
*
* dual_write — 寫入:同時寫 UAE flat table + wp_*meta
* 讀取:走 wp_*meta(原生)
* 用途:遷移前期安全模式,UAE 開始累積資料但不影響讀取
*
* shadow_read — 寫入:同時寫 UAE flat table + wp_*meta
* 讀取:走 UAE flat table,同時比對 wp_*meta 記錄差異
* 用途:驗證期,確認 UAE 資料正確後才進入 aeav_only
*
* aeav_only — 寫入:只寫 UAE flat table
* 讀取:只讀 UAE flat table
* 用途:完成遷移後的最終模式,效能最佳
*
* 合法轉換路徑(安全性):
*
* disabled ←→ dual_write ←→ shadow_read ←→ aeav_only
* ↑ ↑
* └──── aeav_only 可以直接降級到任一前向狀態
*
* 禁止:disabled → shadow_read 或 disabled → aeav_only
* (會讀不到資料,因為 UAE 表還沒有任何寫入)
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Mode_Manager {
public const MODE_DISABLED = 'disabled';
public const MODE_DUAL_WRITE = 'dual_write';
public const MODE_SHADOW_READ = 'shadow_read';
public const MODE_AEAV_ONLY = 'aeav_only';
public const ALL_MODES = array(
self::MODE_DISABLED,
self::MODE_DUAL_WRITE,
self::MODE_SHADOW_READ,
self::MODE_AEAV_ONLY,
);
/** wp_options key holding array<entity_type, mode> */
private const OPT_KEY = 'wpdo_bridge_modes';
/**
* Per-entity 進入當前 mode 的 unix timestamp(v1.5.0+ 供 auto-promoter 使用)。
* Shape: array<string entity_type, int timestamp>
*/
public const OPT_ENTERED_AT = 'wpdo_bridge_mode_entered_at';
/** Per-request memoization(避免每次 filter 都查 DB option) */
private static ?array $cache = null;
/**
* 預設模式(v2.5.4 起):
* post → disabled(由 Legacy Feature_Flags FSM 管理,不由 Mode_Manager 控制)
* user / term / comment → dual_write(安全雙寫:寫入 flat table + 原生 EAV,讀取仍走 EAV
*/
private static function defaults(): array {
return array(
'post' => self::MODE_DISABLED,
'user' => self::MODE_DUAL_WRITE,
'term' => self::MODE_DUAL_WRITE,
'comment' => self::MODE_DUAL_WRITE,
);
}
/**
* 取得所有 entity 的 mode
*
* @return array<string, string>
*/
public static function all(): array {
if ( self::$cache !== null ) {
return self::$cache;
}
$stored = get_option( self::OPT_KEY, array() );
$modes = self::defaults();
if ( is_array( $stored ) ) {
foreach ( $stored as $type => $mode ) {
if ( is_string( $type ) && self::is_valid_mode( $mode ) && in_array( $type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
$modes[ $type ] = $mode;
}
}
}
self::$cache = $modes;
return $modes;
}
/**
* 取得單一 entity type 的 mode
*/
public static function get( string $entity_type ): string {
$all = self::all();
return $all[ $entity_type ] ?? self::MODE_DISABLED;
}
/**
* 設定單一 entity type 的 mode(包含轉換安全檢查)
*
* @return true|\WP_Error
*/
public static function set( string $entity_type, string $new_mode ) {
if ( ! in_array( $entity_type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
return new \WP_Error( 'invalid_entity', "Invalid entity type: {$entity_type}" );
}
if ( ! self::is_valid_mode( $new_mode ) ) {
return new \WP_Error( 'invalid_mode', "Invalid mode: {$new_mode}" );
}
$current = self::get( $entity_type );
if ( $current === $new_mode ) {
return true; // no-op
}
// 驗證轉換安全性
if ( ! self::is_safe_transition( $current, $new_mode ) ) {
return new \WP_Error(
'unsafe_transition',
sprintf(
__( '不安全的模式轉換:%1$s → %2$s。建議路徑:disabled → dual_write → shadow_read → aeav_only', 'uae' ),
$current,
$new_mode
)
);
}
$all = self::all();
$all[ $entity_type ] = $new_mode;
update_option( self::OPT_KEY, $all, false );
self::$cache = $all;
// 記錄進入此 mode 的 timestamp — auto-promoter 用來判斷停留天數。
$entered = get_option( self::OPT_ENTERED_AT, array() );
if ( ! is_array( $entered ) ) {
$entered = array();
}
$entered[ $entity_type ] = time();
update_option( self::OPT_ENTERED_AT, $entered, false );
TMDO_Logger::info(
'bridge_mode_changed',
array(
'entity_type' => $entity_type,
'from' => $current,
'to' => $new_mode,
'user_id' => get_current_user_id(),
)
);
// 模式變更時清除所有相關快取
TMDO_Cache_Orchestrator::flush_entity( $entity_type );
do_action( 'wpdo_bridge_mode_changed', $entity_type, $new_mode, $current );
return true;
}
/**
* 緊急 kill-switch:全部 entity 瞬間切到 disabled
*
* 這是**安全轉換規則的例外** — 任何狀態都可以瞬間降到 disabled,
* 因為 EAV 資料一直存在(dual_write / shadow_read 都還寫 EAV),
* 只有 aeav_only 切到 disabled 才有資料遺失風險。
*
* 呼叫這個方法表示「出事了,先退回安全狀態」,EAV 可能不是最新的,
* 但至少系統不會壞。
*
* @return int 改變狀態的 entity 數量
*/
public static function emergency_disable_all(): int {
$all = self::all();
$changed = 0;
foreach ( $all as $type => $mode ) {
if ( $mode !== self::MODE_DISABLED ) {
$all[ $type ] = self::MODE_DISABLED;
++$changed;
TMDO_Logger::warning(
'bridge_emergency_disable',
array(
'entity_type' => $type,
'previous_mode' => $mode,
'user_id' => get_current_user_id(),
'is_aeav_only' => $mode === self::MODE_AEAV_ONLY,
'warning' => $mode === self::MODE_AEAV_ONLY
? 'CRITICAL: switching from aeav_only to disabled — EAV may be stale'
: 'Normal emergency fallback',
)
);
}
}
if ( $changed > 0 ) {
update_option( self::OPT_KEY, $all, false );
self::$cache = $all;
TMDO_Cache_Orchestrator::flush_all();
do_action( 'wpdo_bridge_emergency_disabled', $changed );
}
return $changed;
}
/**
* 設定全部 entity 到同一個 mode(批次操作,會做轉換檢查)
*
* @return array<string, true|\WP_Error> 逐 entity 的結果
*/
public static function set_all( string $mode ): array {
$results = array();
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $type ) {
$results[ $type ] = self::set( $type, $mode );
}
return $results;
}
/**
* Helper: 目前 mode 是否會寫入 flat table?
*/
public static function writes_to_flat( string $entity_type ): bool {
return in_array(
self::get( $entity_type ),
array(
self::MODE_DUAL_WRITE,
self::MODE_SHADOW_READ,
self::MODE_AEAV_ONLY,
),
true
);
}
/**
* Helper: 目前 mode 是否會讓 WP 原生 EAV 也被寫入?
*/
public static function writes_to_eav( string $entity_type ): bool {
return in_array(
self::get( $entity_type ),
array(
self::MODE_DISABLED,
self::MODE_DUAL_WRITE,
self::MODE_SHADOW_READ,
),
true
);
}
/**
* Helper: 目前 mode 是否從 flat table 讀取?
*/
public static function reads_from_flat( string $entity_type ): bool {
return in_array(
self::get( $entity_type ),
array(
self::MODE_SHADOW_READ,
self::MODE_AEAV_ONLY,
),
true
);
}
/**
* Helper: 是否要做 shadow 比對?
*/
public static function does_shadow_compare( string $entity_type ): bool {
return self::get( $entity_type ) === self::MODE_SHADOW_READ;
}
// ─────────────────────────────────────────────────────────
// 驗證
// ─────────────────────────────────────────────────────────
public static function is_valid_mode( $mode ): bool {
return is_string( $mode ) && in_array( $mode, self::ALL_MODES, true );
}
/**
* 轉換是否安全?
*
* 規則:
* - 相鄰的階梯移動:允許(正向或反向)
* - 跨階梯降級:允許(後面的 fallback,EAV 還在)
* - 跨階梯升級:禁止(下游資料可能還沒跟上)
*
* 階梯順序(index 越大 = 越「靠 UAE」):
* 0: disabled
* 1: dual_write
* 2: shadow_read
* 3: aeav_only
*
* 降級(index 變小)永遠安全(EAV 都還在,除了 aeav_only→disabled 一跳,
* 但那是刻意的 emergency 用法 — 見 emergency_disable_all)。
*
* 升級只允許 +1(disabled→dual_write、dual_write→shadow_read、shadow_read→aeav_only)。
*/
public static function is_safe_transition( string $from, string $to ): bool {
$order = array(
self::MODE_DISABLED => 0,
self::MODE_DUAL_WRITE => 1,
self::MODE_SHADOW_READ => 2,
self::MODE_AEAV_ONLY => 3,
);
if ( ! isset( $order[ $from ], $order[ $to ] ) ) {
return false;
}
$diff = $order[ $to ] - $order[ $from ];
// 升級:只允許 +1
if ( $diff > 0 ) {
return $diff === 1;
}
// 降級:一律允許(這是 fallback,資料都還在)
return true;
}
// ─────────────────────────────────────────────────────────
// 內部
// ─────────────────────────────────────────────────────────
/**
* 測試用:清除 memoization
*/
public static function reset_cache(): void {
self::$cache = null;
}
/**
* Human-readable label
*/
public static function label( string $mode ): string {
switch ( $mode ) {
case self::MODE_DISABLED:
return __( '停用(原生 EAV)', 'uae' );
case self::MODE_DUAL_WRITE:
return __( '雙寫', 'uae' );
case self::MODE_SHADOW_READ:
return __( '影子讀取(驗證中)', 'uae' );
case self::MODE_AEAV_ONLY:
return __( '僅 UAE(生產模式)', 'uae' );
default:
return $mode;
}
}
/**
* Mode 的說明文字(給 admin UI 用)
*/
public static function description( string $mode ): string {
switch ( $mode ) {
case self::MODE_DISABLED:
return __( '完全停用 UAE 攔截,使用 WordPress 原生 wp_*meta 表。安全 fallback。', 'uae' );
case self::MODE_DUAL_WRITE:
return __( '寫入 UAE + 原生 meta 表,讀取仍走原生。遷移前期的安全起點。', 'uae' );
case self::MODE_SHADOW_READ:
return __( '寫入雙寫,讀取走 UAE 並比對原生 meta。驗證資料一致性的階段。', 'uae' );
case self::MODE_AEAV_ONLY:
return __( '僅讀寫 UAE,不再寫入原生 meta 表。最終生產模式,效能最佳。', 'uae' );
default:
return '';
}
}
}
@@ -0,0 +1,322 @@
<?php
/**
* TMDO_Query_Compiler - 跨實體查詢編譯器
*
* 將 wpdo_meta_query 編譯為高效 SQL
* - 單次 JOIN UAE 表(而非原生 meta_query 多次 JOIN wp_*meta
* - 型別正確的比較(避免 CAST 開銷)
* - 命中索引
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Query_Compiler {
/** @var array 合法的 SQL 比較運算子 */
private const VALID_OPERATORS = array(
'=',
'!=',
'<>',
'>',
'>=',
'<',
'<=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'BETWEEN',
'NOT BETWEEN',
'EXISTS',
'NOT EXISTS',
);
/**
* 注入 wpdo_meta_query 至 WP_Querypost 實體)
*
* @param WP_Query $query
* @param array $wpdo_meta_query 結構同 meta_query
*/
public static function inject_into_wp_query( WP_Query $query, array $wpdo_meta_query ): void {
$post_type = $query->get( 'post_type' );
if ( is_array( $post_type ) ) {
$post_type = $post_type[0] ?? 'post';
}
if ( empty( $post_type ) || $post_type === 'any' ) {
$post_type = 'post';
}
// 編譯 JOIN 與 WHERE
$compiled = self::compile( 'post', $wpdo_meta_query );
if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) {
return;
}
// 注入 posts_clauses filter
add_filter(
'posts_clauses',
function ( $clauses ) use ( $compiled ) {
global $wpdb;
if ( ! empty( $compiled['joins'] ) ) {
$clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] );
}
if ( ! empty( $compiled['where'] ) ) {
$clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
}
return $clauses;
},
10,
1
);
// wpdo_orderby 支援
$wpdo_orderby = $query->get( 'wpdo_orderby' );
if ( $wpdo_orderby ) {
$order_dir = strtoupper( $query->get( 'order' ) ?: 'DESC' );
if ( ! in_array( $order_dir, array( 'ASC', 'DESC' ), true ) ) {
$order_dir = 'DESC';
}
$wpdo_col = TMDO_Schema_Manager::sanitize_column_name( $wpdo_orderby );
// 找出該欄位所在的群組
$field_def = TMDO_Entity_Registry::get_field( 'post', $wpdo_orderby );
if ( $field_def ) {
$alias = 'wpdo_' . $field_def['group'];
add_filter(
'posts_orderby',
function ( $orderby ) use ( $alias, $wpdo_col, $order_dir ) {
return "`{$alias}`.`{$wpdo_col}` {$order_dir}";
},
10,
1
);
}
}
}
/**
* 注入至 WP_User_Query
*/
public static function inject_into_user_query( WP_User_Query $query, array $wpdo_meta_query ): void {
$compiled = self::compile( 'user', $wpdo_meta_query );
if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) {
return;
}
global $wpdb;
// 透過 reflection 存取 WP_User_Query 的 query_vars 來注入
$query_orderby = &$query->query_orderby;
$query_where = &$query->query_where;
$query_from = &$query->query_from;
if ( ! empty( $compiled['joins'] ) ) {
$query_from .= ' ' . implode( ' ', $compiled['joins'] );
}
if ( ! empty( $compiled['where'] ) ) {
$query_where .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
}
// wpdo_orderby
$wpdo_orderby = $query->get( 'wpdo_orderby' );
if ( $wpdo_orderby ) {
$field_def = TMDO_Entity_Registry::get_field( 'user', $wpdo_orderby );
if ( $field_def ) {
$order_dir = strtoupper( $query->get( 'order' ) ?: 'DESC' );
if ( ! in_array( $order_dir, array( 'ASC', 'DESC' ), true ) ) {
$order_dir = 'DESC';
}
$alias = 'wpdo_' . $field_def['group'];
$col = TMDO_Schema_Manager::sanitize_column_name( $wpdo_orderby );
$query_orderby = "ORDER BY `{$alias}`.`{$col}` {$order_dir}";
}
}
}
/**
* 注入至 get_terms() 的 clauses
*/
public static function inject_into_terms_clauses( array $clauses, array $wpdo_meta_query ): array {
$compiled = self::compile( 'term', $wpdo_meta_query );
if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) {
return $clauses;
}
if ( ! empty( $compiled['joins'] ) ) {
$clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] );
}
if ( ! empty( $compiled['where'] ) ) {
$clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
}
return $clauses;
}
/**
* 核心編譯邏輯
*
* @param string $entity_type
* @param array $meta_query 結構範例:
* [
* 'relation' => 'AND',
* [ 'key' => 'price', 'value' => [100, 500], 'compare' => 'BETWEEN' ],
* [ 'key' => 'stock', 'value' => 'instock' ],
* ]
* @return array{joins: array<string>, where: array<string>}
*/
public static function compile( string $entity_type, array $meta_query ): array {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return array(
'joins' => array(),
'where' => array(),
);
}
$relation = strtoupper( $meta_query['relation'] ?? 'AND' );
if ( ! in_array( $relation, array( 'AND', 'OR' ), true ) ) {
$relation = 'AND';
}
unset( $meta_query['relation'] );
$needed_groups = array(); // group → true
$where_clauses = array();
foreach ( $meta_query as $clause ) {
if ( ! is_array( $clause ) || empty( $clause['key'] ) ) {
continue;
}
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $clause['key'] );
if ( ! $field_def ) {
// 非 UAE 管理的欄位,跳過(讓原生 meta_query 接手)
continue;
}
$group = $field_def['group'];
$needed_groups[ $group ] = true;
$compare = strtoupper( $clause['compare'] ?? '=' );
if ( ! in_array( $compare, self::VALID_OPERATORS, true ) ) {
$compare = '=';
}
$alias = 'wpdo_' . $group;
$col = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] );
$value = $clause['value'] ?? null;
$where_clauses[] = self::build_comparison( $alias, $col, $compare, $value, $field_def );
}
// 建立 JOIN
$joins = array();
$prim_table = $adapter->get_primary_table();
$prim_id = $adapter->get_primary_id_column();
$id_col = $adapter->get_entity_id_column();
foreach ( array_keys( $needed_groups ) as $group ) {
$wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
if ( ! TMDO_Schema_Manager::table_exists( $wpdo_table ) ) {
continue;
}
$alias = 'wpdo_' . $group;
$joins[] = "LEFT JOIN `{$wpdo_table}` `{$alias}` ON `{$prim_table}`.`{$prim_id}` = `{$alias}`.`{$id_col}`";
}
// 組合 where,依 relation 連接
$where_sql = array();
if ( ! empty( $where_clauses ) ) {
$where_sql[] = implode( " {$relation} ", $where_clauses );
}
return array(
'joins' => $joins,
'where' => $where_sql,
);
}
/**
* 產生單一比較子句
*/
private static function build_comparison(
string $alias,
string $col,
string $compare,
$value,
array $field_def
): string {
global $wpdb;
$col_ref = "`{$alias}`.`{$col}`";
switch ( $compare ) {
case 'EXISTS':
return "{$col_ref} IS NOT NULL";
case 'NOT EXISTS':
return "{$col_ref} IS NULL";
case 'BETWEEN':
case 'NOT BETWEEN':
if ( ! is_array( $value ) || count( $value ) !== 2 ) {
return '1=1';
}
$v1 = self::escape_value( $value[0], $field_def );
$v2 = self::escape_value( $value[1], $field_def );
return "{$col_ref} {$compare} {$v1} AND {$v2}";
case 'IN':
case 'NOT IN':
if ( ! is_array( $value ) ) {
$value = array( $value );
}
if ( empty( $value ) ) {
return $compare === 'IN' ? '1=0' : '1=1';
}
$escaped = array_map( fn( $v ) => self::escape_value( $v, $field_def ), $value );
return "{$col_ref} {$compare} (" . implode( ',', $escaped ) . ')';
case 'LIKE':
case 'NOT LIKE':
$like_val = '%' . $wpdb->esc_like( (string) $value ) . '%';
return "{$col_ref} {$compare} '" . esc_sql( $like_val ) . "'";
default:
$escaped = self::escape_value( $value, $field_def );
return "{$col_ref} {$compare} {$escaped}";
}
}
/**
* 型別安全地轉義值
*/
private static function escape_value( $value, array $field_def ): string {
$type = $field_def['type'] ?? 'text';
return match ( $type ) {
'integer', 'boolean' => (string) (int) $value,
'decimal' => (string) (float) $value,
default => "'" . esc_sql( (string) $value ) . "'",
};
}
}
@@ -0,0 +1,341 @@
<?php
/**
* TMDO_Schema_Manager - 動態 DDL 引擎
*
* 功能:
* - 根據欄位定義動態建立/升級扁平化資料表
* - WordPress 型別 → MySQL 型別映射
* - 自動索引策略(B-Tree、全文、唯一)
* - Schema 版本控制(hash 比對)
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Schema_Manager {
/**
* WordPress 邏輯型別 → MySQL 實體型別映射
*/
private static array $type_map = array(
'text' => 'VARCHAR(255)',
'textarea' => 'TEXT',
'integer' => 'BIGINT(20)',
'decimal' => 'DECIMAL(18,6)',
'boolean' => 'TINYINT(1)',
'date' => 'DATE',
'datetime' => 'DATETIME',
'timestamp' => 'TIMESTAMP',
'json' => 'LONGTEXT', // MySQL 5.7.8+ 可用 JSON,為相容性用 LONGTEXT
'enum' => 'VARCHAR(100)', // 在 PHP 層驗證
'binary' => 'LONGBLOB',
);
/**
* 取得完整資料表名稱
*/
public static function get_table_name( string $entity_type, string $group_name ): string {
global $wpdb;
return $wpdb->prefix . TMDO_TABLE_PREFIX . sanitize_key( $entity_type ) . '_' . sanitize_key( $group_name );
}
/**
* 批次處理所有待建表
*/
public static function process_pending_migrations(): void {
$pending = TMDO_Entity_Registry::get_pending_schemas();
foreach ( $pending as $schema ) {
self::create_or_upgrade_table(
$schema['type'],
$schema['group'],
$schema['fields']
);
}
TMDO_Entity_Registry::clear_pending_schemas();
}
/**
* 建立或升級資料表
*/
public static function create_or_upgrade_table(
string $entity_type,
string $group_name,
array $field_definitions
): bool {
global $wpdb;
// Schema 版本比對:若未變動則跳過
$schema_hash = self::calculate_schema_hash( $field_definitions );
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
if ( $stored_hash === $schema_hash ) {
return true;
}
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return false;
}
$table = self::get_table_name( $entity_type, $group_name );
$charset = $wpdb->get_charset_collate();
$id_col = $adapter->get_entity_id_column();
// 建立基礎欄位(每張表都有)
$sql_columns = array(
'`id` BIGINT(20) NOT NULL AUTO_INCREMENT',
"`{$id_col}` BIGINT(20) NOT NULL",
'`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
'`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
);
$sql_indexes = array(
'PRIMARY KEY (`id`)',
"UNIQUE KEY `uk_entity` (`{$id_col}`)",
'KEY `idx_created` (`created_at`)',
);
// 處理動態欄位
foreach ( $field_definitions as $field ) {
$col_name = self::sanitize_column_name( $field['key'] );
$col_type = self::$type_map[ $field['type'] ] ?? 'VARCHAR(255)';
$null_clause = ! empty( $field['required'] ) ? 'NOT NULL' : 'DEFAULT NULL';
$default = self::build_default_clause( $field );
// 組合欄位 DDL
$col_ddl = "`{$col_name}` {$col_type} {$null_clause}";
if ( $default !== '' ) {
$col_ddl .= " {$default}";
}
$sql_columns[] = $col_ddl;
// 索引策略
if ( ! empty( $field['unique'] ) ) {
$sql_indexes[] = "UNIQUE KEY `uk_{$col_name}` (`{$col_name}`)";
} elseif ( ! empty( $field['searchable'] ) ) {
// 不同型別決定索引長度
if ( in_array( $field['type'], array( 'text', 'textarea' ), true ) ) {
// 文字欄位使用前綴索引避免過長
$sql_indexes[] = "KEY `idx_{$col_name}` (`{$col_name}`(100))";
} else {
$sql_indexes[] = "KEY `idx_{$col_name}` (`{$col_name}`)";
}
}
if ( ! empty( $field['fulltext'] ) && in_array( $field['type'], array( 'text', 'textarea' ), true ) ) {
$sql_indexes[] = "FULLTEXT KEY `ft_{$col_name}` (`{$col_name}`)";
}
}
$columns_sql = implode( ",\n ", $sql_columns );
$indexes_sql = implode( ",\n ", $sql_indexes );
$sql = "CREATE TABLE `{$table}` (\n {$columns_sql},\n {$indexes_sql}\n) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// dbDelta 自動處理建表/ALTER TABLE
$dbdelta_result = dbDelta( $sql );
// 記錄 Schema 版本與定義
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
/**
* Action: 表建立/升級完成
*/
do_action( 'wpdo_schema_updated', $entity_type, $group_name, $table, $dbdelta_result );
return true;
}
/**
* 計算 Schema Hash(用於偵測欄位變動)
*/
public static function calculate_schema_hash( array $fields ): string {
// 正規化:僅保留影響 Schema 的屬性
$normalized = array_map(
function ( $f ) {
return array(
'key' => $f['key'] ?? '',
'type' => $f['type'] ?? '',
'required' => ! empty( $f['required'] ),
'default' => $f['default'] ?? null,
'searchable' => ! empty( $f['searchable'] ),
'fulltext' => ! empty( $f['fulltext'] ),
'unique' => ! empty( $f['unique'] ),
);
},
$fields
);
// 依 key 排序以確保 hash 穩定
usort( $normalized, fn( $a, $b ) => strcmp( $a['key'], $b['key'] ) );
$json = wp_json_encode( $normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
return hash( 'sha256', $json );
}
/**
* 欄位名稱清理(防止 SQL 注入)
*/
public static function sanitize_column_name( string $key ): string {
// 移除所有非 alphanumeric/底線
$clean = preg_replace( '/[^a-zA-Z0-9_]/', '', $key );
// 若以數字開頭,前綴 f_
if ( $clean !== '' && preg_match( '/^\d/', $clean ) ) {
$clean = 'f_' . $clean;
}
// MySQL 欄位名長度限制 64 字元
return substr( $clean, 0, 60 );
}
/**
* 建立 DEFAULT 子句
*/
private static function build_default_clause( array $field ): string {
if ( ! isset( $field['default'] ) || $field['default'] === null ) {
return '';
}
$default = $field['default'];
switch ( $field['type'] ) {
case 'integer':
case 'boolean':
return 'DEFAULT ' . (int) $default;
case 'decimal':
return 'DEFAULT ' . (float) $default;
case 'date':
case 'datetime':
case 'timestamp':
if ( strtoupper( (string) $default ) === 'CURRENT_TIMESTAMP' ) {
return 'DEFAULT CURRENT_TIMESTAMP';
}
return "DEFAULT '" . esc_sql( (string) $default ) . "'";
case 'json':
case 'text':
case 'textarea':
case 'enum':
default:
return "DEFAULT '" . esc_sql( (string) $default ) . "'";
}
}
/**
* 儲存 Schema metadata 到 wpdo_registry_meta 表
*/
private static function store_schema_metadata(
string $entity_type,
string $group_name,
string $schema_hash,
array $field_definitions
): void {
global $wpdb;
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
$wpdb->replace(
$table,
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
'schema_hash' => $schema_hash,
'field_definitions' => wp_json_encode( $field_definitions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
),
array( '%s', '%s', '%s', '%s' )
);
}
/**
* 取得已儲存的 Schema hash
*/
public static function get_stored_schema_hash( string $entity_type, string $group_name ): string {
global $wpdb;
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
$hash = $wpdb->get_var(
$wpdb->prepare(
"SELECT schema_hash FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
)
);
return $hash ?: '';
}
/**
* 檢查表是否存在
*/
public static function table_exists( string $table_name ): bool {
global $wpdb;
$result = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
return $result === $table_name;
}
/**
* 丟棄表(謹慎使用,僅用於解除安裝)
*/
public static function drop_table( string $entity_type, string $group_name ): bool {
global $wpdb;
$table = self::get_table_name( $entity_type, $group_name );
return (bool) $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
/**
* 取得所有 UAE 建立的表清單
*/
public static function list_all_uae_tables(): array {
global $wpdb;
$prefix = $wpdb->prefix . TMDO_TABLE_PREFIX;
$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $prefix . '%' ) );
return $tables ?: array();
}
/**
* 取得資料表統計資訊(大小、列數)
*/
public static function get_table_stats( string $table_name ): array {
global $wpdb;
$info = $wpdb->get_row(
$wpdb->prepare(
'SELECT TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s',
DB_NAME,
$table_name
),
ARRAY_A
);
if ( ! $info ) {
return array(
'rows' => 0,
'size_mb' => 0,
'index_mb' => 0,
);
}
return array(
'rows' => (int) $info['TABLE_ROWS'],
'size_mb' => round( $info['DATA_LENGTH'] / 1024 / 1024, 2 ),
'index_mb' => round( $info['INDEX_LENGTH'] / 1024 / 1024, 2 ),
);
}
}
@@ -0,0 +1,407 @@
<?php
/**
* TMDO_Shadow_Diff_Logger - Shadow-read 差異偵測與記錄
*
* 當 bridge 為 shadow_read 模式時,每次讀取都會:
* 1. 從 UAE flat table 取值
* 2. 從 wp_*meta 原生表取值
* 3. 比對
* 4. 若不一致 → 記錄到 wp_wpdo_uni_shadow_diffs 表
*
* 這個表是**持久化**的(不像 AEAV 原本用 transient — 會被 flush 掉),
* 因為驗證期可能跨多日,不能丟失。
*
* 寫入有 rate limit(同 entity+key 在 5 分鐘內只記一筆),防止熱門欄位寫爆。
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Shadow_Diff_Logger {
/** 保留最近 N 筆 diffs(超過就 rolling delete)。防止表爆炸。 */
public const MAX_ROWS = 5000;
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
public const RATELIMIT_WINDOW = 300;
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . 'wpdo_shadow_diffs';
}
/**
* 比對並記錄差異
*
* @param string $entity_type
* @param int $entity_id
* @param string $meta_key
* @param mixed $wpdo_value 從 UAE flat table 取得的值(已型別轉換)
* @param array $field_def field definition(含 type)
*/
public static function compare_and_log(
string $entity_type,
int $entity_id,
string $meta_key,
$wpdo_value,
array $field_def
): void {
// 直接從 native meta 表取(繞過 UAE filter 避免無限迴圈)
$eav_raw = self::get_eav_value_raw( $entity_type, $entity_id, $meta_key );
// 兩邊都無 → 一致
if ( $eav_raw === null && ( $wpdo_value === null || $wpdo_value === '' ) ) {
return;
}
$type = $field_def['type'] ?? 'text';
$eav_val = self::cast_eav( $eav_raw, $type );
// 比對
if ( self::values_equal( $eav_val, $wpdo_value, $type ) ) {
return; // 一致
}
// 不一致 → 檢查 rate limit
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) {
return;
}
// 寫入 diff log
self::record(
$entity_type,
$entity_id,
$meta_key,
self::stringify_for_log( $eav_val ),
self::stringify_for_log( $wpdo_value ),
$type
);
// Trim 舊 rows
self::maybe_trim();
}
/**
* 從 wp_*meta 直接取值(繞過 UAE filter)
*/
private static function get_eav_value_raw( string $entity_type, int $entity_id, string $meta_key ): ?string {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return null;
}
$table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
// 直接 SQL 繞過 get_metadata 系列 filter
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s LIMIT 1",
$entity_id,
$meta_key
)
);
return $value === null ? null : (string) $value;
}
/**
* 把 EAV 原始字串轉為該型別的值(用來比對)
*/
private static function cast_eav( ?string $raw, string $type ) {
if ( $raw === null ) {
return null;
}
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
// Mirrors WP's maybe_unserialize but with allowed_classes=false.
$raw = TMDO_Safe_Unserialize::run( $raw );
switch ( $type ) {
case 'integer':
return is_numeric( $raw ) ? (int) $raw : null;
case 'decimal':
return is_numeric( $raw ) ? (float) $raw : null;
case 'boolean':
return (bool) $raw;
case 'json':
if ( is_array( $raw ) || is_object( $raw ) ) {
return $raw;
}
$decoded = json_decode( (string) $raw, true );
return $decoded !== null ? $decoded : $raw;
default:
return $raw;
}
}
/**
* 兩值是否一致(依型別做合適比對)
*/
private static function values_equal( $a, $b, string $type ): bool {
if ( $a === $b ) {
return true;
}
// JSON / array 比對:用 canonical JSON
if ( $type === 'json' || is_array( $a ) || is_array( $b ) ) {
return wp_json_encode( $a ) === wp_json_encode( $b );
}
// Numeric:比數值
if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) {
return is_numeric( $a ) && is_numeric( $b )
? (float) $a === (float) $b
: $a === $b;
}
// Boolean:寬鬆
if ( $type === 'boolean' ) {
return (bool) $a === (bool) $b;
}
// Default:鬆散字串比較(EAV 字串 vs casted)
return (string) $a === (string) $b;
}
/**
* Rate limit 判斷(同 entity+key 在 300 秒內只記一次)
*/
private static function is_rate_limited( string $entity_type, int $entity_id, string $meta_key ): bool {
$key = 'wpdo_sd_rl_' . md5( "{$entity_type}:{$entity_id}:{$meta_key}" );
if ( get_transient( $key ) !== false ) {
return true;
}
set_transient( $key, 1, self::RATELIMIT_WINDOW );
return false;
}
/**
* 寫入 diff row
*
* v2.1.6 fix: aligned to actual `wpdo_shadow_diffs` schema installed by
* TMDO_Installer. The columns are: ts / entity_type / entity_id / meta_key /
* postmeta_value / zone_value / diff_hash. Logger code previously assumed
* eav_value / wpdo_value / created_at / field_type — schema-vs-code drift
* that silently no-op'd every diff INSERT (wpdb returns 0, no exception).
*
* `field_type` is consumed only as input to diff_hash so the same
* (entity_type, entity_id, meta_key) tuple records distinct rows when the
* field's interpreted type changes (rare; mostly a defence-in-depth bucket).
*/
private static function record(
string $entity_type,
int $entity_id,
string $meta_key,
string $eav_value,
string $wpdo_value,
string $field_type
): void {
global $wpdb;
$table = self::table_name();
$diff_hash = sha1( $entity_type . '|' . $meta_key . '|' . $field_type . '|' . $eav_value . '|' . $wpdo_value );
$wpdb->insert(
$table,
array(
'ts' => current_time( 'mysql', true ),
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'meta_key' => $meta_key,
'postmeta_value' => $eav_value,
'zone_value' => $wpdo_value,
'diff_hash' => $diff_hash,
),
array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' )
);
TMDO_Logger::warning(
'shadow_read_diff',
array(
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'meta_key' => $meta_key,
)
);
}
/**
* 轉為 log 字串
*/
private static function stringify_for_log( $value ): string {
if ( is_scalar( $value ) || $value === null ) {
return (string) $value;
}
return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
}
/**
* 超過 MAX_ROWS 時 trim 最舊的(chance 1% 才執行,不用每次都跑)
*/
private static function maybe_trim(): void {
if ( wp_rand( 1, 100 ) !== 1 ) {
return;
}
global $wpdb;
$table = self::table_name();
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
if ( $count <= self::MAX_ROWS ) {
return;
}
$to_delete = $count - self::MAX_ROWS;
$wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d",
$to_delete
)
);
}
// ─────────────────────────────────────────────────────────
// Read API (for admin UI + CLI)
// ─────────────────────────────────────────────────────────
/**
* 取得最近 N 筆 diffs
*/
public static function recent( int $limit = 100, ?string $entity_type = null ): array {
global $wpdb;
$table = self::table_name();
if ( $entity_type ) {
$sql = $wpdb->prepare(
"SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d",
$entity_type,
$limit
);
} else {
$sql = $wpdb->prepare(
"SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d",
$limit
);
}
$rows = $wpdb->get_results( $sql, ARRAY_A );
return is_array( $rows ) ? $rows : array();
}
/**
* 統計:按 entity / meta_key 聚合
*/
public static function stats_by_key( int $limit = 20 ): array {
global $wpdb;
$table = self::table_name();
// v2.1.6: column is `ts`, not `created_at` (matches installer schema).
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT entity_type, meta_key, COUNT(*) AS c, MAX(ts) AS last_seen
FROM `{$table}`
GROUP BY entity_type, meta_key
ORDER BY c DESC LIMIT %d",
$limit
),
ARRAY_A
);
return is_array( $rows ) ? $rows : array();
}
/**
* 全部 diff 總數
*/
public static function total_count(): int {
global $wpdb;
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' );
}
/**
* 按 entity type 統計
*/
public static function count_by_entity(): array {
global $wpdb;
$rows = $wpdb->get_results(
'SELECT entity_type, COUNT(*) AS c FROM `' . self::table_name() . '` GROUP BY entity_type',
ARRAY_A
);
$result = array();
foreach ( $rows ?: array() as $r ) {
$result[ $r['entity_type'] ] = (int) $r['c'];
}
return $result;
}
/**
* 清空所有 diffs(admin confirmed)
*/
public static function clear_all(): int {
global $wpdb;
$table = self::table_name();
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
$wpdb->query( "TRUNCATE TABLE `{$table}`" );
TMDO_Logger::info(
'shadow_diffs_cleared',
array(
'count' => $count,
'user_id' => get_current_user_id(),
)
);
return $count;
}
/**
* 清除特定 entity 的 diffs
*/
public static function clear_entity( string $entity_type ): int {
global $wpdb;
return (int) $wpdb->query(
$wpdb->prepare(
'DELETE FROM `' . self::table_name() . '` WHERE entity_type = %s',
$entity_type
)
);
}
// ─────────────────────────────────────────────────────────
// Schema
// ─────────────────────────────────────────────────────────
public static function install_table(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
entity_type VARCHAR(20) NOT NULL,
entity_id BIGINT UNSIGNED NOT NULL,
meta_key VARCHAR(255) NOT NULL,
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
eav_value LONGTEXT NULL,
wpdo_value LONGTEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_entity (entity_type, entity_id),
KEY idx_key (entity_type, meta_key(191)),
KEY idx_created (created_at)
) {$charset};";
dbDelta( $sql );
}
public static function drop_table(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() );
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
/**
* TMDO_Type_Caster - 型別轉換器
*
* 負責:
* - PHP → DB 序列化(寫入)
* - DB → PHP 反序列化(讀取)
* - wpdb format string 生成
* - 型別驗證與清理
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Type_Caster {
/**
* PHP → DB 轉換(寫入)
*/
public static function to_db( $value, array $field_def ) {
$type = $field_def['type'] ?? 'text';
// null 值處理
if ( $value === null ) {
if ( ! empty( $field_def['required'] ) ) {
return $field_def['default'] ?? '';
}
return null;
}
switch ( $type ) {
case 'integer':
return (int) $value;
case 'decimal':
return (float) $value;
case 'boolean':
return self::to_bool( $value ) ? 1 : 0;
case 'date':
return self::format_date( $value, 'Y-m-d' );
case 'datetime':
case 'timestamp':
return self::format_date( $value, 'Y-m-d H:i:s' );
case 'json':
if ( is_string( $value ) ) {
// 若已是 JSON 字串則直接儲存(驗證後)
$decoded = json_decode( $value, true );
if ( json_last_error() === JSON_ERROR_NONE ) {
return $value;
}
}
return wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
case 'enum':
$options = $field_def['options'] ?? array();
$str = (string) $value;
return ( ! empty( $options ) && in_array( $str, $options, true ) ) ? $str : ( $field_def['default'] ?? '' );
case 'binary':
return $value;
case 'text':
return (string) $value;
case 'textarea':
return (string) $value;
default:
// 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為)
return maybe_serialize( $value );
}
}
/**
* DB → PHP 轉換(讀取)
*/
public static function from_db( $value, array $field_def ) {
$type = $field_def['type'] ?? 'text';
if ( $value === null ) {
return null;
}
switch ( $type ) {
case 'integer':
return (int) $value;
case 'decimal':
return (float) $value;
case 'boolean':
return (bool) (int) $value;
case 'date':
case 'datetime':
case 'timestamp':
return (string) $value;
case 'json':
$decoded = json_decode( (string) $value, true );
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
case 'enum':
case 'text':
case 'textarea':
return (string) $value;
default:
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
return TMDO_Safe_Unserialize::run( $value );
}
}
/**
* wpdb format 字串(%s / %d / %f
*/
public static function get_wpdb_format( string $type ): string {
return match ( $type ) {
'integer', 'boolean' => '%d',
'decimal' => '%f',
default => '%s',
};
}
// ─────────────────────────────────────────────────────────
// 工具方法
// ─────────────────────────────────────────────────────────
private static function to_bool( $value ): bool {
if ( is_bool( $value ) ) {
return $value;
}
if ( is_numeric( $value ) ) {
return (int) $value !== 0;
}
$str = strtolower( trim( (string) $value ) );
return in_array( $str, array( 'true', 'yes', 'y', '1', 'on' ), true );
}
private static function format_date( $value, string $format ): ?string {
if ( $value === '' || $value === null ) {
return null;
}
// 接受 Unix timestamp
if ( is_numeric( $value ) ) {
return gmdate( $format, (int) $value );
}
$ts = strtotime( (string) $value );
if ( $ts === false ) {
return null;
}
return gmdate( $format, $ts );
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* TMDO_CSV_Writer — Lightweight CSV writer with UTF-8 BOM (v2.5.0 M14).
*
* Excel reads UTF-8 CSV correctly only when the file starts with EF BB BF
* BOM. This writer always emits BOM, escapes embedded `"` and wraps fields
* containing `,` / newline / `"`.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Builder API: build_csv_string( $headers, $rows ): string.
*/
class TMDO_CSV_Writer {
/** UTF-8 BOM bytes. */
public const BOM = "\xEF\xBB\xBF";
/**
* Build a complete CSV string with BOM + header + rows.
*
* @param array $headers Header column names.
* @param array $rows Each row is an associative array keyed by header.
* @return string CSV body ready for Content-Disposition: attachment.
*/
public static function build( array $headers, array $rows ): string {
$out = self::BOM;
$out .= self::row_to_csv( $headers );
foreach ( $rows as $row ) {
$line = array();
foreach ( $headers as $h ) {
$v = $row[ $h ] ?? '';
if ( is_array( $v ) || is_object( $v ) ) {
$v = wp_json_encode( $v );
}
$line[] = (string) $v;
}
$out .= self::row_to_csv( $line );
}
return $out;
}
/**
* Format one row of fields with proper escaping.
*
* @param array $fields Field values.
* @return string CSV row including trailing CRLF.
*/
private static function row_to_csv( array $fields ): string {
$escaped = array();
foreach ( $fields as $f ) {
$s = self::sanitize_cell( (string) $f );
$needs_quote = ( str_contains( $s, ',' ) || str_contains( $s, '"' ) || str_contains( $s, "\n" ) || str_contains( $s, "\r" ) );
if ( $needs_quote ) {
$s = '"' . str_replace( '"', '""', $s ) . '"';
}
$escaped[] = $s;
}
return implode( ',', $escaped ) . "\r\n";
}
/**
* Neutralise CSV formula-injection characters (=, +, -, @, TAB, CR).
*
* Excel/Sheets treat cells starting with these as formulas. Prefixing with
* a literal single-quote forces text interpretation without altering the
* visual output for normal users.
*
* @param string $s Raw cell value.
* @return string Safe cell value.
*/
private static function sanitize_cell( string $s ): string {
if ( '' !== $s && preg_match( '/^[=+\-@\t\r]/', $s ) ) {
return "'" . $s;
}
return $s;
}
}
@@ -0,0 +1,227 @@
<?php
/**
* TMDO_Demo_Entity_Counter — production proof-of-life for entity adapters.
*
* Task D follow-up to PR-5: demonstrates that the entity adapter framework
* actually works end-to-end (not just stubs). Implements a "counter" pattern
* shared across post / user / term / comment entities — a common gamification
* primitive (user points, listing views, comment helpful_count, term usage).
*
* Storage:
* wp_wpdo_demo_entity_counters (entity_type, entity_id, counter_key, counter_value, updated_at)
*
* Lifecycle:
* - Plugin or test invokes ::set( 'user', 42, 'points', 50 )
* - This writes to BOTH wp_usermeta (native, preserved) AND wpdo_demo table
* - Reads come from wpdo_demo when feature flag entity_demo_counter == 'cutover',
* otherwise fall through to native usermeta (transparent fallback)
*
* @package WP_Data_Optimizer
* @since 2.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.InlineComment.InvalidEndChar,Generic.Commenting.DocComment.MissingShort,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.PHP.YodaConditions,Generic.CodeAnalysis.EmptyStatement -- v2.0.0 partner integrations: pure registration helpers + intentional silent catches.
/**
* Demo entity counter — proves 4-entity adapter framework works end-to-end.
*/
final class TMDO_Demo_Entity_Counter {
/** Feature flag module name. */
public const MODULE = 'entity_demo_counter';
/** Custom table holding all counters. */
public const TABLE = 'wpdo_demo_entity_counters';
/**
* Idempotent install of the demo table.
*
* Called from TMDO_Installer::install_v2_tables() OR manually for demos.
*
* @return void
*/
public static function install_table(): void {
global $wpdb;
if ( ! function_exists( 'dbDelta' ) ) {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
}
$charset = $wpdb->get_charset_collate();
$table = $wpdb->prefix . self::TABLE;
$sql = "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
entity_type varchar(20) NOT NULL DEFAULT '',
entity_id bigint(20) unsigned NOT NULL DEFAULT 0,
counter_key varchar(100) NOT NULL DEFAULT '',
counter_value bigint(20) NOT NULL DEFAULT 0,
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY ui_entity_counter (entity_type, entity_id, counter_key),
KEY idx_lookup (entity_type, counter_key, counter_value),
KEY idx_entity (entity_type, entity_id)
) {$charset};";
dbDelta( $sql );
}
/**
* Drop the demo table — idempotent.
*
* @return void
*/
public static function drop_table(): void {
global $wpdb;
$table = $wpdb->prefix . self::TABLE;
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
/**
* Set a counter for any entity. Writes to BOTH native meta AND demo table
* when feature flag is in a write-active state. Otherwise writes native
* only (idle path — full backward compatibility).
*
* @param string $entity_type One of: post, user, term, comment.
* @param int $entity_id Entity ID.
* @param string $counter_key Counter slug (e.g. 'points', 'view_count').
* @param int $value New value.
* @return bool
*/
public static function set( string $entity_type, int $entity_id, string $counter_key, int $value ): bool {
if ( ! self::is_valid_entity( $entity_type ) ) {
return false;
}
// Always write the native meta first (durability anchor).
TMDO_API::set_entity( $entity_type, $entity_id, $counter_key, $value );
// Conditional dual-write to demo table.
if ( TMDO_Feature_Flags::is_write_active( self::MODULE ) ) {
self::write_to_table( $entity_type, $entity_id, $counter_key, $value );
}
return true;
}
/**
* Read a counter value. Source depends on feature flag state:
* - read_custom (cutover/cleanup/complete) → demo table
* - otherwise → native meta (fallback)
*
* @param string $entity_type One of: post, user, term, comment.
* @param int $entity_id Entity ID.
* @param string $counter_key Counter slug.
* @return int
*/
public static function get( string $entity_type, int $entity_id, string $counter_key ): int {
if ( ! self::is_valid_entity( $entity_type ) ) {
return 0;
}
if ( TMDO_Feature_Flags::is_read_custom( self::MODULE ) ) {
$row = self::read_from_table( $entity_type, $entity_id, $counter_key );
if ( null !== $row ) {
return (int) $row;
}
// Fallback to native if zone row missing — graceful degradation.
}
return (int) TMDO_API::get_entity( $entity_type, $entity_id, $counter_key );
}
/**
* Top-N entities by counter value — the killer query that postmeta CANNOT
* do efficiently (requires full scan + filesort). Demonstrates the value of
* the entity adapter pattern.
*
* @param string $entity_type One of: post, user, term, comment.
* @param string $counter_key Counter slug.
* @param int $limit Max rows.
* @return array<int, array{entity_id:int, counter_value:int}>
*/
public static function top_n( string $entity_type, string $counter_key, int $limit = 10 ): array {
global $wpdb;
$table = $wpdb->prefix . self::TABLE;
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from constant.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT entity_id, counter_value FROM `{$table}` WHERE entity_type = %s AND counter_key = %s ORDER BY counter_value DESC LIMIT %d",
$entity_type,
$counter_key,
$limit
),
ARRAY_A
);
return array_map(
static fn( array $r ) => array(
'entity_id' => (int) $r['entity_id'],
'counter_value' => (int) $r['counter_value'],
),
$rows ?: array()
);
}
// ── Internals ──────────────────────────────────────────────────────────
/**
* @param string $entity_type Entity type.
*/
private static function is_valid_entity( string $entity_type ): bool {
return in_array( $entity_type, array( 'post', 'user', 'term', 'comment' ), true );
}
/**
* Write a single counter value via UPSERT (1 round-trip).
*
* @param string $entity_type One of: post, user, term, comment.
* @param int $entity_id Entity ID.
* @param string $counter_key Counter slug.
* @param int $value New value.
* @return void
*/
private static function write_to_table( string $entity_type, int $entity_id, string $counter_key, int $value ): void {
global $wpdb;
TMDO_DB::upsert(
$wpdb->prefix . self::TABLE,
array(
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'counter_key' => $counter_key,
'counter_value' => $value,
'updated_at' => current_time( 'mysql' ),
),
array( 'counter_value', 'updated_at' ),
array( 'entity_type', 'entity_id', 'counter_key' ),
array( '%s', '%d', '%s', '%d', '%s' )
);
}
/**
* Read a single counter value from the demo table.
*
* @param string $entity_type Entity type.
* @param int $entity_id Entity ID.
* @param string $counter_key Counter slug.
* @return int|null Null when row absent.
*/
private static function read_from_table( string $entity_type, int $entity_id, string $counter_key ): ?int {
global $wpdb;
$table = $wpdb->prefix . self::TABLE;
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from constant.
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$table}` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s LIMIT 1",
$entity_type,
$entity_id,
$counter_key
)
);
return null === $value ? null : (int) $value;
}
}
@@ -0,0 +1,498 @@
<?php
/**
* Member entity field registration for WP Data Optimizer.
*
* Registers four user entity groups designed for 千萬 (10M) member scale.
* All groups use TMDO_Entity_Registry → flat tables instead of wp_usermeta EAV.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers four user entity groups for membership, activity, profile, and SSO.
*
* Groups → flat tables:
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
* sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
*
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
*/
final class TMDO_Member_Fields {
/**
* Hook into WPDO entity field registration.
*
* @return void
*/
public static function register(): void {
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
}
/**
* Register all user entity groups.
*
* V2.7.0: Adds four legacy-key groups (core_profile, social, commerce, hp_user)
* to absorb wp_usermeta rows that previously bypassed the entity bridge —
* driving the wp_users:wp_usermeta ratio from 1:5.5 toward 1:2.5.
*
* V2.8.4: Adds admin_prefs group — the 7 default keys WP core writes for
* EVERY new user via wp_insert_user (rich_editing, syntax_highlighting,
* comment_shortcuts, admin_color, use_ssl, show_admin_bar_front,
* dismissed_wp_pointers). Without this group, fresh users always show
* ratio 1:9 regardless of any other optimization.
*
* @return void
*/
public static function register_entity_fields(): void {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
self::register_membership_group();
self::register_activity_group();
self::register_profile_group();
self::register_sso_group();
self::register_core_profile_group();
self::register_social_group();
self::register_commerce_group();
self::register_hp_user_group();
self::register_admin_prefs_group();
}
// ── Group definitions ────────────────────────────────────────────────────
/**
* Tier level, points balance, expiry — primary search target.
*
* Extra composite indexes (idx_level_expires, idx_expires_level, idx_points_bal)
* are applied by TMDO_Installer::install_member_indexes() after table creation.
*
* @return void
*/
private static function register_membership_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'membership',
array(
array(
'key' => 'membership_level',
'type' => 'enum',
'searchable' => true,
'options' => array( 'bronze', 'silver', 'gold', 'platinum', 'custom' ),
'label' => 'Membership tier level',
),
array(
'key' => 'points_balance',
'type' => 'integer',
'searchable' => true,
'default' => 0,
'label' => 'Current points balance',
),
array(
'key' => 'membership_expires_at',
'type' => 'datetime',
'searchable' => true,
'label' => 'Membership expiry datetime',
),
array(
'key' => 'membership_activated_at',
'type' => 'datetime',
'label' => 'Membership activation datetime',
),
array(
'key' => 'tier_source',
'type' => 'text',
'label' => 'Tier source: manual / wc_subscription / admin_set',
),
array(
'key' => 'custom_tier_label',
'type' => 'text',
'label' => 'Custom tier display label (when level=custom)',
),
)
);
}
/**
* Login counters and last-active timestamps — separated to avoid lock
* contention with membership reads during high-traffic periods.
*
* @return void
*/
private static function register_activity_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'activity',
array(
array(
'key' => 'login_count',
'type' => 'integer',
'searchable' => true,
'default' => 0,
'label' => 'Cumulative login count',
),
array(
'key' => 'last_active_at',
'type' => 'datetime',
'searchable' => true,
'label' => 'Last activity datetime',
),
array(
'key' => 'last_login_at',
'type' => 'datetime',
'label' => 'Last login datetime',
),
array(
'key' => 'last_order_at',
'type' => 'datetime',
'label' => 'Last order datetime',
),
array(
'key' => 'session_count',
'type' => 'integer',
'default' => 0,
'label' => 'Total session count',
),
array(
'key' => 'account_flags',
'type' => 'integer',
'default' => 0,
'label' => 'Bitmask: 1=email_verified 2=phone_verified 4=kyc 8=social_signup',
),
)
);
}
/**
* Display fields, specialties, avatar — written infrequently.
*
* @return void
*/
private static function register_profile_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'profile',
array(
array(
'key' => 'specialties',
'type' => 'json',
'label' => 'Professional specialties (JSON array)',
),
array(
'key' => 'bio_url',
'type' => 'text',
'label' => 'Bio or portfolio URL',
),
array(
'key' => 'avatar_url',
'type' => 'text',
'label' => 'Avatar image URL',
),
array(
'key' => 'display_name_custom',
'type' => 'text',
'searchable' => true,
'fulltext' => true,
'label' => 'Custom display name (fulltext searchable)',
),
array(
'key' => 'locale',
'type' => 'text',
'label' => 'User locale (e.g. zh_TW)',
),
)
);
}
/**
* Hub/Spoke SSO token cache — replaces _tmso_* usermeta.
*
* Silent refresh fires every 15 min per user; at 10M users this is a
* high-frequency EAV hot-spot. A flat table + object cache hit cuts DB
* load 1050× versus a wp_usermeta EAV scan per refresh.
*
* last_id_token is NOT stored in plaintext (privacy + volume). Only the
* SHA-256 hash is kept for SLO token comparison.
*
* @return void
*/
private static function register_sso_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'sso',
array(
array(
'key' => 'hub_global_user_id',
'type' => 'text',
'searchable' => true,
'label' => 'Hub global user UUID (2mso_user_mapping.global_user_id bridge key)',
),
array(
'key' => 'picture_url',
'type' => 'text',
'label' => 'Social / SSO profile picture URL',
),
array(
'key' => 'last_id_token_hash',
'type' => 'text',
'label' => 'SHA-256(last_id_token) for SLO comparison — no plaintext stored',
),
array(
'key' => 'refresh_token_enc',
'type' => 'textarea',
'label' => 'Encrypted refresh token (TMSO_Crypto — key-versioned enc_vN:ciphertext)',
),
array(
'key' => 'token_expires_at',
'type' => 'datetime',
'searchable' => true,
'label' => 'SSO token expiry (set to past to force re-auth on next request)',
),
array(
'key' => 'sso_last_login_at',
'type' => 'datetime',
'label' => 'Last SSO-initiated login datetime',
),
array(
'key' => 'sso_login_count',
'type' => 'integer',
'default' => 0,
'label' => 'SSO login count',
),
)
);
}
/**
* WP core user fields stored as multi-row EAV in wp_usermeta.
*
* Absorbing these here lets the Hook Bus short-circuit get_user_meta() /
* update_user_meta() for the keys WP itself uses for display_name resolution
* and the WP profile UI.
*
* @return void
*/
private static function register_core_profile_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'core_profile',
array(
array(
'key' => 'nickname',
'type' => 'text',
'searchable' => true,
'label' => 'WP nickname',
),
array(
'key' => 'first_name',
'type' => 'text',
'searchable' => true,
'label' => 'WP first name',
),
array(
'key' => 'last_name',
'type' => 'text',
'searchable' => true,
'label' => 'WP last name',
),
array(
'key' => 'description',
'type' => 'textarea',
'label' => 'WP user bio',
),
)
);
}
/**
* Social profile URLs (HivePress vendor-profile + WP user-contact-methods).
*
* 15 keys × 8 vendor users ≈ 120 EAV rows in this dataset.
*
* @return void
*/
private static function register_social_group(): void {
$social_keys = array(
'facebook',
'twitter',
'instagram',
'youtube',
'tiktok',
'linkedin',
'vimeo',
'vkontakte',
'mastodon',
'medium',
'wordpress',
'odnoklassniki',
'pinterest',
'dribbble',
'github',
);
// Social URLs typed as `textarea` (TEXT) — VARCHAR(255) silently truncates
// long share URLs (utm params, deep paths) under WP's default non-strict
// SQL mode. TEXT (64KB) covers all realistic URL lengths.
$fields = array();
foreach ( $social_keys as $key ) {
$fields[] = array(
'key' => $key,
'type' => 'textarea',
'label' => ucfirst( $key ) . ' profile URL',
);
}
TMDO_Entity_Registry::register_group( 'user', 'social', $fields );
}
/**
* WooCommerce billing & shipping address fields.
*
* Billing_email is searchable for guest-checkout customer lookups.
*
* @return void
*/
private static function register_commerce_group(): void {
$address_keys = array(
'first_name',
'last_name',
'company',
'address_1',
'address_2',
'city',
'state',
'postcode',
'country',
);
$fields = array();
foreach ( $address_keys as $key ) {
$fields[] = array(
'key' => 'billing_' . $key,
'type' => 'text',
'label' => 'WC billing ' . str_replace( '_', ' ', $key ),
);
$fields[] = array(
'key' => 'shipping_' . $key,
'type' => 'text',
'label' => 'WC shipping ' . str_replace( '_', ' ', $key ),
);
}
// Email + phone are billing-only.
$fields[] = array(
'key' => 'billing_email',
'type' => 'text',
'searchable' => true,
'label' => 'WC billing email',
);
$fields[] = array(
'key' => 'billing_phone',
'type' => 'text',
'label' => 'WC billing phone',
);
$fields[] = array(
'key' => 'shipping_phone',
'type' => 'text',
'label' => 'WC shipping phone',
);
TMDO_Entity_Registry::register_group( 'user', 'commerce', $fields );
}
/**
* HivePress per-user fields (favorites + avatar attachment).
*
* Hp_favorited_listings is a serialized array of post IDs in legacy storage;
* the migration engine safe_unserialize()s it (allowed_classes=false to block
* PHP-object injection) then the json type encodes back to a JSON array column.
*
* @return void
*/
private static function register_hp_user_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'hp_user',
array(
array(
'key' => 'hp_favorited_listings',
'type' => 'json',
'label' => 'HivePress favorited listing IDs (array)',
),
array(
'key' => 'hp_image',
'type' => 'text',
'label' => 'HivePress avatar attachment ID',
),
)
);
}
/**
* WP-core admin pref defaults — written by `wp_insert_user()` for EVERY
* new user regardless of role. Without registering these, a fresh user
* lands at ratio 1:9 (7 admin prefs + wp_capabilities + wp_user_level).
* Once registered, Hook Bus intercepts `update_user_meta()` calls from
* `wp_insert_user()` and routes them to `wp_wpdo_user_admin_prefs` flat
* table → fresh user ratio drops to 1:2.
*
* Values are stored as text because WP itself stores 'true'/'false'
* strings (not booleans), 'fresh' / 'classic' (admin_color enum strings),
* and integer-as-string for `use_ssl`. Preserving WP's textual storage
* shape ensures downstream code (e.g. theme switchers reading
* `admin_color`) sees the exact same value as before.
*
* @since 2.8.4
* @return void
*/
private static function register_admin_prefs_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'admin_prefs',
array(
array(
'key' => 'rich_editing',
'type' => 'text',
'label' => 'Visual editor enabled (true/false string)',
),
array(
'key' => 'syntax_highlighting',
'type' => 'text',
'label' => 'Code editor syntax highlighting (true/false string)',
),
array(
'key' => 'comment_shortcuts',
'type' => 'text',
'label' => 'Comment moderation keyboard shortcuts (true/false string)',
),
array(
'key' => 'admin_color',
'type' => 'text',
'label' => 'Admin colour scheme (fresh/classic/etc)',
),
array(
'key' => 'use_ssl',
'type' => 'text',
'label' => 'Force SSL on admin (0/1 as string)',
),
array(
'key' => 'show_admin_bar_front',
'type' => 'text',
'label' => 'Show admin bar on front-end (true/false string)',
),
array(
'key' => 'dismissed_wp_pointers',
'type' => 'textarea',
'label' => 'Comma-separated dismissed pointer IDs',
),
)
);
}
}
@@ -0,0 +1,256 @@
<?php
/**
* Atomic points ledger manager for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Manages member points with atomic DB transactions.
*
* All balance mutations go through _transact(), which wraps
* SELECT … FOR UPDATE + UPDATE membership + INSERT ledger inside a single
* InnoDB transaction. This serialises concurrent debits and prevents the
* classic double-spend race condition (two requests each read balance=100,
* each deduct 60, each write balance=40).
*
* Table layout:
* wp_wpdo_user_membership.points_balance — current snapshot balance
* wp_wpdo_user_points_ledger — append-only journal
*/
final class TMDO_Points_Manager {
/**
* Credit points to a user (positive delta).
*
* @param int $user_id WordPress user ID.
* @param int $delta Points to add (must be > 0).
* @param string $reason Short reason code (≤60 chars).
* @param int $ref_id Optional reference ID (order_id, post_id, …).
* @param string $ref_type Optional reference type ('order', 'post', 'manual', …).
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
*/
public static function credit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '' ): array {
if ( $delta <= 0 ) {
return array(
'ok' => false,
'error' => 'credit delta must be positive',
);
}
return self::transact( $user_id, $delta, $reason, $ref_id, $ref_type, false );
}
/**
* Debit points from a user (negative delta applied internally).
*
* @param int $user_id WordPress user ID.
* @param int $delta Points to deduct (positive number; stored as negative).
* @param string $reason Short reason code (≤60 chars).
* @param int $ref_id Optional reference ID.
* @param string $ref_type Optional reference type.
* @param bool $allow_overdraft When true, debit proceeds even if balance < delta.
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
*/
public static function debit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '', bool $allow_overdraft = false ): array {
if ( $delta <= 0 ) {
return array(
'ok' => false,
'error' => 'debit delta must be positive',
);
}
return self::transact( $user_id, -$delta, $reason, $ref_id, $ref_type, $allow_overdraft );
}
/**
* Return current points balance for a user.
*
* Reads directly from the flat table, bypassing usermeta EAV.
*
* @param int $user_id WordPress user ID.
* @return int Balance (0 when user has no membership row).
*/
public static function get_balance( int $user_id ): int {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_user_membership';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$balance = $wpdb->get_var(
$wpdb->prepare(
"SELECT points_balance FROM `{$table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id
)
);
return (int) ( $balance ?? 0 );
}
/**
* Retrieve ledger entries for a user, newest-first.
*
* The idx_user_created covering index makes this O(log N) regardless of total rows.
*
* @param int $user_id WordPress user ID.
* @param int $limit Max rows to return (default 20).
* @param int $offset Row offset for pagination (default 0).
* @return array<int, array{id:int, delta:int, balance_after:int, reason:string, ref_id:?int, ref_type:?string, created_at:string}>
*/
public static function get_ledger( int $user_id, int $limit = 20, int $offset = 0 ): array {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_user_points_ledger';
$limit = max( 1, min( 500, $limit ) );
$offset = max( 0, $offset );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, delta, balance_after, reason, ref_id, ref_type, created_at FROM `{$table}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id,
$limit,
$offset
),
ARRAY_A
);
return $rows ?: array();
}
// ── Internal ─────────────────────────────────────────────────────────────
/**
* Core atomic transaction: SELECT … FOR UPDATE → validate → UPDATE balance → INSERT ledger.
*
* All balance mutations (credit and debit) route through this single method.
* TMDO_DB::begin() must be called before SELECT … FOR UPDATE; otherwise
* InnoDB ignores the lock hint and the serialisation guarantee is lost.
*
* @param int $user_id WordPress user ID.
* @param int $delta Signed delta (positive = credit, negative = debit).
* @param string $reason Reason code stored in ledger.
* @param int $ref_id Reference ID (0 = none).
* @param string $ref_type Reference type ('' = none).
* @param bool $allow_overdraft Skip balance-floor check when true.
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
*/
private static function transact( int $user_id, int $delta, string $reason, int $ref_id, string $ref_type, bool $allow_overdraft ): array {
global $wpdb;
$mem_table = $wpdb->prefix . 'wpdo_user_membership';
$ledger_table = $wpdb->prefix . 'wpdo_user_points_ledger';
// Truncate reason to column width to avoid silent DB truncation.
$reason = substr( $reason, 0, 60 );
$ref_type = substr( $ref_type, 0, 30 );
TMDO_DB::begin();
try {
// Lock the membership row for this user so concurrent writes wait.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$current_balance = $wpdb->get_var(
$wpdb->prepare(
"SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id
)
);
$current_balance = (int) ( $current_balance ?? 0 );
$new_balance = $current_balance + $delta;
// Reject negative-result debits unless overdraft is explicitly allowed.
if ( ! $allow_overdraft && $new_balance < 0 ) {
TMDO_DB::rollback();
return array(
'ok' => false,
'error' => 'insufficient_balance',
);
}
// Upsert with relative increment — prevents concurrent first-credit race.
// SELECT FOR UPDATE does not lock a non-existent row, so two simultaneous
// first-credits both read balance=0. Using VALUES(points_balance) here means
// InnoDB serialises the two INSERTs: the loser hits ON DUPLICATE KEY and
// applies a relative +delta instead of overwriting with an absolute value.
$upserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare(
"INSERT INTO `{$mem_table}` (user_id, points_balance) VALUES (%d, %d) ON DUPLICATE KEY UPDATE points_balance = points_balance + VALUES(points_balance)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id,
$delta
)
);
if ( false === $upserted ) {
TMDO_DB::rollback();
TMDO_Logger::error( 'points_manager', 'transact', "Membership upsert failed for user {$user_id}: {$wpdb->last_error}" );
return array(
'ok' => false,
'error' => 'db_error',
);
}
// Re-read actual balance so ledger and return value are correct even when
// ON DUPLICATE KEY UPDATE resolved a concurrent race on the first upsert.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$new_balance = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id
)
);
// Append ledger row.
$ledger_data = array(
'user_id' => $user_id,
'delta' => $delta,
'balance_after' => $new_balance,
'reason' => $reason,
'created_at' => current_time( 'mysql' ),
);
$ledger_fmt = array( '%d', '%d', '%d', '%s', '%s' );
if ( $ref_id ) {
$ledger_data['ref_id'] = $ref_id;
$ledger_fmt[] = '%d';
}
if ( '' !== $ref_type ) {
$ledger_data['ref_type'] = $ref_type;
$ledger_fmt[] = '%s';
}
$inserted = $wpdb->insert( $ledger_table, $ledger_data, $ledger_fmt );
if ( false === $inserted ) {
TMDO_DB::rollback();
TMDO_Logger::error( 'points_manager', 'transact', "Ledger insert failed for user {$user_id}: {$wpdb->last_error}" );
return array(
'ok' => false,
'error' => 'db_error',
);
}
$ledger_id = (int) $wpdb->insert_id;
TMDO_DB::commit();
// Notify subscribers — match Hook Bus signature: (type, id, key, value, result, op, before).
do_action( 'wpdo_after_write', 'user', $user_id, 'points_balance', $new_balance, true, 'update', $current_balance );
return array(
'ok' => true,
'balance' => $new_balance,
'ledger_id' => $ledger_id,
);
} catch ( \Throwable $e ) {
TMDO_DB::rollback();
TMDO_Logger::error( 'points_manager', 'transact', $e->getMessage() );
return array(
'ok' => false,
'error' => 'exception',
);
}
}
}
@@ -0,0 +1,487 @@
<?php
/**
* Post entity field registration for WP Data Optimizer (v2.9.1).
*
* Registers seven post entity groups designed to absorb the bulk of
* wp_postmeta rows that previously bypassed any flat-table strategy.
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
*
* Groups → flat tables (post_type targets):
* wp_core → wp_wpdo_post_wp_core (cross post_type)
* attachment → wp_wpdo_post_attachment (attachment)
* wc_product → wp_wpdo_post_wc_product (product)
* hp_listing_core → wp_wpdo_post_hp_listing_core (hp_listing)
* hp_request_core → wp_wpdo_post_hp_request_core (hp_request)
* hp_vendor_core → wp_wpdo_post_hp_vendor_core (hp_vendor)
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
*
* Pattern mirrors TMDO_Member_Fields::register(). Called from
* TMDO_Core::run() before the wpdo_register_entity_fields action fires;
* Schema_Manager materializes the seven flat tables in init:1
* via process_pending_migrations() (idempotent via schema_hash compare).
*
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
* etc.) pass through to wp_postmeta unchanged — same fall-back behavior
* as user entity bridge.
*
* 🔒 v2.9.x frozen contract: this class must NEVER call register_group()
* with entity_type='user'. User entity registration is owned exclusively
* by TMDO_Member_Fields.
*
* @package WP_Data_Optimizer
* @since 2.9.1
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers seven post entity groups.
*/
final class TMDO_Post_Fields {
/**
* Hook into WPDO entity field registration.
*
* @return void
*/
public static function register(): void {
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
}
/**
* Register all post entity groups.
*
* @return void
*/
public static function register_entity_fields(): void {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
self::register_wp_core_group();
self::register_attachment_group();
self::register_wc_product_group();
self::register_hp_listing_core_group();
self::register_hp_request_core_group();
self::register_hp_vendor_core_group();
self::register_nav_menu_item_group();
}
// ── Group definitions ────────────────────────────────────────────────────
/**
* WP core post meta keys present across post_types.
*
* @return void
*/
private static function register_wp_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'wp_core',
array(
array(
'key' => '_thumbnail_id',
'type' => 'integer',
'searchable' => true,
'label' => 'Featured image attachment ID',
),
array(
'key' => '_wp_page_template',
'type' => 'text',
'label' => 'Page template slug',
),
array(
'key' => '_edit_last',
'type' => 'integer',
'searchable' => true,
'label' => 'Last editor user ID',
),
)
);
}
/**
* Attachment-specific meta keys (post_type=attachment).
*
* @return void
*/
private static function register_attachment_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'attachment',
array(
array(
'key' => '_wp_attached_file',
'type' => 'text',
'searchable' => true,
'label' => 'Relative path of the attached file',
),
array(
'key' => '_wp_attachment_metadata',
'type' => 'json',
'label' => 'Image dimensions / EXIF / sizes array',
),
array(
'key' => '_wp_attachment_image_alt',
'type' => 'textarea',
'label' => 'Alt text for accessibility',
),
array(
'key' => '_wp_attachment_caption',
'type' => 'textarea',
'label' => 'Attachment caption',
),
)
);
}
/**
* WooCommerce product core meta keys (post_type=product).
* 19 keys — covers ~94% of wp_postmeta rows for products on dev10.
*
* @return void
*/
private static function register_wc_product_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'wc_product',
array(
array(
'key' => '_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Effective price',
),
array(
'key' => '_regular_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Regular price',
),
array(
'key' => '_sale_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Sale price',
),
array(
'key' => '_stock',
'type' => 'integer',
'searchable' => true,
'label' => 'Stock quantity',
),
array(
'key' => '_stock_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'instock', 'outofstock', 'onbackorder' ),
'label' => 'Stock status',
),
array(
'key' => '_sku',
'type' => 'text',
'searchable' => true,
'label' => 'Product SKU',
),
array(
'key' => '_manage_stock',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Manage stock?',
),
array(
'key' => '_backorders',
'type' => 'enum',
'options' => array( 'yes', 'no', 'notify' ),
'label' => 'Allow backorders?',
),
array(
'key' => '_sold_individually',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Sold individually?',
),
array(
'key' => '_virtual',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Virtual product?',
),
array(
'key' => '_downloadable',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Downloadable?',
),
array(
'key' => '_tax_class',
'type' => 'text',
'label' => 'Tax class slug',
),
array(
'key' => '_tax_status',
'type' => 'enum',
'options' => array( 'taxable', 'shipping', 'none' ),
'label' => 'Tax status',
),
array(
'key' => '_download_limit',
'type' => 'integer',
'label' => 'Download limit',
),
array(
'key' => '_download_expiry',
'type' => 'integer',
'label' => 'Download expiry days',
),
array(
'key' => '_product_version',
'type' => 'text',
'label' => 'WC version product was created on',
),
array(
'key' => '_wc_average_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
array(
'key' => '_wc_review_count',
'type' => 'integer',
'label' => 'Review count',
),
array(
'key' => 'total_sales',
'type' => 'integer',
'searchable' => true,
'label' => 'Total sales count',
),
)
);
}
/**
* HivePress listing core meta keys (post_type=hp_listing).
* Aligns with the existing wpdo_hot_hp_listing flat table; v2.9.5 will
* copy-then-cutover the 230 dev10 rows to this group's table.
*
* @return void
*/
private static function register_hp_listing_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_listing_core',
array(
array(
'key' => 'hp_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Listing price',
),
array(
'key' => 'hp_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'publish', 'draft', 'pending', 'expired', 'private' ),
'label' => 'Listing status',
),
array(
'key' => 'hp_featured',
'type' => 'integer',
'searchable' => true,
'label' => 'Featured flag (0/1)',
),
array(
'key' => 'hp_verified',
'type' => 'integer',
'searchable' => true,
'label' => 'Verified flag (0/1)',
),
array(
'key' => 'hp_vendor',
'type' => 'integer',
'searchable' => true,
'label' => 'Vendor user ID',
),
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
array(
'key' => 'hp_featured_time',
'type' => 'integer',
'label' => 'Featured-until unix ts',
),
array(
'key' => 'hp_view_count',
'type' => 'integer',
'searchable' => true,
'label' => 'View counter',
),
array(
'key' => 'hp_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
array(
'key' => 'hp_rating_count',
'type' => 'integer',
'label' => 'Rating count',
),
array(
'key' => 'hp_hourly_rate',
'type' => 'decimal',
'label' => 'Hourly rate',
),
)
);
}
/**
* HivePress request core meta keys (post_type=hp_request).
*
* @return void
*/
private static function register_hp_request_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_request_core',
array(
array(
'key' => 'hp_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'publish', 'draft', 'pending', 'expired' ),
'label' => 'Request status',
),
array(
'key' => 'hp_user',
'type' => 'integer',
'searchable' => true,
'label' => 'Request author user ID',
),
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
array(
'key' => 'hp_budget',
'type' => 'decimal',
'searchable' => true,
'label' => 'Budget amount',
),
array(
'key' => 'hp_view_count',
'type' => 'integer',
'label' => 'View counter',
),
)
);
}
/**
* HivePress vendor core meta keys (post_type=hp_vendor).
*
* @return void
*/
private static function register_hp_vendor_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_vendor_core',
array(
array(
'key' => 'hp_user',
'type' => 'integer',
'searchable' => true,
'label' => 'Vendor user ID (linked WP user)',
),
array(
'key' => 'hp_verified',
'type' => 'integer',
'searchable' => true,
'label' => 'Verified flag (0/1)',
),
array(
'key' => 'hp_hourly_rate',
'type' => 'decimal',
'searchable' => true,
'label' => 'Hourly rate',
),
array(
'key' => 'hp_rating_count',
'type' => 'integer',
'label' => 'Rating count',
),
array(
'key' => 'hp_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
)
);
}
/**
* Nav menu item meta keys (post_type=nav_menu_item).
*
* @return void
*/
private static function register_nav_menu_item_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'nav_menu_item',
array(
array(
'key' => '_menu_item_type',
'type' => 'enum',
'options' => array( 'post_type', 'taxonomy', 'custom', 'post_type_archive' ),
'label' => 'Menu item linking type',
),
array(
'key' => '_menu_item_menu_item_parent',
'type' => 'integer',
'label' => 'Parent menu item ID',
),
array(
'key' => '_menu_item_object_id',
'type' => 'integer',
'searchable' => true,
'label' => 'Linked object ID',
),
array(
'key' => '_menu_item_object',
'type' => 'text',
'label' => 'Linked object slug (post_type/taxonomy)',
),
array(
'key' => '_menu_item_target',
'type' => 'text',
'label' => 'Link target attribute',
),
array(
'key' => '_menu_item_classes',
'type' => 'json',
'label' => 'CSS classes array',
),
array(
'key' => '_menu_item_xfn',
'type' => 'text',
'label' => 'XFN relationship',
),
array(
'key' => '_menu_item_url',
'type' => 'textarea',
'label' => 'Custom URL (for type=custom)',
),
)
);
}
}
@@ -0,0 +1,229 @@
<?php
/**
* TMDO_Term_Comment_Garbage_Filter — Block known-garbage writes to
* wp_termmeta / wp_commentmeta at the metadata filter layer (v2.12.1 Phase 1).
*
* Phase 0 (v2.12.0) provided cleanup CLI to delete historical garbage. This
* filter prevents the same garbage from accumulating again by intercepting
* writes via WordPress metadata filters (`add_term_metadata`, etc.) and
* silently dropping them — short-circuiting the database INSERT entirely.
*
* Targets (must align with TMDO_Termmeta_Cleaner / TMDO_Commentmeta_Cleaner):
*
* wp_termmeta + wp_commentmeta:
* - meta_key matching `_wxr_import_*` (WordPress importer residue —
* written once during WXR import, never read afterward)
* - meta_key matching `_2meet_demo_*` (project-specific demo markers,
* safe to drop and re-seed)
*
* wp_commentmeta only (orphan post-meta keys):
* - 8 hardcoded keys from TMDO_Commentmeta_Cleaner::ORPHAN_POST_META_KEYS
* — these are bugs / typos writing post-domain meta to comment table
*
* Read paths are NOT filtered. Existing rows in wp_*meta still resolve normally
* via standard WP metadata API; once cleanup CLI runs, reads naturally return
* empty. This minimizes risk of breaking any reader code that still expects
* the keys (none should, but defense in depth).
*
* Init: hooks registered on `init` priority 5 from TMDO_Core (after
* HivePress's plugins_loaded:5 boot, before main entity bridge filters at 10).
*
* Lessons applied from v2.11.7 — this class is added to
* TMDO_Hook_Bus_Bridge::COEXIST_WHITELIST so `wp wpdo conflict-scan` does not
* report a false positive when both Hook Bus and this filter run on the same
* `add_term_metadata` / `add_comment_metadata` hook.
*
* @package WP_Data_Optimizer
* @since 2.12.1
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Block known-garbage writes to wp_termmeta / wp_commentmeta.
*/
final class TMDO_Term_Comment_Garbage_Filter {
/** Option key for admin toggle. */
public const OPT_ENABLED = 'wpdo_term_comment_garbage_filter_enabled';
/** Telemetry option: count of garbage writes dropped (rolling 24h cumulative). */
public const OPT_DROPPED_COUNT = 'wpdo_term_comment_garbage_drops_24h';
/** Telemetry option: timestamp of the last reset of the 24h counter. */
public const OPT_DROPPED_RESET_AT = 'wpdo_term_comment_garbage_drops_reset_at';
/**
* Pattern prefixes that trigger a drop on writes to BOTH wp_termmeta and
* wp_commentmeta. Aligned with cleanup CLI `--target` buckets.
*
* @var string[]
*/
private const SHARED_DROP_PREFIXES = array(
'_wxr_import_',
'_2meet_demo_',
);
/**
* Exact meta_keys that are dropped only for wp_commentmeta writes
* (post-domain keys mistakenly written to comment table — always a bug).
*
* Must stay in sync with TMDO_Commentmeta_Cleaner::ORPHAN_POST_META_KEYS.
*
* @var string[]
*/
private const COMMENT_ONLY_ORPHAN_KEYS = array(
'_hp_price',
'_hp_status',
'_hp_featured',
'_hp_verified',
'_hp_view_count',
'_thumbnail_id',
'_edit_lock',
'_edit_last',
);
/**
* Register filters. Called from TMDO_Core::run() on init:5.
*
* Idempotent — safe to call multiple times.
*
* @return void
*/
public static function init(): void {
if ( ! self::is_enabled() ) {
return;
}
// Term meta writes — priority 9 (before Hook Bus at 10).
add_filter( 'add_term_metadata', array( self::class, 'on_term_write' ), 9, 5 );
add_filter( 'update_term_metadata', array( self::class, 'on_term_write' ), 9, 5 );
// Comment meta writes.
add_filter( 'add_comment_metadata', array( self::class, 'on_comment_write' ), 9, 5 );
add_filter( 'update_comment_metadata', array( self::class, 'on_comment_write' ), 9, 5 );
}
/**
* Check the admin toggle. Defaults to enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return (bool) get_option( self::OPT_ENABLED, '1' );
}
/**
* Test whether a meta_key triggers the shared drop rules
* (applicable to both term and comment meta).
*
* @param mixed $meta_key Candidate meta_key.
* @return bool
*/
public static function is_shared_garbage_key( $meta_key ): bool {
if ( ! is_string( $meta_key ) ) {
return false;
}
foreach ( self::SHARED_DROP_PREFIXES as $prefix ) {
if ( str_starts_with( $meta_key, $prefix ) ) {
return true;
}
}
return false;
}
/**
* Test whether a meta_key triggers the comment-only orphan post-meta drop.
*
* @param mixed $meta_key Candidate meta_key.
* @return bool
*/
public static function is_comment_orphan_key( $meta_key ): bool {
if ( ! is_string( $meta_key ) ) {
return false;
}
return in_array( $meta_key, self::COMMENT_ONLY_ORPHAN_KEYS, true );
}
/**
* Filter callback: add_term_metadata / update_term_metadata.
*
* Returns null → continue normal flow (write to wp_termmeta).
* Returns true → short-circuit; WP treats as success without DB write.
*
* @param mixed $check Filter accumulator (null at our priority).
* @param int $object_id Term ID.
* @param string $meta_key Meta key being written.
* @param mixed $meta_value Value being written (unused).
* @param mixed $extra Either $unique (add) or $prev_value (update). Unused.
* @return mixed
*/
public static function on_term_write( $check, $object_id, $meta_key, $meta_value, $extra ) {
unset( $object_id, $meta_value, $extra );
if ( self::is_shared_garbage_key( $meta_key ) ) {
self::increment_drop_counter();
return true;
}
return $check;
}
/**
* Filter callback: add_comment_metadata / update_comment_metadata.
*
* Returns null → continue normal flow.
* Returns true → short-circuit (silent drop).
*
* @param mixed $check Filter accumulator.
* @param int $object_id Comment ID.
* @param string $meta_key Meta key being written.
* @param mixed $meta_value Value being written (unused).
* @param mixed $extra Either $unique (add) or $prev_value (update). Unused.
* @return mixed
*/
public static function on_comment_write( $check, $object_id, $meta_key, $meta_value, $extra ) {
unset( $object_id, $meta_value, $extra );
if ( self::is_shared_garbage_key( $meta_key ) || self::is_comment_orphan_key( $meta_key ) ) {
self::increment_drop_counter();
return true;
}
return $check;
}
/**
* Increment the rolling 24h drop counter.
*
* Auto-resets every 24h based on a stored timestamp; this avoids
* unbounded growth and gives the admin status panel a meaningful
* "drops in last day" indicator.
*
* @return void
*/
private static function increment_drop_counter(): void {
$now = time();
$reset_at = (int) get_option( self::OPT_DROPPED_RESET_AT, 0 );
if ( 0 === $reset_at || ( $now - $reset_at ) >= DAY_IN_SECONDS ) {
update_option( self::OPT_DROPPED_COUNT, 1, false );
update_option( self::OPT_DROPPED_RESET_AT, $now, false );
return;
}
$count = (int) get_option( self::OPT_DROPPED_COUNT, 0 );
update_option( self::OPT_DROPPED_COUNT, $count + 1, false );
}
/**
* Get the current 24h rolling drop counter (for admin status panel).
*
* @return int
*/
public static function get_drop_count_24h(): int {
$reset_at = (int) get_option( self::OPT_DROPPED_RESET_AT, 0 );
if ( 0 === $reset_at || ( time() - $reset_at ) >= DAY_IN_SECONDS ) {
return 0;
}
return (int) get_option( self::OPT_DROPPED_COUNT, 0 );
}
}

Some files were not shown because too many files have changed in this diff Show More