commit d36bb954d10b90e5e37908f9d928cb9ea52f1e46
Author: wpdev
Date: Fri Jul 31 05:06:36 2026 +0800
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
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4ac74fc
--- /dev/null
+++ b/.gitignore
@@ -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/
diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php
new file mode 100644
index 0000000..69248a4
--- /dev/null
+++ b/2meet-data-optimizer.php
@@ -0,0 +1,331 @@
+';
+ 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 '
';
+ }
+ );
+ 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 '';
+ esc_html_e( '⚠ 偵測到舊版 wp-data-optimizer 已啟用。請停用舊外掛以避免 hook 雙觸發。', '2meet-data-optimizer' );
+ echo '
';
+ }
+ }
+);
+
+// ── 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 );
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..93b7dc4
--- /dev/null
+++ b/CHANGELOG.md
@@ -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 mismatch(cli/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 test(12 指令 全 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 notice(plan §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 header(Phase 5)
+- `TMDO_WooCommerce::doctor_check()` signature 與 Custom_Table_Registry callback 不對齊(warning level)
+- Trait `Trait_TMDO_Anti_EAV_Aware` 暫無 alias wrapper(class_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**: 14(Hub 12 + Spoke 2,未含 6 family no-op)
+- **SSO group fields**: 7
+- **Schema fields**: 23 (Hot 20 + Cold 3,HP 未安裝 → 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
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..430d8aa
--- /dev/null
+++ b/CLAUDE.md
@@ -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`。
diff --git a/DEPLOY.md b/DEPLOY.md
new file mode 100644
index 0000000..b801e92
--- /dev/null
+++ b/DEPLOY.md
@@ -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'
+```
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..afeccb7
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,130 @@
+# DESIGN — 2meet Data Optimizer 架構決策
+
+## 設計目標(pillars)
+
+1. **Zero-side-effect**:未啟用任何 zone 的站台應感受不到此外掛存在
+2. **Non-destructive**:所有操作可 rollback(snapshot + 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 通用 group(user/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
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..1c4eeb3
--- /dev/null
+++ b/PLAN.md
@@ -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 件 MD(README / 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] 主檔重寫成完整 loader(require 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 lint:111 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` group(7 欄位 → 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 adapter(core / reviews / bookings / messages / memberships / requests / favorites / statistics / tags / seo / social-links / blocks / marketplace)
+- [x] 複製 7 HPCT interceptor(reviews / messages / favorites / memberships / statistics / requests / listing-meta)
+- [x] 複製 5 query interceptor(reviews / 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 lint:46 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 registered(mostly WC product/order)
+- ✅ Cold zone 3 fields
+- ✅ Total 23 fields
+- ✅ HP no-op when HivePress not installed(detector 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 表 | ✅ 註冊 OK(DB 表 missing 是 spoke-sso 從未啟用之故) |
+| WC 20 表 | ✅ 註冊 OK(DB 表 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 而非 slug;hub-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` class(v3.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 tests:373/373 GREEN(771 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 專屬 test(HivePress/*、WooCommerceIntegrationTest、ListingStatsTest)
+
+**Integration tests:398/398 GREEN(1139 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` — 本 release(Phase 0-4 完成)
+- `v0.1.1` — Phase 5 follow-up(tests / PHPCS / packaging)
+- `v0.2.0` — WPDO_ deprecation notice + wpdo → tmdo migration CLI
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..216bca7
--- /dev/null
+++ b/README.md
@@ -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 協作指引
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..331e627
--- /dev/null
+++ b/SECURITY.md
@@ -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 release(CVSS ≥ 7 立即出版)
+
+---
+
+## 安全設計重點
+
+### Crypto
+
+- AES-256-GCM AEAD(v2 格式 `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
+
+- (本版本暫無已知安全 risk;Phase 1 ship 時補)
+
+---
+
+## 來源外掛安全沿革
+
+- v2.6.4 H-4:加密 Slack/Discord/Telegram 密鑰
+- v2.6.5 A-4:Spoke 明文 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。
diff --git a/admin/assets/wpdo-admin.css b/admin/assets/wpdo-admin.css
new file mode 100644
index 0000000..78722e0
--- /dev/null
+++ b/admin/assets/wpdo-admin.css
@@ -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;
+ }
+}
diff --git a/admin/assets/wpdo-admin.js b/admin/assets/wpdo-admin.js
new file mode 100644
index 0000000..73a38e0
--- /dev/null
+++ b/admin/assets/wpdo-admin.js
@@ -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.
+ }
+ );
+ }
+
+} )();
diff --git a/admin/assets/wpdo-comment-stress-test.js b/admin/assets/wpdo-comment-stress-test.js
new file mode 100644
index 0000000..3446bc8
--- /dev/null
+++ b/admin/assets/wpdo-comment-stress-test.js
@@ -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 ) => ( {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ }[ 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 += '▍ 寫入指標
';
+ html += '';
+ html += `| 模式 | ${ esc( w.mode ) } |
`;
+ html += `| Post ID | #${ esc( w.post_id ) } |
`;
+ html += `| 完成 / 目標 | ${ fmt( w.processed ) } / ${ fmt( w.target ) } |
`;
+ html += `| 總耗時 | ${ fmt( w.elapsed_sec ) } 秒 |
`;
+ html += `| 平均速率 | ${ fmt( w.rate_per_sec ) } comments/sec |
`;
+ html += `| 批次數 | ${ fmt( w.batches_done ) } |
`;
+ html += `| 批次最快/平均/最慢 | ${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms |
`;
+ html += `| PHP Peak Memory | ${ fmt( w.peak_memory_mb ) } MB |
`;
+ html += '
';
+
+ // DB sizes
+ html += '▍ DB 容量(comment 相關表)
';
+ html += '';
+ html += '| Table | Rows | Data MB | Index MB | Total MB | Avg bytes/row | ';
+ html += '
';
+ dbSizes.forEach( ( r ) => {
+ html += `${ esc( r.table ) } | ${ fmt( r.rows ) } | `;
+ html += `${ r.data_mb ?? '—' } | ${ r.index_mb ?? '—' } | `;
+ html += `${ r.total_mb ?? '—' } | ${ fmt( r.avg_bytes ) } |
`;
+ } );
+ html += '
';
+
+ // Query perf — 3 probes with EAV baseline last for visual speedup comparison
+ html += '▍ 查詢效能
';
+ html += '';
+ html += '| 測試項目 | 耗時 (ms) | ';
+ html += '
';
+
+ 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 = ` (${ ratio.toFixed( 2 ) }× faster)`;
+ }
+ }
+ html += `| ${ esc( label ) }${ speedup } | ${ v.duration_ms } |
`;
+ } );
+ html += '
';
+ html += 'EAV baseline 走 wp_commentmeta,flat probes 走 wpdo_comment_hp_review。倍率即此規模下反 EAV 的查詢加速。
';
+
+ 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 ms(wp_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() } 筆 comment(attached 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();
+ }
+ } );
+ } );
+} )();
diff --git a/admin/assets/wpdo-entity-bridge.js b/admin/assets/wpdo-entity-bridge.js
new file mode 100644
index 0000000..44f3da2
--- /dev/null
+++ b/admin/assets/wpdo-entity-bridge.js
@@ -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 '' +
+ '' +
+ '' + labels[ m ] + '' +
+ '';
+ } ).join( '→' );
+ }
+
+ // ─── 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 );
+
+} )();
diff --git a/admin/assets/wpdo-migration-wizard.css b/admin/assets/wpdo-migration-wizard.css
new file mode 100644
index 0000000..7799f03
--- /dev/null
+++ b/admin/assets/wpdo-migration-wizard.css
@@ -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);
+}
diff --git a/admin/assets/wpdo-migration-wizard.js b/admin/assets/wpdo-migration-wizard.js
new file mode 100644
index 0000000..109e4c4
--- /dev/null
+++ b/admin/assets/wpdo-migration-wizard.js
@@ -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);
+ }
+ });
+ }
+})();
diff --git a/admin/assets/wpdo-post-stress-test.js b/admin/assets/wpdo-post-stress-test.js
new file mode 100644
index 0000000..4e7ddd0
--- /dev/null
+++ b/admin/assets/wpdo-post-stress-test.js
@@ -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 ) => ( {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ }[ 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 += '▍ 寫入指標
';
+ html += '';
+ html += `| 模式 | ${ esc( w.mode ) } |
`;
+ html += `| Post Type | ${ esc( w.post_type ) } |
`;
+ html += `| 完成 / 目標 | ${ fmt( w.processed ) } / ${ fmt( w.target ) } |
`;
+ html += `| 總耗時 | ${ fmt( w.elapsed_sec ) } 秒 |
`;
+ html += `| 平均速率 | ${ fmt( w.rate_per_sec ) } posts/sec |
`;
+ html += `| 批次數 | ${ fmt( w.batches_done ) } |
`;
+ html += `| 批次最快/平均/最慢 | ${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms |
`;
+ html += `| PHP Peak Memory | ${ fmt( w.peak_memory_mb ) } MB |
`;
+ html += '
';
+
+ // DB sizes (wp_posts + wp_postmeta + flat table for this run)
+ html += '▍ DB 容量(post 相關表)
';
+ html += '';
+ html += '| Table | Rows | Data MB | Index MB | Total MB | Avg bytes/row | ';
+ html += '
';
+ dbSizes.forEach( ( r ) => {
+ html += `${ esc( r.table ) } | ${ fmt( r.rows ) } | `;
+ html += `${ r.data_mb ?? '—' } | ${ r.index_mb ?? '—' } | `;
+ html += `${ r.total_mb ?? '—' } | ${ fmt( r.avg_bytes ) } |
`;
+ } );
+ html += '
';
+
+ // Query perf — 3 probes per post_type, with EAV baseline last for visual comparison
+ html += '▍ 查詢效能
';
+ html += '';
+ html += '| 測試項目 | 耗時 (ms) | ';
+ html += '
';
+
+ 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 = ` (${ ratio.toFixed( 2 ) }× faster)`;
+ }
+ }
+ html += `| ${ esc( label ) }${ speedup } | ${ v.duration_ms } |
`;
+ } );
+ html += '
';
+ html += 'EAV baseline 走 wp_postmeta,flat probes 走專屬 group 表。倍率即此規模下反 EAV 的查詢加速。
';
+
+ 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-300ms,batch 太大會撞 nginx 60s timeout 之前的 8s deadline
+ if ( mode === 'realistic' && batch > 30 ) {
+ if ( ! confirm( `Realistic 模式每個 post 約需 100-300 ms,batch_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_postmeta(fixture 用途,非優化驗證)。\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();
+ }
+ } );
+ } );
+} )();
diff --git a/admin/assets/wpdo-rest-sdk.js b/admin/assets/wpdo-rest-sdk.js
new file mode 100644
index 0000000..cdc72cd
--- /dev/null
+++ b/admin/assets/wpdo-rest-sdk.js
@@ -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] 1–100, 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
';
+ return;
+ }
+ $summary = TMDO_Conflict_Monitor::get_summary();
+ $total = (int) ( $summary['total'] ?? 0 );
+ ?>
+
+
+
+ |
+
+
+ ●
+
+
+ ●
+
+
+ |
+
+
+ | Hook overlap |
+ |
+
+
+ | UAEPG overlap |
+ |
+
+
+
+ 0 ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ' . esc_html__( 'Site Health 模組未載入。', '2meet-data-optimizer' ) . '';
+ return;
+ }
+
+ $tests = array(
+ 'wpdo_schema_drift' => array( 'check_schema_drift', __( 'Schema 完整性', '2meet-data-optimizer' ) ),
+ 'wpdo_error_budget' => array( 'check_error_budget', __( '錯誤預算(過去 7 天)', '2meet-data-optimizer' ) ),
+ 'wpdo_hook_conflicts' => array( 'check_hook_conflicts', __( 'Hook 衝突', '2meet-data-optimizer' ) ),
+ 'wpdo_autoload_bloat' => array( 'check_autoload_bloat', __( 'Autoload 大小', '2meet-data-optimizer' ) ),
+ 'wpdo_postmeta_explosion' => array( 'check_postmeta_explosion', __( 'wp_postmeta 爆量', '2meet-data-optimizer' ) ),
+ 'wpdo_orphan_zone_rows' => array( 'check_orphan_zone_rows', __( 'Orphan zone rows', '2meet-data-optimizer' ) ),
+ 'wpdo_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '2meet-data-optimizer' ) ),
+ );
+ ?>
+
+
+
+ |
+ |
+ |
+
+
+
+ $info ) {
+ $result = call_user_func( array( 'TMDO_Site_Health', $info[0] ) );
+ $status = (string) ( $result['status'] ?? 'good' );
+ $badge = 'good' === $status ? '✅' : ( 'critical' === $status ? '🔴' : '🟡' );
+ printf(
+ '| %s | %s %s | %s |
',
+ esc_html( $info[1] ),
+ esc_html( $badge ),
+ esc_html( $status ),
+ wp_kses_post( (string) ( $result['description'] ?? '' ) )
+ );
+ }
+ ?>
+
+
+
+
+
+ %s
',
+ esc_html(
+ sprintf(
+ /* translators: %s: module name */
+ __( '✅ Module %s 已切換至 dual_write 狀態。建議觀察 ≥ 24h 後再推進到 shadow_read(用 Entity Bridge tab 或 wp wpdo bridge-mode-set)。', '2meet-data-optimizer' ),
+ $mod
+ )
+ )
+ );
+ } elseif ( 'enable_blocked' === $msg ) {
+ printf(
+ '',
+ esc_html(
+ sprintf(
+ /* translators: %s: module name */
+ __( '⛔ Module %s 啟用被 FSM Guard 擋下(可能 module 已不在 idle 狀態)。', '2meet-data-optimizer' ),
+ $mod
+ )
+ )
+ );
+ } elseif ( 'enable_failed' === $msg ) {
+ echo '' . esc_html__( '⛔ 啟用失敗,請查日誌。', '2meet-data-optimizer' ) . '
';
+ }
+
+ echo '' . esc_html__( '🤖 模組建議', '2meet-data-optimizer' ) . '
';
+ echo '' . esc_html__( '系統依環境自動偵測哪些 module 適合啟用。每筆建議含 confidence score + reasons + blockers。一鍵啟用會把 module 推進到 dual_write(FSM 第 1 個 active state,FSM Guard 確保不越級)。', '2meet-data-optimizer' ) . '
';
+
+ if ( ! class_exists( 'TMDO_Module_Detector' ) ) {
+ echo '' . esc_html__( '模組偵測器未載入。', '2meet-data-optimizer' ) . '
';
+ return;
+ }
+
+ $all = TMDO_Module_Detector::detect_all( true );
+ // Sort: actionable enable (high confidence first) → wait → skip.
+ $buckets = array(
+ 'enable' => array(),
+ 'wait' => array(),
+ 'skip' => array(),
+ );
+ foreach ( $all as $module => $r ) {
+ $rec = $r['recommendation'] ?? 'skip';
+ if ( ! isset( $buckets[ $rec ] ) ) {
+ $rec = 'skip';
+ }
+ $buckets[ $rec ][ $module ] = $r;
+ }
+ uasort( $buckets['enable'], static fn( $a, $b ) => (float) $b['confidence'] <=> (float) $a['confidence'] );
+
+ // ─── enable bucket(重點)─────────────────────────────────
+ $enable = $buckets['enable'];
+ printf(
+ '%s
',
+ esc_html(
+ sprintf(
+ /* translators: %d: number of recommended modules */
+ __( '✅ 建議啟用(%d 個 module)', '2meet-data-optimizer' ),
+ count( $enable )
+ )
+ )
+ );
+ if ( empty( $enable ) ) {
+ echo '' . esc_html__( '目前沒有可立即啟用的 module 建議。', '2meet-data-optimizer' ) . '
';
+ } else {
+ echo '';
+ printf(
+ '| %s | %s | %s | %s | %s | ',
+ esc_html__( 'Module', '2meet-data-optimizer' ),
+ esc_html__( 'Confidence', '2meet-data-optimizer' ),
+ esc_html__( '說明 / 理由', '2meet-data-optimizer' ),
+ esc_html__( '當前狀態', '2meet-data-optimizer' ),
+ esc_html__( '操作', '2meet-data-optimizer' )
+ );
+ echo '
';
+ foreach ( $enable as $module => $r ) {
+ $conf = (float) $r['confidence'];
+ $bar_w = (int) round( $conf * 100 );
+ $color = $conf >= 0.7 ? '#46b450' : ( $conf >= 0.5 ? '#dba617' : '#c3c4c7' );
+ $enable_url = wp_nonce_url(
+ add_query_arg( 'wpdo_enable_module', $module, admin_url( 'tools.php?page=' . self::MENU_SLUG ) ),
+ 'wpdo_enable_module'
+ );
+ printf(
+ '%s | | ',
+ esc_html( $module ),
+ esc_attr( $color ),
+ (int) $bar_w,
+ esc_html( sprintf( '%.2f', $conf ) )
+ );
+ echo '';
+ if ( ! empty( $r['description'] ) ) {
+ echo '' . esc_html( (string) $r['description'] ) . ' ';
+ }
+ if ( ! empty( $r['reasons'] ) ) {
+ echo '' . esc_html( implode( ' · ', $r['reasons'] ) ) . '';
+ }
+ echo ' | ';
+ printf( '%s | ', esc_html( (string) $r['current_state'] ) );
+ printf(
+ '%s | ',
+ esc_url( $enable_url ),
+ esc_js( __( '確定啟用此 module(推進到 dual_write)?', '2meet-data-optimizer' ) ),
+ esc_html__( '✅ 啟用', '2meet-data-optimizer' )
+ );
+ echo '
';
+ }
+ echo '
';
+ }
+
+ // ─── wait bucket(條件未滿)────────────────────────────────
+ $wait = $buckets['wait'];
+ if ( ! empty( $wait ) ) {
+ printf(
+ '%s
',
+ esc_html(
+ sprintf(
+ /* translators: %d: number of modules not yet ready */
+ __( '⏳ 條件未滿 / 暫不建議(%d 個)', '2meet-data-optimizer' ),
+ count( $wait )
+ )
+ )
+ );
+ echo '';
+ foreach ( $wait as $module => $r ) {
+ printf(
+ '%s | %s |
',
+ esc_html( $module ),
+ esc_html( implode( ' · ', $r['blockers'] ?? array() ) )
+ );
+ }
+ echo '
';
+ }
+
+ // ─── skip bucket(已啟用 / 不適用)────────────────────────
+ $skip = $buckets['skip'];
+ if ( ! empty( $skip ) ) {
+ $skip_count = count( $skip );
+ printf(
+ '%s
',
+ esc_html(
+ sprintf(
+ /* translators: %d: number of modules already enabled or not applicable */
+ __( '已啟用 / 不適用(%d 個)— 點擊展開', '2meet-data-optimizer' ),
+ $skip_count
+ )
+ )
+ );
+ echo '';
+ foreach ( $skip as $module => $r ) {
+ printf(
+ '%s | %s |
',
+ esc_html( $module ),
+ esc_html( implode( ' · ', $r['blockers'] ?? array() ) )
+ );
+ }
+ echo '
';
+ }
+
+ echo '' . esc_html__( '結果由 1 小時 transient 快取;每日健康檢查 cron 也會自動更新。', '2meet-data-optimizer' ) . '
';
+ }
+
+ /**
+ * Settings tab — email alerts + throttle (v2.4.0 M10).
+ */
+ private static function render_settings(): void {
+ if ( isset( $_GET['wpdo_msg'] ) && 'settings_saved' === sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ echo '' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '
';
+ }
+ $enabled = class_exists( 'TMDO_Email_Notifier' ) && TMDO_Email_Notifier::is_enabled();
+ $email = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::recipient() : '';
+ $throttle = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::throttle_hours() : 24;
+ ?>
+
+
+ '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'] );
+ ?>
+
+
+
+
+
+ 0 ) : ?>
+ ·
+
+
+
+
+
+
+
+
+
+
+ = 0.5 ) {
+ ++$actionable_count;
+ }
+ }
+ }
+ }
+ if ( $actionable_count > 0 ) :
+ $ms_url = add_query_arg( 'tab', 'module-suggestions', $page_url );
+ ?>
+
+ 🤖
+
+
+
+
+
+ false );
+
+ if ( ! empty( $attn['needs'] ) && 'running' !== ( $attn['job_state'] ?? '' ) ) :
+ $wizard_url = add_query_arg( 'tab', 'migration-wizard', $page_url );
+ ?>
+
+ 🔴
+ %1$s 行 wp_usermeta EAV 殘留橫跨 %2$s 個 entity group(當前 ratio 1:%3$s)—', '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'] )
+ );
+ ?>
+
+
+
+
+ ⏳
+
+
+
+
+ 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'] )
+ );
+ ?>
+
+ 🧹
+ %1$s 行可清理垃圾(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'] ) )
+ );
+ ?>
+
+
+
+
+
+
+ 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:偵測到 %1$s 行 wp_postmeta EAV 殘留(當前 ratio 1:%2$s,mode=%3$s)—', '2meet-data-optimizer' ),
+ '%1$s',
+ '%2$s',
+ '%3$s'
+ )
+ : sprintf(
+ /* translators: 1: ratio */
+ __( 'Post Entity:無 EAV 殘留(ratio 1:%1$s,mode=%2$s)', '2meet-data-optimizer' ),
+ '%1$s',
+ '%2$s'
+ );
+ ?>
+
+ 📦
+ 0 ) {
+ printf(
+ wp_kses(
+ /* translators: 1: total EAV rows, 2: ratio, 3: mode */
+ __( 'Post Entity:偵測到 %1$s 行 wp_postmeta EAV 殘留(當前 ratio 1:%2$s,mode=%3$s)— 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:%1$s,mode=%2$s)', '2meet-data-optimizer' ),
+ array( 'strong' => array() )
+ ),
+ esc_html( (string) $post_diag['ratio'] ),
+ esc_html( $post_diag['mode'] )
+ );
+ }
+ ?>
+
+
+
+
+ 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';
+ }
+}
diff --git a/admin/class-tmdo-export.php b/admin/class-tmdo-export.php
new file mode 100644
index 0000000..eea536a
--- /dev/null
+++ b/admin/class-tmdo-export.php
@@ -0,0 +1,272 @@
+ '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;
+ }
+}
diff --git a/admin/class-tmdo-help-tabs.php b/admin/class-tmdo-help-tabs.php
new file mode 100644
index 0000000..26dad11
--- /dev/null
+++ b/admin/class-tmdo-help-tabs.php
@@ -0,0 +1,221 @@
+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(
+ '' . esc_html__( '更多資源', '2meet-data-optimizer' ) . '
'
+ . '' . esc_html__( 'Entity Bridge — 主維運入口', '2meet-data-optimizer' ) . '
'
+ . '' . esc_html__( 'WP Site Health', '2meet-data-optimizer' ) . '
'
+ . 'wp wpdo doctor
wp wpdo mode-audit
wp wpdo snapshot create
'
+ );
+ }
+
+ // ─── content templates ─────────────────────────────────────────────
+
+ /**
+ * Overview help content.
+ *
+ * @return string
+ */
+ private static function content_overview(): string {
+ return '' . esc_html__( 'WP Data Optimizer 是反 EAV(meta 爆炸)的解方。', '2meet-data-optimizer' ) . '
'
+ . '' . esc_html__( '核心概念:把 wp_postmeta 的高頻欄位(Hot)、TTL 暫存(Warm)、低頻欄位(Cold)、歷史資料(Archive)拆到 4 種專用表,讀寫快很多、autoload 不再爆。', '2meet-data-optimizer' ) . '
'
+ . '' . esc_html__( '建議第一步:', '2meet-data-optimizer' ) . ' ' . esc_html__( '逛一遍儀表板了解現況 → 看 Entity Bridge tab 各 entity 健康卡片 → 從 1 個 entity 開始用 Migration Wizard 漸進升級 mode。', '2meet-data-optimizer' ) . '
';
+ }
+
+ /**
+ * Dashboard help content.
+ *
+ * @return string
+ */
+ private static function content_dashboard(): string {
+ return '' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '
'
+ . ''
+ . '- ' . esc_html__( 'System Overview — DB 引擎、HivePress、HPCT、Object Cache 是否啟用', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( 'Archive 統計 — 壓縮率、依 post_type 拆分', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( 'REST API rate limit — 429 事件 + Top 10 受限 post', '2meet-data-optimizer' ) . '
'
+ . '
';
+ }
+
+ /**
+ * Zones help content.
+ *
+ * @return string
+ */
+ private static function content_zones(): string {
+ return '' . esc_html__( '4 個 Zone 對應不同存取頻率與保留需求:', '2meet-data-optimizer' ) . '
'
+ . ''
+ . '- Hot — ' . esc_html__( '高頻索引欄位,如 listing 的 price / location。獨立 column + index,WP_Query 可 JOIN。', '2meet-data-optimizer' ) . '
'
+ . '- Warm — ' . esc_html__( 'TTL 暫存(如 view count、cache stats)。固定表 wp_wpdo_warm 含 expires_at。', '2meet-data-optimizer' ) . '
'
+ . '- Cold — ' . esc_html__( '低頻 meta(settings / preferences)。讀寫透過 interceptor 攔截後保持 EAV 形式。', '2meet-data-optimizer' ) . '
'
+ . '- Archive — ' . esc_html__( 'Trashed / 90+ 天舊資料。可 gzip 壓縮。', '2meet-data-optimizer' ) . '
'
+ . '
'
+ . '' . esc_html__( '不確定要哪種 → 用 Classifier,它會看 access pattern 給建議。', '2meet-data-optimizer' ) . '
';
+ }
+
+ /**
+ * Classifier help content.
+ *
+ * @return string
+ */
+ private static function content_classifier(): string {
+ return '' . esc_html__( 'Classifier 分析 wp_postmeta 給每個 meta_key 一個 zone 建議:', '2meet-data-optimizer' ) . '
'
+ . ''
+ . '- Confidence — ' . esc_html__( '0.0~1.0,越高代表分類越確定。≥ 0.8 可放心採納,< 0.5 建議 manual review。', '2meet-data-optimizer' ) . '
'
+ . '- Reasons — ' . esc_html__( '說明為什麼建議這個 zone(access frequency / row count / TTL hints)。', '2meet-data-optimizer' ) . '
'
+ . '- Already-assigned — ' . esc_html__( '已透過 Schema_Registry 註冊的 meta_key 數量。', '2meet-data-optimizer' ) . '
'
+ . '
';
+ }
+
+ /**
+ * Snapshots help content.
+ *
+ * @return string
+ */
+ private static function content_snapshots(): string {
+ return '' . esc_html__( '快照保留政策(v2.2.0):', '2meet-data-optimizer' ) . '
'
+ . ''
+ . '- ' . esc_html__( '預設 30 天 TTL,可在 wp wpdo snapshot create 時用 --retention-days 覆蓋。', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( 'pre_uninstall / pre_v2_upgrade triggers 受 size-cap 保護(不會被自動 evict)。', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( '檔案存於 wp-content/uploads/wpdo-backups/,含 .htaccess deny all + 每個檔 sha256 校驗。', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( '小於 5MB 自動 inline 到 wp_wpdo_snapshots.inline_blob,方便 wp db export 時跟著走。', '2meet-data-optimizer' ) . '
'
+ . '
'
+ . '' . esc_html__( '災難還原 drill', '2meet-data-optimizer' ) . ':'
+ . esc_html__( '建議每月做一次 dry-run 還原驗證 — wp wpdo snapshot restore (不加 --apply)即可預覽會還原什麼。', '2meet-data-optimizer' ) . '
';
+ }
+
+ /**
+ * Conflicts help content.
+ *
+ * @return string
+ */
+ private static function content_conflicts(): string {
+ return '' . esc_html__( 'Hook 衝突偵測:當多個 plugin 在同一 WordPress 的 metadata filter 上掛 callback 時,可能造成資料寫入順序不確定 / 重複處理。', '2meet-data-optimizer' ) . '
'
+ . '' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '
'
+ . ''
+ . '- ' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( 'HPCT (HP Custom Tables) plugin 還沒移除 — 與 WPDO 同時攔截', '2meet-data-optimizer' ) . '
'
+ . '
'
+ . '' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '
';
+ }
+
+ /**
+ * Doctor help content.
+ *
+ * @return string
+ */
+ private static function content_doctor(): string {
+ return '' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '
'
+ . ''
+ . '- schema_drift — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '
'
+ . '- error_budget — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '
'
+ . '- hook_conflicts — ' . esc_html__( '同上 conflicts tab', '2meet-data-optimizer' ) . '
'
+ . '- autoload_bloat — ' . esc_html__( 'wp_options autoload 大小 > 5MB 警告', '2meet-data-optimizer' ) . '
'
+ . '- postmeta_explosion — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '
'
+ . '- orphan_zone_rows — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '
'
+ . '- missing_snapshot — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '
'
+ . '
'
+ . '' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '
';
+ }
+
+ /**
+ * Logs help content.
+ *
+ * @return string
+ */
+ private static function content_logs(): string {
+ return '' . esc_html__( '日誌讀取:', '2meet-data-optimizer' ) . '
'
+ . ''
+ . '- ' . esc_html__( '每筆對應 wp_wpdo_errors 一行:module / zone / hook / message / timestamp。', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( '預設保留 90 天(wpdo_errors_gc daily cron 自動清)。', '2meet-data-optimizer' ) . '
'
+ . '- ' . esc_html__( '看 message 開頭 [WARN] 是 warning level(不影響運作但需注意)。', '2meet-data-optimizer' ) . '
'
+ . '
';
+ }
+
+ /**
+ * REST API help content.
+ *
+ * @return string
+ */
+ private static function content_rest_api(): string {
+ return '' . esc_html__( 'REST API 提供 zone 操作 + diagnostics endpoints。', '2meet-data-optimizer' ) . '
'
+ . '' . esc_html__( '所有 endpoint 用 X-WP-Nonce 認證;rate limit 預設 30/min。', '2meet-data-optimizer' ) . '
';
+ }
+}
diff --git a/admin/class-tmdo-setup-wizard.php b/admin/class-tmdo-setup-wizard.php
new file mode 100644
index 0000000..30243cb
--- /dev/null
+++ b/admin/class-tmdo-setup-wizard.php
@@ -0,0 +1,474 @@
+ 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 );
+ ?>
+
+
+
+ ←
+
+
+
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Zone | | |
+
+
+ | Hot | | |
+ | Warm | | |
+ | Cold | | |
+ | Archive | | |
+
+
+
+
+
+ - 100k 開始考慮', '2meet-data-optimizer' ); ?>
+ - 1M 強烈建議啟用', '2meet-data-optimizer' ); ?>
+
+
+
+
+ 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
+ ?>
+
+
+
+ | |
+ | |
+ |
+
+ 1_000_000 ) : ?>
+ ✅
+ 100_000 ) : ?>
+ 🟡
+
+ ⏸
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+ $r ) : ?>
+
+ |
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✅
+
+
+
+
+
+
+
+ '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 '' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '
';
+ }
+ ?>
+
+
+
+
+ ' . esc_html( gmdate( 'Y-m-d H:i:s', $next ) ) . ''
+ );
+ } else {
+ esc_html_e( '⚠️ Daily cron 未排程;請重新啟用外掛。', '2meet-data-optimizer' );
+ }
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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}" );
+?>
+
+
diff --git a/admin/templates/migration-wizard.php b/admin/templates/migration-wizard.php
new file mode 100644
index 0000000..d736601
--- /dev/null
+++ b/admin/templates/migration-wizard.php
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+ $g ) : ?>
+
+ |
+ |
+ |
+
+ 0 ) : ?>
+
+
+ 0
+
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
>
+
+
+
+
+
+
+
+
+
+
+ 0.0s
+
+
+
+
+
+ 1:
+ →
+ 1:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/templates/post-migration-wizard.php b/admin/templates/post-migration-wizard.php
new file mode 100644
index 0000000..c39d006
--- /dev/null
+++ b/admin/templates/post-migration-wizard.php
@@ -0,0 +1,298 @@
+ __( '🔵 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;
+ }
+};
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+ $g ) : ?>
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ wp option update wpdo_bridge_modes '{"post":"disabled",...}'
+
+
+
+
+
diff --git a/admin/templates/post-stress-test.php b/admin/templates/post-stress-test.php
new file mode 100644
index 0000000..1ef71be
--- /dev/null
+++ b/admin/templates/post-stress-test.php
@@ -0,0 +1,393 @@
+ '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 ),
+);
+?>
+
+
+
+
+
+ ⚠️
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+ |
+ 1: |
+ |
+ |
+
+
+ |
+ |
+
+
+
+
+
+
+
+
+ ✅
+
+ ⚠️
+ ' . esc_html( $current_post_mode ) . ''
+ );
+ ?>
+
+
+
+
+
+
+
+ -
+ ',
+ ''
+ );
+ ?>
+
+ →
+
+
+ -
+
+
+
+
+
+
+
+
+
+ |
+ ⚡ Fast |
+ 🐢 Realistic |
+
+
+
+ >
+ disabled |
+ wp_postmeta 5 rows / flat 0 → 1:5(無優化) |
+ wp_postmeta 5 / flat 0 → 1:5(無優化) |
+
+ >
+ dual_write |
+ wp_postmeta 5 / flat 0 → 1:5 |
+ wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減) |
+
+ >
+ shadow_read |
+ wp_postmeta 5 / flat 0 → 1:5 |
+ wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫) |
+
+ >
+ aeav_only |
+ wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️ |
+ wp_postmeta 0 / flat 1 → 0:1(完全優化) ✅ |
+
+
+
+
+ insert,故意繞過 Hook Bus → 即使 mode=aeav_only 也會寫滿 wp_postmeta(用途:快速灌 fixture 給 Query Router benchmark)。驗證反 EAV 優化效果一律用 Realistic。', '2meet-data-optimizer' ); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ·
+ · mode
+
+ %
+
+
+
+
+
+ |
+ / |
+ |
+ posts/sec |
+
+
+ |
+ 秒 |
+ |
+ 秒 |
+
+
+ |
+ |
+ |
+ MB |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+
+ product |
+ _price, _regular_price, _stock, _stock_status, _sku |
+ wp_wpdo_post_wc_product |
+
+
+ hp_listing |
+ hp_price, hp_status, hp_featured, hp_verified, hp_vendor, hp_view_count, hp_expired_time |
+ wp_wpdo_post_hp_listing_core |
+
+
+ hp_request |
+ hp_status, hp_user, hp_budget, hp_view_count, hp_expired_time |
+ wp_wpdo_post_hp_request_core |
+
+
+ hp_vendor |
+ hp_user, hp_verified, hp_hourly_rate, hp_rating_count, hp_rating |
+ wp_wpdo_post_hp_vendor_core |
+
+
+ attachment |
+ _wp_attached_file, _wp_attachment_image_alt |
+ wp_wpdo_post_attachment |
+
+
+ nav_menu_item |
+ _menu_item_type, _menu_item_object_id, _menu_item_object, _menu_item_target, _menu_item_url |
+ wp_wpdo_post_nav_menu_item |
+
+
+ post |
+ _thumbnail_id, _edit_last |
+ wp_wpdo_post_wp_core |
+
+
+
+
+
+
diff --git a/admin/templates/term-stress-test.php b/admin/templates/term-stress-test.php
new file mode 100644
index 0000000..b418d7d
--- /dev/null
+++ b/admin/templates/term-stress-test.php
@@ -0,0 +1,248 @@
+ 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}" );
+?>
+
+
+
+
+
+ ⚠️
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+ 1: 0 ? round( $total_termmeta / $total_terms, 2 ) : 0 ) ); ?> |
+
+
+ wpdo_term_hp_taxonomy |
+ rows |
+ wpdo_term_misc |
+ rows |
+
+
+ |
+ |
+
+
+
+
+
+
+
+
+ ✅
+
+ ⚠️
+ ' . esc_html( $current_mode ) . ''
+ );
+ ?>
+
+
+
+
+
+
+ -
+ ',
+ ''
+ );
+ ?>
+
+ →
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ·
+ · mode
+
+ %
+
+
+
+
+
+ |
+ / |
+ |
+ terms/sec |
+
+
+ |
+ 秒 |
+ |
+ 秒 |
+
+
+ |
+ |
+ |
+ MB |
+
+
+
+
+
+
+
+
diff --git a/cli/class-tmdo-cli-member.php b/cli/class-tmdo-cli-member.php
new file mode 100644
index 0000000..87a3b6a
--- /dev/null
+++ b/cli/class-tmdo-cli-member.php
@@ -0,0 +1,561 @@
+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=]
+ * : Which group to backfill: membership, activity, profile, sso. Default: membership.
+ *
+ * [--batch-size=]
+ * : 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=]
+ * : Max rows to display (default 20).
+ *
+ * [--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
+ *
+ *
+ * : WordPress user ID.
+ *
+ * [--limit=]
+ * : 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 ' );
+ }
+
+ $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=]
+ * : 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= 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=]
+ * : WordPress local user ID to force-logout.
+ *
+ * [--global-user-id=]
+ * : 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= or --global-user-id=.' );
+ }
+}
+
+// ── 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' ) );
diff --git a/cli/class-tmdo-cli-post.php b/cli/class-tmdo-cli-post.php
new file mode 100644
index 0000000..10565b7
--- /dev/null
+++ b/cli/class-tmdo-cli-post.php
@@ -0,0 +1,844 @@
+]
+ * : 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=
+ * : 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=.' );
+ }
+
+ 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_` / `_transient_timeout_`
+ * 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_` zone table
+ * into the new `wp_wpdo_post_` 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 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 to filter by (e.g. product, hp_listing).
+ *
+ * --meta-key=
+ * : Meta key to query (must be registered in entity registry for $post_type).
+ *
+ * [--compare=]
+ * : Comparison operator. Default: =.
+ * ---
+ * default: =
+ * options:
+ * - "="
+ * - "!="
+ * - "<"
+ * - "<="
+ * - ">"
+ * - ">="
+ * - "LIKE"
+ * ---
+ *
+ * --value=
+ * : Value to compare against.
+ *
+ * [--samples=]
+ * : 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=]
+ * : Aggregation window in hours. Default: 24.
+ *
+ * [--limit=]
+ * : 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' ) );
diff --git a/cli/class-tmdo-cli-term-comment.php b/cli/class-tmdo-cli-term-comment.php
new file mode 100644
index 0000000..3a07368
--- /dev/null
+++ b/cli/class-tmdo-cli-term-comment.php
@@ -0,0 +1,1013 @@
+]
+ * : Which garbage class to address. Default: all.
+ * ---
+ * default: all
+ * options:
+ * - all
+ * - wxr_import
+ * - demo_data
+ * - transients
+ * ---
+ *
+ * [--dry-run]
+ * : Show row counts without deleting.
+ *
+ * [--confirm]
+ * : Required to actually DELETE rows. Mutually exclusive with --dry-run.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo termmeta-cleanup --dry-run
+ * wp wpdo termmeta-cleanup --target=wxr_import --dry-run
+ * wp wpdo termmeta-cleanup --confirm
+ *
+ * @param array $args Positional arguments (unused).
+ * @param array $assoc_args Named arguments.
+ */
+ public function termmeta_cleanup( $args, $assoc_args ): void {
+ $target = (string) ( $assoc_args['target'] ?? TMDO_Termmeta_Cleaner::TARGET_ALL );
+ $dry_run = isset( $assoc_args['dry-run'] );
+ $confirm = isset( $assoc_args['confirm'] );
+
+ if ( ! in_array( $target, TMDO_Termmeta_Cleaner::VALID_TARGETS, true ) ) {
+ WP_CLI::error(
+ 'Invalid --target. Choose: ' . implode( ', ', TMDO_Termmeta_Cleaner::VALID_TARGETS )
+ );
+ }
+
+ 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 termmeta-cleanup --dry-run'
+ );
+ }
+
+ if ( $dry_run ) {
+ $counts = TMDO_Termmeta_Cleaner::count_garbage( $target );
+ self::render_term_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;
+ }
+
+ $deleted = TMDO_Termmeta_Cleaner::delete_garbage( $target );
+ self::render_term_table( $deleted, 'deleted' );
+
+ if ( class_exists( 'TMDO_Logger' ) ) {
+ TMDO_Logger::info(
+ 'termmeta_cleanup',
+ array(
+ 'target' => $target,
+ 'wxr_import' => $deleted['wxr_import'],
+ 'demo_data' => $deleted['demo_data'],
+ 'transients' => $deleted['transients'],
+ 'total' => $deleted['total'],
+ )
+ );
+ }
+
+ WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_termmeta.', $deleted['total'] ) );
+ }
+
+ /**
+ * Render a table for termmeta cleanup output.
+ *
+ * @param array $counts Output from TMDO_Termmeta_Cleaner::count_garbage / delete_garbage.
+ * @param string $verb Column header verb.
+ */
+ private static function render_term_table( array $counts, string $verb ): void {
+ $rows = array(
+ array(
+ 'bucket' => 'wxr_import',
+ $verb => $counts['wxr_import'],
+ 'rule' => '_wxr_import_*',
+ ),
+ array(
+ 'bucket' => 'demo_data',
+ $verb => $counts['demo_data'],
+ 'rule' => '_2meet_demo_*',
+ ),
+ array(
+ 'bucket' => 'transients',
+ $verb => $counts['transients'],
+ 'rule' => '_transient_% OR _transient_timeout_%',
+ ),
+ array(
+ 'bucket' => 'TOTAL',
+ $verb => $counts['total'],
+ 'rule' => '',
+ ),
+ );
+ WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) );
+ }
+
+ // ── commentmeta-cleanup (v2.12.0 Phase 0) ─────────────────────────────────
+
+ /**
+ * Clean wp_commentmeta garbage rows.
+ *
+ * Targets:
+ * - wxr_import — `_wxr_import_*` rows (often dominant — dev10 had 211)
+ * - demo_data — `_2meet_demo_*` rows
+ * - transients — `_transient_*` cache rows
+ * - orphan_post_meta — Stray post-domain keys (`_hp_price`, `_thumbnail_id`,
+ * etc.) mistakenly written to commentmeta
+ *
+ * SAFETY: by default refuses to run. Pass --dry-run to preview, or
+ * --confirm to actually delete.
+ *
+ * ## OPTIONS
+ *
+ * [--target=]
+ * : Which garbage class to address. Default: all.
+ * ---
+ * default: all
+ * options:
+ * - all
+ * - wxr_import
+ * - demo_data
+ * - transients
+ * - orphan_post_meta
+ * ---
+ *
+ * [--dry-run]
+ * : Show row counts without deleting.
+ *
+ * [--confirm]
+ * : Required to actually DELETE rows.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo commentmeta-cleanup --dry-run
+ * wp wpdo commentmeta-cleanup --target=wxr_import --confirm
+ * wp wpdo commentmeta-cleanup --confirm
+ *
+ * @param array $args Positional arguments (unused).
+ * @param array $assoc_args Named arguments.
+ */
+ public function commentmeta_cleanup( $args, $assoc_args ): void {
+ $target = (string) ( $assoc_args['target'] ?? TMDO_Commentmeta_Cleaner::TARGET_ALL );
+ $dry_run = isset( $assoc_args['dry-run'] );
+ $confirm = isset( $assoc_args['confirm'] );
+
+ if ( ! in_array( $target, TMDO_Commentmeta_Cleaner::VALID_TARGETS, true ) ) {
+ WP_CLI::error(
+ 'Invalid --target. Choose: ' . implode( ', ', TMDO_Commentmeta_Cleaner::VALID_TARGETS )
+ );
+ }
+
+ 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 commentmeta-cleanup --dry-run'
+ );
+ }
+
+ if ( $dry_run ) {
+ $counts = TMDO_Commentmeta_Cleaner::count_garbage( $target );
+ self::render_comment_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;
+ }
+
+ $deleted = TMDO_Commentmeta_Cleaner::delete_garbage( $target );
+ self::render_comment_table( $deleted, 'deleted' );
+
+ if ( class_exists( 'TMDO_Logger' ) ) {
+ TMDO_Logger::info(
+ 'commentmeta_cleanup',
+ array(
+ 'target' => $target,
+ 'wxr_import' => $deleted['wxr_import'],
+ 'demo_data' => $deleted['demo_data'],
+ 'transients' => $deleted['transients'],
+ 'orphan_post_meta' => $deleted['orphan_post_meta'],
+ 'total' => $deleted['total'],
+ )
+ );
+ }
+
+ WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_commentmeta.', $deleted['total'] ) );
+ }
+
+ /**
+ * Render a table for commentmeta cleanup output.
+ *
+ * @param array $counts Output from TMDO_Commentmeta_Cleaner::count_garbage / delete_garbage.
+ * @param string $verb Column header verb.
+ */
+ private static function render_comment_table( array $counts, string $verb ): void {
+ $rows = array(
+ array(
+ 'bucket' => 'wxr_import',
+ $verb => $counts['wxr_import'],
+ 'rule' => '_wxr_import_*',
+ ),
+ array(
+ 'bucket' => 'demo_data',
+ $verb => $counts['demo_data'],
+ 'rule' => '_2meet_demo_*',
+ ),
+ array(
+ 'bucket' => 'transients',
+ $verb => $counts['transients'],
+ 'rule' => '_transient_% OR _transient_timeout_%',
+ ),
+ array(
+ 'bucket' => 'orphan_post_meta',
+ $verb => $counts['orphan_post_meta'],
+ 'rule' => '_hp_price / _hp_status / _thumbnail_id / etc.',
+ ),
+ array(
+ 'bucket' => 'TOTAL',
+ $verb => $counts['total'],
+ 'rule' => '',
+ ),
+ );
+ WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) );
+ }
+
+ // ── term-promote-mode (v2.12.5 Phase 5) ───────────────────────────────────
+
+ /**
+ * Promote term entity mode through the FSM safe path.
+ *
+ * Wraps TMDO_Mode_Manager::set('term', $new_mode). Mode_Manager enforces
+ * one-step-at-a-time promotion (disabled → dual_write → shadow_read →
+ * aeav_only); this CLI provides friendly UX + validation.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Target mode. One of: disabled, dual_write, shadow_read, aeav_only.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo term-promote-mode dual_write
+ * wp wpdo term-promote-mode shadow_read
+ * wp wpdo term-promote-mode aeav_only
+ *
+ * @param array $args Positional arguments — [new_mode].
+ * @param array $assoc_args Named arguments (unused).
+ */
+ public function term_promote_mode( $args, $assoc_args ): void {
+ self::promote_mode( 'term', $args, $assoc_args );
+ }
+
+ // ── comment-promote-mode (v2.12.5 Phase 5) ────────────────────────────────
+
+ /**
+ * Promote comment entity mode through the FSM safe path.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Target mode. One of: disabled, dual_write, shadow_read, aeav_only.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo comment-promote-mode dual_write
+ * wp wpdo comment-promote-mode shadow_read
+ *
+ * @param array $args Positional arguments — [new_mode].
+ * @param array $assoc_args Named arguments (unused).
+ */
+ public function comment_promote_mode( $args, $assoc_args ): void {
+ self::promote_mode( 'comment', $args, $assoc_args );
+ }
+
+ /**
+ * Shared promote-mode implementation for both term and comment.
+ *
+ * @param string $entity_type 'term' or 'comment'.
+ * @param array $args Positional arguments — [new_mode].
+ * @param array $assoc_args Named arguments (unused).
+ * @return void
+ */
+ private static function promote_mode( string $entity_type, array $args, array $assoc_args ): void {
+ unset( $assoc_args );
+ if ( empty( $args[0] ) ) {
+ WP_CLI::error( "Missing new_mode argument. Usage: wp wpdo {$entity_type}-promote-mode " );
+ }
+ $new_mode = (string) $args[0];
+
+ if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
+ WP_CLI::error( 'TMDO_Mode_Manager not loaded.' );
+ }
+
+ $current = TMDO_Mode_Manager::get( $entity_type );
+ WP_CLI::log( "Current {$entity_type} mode: {$current}" );
+ WP_CLI::log( "Target {$entity_type} mode: {$new_mode}" );
+
+ $result = TMDO_Mode_Manager::set( $entity_type, $new_mode );
+ if ( true !== $result ) {
+ $msg = $result instanceof \WP_Error ? $result->get_error_message() : 'Unknown error';
+ WP_CLI::error( "Mode promotion failed: {$msg}" );
+ }
+
+ WP_CLI::success( "Promoted {$entity_type} mode {$current} → {$new_mode}" );
+
+ // Hint next step.
+ $advice = self::next_promotion_hint( $new_mode );
+ if ( '' !== $advice ) {
+ WP_CLI::log( '' );
+ WP_CLI::log( $advice );
+ }
+ }
+
+ /**
+ * Suggest the next manual step after a mode promotion.
+ *
+ * @param string $new_mode Just-set mode.
+ * @return string
+ */
+ private static function next_promotion_hint( string $new_mode ): string {
+ switch ( $new_mode ) {
+ case 'dual_write':
+ return 'Next: observe write paths for ≥ 24h, then promote to shadow_read with `-promote-mode shadow_read`.';
+ case 'shadow_read':
+ return 'Next: run `wp wpdo term-comment-shadow-report` periodically; verify drift_total = 0 over 24h, then promote to aeav_only.';
+ case 'aeav_only':
+ return 'Next: monitor wp_termmeta / wp_commentmeta row growth — should be 0 for managed keys. Cleanup historical rows with `termmeta-cleanup` / `commentmeta-cleanup`.';
+ default:
+ return '';
+ }
+ }
+
+ // ── term-comment-diagnose (v2.12.5 Phase 5) ───────────────────────────────
+
+ /**
+ * Read-only diagnostic report of term + comment entity bridge state.
+ *
+ * Shows current modes, ratio, registered group keys, flat table row counts.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo term-comment-diagnose
+ *
+ * @param array $args Positional arguments (unused).
+ * @param array $assoc_args Named arguments (unused).
+ */
+ public function term_comment_diagnose( $args, $assoc_args ): void {
+ unset( $args, $assoc_args );
+ global $wpdb;
+
+ $term_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'term' ) : 'unavailable';
+ $comment_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'comment' ) : 'unavailable';
+
+ $terms_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->terms}" );
+ $termmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta}" );
+ $comments_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comments}" );
+ $commentmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->commentmeta}" );
+
+ WP_CLI::log( '── Term entity ──' );
+ WP_CLI::log( sprintf( 'Mode: %s', $term_mode ) );
+ WP_CLI::log( sprintf( 'wp_terms: %s', number_format_i18n( $terms_count ) ) );
+ WP_CLI::log( sprintf( 'wp_termmeta: %s (1:%s)', number_format_i18n( $termmeta_count ), $terms_count > 0 ? round( $termmeta_count / $terms_count, 2 ) : 'n/a' ) );
+ self::print_flat_row( $wpdb->prefix . 'wpdo_term_hp_taxonomy', 'wpdo_term_hp_taxonomy' );
+ self::print_flat_row( $wpdb->prefix . 'wpdo_term_misc', 'wpdo_term_misc' );
+
+ WP_CLI::log( '' );
+ WP_CLI::log( '── Comment entity ──' );
+ WP_CLI::log( sprintf( 'Mode: %s', $comment_mode ) );
+ WP_CLI::log( sprintf( 'wp_comments: %s', number_format_i18n( $comments_count ) ) );
+ WP_CLI::log( sprintf( 'wp_commentmeta: %s (1:%s)', number_format_i18n( $commentmeta_count ), $comments_count > 0 ? round( $commentmeta_count / $comments_count, 2 ) : 'n/a' ) );
+ self::print_flat_row( $wpdb->prefix . 'wpdo_comment_hp_review', 'wpdo_comment_hp_review' );
+ self::print_flat_row( $wpdb->prefix . 'wpdo_comment_misc', 'wpdo_comment_misc' );
+
+ WP_CLI::log( '' );
+ WP_CLI::log( '── Filter chain status ──' );
+ self::print_filter_status( 'wpdo_term_comment_garbage_filter_enabled', 'Garbage filter (Phase 1)' );
+ self::print_filter_status( 'wpdo_wc_term_count_filter_enabled', 'WC term count filter (Phase 3)' );
+ self::print_filter_status( 'wpdo_term_comment_misc_bucket_enabled', 'Misc bucket (Phase 4)' );
+
+ WP_CLI::log( '' );
+ WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) );
+ }
+
+ /**
+ * Recommend next ops action based on current modes.
+ *
+ * @param string $term_mode Term entity mode.
+ * @param string $comment_mode Comment entity mode.
+ * @return string
+ */
+ private static function recommend_next_action( string $term_mode, string $comment_mode ): string {
+ if ( 'aeav_only' === $term_mode && 'aeav_only' === $comment_mode ) {
+ return 'Both entities at aeav_only ✓. Run `termmeta-cleanup` / `commentmeta-cleanup` to drop historical wp_*meta rows; v3.0.0 will DROP the tables.';
+ }
+ if ( 'shadow_read' === $term_mode || 'shadow_read' === $comment_mode ) {
+ return 'Run `term-comment-shadow-report` to verify drift = 0 over 24h before promoting to aeav_only.';
+ }
+ if ( 'dual_write' === $term_mode || 'dual_write' === $comment_mode ) {
+ return 'Promote to shadow_read when ready: `term-promote-mode shadow_read` / `comment-promote-mode shadow_read`.';
+ }
+ return 'Both entities at disabled. Promote to dual_write first to enable Hook Bus interception.';
+ }
+
+ /**
+ * Print a flat table's row count (or "(missing)" when not present).
+ *
+ * @param string $table Fully-qualified table name.
+ * @param string $label Human-readable name.
+ * @return void
+ */
+ private static function print_flat_row( string $table, string $label ): void {
+ global $wpdb;
+ $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
+ if ( ! $exists ) {
+ WP_CLI::log( sprintf( '%-22s%s', $label . ':', '(missing)' ) );
+ return;
+ }
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ WP_CLI::log( sprintf( '%-22s%s rows', $label . ':', number_format_i18n( $count ) ) );
+ }
+
+ /**
+ * Print a filter's enabled/disabled status.
+ *
+ * @param string $option_key Option storing the toggle value.
+ * @param string $label Human-readable name.
+ * @return void
+ */
+ private static function print_filter_status( string $option_key, string $label ): void {
+ $enabled = (bool) get_option( $option_key, '1' );
+ WP_CLI::log( sprintf( '%-32s%s', $label . ':', $enabled ? '✓ enabled' : '✗ disabled' ) );
+ }
+
+ // ── term-comment-shadow-report (v2.12.5 Phase 5) ──────────────────────────
+
+ /**
+ * Run the shadow verifier on demand and print a report.
+ *
+ * Uses a configurable sample size (default 100). Each registered term +
+ * comment entity group is sampled; per-group results are tabulated.
+ *
+ * ## OPTIONS
+ *
+ * [--samples=]
+ * : Number of entities to sample per group. Default: 100.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo term-comment-shadow-report
+ * wp wpdo term-comment-shadow-report --samples=500
+ *
+ * @param array $args Positional arguments (unused).
+ * @param array $assoc_args Named arguments.
+ */
+ public function term_comment_shadow_report( $args, $assoc_args ): void {
+ unset( $args );
+ if ( ! class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) ) {
+ WP_CLI::error( 'TMDO_Term_Comment_Shadow_Verifier not loaded.' );
+ }
+ $samples = isset( $assoc_args['samples'] ) ? max( 1, (int) $assoc_args['samples'] ) : 100;
+
+ WP_CLI::log( "Running shadow verifier (sample_size={$samples} per group)…" );
+ WP_CLI::log( '' );
+
+ $results = TMDO_Term_Comment_Shadow_Verifier::run_all( $samples );
+ if ( empty( $results ) ) {
+ WP_CLI::warning( 'No groups registered or registry unavailable.' );
+ return;
+ }
+
+ $rows = array();
+ foreach ( $results as $group => $r ) {
+ if ( isset( $r['error'] ) ) {
+ $rows[] = array(
+ 'group' => $group,
+ 'entity' => '?',
+ 'sampled' => 0,
+ 'matched' => 0,
+ 'diffs' => 0,
+ 'missing_flat' => 0,
+ 'missing_meta' => 0,
+ 'note' => $r['error'],
+ );
+ continue;
+ }
+ $rows[] = array(
+ 'group' => $r['group'],
+ 'entity' => $r['entity_type'],
+ 'sampled' => $r['sampled'],
+ 'matched' => $r['matched'],
+ 'diffs' => $r['diffs'],
+ 'missing_flat' => $r['missing_flat'],
+ 'missing_meta' => $r['missing_meta'],
+ 'note' => 0 === $r['diffs'] && 0 === $r['missing_flat'] && 0 === $r['missing_meta'] ? '✓ clean' : '⚠ drift',
+ );
+ }
+
+ WP_CLI\Utils\format_items(
+ 'table',
+ $rows,
+ array( 'group', 'entity', 'sampled', 'matched', 'diffs', 'missing_flat', 'missing_meta', 'note' )
+ );
+
+ // Total drift summary.
+ $total_drift = 0;
+ foreach ( $results as $r ) {
+ if ( isset( $r['error'] ) ) {
+ continue;
+ }
+ $total_drift += $r['diffs'] + $r['missing_flat'] + $r['missing_meta'];
+ }
+ WP_CLI::log( '' );
+ if ( 0 === $total_drift ) {
+ WP_CLI::success( 'Shadow verifier: 0 drift. Safe to promote shadow_read → aeav_only.' );
+ } else {
+ WP_CLI::warning( "Shadow verifier: {$total_drift} drift. Investigate before promoting." );
+ }
+ }
+
+ // ── term-comment-backfill (v2.12.6 Phase 6) ───────────────────────────────
+
+ /**
+ * Backfill historical wp_termmeta / wp_commentmeta into flat tables.
+ *
+ * Pivot SQL one-shot per registered group. After v2.12.x activation,
+ * write-time interception covers new updates — but legacy rows that
+ * predate v2.12.x are still in wp_*meta only. This command reads them
+ * and INSERTs into the corresponding flat table (ON DUPLICATE KEY UPDATE
+ * with COALESCE to preserve any flat-only values).
+ *
+ * Safe to re-run: idempotent thanks to ON DUPLICATE KEY UPDATE.
+ *
+ * SAFETY: --dry-run / --confirm required (default refuses).
+ *
+ * ## OPTIONS
+ *
+ * [--group=]
+ * : Restrict to one group. Default: all.
+ * ---
+ * default: all
+ * options:
+ * - all
+ * - hp_taxonomy
+ * - hp_review
+ * ---
+ *
+ * [--dry-run]
+ * : Show candidate counts without writing.
+ *
+ * [--confirm]
+ * : Required to actually backfill. Mutually exclusive with --dry-run.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo term-comment-backfill --dry-run
+ * wp wpdo term-comment-backfill --confirm
+ * wp wpdo term-comment-backfill --group=hp_taxonomy --confirm
+ *
+ * @param array $args Positional arguments (unused).
+ * @param array $assoc_args Named arguments.
+ */
+ public function term_comment_backfill( $args, $assoc_args ): void {
+ unset( $args );
+ $group = isset( $assoc_args['group'] ) ? (string) $assoc_args['group'] : 'all';
+ $dry_run = isset( $assoc_args['dry-run'] );
+ $confirm = isset( $assoc_args['confirm'] );
+
+ if ( ! class_exists( 'TMDO_Term_Comment_Backfill' ) ) {
+ WP_CLI::error( 'TMDO_Term_Comment_Backfill not loaded.' );
+ }
+ 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 term-comment-backfill --dry-run'
+ );
+ }
+
+ $valid_groups = array( 'all', 'hp_taxonomy', 'hp_review' );
+ if ( ! in_array( $group, $valid_groups, true ) ) {
+ WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid_groups ) );
+ }
+
+ // Run backfill.
+ if ( 'all' === $group ) {
+ $results = TMDO_Term_Comment_Backfill::backfill_all( $dry_run );
+ } else {
+ $entity_type = 'hp_review' === $group ? 'comment' : 'term';
+ $results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $entity_type, $group, $dry_run ) );
+ }
+
+ // Render table.
+ $rows = array();
+ $total_candidates = 0;
+ $total_written = 0;
+ foreach ( $results as $r ) {
+ $rows[] = array(
+ 'group' => $r['group'],
+ 'entity' => $r['entity_type'],
+ 'candidates' => $r['candidates'],
+ 'written' => $r['written'],
+ 'note' => $r['error'] ?? ( $r['dry_run'] ? '[dry-run]' : '✓' ),
+ );
+ $total_candidates += (int) $r['candidates'];
+ $total_written += (int) $r['written'];
+ }
+ WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'entity', 'candidates', 'written', 'note' ) );
+
+ if ( class_exists( 'TMDO_Logger' ) && ! $dry_run ) {
+ TMDO_Logger::info(
+ 'term_comment_backfill',
+ array(
+ 'group' => $group,
+ 'total_candidates' => $total_candidates,
+ 'total_written' => $total_written,
+ )
+ );
+ }
+
+ if ( $dry_run ) {
+ WP_CLI::success(
+ sprintf(
+ '[dry-run] %d candidate row(s) across %d group(s). Re-run with --confirm to apply.',
+ $total_candidates,
+ count( $results )
+ )
+ );
+ } else {
+ WP_CLI::success(
+ sprintf(
+ 'Backfilled %d row(s) across %d group(s). Run `wp wpdo term-comment-shadow-report` to verify drift = 0.',
+ $total_written,
+ count( $results )
+ )
+ );
+ }
+ }
+
+ // ── comment-stress-test (v2.13.2) ─────────────────────────────────────────
+
+ /**
+ * Run / inspect the Comment Stress Tester (CLI equivalent of admin tab).
+ *
+ * Wraps TMDO_Comment_Stress_Tester static API. Supports five subcommands:
+ * status / start / cancel / cleanup / benchmark.
+ *
+ * `start` runs synchronously: it loops `run_batch()` until the state machine
+ * reports `completed` / `failed` / `cancelled` (mirroring how the cron + REST
+ * polling drives the same state machine). For very large targets, prefer the
+ * admin tab so the work happens via WP-Cron and you can watch progress.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : One of: status, start, cancel, cleanup, benchmark.
+ *
+ * [--post-id=]
+ * : Target post ID for `start` (comments will be attached to this post).
+ *
+ * [--target=]
+ * : Number of comments to create for `start`. Default: 100.
+ *
+ * [--mode=]
+ * : Write mode for `start`. Default: fast.
+ * ---
+ * default: fast
+ * options:
+ * - fast
+ * - realistic
+ * ---
+ *
+ * [--batch-size=]
+ * : Batch size for `start`. Default: 200 (fast) / 20 (realistic recommended).
+ *
+ * [--yes]
+ * : Skip cleanup confirmation prompt.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo comment-stress-test status
+ * wp wpdo comment-stress-test start --post-id=50 --target=100 --mode=fast
+ * wp wpdo comment-stress-test start --post-id=50 --target=5 --mode=realistic --batch-size=5
+ * wp wpdo comment-stress-test cancel
+ * wp wpdo comment-stress-test cleanup --yes
+ * wp wpdo comment-stress-test benchmark
+ *
+ * @param array $args Positional arguments — [subcommand].
+ * @param array $assoc_args Named arguments.
+ */
+ public function comment_stress_test( $args, $assoc_args ): void {
+ if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
+ WP_CLI::error( 'TMDO_Comment_Stress_Tester not loaded.' );
+ }
+ if ( empty( $args[0] ) ) {
+ WP_CLI::error( 'Missing . One of: status, start, cancel, cleanup, benchmark.' );
+ }
+ $sub = strtolower( (string) $args[0] );
+
+ switch ( $sub ) {
+ case 'status':
+ self::cst_status();
+ return;
+ case 'start':
+ self::cst_start( $assoc_args );
+ return;
+ case 'cancel':
+ self::cst_cancel();
+ return;
+ case 'cleanup':
+ self::cst_cleanup( $assoc_args );
+ return;
+ case 'benchmark':
+ self::cst_benchmark();
+ return;
+ default:
+ WP_CLI::error( "Unknown subcommand '{$sub}'. One of: status, start, cancel, cleanup, benchmark." );
+ }
+ }
+
+ /**
+ * Print current state machine snapshot.
+ *
+ * @return void
+ */
+ private static function cst_status(): void {
+ $progress = TMDO_Comment_Stress_Tester::get_progress( false );
+ $rows = array(
+ array(
+ 'field' => 'status',
+ 'value' => (string) ( $progress['status'] ?? 'idle' ),
+ ),
+ array(
+ 'field' => 'post_id',
+ 'value' => (string) ( $progress['post_id'] ?? '—' ),
+ ),
+ array(
+ 'field' => 'mode',
+ 'value' => (string) ( $progress['mode'] ?? '—' ),
+ ),
+ array(
+ 'field' => 'processed',
+ 'value' => (string) ( $progress['processed'] ?? 0 ),
+ ),
+ array(
+ 'field' => 'target',
+ 'value' => (string) ( $progress['target'] ?? 0 ),
+ ),
+ array(
+ 'field' => 'pct',
+ 'value' => ( (string) ( $progress['pct'] ?? 0 ) ) . '%',
+ ),
+ array(
+ 'field' => 'rate_per_sec',
+ 'value' => (string) ( $progress['rate_per_sec'] ?? 0 ),
+ ),
+ array(
+ 'field' => 'elapsed_sec',
+ 'value' => (string) ( $progress['elapsed_sec'] ?? 0 ),
+ ),
+ array(
+ 'field' => 'eta_sec',
+ 'value' => (string) ( $progress['eta_sec'] ?? 0 ),
+ ),
+ array(
+ 'field' => 'batches_done',
+ 'value' => (string) ( $progress['batches_done'] ?? 0 ),
+ ),
+ array(
+ 'field' => 'test_comment_count',
+ 'value' => (string) ( $progress['test_comment_count'] ?? 0 ),
+ ),
+ );
+ WP_CLI\Utils\format_items( 'table', $rows, array( 'field', 'value' ) );
+ }
+
+ /**
+ * Start a stress test run synchronously (loops run_batch until terminal).
+ *
+ * @param array $assoc_args CLI args (post-id / target / mode / batch-size).
+ * @return void
+ */
+ private static function cst_start( array $assoc_args ): void {
+ $post_id = (int) ( $assoc_args['post-id'] ?? 0 );
+ $target = (int) ( $assoc_args['target'] ?? 100 );
+ $mode = (string) ( $assoc_args['mode'] ?? 'fast' );
+ $batch = isset( $assoc_args['batch-size'] )
+ ? (int) $assoc_args['batch-size']
+ : ( 'realistic' === $mode ? 20 : TMDO_Comment_Stress_Tester::DEFAULT_BATCH_SIZE );
+
+ if ( $post_id < 1 ) {
+ WP_CLI::error( 'Missing or invalid --post-id (must be >= 1).' );
+ }
+ if ( $target < 1 || $target > TMDO_Comment_Stress_Tester::MAX_COUNT ) {
+ WP_CLI::error( '--target must be between 1 and ' . TMDO_Comment_Stress_Tester::MAX_COUNT . '.' );
+ }
+
+ $result = TMDO_Comment_Stress_Tester::start( $post_id, $target, $mode, $batch );
+ if ( empty( $result['ok'] ) ) {
+ WP_CLI::error( 'Failed to start: ' . ( $result['error'] ?? 'unknown' ) );
+ }
+
+ WP_CLI::log( "Started: post_id={$post_id} target={$target} mode={$mode} batch_size={$batch}" );
+
+ // Drain the state machine synchronously (CLI equivalent of cron pump).
+ $max_loops = 5000; // Safety cap to avoid runaway loop on bad state.
+ $loops = 0;
+ do {
+ TMDO_Comment_Stress_Tester::run_batch();
+ $progress = TMDO_Comment_Stress_Tester::get_progress( false );
+ $status = (string) ( $progress['status'] ?? 'idle' );
+
+ if ( in_array( $status, array( 'completed', 'failed', 'cancelled' ), true ) ) {
+ break;
+ }
+
+ WP_CLI::log(
+ sprintf(
+ ' batch %d done / processed %d / %d (%s%%) — %s',
+ (int) ( $progress['batches_done'] ?? 0 ),
+ (int) ( $progress['processed'] ?? 0 ),
+ (int) ( $progress['target'] ?? 0 ),
+ (string) ( $progress['pct'] ?? 0 ),
+ $status
+ )
+ );
+
+ ++$loops;
+ } while ( $loops < $max_loops );
+
+ $final = TMDO_Comment_Stress_Tester::get_progress( false );
+ WP_CLI::success(
+ sprintf(
+ 'Run %s — created %d / %d comment(s) in %d batch(es), %ss elapsed.',
+ (string) ( $final['status'] ?? 'unknown' ),
+ (int) ( $final['processed'] ?? 0 ),
+ (int) ( $final['target'] ?? 0 ),
+ (int) ( $final['batches_done'] ?? 0 ),
+ (string) ( $final['elapsed_sec'] ?? 0 )
+ )
+ );
+ }
+
+ /**
+ * Cancel an in-flight run.
+ *
+ * @return void
+ */
+ private static function cst_cancel(): void {
+ $result = TMDO_Comment_Stress_Tester::cancel();
+ if ( empty( $result['ok'] ) ) {
+ WP_CLI::error( 'Cancel failed: ' . ( $result['error'] ?? 'unknown' ) );
+ }
+ if ( isset( $result['message'] ) && 'no_active_job' === $result['message'] ) {
+ WP_CLI::warning( 'No active job to cancel.' );
+ return;
+ }
+ WP_CLI::success( 'Cancellation flagged. The next batch tick will mark state as cancelled.' );
+ }
+
+ /**
+ * Delete all stress-test comments + cascade.
+ *
+ * @param array $assoc_args CLI args (--yes to skip prompt).
+ * @return void
+ */
+ private static function cst_cleanup( array $assoc_args ): void {
+ $count = TMDO_Comment_Stress_Tester::count_test_comments();
+ if ( 0 === $count ) {
+ WP_CLI::success( 'No stress-test comments to clean up.' );
+ return;
+ }
+
+ if ( empty( $assoc_args['yes'] ) ) {
+ WP_CLI::confirm(
+ sprintf(
+ 'About to DELETE %d test comment(s) (email LIKE %%@%s) and their wp_commentmeta + flat-table rows. Continue?',
+ $count,
+ TMDO_Comment_Stress_Tester::TEST_EMAIL_DOMAIN
+ )
+ );
+ }
+
+ $result = TMDO_Comment_Stress_Tester::cleanup();
+ delete_option( TMDO_Comment_Stress_Tester::OPT_STATE );
+ delete_transient( TMDO_Comment_Stress_Tester::CANCEL_FLAG );
+
+ WP_CLI::success(
+ sprintf(
+ 'Deleted %d comment(s), %d commentmeta row(s), %d flat row(s).',
+ (int) ( $result['deleted_comments'] ?? 0 ),
+ (int) ( $result['deleted_meta'] ?? 0 ),
+ (int) ( $result['deleted_flat'] ?? 0 )
+ )
+ );
+ }
+
+ /**
+ * Run benchmark report (write metrics + DB sizes + query perf).
+ *
+ * @return void
+ */
+ private static function cst_benchmark(): void {
+ $state = TMDO_Comment_Stress_Tester::get_state();
+ $report = TMDO_Comment_Stress_Tester::run_benchmark( $state ?: null );
+ $query = $report['query'] ?? array();
+ $db_sizes = $report['db_sizes'] ?? array();
+
+ WP_CLI::log( '▍ Query performance' );
+ $rows = array();
+ foreach ( $query as $key => $v ) {
+ $rows[] = array(
+ 'probe' => (string) $key,
+ 'duration_ms' => (string) ( $v['duration_ms'] ?? '—' ),
+ );
+ }
+ WP_CLI\Utils\format_items( 'table', $rows, array( 'probe', 'duration_ms' ) );
+
+ WP_CLI::log( '▍ DB sizes (comment-related)' );
+ $db_rows = array();
+ foreach ( $db_sizes as $r ) {
+ $db_rows[] = array(
+ 'table' => (string) ( $r['table'] ?? '' ),
+ 'rows' => (string) ( $r['rows'] ?? 0 ),
+ 'data_mb' => (string) ( $r['data_mb'] ?? '—' ),
+ 'index_mb' => (string) ( $r['index_mb'] ?? '—' ),
+ 'total_mb' => (string) ( $r['total_mb'] ?? '—' ),
+ );
+ }
+ WP_CLI\Utils\format_items( 'table', $db_rows, array( 'table', 'rows', 'data_mb', 'index_mb', 'total_mb' ) );
+
+ WP_CLI::success( 'Benchmark complete.' );
+ }
+}
+
+// ── Register subcommands ──────────────────────────────────────────────────────
+// (Originally class closed here in v2.12.0; v2.12.5+ adds new methods inside.)
+
+WP_CLI::add_command( 'wpdo termmeta-cleanup', array( 'TMDO_CLI_Term_Comment', 'termmeta_cleanup' ) );
+WP_CLI::add_command( 'wpdo commentmeta-cleanup', array( 'TMDO_CLI_Term_Comment', 'commentmeta_cleanup' ) );
+WP_CLI::add_command( 'wpdo term-promote-mode', array( 'TMDO_CLI_Term_Comment', 'term_promote_mode' ) );
+WP_CLI::add_command( 'wpdo comment-promote-mode', array( 'TMDO_CLI_Term_Comment', 'comment_promote_mode' ) );
+WP_CLI::add_command( 'wpdo term-comment-diagnose', array( 'TMDO_CLI_Term_Comment', 'term_comment_diagnose' ) );
+WP_CLI::add_command( 'wpdo term-comment-shadow-report', array( 'TMDO_CLI_Term_Comment', 'term_comment_shadow_report' ) );
+WP_CLI::add_command( 'wpdo term-comment-backfill', array( 'TMDO_CLI_Term_Comment', 'term_comment_backfill' ) );
+WP_CLI::add_command( 'wpdo comment-stress-test', array( 'TMDO_CLI_Term_Comment', 'comment_stress_test' ) );
diff --git a/cli/class-tmdo-cli-v2.php b/cli/class-tmdo-cli-v2.php
new file mode 100644
index 0000000..3b18c9c
--- /dev/null
+++ b/cli/class-tmdo-cli-v2.php
@@ -0,0 +1,795 @@
+ — Toggle TMDO_Hook_Bus_Bridge.
+ * wp wpdo mode-audit — Per-module state + shadow flags.
+ * wp wpdo mode-set — Set FSM state.
+ * wp wpdo shadow-enable — Enable shadow_read_only sub-flag.
+ * wp wpdo shadow-disable — Disable shadow_read_only.
+ * wp wpdo conflict-scan — Run conflict monitor + emit JSON.
+ * wp wpdo lint --plugin= — 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=]
+ * : 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
+ *
+ *
+ * : 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 ' );
+ }
+ 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 name (e.g. hot_hp_listing)
+ *
+ *
+ * : 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 — 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 ' );
+ }
+ 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 ' );
+ }
+ 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=
+ * : Absolute path to plugin directory.
+ *
+ * [--strict]
+ * : Exit non-zero on any finding.
+ *
+ * [--max-autoload=]
+ * : 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= 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
+ */
+ 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. 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=]
+ * : One of manual|pre_fsm_transition|pre_v2_upgrade|scheduled|pre_uninstall.
+ * ---
+ * default: manual
+ * ---
+ *
+ * [--scope-tables=]
+ * : Comma-separated table list to dump. Empty = all WPDO tables.
+ *
+ * [--scope-entities=]
+ * : Comma-separated entity list (post,user,term,comment) — adds wp_*meta to dump.
+ *
+ * [--notes=]
+ * : Free-form note for the catalog.
+ *
+ * [--retention-days=]
+ * : 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=]
+ * : Filter by trigger type.
+ *
+ * [--limit=]
+ * : Default 50.
+ *
+ * [--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 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 [--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=]
+ * : Older-than threshold (informational; actual TTL stored in row).
+ * ---
+ * default: 30
+ * ---
+ *
+ * [--size-cap-mb=]
+ * : 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 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 ' );
+ }
+ $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
+ */
+ 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=]
+ * : 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=]
+ * : 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' ) );
diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php
new file mode 100644
index 0000000..c32b9a4
--- /dev/null
+++ b/cli/class-tmdo-cli.php
@@ -0,0 +1,1755 @@
+
+ */
+class TMDO_CLI {
+
+ /**
+ * Show status of all modules and zones.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo status
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand status
+ */
+ public function status( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ WP_CLI::log( '=== WP Data Optimizer Status ===' );
+ WP_CLI::log( '' );
+
+ WP_CLI::log( 'Database Engine: ' . ( TMDO_IS_MYSQL ? 'MySQL' : 'SQLite' ) );
+ WP_CLI::log( 'Plugin Version: ' . TMDO_VERSION );
+ WP_CLI::log( 'DB Version: ' . get_option( 'wpdo_db_version', 'N/A' ) );
+ WP_CLI::log( '' );
+
+ $compat = TMDO_Compatibility::check();
+ WP_CLI::log( 'HivePress: ' . ( $compat['hivepress'] ? 'Active' : 'Not found' ) );
+ WP_CLI::log( 'HP Custom Tables: ' . self::format_hpct_status( $compat ) );
+ WP_CLI::log( 'Object Cache: ' . ( wp_using_ext_object_cache() ? 'External' : 'Built-in' ) );
+ WP_CLI::log( '' );
+
+ $stats = TMDO_Schema_Registry::instance()->get_stats();
+ WP_CLI::log( 'Registered Fields:' );
+ WP_CLI::log( " Hot (A): {$stats['hot']}" );
+ WP_CLI::log( " Warm (B): {$stats['warm']}" );
+ WP_CLI::log( " Cold (C): {$stats['cold']}" );
+ WP_CLI::log( " Archive (D): {$stats['archive']}" );
+ WP_CLI::log( " Total: {$stats['total']}" );
+ WP_CLI::log( '' );
+
+ WP_CLI::log( 'Module States:' );
+
+ $hpct_flags = TMDO_Feature_Flags::hpct_modules();
+ if ( ! empty( $hpct_flags ) ) {
+ WP_CLI::log( ' [HPCT Modules]' );
+ foreach ( $hpct_flags as $module => $state ) {
+ WP_CLI::log( " {$module}: {$state}" );
+ }
+ }
+
+ $zone_flags = TMDO_Feature_Flags::zone_modules();
+ if ( ! empty( $zone_flags ) ) {
+ WP_CLI::log( ' [Zone Modules]' );
+ foreach ( $zone_flags as $module => $state ) {
+ WP_CLI::log( " {$module}: {$state}" );
+ }
+ }
+
+ WP_CLI::success( 'Status complete.' );
+ }
+
+ /**
+ * Install or upgrade database tables.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo install
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand install
+ */
+ public function install( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ WP_CLI::log( 'Installing WPDO tables...' );
+ TMDO_Installer::install();
+ WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) );
+ }
+
+ /**
+ * Run health check on all tables.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo doctor
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand doctor
+ */
+ public function doctor( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ global $wpdb;
+
+ WP_CLI::log( 'Running health check...' );
+
+ $required_tables = array(
+ 'wpdo_migrations',
+ 'wpdo_errors',
+ 'wpdo_benchmarks',
+ 'wpdo_warm',
+ 'wpdo_archive',
+ );
+
+ $all_ok = true;
+
+ foreach ( $required_tables as $table ) {
+ $full_name = $wpdb->prefix . $table;
+
+ if ( TMDO_IS_SQLITE ) {
+ $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $full_name ) );
+ } else {
+ $exists = $wpdb->get_var(
+ $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $full_name )
+ );
+ }
+
+ if ( $exists ) {
+ WP_CLI::log( " [OK] {$full_name}" );
+ } else {
+ WP_CLI::warning( " [MISSING] {$full_name}" );
+ $all_ok = false;
+ }
+ }
+
+ // Check dynamic zone tables.
+ $registry = TMDO_Schema_Registry::instance();
+ $hot_types = $registry->get_hot_post_types();
+ foreach ( $hot_types as $pt ) {
+ if ( '' === $pt ) {
+ // Skip the bogus empty-string post_type bucket (legacy artifact pre-v2.1.2 normalization).
+ continue;
+ }
+ $t = TMDO_Zone_Hot::table( $pt );
+ if ( TMDO_IS_SQLITE ) {
+ $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
+ } else {
+ $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
+ }
+ if ( ! $exists ) {
+ WP_CLI::log( " [NOT CREATED] {$t} (will be created on first use)" );
+ continue;
+ }
+
+ // v2.1.2 doctor column-drift check: compare DB columns vs Schema_Registry declared columns.
+ $declared = array_keys( $registry->get_hot_columns( $pt ) );
+ $existing = array();
+ if ( TMDO_IS_MYSQL ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ $rows = $wpdb->get_col( "SHOW COLUMNS FROM `{$t}`" );
+ $existing = is_array( $rows ) ? $rows : array();
+ } else {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ $rows = $wpdb->get_results( "PRAGMA table_info(`{$t}`)", ARRAY_A );
+ foreach ( (array) $rows as $row ) {
+ $existing[] = $row['name'];
+ }
+ }
+ $missing = array_diff( $declared, $existing );
+ if ( empty( $missing ) ) {
+ WP_CLI::log( " [OK] {$t}" );
+ } else {
+ WP_CLI::warning( " [DRIFT] {$t} — missing columns: " . implode( ', ', $missing ) . ' (run a write to trigger ensure_hot_columns auto-fix)' );
+ $all_ok = false;
+ }
+ }
+
+ $cold_types = $registry->get_cold_post_types();
+ foreach ( $cold_types as $pt ) {
+ $t = TMDO_Zone_Cold::table( $pt );
+ if ( TMDO_IS_SQLITE ) {
+ $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
+ } else {
+ $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
+ }
+ WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" );
+ }
+
+ // Check user entity flat tables (v2.5.5+).
+ $user_entity_tables = array(
+ $wpdb->prefix . 'wpdo_user_membership',
+ $wpdb->prefix . 'wpdo_user_activity',
+ $wpdb->prefix . 'wpdo_user_profile',
+ $wpdb->prefix . 'wpdo_user_sso',
+ $wpdb->prefix . 'wpdo_user_points_ledger',
+ $wpdb->prefix . 'wpdo_migration_status',
+ );
+ foreach ( $user_entity_tables as $t ) {
+ if ( TMDO_IS_SQLITE ) {
+ $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
+ } else {
+ $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
+ }
+ if ( $exists ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
+ WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
+ } else {
+ WP_CLI::warning( " [MISSING] {$t} — run \`wp wpdo install\` to create." );
+ $all_ok = false;
+ }
+ }
+
+ // Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
+ if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
+ $ctr = TMDO_Custom_Table_Registry::instance();
+ $tables = $ctr->all();
+
+ if ( ! empty( $tables ) ) {
+ WP_CLI::log( '' );
+ WP_CLI::log( 'Partner plugin custom tables (' . count( $tables ) . ' registered):' );
+
+ $current_provider = '';
+ foreach ( $tables as $cfg ) {
+ $provider = $cfg['provider'];
+ $tbl_raw = $cfg['table_name'];
+ $full_name = $wpdb->prefix . $tbl_raw;
+
+ if ( $provider !== $current_provider ) {
+ WP_CLI::log( " [{$provider}]" );
+ $current_provider = $provider;
+ }
+
+ // Existence check.
+ if ( TMDO_IS_SQLITE ) {
+ $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $full_name ) );
+ } else {
+ $exists = $wpdb->get_var(
+ $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $full_name )
+ );
+ }
+
+ if ( ! $exists ) {
+ WP_CLI::warning( " [MISSING] {$full_name}" );
+ $all_ok = false;
+ continue;
+ }
+
+ // Row count.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$full_name}`" );
+
+ // Optional doctor_callback.
+ // 傳入 table_suffix + rows + full_name 作為 context;多餘參數對 callable 無害。
+ $cb = $cfg['doctor_callback'] ?? null;
+ if ( is_callable( $cb ) ) {
+ try {
+ $result = call_user_func( $cb, $cfg['table_name'], $rows, $full_name );
+ $cb_ok = (bool) ( $result['ok'] ?? true );
+ $cb_msg = (string) ( $result['message'] ?? '' );
+ $status = $cb_ok ? '[OK]' : '[WARN]';
+ $detail = '' !== $cb_msg ? " — {$cb_msg}" : '';
+ $line = " {$status} {$full_name} ({$rows} rows){$detail}";
+ $cb_ok ? WP_CLI::log( $line ) : WP_CLI::warning( $line );
+ } catch ( \Throwable $e ) {
+ WP_CLI::warning( " [WARN] {$full_name} ({$rows} rows) — doctor_callback threw: " . $e->getMessage() );
+ }
+ } else {
+ WP_CLI::log( " [OK] {$full_name} ({$rows} rows)" );
+ }
+ }
+ }
+ }
+
+ // Check recent errors.
+ $errors = TMDO_Logger::get_recent( '', 5 );
+ if ( ! empty( $errors ) ) {
+ WP_CLI::log( '' );
+ WP_CLI::log( 'Recent errors:' );
+ foreach ( $errors as $err ) {
+ WP_CLI::log( " [{$err['module']}] {$err['message']} ({$err['created_at']})" );
+ }
+ }
+
+ if ( $all_ok ) {
+ WP_CLI::success( 'All checks passed.' );
+ } else {
+ WP_CLI::warning( 'Some checks failed. Run `wp wpdo install` to fix.' );
+ }
+ }
+
+ /**
+ * Analyze postmeta and suggest zone classifications.
+ *
+ * ## OPTIONS
+ *
+ * [--post-type=]
+ * : Post type to analyze. If omitted, analyzes hp_listing.
+ *
+ * [--format=]
+ * : Output format (table or json). Default: table.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo analyze --post-type=hp_listing
+ * wp wpdo analyze --post-type=hp_vendor --format=json
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments (post-type, format).
+ * @return void
+ *
+ * @subcommand analyze
+ */
+ public function analyze( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
+ $post_type = sanitize_key( $assoc_args['post-type'] ?? 'hp_listing' );
+ $format = in_array( $assoc_args['format'] ?? 'table', array( 'table', 'json' ), true ) ? $assoc_args['format'] : 'table';
+
+ WP_CLI::log( "Analyzing postmeta for post type: {$post_type}..." );
+
+ $suggestions = TMDO_Zone_Classifier::analyze( $post_type );
+
+ if ( empty( $suggestions ) ) {
+ WP_CLI::warning( "No postmeta found for post type '{$post_type}'." );
+ return;
+ }
+
+ if ( 'json' === $format ) {
+ WP_CLI::log( wp_json_encode( $suggestions, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ) );
+ return;
+ }
+
+ $table_data = array();
+ foreach ( $suggestions as $s ) {
+ $table_data[] = array(
+ 'meta_key' => $s['meta_key'],
+ 'rows' => $s['row_count'],
+ 'suggested' => $s['suggested_zone'],
+ 'confidence' => $s['confidence'],
+ 'assigned' => $s['already_assigned'] ?: '—',
+ 'reason' => implode( '; ', $s['reasons'] ),
+ );
+ }
+
+ WP_CLI\Utils\format_items( 'table', $table_data, array( 'meta_key', 'rows', 'suggested', 'confidence', 'assigned', 'reason' ) );
+
+ $summary = TMDO_Zone_Classifier::summary( $post_type );
+ WP_CLI::log( '' );
+ WP_CLI::log( "Summary: Hot={$summary['hot']}, Warm={$summary['warm']}, Cold={$summary['cold']}, Archive={$summary['archive']}, Already assigned={$summary['already_assigned']}" );
+ WP_CLI::success( 'Analysis complete.' );
+ }
+
+ /**
+ * Run data migration for a module.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Module name (e.g., hot_hp_listing, warm, cold_hp_vendor, archive).
+ *
+ * [--resume]
+ * : Resume an interrupted migration instead of starting fresh.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo migrate hot_hp_listing
+ * wp wpdo migrate warm --resume
+ * wp wpdo migrate archive
+ *
+ * @param array $args Positional arguments (module name).
+ * @param array $assoc_args Associative arguments (resume flag).
+ * @return void
+ *
+ * @subcommand migrate
+ */
+ public function migrate( $args, $assoc_args ): void {
+ $module = sanitize_key( $args[0] ?? '' );
+ $resume = isset( $assoc_args['resume'] );
+
+ if ( empty( $module ) ) {
+ WP_CLI::error( 'Module name required. Examples: hot_hp_listing, warm, cold_hp_vendor, archive' );
+ }
+
+ $migration = self::get_migration_instance( $module );
+ if ( ! $migration ) {
+ WP_CLI::error( "Unknown module: {$module}" );
+ }
+
+ WP_CLI::log( "Starting migration for module: {$module}" . ( $resume ? ' (resuming)' : '' ) );
+
+ $progress = null;
+ $completed = $migration->run(
+ $resume,
+ function ( $processed, $total ) use ( &$progress ) {
+ if ( null === $progress && $total > 0 ) {
+ $progress = \WP_CLI\Utils\make_progress_bar( 'Migrating', $total );
+ }
+ if ( $progress ) {
+ $progress->tick();
+ }
+ }
+ );
+
+ if ( $progress ) {
+ $progress->finish();
+ }
+
+ if ( $completed ) {
+ WP_CLI::success( "Migration complete for {$module}. State: verify. Run `wp wpdo verify {$module}` next." );
+ } else {
+ $record = $migration->get_record();
+ $processed = $record ? (int) $record['processed_rows'] : 0;
+ $total = $record ? (int) $record['total_rows'] : 0;
+ WP_CLI::warning( "Migration timed out ({$processed}/{$total}). Run `wp wpdo migrate {$module} --resume` to continue." );
+ }
+ }
+
+ /**
+ * Verify data consistency for a module.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Module name.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo verify hot_hp_listing
+ *
+ * @param array $args Positional arguments (module name).
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand verify
+ */
+ public function verify( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $module = sanitize_key( $args[0] ?? '' );
+
+ if ( empty( $module ) ) {
+ WP_CLI::error( 'Module name required.' );
+ }
+
+ $migration = self::get_migration_instance( $module );
+ if ( ! $migration ) {
+ WP_CLI::error( "Unknown module: {$module}" );
+ }
+
+ WP_CLI::log( "Verifying data consistency for: {$module}..." );
+
+ if ( $migration->verify_counts() ) {
+ WP_CLI::success( "Verification passed for {$module}. Run `wp wpdo cutover {$module}` to switch reads." );
+ } else {
+ WP_CLI::warning( "Verification failed — row counts don't match. Re-run migration or investigate." );
+ }
+ }
+
+ /**
+ * Switch reads to custom table (cutover).
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Module name.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo cutover hot_hp_listing
+ *
+ * @param array $args Positional arguments (module name).
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand cutover
+ */
+ public function cutover( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $module = sanitize_key( $args[0] ?? '' );
+
+ if ( empty( $module ) ) {
+ WP_CLI::error( 'Module name required.' );
+ }
+
+ $state = TMDO_Feature_Flags::get( $module );
+ if ( ! in_array( $state, array( 'verify', 'dual_write', 'backfill' ), true ) ) {
+ WP_CLI::error( "Module {$module} is in state '{$state}' — cutover requires verify, dual_write, or backfill state." );
+ }
+
+ TMDO_Feature_Flags::set( $module, 'cutover' );
+ WP_CLI::success( "Module {$module} switched to cutover. Reads now come from custom table." );
+ }
+
+ /**
+ * Rollback a module to idle (reads from postmeta).
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Module name.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo rollback hot_hp_listing
+ *
+ * @param array $args Positional arguments (module name).
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand rollback
+ */
+ public function rollback( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $module = sanitize_key( $args[0] ?? '' );
+
+ if ( empty( $module ) ) {
+ WP_CLI::error( 'Module name required.' );
+ }
+
+ WP_CLI::confirm( "Are you sure you want to rollback module '{$module}' to idle?" );
+
+ TMDO_Feature_Flags::reset( $module );
+ WP_CLI::success( "Module {$module} rolled back to idle." );
+ }
+
+ /**
+ * Mark a module as complete (reads and writes on custom table only).
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Module name.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo enable hot_hp_listing
+ *
+ * @param array $args Positional arguments (module name).
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand enable
+ */
+ public function enable( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $module = sanitize_key( $args[0] ?? '' );
+
+ if ( empty( $module ) ) {
+ WP_CLI::error( 'Module name required.' );
+ }
+
+ TMDO_Feature_Flags::set( $module, 'complete' );
+ WP_CLI::success( "Module {$module} set to complete." );
+ }
+
+ /**
+ * Disable a module (set to idle).
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Module name.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo disable hot_hp_listing
+ *
+ * @param array $args Positional arguments (module name).
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand disable
+ */
+ public function disable( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $module = sanitize_key( $args[0] ?? '' );
+
+ if ( empty( $module ) ) {
+ WP_CLI::error( 'Module name required.' );
+ }
+
+ TMDO_Feature_Flags::reset( $module );
+ WP_CLI::success( "Module {$module} disabled (idle)." );
+ }
+
+ /**
+ * Import settings from HP Custom Tables.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo import-hpct
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand import-hpct
+ */
+ public function import_hpct( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ if ( TMDO_HPCT_Import::is_imported() ) {
+ WP_CLI::warning( 'HPCT settings have already been imported.' );
+ return;
+ }
+
+ if ( ! TMDO_HPCT_Import::can_import() ) {
+ WP_CLI::error( 'HP Custom Tables plugin not detected or already imported.' );
+ }
+
+ // Show preview.
+ $preview_data = TMDO_HPCT_Import::preview();
+ if ( ! empty( $preview_data['modules'] ) ) {
+ WP_CLI::log( 'Import preview:' );
+ WP_CLI\Utils\format_items( 'table', $preview_data['modules'], array( 'module', 'hpct_status', 'wpdo_state' ) );
+ }
+
+ WP_CLI::confirm( 'Proceed with import?' );
+
+ $result = TMDO_HPCT_Import::run();
+ if ( is_wp_error( $result ) ) {
+ WP_CLI::error( $result->get_error_message() );
+ }
+
+ WP_CLI::success( 'HPCT settings imported successfully. You can now deactivate HP Custom Tables.' );
+ }
+
+ /**
+ * Run performance benchmark.
+ *
+ * ## OPTIONS
+ *
+ * []
+ * : Optional module name to benchmark.
+ *
+ * [--samples=]
+ * : Number of samples. Default: 50.
+ *
+ * [--custom-tables]
+ * : Also benchmark all partner plugin custom tables registered in TMDO_Custom_Table_Registry.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo benchmark
+ * wp wpdo benchmark hot_hp_listing --samples=100
+ * wp wpdo benchmark --custom-tables
+ *
+ * @param array $args Positional arguments (optional module name).
+ * @param array $assoc_args Associative arguments (samples, custom-tables).
+ * @return void
+ *
+ * @subcommand benchmark
+ */
+ public function benchmark( $args, $assoc_args ): void {
+ $module = sanitize_key( $args[0] ?? '' );
+ $samples = absint( $assoc_args['samples'] ?? 50 );
+ $custom_tables = ! empty( $assoc_args['custom-tables'] );
+
+ WP_CLI::log( 'Running benchmark' . ( $module ? " for {$module}" : '' ) . " ({$samples} samples)..." );
+
+ global $wpdb;
+
+ // Benchmark postmeta reads vs zone reads.
+ $registry = TMDO_Schema_Registry::instance();
+ $hot_types = $registry->get_hot_post_types();
+
+ if ( empty( $hot_types ) ) {
+ WP_CLI::warning( 'No hot zone post types registered. Register fields first.' );
+ return;
+ }
+
+ foreach ( $hot_types as $pt ) {
+ if ( $module && "hot_{$pt}" !== $module ) {
+ continue;
+ }
+
+ $columns = $registry->get_hot_columns( $pt );
+ if ( empty( $columns ) ) {
+ continue;
+ }
+
+ // Get sample post IDs.
+ $post_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' LIMIT %d",
+ $pt,
+ $samples
+ )
+ );
+
+ if ( empty( $post_ids ) ) {
+ WP_CLI::log( " {$pt}: No published posts found." );
+ continue;
+ }
+
+ $fields = $registry->get_zone_fields_for_type( 'hot', $pt );
+ $first_field = reset( $fields );
+ $meta_key = $first_field['meta_key'];
+ $column = $first_field['column'];
+
+ // Native benchmark.
+ $start = microtime( true );
+ foreach ( $post_ids as $pid ) {
+ get_post_meta( (int) $pid, $meta_key, true );
+ }
+ $native_ms = ( microtime( true ) - $start ) * 1000;
+
+ // Zone benchmark.
+ $start = microtime( true );
+ foreach ( $post_ids as $pid ) {
+ TMDO_Zone_Hot::get( (int) $pid, $pt, $column );
+ }
+ $zone_ms = ( microtime( true ) - $start ) * 1000;
+
+ $speedup = $native_ms > 0 ? round( $native_ms / max( $zone_ms, 0.001 ), 1 ) : 'N/A';
+
+ WP_CLI::log( " hot_{$pt} ({$meta_key}):" );
+ WP_CLI::log( ' Native (postmeta): ' . round( $native_ms, 2 ) . ' ms' );
+ WP_CLI::log( ' Zone A (hot): ' . round( $zone_ms, 2 ) . ' ms' );
+ WP_CLI::log( " Speedup: {$speedup}x" );
+
+ // Save to benchmarks table.
+ $bench_table = TMDO_DB::table( 'wpdo_benchmarks' );
+ $wpdb->insert(
+ $bench_table,
+ array(
+ 'module' => "hot_{$pt}",
+ 'zone' => 'hot',
+ 'query_type' => 'single_read',
+ 'native_ms' => round( $native_ms, 3 ),
+ 'custom_ms' => round( $zone_ms, 3 ),
+ 'sample_size' => count( $post_ids ),
+ 'created_at' => TMDO_DB::now(),
+ ),
+ array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
+ );
+ }
+
+ // ── Zone B (Warm) benchmark ─────────────────────────────────────────────
+ if ( ! $module || 'warm_hp_listing' === $module ) {
+ $warm_posts = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' LIMIT %d",
+ 'hp_listing',
+ $samples
+ )
+ );
+
+ if ( ! empty( $warm_posts ) ) {
+ // Seed warm zone entries so reads are non-trivial.
+ foreach ( $warm_posts as $pid ) {
+ TMDO_Zone_Warm::set( (int) $pid, TMDO_Listing_Stats::VIEW_KEY, '1', DAY_IN_SECONDS );
+ }
+
+ $start = microtime( true );
+ foreach ( $warm_posts as $pid ) {
+ TMDO_Zone_Warm::get( (int) $pid, TMDO_Listing_Stats::VIEW_KEY );
+ }
+ $warm_ms = ( microtime( true ) - $start ) * 1000;
+
+ $start = microtime( true );
+ foreach ( $warm_posts as $pid ) {
+ get_post_meta( (int) $pid, 'hp_view_count', true );
+ }
+ $native_warm_ms = ( microtime( true ) - $start ) * 1000;
+
+ $speedup = $native_warm_ms > 0 ? round( $native_warm_ms / max( $warm_ms, 0.001 ), 1 ) : 'N/A';
+
+ WP_CLI::log( '' );
+ WP_CLI::log( ' warm_hp_listing (view count reads):' );
+ WP_CLI::log( ' Native (postmeta): ' . round( $native_warm_ms, 2 ) . ' ms' );
+ WP_CLI::log( ' Zone B (warm): ' . round( $warm_ms, 2 ) . ' ms' );
+ WP_CLI::log( ' Speedup: ' . $speedup . 'x' );
+
+ $bench_table = TMDO_DB::table( 'wpdo_benchmarks' );
+ $wpdb->insert(
+ $bench_table,
+ array(
+ 'module' => 'warm_hp_listing',
+ 'zone' => 'warm',
+ 'query_type' => 'view_count_read',
+ 'native_ms' => round( $native_warm_ms, 3 ),
+ 'custom_ms' => round( $warm_ms, 3 ),
+ 'sample_size' => count( $warm_posts ),
+ 'created_at' => TMDO_DB::now(),
+ ),
+ array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
+ );
+ } else {
+ WP_CLI::log( ' warm_hp_listing: No published hp_listing posts found.' );
+ }
+ }
+
+ // ── Zone C (Cold) benchmark ──────────────────────────────────────────────
+ $cold_types = $registry->get_cold_post_types();
+
+ foreach ( $cold_types as $pt ) {
+ if ( $module && "cold_{$pt}" !== $module ) {
+ continue;
+ }
+
+ $cold_meta_keys = $registry->get_cold_meta_keys( $pt );
+ if ( empty( $cold_meta_keys ) ) {
+ continue;
+ }
+
+ $cold_post_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' LIMIT %d",
+ $pt,
+ $samples
+ )
+ );
+
+ if ( empty( $cold_post_ids ) ) {
+ WP_CLI::log( " cold_{$pt}: No published posts found." );
+ continue;
+ }
+
+ // Zone C: one get_blob (JSON decode) vs N postmeta reads per post.
+ $start = microtime( true );
+ foreach ( $cold_post_ids as $pid ) {
+ TMDO_Zone_Cold::get_blob( (int) $pid, $pt );
+ }
+ $cold_ms = ( microtime( true ) - $start ) * 1000;
+
+ $start = microtime( true );
+ foreach ( $cold_post_ids as $pid ) {
+ foreach ( $cold_meta_keys as $key ) {
+ get_post_meta( (int) $pid, $key, true );
+ }
+ }
+ $native_cold_ms = ( microtime( true ) - $start ) * 1000;
+
+ $key_count = count( $cold_meta_keys );
+ $speedup = $native_cold_ms > 0 ? round( $native_cold_ms / max( $cold_ms, 0.001 ), 1 ) : 'N/A';
+
+ WP_CLI::log( '' );
+ WP_CLI::log( " cold_{$pt} ({$key_count} keys per post):" );
+ WP_CLI::log( ' Native (postmeta): ' . round( $native_cold_ms, 2 ) . ' ms' );
+ WP_CLI::log( ' Zone C (cold): ' . round( $cold_ms, 2 ) . ' ms' );
+ WP_CLI::log( ' Speedup: ' . $speedup . 'x' );
+
+ $bench_table = TMDO_DB::table( 'wpdo_benchmarks' );
+ $wpdb->insert(
+ $bench_table,
+ array(
+ 'module' => "cold_{$pt}",
+ 'zone' => 'cold',
+ 'query_type' => 'blob_read',
+ 'native_ms' => round( $native_cold_ms, 3 ),
+ 'custom_ms' => round( $cold_ms, 3 ),
+ 'sample_size' => count( $cold_post_ids ),
+ 'created_at' => TMDO_DB::now(),
+ ),
+ array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
+ );
+ }
+
+ // ── Custom tables benchmark ───────────────────────────────────────
+ if ( $custom_tables ) {
+ $this->benchmark_custom_tables( $samples );
+ }
+
+ WP_CLI::success( 'Benchmark complete. Results saved to wpdo_benchmarks table.' );
+ }
+
+ /**
+ * Benchmark all partner plugin custom tables from TMDO_Custom_Table_Registry.
+ *
+ * For tables that supply a `benchmark_callback`, the callback is invoked
+ * and its result used directly. For tables without a callback a built-in
+ * generic probe is run: COUNT(*) + a LIMIT-N sequential read.
+ *
+ * @param int $samples Number of rows to read in the sequential read probe.
+ * @return void
+ */
+ private function benchmark_custom_tables( int $samples ): void {
+ global $wpdb;
+
+ $registry = TMDO_Custom_Table_Registry::instance();
+ $all = $registry->all();
+
+ if ( empty( $all ) ) {
+ WP_CLI::log( '' );
+ WP_CLI::warning( 'No custom tables registered. Partner plugins may not be active.' );
+ return;
+ }
+
+ WP_CLI::log( '' );
+ WP_CLI::log( sprintf( '── Custom table benchmark (%d tables, %d samples) ──', count( $all ), $samples ) );
+
+ $bench_table = TMDO_DB::table( 'wpdo_benchmarks' );
+ $current_provider = '';
+
+ foreach ( $all as $key => $cfg ) {
+ $provider = (string) $cfg['provider'];
+ $table_name = (string) $cfg['table_name'];
+ $full_table = $wpdb->prefix . $table_name;
+
+ if ( $provider !== $current_provider ) {
+ WP_CLI::log( '' );
+ WP_CLI::log( " [{$provider}]" );
+ $current_provider = $provider;
+ }
+
+ // Check table exists.
+ $exists = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
+ $full_table
+ )
+ ); // phpcs:ignore WordPress.DB
+ if ( ! $exists ) {
+ WP_CLI::log( " {$table_name}: [MISSING]" );
+ continue;
+ }
+
+ $total_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$full_table}`" ); // phpcs:ignore WordPress.DB
+
+ // Use partner-supplied callback when available.
+ if ( is_callable( $cfg['benchmark_callback'] ?? null ) ) {
+ try {
+ $result = call_user_func( $cfg['benchmark_callback'], $samples );
+ $duration = (float) ( $result['duration_ms'] ?? 0 );
+ $sample_n = (int) ( $result['sample_size'] ?? $samples );
+ $query_type = (string) ( $result['query_type'] ?? 'custom_callback' );
+ WP_CLI::log( sprintf( ' %s: %s rows | callback %.2fms (%d samples)', $table_name, number_format( $total_rows ), $duration, $sample_n ) );
+ } catch ( \Throwable $e ) {
+ WP_CLI::warning( " {$table_name}: benchmark_callback threw: " . $e->getMessage() );
+ continue;
+ }
+ } else {
+ // Built-in generic probe: time a COUNT(*) + a sequential LIMIT read.
+ $probe_n = min( $samples, $total_rows );
+ $query_type = 'generic_read';
+
+ // Count timing.
+ $start = microtime( true );
+ $wpdb->get_var( "SELECT COUNT(*) FROM `{$full_table}`" ); // phpcs:ignore WordPress.DB
+ $count_ms = ( microtime( true ) - $start ) * 1000;
+
+ // Sequential read timing.
+ $start = microtime( true );
+ $pk = sanitize_key( (string) ( $cfg['primary_key'] ?? 'id' ) );
+ $wpdb->get_results( // phpcs:ignore WordPress.DB
+ $wpdb->prepare( "SELECT * FROM `{$full_table}` ORDER BY `{$pk}` LIMIT %d", $probe_n ) // phpcs:ignore WordPress.DB
+ );
+ $read_ms = ( microtime( true ) - $start ) * 1000;
+
+ $duration = $count_ms + $read_ms;
+ WP_CLI::log(
+ sprintf(
+ ' %s: %s rows | count %.2fms | read(%d rows) %.2fms',
+ $table_name,
+ number_format( $total_rows ),
+ $count_ms,
+ $probe_n,
+ $read_ms
+ )
+ );
+ }
+
+ $wpdb->insert(
+ $bench_table,
+ array(
+ 'module' => "custom_{$table_name}",
+ 'zone' => 'custom',
+ 'query_type' => $query_type,
+ 'native_ms' => 0,
+ 'custom_ms' => round( $duration, 3 ),
+ 'sample_size' => $total_rows,
+ 'created_at' => TMDO_DB::now(),
+ ),
+ array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
+ );
+ }
+ }
+
+ /**
+ * Show site-wide EAV health metrics (latest snapshot + optional history).
+ *
+ * ## OPTIONS
+ *
+ * [--collect]
+ * : Force-collect a fresh snapshot now (does not wait for cron).
+ *
+ * [--history=]
+ * : Show 30-day daily history for a specific metric key (e.g. eav.postmeta_rows).
+ *
+ * [--days=]
+ * : Number of days to show in history mode. Default: 30.
+ *
+ * [--format=]
+ * : Output format (table, json, csv). Default: table.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo site-metrics
+ * wp wpdo site-metrics --collect
+ * wp wpdo site-metrics --history=eav.postmeta_rows --days=7
+ * wp wpdo site-metrics --format=json
+ *
+ * @param array $args Positional arguments (unused).
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand site-metrics
+ */
+ public function site_metrics( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
+ $do_collect = ! empty( $assoc_args['collect'] );
+ // Allow dot notation (e.g. eav.postmeta_rows); sanitize without losing dots.
+ $history_key = isset( $assoc_args['history'] )
+ ? preg_replace( '/[^a-z0-9._]/', '', strtolower( (string) $assoc_args['history'] ) )
+ : '';
+ $days = absint( $assoc_args['days'] ?? 30 );
+ $format = in_array( $assoc_args['format'] ?? 'table', array( 'table', 'json', 'csv' ), true )
+ ? (string) ( $assoc_args['format'] ?? 'table' )
+ : 'table';
+
+ if ( ! class_exists( 'TMDO_Site_Metrics_Collector' ) ) {
+ WP_CLI::error( 'TMDO_Site_Metrics_Collector not loaded. Upgrade to v2.6.2+.' );
+ }
+
+ if ( $do_collect ) {
+ WP_CLI::log( 'Collecting site metrics now...' );
+ $metrics = TMDO_Site_Metrics_Collector::collect();
+ WP_CLI::success( sprintf( 'Collected %d metrics.', count( $metrics ) ) );
+ }
+
+ if ( $history_key ) {
+ $rows = TMDO_Site_Metrics_Collector::get_history( $history_key, $days );
+ if ( empty( $rows ) ) {
+ WP_CLI::warning( "No history for '{$history_key}' in the last {$days} days." );
+ return;
+ }
+ $items = array_map(
+ static fn( $r ) => array(
+ 'date' => substr( (string) $r['collected_at'], 0, 10 ),
+ 'value' => $r['value'],
+ ),
+ $rows
+ );
+ WP_CLI\Utils\format_items( $format, $items, array( 'date', 'value' ) );
+ return;
+ }
+
+ $snapshot = TMDO_Site_Metrics_Collector::get_latest_snapshot();
+ if ( empty( $snapshot ) ) {
+ WP_CLI::warning( 'No metrics collected yet. Run: wp wpdo site-metrics --collect' );
+ return;
+ }
+
+ $items = array();
+ foreach ( $snapshot as $key => $value ) {
+ $items[] = array(
+ 'metric' => $key,
+ 'value' => number_format( (int) $value ),
+ 'raw_value' => $value,
+ );
+ }
+ WP_CLI\Utils\format_items( $format, $items, array( 'metric', 'value' ) );
+ }
+
+ /**
+ * Purge old error logs and optionally archive expired listing fields to Zone D.
+ *
+ * ## OPTIONS
+ *
+ * [--days=]
+ * : Delete logs older than N days. Default: 30.
+ *
+ * [--archive-expired]
+ * : Archive hot-zone fields of expired listings (hp_expired_time > 30 days ago) to Zone D.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo cleanup
+ * wp wpdo cleanup --days=7
+ * wp wpdo cleanup --archive-expired
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments (days, archive-expired).
+ * @return void
+ *
+ * @subcommand cleanup
+ */
+ public function cleanup( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
+ $days = absint( $assoc_args['days'] ?? 30 );
+ $archive_expired = ! empty( $assoc_args['archive-expired'] );
+
+ WP_CLI::log( "Purging logs older than {$days} days..." );
+ $deleted = TMDO_Logger::purge( $days );
+ WP_CLI::log( "Deleted {$deleted} log entries." );
+
+ WP_CLI::log( 'Purging expired warm zone entries...' );
+ $warm_deleted = TMDO_Zone_Warm::purge_expired();
+ WP_CLI::log( "Deleted {$warm_deleted} expired warm entries." );
+
+ if ( $archive_expired ) {
+ WP_CLI::log( 'Archiving expired listing fields to Zone D...' );
+ $archived = TMDO_Listing_Stats::archive_expired_listings();
+ WP_CLI::log( "Archived {$archived} fields from expired listings." );
+
+ $stats = TMDO_Zone_Archive::stats();
+ WP_CLI::log( "Zone D total: {$stats['total_rows']} rows, {$stats['compressed_rows']} compressed." );
+ }
+
+ WP_CLI::success( 'Cleanup complete.' );
+ }
+
+ // ── REST API health check ─────────────────────────────────────────────
+
+ /**
+ * Health-check all /wp-json/wpdo/v1/ endpoints and report status.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo rest-test
+ * wp wpdo rest-test --post-type=hp_vendor
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments (post-type).
+ * @return void
+ *
+ * @subcommand rest-test
+ */
+ public function rest_test( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
+ $post_type = sanitize_key( $assoc_args['post-type'] ?? 'hp_listing' );
+
+ WP_CLI::log( '=== WP Data Optimizer REST API Health Check ===' );
+ WP_CLI::log( "Base: /wp-json/wpdo/v1 | post_type: {$post_type}" );
+ WP_CLI::log( '' );
+
+ $pass = 0;
+ $fail = 0;
+ $items = array();
+
+ // ── T1: GET /listings ─────────────────────────────────────────────────
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', $post_type );
+ $req->set_param( 'per_page', 5 );
+ $server = rest_get_server();
+ $res = $server->dispatch( $req );
+ $status = $res->get_status();
+ $data = $res->get_data();
+ $total = $res->get_headers()['X-WP-Total'] ?? '?';
+ $count = is_array( $data ) ? count( $data ) : 0;
+
+ if ( 200 === $status ) {
+ WP_CLI::log( "[OK] GET /listings → HTTP {$status}, items={$count}, X-WP-Total={$total}" );
+ ++$pass;
+ // Pick a test post ID from results.
+ if ( ! empty( $data[0]['id'] ) ) {
+ $items[] = (int) $data[0]['id'];
+ }
+ } else {
+ WP_CLI::warning( "[FAIL] GET /listings → HTTP {$status}" );
+ ++$fail;
+ }
+
+ // ── T2: GET /listings with filter ────────────────────────────────────
+ $registry = TMDO_Schema_Registry::instance();
+ $cols = array_keys( $registry->get_hot_columns( $post_type ) );
+
+ if ( $cols ) {
+ $req2 = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req2->set_param( 'post_type', $post_type );
+ $req2->set_param( 'per_page', 3 );
+ // No filter value — just verify the query doesn't error.
+ $res2 = $server->dispatch( $req2 );
+ $status2 = $res2->get_status();
+ if ( 200 === $status2 ) {
+ WP_CLI::log( "[OK] GET /listings?per_page=3 → HTTP {$status2}" );
+ ++$pass;
+ } else {
+ WP_CLI::warning( "[FAIL] GET /listings?per_page=3 → HTTP {$status2}" );
+ ++$fail;
+ }
+ }
+
+ // ── T3: GET /listings/{id} ────────────────────────────────────────────
+ $test_id = $items[0] ?? 0;
+
+ if ( $test_id ) {
+ $req3 = new WP_REST_Request( 'GET', "/wpdo/v1/listings/{$test_id}" );
+ $req3->set_param( 'id', $test_id );
+ $res3 = $server->dispatch( $req3 );
+ $status3 = $res3->get_status();
+ $d3 = $res3->get_data();
+ $keys = is_array( $d3 ) ? implode( ', ', array_keys( $d3 ) ) : '?';
+
+ if ( 200 === $status3 ) {
+ WP_CLI::log( "[OK] GET /listings/{$test_id} → HTTP {$status3}, keys: {$keys}" );
+ ++$pass;
+ } else {
+ WP_CLI::warning( "[FAIL] GET /listings/{$test_id} → HTTP {$status3}" );
+ ++$fail;
+ }
+ } else {
+ WP_CLI::log( "[SKIP] GET /listings/{id} — no posts found for post_type={$post_type}" );
+ }
+
+ // ── T4: GET /stats/{id} ───────────────────────────────────────────────
+ if ( $test_id ) {
+ $req4 = new WP_REST_Request( 'GET', "/wpdo/v1/stats/{$test_id}" );
+ $req4->set_param( 'id', $test_id );
+ $res4 = $server->dispatch( $req4 );
+ $status4 = $res4->get_status();
+ $d4 = $res4->get_data();
+ $views = $d4['view_count'] ?? '?';
+
+ if ( 200 === $status4 ) {
+ WP_CLI::log( "[OK] GET /stats/{$test_id} → HTTP {$status4}, view_count={$views}" );
+ ++$pass;
+ } else {
+ WP_CLI::warning( "[FAIL] GET /stats/{$test_id} → HTTP {$status4}" );
+ ++$fail;
+ }
+ } else {
+ WP_CLI::log( '[SKIP] GET /stats/{id} — no posts found' );
+ }
+
+ // ── T5: GET /status ───────────────────────────────────────────────────
+ // Temporarily grant manage_options for CLI context.
+ add_filter(
+ 'user_has_cap',
+ function ( $caps ) {
+ $caps['manage_options'] = true;
+ return $caps;
+ }
+ );
+
+ $req5 = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
+ $res5 = $server->dispatch( $req5 );
+ $status5 = $res5->get_status();
+ $d5 = $res5->get_data();
+ $version = $d5['version'] ?? '?';
+ $engine = $d5['engine'] ?? '?';
+
+ if ( 200 === $status5 ) {
+ WP_CLI::log( "[OK] GET /status → HTTP {$status5}, version={$version}, engine={$engine}" );
+ ++$pass;
+ } else {
+ WP_CLI::warning( "[FAIL] GET /status → HTTP {$status5}" );
+ ++$fail;
+ }
+
+ // ── Summary ───────────────────────────────────────────────────────────
+ WP_CLI::log( '' );
+ $total_tests = $pass + $fail;
+ if ( 0 === $fail ) {
+ WP_CLI::success( "All {$total_tests} REST tests passed." );
+ } else {
+ WP_CLI::error( "{$fail}/{$total_tests} REST tests failed.", false );
+ }
+ }
+
+ /**
+ * Add covering indexes to all Zone A (hot) tables.
+ *
+ * Analyses each hot table's column types and creates optimal covering indexes:
+ * - DECIMAL columns → single-column idx for range queries + ORDER BY
+ * - TINYINT + DECIMAL → compound (flag, price) for filtered sorts
+ * - BIGINT _time + DECIMAL → compound (expiry, price) for active listing queries
+ * - TINYINT + matching BIGINT _time → compound (flag, time) for featured ordering
+ *
+ * Safe to run on existing installations — skips already-present indexes.
+ * No-op on SQLite.
+ *
+ * ## OPTIONS
+ *
+ * []
+ * : Limit to a specific post type (e.g. hp_listing). Defaults to all hot post types.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo add-indexes
+ * wp wpdo add-indexes hp_listing
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ *
+ * @subcommand add-indexes
+ */
+ public function add_indexes( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ if ( TMDO_IS_SQLITE ) {
+ WP_CLI::warning( 'Covering indexes are MySQL-only. SQLite is not supported.' );
+ return;
+ }
+
+ $registry = TMDO_Schema_Registry::instance();
+ $post_types = $registry->get_hot_post_types();
+
+ // Optional filter by post_type argument.
+ if ( ! empty( $args[0] ) ) {
+ $filter = sanitize_key( $args[0] );
+ $post_types = array_filter( $post_types, fn( $pt ) => sanitize_key( $pt ) === $filter );
+ if ( empty( $post_types ) ) {
+ WP_CLI::error( "No hot zone table registered for post_type '{$args[0]}'." );
+ return;
+ }
+ }
+
+ foreach ( $post_types as $post_type ) {
+ $columns = $registry->get_hot_columns( $post_type );
+ if ( empty( $columns ) ) {
+ WP_CLI::log( "[SKIP] {$post_type} — no hot columns registered." );
+ continue;
+ }
+
+ WP_CLI::log( "Adding covering indexes for {$post_type}..." );
+ TMDO_Installer::add_covering_indexes( $post_type, $columns );
+ WP_CLI::log( ' Done.' );
+ }
+
+ WP_CLI::success( 'Covering indexes applied.' );
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────
+
+ /**
+ * Format the HPCT status string for display.
+ *
+ * @param array $compat Compatibility check result array.
+ * @return string Human-readable HPCT status.
+ */
+ private static function format_hpct_status( array $compat ): string {
+ if ( $compat['hpct_active'] && $compat['hpct_imported'] ) {
+ return 'Active (Imported)';
+ }
+ if ( $compat['hpct_active'] ) {
+ return 'Active (Not imported — run `wp wpdo import-hpct`)';
+ }
+ return 'Not found';
+ }
+
+ /**
+ * Create the appropriate migration instance for a module name.
+ *
+ * @param string $module Module identifier (e.g., hot_hp_listing, warm, archive).
+ * @return TMDO_Migration_Base|null Migration instance, or null if module is unknown.
+ */
+ private static function get_migration_instance( string $module ): ?TMDO_Migration_Base {
+ // Zone migrations.
+ if ( str_starts_with( $module, 'hot_' ) ) {
+ $post_type = substr( $module, 4 );
+ return new TMDO_Hot_Migration( $post_type );
+ }
+
+ if ( str_starts_with( $module, 'cold_' ) ) {
+ $post_type = substr( $module, 5 );
+ return new TMDO_Cold_Migration( $post_type );
+ }
+
+ if ( 'warm' === $module ) {
+ return new TMDO_Warm_Migration();
+ }
+
+ if ( 'archive' === $module ) {
+ return new TMDO_Archive_Migration();
+ }
+
+ return null;
+ }
+
+ /**
+ * Capture a site-wide health snapshot. Writes JSON to the output dir
+ * (default: wp-data-optimizer/docs/snapshots/) and prints a summary.
+ *
+ * Re-running monthly produces a series of time-stamped snapshots that can
+ * be diffed to detect regressions (DB bloat, autoload pollution, missing
+ * registrations).
+ *
+ * ## OPTIONS
+ *
+ * [--out=]
+ * : Output directory. Defaults to plugin's docs/snapshots/.
+ *
+ * [--quiet]
+ * : Suppress human-readable summary; only print the JSON path.
+ *
+ * [--json-only]
+ * : Emit raw JSON to stdout (CI-friendly). Suppresses human prose entirely.
+ *
+ * [--diff-since=]
+ * : Compare against snapshot from N days ago instead of the previous one.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo health-snapshot
+ * wp wpdo health-snapshot --out=/tmp
+ * wp wpdo health-snapshot --json-only > today.json
+ * wp wpdo health-snapshot --diff-since=30
+ *
+ * @param array $args Positional args (unused).
+ * @param array $assoc_args Flag args.
+ * @return void
+ *
+ * @subcommand health-snapshot
+ */
+ public function health_snapshot( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ global $wpdb;
+ $quiet = ! empty( $assoc_args['quiet'] );
+ $json_only = ! empty( $assoc_args['json-only'] );
+ $diff_since = isset( $assoc_args['diff-since'] ) ? max( 1, (int) $assoc_args['diff-since'] ) : 0;
+ // v2.1.2 fix: default to wp-content/uploads/wpdo-snapshots/ — plugin dir
+ // is read-only on hardened production hosts. Override with --out.
+ $default_outdir = ( function_exists( 'wp_upload_dir' ) ? ( wp_upload_dir()['basedir'] ?? sys_get_temp_dir() ) : sys_get_temp_dir() ) . '/wpdo-snapshots';
+ $outdir = $assoc_args['out'] ?? $default_outdir;
+ if ( ! is_dir( $outdir ) ) {
+ wp_mkdir_p( $outdir );
+ }
+
+ // Trigger field registration so we capture the full picture.
+ do_action( 'wpdo_register_fields', TMDO_Schema_Registry::instance() );
+
+ // 1. DB size + top tables (v2.1.2: SQLite-aware).
+ if ( defined( 'TMDO_IS_SQLITE' ) && TMDO_IS_SQLITE ) {
+ // SQLite has no information_schema; size is the .sqlite file size.
+ $db_file = defined( 'DB_FILE' ) ? DB_FILE : ( WP_CONTENT_DIR . '/database/.ht.sqlite' );
+ $db_size = file_exists( $db_file ) ? (int) filesize( $db_file ) : 0;
+ // Top tables via sqlite_master + per-table COUNT (slower but correct).
+ $tables = $wpdb->get_col( "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE '" . $wpdb->prefix . "2m%' OR name LIKE '" . $wpdb->prefix . "wpdo%' OR name LIKE '" . $wpdb->prefix . "tmqi%')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+ $top_tables = array();
+ foreach ( (array) $tables as $t ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
+ $top_tables[] = array(
+ 'TABLE_NAME' => $t,
+ 'TABLE_ROWS' => $rows,
+ 'DATA_LENGTH' => 0,
+ );
+ }
+ usort( $top_tables, static fn( $a, $b ) => $b['TABLE_ROWS'] <=> $a['TABLE_ROWS'] );
+ $top_tables = array_slice( $top_tables, 0, 10 );
+ } else {
+ $db_size = (int) $wpdb->get_var(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ 'SELECT SUM(DATA_LENGTH + INDEX_LENGTH) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s',
+ DB_NAME
+ )
+ );
+ $top_tables = $wpdb->get_results(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ 'SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = %s
+ AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)
+ ORDER BY DATA_LENGTH DESC LIMIT 10',
+ DB_NAME,
+ $wpdb->prefix . '2m%',
+ $wpdb->prefix . 'wpdo%',
+ $wpdb->prefix . 'tmqi%'
+ ),
+ ARRAY_A
+ );
+ }
+
+ // 2. autoload pollution.
+ $autoload_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE autoload IN ('yes','on')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $autoload_size = (int) $wpdb->get_var( "SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options} WHERE autoload IN ('yes','on')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+
+ // 3. WPDO custom_table coverage.
+ $registry = TMDO_Custom_Table_Registry::instance();
+ $ref = new ReflectionClass( $registry );
+ $prop = $ref->getProperty( 'tables' );
+ $prop->setAccessible( true );
+ $all_tables = $prop->getValue( $registry );
+ $by_provider = array();
+ foreach ( $all_tables as $t ) {
+ $p = $t['provider'] ?? 'unknown';
+ $by_provider[ $p ] = ( $by_provider[ $p ] ?? 0 ) + 1;
+ }
+
+ // 4. Schema_Registry hot fields.
+ $schema = TMDO_Schema_Registry::instance();
+ $pref = new ReflectionClass( $schema );
+ $pp = $pref->getProperty( 'fields' );
+ $pp->setAccessible( true );
+ $fields = $pp->getValue( $schema );
+ $fields_by_provider = array();
+ foreach ( $fields as $f ) {
+ $p = $f['provider'] ?? 'unknown';
+ $fields_by_provider[ $p ] = ( $fields_by_provider[ $p ] ?? 0 ) + 1;
+ }
+
+ // 5. wpdo_errors row count.
+ $errors_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_errors" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+
+ $snapshot = array(
+ 'taken_at' => current_time( 'mysql' ),
+ 'wpdo_version' => defined( 'TMDO_VERSION' ) ? TMDO_VERSION : 'unknown',
+ 'db' => array(
+ 'total_size_bytes' => $db_size,
+ 'total_size_mb' => round( $db_size / 1048576, 2 ),
+ 'top_tables' => $top_tables ?: array(),
+ ),
+ 'autoload' => array(
+ 'count' => $autoload_total,
+ 'size_kb' => round( ( $autoload_size ?: 0 ) / 1024, 1 ),
+ ),
+ 'custom_tables' => array(
+ 'total' => count( $all_tables ),
+ 'by_provider' => $by_provider,
+ ),
+ 'schema_fields' => array(
+ 'total' => count( $fields ),
+ 'by_provider' => $fields_by_provider,
+ ),
+ 'wpdo_errors_count' => $errors_count,
+ );
+
+ $filename = sprintf( '%s/health-%s.json', rtrim( $outdir, '/' ), gmdate( 'Y-m-d-His' ) );
+ $ok = file_put_contents(
+ $filename,
+ wp_json_encode( $snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES )
+ );
+
+ if ( false === $ok ) {
+ WP_CLI::error( "Failed to write snapshot to {$filename}" );
+ }
+
+ // v2.1.2 fix: prune snapshots > 24 to bound directory growth (monthly cron
+ // over years would otherwise fill disk + slow glob+sort).
+ $all_snaps = glob( rtrim( $outdir, '/' ) . '/health-*.json' );
+ if ( $all_snaps && count( $all_snaps ) > 24 ) {
+ sort( $all_snaps );
+ foreach ( array_slice( $all_snaps, 0, count( $all_snaps ) - 24 ) as $old ) {
+ @unlink( $old ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup
+ }
+ }
+
+ // --json-only: emit the raw JSON to stdout and exit silently.
+ if ( $json_only ) {
+ WP_CLI::log( wp_json_encode( $snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) );
+ return;
+ }
+
+ if ( $quiet ) {
+ WP_CLI::log( $filename );
+ return;
+ }
+
+ WP_CLI::log( '═══ WPDO Health Snapshot ═══' );
+ WP_CLI::log( ' Taken: ' . $snapshot['taken_at'] );
+ WP_CLI::log( ' WPDO version: ' . $snapshot['wpdo_version'] );
+ WP_CLI::log( ' DB total: ' . $snapshot['db']['total_size_mb'] . ' MB' );
+ WP_CLI::log( ' Custom tables registered: ' . $snapshot['custom_tables']['total'] );
+ WP_CLI::log( ' Schema fields registered: ' . $snapshot['schema_fields']['total'] );
+ WP_CLI::log( ' Autoload entries: ' . $snapshot['autoload']['count'] . ' (' . $snapshot['autoload']['size_kb'] . ' KB)' );
+ WP_CLI::log( ' wp_wpdo_errors rows: ' . $snapshot['wpdo_errors_count'] );
+ WP_CLI::log( '' );
+ WP_CLI::log( "Snapshot saved: {$filename}" );
+
+ // Prefer --diff-since=N (find snapshot from ~N days ago); fallback to
+ // previous snapshot in the directory.
+ $snapshots = glob( rtrim( $outdir, '/' ) . '/health-*.json' );
+ $prev_path = null;
+ if ( $snapshots && count( $snapshots ) >= 2 ) {
+ sort( $snapshots );
+ if ( $diff_since > 0 ) {
+ // Find the snapshot closest to (now - $diff_since days).
+ $target_ts = time() - $diff_since * DAY_IN_SECONDS;
+ $best_diff = PHP_INT_MAX;
+ foreach ( $snapshots as $candidate ) {
+ if ( preg_match( '/health-(\d{4}-\d{2}-\d{2})/', basename( $candidate ), $m ) ) {
+ $cand_ts = strtotime( $m[1] );
+ $diff = abs( $cand_ts - $target_ts );
+ if ( $diff < $best_diff && $candidate !== $filename ) {
+ $best_diff = $diff;
+ $prev_path = $candidate;
+ }
+ }
+ }
+ } else {
+ $prev_path = $snapshots[ count( $snapshots ) - 2 ]; // second-latest.
+ }
+ }
+
+ if ( $prev_path ) {
+ $prev = json_decode( (string) file_get_contents( $prev_path ), true );
+ if ( is_array( $prev ) ) {
+ WP_CLI::log( '' );
+ WP_CLI::log( 'Δ vs ' . basename( $prev_path ) . ':' );
+ WP_CLI::log( sprintf( ' DB: %+0.2f MB', $snapshot['db']['total_size_mb'] - ( $prev['db']['total_size_mb'] ?? 0 ) ) );
+ WP_CLI::log( sprintf( ' Tables: %+d', $snapshot['custom_tables']['total'] - ( $prev['custom_tables']['total'] ?? 0 ) ) );
+ WP_CLI::log( sprintf( ' Fields: %+d', $snapshot['schema_fields']['total'] - ( $prev['schema_fields']['total'] ?? 0 ) ) );
+ WP_CLI::log( sprintf( ' Errors: %+d', $snapshot['wpdo_errors_count'] - ( $prev['wpdo_errors_count'] ?? 0 ) ) );
+ WP_CLI::log( sprintf( ' Autoload: %+d entries', $snapshot['autoload']['count'] - ( $prev['autoload']['count'] ?? 0 ) ) );
+
+ // Threshold breach detection — write to last_snapshot_alert option.
+ $db_growth_pct = ( $prev['db']['total_size_mb'] ?? 0 ) > 0
+ ? ( ( $snapshot['db']['total_size_mb'] - $prev['db']['total_size_mb'] ) / $prev['db']['total_size_mb'] ) * 100
+ : 0;
+ if ( $db_growth_pct > 10 ) {
+ update_option(
+ 'wpdo_health_alert',
+ sprintf(
+ 'DB grew %.1f%% (%.2f MB → %.2f MB) since %s',
+ $db_growth_pct,
+ (float) ( $prev['db']['total_size_mb'] ?? 0 ),
+ (float) $snapshot['db']['total_size_mb'],
+ basename( $prev_path )
+ ),
+ false
+ );
+ WP_CLI::warning( sprintf( 'DB grew %.1f%% — admin notice will fire.', $db_growth_pct ) );
+ } else {
+ delete_option( 'wpdo_health_alert' );
+ }
+ }
+ }
+
+ WP_CLI::success( 'Health snapshot complete.' );
+ }
+
+ /**
+ * Generate scaffolding for a new partner plugin's WPDO integration class.
+ *
+ * Output: prints a ready-to-paste integration class to stdout (or --out file).
+ * Saves ~30 minutes per new plugin onboarding by following Tier 4 cookbook pattern.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Plugin slug (e.g. 2meet-newplugin). Used to derive class name + table prefix.
+ *
+ * [--prefix=]
+ * : Table prefix (e.g. 2mn for 2meet-newplugin). Defaults to first 3 chars of slug.
+ *
+ * [--class=]
+ * : Class name (default: derived from slug, e.g. `TMEETIC_Newplugin_WPDO`).
+ *
+ * [--out=]
+ * : Output file. Defaults to stdout.
+ *
+ * ## EXAMPLES
+ *
+ * wp wpdo register-stub 2meet-newplugin
+ * wp wpdo register-stub 2meet-newplugin --prefix=2mn --out=/tmp/stub.php
+ *
+ * @param array $args Positional args.
+ * @param array $assoc_args Flag args.
+ * @return void
+ *
+ * @subcommand register-stub
+ */
+ public function register_stub( $args, $assoc_args ): void {
+ $slug = isset( $args[0] ) ? sanitize_title( (string) $args[0] ) : '';
+ if ( '' === $slug ) {
+ WP_CLI::error( 'Usage: wp wpdo register-stub ' );
+ }
+
+ $prefix = isset( $assoc_args['prefix'] )
+ ? sanitize_key( (string) $assoc_args['prefix'] )
+ : substr( preg_replace( '/[^a-z]/', '', strtolower( str_replace( array( '2meet-', '-' ), '', $slug ) ) ), 0, 3 );
+
+ $class = isset( $assoc_args['class'] )
+ ? (string) $assoc_args['class']
+ : 'TMEETIC_' . str_replace( ' ', '_', ucwords( str_replace( array( '2meet-', '-' ), array( '', ' ' ), $slug ) ) ) . '_WPDO';
+
+ $stub = self::render_stub( $slug, $prefix, $class );
+
+ if ( ! empty( $assoc_args['out'] ) ) {
+ $ok = file_put_contents( $assoc_args['out'], $stub );
+ if ( false === $ok ) {
+ WP_CLI::error( "Failed to write {$assoc_args['out']}" );
+ }
+ WP_CLI::success( "Stub written to {$assoc_args['out']}" );
+ return;
+ }
+
+ WP_CLI::log( $stub );
+ }
+
+ /**
+ * Render the scaffolding template.
+ *
+ * @param string $slug Plugin slug.
+ * @param string $prefix Table prefix.
+ * @param string $class_name Integration class name.
+ * @return string PHP source code.
+ */
+ private static function render_stub( string $slug, string $prefix, string $class_name ): string {
+ $year = gmdate( 'Y' );
+ return <<
+\t */
+\tprivate const TABLES = array(
+\t\t'{$prefix}_example_one' => array( 'post_type_link' => null, 'description' => '範例表 1' ),
+\t\t'{$prefix}_example_two' => array( 'post_type_link' => null, 'description' => '範例表 2' ),
+\t);
+
+\tpublic static function register(): void {
+\t\tadd_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_tables' ) );
+\t}
+
+\tpublic static function register_tables( \$registry = null ): void {
+\t\tif ( ! class_exists( 'TMDO_Custom_Table_Registry' ) ) {
+\t\t\treturn;
+\t\t}
+\t\t\$registry = \$registry ?: TMDO_Custom_Table_Registry::instance();
+
+\t\tforeach ( self::TABLES as \$name => \$meta ) {
+\t\t\t\$registry->register(
+\t\t\t\t'{$slug}',
+\t\t\t\tarray(
+\t\t\t\t\t'table_name' => \$name,
+\t\t\t\t\t'primary_key' => 'id',
+\t\t\t\t\t'post_type_link' => \$meta['post_type_link'],
+\t\t\t\t\t'doctor_callback' => array( __CLASS__, 'doctor_check' ),
+\t\t\t\t\t'description' => \$meta['description'],
+\t\t\t\t)
+\t\t\t);
+\t\t}
+\t}
+
+\tpublic static function doctor_check( string \$table_suffix ): array {
+\t\tglobal \$wpdb;
+\t\t\$full = \$wpdb->prefix . \$table_suffix;
+\t\t\$exists = (bool) \$wpdb->get_var( \$wpdb->prepare( 'SHOW TABLES LIKE %s', \$full ) );
+\t\tif ( ! \$exists ) {
+\t\t\treturn array( 'ok' => false, 'message' => "{\$full} 不存在" );
+\t\t}
+\t\t\$count = (int) \$wpdb->get_var( "SELECT COUNT(*) FROM `{\$full}`" );
+\t\treturn array( 'ok' => true, 'message' => "{\$full}: {\$count} rows" );
+\t}
+}
+
+PHP;
+ }
+}
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..205ebb2
--- /dev/null
+++ b/composer.json
@@ -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
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..449b3bc
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,2840 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "cd0fc7632900a6c7831f04b981678980",
+ "packages": [],
+ "packages-dev": [
+ {
+ "name": "antecedent/patchwork",
+ "version": "2.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/antecedent/patchwork.git",
+ "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce",
+ "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": ">=4"
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignas Rudaitis",
+ "email": "ignas.rudaitis@gmail.com"
+ }
+ ],
+ "description": "Method redefinition (monkey-patching) functionality for PHP.",
+ "homepage": "https://antecedent.github.io/patchwork/",
+ "keywords": [
+ "aop",
+ "aspect",
+ "interception",
+ "monkeypatching",
+ "redefinition",
+ "runkit",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/antecedent/patchwork/issues",
+ "source": "https://github.com/antecedent/patchwork/tree/2.2.3"
+ },
+ "time": "2025-09-17T09:00:56+00:00"
+ },
+ {
+ "name": "brain/monkey",
+ "version": "2.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Brain-WP/BrainMonkey.git",
+ "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83",
+ "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83",
+ "shasum": ""
+ },
+ "require": {
+ "antecedent/patchwork": "^2.1.17",
+ "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10",
+ "php": ">=5.6.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0",
+ "phpcompatibility/php-compatibility": "^9.3.0",
+ "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev",
+ "dev-version/1": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "inc/api.php"
+ ],
+ "psr-4": {
+ "Brain\\Monkey\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Giuseppe Mazzapica",
+ "email": "giuseppe.mazzapica@gmail.com",
+ "homepage": "https://gmazzap.me",
+ "role": "Developer"
+ }
+ ],
+ "description": "Mocking utility for PHP functions and WordPress plugin API",
+ "keywords": [
+ "Monkey Patching",
+ "interception",
+ "mock",
+ "mock functions",
+ "mockery",
+ "patchwork",
+ "redefinition",
+ "runkit",
+ "test",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/Brain-WP/BrainMonkey/issues",
+ "source": "https://github.com/Brain-WP/BrainMonkey"
+ },
+ "time": "2026-02-05T09:22:14+00:00"
+ },
+ {
+ "name": "dealerdirect/phpcodesniffer-composer-installer",
+ "version": "v1.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/composer-installer.git",
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^2.2",
+ "php": ">=5.4",
+ "squizlabs/php_codesniffer": "^3.1.0 || ^4.0"
+ },
+ "require-dev": {
+ "composer/composer": "^2.2",
+ "ext-json": "*",
+ "ext-zip": "*",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev",
+ "yoast/phpunit-polyfills": "^1.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin"
+ },
+ "autoload": {
+ "psr-4": {
+ "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Franck Nijhof",
+ "email": "opensource@frenck.dev",
+ "homepage": "https://frenck.dev",
+ "role": "Open source developer"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors"
+ }
+ ],
+ "description": "PHP_CodeSniffer Standards Composer Installer Plugin",
+ "keywords": [
+ "PHPCodeSniffer",
+ "PHP_CodeSniffer",
+ "code quality",
+ "codesniffer",
+ "composer",
+ "installer",
+ "phpcbf",
+ "phpcs",
+ "plugin",
+ "qa",
+ "quality",
+ "standard",
+ "standards",
+ "style guide",
+ "stylecheck",
+ "tests"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/composer-installer/issues",
+ "security": "https://github.com/PHPCSStandards/composer-installer/security/policy",
+ "source": "https://github.com/PHPCSStandards/composer-installer"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-05-06T08:26:05+00:00"
+ },
+ {
+ "name": "hamcrest/hamcrest-php",
+ "version": "v2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/hamcrest/hamcrest-php.git",
+ "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
+ "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0"
+ },
+ "replace": {
+ "cordoval/hamcrest-php": "*",
+ "davedevelopment/hamcrest-php": "*",
+ "kodova/hamcrest-php": "*"
+ },
+ "require-dev": {
+ "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0",
+ "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "hamcrest"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "description": "This is the PHP port of Hamcrest Matchers",
+ "keywords": [
+ "test"
+ ],
+ "support": {
+ "issues": "https://github.com/hamcrest/hamcrest-php/issues",
+ "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1"
+ },
+ "time": "2025-04-30T06:54:44+00:00"
+ },
+ {
+ "name": "mockery/mockery",
+ "version": "1.6.12",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mockery/mockery.git",
+ "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699",
+ "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699",
+ "shasum": ""
+ },
+ "require": {
+ "hamcrest/hamcrest-php": "^2.0.1",
+ "lib-pcre": ">=7.0",
+ "php": ">=7.3"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5 || ^9.6.17",
+ "symplify/easy-coding-standard": "^12.1.14"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "library/helpers.php",
+ "library/Mockery.php"
+ ],
+ "psr-4": {
+ "Mockery\\": "library/Mockery"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Pádraic Brady",
+ "email": "padraic.brady@gmail.com",
+ "homepage": "https://github.com/padraic",
+ "role": "Author"
+ },
+ {
+ "name": "Dave Marshall",
+ "email": "dave.marshall@atstsolutions.co.uk",
+ "homepage": "https://davedevelopment.co.uk",
+ "role": "Developer"
+ },
+ {
+ "name": "Nathanael Esayeas",
+ "email": "nathanael.esayeas@protonmail.com",
+ "homepage": "https://github.com/ghostwriter",
+ "role": "Lead Developer"
+ }
+ ],
+ "description": "Mockery is a simple yet flexible PHP mock object framework",
+ "homepage": "https://github.com/mockery/mockery",
+ "keywords": [
+ "BDD",
+ "TDD",
+ "library",
+ "mock",
+ "mock objects",
+ "mockery",
+ "stub",
+ "test",
+ "test double",
+ "testing"
+ ],
+ "support": {
+ "docs": "https://docs.mockery.io/",
+ "issues": "https://github.com/mockery/mockery/issues",
+ "rss": "https://github.com/mockery/mockery/releases.atom",
+ "security": "https://github.com/mockery/mockery/security/advisories",
+ "source": "https://github.com/mockery/mockery"
+ },
+ "time": "2024-05-16T03:13:13+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
+ },
+ "time": "2026-07-04T14:30:18+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "php-stubs/wordpress-stubs",
+ "version": "v6.9.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-stubs/wordpress-stubs.git",
+ "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/90a9412826b9944f93b10bf41d795b5fe68abcd5",
+ "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5",
+ "shasum": ""
+ },
+ "conflict": {
+ "phpdocumentor/reflection-docblock": "5.6.1"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "nikic/php-parser": "^5.5",
+ "php": "^7.4 || ^8.0",
+ "php-stubs/generator": "^0.8.6",
+ "phpdocumentor/reflection-docblock": "^6.0",
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^9.5",
+ "symfony/polyfill-php80": "*",
+ "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1",
+ "wp-coding-standards/wpcs": "3.1.0 as 2.3.0"
+ },
+ "suggest": {
+ "paragonie/sodium_compat": "Pure PHP implementation of libsodium",
+ "symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+ "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan"
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "WordPress function and class declaration stubs for static analysis.",
+ "homepage": "https://github.com/php-stubs/wordpress-stubs",
+ "keywords": [
+ "PHPStan",
+ "static analysis",
+ "wordpress"
+ ],
+ "support": {
+ "issues": "https://github.com/php-stubs/wordpress-stubs/issues",
+ "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.9.4"
+ },
+ "time": "2026-05-01T20:36:01+00:00"
+ },
+ {
+ "name": "php-stubs/wp-cli-stubs",
+ "version": "v2.12.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-stubs/wp-cli-stubs.git",
+ "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-stubs/wp-cli-stubs/zipball/af16401e299a3fd2229bd0fa9a037638a4174a9d",
+ "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d",
+ "shasum": ""
+ },
+ "require": {
+ "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0"
+ },
+ "require-dev": {
+ "php": "~7.3 || ~8.0",
+ "php-stubs/generator": "^0.8.0"
+ },
+ "suggest": {
+ "symfony/polyfill-php73": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
+ "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan"
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "WP-CLI function and class declaration stubs for static analysis.",
+ "homepage": "https://github.com/php-stubs/wp-cli-stubs",
+ "keywords": [
+ "PHPStan",
+ "static analysis",
+ "wordpress",
+ "wp-cli"
+ ],
+ "support": {
+ "issues": "https://github.com/php-stubs/wp-cli-stubs/issues",
+ "source": "https://github.com/php-stubs/wp-cli-stubs/tree/v2.12.0"
+ },
+ "time": "2025-06-10T09:58:05+00:00"
+ },
+ {
+ "name": "phpcompatibility/php-compatibility",
+ "version": "9.3.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCompatibility/PHPCompatibility.git",
+ "reference": "9fb324479acf6f39452e0655d2429cc0d3914243"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243",
+ "reference": "9fb324479acf6f39452e0655d2429cc0d3914243",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3",
+ "squizlabs/php_codesniffer": "^2.3 || ^3.0.2"
+ },
+ "conflict": {
+ "squizlabs/php_codesniffer": "2.6.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0"
+ },
+ "suggest": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.",
+ "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
+ },
+ "type": "phpcodesniffer-standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Wim Godden",
+ "homepage": "https://github.com/wimg",
+ "role": "lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "homepage": "https://github.com/jrfnl",
+ "role": "lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors"
+ }
+ ],
+ "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.",
+ "homepage": "http://techblog.wimgodden.be/tag/codesniffer/",
+ "keywords": [
+ "compatibility",
+ "phpcs",
+ "standards"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues",
+ "source": "https://github.com/PHPCompatibility/PHPCompatibility"
+ },
+ "time": "2019-12-27T09:44:58+00:00"
+ },
+ {
+ "name": "phpcompatibility/phpcompatibility-paragonie",
+ "version": "1.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git",
+ "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
+ "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
+ "shasum": ""
+ },
+ "require": {
+ "phpcompatibility/php-compatibility": "^9.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "paragonie/random_compat": "dev-master",
+ "paragonie/sodium_compat": "dev-master"
+ },
+ "suggest": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
+ "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
+ },
+ "type": "phpcodesniffer-standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Wim Godden",
+ "role": "lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "lead"
+ }
+ ],
+ "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.",
+ "homepage": "http://phpcompatibility.com/",
+ "keywords": [
+ "compatibility",
+ "paragonie",
+ "phpcs",
+ "polyfill",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues",
+ "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy",
+ "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCompatibility",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcompatibility",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-09-19T17:43:28+00:00"
+ },
+ {
+ "name": "phpcompatibility/phpcompatibility-wp",
+ "version": "2.1.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git",
+ "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa",
+ "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa",
+ "shasum": ""
+ },
+ "require": {
+ "phpcompatibility/php-compatibility": "^9.0",
+ "phpcompatibility/phpcompatibility-paragonie": "^1.0",
+ "squizlabs/php_codesniffer": "^3.3"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0"
+ },
+ "suggest": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
+ "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
+ },
+ "type": "phpcodesniffer-standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Wim Godden",
+ "role": "lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "lead"
+ }
+ ],
+ "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.",
+ "homepage": "http://phpcompatibility.com/",
+ "keywords": [
+ "compatibility",
+ "phpcs",
+ "standards",
+ "static analysis",
+ "wordpress"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues",
+ "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy",
+ "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCompatibility",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcompatibility",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-10-18T00:05:59+00:00"
+ },
+ {
+ "name": "phpcsstandards/phpcsextra",
+ "version": "1.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHPCSExtra.git",
+ "reference": "39467533fdb742446d68c1d10ac33d625ee0311c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c",
+ "reference": "39467533fdb742446d68c1d10ac33d625ee0311c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.4",
+ "phpcsstandards/phpcsutils": "^1.2.3",
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-console-highlighter": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
+ "phpcsstandards/phpcsdevtools": "^1.2.1",
+ "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+ },
+ "type": "phpcodesniffer-standard",
+ "extra": {
+ "branch-alias": {
+ "dev-stable": "1.x-dev",
+ "dev-develop": "1.x-dev"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Juliette Reinders Folmer",
+ "homepage": "https://github.com/jrfnl",
+ "role": "lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors"
+ }
+ ],
+ "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.",
+ "keywords": [
+ "PHP_CodeSniffer",
+ "phpcbf",
+ "phpcodesniffer-standard",
+ "phpcs",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues",
+ "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHPCSExtra"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-07-27T11:13:17+00:00"
+ },
+ {
+ "name": "phpcsstandards/phpcsutils",
+ "version": "1.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHPCSUtils.git",
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f",
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f",
+ "shasum": ""
+ },
+ "require": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0",
+ "php": ">=5.4",
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
+ },
+ "require-dev": {
+ "ext-filter": "*",
+ "php-parallel-lint/php-console-highlighter": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
+ "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0"
+ },
+ "type": "phpcodesniffer-standard",
+ "extra": {
+ "branch-alias": {
+ "dev-stable": "1.x-dev",
+ "dev-develop": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "PHPCSUtils/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Juliette Reinders Folmer",
+ "homepage": "https://github.com/jrfnl",
+ "role": "lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors"
+ }
+ ],
+ "description": "A suite of utility functions for use with PHP_CodeSniffer",
+ "homepage": "https://phpcsutils.com/",
+ "keywords": [
+ "PHP_CodeSniffer",
+ "phpcbf",
+ "phpcodesniffer-standard",
+ "phpcs",
+ "phpcs3",
+ "phpcs4",
+ "standards",
+ "static analysis",
+ "tokens",
+ "utility"
+ ],
+ "support": {
+ "docs": "https://phpcsutils.com/",
+ "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues",
+ "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHPCSUtils"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-07-27T10:28:41+00:00"
+ },
+ {
+ "name": "phpstan/phpstan",
+ "version": "2.2.7",
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921",
+ "reference": "692db47b9dddb0487934e5236e77d48594aef921",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan-shim": "*"
+ },
+ "bin": [
+ "phpstan",
+ "phpstan.phar"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ondřej Mirtes"
+ },
+ {
+ "name": "Markus Staab"
+ },
+ {
+ "name": "Vincent Langlet"
+ }
+ ],
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "keywords": [
+ "dev",
+ "static analysis"
+ ],
+ "support": {
+ "docs": "https://phpstan.org/user-guide/getting-started",
+ "forum": "https://github.com/phpstan/phpstan/discussions",
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "security": "https://github.com/phpstan/phpstan/security/policy",
+ "source": "https://github.com/phpstan/phpstan-src"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ondrejmirtes",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phpstan",
+ "type": "github"
+ }
+ ],
+ "time": "2026-07-29T17:39:32+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "10.1.16",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "7e308268858ed6baedc8704a304727d20bc07c77"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77",
+ "reference": "7e308268858ed6baedc8704a304727d20bc07c77",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^4.19.1 || ^5.1.0",
+ "php": ">=8.1",
+ "phpunit/php-file-iterator": "^4.1.0",
+ "phpunit/php-text-template": "^3.0.1",
+ "sebastian/code-unit-reverse-lookup": "^3.0.0",
+ "sebastian/complexity": "^3.2.0",
+ "sebastian/environment": "^6.1.0",
+ "sebastian/lines-of-code": "^2.0.2",
+ "sebastian/version": "^4.0.1",
+ "theseer/tokenizer": "^1.2.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.1"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "10.1.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-22T04:31:57+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "4.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c",
+ "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-08-31T06:24:48+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
+ "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^10.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:56:09+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "3.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748",
+ "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-08-31T14:07:24+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d",
+ "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:57:52+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "10.5.64",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e8c1d19cea35ad97d4887f363d07c78e30fbf06",
+ "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.1",
+ "phpunit/php-code-coverage": "^10.1.16",
+ "phpunit/php-file-iterator": "^4.1.0",
+ "phpunit/php-invoker": "^4.0.0",
+ "phpunit/php-text-template": "^3.0.1",
+ "phpunit/php-timer": "^6.0.0",
+ "sebastian/cli-parser": "^2.0.1",
+ "sebastian/code-unit": "^2.0.0",
+ "sebastian/comparator": "^5.0.5",
+ "sebastian/diff": "^5.1.1",
+ "sebastian/environment": "^6.1.0",
+ "sebastian/exporter": "^5.1.4",
+ "sebastian/global-state": "^6.0.2",
+ "sebastian/object-enumerator": "^5.0.0",
+ "sebastian/recursion-context": "^5.0.1",
+ "sebastian/type": "^4.0.0",
+ "sebastian/version": "^4.0.1"
+ },
+ "suggest": {
+ "ext-soap": "To be able to generate mocks based on WSDL files"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "10.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.64"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
+ }
+ ],
+ "time": "2026-07-06T14:50:35+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084",
+ "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:12:49+00:00"
+ },
+ {
+ "name": "sebastian/code-unit",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "a81fee9eef0b7a76af11d121767abc44c104e503"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503",
+ "reference": "a81fee9eef0b7a76af11d121767abc44c104e503",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:58:43+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
+ "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:59:15+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "5.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
+ "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.1",
+ "sebastian/diff": "^5.0",
+ "sebastian/exporter": "^5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-24T09:25:16+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "3.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "68ff824baeae169ec9f2137158ee529584553799"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799",
+ "reference": "68ff824baeae169ec9f2137158ee529584553799",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-21T08:37:17+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "5.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e",
+ "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0",
+ "symfony/process": "^6.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:15:17+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "6.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "8074dbcd93529b357029f5cc5058fd3e43666984"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984",
+ "reference": "8074dbcd93529b357029f5cc5058fd3e43666984",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-23T08:47:14+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "5.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "0735b90f4da94969541dac1da743446e276defa6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6",
+ "reference": "0735b90f4da94969541dac1da743446e276defa6",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.1",
+ "sebastian/recursion-context": "^5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-09-24T06:09:11+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "6.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
+ "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "sebastian/object-reflector": "^3.0",
+ "sebastian/recursion-context": "^5.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T07:19:19+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0",
+ "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-21T08:38:20+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906",
+ "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "sebastian/object-reflector": "^3.0",
+ "sebastian/recursion-context": "^5.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:08:32+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "24ed13d98130f0e7122df55d06c5c4942a577957"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957",
+ "reference": "24ed13d98130f0e7122df55d06c5c4942a577957",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:06:18+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "5.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a",
+ "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-10T07:50:56+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "462699a16464c3944eefc02ebdd77882bd3925bf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf",
+ "reference": "462699a16464c3944eefc02ebdd77882bd3925bf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/4.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T07:10:45+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17",
+ "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/4.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-07T11:34:05+00:00"
+ },
+ {
+ "name": "squizlabs/php_codesniffer",
+ "version": "3.13.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+ "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
+ "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-simplexml": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+ },
+ "bin": [
+ "bin/phpcbf",
+ "bin/phpcs"
+ ],
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Greg Sherwood",
+ "role": "Former lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "Current lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
+ }
+ ],
+ "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "keywords": [
+ "phpcs",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+ "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-11-04T16:30:35+00:00"
+ },
+ {
+ "name": "szepeviktor/phpstan-wordpress",
+ "version": "v2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/szepeviktor/phpstan-wordpress.git",
+ "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/aa722f037b2d034828cd6c55ebe9e5c74961927e",
+ "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "php-stubs/wordpress-stubs": "^6.6.2",
+ "phpstan/phpstan": "^2.0"
+ },
+ "require-dev": {
+ "composer/composer": "^2.1.14",
+ "composer/semver": "^3.4",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.1",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^9.0",
+ "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.0",
+ "wp-coding-standards/wpcs": "3.1.0 as 2.3.0"
+ },
+ "suggest": {
+ "swissspidy/phpstan-no-private": "Detect usage of internal core functions, classes and methods"
+ },
+ "type": "phpstan-extension",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "SzepeViktor\\PHPStan\\WordPress\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "WordPress extensions for PHPStan",
+ "keywords": [
+ "PHPStan",
+ "code analyse",
+ "code analysis",
+ "static analysis",
+ "wordpress"
+ ],
+ "support": {
+ "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues",
+ "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.3"
+ },
+ "time": "2025-09-14T02:58:22+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-11-17T20:03:58+00:00"
+ },
+ {
+ "name": "wp-coding-standards/wpcs",
+ "version": "3.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/WordPress/WordPress-Coding-Standards.git",
+ "reference": "ec2ff942335f33683a5957a85d138753876a05cf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf",
+ "reference": "ec2ff942335f33683a5957a85d138753876a05cf",
+ "shasum": ""
+ },
+ "require": {
+ "ext-filter": "*",
+ "ext-libxml": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlreader": "*",
+ "php": ">=7.2",
+ "phpcsstandards/phpcsextra": "^1.5.1",
+ "phpcsstandards/phpcsutils": "^1.2.3",
+ "squizlabs/php_codesniffer": "^3.13.5"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-console-highlighter": "^1.0.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcompatibility/php-compatibility": "^10.0.0@dev",
+ "phpcsstandards/phpcsdevtools": "^1.2.0",
+ "phpunit/phpunit": "^8.0 || ^9.0"
+ },
+ "suggest": {
+ "ext-iconv": "For improved results",
+ "ext-mbstring": "For improved results"
+ },
+ "type": "phpcodesniffer-standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors"
+ }
+ ],
+ "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions",
+ "keywords": [
+ "phpcs",
+ "standards",
+ "static analysis",
+ "wordpress"
+ ],
+ "support": {
+ "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues",
+ "source": "https://github.com/WordPress/WordPress-Coding-Standards",
+ "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "custom"
+ }
+ ],
+ "time": "2026-07-27T11:53:23+00:00"
+ },
+ {
+ "name": "yoast/phpunit-polyfills",
+ "version": "2.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Yoast/PHPUnit-Polyfills.git",
+ "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27",
+ "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6",
+ "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-console-highlighter": "^1.0.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "yoast/yoastcs": "^3.2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "phpunitpolyfills-autoload.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Team Yoast",
+ "email": "support@yoast.com",
+ "homepage": "https://yoast.com"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors"
+ }
+ ],
+ "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests",
+ "homepage": "https://github.com/Yoast/PHPUnit-Polyfills",
+ "keywords": [
+ "phpunit",
+ "polyfill",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues",
+ "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy",
+ "source": "https://github.com/Yoast/PHPUnit-Polyfills"
+ },
+ "time": "2025-08-10T05:13:49+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.1"
+ },
+ "platform-dev": [],
+ "plugin-api-version": "2.6.0"
+}
diff --git a/includes/adapters/class-tmdo-adapter-comment.php b/includes/adapters/class-tmdo-adapter-comment.php
new file mode 100644
index 0000000..fdc38ec
--- /dev/null
+++ b/includes/adapters/class-tmdo-adapter-comment.php
@@ -0,0 +1,105 @@
+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
+ );
+}
diff --git a/includes/adapters/class-tmdo-adapter-post.php b/includes/adapters/class-tmdo-adapter-post.php
new file mode 100644
index 0000000..6c5bdcd
--- /dev/null
+++ b/includes/adapters/class-tmdo-adapter-post.php
@@ -0,0 +1,93 @@
+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
+ );
+}
diff --git a/includes/adapters/class-tmdo-adapter-term.php b/includes/adapters/class-tmdo-adapter-term.php
new file mode 100644
index 0000000..0a6379d
--- /dev/null
+++ b/includes/adapters/class-tmdo-adapter-term.php
@@ -0,0 +1,89 @@
+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
+ );
+}
diff --git a/includes/adapters/class-tmdo-adapter-user.php b/includes/adapters/class-tmdo-adapter-user.php
new file mode 100644
index 0000000..8d1df01
--- /dev/null
+++ b/includes/adapters/class-tmdo-adapter-user.php
@@ -0,0 +1,87 @@
+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
+ );
+}
diff --git a/includes/adapters/interface-entity-adapter.php b/includes/adapters/interface-entity-adapter.php
new file mode 100644
index 0000000..fe86d55
--- /dev/null
+++ b/includes/adapters/interface-entity-adapter.php
@@ -0,0 +1,83 @@
+
+ */
+ public function get_entity_ids_after( int $after_id, int $limit ): array;
+}
diff --git a/includes/advisor/class-tmdo-fsm-advisor.php b/includes/advisor/class-tmdo-fsm-advisor.php
new file mode 100644
index 0000000..5cd666f
--- /dev/null
+++ b/includes/advisor/class-tmdo-fsm-advisor.php
@@ -0,0 +1,266 @@
+ 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
+ */
+ 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(),
+ );
+ }
+}
diff --git a/includes/advisor/class-tmdo-fsm-automator.php b/includes/advisor/class-tmdo-fsm-automator.php
new file mode 100644
index 0000000..ecc397c
--- /dev/null
+++ b/includes/advisor/class-tmdo-fsm-automator.php
@@ -0,0 +1,234 @@
+ 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;
+ }
+}
diff --git a/includes/advisor/class-tmdo-module-detector.php b/includes/advisor/class-tmdo-module-detector.php
new file mode 100644
index 0000000..fec9947
--- /dev/null
+++ b/includes/advisor/class-tmdo-module-detector.php
@@ -0,0 +1,381 @@
+ 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
+ */
+ 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 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;
+ }
+}
diff --git a/includes/advisor/class-tmdo-module-rules.php b/includes/advisor/class-tmdo-module-rules.php
new file mode 100644
index 0000000..e88eedf
--- /dev/null
+++ b/includes/advisor/class-tmdo-module-rules.php
@@ -0,0 +1,171 @@
+ 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
+ */
+ 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
+ */
+ public static function known_modules(): array {
+ return array_keys( self::all() );
+ }
+}
diff --git a/includes/back-compat/interface-wpdo-entity-adapter-alias.php b/includes/back-compat/interface-wpdo-entity-adapter-alias.php
new file mode 100644
index 0000000..470b891
--- /dev/null
+++ b/includes/back-compat/interface-wpdo-entity-adapter-alias.php
@@ -0,0 +1,30 @@
+ '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';
+ }
+}
diff --git a/includes/class-tmdo-back-compat.php b/includes/class-tmdo-back-compat.php
new file mode 100644
index 0000000..a2353ac
--- /dev/null
+++ b/includes/class-tmdo-back-compat.php
@@ -0,0 +1,182 @@
+ '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 / Adapters(second-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-compat(PHP 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 );
diff --git a/includes/class-tmdo-cache-layer.php b/includes/class-tmdo-cache-layer.php
new file mode 100644
index 0000000..8a14909
--- /dev/null
+++ b/includes/class-tmdo-cache-layer.php
@@ -0,0 +1,236 @@
+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 );
+ }
+}
diff --git a/includes/class-tmdo-capability.php b/includes/class-tmdo-capability.php
new file mode 100644
index 0000000..d61030a
--- /dev/null
+++ b/includes/class-tmdo-capability.php
@@ -0,0 +1,49 @@
+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|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
+ */
+ 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' ) );
+ }
+}
diff --git a/includes/class-tmdo-commentmeta-cleaner.php b/includes/class-tmdo-commentmeta-cleaner.php
new file mode 100644
index 0000000..2c75650
--- /dev/null
+++ b/includes/class-tmdo-commentmeta-cleaner.php
@@ -0,0 +1,277 @@
+ 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
+}
diff --git a/includes/class-tmdo-compatibility.php b/includes/class-tmdo-compatibility.php
new file mode 100644
index 0000000..73284e8
--- /dev/null
+++ b/includes/class-tmdo-compatibility.php
@@ -0,0 +1,133 @@
+ 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 );
+ }
+}
diff --git a/includes/class-tmdo-conflict-monitor.php b/includes/class-tmdo-conflict-monitor.php
new file mode 100644
index 0000000..25a0ee6
--- /dev/null
+++ b/includes/class-tmdo-conflict-monitor.php
@@ -0,0 +1,245 @@
+ 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|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
+ */
+ 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
+ */
+ 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(
+ '',
+ 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 $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 )
+ );
+ }
+}
diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php
new file mode 100644
index 0000000..bb2497a
--- /dev/null
+++ b/includes/class-tmdo-core.php
@@ -0,0 +1,840 @@
+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_ 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'
+ );
+ ?>
+
+
+ WP Data Optimizer:
+
+
+
+
+
+
+
+ WP Data Optimizer:
+ ' . esc_html__( 'HPCT 匯入頁面', '2meet-data-optimizer' ) . ''
+ );
+ ?>
+
+
+ $entity_type,
+ 'group_name' => $group_name,
+ 'migrated' => $result['migrated'],
+ 'total' => $result['total'],
+ 'done' => $result['done'],
+ 'status' => $result['status'],
+ )
+ );
+ }
+}
diff --git a/includes/class-tmdo-crypto.php b/includes/class-tmdo-crypto.php
new file mode 100644
index 0000000..dddf61f
--- /dev/null
+++ b/includes/class-tmdo-crypto.php
@@ -0,0 +1,370 @@
+" (current — AES-256-GCM, AEAD)
+ * - "enc:v1:" (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
+ * }
+ * @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;
+ }
+}
diff --git a/includes/class-tmdo-custom-table-registry.php b/includes/class-tmdo-custom-table-registry.php
new file mode 100644
index 0000000..d95bdd9
--- /dev/null
+++ b/includes/class-tmdo-custom-table-registry.php
@@ -0,0 +1,297 @@
+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
+ */
+ 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> 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) Column => SQL type for schema drift detection.
+ * - indexes (array) 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
+ */
+ public function all(): array {
+ return $this->tables;
+ }
+
+ /**
+ * Get tables for a specific provider.
+ *
+ * @param string $provider Plugin slug.
+ * @return 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
+ */
+ 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
+ */
+ 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;
+ }
+}
diff --git a/includes/class-tmdo-db.php b/includes/class-tmdo-db.php
new file mode 100644
index 0000000..1b2da94
--- /dev/null
+++ b/includes/class-tmdo-db.php
@@ -0,0 +1,210 @@
+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
+ }
+}
diff --git a/includes/class-tmdo-feature-flags.php b/includes/class-tmdo-feature-flags.php
new file mode 100644
index 0000000..6e0a632
--- /dev/null
+++ b/includes/class-tmdo-feature-flags.php
@@ -0,0 +1,346 @@
+|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
+ */
+ 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
+ */
+ 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
+ */
+ 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
+ */
+ 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',
+ };
+ }
+}
diff --git a/includes/class-tmdo-hook-bus-bridge.php b/includes/class-tmdo-hook-bus-bridge.php
new file mode 100644
index 0000000..b76fd9b
--- /dev/null
+++ b/includes/class-tmdo-hook-bus-bridge.php
@@ -0,0 +1,232 @@
+
+ */
+ 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;
+ }
+}
diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php
new file mode 100644
index 0000000..a030b53
--- /dev/null
+++ b/includes/class-tmdo-installer.php
@@ -0,0 +1,1348 @@
+ $batch,
+ 'offset' => $offset,
+ 'fields' => 'ids',
+ )
+ );
+ foreach ( $site_ids as $blog_id ) {
+ switch_to_blog( (int) $blog_id );
+ try {
+ self::install();
+ } finally {
+ // v2.14.1: ensure blog context restored even if install() throws.
+ restore_current_blog();
+ }
+ }
+ $offset += $batch;
+ } while ( count( $site_ids ) === $batch ); // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- count() used in do-while condition, loop body does not modify $site_ids, so caching is unnecessary.
+ }
+
+ /**
+ * Auto-install tables when a new site is created in Multisite.
+ *
+ * @param \WP_Site $site The newly created site.
+ */
+ public static function on_new_site( \WP_Site $site ): void {
+ if ( ! is_plugin_active_for_network( plugin_basename( TMDO_FILE ) ) ) {
+ return;
+ }
+
+ switch_to_blog( (int) $site->blog_id );
+ try {
+ self::install();
+ } finally {
+ // v2.14.1: ensure blog context restored even if install() throws.
+ restore_current_blog();
+ }
+ }
+
+ /**
+ * Drop WPDO tables when a Multisite site is being deleted (v2.14.0).
+ *
+ * Fires on `wp_uninitialize_site` (the recommended hook for plugins to
+ * clean up site-scoped data; runs BEFORE WordPress drops the core
+ * wp_N_* tables). Without this hook, deleting a site leaves up to ~36
+ * orphan `wp_N_wpdo_*` tables permanently consuming DB space.
+ *
+ * Only runs when the plugin is network-active (per-site activations
+ * are scoped to that single site, no cross-site cleanup needed).
+ *
+ * @param \WP_Site|int $site Site or site ID being deleted.
+ * @return void
+ * @since 2.14.0
+ */
+ public static function on_site_delete( $site ): void {
+ if ( ! is_multisite() ) {
+ return;
+ }
+ if ( function_exists( 'is_plugin_active_for_network' )
+ && ! is_plugin_active_for_network( plugin_basename( TMDO_FILE ) )
+ ) {
+ return;
+ }
+
+ $blog_id = $site instanceof \WP_Site ? (int) $site->blog_id : (int) $site;
+ if ( $blog_id < 1 ) {
+ return;
+ }
+
+ switch_to_blog( $blog_id );
+ try {
+ $counts = self::drop_all_tables_for_current_blog();
+ if ( class_exists( 'TMDO_Logger' ) ) {
+ TMDO_Logger::info(
+ 'multisite_site_delete_cleanup',
+ array(
+ 'blog_id' => $blog_id,
+ 'counts' => $counts,
+ )
+ );
+ }
+ } finally {
+ restore_current_blog();
+ }
+ }
+
+ /**
+ * Install tables for the current blog.
+ *
+ * Idempotent. Safely re-runs on every activation / version bump.
+ * v2.0.0: also installs the entity / audit / shadow_diffs / site_metrics
+ * tables via install_v2_tables() — kept here so plain `wp plugin activate`
+ * gets a complete v2 schema without needing the explicit V2_Upgrader run.
+ */
+ public static function install(): void {
+ self::run_dbdelta();
+
+ if ( TMDO_IS_MYSQL ) {
+ self::run_mysql_indexes();
+ }
+
+ if ( TMDO_IS_SQLITE ) {
+ TMDO_SQLite_Compat::patch_all();
+ }
+
+ // v2.0.0 entity + audit + shadow_diffs + site_metrics + uni_options.
+ // Idempotent — only creates tables that don't already exist.
+ self::install_v2_tables();
+
+ update_option( 'wpdo_db_version', self::SCHEMA_VERSION );
+
+ // Register wpdo_features with autoload=no so it doesn't inflate every page load.
+ if ( false === get_option( 'wpdo_features' ) ) {
+ add_option( 'wpdo_features', array(), '', 'no' );
+ }
+
+ // v2.5.4: one-time migration — enable Hook Bus + activate user/term/comment bridge.
+ self::maybe_autoactivate_entity_bridge();
+
+ // v2.15.0: one-time crypto migration — re-encrypt v1 CBC ciphertext as
+ // v2 GCM. Idempotent best-effort: skipped if no v1 ciphertext present
+ // or if the migration flag is already set.
+ self::maybe_migrate_crypto_v1_to_v2();
+ }
+
+ /**
+ * One-time migration: enable Hook Bus and set user/term/comment to dual_write.
+ *
+ * Runs once per site (guarded by wpdo_entity_bridge_autoactivated_v2 flag).
+ * Safe: dual_write still writes native EAV — zero data-loss risk.
+ *
+ * @return void
+ */
+ private static function maybe_autoactivate_entity_bridge(): void {
+ if ( get_option( 'wpdo_entity_bridge_autoactivated_v2' ) ) {
+ return;
+ }
+
+ // Enable Hook Bus.
+ update_option( 'wpdo_hook_bus_enabled', '1', false );
+
+ // Upgrade user/term/comment from disabled → dual_write.
+ $modes = get_option( 'wpdo_bridge_modes', array() );
+ if ( ! is_array( $modes ) ) {
+ $modes = array();
+ }
+ foreach ( array( 'user', 'term', 'comment' ) as $type ) {
+ if ( ! isset( $modes[ $type ] ) || 'disabled' === $modes[ $type ] ) {
+ $modes[ $type ] = 'dual_write';
+ }
+ }
+ // post entity is intentionally left alone — it uses the legacy Feature_Flags FSM.
+ update_option( 'wpdo_bridge_modes', $modes, false );
+
+ update_option( 'wpdo_entity_bridge_autoactivated_v2', '1', false );
+ }
+
+ /**
+ * One-time best-effort crypto migration v1 (CBC) → v2 (GCM) (v2.15.0).
+ *
+ * Idempotent: guarded by the `wpdo_crypto_migrated_v2` flag option. Re-runs
+ * are no-ops. Errors are logged but do not block plugin activation — v1
+ * blobs remain readable so notifications continue to work.
+ *
+ * @return void
+ */
+ private static function maybe_migrate_crypto_v1_to_v2(): void {
+ if ( get_option( 'wpdo_crypto_migrated_v2' ) ) {
+ return;
+ }
+ if ( ! class_exists( 'TMDO_Crypto' ) ) {
+ return;
+ }
+ $counts = TMDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
+
+ if ( $counts['migrated'] > 0 || $counts['failed'] > 0 ) {
+ if ( class_exists( 'TMDO_Logger' ) ) {
+ TMDO_Logger::info( 'crypto_migrate_v1_to_v2', $counts );
+ }
+ }
+
+ // Mark as migrated even if 0 v1 ciphertexts were found, so we don't
+ // re-scan wp_options on every activation. The flag itself uses the
+ // option name pattern but isn't an encrypted secret.
+ update_option( 'wpdo_crypto_migrated_v2', '1', false );
+ }
+
+ /**
+ * Called on plugins_loaded to upgrade when SCHEMA_VERSION changes.
+ */
+ public static function maybe_upgrade(): void {
+ if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) {
+ self::install();
+ }
+ }
+
+ // ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ─────────────────
+ // These tables are installed only when v2.0.0 upgrade runs (PR-7).
+ // install_v2_tables() is idempotent; safe to call multiple times.
+
+ /**
+ * Install v2.0.0 tables — entity tables (user/term/comment), audit log,
+ * shadow_diffs, site_metrics, options manager backing table.
+ *
+ * Idempotent. Called from TMDO_V2_Upgrader::upgrade_to_v2() in PR-7.
+ *
+ * @internal Public so PR-7 upgrader and integration tests can invoke directly.
+ * @return void
+ */
+ public static function install_v2_tables(): void {
+ global $wpdb;
+ // Allow integration tests to provide a stub dbDelta() without WP being bootstrapped.
+ if ( ! function_exists( 'dbDelta' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ }
+
+ $charset = $wpdb->get_charset_collate();
+ $p = $wpdb->prefix;
+
+ $sqls = array();
+
+ // ── wpdo_audit (structured op-aware audit log) ────────────────────
+ $sqls[] = "CREATE TABLE {$p}wpdo_audit (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ ts datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ user_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ entity_type varchar(20) NOT NULL DEFAULT '',
+ entity_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) NOT NULL DEFAULT '',
+ op varchar(20) NOT NULL DEFAULT '',
+ value_before longtext,
+ value_after longtext,
+ source varchar(20) NOT NULL DEFAULT '',
+ trace_id varchar(36) NOT NULL DEFAULT '',
+ PRIMARY KEY (id),
+ KEY idx_entity (entity_type, entity_id),
+ KEY idx_meta_key (meta_key(191)),
+ KEY idx_ts (ts),
+ KEY idx_trace (trace_id)
+) {$charset};";
+
+ // ── wpdo_shadow_diffs (verify-stage divergence log) ───────────────
+ $sqls[] = "CREATE TABLE {$p}wpdo_shadow_diffs (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ ts datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ entity_type varchar(20) NOT NULL DEFAULT '',
+ entity_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) NOT NULL DEFAULT '',
+ postmeta_value longtext,
+ zone_value longtext,
+ diff_hash varchar(40) NOT NULL DEFAULT '',
+ PRIMARY KEY (id),
+ KEY idx_entity (entity_type, entity_id),
+ KEY idx_meta_key (meta_key(191)),
+ KEY idx_diff_hash (diff_hash),
+ KEY idx_ts (ts)
+) {$charset};";
+
+ // ── wpdo_site_metrics (Part C.3 site-wide EAV health) ─────────────
+ $sqls[] = "CREATE TABLE {$p}wpdo_site_metrics (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ collected_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ metric_key varchar(100) NOT NULL DEFAULT '',
+ metric_value bigint(20) NOT NULL DEFAULT 0,
+ context longtext,
+ PRIMARY KEY (id),
+ KEY idx_metric_key (metric_key),
+ KEY idx_collected_at (collected_at)
+) {$charset};";
+
+ // ── wpdo_uni_options (autoload-optimized options) ─────────────────
+ $sqls[] = "CREATE TABLE {$p}wpdo_uni_options (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ option_name varchar(191) NOT NULL DEFAULT '',
+ option_value longtext NOT NULL,
+ autoload varchar(20) NOT NULL DEFAULT 'no',
+ provider varchar(50) NOT NULL DEFAULT '',
+ updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ UNIQUE KEY ui_option_name (option_name),
+ KEY idx_provider (provider)
+) {$charset};";
+
+ // ── wpdo_registry_meta (v2.5.1 fix — engine schema_manager metadata catalog) ─
+ // Stores schema hash + field_definitions per (entity_type, group_name).
+ // Previously referenced by `TMDO_Schema_Manager::store_schema_metadata` /
+ // `get_stored_schema_hash` via `$wpdb->replace()` but never created → produced
+ // continuous WordPress database errors on every wp-cli init. Schema derived
+ // from the columns those callers write.
+ $sqls[] = "CREATE TABLE {$p}wpdo_registry_meta (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ entity_type varchar(20) NOT NULL DEFAULT '',
+ group_name varchar(40) NOT NULL DEFAULT '',
+ schema_hash varchar(64) NOT NULL DEFAULT '',
+ field_definitions longtext,
+ updated_at datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
+ PRIMARY KEY (id),
+ UNIQUE KEY ui_entity_group (entity_type, group_name),
+ KEY idx_schema_hash (schema_hash)
+) {$charset};";
+
+ // ── wpdo_snapshots (v2.2.0 M1 — backup/restore catalog with hybrid storage) ─
+ // Catalog rows for every backup snapshot. Small payloads (≤5MB) live in
+ // `inline_blob`; larger snapshots are stored as gzipped SQL dumps under
+ // wp-content/uploads/wpdo-backups/.sql.gz with sha256 verify.
+ // `trigger` records why the snapshot was created (manual / pre_fsm_transition /
+ // pre_v2_upgrade / scheduled / pre_uninstall). `scope` JSON declares which
+ // entities/modules were dumped. `expires_at` powers the daily prune cron.
+ $sqls[] = "CREATE TABLE {$p}wpdo_snapshots (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ snapshot_id varchar(64) NOT NULL DEFAULT '',
+ trigger_type varchar(40) NOT NULL DEFAULT 'manual',
+ scope longtext,
+ size_bytes bigint(20) unsigned NOT NULL DEFAULT 0,
+ row_count bigint(20) unsigned NOT NULL DEFAULT 0,
+ storage varchar(20) NOT NULL DEFAULT 'file',
+ file_path varchar(500) DEFAULT NULL,
+ file_sha256 varchar(64) DEFAULT NULL,
+ inline_blob longblob,
+ fsm_states longtext,
+ notes varchar(500) DEFAULT NULL,
+ created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ expires_at datetime DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY ui_snapshot_id (snapshot_id),
+ KEY idx_trigger_created (trigger_type, created_at),
+ KEY idx_expires (expires_at)
+) {$charset};";
+
+ // ── wpdo_wc_commissions (v2.1.0 WC integration — vendor commission) ─
+ // Replaces legacy `hpct_wc_orders` from HPCT era. HPOS-aware via
+ // wc_get_order() abstraction in TMDO_WC_Orders_Interceptor.
+ $sqls[] = "CREATE TABLE {$p}wpdo_wc_commissions (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ wc_order_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ vendor_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ listing_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ subtotal decimal(15,4) NOT NULL DEFAULT 0,
+ commission decimal(15,4) NOT NULL DEFAULT 0,
+ vendor_payout decimal(15,4) NOT NULL DEFAULT 0,
+ commission_rate decimal(5,2) NOT NULL DEFAULT 0,
+ status varchar(40) NOT NULL DEFAULT 'pending',
+ hpos_enabled tinyint(1) NOT NULL DEFAULT 0,
+ payout_at datetime DEFAULT NULL,
+ created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ UNIQUE KEY ui_order_vendor (wc_order_id, vendor_id),
+ KEY idx_vendor (vendor_id),
+ KEY idx_status (status),
+ KEY idx_payout_at (payout_at)
+) {$charset};";
+
+ // ── wpdo_migration_status (entity migration engine checkpoint) ────────
+ // Bug fix: TMDO_Entity_Migration_Engine::migrate_group() reads/writes this
+ // table at every batch. Missing in original schema → first backfill fails.
+ $sqls[] = "CREATE TABLE {$p}wpdo_migration_status (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ entity_type varchar(20) NOT NULL DEFAULT '',
+ group_name varchar(40) NOT NULL DEFAULT '',
+ last_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ total_migrated bigint(20) unsigned NOT NULL DEFAULT 0,
+ status varchar(20) NOT NULL DEFAULT 'idle',
+ started_at datetime NULL DEFAULT NULL,
+ completed_at datetime NULL DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY ui_entity_group (entity_type, group_name)
+) {$charset};";
+
+ // ── wpdo_user_points_ledger (append-only points journal) ──────────────
+ // One row per transaction. idx_user_created is a covering index —
+ // WHERE user_id=? ORDER BY created_at DESC LIMIT N never touches data pages.
+ $sqls[] = "CREATE TABLE {$p}wpdo_user_points_ledger (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ user_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ delta int(11) NOT NULL DEFAULT 0,
+ balance_after bigint(20) NOT NULL DEFAULT 0,
+ reason varchar(60) NOT NULL DEFAULT '',
+ ref_id bigint(20) DEFAULT NULL,
+ ref_type varchar(30) DEFAULT NULL,
+ created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ KEY idx_user_created (user_id, created_at),
+ KEY idx_user_reason (user_id, reason),
+ KEY idx_created (created_at)
+) {$charset};";
+
+ // ── v2.12.4 Phase 4: Term + Comment misc bucket (catch-all flat) ───────
+ // Last-resort storage for unregistered term/comment meta keys, so
+ // wp_termmeta / wp_commentmeta can become DROPpable in v3.0.0.
+ // Composite PK (entity_id, meta_key) means each (entity, key) pair
+ // has exactly one canonical row — UPSERT semantics on write.
+ $sqls[] = "CREATE TABLE {$p}wpdo_term_misc (
+ term_id bigint(20) unsigned NOT NULL,
+ meta_key varchar(191) NOT NULL,
+ meta_value longtext,
+ updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (term_id, meta_key),
+ KEY meta_key (meta_key)
+) {$charset};";
+
+ $sqls[] = "CREATE TABLE {$p}wpdo_comment_misc (
+ comment_id bigint(20) unsigned NOT NULL,
+ meta_key varchar(191) NOT NULL,
+ meta_value longtext,
+ updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (comment_id, meta_key),
+ KEY meta_key (meta_key)
+) {$charset};";
+
+ foreach ( $sqls as $sql ) {
+ dbDelta( $sql );
+ }
+
+ if ( TMDO_IS_SQLITE ) {
+ TMDO_SQLite_Compat::patch_table( $p . 'wpdo_audit', self::v2_audit_sqlite_cols() );
+ TMDO_SQLite_Compat::patch_table( $p . 'wpdo_shadow_diffs', self::v2_shadow_diffs_sqlite_cols() );
+ }
+
+ // Composite indexes for member flat tables — MySQL-only, idempotent.
+ if ( TMDO_IS_MYSQL ) {
+ self::install_member_indexes();
+ }
+ }
+
+ /**
+ * Add composite indexes to wp_wpdo_user_membership after Schema Manager
+ * creates the flat table from Entity Registry field definitions.
+ *
+ * Three indexes enable the key 10M-scale query patterns:
+ * idx_level_expires → WHERE level='gold' AND expires_at < NOW()
+ * idx_expires_level → ORDER BY expires_at (scan all expiring this month)
+ * idx_points_bal → ORDER BY points_balance DESC (leaderboard)
+ *
+ * Safe to call multiple times — skips existing indexes.
+ *
+ * @return void
+ */
+ private static function install_member_indexes(): void {
+ global $wpdb;
+ $table = $wpdb->prefix . 'wpdo_user_membership';
+
+ // Skip if Schema Manager hasn't created the table yet.
+ $exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
+ );
+ if ( ! $exists ) {
+ return;
+ }
+
+ $desired = array(
+ 'idx_level_expires' => 'ADD INDEX `idx_level_expires` (membership_level, membership_expires_at)',
+ 'idx_expires_level' => 'ADD INDEX `idx_expires_level` (membership_expires_at, membership_level)',
+ 'idx_points_bal' => 'ADD INDEX `idx_points_bal` (points_balance)',
+ );
+
+ foreach ( $desired as $idx_name => $add_sql ) {
+ $exists = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s',
+ $table,
+ $idx_name
+ )
+ );
+ if ( ! $exists ) {
+ $wpdb->query( "ALTER TABLE `{$table}` {$add_sql}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- table/index names are safe string literals with no user input
+ }
+ }
+ }
+
+ /**
+ * SQLite column metadata for wpdo_audit (used by SQLite_Compat patcher).
+ *
+ * @return array
+ */
+ private static function v2_audit_sqlite_cols(): array {
+ return array(
+ array(
+ 'name' => 'id',
+ 'type' => 'bigint',
+ 'nullable' => false,
+ 'default' => null,
+ ),
+ array(
+ 'name' => 'ts',
+ 'type' => 'datetime',
+ 'nullable' => false,
+ 'default' => '0000-00-00 00:00:00',
+ ),
+ array(
+ 'name' => 'entity_type',
+ 'type' => 'varchar',
+ 'nullable' => false,
+ 'default' => '',
+ ),
+ array(
+ 'name' => 'entity_id',
+ 'type' => 'bigint',
+ 'nullable' => false,
+ 'default' => '0',
+ ),
+ array(
+ 'name' => 'op',
+ 'type' => 'varchar',
+ 'nullable' => false,
+ 'default' => '',
+ ),
+ array(
+ 'name' => 'trace_id',
+ 'type' => 'varchar',
+ 'nullable' => false,
+ 'default' => '',
+ ),
+ );
+ }
+
+ /**
+ * SQLite column metadata for wpdo_shadow_diffs.
+ *
+ * @return array
+ */
+ private static function v2_shadow_diffs_sqlite_cols(): array {
+ return array(
+ array(
+ 'name' => 'id',
+ 'type' => 'bigint',
+ 'nullable' => false,
+ 'default' => null,
+ ),
+ array(
+ 'name' => 'entity_type',
+ 'type' => 'varchar',
+ 'nullable' => false,
+ 'default' => '',
+ ),
+ array(
+ 'name' => 'entity_id',
+ 'type' => 'bigint',
+ 'nullable' => false,
+ 'default' => '0',
+ ),
+ array(
+ 'name' => 'diff_hash',
+ 'type' => 'varchar',
+ 'nullable' => false,
+ 'default' => '',
+ ),
+ );
+ }
+
+ /**
+ * Verify v2 tables exist. Used by upgrader pre-flight + tests.
+ *
+ * @return array Table name => exists.
+ */
+ public static function v2_tables_status(): array {
+ global $wpdb;
+ $p = $wpdb->prefix;
+ $tables = array(
+ $p . 'wpdo_audit',
+ $p . 'wpdo_shadow_diffs',
+ $p . 'wpdo_site_metrics',
+ $p . 'wpdo_uni_options',
+ );
+
+ $status = array();
+ foreach ( $tables as $table ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
+ $status[ $table ] = $exists;
+ }
+ return $status;
+ }
+
+ /**
+ * Called on plugin deactivation. Non-destructive.
+ */
+ public static function deactivate(): void {
+ wp_clear_scheduled_hook( 'wpdo_warm_cleanup' );
+ wp_clear_scheduled_hook( 'wpdo_archive_sweep' );
+ // v2.14.0: clear all plugin cron hooks (was incomplete pre-v2.14.0).
+ wp_clear_scheduled_hook( 'wpdo_errors_gc' );
+ wp_clear_scheduled_hook( 'wpdo_flush_views' );
+ wp_clear_scheduled_hook( 'wpdo_remove_uae_plugin_dir' );
+ wp_clear_scheduled_hook( 'wpdo_daily_health_check' );
+ wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' );
+ wp_clear_scheduled_hook( 'wpdo_fsm_automator_run' );
+ wp_clear_scheduled_hook( 'wpdo_collect_site_metrics' );
+ wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
+ wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
+ wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
+ wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
+ wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
+ wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
+ wp_clear_scheduled_hook( 'wpdo_comment_stress_test_batch' );
+ }
+
+ // ── v2.14.0: Multisite-aware site cleanup ─────────────────────────────────
+
+ /**
+ * Drop all WPDO tables and clear options + cron for the *current* blog.
+ *
+ * Idempotent. Used by:
+ * - `uninstall.php` (single-site delete-and-uninstall path)
+ * - `on_site_delete()` / `on_uninitialize_site()` (multisite per-site cleanup)
+ * - `uninstall.php` network branch via `switch_to_blog` loop
+ *
+ * Does NOT take pre-uninstall snapshot — that's the caller's responsibility
+ * (snapshot only makes sense at uninstall, not at site-deletion).
+ *
+ * Validates each table name against `^[a-zA-Z0-9_]+$` + the `wpdo_` prefix
+ * to prevent accidental DROP of unrelated tables if registry is poisoned.
+ *
+ * @return array{tables_dropped:int,options_deleted:int,crons_cleared:int}
+ * @since 2.14.0
+ */
+ public static function drop_all_tables_for_current_blog(): array {
+ global $wpdb;
+
+ $tables_dropped = 0;
+ $options_deleted = 0;
+ $crons_cleared = 0;
+
+ // ── 1) Static system + entity flat tables ─────────────────────────────
+ $tables = array(
+ $wpdb->prefix . 'wpdo_migrations',
+ $wpdb->prefix . 'wpdo_errors',
+ $wpdb->prefix . 'wpdo_benchmarks',
+ $wpdb->prefix . 'wpdo_warm',
+ $wpdb->prefix . 'wpdo_archive',
+ $wpdb->prefix . 'wpdo_audit',
+ $wpdb->prefix . 'wpdo_shadow_diffs',
+ $wpdb->prefix . 'wpdo_site_metrics',
+ $wpdb->prefix . 'wpdo_registry_meta',
+ $wpdb->prefix . 'wpdo_uni_options',
+ $wpdb->prefix . 'wpdo_wc_commissions',
+ $wpdb->prefix . 'wpdo_snapshots',
+ $wpdb->prefix . 'wpdo_migration_status',
+ $wpdb->prefix . 'wpdo_user_points_ledger',
+ $wpdb->prefix . 'wpdo_user_membership',
+ $wpdb->prefix . 'wpdo_user_activity',
+ $wpdb->prefix . 'wpdo_user_profile',
+ $wpdb->prefix . 'wpdo_user_sso',
+ $wpdb->prefix . 'wpdo_user_core_profile',
+ $wpdb->prefix . 'wpdo_user_social',
+ $wpdb->prefix . 'wpdo_user_commerce',
+ $wpdb->prefix . 'wpdo_user_hp_user',
+ $wpdb->prefix . 'wpdo_user_admin_prefs',
+ $wpdb->prefix . 'wpdo_post_wp_core',
+ $wpdb->prefix . 'wpdo_post_attachment',
+ $wpdb->prefix . 'wpdo_post_wc_product',
+ $wpdb->prefix . 'wpdo_post_hp_listing_core',
+ $wpdb->prefix . 'wpdo_post_hp_request_core',
+ $wpdb->prefix . 'wpdo_post_hp_vendor_core',
+ $wpdb->prefix . 'wpdo_post_nav_menu_item',
+ $wpdb->prefix . 'wpdo_hot_hp_listing',
+ $wpdb->prefix . 'wpdo_term_hp_taxonomy',
+ $wpdb->prefix . 'wpdo_comment_hp_review',
+ $wpdb->prefix . 'wpdo_term_misc',
+ $wpdb->prefix . 'wpdo_comment_misc',
+ );
+
+ // ── 2) Dynamic zone + entity flat tables (per-site discovery) ─────────
+ $hot_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_hot_' ) . '%';
+ $cold_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_cold_' ) . '%';
+ $entity_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_user_' ) . '%';
+ $post_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%';
+ $term_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_term_' ) . '%';
+ $comment_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_comment_' ) . '%';
+
+ if ( class_exists( 'WP_SQLite_Driver' ) ) {
+ $dynamic = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s)",
+ $hot_like,
+ $cold_like,
+ $entity_like,
+ $post_ent_like,
+ $term_ent_like,
+ $comment_ent_like
+ )
+ );
+ } else {
+ $dynamic = $wpdb->get_col(
+ $wpdb->prepare(
+ 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)',
+ $hot_like,
+ $cold_like,
+ $entity_like,
+ $post_ent_like,
+ $term_ent_like,
+ $comment_ent_like
+ )
+ );
+ }
+
+ $tables = array_unique( array_merge( $tables, $dynamic ?: array() ) );
+
+ // ── 3) Validate + filter existing-only + DROP ─────────────────────────
+ // Pre-check existence so the returned count reflects ACTUAL drops, not
+ // `DROP IF EXISTS` attempts. `$dynamic` is already pre-filtered (came
+ // from information_schema), but the static list may contain non-existent
+ // tables that would inflate the counter.
+ $prefix = $wpdb->prefix . 'wpdo_';
+ $valid_static = array();
+ foreach ( $tables as $table ) {
+ if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) {
+ continue;
+ }
+ $valid_static[] = $table;
+ }
+
+ if ( ! empty( $valid_static ) ) {
+ // Single information_schema lookup to confirm which actually exist.
+ $placeholders = implode( ',', array_fill( 0, count( $valid_static ), '%s' ) );
+ if ( class_exists( 'WP_SQLite_Driver' ) ) {
+ $existing = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ ...$valid_static
+ )
+ );
+ } else {
+ $existing = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ ...$valid_static
+ )
+ );
+ }
+ $existing = (array) $existing;
+ } else {
+ $existing = array();
+ }
+
+ foreach ( $existing as $table ) {
+ // Re-validate before raw interpolation as a defence-in-depth measure.
+ if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) {
+ continue;
+ }
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
+ ++$tables_dropped;
+ }
+
+ // ── 4) Clear options ──────────────────────────────────────────────────
+ $known_options = array(
+ 'wpdo_db_version',
+ 'wpdo_features',
+ 'wpdo_features_shadow',
+ 'wpdo_hpct_imported',
+ 'wpdo_hook_bus_enabled',
+ 'wpdo_v2_features_backup',
+ 'wpdo_v2_upgrade_status',
+ 'wpdo_v2_upgrade_error',
+ 'wpdo_v2_upgraded_at',
+ 'wpdo_health_alert',
+ 'wpdo_rl_stats',
+ 'wpdo_setup_wizard_completed',
+ 'wpdo_first_run_at',
+ // Stress test state.
+ 'wpdo_post_stress_test_state',
+ 'wpdo_user_stress_test_state',
+ 'wpdo_term_stress_test_state',
+ 'wpdo_comment_stress_test_state',
+ );
+ foreach ( $known_options as $opt ) {
+ if ( delete_option( $opt ) ) {
+ ++$options_deleted;
+ }
+ }
+
+ // Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0
+ // or by 3rd-party hooks). Validates name pattern before delete to avoid
+ // accidental option removal.
+ $residual = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
+ $wpdb->esc_like( 'wpdo_' ) . '%'
+ )
+ );
+ foreach ( (array) $residual as $opt ) {
+ if ( preg_match( '/^wpdo_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
+ ++$options_deleted;
+ }
+ }
+
+ // ── 5) Clear cron events ──────────────────────────────────────────────
+ $crons = array(
+ 'wpdo_warm_cleanup',
+ 'wpdo_archive_sweep',
+ 'wpdo_errors_gc',
+ 'wpdo_flush_views',
+ 'wpdo_remove_uae_plugin_dir',
+ 'wpdo_daily_health_check',
+ 'wpdo_snapshot_prune_daily',
+ 'wpdo_fsm_automator_run',
+ 'wpdo_collect_site_metrics',
+ 'wpdo_health_snapshot_monthly',
+ 'wpdo_post_shadow_verify',
+ 'wpdo_term_comment_shadow_verify',
+ 'wpdo_post_stress_test_batch',
+ 'wpdo_user_stress_test_batch',
+ 'wpdo_term_stress_test_batch',
+ 'wpdo_comment_stress_test_batch',
+ );
+ foreach ( $crons as $hook ) {
+ if ( false !== wp_next_scheduled( $hook ) ) {
+ wp_clear_scheduled_hook( $hook );
+ ++$crons_cleared;
+ }
+ }
+
+ return array(
+ 'tables_dropped' => $tables_dropped,
+ 'options_deleted' => $options_deleted,
+ 'crons_cleared' => $crons_cleared,
+ );
+ }
+
+ // ── Dynamic Zone Table Creation ──────────────────────────────────────
+
+ /**
+ * Ensure the Zone A (hot) table has all columns declared by Schema_Registry.
+ *
+ * V2.1.2 critical fix: when a partner plugin registers new hot-zone fields
+ * AFTER the initial table creation, those columns never make it to the DB
+ * → WPDO silently falls back to postmeta → zero advertised speedup.
+ *
+ * This method:
+ * 1. Reads current columns from the existing table (SHOW COLUMNS / pragma)
+ * 2. Diffs against $expected_columns (from Schema_Registry)
+ * 3. ALTER TABLE ADD COLUMN for any missing columns (idempotent)
+ * 4. Returns the list of columns that were added (for logging / doctor)
+ *
+ * Safe to call on every page load — diff is fast (~0.5ms), ALTER only fires
+ * on actual drift.
+ *
+ * @param string $post_type Post type slug.
+ * @param array $expected_columns Schema_Registry's declared columns: [name => sql_type].
+ * @return array Names of columns added (empty when no drift).
+ *
+ * @since 2.1.2
+ */
+ public static function ensure_hot_columns( string $post_type, array $expected_columns ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type );
+
+ // Skip if table doesn't exist — create_hot_table will handle it.
+ if ( TMDO_IS_SQLITE ) {
+ $exists = $wpdb->get_var(
+ $wpdb->prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name=%s", $table )
+ );
+ } else {
+ $exists = $wpdb->get_var(
+ $wpdb->prepare( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table )
+ );
+ }
+ if ( ! $exists ) {
+ return array();
+ }
+
+ // Read existing columns.
+ $existing = array();
+ if ( TMDO_IS_SQLITE ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ $rows = $wpdb->get_results( "PRAGMA table_info(`{$table}`)", ARRAY_A );
+ foreach ( (array) $rows as $row ) {
+ $existing[ $row['name'] ] = true;
+ }
+ } else {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ $rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A );
+ foreach ( (array) $rows as $row ) {
+ $existing[ $row['Field'] ] = true;
+ }
+ }
+
+ // Diff: which expected columns are missing from the DB?
+ $added = array();
+ foreach ( $expected_columns as $col_name => $col_type ) {
+ $safe_name = sanitize_key( $col_name );
+ if ( isset( $existing[ $safe_name ] ) ) {
+ continue;
+ }
+
+ // MySQL/MariaDB ALTER TABLE ADD COLUMN. SQLite supports the same syntax.
+ // Suppress PHP warnings during the ALTER — concurrent races on cold deploy
+ // (N php-fpm workers each detecting drift simultaneously) cause the loser
+ // to error with "Duplicate column name". We tolerate that case (the column
+ // IS now present) but surface real failures (disk full, permission denied).
+ $prev_show = $wpdb->show_errors ?? false;
+ if ( method_exists( $wpdb, 'hide_errors' ) ) {
+ $wpdb->hide_errors();
+ }
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ $ok = $wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN `{$safe_name}` {$col_type}" );
+ if ( $prev_show && method_exists( $wpdb, 'show_errors' ) ) {
+ $wpdb->show_errors();
+ }
+
+ if ( false !== $ok ) {
+ $added[] = $safe_name;
+ continue;
+ }
+
+ // v2.1.3 race tolerance: "Duplicate column name" (MySQL errno 1060) means
+ // another worker won the race. The column IS present now — confirm and treat
+ // as success. Real failures (errno != 1060, e.g. 1142 access denied, 1114
+ // table full) still get logged as errors with SQLSTATE/errno for ops.
+ $err = (string) ( $wpdb->last_error ?? '' );
+ $is_duplicate = stripos( $err, 'Duplicate column' ) !== false
+ || stripos( $err, 'duplicate column' ) !== false
+ || stripos( $err, '1060' ) !== false; // MySQL errno.
+ if ( $is_duplicate ) {
+ $added[] = $safe_name; // race winner already added it; we're consistent.
+ continue;
+ }
+
+ TMDO_Logger::error(
+ 'installer',
+ 'ensure_hot_columns',
+ "Failed to ALTER TABLE {$table} ADD {$safe_name}: {$err}"
+ );
+ }
+
+ // Re-add covering indexes to pick up any newly indexed columns.
+ if ( ! empty( $added ) && TMDO_IS_MYSQL ) {
+ self::add_covering_indexes( $post_type, $expected_columns );
+ }
+
+ return $added;
+ }
+
+ /**
+ * Create a Zone A (hot) table for a specific post type.
+ *
+ * @param string $post_type Post type slug (e.g. 'hp_listing').
+ * @param array $columns Column definitions from Schema Registry. Format: [ 'column_name' => 'column_type_sql', ... ].
+ * @return void
+ */
+ public static function create_hot_table( string $post_type, array $columns ): void {
+ global $wpdb;
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ $charset = $wpdb->get_charset_collate();
+ $table_name = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type );
+
+ $col_defs = " id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n";
+ $col_defs .= " post_id bigint(20) unsigned NOT NULL DEFAULT 0,\n";
+
+ $index_defs = array();
+ $sqlite_cols = array(
+ array(
+ 'name' => 'id',
+ 'type' => 'bigint',
+ 'nullable' => false,
+ 'default' => null,
+ ),
+ array(
+ 'name' => 'post_id',
+ 'type' => 'bigint',
+ 'nullable' => false,
+ 'default' => '0',
+ ),
+ );
+
+ foreach ( $columns as $col_name => $col_type ) {
+ $safe_name = sanitize_key( $col_name );
+ $col_defs .= " {$safe_name} {$col_type},\n";
+
+ $base_type = strtolower( strtok( $col_type, '(' ) );
+ $sqlite_cols[] = array(
+ 'name' => $safe_name,
+ 'type' => $base_type,
+ 'nullable' => str_contains( strtolower( $col_type ), 'null' ) && ! str_contains( strtolower( $col_type ), 'not null' ),
+ 'default' => '0',
+ );
+ }
+
+ $col_defs .= " updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',\n";
+ $sqlite_cols[] = array(
+ 'name' => 'updated_at',
+ 'type' => 'datetime',
+ 'nullable' => false,
+ 'default' => '0000-00-00 00:00:00',
+ );
+
+ $indexes = " PRIMARY KEY (id),\n UNIQUE KEY ui_post_id (post_id)";
+ foreach ( $index_defs as $idx ) {
+ $indexes .= ",\n {$idx}";
+ }
+
+ $sql = "CREATE TABLE {$table_name} (\n{$col_defs}{$indexes}\n) {$charset};";
+ dbDelta( $sql );
+
+ if ( TMDO_IS_SQLITE ) {
+ TMDO_SQLite_Compat::patch_table( $table_name, $sqlite_cols );
+ }
+
+ // Add covering indexes after table creation (MySQL only).
+ if ( TMDO_IS_MYSQL ) {
+ self::add_covering_indexes( $post_type, $columns );
+ }
+ }
+
+ /**
+ * Add covering indexes to an existing Zone A (hot) table.
+ *
+ * Index strategy:
+ * - DECIMAL/FLOAT columns → single-column idx for range + ORDER BY
+ * - TINYINT + DECIMAL → compound (flag, sort) for filtered sorts
+ * - BIGINT *_time + DECIMAL → compound (time, sort) for expiry + price
+ * - TINYINT matching *_featured + BIGINT *_featured_time → compound (flag, time)
+ *
+ * Safe to call multiple times — skips already-existing indexes.
+ * No-op on SQLite.
+ *
+ * @param string $post_type Post type slug.
+ * @param array $columns Column name => SQL type from Schema Registry.
+ */
+ public static function add_covering_indexes( string $post_type, array $columns ): void {
+ if ( TMDO_IS_SQLITE ) {
+ return;
+ }
+
+ global $wpdb;
+ $table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type );
+
+ // Categorise columns by SQL type.
+ $decimal_cols = array(); // decimal/float/double → good for range + ORDER BY.
+ $tinyint_cols = array(); // tinyint → boolean flags.
+ $bigint_time = array(); // bigint with _time suffix → timestamps.
+
+ foreach ( $columns as $col_name => $col_type ) {
+ $safe = sanitize_key( $col_name );
+ $base = strtolower( strtok( $col_type, '( ' ) );
+ if ( in_array( $base, array( 'decimal', 'float', 'double' ), true ) ) {
+ $decimal_cols[] = $safe;
+ } elseif ( 'tinyint' === $base ) {
+ $tinyint_cols[] = $safe;
+ } elseif ( 'bigint' === $base && str_ends_with( $safe, '_time' ) ) {
+ $bigint_time[] = $safe;
+ }
+ }
+
+ // Build desired index map: name => ADD INDEX SQL fragment.
+ $desired = array();
+
+ // 1. Single-column index on every decimal column.
+ foreach ( $decimal_cols as $col ) {
+ $desired[ "idx_{$col}" ] = "ADD INDEX `idx_{$col}` (`{$col}`)";
+ }
+
+ // 2. Compound (tinyint_flag, decimal_sort) — improves "WHERE flag=1 ORDER BY price".
+ foreach ( $tinyint_cols as $flag ) {
+ foreach ( $decimal_cols as $sort ) {
+ $name = "idx_{$flag}_{$sort}";
+ $desired[ $name ] = "ADD INDEX `{$name}` (`{$flag}`, `{$sort}`)";
+ }
+ }
+
+ // 3. Compound (bigint_time, decimal_sort) — improves "WHERE exp_time > ? ORDER BY price".
+ foreach ( $bigint_time as $time_col ) {
+ foreach ( $decimal_cols as $sort ) {
+ $name = "idx_{$time_col}_{$sort}";
+ $desired[ $name ] = "ADD INDEX `{$name}` (`{$time_col}`, `{$sort}`)";
+ }
+
+ // 4. Compound (matching_tinyint_flag, bigint_time) — "WHERE featured=1 ORDER BY featured_time".
+ // Matches pattern: tinyint column name is prefix of time column (hp_featured → hp_featured_time).
+ foreach ( $tinyint_cols as $flag ) {
+ if ( str_starts_with( $time_col, $flag . '_' ) ) {
+ $name = "idx_{$flag}_{$time_col}";
+ $desired[ $name ] = "ADD INDEX `{$name}` (`{$flag}`, `{$time_col}`)";
+ }
+ }
+ }
+
+ if ( empty( $desired ) ) {
+ return;
+ }
+
+ // Apply only missing indexes.
+ foreach ( $desired as $idx_name => $add_sql ) {
+ $exists = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s',
+ $table,
+ $idx_name
+ )
+ );
+ if ( ! $exists ) {
+ $wpdb->query( "ALTER TABLE `{$table}` {$add_sql}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- table/index names are validated via sanitize_key(); add_sql contains no user input.
+ }
+ }
+ }
+
+ /**
+ * Create a Zone C (cold) table for a specific post type.
+ *
+ * @param string $post_type Post type slug (e.g. 'hp_vendor').
+ */
+ public static function create_cold_table( string $post_type ): void {
+ global $wpdb;
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ $charset = $wpdb->get_charset_collate();
+ $table_name = $wpdb->prefix . 'wpdo_cold_' . sanitize_key( $post_type );
+
+ $sql = "CREATE TABLE {$table_name} (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ data longtext NOT NULL,
+ updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ UNIQUE KEY ui_post_id (post_id)
+) {$charset};";
+
+ dbDelta( $sql );
+
+ if ( TMDO_IS_SQLITE ) {
+ TMDO_SQLite_Compat::patch_table(
+ $table_name,
+ array(
+ array(
+ 'name' => 'id',
+ 'type' => 'bigint',
+ 'nullable' => false,
+ 'default' => null,
+ ),
+ array(
+ 'name' => 'post_id',
+ 'type' => 'bigint',
+ 'nullable' => false,
+ 'default' => '0',
+ ),
+ array(
+ 'name' => 'data',
+ 'type' => 'longtext',
+ 'nullable' => false,
+ 'default' => null,
+ ),
+ array(
+ 'name' => 'updated_at',
+ 'type' => 'datetime',
+ 'nullable' => false,
+ 'default' => '0000-00-00 00:00:00',
+ ),
+ )
+ );
+ }
+ }
+
+ // ── Layer 1: dbDelta ─────────────────────────────────────────────────
+
+ /**
+ * Runs dbDelta to create or upgrade all WPDO tables.
+ *
+ * @return void
+ */
+ private static function run_dbdelta(): void {
+ global $wpdb;
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ $charset = $wpdb->get_charset_collate();
+ $p = $wpdb->prefix;
+
+ $sqls = array();
+
+ // ── wpdo_migrations ──────────────────────────────────────────────
+ $sqls[] = "CREATE TABLE {$p}wpdo_migrations (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ module varchar(50) NOT NULL DEFAULT '',
+ zone varchar(10) NOT NULL DEFAULT '',
+ state varchar(20) NOT NULL DEFAULT 'idle',
+ total_rows bigint(20) unsigned NOT NULL DEFAULT 0,
+ processed_rows bigint(20) unsigned NOT NULL DEFAULT 0,
+ last_offset bigint(20) unsigned NOT NULL DEFAULT 0,
+ error_count int(11) NOT NULL DEFAULT 0,
+ started_at datetime NULL DEFAULT NULL,
+ completed_at datetime NULL DEFAULT NULL,
+ created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ KEY idx_module (module)
+) {$charset};";
+
+ // ── wpdo_errors ──────────────────────────────────────────────────
+ $sqls[] = "CREATE TABLE {$p}wpdo_errors (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ module varchar(50) NOT NULL DEFAULT '',
+ zone varchar(10) NOT NULL DEFAULT '',
+ hook varchar(255) NOT NULL DEFAULT '',
+ message longtext NOT NULL,
+ context longtext,
+ created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ KEY idx_module (module),
+ KEY idx_created_at (created_at)
+) {$charset};";
+
+ // ── wpdo_benchmarks ──────────────────────────────────────────────
+ $sqls[] = "CREATE TABLE {$p}wpdo_benchmarks (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ module varchar(50) NOT NULL DEFAULT '',
+ zone varchar(10) NOT NULL DEFAULT '',
+ query_type varchar(50) NOT NULL DEFAULT '',
+ native_ms decimal(10,3) NOT NULL DEFAULT 0,
+ custom_ms decimal(10,3) NOT NULL DEFAULT 0,
+ sample_size int(11) NOT NULL DEFAULT 0,
+ created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ KEY idx_module (module)
+) {$charset};";
+
+ // ── wpdo_warm (Zone B) ───────────────────────────────────────────
+ $sqls[] = "CREATE TABLE {$p}wpdo_warm (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) NOT NULL DEFAULT '',
+ meta_value longtext,
+ expires_at datetime NULL DEFAULT NULL,
+ created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ KEY idx_post_meta (post_id, meta_key(191)),
+ KEY idx_expires_at (expires_at)
+) {$charset};";
+
+ // ── wpdo_archive (Zone D) ────────────────────────────────────────
+ $sqls[] = "CREATE TABLE {$p}wpdo_archive (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ post_type varchar(20) NOT NULL DEFAULT '',
+ meta_key varchar(255) NOT NULL DEFAULT '',
+ meta_value longtext,
+ compressed tinyint(1) NOT NULL DEFAULT 0,
+ archived_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ KEY idx_post_id (post_id),
+ KEY idx_post_type_date (post_type, archived_at)
+) {$charset};";
+
+ foreach ( $sqls as $sql ) {
+ dbDelta( $sql );
+ }
+ }
+
+ // ── Layer 2: MySQL-only composite indexes ────────────────────────────
+
+ /**
+ * Adds MySQL-only composite indexes if they do not already exist.
+ *
+ * @return void
+ */
+ private static function run_mysql_indexes(): void {
+ global $wpdb;
+ $p = $wpdb->prefix;
+
+ $indexes = array(
+ "{$p}wpdo_migrations" => array(
+ 'ui_module' => "ALTER TABLE `{$p}wpdo_migrations` ADD UNIQUE KEY `ui_module` (module)",
+ ),
+ "{$p}wpdo_warm" => array(
+ 'ui_post_meta' => "ALTER TABLE `{$p}wpdo_warm` ADD UNIQUE KEY `ui_post_meta` (post_id, meta_key(191))",
+ ),
+ );
+
+ foreach ( $indexes as $table => $defs ) {
+ foreach ( $defs as $key_name => $sql ) {
+ $exists = $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s',
+ $table,
+ $key_name
+ )
+ );
+ if ( ! $exists ) {
+ $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ }
+ }
+ }
+ }
+}
diff --git a/includes/class-tmdo-logger.php b/includes/class-tmdo-logger.php
new file mode 100644
index 0000000..5f41696
--- /dev/null
+++ b/includes/class-tmdo-logger.php
@@ -0,0 +1,160 @@
+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
+ }
+}
diff --git a/includes/class-tmdo-post-shadow-verifier.php b/includes/class-tmdo-post-shadow-verifier.php
new file mode 100644
index 0000000..3961c33
--- /dev/null
+++ b/includes/class-tmdo-post-shadow-verifier.php
@@ -0,0 +1,437 @@
+ 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}
+ */
+ 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' ),
+ )
+ );
+ }
+}
diff --git a/includes/class-tmdo-post-stress-tester.php b/includes/class-tmdo-post-stress-tester.php
new file mode 100644
index 0000000..385ada5
--- /dev/null
+++ b/includes/class-tmdo-post-stress-tester.php
@@ -0,0 +1,1043 @@
+posts/postmeta are WP-managed; meta_key strings are static class constants; user-controlled values use prepare() placeholders.
+
+/**
+ * Bulk fixture generator for post entity stress tests.
+ */
+final class TMDO_Post_Stress_Tester {
+
+ /** Prefix for stress test post titles — used for cleanup matching. */
+ public const TEST_POST_PREFIX = 'TMDO_STRESS_TEST_';
+
+ /** Hard cap to prevent runaway create() calls. */
+ private const MAX_COUNT = 100000;
+
+ // v2.11.4 state machine constants (mirrors TMDO_User_Stress_Tester).
+ public const OPT_STATE = 'wpdo_post_stress_test_state';
+ public const CRON_HOOK = 'wpdo_post_stress_test_batch';
+ public const CANCEL_FLAG = 'wpdo_post_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-post_type seed map: meta_key → value generator (closure or static value).
+ * Static values are sufficient for fixture purposes; randomization belongs in
+ * the user-side realistic_mode benchmarks (out of scope for v2.9.4).
+ *
+ * @var array>|null
+ */
+ private static ?array $seed_map_cache = null;
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Public API
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Bulk-create N stress test posts of the given post_type, plus their
+ * canonical postmeta rows (matching the v2.9.1 entity group definitions).
+ *
+ * @param string $post_type One of: product, hp_listing, hp_request, hp_vendor,
+ * attachment, nav_menu_item, post.
+ * @param int $count Number of posts to insert. Capped at MAX_COUNT.
+ * @return array{created:int,post_type:string,first_id:int|null,last_id:int|null}
+ * @throws InvalidArgumentException When post_type unsupported or $count out of range.
+ */
+ public static function create( string $post_type, int $count ): array {
+ $seed_map = self::seed_map();
+
+ if ( ! isset( $seed_map[ $post_type ] ) ) {
+ throw new InvalidArgumentException(
+ 'Unsupported post_type for stress test: ' . esc_html( $post_type )
+ . '. Supported: ' . esc_html( implode( ', ', array_keys( $seed_map ) ) )
+ );
+ }
+ if ( $count <= 0 ) {
+ throw new InvalidArgumentException( 'Count must be > 0.' );
+ }
+ if ( $count > self::MAX_COUNT ) {
+ $msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . '). Use multiple smaller batches.';
+ throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ global $wpdb;
+
+ $now = current_time( 'mysql' );
+ $now_gmt = current_time( 'mysql', true );
+ $first_id = null;
+ $last_id = null;
+
+ // Insert posts one-by-one — bulk INSERT would require shared insert_id
+ // gymnastics for the postmeta foreign-key relationship. For fixture
+ // volumes (target ≤ 10k), per-row insert is fine and keeps the code
+ // straightforward.
+ for ( $i = 0; $i < $count; $i++ ) {
+ $title = self::TEST_POST_PREFIX . $post_type . '_' . wp_generate_password( 8, false );
+ $ok = $wpdb->insert(
+ $wpdb->posts,
+ array(
+ 'post_title' => $title,
+ 'post_type' => $post_type,
+ 'post_status' => 'publish',
+ 'post_date' => $now,
+ 'post_date_gmt' => $now_gmt,
+ 'post_modified' => $now,
+ 'post_modified_gmt' => $now_gmt,
+ 'post_content' => '',
+ 'post_excerpt' => '',
+ 'post_content_filtered' => '',
+ 'to_ping' => '',
+ 'pinged' => '',
+ 'post_name' => sanitize_title( $title ),
+ 'guid' => '',
+ )
+ );
+ if ( ! $ok ) {
+ continue;
+ }
+ $post_id = (int) $wpdb->insert_id;
+ if ( null === $first_id ) {
+ $first_id = $post_id;
+ }
+ $last_id = $post_id;
+
+ // Seed canonical meta keys for this post_type.
+ foreach ( $seed_map[ $post_type ] as $meta_key => $value_spec ) {
+ $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
+ $wpdb->insert(
+ $wpdb->postmeta,
+ array(
+ 'post_id' => $post_id,
+ 'meta_key' => $meta_key,
+ 'meta_value' => (string) $value,
+ )
+ );
+ }
+ }
+
+ return array(
+ 'created' => $count,
+ 'post_type' => $post_type,
+ 'first_id' => $first_id,
+ 'last_id' => $last_id,
+ );
+ }
+
+ /**
+ * Realistic-mode counterpart of create() (v2.11.2) — uses wp_insert_post()
+ * and update_post_meta() instead of direct $wpdb->insert. Exercises the
+ * full WP filter chain so Hook Bus interception (when post mode is
+ * dual_write or higher) is naturally triggered.
+ *
+ * Use this mode when:
+ * - Validating production write path (mode=dual_write+ → flat tables auto-fill)
+ * - Benchmarking realistic insert latency vs fast-path
+ * - Stress-testing the Hook Bus + Sync_Bridge guard for post entity
+ *
+ * @param string $post_type One of: product, hp_listing, hp_request, hp_vendor,
+ * attachment, nav_menu_item, post.
+ * @param int $count Number of posts to insert. Capped at MAX_COUNT.
+ * @return array{created:int,post_type:string,mode:string,first_id:int|null,last_id:int|null}
+ * @throws InvalidArgumentException When post_type unsupported or $count out of range.
+ */
+ public static function create_realistic( string $post_type, int $count ): array {
+ $seed_map = self::seed_map();
+
+ if ( ! isset( $seed_map[ $post_type ] ) ) {
+ throw new InvalidArgumentException(
+ 'Unsupported post_type for stress test: ' . esc_html( $post_type )
+ . '. Supported: ' . esc_html( implode( ', ', array_keys( $seed_map ) ) )
+ );
+ }
+ if ( $count <= 0 ) {
+ throw new InvalidArgumentException( 'Count must be > 0.' );
+ }
+ if ( $count > self::MAX_COUNT ) {
+ $msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . '). Use multiple smaller batches.';
+ throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ $first_id = null;
+ $last_id = null;
+
+ for ( $i = 0; $i < $count; $i++ ) {
+ $title = self::TEST_POST_PREFIX . $post_type . '_' . wp_generate_password( 8, false );
+ $post_id = wp_insert_post(
+ array(
+ 'post_title' => $title,
+ 'post_type' => $post_type,
+ 'post_status' => 'publish',
+ )
+ );
+ if ( ! $post_id || is_wp_error( $post_id ) ) {
+ continue;
+ }
+ $post_id = (int) $post_id;
+ if ( null === $first_id ) {
+ $first_id = $post_id;
+ }
+ $last_id = $post_id;
+
+ // Use update_post_meta() so WP fires update_post_metadata filter →
+ // Hook Bus intercepts when post mode is dual_write or higher.
+ foreach ( $seed_map[ $post_type ] as $meta_key => $value_spec ) {
+ $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
+ update_post_meta( $post_id, $meta_key, $value );
+ }
+ }
+
+ return array(
+ 'created' => $count,
+ 'post_type' => $post_type,
+ 'mode' => 'realistic',
+ 'first_id' => $first_id,
+ 'last_id' => $last_id,
+ );
+ }
+
+ /**
+ * Count posts matching the stress-test title prefix.
+ *
+ * @return int
+ */
+ public static function count_test_posts(): int {
+ global $wpdb;
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_title LIKE %s",
+ $wpdb->esc_like( self::TEST_POST_PREFIX ) . '%'
+ )
+ );
+ }
+
+ /**
+ * Delete every stress-test post + its postmeta + any flat-table rows
+ * still keyed to those post IDs.
+ *
+ * @return array{deleted_posts:int,deleted_meta:int,deleted_flat_rows:int}
+ */
+ public static function cleanup(): array {
+ global $wpdb;
+
+ $post_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE post_title LIKE %s",
+ $wpdb->esc_like( self::TEST_POST_PREFIX ) . '%'
+ )
+ );
+
+ if ( empty( $post_ids ) ) {
+ return array(
+ 'deleted_posts' => 0,
+ 'deleted_meta' => 0,
+ 'deleted_flat_rows' => 0,
+ );
+ }
+
+ $id_list = implode( ',', array_map( 'absint', $post_ids ) );
+
+ // Delete flat rows first (best-effort — tables may not exist yet).
+ $flat_deleted = 0;
+ foreach ( self::get_post_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 post_id IN ({$id_list})" );
+ $flat_deleted += $rows;
+ }
+
+ // Delete postmeta rows for these post IDs.
+ $meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ({$id_list})" );
+
+ // Finally delete the posts.
+ $post_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->posts} WHERE ID IN ({$id_list})" );
+
+ return array(
+ 'deleted_posts' => $post_deleted,
+ 'deleted_meta' => $meta_deleted,
+ 'deleted_flat_rows' => $flat_deleted,
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // v2.11.4 — State machine (cron pump + progress polling), mirrors User side
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Start an async stress run.
+ *
+ * Persists state in `wpdo_post_stress_test_state` and schedules the first
+ * batch via wp_schedule_single_event(). The first batch does NOT run sync —
+ * it is pushed by the cron event or by `pump_if_due()` on the next polling
+ * request.
+ *
+ * @param string $post_type One of seed_map() keys.
+ * @param int $target Total posts to create (1..MAX_COUNT).
+ * @param string $mode MODE_FAST | MODE_REALISTIC.
+ * @param int $batch_size Per-batch insert count (1..MAX_BATCH_SIZE).
+ * @return array{ok:bool,error?:string,state?:array}
+ */
+ public static function start( string $post_type, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array {
+ $seed_map = self::seed_map();
+ if ( ! isset( $seed_map[ $post_type ] ) ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'unsupported_post_type',
+ );
+ }
+ 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( 'pstress_', true ),
+ 'status' => 'running',
+ 'mode' => $mode,
+ 'post_type' => $post_type,
+ '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 );
+
+ // 清掉前次留下的 cancellation flag, avoid 新測試啟動被誤判為已取消.
+ 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. Sets cancellation flag for in-flight batch
+ * to detect on next iteration; cron events are cleared.
+ *
+ * @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 from option storage.
+ *
+ * @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 advance one batch if cron
+ * is overdue. The REST status endpoint passes true so
+ * admin polling makes progress without an external
+ * cron worker (dev / low-traffic environments).
+ * @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_post_count' => self::count_test_posts(),
+ )
+ );
+ }
+
+ /**
+ * Opportunistic pump. Triggered by status polling. Holds a transient lock
+ * so concurrent polls don't double-pump.
+ *
+ * @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_post_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. Run a single batch then either reschedule or finalize.
+ *
+ * @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_type = (string) ( $state['post_type'] ?? '' );
+ $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_type, $this_batch_size );
+ } else {
+ $inserted = self::run_batch_realistic( $post_type, $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( 'post_stress_test_batch_failed', array( 'message' => $e->getMessage() ) );
+ }
+ return;
+ }
+ $batch_elapsed = microtime( true ) - $batch_started;
+
+ // 重讀 state, cancel() 可能在 batch 執行中改了 status.
+ $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. Delegates to existing create() to share insert
+ * logic, then returns the count actually inserted.
+ *
+ * @param string $post_type Post type (already validated by start()).
+ * @param int $count Posts to insert this batch.
+ * @return int Inserted count.
+ */
+ private static function run_batch_fast( string $post_type, int $count ): int {
+ $result = self::create( $post_type, $count );
+ return (int) ( $result['created'] ?? 0 );
+ }
+
+ /**
+ * Run one realistic-mode batch. Honors BATCH_DEADLINE_SEC and the cancel
+ * flag — checked between each post insert so cancel takes effect within
+ * one wp_insert_post() call.
+ *
+ * @param string $post_type Post type (already validated by start()).
+ * @param int $count Posts to insert this batch.
+ * @return int Inserted count (may be < $count if deadline / cancel hit).
+ */
+ private static function run_batch_realistic( string $post_type, int $count ): int {
+ $deadline = microtime( true ) + self::BATCH_DEADLINE_SEC;
+ $inserted = 0;
+
+ $seed_map = self::seed_map();
+ if ( ! isset( $seed_map[ $post_type ] ) ) {
+ return 0;
+ }
+
+ for ( $i = 0; $i < $count; $i++ ) {
+ if ( microtime( true ) > $deadline ) {
+ break;
+ }
+ if ( false !== get_transient( self::CANCEL_FLAG ) ) {
+ break;
+ }
+
+ $title = self::TEST_POST_PREFIX . $post_type . '_' . wp_generate_password( 8, false );
+ $pid = wp_insert_post(
+ array(
+ 'post_title' => $title,
+ 'post_type' => $post_type,
+ 'post_status' => 'publish',
+ )
+ );
+ if ( ! $pid || is_wp_error( $pid ) ) {
+ continue;
+ }
+ $pid = (int) $pid;
+ ++$inserted;
+
+ foreach ( $seed_map[ $post_type ] as $meta_key => $value_spec ) {
+ $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
+ update_post_meta( $pid, $meta_key, $value );
+ }
+ }
+ return $inserted;
+ }
+
+ /**
+ * Finalize a completed run: clear cron, switch status, run benchmark, save.
+ *
+ * @param array $state Current state (passed in to avoid re-reading).
+ * @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 the benchmark report on the current dataset. Safe to call externally
+ * via the /post-stress-test/benchmark REST endpoint to re-measure without
+ * generating new fixtures.
+ *
+ * @param array|null $state Optional state snapshot; defaults to get_state().
+ * @return array
+ */
+ public static function run_benchmark( ?array $state = null ): array {
+ $state = $state ?? self::get_state();
+ $post_type = (string) ( $state['post_type'] ?? '' );
+
+ return array(
+ 'generated_at' => time(),
+ 'post_type' => $post_type,
+ 'write' => self::compute_write_metrics( $state ),
+ 'db_sizes' => self::measure_db_sizes( $post_type ),
+ 'query' => $post_type ? self::measure_query_performance( $post_type ) : array(),
+ );
+ }
+
+ /**
+ * Compute write-side throughput metrics from state.
+ *
+ * @param array $state Stress 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_type' => $state['post_type'] ?? '',
+ '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 the post-side tables relevant to this run:
+ * wp_posts + wp_postmeta + the flat table that matches $post_type.
+ *
+ * @param string $post_type Post type from state.
+ * @return array
+ */
+ private static function measure_db_sizes( string $post_type ): array {
+ global $wpdb;
+
+ $tables = array( $wpdb->posts, $wpdb->postmeta );
+ $flat = self::flat_table_for_post_type( $post_type );
+ if ( $flat && self::table_exists( $flat ) ) {
+ $tables[] = $flat;
+ }
+
+ 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 lookup on the flat table (indexed key, e.g. _stock_status / hp_status)
+ * - range scan on the flat table (e.g. price >= X)
+ * - EAV-baseline: same range query expressed against wp_postmeta
+ *
+ * The ratio of (3) ÷ (2) is the speedup the stress fixture proves.
+ *
+ * @param string $post_type Post type from state.
+ * @return array
+ */
+ private static function measure_query_performance( string $post_type ): array {
+ global $wpdb;
+
+ $flat = self::flat_table_for_post_type( $post_type );
+ if ( ! $flat || ! self::table_exists( $flat ) ) {
+ return array( 'note' => 'flat_table_missing' );
+ }
+
+ $probes = self::query_probes_for_post_type( $post_type, $flat, $wpdb->postmeta );
+ $out = array();
+ foreach ( $probes as $key => $sql ) {
+ $out[ $key ] = self::time_query( $sql );
+ }
+ return $out;
+ }
+
+ /**
+ * Build per-post_type representative SQL probes. Three probes per type:
+ * point / range / eav_baseline. Returned in the same key order so the JS
+ * report can render them without per-type knowledge.
+ *
+ * @param string $post_type Post type slug.
+ * @param string $flat Flat table for the post type.
+ * @param string $postmeta $wpdb->postmeta.
+ * @return array
+ */
+ private static function query_probes_for_post_type( string $post_type, string $flat, string $postmeta ): array {
+ switch ( $post_type ) {
+ case 'product':
+ return array(
+ 'point_stock_status' => "SELECT post_id FROM `{$flat}` WHERE _stock_status = 'instock' LIMIT 100",
+ 'range_price_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(_price AS DECIMAL(20,2)) > 100 ORDER BY _price LIMIT 100",
+ 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_stock_status' AND m.meta_value = 'instock' LIMIT 100",
+ );
+ case 'hp_listing':
+ return array(
+ 'point_status' => "SELECT post_id FROM `{$flat}` WHERE hp_status = 'publish' LIMIT 100",
+ 'range_price_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(hp_price AS DECIMAL(20,2)) > 100 ORDER BY hp_price LIMIT 100",
+ 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = 'hp_status' AND m.meta_value = 'publish' LIMIT 100",
+ );
+ case 'hp_request':
+ return array(
+ 'point_status' => "SELECT post_id FROM `{$flat}` WHERE hp_status = 'publish' LIMIT 100",
+ 'range_budget_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(hp_budget AS DECIMAL(20,2)) > 100 ORDER BY hp_budget LIMIT 100",
+ 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = 'hp_status' AND m.meta_value = 'publish' LIMIT 100",
+ );
+ case 'hp_vendor':
+ return array(
+ 'point_verified' => "SELECT post_id FROM `{$flat}` WHERE hp_verified = '1' LIMIT 100",
+ 'range_rate_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(hp_hourly_rate AS DECIMAL(20,2)) > 50 ORDER BY hp_hourly_rate LIMIT 100",
+ 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = 'hp_verified' AND m.meta_value = '1' LIMIT 100",
+ );
+ case 'attachment':
+ return array(
+ 'point_alt_present' => "SELECT post_id FROM `{$flat}` WHERE _wp_attachment_image_alt = 'Stress test image' LIMIT 100",
+ 'range_id_above' => "SELECT post_id FROM `{$flat}` WHERE post_id > 0 ORDER BY post_id DESC LIMIT 100",
+ 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_wp_attachment_image_alt' AND m.meta_value = 'Stress test image' LIMIT 100",
+ );
+ case 'nav_menu_item':
+ return array(
+ 'point_type' => "SELECT post_id FROM `{$flat}` WHERE _menu_item_type = 'custom' LIMIT 100",
+ 'range_id_above' => "SELECT post_id FROM `{$flat}` WHERE post_id > 0 ORDER BY post_id DESC LIMIT 100",
+ 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_menu_item_type' AND m.meta_value = 'custom' LIMIT 100",
+ );
+ case 'post':
+ default:
+ return array(
+ 'point_thumbnail' => "SELECT post_id FROM `{$flat}` WHERE _thumbnail_id = '0' LIMIT 100",
+ 'range_id_above' => "SELECT post_id FROM `{$flat}` WHERE post_id > 0 ORDER BY post_id DESC LIMIT 100",
+ 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_thumbnail_id' AND m.meta_value = '0' LIMIT 100",
+ );
+ }
+ }
+
+ /**
+ * Map post_type → its flat table. Returns empty string for unsupported types.
+ *
+ * @param string $post_type Post type slug.
+ * @return string Fully-qualified flat table name or '' if unknown.
+ */
+ private static function flat_table_for_post_type( string $post_type ): string {
+ global $wpdb;
+ $prefix = $wpdb->prefix . 'wpdo_post_';
+ $map = array(
+ 'product' => $prefix . 'wc_product',
+ 'hp_listing' => $prefix . 'hp_listing_core',
+ 'hp_request' => $prefix . 'hp_request_core',
+ 'hp_vendor' => $prefix . 'hp_vendor_core',
+ 'attachment' => $prefix . 'attachment',
+ 'nav_menu_item' => $prefix . 'nav_menu_item',
+ 'post' => $prefix . 'wp_core',
+ );
+ return $map[ $post_type ] ?? '';
+ }
+
+ /**
+ * Time a callable.
+ *
+ * @param callable $cb Callable to invoke once.
+ * @param int $n Logical operation count for QPS.
+ * @return array
+ */
+ private static function time_calls( callable $cb, int $n ): array {
+ $start = microtime( true );
+ $cb();
+ $elapsed_ms = ( microtime( true ) - $start ) * 1000;
+ return array(
+ 'n' => $n,
+ 'total_ms' => round( $elapsed_ms, 2 ),
+ 'avg_ms' => $n > 0 ? round( $elapsed_ms / $n, 3 ) : 0,
+ 'qps' => $elapsed_ms > 0 ? round( $n / ( $elapsed_ms / 1000 ), 1 ) : 0,
+ );
+ }
+
+ /**
+ * Time a SQL query.
+ *
+ * @param string $sql Query to execute via $wpdb->get_results().
+ * @return array
+ */
+ 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 ),
+ );
+ }
+
+ /**
+ * Cheap row-count probe (used for SQLite fallback in measure_db_sizes()).
+ *
+ * @param string $table Fully-qualified 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}`" );
+ }
+
+ /**
+ * Memoized table-exists probe.
+ *
+ * @param string $table Fully-qualified 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 ];
+ }
+
+ /**
+ * Detect MySQL vs SQLite (information_schema is unsupported on the latter).
+ *
+ * @return bool True when running against MySQL/MariaDB.
+ */
+ private static function is_mysql(): bool {
+ return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Internals
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Lazy-built map of post_type → meta_key → value (or value generator).
+ * Mirrors the v2.9.1 entity group key definitions, but seeds only a subset
+ * (5–11 keys) per post_type to keep fixture overhead reasonable.
+ *
+ * @return array>
+ */
+ private static function seed_map(): array {
+ if ( null !== self::$seed_map_cache ) {
+ return self::$seed_map_cache;
+ }
+
+ self::$seed_map_cache = array(
+ 'product' => array(
+ '_price' => static fn( int $i ) => number_format( 10 + ( $i * 0.5 ), 2, '.', '' ),
+ '_regular_price' => static fn( int $i ) => number_format( 12 + ( $i * 0.5 ), 2, '.', '' ),
+ '_stock' => static fn( int $i ) => (string) ( ( $i % 100 ) + 1 ),
+ '_stock_status' => 'instock',
+ '_sku' => static fn( int $i ) => 'STRESS-SKU-' . $i,
+ ),
+ 'hp_listing' => array(
+ 'hp_price' => static fn( int $i ) => number_format( 50 + $i, 2, '.', '' ),
+ 'hp_status' => 'publish',
+ 'hp_featured' => '0',
+ 'hp_verified' => '1',
+ 'hp_vendor' => '1',
+ 'hp_view_count' => static fn( int $i ) => (string) $i,
+ 'hp_expired_time' => static fn() => (string) ( time() + 30 * 86400 ),
+ ),
+ 'hp_request' => array(
+ 'hp_status' => 'publish',
+ 'hp_user' => '1',
+ 'hp_budget' => static fn( int $i ) => number_format( 100 + $i * 10, 2, '.', '' ),
+ 'hp_view_count' => '0',
+ 'hp_expired_time' => static fn() => (string) ( time() + 14 * 86400 ),
+ ),
+ 'hp_vendor' => array(
+ 'hp_user' => static fn( int $i ) => (string) ( $i + 1 ),
+ 'hp_verified' => '1',
+ 'hp_hourly_rate' => static fn( int $i ) => number_format( 50 + $i * 5, 2, '.', '' ),
+ 'hp_rating_count' => static fn( int $i ) => (string) $i,
+ 'hp_rating' => '4.5',
+ ),
+ 'attachment' => array(
+ '_wp_attached_file' => static fn( int $i ) => "stress/test-{$i}.jpg",
+ '_wp_attachment_image_alt' => 'Stress test image',
+ ),
+ 'nav_menu_item' => array(
+ '_menu_item_type' => 'custom',
+ '_menu_item_object_id' => '0',
+ '_menu_item_object' => 'custom',
+ '_menu_item_target' => '',
+ '_menu_item_url' => static fn( int $i ) => "https://example.com/stress-{$i}",
+ ),
+ 'post' => array(
+ '_thumbnail_id' => '0',
+ '_edit_last' => '1',
+ ),
+ );
+
+ return self::$seed_map_cache;
+ }
+
+ /**
+ * Names of all wp_wpdo_post_* flat tables that cleanup should sweep.
+ *
+ * @return string[]
+ */
+ private static function get_post_flat_tables(): array {
+ global $wpdb;
+ $prefix = $wpdb->prefix . 'wpdo_post_';
+ return array(
+ $prefix . 'wp_core',
+ $prefix . 'attachment',
+ $prefix . 'wc_product',
+ $prefix . 'hp_listing_core',
+ $prefix . 'hp_request_core',
+ $prefix . 'hp_vendor_core',
+ $prefix . 'nav_menu_item',
+ );
+ }
+}
diff --git a/includes/class-tmdo-postmeta-cleaner.php b/includes/class-tmdo-postmeta-cleaner.php
new file mode 100644
index 0000000..e7e9a16
--- /dev/null
+++ b/includes/class-tmdo-postmeta-cleaner.php
@@ -0,0 +1,229 @@
+ 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 ":"; 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
+}
diff --git a/includes/class-tmdo-rest-api.php b/includes/class-tmdo-rest-api.php
new file mode 100644
index 0000000..e532af2
--- /dev/null
+++ b/includes/class-tmdo-rest-api.php
@@ -0,0 +1,1608 @@
+ WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_listings' ),
+ 'permission_callback' => '__return_true',
+ 'args' => $this->listings_args(),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/listings/(?P\d+)',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_listing' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'id' => array(
+ 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0,
+ 'sanitize_callback' => 'absint',
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/stats/(?P\d+)',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_stats' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'id' => array(
+ 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0,
+ 'sanitize_callback' => 'absint',
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/listings/(?P\d+)/view',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'post_view' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'id' => array(
+ 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0,
+ 'sanitize_callback' => 'absint',
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/status',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_status' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+
+ // ── Entity Bridge 端點 (v2.6.6) ───────────────────────────────────────
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/entity-bridge/health',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'entity_bridge_health_all' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/entity-bridge/health/(?P[a-z]+)',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'entity_bridge_health_one' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ 'args' => array(
+ 'type' => array(
+ 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/entity-bridge/backfill',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'entity_bridge_start_backfill' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ 'args' => array(
+ 'entity_type' => array(
+ 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => true,
+ ),
+ 'group_name' => array(
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/entity-bridge/promote',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'entity_bridge_promote' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ 'args' => array(
+ 'entity_type' => array(
+ 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/entity-bridge/demote',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'entity_bridge_demote' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ 'args' => array(
+ 'entity_type' => array(
+ 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+
+ // v2.6.7: User stress-test endpoints.
+ register_rest_route(
+ self::NAMESPACE,
+ '/stress-test/status',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'stress_test_status' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/stress-test/start',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'stress_test_start' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ 'args' => array(
+ 'target' => array(
+ 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000000,
+ 'sanitize_callback' => 'absint',
+ 'required' => true,
+ ),
+ 'mode' => array(
+ 'validate_callback' => fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => false,
+ ),
+ 'batch_size' => array(
+ 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 2000,
+ 'sanitize_callback' => 'absint',
+ 'required' => false,
+ ),
+ ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/stress-test/cancel',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'stress_test_cancel' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/stress-test/cleanup',
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'stress_test_cleanup' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/stress-test/benchmark',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'stress_test_run_benchmark' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+
+ // v2.11.4: Post stress-test endpoints (mirror of user-side, post_type-scoped).
+ register_rest_route(
+ self::NAMESPACE,
+ '/post-stress-test/status',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'post_stress_test_status' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/post-stress-test/start',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'post_stress_test_start' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ 'args' => array(
+ 'post_type' => array(
+ 'validate_callback' => fn( $v ) => in_array(
+ $v,
+ array( 'product', 'hp_listing', 'hp_request', 'hp_vendor', 'attachment', 'nav_menu_item', 'post' ),
+ true
+ ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => true,
+ ),
+ 'target' => array(
+ 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000,
+ 'sanitize_callback' => 'absint',
+ 'required' => true,
+ ),
+ 'mode' => array(
+ 'validate_callback' => fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => false,
+ ),
+ 'batch_size' => array(
+ 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000,
+ 'sanitize_callback' => 'absint',
+ 'required' => false,
+ ),
+ ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/post-stress-test/cancel',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'post_stress_test_cancel' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/post-stress-test/cleanup',
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'post_stress_test_cleanup' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/post-stress-test/benchmark',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'post_stress_test_run_benchmark' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+
+ // v2.13.0: Term stress-test endpoints (mirror post side, taxonomy-scoped).
+ register_rest_route(
+ self::NAMESPACE,
+ '/term-stress-test/status',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'term_stress_test_status' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/term-stress-test/start',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'term_stress_test_start' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ 'args' => array(
+ 'taxonomy' => array(
+ 'validate_callback' => static fn( $v ) => is_string( $v ) && '' !== $v && taxonomy_exists( $v ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => true,
+ ),
+ 'target' => array(
+ 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000,
+ 'sanitize_callback' => 'absint',
+ 'required' => true,
+ ),
+ 'mode' => array(
+ 'validate_callback' => static fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => false,
+ ),
+ 'batch_size' => array(
+ 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000,
+ 'sanitize_callback' => 'absint',
+ 'required' => false,
+ ),
+ ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/term-stress-test/cancel',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'term_stress_test_cancel' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/term-stress-test/cleanup',
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'term_stress_test_cleanup' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/term-stress-test/benchmark',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'term_stress_test_run_benchmark' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+
+ // v2.13.1: Comment stress-test endpoints (mirror term side, post-scoped).
+ register_rest_route(
+ self::NAMESPACE,
+ '/comment-stress-test/status',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'comment_stress_test_status' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/comment-stress-test/start',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'comment_stress_test_start' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ 'args' => array(
+ 'post_id' => array(
+ 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1,
+ 'sanitize_callback' => 'absint',
+ 'required' => true,
+ ),
+ 'target' => array(
+ 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000,
+ 'sanitize_callback' => 'absint',
+ 'required' => true,
+ ),
+ 'mode' => array(
+ 'validate_callback' => static fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'required' => false,
+ ),
+ 'batch_size' => array(
+ 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000,
+ 'sanitize_callback' => 'absint',
+ 'required' => false,
+ ),
+ ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/comment-stress-test/cancel',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'comment_stress_test_cancel' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/comment-stress-test/cleanup',
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'comment_stress_test_cleanup' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/comment-stress-test/benchmark',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'comment_stress_test_run_benchmark' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+
+ // ── Migration Wizard endpoints (v2.8.0) ───────────────────────────
+ register_rest_route(
+ self::NAMESPACE,
+ '/migration/preflight',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'migration_preflight' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/migration/start',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'migration_start' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ 'args' => array(
+ 'verify_strict' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
+ 'verify_24h' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
+ 'auto_backup' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
+ 'force_async' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
+ 'dry_run' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
+ ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/migration/status',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'migration_status' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/migration/cancel',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'migration_cancel' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ register_rest_route(
+ self::NAMESPACE,
+ '/migration/resume',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'migration_resume' ),
+ 'permission_callback' => array( $this, 'require_manage_options' ),
+ )
+ );
+ }
+
+ // ── Handlers ─────────────────────────────────────────────────────────────
+
+ /**
+ * GET /wpdo/v1/listings
+ *
+ * Query Zone A flat table (if cutover) or fall back to WP_Query.
+ * Supports pagination and per-column numeric filters.
+ *
+ * @param WP_REST_Request $request REST request object.
+ * @return WP_REST_Response REST response.
+ */
+ public function get_listings( WP_REST_Request $request ): WP_REST_Response {
+ $post_type = $request->get_param( 'post_type' ) ?? 'hp_listing';
+ // Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open.
+ // Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound.
+ $max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 );
+ $per_page = max( 1, min( $max_per_page, (int) ( $request->get_param( 'per_page' ) ?? 20 ) ) );
+ $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
+ $orderby = $request->get_param( 'orderby' ) ?? 'post_id';
+ $order = strtoupper( (string) ( $request->get_param( 'order' ) ?? 'DESC' ) );
+ $offset = ( $page - 1 ) * $per_page;
+
+ $module = 'hot_' . sanitize_key( $post_type );
+
+ if ( TMDO_Feature_Flags::is_read_custom( $module ) ) {
+ return $this->listings_from_zone_a( $post_type, $per_page, $offset, $orderby, $order, $request );
+ }
+
+ return $this->listings_from_wp_query( $post_type, $per_page, $page, $request );
+ }
+
+ /**
+ * GET /wpdo/v1/listings/{id}
+ *
+ * Returns Zone A fields + Zone C blob merged (or postmeta fallback).
+ *
+ * @param WP_REST_Request $request REST request object.
+ * @return WP_REST_Response REST response.
+ */
+ public function get_listing( WP_REST_Request $request ): WP_REST_Response {
+ $post_id = (int) $request->get_param( 'id' );
+
+ // v2.13.3: post_status / read_post capability guard (fixes M-AUTH-1).
+ $err = $this->assert_post_viewable_or_error( $post_id );
+ if ( null !== $err ) {
+ return $err;
+ }
+
+ $post_type = get_post_type( $post_id );
+
+ $data = array(
+ 'id' => $post_id,
+ 'post_type' => $post_type,
+ );
+
+ // Zone A.
+ $hot_module = 'hot_' . sanitize_key( $post_type );
+ if ( TMDO_Feature_Flags::is_read_custom( $hot_module ) ) {
+ $hot = TMDO_Zone_Hot::get_row( $post_id, $post_type );
+ if ( $hot ) {
+ unset( $hot['post_id'], $hot['updated_at'] );
+ $data = array_merge( $data, $hot );
+ }
+ } else {
+ foreach ( array_keys( TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ) ) as $col ) {
+ $data[ $col ] = get_post_meta( $post_id, $col, true );
+ }
+ }
+
+ // Zone C.
+ $cold_module = 'cold_' . sanitize_key( $post_type );
+ if ( TMDO_Feature_Flags::is_read_custom( $cold_module ) ) {
+ $cold = TMDO_Zone_Cold::get_blob( $post_id, $post_type );
+ $data = array_merge( $data, $cold );
+ } else {
+ foreach ( TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ) as $key ) {
+ $data[ $key ] = get_post_meta( $post_id, $key, true );
+ }
+ }
+
+ return new WP_REST_Response( $data, 200 );
+ }
+
+ /**
+ * GET /wpdo/v1/stats/{id}
+ *
+ * Returns Zone B view count (with postmeta fallback).
+ *
+ * @param WP_REST_Request $request REST request object.
+ * @return WP_REST_Response REST response.
+ */
+ public function get_stats( WP_REST_Request $request ): WP_REST_Response {
+ $post_id = (int) $request->get_param( 'id' );
+
+ // v2.13.3: post_status / read_post capability guard (fixes M-AUTH-1).
+ $err = $this->assert_post_viewable_or_error( $post_id );
+ if ( null !== $err ) {
+ return $err;
+ }
+
+ $views = TMDO_Listing_Stats::get_view_count( $post_id );
+
+ return new WP_REST_Response(
+ array(
+ 'post_id' => $post_id,
+ 'view_count' => (int) $views,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * POST /wpdo/v1/listings/{id}/view
+ *
+ * Increment Zone B view count for a post.
+ * Requires a valid WP REST nonce (X-WP-Nonce header or _wpnonce query param).
+ * Rate-limited to one increment per IP per post per hour (transient).
+ * Returns the new view count.
+ *
+ * @param WP_REST_Request $request REST request object.
+ * @return WP_REST_Response REST response.
+ */
+ public function post_view( WP_REST_Request $request ): WP_REST_Response {
+ // Verify nonce — prevents CSRF from third-party sites.
+ $nonce = $request->get_header( 'X-WP-Nonce' )
+ ?? $request->get_param( '_wpnonce' )
+ ?? '';
+
+ if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
+ return new WP_REST_Response(
+ array(
+ 'code' => 'rest_forbidden',
+ 'message' => 'Invalid or missing nonce.',
+ ),
+ 403
+ );
+ }
+
+ $post_id = (int) $request->get_param( 'id' );
+
+ // v2.13.3: post_status / read_post capability guard (fixes M-LOGIC-1).
+ // Prevents accumulating view counts on draft / private / trash posts.
+ $err = $this->assert_post_viewable_or_error( $post_id );
+ if ( null !== $err ) {
+ return $err;
+ }
+
+ // Cookie-based dedup — browser clients won't re-count on page reload.
+ $cookie_key = 'wpdo_view_' . $post_id;
+ $cookie_blocked = ! empty( $_COOKIE[ $cookie_key ] );
+
+ // IP-based rate limit — one increment per IP per post per hour.
+ // v2.13.3: behind a reverse proxy REMOTE_ADDR collapses to the proxy IP
+ // and the rate limit degenerates. Site owners can hook `wpdo_view_client_ip`
+ // to resolve X-Forwarded-For (or another trusted header) per their topology.
+ // Default keeps REMOTE_ADDR (no implicit trust of forwarded headers).
+ $ip_default = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1';
+ $ip = (string) apply_filters( 'wpdo_view_client_ip', $ip_default, $request );
+ $rl_key = 'wpdo_view_' . $post_id . '_' . substr( md5( $ip ), 0, 12 ); // Max 43 chars.
+ $ip_blocked = (bool) get_transient( $rl_key );
+
+ if ( $cookie_blocked || $ip_blocked ) {
+ // Track rate-limit hits per post for admin visibility, capped at 100 entries.
+ $rl_stats = get_option( 'wpdo_rl_stats', array() );
+ $rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1;
+ if ( count( $rl_stats ) > 100 ) {
+ // Keep only the top-100 posts by hit count to bound option size.
+ arsort( $rl_stats );
+ $rl_stats = array_slice( $rl_stats, 0, 100, true );
+ }
+ update_option( 'wpdo_rl_stats', $rl_stats, false );
+
+ return new WP_REST_Response(
+ array(
+ 'code' => 'too_many_requests',
+ 'message' => 'View already counted for this session.',
+ ),
+ 429
+ );
+ }
+
+ // Mark as counted for this IP/session.
+ set_transient( $rl_key, 1, HOUR_IN_SECONDS );
+
+ TMDO_Listing_Stats::increment_view( $post_id );
+ $views = TMDO_Listing_Stats::get_view_count( $post_id );
+
+ $response = new WP_REST_Response(
+ array(
+ 'post_id' => $post_id,
+ 'view_count' => (int) $views,
+ ),
+ 200
+ );
+ // Tell browsers not to re-count on page reload.
+ $response->header( 'Set-Cookie', "{$cookie_key}=1; Max-Age=3600; Path=/; SameSite=Strict" );
+ return $response;
+ }
+
+ /**
+ * GET /wpdo/v1/status (requires manage_options)
+ *
+ * Returns zone module states and registered field counts.
+ *
+ * @param WP_REST_Request $request REST request object. Not used directly.
+ * @return WP_REST_Response REST response.
+ */
+ public function get_status( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by WP REST API callback signature.
+ $registry = TMDO_Schema_Registry::instance();
+ $modules = TMDO_Feature_Flags::all();
+ $stats = $registry->get_stats();
+
+ return new WP_REST_Response(
+ array(
+ 'version' => TMDO_VERSION,
+ 'engine' => TMDO_IS_SQLITE ? 'sqlite' : 'mysql',
+ 'fields' => $stats,
+ 'modules' => $modules,
+ 'rate_limit_stats' => get_option( 'wpdo_rl_stats', array() ),
+ ),
+ 200
+ );
+ }
+
+ // ── Permission ────────────────────────────────────────────────────────────
+
+ /**
+ * Permission callback that requires plugin admin capability.
+ *
+ * Delegates to TMDO_Capability::current_user_can_admin() which additionally
+ * allows super admins on Multisite (v2.14.0). Method name retained for
+ * back-compat with the existing 30 REST endpoint registrations.
+ *
+ * @return bool True if the current user can manage WP Data Optimizer.
+ */
+ public function require_manage_options(): bool {
+ return TMDO_Capability::current_user_can_admin();
+ }
+
+ /**
+ * Visibility guard for the four public REST endpoints (v2.13.3 — fixes
+ * M-AUTH-1 / M-LOGIC-1).
+ *
+ * The 4 `__return_true` endpoints (get_listings / get_listing / get_stats /
+ * post_view) historically only checked post existence via `get_post_type()`.
+ * That allowed anonymous reads of draft / private / trash post fields
+ * (Zone A flat columns + Zone C JSON blob), and accumulated view counts
+ * against unpublished posts.
+ *
+ * Rule: a post is viewable to the current request if either
+ * (a) it is publicly viewable (`is_post_publicly_viewable` — typically
+ * publish status + non-private + password-protected handled by WP), or
+ * (b) the current user has `read_post` capability on it.
+ *
+ * @param int $post_id Post ID to check.
+ * @return WP_REST_Response|null Null when allowed; 404 / 403 response otherwise.
+ */
+ private function assert_post_viewable_or_error( int $post_id ): ?WP_REST_Response {
+ if ( $post_id < 1 || ! get_post_type( $post_id ) ) {
+ return new WP_REST_Response(
+ array(
+ 'code' => 'not_found',
+ 'message' => 'Post not found.',
+ ),
+ 404
+ );
+ }
+
+ $publicly_viewable = function_exists( 'is_post_publicly_viewable' )
+ ? is_post_publicly_viewable( $post_id )
+ : ( 'publish' === get_post_status( $post_id ) );
+
+ if ( $publicly_viewable ) {
+ return null;
+ }
+
+ if ( current_user_can( 'read_post', $post_id ) ) {
+ return null;
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'code' => 'rest_forbidden',
+ 'message' => 'Post not viewable.',
+ ),
+ 403
+ );
+ }
+
+ // ── Entity Bridge Handlers (v2.6.6) ──────────────────────────────────────
+
+ /**
+ * GET /wpdo/v1/entity-bridge/health
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response
+ */
+ public function entity_bridge_health_all( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
+ if ( ! class_exists( 'TMDO_Entity_Health' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Entity health class unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_Entity_Health::get_all(), 200 );
+ }
+
+ /**
+ * GET /wpdo/v1/entity-bridge/health/{type}
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response
+ */
+ public function entity_bridge_health_one( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Entity_Health' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Entity health class unavailable' ), 500 );
+ }
+ $type = (string) $request->get_param( 'type' );
+ return new WP_REST_Response( TMDO_Entity_Health::get_one( $type ), 200 );
+ }
+
+ /**
+ * POST /wpdo/v1/entity-bridge/backfill
+ *
+ * Schedules async cron-driven backfill for one (entity_type, group_name) pair.
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response
+ */
+ public function entity_bridge_start_backfill( WP_REST_Request $request ): WP_REST_Response {
+ $entity_type = (string) $request->get_param( 'entity_type' );
+ $group_name = (string) $request->get_param( 'group_name' );
+
+ if ( ! class_exists( 'TMDO_Entity_Registry' ) || ! TMDO_Entity_Registry::get_adapter( $entity_type ) ) {
+ return new WP_REST_Response( array( 'error' => 'Unknown entity type' ), 400 );
+ }
+
+ $groups = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_groups_for_type( $entity_type ) : array();
+ if ( ! in_array( $group_name, $groups, true ) ) {
+ return new WP_REST_Response( array( 'error' => 'Unknown group for this entity type' ), 400 );
+ }
+
+ $hook = 'wpdo_entity_backfill_batch';
+ $args = array( $entity_type, $group_name );
+
+ // Clear existing scheduled event for this pair before scheduling fresh.
+ $existing = wp_next_scheduled( $hook, $args );
+ if ( $existing ) {
+ wp_unschedule_event( $existing, $hook, $args );
+ }
+
+ // Also reset the checkpoint so backfill starts from the beginning.
+ if ( class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
+ TMDO_Entity_Migration_Engine::reset_checkpoint( $entity_type, $group_name );
+ }
+
+ wp_schedule_single_event( time(), $hook, $args );
+
+ TMDO_Logger::info(
+ 'entity_backfill_scheduled',
+ array(
+ 'entity_type' => $entity_type,
+ 'group_name' => $group_name,
+ 'user_id' => get_current_user_id(),
+ )
+ );
+
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'message' => "Backfill scheduled for {$entity_type}/{$group_name}",
+ 'entity_type' => $entity_type,
+ 'group_name' => $group_name,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * POST /wpdo/v1/entity-bridge/promote
+ *
+ * Advance entity mode by one step (disabled→dual_write→shadow_read→aeav_only).
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response
+ */
+ public function entity_bridge_promote( WP_REST_Request $request ): WP_REST_Response {
+ $entity_type = (string) $request->get_param( 'entity_type' );
+
+ if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Mode manager unavailable' ), 500 );
+ }
+
+ $current = TMDO_Mode_Manager::get( $entity_type );
+ $order = TMDO_Mode_Manager::ALL_MODES;
+ $idx = array_search( $current, $order, true );
+
+ if ( false === $idx || $idx >= count( $order ) - 1 ) {
+ return new WP_REST_Response( array( 'error' => "Already at maximum mode: {$current}" ), 400 );
+ }
+
+ $next = $order[ $idx + 1 ];
+ $result = TMDO_Mode_Manager::set( $entity_type, $next );
+
+ if ( is_wp_error( $result ) ) {
+ return new WP_REST_Response( array( 'error' => $result->get_error_message() ), 400 );
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'entity_type' => $entity_type,
+ 'from' => $current,
+ 'to' => $next,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * POST /wpdo/v1/entity-bridge/demote
+ *
+ * Roll back entity mode by one step.
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response
+ */
+ public function entity_bridge_demote( WP_REST_Request $request ): WP_REST_Response {
+ $entity_type = (string) $request->get_param( 'entity_type' );
+
+ if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Mode manager unavailable' ), 500 );
+ }
+
+ $current = TMDO_Mode_Manager::get( $entity_type );
+ $order = TMDO_Mode_Manager::ALL_MODES;
+ $idx = array_search( $current, $order, true );
+
+ if ( false === $idx || $idx <= 0 ) {
+ return new WP_REST_Response( array( 'error' => "Already at minimum mode: {$current}" ), 400 );
+ }
+
+ $prev = $order[ $idx - 1 ];
+ $result = TMDO_Mode_Manager::set( $entity_type, $prev );
+
+ if ( is_wp_error( $result ) ) {
+ return new WP_REST_Response( array( 'error' => $result->get_error_message() ), 400 );
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'entity_type' => $entity_type,
+ 'from' => $current,
+ 'to' => $prev,
+ ),
+ 200
+ );
+ }
+
+ // ── v2.6.7: User Stress Test Handlers ────────────────────────────────────
+
+ /**
+ * GET /wpdo/v1/stress-test/status
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function stress_test_status( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_User_Stress_Tester::get_progress(), 200 );
+ }
+
+ /**
+ * POST /wpdo/v1/stress-test/start
+ *
+ * @param WP_REST_Request $request REST request with target / mode / batch_size.
+ * @return WP_REST_Response REST response.
+ */
+ public function stress_test_start( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
+ }
+ $target = (int) $request->get_param( 'target' );
+ $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' );
+ $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_User_Stress_Tester::DEFAULT_BATCH_SIZE );
+
+ $result = TMDO_User_Stress_Tester::start( $target, $mode, $batch_size );
+ $status = ! empty( $result['ok'] ) ? 200 : 409;
+ return new WP_REST_Response( $result, $status );
+ }
+
+ /**
+ * POST /wpdo/v1/stress-test/cancel
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function stress_test_cancel( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_User_Stress_Tester::cancel(), 200 );
+ }
+
+ /**
+ * DELETE /wpdo/v1/stress-test/cleanup
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_User_Stress_Tester::cleanup(), 200 );
+ }
+
+ /**
+ * POST /wpdo/v1/stress-test/benchmark — re-run benchmark on current dataset.
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
+ }
+ $report = TMDO_User_Stress_Tester::run_benchmark();
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'benchmark' => $report,
+ ),
+ 200
+ );
+ }
+
+ // ── v2.11.4: Post Stress Test Handlers ────────────────────────────────────
+
+ /**
+ * GET /wpdo/v1/post-stress-test/status
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function post_stress_test_status( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_Post_Stress_Tester::get_progress(), 200 );
+ }
+
+ /**
+ * POST /wpdo/v1/post-stress-test/start
+ *
+ * @param WP_REST_Request $request REST request with post_type / target / mode / batch_size.
+ * @return WP_REST_Response REST response.
+ */
+ public function post_stress_test_start( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
+ }
+ $post_type = (string) $request->get_param( 'post_type' );
+ $target = (int) $request->get_param( 'target' );
+ $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' );
+ $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Post_Stress_Tester::DEFAULT_BATCH_SIZE );
+
+ $result = TMDO_Post_Stress_Tester::start( $post_type, $target, $mode, $batch_size );
+ $status = ! empty( $result['ok'] ) ? 200 : 409;
+ return new WP_REST_Response( $result, $status );
+ }
+
+ /**
+ * POST /wpdo/v1/post-stress-test/cancel
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function post_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_Post_Stress_Tester::cancel(), 200 );
+ }
+
+ /**
+ * DELETE /wpdo/v1/post-stress-test/cleanup
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function post_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
+ }
+ $result = TMDO_Post_Stress_Tester::cleanup();
+ // Also wipe the persisted run state so the UI re-renders idle after cleanup.
+ delete_option( TMDO_Post_Stress_Tester::OPT_STATE );
+ delete_transient( TMDO_Post_Stress_Tester::CANCEL_FLAG );
+
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'deleted' => (int) ( $result['deleted_posts'] ?? 0 ),
+ 'detail' => $result,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * POST /wpdo/v1/post-stress-test/benchmark — re-run benchmark on current state.
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function post_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
+ }
+ $report = TMDO_Post_Stress_Tester::run_benchmark();
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'benchmark' => $report,
+ ),
+ 200
+ );
+ }
+
+ // ── v2.13.0: Term Stress Test Handlers ────────────────────────────────────
+
+ /**
+ * GET /wpdo/v1/term-stress-test/status
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function term_stress_test_status( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_Term_Stress_Tester::get_progress(), 200 );
+ }
+
+ /**
+ * POST /wpdo/v1/term-stress-test/start
+ *
+ * @param WP_REST_Request $request REST request with taxonomy / target / mode / batch_size.
+ * @return WP_REST_Response REST response.
+ */
+ public function term_stress_test_start( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
+ }
+ $taxonomy = (string) $request->get_param( 'taxonomy' );
+ $target = (int) $request->get_param( 'target' );
+ $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' );
+ $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Term_Stress_Tester::DEFAULT_BATCH_SIZE );
+
+ $result = TMDO_Term_Stress_Tester::start( $taxonomy, $target, $mode, $batch_size );
+ $status = ! empty( $result['ok'] ) ? 200 : 409;
+ return new WP_REST_Response( $result, $status );
+ }
+
+ /**
+ * POST /wpdo/v1/term-stress-test/cancel
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function term_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_Term_Stress_Tester::cancel(), 200 );
+ }
+
+ /**
+ * DELETE /wpdo/v1/term-stress-test/cleanup
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function term_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
+ }
+ $result = TMDO_Term_Stress_Tester::cleanup();
+ delete_option( TMDO_Term_Stress_Tester::OPT_STATE );
+ delete_transient( TMDO_Term_Stress_Tester::CANCEL_FLAG );
+
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'deleted' => (int) ( $result['deleted_terms'] ?? 0 ),
+ 'detail' => $result,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * POST /wpdo/v1/term-stress-test/benchmark
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function term_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
+ }
+ $report = TMDO_Term_Stress_Tester::run_benchmark();
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'benchmark' => $report,
+ ),
+ 200
+ );
+ }
+
+ // ── v2.13.1: Comment Stress Test Handlers ─────────────────────────────────
+
+ /**
+ * GET /wpdo/v1/comment-stress-test/status
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function comment_stress_test_status( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_Comment_Stress_Tester::get_progress(), 200 );
+ }
+
+ /**
+ * POST /wpdo/v1/comment-stress-test/start
+ *
+ * @param WP_REST_Request $request REST request with post_id / target / mode / batch_size.
+ * @return WP_REST_Response REST response.
+ */
+ public function comment_stress_test_start( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
+ }
+ $post_id = (int) $request->get_param( 'post_id' );
+ $target = (int) $request->get_param( 'target' );
+ $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' );
+ $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Comment_Stress_Tester::DEFAULT_BATCH_SIZE );
+
+ $result = TMDO_Comment_Stress_Tester::start( $post_id, $target, $mode, $batch_size );
+ $status = ! empty( $result['ok'] ) ? 200 : 409;
+ return new WP_REST_Response( $result, $status );
+ }
+
+ /**
+ * POST /wpdo/v1/comment-stress-test/cancel
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function comment_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
+ }
+ return new WP_REST_Response( TMDO_Comment_Stress_Tester::cancel(), 200 );
+ }
+
+ /**
+ * DELETE /wpdo/v1/comment-stress-test/cleanup
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function comment_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
+ }
+ $result = TMDO_Comment_Stress_Tester::cleanup();
+ delete_option( TMDO_Comment_Stress_Tester::OPT_STATE );
+ delete_transient( TMDO_Comment_Stress_Tester::CANCEL_FLAG );
+
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'deleted' => (int) ( $result['deleted_comments'] ?? 0 ),
+ 'detail' => $result,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * POST /wpdo/v1/comment-stress-test/benchmark
+ *
+ * @param WP_REST_Request $request REST request (unused).
+ * @return WP_REST_Response REST response.
+ */
+ public function comment_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response {
+ if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
+ return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
+ }
+ $report = TMDO_Comment_Stress_Tester::run_benchmark();
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'benchmark' => $report,
+ ),
+ 200
+ );
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────────
+
+ /**
+ * Query Zone A flat table directly.
+ *
+ * @param string $post_type Post type slug.
+ * @param int $per_page Number of items per page.
+ * @param int $offset Database offset.
+ * @param string $orderby Column to order by.
+ * @param string $order Order direction (ASC or DESC).
+ * @param WP_REST_Request $request REST request object.
+ * @return WP_REST_Response REST response.
+ */
+ private function listings_from_zone_a(
+ string $post_type,
+ int $per_page,
+ int $offset,
+ string $orderby,
+ string $order,
+ WP_REST_Request $request
+ ): WP_REST_Response {
+ global $wpdb;
+
+ $table = TMDO_Zone_Hot::table( $post_type );
+ $columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
+
+ // Build WHERE clauses from numeric filter params.
+ $where = array();
+ $params = array();
+
+ foreach ( $this->get_filter_params( $request, $columns ) as $filter ) {
+ $where[] = $filter['sql'];
+ $params[] = $filter['value'];
+ }
+
+ $where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : '';
+
+ // Validate orderby against known columns + post_id.
+ $allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) );
+ $orderby_col = in_array( $orderby, $allowed_orderby, true ) ? sanitize_key( $orderby ) : 'post_id';
+ $order_dir = 'ASC' === $order ? 'ASC' : 'DESC';
+
+ // Count total.
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated.
+ if ( $params ) {
+ $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` {$where_sql}", ...$params ) );
+ } else {
+ $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
+ }
+
+ // Fetch rows.
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM `{$table}` {$where_sql} ORDER BY `{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
+ ...array_merge( $params, array( $per_page, $offset ) )
+ ),
+ ARRAY_A
+ ) ?: array();
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+
+ // Rename post_id → id, drop internal columns.
+ $items = array_map(
+ function ( array $row ) use ( $post_type ): array {
+ $item = array(
+ 'id' => (int) $row['post_id'],
+ 'post_type' => $post_type,
+ );
+ unset( $row['post_id'], $row['updated_at'] );
+ return array_merge( $item, $row );
+ },
+ $rows
+ );
+
+ $response = new WP_REST_Response( $items, 200 );
+ $response->header( 'X-WP-Total', (string) $total );
+ $response->header( 'X-WP-TotalPages', (string) (int) ceil( $total / $per_page ) );
+
+ return $response;
+ }
+
+ /**
+ * Fall back to WP_Query when zone is not cutover.
+ *
+ * @param string $post_type Post type slug.
+ * @param int $per_page Number of items per page.
+ * @param int $page Page number.
+ * @param WP_REST_Request $request REST request object. Not used directly.
+ * @return WP_REST_Response REST response.
+ */
+ private function listings_from_wp_query( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $request kept for potential future filtering.
+ string $post_type,
+ int $per_page,
+ int $page,
+ WP_REST_Request $request
+ ): WP_REST_Response {
+ $args = array(
+ 'post_type' => $post_type,
+ 'posts_per_page' => $per_page,
+ 'paged' => $page,
+ 'post_status' => 'publish',
+ );
+
+ $query = new WP_Query( $args );
+ $items = array();
+
+ foreach ( $query->posts as $post ) {
+ $item = array(
+ 'id' => $post->ID,
+ 'post_type' => $post->post_type,
+ );
+ $cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
+ foreach ( array_keys( $cols ) as $col ) {
+ $item[ $col ] = get_post_meta( $post->ID, $col, true );
+ }
+ $items[] = $item;
+ }
+
+ $response = new WP_REST_Response( $items, 200 );
+ $response->header( 'X-WP-Total', (string) $query->found_posts );
+ $response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );
+
+ return $response;
+ }
+
+ /**
+ * Extract numeric filter params (e.g. hp_price_min => hp_price >= ?)
+ * from the request, validated against registered hot columns.
+ *
+ * @param WP_REST_Request $request REST request object.
+ * @param array $columns Registered hot columns from Schema Registry.
+ * @return array[] Each element: ['sql' => string, 'value' => mixed].
+ */
+ private function get_filter_params( WP_REST_Request $request, array $columns ): array {
+ $filters = array();
+
+ foreach ( array_keys( $columns ) as $col ) {
+ $col = sanitize_key( $col );
+
+ $min = $request->get_param( $col . '_min' );
+ if ( null !== $min && is_numeric( $min ) ) {
+ $filters[] = array(
+ 'sql' => "`{$col}` >= %f",
+ 'value' => (float) $min,
+ );
+ }
+
+ $max = $request->get_param( $col . '_max' );
+ if ( null !== $max && is_numeric( $max ) ) {
+ $filters[] = array(
+ 'sql' => "`{$col}` <= %f",
+ 'value' => (float) $max,
+ );
+ }
+
+ $exact = $request->get_param( $col );
+ if ( null !== $exact && '' !== $exact ) {
+ if ( is_numeric( $exact ) ) {
+ $filters[] = array(
+ 'sql' => "`{$col}` = %f",
+ 'value' => (float) $exact,
+ );
+ } else {
+ $filters[] = array(
+ 'sql' => "`{$col}` = %s",
+ 'value' => (string) $exact,
+ );
+ }
+ }
+ }
+
+ return $filters;
+ }
+
+ /**
+ * Argument definitions for GET /listings.
+ */
+ private function listings_args(): array {
+ return array(
+ 'post_type' => array(
+ 'default' => 'hp_listing',
+ 'sanitize_callback' => 'sanitize_key',
+ ),
+ 'per_page' => array(
+ 'default' => 20,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v >= 1 && (int) $v <= 100,
+ ),
+ 'page' => array(
+ 'default' => 1,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v >= 1,
+ ),
+ 'orderby' => array(
+ 'default' => 'post_id',
+ 'sanitize_callback' => 'sanitize_key',
+ ),
+ 'order' => array(
+ 'default' => 'DESC',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => fn( $v ) => in_array( strtoupper( $v ), array( 'ASC', 'DESC' ), true ),
+ ),
+ );
+ }
+
+ // ── Migration Wizard handlers (v2.8.0) ────────────────────────────────────
+
+ /**
+ * GET /wpdo/v1/migration/preflight
+ *
+ * Read-only diagnostic — returns current ratio, mode, group residue, and
+ * the strategy `start()` would pick.
+ *
+ * @param WP_REST_Request $request Unused (read-only endpoint takes no params).
+ * @return WP_REST_Response
+ */
+ public function migration_preflight( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+ return new WP_REST_Response( TMDO_Migration_Orchestrator::preflight(), 200 );
+ }
+
+ /**
+ * POST /wpdo/v1/migration/start
+ *
+ * Body params: verify_strict (bool, default true), verify_24h (bool),
+ * auto_backup (bool, default true), force_async (bool), dry_run (bool).
+ *
+ * @param WP_REST_Request $request Body parameters.
+ * @return WP_REST_Response
+ */
+ public function migration_start( WP_REST_Request $request ): WP_REST_Response {
+ $options = array(
+ 'verify_strict' => null === $request->get_param( 'verify_strict' ) ? true : (bool) $request->get_param( 'verify_strict' ),
+ 'verify_24h' => (bool) $request->get_param( 'verify_24h' ),
+ 'auto_backup' => null === $request->get_param( 'auto_backup' ) ? true : (bool) $request->get_param( 'auto_backup' ),
+ 'force_async' => (bool) $request->get_param( 'force_async' ),
+ 'dry_run' => (bool) $request->get_param( 'dry_run' ),
+ );
+ $result = TMDO_Migration_Orchestrator::start( $options );
+ $status = ! empty( $result['ok'] ) ? 200 : ( 'nothing_to_do' === ( $result['reason'] ?? '' ) ? 200 : 409 );
+ return new WP_REST_Response( $result, $status );
+ }
+
+ /**
+ * GET /wpdo/v1/migration/status — polling target.
+ *
+ * @param WP_REST_Request $request Unused.
+ * @return WP_REST_Response
+ */
+ public function migration_status( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+ return new WP_REST_Response( TMDO_Migration_Orchestrator::get_status(), 200 );
+ }
+
+ /**
+ * POST /wpdo/v1/migration/cancel
+ *
+ * @param WP_REST_Request $request Unused.
+ * @return WP_REST_Response
+ */
+ public function migration_cancel( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+ $ok = TMDO_Migration_Orchestrator::cancel();
+ return new WP_REST_Response( array( 'ok' => $ok ), $ok ? 200 : 409 );
+ }
+
+ /**
+ * POST /wpdo/v1/migration/resume
+ *
+ * @param WP_REST_Request $request Unused.
+ * @return WP_REST_Response
+ */
+ public function migration_resume( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+ $result = TMDO_Migration_Orchestrator::resume();
+ return new WP_REST_Response( $result, ! empty( $result['ok'] ) ? 200 : 409 );
+ }
+}
diff --git a/includes/class-tmdo-safe-unserialize.php b/includes/class-tmdo-safe-unserialize.php
new file mode 100644
index 0000000..9dc0147
--- /dev/null
+++ b/includes/class-tmdo-safe-unserialize.php
@@ -0,0 +1,91 @@
+ 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;
+ }
+}
diff --git a/includes/class-tmdo-schema-registry.php b/includes/class-tmdo-schema-registry.php
new file mode 100644
index 0000000..9b1fec5
--- /dev/null
+++ b/includes/class-tmdo-schema-registry.php
@@ -0,0 +1,287 @@
+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
+ */
+ private array $fields = array();
+
+ /**
+ * Zone → post_type → columns map for Zone A (hot) table creation.
+ *
+ * @var array>
+ */
+ private array $hot_columns = array();
+
+ /**
+ * Zone C (cold) fields grouped by post_type.
+ *
+ * @var array
+ */
+ private array $cold_fields = array();
+
+ /**
+ * Zone B (warm) fields with TTL config.
+ *
+ * @var 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 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
+ */
+ 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;
+ }
+}
diff --git a/includes/class-tmdo-sqlite-compat.php b/includes/class-tmdo-sqlite-compat.php
new file mode 100644
index 0000000..60ebd0f
--- /dev/null
+++ b/includes/class-tmdo-sqlite-compat.php
@@ -0,0 +1,359 @@
+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',
+ ),
+ ),
+ );
+ }
+}
diff --git a/includes/class-tmdo-term-comment-backfill.php b/includes/class-tmdo-term-comment-backfill.php
new file mode 100644
index 0000000..1bee04f
--- /dev/null
+++ b/includes/class-tmdo-term-comment-backfill.php
@@ -0,0 +1,224 @@
+
+ */
+ 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
+ */
+ 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 );
+ }
+}
diff --git a/includes/class-tmdo-term-comment-shadow-verifier.php b/includes/class-tmdo-term-comment-shadow-verifier.php
new file mode 100644
index 0000000..9ec855b
--- /dev/null
+++ b/includes/class-tmdo-term-comment-shadow-verifier.php
@@ -0,0 +1,428 @@
+
+ */
+ 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}
+ */
+ 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 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;
+ }
+}
diff --git a/includes/class-tmdo-term-stress-tester.php b/includes/class-tmdo-term-stress-tester.php
new file mode 100644
index 0000000..4b6c5d0
--- /dev/null
+++ b/includes/class-tmdo-term-stress-tester.php
@@ -0,0 +1,880 @@
+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|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
+ */
+ 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' ) );
+ }
+}
diff --git a/includes/class-tmdo-termmeta-cleaner.php b/includes/class-tmdo-termmeta-cleaner.php
new file mode 100644
index 0000000..1f6aa90
--- /dev/null
+++ b/includes/class-tmdo-termmeta-cleaner.php
@@ -0,0 +1,213 @@
+ 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
+}
diff --git a/includes/class-tmdo-user-stress-tester.php b/includes/class-tmdo-user-stress-tester.php
new file mode 100644
index 0000000..c9f0156
--- /dev/null
+++ b/includes/class-tmdo-user-stress-tester.php
@@ -0,0 +1,1417 @@
+ false,
+ 'error' => 'target_count must be >= 1',
+ );
+ }
+ if ( $target_count > 1000000 ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'target_count too large (max 1,000,000)',
+ );
+ }
+ 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( 'stress_', true ),
+ 'status' => 'running',
+ 'mode' => $mode,
+ 'target' => $target_count,
+ 'batch_size' => $batch_size,
+ 'started_at' => time(),
+ 'processed' => 0,
+ 'last_user_id' => 0,
+ 'batches_done' => 0,
+ 'batches_log' => array(),
+ 'errors' => array(),
+ 'peak_memory' => 0,
+ 'completed_at' => null,
+ 'benchmark' => null,
+ );
+ update_option( self::OPT_STATE, $state, false );
+
+ // 清掉前次留下的 cancellation flag(避免新測試一啟動就被誤判為已取消)
+ delete_transient( self::CANCEL_FLAG );
+
+ wp_clear_scheduled_hook( self::CRON_HOOK );
+ wp_schedule_single_event( time(), self::CRON_HOOK );
+
+ // 注意:不在這裡同步執行 run_batch()。
+ // 若 batch_size 大或 mode=realistic,run_batch() 可能跑數十秒到數分鐘,
+ // PHP-FPM / nginx 會在 60s 時 504 Gateway Timeout。
+ // 第一個 batch 由前端 polling 進來時的 pump_if_due() 推進,每個 batch 有 wall-clock 限制。
+ return array(
+ 'ok' => true,
+ 'state' => $state,
+ );
+ }
+
+ /**
+ * 取消執行中的測試。
+ *
+ * 設 cancellation transient flag,in-flight 的 run_batch() 與 run_batch_realistic()
+ * 會在每個 user 迭代之前檢查並提早 break;run_batch() 結尾的 update_option 會
+ * 重讀 state 確認 status 仍是 'running' 才覆寫,避免 race condition 把 cancelled
+ * 重新覆蓋為 running。
+ */
+ public static function cancel(): array {
+ $state = self::get_state();
+ if ( empty( $state ) ) {
+ return array(
+ 'ok' => true,
+ 'message' => 'no_active_job',
+ );
+ }
+
+ // 設 flag 給 in-flight batch 看到,提早 break
+ set_transient( self::CANCEL_FLAG, 1, 600 );
+
+ wp_clear_scheduled_hook( self::CRON_HOOK );
+
+ // 重讀 state(避免覆寫 in-flight batch 已寫入的 progress)
+ $state = self::get_state();
+ $state['status'] = 'cancelled';
+ $state['completed_at'] = time();
+ update_option( self::OPT_STATE, $state, false );
+
+ return array(
+ 'ok' => true,
+ 'state' => $state,
+ );
+ }
+
+ /**
+ * 取得目前進度狀態。
+ */
+ public static function get_state(): array {
+ $state = get_option( self::OPT_STATE, array() );
+ return is_array( $state ) ? $state : array();
+ }
+
+ /**
+ * 取得進度(含計算的速率與 ETA)。
+ *
+ * 副作用:若狀態為 running 且 wp-cron 沒按時觸發(dev 環境常見),主動同步推進一個 batch,
+ * 確保 admin polling 看得到進度,不依賴外部 cron worker。
+ *
+ * @param bool $pump 是否在偵測到延誤時主動推進。預設 true,REST status endpoint 用。
+ */
+ 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_user_count' => self::count_test_users(),
+ )
+ );
+ }
+
+ /**
+ * 檢查是否需要主動推進一個 batch。
+ *
+ * 觸發條件:
+ * - state.status === 'running'
+ * - 距離上次 batch 已超過 N 秒(避免 polling 太密集連續跑)
+ * - 沒有其他 request 在執行(用 transient lock 防併發)
+ *
+ * 之所以這樣設計:dev / 低流量環境的 wp-cron 可能不會準時跑(CLI 沒 web request、
+ * spawn_cron 是 async fire-and-forget)。讓 polling 自帶推進可確保進度條會動。
+ */
+ public static function pump_if_due(): void {
+ $state = self::get_state();
+ if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
+ return;
+ }
+
+ // 至少間隔 1 秒推一次,避免極端密集 polling 把 DB 壓垮
+ $last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 );
+ if ( time() - $last_pushed_at < 1 ) {
+ return;
+ }
+
+ // Transient lock 防多個 polling request 同時執行(30s TTL,確保即使崩潰也會自動釋放)
+ $lock_key = 'wpdo_stress_pump_lock';
+ if ( false !== get_transient( $lock_key ) ) {
+ return;
+ }
+ set_transient( $lock_key, 1, 30 );
+
+ // 確保 PHP 有足夠時間跑滿 batch deadline;不超過 nginx 60s timeout
+ if ( function_exists( 'set_time_limit' ) ) {
+ @set_time_limit( self::BATCH_DEADLINE_SEC + 10 );
+ }
+
+ try {
+ self::run_batch();
+ } finally {
+ delete_transient( $lock_key );
+ }
+ }
+
+ /**
+ * Cron 觸發點:執行一個批次。完成則啟動 benchmark;未完成 reschedule。
+ */
+ public static function run_batch(): void {
+ $state = self::get_state();
+ if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
+ return;
+ }
+
+ // 開頭就檢查 cancellation flag — 即使 cron 已排到,看到 flag 立即放棄
+ if ( false !== get_transient( self::CANCEL_FLAG ) ) {
+ return;
+ }
+
+ $target = (int) $state['target'];
+ $processed = (int) $state['processed'];
+ $batch_size = (int) $state['batch_size'];
+ $mode = (string) $state['mode'];
+ $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( $this_batch_size );
+ } else {
+ $inserted = self::run_batch_realistic( $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( 'stress_test_batch_failed', array( 'message' => $e->getMessage() ) );
+ }
+ return;
+ }
+ $batch_elapsed = microtime( true ) - $batch_started;
+
+ // 重讀 state — 中間可能被 cancel() 改成 'cancelled',不能用本地快照覆寫
+ $latest = self::get_state();
+ if ( empty( $latest ) ) {
+ return; // state 已被 cleanup() 刪光,不再寫入
+ }
+ $is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG );
+
+ // 累加 progress 到「最新」state(保留 cancel 寫入的 status)
+ $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();
+
+ // 若已被 cancel:保留 'cancelled' 狀態,僅累加 progress 給 UI 顯示「實際已寫入幾筆」
+ // 不重新排 cron、不 finalize benchmark
+ 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 );
+ }
+
+ /**
+ * 清除所有 test_* 使用者及其 flat table 資料。
+ *
+ * 也設 cancellation flag,確保 in-flight batch 看到立即中止,避免 cleanup 後
+ * 又被新建一筆 user 進來。
+ */
+ public static function cleanup(): array {
+ global $wpdb;
+
+ // 通知 in-flight batch 立即停(最壞情況等 ~3 秒讓當前 user 完成)
+ set_transient( self::CANCEL_FLAG, 1, 600 );
+ wp_clear_scheduled_hook( self::CRON_HOOK );
+
+ $user_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->users} WHERE user_login LIKE %s",
+ $wpdb->esc_like( self::TEST_USER_PREFIX ) . '%'
+ )
+ );
+
+ if ( empty( $user_ids ) ) {
+ return array(
+ 'ok' => true,
+ 'deleted' => 0,
+ );
+ }
+
+ $count = count( $user_ids );
+ $id_list = implode( ',', array_map( 'absint', $user_ids ) );
+
+ // 先刪 flat tables
+ $flat_tables = self::get_user_flat_tables();
+ foreach ( $flat_tables as $tbl ) {
+ $wpdb->query( "DELETE FROM `{$tbl}` WHERE user_id IN ({$id_list})" );
+ }
+
+ // 刪 usermeta + users
+ $wpdb->query( "DELETE FROM {$wpdb->usermeta} WHERE user_id IN ({$id_list})" );
+ $wpdb->query( "DELETE FROM {$wpdb->users} WHERE ID IN ({$id_list})" );
+
+ // 重置 state + cancel flag(後者確保下一次啟動不會被誤判為 cancelled)
+ delete_option( self::OPT_STATE );
+ delete_transient( self::CANCEL_FLAG );
+
+ // 清空 cache(每個 user 各自清,clean_user_cache 不接受 array)
+ foreach ( $user_ids as $uid ) {
+ clean_user_cache( (int) $uid );
+ }
+
+ return array(
+ 'ok' => true,
+ 'deleted' => $count,
+ );
+ }
+
+ /**
+ * 取得目前 test_* 使用者數量。
+ */
+ public static function count_test_users(): int {
+ global $wpdb;
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->users} WHERE user_login LIKE %s",
+ $wpdb->esc_like( self::TEST_USER_PREFIX ) . '%'
+ )
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // 批次寫入:Fast Mode
+ // ─────────────────────────────────────────────────────────
+
+ private static function run_batch_fast( int $count ): int {
+ global $wpdb;
+
+ // 一次計算密碼 hash(重複用同一個 hash,若密碼相同 phpass 會比對通過嗎?
+ // 注意:phpass 每次 hash_password 會產生不同 salt,所以這裡用同一 hash 對所有 user 是 OK 的
+ // 因為 wp_check_password 只是用儲存的 hash 校驗輸入密碼,相同密碼+相同 hash 會通過
+ static $cached_hash = null;
+ if ( null === $cached_hash ) {
+ $cached_hash = wp_hash_password( self::TEST_PASSWORD );
+ }
+
+ $registered_at = current_time( 'mysql', true );
+
+ // 先取得目前最大 test 序號(避免衝突)
+ $next_seq = self::next_test_user_seq();
+
+ // 準備 wp_users bulk INSERT
+ $user_rows = array();
+ $user_data = array();
+ for ( $i = 0; $i < $count; $i++ ) {
+ $seq = $next_seq + $i;
+ $login = self::TEST_USER_PREFIX . $seq;
+ $email = $login . '@' . self::TEST_EMAIL_DOMAIN;
+ $first = self::FIRST_NAMES[ $seq % count( self::FIRST_NAMES ) ];
+ $last = self::LAST_NAMES[ ( $seq * 7 ) % count( self::LAST_NAMES ) ];
+ $display = $first . ' ' . $last . ' #' . $seq;
+
+ $user_rows[] = $wpdb->prepare(
+ '(%s,%s,%s,%s,%s,%s,%s,%d)',
+ $login,
+ $cached_hash,
+ $login,
+ $email,
+ '',
+ $registered_at,
+ $display,
+ 0
+ );
+ $user_data[ $seq ] = array(
+ 'login' => $login,
+ 'email' => $email,
+ 'first' => $first,
+ 'last' => $last,
+ 'display' => $display,
+ );
+ }
+
+ $sql = "INSERT INTO {$wpdb->users}
+ (user_login, user_pass, user_nicename, user_email, user_url, user_registered, display_name, user_status)
+ VALUES " . implode( ',', $user_rows );
+ $wpdb->query( $sql );
+
+ // 取出剛 INSERT 的 user IDs(用 user_login 對應)
+ $placeholders = implode( ',', array_fill( 0, count( $user_data ), '%s' ) );
+ $logins = array_map( fn( $d ) => $d['login'], $user_data );
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT ID, user_login FROM {$wpdb->users} WHERE user_login IN ({$placeholders})",
+ ...$logins
+ ),
+ ARRAY_A
+ );
+ $login_to_id = array();
+ foreach ( (array) $rows as $row ) {
+ $login_to_id[ $row['user_login'] ] = (int) $row['ID'];
+ }
+
+ // 從 user_login 反查回 seq
+ $seq_to_id = array();
+ foreach ( $user_data as $seq => $data ) {
+ if ( isset( $login_to_id[ $data['login'] ] ) ) {
+ $seq_to_id[ $seq ] = $login_to_id[ $data['login'] ];
+ }
+ }
+
+ if ( empty( $seq_to_id ) ) {
+ return 0;
+ }
+
+ // usermeta:必填的 caps + nickname + first_name + last_name
+ self::insert_usermeta_bulk( $seq_to_id, $user_data );
+
+ // flat tables — existing v2.5.x groups
+ self::insert_flat_hot( $seq_to_id );
+ self::insert_flat_membership( $seq_to_id );
+ self::insert_flat_activity( $seq_to_id );
+ self::insert_flat_profile( $seq_to_id, $user_data );
+ self::insert_flat_sso( $seq_to_id );
+ self::insert_flat_cold( $seq_to_id );
+ self::insert_points_ledger( $seq_to_id );
+
+ // v2.7.0+ groups: legacy WP/WC/HP keys absorbed into entity bridge
+ self::insert_flat_core_profile( $seq_to_id, $user_data );
+ self::insert_flat_social( $seq_to_id );
+ self::insert_flat_commerce( $seq_to_id, $user_data );
+ self::insert_flat_hp_user( $seq_to_id );
+
+ return count( $seq_to_id );
+ }
+
+ private static function next_test_user_seq(): int {
+ global $wpdb;
+ $max = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT MAX(CAST(SUBSTRING(user_login, %d) AS UNSIGNED)) FROM {$wpdb->users} WHERE user_login LIKE %s",
+ strlen( self::TEST_USER_PREFIX ) + 1,
+ $wpdb->esc_like( self::TEST_USER_PREFIX ) . '%'
+ )
+ );
+ return ( null === $max ) ? 1 : ( (int) $max ) + 1;
+ }
+
+ /**
+ * Bulk-insert the WP-core-required usermeta rows for a batch of test users.
+ *
+ * v2.8.3: drops `nickname` / `first_name` / `last_name` rows — those keys
+ * are managed by the v2.7.0 `core_profile` entity group and written directly
+ * into `wp_wpdo_user_core_profile` by `insert_flat_core_profile()`. Writing
+ * them to `wp_usermeta` here was a direct-SQL bypass of Hook Bus aeav_only
+ * short-circuit logic and inflated the wp_users:wp_usermeta ratio by 3 rows
+ * per test user (1:5 instead of the expected 1:2). Only `wp_capabilities`
+ * and `wp_user_level` are kept because WP core itself writes them on every
+ * `wp_insert_user()` and they are NOT registered as entity fields.
+ *
+ * @param array $seq_to_id Map seq → user_id from the bulk wp_users insert.
+ * @param array $user_data Per-seq generated user data (unused in v2.8.3).
+ */
+ private static function insert_usermeta_bulk( array $seq_to_id, array $user_data ): void {
+ global $wpdb;
+ unset( $user_data ); // v2.8.3: name fields no longer written here.
+ $caps_value = serialize( array( 'subscriber' => true ) );
+
+ $rows = array();
+ foreach ( $seq_to_id as $uid ) {
+ $rows[] = $wpdb->prepare( '(%d,%s,%s)', $uid, $wpdb->prefix . 'capabilities', $caps_value );
+ $rows[] = $wpdb->prepare( '(%d,%s,%s)', $uid, $wpdb->prefix . 'user_level', '0' );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO {$wpdb->usermeta} (user_id, meta_key, meta_value) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ private static function insert_flat_hot( array $seq_to_id ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_hot";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $money = mt_rand( 0, 1000000 ) / 100;
+ $orders = mt_rand( 0, 50 );
+ $last = time() - mt_rand( 0, 365 * DAY_IN_SECONDS );
+ $rows[] = $wpdb->prepare( '(%d,%f,%d,%d)', $uid, $money, $orders, $last );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` (user_id, _money_spent, _order_count, _last_order) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ private static function insert_flat_membership( array $seq_to_id ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_membership";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $level = self::MEMBERSHIP_LEVELS[ $seq % count( self::MEMBERSHIP_LEVELS ) ];
+ $points = mt_rand( 0, 50000 );
+ $expires = gmdate( 'Y-m-d H:i:s', time() + mt_rand( 30, 730 ) * DAY_IN_SECONDS );
+ $activated = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 1, 365 ) * DAY_IN_SECONDS );
+ $source = self::TIER_SOURCES[ ( $seq * 3 ) % count( self::TIER_SOURCES ) ];
+ $label = ucfirst( $level ) . ' Tier';
+ $rows[] = $wpdb->prepare( '(%d,%s,%d,%s,%s,%s,%s)', $uid, $level, $points, $expires, $activated, $source, $label );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` (user_id, membership_level, points_balance, membership_expires_at, membership_activated_at, tier_source, custom_tier_label) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ private static function insert_flat_activity( array $seq_to_id ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_activity";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $login_count = mt_rand( 1, 500 );
+ $last_active = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 30 * DAY_IN_SECONDS ) );
+ $last_login = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 7 * DAY_IN_SECONDS ) );
+ $last_order = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 90 * DAY_IN_SECONDS ) );
+ $sessions = mt_rand( 0, 200 );
+ $flags = mt_rand( 0, 7 );
+ $rows[] = $wpdb->prepare( '(%d,%d,%s,%s,%s,%d,%d)', $uid, $login_count, $last_active, $last_login, $last_order, $sessions, $flags );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` (user_id, login_count, last_active_at, last_login_at, last_order_at, session_count, account_flags) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ private static function insert_flat_profile( array $seq_to_id, array $user_data ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_profile";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $d = $user_data[ $seq ];
+ $num_specs = ( $seq % 3 ) + 1;
+ $specs_array = array_slice( self::SPECIALTIES_POOL, $seq % 7, $num_specs );
+ $specialties = wp_json_encode( $specs_array );
+ $bio_url = 'https://example.com/' . $d['login'];
+ $avatar_url = 'https://example.com/avatars/' . $d['login'] . '.jpg';
+ $display_custom = $d['display'];
+ $locale = self::LOCALES[ $seq % count( self::LOCALES ) ];
+ $rows[] = $wpdb->prepare( '(%d,%s,%s,%s,%s,%s)', $uid, $specialties, $bio_url, $avatar_url, $display_custom, $locale );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` (user_id, specialties, bio_url, avatar_url, display_name_custom, locale) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ private static function insert_flat_sso( array $seq_to_id ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_sso";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $hub_id = 'hub_' . wp_generate_password( 16, false );
+ $picture = 'https://example.com/sso/' . $uid . '.jpg';
+ $token_hash = hash( 'sha256', 'token_' . $uid );
+ $refresh_enc = 'enc:v1:' . base64_encode( random_bytes( 32 ) );
+ $expires = gmdate( 'Y-m-d H:i:s', time() + 3600 );
+ $last_login = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 7 * DAY_IN_SECONDS ) );
+ $count = mt_rand( 1, 100 );
+ $rows[] = $wpdb->prepare( '(%d,%s,%s,%s,%s,%s,%s,%d)', $uid, $hub_id, $picture, $token_hash, $refresh_enc, $expires, $last_login, $count );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` (user_id, hub_global_user_id, picture_url, last_id_token_hash, refresh_token_enc, token_expires_at, sso_last_login_at, sso_login_count) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ private static function insert_flat_cold( array $seq_to_id ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_cold";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $picture = 'https://cdn.example.com/' . $uid . '.png';
+ $tok = 'tok_legacy_' . wp_generate_password( 32, false );
+ $ref = 'ref_legacy_' . wp_generate_password( 32, false );
+ $rows[] = $wpdb->prepare( '(%d,%s,%s,%s)', $uid, $picture, $tok, $ref );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` (user_id, _tmso_picture_url, _tmso_last_id_token, _tmso_refresh_token) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ /**
+ * v2.7.0 core_profile — nickname/first_name/last_name/description.
+ *
+ * @since 2.8.2
+ */
+ private static function insert_flat_core_profile( array $seq_to_id, array $user_data ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_core_profile";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $d = $user_data[ $seq ];
+ $nickname = $d['login'];
+ $first = $d['first'];
+ $last = $d['last'];
+ $description = self::BIO_POOL[ $seq % count( self::BIO_POOL ) ] . ' #' . $seq;
+ $rows[] = $wpdb->prepare( '(%d,%s,%s,%s,%s)', $uid, $nickname, $first, $last, $description );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` (user_id, nickname, first_name, last_name, description) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ /**
+ * v2.7.0 social — 15 social profile URLs (textarea per H2 fix).
+ *
+ * @since 2.8.2
+ */
+ private static function insert_flat_social( array $seq_to_id ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_social";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $cols = array_merge( array( 'user_id' ), self::SOCIAL_KEYS );
+ $col_sql = implode( ',', array_map( fn( $c ) => "`{$c}`", $cols ) );
+
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $values = array( $uid );
+ $formats = array( '%d' );
+ foreach ( self::SOCIAL_KEYS as $i => $key ) {
+ // Populate ~3 of the 15 social URLs per user — realistic for vendor profiles.
+ if ( ( $seq + $i ) % 5 === 0 ) {
+ $values[] = sprintf( 'https://%s.example.com/%s', $key, $uid );
+ $formats[] = '%s';
+ } else {
+ $values[] = null;
+ $formats[] = '%s';
+ }
+ }
+ $rows[] = $wpdb->prepare( '(' . implode( ',', $formats ) . ')', ...$values );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` ({$col_sql}) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ /**
+ * v2.7.0 commerce — WooCommerce billing + shipping address fields.
+ *
+ * @since 2.8.2
+ */
+ private static function insert_flat_commerce( array $seq_to_id, array $user_data ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_commerce";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+
+ $all_keys = array_merge( self::BILLING_KEYS, self::SHIPPING_KEYS );
+ $cols = array_merge( array( 'user_id' ), $all_keys );
+ $col_sql = implode( ',', array_map( fn( $c ) => "`{$c}`", $cols ) );
+
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $d = $user_data[ $seq ];
+ $first = $d['first'];
+ $last = $d['last'];
+ $country = self::COUNTRY_POOL[ $seq % count( self::COUNTRY_POOL ) ];
+ $state = self::STATE_POOL[ $seq % count( self::STATE_POOL ) ];
+ $city = self::CITY_POOL[ $seq % count( self::CITY_POOL ) ];
+ $company = 'Test Co. #' . $seq;
+ $addr1 = sprintf( '%d %s St.', ( $seq * 13 ) % 9999 + 1, $city );
+ $addr2 = ( $seq % 3 === 0 ) ? sprintf( 'Apt %d', $seq % 100 ) : '';
+ $post = sprintf( '%05d', ( $seq * 7 ) % 99999 );
+ $email = $d['login'] . '@' . self::TEST_EMAIL_DOMAIN;
+ $phone = sprintf( '+886-%d-%07d', mt_rand( 2, 9 ), mt_rand( 1000000, 9999999 ) );
+
+ $values = array( $uid );
+ $formats = array( '%d' );
+
+ // billing block
+ $billing_values = array(
+ 'billing_first_name' => $first,
+ 'billing_last_name' => $last,
+ 'billing_company' => $company,
+ 'billing_address_1' => $addr1,
+ 'billing_address_2' => $addr2,
+ 'billing_city' => $city,
+ 'billing_state' => $state,
+ 'billing_postcode' => $post,
+ 'billing_country' => $country,
+ 'billing_email' => $email,
+ 'billing_phone' => $phone,
+ );
+ foreach ( self::BILLING_KEYS as $k ) {
+ $values[] = $billing_values[ $k ];
+ $formats[] = '%s';
+ }
+
+ // shipping block — 60% of users have shipping = billing, rest different
+ $same_address = ( $seq % 5 ) < 3;
+ foreach ( self::SHIPPING_KEYS as $k ) {
+ $billing_equiv = str_replace( 'shipping_', 'billing_', $k );
+ if ( isset( $billing_values[ $billing_equiv ] ) && $same_address ) {
+ $values[] = $billing_values[ $billing_equiv ];
+ } else {
+ $values[] = $billing_values[ $billing_equiv ] ?? '';
+ }
+ $formats[] = '%s';
+ }
+
+ $rows[] = $wpdb->prepare( '(' . implode( ',', $formats ) . ')', ...$values );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` ({$col_sql}) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ /**
+ * v2.7.0 hp_user — HivePress favorites (json array) + avatar attachment.
+ *
+ * @since 2.8.2
+ */
+ private static function insert_flat_hp_user( array $seq_to_id ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_hp_user";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $rows = array();
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $num_favs = $seq % 5; // 0..4 favorites
+ $favs = array();
+ for ( $i = 0; $i < $num_favs; $i++ ) {
+ $favs[] = 1700 + ( ( $seq + $i ) % 60 ); // listing IDs 1700-1759
+ }
+ $favs_json = wp_json_encode( $favs );
+ $hp_image = ( $seq % 4 === 0 ) ? (string) ( 2000 + ( $seq % 100 ) ) : '';
+ $rows[] = $wpdb->prepare( '(%d,%s,%s)', $uid, $favs_json, $hp_image );
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` (user_id, hp_favorited_listings, hp_image) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ private static function insert_points_ledger( array $seq_to_id ): void {
+ global $wpdb;
+ $tbl = "{$wpdb->prefix}wpdo_user_points_ledger";
+ if ( ! self::table_exists( $tbl ) ) {
+ return;
+ }
+ $reasons = array( 'signup_bonus', 'order_reward', 'referral', 'promo', 'manual_adjust' );
+ $rows = array();
+ $created = current_time( 'mysql', true );
+ foreach ( $seq_to_id as $seq => $uid ) {
+ $num_entries = ( $seq % 3 ) + 1; // 1-3 筆
+ $balance = 0;
+ for ( $i = 0; $i < $num_entries; $i++ ) {
+ $delta = mt_rand( -200, 1000 );
+ $balance += $delta;
+ $reason = $reasons[ ( $seq + $i ) % count( $reasons ) ];
+ $rows[] = $wpdb->prepare(
+ '(%d,%d,%d,%s,%d,%s,%s)',
+ $uid,
+ $delta,
+ $balance,
+ $reason,
+ 0,
+ '',
+ $created
+ );
+ }
+ }
+ if ( ! empty( $rows ) ) {
+ $wpdb->query( "INSERT INTO `{$tbl}` (user_id, delta, balance_after, reason, ref_id, ref_type, created_at) VALUES " . implode( ',', $rows ) );
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // 批次寫入:Realistic Mode(走 wp_insert_user + update_user_meta)
+ // ─────────────────────────────────────────────────────────
+
+ private static function run_batch_realistic( int $count ): int {
+ // Realistic mode:走 wp_insert_user + update_user_meta,每個 user 1-3 秒。
+ // 加 wall-clock deadline,避免 PHP-FPM / nginx 504 Gateway Timeout(預設 60 秒)。
+ $deadline = microtime( true ) + self::BATCH_DEADLINE_SEC;
+ $next_seq = self::next_test_user_seq();
+ $inserted = 0;
+ for ( $i = 0; $i < $count; $i++ ) {
+ if ( microtime( true ) > $deadline ) {
+ break; // 超時:把已完成的回傳,剩餘讓下一個 pump/cron 接手
+ }
+ // 使用者點 cancel:每個 user 開始前檢查 — realistic mode 每 user ~3s,
+ // 最壞情況 cancel 後 3 秒內即可中止
+ if ( false !== get_transient( self::CANCEL_FLAG ) ) {
+ break;
+ }
+
+ $seq = $next_seq + $i;
+ $login = self::TEST_USER_PREFIX . $seq;
+ $email = $login . '@' . self::TEST_EMAIL_DOMAIN;
+ $first = self::FIRST_NAMES[ $seq % count( self::FIRST_NAMES ) ];
+ $last = self::LAST_NAMES[ ( $seq * 7 ) % count( self::LAST_NAMES ) ];
+
+ $uid = wp_insert_user(
+ array(
+ 'user_login' => $login,
+ 'user_pass' => self::TEST_PASSWORD,
+ 'user_email' => $email,
+ 'first_name' => $first,
+ 'last_name' => $last,
+ 'display_name' => $first . ' ' . $last . ' #' . $seq,
+ 'role' => 'subscriber',
+ )
+ );
+ if ( is_wp_error( $uid ) ) {
+ continue;
+ }
+ ++$inserted;
+
+ // 走 update_user_meta,讓 Hook Bus 自然攔截到 flat tables
+ $meta = self::generate_realistic_meta( $seq );
+ foreach ( $meta as $key => $value ) {
+ update_user_meta( $uid, $key, $value );
+ }
+ }
+ return $inserted;
+ }
+
+ private static function generate_realistic_meta( int $seq ): array {
+ $first = self::FIRST_NAMES[ $seq % count( self::FIRST_NAMES ) ];
+ $last = self::LAST_NAMES[ ( $seq * 7 ) % count( self::LAST_NAMES ) ];
+ $login = self::TEST_USER_PREFIX . $seq;
+ $country = self::COUNTRY_POOL[ $seq % count( self::COUNTRY_POOL ) ];
+ $city = self::CITY_POOL[ $seq % count( self::CITY_POOL ) ];
+ $num_favs = $seq % 5;
+ $favs = array();
+ for ( $i = 0; $i < $num_favs; $i++ ) {
+ $favs[] = 1700 + ( ( $seq + $i ) % 60 );
+ }
+
+ return array(
+ // hot
+ '_money_spent' => mt_rand( 0, 1000000 ) / 100,
+ '_order_count' => mt_rand( 0, 50 ),
+ '_last_order' => time() - mt_rand( 0, 365 * DAY_IN_SECONDS ),
+ // membership
+ 'membership_level' => self::MEMBERSHIP_LEVELS[ $seq % count( self::MEMBERSHIP_LEVELS ) ],
+ 'points_balance' => mt_rand( 0, 50000 ),
+ 'membership_expires_at' => gmdate( 'Y-m-d H:i:s', time() + mt_rand( 30, 730 ) * DAY_IN_SECONDS ),
+ 'membership_activated_at' => gmdate( 'Y-m-d H:i:s', time() - mt_rand( 1, 365 ) * DAY_IN_SECONDS ),
+ 'tier_source' => self::TIER_SOURCES[ ( $seq * 3 ) % count( self::TIER_SOURCES ) ],
+ // activity
+ 'login_count' => mt_rand( 1, 500 ),
+ 'last_active_at' => gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 30 * DAY_IN_SECONDS ) ),
+ 'last_login_at' => gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 7 * DAY_IN_SECONDS ) ),
+ 'session_count' => mt_rand( 0, 200 ),
+ // profile
+ 'specialties' => array_slice( self::SPECIALTIES_POOL, $seq % 7, ( $seq % 3 ) + 1 ),
+ 'bio_url' => 'https://example.com/test' . $seq,
+ 'locale' => self::LOCALES[ $seq % count( self::LOCALES ) ],
+ // sso
+ 'hub_global_user_id' => 'hub_' . wp_generate_password( 16, false ),
+ 'sso_login_count' => mt_rand( 1, 100 ),
+ // v2.7.0 core_profile (note: nickname/first_name/last_name are also
+ // set via wp_insert_user, but description is unique to this group)
+ 'description' => self::BIO_POOL[ $seq % count( self::BIO_POOL ) ] . ' #' . $seq,
+ // v2.7.0 social — populate 3 of 15 to mimic real vendor profile usage
+ 'facebook' => sprintf( 'https://facebook.com/%s', $login ),
+ 'instagram' => sprintf( 'https://instagram.com/%s', $login ),
+ 'youtube' => sprintf( 'https://youtube.com/@%s', $login ),
+ // v2.7.0 commerce (subset; 5 representative billing fields)
+ 'billing_first_name' => $first,
+ 'billing_last_name' => $last,
+ 'billing_email' => $login . '@' . self::TEST_EMAIL_DOMAIN,
+ 'billing_country' => $country,
+ 'billing_city' => $city,
+ // v2.7.0 hp_user
+ 'hp_favorited_listings' => $favs,
+ 'hp_image' => ( $seq % 4 === 0 ) ? (string) ( 2000 + ( $seq % 100 ) ) : '',
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // Benchmark
+ // ─────────────────────────────────────────────────────────
+
+ private static function finalize( array $state ): void {
+ // 清掉殘留的 cron event(避免完成後仍有過期 event)
+ 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 );
+ }
+
+ /**
+ * 執行 benchmark:寫入指標 + DB 容量 + 查詢效能。
+ */
+ public static function run_benchmark( ?array $state = null ): array {
+ global $wpdb;
+ $state = $state ?? self::get_state();
+
+ // 1. 寫入指標
+ $write_metrics = self::compute_write_metrics( $state );
+
+ // 2. DB 容量
+ $db_sizes = self::measure_db_sizes();
+
+ // 3. 查詢效能
+ $query_perf = self::measure_query_performance();
+
+ return array(
+ 'generated_at' => time(),
+ 'write' => $write_metrics,
+ 'db_sizes' => $db_sizes,
+ 'query' => $query_perf,
+ );
+ }
+
+ 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'] ?? '',
+ '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 ),
+ );
+ }
+
+ private static function measure_db_sizes(): array {
+ global $wpdb;
+ $tables = array_merge( array( $wpdb->users, $wpdb->usermeta ), self::get_user_flat_tables() );
+ $placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) );
+ // MySQL only — SQLite 不支援 information_schema.TABLES,跳過
+ if ( ! self::is_mysql() ) {
+ return array_map(
+ fn( $t ) => array(
+ 'table' => $t,
+ 'rows' => self::table_row_count( $t ),
+ 'size_mb' => null,
+ ),
+ $tables
+ );
+ }
+
+ $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 ( $rows as $r ) {
+ $out[] = array(
+ 'table' => $r['t'],
+ 'rows' => (int) $r['rows_count'],
+ 'data_mb' => round( $r['dl'] / 1048576, 2 ),
+ 'index_mb' => round( $r['il'] / 1048576, 2 ),
+ 'total_mb' => round( ( $r['dl'] + $r['il'] ) / 1048576, 2 ),
+ 'avg_bytes' => $r['rows_count'] > 0 ? (int) ( ( $r['dl'] + $r['il'] ) / $r['rows_count'] ) : 0,
+ );
+ }
+ return $out;
+ }
+
+ private static function measure_query_performance(): array {
+ global $wpdb;
+
+ $sample_ids = $wpdb->get_col(
+ "SELECT ID FROM {$wpdb->users} WHERE user_login LIKE '" . esc_sql( self::TEST_USER_PREFIX ) . "%' ORDER BY ID DESC LIMIT 100"
+ );
+ $sample_ids = array_map( 'intval', $sample_ids );
+
+ if ( empty( $sample_ids ) ) {
+ return array( 'note' => 'no_test_users_found' );
+ }
+
+ $results = array();
+
+ // Q1: 讀單一 meta(透過 Hook Bus / get_user_meta 攔截路徑),走 flat table
+ $results['get_field_membership_level'] = self::time_calls(
+ function () use ( $sample_ids ) {
+ foreach ( $sample_ids as $id ) {
+ get_user_meta( $id, 'membership_level', true );
+ }
+ },
+ count( $sample_ids )
+ );
+
+ // Q2: 讀多個 meta key(模擬整 entity 讀取,覆蓋多個 group)
+ $multi_keys = array(
+ 'membership_level', // membership group
+ 'points_balance', // membership
+ 'login_count', // activity
+ 'last_active_at', // activity
+ '_money_spent', // hot
+ 'specialties', // profile
+ 'locale', // profile
+ );
+ $results['get_entity_full'] = self::time_calls(
+ function () use ( $sample_ids, $multi_keys ) {
+ foreach ( $sample_ids as $id ) {
+ foreach ( $multi_keys as $key ) {
+ get_user_meta( $id, $key, true );
+ }
+ }
+ },
+ count( $sample_ids )
+ );
+
+ // Q3: 索引範圍查詢 — gold 等級且 points > 5000 的用戶總數
+ $tbl = $wpdb->prefix . 'wpdo_user_membership';
+ if ( self::table_exists( $tbl ) ) {
+ $results['range_gold_high_points'] = self::time_query(
+ "SELECT COUNT(*) FROM `{$tbl}` WHERE membership_level = 'gold' AND points_balance > 5000"
+ );
+ }
+
+ // Q4: 排序查詢 — last_active_at DESC LIMIT 100
+ $tbl = $wpdb->prefix . 'wpdo_user_activity';
+ if ( self::table_exists( $tbl ) ) {
+ $results['sort_recent_active_100'] = self::time_query(
+ "SELECT user_id, last_active_at FROM `{$tbl}` ORDER BY last_active_at DESC LIMIT 100"
+ );
+ }
+
+ // Q5: JOIN — top 100 most active gold members
+ $mtbl = $wpdb->prefix . 'wpdo_user_membership';
+ $atbl = $wpdb->prefix . 'wpdo_user_activity';
+ if ( self::table_exists( $mtbl ) && self::table_exists( $atbl ) ) {
+ $results['join_top_gold_active'] = self::time_query(
+ "SELECT m.user_id, m.points_balance, a.login_count
+ FROM `{$mtbl}` m
+ JOIN `{$atbl}` a ON a.user_id = m.user_id
+ WHERE m.membership_level = 'gold'
+ ORDER BY a.last_active_at DESC LIMIT 100"
+ );
+ }
+
+ // Q6 baseline:原生 EAV 等價查詢(usermeta 範圍查詢)
+ $results['eav_range_baseline'] = self::time_query(
+ "SELECT COUNT(*) FROM {$wpdb->usermeta} m1
+ JOIN {$wpdb->usermeta} m2 ON m1.user_id = m2.user_id
+ WHERE m1.meta_key = 'membership_level' AND m1.meta_value = 'gold'
+ AND m2.meta_key = 'points_balance' AND CAST(m2.meta_value AS UNSIGNED) > 5000"
+ );
+
+ // v2.8.2 — exercise v2.7.0 groups too:
+
+ // Q7: billing_email indexed lookup (commerce group, the only searchable
+ // commerce field; representative WC customer-search workload)
+ $ctbl = $wpdb->prefix . 'wpdo_user_commerce';
+ if ( self::table_exists( $ctbl ) ) {
+ $sample_email = self::TEST_USER_PREFIX . ( $sample_ids[0] ?? 1 ) . '@' . self::TEST_EMAIL_DOMAIN;
+ $results['commerce_email_lookup'] = self::time_query(
+ $wpdb->prepare(
+ "SELECT user_id FROM `{$ctbl}` WHERE billing_email = %s LIMIT 1",
+ $sample_email
+ )
+ );
+ }
+
+ // Q8: core_profile fulltext-ish search (display_name lookup pattern)
+ $cptbl = $wpdb->prefix . 'wpdo_user_core_profile';
+ if ( self::table_exists( $cptbl ) ) {
+ $results['core_profile_first_name_scan'] = self::time_query(
+ "SELECT user_id, first_name FROM `{$cptbl}` WHERE first_name = 'Alice' LIMIT 100"
+ );
+ }
+
+ // Q9: hp_user JSON read (favorites count distribution — N+1 pattern through Hook Bus)
+ $hpttbl = $wpdb->prefix . 'wpdo_user_hp_user';
+ if ( self::table_exists( $hpttbl ) ) {
+ $results['hp_favorites_full_scan'] = self::time_query(
+ "SELECT user_id, hp_favorited_listings FROM `{$hpttbl}` LIMIT 100"
+ );
+ }
+
+ // Q10: simulate a realistic "render a vendor profile" — read 1 user across
+ // ALL 9 groups via Hook Bus (=10 get_user_meta calls hitting flat tables)
+ if ( ! empty( $sample_ids ) ) {
+ $probe_keys = array(
+ 'membership_level',
+ 'points_balance', // membership
+ 'last_active_at',
+ 'login_count', // activity
+ 'specialties',
+ 'locale', // profile
+ 'hub_global_user_id',
+ 'sso_login_count', // sso
+ 'nickname',
+ 'first_name',
+ 'last_name',
+ 'description', // core_profile
+ 'facebook',
+ 'instagram',
+ 'youtube', // social
+ 'billing_email',
+ 'billing_country',
+ 'billing_first_name', // commerce
+ 'hp_favorited_listings',
+ 'hp_image', // hp_user
+ );
+ $results['hook_bus_full_profile_render'] = self::time_calls(
+ function () use ( $sample_ids, $probe_keys ) {
+ $first_id = $sample_ids[0];
+ foreach ( $probe_keys as $key ) {
+ get_user_meta( $first_id, $key, true );
+ }
+ },
+ count( $probe_keys )
+ );
+ }
+
+ return $results;
+ }
+
+ private static function time_calls( callable $cb, int $n ): array {
+ $start = microtime( true );
+ $cb();
+ $elapsed_ms = ( microtime( true ) - $start ) * 1000;
+ return array(
+ 'n' => $n,
+ 'total_ms' => round( $elapsed_ms, 2 ),
+ 'avg_ms' => $n > 0 ? round( $elapsed_ms / $n, 3 ) : 0,
+ 'qps' => $elapsed_ms > 0 ? round( $n / ( $elapsed_ms / 1000 ), 1 ) : 0,
+ );
+ }
+
+ 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 ),
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // 輔助
+ // ─────────────────────────────────────────────────────────
+
+ private static function get_user_flat_tables(): array {
+ global $wpdb;
+ return array_filter(
+ array_map(
+ fn( $name ) => $wpdb->prefix . 'wpdo_user_' . $name,
+ array(
+ // v2.5.x groups
+ 'hot',
+ 'cold',
+ 'membership',
+ 'activity',
+ 'profile',
+ 'sso',
+ 'points_ledger',
+ // v2.7.0 groups
+ 'core_profile',
+ 'social',
+ 'commerce',
+ 'hp_user',
+ // v2.8.4 group
+ 'admin_prefs',
+ )
+ ),
+ array( __CLASS__, 'table_exists' )
+ );
+ }
+
+ 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 ];
+ }
+
+ private static function table_row_count( string $table ): int {
+ global $wpdb;
+ return self::table_exists( $table ) ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ) : 0;
+ }
+
+ private static function is_mysql(): bool {
+ return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) );
+ }
+}
diff --git a/includes/class-tmdo-v2-upgrader.php b/includes/class-tmdo-v2-upgrader.php
new file mode 100644
index 0000000..09ffe99
--- /dev/null
+++ b/includes/class-tmdo-v2-upgrader.php
@@ -0,0 +1,301 @@
+ bool.
+ *
+ * @param array $opts Optional overrides (mostly for tests).
+ * @return array
+ */
+ 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' );
+ }
+ }
+}
diff --git a/includes/class-tmdo-zone-classifier.php b/includes/class-tmdo-zone-classifier.php
new file mode 100644
index 0000000..f6cbc32
--- /dev/null
+++ b/includes/class-tmdo-zone-classifier.php
@@ -0,0 +1,396 @@
+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 );
+ }
+}
diff --git a/includes/diagnostic/class-tmdo-health-cron.php b/includes/diagnostic/class-tmdo-health-cron.php
new file mode 100644
index 0000000..bed04f3
--- /dev/null
+++ b/includes/diagnostic/class-tmdo-health-cron.php
@@ -0,0 +1,288 @@
+ $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 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
+ */
+ 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;
+ }
+}
diff --git a/includes/diagnostic/class-tmdo-monthly-summary.php b/includes/diagnostic/class-tmdo-monthly-summary.php
new file mode 100644
index 0000000..b54d9f4
--- /dev/null
+++ b/includes/diagnostic/class-tmdo-monthly-summary.php
@@ -0,0 +1,283 @@
+ 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
+ */
+ 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
+ }
+}
diff --git a/includes/diagnostic/class-tmdo-site-health.php b/includes/diagnostic/class-tmdo-site-health.php
new file mode 100644
index 0000000..c11abfc
--- /dev/null
+++ b/includes/diagnostic/class-tmdo-site-health.php
@@ -0,0 +1,491 @@
+ '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' => '' . esc_html( $description ) . '
',
+ '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' => '' . esc_html( $description ) . '
',
+ 'severity' => $severity,
+ 'actions' => '' . esc_html__( 'Open WPDO admin', '2meet-data-optimizer' ) . '
',
+ );
+ }
+}
diff --git a/includes/diagnostic/class-tmdo-site-metrics-collector.php b/includes/diagnostic/class-tmdo-site-metrics-collector.php
new file mode 100644
index 0000000..f7fb0ae
--- /dev/null
+++ b/includes/diagnostic/class-tmdo-site-metrics-collector.php
@@ -0,0 +1,236 @@
+ 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 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 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
+ );
+ }
+}
diff --git a/includes/engine/class-tmdo-audit-logger.php b/includes/engine/class-tmdo-audit-logger.php
new file mode 100644
index 0000000..f589e7f
--- /dev/null
+++ b/includes/engine/class-tmdo-audit-logger.php
@@ -0,0 +1,322 @@
+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>
+ */
+ 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() . '`' );
+ }
+}
diff --git a/includes/engine/class-tmdo-auto-promoter.php b/includes/engine/class-tmdo-auto-promoter.php
new file mode 100644
index 0000000..c3a23e8
--- /dev/null
+++ b/includes/engine/class-tmdo-auto-promoter.php
@@ -0,0 +1,134 @@
+ 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';
+ }
+}
diff --git a/includes/engine/class-tmdo-cache-orchestrator.php b/includes/engine/class-tmdo-cache-orchestrator.php
new file mode 100644
index 0000000..65da265
--- /dev/null
+++ b/includes/engine/class-tmdo-cache-orchestrator.php
@@ -0,0 +1,205 @@
+ 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,
+ );
+ }
+}
diff --git a/includes/engine/class-tmdo-conflict-detector.php b/includes/engine/class-tmdo-conflict-detector.php
new file mode 100644
index 0000000..74a70ec
--- /dev/null
+++ b/includes/engine/class-tmdo-conflict-detector.php
@@ -0,0 +1,148 @@
+|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
+ */
+ 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(
+ '',
+ 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( '', $lines ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above.
+ $extra ? '' . esc_html( $extra ) . '
' : '',
+ esc_html__( '查看完整清單:', 'uae' )
+ );
+ }
+}
diff --git a/includes/engine/class-tmdo-entity-health.php b/includes/engine/class-tmdo-entity-health.php
new file mode 100644
index 0000000..0a466cc
--- /dev/null
+++ b/includes/engine/class-tmdo-entity-health.php
@@ -0,0 +1,329 @@
+
+ */
+ 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 ) );
+ }
+}
diff --git a/includes/engine/class-tmdo-entity-migration-engine.php b/includes/engine/class-tmdo-entity-migration-engine.php
new file mode 100644
index 0000000..c07c495
--- /dev/null
+++ b/includes/engine/class-tmdo-entity-migration-engine.php
@@ -0,0 +1,799 @@
+ 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();
+ }
+}
diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php
new file mode 100644
index 0000000..94496da
--- /dev/null
+++ b/includes/engine/class-tmdo-entity-registry.php
@@ -0,0 +1,226 @@
+ 實體類型 → 適配器實例 */
+ private static array $adapters = array();
+
+ /** @var array> 實體類型 → 群組名 → 欄位定義陣列 */
+ private static array $groups = array();
+
+ /** @var array> 實體類型 → meta_key → 欄位定義(含 group 資訊)*/
+ private static array $field_index = array();
+
+ /** @var 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
+ */
+ 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
+ */
+ 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 ] );
+ }
+}
diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php
new file mode 100644
index 0000000..8638542
--- /dev/null
+++ b/includes/engine/class-tmdo-hook-bus.php
@@ -0,0 +1,658 @@
+ $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;
+ }
+
+ // 空 key:WP 要求所有 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 );
+ }
+}
diff --git a/includes/engine/class-tmdo-mode-manager.php b/includes/engine/class-tmdo-mode-manager.php
new file mode 100644
index 0000000..090415b
--- /dev/null
+++ b/includes/engine/class-tmdo-mode-manager.php
@@ -0,0 +1,380 @@
+ */
+ private const OPT_KEY = 'wpdo_bridge_modes';
+
+ /**
+ * Per-entity 進入當前 mode 的 unix timestamp(v1.5.0+ 供 auto-promoter 使用)。
+ * Shape: array
+ */
+ 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
+ */
+ 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 逐 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 '';
+ }
+ }
+}
diff --git a/includes/engine/class-tmdo-query-compiler.php b/includes/engine/class-tmdo-query-compiler.php
new file mode 100644
index 0000000..492f1ef
--- /dev/null
+++ b/includes/engine/class-tmdo-query-compiler.php
@@ -0,0 +1,322 @@
+',
+ '>',
+ '>=',
+ '<',
+ '<=',
+ 'LIKE',
+ 'NOT LIKE',
+ 'IN',
+ 'NOT IN',
+ 'BETWEEN',
+ 'NOT BETWEEN',
+ 'EXISTS',
+ 'NOT EXISTS',
+ );
+
+ /**
+ * 注入 wpdo_meta_query 至 WP_Query(post 實體)
+ *
+ * @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, where: array}
+ */
+ 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 ) . "'",
+ };
+ }
+}
diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php
new file mode 100644
index 0000000..45df82d
--- /dev/null
+++ b/includes/engine/class-tmdo-schema-manager.php
@@ -0,0 +1,341 @@
+ '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 ),
+ );
+ }
+}
diff --git a/includes/engine/class-tmdo-shadow-diff-logger.php b/includes/engine/class-tmdo-shadow-diff-logger.php
new file mode 100644
index 0000000..92b5fee
--- /dev/null
+++ b/includes/engine/class-tmdo-shadow-diff-logger.php
@@ -0,0 +1,407 @@
+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() );
+ }
+}
diff --git a/includes/engine/class-tmdo-type-caster.php b/includes/engine/class-tmdo-type-caster.php
new file mode 100644
index 0000000..9a1cf6e
--- /dev/null
+++ b/includes/engine/class-tmdo-type-caster.php
@@ -0,0 +1,165 @@
+ '%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 );
+ }
+}
diff --git a/includes/export/class-tmdo-csv-writer.php b/includes/export/class-tmdo-csv-writer.php
new file mode 100644
index 0000000..912e2c0
--- /dev/null
+++ b/includes/export/class-tmdo-csv-writer.php
@@ -0,0 +1,83 @@
+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
+ */
+ 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;
+ }
+}
diff --git a/includes/integrations/class-tmdo-member-fields.php b/includes/integrations/class-tmdo-member-fields.php
new file mode 100644
index 0000000..47cc468
--- /dev/null
+++ b/includes/integrations/class-tmdo-member-fields.php
@@ -0,0 +1,498 @@
+ '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 10–50× 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',
+ ),
+ )
+ );
+ }
+}
diff --git a/includes/integrations/class-tmdo-points-manager.php b/includes/integrations/class-tmdo-points-manager.php
new file mode 100644
index 0000000..d625378
--- /dev/null
+++ b/includes/integrations/class-tmdo-points-manager.php
@@ -0,0 +1,256 @@
+ 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
+ */
+ 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',
+ );
+ }
+ }
+}
diff --git a/includes/integrations/class-tmdo-post-fields.php b/includes/integrations/class-tmdo-post-fields.php
new file mode 100644
index 0000000..6aead41
--- /dev/null
+++ b/includes/integrations/class-tmdo-post-fields.php
@@ -0,0 +1,487 @@
+ '_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)',
+ ),
+ )
+ );
+ }
+}
diff --git a/includes/integrations/class-tmdo-term-comment-garbage-filter.php b/includes/integrations/class-tmdo-term-comment-garbage-filter.php
new file mode 100644
index 0000000..859b0a6
--- /dev/null
+++ b/includes/integrations/class-tmdo-term-comment-garbage-filter.php
@@ -0,0 +1,229 @@
+= 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 );
+ }
+}
diff --git a/includes/integrations/class-tmdo-term-comment-misc-bucket.php b/includes/integrations/class-tmdo-term-comment-misc-bucket.php
new file mode 100644
index 0000000..c3116bc
--- /dev/null
+++ b/includes/integrations/class-tmdo-term-comment-misc-bucket.php
@@ -0,0 +1,412 @@
+prefix . 'wpdo_term_misc';
+ }
+
+ /**
+ * Comment-side fully-qualified misc table name.
+ *
+ * @return string
+ */
+ public static function comment_table(): string {
+ global $wpdb;
+ return $wpdb->prefix . 'wpdo_comment_misc';
+ }
+
+ // ── Term metadata callbacks ───────────────────────────────────────────────
+
+ /**
+ * Filter callback: get_term_metadata.
+ *
+ * @param mixed $pre Filter accumulator.
+ * @param int $object_id Term ID.
+ * @param string $meta_key Meta key being read.
+ * @param bool $single Whether single value was requested.
+ * @return mixed
+ */
+ public static function on_term_read( $pre, $object_id, $meta_key, $single ) {
+ unset( $single );
+ // Only handle when no earlier filter has resolved this read.
+ if ( null !== $pre ) {
+ return $pre;
+ }
+ if ( ! is_string( $meta_key ) || '' === $meta_key ) {
+ return $pre;
+ }
+ $value = self::read( self::term_table(), 'term_id', (int) $object_id, $meta_key );
+ if ( null === $value ) {
+ return $pre;
+ }
+ return array( $value );
+ }
+
+ /**
+ * Filter callback: add_term_metadata.
+ *
+ * @param mixed $check Filter accumulator.
+ * @param int $object_id Term ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Value.
+ * @param bool $unique Unique flag (unused).
+ * @return mixed
+ */
+ public static function on_term_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
+ unset( $unique );
+ if ( null !== $check ) {
+ return $check;
+ }
+ if ( ! is_string( $meta_key ) || '' === $meta_key ) {
+ return $check;
+ }
+ self::write( self::term_table(), 'term_id', (int) $object_id, $meta_key, $meta_value );
+ return true;
+ }
+
+ /**
+ * Filter callback: update_term_metadata.
+ *
+ * @param mixed $check Filter accumulator.
+ * @param int $object_id Term ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Value.
+ * @param mixed $prev_value Previous value (unused).
+ * @return mixed
+ */
+ public static function on_term_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
+ unset( $prev_value );
+ if ( null !== $check ) {
+ return $check;
+ }
+ if ( ! is_string( $meta_key ) || '' === $meta_key ) {
+ return $check;
+ }
+ self::write( self::term_table(), 'term_id', (int) $object_id, $meta_key, $meta_value );
+ return true;
+ }
+
+ /**
+ * Filter callback: delete_term_metadata.
+ *
+ * @param mixed $check Filter accumulator.
+ * @param int $object_id Term ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Value-scoped delete (unused).
+ * @param bool $delete_all Delete-all flag (unused).
+ * @return mixed
+ */
+ public static function on_term_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
+ unset( $meta_value, $delete_all );
+ if ( null !== $check ) {
+ return $check;
+ }
+ if ( ! is_string( $meta_key ) || '' === $meta_key ) {
+ return $check;
+ }
+ self::delete_row( self::term_table(), 'term_id', (int) $object_id, $meta_key );
+ return true;
+ }
+
+ // ── Comment metadata callbacks ────────────────────────────────────────────
+
+ /**
+ * Filter callback: get_comment_metadata.
+ *
+ * @param mixed $pre Filter accumulator.
+ * @param int $object_id Comment ID.
+ * @param string $meta_key Meta key.
+ * @param bool $single Single flag (unused).
+ * @return mixed
+ */
+ public static function on_comment_read( $pre, $object_id, $meta_key, $single ) {
+ unset( $single );
+ if ( null !== $pre ) {
+ return $pre;
+ }
+ if ( ! is_string( $meta_key ) || '' === $meta_key ) {
+ return $pre;
+ }
+ $value = self::read( self::comment_table(), 'comment_id', (int) $object_id, $meta_key );
+ if ( null === $value ) {
+ return $pre;
+ }
+ return array( $value );
+ }
+
+ /**
+ * Filter callback: add_comment_metadata.
+ *
+ * @param mixed $check Filter accumulator.
+ * @param int $object_id Comment ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Value.
+ * @param bool $unique Unique flag (unused).
+ * @return mixed
+ */
+ public static function on_comment_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
+ unset( $unique );
+ if ( null !== $check ) {
+ return $check;
+ }
+ if ( ! is_string( $meta_key ) || '' === $meta_key ) {
+ return $check;
+ }
+ self::write( self::comment_table(), 'comment_id', (int) $object_id, $meta_key, $meta_value );
+ return true;
+ }
+
+ /**
+ * Filter callback: update_comment_metadata.
+ *
+ * @param mixed $check Filter accumulator.
+ * @param int $object_id Comment ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Value.
+ * @param mixed $prev_value Previous value (unused).
+ * @return mixed
+ */
+ public static function on_comment_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
+ unset( $prev_value );
+ if ( null !== $check ) {
+ return $check;
+ }
+ if ( ! is_string( $meta_key ) || '' === $meta_key ) {
+ return $check;
+ }
+ self::write( self::comment_table(), 'comment_id', (int) $object_id, $meta_key, $meta_value );
+ return true;
+ }
+
+ /**
+ * Filter callback: delete_comment_metadata.
+ *
+ * @param mixed $check Filter accumulator.
+ * @param int $object_id Comment ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Value-scoped (unused).
+ * @param bool $delete_all Delete-all flag (unused).
+ * @return mixed
+ */
+ public static function on_comment_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
+ unset( $meta_value, $delete_all );
+ if ( null !== $check ) {
+ return $check;
+ }
+ if ( ! is_string( $meta_key ) || '' === $meta_key ) {
+ return $check;
+ }
+ self::delete_row( self::comment_table(), 'comment_id', (int) $object_id, $meta_key );
+ return true;
+ }
+
+ // ── Internal storage helpers ──────────────────────────────────────────────
+
+ /**
+ * Read a value from a misc bucket table.
+ *
+ * Returns the raw stored value (string), or null when no row exists.
+ * Caller must wrap in array for the get_*_metadata filter contract.
+ *
+ * @param string $table Fully-qualified table name.
+ * @param string $id_column 'term_id' or 'comment_id'.
+ * @param int $object_id Entity ID.
+ * @param string $meta_key Meta key.
+ * @return string|null
+ */
+ private static function read( string $table, string $id_column, int $object_id, string $meta_key ): ?string {
+ global $wpdb;
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $value = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT meta_value FROM `{$table}` WHERE `{$id_column}` = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $object_id,
+ $meta_key
+ )
+ );
+ // phpcs:enable
+ return ( null === $value ) ? null : (string) $value;
+ }
+
+ /**
+ * Write (INSERT or UPDATE) a value to a misc bucket table.
+ *
+ * Uses ON DUPLICATE KEY UPDATE — the (id, meta_key) primary key
+ * means each (entity, meta_key) pair has a single canonical row.
+ *
+ * @param string $table Fully-qualified table name.
+ * @param string $id_column 'term_id' or 'comment_id'.
+ * @param int $object_id Entity ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Value to store. Non-scalar types are serialized.
+ * @return void
+ */
+ private static function write( string $table, string $id_column, int $object_id, string $meta_key, $meta_value ): void {
+ global $wpdb;
+
+ // Match WP convention: serialize arrays/objects, scalar values stored as-is.
+ $serialized = is_scalar( $meta_value ) || null === $meta_value
+ ? (string) $meta_value
+ : maybe_serialize( $meta_value );
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO `{$table}` (`{$id_column}`, meta_key, meta_value) VALUES (%d, %s, %s)
+ ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)",
+ $object_id,
+ $meta_key,
+ $serialized
+ )
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Delete the row matching (object_id, meta_key) from a misc bucket table.
+ *
+ * @param string $table Fully-qualified table name.
+ * @param string $id_column 'term_id' or 'comment_id'.
+ * @param int $object_id Entity ID.
+ * @param string $meta_key Meta key.
+ * @return void
+ */
+ private static function delete_row( string $table, string $id_column, int $object_id, string $meta_key ): void {
+ global $wpdb;
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM `{$table}` WHERE `{$id_column}` = %d AND meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $object_id,
+ $meta_key
+ )
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Count rows in a misc bucket table (for admin status panel).
+ *
+ * @param string $entity_type 'term' or 'comment'.
+ * @return int
+ */
+ public static function count_rows( string $entity_type ): int {
+ global $wpdb;
+ $table = 'comment' === $entity_type ? self::comment_table() : self::term_table();
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $exists = (bool) $wpdb->get_var(
+ $wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
+ );
+ if ( ! $exists ) {
+ return 0;
+ }
+ return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
+ // phpcs:enable
+ }
+}
diff --git a/includes/interceptors/class-tmdo-interceptor-base.php b/includes/interceptors/class-tmdo-interceptor-base.php
new file mode 100644
index 0000000..c8b5810
--- /dev/null
+++ b/includes/interceptors/class-tmdo-interceptor-base.php
@@ -0,0 +1,195 @@
+module );
+ }
+
+ /**
+ * Return true when dual-write is active (writes go to both native + custom).
+ *
+ * @return bool True if module is in a write-active state.
+ */
+ protected function is_write_active(): bool {
+ return TMDO_Feature_Flags::is_write_active( $this->module );
+ }
+
+ /**
+ * Return true when the module should intercept reads or writes.
+ *
+ * @return bool True if module is enabled or write-active.
+ */
+ protected function is_active(): bool {
+ return $this->is_enabled() || $this->is_write_active();
+ }
+
+ /**
+ * Execute a custom-table callable safely.
+ *
+ * If $custom throws, log and return $native_fallback().
+ * After 3 consecutive errors in a request, disable the module.
+ *
+ * @param callable $custom Custom table read/write callable.
+ * @param callable $native_fallback Original WordPress callable.
+ * @param string $hook Hook name for logging context.
+ * @return mixed
+ * @throws \RuntimeException When the custom callable returns a WP_Error.
+ */
+ protected function intercept( callable $custom, callable $native_fallback, string $hook = '' ): mixed {
+ if ( ! $this->is_enabled() ) {
+ return $native_fallback();
+ }
+
+ try {
+ $result = $custom();
+
+ if ( is_wp_error( $result ) ) {
+ throw new \RuntimeException( $result->get_error_message() );
+ }
+
+ return $result;
+
+ } catch ( \Throwable $e ) {
+ $this->handle_error(
+ $hook ?: 'intercept',
+ $e->getMessage(),
+ array(
+ 'exception' => get_class( $e ),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ )
+ );
+
+ return $native_fallback();
+ }
+ }
+
+ /**
+ * Dual-write: call native first, then sync to custom table.
+ * Custom failure is non-fatal.
+ *
+ * @param callable $native Original write callable (always executed).
+ * @param callable $custom Custom table write callable.
+ * @param string $hook Hook name for logging.
+ * @return mixed Return value of $native.
+ */
+ protected function dual_write( callable $native, callable $custom, string $hook = '' ): mixed {
+ $result = $native();
+
+ if ( $this->is_write_active() || $this->is_enabled() ) {
+ try {
+ $custom( $result );
+ } catch ( \Throwable $e ) {
+ $this->handle_error(
+ $hook ?: 'dual_write',
+ $e->getMessage(),
+ array(
+ 'exception' => get_class( $e ),
+ )
+ );
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Register all hooks. Called by TMDO_Core.
+ *
+ * @return void
+ */
+ abstract public function register_hooks(): void;
+
+ // ── Private helpers ───────────────────────────────────────────────────
+
+ /**
+ * Per-request consecutive error counter, keyed by module.
+ *
+ * @var array
+ */
+ private static array $error_counts = array();
+
+ /**
+ * Handles an interceptor error and auto-disables the module after 3 consecutive errors.
+ *
+ * @param string $hook Hook name for logging context.
+ * @param string $message Error message.
+ * @param array $context Additional context data.
+ * @return void
+ */
+ private function handle_error( string $hook, string $message, array $context = array() ): void {
+ TMDO_Logger::error( $this->module, $hook, $message, $context );
+
+ self::$error_counts[ $this->module ] = ( self::$error_counts[ $this->module ] ?? 0 ) + 1;
+
+ if ( self::$error_counts[ $this->module ] >= 3 ) {
+ TMDO_Feature_Flags::reset( $this->module );
+ TMDO_Logger::error( $this->module, $hook, 'Module auto-disabled after 3 consecutive errors.' );
+
+ if ( $this->email_on_error ) {
+ $this->notify_admin( $message );
+ $this->email_on_error = false;
+ }
+ }
+ }
+
+ /**
+ * Sends an admin notification email when a module is auto-disabled.
+ *
+ * @param string $message Error message to include in the notification.
+ * @return void
+ */
+ private function notify_admin( string $message ): void {
+ $admin_email = get_option( 'admin_email' );
+ if ( ! $admin_email ) {
+ return;
+ }
+
+ wp_mail(
+ $admin_email,
+ sprintf( '[WPDO] Module "%s" auto-disabled', $this->module ),
+ sprintf(
+ "The WPDO module \"%s\" has been automatically disabled due to repeated errors.\n\nLast error: %s\n\nPlease review the error log at Tools > WP Data Optimizer > Logs.",
+ $this->module,
+ $message
+ )
+ );
+ }
+}
diff --git a/includes/interceptors/class-tmdo-sync-bridge.php b/includes/interceptors/class-tmdo-sync-bridge.php
new file mode 100644
index 0000000..94c39b4
--- /dev/null
+++ b/includes/interceptors/class-tmdo-sync-bridge.php
@@ -0,0 +1,400 @@
+ field|false).
+ *
+ * @var array
+ */
+ private static array $field_cache = array();
+
+ /**
+ * Register all metadata hooks.
+ */
+ public function register_hooks(): void {
+ add_filter( 'get_post_metadata', array( $this, 'intercept_get' ), 10, 5 );
+ add_filter( 'update_post_metadata', array( $this, 'intercept_update' ), 10, 5 );
+ add_filter( 'add_post_metadata', array( $this, 'intercept_add' ), 10, 5 );
+ add_action( 'deleted_post_meta', array( $this, 'intercept_delete' ), 10, 4 );
+ add_action( 'before_delete_post', array( $this, 'cleanup_post' ), 10, 1 );
+ }
+
+ /**
+ * Intercept get_post_meta — read from zone table when module is in read-custom state.
+ *
+ * @param mixed $value Existing filtered value (null by default).
+ * @param int $post_id Post ID.
+ * @param string $meta_key Meta key (empty = get all).
+ * @param bool $single Whether to return single value.
+ * @param string $meta_type Meta type (always 'post').
+ * @return mixed
+ */
+ public function intercept_get( $value, int $post_id, string $meta_key, bool $single, string $meta_type = 'post' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by get_post_metadata filter signature.
+ if ( self::$bypassing || empty( $meta_key ) || $post_id <= 0 ) {
+ return $value;
+ }
+
+ $post_type = get_post_type( $post_id );
+ if ( ! $post_type ) {
+ return $value;
+ }
+
+ $field = $this->get_field_cached( $post_type, $meta_key );
+ if ( ! $field ) {
+ return $value;
+ }
+
+ $module = $this->get_zone_module( $field['zone'], $post_type );
+ if ( ! TMDO_Feature_Flags::is_read_custom( $module ) ) {
+ return $value;
+ }
+
+ try {
+ $zone_value = $this->read_from_zone( $field, $post_id, $post_type, $meta_key );
+
+ if ( null === $zone_value ) {
+ return $value;
+ }
+
+ // phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- This is an explanatory comment, not commented-out code.
+ // Wrap in array: WP unwraps $check[0] for $single=true, casts (array)$check for $single=false.
+ return array( $zone_value );
+
+ } catch ( \Throwable $e ) {
+ TMDO_Logger::error( $module, 'get_post_metadata', $e->getMessage() );
+ return $value;
+ }
+ }
+
+ /**
+ * Intercept update_post_meta — dual-write to zone table.
+ *
+ * @param null|bool $check Whether to short-circuit (null = proceed).
+ * @param int $post_id Post ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Meta value.
+ * @param mixed $prev_value Previous value.
+ * @return null|bool
+ */
+ public function intercept_update( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ if ( self::$bypassing || $post_id <= 0 ) {
+ return $check;
+ }
+
+ $post_type = get_post_type( $post_id );
+ if ( ! $post_type ) {
+ return $check;
+ }
+
+ // v2.9.2 Entity Bridge guard: when post mode is dual_write or higher
+ // AND the key is registered in the new Entity Registry, the unified
+ // Hook Bus is the source of truth — Sync_Bridge must not also write
+ // to the legacy zone table to avoid duplicate flat writes.
+ if ( self::is_owned_by_entity_bridge( $meta_key ) ) {
+ return $check;
+ }
+
+ $field = $this->get_field_cached( $post_type, $meta_key );
+ if ( ! $field ) {
+ return $check;
+ }
+
+ $module = $this->get_zone_module( $field['zone'], $post_type );
+ if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) {
+ return $check;
+ }
+
+ // Write to zone table (non-fatal on failure).
+ try {
+ $this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value );
+ } catch ( \Throwable $e ) {
+ TMDO_Logger::error( $module, 'update_post_metadata', $e->getMessage() );
+ }
+
+ // Return null — let WordPress proceed with native postmeta write.
+ // In cleanup/complete states, we could skip native write, but for safety
+ // we always allow it during zone migration lifecycle.
+ return $check;
+ }
+
+ /**
+ * Whether the given post meta_key is now owned by the Entity Bridge,
+ * meaning Sync_Bridge should defer to the unified Hook Bus and skip its
+ * zone write to avoid duplicate flat writes.
+ *
+ * Returns true only when ALL of:
+ * - TMDO_Mode_Manager and TMDO_Entity_Registry classes exist
+ * - post mode is dual_write or higher (writes_to_flat returns true)
+ * - the key is registered for entity_type=post
+ *
+ * Default post mode is `disabled`, so this returns false in all
+ * environments that have not opted in to Entity Bridge — keeping the
+ * legacy Sync_Bridge → zone path unchanged.
+ *
+ * @param string $meta_key Meta key being written.
+ * @return bool
+ * @since 2.9.2
+ */
+ private static function is_owned_by_entity_bridge( string $meta_key ): bool {
+ if ( ! class_exists( 'TMDO_Mode_Manager' ) || ! class_exists( 'TMDO_Entity_Registry' ) ) {
+ return false;
+ }
+ if ( ! TMDO_Mode_Manager::writes_to_flat( 'post' ) ) {
+ return false;
+ }
+ return null !== TMDO_Entity_Registry::get_field( 'post', $meta_key );
+ }
+
+ /**
+ * Intercept add_post_meta — dual-write to zone table.
+ *
+ * @param mixed $check Whether to short-circuit.
+ * @param int $post_id Post ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Meta value.
+ * @param mixed $unique Whether the meta key should be unique. Not used directly.
+ * @return mixed Filtered check value.
+ */
+ public function intercept_add( $check, int $post_id, string $meta_key, $meta_value, $unique ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by add_post_metadata filter signature.
+ if ( self::$bypassing || $post_id <= 0 ) {
+ return $check;
+ }
+
+ $post_type = get_post_type( $post_id );
+ if ( ! $post_type ) {
+ return $check;
+ }
+
+ // v2.9.2 Entity Bridge guard — see is_owned_by_entity_bridge() docblock.
+ if ( self::is_owned_by_entity_bridge( $meta_key ) ) {
+ return $check;
+ }
+
+ $field = $this->get_field_cached( $post_type, $meta_key );
+ if ( ! $field ) {
+ return $check;
+ }
+
+ $module = $this->get_zone_module( $field['zone'], $post_type );
+ if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) {
+ return $check;
+ }
+
+ try {
+ $this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value );
+ } catch ( \Throwable $e ) {
+ TMDO_Logger::error( $module, 'add_post_metadata', $e->getMessage() );
+ }
+
+ return $check;
+ }
+
+ /**
+ * After a postmeta is deleted, remove from zone table too.
+ *
+ * Hooked to 'deleted_post_meta' (fires after native delete completes).
+ *
+ * @param int[] $meta_ids Array of deleted meta IDs.
+ * @param int $post_id Post ID.
+ * @param string $meta_key Meta key.
+ * @param mixed $meta_value Meta value. Not used directly.
+ * @return void
+ */
+ public function intercept_delete( $meta_ids, int $post_id, string $meta_key, $meta_value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by deleted_post_meta action signature.
+ if ( self::$bypassing || $post_id <= 0 ) {
+ return;
+ }
+
+ $post_type = get_post_type( $post_id );
+ if ( ! $post_type ) {
+ return;
+ }
+
+ $field = $this->get_field_cached( $post_type, $meta_key );
+ if ( ! $field ) {
+ return;
+ }
+
+ $module = $this->get_zone_module( $field['zone'], $post_type );
+ if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) {
+ return;
+ }
+
+ try {
+ $this->delete_from_zone( $field, $post_id, $post_type, $meta_key );
+ } catch ( \Throwable $e ) {
+ TMDO_Logger::error( $module, 'deleted_post_meta', $e->getMessage() );
+ }
+ }
+
+ /**
+ * When a post is permanently deleted, clean up all zone data.
+ *
+ * @param int $post_id Post ID being deleted.
+ * @return void
+ */
+ public function cleanup_post( int $post_id ): void {
+ $post_type = get_post_type( $post_id );
+ if ( ! $post_type ) {
+ return;
+ }
+
+ $registry = TMDO_Schema_Registry::instance();
+
+ // Clean Hot zone.
+ if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) {
+ try {
+ TMDO_Zone_Hot::delete( $post_id, $post_type );
+ } catch ( \Throwable $e ) {
+ TMDO_Logger::error( 'hot_' . $post_type, 'before_delete_post', $e->getMessage() );
+ }
+ }
+
+ // Clean Cold zone.
+ if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) {
+ try {
+ TMDO_Zone_Cold::delete( $post_id, $post_type );
+ } catch ( \Throwable $e ) {
+ TMDO_Logger::error( 'cold_' . $post_type, 'before_delete_post', $e->getMessage() );
+ }
+ }
+
+ // Clean Warm zone.
+ try {
+ TMDO_Zone_Warm::delete_all( $post_id );
+ } catch ( \Throwable $e ) {
+ TMDO_Logger::error( 'warm', 'before_delete_post', $e->getMessage() );
+ }
+
+ // Clean Archive zone.
+ try {
+ TMDO_Zone_Archive::delete( $post_id );
+ } catch ( \Throwable $e ) {
+ TMDO_Logger::error( 'archive', 'before_delete_post', $e->getMessage() );
+ }
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────
+
+ /**
+ * Get a registered field with a request-level static cache.
+ *
+ * @param string $post_type Post type.
+ * @param string $meta_key Meta key.
+ * @return array|null Field definition, or null if not registered.
+ */
+ private function get_field_cached( string $post_type, string $meta_key ): ?array {
+ $cache_key = $post_type . ':' . $meta_key;
+ if ( ! array_key_exists( $cache_key, self::$field_cache ) ) {
+ self::$field_cache[ $cache_key ] = TMDO_Schema_Registry::instance()->get_field( $post_type, $meta_key );
+ }
+ return self::$field_cache[ $cache_key ];
+ }
+
+ /**
+ * Derive module name from zone + post_type for feature flag lookups.
+ *
+ * @param string $zone Zone identifier (hot, cold, warm, archive).
+ * @param string $post_type Post type.
+ * @return string Module name.
+ */
+ private function get_zone_module( string $zone, string $post_type ): string {
+ return match ( $zone ) {
+ 'hot' => 'hot_' . sanitize_key( $post_type ),
+ 'cold' => 'cold_' . sanitize_key( $post_type ),
+ 'warm' => 'warm',
+ 'archive' => 'archive',
+ default => 'unknown',
+ };
+ }
+
+ /**
+ * Read a value from the appropriate zone.
+ *
+ * @param array $field Field definition from Schema Registry.
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @param string $meta_key Meta key.
+ * @return mixed Value from zone or null.
+ */
+ private function read_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): mixed {
+ return match ( $field['zone'] ) {
+ 'hot' => TMDO_Zone_Hot::get( $post_id, $post_type, $field['column'] ),
+ 'cold' => TMDO_Zone_Cold::get( $post_id, $post_type, $meta_key ),
+ 'warm' => TMDO_Zone_Warm::get( $post_id, $meta_key ),
+ default => null,
+ };
+ }
+
+ /**
+ * Write a value to the appropriate zone.
+ *
+ * @param array $field Field definition from Schema Registry.
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @param string $meta_key Meta key.
+ * @param mixed $value Value to write.
+ * @return void
+ */
+ private function write_to_zone( array $field, int $post_id, string $post_type, string $meta_key, mixed $value ): void {
+ match ( $field['zone'] ) {
+ 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], $value ),
+ 'cold' => TMDO_Zone_Cold::set( $post_id, $post_type, $meta_key, $value ),
+ 'warm' => TMDO_Zone_Warm::set(
+ $post_id,
+ $meta_key,
+ is_string( $value ) ? $value : wp_json_encode( $value ),
+ $field['ttl'] ?? null
+ ),
+ default => null,
+ };
+ }
+
+ /**
+ * Delete a value from the appropriate zone.
+ *
+ * @param array $field Field definition from Schema Registry.
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @param string $meta_key Meta key.
+ * @return void
+ */
+ private function delete_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): void {
+ match ( $field['zone'] ) {
+ 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], null ),
+ 'cold' => TMDO_Zone_Cold::remove( $post_id, $post_type, $meta_key ),
+ 'warm' => TMDO_Zone_Warm::delete( $post_id, $meta_key ),
+ default => null,
+ };
+ }
+}
diff --git a/includes/migration/class-tmdo-archive-migration.php b/includes/migration/class-tmdo-archive-migration.php
new file mode 100644
index 0000000..0c68878
--- /dev/null
+++ b/includes/migration/class-tmdo-archive-migration.php
@@ -0,0 +1,168 @@
+days = $days;
+ $this->compress = $compress;
+ }
+
+ /**
+ * Returns the module identifier.
+ *
+ * @return string Module name.
+ */
+ public function get_module(): string {
+ return 'archive';
+ }
+
+ /**
+ * Returns the zone identifier.
+ *
+ * @return string Zone name.
+ */
+ public function get_zone(): string {
+ return 'archive';
+ }
+
+ /**
+ * Returns the total number of archive-eligible postmeta rows.
+ *
+ * @return int Total row count.
+ */
+ protected function count_source(): int {
+ global $wpdb;
+
+ $cutoff = $this->get_cutoff();
+
+ return (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_status = 'trash'
+ AND p.post_modified_gmt < %s",
+ $cutoff
+ )
+ );
+ }
+
+ /**
+ * Migrates one batch of eligible postmeta rows to the archive table.
+ *
+ * @param int $offset Starting row offset.
+ * @return int Number of rows processed.
+ */
+ protected function migrate_batch( int $offset ): int {
+ global $wpdb;
+
+ $cutoff = $this->get_cutoff();
+
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE p.post_status = 'trash'
+ AND p.post_modified_gmt < %s
+ ORDER BY pm.meta_id ASC
+ LIMIT %d OFFSET %d",
+ $cutoff,
+ self::BATCH_SIZE,
+ $offset
+ ),
+ ARRAY_A
+ );
+
+ if ( empty( $rows ) ) {
+ return 0;
+ }
+
+ $entries = array();
+ foreach ( $rows as $row ) {
+ $entries[] = array(
+ 'post_id' => $row['post_id'],
+ 'post_type' => $row['post_type'],
+ 'meta_key' => $row['meta_key'],
+ 'meta_value' => $row['meta_value'],
+ 'meta_id' => $row['meta_id'],
+ );
+ }
+
+ TMDO_Zone_Archive::archive_batch( $entries, $this->compress );
+
+ return count( $rows );
+ }
+
+ /**
+ * Verifies that archive table has rows when eligible source rows exist.
+ *
+ * @return bool True if verification passes.
+ */
+ public function verify_counts(): bool {
+ global $wpdb;
+
+ $source = $this->count_source();
+ $table = TMDO_Zone_Archive::table();
+ $target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Archive::table()
+
+ // Archive may have more rows than current source (trashed posts may have
+ // been permanently deleted after archival). So we just check target > 0
+ // when source is 0, or target >= some threshold.
+ if ( 0 === $source ) {
+ return true;
+ }
+
+ return $target >= $source;
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────
+
+ /**
+ * Returns the cutoff datetime string for archival eligibility.
+ *
+ * @return string MySQL datetime string.
+ */
+ private function get_cutoff(): string {
+ return gmdate( 'Y-m-d H:i:s', time() - ( $this->days * DAY_IN_SECONDS ) );
+ }
+}
diff --git a/includes/migration/class-tmdo-cold-migration.php b/includes/migration/class-tmdo-cold-migration.php
new file mode 100644
index 0000000..7512ae6
--- /dev/null
+++ b/includes/migration/class-tmdo-cold-migration.php
@@ -0,0 +1,166 @@
+post_type = $post_type;
+ }
+
+ /**
+ * Returns the module identifier.
+ *
+ * @return string Module name.
+ */
+ public function get_module(): string {
+ return 'cold_' . sanitize_key( $this->post_type );
+ }
+
+ /**
+ * Returns the zone identifier.
+ *
+ * @return string Zone name.
+ */
+ public function get_zone(): string {
+ return 'cold';
+ }
+
+ /**
+ * Returns the total number of posts to migrate.
+ *
+ * @return int Total post count.
+ */
+ protected function count_source(): int {
+ global $wpdb;
+
+ $meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type );
+ if ( empty( $meta_keys ) ) {
+ return 0;
+ }
+
+ $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
+ $args = array_merge( array( $this->post_type ), $meta_keys );
+
+ // Count distinct posts that have at least one cold field.
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(DISTINCT pm.post_id)
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
+ ...$args
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ }
+
+ /**
+ * Migrates one batch of posts to the cold table.
+ *
+ * @param int $offset Starting post offset.
+ * @return int Number of posts processed.
+ */
+ protected function migrate_batch( int $offset ): int {
+ global $wpdb;
+
+ $meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type );
+ if ( empty( $meta_keys ) ) {
+ return 0;
+ }
+
+ $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
+
+ // Get batch of distinct post IDs.
+ $args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) );
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
+ $post_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT DISTINCT pm.post_id
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})
+ ORDER BY pm.post_id ASC
+ LIMIT %d OFFSET %d",
+ ...$args
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+
+ // Ensure cold table exists before any write (even if this batch is empty).
+ TMDO_Zone_Cold::ensure_table( $this->post_type );
+
+ if ( empty( $post_ids ) ) {
+ return 0;
+ }
+
+ // Fetch all cold meta for these posts.
+ $id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ $meta_rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT post_id, meta_key, meta_value
+ FROM {$wpdb->postmeta}
+ WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})",
+ ...array_merge( array_map( 'intval', $post_ids ), $meta_keys )
+ ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+
+ // Group by post_id.
+ $grouped = array();
+ foreach ( $meta_rows ?: array() as $row ) {
+ $grouped[ $row['post_id'] ][ $row['meta_key'] ] = $row['meta_value'];
+ }
+
+ // Write each post's cold data as a JSON blob.
+ foreach ( $grouped as $pid => $data ) {
+ TMDO_Zone_Cold::set_many( (int) $pid, $this->post_type, $data );
+ }
+
+ return count( $post_ids );
+ }
+
+ /**
+ * Verifies that the cold table row count is at least as large as the source.
+ *
+ * @return bool True if verification passes.
+ */
+ public function verify_counts(): bool {
+ global $wpdb;
+
+ $source = $this->count_source();
+ $table = TMDO_Zone_Cold::table( $this->post_type );
+ $target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Cold::table()
+
+ return $target >= $source;
+ }
+}
diff --git a/includes/migration/class-tmdo-hot-migration.php b/includes/migration/class-tmdo-hot-migration.php
new file mode 100644
index 0000000..dc4ec0e
--- /dev/null
+++ b/includes/migration/class-tmdo-hot-migration.php
@@ -0,0 +1,191 @@
+post_type = $post_type;
+ }
+
+ /**
+ * Returns the module identifier.
+ *
+ * @return string Module name.
+ */
+ public function get_module(): string {
+ return 'hot_' . sanitize_key( $this->post_type );
+ }
+
+ /**
+ * Returns the zone identifier.
+ *
+ * @return string Zone name.
+ */
+ public function get_zone(): string {
+ return 'hot';
+ }
+
+ /**
+ * Returns the total number of posts to migrate.
+ *
+ * @return int Total post count.
+ */
+ protected function count_source(): int {
+ global $wpdb;
+
+ $meta_keys = $this->get_meta_keys();
+ if ( empty( $meta_keys ) ) {
+ return 0;
+ }
+
+ $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
+ $args = array_merge( array( $this->post_type ), $meta_keys );
+
+ // Count distinct posts that have at least one hot field.
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders built from array_fill with %s; $wpdb->postmeta/$wpdb->posts are core properties.
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(DISTINCT pm.post_id)
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
+ ...$args
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ }
+
+ /**
+ * Migrates one batch of posts to the hot table.
+ *
+ * @param int $offset Starting post offset.
+ * @return int Number of posts processed.
+ */
+ protected function migrate_batch( int $offset ): int {
+ global $wpdb;
+
+ $meta_keys = $this->get_meta_keys();
+ if ( empty( $meta_keys ) ) {
+ return 0;
+ }
+
+ $columns = TMDO_Schema_Registry::instance()->get_hot_columns( $this->post_type );
+ $key_map = $this->build_key_to_column_map();
+ $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
+
+ // Get batch of distinct post IDs.
+ $args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) );
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders built from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
+ $post_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT DISTINCT pm.post_id
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})
+ ORDER BY pm.post_id ASC
+ LIMIT %d OFFSET %d",
+ ...$args
+ )
+ );
+
+ if ( empty( $post_ids ) ) {
+ return 0;
+ }
+
+ // Ensure hot table exists.
+ TMDO_Zone_Hot::ensure_table( $this->post_type );
+
+ // For each post, gather all hot meta and upsert.
+ $id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
+
+ $meta_rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT post_id, meta_key, meta_value
+ FROM {$wpdb->postmeta}
+ WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})",
+ ...array_merge( array_map( 'intval', $post_ids ), $meta_keys )
+ ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+
+ // Group by post_id.
+ $grouped = array();
+ foreach ( $meta_rows ?: array() as $row ) {
+ $col = $key_map[ $row['meta_key'] ] ?? null;
+ if ( $col ) {
+ $grouped[ $row['post_id'] ][ $col ] = $row['meta_value'];
+ }
+ }
+
+ // Upsert each post's hot data.
+ foreach ( $grouped as $pid => $data ) {
+ TMDO_Zone_Hot::set_many( (int) $pid, $this->post_type, $data );
+ }
+
+ return count( $post_ids );
+ }
+
+ /**
+ * Verifies that the hot table row count is at least as large as the source.
+ *
+ * @return bool True if verification passes.
+ */
+ public function verify_counts(): bool {
+ global $wpdb;
+
+ $source = $this->count_source();
+ $table = TMDO_Zone_Hot::table( $this->post_type );
+ $target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Hot::table()
+
+ return $target >= $source;
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────
+
+ /**
+ * Get all registered hot meta_keys for this post type.
+ */
+ private function get_meta_keys(): array {
+ $fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type );
+ return array_column( $fields, 'meta_key' );
+ }
+
+ /**
+ * Build meta_key → column_name map.
+ */
+ private function build_key_to_column_map(): array {
+ $fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type );
+ $map = array();
+ foreach ( $fields as $field ) {
+ $map[ $field['meta_key'] ] = $field['column'];
+ }
+ return $map;
+ }
+}
diff --git a/includes/migration/class-tmdo-migration-base.php b/includes/migration/class-tmdo-migration-base.php
new file mode 100644
index 0000000..6b5c80e
--- /dev/null
+++ b/includes/migration/class-tmdo-migration-base.php
@@ -0,0 +1,287 @@
+= native count
+ *
+ * Migration record is tracked in wpdo_migrations table.
+ * Batch size: 500 rows. Timeout: 28 seconds per run.
+ */
+abstract class TMDO_Migration_Base {
+
+ protected const BATCH_SIZE = 500;
+ protected const TIMEOUT = 28; // Seconds.
+
+ /**
+ * Returns the module identifier for this migration.
+ *
+ * @return string Module name.
+ */
+ abstract public function get_module(): string;
+
+ /**
+ * Zone identifier. Empty string for HPCT-inherited modules.
+ */
+ public function get_zone(): string {
+ return '';
+ }
+
+ /**
+ * Returns the total number of source rows to migrate.
+ *
+ * @return int Total row count.
+ */
+ abstract protected function count_source(): int;
+
+ /**
+ * Migrate one batch of rows.
+ *
+ * @param int $offset Starting row offset.
+ * @return int Number of rows processed in this batch.
+ */
+ abstract protected function migrate_batch( int $offset ): int;
+
+ /**
+ * Verify that the custom table count is >= native count.
+ */
+ abstract public function verify_counts(): bool;
+
+ // ── Migration record helpers ──────────────────────────────────────────
+
+ /**
+ * Retrieves the current migration record from the database.
+ *
+ * @return array|null Migration record array, or null if not found.
+ */
+ public function get_record(): ?array {
+ global $wpdb;
+ $table = TMDO_DB::table( 'wpdo_migrations' );
+ $row = $wpdb->get_row(
+ $wpdb->prepare( "SELECT * FROM `{$table}` WHERE module = %s", $this->get_module() ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
+ ARRAY_A
+ );
+
+ return $row ?: null;
+ }
+
+ /**
+ * Initialize or reset the migration record.
+ */
+ public function init_record(): void {
+ global $wpdb;
+ $table = TMDO_DB::table( 'wpdo_migrations' );
+ $now = TMDO_DB::now();
+ $total = $this->count_source();
+
+ $existing = $this->get_record();
+
+ if ( $existing ) {
+ $wpdb->update(
+ $table,
+ array(
+ 'state' => 'backfill',
+ 'zone' => $this->get_zone(),
+ 'total_rows' => $total,
+ 'processed_rows' => 0,
+ 'last_offset' => 0,
+ 'error_count' => 0,
+ 'started_at' => $now,
+ 'completed_at' => null,
+ 'updated_at' => $now,
+ ),
+ array( 'module' => $this->get_module() ),
+ array( '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' ),
+ array( '%s' )
+ );
+ } else {
+ $wpdb->insert(
+ $table,
+ array(
+ 'module' => $this->get_module(),
+ 'zone' => $this->get_zone(),
+ 'state' => 'backfill',
+ 'total_rows' => $total,
+ 'processed_rows' => 0,
+ 'last_offset' => 0,
+ 'error_count' => 0,
+ 'started_at' => $now,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ),
+ array( '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' )
+ );
+ }
+ }
+
+ /**
+ * Resume an existing migration (does NOT reset processed_rows).
+ */
+ public function resume_record(): void {
+ global $wpdb;
+ $table = TMDO_DB::table( 'wpdo_migrations' );
+
+ $wpdb->update(
+ $table,
+ array(
+ 'state' => 'backfill',
+ 'updated_at' => TMDO_DB::now(),
+ ),
+ array( 'module' => $this->get_module() ),
+ array( '%s', '%s' ),
+ array( '%s' )
+ );
+ }
+
+ /**
+ * Update progress after each batch.
+ *
+ * @param int $processed Number of rows processed in this batch.
+ * @param int $last_offset Last row offset processed.
+ * @return void
+ */
+ public function update_progress( int $processed, int $last_offset ): void {
+ global $wpdb;
+ $table = TMDO_DB::table( 'wpdo_migrations' );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE `{$table}` SET
+ processed_rows = processed_rows + %d,
+ last_offset = %d,
+ updated_at = %s
+ WHERE module = %s",
+ $processed,
+ $last_offset,
+ TMDO_DB::now(),
+ $this->get_module()
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
+ /**
+ * Mark migration as completed (state → verify).
+ */
+ public function mark_complete(): void {
+ global $wpdb;
+ $table = TMDO_DB::table( 'wpdo_migrations' );
+ $now = TMDO_DB::now();
+
+ $wpdb->update(
+ $table,
+ array(
+ 'state' => 'verify',
+ 'completed_at' => $now,
+ 'updated_at' => $now,
+ ),
+ array( 'module' => $this->get_module() ),
+ array( '%s', '%s', '%s' ),
+ array( '%s' )
+ );
+ }
+
+ /**
+ * Increment error count.
+ */
+ public function increment_errors(): void {
+ global $wpdb;
+ $table = TMDO_DB::table( 'wpdo_migrations' );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE `{$table}` SET error_count = error_count + 1, updated_at = %s WHERE module = %s",
+ TMDO_DB::now(),
+ $this->get_module()
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
+ /**
+ * Run the migration in batches. Stops after ~28 seconds.
+ *
+ * @param bool $resume If true, resumes from last_offset.
+ * @param callable|null $progress_callback Called after each batch with (processed, total).
+ * @return bool True if migration completed, false if timed out (resume needed).
+ */
+ public function run( bool $resume = false, ?callable $progress_callback = null ): bool {
+ // Set feature flag to dual_write before starting.
+ TMDO_Feature_Flags::set( $this->get_module(), 'dual_write' );
+
+ if ( $resume ) {
+ $record = $this->get_record();
+ $offset = (int) ( $record['last_offset'] ?? 0 );
+ $this->resume_record();
+ } else {
+ $offset = 0;
+ $this->init_record();
+ }
+
+ // Advance to backfill state.
+ TMDO_Feature_Flags::set( $this->get_module(), 'backfill' );
+
+ $start = microtime( true );
+
+ do {
+ try {
+ $batch_count = $this->migrate_batch( $offset );
+ } catch ( \Throwable $e ) {
+ $this->increment_errors();
+ TMDO_Logger::error(
+ $this->get_module(),
+ 'migrate_batch',
+ $e->getMessage(),
+ array(
+ 'offset' => $offset,
+ 'zone' => $this->get_zone(),
+ )
+ );
+ $batch_count = 0;
+
+ // Check if too many errors.
+ $record = $this->get_record();
+ if ( $record && (int) $record['error_count'] >= 10 ) {
+ TMDO_Feature_Flags::reset( $this->get_module() );
+ return false;
+ }
+ }
+
+ if ( $batch_count > 0 ) {
+ $this->update_progress( $batch_count, $offset + $batch_count );
+ $offset += $batch_count;
+ }
+
+ if ( $progress_callback ) {
+ $record = $this->get_record();
+ $progress_callback( (int) $record['processed_rows'], (int) $record['total_rows'] );
+ }
+
+ if ( ( microtime( true ) - $start ) > self::TIMEOUT ) {
+ return false; // Timed out — next run will resume.
+ }
+ } while ( $batch_count >= self::BATCH_SIZE );
+
+ // Backfill done → move to verify.
+ $this->mark_complete();
+ TMDO_Feature_Flags::set( $this->get_module(), 'verify' );
+
+ return true;
+ }
+}
diff --git a/includes/migration/class-tmdo-migration-engine.php b/includes/migration/class-tmdo-migration-engine.php
new file mode 100644
index 0000000..8e91104
--- /dev/null
+++ b/includes/migration/class-tmdo-migration-engine.php
@@ -0,0 +1,329 @@
+ array( 'dual_write' ),
+ 'dual_write' => array( 'backfill', 'idle' ),
+ 'backfill' => array( 'verify', 'dual_write', 'idle' ),
+ 'verify' => array( 'cutover', 'dual_write', 'idle' ),
+ 'cutover' => array( 'cleanup', 'idle' ),
+ 'cleanup' => array( 'complete', 'idle' ),
+ 'complete' => array( 'idle' ),
+ );
+
+ /**
+ * Registry of migration class instances, keyed by module name.
+ *
+ * @var array
+ */
+ private static array $migrations = array();
+
+ /**
+ * Register a migration class for a module.
+ *
+ * @param string $module Module identifier.
+ * @param TMDO_Migration_Base $migration Migration instance.
+ * @return void
+ */
+ public static function register( string $module, TMDO_Migration_Base $migration ): void {
+ self::$migrations[ $module ] = $migration;
+ }
+
+ /**
+ * Get the migration instance for a module.
+ *
+ * @param string $module Module identifier.
+ * @return TMDO_Migration_Base|null Migration instance, or null if not found.
+ */
+ public static function get_migration( string $module ): ?TMDO_Migration_Base {
+ return self::$migrations[ $module ] ?? null;
+ }
+
+ /**
+ * Get all registered migration instances.
+ *
+ * @return array
+ */
+ public static function all(): array {
+ return self::$migrations;
+ }
+
+ // ── State transitions ─────────────────────────────────────────────────
+
+ /**
+ * Check if a state transition is valid.
+ *
+ * @param string $module Module identifier.
+ * @param string $target_state Target state to transition to.
+ * @return bool True if the transition is allowed.
+ */
+ public static function can_transition( string $module, string $target_state ): bool {
+ $current = TMDO_Feature_Flags::get( $module );
+
+ // Rollback to idle is always allowed.
+ if ( 'idle' === $target_state ) {
+ return true;
+ }
+
+ $allowed = self::TRANSITIONS[ $current ] ?? array();
+ return in_array( $target_state, $allowed, true );
+ }
+
+ /**
+ * Transition a module to a new state.
+ *
+ * @param string $module Module identifier.
+ * @param string $target_state Target state to transition to.
+ * @return bool True on success, false if transition is invalid.
+ */
+ public static function transition( string $module, string $target_state ): bool {
+ if ( ! self::can_transition( $module, $target_state ) ) {
+ return false;
+ }
+
+ return TMDO_Feature_Flags::set( $module, $target_state );
+ }
+
+ // ── High-level operations ─────────────────────────────────────────────
+
+ /**
+ * Start or resume migration for a module.
+ *
+ * Flow: idle → dual_write → backfill (run batches)
+ *
+ * @param string $module Module name.
+ * @param callable|null $progress_callback Called after each batch.
+ * @return array{status: string, message: string}
+ */
+ public static function migrate( string $module, ?callable $progress_callback = null ): array {
+ $migration = self::get_migration( $module );
+ if ( ! $migration ) {
+ return array(
+ 'status' => 'error',
+ 'message' => "No migration registered for module: {$module}",
+ );
+ }
+
+ $current = TMDO_Feature_Flags::get( $module );
+
+ // Already in backfill — resume.
+ if ( 'backfill' === $current ) {
+ $completed = $migration->run( true, $progress_callback );
+ return array(
+ 'status' => $completed ? 'verify' : 'backfill',
+ 'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.',
+ );
+ }
+
+ // Already past backfill.
+ if ( in_array( $current, array( 'verify', 'cutover', 'cleanup', 'complete' ), true ) ) {
+ return array(
+ 'status' => $current,
+ 'message' => "Module is already in state: {$current}.",
+ );
+ }
+
+ // Start fresh: idle or dual_write → backfill.
+ if ( ! self::transition( $module, 'dual_write' ) && 'dual_write' !== $current ) {
+ return array(
+ 'status' => 'error',
+ 'message' => "Cannot start migration from state: {$current}",
+ );
+ }
+
+ $completed = $migration->run( false, $progress_callback );
+
+ return array(
+ 'status' => $completed ? 'verify' : 'backfill',
+ 'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.',
+ );
+ }
+
+ /**
+ * Verify data consistency for a module.
+ *
+ * @param string $module Module identifier.
+ * @return array{status: string, message: string, verified: bool} Verification result.
+ */
+ public static function verify( string $module ): array {
+ $migration = self::get_migration( $module );
+ if ( ! $migration ) {
+ return array(
+ 'status' => 'error',
+ 'message' => "No migration registered for module: {$module}",
+ 'verified' => false,
+ );
+ }
+
+ $current = TMDO_Feature_Flags::get( $module );
+ if ( 'verify' !== $current ) {
+ return array(
+ 'status' => 'error',
+ 'message' => "Module must be in 'verify' state. Current: {$current}",
+ 'verified' => false,
+ );
+ }
+
+ $ok = $migration->verify_counts();
+
+ if ( ! $ok ) {
+ // Verification failed — allow retry via dual_write → backfill.
+ self::transition( $module, 'dual_write' );
+ return array(
+ 'status' => 'dual_write',
+ 'message' => 'Verification failed. Rolled back to dual_write for retry.',
+ 'verified' => false,
+ );
+ }
+
+ return array(
+ 'status' => 'verify',
+ 'message' => 'Verification passed. Ready for cutover.',
+ 'verified' => true,
+ );
+ }
+
+ /**
+ * Cutover: switch reads to the custom table.
+ *
+ * @param string $module Module identifier.
+ * @return array{status: string, message: string} Operation result.
+ */
+ public static function cutover( string $module ): array {
+ $current = TMDO_Feature_Flags::get( $module );
+
+ if ( 'verify' !== $current ) {
+ return array(
+ 'status' => 'error',
+ 'message' => "Module must be in 'verify' state. Current: {$current}",
+ );
+ }
+
+ self::transition( $module, 'cutover' );
+
+ return array(
+ 'status' => 'cutover',
+ 'message' => 'Cutover complete. Reads now come from the custom table. Run cleanup when ready.',
+ );
+ }
+
+ /**
+ * Rollback: return to idle state from any state.
+ *
+ * @param string $module Module identifier.
+ * @return array{status: string, message: string} Operation result.
+ */
+ public static function rollback( string $module ): array {
+ $current = TMDO_Feature_Flags::get( $module );
+
+ if ( 'idle' === $current ) {
+ return array(
+ 'status' => 'idle',
+ 'message' => 'Module is already idle.',
+ );
+ }
+
+ TMDO_Feature_Flags::reset( $module );
+
+ return array(
+ 'status' => 'idle',
+ 'message' => "Module rolled back from '{$current}' to idle.",
+ );
+ }
+
+ /**
+ * Cleanup: stop writing to native postmeta.
+ *
+ * @param string $module Module identifier.
+ * @return array{status: string, message: string} Operation result.
+ */
+ public static function cleanup( string $module ): array {
+ $current = TMDO_Feature_Flags::get( $module );
+
+ if ( 'cutover' !== $current ) {
+ return array(
+ 'status' => 'error',
+ 'message' => "Module must be in 'cutover' state. Current: {$current}",
+ );
+ }
+
+ self::transition( $module, 'cleanup' );
+
+ return array(
+ 'status' => 'cleanup',
+ 'message' => 'Cleanup started. Native postmeta writes are stopped. Run enable to complete.',
+ );
+ }
+
+ /**
+ * Enable: mark migration fully complete.
+ *
+ * @param string $module Module identifier.
+ * @return array{status: string, message: string} Operation result.
+ */
+ public static function enable( string $module ): array {
+ $current = TMDO_Feature_Flags::get( $module );
+
+ if ( 'cleanup' !== $current ) {
+ return array(
+ 'status' => 'error',
+ 'message' => "Module must be in 'cleanup' state. Current: {$current}",
+ );
+ }
+
+ self::transition( $module, 'complete' );
+
+ return array(
+ 'status' => 'complete',
+ 'message' => 'Module fully enabled. All reads and writes use the custom table.',
+ );
+ }
+
+ /**
+ * Get comprehensive status for a module.
+ *
+ * @param string $module Module identifier.
+ * @return array{module: string, state: string, zone: string, record: ?array} Module status.
+ */
+ public static function status( string $module ): array {
+ $migration = self::get_migration( $module );
+ $record = $migration ? $migration->get_record() : null;
+
+ return array(
+ 'module' => $module,
+ 'state' => TMDO_Feature_Flags::get( $module ),
+ 'zone' => $migration ? $migration->get_zone() : '',
+ 'record' => $record,
+ );
+ }
+}
diff --git a/includes/migration/class-tmdo-migration-orchestrator.php b/includes/migration/class-tmdo-migration-orchestrator.php
new file mode 100644
index 0000000..4172f44
--- /dev/null
+++ b/includes/migration/class-tmdo-migration-orchestrator.php
@@ -0,0 +1,1107 @@
+v2 legacy meta orchestration
+/**
+ * One-click User Entity Migration Orchestrator.
+ *
+ * Drives the full demote → backfill → verify → promote → cleanup flow as a
+ * single state machine. Designed for both small (sync, <30s) and large
+ * (async via cron, minutes-to-hours) sites with auto-detection.
+ *
+ * Fast mode: bulk SQL pivot (INSERT...SELECT...GROUP BY) for text-only groups
+ * is 10-50× faster than per-row PHP loops. Per-row safe_unserialize fallback
+ * applies only to groups containing `json` fields (currently only `hp_user`).
+ *
+ * @package WP_Data_Optimizer
+ * @since 2.8.0
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+// phpcs:disable Squiz.Commenting.FunctionComment,Squiz.Commenting.VariableComment,Squiz.Commenting.ClassComment,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.Security.EscapeOutput.ExceptionNotEscaped,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- Internal state-machine helpers and Throwable messages — not user output. SQL composed with sanitize_column_name() + $wpdb->prepare() values.
+
+/**
+ * One-click User Migration Orchestrator — drives the demote → backfill →
+ * verify → promote → cleanup state machine for the user entity.
+ *
+ * @since 2.8.0
+ */
+final class TMDO_Migration_Orchestrator {
+
+ const OPT_JOB = 'wpdo_migration_job';
+ const OPT_LOCK = 'wpdo_migration_lock';
+ const TRANS_PROGRESS = 'wpdo_migration_progress';
+ const TRANS_NEEDS = 'wpdo_migration_needs_attention';
+ const CRON_HOOK = 'wpdo_migration_tick';
+ const BACKUP_DIR_REL = 'wpdo-backups';
+ const MAX_LOG_LINES = 50;
+ const SYNC_THRESHOLD_SEC = 30;
+ const SYNC_DEADLINE_SEC = 110;
+ const VERIFY_SAMPLE_MIN = 500;
+ const VERIFY_SAMPLE_RATIO = 0.10;
+ const LOCK_TTL_SEC = 1800;
+ const NEEDS_TTL_SEC = 300;
+ const ENTITY_TYPE = 'user';
+
+ /**
+ * Phase order. Each phase is idempotent — re-entering returns ok if
+ * the desired state is already achieved.
+ */
+ private const PHASES = array(
+ 'diagnose',
+ 'backup',
+ 'demote',
+ 'install_schema',
+ 'backfill_bulk',
+ 'backfill_unserialize',
+ 'promote_shadow',
+ 'verify_sample',
+ 'promote_aeav',
+ 'cleanup',
+ 'completed',
+ );
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Public API
+ // ─────────────────────────────────────────────────────────────────────
+
+ /**
+ * Read-only inspection — returns what `start()` would do.
+ *
+ * @return array{managed_keys:array,eav_rows:int,users:int,usermeta:int,ratio:float,mode:string,groups:array,estimated_strategy:string,estimated_sec:float}
+ */
+ public static function preflight(): array {
+ global $wpdb;
+
+ $managed_keys = self::get_managed_keys();
+ $eav_rows = self::count_eav_residue( $managed_keys );
+ $users = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" );
+ $usermeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->usermeta}" );
+ $mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
+
+ $groups = array();
+ foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
+ $keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group );
+ $has_json = self::group_has_json_field( $group );
+ $residue = $keys ? self::count_eav_residue( $keys ) : 0;
+ $flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group );
+ $flat_rows = TMDO_Schema_Manager::table_exists( $flat_table )
+ ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" )
+ : 0;
+ $groups[ $group ] = array(
+ 'keys' => $keys,
+ 'residue' => $residue,
+ 'flat_rows' => $flat_rows,
+ 'has_json' => $has_json,
+ 'strategy' => $has_json ? 'row_by_row' : 'bulk_pivot',
+ );
+ }
+
+ // Heuristic: 5ms per bulk row, 80ms per per-row PHP entity.
+ $bulk_rows = 0;
+ $row_rows = 0;
+ foreach ( $groups as $g ) {
+ if ( $g['has_json'] ) {
+ $row_rows += $g['residue'];
+ } else {
+ $bulk_rows += $g['residue'];
+ }
+ }
+ $estimated_sec = ( $bulk_rows * 0.005 ) + ( $row_rows * 0.080 ) + 2.0;
+ $strategy = $estimated_sec <= self::SYNC_THRESHOLD_SEC ? 'sync' : 'async';
+
+ return array(
+ 'managed_keys' => $managed_keys,
+ 'eav_rows' => $eav_rows,
+ 'users' => $users,
+ 'usermeta' => $usermeta,
+ 'ratio' => $users > 0 ? round( $usermeta / $users, 2 ) : 0,
+ 'mode' => $mode,
+ 'groups' => $groups,
+ 'estimated_strategy' => $strategy,
+ 'estimated_sec' => round( $estimated_sec, 1 ),
+ );
+ }
+
+ /**
+ * Cached attention summary for dashboard widget + tab-nav red dot.
+ *
+ * Hits a single SELECT against wp_usermeta + an in-process ratio
+ * calculation; cached for NEEDS_TTL_SEC (5 min) so dashboard widgets
+ * stay snappy. Bust the cache after migrations or mode changes via
+ * `bust_attention_cache()`.
+ *
+ * @return array{needs:bool,eav_rows:int,groups_with_residue:int,ratio:float,mode:string,job_state:string}
+ */
+ public static function needs_attention(): array {
+ $cached = get_transient( self::TRANS_NEEDS );
+ if ( is_array( $cached ) ) {
+ return $cached;
+ }
+
+ global $wpdb;
+ $managed_keys = self::get_managed_keys();
+ $eav_rows = self::count_eav_residue( $managed_keys );
+
+ $groups_with_residue = 0;
+ foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
+ $keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group );
+ if ( $keys && self::count_eav_residue( $keys ) > 0 ) {
+ ++$groups_with_residue;
+ }
+ }
+
+ $users = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" );
+ $usermeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->usermeta}" );
+ $mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
+
+ $job_state = 'idle';
+ $job = get_option( self::OPT_JOB );
+ if ( is_array( $job ) && ! empty( $job['state'] ) ) {
+ $job_state = (string) $job['state'];
+ }
+
+ $result = array(
+ 'needs' => $eav_rows > 0,
+ 'eav_rows' => $eav_rows,
+ 'groups_with_residue' => $groups_with_residue,
+ 'ratio' => $users > 0 ? round( $usermeta / $users, 2 ) : 0,
+ 'mode' => $mode,
+ 'job_state' => $job_state,
+ );
+
+ set_transient( self::TRANS_NEEDS, $result, self::NEEDS_TTL_SEC );
+ return $result;
+ }
+
+ /**
+ * Bust the attention cache — call after migrations, mode changes,
+ * group registrations, etc.
+ */
+ public static function bust_attention_cache(): void {
+ delete_transient( self::TRANS_NEEDS );
+ }
+
+ /**
+ * Begin a migration job. If small, runs to completion in this request.
+ * Otherwise schedules cron-driven ticks and returns immediately.
+ *
+ * @param array{verify_strict?:bool,verify_24h?:bool,auto_backup?:bool,force_async?:bool,dry_run?:bool} $options
+ * @return array{ok:bool,job_id?:string,strategy?:string,error?:string,reason?:string}
+ */
+ public static function start( array $options = array() ): array {
+ if ( ! self::acquire_lock() ) {
+ return array(
+ 'ok' => false,
+ 'error' => __( '另一個 migration job 正在執行;請先取消或等候完成。', '2meet-data-optimizer' ),
+ );
+ }
+
+ $preflight = self::preflight();
+
+ // Idempotency: nothing to do.
+ if ( $preflight['eav_rows'] === 0 && TMDO_Mode_Manager::MODE_AEAV_ONLY === $preflight['mode'] ) {
+ self::release_lock();
+ self::bust_attention_cache();
+ return array(
+ 'ok' => false,
+ 'reason' => 'nothing_to_do',
+ 'error' => sprintf(
+ /* translators: %s: ratio. */
+ __( '所有 entity group 已 aeav_only / 0 EAV 殘留。當前 ratio %s。', '2meet-data-optimizer' ),
+ (string) $preflight['ratio']
+ ),
+ );
+ }
+
+ $options = wp_parse_args(
+ $options,
+ array(
+ 'verify_strict' => true,
+ 'verify_24h' => false,
+ 'auto_backup' => true,
+ 'force_async' => false,
+ 'dry_run' => false,
+ )
+ );
+
+ $job = array(
+ 'job_id' => 'mig_' . wp_generate_password( 12, false ),
+ 'started_at' => time(),
+ 'updated_at' => time(),
+ 'phase' => 'diagnose',
+ 'phase_index' => 0,
+ 'phase_progress' => 0,
+ 'overall_progress' => 0,
+ 'log' => array(),
+ 'options' => $options,
+ 'metrics' => array(
+ 'mode_start' => $preflight['mode'],
+ 'ratio_start' => $preflight['ratio'],
+ 'ratio_now' => $preflight['ratio'],
+ 'eav_rows_start' => $preflight['eav_rows'],
+ 'eav_rows_now' => $preflight['eav_rows'],
+ 'users' => $preflight['users'],
+ ),
+ 'strategy' => $options['force_async'] ? 'async' : $preflight['estimated_strategy'],
+ 'state' => 'running',
+ 'errors' => array(),
+ 'backup_path' => '',
+ );
+
+ self::log(
+ $job,
+ sprintf(
+ 'Job started — strategy=%s, residue=%d rows across %d groups, mode=%s',
+ $job['strategy'],
+ $preflight['eav_rows'],
+ count( array_filter( $preflight['groups'], fn( $g ) => $g['residue'] > 0 ) ),
+ $preflight['mode']
+ )
+ );
+
+ self::persist_job( $job );
+
+ if ( 'sync' === $job['strategy'] ) {
+ self::run_sync_loop( $job );
+ } else {
+ self::schedule_next_tick();
+ }
+
+ return array(
+ 'ok' => true,
+ 'job_id' => $job['job_id'],
+ 'strategy' => $job['strategy'],
+ );
+ }
+
+ /**
+ * Advance one phase. Used by cron and by sync inline loop.
+ *
+ * @return array{done:bool,phase:string,error?:string}
+ * @throws \RuntimeException When a phase callback throws or returns non-ok status; caught internally and converted to an `error` array entry.
+ */
+ public static function tick(): array {
+ $job = get_option( self::OPT_JOB );
+ if ( ! is_array( $job ) || empty( $job['job_id'] ) ) {
+ return array(
+ 'done' => true,
+ 'phase' => 'idle',
+ 'error' => 'No active job',
+ );
+ }
+ if ( 'running' !== ( $job['state'] ?? '' ) ) {
+ return array(
+ 'done' => true,
+ 'phase' => $job['phase'] ?? 'unknown',
+ );
+ }
+
+ $current = $job['phase'];
+ $method = 'phase_' . $current;
+
+ try {
+ if ( ! method_exists( __CLASS__, $method ) ) {
+ throw new \RuntimeException( "Unknown phase: {$current}" );
+ }
+
+ $result = self::{$method}( $job );
+
+ if ( 'in_progress' === ( $result['status'] ?? '' ) ) {
+ self::persist_job( $job );
+ if ( 'async' === $job['strategy'] ) {
+ self::schedule_next_tick();
+ }
+ return array(
+ 'done' => false,
+ 'phase' => $current,
+ );
+ }
+
+ if ( 'ok' !== ( $result['status'] ?? '' ) ) {
+ throw new \RuntimeException( $result['message'] ?? 'Phase returned non-ok status' );
+ }
+
+ $next = self::next_phase( $current );
+ self::log( $job, sprintf( '✓ %s (%.2fs)', $current, microtime( true ) - ( $result['_started_at'] ?? microtime( true ) ) ) );
+ $job['phase'] = $next;
+ $job['phase_index'] = array_search( $next, self::PHASES, true );
+ $job['phase_progress'] = 0;
+ $job['overall_progress'] = (int) round( ( $job['phase_index'] / ( count( self::PHASES ) - 1 ) ) * 100 );
+ $job['updated_at'] = time();
+
+ if ( 'completed' === $next ) {
+ $job['state'] = 'completed';
+ $job['completed_at'] = time();
+ $job['overall_progress'] = 100;
+ self::log(
+ $job,
+ sprintf(
+ '✅ Migration complete — ratio %s → %s (-%.0f%%)',
+ $job['metrics']['ratio_start'],
+ $job['metrics']['ratio_now'],
+ ( $job['metrics']['ratio_start'] - $job['metrics']['ratio_now'] ) / max( $job['metrics']['ratio_start'], 0.01 ) * 100
+ )
+ );
+ self::persist_job( $job );
+ self::release_lock();
+ self::bust_attention_cache();
+ return array(
+ 'done' => true,
+ 'phase' => 'completed',
+ );
+ }
+
+ self::persist_job( $job );
+
+ if ( 'async' === $job['strategy'] ) {
+ self::schedule_next_tick();
+ }
+
+ return array(
+ 'done' => false,
+ 'phase' => $next,
+ );
+
+ } catch ( \Throwable $e ) {
+ self::handle_failure( $job, $e );
+ return array(
+ 'done' => true,
+ 'phase' => 'failed',
+ 'error' => $e->getMessage(),
+ );
+ }
+ }
+
+ /**
+ * Inspection — read-only snapshot for the polling UI.
+ */
+ public static function get_status(): array {
+ $cached = get_transient( self::TRANS_PROGRESS );
+ if ( is_array( $cached ) ) {
+ return $cached;
+ }
+ $job = get_option( self::OPT_JOB );
+ if ( ! is_array( $job ) ) {
+ return array( 'state' => 'idle' );
+ }
+ return self::project_status( $job );
+ }
+
+ public static function cancel(): bool {
+ $job = get_option( self::OPT_JOB );
+ if ( ! is_array( $job ) || 'running' !== ( $job['state'] ?? '' ) ) {
+ return false;
+ }
+ // Auto-rollback to safe state (dual_write) before clearing.
+ try {
+ $current_mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
+ if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === $current_mode ) {
+ TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ );
+ TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE );
+ } elseif ( TMDO_Mode_Manager::MODE_SHADOW_READ === $current_mode ) {
+ TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE );
+ }
+ } catch ( \Throwable $e ) {
+ // Logged but non-fatal.
+ TMDO_Logger::warning( 'migration_cancel_rollback_failed', array( 'error' => $e->getMessage() ) );
+ }
+
+ $job['state'] = 'cancelled';
+ $job['cancelled_at'] = time();
+ self::log( $job, '⚠ Cancelled by operator — rolled back to dual_write.' );
+ self::persist_job( $job );
+ self::release_lock();
+ self::bust_attention_cache();
+ wp_clear_scheduled_hook( self::CRON_HOOK );
+ return true;
+ }
+
+ public static function resume(): array {
+ $job = get_option( self::OPT_JOB );
+ if ( ! is_array( $job ) ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'No job to resume',
+ );
+ }
+ if ( ! in_array( $job['state'] ?? '', array( 'failed', 'paused' ), true ) ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'Job is not in a resumable state',
+ );
+ }
+ $job['state'] = 'running';
+ $job['updated_at'] = time();
+ self::log( $job, '↻ Resumed by operator.' );
+ self::persist_job( $job );
+ self::acquire_lock();
+
+ if ( 'sync' === $job['strategy'] ) {
+ self::run_sync_loop( $job );
+ } else {
+ self::schedule_next_tick();
+ }
+ return array( 'ok' => true );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Sync inline loop — for sites where total work fits within
+ // SYNC_DEADLINE_SEC. Heartbeats progress via transient on every phase.
+ // ─────────────────────────────────────────────────────────────────────
+
+ private static function run_sync_loop( array $job ): void {
+ set_time_limit( self::SYNC_DEADLINE_SEC + 10 );
+ $deadline = microtime( true ) + self::SYNC_DEADLINE_SEC;
+
+ while ( microtime( true ) < $deadline ) {
+ $result = self::tick();
+ if ( $result['done'] ) {
+ return;
+ }
+ // Tiny pause to let DB breathe and avoid 100% CPU pegs.
+ usleep( 5000 );
+ $job = get_option( self::OPT_JOB );
+ if ( ! is_array( $job ) || 'running' !== ( $job['state'] ?? '' ) ) {
+ return;
+ }
+ }
+
+ // Deadline reached but not done — convert to async.
+ $job = get_option( self::OPT_JOB );
+ $job['strategy'] = 'async';
+ self::log( $job, '⏱ Sync deadline reached — switching to async (cron-driven) for remaining phases.' );
+ self::persist_job( $job );
+ self::schedule_next_tick();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Phases — each returns ['status' => 'ok'|'in_progress'|'error', 'message' => string]
+ // ─────────────────────────────────────────────────────────────────────
+
+ private static function phase_diagnose( array &$job ): array {
+ $preflight = self::preflight();
+ $job['metrics']['eav_rows_now'] = $preflight['eav_rows'];
+ $job['metrics']['ratio_now'] = $preflight['ratio'];
+
+ self::log(
+ $job,
+ sprintf(
+ 'Diagnose: %d EAV residue rows, %d users, ratio %s, mode %s',
+ $preflight['eav_rows'],
+ $preflight['users'],
+ (string) $preflight['ratio'],
+ $preflight['mode']
+ )
+ );
+
+ return array(
+ 'status' => 'ok',
+ 'message' => 'Diagnose complete',
+ );
+ }
+
+ private static function phase_backup( array &$job ): array {
+ if ( empty( $job['options']['auto_backup'] ) ) {
+ self::log( $job, '↪ Backup skipped (auto_backup=false)' );
+ return array( 'status' => 'ok' );
+ }
+
+ $upload_dir = wp_upload_dir();
+ $backup_dir = trailingslashit( $upload_dir['basedir'] ) . self::BACKUP_DIR_REL;
+
+ if ( ! wp_mkdir_p( $backup_dir ) ) {
+ throw new \RuntimeException( "Cannot create backup directory: {$backup_dir}" );
+ }
+
+ // HTTP-level deny for Apache/IIS. Note: nginx silently ignores .htaccess —
+ // operators on nginx must add a `location ~ /wp-content/uploads/wpdo-backups/
+ // { deny all; }` block (logged in admin notice).
+ $htaccess = $backup_dir . '/.htaccess';
+ if ( ! file_exists( $htaccess ) ) {
+ file_put_contents( $htaccess, "Require all denied\n" );
+ @chmod( $htaccess, 0644 );
+ }
+ $webconfig = $backup_dir . '/web.config';
+ if ( ! file_exists( $webconfig ) ) {
+ file_put_contents(
+ $webconfig,
+ "\n\n"
+ );
+ @chmod( $webconfig, 0644 );
+ }
+ $index = $backup_dir . '/index.php';
+ if ( ! file_exists( $index ) ) {
+ file_put_contents( $index, "get_results(
+ "SELECT umeta_id, user_id, meta_key, meta_value FROM {$wpdb->usermeta} ORDER BY umeta_id ASC",
+ ARRAY_A
+ );
+
+ $fp = fopen( $backup_path, 'wb' );
+ if ( ! $fp ) {
+ throw new \RuntimeException( "Cannot open backup file for writing: {$backup_path}" );
+ }
+ // Owner-only read/write — backup contains user PII (emails, billing
+ // addresses, OAuth tokens, session blobs). Default umask leaks to
+ // other system users on shared hosts.
+ @chmod( $backup_path, 0600 );
+
+ fwrite( $fp, "-- WPDO migration backup of {$wpdb->usermeta}\n" );
+ fwrite( $fp, '-- Job: ' . $job['job_id'] . "\n" );
+ fwrite( $fp, '-- Generated: ' . gmdate( 'c' ) . "\n" );
+ fwrite( $fp, "-- Restore: mysql ... < this_file.sql\n\n" );
+ fwrite( $fp, "/*!40101 SET NAMES utf8mb4 */;\n" );
+ fwrite( $fp, "/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n" );
+ fwrite( $fp, "SET FOREIGN_KEY_CHECKS=0;\n" );
+ fwrite( $fp, "LOCK TABLES `{$wpdb->usermeta}` WRITE;\n" );
+
+ $batch = array();
+ $count = 0;
+ foreach ( $rows as $row ) {
+ // UNHEX-encode meta_value: avoids ALL escape edge cases (binary,
+ // embedded NULs, non-UTF8, sql_mode mismatches at restore time).
+ // meta_key is %-bound through esc_sql which is sufficient for an
+ // ASCII-only key universe.
+ $batch[] = sprintf(
+ "(%d,%d,'%s',UNHEX('%s'))",
+ (int) $row['umeta_id'],
+ (int) $row['user_id'],
+ esc_sql( (string) $row['meta_key'] ),
+ bin2hex( (string) ( $row['meta_value'] ?? '' ) )
+ );
+ ++$count;
+ if ( count( $batch ) >= 500 ) {
+ fwrite( $fp, "INSERT INTO `{$wpdb->usermeta}` (umeta_id,user_id,meta_key,meta_value) VALUES\n" . implode( ",\n", $batch ) . ";\n" );
+ $batch = array();
+ }
+ }
+ if ( $batch ) {
+ fwrite( $fp, "INSERT INTO `{$wpdb->usermeta}` (umeta_id,user_id,meta_key,meta_value) VALUES\n" . implode( ",\n", $batch ) . ";\n" );
+ }
+ fwrite( $fp, "UNLOCK TABLES;\n" );
+ fwrite( $fp, "SET FOREIGN_KEY_CHECKS=1;\n" );
+ fwrite( $fp, "/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;\n" );
+ fclose( $fp );
+
+ $job['backup_path'] = $backup_path;
+ self::log(
+ $job,
+ sprintf(
+ 'Backup written: %s (%d rows, %s, chmod 0600)',
+ $filename,
+ $count,
+ size_format( filesize( $backup_path ) )
+ )
+ );
+
+ // Best-effort web-server detection — log nginx warning so operator
+ // can add the manual location block (htaccess/web.config don't apply).
+ $server = isset( $_SERVER['SERVER_SOFTWARE'] ) ? strtolower( sanitize_text_field( wp_unslash( (string) $_SERVER['SERVER_SOFTWARE'] ) ) ) : '';
+ if ( str_contains( $server, 'nginx' ) ) {
+ self::log( $job, '⚠ nginx detected: add `location ~ /wp-content/uploads/wpdo-backups/ { deny all; }` to your nginx config — .htaccess does not apply.' );
+ }
+
+ return array( 'status' => 'ok' );
+ }
+
+ private static function phase_demote( array &$job ): array {
+ $current = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
+ if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $current ) {
+ self::log( $job, "↪ Demote skipped (mode is {$current}, not aeav_only)" );
+ return array( 'status' => 'ok' );
+ }
+
+ // Two-step demotion: aeav_only → shadow_read → dual_write.
+ // (Safe transition: demotion always allowed.)
+ $r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ );
+ if ( is_wp_error( $r ) ) {
+ throw new \RuntimeException( 'Demote step 1 failed: ' . $r->get_error_message() );
+ }
+ $r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE );
+ if ( is_wp_error( $r ) ) {
+ throw new \RuntimeException( 'Demote step 2 failed: ' . $r->get_error_message() );
+ }
+
+ self::log( $job, 'Mode: aeav_only → dual_write (reads now go to EAV)' );
+ return array( 'status' => 'ok' );
+ }
+
+ private static function phase_install_schema( array &$job ): array {
+ do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
+ TMDO_Schema_Manager::process_pending_migrations();
+
+ // Verify all 9 expected tables exist.
+ $missing = array();
+ foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
+ $table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group );
+ if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
+ $missing[] = $group;
+ }
+ }
+ if ( $missing ) {
+ throw new \RuntimeException( 'Schema migration failed; missing tables: ' . implode( ',', $missing ) );
+ }
+
+ $count = count( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) );
+ self::log( $job, "Schema migration ok ({$count} flat tables verified)" );
+ return array( 'status' => 'ok' );
+ }
+
+ private static function phase_backfill_bulk( array &$job ): array {
+ if ( ! empty( $job['options']['dry_run'] ) ) {
+ self::log( $job, '↪ Backfill bulk skipped (dry_run)' );
+ return array( 'status' => 'ok' );
+ }
+
+ $total_groups = 0;
+ $total_rows = 0;
+ foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
+ if ( self::group_has_json_field( $group ) ) {
+ continue; // handled in phase_backfill_unserialize
+ }
+ $rows = self::execute_bulk_pivot( $group );
+ $total_rows += $rows;
+ ++$total_groups;
+ self::log( $job, sprintf( ' • bulk pivot %s: %d row(s)', $group, $rows ) );
+ }
+ self::log( $job, sprintf( 'Bulk backfill: %d groups, %d rows total', $total_groups, $total_rows ) );
+
+ // Update live ratio.
+ $preflight = self::preflight();
+ $job['metrics']['eav_rows_now'] = $preflight['eav_rows'];
+ $job['metrics']['ratio_now'] = $preflight['ratio'];
+
+ return array( 'status' => 'ok' );
+ }
+
+ private static function phase_backfill_unserialize( array &$job ): array {
+ if ( ! empty( $job['options']['dry_run'] ) ) {
+ self::log( $job, '↪ Backfill unserialize skipped (dry_run)' );
+ return array( 'status' => 'ok' );
+ }
+
+ $total_rows = 0;
+ foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
+ if ( ! self::group_has_json_field( $group ) ) {
+ continue;
+ }
+ $result = TMDO_Entity_Migration_Engine::migrate_group(
+ self::ENTITY_TYPE,
+ $group,
+ array( 'sleep_ms' => 0 )
+ );
+ if ( ! empty( $result['error'] ) ) {
+ throw new \RuntimeException( "Row-by-row backfill {$group} failed: " . $result['error'] );
+ }
+ if ( ( $result['errors'] ?? 0 ) > 0 && 0 === ( $result['migrated'] ?? 0 ) ) {
+ throw new \RuntimeException( "All rows failed during {$group} backfill — see error_log" );
+ }
+ $total_rows += (int) ( $result['migrated'] ?? 0 );
+ self::log( $job, sprintf( ' • row-by-row %s: %d row(s)', $group, $result['migrated'] ?? 0 ) );
+ }
+ self::log( $job, sprintf( 'Row-by-row backfill: %d rows total', $total_rows ) );
+ return array( 'status' => 'ok' );
+ }
+
+ private static function phase_promote_shadow( array &$job ): array {
+ $current = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
+ if ( TMDO_Mode_Manager::MODE_DUAL_WRITE === $current ) {
+ $r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ );
+ if ( is_wp_error( $r ) ) {
+ throw new \RuntimeException( 'Promote to shadow_read failed: ' . $r->get_error_message() );
+ }
+ self::log( $job, 'Mode: dual_write → shadow_read (verifying flat reads)' );
+ } else {
+ self::log( $job, "↪ Already at {$current}; skip promote_shadow" );
+ }
+ return array( 'status' => 'ok' );
+ }
+
+ private static function phase_verify_sample( array &$job ): array {
+ global $wpdb;
+
+ if ( ! empty( $job['options']['verify_24h'] ) ) {
+ // Operator opted into the 24-hour shadow-read window — pause here.
+ $elapsed = time() - ( $job['phase_started_at'] ?? time() );
+ if ( ! isset( $job['phase_started_at'] ) ) {
+ $job['phase_started_at'] = time();
+ self::log( $job, '⏸ 24h shadow_read window started — wizard will resume after window elapses.' );
+ return array( 'status' => 'in_progress' );
+ }
+ if ( $elapsed < DAY_IN_SECONDS ) {
+ return array( 'status' => 'in_progress' );
+ }
+ self::log( $job, '⏳ 24h shadow_read window complete; running sample compare' );
+ }
+
+ $strict = ! empty( $job['options']['verify_strict'] );
+ $users_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" );
+ $sample_size = $strict
+ ? (int) max( self::VERIFY_SAMPLE_MIN, ceil( $users_total * self::VERIFY_SAMPLE_RATIO ) )
+ : 100;
+ $sample_size = min( $sample_size, $users_total );
+ $sample_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->users} ORDER BY RAND() LIMIT %d",
+ $sample_size
+ )
+ );
+
+ $diffs = 0;
+ $compared = 0;
+ $managed_keys = self::get_managed_keys();
+
+ foreach ( $sample_ids as $uid ) {
+ foreach ( $managed_keys as $key ) {
+ $eav = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s LIMIT 1",
+ $uid,
+ $key
+ )
+ );
+
+ // Skip: no EAV source-of-truth to verify against (key already cleaned
+ // or never existed). The wizard's invariant only flags as DIFF the
+ // case where EAV has data but flat doesn't match it — that is the
+ // only real "backfill error".
+ if ( null === $eav || '' === $eav ) {
+ continue;
+ }
+
+ ++$compared;
+ $flat = TMDO_Hook_Bus::direct_read( self::ENTITY_TYPE, (int) $uid, $key );
+
+ if ( ! self::values_loose_equal( $flat, $eav ) ) {
+ ++$diffs;
+ if ( $diffs <= 3 ) {
+ self::log(
+ $job,
+ sprintf(
+ ' ! diff uid=%d key=%s flat=%s eav=%s',
+ $uid,
+ $key,
+ is_scalar( $flat ) ? (string) $flat : gettype( $flat ),
+ is_scalar( $eav ) ? (string) $eav : gettype( $eav )
+ )
+ );
+ }
+ }
+ }
+ }
+
+ self::log(
+ $job,
+ sprintf(
+ 'Verify: sampled %d users × %d keys = %d compares, %d diff(s)',
+ count( $sample_ids ),
+ count( $managed_keys ),
+ $compared,
+ $diffs
+ )
+ );
+
+ if ( $diffs > 0 ) {
+ throw new \RuntimeException(
+ sprintf(
+ 'Verification found %d divergence(s) across %d compares — aborting before destructive cleanup.',
+ $diffs,
+ $compared
+ )
+ );
+ }
+
+ return array( 'status' => 'ok' );
+ }
+
+ private static function phase_promote_aeav( array &$job ): array {
+ $current = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
+ if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === $current ) {
+ self::log( $job, '↪ Already aeav_only' );
+ return array( 'status' => 'ok' );
+ }
+ $r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_AEAV_ONLY );
+ if ( is_wp_error( $r ) ) {
+ throw new \RuntimeException( 'Promote to aeav_only failed: ' . $r->get_error_message() );
+ }
+ self::log( $job, 'Mode: shadow_read → aeav_only (cutover complete)' );
+ return array( 'status' => 'ok' );
+ }
+
+ private static function phase_cleanup( array &$job ): array {
+ if ( ! empty( $job['options']['dry_run'] ) ) {
+ self::log( $job, '↪ Cleanup skipped (dry_run)' );
+ return array( 'status' => 'ok' );
+ }
+
+ // Hard guard: must be in aeav_only AND backup must exist (if requested).
+ $mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
+ if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
+ throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" );
+ }
+ if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) {
+ throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' );
+ }
+
+ global $wpdb;
+ $keys = self::get_managed_keys();
+ if ( empty( $keys ) ) {
+ self::log( $job, '↪ No managed keys to clean' );
+ return array( 'status' => 'ok' );
+ }
+
+ $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
+ $deleted = (int) $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->usermeta} WHERE meta_key IN ({$placeholders})",
+ ...$keys
+ )
+ );
+
+ // Refresh metrics.
+ $preflight = self::preflight();
+ $job['metrics']['eav_rows_now'] = $preflight['eav_rows'];
+ $job['metrics']['ratio_now'] = $preflight['ratio'];
+
+ self::log( $job, sprintf( 'Cleanup: deleted %d EAV row(s); ratio now %s', $deleted, (string) $preflight['ratio'] ) );
+ return array( 'status' => 'ok' );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────
+
+ /** Returns all meta_keys registered as entity fields for `user`. */
+ private static function get_managed_keys(): array {
+ $keys = array();
+ foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
+ $keys = array_merge( $keys, TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ) );
+ }
+ return array_values( array_unique( $keys ) );
+ }
+
+ private static function group_has_json_field( string $group ): bool {
+ foreach ( TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group ) as $field ) {
+ if ( 'json' === ( $field['type'] ?? '' ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static function count_eav_residue( array $keys = array() ): int {
+ global $wpdb;
+ if ( empty( $keys ) ) {
+ $keys = self::get_managed_keys();
+ }
+ if ( empty( $keys ) ) {
+ return 0;
+ }
+ $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key IN ({$placeholders})",
+ ...$keys
+ )
+ );
+ }
+
+ /**
+ * Single-statement bulk pivot for a text-only group.
+ *
+ * @param string $group Entity group name.
+ * @return int Affected rows.
+ * @throws \RuntimeException If $wpdb->query() fails.
+ */
+ private static function execute_bulk_pivot( string $group ): int {
+ global $wpdb;
+
+ $fields = TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group );
+ if ( empty( $fields ) ) {
+ return 0;
+ }
+
+ $adapter = TMDO_Entity_Registry::get_adapter( self::ENTITY_TYPE );
+ $id_col = $adapter->get_entity_id_column();
+ $table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group );
+
+ $select_cases = array();
+ $update_cols = array();
+ $col_names = array();
+ $keys = array();
+ foreach ( $fields as $f ) {
+ $key = $f['key'];
+ $col = TMDO_Schema_Manager::sanitize_column_name( $key );
+ $keys[] = $key;
+ $col_names[] = "`{$col}`";
+ // MAX(CASE WHEN meta_key='...' THEN meta_value END)
+ $select_cases[] = $wpdb->prepare(
+ "MAX(CASE WHEN um.meta_key = %s THEN um.meta_value END) AS `{$col}`",
+ $key
+ );
+ $update_cols[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
+ }
+
+ $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
+
+ $sql = sprintf(
+ 'INSERT INTO `%s` (`%s`, %s)
+ SELECT um.user_id, %s
+ FROM `%s` um
+ WHERE um.meta_key IN (%s)
+ GROUP BY um.user_id
+ ON DUPLICATE KEY UPDATE %s',
+ $table,
+ $id_col,
+ implode( ', ', $col_names ),
+ implode( ', ', $select_cases ),
+ $wpdb->usermeta,
+ $placeholders,
+ implode( ', ', $update_cols )
+ );
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- pivot SQL composed from registry-validated identifiers + prepared values inside CASE/IN.
+ $result = $wpdb->query( $wpdb->prepare( $sql, ...$keys ) );
+
+ if ( false === $result ) {
+ throw new \RuntimeException( "Bulk pivot failed for group {$group}: " . $wpdb->last_error );
+ }
+ return (int) $result;
+ }
+
+ private static function values_loose_equal( $a, $b ): bool {
+ if ( null === $a && null === $b ) {
+ return true;
+ }
+ if ( null === $a || null === $b ) {
+ $other = null === $a ? $b : $a;
+ return '' === $other || array() === $other || 0 === $other || '0' === $other;
+ }
+ if ( is_array( $a ) || is_array( $b ) ) {
+ return wp_json_encode( $a ) === wp_json_encode( $b );
+ }
+ return (string) $a === (string) $b;
+ }
+
+ private static function next_phase( string $current ): string {
+ $idx = array_search( $current, self::PHASES, true );
+ if ( false === $idx || $idx + 1 >= count( self::PHASES ) ) {
+ return 'completed';
+ }
+ return self::PHASES[ $idx + 1 ];
+ }
+
+ private static function log( array &$job, string $message ): void {
+ $line = '[' . gmdate( 'H:i:s' ) . '] ' . $message;
+ $job['log'][] = $line;
+ if ( count( $job['log'] ) > self::MAX_LOG_LINES ) {
+ $job['log'] = array_slice( $job['log'], -self::MAX_LOG_LINES );
+ }
+ $job['updated_at'] = time();
+ }
+
+ private static function persist_job( array $job ): void {
+ update_option( self::OPT_JOB, $job, false );
+ set_transient( self::TRANS_PROGRESS, self::project_status( $job ), 60 );
+ }
+
+ /** Public-safe projection of job state for the polling UI. */
+ private static function project_status( array $job ): array {
+ return array(
+ 'job_id' => $job['job_id'] ?? '',
+ 'state' => $job['state'] ?? 'idle',
+ 'phase' => $job['phase'] ?? 'idle',
+ 'phase_index' => $job['phase_index'] ?? 0,
+ 'phase_total' => count( self::PHASES ) - 1,
+ 'overall_progress' => $job['overall_progress'] ?? 0,
+ 'log' => $job['log'] ?? array(),
+ 'metrics' => $job['metrics'] ?? array(),
+ 'strategy' => $job['strategy'] ?? 'sync',
+ 'started_at' => $job['started_at'] ?? 0,
+ 'updated_at' => $job['updated_at'] ?? 0,
+ 'completed_at' => $job['completed_at'] ?? null,
+ 'cancelled_at' => $job['cancelled_at'] ?? null,
+ 'errors' => $job['errors'] ?? array(),
+ 'backup_path' => isset( $job['backup_path'] ) ? basename( $job['backup_path'] ) : '',
+ );
+ }
+
+ private static function acquire_lock(): bool {
+ if ( false === add_option( self::OPT_LOCK, time(), '', false ) ) {
+ $existing = (int) get_option( self::OPT_LOCK, 0 );
+ if ( $existing > 0 && time() - $existing > self::LOCK_TTL_SEC ) {
+ delete_option( self::OPT_LOCK );
+ return add_option( self::OPT_LOCK, time(), '', false );
+ }
+ return false;
+ }
+ return true;
+ }
+
+ private static function release_lock(): void {
+ delete_option( self::OPT_LOCK );
+ }
+
+ private static function schedule_next_tick(): void {
+ if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
+ wp_schedule_single_event( time() + 1, self::CRON_HOOK );
+ }
+ }
+
+ private static function handle_failure( array &$job, \Throwable $e ): void {
+ $job['state'] = 'failed';
+ $job['errors'][] = array(
+ 'phase' => $job['phase'] ?? 'unknown',
+ 'message' => $e->getMessage(),
+ 'at' => time(),
+ );
+ self::log( $job, sprintf( '✗ %s failed: %s', $job['phase'] ?? '?', $e->getMessage() ) );
+
+ // Auto-rollback: only if mode is currently aeav_only AND we haven't reached cleanup yet.
+ try {
+ $current = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
+ $phase = $job['phase'] ?? '';
+ $pre_cleanup = ! in_array( $phase, array( 'cleanup', 'completed' ), true );
+ if ( $pre_cleanup && TMDO_Mode_Manager::MODE_AEAV_ONLY === $current ) {
+ TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ );
+ TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE );
+ self::log( $job, '↩ Auto-rolled back to dual_write (reads safe)' );
+ }
+ } catch ( \Throwable $inner ) {
+ TMDO_Logger::warning( 'migration_rollback_failed', array( 'error' => $inner->getMessage() ) );
+ }
+
+ TMDO_Logger::warning(
+ 'migration_phase_failed',
+ array(
+ 'phase' => $job['phase'] ?? '?',
+ 'job' => $job['job_id'] ?? '?',
+ 'error' => $e->getMessage(),
+ )
+ );
+
+ self::persist_job( $job );
+ self::release_lock();
+ self::bust_attention_cache();
+ }
+
+ /**
+ * Cron callback — wires CRON_HOOK to tick().
+ */
+ public static function cron_tick(): void {
+ self::tick();
+ }
+}
diff --git a/includes/migration/class-tmdo-post-migration.php b/includes/migration/class-tmdo-post-migration.php
new file mode 100644
index 0000000..a957de3
--- /dev/null
+++ b/includes/migration/class-tmdo-post-migration.php
@@ -0,0 +1,628 @@
+v2 legacy post meta migration
+/**
+ * TMDO_Post_Migration — Post entity migration core (v2.9.3).
+ *
+ * Independent of the user-side TMDO_Migration_Orchestrator. The user
+ * orchestrator is intentionally frozen (1105 lines, hardcoded ENTITY_TYPE='user'
+ * via `self::ENTITY_TYPE` const) — this class implements the equivalent
+ * post-side flow without touching any user code path.
+ *
+ * Phase coverage (compared to user 10-phase orchestrator):
+ * diagnose ✓ implemented
+ * backup ✗ skipped — v2.9.0 postmeta-cleanup CLI handles garbage;
+ * full wp_postmeta backup deferred to v2.9.4 (admin tab
+ * + DB hook) since wp_postmeta tends to be very large
+ * (29k+ rows on dev10) and requires streaming approach
+ * demote ✗ not applicable — post mode starts at 'disabled', no
+ * aeav_only state to demote from
+ * install_schema ✗ already done in v2.9.1 by Schema_Manager auto-create
+ * backfill_bulk ✓ implemented (per-group, by post_type filter)
+ * backfill_unserialize ✗ deferred to v2.9.4 (json groups: attachment +
+ * nav_menu_item have only ~200 rows on dev10)
+ * promote_shadow ✓ implemented (set_mode dual_write → shadow_read)
+ * verify_sample ✓ implemented (sample-and-compare)
+ * promote_aeav ✓ implemented (set_mode → aeav_only)
+ * cleanup ✓ implemented (DELETE managed wp_postmeta keys)
+ *
+ * 🔒 v2.9.x frozen contract: this class must NEVER touch wp_usermeta,
+ * wp_users, or any wp_wpdo_user_* table. All operations target wp_posts /
+ * wp_postmeta / wp_wpdo_post_*.
+ *
+ * @package WP_Data_Optimizer
+ * @since 2.9.3
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Internal migration: $flat_table goes through Schema_Manager::sanitize_column_name + TMDO_Entity_Registry; $columns_sql/$cases_sql/$update_sql composed from the same sanitized sources; user-controlled values use prepare() placeholders. Multi-statement IN clauses with array_fill('%s') trigger false positives.
+
+/**
+ * Post entity migration core. Static API mirrors TMDO_Migration_Orchestrator
+ * for predictability, but each method is post-only.
+ */
+final class TMDO_Post_Migration {
+
+ private const ENTITY_TYPE = 'post';
+
+ /**
+ * Read-only inspection — what's the current post EAV state?
+ *
+ * @return array{
+ * posts:int,
+ * postmeta:int,
+ * ratio:float,
+ * mode:string,
+ * groups:array
+ * }
+ */
+ public static function diagnose(): array {
+ global $wpdb;
+
+ $posts_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts}" );
+ $postmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta}" );
+ $ratio = $posts_count > 0 ? round( $postmeta_count / $posts_count, 2 ) : 0.0;
+
+ $groups = array();
+ foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
+ $keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group );
+ $post_type = self::group_post_type( $group );
+ $eav_rows = $keys ? self::count_eav_residue( $keys, $post_type ) : 0;
+
+ $flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group );
+ $flat_rows = TMDO_Schema_Manager::table_exists( $flat_table )
+ ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ : 0;
+
+ $groups[ $group ] = array(
+ 'keys' => $keys,
+ 'eav_rows' => $eav_rows,
+ 'flat_rows' => $flat_rows,
+ 'post_type' => $post_type,
+ );
+ }
+
+ return array(
+ 'posts' => $posts_count,
+ 'postmeta' => $postmeta_count,
+ 'ratio' => $ratio,
+ 'mode' => TMDO_Mode_Manager::get( self::ENTITY_TYPE ),
+ 'groups' => $groups,
+ );
+ }
+
+ /**
+ * Bulk SQL pivot for one group: select managed keys from wp_postmeta,
+ * filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT
+ * into the group's flat table.
+ *
+ * @param string $group_name Entity group name (e.g. 'wc_product').
+ * @return array{migrated:int,group:string,post_type:string|null}
+ * @throws InvalidArgumentException When group is not registered.
+ * @throws RuntimeException When the pivot SQL fails.
+ */
+ public static function backfill_group( string $group_name ): array {
+ global $wpdb;
+
+ $fields = TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group_name );
+ if ( empty( $fields ) ) {
+ throw new InvalidArgumentException(
+ 'Unknown post entity group: ' . esc_html( $group_name )
+ );
+ }
+
+ $post_type = self::group_post_type( $group_name );
+ $flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name );
+
+ // Introspect target table columns so we only pivot fields that actually
+ // exist in the flat schema. Defends against partial schema environments
+ // (e.g. v2.9.3 deployed before Schema_Manager auto-create has run, or
+ // custom installs that intentionally pruned columns).
+ $existing_cols = self::get_existing_columns( $flat_table );
+ if ( empty( $existing_cols ) ) {
+ throw new RuntimeException(
+ 'Flat table missing or has no columns: ' . esc_html( $flat_table )
+ );
+ }
+
+ // Build column list and CASE expressions.
+ // Skip json/textarea types from the bulk pivot — those need row-by-row
+ // unserialize handling (deferred to v2.9.4 backfill_unserialize phase).
+ $columns = array();
+ $cases = array();
+ $update_parts = array();
+ foreach ( $fields as $field ) {
+ $type = $field['type'] ?? 'text';
+ if ( 'json' === $type ) {
+ continue;
+ }
+ $col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
+ if ( ! isset( $existing_cols[ $col ] ) ) {
+ continue; // Column not present in this table — skip silently.
+ }
+ $key = esc_sql( (string) $field['key'] );
+ $columns[] = "`{$col}`";
+ $cases[] = "MAX(CASE WHEN meta_key = '{$key}' THEN meta_value END) AS `{$col}`";
+ $update_parts[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
+ }
+
+ if ( empty( $columns ) ) {
+ return array(
+ 'migrated' => 0,
+ 'group' => $group_name,
+ 'post_type' => $post_type,
+ );
+ }
+
+ $columns_sql = implode( ', ', $columns );
+ $cases_sql = implode( ', ', $cases );
+ $update_sql = implode( ', ', $update_parts );
+ $post_type_filter = $post_type ? $wpdb->prepare( 'AND p.post_type = %s', $post_type ) : '';
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ $rows = $wpdb->query(
+ "INSERT INTO `{$flat_table}` (post_id, {$columns_sql})
+ SELECT pm.post_id, {$cases_sql}
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE 1=1 {$post_type_filter}
+ GROUP BY pm.post_id
+ ON DUPLICATE KEY UPDATE {$update_sql}"
+ );
+
+ if ( false === $rows ) {
+ throw new RuntimeException(
+ 'backfill_group SQL failed: ' . esc_html( (string) $wpdb->last_error )
+ );
+ }
+
+ // MySQL ON DUPLICATE KEY UPDATE counts changes as 2 per affected row;
+ // $rows = 2 * matched if pure update; for our migrate use case we just
+ // want to confirm the operation completed. Re-count flat table rows
+ // limited to $post_type for a clean migrated count.
+ $count_sql = $post_type
+ ? $wpdb->prepare(
+ "SELECT COUNT(DISTINCT pm.post_id) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = %s",
+ $post_type
+ )
+ : "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta}";
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $migrated = (int) $wpdb->get_var( $count_sql );
+
+ return array(
+ 'migrated' => $migrated,
+ 'group' => $group_name,
+ 'post_type' => $post_type,
+ );
+ }
+
+ /**
+ * Promote post mode along the safe transition path.
+ *
+ * Path: disabled → dual_write → shadow_read → aeav_only.
+ * Must be invoked separately for each step (caller decides timing).
+ *
+ * @param string $target_mode One of TMDO_Mode_Manager::MODE_* constants.
+ * @return true|WP_Error
+ */
+ public static function set_mode( string $target_mode ) {
+ return TMDO_Mode_Manager::set( self::ENTITY_TYPE, $target_mode );
+ }
+
+ /**
+ * Cleanup: DELETE managed keys from wp_postmeta. Only callable when
+ * post mode is aeav_only — otherwise EAV is still authoritative source.
+ *
+ * @return array{deleted:int}
+ * @throws RuntimeException When mode != aeav_only.
+ */
+ public static function cleanup(): array {
+ $mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
+ if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
+ throw new RuntimeException(
+ 'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
+ );
+ }
+
+ global $wpdb;
+ $keys = self::get_managed_keys();
+ if ( empty( $keys ) ) {
+ return array( 'deleted' => 0 );
+ }
+
+ $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
+ $deleted = (int) $wpdb->query(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ "DELETE FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})",
+ ...$keys
+ )
+ );
+
+ return array( 'deleted' => $deleted );
+ }
+
+ /**
+ * Row-by-row backfill for groups containing json-typed fields (v2.10.4).
+ *
+ * Bulk SQL pivot in backfill_group() can't handle json/serialized values
+ * because the conversion serialize() → wp_json_encode() requires PHP-level
+ * processing. This method delegates to TMDO_Entity_Migration_Engine which
+ * already handles safe_unserialize and json encoding row-by-row.
+ *
+ * Idempotent — uses checkpoint cursor; safe to re-run.
+ *
+ * @param string $group_name Entity group name (e.g. 'attachment', 'nav_menu_item').
+ * @param array $options Optional: batch_size (default 500), sleep_ms (50),
+ * resume (true), dry_run (false).
+ * @return array Engine result tuple including migrated/errors/skipped.
+ */
+ public static function backfill_group_json( string $group_name, array $options = array() ): array {
+ if ( ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
+ return array( 'error' => 'TMDO_Entity_Migration_Engine class not available' );
+ }
+ return TMDO_Entity_Migration_Engine::migrate_group(
+ self::ENTITY_TYPE,
+ $group_name,
+ $options
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Legacy zone-table cutover (v2.9.5)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Non-destructive copy of a legacy `wpdo_hot_` zone table
+ * into the new `wp_wpdo_post_` flat table.
+ *
+ * Copies the intersection of columns (by name), excluding `id` and
+ * `updated_at` so the flat table manages those itself. Uses ON DUPLICATE
+ * KEY UPDATE so the operation is idempotent — re-running is safe.
+ *
+ * The legacy table is left UNTOUCHED — this is critical for v3.0.0 rollback
+ * safety. The legacy table is only DROP'd at v3.0.0 release after several
+ * release cycles of the new flat table being authoritative.
+ *
+ * @param string $post_type Post type the legacy table targets.
+ * @param string $hot_table Fully-qualified legacy table name.
+ * @param string $flat_table Fully-qualified target flat table name.
+ * @return array{copied:int,common_columns:string[],post_type:string}
+ * @throws RuntimeException When tables missing or copy SQL fails.
+ */
+ public static function copy_legacy_hot_table(
+ string $post_type,
+ string $hot_table,
+ string $flat_table
+ ): array {
+ global $wpdb;
+
+ $hot_cols = self::get_existing_columns( $hot_table );
+ $flat_cols = self::get_existing_columns( $flat_table );
+
+ if ( empty( $hot_cols ) ) {
+ throw new RuntimeException(
+ 'Legacy hot table missing or empty: ' . esc_html( $hot_table )
+ );
+ }
+ if ( empty( $flat_cols ) ) {
+ throw new RuntimeException(
+ 'Target flat table missing: ' . esc_html( $flat_table )
+ );
+ }
+
+ // Intersect columns by name, excluding ones the flat table manages itself.
+ $skip = array( 'id', 'updated_at', 'created_at' );
+ $common = array();
+ foreach ( $hot_cols as $name => $_ ) {
+ if ( in_array( $name, $skip, true ) ) {
+ continue;
+ }
+ if ( ! isset( $flat_cols[ $name ] ) ) {
+ continue;
+ }
+ $common[] = $name;
+ }
+
+ // post_id is the unique key — must always be present.
+ if ( ! in_array( 'post_id', $common, true ) ) {
+ throw new RuntimeException(
+ 'Cannot copy: post_id column not present in both tables'
+ );
+ }
+
+ $cols_quoted = '`' . implode( '`, `', $common ) . '`';
+ $update_parts = array();
+ foreach ( $common as $col ) {
+ if ( 'post_id' === $col ) {
+ continue;
+ }
+ $update_parts[] = "`{$col}` = VALUES(`{$col}`)";
+ }
+ $update_sql = implode( ', ', $update_parts );
+
+ $rows = $wpdb->query(
+ "INSERT INTO `{$flat_table}` ({$cols_quoted})
+ SELECT {$cols_quoted} FROM `{$hot_table}`
+ ON DUPLICATE KEY UPDATE {$update_sql}"
+ );
+
+ if ( false === $rows ) {
+ throw new RuntimeException(
+ 'copy_legacy_hot_table SQL failed: ' . esc_html( (string) $wpdb->last_error )
+ );
+ }
+
+ // MySQL counts UPSERT modified rows differently from inserted rows;
+ // re-count flat table by post_id intersection for clean number.
+ $copied = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `{$flat_table}` f
+ INNER JOIN `{$hot_table}` h ON h.post_id = f.post_id"
+ );
+
+ return array(
+ 'copied' => $copied,
+ 'common_columns' => $common,
+ 'post_type' => $post_type,
+ );
+ }
+
+ /**
+ * Verify legacy cutover by row count + sampled value comparison.
+ *
+ * Note: only checks row count + post_id presence. Per-column value
+ * comparison would require knowing both tables' column types and
+ * applying lossy/lossless conversion rules — out of scope for v2.9.5
+ * (the COPY operation itself uses INSERT...SELECT which preserves
+ * values byte-for-byte where types match).
+ *
+ * @param string $hot_table Legacy hot table name.
+ * @param string $flat_table Target flat table name.
+ * @return array{hot_rows:int,flat_rows:int,mismatched_rows:int,ok:bool}
+ */
+ public static function verify_legacy_cutover( string $hot_table, string $flat_table ): array {
+ global $wpdb;
+
+ $hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" );
+ $flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" );
+
+ // Find post_ids in hot but not in flat (i.e. failed copies).
+ $mismatched = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `{$hot_table}` h
+ LEFT JOIN `{$flat_table}` f ON f.post_id = h.post_id
+ WHERE f.post_id IS NULL"
+ );
+
+ return array(
+ 'hot_rows' => $hot_rows,
+ 'flat_rows' => $flat_rows,
+ 'mismatched_rows' => $mismatched,
+ 'ok' => 0 === $mismatched && $flat_rows >= $hot_rows,
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Query Router benchmark (v2.10.2)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Compare query latency between wp_postmeta JOIN path and flat-table path
+ * for a single meta_key/value/compare combination.
+ *
+ * Both queries are functionally equivalent but use different storage:
+ * postmeta path → INNER JOIN wp_postmeta (the legacy WP behavior)
+ * flat path → INNER JOIN wp_wpdo_post_ (the v2.10.1 router target)
+ *
+ * Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better.
+ *
+ * @param string $post_type WP post_type to filter by.
+ * @param string $meta_key Meta key to query.
+ * @param string $compare Comparison operator (=, !=, <, <=, >, >=, LIKE).
+ * @param string $value Value to compare against.
+ * @param string $flat_table Fully qualified flat table name (must contain $meta_key column).
+ * @param int $samples Number of times to run each query (default 50).
+ * @return array{
+ * samples:int,
+ * postmeta_avg_ms:float,
+ * flat_avg_ms:float,
+ * speedup:float,
+ * postmeta_rows:int,
+ * flat_rows:int,
+ * meta_key:string,
+ * post_type:string,
+ * }
+ * @throws InvalidArgumentException When $samples <= 0 or compare invalid.
+ * @throws RuntimeException When flat table doesn't exist.
+ */
+ public static function benchmark_query(
+ string $post_type,
+ string $meta_key,
+ string $compare,
+ string $value,
+ string $flat_table,
+ int $samples = 50
+ ): array {
+ if ( $samples <= 0 ) {
+ $msg = 'Samples must be > 0, got ' . $samples;
+ throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ $allowed_compare = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'LIKE' );
+ if ( ! in_array( strtoupper( $compare ), $allowed_compare, true ) ) {
+ $msg = 'Invalid compare: ' . $compare . '. Allowed: ' . implode( ', ', $allowed_compare );
+ throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ // Verify flat table exists.
+ $existing = self::get_existing_columns( $flat_table );
+ if ( empty( $existing ) ) {
+ throw new RuntimeException(
+ 'Benchmark target flat table missing: ' . esc_html( $flat_table )
+ );
+ }
+
+ $col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
+ if ( ! isset( $existing[ $col ] ) ) {
+ throw new RuntimeException(
+ 'Flat table does not have column for ' . esc_html( $meta_key )
+ );
+ }
+
+ global $wpdb;
+
+ // Build both query templates (parameterized via prepare in the loop).
+ $pm_sql = $wpdb->prepare(
+ "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
+ INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
+ WHERE p.post_type = %s AND pm.meta_key = %s AND pm.meta_value {$compare} %s",
+ $post_type,
+ $meta_key,
+ $value
+ );
+
+ $flat_sql = $wpdb->prepare(
+ "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
+ INNER JOIN `{$flat_table}` f ON f.post_id = p.ID
+ WHERE p.post_type = %s AND f.`{$col}` {$compare} %s",
+ $post_type,
+ $value
+ );
+
+ // Warm up MySQL query cache so the first run isn't penalized.
+ $wpdb->get_var( $pm_sql );
+ $wpdb->get_var( $flat_sql );
+
+ $pm_total = 0.0;
+ $flat_total = 0.0;
+ $pm_rows = 0;
+ $flat_rows = 0;
+
+ for ( $i = 0; $i < $samples; $i++ ) {
+ $start = microtime( true );
+ $pm_rows = (int) $wpdb->get_var( $pm_sql );
+ $pm_total += microtime( true ) - $start;
+
+ $start = microtime( true );
+ $flat_rows = (int) $wpdb->get_var( $flat_sql );
+ $flat_total += microtime( true ) - $start;
+ }
+
+ $pm_avg_ms = ( $pm_total / $samples ) * 1000;
+ $flat_avg_ms = ( $flat_total / $samples ) * 1000;
+ $speedup = $flat_avg_ms > 0 ? ( $pm_avg_ms / $flat_avg_ms ) : 0.0;
+
+ return array(
+ 'samples' => $samples,
+ 'postmeta_avg_ms' => round( $pm_avg_ms, 3 ),
+ 'flat_avg_ms' => round( $flat_avg_ms, 3 ),
+ 'speedup' => round( $speedup, 2 ),
+ 'postmeta_rows' => $pm_rows,
+ 'flat_rows' => $flat_rows,
+ 'meta_key' => $meta_key,
+ 'post_type' => $post_type,
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * All meta_keys registered for any post entity group.
+ *
+ * @return string[]
+ */
+ public static function get_managed_keys(): array {
+ $keys = array();
+ foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
+ $keys = array_merge( $keys, TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ) );
+ }
+ return array_values( array_unique( $keys ) );
+ }
+
+ /**
+ * Map group name to the post_type it targets. The mapping is canonical
+ * to v2.9.1 group definitions in TMDO_Post_Fields.
+ *
+ * Returns null for cross-post_type groups (e.g. '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;
+ }
+ }
+
+ /**
+ * Map of column_name → true for every column that exists in $table.
+ * Returns empty array if table missing.
+ *
+ * @param string $table Fully qualified table name.
+ * @return array
+ */
+ private static function get_existing_columns( string $table ): array {
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A );
+ if ( empty( $rows ) ) {
+ return array();
+ }
+ $cols = array();
+ foreach ( $rows as $r ) {
+ $cols[ (string) ( $r['Field'] ?? '' ) ] = true;
+ }
+ return $cols;
+ }
+
+ /**
+ * Count wp_postmeta rows matching $keys, optionally filtered by post_type.
+ *
+ * @param string[] $keys Meta keys to match.
+ * @param string|null $post_type Optional post_type filter.
+ * @return int
+ */
+ private static function count_eav_residue( array $keys, ?string $post_type = null ): int {
+ global $wpdb;
+ if ( empty( $keys ) ) {
+ return 0;
+ }
+ $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
+ if ( $post_type ) {
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ "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 IN ({$placeholders})",
+ $post_type,
+ ...$keys
+ )
+ );
+ }
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})",
+ ...$keys
+ )
+ );
+ }
+}
diff --git a/includes/migration/class-tmdo-warm-migration.php b/includes/migration/class-tmdo-warm-migration.php
new file mode 100644
index 0000000..ef3e100
--- /dev/null
+++ b/includes/migration/class-tmdo-warm-migration.php
@@ -0,0 +1,140 @@
+get_meta_keys();
+ if ( empty( $meta_keys ) ) {
+ return 0;
+ }
+
+ $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property.
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})", // phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Warm migration: postmeta → Zone B path.
+ ...$meta_keys
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ }
+
+ /**
+ * Migrates one batch of warm meta rows.
+ *
+ * @param int $offset Starting row offset.
+ * @return int Number of rows processed.
+ */
+ protected function migrate_batch( int $offset ): int {
+ global $wpdb;
+
+ $meta_keys = $this->get_meta_keys();
+ if ( empty( $meta_keys ) ) {
+ return 0;
+ }
+
+ $registry = TMDO_Schema_Registry::instance();
+ $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
+
+ $args = array_merge( $meta_keys, array( self::BATCH_SIZE, $offset ) );
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property.
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT post_id, meta_key, meta_value
+ FROM {$wpdb->postmeta}
+ WHERE meta_key IN ({$placeholders})
+ ORDER BY meta_id ASC
+ LIMIT %d OFFSET %d",
+ ...$args
+ ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+
+ if ( empty( $rows ) ) {
+ return 0;
+ }
+
+ foreach ( $rows as $row ) {
+ $field = $registry->get_warm_field( $row['meta_key'] );
+ $ttl = $field['ttl'] ?? null;
+
+ TMDO_Zone_Warm::set(
+ (int) $row['post_id'],
+ $row['meta_key'],
+ $row['meta_value'],
+ $ttl
+ );
+ }
+
+ return count( $rows );
+ }
+
+ /**
+ * Verifies that the warm table row count is at least as large as the source.
+ *
+ * @return bool True if verification passes.
+ */
+ public function verify_counts(): bool {
+ global $wpdb;
+
+ $source = $this->count_source();
+ $table = TMDO_Zone_Warm::table();
+ $target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Warm::table()
+
+ return $target >= $source;
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────
+
+ /**
+ * Get all registered warm meta_keys.
+ *
+ * @return array List of meta key strings.
+ */
+ private function get_meta_keys(): array {
+ $fields = TMDO_Schema_Registry::instance()->get_zone_fields( 'warm' );
+ return array_column( $fields, 'meta_key' );
+ }
+}
diff --git a/includes/notifications/abstract-class-tmdo-notifier.php b/includes/notifications/abstract-class-tmdo-notifier.php
new file mode 100644
index 0000000..48bd929
--- /dev/null
+++ b/includes/notifications/abstract-class-tmdo-notifier.php
@@ -0,0 +1,249 @@
+ $fingerprint,
+ 'critical' => (int) ( $summary['critical_count'] ?? 0 ),
+ )
+ );
+ }
+ }
+ return $sent;
+ }
+
+ // ─── settings accessors(optional override)────────────────────────
+
+ /**
+ * Whether this notification channel is enabled.
+ *
+ * @return bool
+ */
+ public static function is_enabled(): bool {
+ return '1' === (string) get_option( static::option_key( 'enabled' ), '0' );
+ }
+
+ /**
+ * Throttle window in hours (clamped 1..168).
+ *
+ * @return int
+ */
+ public static function throttle_hours(): int {
+ $v = (int) get_option( static::option_key( 'throttle_hours' ), self::DEFAULT_THROTTLE_HOURS );
+ return max( 1, min( 168, $v ) );
+ }
+
+ /**
+ * Severity subscription filter.
+ *
+ * @return string 'critical_only' or 'critical_and_recommended'
+ */
+ public static function severity_filter(): string {
+ $v = (string) get_option( static::option_key( 'severity' ), 'critical_only' );
+ return in_array( $v, array( 'critical_only', 'critical_and_recommended' ), true ) ? $v : 'critical_only';
+ }
+
+ /**
+ * Build channel-specific context (e.g. webhook URL or recipient address).
+ * Return null to skip send (e.g. invalid email or empty webhook).
+ *
+ * @return array|null
+ */
+ public static function build_context(): ?array {
+ return array();
+ }
+
+ // ─── shared subject / body builders ───────────────────────────────
+
+ /**
+ * Build alert subject line.
+ *
+ * @param array $summary Summary array from Health_Cron::run().
+ * @return string
+ */
+ public static function build_subject( array $summary ): string {
+ $site = (string) get_option( 'blogname', 'WordPress' );
+ $crit = (int) ( $summary['critical_count'] ?? 0 );
+ $first = '';
+ foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
+ if ( 'critical' === ( $t['status'] ?? '' ) ) {
+ $first = $slug;
+ break;
+ }
+ }
+ return sprintf( '[%s] WPDO 警告:%d 項 critical (%s)', $site, $crit, $first );
+ }
+
+ /**
+ * Generic plain-text body. Subclasses can override to add channel-specific
+ * formatting (Slack mrkdwn, Discord markdown, Telegram MarkdownV2, etc.).
+ *
+ * @param array $summary Summary.
+ * @return string
+ */
+ public static function build_body( array $summary ): string {
+ $lines = array();
+ $lines[] = sprintf( '站台:%s', home_url( '/' ) );
+ $lines[] = sprintf( '檢查時間:%s UTC', (string) ( $summary['ran_at'] ?? '?' ) );
+ $lines[] = sprintf(
+ '結果:%d critical / %d recommended',
+ (int) ( $summary['critical_count'] ?? 0 ),
+ (int) ( $summary['recommended_count'] ?? 0 )
+ );
+ $lines[] = '';
+ $lines[] = '失敗的檢查:';
+ foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
+ if ( in_array( ( $t['status'] ?? '' ), array( 'critical', 'recommended' ), true ) ) {
+ $lines[] = sprintf(
+ ' [%s] %s — %s',
+ strtoupper( (string) ( $t['status'] ?? '' ) ),
+ $slug,
+ (string) ( $t['description'] ?? '' )
+ );
+ }
+ }
+ $lines[] = '';
+ $lines[] = '查看詳情:' . admin_url( 'tools.php?page=wp-data-optimizer&tab=doctor' );
+ return implode( "\n", $lines );
+ }
+
+ // ─── private helpers ─────────────────────────────────────────────
+
+ /**
+ * Per-channel option key (e.g. wpdo_email_enabled, wpdo_slack_webhook).
+ *
+ * @param string $field 'enabled' / 'throttle_hours' / 'severity' / etc.
+ * @return string
+ */
+ protected static function option_key( string $field ): string {
+ return sprintf( 'wpdo_%s_%s', static::channel_id(), $field );
+ }
+
+ /**
+ * Stable fingerprint from the alert content for deduplication.
+ *
+ * @param array $summary Summary array from Health_Cron::run().
+ * @return string md5 hash.
+ */
+ protected static function fingerprint( array $summary ): string {
+ $relevant = array(
+ 'critical_count' => (int) ( $summary['critical_count'] ?? 0 ),
+ 'first_critical' => null,
+ );
+ foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
+ if ( 'critical' === ( $t['status'] ?? '' ) ) {
+ $relevant['first_critical'] = $slug;
+ break;
+ }
+ }
+ return md5( wp_json_encode( $relevant ) );
+ }
+
+ /**
+ * Check whether an alert with this fingerprint was recently sent.
+ *
+ * @param string $fingerprint Alert fingerprint (md5).
+ * @return bool
+ */
+ protected static function is_throttled( string $fingerprint ): bool {
+ $key = sprintf( 'wpdo_%s_sent_%s', static::channel_id(), $fingerprint );
+ return false !== get_transient( $key );
+ }
+
+ /**
+ * Record that an alert was sent (sets throttle transient).
+ *
+ * @param string $fingerprint Alert fingerprint (md5).
+ * @return void
+ */
+ protected static function mark_sent( string $fingerprint ): void {
+ $key = sprintf( 'wpdo_%s_sent_%s', static::channel_id(), $fingerprint );
+ set_transient( $key, time(), static::throttle_hours() * HOUR_IN_SECONDS );
+ }
+}
diff --git a/includes/notifications/class-tmdo-discord-notifier.php b/includes/notifications/class-tmdo-discord-notifier.php
new file mode 100644
index 0000000..3c6432a
--- /dev/null
+++ b/includes/notifications/class-tmdo-discord-notifier.php
@@ -0,0 +1,76 @@
+ $url );
+ }
+
+ /**
+ * Send alert via Discord webhook.
+ *
+ * @param string $subject Alert subject / title.
+ * @param string $body Alert body text.
+ * @param array $context Channel context (webhook_url).
+ * @return bool True on HTTP 2xx response.
+ */
+ public static function actually_send( string $subject, string $body, array $context ): bool {
+ $url = (string) ( $context['webhook_url'] ?? '' );
+ if ( '' === $url ) {
+ return false;
+ }
+ // Discord max content length = 2000 chars.
+ $content = "**{$subject}**\n```\n" . substr( $body, 0, 1800 ) . "\n```";
+ $payload = wp_json_encode( array( 'content' => $content ) );
+ $resp = wp_remote_post(
+ $url,
+ array(
+ 'headers' => array( 'Content-Type' => 'application/json' ),
+ 'body' => $payload,
+ 'timeout' => 5,
+ 'blocking' => true,
+ )
+ );
+ if ( is_wp_error( $resp ) ) {
+ return false;
+ }
+ $code = (int) wp_remote_retrieve_response_code( $resp );
+ // Discord returns 204 on success.
+ return $code >= 200 && $code < 300;
+ }
+}
diff --git a/includes/notifications/class-tmdo-email-notifier.php b/includes/notifications/class-tmdo-email-notifier.php
new file mode 100644
index 0000000..cfb333e
--- /dev/null
+++ b/includes/notifications/class-tmdo-email-notifier.php
@@ -0,0 +1,238 @@
+ 0). Sends a plain-text email to the configured
+ * recipient(s) with a 24h throttle key (per-alert-fingerprint) so admins
+ * don't get spammed.
+ *
+ * Default OFF — admin must explicitly enable via Settings tab.
+ *
+ * @package WP_Data_Optimizer
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Email notifier — stateless static API.
+ */
+class TMDO_Email_Notifier {
+
+ public const OPT_ENABLED = 'wpdo_email_alerts_enabled';
+ public const OPT_RECIPIENT = 'wpdo_alert_email';
+ public const OPT_THROTTLE_HRS = 'wpdo_alert_throttle_hours';
+
+ /** Default throttle window. */
+ public const DEFAULT_THROTTLE_HOURS = 24;
+
+ /**
+ * Register subscriber.
+ *
+ * @return void
+ */
+ public static function register(): void {
+ add_action( 'wpdo/health_alert_critical', array( __CLASS__, 'maybe_send' ), 10, 1 );
+ }
+
+ /**
+ * Decide + send.
+ *
+ * @param array $summary Health summary from Health_Cron::run().
+ * @return bool true on send, false on skip / fail.
+ */
+ public static function maybe_send( array $summary ): bool {
+ if ( ! self::is_enabled() ) {
+ return false;
+ }
+ $recipient = self::recipient();
+ if ( ! is_email( $recipient ) ) {
+ return false;
+ }
+ $fingerprint = self::fingerprint( $summary );
+ if ( self::is_throttled( $fingerprint ) ) {
+ return false;
+ }
+ $subject = self::build_subject( $summary );
+ $body = self::build_body( $summary );
+ $sent = wp_mail( $recipient, $subject, $body );
+ if ( $sent ) {
+ self::mark_sent( $fingerprint );
+ if ( class_exists( 'TMDO_Logger' ) ) {
+ TMDO_Logger::info(
+ 'email_alert_sent',
+ array(
+ 'fingerprint' => $fingerprint,
+ 'critical' => (int) ( $summary['critical_count'] ?? 0 ),
+ 'recipient' => $recipient,
+ )
+ );
+ }
+ }
+ return (bool) $sent;
+ }
+
+ // ─── settings accessors ─────────────────────────────────────────────
+
+ /**
+ * Whether email alerts are enabled.
+ *
+ * @return bool
+ */
+ public static function is_enabled(): bool {
+ return '1' === (string) get_option( self::OPT_ENABLED, '0' );
+ }
+
+ /**
+ * Alert recipient email address (falls back to admin_email).
+ *
+ * @return string
+ */
+ public static function recipient(): string {
+ $v = (string) get_option( self::OPT_RECIPIENT, '' );
+ if ( '' === $v ) {
+ $v = (string) get_option( 'admin_email', '' );
+ }
+ return $v;
+ }
+
+ /**
+ * Throttle window in hours (clamped 1..168).
+ *
+ * @return int
+ */
+ public static function throttle_hours(): int {
+ $v = (int) get_option( self::OPT_THROTTLE_HRS, self::DEFAULT_THROTTLE_HOURS );
+ return max( 1, min( 168, $v ) ); // Clamp 1h..1week.
+ }
+
+ // ─── private helpers ──────────────────────────────────────────────
+
+ /**
+ * Stable fingerprint from the alert content (so identical incidents
+ * dedupe within the throttle window).
+ *
+ * @param array $summary Summary array.
+ * @return string md5 hash.
+ */
+ private static function fingerprint( array $summary ): string {
+ $relevant = array(
+ 'critical_count' => (int) ( $summary['critical_count'] ?? 0 ),
+ 'first_critical' => null,
+ );
+ foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
+ if ( 'critical' === ( $t['status'] ?? '' ) ) {
+ $relevant['first_critical'] = $slug;
+ break;
+ }
+ }
+ return md5( wp_json_encode( $relevant ) );
+ }
+
+ /**
+ * Check whether an alert with this fingerprint was recently sent.
+ *
+ * @param string $fingerprint Alert fingerprint (md5).
+ * @return bool
+ */
+ private static function is_throttled( string $fingerprint ): bool {
+ $key = 'wpdo_alert_sent_' . $fingerprint;
+ return false !== get_transient( $key );
+ }
+
+ /**
+ * Record that an alert was sent (sets throttle transient).
+ *
+ * @param string $fingerprint Alert fingerprint (md5).
+ * @return void
+ */
+ private static function mark_sent( string $fingerprint ): void {
+ $key = 'wpdo_alert_sent_' . $fingerprint;
+ set_transient( $key, time(), self::throttle_hours() * HOUR_IN_SECONDS );
+ }
+
+ /**
+ * Build subject. Site name + critical count + first slug.
+ *
+ * @param array $summary Summary array.
+ * @return string
+ */
+ private static function build_subject( array $summary ): string {
+ $site = (string) get_option( 'blogname', 'WordPress' );
+ $crit = (int) ( $summary['critical_count'] ?? 0 );
+ $first = '';
+ foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
+ if ( 'critical' === ( $t['status'] ?? '' ) ) {
+ $first = $slug;
+ break;
+ }
+ }
+ return sprintf(
+ /* translators: 1: site name, 2: critical count, 3: first critical test slug */
+ __( '[%1$s] WPDO 警告:%2$d 項 critical (%3$s)', '2meet-data-optimizer' ),
+ $site,
+ $crit,
+ $first
+ );
+ }
+
+ /**
+ * Build plain-text body.
+ *
+ * @param array $summary Summary array.
+ * @return string
+ */
+ private static function build_body( array $summary ): string {
+ $site_url = home_url( '/' );
+ $health_url = admin_url( 'site-health.php' );
+ $wpdo_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=doctor' );
+ $lines = array();
+ $lines[] = __( 'WP Data Optimizer 自動健康檢查發現 critical 警告。', '2meet-data-optimizer' );
+ $lines[] = '';
+ $lines[] = sprintf( '站台:%s', $site_url );
+ $lines[] = sprintf(
+ /* translators: %s: timestamp */
+ __( '檢查時間:%s UTC', '2meet-data-optimizer' ),
+ (string) ( $summary['ran_at'] ?? '?' )
+ );
+ $lines[] = sprintf(
+ /* translators: 1: critical count, 2: recommended count */
+ __( '結果:%1$d critical / %2$d recommended', '2meet-data-optimizer' ),
+ (int) ( $summary['critical_count'] ?? 0 ),
+ (int) ( $summary['recommended_count'] ?? 0 )
+ );
+ $lines[] = '';
+ $lines[] = __( '失敗的檢查:', '2meet-data-optimizer' );
+ foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
+ if ( in_array( ( $t['status'] ?? '' ), array( 'critical', 'recommended' ), true ) ) {
+ $lines[] = sprintf(
+ ' [%s] %s — %s',
+ strtoupper( (string) $t['status'] ),
+ $slug,
+ (string) ( $t['description'] ?? '' )
+ );
+ }
+ }
+ $lines[] = '';
+ $lines[] = __( '建議行動:', '2meet-data-optimizer' );
+ $lines[] = ' · ' . sprintf(
+ /* translators: %s: WPDO Doctor admin URL */
+ __( '立即查看 WPDO Doctor:%s', '2meet-data-optimizer' ),
+ $wpdo_url
+ );
+ $lines[] = ' · ' . sprintf(
+ /* translators: %s: WP Site Health admin URL */
+ __( '或 WP Site Health:%s', '2meet-data-optimizer' ),
+ $health_url
+ );
+ $lines[] = '';
+ $lines[] = sprintf(
+ /* translators: %d: hours */
+ __( '註:相同警告在 %d 小時內不會重發;前往設定可調整 throttle / 收件人 / 關閉。', '2meet-data-optimizer' ),
+ self::throttle_hours()
+ );
+ return implode( "\n", $lines );
+ }
+}
diff --git a/includes/notifications/class-tmdo-slack-notifier.php b/includes/notifications/class-tmdo-slack-notifier.php
new file mode 100644
index 0000000..9304af8
--- /dev/null
+++ b/includes/notifications/class-tmdo-slack-notifier.php
@@ -0,0 +1,78 @@
+ $url );
+ }
+
+ /**
+ * Send alert via Slack incoming webhook.
+ *
+ * @param string $subject Alert subject / title.
+ * @param string $body Alert body text.
+ * @param array $context Channel context (webhook_url).
+ * @return bool True on HTTP 2xx response.
+ */
+ public static function actually_send( string $subject, string $body, array $context ): bool {
+ $url = (string) ( $context['webhook_url'] ?? '' );
+ if ( '' === $url ) {
+ return false;
+ }
+ // Slack mrkdwn — bold subject + plaintext body in code block for readability.
+ $payload = wp_json_encode(
+ array(
+ 'text' => "*{$subject}*\n```\n{$body}\n```",
+ )
+ );
+ $resp = wp_remote_post(
+ $url,
+ array(
+ 'headers' => array( 'Content-Type' => 'application/json' ),
+ 'body' => $payload,
+ 'timeout' => 5,
+ 'blocking' => true,
+ )
+ );
+ if ( is_wp_error( $resp ) ) {
+ return false;
+ }
+ $code = (int) wp_remote_retrieve_response_code( $resp );
+ return $code >= 200 && $code < 300;
+ }
+}
diff --git a/includes/notifications/class-tmdo-telegram-notifier.php b/includes/notifications/class-tmdo-telegram-notifier.php
new file mode 100644
index 0000000..cfb9e80
--- /dev/null
+++ b/includes/notifications/class-tmdo-telegram-notifier.php
@@ -0,0 +1,95 @@
+ $token,
+ 'chat_id' => $chat,
+ );
+ }
+
+ /**
+ * Send alert via Telegram Bot API.
+ *
+ * @param string $subject Alert subject / title.
+ * @param string $body Alert body text.
+ * @param array $context Channel context (token, chat_id).
+ * @return bool True on HTTP 2xx response.
+ */
+ public static function actually_send( string $subject, string $body, array $context ): bool {
+ $token = (string) ( $context['token'] ?? '' );
+ $chat = (string) ( $context['chat_id'] ?? '' );
+ if ( '' === $token || '' === $chat ) {
+ return false;
+ }
+ // Telegram MarkdownV2 has many escaped chars; use plain text mode for safety.
+ $text = "🚨 {$subject}\n\n{$body}";
+ // Telegram message limit = 4096 chars.
+ $text = substr( $text, 0, 4000 );
+
+ $url = sprintf( 'https://api.telegram.org/bot%s/sendMessage', rawurlencode( $token ) );
+ $resp = wp_remote_post(
+ $url,
+ array(
+ 'headers' => array( 'Content-Type' => 'application/json' ),
+ 'body' => wp_json_encode(
+ array(
+ 'chat_id' => $chat,
+ 'text' => $text,
+ )
+ ),
+ 'timeout' => 5,
+ 'blocking' => true,
+ )
+ );
+ if ( is_wp_error( $resp ) ) {
+ return false;
+ }
+ $code = (int) wp_remote_retrieve_response_code( $resp );
+ return $code >= 200 && $code < 300;
+ }
+}
diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php
new file mode 100644
index 0000000..4e9ccfb
--- /dev/null
+++ b/includes/query/class-tmdo-post-query-router.php
@@ -0,0 +1,320 @@
+set( 'wpdo_post_clauses', $routed )
+ * 2. posts_join — LEFT JOIN wp_wpdo_post_ per routed post_type
+ * 3. posts_where — append WHERE conditions targeting flat columns
+ * 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
+ *
+ * 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
+ * only entity_type='post'.
+ *
+ * @package WP_Data_Optimizer
+ * @since 2.10.1
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal query rewriter: table names go through Schema_Manager::sanitize_column_name, column names go through esc_sql, user-controlled values use $wpdb->prepare() in build_condition().
+
+/**
+ * Rewrites WP_Query meta_query clauses to JOIN against post entity flat tables.
+ */
+class TMDO_Post_Query_Router {
+
+ /** Query var key for capturing routed clauses (consumed by posts_join/where). */
+ private const QUERY_VAR = 'wpdo_post_clauses';
+
+ /**
+ * Map post_type → entity group (mirror of TMDO_Post_Migration::group_post_type
+ * but inverted; a post_type may map to multiple groups eventually, currently
+ * we route to the most-specific *_core group plus the cross-cutting wp_core
+ * group when the key is in wp_core).
+ *
+ * @var array
+ */
+ private const POST_TYPE_GROUP_MAP = array(
+ 'product' => 'wc_product',
+ 'hp_listing' => 'hp_listing_core',
+ 'hp_request' => 'hp_request_core',
+ 'hp_vendor' => 'hp_vendor_core',
+ 'attachment' => 'attachment',
+ 'nav_menu_item' => 'nav_menu_item',
+ );
+
+ /**
+ * Register all WP_Query hooks.
+ *
+ * @return void
+ */
+ public function register_hooks(): void {
+ add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 );
+ add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
+ add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
+ add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
+ }
+
+ /**
+ * Strip Entity-Registry-managed meta_query keys; store routed clauses.
+ *
+ * @param \WP_Query $query Current WP_Query.
+ * @return void
+ */
+ public function pre_get_posts( \WP_Query $query ): void {
+ // Mode gate: only run when post mode is reads_from_flat.
+ if ( ! self::is_router_active() ) {
+ return;
+ }
+
+ // is_admin guard (matches legacy TMDO_Query_Router behavior).
+ if ( is_admin() && ! wp_doing_ajax() ) {
+ return;
+ }
+
+ $post_types = (array) $query->get( 'post_type' );
+ if ( empty( $post_types ) ) {
+ return;
+ }
+
+ $raw_meta_query = (array) $query->get( 'meta_query' );
+ if ( empty( $raw_meta_query ) ) {
+ return;
+ }
+
+ $routed = array(); // Keyed by post_type.
+ $remaining = array();
+
+ foreach ( $raw_meta_query as $k => $clause ) {
+ if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
+ $remaining[ $k ] = $clause;
+ continue;
+ }
+
+ $matched = false;
+ foreach ( $post_types as $pt ) {
+ $field = TMDO_Entity_Registry::get_field( 'post', $clause['key'] );
+ if ( ! $field ) {
+ continue;
+ }
+ // Confirm the field's group is appropriate for this post_type:
+ // either cross-cutting wp_core (any) or the post_type-specific group.
+ $group = $field['group'] ?? '';
+ $expected_grp = self::POST_TYPE_GROUP_MAP[ $pt ] ?? null;
+ $is_appropriate = ( 'wp_core' === $group ) || ( null !== $expected_grp && $expected_grp === $group );
+ if ( ! $is_appropriate ) {
+ continue;
+ }
+
+ $column = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] );
+
+ $routed[ $pt ][] = array(
+ 'group' => $group,
+ 'column' => $column,
+ 'value' => $clause['value'] ?? '',
+ 'compare' => strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ),
+ 'type' => strtoupper( trim( (string) ( $clause['type'] ?? 'CHAR' ) ) ),
+ );
+ $matched = true;
+ break;
+ }
+
+ if ( ! $matched ) {
+ $remaining[ $k ] = $clause;
+ }
+ }
+
+ if ( empty( $routed ) ) {
+ return;
+ }
+
+ // Preserve relation if surviving clauses still have it.
+ if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
+ $remaining['relation'] = $raw_meta_query['relation'];
+ }
+
+ $query->set( 'meta_query', $remaining );
+ $query->set( self::QUERY_VAR, $routed );
+ }
+
+ /**
+ * LEFT JOIN flat tables for each routed post_type.
+ *
+ * @param string|null $join Current JOIN SQL.
+ * @param \WP_Query $query Current WP_Query.
+ * @return string Modified JOIN SQL.
+ */
+ public function posts_join( ?string $join, \WP_Query $query ): string {
+ $join = (string) ( $join ?? '' );
+ $routed = $query->get( self::QUERY_VAR );
+ if ( empty( $routed ) || ! is_array( $routed ) ) {
+ return $join;
+ }
+
+ global $wpdb;
+
+ foreach ( $routed as $post_type => $clauses ) {
+ // Each routed clause carries its group; use the first clause's group
+ // (all clauses for a given post_type currently target one group).
+ $group = $clauses[0]['group'] ?? '';
+ if ( '' === $group ) {
+ continue;
+ }
+ $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
+ $alias = self::table_alias( $post_type, $group );
+
+ // Don't double-join.
+ if ( false !== strpos( $join, "`{$alias}`" ) ) {
+ continue;
+ }
+
+ $join .= " LEFT JOIN `{$table}` AS `{$alias}` "
+ . "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
+ }
+
+ return $join;
+ }
+
+ /**
+ * Append WHERE conditions for routed clauses.
+ *
+ * @param string|null $where Current WHERE SQL.
+ * @param \WP_Query $query Current WP_Query.
+ * @return string Modified WHERE SQL.
+ */
+ public function posts_where( ?string $where, \WP_Query $query ): string {
+ $where = (string) ( $where ?? '' );
+ $routed = $query->get( self::QUERY_VAR );
+ if ( empty( $routed ) || ! is_array( $routed ) ) {
+ return $where;
+ }
+
+ global $wpdb;
+
+ foreach ( $routed as $post_type => $clauses ) {
+ foreach ( $clauses as $clause ) {
+ $alias = self::table_alias( $post_type, $clause['group'] );
+ $col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`';
+ $compare = self::sanitize_compare( $clause['compare'] );
+ $value = $clause['value'];
+
+ $where .= self::build_condition( $col, $compare, $value );
+ }
+ }
+
+ return $where;
+ }
+
+ /**
+ * Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
+ *
+ * @param string|null $groupby Current GROUP BY SQL.
+ * @param \WP_Query $query Current WP_Query.
+ * @return string Modified GROUP BY SQL.
+ */
+ public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
+ $groupby = (string) ( $groupby ?? '' );
+ $routed = $query->get( self::QUERY_VAR );
+ if ( empty( $routed ) || ! is_array( $routed ) ) {
+ return $groupby;
+ }
+
+ global $wpdb;
+ if ( '' === trim( $groupby ) ) {
+ $groupby = "`{$wpdb->posts}`.`ID`";
+ }
+
+ return $groupby;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Whether the router is mode-active (post mode is shadow_read or aeav_only).
+ *
+ * @return bool
+ */
+ public static function is_router_active(): bool {
+ if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
+ return false;
+ }
+ return TMDO_Mode_Manager::reads_from_flat( 'post' );
+ }
+
+ /**
+ * Generate a deterministic JOIN alias for a (post_type, group) pair.
+ *
+ * @param string $post_type Post type slug.
+ * @param string $group Entity group name.
+ * @return string SQL alias.
+ */
+ private static function table_alias( string $post_type, string $group ): string {
+ return 'wpdoflat_' . sanitize_key( $post_type ) . '_' . sanitize_key( $group );
+ }
+
+ /**
+ * Allow-list of comparison operators.
+ *
+ * @param string $compare Raw operator from clause.
+ * @return string Sanitized operator (defaults to '=').
+ */
+ private static function sanitize_compare( string $compare ): string {
+ $allowed = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
+ $compare = strtoupper( trim( $compare ) );
+ return in_array( $compare, $allowed, true ) ? $compare : '=';
+ }
+
+ /**
+ * Build a single WHERE condition fragment (always prepended with " AND ").
+ *
+ * @param string $col_sql Quoted column reference.
+ * @param string $compare Sanitized comparison operator.
+ * @param mixed $value Raw value (scalar or array).
+ * @return string
+ */
+ private static function build_condition( string $col_sql, string $compare, $value ): string {
+ global $wpdb;
+
+ if ( in_array( $compare, array( 'IN', 'NOT IN' ), true ) ) {
+ $values = (array) $value;
+ if ( empty( $values ) ) {
+ return '';
+ }
+ $placeholders = implode( ',', array_fill( 0, count( $values ), '%s' ) );
+ return ' AND ' . $col_sql . ' ' . $compare . ' (' . $wpdb->prepare( $placeholders, ...$values ) . ')';
+ }
+
+ if ( in_array( $compare, array( 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
+ $values = (array) $value;
+ if ( count( $values ) < 2 ) {
+ return '';
+ }
+ return ' AND ' . $col_sql . ' ' . $compare . ' '
+ . $wpdb->prepare( '%s AND %s', $values[0], $values[1] );
+ }
+
+ if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
+ $op = 'EXISTS' === $compare ? 'IS NOT NULL' : 'IS NULL';
+ return ' AND ' . $col_sql . ' ' . $op;
+ }
+
+ return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
+ }
+}
diff --git a/includes/query/class-tmdo-query-interceptor-base.php b/includes/query/class-tmdo-query-interceptor-base.php
new file mode 100644
index 0000000..d1d9aff
--- /dev/null
+++ b/includes/query/class-tmdo-query-interceptor-base.php
@@ -0,0 +1,358 @@
+ 'column_name' ] for flat-column tables
+ *
+ * Ported from HPCT_Query_Interceptor_Base with WPDO enhancements:
+ * - Uses TMDO_Feature_Flags (7-state) for query-active check
+ * - Uses TMDO_DB::table() for table name resolution
+ * - Query var prefix: wpdo_qi_ (avoids collision with HPCT)
+ */
+abstract class TMDO_Query_Interceptor_Base {
+
+ /**
+ * Returns the module identifier for feature flag lookups.
+ *
+ * @return string Module name.
+ */
+ abstract protected function get_module(): string;
+
+ /**
+ * Returns the post types this interceptor handles.
+ *
+ * @return string[] Array of post type slugs.
+ */
+ abstract protected function get_post_types(): array;
+
+ /**
+ * Custom table name without $wpdb->prefix.
+ * Passed through TMDO_DB::table() at runtime.
+ */
+ abstract protected function get_table(): string;
+
+ /**
+ * Returns the column name used to join to wp_posts.ID.
+ *
+ * @return string Join column name, default 'post_id'.
+ */
+ protected function get_join_column(): string {
+ return 'post_id';
+ }
+
+ /**
+ * Meta_key to custom table column map (flat-column tables only).
+ * Return empty array for KV tables (override posts_where instead).
+ *
+ * @return array Map of meta_key to column name.
+ */
+ abstract protected function get_meta_key_map(): array;
+
+ // ── Hook registration ─────────────────────────────────────────────────
+
+ /**
+ * Registers all WordPress query hooks.
+ *
+ * @return void
+ */
+ public function register_hooks(): void {
+ add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 );
+ add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
+ add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
+ add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
+ }
+
+ // ── Hook handlers ─────────────────────────────────────────────────────
+
+ /**
+ * Intercepts pre_get_posts to rewrite meta_query conditions for our custom tables.
+ *
+ * @param \WP_Query $query The WP_Query object.
+ * @return void
+ */
+ public function pre_get_posts( \WP_Query $query ): void {
+ if ( ! $this->should_intercept( $query ) ) {
+ return;
+ }
+
+ $raw_meta_query = (array) $query->get( 'meta_query' );
+ if ( empty( $raw_meta_query ) ) {
+ return;
+ }
+
+ $key_map = $this->get_meta_key_map();
+ $our_clauses = array();
+ $remaining = array();
+
+ foreach ( $raw_meta_query as $k => $clause ) {
+ if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
+ $remaining[ $k ] = $clause;
+ continue;
+ }
+
+ if ( isset( $key_map[ $clause['key'] ] ) ) {
+ $our_clauses[] = array(
+ 'column' => $key_map[ $clause['key'] ],
+ 'value' => $clause['value'] ?? '',
+ 'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ),
+ 'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ),
+ );
+ } else {
+ $remaining[ $k ] = $clause;
+ }
+ }
+
+ if ( empty( $our_clauses ) ) {
+ return;
+ }
+
+ if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
+ $remaining['relation'] = $raw_meta_query['relation'];
+ }
+
+ $query->set( 'meta_query', $remaining );
+ $query->set( $this->query_var(), $our_clauses );
+ }
+
+ /**
+ * Appends a LEFT JOIN to the custom table when this interceptor has active clauses.
+ *
+ * @param string $join Current JOIN SQL.
+ * @param \WP_Query $query The WP_Query object.
+ * @return string Modified JOIN SQL.
+ */
+ public function posts_join( ?string $join, \WP_Query $query ): string {
+ $join = (string) ( $join ?? '' );
+ if ( ! $this->has_clauses( $query ) ) {
+ return $join;
+ }
+
+ global $wpdb;
+ $alias = $this->table_alias();
+
+ if ( false === strpos( $join, "`{$alias}`" ) ) {
+ $table = TMDO_DB::table( $this->get_table() );
+ $join_col = esc_sql( $this->get_join_column() );
+ $join .= " LEFT JOIN `{$table}` AS `{$alias}`"
+ . " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`{$join_col}`)";
+ }
+
+ return $join;
+ }
+
+ /**
+ * Appends WHERE conditions for the custom table columns.
+ *
+ * @param string $where Current WHERE SQL.
+ * @param \WP_Query $query The WP_Query object.
+ * @return string Modified WHERE SQL.
+ */
+ public function posts_where( ?string $where, \WP_Query $query ): string {
+ $where = (string) ( $where ?? '' );
+ $clauses = $this->get_clauses( $query );
+ if ( empty( $clauses ) ) {
+ return $where;
+ }
+
+ $alias = $this->table_alias();
+
+ foreach ( $clauses as $clause ) {
+ $col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`';
+ $compare = $this->sanitize_compare( $clause['compare'] );
+ $type = $clause['type'];
+ $value = $clause['value'];
+
+ $where .= $this->build_condition( $col, $compare, $type, $value );
+ }
+
+ return $where;
+ }
+
+ /**
+ * Ensures GROUP BY is set to prevent duplicates from LEFT JOINs.
+ *
+ * @param string $groupby Current GROUP BY SQL.
+ * @param \WP_Query $query The WP_Query object.
+ * @return string Modified GROUP BY SQL.
+ */
+ public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
+ $groupby = (string) ( $groupby ?? '' );
+ if ( ! $this->has_clauses( $query ) ) {
+ return $groupby;
+ }
+
+ global $wpdb;
+ if ( '' === trim( $groupby ) ) {
+ $groupby = "`{$wpdb->posts}`.`ID`";
+ }
+
+ return $groupby;
+ }
+
+ // ── Internal helpers ──────────────────────────────────────────────────
+
+ /**
+ * Determines whether this interceptor should handle the given query.
+ *
+ * @param \WP_Query $query The WP_Query object.
+ * @return bool True if the query should be intercepted.
+ */
+ protected function should_intercept( \WP_Query $query ): bool {
+ if ( ! TMDO_Feature_Flags::is_query_active( $this->get_module() ) ) {
+ return false;
+ }
+
+ $post_types = (array) $query->get( 'post_type' );
+
+ return ! empty( array_intersect( $post_types, $this->get_post_types() ) );
+ }
+
+ /**
+ * Returns the query var key used to store intercepted clauses.
+ *
+ * @return string Query var name.
+ */
+ protected function query_var(): string {
+ return 'wpdo_qi_' . $this->get_module();
+ }
+
+ /**
+ * Returns the SQL alias for the custom table in JOIN clauses.
+ *
+ * @return string Table alias.
+ */
+ protected function table_alias(): string {
+ return 'wpdo_' . $this->get_module();
+ }
+
+ /**
+ * Checks whether the query has any intercepted clauses stored.
+ *
+ * @param \WP_Query $query The WP_Query object.
+ * @return bool True if there are stored clauses.
+ */
+ protected function has_clauses( \WP_Query $query ): bool {
+ $clauses = $query->get( $this->query_var() );
+ return ! empty( $clauses );
+ }
+
+ /**
+ * Retrieves stored intercepted clauses from the query.
+ *
+ * @param \WP_Query $query The WP_Query object.
+ * @return array[] Array of clause definitions.
+ */
+ protected function get_clauses( \WP_Query $query ): array {
+ $val = $query->get( $this->query_var() );
+ return is_array( $val ) ? $val : array();
+ }
+
+ /**
+ * Build a single WHERE condition string.
+ * All values go through $wpdb->prepare().
+ *
+ * @param string $col Column reference (escaped).
+ * @param string $compare Comparison operator.
+ * @param string $type Meta type for placeholder selection.
+ * @param mixed $value Value(s) to compare against.
+ * @return string SQL WHERE condition fragment.
+ */
+ protected function build_condition( string $col, string $compare, string $type, $value ): string {
+ global $wpdb;
+
+ $ph = $this->placeholder( $type );
+
+ switch ( $compare ) {
+ case 'IN':
+ case 'NOT IN':
+ $vals = array_values( (array) $value );
+ if ( empty( $vals ) ) {
+ return ' AND 1=0';
+ }
+ $phs = implode( ',', array_fill( 0, count( $vals ), $ph ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ return $wpdb->prepare( " AND {$col} {$compare} ({$phs})", ...$vals );
+
+ case 'BETWEEN':
+ case 'NOT BETWEEN':
+ $vals = array_values( (array) $value );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string.
+ return $wpdb->prepare( " AND {$col} {$compare} {$ph} AND {$ph}", $vals[0], $vals[1] ?? $vals[0] );
+
+ case 'EXISTS':
+ return " AND {$col} IS NOT NULL";
+
+ case 'NOT EXISTS':
+ return " AND {$col} IS NULL";
+
+ default:
+ // =, !=, >, >=, <, <=, LIKE, NOT LIKE
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string.
+ return $wpdb->prepare( " AND {$col} {$compare} {$ph}", $value );
+ }
+ }
+
+ /**
+ * Returns the SQL placeholder string for a given meta type.
+ *
+ * @param string $type Meta type (NUMERIC, SIGNED, etc. or CHAR).
+ * @return string SQL placeholder (%d or %s).
+ */
+ protected function placeholder( string $type ): string {
+ $int_types = array( 'NUMERIC', 'SIGNED', 'UNSIGNED', 'INTEGER' );
+ return in_array( $type, $int_types, true ) ? '%d' : '%s';
+ }
+
+ /**
+ * Sanitizes a comparison operator against an allowed list.
+ *
+ * @param string $compare Comparison operator string.
+ * @return string Sanitized comparison operator, defaults to '='.
+ */
+ protected function sanitize_compare( string $compare ): string {
+ $allowed = array(
+ '=',
+ '!=',
+ '>',
+ '>=',
+ '<',
+ '<=',
+ 'LIKE',
+ 'NOT LIKE',
+ 'IN',
+ 'NOT IN',
+ 'BETWEEN',
+ 'NOT BETWEEN',
+ 'EXISTS',
+ 'NOT EXISTS',
+ );
+ return in_array( $compare, $allowed, true ) ? $compare : '=';
+ }
+}
diff --git a/includes/query/class-tmdo-query-router.php b/includes/query/class-tmdo-query-router.php
new file mode 100644
index 0000000..e775e00
--- /dev/null
+++ b/includes/query/class-tmdo-query-router.php
@@ -0,0 +1,292 @@
+get( 'post_type' );
+ if ( empty( $post_types ) ) {
+ return;
+ }
+
+ $raw_meta_query = (array) $query->get( 'meta_query' );
+ if ( empty( $raw_meta_query ) ) {
+ return;
+ }
+
+ $registry = TMDO_Schema_Registry::instance();
+ $hot_clauses = array(); // Keyed by post_type.
+ $remaining = array();
+
+ foreach ( $raw_meta_query as $k => $clause ) {
+ if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
+ $remaining[ $k ] = $clause;
+ continue;
+ }
+
+ $matched = false;
+ foreach ( $post_types as $pt ) {
+ $field = $registry->get_field( $pt, $clause['key'] );
+ if ( $field && 'hot' === $field['zone'] ) {
+ // Check module is query-active.
+ $module = 'hot_' . sanitize_key( $pt );
+ if ( ! TMDO_Feature_Flags::is_query_active( $module ) ) {
+ continue;
+ }
+
+ $hot_clauses[ $pt ][] = array(
+ 'column' => $field['column'],
+ 'value' => $clause['value'] ?? '',
+ 'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ),
+ 'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ),
+ );
+ $matched = true;
+ break;
+ }
+ }
+
+ if ( ! $matched ) {
+ $remaining[ $k ] = $clause;
+ }
+ }
+
+ if ( empty( $hot_clauses ) ) {
+ return;
+ }
+
+ // Preserve relation if there are remaining clauses.
+ if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
+ $remaining['relation'] = $raw_meta_query['relation'];
+ }
+
+ $query->set( 'meta_query', $remaining );
+ $query->set( self::QUERY_VAR, $hot_clauses );
+ }
+
+ /**
+ * LEFT JOIN hot tables for each post type with extracted clauses.
+ *
+ * @param string $join Current JOIN SQL.
+ * @param \WP_Query $query The WP_Query object.
+ * @return string Modified JOIN SQL.
+ */
+ public function posts_join( ?string $join, \WP_Query $query ): string {
+ $join = (string) ( $join ?? '' );
+ $hot_clauses = $query->get( self::QUERY_VAR );
+ if ( empty( $hot_clauses ) || ! is_array( $hot_clauses ) ) {
+ return $join;
+ }
+
+ global $wpdb;
+
+ foreach ( $hot_clauses as $post_type => $clauses ) {
+ $alias = $this->table_alias( $post_type );
+
+ if ( false !== strpos( $join, "`{$alias}`" ) ) {
+ continue;
+ }
+
+ $table = TMDO_Zone_Hot::table( $post_type );
+ $join .= " LEFT JOIN `{$table}` AS `{$alias}`"
+ . " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
+ }
+
+ return $join;
+ }
+
+ /**
+ * Append WHERE conditions for hot table columns.
+ *
+ * @param string $where Current WHERE SQL.
+ * @param \WP_Query $query The WP_Query object.
+ * @return string Modified WHERE SQL.
+ */
+ public function posts_where( ?string $where, \WP_Query $query ): string {
+ $where = (string) ( $where ?? '' );
+ $hot_clauses = $query->get( self::QUERY_VAR );
+ if ( empty( $hot_clauses ) || ! is_array( $hot_clauses ) ) {
+ return $where;
+ }
+
+ foreach ( $hot_clauses as $post_type => $clauses ) {
+ $alias = $this->table_alias( $post_type );
+
+ foreach ( $clauses as $clause ) {
+ $col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`';
+ $compare = $this->sanitize_compare( $clause['compare'] );
+ $type = $clause['type'];
+ $value = $clause['value'];
+
+ $where .= $this->build_condition( $col, $compare, $type, $value );
+ }
+ }
+
+ return $where;
+ }
+
+ /**
+ * Ensure GROUP BY to prevent duplicate rows from JOINs.
+ *
+ * @param string $groupby Current GROUP BY SQL.
+ * @param \WP_Query $query The WP_Query object.
+ * @return string Modified GROUP BY SQL.
+ */
+ public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
+ $groupby = (string) ( $groupby ?? '' );
+ $hot_clauses = $query->get( self::QUERY_VAR );
+ if ( empty( $hot_clauses ) || ! is_array( $hot_clauses ) ) {
+ return $groupby;
+ }
+
+ global $wpdb;
+ if ( '' === trim( $groupby ) ) {
+ $groupby = "`{$wpdb->posts}`.`ID`";
+ }
+
+ return $groupby;
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────
+
+ /**
+ * Generate a unique alias for the hot table JOIN.
+ *
+ * @param string $post_type Post type slug.
+ * @return string SQL table alias.
+ */
+ private function table_alias( string $post_type ): string {
+ return 'wpdo_hot_' . sanitize_key( $post_type );
+ }
+
+ /**
+ * Build a single WHERE condition with prepared values.
+ *
+ * @param string $col Column reference (escaped).
+ * @param string $compare Comparison operator.
+ * @param string $type Meta type for placeholder selection.
+ * @param mixed $value Value(s) to compare against.
+ * @return string SQL WHERE condition fragment.
+ */
+ private function build_condition( string $col, string $compare, string $type, $value ): string {
+ global $wpdb;
+
+ $ph = $this->placeholder( $type );
+
+ switch ( $compare ) {
+ case 'IN':
+ case 'NOT IN':
+ $vals = array_values( (array) $value );
+ if ( empty( $vals ) ) {
+ return ' AND 1=0';
+ }
+ $phs = implode( ',', array_fill( 0, count( $vals ), $ph ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ return $wpdb->prepare( " AND {$col} {$compare} ({$phs})", ...$vals );
+
+ case 'BETWEEN':
+ case 'NOT BETWEEN':
+ $vals = array_values( (array) $value );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string.
+ return $wpdb->prepare( " AND {$col} {$compare} {$ph} AND {$ph}", $vals[0], $vals[1] ?? $vals[0] );
+
+ case 'EXISTS':
+ return " AND {$col} IS NOT NULL";
+
+ case 'NOT EXISTS':
+ return " AND {$col} IS NULL";
+
+ default:
+ // =, !=, >, >=, <, <=, LIKE, NOT LIKE
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string.
+ return $wpdb->prepare( " AND {$col} {$compare} {$ph}", $value );
+ }
+ }
+
+ /**
+ * Returns the SQL placeholder string for a given meta type.
+ *
+ * @param string $type Meta type (NUMERIC, SIGNED, etc. or CHAR).
+ * @return string SQL placeholder (%d or %s).
+ */
+ private function placeholder( string $type ): string {
+ $int_types = array( 'NUMERIC', 'SIGNED', 'UNSIGNED', 'INTEGER' );
+ return in_array( $type, $int_types, true ) ? '%d' : '%s';
+ }
+
+ /**
+ * Sanitizes a comparison operator against an allowed list.
+ *
+ * @param string $compare Comparison operator string.
+ * @return string Sanitized comparison operator, defaults to '='.
+ */
+ private function sanitize_compare( string $compare ): string {
+ $allowed = array(
+ '=',
+ '!=',
+ '>',
+ '>=',
+ '<',
+ '<=',
+ 'LIKE',
+ 'NOT LIKE',
+ 'IN',
+ 'NOT IN',
+ 'BETWEEN',
+ 'NOT BETWEEN',
+ 'EXISTS',
+ 'NOT EXISTS',
+ );
+ return in_array( $compare, $allowed, true ) ? $compare : '=';
+ }
+}
diff --git a/includes/safety/class-tmdo-fsm-guard.php b/includes/safety/class-tmdo-fsm-guard.php
new file mode 100644
index 0000000..6e9e000
--- /dev/null
+++ b/includes/safety/class-tmdo-fsm-guard.php
@@ -0,0 +1,215 @@
+ array( 'dual_write' ),
+ 'dual_write' => array( 'backfill', 'verify' ),
+ 'backfill' => array( 'verify' ),
+ 'verify' => array( 'cutover' ),
+ 'cutover' => array( 'cleanup' ),
+ 'cleanup' => array( 'complete' ),
+ 'complete' => array(), // terminal — only rewind to idle.
+ );
+
+ /**
+ * Transitions that destroy data on completion (write-only-custom or purge).
+ * These trigger an automatic pre-transition snapshot.
+ */
+ public const DESTRUCTIVE_TRANSITIONS = array(
+ 'cutover' => array( 'cleanup' ), // cleanup purges wp_*meta.
+ 'cleanup' => array( 'complete' ), // complete = no fallback to wp_*meta.
+ );
+
+ /**
+ * "Active" states (writes hit custom tables). Reverting these to idle
+ * potentially loses data and warrants a snapshot.
+ */
+ public const ACTIVE_STATES = array(
+ 'dual_write',
+ 'backfill',
+ 'verify',
+ 'cutover',
+ 'cleanup',
+ 'complete',
+ );
+
+ /**
+ * Check whether a transition is allowed. Returns true on allow,
+ * WP_Error on block.
+ *
+ * @param string $module Module name (must be a known module).
+ * @param string $from Current state.
+ * @param string $to Target state.
+ * @return true|WP_Error
+ */
+ public static function can_transition( string $module, string $from, string $to ) {
+ // Allow filter-based bypass (CLI --force, integration tests, manual override).
+ $bypass = (bool) apply_filters( 'wpdo/fsm_guard/bypass', false, $module, $from, $to );
+ if ( $bypass ) {
+ return true;
+ }
+
+ // No-op transition.
+ if ( $from === $to ) {
+ return true;
+ }
+
+ // Backward to idle is always allowed (emergency rewind).
+ if ( 'idle' === $to ) {
+ return true;
+ }
+
+ // Validate against forward graph.
+ $allowed = self::FORWARD_GRAPH[ $from ] ?? array();
+ if ( ! in_array( $to, $allowed, true ) ) {
+ return new WP_Error(
+ 'wpdo_fsm_invalid_transition',
+ sprintf(
+ /* translators: 1: module, 2: from-state, 3: to-state, 4: list of allowed states */
+ __( 'Cannot transition module %1$s from %2$s to %3$s. Allowed forward states from %2$s: %4$s. Pass --force or set the wpdo/fsm_guard/bypass filter to override.', '2meet-data-optimizer' ),
+ $module,
+ $from,
+ $to,
+ empty( $allowed ) ? __( '(none — terminal state)', '2meet-data-optimizer' ) : implode( ', ', $allowed )
+ ),
+ array(
+ 'module' => $module,
+ 'from' => $from,
+ 'to' => $to,
+ 'allowed' => $allowed,
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Decide whether a transition is destructive (warrants auto-snapshot).
+ *
+ * @param string $from Current state.
+ * @param string $to Target state.
+ * @return bool
+ */
+ public static function is_destructive( string $from, string $to ): bool {
+ // Cleanup/complete transitions purge data.
+ if ( isset( self::DESTRUCTIVE_TRANSITIONS[ $from ] )
+ && in_array( $to, self::DESTRUCTIVE_TRANSITIONS[ $from ], true ) ) {
+ return true;
+ }
+ // Reverting an active module to idle abandons custom-table data.
+ if ( 'idle' === $to && in_array( $from, self::ACTIVE_STATES, true ) ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Take an auto-snapshot for the pending FSM transition.
+ * Called from Feature_Flags::set() before update_option.
+ *
+ * @param string $module Module name.
+ * @param string $from Current state.
+ * @param string $to Target state.
+ * @return string|null Snapshot ID on success; null on skip/failure.
+ */
+ public static function maybe_snapshot( string $module, string $from, string $to ): ?string {
+ if ( ! self::is_destructive( $from, $to ) ) {
+ return null;
+ }
+ // Check if Snapshot_Manager is loaded — defensive (in case unit tests skip include).
+ if ( ! class_exists( 'TMDO_Snapshot_Manager' ) ) {
+ return null;
+ }
+ // Allow filter to suppress (e.g. CLI passed --skip-snapshot for emergency rewind).
+ $skip = (bool) apply_filters( 'wpdo/fsm_guard/skip_snapshot', false, $module, $from, $to );
+ if ( $skip ) {
+ return null;
+ }
+ $result = TMDO_Snapshot_Manager::create(
+ 'pre_fsm_transition',
+ array(),
+ array(
+ 'notes' => sprintf( '[%s] %s → %s', $module, $from, $to ),
+ 'retention_days' => 90, // Keep FSM-rewind snapshots longer.
+ )
+ );
+ return ! empty( $result['ok'] ) ? (string) $result['snapshot_id'] : null;
+ }
+
+ /**
+ * Convenience: check + snapshot + record entry time. Called from
+ * Feature_Flags::set(). Returns WP_Error to block, true to allow.
+ *
+ * @param string $module Module identifier.
+ * @param string $to Target state.
+ * @return true|WP_Error
+ */
+ public static function before_transition( string $module, string $to ) {
+ $from = TMDO_Feature_Flags::get( $module );
+ $check = self::can_transition( $module, $from, $to );
+ if ( is_wp_error( $check ) ) {
+ return $check;
+ }
+ self::maybe_snapshot( $module, $from, $to );
+ return true;
+ }
+
+ /**
+ * Record state entry time after a successful transition. Stored in
+ * wp_options key `wpdo_fsm_state_entered` for verify-gate / wash-period
+ * checks (v2.3.0 M5 will consume this).
+ *
+ * @param string $module Module identifier.
+ * @param string $to New state.
+ * @return void
+ */
+ public static function record_entry( string $module, string $to ): void {
+ $key = 'wpdo_fsm_state_entered';
+ $data = get_option( $key, array() );
+ if ( ! is_array( $data ) ) {
+ $data = array();
+ }
+ $data[ $module ] = array(
+ 'state' => $to,
+ 'entered_at' => current_time( 'mysql', true ),
+ );
+ update_option( $key, $data, false );
+ }
+
+ /**
+ * Read the entry timestamp for a module's current state.
+ *
+ * @param string $module Module identifier.
+ * @return string|null UTC mysql datetime, or null when unrecorded.
+ */
+ public static function get_entry_time( string $module ): ?string {
+ $data = get_option( 'wpdo_fsm_state_entered', array() );
+ return is_array( $data ) && isset( $data[ $module ]['entered_at'] ) ? (string) $data[ $module ]['entered_at'] : null;
+ }
+}
diff --git a/includes/snapshots/class-tmdo-snapshot-manager.php b/includes/snapshots/class-tmdo-snapshot-manager.php
new file mode 100644
index 0000000..3daa377
--- /dev/null
+++ b/includes/snapshots/class-tmdo-snapshot-manager.php
@@ -0,0 +1,431 @@
+ false,
+ 'error' => 'invalid_trigger',
+ );
+ }
+ if ( ! self::ensure_backup_dir() ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'backup_dir_unwritable',
+ );
+ }
+
+ $snapshot_id = self::generate_id();
+ $writer = new TMDO_Snapshot_Writer( $snapshot_id, self::resolve_scope( $scope ), $opts );
+
+ try {
+ $result = $writer->write();
+ } catch ( Throwable $e ) {
+ TMDO_Logger::error(
+ 'snapshots',
+ 'create',
+ $e->getMessage(),
+ array(
+ 'snapshot_id' => $snapshot_id,
+ 'trigger' => $trigger,
+ ),
+ ''
+ );
+ return array(
+ 'ok' => false,
+ 'error' => 'writer_failed',
+ 'message' => $e->getMessage(),
+ );
+ }
+
+ $retention_days = (int) ( $opts['retention_days'] ?? self::DEFAULT_RETENTION_DAYS );
+ $expires_at = $retention_days > 0
+ ? gmdate( 'Y-m-d H:i:s', time() + $retention_days * DAY_IN_SECONDS )
+ : null;
+
+ $row = array(
+ 'snapshot_id' => $snapshot_id,
+ 'trigger_type' => $trigger,
+ 'scope' => wp_json_encode( $writer->get_scope() ),
+ 'size_bytes' => (int) $result['size_bytes'],
+ 'row_count' => (int) $result['row_count'],
+ 'storage' => (string) $result['storage'],
+ 'file_path' => $result['file_path'] ?? null,
+ 'file_sha256' => $result['sha256'] ?? null,
+ 'inline_blob' => $result['inline_blob'] ?? null,
+ 'fsm_states' => wp_json_encode( self::capture_fsm_states() ),
+ 'notes' => isset( $opts['notes'] ) ? (string) $opts['notes'] : null,
+ 'created_at' => current_time( 'mysql', true ),
+ 'expires_at' => $expires_at,
+ );
+
+ $inserted = $wpdb->insert( $wpdb->prefix . self::TABLE_SLUG, $row ); // phpcs:ignore WordPress.DB
+ if ( false === $inserted ) {
+ // Insert failed — clean up the written file to avoid orphan.
+ if ( ! empty( $result['file_path'] ) && file_exists( $result['file_path'] ) ) {
+ @unlink( $result['file_path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ }
+ return array(
+ 'ok' => false,
+ 'error' => 'db_insert_failed',
+ 'message' => $wpdb->last_error,
+ );
+ }
+
+ TMDO_Logger::info(
+ 'snapshot_created',
+ array(
+ 'snapshot_id' => $snapshot_id,
+ 'trigger' => $trigger,
+ 'size_bytes' => $result['size_bytes'],
+ 'row_count' => $result['row_count'],
+ 'storage' => $result['storage'],
+ )
+ );
+
+ do_action( 'wpdo_after_snapshot_create', $snapshot_id, $trigger, $row );
+
+ return array(
+ 'ok' => true,
+ 'snapshot_id' => $snapshot_id,
+ 'size_bytes' => (int) $result['size_bytes'],
+ 'row_count' => (int) $result['row_count'],
+ 'storage' => (string) $result['storage'],
+ );
+ }
+
+ /**
+ * Restore a snapshot. Default dry-run; pass false to actually replay.
+ *
+ * @param string $snapshot_id Snapshot ULID.
+ * @param bool $dry_run When true, return preview without applying.
+ * @return array {ok:bool, preview?:array, restored?:array, error?:string}
+ */
+ public static function restore( string $snapshot_id, bool $dry_run = true ): array {
+ $row = self::get( $snapshot_id );
+ if ( null === $row ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'not_found',
+ );
+ }
+ try {
+ $reader = new TMDO_Snapshot_Reader( $row );
+ } catch ( Throwable $e ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'reader_init_failed',
+ 'message' => $e->getMessage(),
+ );
+ }
+ try {
+ if ( $dry_run ) {
+ $preview = $reader->preview();
+ return array(
+ 'ok' => true,
+ 'preview' => $preview,
+ );
+ }
+ $applied = $reader->apply();
+ TMDO_Logger::warning(
+ 'snapshot_restored',
+ array(
+ 'snapshot_id' => $snapshot_id,
+ 'tables' => $applied['tables'] ?? array(),
+ 'rows' => $applied['rows_restored'] ?? 0,
+ )
+ );
+ do_action( 'wpdo_after_snapshot_restore', $snapshot_id, $applied );
+ return array(
+ 'ok' => true,
+ 'restored' => $applied,
+ );
+ } catch ( Throwable $e ) {
+ TMDO_Logger::error( 'snapshots', 'restore', $e->getMessage(), array( 'snapshot_id' => $snapshot_id ), '' );
+ return array(
+ 'ok' => false,
+ 'error' => 'restore_failed',
+ 'message' => $e->getMessage(),
+ );
+ }
+ }
+
+ /**
+ * Verify integrity (sha256, file existence/readability).
+ *
+ * @param string $snapshot_id Snapshot ULID.
+ * @return array {ok:bool, sha256_ok?:bool, size_match?:bool, error?:string}
+ */
+ public static function verify( string $snapshot_id ): array {
+ $row = self::get( $snapshot_id );
+ if ( null === $row ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'not_found',
+ );
+ }
+ try {
+ $reader = new TMDO_Snapshot_Reader( $row );
+ return $reader->verify();
+ } catch ( Throwable $e ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'verify_failed',
+ 'message' => $e->getMessage(),
+ );
+ }
+ }
+
+ /**
+ * List recent snapshots (catalog rows only — no payload).
+ *
+ * @param int $limit Max rows.
+ * @param string|null $trigger_filter Only this trigger_type if set.
+ * @return array>
+ */
+ public static function list_recent( int $limit = 50, ?string $trigger_filter = null ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE_SLUG;
+ $limit = max( 1, min( 1000, $limit ) );
+ if ( null !== $trigger_filter ) {
+ $rows = $wpdb->get_results(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB
+ "SELECT id, snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, notes, created_at, expires_at FROM `{$table}` WHERE trigger_type = %s ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
+ $trigger_filter,
+ $limit
+ ),
+ ARRAY_A
+ );
+ } else {
+ $rows = $wpdb->get_results(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB
+ "SELECT id, snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, notes, created_at, expires_at FROM `{$table}` ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
+ $limit
+ ),
+ ARRAY_A
+ );
+ }
+ return is_array( $rows ) ? $rows : array();
+ }
+
+ /**
+ * Prune expired or oldest-first snapshots up to a size cap.
+ *
+ * @param int $older_than_days Default retention window.
+ * @param int $size_cap_bytes When backup dir exceeds this, evict oldest.
+ * @return array {pruned:int, freed_bytes:int, errors:array}
+ */
+ public static function prune( int $older_than_days = self::DEFAULT_RETENTION_DAYS, int $size_cap_bytes = self::DEFAULT_SIZE_CAP_BYTES ): array {
+ return TMDO_Snapshot_Pruner::prune( $older_than_days, $size_cap_bytes );
+ }
+
+ /**
+ * Read a single catalog row by snapshot_id (includes inline_blob).
+ *
+ * @param string $snapshot_id Snapshot ULID.
+ * @return array|null
+ */
+ public static function get( string $snapshot_id ): ?array {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE_SLUG;
+ $row = $wpdb->get_row(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB
+ "SELECT * FROM `{$table}` WHERE snapshot_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
+ $snapshot_id
+ ),
+ ARRAY_A
+ );
+ return is_array( $row ) ? $row : null;
+ }
+
+ /**
+ * Delete one snapshot (catalog row + file). Idempotent.
+ *
+ * @param string $snapshot_id Snapshot ULID.
+ * @return bool true on row+file deletion (or row absent), false on DB error.
+ */
+ public static function delete( string $snapshot_id ): bool {
+ global $wpdb;
+ $row = self::get( $snapshot_id );
+ if ( null === $row ) {
+ return true;
+ }
+ if ( ! empty( $row['file_path'] ) && file_exists( $row['file_path'] ) ) {
+ @unlink( $row['file_path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ }
+ $table = $wpdb->prefix . self::TABLE_SLUG;
+ $deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB
+ TMDO_Logger::info( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) );
+ return false !== $deleted;
+ }
+
+ /**
+ * Resolve absolute path to wp-content/uploads/wpdo-backups/.
+ *
+ * Multisite (v2.14.0): `wp_upload_dir()` automatically returns the
+ * current site's uploads basedir — i.e. `/wp-content/uploads/sites/N/`
+ * for sub-sites and `/wp-content/uploads/` for the main site. So the
+ * backup directory is naturally per-site isolated; no extra handling
+ * needed. When a site is deleted via Network → Sites → Delete, the
+ * entire `sites/N/` tree is removed by WP, taking the snapshots with it.
+ *
+ * @return string
+ */
+ public static function backup_dir(): string {
+ $uploads = wp_upload_dir();
+ $base = $uploads['basedir'] ?? WP_CONTENT_DIR . '/uploads';
+ return trailingslashit( $base ) . self::BACKUP_DIR_NAME;
+ }
+
+ /**
+ * Ensure wp-content/uploads/wpdo-backups/ exists with access-denial guards.
+ *
+ * Apache: .htaccess "Deny from all" + modern "Require all denied".
+ * Nginx: nginx does not read .htaccess; add a location block to site config:
+ * location ~* /wpdo-backups/ { deny all; }
+ * The README-NGINX.txt placed here documents this for server admins.
+ *
+ * @return bool
+ */
+ public static function ensure_backup_dir(): bool {
+ $dir = self::backup_dir();
+ if ( ! file_exists( $dir ) ) {
+ if ( ! wp_mkdir_p( $dir ) ) {
+ return false;
+ }
+ }
+ $base = trailingslashit( $dir );
+
+ // Apache — "Deny from all" (Apache 2.2) + "Require all denied" (Apache 2.4).
+ $htaccess = $base . '.htaccess';
+ if ( ! file_exists( $htaccess ) ) {
+ file_put_contents(
+ $htaccess,
+ "\n Require all denied\n\n\n Order allow,deny\n Deny from all\n\n"
+ );
+ }
+
+ // Empty index.php — prevents directory listing on Apache without Options -Indexes.
+ $index = $base . 'index.php';
+ if ( ! file_exists( $index ) ) {
+ file_put_contents( $index, " isset( $scope['entities'] ) && is_array( $scope['entities'] ) ? array_values( array_map( 'strval', $scope['entities'] ) ) : array(),
+ 'modules' => isset( $scope['modules'] ) && is_array( $scope['modules'] ) ? array_values( array_map( 'strval', $scope['modules'] ) ) : array(),
+ 'tables' => isset( $scope['tables'] ) && is_array( $scope['tables'] ) ? array_values( array_map( 'strval', $scope['tables'] ) ) : array(),
+ );
+ }
+
+ /**
+ * Capture per-module FSM states at snapshot creation time (for context
+ * during restore — admin can decide whether to also rewind FSM).
+ *
+ * @return array
+ */
+ private static function capture_fsm_states(): array {
+ if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
+ return array();
+ }
+ $out = array();
+ $modules = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
+ foreach ( $modules as $m ) {
+ $out[ $m ] = TMDO_Feature_Flags::get( $m );
+ }
+ return $out;
+ }
+}
diff --git a/includes/snapshots/class-tmdo-snapshot-pruner.php b/includes/snapshots/class-tmdo-snapshot-pruner.php
new file mode 100644
index 0000000..da2f8a0
--- /dev/null
+++ b/includes/snapshots/class-tmdo-snapshot-pruner.php
@@ -0,0 +1,198 @@
+prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
+
+ $ttl_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB
+ "SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE expires_at IS NOT NULL AND expires_at < UTC_TIMESTAMP()", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
+ ARRAY_A
+ );
+ $result = self::evict_rows( is_array( $ttl_rows ) ? $ttl_rows : array(), 'ttl' );
+
+ if ( $size_cap_bytes > 0 ) {
+ $current_size = self::current_dir_size();
+ if ( $current_size > $size_cap_bytes ) {
+ $over = $current_size - $size_cap_bytes;
+ $candidates = $wpdb->get_results(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB
+ "SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE trigger_type NOT IN ('" . implode( "','", array_map( 'esc_sql', self::PROTECTED_TRIGGERS ) ) . "') ORDER BY created_at ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.QuotedDynamicPlaceholderGeneration -- dynamic IN clause with trusted table name and string-literal enum values
+ 100
+ ),
+ ARRAY_A
+ );
+ $cap_result = self::evict_to_recover( is_array( $candidates ) ? $candidates : array(), $over );
+ $result['sizecap_pruned'] = $cap_result['pruned'];
+ $result['freed_bytes'] += $cap_result['freed_bytes'];
+ $result['pruned'] += $cap_result['pruned'];
+ $result['errors'] = array_merge( $result['errors'], $cap_result['errors'] );
+ } else {
+ $result['sizecap_pruned'] = 0;
+ }
+ } else {
+ $result['sizecap_pruned'] = 0;
+ }
+
+ TMDO_Logger::info(
+ 'snapshot_prune',
+ array(
+ 'older_than_days' => $older_than_days,
+ 'size_cap_bytes' => $size_cap_bytes,
+ 'pruned' => $result['pruned'],
+ 'freed_bytes' => $result['freed_bytes'],
+ 'ttl_pruned' => $result['ttl_pruned'] ?? 0,
+ 'sizecap_pruned' => $result['sizecap_pruned'],
+ )
+ );
+
+ return $result;
+ }
+
+ /**
+ * Cron handler — called from class-tmdo-core.php via the wpdo_daily_health_check
+ * subroutine (v2.3.0) or its own scheduled hook.
+ *
+ * @return void
+ */
+ public static function cron_run(): void {
+ self::prune( TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS, TMDO_Snapshot_Manager::DEFAULT_SIZE_CAP_BYTES );
+ }
+
+ // ─── private ──────────────────────────────────────────────────────────
+
+ /**
+ * Delete rows + their files. Returns prune accounting.
+ *
+ * @param array $rows Rows to evict.
+ * @param string $phase Tag for logs.
+ * @return array {pruned:int,freed_bytes:int,ttl_pruned?:int,errors:array}
+ */
+ private static function evict_rows( array $rows, string $phase ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
+ $pruned = 0;
+ $freed = 0;
+ $errors = array();
+ foreach ( $rows as $row ) {
+ $path = (string) ( $row['file_path'] ?? '' );
+ if ( '' !== $path && file_exists( $path ) ) {
+ $ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ if ( ! $ok ) {
+ $errors[] = "unlink failed: {$path}";
+ }
+ }
+ $deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB
+ if ( false === $deleted ) {
+ $errors[] = 'db delete failed: ' . $row['snapshot_id'];
+ continue;
+ }
+ ++$pruned;
+ $freed += (int) $row['size_bytes'];
+ }
+ $out = array(
+ 'pruned' => $pruned,
+ 'freed_bytes' => $freed,
+ 'errors' => $errors,
+ );
+ if ( 'ttl' === $phase ) {
+ $out['ttl_pruned'] = $pruned;
+ }
+ return $out;
+ }
+
+ /**
+ * Evict from candidates until we've freed `$target_bytes` (or run out).
+ *
+ * @param array $candidates Sorted oldest-first.
+ * @param int $target_bytes Bytes to free.
+ * @return array {pruned:int,freed_bytes:int,errors:array}
+ */
+ private static function evict_to_recover( array $candidates, int $target_bytes ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
+ $pruned = 0;
+ $freed = 0;
+ $errors = array();
+ foreach ( $candidates as $row ) {
+ if ( $freed >= $target_bytes ) {
+ break;
+ }
+ $path = (string) ( $row['file_path'] ?? '' );
+ if ( '' !== $path && file_exists( $path ) ) {
+ $ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ if ( ! $ok ) {
+ $errors[] = "unlink failed: {$path}";
+ }
+ }
+ $deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB
+ if ( false === $deleted ) {
+ $errors[] = 'db delete failed: ' . $row['snapshot_id'];
+ continue;
+ }
+ ++$pruned;
+ $freed += (int) $row['size_bytes'];
+ }
+ return array(
+ 'pruned' => $pruned,
+ 'freed_bytes' => $freed,
+ 'errors' => $errors,
+ );
+ }
+
+ /**
+ * Sum all backup directory file sizes.
+ *
+ * @return int Bytes.
+ */
+ private static function current_dir_size(): int {
+ $dir = TMDO_Snapshot_Manager::backup_dir();
+ if ( ! is_dir( $dir ) ) {
+ return 0;
+ }
+ $total = 0;
+ $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) );
+ foreach ( $it as $file ) {
+ if ( $file->isFile() ) {
+ $total += $file->getSize();
+ }
+ }
+ return $total;
+ }
+}
diff --git a/includes/snapshots/class-tmdo-snapshot-reader.php b/includes/snapshots/class-tmdo-snapshot-reader.php
new file mode 100644
index 0000000..ec871f2
--- /dev/null
+++ b/includes/snapshots/class-tmdo-snapshot-reader.php
@@ -0,0 +1,266 @@
+ WHERE 1=1 before re-inserting (clean slate per table).
+ * Caller is responsible for FSM rewind / cache flush around this call.
+ *
+ * @package WP_Data_Optimizer
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Snapshot reader: integrity check + apply.
+ */
+class TMDO_Snapshot_Reader {
+
+ /**
+ * Catalog row from wp_wpdo_snapshots.
+ *
+ * @var array
+ */
+ private array $row;
+
+ /**
+ * Constructor.
+ *
+ * @param array $row Catalog row (must contain snapshot_id, storage, file_path, file_sha256, inline_blob, size_bytes).
+ * @throws InvalidArgumentException When row is missing required keys.
+ */
+ public function __construct( array $row ) {
+ foreach ( array( 'snapshot_id', 'storage', 'size_bytes' ) as $required ) {
+ if ( ! array_key_exists( $required, $row ) ) {
+ throw new InvalidArgumentException( "snapshot row missing key: {$required}" ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
+ }
+ }
+ $this->row = $row;
+ }
+
+ /**
+ * Verify sha256 + readability.
+ *
+ * @return array {ok:bool, sha256_ok:bool, size_match:bool, error?:string}
+ */
+ public function verify(): array {
+ $storage = (string) $this->row['storage'];
+ $expected_size = (int) $this->row['size_bytes'];
+ $expected_sha = (string) ( $this->row['file_sha256'] ?? '' );
+
+ if ( 'inline' === $storage ) {
+ $blob = (string) ( $this->row['inline_blob'] ?? '' );
+ $actual_size = strlen( $blob );
+ // inline blobs aren't sha-checked by default — just check size.
+ return array(
+ 'ok' => $actual_size === $expected_size,
+ 'sha256_ok' => true,
+ 'size_match' => $actual_size === $expected_size,
+ 'storage' => 'inline',
+ );
+ }
+
+ $path = (string) ( $this->row['file_path'] ?? '' );
+ if ( '' === $path || ! file_exists( $path ) ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'file_missing',
+ 'storage' => 'file',
+ );
+ }
+ if ( ! is_readable( $path ) ) {
+ return array(
+ 'ok' => false,
+ 'error' => 'file_unreadable',
+ 'storage' => 'file',
+ );
+ }
+ $actual_size = (int) filesize( $path );
+ $actual_sha = hash_file( 'sha256', $path );
+ return array(
+ 'ok' => ( $actual_size === $expected_size ) && ( $actual_sha === $expected_sha ),
+ 'sha256_ok' => ( $actual_sha === $expected_sha ),
+ 'size_match' => ( $actual_size === $expected_size ),
+ 'storage' => 'file',
+ );
+ }
+
+ /**
+ * Parse SQL stream and return a preview of tables + row counts (no apply).
+ *
+ * @return array {tables:array, total_rows:int, statements:int, sql_bytes:int}
+ * @throws RuntimeException When payload cannot be loaded.
+ */
+ public function preview(): array {
+ $sql = $this->load_sql();
+ return $this->parse_summary( $sql );
+ }
+
+ /**
+ * Load + apply (DELETE + INSERT per table).
+ *
+ * For file-storage snapshots, sha256 is verified before execution.
+ * Only tables whose names pass is_safe_name() (prefix-prefixed, alphanum+_)
+ * are touched — arbitrary table names in the SQL file cannot be applied.
+ *
+ * @return array {tables:array, rows_restored:int, statements_run:int}
+ * @throws RuntimeException When payload cannot be loaded or integrity check fails.
+ */
+ public function apply(): array {
+ global $wpdb;
+
+ // Verify sha256 / size for file-storage snapshots before executing any SQL.
+ if ( 'inline' !== (string) ( $this->row['storage'] ?? 'file' ) ) {
+ $check = $this->verify();
+ if ( ! $check['ok'] ) {
+ $reason = $check['error'] ?? ( $check['sha256_ok'] ? 'size_mismatch' : 'sha256_mismatch' );
+ throw new RuntimeException( 'snapshot integrity check failed: ' . $reason ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
+ }
+ }
+
+ $sql = $this->load_sql();
+ $summary = $this->parse_summary( $sql );
+
+ // Wipe target tables before inserting (clean slate).
+ foreach ( array_keys( $summary['tables'] ) as $table ) {
+ if ( $this->is_safe_name( $table ) ) {
+ $wpdb->query( "DELETE FROM `{$table}`" ); // phpcs:ignore WordPress.DB
+ }
+ }
+
+ $statements_run = 0;
+ $rows_restored = 0;
+ // Naive split on `;\n` — INSERT statements never contain unescaped semicolon-newline.
+ $lines = preg_split( '/;\s*\n/', $sql );
+ if ( ! is_array( $lines ) ) {
+ throw new RuntimeException( 'sql parse failed' );
+ }
+ foreach ( $lines as $stmt ) {
+ $stmt = trim( $stmt );
+ if ( '' === $stmt || str_starts_with( $stmt, '--' ) ) {
+ continue;
+ }
+ if ( ! preg_match( '/^(INSERT|SET)\b/i', $stmt ) ) {
+ continue;
+ }
+ $result = $wpdb->query( $stmt ); // phpcs:ignore WordPress.DB
+ if ( false === $result ) {
+ throw new RuntimeException( 'restore stmt failed: ' . substr( $stmt, 0, 80 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
+ }
+ if ( stripos( $stmt, 'INSERT' ) === 0 ) {
+ $rows_restored += (int) $result;
+ }
+ ++$statements_run;
+ }
+
+ return array(
+ 'tables' => array_keys( $summary['tables'] ),
+ 'rows_restored' => $rows_restored,
+ 'statements_run' => $statements_run,
+ );
+ }
+
+ // ─── private ──────────────────────────────────────────────────────────
+
+ /**
+ * Load + ungzip the snapshot SQL payload.
+ *
+ * @return string Raw SQL.
+ * @throws RuntimeException When payload is missing or unreadable.
+ */
+ private function load_sql(): string {
+ $storage = (string) $this->row['storage'];
+ if ( 'inline' === $storage ) {
+ $blob = (string) ( $this->row['inline_blob'] ?? '' );
+ if ( '' === $blob ) {
+ throw new RuntimeException( 'inline_blob is empty' );
+ }
+ return $this->maybe_gunzip( $blob );
+ }
+ $path = (string) ( $this->row['file_path'] ?? '' );
+ if ( '' === $path || ! file_exists( $path ) ) {
+ throw new RuntimeException( 'snapshot file missing: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
+ }
+ if ( str_ends_with( $path, '.gz' ) ) {
+ $content = '';
+ $handle = gzopen( $path, 'rb' );
+ if ( false === $handle ) {
+ throw new RuntimeException( 'gzopen failed: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
+ }
+ while ( ! gzeof( $handle ) ) {
+ $content .= gzread( $handle, 65536 );
+ }
+ gzclose( $handle );
+ return $content;
+ }
+ return (string) file_get_contents( $path );
+ }
+
+ /**
+ * Decompress if first 2 bytes are gzip magic.
+ *
+ * @param string $blob Possibly-gzipped data.
+ * @return string Decompressed.
+ */
+ private function maybe_gunzip( string $blob ): string {
+ if ( strlen( $blob ) >= 2 && "\x1f\x8b" === substr( $blob, 0, 2 ) ) {
+ $out = @gzdecode( $blob ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ return false === $out ? $blob : $out;
+ }
+ return $blob;
+ }
+
+ /**
+ * Walk SQL and count INSERT rows per table.
+ *
+ * @param string $sql Raw SQL.
+ * @return array {tables:array, total_rows:int, statements:int, sql_bytes:int}
+ */
+ private function parse_summary( string $sql ): array {
+ $tables = array();
+ $total = 0;
+ $statements = 0;
+ preg_match_all( '/INSERT INTO `([^`]+)` \([^)]*\) VALUES (.+?);\s*\n/s', $sql, $matches, PREG_SET_ORDER );
+ foreach ( $matches as $m ) {
+ $table = $m[1];
+ $body = $m[2];
+ // Count `(` at the start of value tuples — substring_count of `,(` + 1.
+ $rows = substr_count( $body, "),\n (" ) + 1;
+ $tables[ $table ] = ( $tables[ $table ] ?? 0 ) + $rows;
+ $total += $rows;
+ ++$statements;
+ }
+ return array(
+ 'tables' => $tables,
+ 'total_rows' => $total,
+ 'statements' => $statements,
+ 'sql_bytes' => strlen( $sql ),
+ );
+ }
+
+ /**
+ * Match safe table names: alphanumeric + underscore, must start with $wpdb->prefix.
+ *
+ * Mirrors TMDO_Snapshot_Writer::is_safe_name() so writer and reader apply
+ * the same gate — an attacker-crafted SQL file cannot target tables outside
+ * this site's WordPress prefix.
+ *
+ * @param string $name Table name.
+ * @return bool
+ */
+ private function is_safe_name( string $name ): bool {
+ global $wpdb;
+ return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name )
+ && strpos( $name, $wpdb->prefix ) === 0;
+ }
+}
diff --git a/includes/snapshots/class-tmdo-snapshot-writer.php b/includes/snapshots/class-tmdo-snapshot-writer.php
new file mode 100644
index 0000000..c7e6144
--- /dev/null
+++ b/includes/snapshots/class-tmdo-snapshot-writer.php
@@ -0,0 +1,331 @@
+
+ */
+ private array $opts;
+
+ /**
+ * Constructor.
+ *
+ * @param string $snapshot_id Snapshot ULID.
+ * @param array $scope Resolved scope.
+ * @param array $opts {gzip:bool, inline_threshold_bytes:int, max_tables:int}.
+ */
+ public function __construct( string $snapshot_id, array $scope, array $opts = array() ) {
+ $this->snapshot_id = $snapshot_id;
+ $this->scope = $scope;
+ $this->opts = $opts;
+ }
+
+ /**
+ * Run the dump.
+ *
+ * @return array {storage,file_path,sha256,size_bytes,row_count,inline_blob}
+ * @throws RuntimeException When no tables resolved or filesystem unwritable.
+ */
+ public function write(): array {
+ $tables = $this->resolve_tables();
+ if ( empty( $tables ) ) {
+ throw new RuntimeException( 'snapshot scope produced 0 tables' );
+ }
+
+ $gzip = (bool) ( $this->opts['gzip'] ?? true );
+ $inline_th = (int) ( $this->opts['inline_threshold_bytes'] ?? TMDO_Snapshot_Manager::INLINE_THRESHOLD_BYTES );
+ $dir = TMDO_Snapshot_Manager::backup_dir();
+ $ext = $gzip ? '.sql.gz' : '.sql';
+ $path = trailingslashit( $dir ) . $this->snapshot_id . $ext;
+
+ $handle = $gzip ? gzopen( $path, 'wb6' ) : fopen( $path, 'wb' );
+ if ( false === $handle ) {
+ throw new RuntimeException( 'cannot open ' . $path . ' for write' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
+ }
+ $write = static function ( string $chunk ) use ( $handle, $gzip ): void {
+ if ( $gzip ) {
+ gzwrite( $handle, $chunk );
+ } else {
+ fwrite( $handle, $chunk );
+ }
+ };
+
+ $row_count = 0;
+ $header = $this->dump_header( $tables );
+ $write( $header );
+
+ global $wpdb;
+ foreach ( $tables as $table ) {
+ $row_count += $this->dump_table( $table, $write );
+ }
+ $write( "\n-- end of dump\n" );
+
+ if ( $gzip ) {
+ gzclose( $handle );
+ } else {
+ fclose( $handle );
+ }
+
+ $size = (int) filesize( $path );
+ $sha = hash_file( 'sha256', $path );
+
+ // Decide inline vs file storage.
+ if ( $size <= $inline_th ) {
+ $blob = file_get_contents( $path );
+ @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ return array(
+ 'storage' => 'inline',
+ 'file_path' => null,
+ 'sha256' => $sha,
+ 'size_bytes' => $size,
+ 'row_count' => $row_count,
+ 'inline_blob' => $blob,
+ );
+ }
+
+ return array(
+ 'storage' => 'file',
+ 'file_path' => $path,
+ 'sha256' => $sha,
+ 'size_bytes' => $size,
+ 'row_count' => $row_count,
+ 'inline_blob' => null,
+ );
+ }
+
+ /**
+ * Returns canonical scope (after manager resolution).
+ *
+ * @return array
+ */
+ public function get_scope(): array {
+ return $this->scope;
+ }
+
+ // ─── private ──────────────────────────────────────────────────────────
+
+ /**
+ * Resolve which tables to dump.
+ *
+ * Priority:
+ * 1. Explicit `scope.tables` if provided.
+ * 2. `scope.entities` → wp_postmeta / wp_usermeta / etc.
+ * 3. Empty scope → all WPDO-owned tables (zone + system) + wp_postmeta if classifier active.
+ *
+ * @return array Fully-prefixed table names.
+ */
+ private function resolve_tables(): array {
+ global $wpdb;
+ if ( ! empty( $this->scope['tables'] ) ) {
+ return array_values( array_filter( array_map( 'strval', $this->scope['tables'] ), array( $this, 'is_safe_name' ) ) );
+ }
+
+ $max = (int) ( $this->opts['max_tables'] ?? 200 );
+ $out = array();
+
+ // Always include WPDO system tables.
+ $wpdo_likes = array(
+ $wpdb->prefix . 'wpdo_%',
+ );
+ foreach ( $wpdo_likes as $like ) {
+ if ( class_exists( 'WP_SQLite_Driver' ) ) {
+ $rows = $wpdb->get_col(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB
+ "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE %s",
+ $like
+ )
+ );
+ } else {
+ $rows = $wpdb->get_col(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB
+ 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE %s',
+ $like
+ )
+ );
+ }
+ if ( is_array( $rows ) ) {
+ foreach ( $rows as $t ) {
+ if ( $this->is_safe_name( (string) $t ) ) {
+ $out[ $t ] = true;
+ }
+ }
+ }
+ }
+
+ // Skip wp_wpdo_snapshots itself — we don't want a dump of a dump.
+ unset( $out[ $wpdb->prefix . 'wpdo_snapshots' ] );
+
+ // If `entities` includes 'post', also dump wp_postmeta (mainly used for
+ // pre_v2_upgrade trigger so we have the pre-migration meta).
+ if ( ! empty( $this->scope['entities'] ) ) {
+ $entity_to_table = array(
+ 'post' => $wpdb->postmeta,
+ 'user' => $wpdb->usermeta,
+ 'term' => $wpdb->termmeta ?? ( $wpdb->prefix . 'termmeta' ),
+ 'comment' => $wpdb->commentmeta,
+ );
+ foreach ( $this->scope['entities'] as $entity ) {
+ if ( isset( $entity_to_table[ $entity ] ) ) {
+ $t = $entity_to_table[ $entity ];
+ if ( $this->is_safe_name( $t ) ) {
+ $out[ $t ] = true;
+ }
+ }
+ }
+ }
+
+ $tables = array_keys( $out );
+ if ( count( $tables ) > $max ) {
+ $tables = array_slice( $tables, 0, $max );
+ }
+ sort( $tables );
+ return $tables;
+ }
+
+ /**
+ * Validate a table name (alphanumeric + underscore only, must start with $wpdb->prefix).
+ *
+ * @param string $name Table name.
+ * @return bool
+ */
+ private function is_safe_name( string $name ): bool {
+ global $wpdb;
+ return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name )
+ && strpos( $name, $wpdb->prefix ) === 0;
+ }
+
+ /**
+ * Dump header (timestamp + table list).
+ *
+ * @param array $tables Tables that will be dumped.
+ * @return string
+ */
+ private function dump_header( array $tables ): string {
+ $out = "-- WPDO snapshot {$this->snapshot_id}\n";
+ $out .= '-- Generated: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n";
+ $out .= '-- Tables: ' . count( $tables ) . "\n";
+ $out .= "-- Format: SQL INSERT statements (one per row, batched by table)\n\n";
+ $out .= "SET NAMES utf8mb4;\n";
+ $out .= "SET FOREIGN_KEY_CHECKS=0;\n\n";
+ return $out;
+ }
+
+ /**
+ * Stream-dump one table.
+ *
+ * @param string $table Fully-prefixed table name.
+ * @param callable $write Stream writer.
+ * @return int rows dumped.
+ */
+ private function dump_table( string $table, callable $write ): int {
+ global $wpdb;
+
+ $write( "-- Table {$table}\n" );
+ $count_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
+ if ( 0 === $count_total ) {
+ $write( "-- (empty)\n\n" );
+ return 0;
+ }
+
+ $columns = $wpdb->get_col( "DESC `{$table}`" ); // phpcs:ignore WordPress.DB
+ if ( ! is_array( $columns ) || empty( $columns ) ) {
+ return 0;
+ }
+ $col_list = '`' . implode( '`,`', $columns ) . '`';
+
+ $offset = 0;
+ $rows_dumped = 0;
+ while ( $offset < $count_total ) {
+ $chunk = $wpdb->get_results(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB
+ "SELECT * FROM `{$table}` LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
+ self::CHUNK_SIZE,
+ $offset
+ ),
+ ARRAY_A
+ );
+ if ( ! is_array( $chunk ) || empty( $chunk ) ) {
+ break;
+ }
+ $values = array();
+ foreach ( $chunk as $row ) {
+ $row_vals = array();
+ foreach ( $columns as $col ) {
+ $row_vals[] = $this->escape_sql_value( $row[ $col ] ?? null );
+ }
+ $values[] = '(' . implode( ',', $row_vals ) . ')';
+ }
+ $write( "INSERT INTO `{$table}` ({$col_list}) VALUES " . implode( ",\n ", $values ) . ";\n" );
+ $rows_dumped += count( $chunk );
+ $offset += self::CHUNK_SIZE;
+ }
+ $write( "\n" );
+ return $rows_dumped;
+ }
+
+ /**
+ * Escape a single value for SQL INSERT.
+ *
+ * @param mixed $v Value.
+ * @return string SQL-quoted literal.
+ */
+ private function escape_sql_value( $v ): string {
+ global $wpdb;
+ if ( null === $v ) {
+ return 'NULL';
+ }
+ if ( is_bool( $v ) ) {
+ return $v ? '1' : '0';
+ }
+ if ( is_int( $v ) || is_float( $v ) ) {
+ return (string) $v;
+ }
+ // String / longtext / blob — wrap with hex notation if non-utf8 bytes.
+ $s = (string) $v;
+ // Use mb_check_encoding when available; fall back to a heuristic.
+ $is_utf8 = function_exists( 'mb_check_encoding' ) ? mb_check_encoding( $s, 'UTF-8' ) : ( @iconv( 'UTF-8', 'UTF-8//IGNORE', $s ) === $s ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ if ( ! $is_utf8 ) {
+ return '0x' . bin2hex( $s );
+ }
+ return "'" . esc_sql( $s ) . "'";
+ }
+}
diff --git a/includes/trait-tmdo-anti-eav-aware.php b/includes/trait-tmdo-anti-eav-aware.php
new file mode 100644
index 0000000..3726499
--- /dev/null
+++ b/includes/trait-tmdo-anti-eav-aware.php
@@ -0,0 +1,211 @@
+register_field( $registry, [...] )`
+ * for each post-meta key the plugin manages.
+ *
+ * @param TMDO_Schema_Registry $registry The shared field registry.
+ */
+ public function on_register_fields( TMDO_Schema_Registry $registry ): void {
+ // Default no-op. Override in subclass.
+ }
+
+ /**
+ * Register custom tables owned by this plugin.
+ *
+ * Override in subclass and call `$this->register_custom_table( $r, [...] )`
+ * for each table.
+ *
+ * @param TMDO_Custom_Table_Registry $r The shared custom-table registry.
+ */
+ public function on_register_custom_tables( TMDO_Custom_Table_Registry $r ): void {
+ // Default no-op. Override in subclass.
+ }
+
+ /**
+ * Register entity field groups (user / term / comment / post metadata).
+ *
+ * Override in subclass and call `$this->register_entity_fields( $type, $group, [...] )`
+ * for each logical field group the plugin contributes.
+ */
+ public function on_register_entity_fields(): void {
+ // Default no-op. Override in subclass.
+ }
+
+ // ── Sugar wrappers around WPDO public API ────────────────────────────────
+
+ /**
+ * Register a single zone field via TMDO_Schema_Registry.
+ *
+ * @param TMDO_Schema_Registry $registry Provided by the `wpdo_register_fields` hook.
+ * @param array $field_def Field definition (post_type, meta_key, zone, ...).
+ */
+ protected function register_field( TMDO_Schema_Registry $registry, array $field_def ): void {
+ $registry->register( $this->plugin_slug(), $field_def );
+ }
+
+ /**
+ * Register a custom table via TMDO_Custom_Table_Registry.
+ *
+ * @param TMDO_Custom_Table_Registry $r Provided by the `wpdo_register_custom_tables` hook.
+ * @param array $table_def Table definition (table_name, primary_key, expected_columns, indexes, ...).
+ */
+ protected function register_custom_table( TMDO_Custom_Table_Registry $r, array $table_def ): void {
+ $r->register( $this->plugin_slug(), $table_def );
+ }
+
+ /**
+ * Register an entity field group via TMDO_Entity_Registry.
+ *
+ * @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
+ * @param string $group_name Logical group name (e.g. 'tmos_vendor', 'tmos_audience').
+ * @param array> $fields Field definitions.
+ */
+ protected function register_entity_fields( string $entity_type, string $group_name, array $fields ): bool {
+ if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
+ return false;
+ }
+ return TMDO_Entity_Registry::register_group( $entity_type, $group_name, $fields );
+ }
+
+ // ── Read/write convenience wrappers ──────────────────────────────────────
+
+ /**
+ * Read an entity field via the TMDO_API facade.
+ *
+ * @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
+ * @param int $entity_id Entity primary id.
+ * @param string $key Meta key.
+ * @param bool $single Return single value (default true).
+ */
+ protected function get_field( string $entity_type, int $entity_id, string $key, bool $single = true ): mixed {
+ return TMDO_API::get_entity( $entity_type, $entity_id, $key, $single );
+ }
+
+ /**
+ * Write an entity field via the TMDO_API facade.
+ *
+ * @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
+ * @param int $entity_id Entity primary id.
+ * @param string $key Meta key.
+ * @param mixed $value Meta value.
+ *
+ * @return bool|int False on failure, otherwise the meta id (add) or true (update).
+ */
+ protected function set_field( string $entity_type, int $entity_id, string $key, mixed $value ): bool|int {
+ return TMDO_API::set_entity( $entity_type, $entity_id, $key, $value );
+ }
+
+ /**
+ * Subscribe to write events on a specific meta key.
+ *
+ * Convenience wrapper around `add_action('wpdo_after_write', ...)` that
+ * filters callbacks to the plugin's own keys via prefix match.
+ *
+ * @param string $key_prefix Meta-key prefix to react on (e.g. 'tmos_vendor_').
+ * @param callable $callback `function(string $entity_type, int $entity_id, string $key, mixed $value, bool $ok, string $op, mixed $before)`.
+ * @param int $priority WordPress action priority (default 10).
+ */
+ protected function on_after_write( string $key_prefix, callable $callback, int $priority = 10 ): void {
+ add_action(
+ 'wpdo_after_write',
+ static function ( $entity_type, $entity_id, $meta_key, $meta_value, $ok, $op, $before ) use ( $key_prefix, $callback ): void {
+ if ( is_string( $meta_key ) && str_starts_with( $meta_key, $key_prefix ) ) {
+ $callback( $entity_type, $entity_id, $meta_key, $meta_value, $ok, $op, $before );
+ }
+ },
+ $priority,
+ 7
+ );
+ }
+
+ // ── Backward-compat (legacy 2meet-* mu-plugin trait surface) ─────────────
+ // The pre-v2.17.0 mu-plugin trait stub exposed two abstract static methods
+ // (`register_wpdo_fields`, `register_custom_tables`). Older partner plugins
+ // (e.g. 2meet-inquiries) implemented those static methods. The promoted
+ // trait keeps the names with non-abstract no-op defaults so existing
+ // classes continue to work without modification.
+
+ /**
+ * Legacy entry point for plugins that registered fields via a static method
+ * before v2.17.0. New partner plugins should override `on_register_fields()`
+ * (instance method) instead.
+ *
+ * @deprecated 2.17.0 Use instance method `on_register_fields()` + `bind_anti_eav_hooks()` instead.
+ */
+ public static function register_wpdo_fields(): void {
+ // No-op default. Legacy classes overrode this and called the registry directly.
+ }
+
+ /**
+ * Legacy entry point for plugins that registered custom tables via a static
+ * method before v2.17.0.
+ *
+ * @deprecated 2.17.0 Use instance method `on_register_custom_tables()` + `bind_anti_eav_hooks()` instead.
+ */
+ public static function register_custom_tables(): void {
+ // No-op default.
+ }
+}
+
+} // end if ( ! trait_exists )
diff --git a/includes/zones/class-tmdo-zone-archive.php b/includes/zones/class-tmdo-zone-archive.php
new file mode 100644
index 0000000..4293a55
--- /dev/null
+++ b/includes/zones/class-tmdo-zone-archive.php
@@ -0,0 +1,271 @@
+insert(
+ $table,
+ array(
+ 'post_id' => $post_id,
+ 'post_type' => $post_type,
+ 'meta_key' => $meta_key,
+ 'meta_value' => $meta_value,
+ 'compressed' => $compressed,
+ 'archived_at' => TMDO_DB::now(),
+ 'original_meta_id' => $meta_id,
+ ),
+ array( '%d', '%s', '%s', '%s', '%d', '%s', '%d' )
+ );
+ }
+
+ /**
+ * Archive multiple postmeta entries in batch.
+ *
+ * @param array $entries Array of [post_id, post_type, meta_key, meta_value, meta_id].
+ * @param bool $compress Whether to compress values.
+ * @return void
+ * @throws \Throwable When a batch insert fails and the transaction is rolled back.
+ */
+ public static function archive_batch( array $entries, bool $compress = false ): void {
+ TMDO_DB::begin();
+ try {
+ foreach ( $entries as $entry ) {
+ self::archive(
+ (int) $entry['post_id'],
+ $entry['post_type'],
+ $entry['meta_key'],
+ $entry['meta_value'],
+ (int) ( $entry['meta_id'] ?? 0 ),
+ $compress
+ );
+ }
+ TMDO_DB::commit();
+ } catch ( \Throwable $e ) {
+ TMDO_DB::rollback();
+ throw $e;
+ }
+ }
+
+ /**
+ * Retrieve archived values for a post.
+ *
+ * @param int $post_id Post ID.
+ * @param string|null $meta_key Optional specific meta_key filter.
+ * @return array Array of [meta_key, meta_value, archived_at, compressed].
+ */
+ public static function get( int $post_id, ?string $meta_key = null ): array {
+ global $wpdb;
+ $table = self::table();
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Archive::table() via TMDO_DB::table().
+ if ( $meta_key ) {
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d AND meta_key = %s ORDER BY archived_at DESC",
+ $post_id,
+ $meta_key
+ ),
+ ARRAY_A
+ );
+ } else {
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d ORDER BY archived_at DESC",
+ $post_id
+ ),
+ ARRAY_A
+ );
+ }
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ // Decompress where needed.
+ // Note: must iterate $rows directly (not $rows ?: []) to allow &$row to modify the original array.
+ foreach ( $rows as &$row ) {
+ if ( (int) $row['compressed'] && function_exists( 'gzdecode' ) ) {
+ $decoded = base64_decode( $row['meta_value'] );
+ if ( false !== $decoded ) {
+ $decompressed = gzdecode( $decoded );
+ if ( false !== $decompressed ) {
+ $row['meta_value'] = $decompressed;
+ }
+ }
+ }
+ unset( $row['compressed'] );
+ }
+
+ return $rows ?: array();
+ }
+
+ /**
+ * Restore archived entries back to wp_postmeta.
+ *
+ * @param int $post_id Post ID.
+ * @param string|null $meta_key Optional meta_key filter (null = restore all).
+ * @return int Number of entries restored.
+ */
+ public static function restore( int $post_id, ?string $meta_key = null ): int {
+ $entries = self::get( $post_id, $meta_key );
+ $count = 0;
+
+ foreach ( $entries as $entry ) {
+ update_post_meta( $post_id, $entry['meta_key'], $entry['meta_value'] );
+ ++$count;
+ }
+
+ // Delete restored entries from archive.
+ if ( $count > 0 ) {
+ global $wpdb;
+ $table = self::table();
+
+ if ( $meta_key ) {
+ $wpdb->delete(
+ $table,
+ array(
+ 'post_id' => $post_id,
+ 'meta_key' => $meta_key,
+ ),
+ array( '%d', '%s' )
+ );
+ } else {
+ $wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) );
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Delete all archived entries for a post.
+ *
+ * @param int $post_id Post ID.
+ * @return void
+ */
+ public static function delete( int $post_id ): void {
+ global $wpdb;
+ $wpdb->delete( self::table(), array( 'post_id' => $post_id ), array( '%d' ) );
+ }
+
+ /**
+ * Sweep: archive stale postmeta entries by age.
+ *
+ * Finds postmeta for trashed/deleted posts older than $days and archives them.
+ *
+ * @param int $days Minimum age in days.
+ * @param bool $compress Whether to compress.
+ * @param int $limit Maximum rows per sweep.
+ * @return int Number of entries archived.
+ */
+ public static function sweep( int $days = 90, bool $compress = true, int $limit = 500 ): int {
+ global $wpdb;
+
+ $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
+
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE p.post_status = 'trash'
+ AND p.post_modified_gmt < %s
+ LIMIT %d",
+ $cutoff,
+ $limit
+ ),
+ ARRAY_A
+ );
+
+ if ( ! $rows ) {
+ return 0;
+ }
+
+ $entries = array();
+ foreach ( $rows as $row ) {
+ $entries[] = array(
+ 'post_id' => $row['post_id'],
+ 'post_type' => $row['post_type'],
+ 'meta_key' => $row['meta_key'],
+ 'meta_value' => $row['meta_value'],
+ 'meta_id' => $row['meta_id'],
+ );
+ }
+
+ self::archive_batch( $entries, $compress );
+
+ return count( $entries );
+ }
+
+ /**
+ * Get archive statistics.
+ *
+ * @return array{total_rows: int, compressed_rows: int, post_types: array}
+ */
+ public static function stats(): array {
+ global $wpdb;
+ $table = self::table();
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
+ $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
+ $compressed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE compressed = 1" );
+
+ $types = $wpdb->get_results(
+ "SELECT post_type, COUNT(*) as cnt FROM `{$table}` GROUP BY post_type ORDER BY cnt DESC",
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ return array(
+ 'total_rows' => $total,
+ 'compressed_rows' => $compressed,
+ 'post_types' => $types ?: array(),
+ );
+ }
+}
diff --git a/includes/zones/class-tmdo-zone-cold.php b/includes/zones/class-tmdo-zone-cold.php
new file mode 100644
index 0000000..6b997a3
--- /dev/null
+++ b/includes/zones/class-tmdo-zone-cold.php
@@ -0,0 +1,262 @@
+get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $table )
+ );
+ } else {
+ $exists = $wpdb->get_var(
+ $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table )
+ );
+ }
+
+ if ( ! $exists ) {
+ TMDO_Installer::create_cold_table( $post_type );
+ }
+ }
+
+ /**
+ * Get a single cold meta value for a post.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @param string $meta_key Meta key.
+ * @return mixed|null
+ */
+ public static function get( int $post_id, string $post_type, string $meta_key ): mixed {
+ $data = self::get_blob( $post_id, $post_type );
+ return $data[ $meta_key ] ?? null;
+ }
+
+ /**
+ * Get the full JSON blob for a post, with Object Cache layer.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @return array Decoded JSON data (meta_key => value).
+ */
+ public static function get_blob( int $post_id, string $post_type ): array {
+ $group = self::cache_group( $post_type );
+ $cache_key = "cold_{$post_id}";
+
+ $cached = wp_cache_get( $cache_key, $group );
+ if ( false !== $cached ) {
+ return is_array( $cached ) ? $cached : array();
+ }
+
+ global $wpdb;
+ $table = self::table( $post_type );
+
+ $json = $wpdb->get_var(
+ $wpdb->prepare( "SELECT data FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table().
+ );
+
+ $data = $json ? json_decode( $json, true ) : array();
+ if ( ! is_array( $data ) ) {
+ $data = array();
+ }
+
+ // Get cache TTL from registry — 粒度化策略:取所有 cold fields 的最小 TTL,
+ // 確保任何短 TTL 欄位(如 IG token:v2.0.4 起預期 < 5 min)能在保護期內失效,
+ // 而非被某個長 TTL 欄位拖拽到 1h 才更新。原本的「取第一個」策略會視註冊順序
+ // 而異,無法保證安全。
+ // 同時提供 `wpdo_cold_cache_ttl` filter 讓 partner integration 進一步覆蓋.
+ $fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'cold', $post_type );
+ $ttls = array();
+ foreach ( $fields as $field ) {
+ if ( ! empty( $field['cache_ttl'] ) ) {
+ $ttls[] = (int) $field['cache_ttl'];
+ }
+ }
+ $ttl = empty( $ttls ) ? HOUR_IN_SECONDS : min( $ttls );
+
+ /**
+ * Filter the effective cache TTL for a cold zone post type.
+ *
+ * @since 2.0.5
+ * @param int $ttl Computed TTL (min of all registered fields, or HOUR_IN_SECONDS default).
+ * @param string $post_type Post type being cached.
+ * @param int $post_id Post ID (entity being read).
+ * @param array $fields Fields registry config for this post_type.
+ */
+ $ttl = (int) apply_filters( 'wpdo_cold_cache_ttl', $ttl, $post_type, $post_id, $fields );
+
+ wp_cache_set( $cache_key, $data, $group, $ttl );
+
+ return $data;
+ }
+
+ /**
+ * Set a single cold meta value for a post.
+ * Merges into the existing JSON blob.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @param string $meta_key Meta key.
+ * @param mixed $value Value to store.
+ */
+ public static function set( int $post_id, string $post_type, string $meta_key, mixed $value ): void {
+ $data = self::get_blob_raw( $post_id, $post_type );
+ $data[ $meta_key ] = $value;
+ self::save_blob( $post_id, $post_type, $data );
+ }
+
+ /**
+ * Set multiple cold meta values at once.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @param array $values meta_key => value pairs.
+ */
+ public static function set_many( int $post_id, string $post_type, array $values ): void {
+ $data = self::get_blob_raw( $post_id, $post_type );
+ $data = array_merge( $data, $values );
+ self::save_blob( $post_id, $post_type, $data );
+ }
+
+ /**
+ * Remove a key from the cold blob.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type slug.
+ * @param string $meta_key Meta key to remove.
+ * @return void
+ */
+ public static function remove( int $post_id, string $post_type, string $meta_key ): void {
+ $data = self::get_blob_raw( $post_id, $post_type );
+ unset( $data[ $meta_key ] );
+ self::save_blob( $post_id, $post_type, $data );
+ }
+
+ /**
+ * Delete the entire cold row for a post.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type slug.
+ * @return void
+ */
+ public static function delete( int $post_id, string $post_type ): void {
+ global $wpdb;
+ $wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
+ wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────
+
+ /**
+ * Get raw blob from DB (no cache).
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type slug.
+ * @return array Decoded JSON data array.
+ */
+ private static function get_blob_raw( int $post_id, string $post_type ): array {
+ global $wpdb;
+ $table = self::table( $post_type );
+
+ $json = $wpdb->get_var(
+ $wpdb->prepare( "SELECT data FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table().
+ );
+
+ $data = $json ? json_decode( $json, true ) : array();
+ return is_array( $data ) ? $data : array();
+ }
+
+ /**
+ * Save the JSON blob and invalidate cache.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type slug.
+ * @param array $data Key-value pairs to store as JSON.
+ * @return void
+ */
+ private static function save_blob( int $post_id, string $post_type, array $data ): void {
+ global $wpdb;
+ $table = self::table( $post_type );
+ $now = TMDO_DB::now();
+ $json = wp_json_encode( $data );
+
+ $existing = $wpdb->get_var(
+ $wpdb->prepare( "SELECT id FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table().
+ );
+
+ if ( $existing ) {
+ $wpdb->update(
+ $table,
+ array(
+ 'data' => $json,
+ 'updated_at' => $now,
+ ),
+ array( 'post_id' => $post_id ),
+ array( '%s', '%s' ),
+ array( '%d' )
+ );
+ } else {
+ $wpdb->insert(
+ $table,
+ array(
+ 'post_id' => $post_id,
+ 'data' => $json,
+ 'updated_at' => $now,
+ ),
+ array( '%d', '%s', '%s' )
+ );
+ }
+
+ // Invalidate object cache.
+ wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
+ }
+}
diff --git a/includes/zones/class-tmdo-zone-hot.php b/includes/zones/class-tmdo-zone-hot.php
new file mode 100644
index 0000000..2657959
--- /dev/null
+++ b/includes/zones/class-tmdo-zone-hot.php
@@ -0,0 +1,212 @@
+get_hot_columns( $post_type );
+ if ( empty( $columns ) ) {
+ return;
+ }
+
+ global $wpdb;
+ $table = self::table( $post_type );
+
+ // Quick existence check.
+ if ( TMDO_IS_SQLITE ) {
+ $exists = $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $table )
+ );
+ } else {
+ $exists = $wpdb->get_var(
+ $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table )
+ );
+ }
+
+ if ( ! $exists ) {
+ TMDO_Installer::create_hot_table( $post_type, $columns );
+ return;
+ }
+
+ // v2.1.2 critical fix: ensure existing tables have all columns declared
+ // by Schema_Registry. Partner plugins registering new hot fields after
+ // the initial migration would otherwise silently fall back to postmeta.
+ //
+ // v2.1.3 optimization: schema fingerprint stored in option. When the hash
+ // of declared columns matches the stored hash, skip SHOW COLUMNS entirely.
+ // Only diff + ALTER fires when fingerprint actually changed (drift detected).
+ // This avoids per-request SHOW COLUMNS overhead on stable production sites.
+ static $checked = array();
+ if ( isset( $checked[ $post_type ] ) ) {
+ return;
+ }
+
+ $fingerprint = self::compute_fingerprint( $columns );
+ $opt_key = 'wpdo_hot_fp_' . $post_type;
+ $stored_fp = (string) get_option( $opt_key, '' );
+
+ if ( $stored_fp === $fingerprint ) {
+ // Schema unchanged since last successful ensure → no need to SHOW COLUMNS.
+ $checked[ $post_type ] = true;
+ return;
+ }
+
+ // Fingerprint mismatch (or first run) → run diff + ALTER, then store new hash.
+ TMDO_Installer::ensure_hot_columns( $post_type, $columns );
+ update_option( $opt_key, $fingerprint, false );
+ $checked[ $post_type ] = true;
+ }
+
+ /**
+ * Stable hash of declared column definitions. Used to short-circuit SHOW COLUMNS
+ * when Schema_Registry hasn't changed since the last successful ensure.
+ *
+ * @param array $columns column_name => sql_type map.
+ * @return string Short SHA-1 prefix (10 chars — collision-safe at our scale).
+ *
+ * @since 2.1.3
+ */
+ private static function compute_fingerprint( array $columns ): string {
+ ksort( $columns );
+ return substr( sha1( wp_json_encode( $columns ) ?: '' ), 0, 10 );
+ }
+
+ /**
+ * Read a single field value from the hot table.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @param string $column Column name in hot table.
+ * @return mixed|null Value or null if not found.
+ */
+ public static function get( int $post_id, string $post_type, string $column ): mixed {
+ global $wpdb;
+ $table = self::table( $post_type );
+ $column = sanitize_key( $column );
+
+ return $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT `{$column}` FROM `{$table}` WHERE post_id = %d LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column from sanitize_key(); table from TMDO_DB::table().
+ $post_id
+ )
+ );
+ }
+
+ /**
+ * Read all hot fields for a post as an associative array.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @return array|null Column => value pairs, or null.
+ */
+ public static function get_row( int $post_id, string $post_type ): ?array {
+ global $wpdb;
+ $table = self::table( $post_type );
+
+ $row = $wpdb->get_row(
+ $wpdb->prepare( "SELECT * FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Hot::table() via TMDO_DB::table().
+ ARRAY_A
+ );
+
+ return $row ?: null;
+ }
+
+ /**
+ * Upsert a single field into the hot table.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @param string $column Column name.
+ * @param mixed $value Value to set.
+ */
+ public static function set( int $post_id, string $post_type, string $column, mixed $value ): void {
+ $table = self::table( $post_type );
+ $column = sanitize_key( $column );
+ $now = TMDO_DB::now();
+
+ TMDO_DB::upsert(
+ $table,
+ array(
+ 'post_id' => $post_id,
+ $column => $value,
+ 'updated_at' => $now,
+ ),
+ array( $column, 'updated_at' ),
+ 'post_id'
+ );
+ }
+
+ /**
+ * Upsert multiple fields at once for a post.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type.
+ * @param array $data Column => value pairs.
+ */
+ public static function set_many( int $post_id, string $post_type, array $data ): void {
+ $table = self::table( $post_type );
+ $now = TMDO_DB::now();
+
+ $data['post_id'] = $post_id;
+ $data['updated_at'] = $now;
+ $update_cols = array_diff( array_keys( $data ), array( 'post_id' ) );
+
+ TMDO_DB::upsert( $table, $data, $update_cols, 'post_id' );
+ }
+
+ /**
+ * Delete a post's row from the hot table.
+ *
+ * @param int $post_id Post ID.
+ * @param string $post_type Post type slug.
+ * @return void
+ */
+ public static function delete( int $post_id, string $post_type ): void {
+ global $wpdb;
+ $wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
+ }
+
+ /**
+ * Get all post types that have hot zone tables registered.
+ *
+ * @return string[]
+ */
+ public static function get_post_types(): array {
+ return TMDO_Schema_Registry::instance()->get_hot_post_types();
+ }
+}
diff --git a/includes/zones/class-tmdo-zone-warm.php b/includes/zones/class-tmdo-zone-warm.php
new file mode 100644
index 0000000..2599f1c
--- /dev/null
+++ b/includes/zones/class-tmdo-zone-warm.php
@@ -0,0 +1,193 @@
+get_var(
+ $wpdb->prepare(
+ "SELECT meta_value FROM `{$table}`
+ WHERE post_id = %d AND meta_key = %s
+ AND (expires_at IS NULL OR expires_at > %s)
+ LIMIT 1",
+ $post_id,
+ $meta_key,
+ $now
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ return $val;
+ }
+
+ /**
+ * Read all warm values for a post.
+ *
+ * @param int $post_id Post ID.
+ * @return array meta_key => meta_value pairs.
+ */
+ public static function get_all( int $post_id ): array {
+ global $wpdb;
+ $table = self::table();
+ $now = TMDO_DB::now();
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT meta_key, meta_value FROM `{$table}`
+ WHERE post_id = %d AND (expires_at IS NULL OR expires_at > %s)",
+ $post_id,
+ $now
+ ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $result = array();
+ foreach ( $rows ?: array() as $row ) {
+ $result[ $row['meta_key'] ] = $row['meta_value'];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Set a value in the warm zone with optional TTL.
+ *
+ * @param int $post_id Post ID.
+ * @param string $meta_key Meta key.
+ * @param string $value Value to store.
+ * @param int|null $ttl TTL in seconds. Null = no expiry.
+ */
+ public static function set( int $post_id, string $meta_key, string $value, ?int $ttl = null ): void {
+ global $wpdb;
+ $table = self::table();
+ $now = TMDO_DB::now();
+
+ $expires_at = null;
+ if ( $ttl && $ttl > 0 ) {
+ $expires_at = gmdate( 'Y-m-d H:i:s', time() + $ttl );
+ }
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
+ $existing = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT id FROM `{$table}` WHERE post_id = %d AND meta_key = %s LIMIT 1",
+ $post_id,
+ $meta_key
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( $existing ) {
+ $update_data = array( 'meta_value' => $value );
+ $update_format = array( '%s' );
+ if ( null !== $expires_at ) {
+ $update_data['expires_at'] = $expires_at;
+ $update_format[] = '%s';
+ }
+ $wpdb->update( $table, $update_data, array( 'id' => (int) $existing ), $update_format, array( '%d' ) );
+ } else {
+ $wpdb->insert(
+ $table,
+ array(
+ 'post_id' => $post_id,
+ 'meta_key' => $meta_key,
+ 'meta_value' => $value,
+ 'expires_at' => $expires_at,
+ 'created_at' => $now,
+ ),
+ array( '%d', '%s', '%s', $expires_at ? '%s' : null, '%s' )
+ );
+ }
+ }
+
+ /**
+ * Delete a specific key from the warm zone.
+ *
+ * @param int $post_id Post ID.
+ * @param string $meta_key Meta key to delete.
+ * @return void
+ */
+ public static function delete( int $post_id, string $meta_key ): void {
+ global $wpdb;
+ $wpdb->delete(
+ self::table(),
+ array(
+ 'post_id' => $post_id,
+ 'meta_key' => $meta_key,
+ ),
+ array( '%d', '%s' )
+ );
+ }
+
+ /**
+ * Delete all warm entries for a post.
+ *
+ * @param int $post_id Post ID.
+ * @return void
+ */
+ public static function delete_all( int $post_id ): void {
+ global $wpdb;
+ $wpdb->delete( self::table(), array( 'post_id' => $post_id ), array( '%d' ) );
+ }
+
+ /**
+ * Purge all expired entries (called by cron).
+ *
+ * @return int Number of rows deleted.
+ */
+ public static function purge_expired(): int {
+ global $wpdb;
+ $table = self::table();
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
+ return (int) $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM `{$table}` WHERE expires_at IS NOT NULL AND expires_at < %s",
+ TMDO_DB::now()
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+}
diff --git a/languages/2meet-data-optimizer-zh_TW.mo b/languages/2meet-data-optimizer-zh_TW.mo
new file mode 100644
index 0000000..7e8c670
Binary files /dev/null and b/languages/2meet-data-optimizer-zh_TW.mo differ
diff --git a/languages/2meet-data-optimizer-zh_TW.po b/languages/2meet-data-optimizer-zh_TW.po
new file mode 100644
index 0000000..2febb17
--- /dev/null
+++ b/languages/2meet-data-optimizer-zh_TW.po
@@ -0,0 +1,387 @@
+# WP Data Optimizer — 繁體中文 (zh_TW)
+# Copyright (C) 2026 2meet.io
+# This file is distributed under the same license as the WP Data Optimizer plugin.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: WP Data Optimizer 1.3.14\n"
+"Report-Msgid-Bugs-To: https://2meet.io\n"
+"POT-Creation-Date: 2026-03-30T00:00:00+08:00\n"
+"PO-Revision-Date: 2026-03-30T00:00:00+08:00\n"
+"Last-Translator: 2meet.io \n"
+"Language-Team: Traditional Chinese \n"
+"Language: zh_TW\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+"X-Generator: Claude Code\n"
+"X-Domain: wp-data-optimizer\n"
+
+#. Plugin URI of the plugin.
+msgid "https://2meet.io"
+msgstr "https://2meet.io"
+
+#. Description of the plugin.
+msgid "Four-zone data optimization for WordPress — migrates wp_postmeta to dedicated custom tables (Hot/Warm/Cold/Archive). Fully replaces HP Custom Tables."
+msgstr "WordPress 四象限資料最佳化外掛 — 將 wp_postmeta 遷移至專屬自訂資料表(Hot/Warm/Cold/Archive),完整取代 HP Custom Tables。"
+
+#. Author of the plugin.
+msgid "2meet.io"
+msgstr "2meet.io"
+
+#. Plugin Name of the plugin.
+#: admin/class-wpdo-admin.php:36
+#: admin/class-wpdo-admin.php:37
+#: admin/class-wpdo-admin.php:101
+msgid "WP Data Optimizer"
+msgstr "WP 資料最佳化器"
+
+#: admin/class-wpdo-admin.php:82
+msgid "儀表板"
+msgstr "儀表板"
+
+#: admin/class-wpdo-admin.php:83
+msgid "Zone 配置"
+msgstr "Zone 配置"
+
+#: admin/class-wpdo-admin.php:84
+#: admin/class-wpdo-admin.php:473
+msgid "遷移狀態"
+msgstr "遷移狀態"
+
+#: admin/class-wpdo-admin.php:85
+msgid "自動分類"
+msgstr "自動分類"
+
+#: admin/class-wpdo-admin.php:86
+msgid "日誌"
+msgstr "日誌"
+
+#: admin/class-wpdo-admin.php:96
+msgid "HPCT 匯入"
+msgstr "HPCT 匯入"
+
+#: admin/class-wpdo-admin.php:193
+msgid "系統概覽"
+msgstr "系統概覽"
+
+#: admin/class-wpdo-admin.php:197
+msgid "資料庫引擎"
+msgstr "資料庫引擎"
+
+#: admin/class-wpdo-admin.php:201
+msgid "外掛版本"
+msgstr "外掛版本"
+
+#: admin/class-wpdo-admin.php:205
+msgid "DB Schema 版本"
+msgstr "DB Schema 版本"
+
+#: admin/class-wpdo-admin.php:209
+msgid "HivePress"
+msgstr "HivePress"
+
+#: admin/class-wpdo-admin.php:213
+msgid "HP Custom Tables"
+msgstr "HP Custom Tables"
+
+#: admin/class-wpdo-admin.php:227
+msgid "Object Cache"
+msgstr "物件快取"
+
+#: admin/class-wpdo-admin.php:240
+msgid "Zone 欄位統計"
+msgstr "Zone 欄位統計"
+
+#: admin/class-wpdo-admin.php:243
+msgid "欄位數"
+msgstr "欄位數"
+
+#: admin/class-wpdo-admin.php:250
+msgid "總計"
+msgstr "總計"
+
+#: admin/class-wpdo-admin.php:257
+#: admin/class-wpdo-admin.php:262
+#: admin/class-wpdo-admin.php:276
+msgid "模組"
+msgstr "模組"
+
+#: admin/class-wpdo-admin.php:262
+#: admin/class-wpdo-admin.php:276
+msgid "狀態"
+msgstr "狀態"
+
+#: admin/class-wpdo-admin.php:291
+msgid "Zone 即時狀態"
+msgstr "Zone 即時狀態"
+
+#: admin/class-wpdo-admin.php:295
+msgid "即時概覽"
+msgstr "即時概覽"
+
+#: admin/class-wpdo-admin.php:299
+msgid "有效條目"
+msgstr "有效條目"
+
+#: admin/class-wpdo-admin.php:303
+msgid "24h 內到期"
+msgstr "24 小時內到期"
+
+#: admin/class-wpdo-admin.php:310
+msgid "Top 10 瀏覽數 (Warm)"
+msgstr "Top 10 瀏覽數(暖區)"
+
+#: admin/class-wpdo-admin.php:315
+msgid "瀏覽數"
+msgstr "瀏覽數"
+
+#: admin/class-wpdo-admin.php:328
+msgid "目前無暖區瀏覽數據。"
+msgstr "目前無暖區瀏覽數據。"
+
+#: admin/class-wpdo-admin.php:333
+msgid "歸檔統計"
+msgstr "歸檔統計"
+
+#: admin/class-wpdo-admin.php:337
+msgid "總歸檔筆數"
+msgstr "總歸檔筆數"
+
+#: admin/class-wpdo-admin.php:341
+msgid "已壓縮"
+msgstr "已壓縮"
+
+#: admin/class-wpdo-admin.php:356
+msgid "按 Post Type"
+msgstr "按文章類型"
+
+#: admin/class-wpdo-admin.php:361
+msgid "筆數"
+msgstr "筆數"
+
+#: admin/class-wpdo-admin.php:374
+msgid "目前無歸檔資料。"
+msgstr "目前無歸檔資料。"
+
+#: admin/class-wpdo-admin.php:391
+msgid "Zone 欄位映射"
+msgstr "Zone 欄位映射"
+
+#: admin/class-wpdo-admin.php:392
+msgid "以下是所有已註冊的 postmeta 欄位及其 Zone 分配。可透過 wpdo_register_fields action 或 HivePress 整合自動註冊。"
+msgstr "以下是所有已註冊的 postmeta 欄位及其 Zone 分配。可透過 wpdo_register_fields action 或 HivePress 整合自動註冊。"
+
+#: admin/class-wpdo-admin.php:395
+msgid "尚無已註冊的欄位映射。啟用 HivePress 或手動註冊欄位後即會顯示。"
+msgstr "尚無已註冊的欄位映射。啟用 HivePress 或手動註冊欄位後即會顯示。"
+
+#: admin/class-wpdo-admin.php:434
+msgid "Object Cache 管理"
+msgstr "物件快取管理"
+
+#: admin/class-wpdo-admin.php:435
+msgid "清除指定 Post Type 的 Zone C Object Cache 群組。"
+msgstr "清除指定文章類型的 Zone C 物件快取群組。"
+
+#: admin/class-wpdo-admin.php:439
+msgid "Post Type"
+msgstr "文章類型"
+
+#: admin/class-wpdo-admin.php:440
+msgid "操作"
+msgstr "操作"
+
+#: admin/class-wpdo-admin.php:450
+msgid "Flush Cache"
+msgstr "清除快取"
+
+#: admin/class-wpdo-admin.php:474
+msgid "管理各模組的資料遷移狀態。使用 WP-CLI 執行遷移操作:wp wpdo migrate "
+msgstr "管理各模組的資料遷移狀態。使用 WP-CLI 執行遷移操作:wp wpdo migrate "
+
+#: admin/class-wpdo-admin.php:477
+msgid "尚無遷移記錄。使用 wp wpdo migrate 指令開始遷移。"
+msgstr "尚無遷移記錄。使用 wp wpdo migrate 指令開始遷移。"
+
+#: admin/class-wpdo-admin.php:484
+msgid "模組狀態"
+msgstr "模組狀態"
+
+#: admin/class-wpdo-admin.php:485
+msgid "進度"
+msgstr "進度"
+
+#: admin/class-wpdo-admin.php:486
+msgid "錯誤數"
+msgstr "錯誤數"
+
+#: admin/class-wpdo-admin.php:487
+msgid "開始時間"
+msgstr "開始時間"
+
+#: admin/class-wpdo-admin.php:488
+msgid "完成時間"
+msgstr "完成時間"
+
+#: admin/class-wpdo-admin.php:517
+msgid "CLI 指令參考"
+msgstr "CLI 指令參考"
+
+#: admin/class-wpdo-admin.php:520
+msgid "開始或繼續遷移"
+msgstr "開始或繼續遷移"
+
+#: admin/class-wpdo-admin.php:521
+msgid "驗證資料一致性"
+msgstr "驗證資料一致性"
+
+#: admin/class-wpdo-admin.php:522
+msgid "切換讀取來源到自訂表"
+msgstr "切換讀取來源到自訂資料表"
+
+#: admin/class-wpdo-admin.php:523
+msgid "回滾到 postmeta"
+msgstr "回滾至 postmeta"
+
+#: admin/class-wpdo-admin.php:524
+msgid "標記模組為完成"
+msgstr "標記模組為完成"
+
+#: admin/class-wpdo-admin.php:550
+msgid "Zone 自動分類器"
+msgstr "Zone 自動分類器"
+
+#: admin/class-wpdo-admin.php:551
+msgid "分析 wp_postmeta 中的欄位,根據值的特徵自動建議最佳 Zone 分配。"
+msgstr "分析 wp_postmeta 中的欄位,根據值的特徵自動建議最佳 Zone 分配。"
+
+#: admin/class-wpdo-admin.php:556
+msgid "選擇 Post Type:"
+msgstr "選擇文章類型:"
+
+#: admin/class-wpdo-admin.php:558
+msgid "— 選擇 —"
+msgstr "— 請選擇 —"
+
+#: admin/class-wpdo-admin.php:563
+msgid "分析"
+msgstr "分析"
+
+#: admin/class-wpdo-admin.php:573
+msgid "此 post type 沒有可分析的 postmeta 欄位。"
+msgstr "此文章類型沒有可分析的 postmeta 欄位。"
+
+#: admin/class-wpdo-admin.php:582
+msgid "分類摘要"
+msgstr "分類摘要"
+
+#: admin/class-wpdo-admin.php:585
+#: admin/class-wpdo-admin.php:586
+#: admin/class-wpdo-admin.php:587
+#: admin/class-wpdo-admin.php:588
+msgid "建議欄位"
+msgstr "建議欄位"
+
+#: admin/class-wpdo-admin.php:589
+msgid "已分配"
+msgstr "已分配"
+
+#: admin/class-wpdo-admin.php:589
+msgid "欄位"
+msgstr "欄位"
+
+#: admin/class-wpdo-admin.php:599
+msgid "資料筆數"
+msgstr "資料筆數"
+
+#: admin/class-wpdo-admin.php:600
+msgid "建議 Zone"
+msgstr "建議 Zone"
+
+#: admin/class-wpdo-admin.php:601
+msgid "信心度"
+msgstr "信心度"
+
+#: admin/class-wpdo-admin.php:602
+msgid "目前分配"
+msgstr "目前分配"
+
+#: admin/class-wpdo-admin.php:603
+msgid "原因"
+msgstr "原因"
+
+#. translators: %1$d: number of deleted logs, %2$d: number of days
+#: admin/class-wpdo-admin.php:641
+msgid "已清除 %d 筆超過 %d 天的日誌。"
+msgstr "已清除 %d 筆超過 %d 天的日誌。"
+
+#: admin/class-wpdo-admin.php:647
+msgid "錯誤日誌"
+msgstr "錯誤日誌"
+
+#: admin/class-wpdo-admin.php:651
+msgid "清除超過"
+msgstr "清除超過"
+
+#: admin/class-wpdo-admin.php:653
+msgid "天的日誌"
+msgstr "天的日誌"
+
+#: admin/class-wpdo-admin.php:655
+msgid "清除"
+msgstr "清除"
+
+#: admin/class-wpdo-admin.php:659
+msgid "沒有錯誤記錄。系統運作正常。"
+msgstr "沒有錯誤記錄。系統運作正常。"
+
+#: admin/class-wpdo-admin.php:699
+msgid "HPCT 匯入成功!建議停用 HP Custom Tables 外掛。"
+msgstr "HPCT 匯入成功!建議停用 HP Custom Tables 外掛。"
+
+#: admin/class-wpdo-admin.php:707
+msgid "HP Custom Tables 匯入"
+msgstr "HP Custom Tables 匯入"
+
+#: admin/class-wpdo-admin.php:711
+msgid "HPCT 設定已匯入完成。如果 HP Custom Tables 外掛仍然啟用,建議停用它。"
+msgstr "HPCT 設定已匯入完成。如果 HP Custom Tables 外掛仍然啟用,建議停用它。"
+
+#: admin/class-wpdo-admin.php:714
+msgid "偵測到 HP Custom Tables 外掛。匯入會將 HPCT 的模組狀態和遷移記錄複製到 WPDO,然後由 WPDO 接管所有攔截器。"
+msgstr "偵測到 HP Custom Tables 外掛。匯入會將 HPCT 的模組狀態和遷移記錄複製到 WPDO,然後由 WPDO 接管所有攔截器。"
+
+#: admin/class-wpdo-admin.php:721
+msgid "匯入預覽"
+msgstr "匯入預覽"
+
+#: admin/class-wpdo-admin.php:725
+msgid "HPCT 模組"
+msgstr "HPCT 模組"
+
+#: admin/class-wpdo-admin.php:726
+msgid "HPCT 狀態"
+msgstr "HPCT 狀態"
+
+#: admin/class-wpdo-admin.php:727
+msgid "WPDO 狀態"
+msgstr "WPDO 狀態"
+
+#: admin/class-wpdo-admin.php:743
+msgid "執行匯入"
+msgstr "執行匯入"
+
+#: admin/class-wpdo-admin.php:748
+msgid "未偵測到 HP Custom Tables 外掛,或已完成匯入。"
+msgstr "未偵測到 HP Custom Tables 外掛,或已完成匯入。"
+
+#. translators: %s: link to the HPCT import page
+#: includes/class-wpdo-core.php:257
+msgid "HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。"
+msgstr "HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。"
+
+#: includes/class-wpdo-core.php:258
+msgid "HPCT 匯入頁面"
+msgstr "HPCT 匯入頁面"
diff --git a/languages/2meet-data-optimizer.pot b/languages/2meet-data-optimizer.pot
new file mode 100644
index 0000000..e3e7b89
--- /dev/null
+++ b/languages/2meet-data-optimizer.pot
@@ -0,0 +1,541 @@
+# Copyright (C) 2026 2meet.io
+# This file is distributed under the same license as the WP Data Optimizer plugin.
+msgid ""
+msgstr ""
+"Project-Id-Version: WP Data Optimizer 1.3.34\n"
+"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/wp-data-optimizer\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: LANGUAGE \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"POT-Creation-Date: 2026-04-12T06:53:18+00:00\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"X-Generator: WP-CLI 2.12.0\n"
+"X-Domain: wp-data-optimizer\n"
+
+#. Plugin Name of the plugin
+#: wp-data-optimizer.php
+#: admin/class-wpdo-admin.php:42
+#: admin/class-wpdo-admin.php:43
+#: admin/class-wpdo-admin.php:141
+msgid "WP Data Optimizer"
+msgstr ""
+
+#. Plugin URI of the plugin
+#: wp-data-optimizer.php
+msgid "https://2meet.io"
+msgstr ""
+
+#. Description of the plugin
+#: wp-data-optimizer.php
+msgid "Four-zone data optimization for WordPress — migrates wp_postmeta to dedicated custom tables (Hot/Warm/Cold/Archive). Fully replaces HP Custom Tables."
+msgstr ""
+
+#. Author of the plugin
+#: wp-data-optimizer.php
+msgid "2meet.io"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:121
+msgid "儀表板"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:122
+msgid "Zone 配置"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:123
+#: admin/class-wpdo-admin.php:583
+msgid "遷移狀態"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:124
+msgid "自動分類"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:125
+msgid "日誌"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:126
+#: admin/class-wpdo-admin.php:806
+msgid "REST API"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:136
+msgid "HPCT 匯入"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:245
+msgid "系統概覽"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:249
+msgid "資料庫引擎"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:253
+msgid "外掛版本"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:257
+msgid "DB Schema 版本"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:261
+msgid "HivePress"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:265
+msgid "HP Custom Tables"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:279
+msgid "Object Cache"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:292
+msgid "Zone 欄位統計"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:295
+msgid "欄位數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:302
+msgid "總計"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:309
+msgid "模組狀態"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:314
+#: admin/class-wpdo-admin.php:328
+#: admin/class-wpdo-admin.php:592
+msgid "模組"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:314
+#: admin/class-wpdo-admin.php:328
+#: admin/class-wpdo-admin.php:594
+msgid "狀態"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:343
+msgid "Zone 即時狀態"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:347
+msgid "即時概覽"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:351
+msgid "有效條目"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:355
+msgid "24h 內到期"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:362
+msgid "Top 10 瀏覽數 (Warm)"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:367
+msgid "瀏覽數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:380
+msgid "目前無暖區瀏覽數據。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:385
+msgid "歸檔統計"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:389
+msgid "總歸檔筆數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:393
+msgid "已壓縮"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:408
+msgid "按 Post Type"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:413
+msgid "筆數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:426
+msgid "目前無歸檔資料。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:434
+msgid "REST API 速率限制統計"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:436
+msgid "POST /view 端點因 IP 或 Cookie 重複計數而被拒絕的次數(HTTP 429)。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:452
+msgid "重置統計"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:458
+msgid "總 429 事件"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:462
+msgid "受影響的 Post 數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:469
+msgid "Top 10 被限速 Post"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:474
+msgid "429 次數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:487
+msgid "目前無速率限制事件記錄。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:501
+msgid "Zone 欄位映射"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:502
+msgid "以下是所有已註冊的 postmeta 欄位及其 Zone 分配。可透過 wpdo_register_fields action 或 HivePress 整合自動註冊。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:505
+msgid "尚無已註冊的欄位映射。啟用 HivePress 或手動註冊欄位後即會顯示。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:544
+msgid "Object Cache 管理"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:545
+msgid "清除指定 Post Type 的 Zone C Object Cache 群組。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:549
+msgid "Post Type"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:550
+msgid "操作"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:560
+msgid "Flush Cache"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:584
+msgid "管理各模組的資料遷移狀態。使用 WP-CLI 執行遷移操作:wp wpdo migrate "
+msgstr ""
+
+#: admin/class-wpdo-admin.php:587
+msgid "尚無遷移記錄。使用 wp wpdo migrate 指令開始遷移。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:595
+msgid "進度"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:596
+msgid "錯誤數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:597
+msgid "開始時間"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:598
+msgid "完成時間"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:627
+msgid "CLI 指令參考"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:630
+msgid "開始或繼續遷移"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:631
+msgid "驗證資料一致性"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:632
+msgid "切換讀取來源到自訂表"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:633
+msgid "回滾到 postmeta"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:634
+msgid "標記模組為完成"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:660
+msgid "Zone 自動分類器"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:661
+msgid "分析 wp_postmeta 中的欄位,根據值的特徵自動建議最佳 Zone 分配。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:666
+msgid "選擇 Post Type:"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:668
+msgid "— 選擇 —"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:673
+msgid "分析"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:683
+msgid "此 post type 沒有可分析的 postmeta 欄位。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:692
+msgid "分類摘要"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:695
+#: admin/class-wpdo-admin.php:696
+#: admin/class-wpdo-admin.php:697
+#: admin/class-wpdo-admin.php:698
+msgid "建議欄位"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:699
+msgid "已分配"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:699
+msgid "欄位"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:709
+msgid "資料筆數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:710
+msgid "建議 Zone"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:711
+msgid "信心度"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:712
+msgid "目前分配"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:713
+msgid "原因"
+msgstr ""
+
+#. translators: 1: number of deleted log entries, 2: number of days.
+#: admin/class-wpdo-admin.php:752
+#, php-format
+msgid "已清除 %1$d 筆超過 %2$d 天的日誌。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:758
+msgid "錯誤日誌"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:762
+msgid "清除超過"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:764
+msgid "天的日誌"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:766
+msgid "清除"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:770
+msgid "沒有錯誤記錄。系統運作正常。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:807
+msgid "以下端點讓前端直接查詢 Zone 資料,取代 WP_Query / postmeta。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:814
+msgid "增加 Zone B 瀏覽計數。需要 WP REST nonce(X-WP-Nonce header)。warm zone cutover 前自動 fallback postmeta hp_view_count。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:816
+#: admin/class-wpdo-admin.php:846
+#: admin/class-wpdo-admin.php:854
+msgid "Post ID(路徑參數)"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:817
+msgid "WP REST nonce(wp_create_nonce(\"wp_rest\"))"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:825
+msgid "查詢 Zone A 扁平欄位(搜尋/篩選),支援分頁與排序。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:827
+msgid "post type(預設 hp_listing)"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:828
+msgid "每頁筆數 1–100(預設 20)"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:829
+msgid "頁碼(預設 1)"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:830
+msgid "排序欄位(預設 post_id)"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:831
+msgid "ASC 或 DESC(預設 DESC)"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:832
+msgid "數值篩選下限,例如 hp_price_min=1000"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:833
+msgid "數值篩選上限,例如 hp_price_max=5000"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:834
+msgid "精確值篩選,例如 hp_featured=1"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:837
+msgid "符合條件的總筆數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:838
+msgid "總頁數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:845
+msgid "單筆 listing:Zone A(熱區欄位)+ Zone C(JSON blob)合併回傳。Zone 未啟用時自動 fallback postmeta。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:853
+msgid "Zone B 瀏覽計數(warm zone),warm 未啟用時 fallback hp_view_count postmeta。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:861
+msgid "版本、引擎、欄位統計、模組狀態。需要 manage_options 權限(帶 WP Nonce)。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:878
+msgid "參數"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:879
+msgid "說明"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:892
+msgid "回應 Headers:"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:901
+msgid "JavaScript SDK"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:903
+msgid "在主題或外掛中引入 SDK,即可使用 "
+msgstr ""
+
+#: admin/class-wpdo-admin.php:905
+msgid " 類別操作所有端點,支援分頁生成器(async generator)。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:907
+msgid "方式一:wp_enqueue_script(推薦)"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:928
+msgid "方式二:直接使用(免 localize)"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:959
+msgid "即時測試"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:960
+msgid "在瀏覽器 Console 輸入(需已載入 SDK):"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:982
+msgid "HPCT 匯入成功!建議停用 HP Custom Tables 外掛。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:990
+msgid "HP Custom Tables 匯入"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:994
+msgid "HPCT 設定已匯入完成。如果 HP Custom Tables 外掛仍然啟用,建議停用它。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:997
+msgid "偵測到 HP Custom Tables 外掛。匯入會將 HPCT 的模組狀態和遷移記錄複製到 WPDO,然後由 WPDO 接管所有攔截器。"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:1004
+msgid "匯入預覽"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:1008
+msgid "HPCT 模組"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:1009
+msgid "HPCT 狀態"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:1010
+msgid "WPDO 狀態"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:1026
+msgid "執行匯入"
+msgstr ""
+
+#: admin/class-wpdo-admin.php:1031
+msgid "未偵測到 HP Custom Tables 外掛,或已完成匯入。"
+msgstr ""
+
+#. translators: %s: URL to import page
+#: includes/class-wpdo-core.php:272
+#, php-format
+msgid "HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。"
+msgstr ""
+
+#: includes/class-wpdo-core.php:273
+msgid "HPCT 匯入頁面"
+msgstr ""
diff --git a/modules/options/class-tmdo-options-manager.php b/modules/options/class-tmdo-options-manager.php
new file mode 100644
index 0000000..c00f9d6
--- /dev/null
+++ b/modules/options/class-tmdo-options-manager.php
@@ -0,0 +1,285 @@
+get_row(
+ "
+ SELECT
+ COUNT(*) as cnt,
+ SUM(LENGTH(option_value)) as total_bytes
+ FROM {$wpdb->options}
+ WHERE autoload = 'yes'
+ ",
+ ARRAY_A
+ );
+
+ // 最大的 50 個 autoload 選項
+ $largest = $wpdb->get_results(
+ "
+ SELECT
+ option_name,
+ LENGTH(option_value) AS size_bytes,
+ autoload
+ FROM {$wpdb->options}
+ WHERE autoload = 'yes'
+ ORDER BY size_bytes DESC
+ LIMIT 50
+ ",
+ ARRAY_A
+ );
+
+ // 可 defer 的候選項
+ $deferrable_candidates = array();
+ foreach ( $largest as $row ) {
+ if ( (int) $row['size_bytes'] > self::SINGLE_OPTION_SIZE_THRESHOLD ||
+ in_array( $row['option_name'], self::KNOWN_DEFERRABLE_OPTIONS, true )
+ ) {
+ $deferrable_candidates[] = $row;
+ }
+ }
+
+ return array(
+ 'total_count' => (int) $total['cnt'],
+ 'total_bytes' => (int) $total['total_bytes'],
+ 'total_mb' => round( $total['total_bytes'] / 1024 / 1024, 2 ),
+ 'warning' => (int) $total['total_bytes'] > self::AUTOLOAD_WARNING_THRESHOLD_MB * 1024 * 1024,
+ 'largest' => $largest,
+ 'deferrable' => $deferrable_candidates,
+ 'estimated_save_mb' => round(
+ array_sum( array_column( $deferrable_candidates, 'size_bytes' ) ) / 1024 / 1024,
+ 2
+ ),
+ );
+ }
+
+ /**
+ * 執行 autoload 最佳化
+ *
+ * @param array $options_to_defer 要設為 autoload=no 的選項名稱陣列
+ * 若為空,使用內建安全清單
+ * @param bool $dry_run
+ */
+ public static function optimize_autoload( array $options_to_defer = array(), bool $dry_run = false ): array {
+ global $wpdb;
+
+ if ( empty( $options_to_defer ) ) {
+ $options_to_defer = self::KNOWN_DEFERRABLE_OPTIONS;
+ }
+
+ $result = array(
+ 'dry_run' => $dry_run,
+ 'processed' => 0,
+ 'saved_bytes' => 0,
+ 'details' => array(),
+ );
+
+ foreach ( $options_to_defer as $option_name ) {
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT option_id, LENGTH(option_value) AS size_bytes, autoload
+ FROM {$wpdb->options}
+ WHERE option_name = %s",
+ $option_name
+ ),
+ ARRAY_A
+ );
+
+ if ( ! $row ) {
+ continue;
+ }
+
+ if ( $row['autoload'] === 'no' ) {
+ continue;
+ }
+
+ if ( ! $dry_run ) {
+ $wpdb->update(
+ $wpdb->options,
+ array( 'autoload' => 'no' ),
+ array( 'option_name' => $option_name ),
+ array( '%s' ),
+ array( '%s' )
+ );
+
+ // 清除該選項的快取(下次讀取時會重建)
+ wp_cache_delete( $option_name, 'options' );
+ wp_cache_delete( 'alloptions', 'options' );
+ }
+
+ ++$result['processed'];
+ $result['saved_bytes'] += (int) $row['size_bytes'];
+ $result['details'][] = array(
+ 'option_name' => $option_name,
+ 'saved_bytes' => (int) $row['size_bytes'],
+ );
+ }
+
+ $result['saved_mb'] = round( $result['saved_bytes'] / 1024 / 1024, 2 );
+
+ return $result;
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // 選項群組重導向
+ // ─────────────────────────────────────────────────────────
+
+ /**
+ * 將一組選項重導向至專屬設定表
+ *
+ * @param string $group_name 設定群組名
+ * @param array $option_keys 要管理的 option_name 清單
+ */
+ public static function register_settings_group( string $group_name, array $option_keys ): void {
+ global $wpdb;
+
+ $table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'settings_' . sanitize_key( $group_name );
+
+ // 建立設定專屬表
+ $charset = $wpdb->get_charset_collate();
+ $sql = "CREATE TABLE {$table} (
+ id INT NOT NULL AUTO_INCREMENT,
+ setting_key VARCHAR(191) NOT NULL,
+ setting_value LONGTEXT,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_key (setting_key)
+ ) {$charset};";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta( $sql );
+
+ // 註冊為管理中選項
+ foreach ( $option_keys as $key ) {
+ self::$redirected_options[ $key ] = array(
+ 'group' => $group_name,
+ 'table' => $table,
+ );
+
+ // 攔截讀取
+ add_filter(
+ "pre_option_{$key}",
+ function ( $value ) use ( $key, $table ) {
+ return self::read_setting( $table, $key, $value );
+ },
+ 10,
+ 1
+ );
+
+ // 攔截寫入:寫入 UAE 表,並回傳 $old_value 使 WP 跳過寫 wp_options
+ add_filter(
+ "pre_update_option_{$key}",
+ function ( $value, $old_value ) use ( $key, $table ) {
+ self::write_setting( $table, $key, $value );
+ // 回傳 $old_value 會讓 update_option() 判定「值未變動」進而跳過 wp_options 寫入
+ return $old_value;
+ },
+ 10,
+ 2
+ );
+ }
+ }
+
+ private static function read_setting( string $table, string $key, $default ) {
+ global $wpdb;
+
+ $value = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT setting_value FROM `{$table}` WHERE setting_key = %s",
+ $key
+ )
+ );
+
+ if ( $value === null ) {
+ return $default;
+ }
+
+ // v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
+ $decoded = TMDO_Safe_Unserialize::run( $value );
+ return $decoded;
+ }
+
+ private static function write_setting( string $table, string $key, $value ): void {
+ global $wpdb;
+
+ $wpdb->replace(
+ $table,
+ array(
+ 'setting_key' => $key,
+ 'setting_value' => maybe_serialize( $value ),
+ ),
+ array( '%s', '%s' )
+ );
+ }
+
+ public static function get_redirected_options(): array {
+ return self::$redirected_options;
+ }
+}
diff --git a/modules/options/index.php b/modules/options/index.php
new file mode 100644
index 0000000..7f3b1a3
--- /dev/null
+++ b/modules/options/index.php
@@ -0,0 +1,10 @@
+
+
+
+
+ tests/integration
+
+
+
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..3c8e9ae
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ tests/unit
+
+
+
+
+ includes
+
+
+ vendor
+
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..25dbbeb
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,651 @@
+ $c !== $cb )
+ );
+ }
+ return true;
+ }
+}
+if ( ! function_exists( 'remove_action' ) ) {
+ function remove_action( string $hook, $cb, int $p = 10 ): bool {
+ return remove_filter( $hook, $cb, $p );
+ }
+}
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$args ) {
+ foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
+ $value = $cb( $value, ...$args );
+ }
+ return $value;
+ }
+}
+if ( ! function_exists( 'do_action' ) ) {
+ function do_action( string $hook, ...$args ): void {
+ foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
+ $cb( ...$args );
+ }
+ }
+}
+if ( ! function_exists( 'wp_next_scheduled' ) ) {
+ function wp_next_scheduled( string $hook ): int|false { return false; }
+}
+if ( ! function_exists( 'wp_schedule_event' ) ) {
+ function wp_schedule_event( int $t, string $r, string $h ): bool { return true; }
+}
+if ( ! function_exists( 'wp_schedule_single_event' ) ) {
+ function wp_schedule_single_event( int $ts, string $hook ): bool { return true; }
+}
+if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) {
+ function wp_clear_scheduled_hook( string $hook ): int|false { return 0; }
+}
+if ( ! function_exists( 'is_admin' ) ) {
+ function is_admin(): bool { return false; }
+}
+if ( ! function_exists( 'is_singular' ) ) {
+ function is_singular( $t = '' ): bool { return false; }
+}
+if ( ! function_exists( 'get_the_ID' ) ) {
+ function get_the_ID(): int|false { return false; }
+}
+if ( ! function_exists( 'wp_doing_ajax' ) ) {
+ function wp_doing_ajax(): bool { return false; }
+}
+if ( ! function_exists( 'sanitize_text_field' ) ) {
+ function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); }
+}
+if ( ! function_exists( '__' ) ) {
+ function __( string $text, string $domain = 'default' ): string { return $text; }
+}
+if ( ! function_exists( 'esc_html' ) ) {
+ function esc_html( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); }
+}
+if ( ! function_exists( 'esc_html__' ) ) {
+ function esc_html__( string $text, string $domain = 'default' ): string { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); }
+}
+if ( ! function_exists( 'esc_html_e' ) ) {
+ function esc_html_e( string $text, string $domain = 'default' ): void { echo htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); }
+}
+if ( ! function_exists( 'esc_attr' ) ) {
+ function esc_attr( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); }
+}
+if ( ! function_exists( 'esc_url' ) ) {
+ function esc_url( string $url ): string { return filter_var( $url, FILTER_SANITIZE_URL ) ?: ''; }
+}
+if ( ! function_exists( 'esc_sql' ) ) {
+ function esc_sql( $s ): string {
+ $s = is_string( $s ) ? $s : (string) $s;
+ return addslashes( $s );
+ }
+}
+if ( ! function_exists( '_doing_it_wrong' ) ) {
+ function _doing_it_wrong( string $fn, string $msg, string $ver ): void {}
+}
+if ( ! function_exists( 'get_option' ) ) {
+ function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; }
+}
+if ( ! function_exists( 'update_option' ) ) {
+ function update_option( string $key, $value ): bool { $GLOBALS['_wp_options'][ $key ] = $value; return true; }
+}
+if ( ! function_exists( 'delete_option' ) ) {
+ function delete_option( string $key ): bool { unset( $GLOBALS['_wp_options'][ $key ] ); return true; }
+}
+if ( ! function_exists( 'get_post_meta' ) ) {
+ function get_post_meta( int $post_id, string $key = '', bool $single = false ) {
+ return $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] ?? ( $single ? '' : [] );
+ }
+}
+if ( ! function_exists( 'update_post_meta' ) ) {
+ function update_post_meta( int $post_id, string $key, $value, $prev = '' ): int|bool {
+ $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] = $value;
+ return true;
+ }
+}
+if ( ! function_exists( 'get_user_meta' ) ) {
+ function get_user_meta( int $uid, string $key = '', bool $single = false ) {
+ return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] );
+ }
+}
+if ( ! function_exists( 'update_user_meta' ) ) {
+ function update_user_meta( int $uid, string $key, $value, $prev = '' ): int|bool {
+ $GLOBALS['_wp_usermeta'][ $uid ][ $key ] = $value;
+ return true;
+ }
+}
+if ( ! function_exists( 'get_term_meta' ) ) {
+ function get_term_meta( int $tid, string $key = '', bool $single = false ) {
+ return $GLOBALS['_wp_termmeta'][ $tid ][ $key ] ?? ( $single ? '' : [] );
+ }
+}
+if ( ! function_exists( 'update_term_meta' ) ) {
+ function update_term_meta( int $tid, string $key, $value, $prev = '' ): int|bool {
+ $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value;
+ return true;
+ }
+}
+if ( ! function_exists( 'get_comment_meta' ) ) {
+ function get_comment_meta( int $cid, string $key = '', bool $single = false ) {
+ return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] );
+ }
+}
+if ( ! function_exists( 'update_comment_meta' ) ) {
+ function update_comment_meta( int $cid, string $key, $value, $prev = '' ): int|bool {
+ $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value;
+ return true;
+ }
+}
+if ( ! function_exists( 'get_post_type' ) ) {
+ function get_post_type( $post_id ) {
+ return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false;
+ }
+}
+if ( ! function_exists( 'get_post_status' ) ) {
+ function get_post_status( $post_id ) {
+ return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish';
+ }
+}
+if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
+ function is_post_publicly_viewable( $post_id ): bool {
+ if ( isset( $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ] ) ) {
+ return (bool) $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ];
+ }
+ return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' );
+ }
+}
+if ( ! function_exists( 'wp_die' ) ) {
+ function wp_die( $message = '' ): void { throw new RuntimeException( is_string( $message ) ? $message : 'wp_die' ); }
+}
+if ( ! function_exists( 'wp_verify_nonce' ) ) {
+ function wp_verify_nonce( $nonce, string $action = '' ) {
+ return $GLOBALS['_wp_valid_nonces'][ (string) $nonce ] ?? false;
+ }
+}
+if ( ! function_exists( 'wp_create_nonce' ) ) {
+ function wp_create_nonce( string $action = '' ): string {
+ $nonce = 'test_nonce_' . md5( $action );
+ $GLOBALS['_wp_valid_nonces'][ $nonce ] = 1;
+ return $nonce;
+ }
+}
+if ( ! function_exists( 'current_user_can' ) ) {
+ function current_user_can( string $cap, ...$args ): bool {
+ if ( ! empty( $args ) ) {
+ $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) );
+ if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) {
+ return (bool) $GLOBALS['_wp_current_user_can'][ $key ];
+ }
+ }
+ return $GLOBALS['_wp_current_user_can'][ $cap ] ?? false;
+ }
+}
+if ( ! function_exists( 'get_current_user_id' ) ) {
+ function get_current_user_id(): int { return (int) ( $GLOBALS['_wp_current_user_id'] ?? 0 ); }
+}
+if ( ! function_exists( 'is_multisite' ) ) {
+ function is_multisite(): bool { return (bool) ( $GLOBALS['_wp_is_multisite'] ?? false ); }
+}
+if ( ! function_exists( 'is_super_admin' ) ) {
+ function is_super_admin( ?int $uid = null ): bool { return (bool) ( $GLOBALS['_wp_is_super_admin'] ?? false ); }
+}
+if ( ! function_exists( 'switch_to_blog' ) ) {
+ function switch_to_blog( int $blog_id ): bool { $GLOBALS['_wp_current_blog_id'] = $blog_id; return true; }
+}
+if ( ! function_exists( 'restore_current_blog' ) ) {
+ function restore_current_blog(): bool { unset( $GLOBALS['_wp_current_blog_id'] ); return true; }
+}
+if ( ! function_exists( 'get_sites' ) ) {
+ function get_sites( array $args = [] ): array { return $GLOBALS['_wp_sites'] ?? []; }
+}
+if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
+ function is_plugin_active_for_network( string $plugin ): bool {
+ return (bool) ( $GLOBALS['_wp_plugin_active_for_network'][ $plugin ] ?? false );
+ }
+}
+if ( ! function_exists( 'plugin_basename' ) ) {
+ function plugin_basename( string $file ): string {
+ return basename( dirname( $file ) ) . '/' . basename( $file );
+ }
+}
+if ( ! function_exists( 'wp_generate_password' ) ) {
+ function wp_generate_password( int $len = 12, bool $special = true ): string {
+ return substr( str_replace( [ '/', '+', '=' ], '', base64_encode( random_bytes( $len ) ) ), 0, $len );
+ }
+}
+if ( ! function_exists( 'wp_json_encode' ) ) {
+ function wp_json_encode( $data, int $flags = 0 ): string|false { return json_encode( $data, $flags ); }
+}
+if ( ! function_exists( 'is_serialized' ) ) {
+ function is_serialized( $data ): bool {
+ return is_string( $data ) && strlen( $data ) >= 4
+ && in_array( $data[0], [ 'a', 's', 'i', 'd', 'b', 'O', 'N' ], true )
+ && str_ends_with( $data, ';' );
+ }
+}
+if ( ! function_exists( 'maybe_serialize' ) ) {
+ function maybe_serialize( $data ) { return is_array( $data ) || is_object( $data ) ? serialize( $data ) : $data; }
+}
+if ( ! function_exists( 'maybe_unserialize' ) ) {
+ function maybe_unserialize( $value ) {
+ if ( ! is_string( $value ) ) { return $value; }
+ $u = @unserialize( $value );
+ return ( false !== $u || 'b:0;' === $value ) ? $u : $value;
+ }
+}
+if ( ! function_exists( 'get_transient' ) ) {
+ function get_transient( string $key ) { return $GLOBALS['_wp_options'][ '_transient_' . $key ] ?? false; }
+}
+if ( ! function_exists( 'set_transient' ) ) {
+ function set_transient( string $key, $value, int $expiry = 0 ): bool {
+ $GLOBALS['_wp_options'][ '_transient_' . $key ] = $value;
+ return true;
+ }
+}
+if ( ! function_exists( 'delete_transient' ) ) {
+ function delete_transient( string $key ): bool { unset( $GLOBALS['_wp_options'][ '_transient_' . $key ] ); return true; }
+}
+if ( ! function_exists( 'wp_rand' ) ) {
+ function wp_rand( int $min = 0, int $max = 0 ): int { return random_int( $min, $max ?: PHP_INT_MAX ); }
+}
+if ( ! function_exists( 'is_wp_error' ) ) {
+ function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; }
+}
+
+// Object cache simulation.
+$GLOBALS['_wp_cache'] = [];
+if ( ! function_exists( 'wp_cache_get' ) ) {
+ function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; }
+}
+if ( ! function_exists( 'wp_cache_set' ) ) {
+ function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool {
+ $GLOBALS['_wp_cache'][ $group ][ $key ] = $value;
+ return true;
+ }
+}
+if ( ! function_exists( 'wp_cache_delete' ) ) {
+ function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; }
+}
+
+// ── Stub classes ───────────────────────────────────────────────────────────
+
+if ( ! class_exists( 'WP_Query' ) ) {
+ class WP_Query {
+ public array $posts = [];
+ public int $found_posts = 0;
+ public int $max_num_pages = 0;
+ private array $args = [];
+ public function __construct( array $args = [] ) { $this->args = $args; }
+ public function get( string $key, $default = '' ) { return $this->args[ $key ] ?? $default; }
+ public function set( string $key, $value ): void { $this->args[ $key ] = $value; }
+ }
+}
+if ( ! class_exists( 'WP_Error' ) ) {
+ class WP_Error {
+ private string $code;
+ private string $message;
+ private array $data;
+ public function __construct( string $code = '', string $message = '', $data = [] ) {
+ $this->code = $code;
+ $this->message = $message;
+ $this->data = is_array( $data ) ? $data : [];
+ }
+ public function get_error_code(): string { return $this->code; }
+ public function get_error_message(): string { return $this->message; }
+ public function get_error_data() { return $this->data; }
+ }
+}
+if ( ! class_exists( 'WP_REST_Request' ) ) {
+ class WP_REST_Request {
+ private array $params = [];
+ private array $headers = [];
+ public function __construct( string $method = 'GET', string $route = '' ) {}
+ public function get_param( string $key ) { return $this->params[ $key ] ?? null; }
+ public function set_param( string $key, $value ): void { $this->params[ $key ] = $value; }
+ public function get_header( string $key ): ?string { return $this->headers[ strtolower( $key ) ] ?? null; }
+ public function set_header( string $key, string $value ): void { $this->headers[ strtolower( $key ) ] = $value; }
+ }
+}
+if ( ! class_exists( 'WP_REST_Response' ) ) {
+ class WP_REST_Response {
+ private $data;
+ private int $status;
+ private array $headers = [];
+ public function __construct( $data = null, int $status = 200 ) { $this->data = $data; $this->status = $status; }
+ public function get_data() { return $this->data; }
+ public function get_status(): int { return $this->status; }
+ public function header( string $k, string $v ): void { $this->headers[ $k ] = $v; }
+ public function get_headers(): array { return $this->headers; }
+ }
+}
+if ( ! class_exists( 'WP_REST_Server' ) ) {
+ class WP_REST_Server {
+ const READABLE = 'GET';
+ const CREATABLE = 'POST';
+ const EDITABLE = 'POST, PUT, PATCH';
+ const DELETABLE = 'DELETE';
+ }
+}
+if ( ! function_exists( 'register_rest_route' ) ) {
+ function register_rest_route( string $ns, string $route, array $args ): bool { return true; }
+}
+
+// WP_CLI stub (needed before loading CLI classes).
+if ( ! class_exists( 'WP_CLI' ) ) {
+ class WP_CLI {
+ public static function log( string $msg ): void {}
+ public static function warning( string $msg ): void {}
+ public static function success( string $msg ): void {}
+ public static function error( string $msg ): void {}
+ public static function add_command( string $name, $class ): void {}
+ }
+}
+
+// ── Load plugin classes (TMDO_ prefix, dependency order) ──────────────────
+
+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';
+
+// Interceptors.
+require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php';
+require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php';
+
+// Zones.
+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.
+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.
+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.
+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.
+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 safety guard.
+require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
+
+// Diagnostic — do NOT load site-health here; its DB checks would fail with
+// the stub $wpdb and trigger false schema_drift criticals in HealthCronTest.
+// TMDO_Site_Health loads lazily if needed by integration tests.
+require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-health-cron.php';
+require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-monthly-summary.php';
+require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-metrics-collector.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';
+
+// 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';
+
+// Export.
+require_once TMDO_PATH . 'includes/export/class-tmdo-csv-writer.php';
+
+// REST API.
+require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
+
+// Engine (v2.0.0).
+require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
+require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
+require_once TMDO_PATH . 'includes/engine/class-tmdo-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/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';
+
+// Migration Orchestrator (needs Entity_Registry).
+require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
+require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
+
+// Options Manager.
+require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
+
+// Stress testers + shadow verifiers.
+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';
+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';
+
+// Core.
+require_once TMDO_PATH . 'includes/class-tmdo-core.php';
+
+// CLI.
+require_once TMDO_PATH . 'cli/class-tmdo-cli.php';
+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';
+
+// Back-compat aliases (WPDO_* → TMDO_*) — must be last.
+require_once TMDO_PATH . 'includes/class-tmdo-back-compat.php';
+
+// Global FSM Guard bypass for all unit tests except FSMGuardTest itself.
+// FSMGuardTest::setUp() clears _wp_filter_callbacks to restore guard behavior.
+if ( ! function_exists( '__return_true' ) ) {
+ function __return_true(): bool { return true; }
+}
+add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
+
+// ── AddOn stubs (classes moved to optional AddOns) ─────────────────────────
+
+// TMDO_Listing_Stats → 2meet-data-optimizer-hivepress-addon.
+// Tests that hit REST endpoints using listing stats receive stub responses.
+if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
+ class TMDO_Listing_Stats {
+ public static function register(): void {}
+ public static function get_view_count( int $post_id ): int { return 0; }
+ public static function increment_view( int $post_id, string $ip = '' ): int { return 0; }
+ public static function is_rate_limited( int $post_id, string $ip ): bool { return false; }
+ }
+ class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
+}
diff --git a/tests/integration/BenchmarkIntegrationTest.php b/tests/integration/BenchmarkIntegrationTest.php
new file mode 100644
index 0000000..8231fa1
--- /dev/null
+++ b/tests/integration/BenchmarkIntegrationTest.php
@@ -0,0 +1,297 @@
+prefix . 'wpdo_hot_bench';
+ self::$warm_table = $wpdb->prefix . 'wpdo_warm_bench';
+ self::$cold_table = $wpdb->prefix . 'wpdo_cold_bench';
+
+ // Zone A table.
+ $wpdb->query(
+ "CREATE TABLE IF NOT EXISTS `" . self::$hot_table . "` (
+ post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
+ bench_val DECIMAL(10,2) DEFAULT NULL,
+ updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (post_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+
+ // Zone B table (KV schema).
+ $wpdb->query(
+ "CREATE TABLE IF NOT EXISTS `" . self::$warm_table . "` (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
+ meta_key VARCHAR(255) NOT NULL DEFAULT '',
+ meta_value LONGTEXT DEFAULT NULL,
+ expires_at DATETIME DEFAULT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY post_meta (post_id, meta_key)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+
+ // Zone C table.
+ $wpdb->query(
+ "CREATE TABLE IF NOT EXISTS `" . self::$cold_table . "` (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
+ data LONGTEXT NOT NULL,
+ updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ UNIQUE KEY ui_post_id (post_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$cold_table . "`" );
+
+ // Print timing summary.
+ fwrite( STDOUT, "\n\n ── Benchmark Results (N=" . self::N . " per zone) ──────────────────────\n" );
+ foreach ( self::$report as $label => $ms ) {
+ fwrite( STDOUT, sprintf( " %-40s %7.1f ms\n", $label, $ms ) );
+ }
+ fwrite( STDOUT, " ──────────────────────────────────────────────────────\n\n" );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( "TRUNCATE TABLE `" . self::$hot_table . "`" );
+ $wpdb->query( "TRUNCATE TABLE `" . self::$warm_table . "`" );
+ $wpdb->query( "TRUNCATE TABLE `" . self::$cold_table . "`" );
+ $GLOBALS['_wp_cache'] = [];
+ }
+
+ // ── Zone A (Hot) ─────────────────────────────────────────────────────────
+
+ public function test_zone_a_bulk_write_performance(): void {
+ global $wpdb;
+ $now = gmdate( 'Y-m-d H:i:s' );
+ $start = microtime( true );
+
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at)
+ VALUES (%d, %f, %s)
+ ON DUPLICATE KEY UPDATE bench_val = VALUES(bench_val), updated_at = VALUES(updated_at)",
+ $i,
+ $i * 10.0,
+ $now
+ )
+ );
+ }
+
+ $ms = ( microtime( true ) - $start ) * 1000;
+ self::$report['Zone A: ' . self::N . ' UPSERT writes'] = $ms;
+
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$hot_table . "`" );
+ $this->assertSame( self::N, $count, 'Zone A: all rows written' );
+ }
+
+ public function test_zone_a_bulk_read_performance(): void {
+ global $wpdb;
+
+ // Seed data.
+ $now = gmdate( 'Y-m-d H:i:s' );
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $wpdb->query( $wpdb->prepare(
+ "INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at) VALUES (%d, %f, %s)",
+ $i, $i * 10.0, $now
+ ) );
+ }
+
+ // Benchmark individual point-reads.
+ $start = microtime( true );
+ $values = [];
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $values[] = $wpdb->get_var(
+ $wpdb->prepare( "SELECT bench_val FROM `" . self::$hot_table . "` WHERE post_id = %d", $i )
+ );
+ }
+ $ms = ( microtime( true ) - $start ) * 1000;
+ self::$report['Zone A: ' . self::N . ' point reads'] = $ms;
+
+ $this->assertCount( self::N, $values, 'Zone A: all rows readable' );
+ $this->assertSame( '10.00', $values[0] ); // post_id=1 → 1*10=10
+ }
+
+ public function test_zone_a_filtered_query_performance(): void {
+ global $wpdb;
+
+ $now = gmdate( 'Y-m-d H:i:s' );
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $wpdb->query( $wpdb->prepare(
+ "INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at) VALUES (%d, %f, %s)",
+ $i, $i * 10.0, $now
+ ) );
+ }
+
+ // Filtered query: bench_val >= 1000 (100 rows).
+ $start = microtime( true );
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT post_id, bench_val FROM `" . self::$hot_table . "` WHERE bench_val >= %f ORDER BY bench_val ASC",
+ 1000.0
+ ),
+ ARRAY_A
+ );
+ $ms = ( microtime( true ) - $start ) * 1000;
+ self::$report['Zone A: filtered query (half dataset)'] = $ms;
+
+ $this->assertCount( 101, $rows, 'Zone A: filter returns correct row count' );
+ $this->assertSame( '1000.00', $rows[0]['bench_val'] );
+ }
+
+ // ── Zone B (Warm) ─────────────────────────────────────────────────────────
+
+ public function test_zone_b_bulk_write_performance(): void {
+ global $wpdb;
+ $now = gmdate( 'Y-m-d H:i:s' );
+ $start = microtime( true );
+
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO `" . self::$warm_table . "`
+ (post_id, meta_key, meta_value, created_at)
+ VALUES (%d, %s, %s, %s)
+ ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)",
+ $i, 'bench_views', (string) $i, $now
+ )
+ );
+ }
+
+ $ms = ( microtime( true ) - $start ) * 1000;
+ self::$report['Zone B: ' . self::N . ' KV writes'] = $ms;
+
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$warm_table . "`" );
+ $this->assertSame( self::N, $count, 'Zone B: all KV rows written' );
+ }
+
+ public function test_zone_b_bulk_read_performance(): void {
+ global $wpdb;
+
+ $now = gmdate( 'Y-m-d H:i:s' );
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $wpdb->query( $wpdb->prepare(
+ "INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, created_at) VALUES (%d, %s, %s, %s)",
+ $i, 'bench_views', (string) $i, $now
+ ) );
+ }
+
+ $start = microtime( true );
+ $values = [];
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $values[] = $wpdb->get_var( $wpdb->prepare(
+ "SELECT meta_value FROM `" . self::$warm_table . "` WHERE post_id = %d AND meta_key = %s",
+ $i, 'bench_views'
+ ) );
+ }
+ $ms = ( microtime( true ) - $start ) * 1000;
+ self::$report['Zone B: ' . self::N . ' KV reads'] = $ms;
+
+ $this->assertCount( self::N, $values, 'Zone B: all KV rows readable' );
+ $this->assertSame( '1', $values[0] ); // post_id=1 → value=1
+ }
+
+ // ── Zone C (Cold) ─────────────────────────────────────────────────────────
+
+ public function test_zone_c_bulk_write_performance(): void {
+ global $wpdb;
+ $now = gmdate( 'Y-m-d H:i:s' );
+ $start = microtime( true );
+
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $json = wp_json_encode( [
+ 'hp_description' => 'Benchmark listing description number ' . $i,
+ 'hp_website' => 'https://listing' . $i . '.example.com',
+ 'hp_facebook' => 'https://facebook.com/listing' . $i,
+ ] );
+ $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO `" . self::$cold_table . "` (post_id, data, updated_at)
+ VALUES (%d, %s, %s)
+ ON DUPLICATE KEY UPDATE data = VALUES(data), updated_at = VALUES(updated_at)",
+ $i, $json, $now
+ )
+ );
+ }
+
+ $ms = ( microtime( true ) - $start ) * 1000;
+ self::$report['Zone C: ' . self::N . ' JSON blob writes'] = $ms;
+
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$cold_table . "`" );
+ $this->assertSame( self::N, $count, 'Zone C: all JSON rows written' );
+ }
+
+ public function test_zone_c_bulk_read_performance(): void {
+ global $wpdb;
+
+ $now = gmdate( 'Y-m-d H:i:s' );
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $json = wp_json_encode( [
+ 'hp_description' => 'Description ' . $i,
+ 'hp_website' => 'https://listing' . $i . '.example.com',
+ ] );
+ $wpdb->query( $wpdb->prepare(
+ "INSERT INTO `" . self::$cold_table . "` (post_id, data, updated_at) VALUES (%d, %s, %s)",
+ $i, $json, $now
+ ) );
+ }
+
+ $start = microtime( true );
+ $decoded = 0;
+ for ( $i = 1; $i <= self::N; $i++ ) {
+ $json = $wpdb->get_var( $wpdb->prepare(
+ "SELECT data FROM `" . self::$cold_table . "` WHERE post_id = %d",
+ $i
+ ) );
+ $data = json_decode( (string) $json, true );
+ if ( is_array( $data ) && isset( $data['hp_description'] ) ) {
+ $decoded++;
+ }
+ }
+ $ms = ( microtime( true ) - $start ) * 1000;
+ self::$report['Zone C: ' . self::N . ' JSON blob reads'] = $ms;
+
+ $this->assertSame( self::N, $decoded, 'Zone C: all JSON blobs readable' );
+ }
+}
diff --git a/tests/integration/CommentStressTesterTest.php b/tests/integration/CommentStressTesterTest.php
new file mode 100644
index 0000000..3e608ec
--- /dev/null
+++ b/tests/integration/CommentStressTesterTest.php
@@ -0,0 +1,322 @@
+posts = self::POSTS;
+ $wpdb->comments = self::COMMENTS;
+ $wpdb->commentmeta = self::COMMENTMETA;
+
+ $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::POSTS . '` (
+ ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_title text NOT NULL DEFAULT "",
+ post_status varchar(20) NOT NULL DEFAULT "publish",
+ post_type varchar(20) NOT NULL DEFAULT "post",
+ comment_count bigint(20) NOT NULL DEFAULT 0,
+ PRIMARY KEY (ID)
+ ) DEFAULT CHARACTER SET utf8mb4' );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::COMMENTS . '` (
+ comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ comment_post_ID bigint(20) unsigned NOT NULL DEFAULT 0,
+ comment_author tinytext NOT NULL,
+ comment_author_email varchar(100) NOT NULL DEFAULT "",
+ comment_author_url varchar(200) NOT NULL DEFAULT "",
+ comment_author_IP varchar(100) NOT NULL DEFAULT "",
+ comment_date datetime NOT NULL DEFAULT "1970-01-01 00:00:00",
+ comment_date_gmt datetime NOT NULL DEFAULT "1970-01-01 00:00:00",
+ comment_content text NOT NULL,
+ comment_karma int(11) NOT NULL DEFAULT 0,
+ comment_approved varchar(20) NOT NULL DEFAULT "1",
+ comment_agent varchar(255) NOT NULL DEFAULT "",
+ comment_type varchar(20) NOT NULL DEFAULT "comment",
+ comment_parent bigint(20) unsigned NOT NULL DEFAULT 0,
+ user_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ PRIMARY KEY (comment_ID),
+ KEY comment_author_email (comment_author_email(10)),
+ KEY comment_post_ID (comment_post_ID)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::COMMENTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ comment_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY comment_id (comment_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4' );
+
+ // Seed a single fixture post to satisfy post_exists() checks.
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
+ $wpdb->insert( self::POSTS, array(
+ 'post_title' => 'WPDO Comment Stress Fixture Post',
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ ) );
+ self::$test_post_id = (int) $wpdb->insert_id;
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ foreach ( array( self::COMMENTS, self::COMMENTMETA ) as $tbl ) {
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
+ }
+ // Don't drop wp_itest_posts — shared fixture across test classes.
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTS . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' );
+ // Reset state per test so each starts idle.
+ unset( $GLOBALS['_wp_options'][ WPDO_Comment_Stress_Tester::OPT_STATE ] );
+ unset( $GLOBALS['_wp_transients'][ WPDO_Comment_Stress_Tester::CANCEL_FLAG ] );
+ unset( $GLOBALS['_wp_transients']['wpdo_comment_stress_pump_lock'] );
+ }
+
+ // ── create() (fast-path direct SQL) ──────────────────────────────────────
+
+ public function test_create_inserts_comments_for_post(): void {
+ $result = WPDO_Comment_Stress_Tester::create( self::$test_post_id, 5 );
+
+ $this->assertSame( 5, $result['created'] );
+ $this->assertSame( self::$test_post_id, $result['post_id'] );
+ $this->assertNotNull( $result['first_id'] );
+ $this->assertNotNull( $result['last_id'] );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' );
+ $this->assertSame( 5, $count );
+ }
+
+ public function test_create_uses_stress_email_domain(): void {
+ WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 );
+
+ global $wpdb;
+ $prefix_count = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM `" . self::COMMENTS . "` WHERE comment_author_email LIKE %s",
+ '%@' . WPDO_Comment_Stress_Tester::TEST_EMAIL_DOMAIN
+ )
+ );
+ $this->assertSame( 3, $prefix_count );
+ }
+
+ public function test_create_seeds_commentmeta_keys(): void {
+ WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 );
+
+ global $wpdb;
+ $total_meta = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' );
+ // 3 comments × 1 key (hp_rating) = 3.
+ $this->assertSame( 3, $total_meta );
+ }
+
+ public function test_create_rejects_bad_post_id(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Comment_Stress_Tester::create( 0, 3 );
+ }
+
+ public function test_create_rejects_zero_count(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Comment_Stress_Tester::create( self::$test_post_id, 0 );
+ }
+
+ public function test_create_rejects_excessive_count(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Comment_Stress_Tester::create( self::$test_post_id, 100001 );
+ }
+
+ // ── count_test_comments() ────────────────────────────────────────────────
+
+ public function test_count_test_comments_returns_zero_for_empty(): void {
+ $this->assertSame( 0, WPDO_Comment_Stress_Tester::count_test_comments() );
+ }
+
+ public function test_count_test_comments_counts_only_stress_emails(): void {
+ WPDO_Comment_Stress_Tester::create( self::$test_post_id, 4 );
+
+ global $wpdb;
+ $wpdb->insert( self::COMMENTS, array(
+ 'comment_post_ID' => self::$test_post_id,
+ 'comment_author' => 'Real',
+ 'comment_author_email' => 'real@example.com',
+ 'comment_content' => 'Real comment',
+ 'comment_approved' => '1',
+ ) );
+
+ $this->assertSame( 4, WPDO_Comment_Stress_Tester::count_test_comments() );
+ }
+
+ // ── cleanup() ─────────────────────────────────────────────────────────────
+
+ public function test_cleanup_removes_test_comments_and_cascade(): void {
+ WPDO_Comment_Stress_Tester::create( self::$test_post_id, 5 );
+ $this->assertSame( 5, WPDO_Comment_Stress_Tester::count_test_comments() );
+
+ $result = WPDO_Comment_Stress_Tester::cleanup();
+ $this->assertSame( 5, $result['deleted_comments'] );
+
+ global $wpdb;
+ $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' ) );
+ $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' ) );
+ }
+
+ public function test_cleanup_preserves_non_stress_comments(): void {
+ global $wpdb;
+ $wpdb->insert( self::COMMENTS, array(
+ 'comment_post_ID' => self::$test_post_id,
+ 'comment_author' => 'Real',
+ 'comment_author_email' => 'real@example.com',
+ 'comment_content' => 'Real comment',
+ 'comment_approved' => '1',
+ ) );
+ WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 );
+
+ $result = WPDO_Comment_Stress_Tester::cleanup();
+ $this->assertSame( 3, $result['deleted_comments'] );
+
+ $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' );
+ $this->assertSame( 1, $remaining );
+ }
+
+ public function test_cleanup_idempotent_on_empty(): void {
+ $first = WPDO_Comment_Stress_Tester::cleanup();
+ $second = WPDO_Comment_Stress_Tester::cleanup();
+ $this->assertSame( 0, $first['deleted_comments'] );
+ $this->assertSame( 0, $second['deleted_comments'] );
+ }
+
+ // ── State machine ────────────────────────────────────────────────────────
+
+ public function test_get_state_returns_empty_when_idle(): void {
+ $this->assertSame( array(), WPDO_Comment_Stress_Tester::get_state() );
+ }
+
+ public function test_get_progress_returns_idle_when_no_state(): void {
+ $progress = WPDO_Comment_Stress_Tester::get_progress( false );
+ $this->assertSame( 'idle', $progress['status'] );
+ }
+
+ public function test_start_persists_state_with_running_status(): void {
+ $result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'fast', 5 );
+
+ $this->assertTrue( $result['ok'], 'start should succeed' );
+ $state = $result['state'];
+ $this->assertSame( 'running', $state['status'] );
+ $this->assertSame( self::$test_post_id, $state['post_id'] );
+ $this->assertSame( 'fast', $state['mode'] );
+ $this->assertSame( 10, $state['target'] );
+ $this->assertSame( 5, $state['batch_size'] );
+ }
+
+ public function test_start_rejects_unknown_post(): void {
+ $result = WPDO_Comment_Stress_Tester::start( 999999, 10 );
+ $this->assertFalse( $result['ok'] );
+ $this->assertStringContainsString( 'unknown_post', $result['error'] );
+ }
+
+ public function test_start_rejects_invalid_mode(): void {
+ $result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'turbo' );
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'invalid mode', $result['error'] );
+ }
+
+ public function test_start_rejects_concurrent_run(): void {
+ WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10 );
+ $result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 5 );
+
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'already_running', $result['error'] );
+ }
+
+ public function test_run_batch_advances_processed_count(): void {
+ WPDO_Comment_Stress_Tester::start( self::$test_post_id, 6, 'fast', 3 );
+
+ WPDO_Comment_Stress_Tester::run_batch();
+ $progress = WPDO_Comment_Stress_Tester::get_progress( false );
+ $this->assertSame( 3, $progress['processed'] );
+ $this->assertSame( 1, $progress['batches_done'] );
+ $this->assertSame( 'running', $progress['status'] );
+
+ WPDO_Comment_Stress_Tester::run_batch();
+ $progress = WPDO_Comment_Stress_Tester::get_progress( false );
+ $this->assertSame( 6, $progress['processed'] );
+ $this->assertSame( 'completed', $progress['status'] );
+ }
+
+ public function test_cancel_marks_state_as_cancelled(): void {
+ WPDO_Comment_Stress_Tester::start( self::$test_post_id, 100, 'fast', 50 );
+
+ $result = WPDO_Comment_Stress_Tester::cancel();
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 'cancelled', $result['state']['status'] );
+
+ // In-flight batch run after cancel must NOT bump status back to running.
+ WPDO_Comment_Stress_Tester::run_batch();
+ $state = WPDO_Comment_Stress_Tester::get_state();
+ $this->assertSame( 'cancelled', $state['status'] );
+ }
+
+ public function test_cancel_returns_no_active_job_when_idle(): void {
+ $result = WPDO_Comment_Stress_Tester::cancel();
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 'no_active_job', $result['message'] ?? '' );
+ }
+
+ public function test_get_progress_includes_pct_and_eta_keys(): void {
+ WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'fast', 5 );
+ WPDO_Comment_Stress_Tester::run_batch();
+
+ $progress = WPDO_Comment_Stress_Tester::get_progress( false );
+ $this->assertArrayHasKey( 'pct', $progress );
+ $this->assertArrayHasKey( 'rate_per_sec', $progress );
+ $this->assertArrayHasKey( 'elapsed_sec', $progress );
+ $this->assertArrayHasKey( 'eta_sec', $progress );
+ $this->assertArrayHasKey( 'test_comment_count', $progress );
+ $this->assertSame( 50.0, $progress['pct'] );
+ }
+
+ public function test_run_benchmark_returns_structured_payload(): void {
+ WPDO_Comment_Stress_Tester::start( self::$test_post_id, 4, 'fast', 4 );
+ WPDO_Comment_Stress_Tester::run_batch();
+
+ $state = WPDO_Comment_Stress_Tester::get_state();
+ $this->assertSame( 'completed', $state['status'] );
+ $this->assertIsArray( $state['benchmark'] );
+ $this->assertArrayHasKey( 'write', $state['benchmark'] );
+ $this->assertArrayHasKey( 'db_sizes', $state['benchmark'] );
+ $this->assertSame( self::$test_post_id, $state['benchmark']['post_id'] );
+ }
+}
diff --git a/tests/integration/CommentmetaCleanerIntegrationTest.php b/tests/integration/CommentmetaCleanerIntegrationTest.php
new file mode 100644
index 0000000..5df4ef4
--- /dev/null
+++ b/tests/integration/CommentmetaCleanerIntegrationTest.php
@@ -0,0 +1,169 @@
+commentmeta = self::COMMENTMETA;
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTMETA . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::COMMENTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ comment_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY comment_id (comment_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTMETA . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' );
+ }
+
+ private function seed( array $rows ): void {
+ global $wpdb;
+ foreach ( $rows as $row ) {
+ $wpdb->insert( self::COMMENTMETA, $row );
+ }
+ }
+
+ // ── count_garbage ────────────────────────────────────────────────────────
+
+ public function test_count_garbage_returns_zero_for_empty_table(): void {
+ $counts = WPDO_Commentmeta_Cleaner::count_garbage( 'all' );
+ $this->assertSame( 0, $counts['wxr_import'] );
+ $this->assertSame( 0, $counts['demo_data'] );
+ $this->assertSame( 0, $counts['transients'] );
+ $this->assertSame( 0, $counts['orphan_post_meta'] );
+ $this->assertSame( 0, $counts['total'] );
+ }
+
+ public function test_count_garbage_counts_wxr_import(): void {
+ $this->seed( array(
+ array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
+ array( 'comment_id' => 2, 'meta_key' => '_wxr_import_post', 'meta_value' => 'b' ),
+ array( 'comment_id' => 3, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
+ ) );
+
+ $counts = WPDO_Commentmeta_Cleaner::count_garbage( 'wxr_import' );
+ $this->assertSame( 2, $counts['wxr_import'] );
+ $this->assertSame( 2, $counts['total'] );
+ }
+
+ public function test_count_garbage_counts_orphan_post_meta(): void {
+ $this->seed( array(
+ array( 'comment_id' => 1, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
+ array( 'comment_id' => 1, 'meta_key' => '_hp_status', 'meta_value' => 'publish' ),
+ array( 'comment_id' => 2, 'meta_key' => '_thumbnail_id', 'meta_value' => '50' ),
+ array( 'comment_id' => 3, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
+ array( 'comment_id' => 4, 'meta_key' => 'note_group', 'meta_value' => 'foo' ),
+ ) );
+
+ $counts = WPDO_Commentmeta_Cleaner::count_garbage( 'orphan_post_meta' );
+ $this->assertSame( 3, $counts['orphan_post_meta'] );
+ $this->assertSame( 3, $counts['total'] );
+ }
+
+ public function test_count_garbage_all_unions_four_buckets(): void {
+ $this->seed( array(
+ array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
+ array( 'comment_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
+ array( 'comment_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
+ array( 'comment_id' => 4, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
+ array( 'comment_id' => 5, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
+ ) );
+
+ $counts = WPDO_Commentmeta_Cleaner::count_garbage( 'all' );
+ $this->assertSame( 1, $counts['wxr_import'] );
+ $this->assertSame( 1, $counts['demo_data'] );
+ $this->assertSame( 1, $counts['transients'] );
+ $this->assertSame( 1, $counts['orphan_post_meta'] );
+ $this->assertSame( 4, $counts['total'] );
+ }
+
+ // ── delete_garbage ────────────────────────────────────────────────────────
+
+ public function test_delete_garbage_removes_targeted_rows_only(): void {
+ $this->seed( array(
+ array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
+ array( 'comment_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
+ array( 'comment_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
+ array( 'comment_id' => 4, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
+ array( 'comment_id' => 5, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
+ array( 'comment_id' => 6, 'meta_key' => 'note_group', 'meta_value' => 'foo' ),
+ ) );
+
+ $deleted = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' );
+ $this->assertSame( 1, $deleted['wxr_import'] );
+ $this->assertSame( 1, $deleted['demo_data'] );
+ $this->assertSame( 1, $deleted['transients'] );
+ $this->assertSame( 1, $deleted['orphan_post_meta'] );
+ $this->assertSame( 4, $deleted['total'] );
+
+ global $wpdb;
+ $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' );
+ $this->assertSame( 2, $remaining, 'hp_rating + note_group must survive' );
+ }
+
+ public function test_delete_garbage_orphan_post_meta_specific(): void {
+ $this->seed( array(
+ array( 'comment_id' => 1, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
+ array( 'comment_id' => 2, 'meta_key' => '_hp_featured', 'meta_value' => '1' ),
+ array( 'comment_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => '111:1' ),
+ array( 'comment_id' => 4, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
+ array( 'comment_id' => 5, 'meta_key' => 'note_group', 'meta_value' => 'foo' ),
+ ) );
+
+ $deleted = WPDO_Commentmeta_Cleaner::delete_garbage( 'orphan_post_meta' );
+ $this->assertSame( 3, $deleted['orphan_post_meta'] );
+ $this->assertSame( 3, $deleted['total'] );
+
+ global $wpdb;
+ $keys = $wpdb->get_col( 'SELECT meta_key FROM `' . self::COMMENTMETA . '` ORDER BY meta_key' );
+ $this->assertSame( array( 'hp_rating', 'note_group' ), $keys );
+ }
+
+ public function test_delete_garbage_idempotent_on_clean_table(): void {
+ $this->seed( array(
+ array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
+ ) );
+
+ $first = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' );
+ $second = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' );
+ $this->assertSame( 0, $first['total'] );
+ $this->assertSame( 0, $second['total'] );
+ }
+
+ public function test_invalid_target_throws(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Commentmeta_Cleaner::count_garbage( 'bogus' );
+ }
+}
diff --git a/tests/integration/CryptoMigrationTest.php b/tests/integration/CryptoMigrationTest.php
new file mode 100644
index 0000000..4504581
--- /dev/null
+++ b/tests/integration/CryptoMigrationTest.php
@@ -0,0 +1,159 @@
+prefix = self::TEST_PREFIX;
+ $wpdb->options = self::TEST_PREFIX . 'options';
+
+ $wpdb->query(
+ 'CREATE TABLE IF NOT EXISTS `' . self::TEST_PREFIX . 'options` (
+ option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ option_name varchar(191) NOT NULL DEFAULT "",
+ option_value longtext NOT NULL,
+ autoload varchar(20) NOT NULL DEFAULT "yes",
+ PRIMARY KEY (option_id),
+ UNIQUE KEY option_name (option_name)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ // Define WP auth constants for stable key derivation.
+ if ( ! defined( 'AUTH_KEY' ) ) {
+ define( 'AUTH_KEY', 'integration_auth_key_long_enough_xxxxxxxxxxxxxxxxxxxxxx' );
+ }
+ if ( ! defined( 'SECURE_AUTH_SALT' ) ) {
+ define( 'SECURE_AUTH_SALT', 'integration_secure_auth_salt_long_xxxxxxxxxxxxxxxxxxxx' );
+ }
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ // Clear all wpdo_* options before each test for isolation.
+ $wpdb->query( "DELETE FROM `" . self::TEST_PREFIX . "options` WHERE option_name LIKE 'wpdo_%' OR option_name LIKE 'unrelated_%'" );
+ }
+
+ public function test_migrate_mixed_format_inputs(): void {
+ // Set up: 2 v1 blobs, 1 v2 blob, 1 plaintext, 1 unrelated (non-wpdo).
+ $plain1 = 'https://hooks.slack.com/services/legacy1';
+ $plain2 = 'https://discord.com/api/webhooks/legacy2';
+ $this->insert_v1_option( 'wpdo_legacy_slack', $plain1 );
+ $this->insert_v1_option( 'wpdo_legacy_discord', $plain2 );
+
+ // Already v2.
+ $this->set_option_raw( 'wpdo_already_v2', WPDO_Crypto::encrypt( 'already encrypted' ) );
+
+ // Plaintext.
+ $this->set_option_raw( 'wpdo_plaintext_secret', 'just text' );
+
+ // Unrelated prefix — must NOT be touched.
+ $this->set_option_raw( 'unrelated_secret', 'should be ignored' );
+
+ $counts = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
+
+ // Scanned 4 wpdo_* options (unrelated_ excluded).
+ $this->assertSame( 4, $counts['scanned'] );
+ $this->assertSame( 2, $counts['migrated'] );
+ $this->assertSame( 1, $counts['already_v2'] );
+ $this->assertSame( 1, $counts['plaintext'] );
+ $this->assertSame( 0, $counts['failed'] );
+
+ // Verify v1 blobs were upgraded to v2 and decrypt correctly.
+ $this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_slack' ) );
+ $this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_discord' ) );
+ $this->assertSame( $plain1, WPDO_Crypto::get_option( 'wpdo_legacy_slack' ) );
+ $this->assertSame( $plain2, WPDO_Crypto::get_option( 'wpdo_legacy_discord' ) );
+
+ // Plaintext untouched.
+ $this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'wpdo_plaintext_secret' ) );
+
+ // Unrelated option untouched.
+ global $wpdb;
+ $unrelated_value = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT option_value FROM `" . self::TEST_PREFIX . "options` WHERE option_name = %s",
+ 'unrelated_secret'
+ )
+ );
+ $this->assertSame( 'should be ignored', $unrelated_value );
+ }
+
+ public function test_migrate_idempotent_second_run_is_noop(): void {
+ $plain = 'a value';
+ $this->insert_v1_option( 'wpdo_test_idempotent', $plain );
+
+ $first = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
+ $second = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
+
+ // First run migrates 1, second run sees it as already_v2.
+ $this->assertSame( 1, $first['migrated'] );
+ $this->assertSame( 0, $second['migrated'] );
+ $this->assertSame( 1, $second['already_v2'] );
+
+ // Value still decrypts correctly after both runs.
+ $this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_test_idempotent' ) );
+ }
+
+ public function test_migrate_empty_set(): void {
+ $counts = WPDO_Crypto::migrate_v1_to_v2( 'nonexistent_prefix_' );
+
+ $this->assertSame( 0, $counts['scanned'] );
+ $this->assertSame( 0, $counts['migrated'] );
+ $this->assertSame( 0, $counts['failed'] );
+ }
+
+ public function test_migrate_preserves_value_semantics(): void {
+ // Realistic test: write a webhook-shaped string that includes URL chars
+ // + special padding to make sure no encoding artifacts surface.
+ $plain = 'https://hooks.slack.com/services/T01/B02/=+&%/special?chars=true';
+ $this->insert_v1_option( 'wpdo_realistic_webhook', $plain );
+
+ WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
+
+ $this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_realistic_webhook' ) );
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ /**
+ * Insert an option containing a hand-crafted v1 (CBC) ciphertext.
+ */
+ private function insert_v1_option( string $name, string $plaintext ): void {
+ $key = substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', AUTH_KEY . SECURE_AUTH_SALT, true ), 0, 32 );
+ $iv = random_bytes( 16 );
+ $ct = openssl_encrypt( $plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
+ $blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
+ $this->set_option_raw( $name, $blob );
+ }
+
+ /**
+ * Write a raw option value directly (bypasses WPDO_Crypto::set_option).
+ */
+ private function set_option_raw( string $name, string $value ): void {
+ global $wpdb;
+ $wpdb->query(
+ $wpdb->prepare(
+ 'REPLACE INTO `' . self::TEST_PREFIX . 'options` (option_name, option_value, autoload) VALUES (%s, %s, %s)',
+ $name,
+ $value,
+ 'no'
+ )
+ );
+ $GLOBALS['_wp_options'][ $name ] = $value;
+ }
+}
diff --git a/tests/integration/DemoEntityCounterTest.php b/tests/integration/DemoEntityCounterTest.php
new file mode 100644
index 0000000..7715ab5
--- /dev/null
+++ b/tests/integration/DemoEntityCounterTest.php
@@ -0,0 +1,268 @@
+query( "TRUNCATE TABLE `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "`" );
+
+ // Reset native usermeta global stubs (when running under integration env).
+ $GLOBALS['_wp_usermeta'] = array();
+ }
+
+ // ── Schema ─────────────────────────────────────────────────────────────
+
+ public function test_install_table_creates_with_composite_unique(): void {
+ global $wpdb;
+ $table = $wpdb->prefix . WPDO_Demo_Entity_Counter::TABLE;
+
+ // Index check: ui_entity_counter must be UNIQUE on (entity_type, entity_id, counter_key).
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ 'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s ORDER BY INDEX_NAME, SEQ_IN_INDEX',
+ $table
+ ),
+ ARRAY_A
+ );
+
+ $ui_cols = array();
+ foreach ( $rows as $r ) {
+ if ( 'ui_entity_counter' === $r['INDEX_NAME'] && '0' === (string) $r['NON_UNIQUE'] ) {
+ $ui_cols[] = $r['COLUMN_NAME'];
+ }
+ }
+ $this->assertSame( array( 'entity_type', 'entity_id', 'counter_key' ), $ui_cols );
+ }
+
+ // ── set() / get() — idle state (native fallback only) ──────────────────
+
+ public function test_set_writes_to_native_meta_in_idle_state(): void {
+ WPDO_Demo_Entity_Counter::set( 'user', 100, 'points', 50 );
+ $this->assertSame( 50, (int) get_user_meta( 100, 'points', true ) );
+ }
+
+ public function test_get_reads_native_in_idle_state(): void {
+ update_user_meta( 200, 'points', 75 );
+ $this->assertSame( 75, WPDO_Demo_Entity_Counter::get( 'user', 200, 'points' ) );
+ }
+
+ public function test_idle_state_does_not_dual_write(): void {
+ WPDO_Demo_Entity_Counter::set( 'user', 300, 'points', 99 );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d",
+ 'user',
+ 300
+ )
+ );
+ $this->assertSame( 0, $count, 'idle state must NOT dual-write to demo table' );
+ }
+
+ // ── set() — dual_write state ────────────────────────────────────────────
+
+ public function test_dual_write_state_writes_to_both(): void {
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
+
+ WPDO_Demo_Entity_Counter::set( 'user', 400, 'points', 123 );
+
+ global $wpdb;
+ $zone_value = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
+ 'user',
+ 400,
+ 'points'
+ )
+ );
+ $this->assertSame( 123, $zone_value, 'dual_write must populate the zone table' );
+ $this->assertSame( 123, (int) get_user_meta( 400, 'points', true ), 'dual_write must also keep native meta' );
+ }
+
+ public function test_upsert_uses_single_round_trip(): void {
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
+
+ // Two rapid writes to the same key — should produce exactly 1 row, not 2.
+ WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 10 );
+ WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 25 );
+
+ global $wpdb;
+ $rows = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d",
+ 'user',
+ 500
+ )
+ );
+ $this->assertSame( '1', (string) $rows, 'composite UNIQUE must collapse to 1 row' );
+
+ $value = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
+ 'user',
+ 500,
+ 'points'
+ )
+ );
+ $this->assertSame( '25', (string) $value, 'second write must overwrite via UPSERT' );
+ }
+
+ // ── get() — cutover state (read from zone) ─────────────────────────────
+
+ public function test_cutover_state_reads_from_zone_table(): void {
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
+ WPDO_Demo_Entity_Counter::set( 'user', 600, 'points', 999 );
+
+ // Switch to cutover — reads now come from zone.
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' );
+
+ // Tamper with native meta to prove zone table is the source of truth.
+ update_user_meta( 600, 'points', 0 );
+
+ $this->assertSame( 999, WPDO_Demo_Entity_Counter::get( 'user', 600, 'points' ) );
+ }
+
+ public function test_cutover_falls_back_to_native_when_zone_row_missing(): void {
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' );
+ // No dual_write history — zone table is empty for this entity.
+ update_user_meta( 700, 'points', 42 );
+
+ $this->assertSame( 42, WPDO_Demo_Entity_Counter::get( 'user', 700, 'points' ), 'graceful fallback when zone row absent' );
+ }
+
+ // ── Cross-entity coverage ──────────────────────────────────────────────
+
+ public function test_term_entity_works(): void {
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
+ WPDO_Demo_Entity_Counter::set( 'term', 800, 'usage_count', 17 );
+
+ global $wpdb;
+ $value = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
+ 'term',
+ 800,
+ 'usage_count'
+ )
+ );
+ $this->assertSame( 17, $value );
+ }
+
+ public function test_comment_entity_works(): void {
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
+ WPDO_Demo_Entity_Counter::set( 'comment', 900, 'helpful_count', 8 );
+
+ global $wpdb;
+ $value = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
+ 'comment',
+ 900,
+ 'helpful_count'
+ )
+ );
+ $this->assertSame( 8, $value );
+ }
+
+ public function test_invalid_entity_type_returns_false(): void {
+ $this->assertFalse( WPDO_Demo_Entity_Counter::set( 'bogus', 1, 'k', 1 ) );
+ $this->assertSame( 0, WPDO_Demo_Entity_Counter::get( 'bogus', 1, 'k' ) );
+ }
+
+ // ── Top-N query (killer use case postmeta can't do efficiently) ───────
+
+ public function test_top_n_query_returns_sorted_results(): void {
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
+
+ // Seed 5 users with varying point counts.
+ WPDO_Demo_Entity_Counter::set( 'user', 1001, 'points', 100 );
+ WPDO_Demo_Entity_Counter::set( 'user', 1002, 'points', 500 );
+ WPDO_Demo_Entity_Counter::set( 'user', 1003, 'points', 200 );
+ WPDO_Demo_Entity_Counter::set( 'user', 1004, 'points', 800 );
+ WPDO_Demo_Entity_Counter::set( 'user', 1005, 'points', 350 );
+
+ $top3 = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 3 );
+
+ $this->assertCount( 3, $top3 );
+ // Sorted DESC: 1004(800) > 1002(500) > 1005(350) > 1003(200) > 1001(100)
+ $this->assertSame( 1004, $top3[0]['entity_id'] );
+ $this->assertSame( 800, $top3[0]['counter_value'] );
+ $this->assertSame( 1002, $top3[1]['entity_id'] );
+ $this->assertSame( 500, $top3[1]['counter_value'] );
+ $this->assertSame( 1005, $top3[2]['entity_id'] );
+ $this->assertSame( 350, $top3[2]['counter_value'] );
+ }
+
+ public function test_top_n_filters_by_entity_type(): void {
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
+ WPDO_Demo_Entity_Counter::set( 'user', 2001, 'points', 999 );
+ WPDO_Demo_Entity_Counter::set( 'term', 2001, 'usage_count', 999 ); // same id, different type.
+
+ $users = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 );
+ $terms = WPDO_Demo_Entity_Counter::top_n( 'term', 'usage_count', 10 );
+
+ $this->assertCount( 1, $users );
+ $this->assertCount( 1, $terms );
+ $this->assertSame( 2001, $users[0]['entity_id'] );
+ $this->assertSame( 2001, $terms[0]['entity_id'] );
+ }
+
+ // ── Mini benchmark — proves zone table beats postmeta on top-N ────────
+
+ public function test_benchmark_top_n_zone_vs_postmeta_simulated(): void {
+ WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
+
+ // Seed 100 users with random point values.
+ for ( $i = 3001; $i <= 3100; $i++ ) {
+ WPDO_Demo_Entity_Counter::set( 'user', $i, 'points', wp_rand( 0, 10000 ) );
+ }
+
+ // Time the zone-table top-10 query.
+ $t1 = microtime( true );
+ for ( $i = 0; $i < 100; $i++ ) {
+ WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 );
+ }
+ $zone_ms = ( microtime( true ) - $t1 ) * 1000;
+
+ // We expect 100 zone reads under 200ms total (well under "1 LEFT JOIN per request").
+ $this->assertLessThan(
+ 500,
+ $zone_ms,
+ "100 top-N reads from zone table took {$zone_ms}ms — exceeded 500ms ceiling"
+ );
+
+ // Print for visibility (PHPUnit captures to test output, no assertion impact).
+ fwrite( STDOUT, "\n Demo benchmark: 100x top-10 in {$zone_ms}ms (avg " . round( $zone_ms / 100, 2 ) . "ms/call)\n" );
+ }
+}
diff --git a/tests/integration/InstallerCleanupTest.php b/tests/integration/InstallerCleanupTest.php
new file mode 100644
index 0000000..b95224b
--- /dev/null
+++ b/tests/integration/InstallerCleanupTest.php
@@ -0,0 +1,178 @@
+prefix` so changing the prefix scopes drops to our tables only.
+ private const TEST_PREFIX = 'wp_clnup_';
+
+ public static function setUpBeforeClass(): void {
+ global $wpdb;
+
+ if ( ! class_exists( 'WPDO_Installer' ) ) {
+ require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php';
+ }
+
+ $wpdb->prefix = self::TEST_PREFIX;
+ $wpdb->options = self::TEST_PREFIX . 'options';
+
+ // Ensure options table exists (needed for delete_option fallback path).
+ $wpdb->query(
+ 'CREATE TABLE IF NOT EXISTS `' . self::TEST_PREFIX . 'options` (
+ option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ option_name varchar(191) NOT NULL DEFAULT "",
+ option_value longtext NOT NULL,
+ autoload varchar(20) NOT NULL DEFAULT "yes",
+ PRIMARY KEY (option_id),
+ UNIQUE KEY option_name (option_name)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ // Drop the dedicated options table + any residual prefix tables.
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TEST_PREFIX . 'options`' );
+ foreach ( array(
+ self::TEST_PREFIX . 'wpdo_archive',
+ self::TEST_PREFIX . 'wpdo_warm',
+ self::TEST_PREFIX . 'wpdo_errors',
+ self::TEST_PREFIX . 'wpdo_hot_test_type',
+ self::TEST_PREFIX . 'wpdo_user_profile',
+ self::TEST_PREFIX . 'wpdo_post_attachment',
+ self::TEST_PREFIX . 'wpdo_term_hp_taxonomy',
+ self::TEST_PREFIX . 'wpdo_comment_hp_review',
+ self::TEST_PREFIX . 'unrelated_table',
+ ) as $tbl ) {
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
+ }
+ // Restore the shared integration test prefix so any teardown elsewhere
+ // that depends on `$wpdb->prefix === 'wp_itest_'` still works.
+ $wpdb->prefix = 'wp_itest_';
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ // Reset options table state.
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TEST_PREFIX . 'options`' );
+ $GLOBALS['_wp_options'] = array();
+ }
+
+ public function test_drops_static_tables(): void {
+ global $wpdb;
+
+ // Create a few WPDO-prefixed tables that should be dropped.
+ $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_archive` (id INT)' );
+ $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_warm` (id INT)' );
+
+ $counts = WPDO_Installer::drop_all_tables_for_current_blog();
+
+ $this->assertGreaterThanOrEqual( 2, $counts['tables_dropped'] );
+
+ $exists = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
+ self::TEST_PREFIX . 'wpdo_archive'
+ )
+ );
+ $this->assertSame( 0, $exists, 'wpdo_archive should be dropped' );
+ }
+
+ public function test_drops_dynamic_zone_tables(): void {
+ global $wpdb;
+
+ // Dynamic hot/cold zone tables should be discovered via LIKE pattern.
+ $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_hot_test_type` (id INT)' );
+ $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_user_profile` (id INT)' );
+ $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_post_attachment` (id INT)' );
+ $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_term_hp_taxonomy` (id INT)' );
+ $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_comment_hp_review` (id INT)' );
+
+ WPDO_Installer::drop_all_tables_for_current_blog();
+
+ foreach ( array(
+ 'wpdo_hot_test_type',
+ 'wpdo_user_profile',
+ 'wpdo_post_attachment',
+ 'wpdo_term_hp_taxonomy',
+ 'wpdo_comment_hp_review',
+ ) as $tbl_suffix ) {
+ $exists = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
+ self::TEST_PREFIX . $tbl_suffix
+ )
+ );
+ $this->assertSame( 0, $exists, $tbl_suffix . ' should be dropped' );
+ }
+ }
+
+ public function test_does_not_drop_unrelated_tables(): void {
+ global $wpdb;
+ // Defensive: a table named like wpdo_X should be dropped, but a table
+ // with a non-wpdo prefix MUST NEVER be dropped even if name pattern
+ // would match.
+ $unrelated = self::TEST_PREFIX . 'unrelated_table';
+ $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . $unrelated . '` (id INT)' );
+
+ WPDO_Installer::drop_all_tables_for_current_blog();
+
+ $exists = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
+ $unrelated
+ )
+ );
+ $this->assertSame( 1, $exists, 'Non-wpdo table must not be dropped' );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . $unrelated . '`' );
+ }
+
+ public function test_returns_counts_structure(): void {
+ $counts = WPDO_Installer::drop_all_tables_for_current_blog();
+
+ $this->assertIsArray( $counts );
+ $this->assertArrayHasKey( 'tables_dropped', $counts );
+ $this->assertArrayHasKey( 'options_deleted', $counts );
+ $this->assertArrayHasKey( 'crons_cleared', $counts );
+ }
+
+ public function test_idempotent_on_empty_state(): void {
+ // Run twice — second run should be a no-op for tables (we already
+ // dropped them all in the first run). Options may not be 0 because
+ // other test classes share the same wp_itest_options table and may
+ // continually re-create wpdo_* rows; just verify that running cleanup
+ // twice in a row does not throw.
+ WPDO_Installer::drop_all_tables_for_current_blog();
+ $second = WPDO_Installer::drop_all_tables_for_current_blog();
+
+ $this->assertSame( 0, $second['tables_dropped'] );
+ $this->assertIsInt( $second['options_deleted'] );
+ $this->assertIsInt( $second['crons_cleared'] );
+ }
+
+ public function test_drops_known_options(): void {
+ // Stub `delete_option` does not interact with DB layer in our test stubs;
+ // it modifies `$GLOBALS['_wp_options']`. Verify counts work via the
+ // known-options list.
+ $GLOBALS['_wp_options']['wpdo_db_version'] = '2.14.0';
+ $GLOBALS['_wp_options']['wpdo_features'] = array();
+ $GLOBALS['_wp_options']['wpdo_health_alert'] = '1';
+
+ $counts = WPDO_Installer::drop_all_tables_for_current_blog();
+
+ // Note: the actual count depends on $wpdb->options interaction in
+ // the residual sweep. Just verify the structure works without errors.
+ $this->assertIsInt( $counts['options_deleted'] );
+ }
+}
diff --git a/tests/integration/InstallerV2Test.php b/tests/integration/InstallerV2Test.php
new file mode 100644
index 0000000..689597c
--- /dev/null
+++ b/tests/integration/InstallerV2Test.php
@@ -0,0 +1,118 @@
+prefix;
+ $wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_audit`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_shadow_diffs`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_site_metrics`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_uni_options`" );
+ }
+
+ public function test_v2_tables_initially_absent(): void {
+ $status = WPDO_Installer::v2_tables_status();
+ foreach ( $status as $table => $exists ) {
+ $this->assertFalse( $exists, "Expected {$table} to NOT exist initially" );
+ }
+ }
+
+ public function test_install_v2_tables_creates_all_four(): void {
+ WPDO_Installer::install_v2_tables();
+
+ $status = WPDO_Installer::v2_tables_status();
+ foreach ( $status as $table => $exists ) {
+ $this->assertTrue( $exists, "Expected {$table} to exist after install_v2_tables()" );
+ }
+ }
+
+ public function test_install_v2_tables_is_idempotent(): void {
+ WPDO_Installer::install_v2_tables();
+ WPDO_Installer::install_v2_tables(); // Second call must not error.
+ WPDO_Installer::install_v2_tables(); // Third for good measure.
+
+ $status = WPDO_Installer::v2_tables_status();
+ $this->assertCount( 4, $status );
+ $this->assertTrue( array_reduce( $status, static fn( $carry, $v ) => $carry && $v, true ) );
+ }
+
+ public function test_audit_table_has_required_columns(): void {
+ global $wpdb;
+ $p = $wpdb->prefix;
+ $cols = $wpdb->get_col( "SHOW COLUMNS FROM `{$p}wpdo_audit`" );
+
+ // PR-2 spec: op, value_before, value_after, source, trace_id are required.
+ foreach ( array( 'op', 'value_before', 'value_after', 'source', 'trace_id' ) as $required ) {
+ $this->assertContains( $required, $cols, "wpdo_audit missing column {$required}" );
+ }
+ }
+
+ public function test_shadow_diffs_table_has_required_columns(): void {
+ global $wpdb;
+ $p = $wpdb->prefix;
+ $cols = $wpdb->get_col( "SHOW COLUMNS FROM `{$p}wpdo_shadow_diffs`" );
+
+ foreach ( array( 'entity_type', 'entity_id', 'meta_key', 'postmeta_value', 'zone_value', 'diff_hash' ) as $required ) {
+ $this->assertContains( $required, $cols, "wpdo_shadow_diffs missing column {$required}" );
+ }
+ }
+
+ public function test_uni_options_has_unique_index_on_option_name(): void {
+ global $wpdb;
+ $p = $wpdb->prefix;
+ $indexes = $wpdb->get_results(
+ $wpdb->prepare(
+ 'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
+ $p . 'wpdo_uni_options'
+ ),
+ ARRAY_A
+ );
+
+ $found_unique = false;
+ foreach ( $indexes as $idx ) {
+ if ( 'option_name' === $idx['COLUMN_NAME'] && '0' === (string) $idx['NON_UNIQUE'] ) {
+ $found_unique = true;
+ break;
+ }
+ }
+ $this->assertTrue( $found_unique, 'Expected UNIQUE index on wpdo_uni_options.option_name' );
+ }
+
+ public function test_audit_table_indexes_for_query_performance(): void {
+ global $wpdb;
+ $p = $wpdb->prefix;
+ $indexes = $wpdb->get_col(
+ $wpdb->prepare(
+ 'SELECT DISTINCT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
+ $p . 'wpdo_audit'
+ )
+ );
+
+ // Performance-critical indexes per Part F.3 schema spec.
+ foreach ( array( 'idx_entity', 'idx_meta_key', 'idx_ts', 'idx_trace' ) as $required ) {
+ $this->assertContains( $required, $indexes, "wpdo_audit missing index {$required}" );
+ }
+ }
+
+ public static function tearDownAfterClass(): void {
+ // Leave v2 tables in place for subsequent tests / dev convenience.
+ // Cleanup happens via wp wpdo cleanup-uae-tables --confirm in real upgrades.
+ }
+}
diff --git a/tests/integration/MemberFields/MemberBackfillIntegrationTest.php b/tests/integration/MemberFields/MemberBackfillIntegrationTest.php
new file mode 100644
index 0000000..2910917
--- /dev/null
+++ b/tests/integration/MemberFields/MemberBackfillIntegrationTest.php
@@ -0,0 +1,226 @@
+query( "DROP TABLE IF EXISTS `{$t}`" );
+ }
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::MEM_TABLE . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::STATUS_TABLE . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::USERMETA . '`' );
+ }
+
+ // ── Tests ─────────────────────────────────────────────────────────────────
+
+ public function test_migrates_membership_level_and_points(): void {
+ $this->seed_usermeta( array(
+ array( 'user_id' => 1, 'meta_key' => 'membership_level', 'meta_value' => 'gold' ),
+ array( 'user_id' => 1, 'meta_key' => 'points_balance', 'meta_value' => '500' ),
+ array( 'user_id' => 2, 'meta_key' => 'membership_level', 'meta_value' => 'silver' ),
+ array( 'user_id' => 2, 'meta_key' => 'points_balance', 'meta_value' => '200' ),
+ ) );
+
+ $result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) );
+
+ $this->assertSame( 2, $result['migrated'], 'Expected 2 migrated rows' );
+ $this->assertSame( 0, $result['errors'] );
+
+ global $wpdb;
+ $row1 = $wpdb->get_row( "SELECT * FROM `" . self::MEM_TABLE . "` WHERE user_id = 1", ARRAY_A );
+ $this->assertNotNull( $row1 );
+ $this->assertSame( 'gold', $row1['membership_level'] );
+ $this->assertSame( '500', $row1['points_balance'] );
+
+ $row2 = $wpdb->get_row( "SELECT * FROM `" . self::MEM_TABLE . "` WHERE user_id = 2", ARRAY_A );
+ $this->assertNotNull( $row2 );
+ $this->assertSame( 'silver', $row2['membership_level'] );
+ $this->assertSame( '200', $row2['points_balance'] );
+ }
+
+ public function test_skips_users_with_no_managed_keys(): void {
+ $this->seed_usermeta( array(
+ array( 'user_id' => 3, 'meta_key' => 'some_other_meta', 'meta_value' => 'value' ),
+ ) );
+
+ $result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) );
+
+ $this->assertSame( 0, $result['migrated'] );
+ $this->assertSame( 0, $result['errors'] );
+ }
+
+ public function test_dry_run_does_not_write_to_flat_table(): void {
+ $this->seed_usermeta( array(
+ array( 'user_id' => 4, 'meta_key' => 'membership_level', 'meta_value' => 'platinum' ),
+ ) );
+
+ $result = WPDO_Entity_Migration_Engine::migrate_group(
+ 'user', 'membership',
+ array( 'sleep_ms' => 0, 'dry_run' => true )
+ );
+
+ $this->assertTrue( $result['dry_run'] );
+ $this->assertSame( 1, $result['migrated'] );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::MEM_TABLE . "`" );
+ $this->assertSame( 0, $count, 'Dry run must not write to flat table' );
+ }
+
+ public function test_row_count_matches_seeded_users(): void {
+ $this->seed_usermeta( array(
+ array( 'user_id' => 10, 'meta_key' => 'membership_level', 'meta_value' => 'bronze' ),
+ array( 'user_id' => 11, 'meta_key' => 'membership_level', 'meta_value' => 'bronze' ),
+ array( 'user_id' => 12, 'meta_key' => 'points_balance', 'meta_value' => '50' ),
+ ) );
+
+ $result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) );
+
+ // user_id 10, 11 have membership_level; user_id 12 has points_balance.
+ $this->assertSame( 3, $result['migrated'] );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::MEM_TABLE . "`" );
+ $this->assertSame( 3, $count );
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private function seed_usermeta( array $rows ): void {
+ global $wpdb;
+ foreach ( $rows as $row ) {
+ $wpdb->insert( self::USERMETA, $row );
+ }
+ }
+
+ private static function load_engine_classes(): void {
+ $base = WPDO_PLUGIN_DIR;
+
+ $files = array(
+ 'includes/adapters/interface-entity-adapter.php',
+ 'includes/engine/class-tmdo-type-caster.php',
+ 'includes/engine/class-tmdo-schema-manager.php',
+ 'includes/engine/class-tmdo-entity-registry.php',
+ 'includes/adapters/class-tmdo-adapter-user.php',
+ 'includes/engine/class-tmdo-entity-migration-engine.php',
+ );
+
+ foreach ( $files as $file ) {
+ if ( ! class_exists( self::class_for_file( $file ) ) ) {
+ require_once $base . $file;
+ }
+ }
+ }
+
+ private static function class_for_file( string $file ): string {
+ $map = array(
+ 'interface-entity-adapter.php' => 'WPDO_Entity_Adapter_Interface',
+ 'class-tmdo-type-caster.php' => 'WPDO_Type_Caster',
+ 'class-tmdo-schema-manager.php' => 'WPDO_Schema_Manager',
+ 'class-tmdo-entity-registry.php' => 'WPDO_Entity_Registry',
+ 'class-tmdo-adapter-user.php' => 'WPDO_Adapter_User',
+ 'class-tmdo-entity-migration-engine.php' => 'WPDO_Entity_Migration_Engine',
+ );
+ return $map[ basename( $file ) ] ?? '';
+ }
+
+ private static function create_tables(): void {
+ global $wpdb;
+
+ $wpdb->query(
+ 'CREATE TABLE IF NOT EXISTS `' . self::USERMETA . '` (
+ `umeta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `meta_key` varchar(255) DEFAULT NULL,
+ `meta_value` longtext DEFAULT NULL,
+ PRIMARY KEY (`umeta_id`),
+ KEY `user_id` (`user_id`),
+ KEY `meta_key` (`meta_key`(191))
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+
+ $wpdb->query(
+ 'CREATE TABLE IF NOT EXISTS `' . self::MEM_TABLE . '` (
+ `user_id` bigint(20) NOT NULL,
+ `membership_level` varchar(100) DEFAULT NULL,
+ `points_balance` bigint(20) DEFAULT 0,
+ `expires_at` datetime DEFAULT NULL,
+ `activated_at` datetime DEFAULT NULL,
+ `tier_source` varchar(255) DEFAULT NULL,
+ `custom_tier` varchar(255) DEFAULT NULL,
+ PRIMARY KEY (`user_id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+
+ $wpdb->query(
+ 'CREATE TABLE IF NOT EXISTS `' . self::STATUS_TABLE . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `entity_type` varchar(50) NOT NULL,
+ `group_name` varchar(50) NOT NULL,
+ `last_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `total_migrated` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `status` varchar(20) NOT NULL DEFAULT \'pending\',
+ `started_at` datetime DEFAULT NULL,
+ `completed_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `entity_group` (`entity_type`, `group_name`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+ }
+
+ private static function register_user_entity(): void {
+ if ( ! class_exists( 'WPDO_Entity_Registry' ) ) {
+ return;
+ }
+
+ WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
+
+ WPDO_Entity_Registry::register_group(
+ 'user',
+ 'membership',
+ array(
+ array(
+ 'key' => 'membership_level',
+ 'type' => 'enum',
+ 'searchable' => true,
+ 'options' => array( 'bronze', 'silver', 'gold', 'platinum', 'custom' ),
+ ),
+ array( 'key' => 'points_balance', 'type' => 'integer', 'searchable' => true, 'default' => 0 ),
+ array( 'key' => 'expires_at', 'type' => 'datetime', 'searchable' => true ),
+ array( 'key' => 'activated_at', 'type' => 'datetime' ),
+ array( 'key' => 'tier_source', 'type' => 'text' ),
+ array( 'key' => 'custom_tier', 'type' => 'text' ),
+ )
+ );
+ }
+}
diff --git a/tests/integration/MigrationOrchestratorTest.php b/tests/integration/MigrationOrchestratorTest.php
new file mode 100644
index 0000000..d58e5b0
--- /dev/null
+++ b/tests/integration/MigrationOrchestratorTest.php
@@ -0,0 +1,219 @@
+query( 'DROP TABLE IF EXISTS `' . self::USERMETA . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+
+ $wpdb->query(
+ 'CREATE TABLE `' . self::USERMETA . '` (
+ `umeta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `meta_key` varchar(255) DEFAULT NULL,
+ `meta_value` longtext DEFAULT NULL,
+ PRIMARY KEY (`umeta_id`),
+ KEY `meta_key` (`meta_key`(191))
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+
+ $wpdb->query(
+ 'CREATE TABLE `' . self::FLAT . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` bigint(20) NOT NULL,
+ `nickname` varchar(255) DEFAULT NULL,
+ `first_name` varchar(255) DEFAULT NULL,
+ `last_name` varchar(255) DEFAULT NULL,
+ `description` text DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_user` (`user_id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::USERMETA . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+ }
+
+ public function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE `' . self::USERMETA . '`' );
+ $wpdb->query( 'TRUNCATE `' . self::FLAT . '`' );
+ }
+
+ // ── Tests ────────────────────────────────────────────────────────────────
+
+ public function test_bulk_pivot_produces_one_row_per_user(): void {
+ $this->seed_eav( array(
+ array( 'user_id' => 10, 'meta_key' => 'first_name', 'meta_value' => 'Alice' ),
+ array( 'user_id' => 10, 'meta_key' => 'last_name', 'meta_value' => 'Adams' ),
+ array( 'user_id' => 10, 'meta_key' => 'nickname', 'meta_value' => 'al' ),
+ array( 'user_id' => 11, 'meta_key' => 'first_name', 'meta_value' => 'Bob' ),
+ array( 'user_id' => 11, 'meta_key' => 'description', 'meta_value' => 'engineer' ),
+ ) );
+
+ $affected = $this->run_pivot();
+ // MySQL returns 2*N for INSERT...ON DUPLICATE on conflict, N for new inserts.
+ // Two new users → both INSERTs → affected_rows == 2.
+ $this->assertSame( 2, $affected );
+
+ global $wpdb;
+ $row10 = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=10', ARRAY_A );
+ $row11 = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=11', ARRAY_A );
+
+ $this->assertSame( 'Alice', $row10['first_name'] );
+ $this->assertSame( 'Adams', $row10['last_name'] );
+ $this->assertSame( 'al', $row10['nickname'] );
+ $this->assertNull( $row10['description'] );
+
+ $this->assertSame( 'Bob', $row11['first_name'] );
+ $this->assertSame( 'engineer', $row11['description'] );
+ $this->assertNull( $row11['last_name'] );
+ }
+
+ public function test_bulk_pivot_idempotent_re_run_preserves_data(): void {
+ $this->seed_eav( array(
+ array( 'user_id' => 20, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ),
+ ) );
+
+ $this->run_pivot();
+ $this->run_pivot(); // Second run must not lose or double-count data.
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
+ $this->assertSame( 1, $count, 'Re-run should not duplicate user_id row' );
+
+ $first = $wpdb->get_var( 'SELECT first_name FROM `' . self::FLAT . '` WHERE user_id=20' );
+ $this->assertSame( 'Carol', $first );
+ }
+
+ public function test_bulk_pivot_coalesce_preserves_existing_when_new_eav_subset(): void {
+ // Round 1: full data.
+ $this->seed_eav( array(
+ array( 'user_id' => 30, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ),
+ array( 'user_id' => 30, 'meta_key' => 'last_name', 'meta_value' => 'Diaz' ),
+ ) );
+ $this->run_pivot();
+
+ // Round 2: only first_name remains in EAV (last_name was cleaned).
+ global $wpdb;
+ $wpdb->query( "DELETE FROM `" . self::USERMETA . "` WHERE meta_key='last_name'" );
+ $this->run_pivot();
+
+ $row = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=30', ARRAY_A );
+ // COALESCE(VALUES(last_name), last_name) → keeps 'Diaz' even though new VALUES is NULL.
+ $this->assertSame( 'Dora', $row['first_name'] );
+ $this->assertSame( 'Diaz', $row['last_name'], 'COALESCE should preserve previously-migrated value when EAV is now empty' );
+ }
+
+ public function test_bulk_pivot_handles_empty_eav_gracefully(): void {
+ $affected = $this->run_pivot();
+ $this->assertSame( 0, $affected );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
+ $this->assertSame( 0, $count );
+ }
+
+ public function test_bulk_pivot_uses_max_for_duplicate_meta_keys(): void {
+ // HivePress occasionally writes duplicate meta_value rows for the same key.
+ $this->seed_eav( array(
+ array( 'user_id' => 40, 'meta_key' => 'first_name', 'meta_value' => 'older_value' ),
+ array( 'user_id' => 40, 'meta_key' => 'first_name', 'meta_value' => 'newer_value' ),
+ ) );
+ $this->run_pivot();
+
+ global $wpdb;
+ $first = $wpdb->get_var( 'SELECT first_name FROM `' . self::FLAT . '` WHERE user_id=40' );
+ // MAX() picks lexicographically larger; for our purpose this just guarantees
+ // deterministic behavior — no NULL, no error.
+ $this->assertNotNull( $first );
+ $this->assertContains( $first, array( 'older_value', 'newer_value' ) );
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private function seed_eav( array $rows ): void {
+ global $wpdb;
+ foreach ( $rows as $row ) {
+ $wpdb->insert( self::USERMETA, $row );
+ }
+ }
+
+ /**
+ * Local mirror of WPDO_Migration_Orchestrator::execute_bulk_pivot() against
+ * isolated test tables. Builds the same SQL form but pointing at our test
+ * usermeta and flat tables (the orchestrator targets $wpdb->usermeta).
+ */
+ private function run_pivot(): int {
+ global $wpdb;
+
+ $keys = array( 'nickname', 'first_name', 'last_name', 'description' );
+ $cols = $keys;
+ $ph = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
+ $cases = array();
+ $updates = array();
+ foreach ( $cols as $col ) {
+ $cases[] = "MAX(CASE WHEN um.meta_key = '{$col}' THEN um.meta_value END) AS `{$col}`";
+ $updates[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
+ }
+
+ $sql = sprintf(
+ 'INSERT INTO `%s` (`user_id`, %s)
+ SELECT um.user_id, %s
+ FROM `%s` um
+ WHERE um.meta_key IN (%s)
+ GROUP BY um.user_id
+ ON DUPLICATE KEY UPDATE %s',
+ self::FLAT,
+ implode( ', ', array_map( fn( $c ) => "`{$c}`", $cols ) ),
+ implode( ', ', $cases ),
+ self::USERMETA,
+ $ph,
+ implode( ', ', $updates )
+ );
+
+ $result = $wpdb->query( $wpdb->prepare( $sql, ...$keys ) );
+ return false === $result ? 0 : (int) $result;
+ }
+}
diff --git a/tests/integration/PointsAtomicIntegrationTest.php b/tests/integration/PointsAtomicIntegrationTest.php
new file mode 100644
index 0000000..796a9c4
--- /dev/null
+++ b/tests/integration/PointsAtomicIntegrationTest.php
@@ -0,0 +1,261 @@
+query(
+ 'CREATE TABLE IF NOT EXISTS `' . self::MEM_TABLE . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` bigint(20) unsigned NOT NULL,
+ `points_balance` bigint(20) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_user` (`user_id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+
+ $wpdb->query(
+ 'CREATE TABLE IF NOT EXISTS `' . self::LEDGER_TABLE . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` bigint(20) unsigned NOT NULL,
+ `delta` int(11) NOT NULL,
+ `balance_after` bigint(20) NOT NULL,
+ `reason` varchar(60) NOT NULL DEFAULT \'\',
+ `ref_id` bigint(20) DEFAULT NULL,
+ `ref_type` varchar(30) DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_user_created` (`user_id`,`created_at`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+
+ $wpdb->query(
+ 'CREATE TABLE IF NOT EXISTS `' . self::ERRORS_TABLE . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `severity` varchar(10) NOT NULL DEFAULT \'error\',
+ `component` varchar(60) NOT NULL DEFAULT \'\',
+ `context` varchar(60) NOT NULL DEFAULT \'\',
+ `message` text NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::MEM_TABLE . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::LEDGER_TABLE . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::ERRORS_TABLE . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::MEM_TABLE . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::LEDGER_TABLE . '`' );
+ }
+
+ // ── credit() happy path ─────────────────────────────────────────────────
+
+ public function test_credit_creates_membership_row(): void {
+ $result = WPDO_Points_Manager::credit( 1, 100, 'signup_bonus' );
+
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 100, $result['balance'] );
+ $this->assertGreaterThan( 0, $result['ledger_id'] );
+ }
+
+ public function test_credit_accumulates_balance(): void {
+ WPDO_Points_Manager::credit( 2, 200, 'first' );
+ $result = WPDO_Points_Manager::credit( 2, 300, 'second' );
+
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 500, $result['balance'] );
+ }
+
+ public function test_credit_writes_ledger_row(): void {
+ global $wpdb;
+ WPDO_Points_Manager::credit( 3, 50, 'test_reason' );
+
+ $row = $wpdb->get_row(
+ "SELECT * FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 3",
+ ARRAY_A
+ );
+ $this->assertNotNull( $row );
+ $this->assertSame( '50', $row['delta'] );
+ $this->assertSame( '50', $row['balance_after'] );
+ $this->assertSame( 'test_reason', $row['reason'] );
+ }
+
+ public function test_get_balance_reflects_credits(): void {
+ WPDO_Points_Manager::credit( 4, 75, 'top_up' );
+
+ $balance = WPDO_Points_Manager::get_balance( 4 );
+ $this->assertSame( 75, $balance );
+ }
+
+ // ── debit() happy path ──────────────────────────────────────────────────
+
+ public function test_debit_after_credit_reduces_balance(): void {
+ WPDO_Points_Manager::credit( 5, 300, 'load' );
+ $result = WPDO_Points_Manager::debit( 5, 100, 'purchase' );
+
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 200, $result['balance'] );
+ }
+
+ public function test_debit_writes_negative_delta_to_ledger(): void {
+ global $wpdb;
+ WPDO_Points_Manager::credit( 6, 200, 'load' );
+ WPDO_Points_Manager::debit( 6, 50, 'spend' );
+
+ $rows = $wpdb->get_results(
+ "SELECT delta, balance_after FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 6 ORDER BY id",
+ ARRAY_A
+ );
+ $this->assertCount( 2, $rows );
+ $this->assertSame( '200', $rows[0]['delta'] ); // credit row.
+ $this->assertSame( '-50', $rows[1]['delta'] );
+ $this->assertSame( '150', $rows[1]['balance_after'] );
+ }
+
+ // ── debit() insufficient balance ─────────────────────────────────────────
+
+ public function test_debit_fails_when_insufficient(): void {
+ WPDO_Points_Manager::credit( 7, 50, 'load' );
+ $result = WPDO_Points_Manager::debit( 7, 100, 'purchase' );
+
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'insufficient_balance', $result['error'] );
+ }
+
+ public function test_debit_failure_does_not_write_ledger(): void {
+ global $wpdb;
+ WPDO_Points_Manager::credit( 8, 30, 'load' );
+ WPDO_Points_Manager::debit( 8, 100, 'purchase' ); // should fail.
+
+ $count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 8 AND delta < 0"
+ );
+ $this->assertSame( 0, $count );
+ }
+
+ public function test_debit_failure_preserves_balance(): void {
+ WPDO_Points_Manager::credit( 9, 40, 'load' );
+ WPDO_Points_Manager::debit( 9, 200, 'purchase' ); // fails.
+
+ $balance = WPDO_Points_Manager::get_balance( 9 );
+ $this->assertSame( 40, $balance );
+ }
+
+ // ── sequential double-spend scenario ────────────────────────────────────
+
+ /**
+ * Simulate the classic double-spend race:
+ * Balance = 100. Two requests each try to debit 80.
+ * With FOR UPDATE serialisation: first succeeds → balance = 20,
+ * second then reads balance = 20 and correctly rejects (insufficient).
+ */
+ public function test_sequential_debit_only_first_succeeds(): void {
+ WPDO_Points_Manager::credit( 10, 100, 'load' );
+
+ $first = WPDO_Points_Manager::debit( 10, 80, 'spend_1' );
+ $second = WPDO_Points_Manager::debit( 10, 80, 'spend_2' );
+
+ $this->assertTrue( $first['ok'], 'First debit should succeed' );
+ $this->assertSame( 20, $first['balance'] );
+
+ $this->assertFalse( $second['ok'], 'Second debit should fail (insufficient)' );
+ $this->assertSame( 'insufficient_balance', $second['error'] );
+
+ $this->assertSame( 20, WPDO_Points_Manager::get_balance( 10 ) );
+ }
+
+ public function test_sequential_debits_leave_correct_ledger_count(): void {
+ global $wpdb;
+ WPDO_Points_Manager::credit( 11, 200, 'load' );
+ WPDO_Points_Manager::debit( 11, 150, 'spend_1' ); // succeeds: balance=50.
+ WPDO_Points_Manager::debit( 11, 150, 'spend_2' ); // fails: insufficient.
+
+ $debit_count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 11 AND delta < 0"
+ );
+ $this->assertSame( 1, $debit_count, 'Only one successful debit should be in ledger' );
+ }
+
+ // ── allow_overdraft ──────────────────────────────────────────────────────
+
+ public function test_overdraft_debit_goes_negative(): void {
+ WPDO_Points_Manager::credit( 12, 50, 'load' );
+ $result = WPDO_Points_Manager::debit( 12, 200, 'force', 0, '', true );
+
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( -150, $result['balance'] );
+ }
+
+ // ── ref_id / ref_type ────────────────────────────────────────────────────
+
+ public function test_credit_with_ref_id_and_type(): void {
+ global $wpdb;
+ WPDO_Points_Manager::credit( 13, 100, 'order_reward', 9999, 'order' );
+
+ $row = $wpdb->get_row(
+ "SELECT ref_id, ref_type FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 13",
+ ARRAY_A
+ );
+ $this->assertSame( '9999', $row['ref_id'] );
+ $this->assertSame( 'order', $row['ref_type'] );
+ }
+
+ // ── get_ledger() ─────────────────────────────────────────────────────────
+
+ public function test_get_ledger_returns_entries_newest_first(): void {
+ WPDO_Points_Manager::credit( 14, 100, 'a' );
+ WPDO_Points_Manager::credit( 14, 200, 'b' );
+
+ $ledger = WPDO_Points_Manager::get_ledger( 14 );
+
+ $this->assertCount( 2, $ledger );
+ // Newest-first: second credit (delta=200) should be first.
+ $this->assertSame( '200', $ledger[0]['delta'] );
+ $this->assertSame( '100', $ledger[1]['delta'] );
+ }
+
+ public function test_get_ledger_respects_limit(): void {
+ for ( $i = 1; $i <= 5; $i++ ) {
+ WPDO_Points_Manager::credit( 15, 10, "entry_{$i}" );
+ }
+
+ $ledger = WPDO_Points_Manager::get_ledger( 15, 3 );
+ $this->assertCount( 3, $ledger );
+ }
+}
diff --git a/tests/integration/PostBackfillJsonTest.php b/tests/integration/PostBackfillJsonTest.php
new file mode 100644
index 0000000..4a6bf39
--- /dev/null
+++ b/tests/integration/PostBackfillJsonTest.php
@@ -0,0 +1,224 @@
+query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTS . '` (
+ ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_type varchar(20) NOT NULL DEFAULT \'post\',
+ PRIMARY KEY (ID),
+ KEY post_type (post_type)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY post_id (post_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::FLAT . '` (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL,
+ _wp_attached_file varchar(255) DEFAULT NULL,
+ _wp_attachment_metadata longtext DEFAULT NULL,
+ _wp_attachment_image_alt longtext DEFAULT NULL,
+ _wp_attachment_caption longtext DEFAULT NULL,
+ created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_post_id (post_id)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ // migration_status table needed by Entity_Migration_Engine
+ // (schema mirrors MemberBackfillIntegrationTest fixture).
+ $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' );
+ $wpdb->query(
+ 'CREATE TABLE `wp_itest_wpdo_migration_status` (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ entity_type varchar(50) NOT NULL,
+ group_name varchar(50) NOT NULL,
+ last_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ total_migrated bigint(20) unsigned NOT NULL DEFAULT 0,
+ status varchar(20) NOT NULL DEFAULT \'pending\',
+ started_at datetime DEFAULT NULL,
+ completed_at datetime DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY entity_group (entity_type, group_name)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ WPDO_Entity_Registry::init();
+ WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
+ WPDO_Post_Fields::register_entity_fields();
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' );
+ WPDO_Entity_Registry::init();
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `wp_itest_wpdo_migration_status`' );
+ WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' );
+ }
+
+ private function seed_attachment( int $post_id, array $metadata, string $alt = '' ): void {
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'attachment' ) );
+ // Real WP serializes _wp_attachment_metadata via PHP serialize().
+ $wpdb->insert( self::POSTMETA, array(
+ 'post_id' => $post_id,
+ 'meta_key' => '_wp_attachment_metadata',
+ 'meta_value' => serialize( $metadata ),
+ ) );
+ if ( '' !== $alt ) {
+ $wpdb->insert( self::POSTMETA, array(
+ 'post_id' => $post_id,
+ 'meta_key' => '_wp_attachment_image_alt',
+ 'meta_value' => $alt,
+ ) );
+ }
+ }
+
+ // ── backfill_group_json() ────────────────────────────────────────────────
+
+ public function test_unserializes_attachment_metadata_to_json(): void {
+ $this->seed_attachment( 1, array(
+ 'width' => 800,
+ 'height' => 600,
+ 'file' => '2026/04/test.jpg',
+ 'sizes' => array(
+ 'thumbnail' => array( 'width' => 150, 'height' => 150 ),
+ ),
+ ), 'Stress test alt' );
+
+ $result = WPDO_Post_Migration::backfill_group_json( 'attachment' );
+
+ $this->assertGreaterThan( 0, $result['migrated'] ?? 0 );
+ $this->assertSame( 0, $result['errors'] ?? -1 );
+
+ global $wpdb;
+ $row = $wpdb->get_row(
+ 'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1',
+ ARRAY_A
+ );
+
+ $this->assertNotNull( $row );
+
+ // metadata value should now be JSON.
+ $decoded = json_decode( (string) $row['_wp_attachment_metadata'], true );
+ $this->assertIsArray( $decoded );
+ $this->assertSame( 800, $decoded['width'] );
+ $this->assertSame( 'thumbnail', array_keys( $decoded['sizes'] )[0] );
+
+ // Non-json field still passes through.
+ $this->assertSame( 'Stress test alt', $row['_wp_attachment_image_alt'] );
+ }
+
+ public function test_idempotent_re_run(): void {
+ $this->seed_attachment( 1, array( 'width' => 100 ) );
+
+ WPDO_Post_Migration::backfill_group_json( 'attachment' );
+ // reset checkpoint so the engine reprocesses the same row.
+ WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' );
+ $result2 = WPDO_Post_Migration::backfill_group_json( 'attachment' );
+
+ $this->assertSame( 0, $result2['errors'] ?? -1 );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
+ $this->assertSame( 1, $count, 'No duplicate row after re-run.' );
+ }
+
+ public function test_handles_already_serialized_string_safely(): void {
+ // Attacker-style: write an object signature into _wp_attachment_metadata
+ // (this is what safe_unserialize defends against).
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array( 'ID' => 99, 'post_type' => 'attachment' ) );
+ $wpdb->insert( self::POSTMETA, array(
+ 'post_id' => 99,
+ 'meta_key' => '_wp_attachment_metadata',
+ 'meta_value' => 'O:8:"stdClass":0:{}', // Object string — should be NULL'd
+ ) );
+
+ $result = WPDO_Post_Migration::backfill_group_json( 'attachment' );
+
+ // Engine should NOT throw — safe_unserialize converts object to NULL.
+ $this->assertSame( 0, $result['errors'] ?? -1 );
+ }
+
+ public function test_handles_empty_postmeta_gracefully(): void {
+ $result = WPDO_Post_Migration::backfill_group_json( 'attachment' );
+
+ $this->assertSame( 0, $result['migrated'] ?? -1 );
+ $this->assertSame( 0, $result['errors'] ?? -1 );
+ }
+
+ public function test_returns_error_for_unknown_group(): void {
+ $result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' );
+
+ // Engine returns error_result with 'error' key set.
+ $this->assertNotEmpty( $result['error'] ?? null );
+ }
+}
diff --git a/tests/integration/PostBenchmarkTest.php b/tests/integration/PostBenchmarkTest.php
new file mode 100644
index 0000000..98b13ab
--- /dev/null
+++ b/tests/integration/PostBenchmarkTest.php
@@ -0,0 +1,159 @@
+query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTS . '` (
+ ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_type varchar(20) NOT NULL DEFAULT \'post\',
+ post_status varchar(20) NOT NULL DEFAULT \'publish\',
+ PRIMARY KEY (ID),
+ KEY post_type (post_type)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY post_id (post_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::FLAT . '` (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL,
+ _price decimal(18,6) DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_post_id (post_id),
+ KEY idx__price (_price)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ // Seed 50 products with _price = 10..59 in both tables.
+ for ( $i = 1; $i <= 50; $i++ ) {
+ $wpdb->insert( self::POSTS, array( 'ID' => $i, 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $price = (string) ( 10 + $i );
+ $wpdb->insert( self::POSTMETA, array( 'post_id' => $i, 'meta_key' => '_price', 'meta_value' => $price ) );
+ $wpdb->insert( self::FLAT, array( 'post_id' => $i, '_price' => $price ) );
+ }
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+ }
+
+ // ── benchmark_query() ────────────────────────────────────────────────────
+
+ public function test_benchmark_returns_expected_shape(): void {
+ $result = WPDO_Post_Migration::benchmark_query(
+ 'product',
+ '_price',
+ '>=',
+ '30',
+ self::FLAT,
+ 5
+ );
+
+ $this->assertArrayHasKey( 'samples', $result );
+ $this->assertArrayHasKey( 'postmeta_avg_ms', $result );
+ $this->assertArrayHasKey( 'flat_avg_ms', $result );
+ $this->assertArrayHasKey( 'speedup', $result );
+ $this->assertArrayHasKey( 'postmeta_rows', $result );
+ $this->assertArrayHasKey( 'flat_rows', $result );
+
+ $this->assertSame( 5, $result['samples'] );
+ $this->assertGreaterThan( 0.0, $result['postmeta_avg_ms'] );
+ $this->assertGreaterThan( 0.0, $result['flat_avg_ms'] );
+ }
+
+ public function test_benchmark_finds_same_rows_via_both_paths(): void {
+ // _price >= 30 should match products 20..50 (i.e. 31 rows).
+ $result = WPDO_Post_Migration::benchmark_query(
+ 'product',
+ '_price',
+ '>=',
+ '30',
+ self::FLAT,
+ 3
+ );
+
+ // Both paths must return the same count — verifies router correctness.
+ $this->assertSame( $result['postmeta_rows'], $result['flat_rows'] );
+ $this->assertGreaterThan( 0, $result['postmeta_rows'] );
+ }
+
+ public function test_benchmark_speedup_is_positive_number(): void {
+ $result = WPDO_Post_Migration::benchmark_query(
+ 'product',
+ '_price',
+ '=',
+ '25',
+ self::FLAT,
+ 3
+ );
+
+ $this->assertIsFloat( $result['speedup'] );
+ $this->assertGreaterThan( 0.0, $result['speedup'] );
+ }
+
+ public function test_benchmark_rejects_zero_samples(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Migration::benchmark_query( 'product', '_price', '=', '25', self::FLAT, 0 );
+ }
+
+ public function test_benchmark_rejects_invalid_compare(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Migration::benchmark_query( 'product', '_price', 'BOGUS', '25', self::FLAT, 3 );
+ }
+
+ public function test_benchmark_throws_when_flat_table_missing(): void {
+ $this->expectException( RuntimeException::class );
+ WPDO_Post_Migration::benchmark_query(
+ 'product',
+ '_price',
+ '=',
+ '25',
+ 'wp_itest_nonexistent_flat',
+ 3
+ );
+ }
+}
diff --git a/tests/integration/PostEntityLifecycleTest.php b/tests/integration/PostEntityLifecycleTest.php
new file mode 100644
index 0000000..345e89c
--- /dev/null
+++ b/tests/integration/PostEntityLifecycleTest.php
@@ -0,0 +1,260 @@
+query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTS . '` (
+ ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_title text NOT NULL,
+ post_type varchar(20) NOT NULL DEFAULT \'post\',
+ post_status varchar(20) NOT NULL DEFAULT \'publish\',
+ post_date datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ post_date_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ post_modified datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ post_modified_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ post_author bigint(20) unsigned NOT NULL DEFAULT 0,
+ post_content longtext NOT NULL,
+ post_excerpt text NOT NULL,
+ comment_status varchar(20) NOT NULL DEFAULT \'open\',
+ ping_status varchar(20) NOT NULL DEFAULT \'open\',
+ post_password varchar(255) NOT NULL DEFAULT \'\',
+ post_name varchar(200) NOT NULL DEFAULT \'\',
+ to_ping text NOT NULL,
+ pinged text NOT NULL,
+ post_content_filtered longtext NOT NULL,
+ post_parent bigint(20) unsigned NOT NULL DEFAULT 0,
+ guid varchar(255) NOT NULL DEFAULT \'\',
+ menu_order int(11) NOT NULL DEFAULT 0,
+ post_mime_type varchar(100) NOT NULL DEFAULT \'\',
+ comment_count bigint(20) NOT NULL DEFAULT 0,
+ PRIMARY KEY (ID),
+ KEY post_type (post_type),
+ KEY post_title (post_title(64))
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY post_id (post_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ // wc_product flat target with all 19 columns.
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::FLAT . '` (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL,
+ _price decimal(18,6) DEFAULT NULL,
+ _regular_price decimal(18,6) DEFAULT NULL,
+ _sale_price decimal(18,6) DEFAULT NULL,
+ _stock bigint(20) DEFAULT NULL,
+ _stock_status varchar(100) DEFAULT NULL,
+ _sku varchar(255) DEFAULT NULL,
+ _manage_stock varchar(100) DEFAULT NULL,
+ _backorders varchar(100) DEFAULT NULL,
+ _sold_individually varchar(100) DEFAULT NULL,
+ _virtual varchar(100) DEFAULT NULL,
+ _downloadable varchar(100) DEFAULT NULL,
+ _tax_class varchar(255) DEFAULT NULL,
+ _tax_status varchar(100) DEFAULT NULL,
+ _download_limit bigint(20) DEFAULT NULL,
+ _download_expiry bigint(20) DEFAULT NULL,
+ _product_version varchar(255) DEFAULT NULL,
+ _wc_average_rating decimal(18,6) DEFAULT NULL,
+ _wc_review_count bigint(20) DEFAULT NULL,
+ total_sales bigint(20) DEFAULT NULL,
+ created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_post_id (post_id)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ // Register post adapter + groups.
+ WPDO_Entity_Registry::init();
+ WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
+ WPDO_Post_Fields::register_entity_fields();
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+
+ // Reset Mode_Manager + Entity_Registry to avoid leaking state.
+ $ref = new ReflectionClass( WPDO_Mode_Manager::class );
+ $cache = $ref->getProperty( 'cache' );
+ $cache->setAccessible( true );
+ $cache->setValue( null, null );
+ WPDO_Entity_Registry::init();
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' );
+ }
+
+ private static function class_for( string $rel ): ?string {
+ $map = array(
+ 'interface-entity-adapter.php' => 'WPDO_Entity_Adapter_Interface',
+ 'class-tmdo-entity-registry.php' => 'WPDO_Entity_Registry',
+ 'class-tmdo-mode-manager.php' => 'WPDO_Mode_Manager',
+ 'class-tmdo-schema-manager.php' => 'WPDO_Schema_Manager',
+ 'class-tmdo-adapter-post.php' => 'WPDO_Adapter_Post',
+ 'class-tmdo-post-fields.php' => 'WPDO_Post_Fields',
+ 'class-tmdo-post-migration.php' => 'WPDO_Post_Migration',
+ 'class-tmdo-postmeta-cleaner.php' => 'WPDO_Postmeta_Cleaner',
+ 'class-tmdo-post-stress-tester.php' => 'WPDO_Post_Stress_Tester',
+ );
+ foreach ( $map as $needle => $cls ) {
+ if ( str_contains( $rel, $needle ) ) {
+ return $cls;
+ }
+ }
+ return null;
+ }
+
+ // ── End-to-end lifecycle ──────────────────────────────────────────────────
+
+ /**
+ * Full lifecycle: stress create → cleanup garbage → backfill → diagnose
+ * → stress cleanup → final ratio assertion.
+ *
+ * This test is the contract net for v2.9.x phases working together.
+ */
+ public function test_full_post_entity_lifecycle(): void {
+ global $wpdb;
+
+ // Phase 1 (v2.9.4): seed 10 product posts via stress tester.
+ $create_result = WPDO_Post_Stress_Tester::create( 'product', 10 );
+ $this->assertSame( 10, $create_result['created'] );
+ $this->assertSame( 10, WPDO_Post_Stress_Tester::count_test_posts() );
+
+ // Add some garbage to validate v2.9.0 cleanup phase.
+ for ( $i = 0; $i < 5; $i++ ) {
+ $wpdb->insert( self::POSTMETA, array(
+ 'post_id' => 1,
+ 'meta_key' => '_transient_test_' . $i,
+ 'meta_value' => 'x',
+ ) );
+ $wpdb->insert( self::POSTMETA, array(
+ 'post_id' => 1,
+ 'meta_key' => '_wp_old_date',
+ 'meta_value' => '2024-01-01',
+ ) );
+ }
+
+ // Phase 2 (v2.9.0): cleanup garbage.
+ $garbage_before = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
+ $this->assertSame( 5, $garbage_before['transients'] );
+ $this->assertSame( 5, $garbage_before['wp_old_date'] );
+
+ $deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'all' );
+ $this->assertSame( 10, $deleted['total'] );
+
+ $garbage_after = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
+ $this->assertSame( 0, $garbage_after['total'], 'After delete, all garbage gone.' );
+
+ // Phase 3 (v2.9.3): diagnose post entity state.
+ $diag1 = WPDO_Post_Migration::diagnose();
+ $this->assertSame( 10, $diag1['posts'] );
+ $this->assertSame( 'disabled', $diag1['mode'] );
+ $this->assertGreaterThan( 0, $diag1['groups']['wc_product']['eav_rows'], 'wc_product seeded keys present.' );
+ $this->assertSame( 0, $diag1['groups']['wc_product']['flat_rows'], 'flat empty before backfill.' );
+
+ // Phase 4 (v2.9.3): backfill wc_product from postmeta to flat.
+ $backfill = WPDO_Post_Migration::backfill_group( 'wc_product' );
+ $this->assertSame( 10, $backfill['migrated'], '10 products backfilled to flat.' );
+
+ // Phase 5: re-diagnose; flat_rows must equal post count for wc_product.
+ $diag2 = WPDO_Post_Migration::diagnose();
+ $this->assertSame( 10, $diag2['groups']['wc_product']['flat_rows'] );
+
+ // Phase 6 (v2.9.4): stress cleanup removes everything.
+ $cleanup = WPDO_Post_Stress_Tester::cleanup();
+ $this->assertSame( 10, $cleanup['deleted_posts'] );
+ $this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() );
+
+ // Final state: empty everywhere.
+ $diag3 = WPDO_Post_Migration::diagnose();
+ $this->assertSame( 0, $diag3['posts'] );
+ $this->assertSame( 0, $diag3['groups']['wc_product']['eav_rows'] );
+ // Note: flat rows survive stress cleanup (ON DELETE CASCADE not configured
+ // in test fixture); production v2.9.4 cleanup() also sweeps flat tables.
+ $this->assertGreaterThanOrEqual( 0, $diag3['groups']['wc_product']['flat_rows'] );
+ }
+
+ /**
+ * Verifies that v2.9.0 cleanup + v2.9.3 backfill have zero overlap:
+ * cleanup keys (_transient_*, _wp_old_date, stale _edit_lock) must not
+ * collide with any v2.9.1 entity group's managed keys.
+ */
+ public function test_cleanup_keys_never_overlap_managed_group_keys(): void {
+ $managed = WPDO_Post_Migration::get_managed_keys();
+
+ foreach ( $managed as $key ) {
+ $this->assertStringStartsNotWith( '_transient_', $key );
+ $this->assertStringStartsNotWith( '_transient_timeout_', $key );
+ $this->assertNotSame( '_wp_old_date', $key );
+ $this->assertNotSame( '_edit_lock', $key );
+ }
+ }
+}
diff --git a/tests/integration/PostLegacyCutoverTest.php b/tests/integration/PostLegacyCutoverTest.php
new file mode 100644
index 0000000..d3d765a
--- /dev/null
+++ b/tests/integration/PostLegacyCutoverTest.php
@@ -0,0 +1,239 @@
+` table
+ * to the new `wp_wpdo_post_` flat table:
+ *
+ * - copies common columns by name intersection
+ * - skips auto_increment id + updated_at columns (let flat manage them)
+ * - idempotent (re-run doesn't duplicate; ON DUPLICATE KEY UPDATE)
+ * - leaves the legacy table untouched (safety net for v3.0.0 DROP)
+ * - verify_legacy_cutover() reports row count + sample mismatches
+ */
+class PostLegacyCutoverTest extends TestCase {
+
+ private const HOT_TABLE = 'wp_itest_wpdo_hot_hp_listing';
+ private const FLAT_TABLE = 'wp_itest_wpdo_post_hp_listing_core';
+
+ public static function setUpBeforeClass(): void {
+ global $wpdb;
+
+ if ( ! class_exists( 'WPDO_Post_Migration' ) ) {
+ require_once WPDO_PLUGIN_DIR . 'includes/migration/class-tmdo-post-migration.php';
+ }
+
+ // Legacy hot table — narrower schema, hp_booking_enabled is hot-only.
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::HOT_TABLE . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::HOT_TABLE . '` (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ hp_price decimal(10,2) NOT NULL DEFAULT 0.00,
+ hp_featured tinyint(1) NOT NULL DEFAULT 0,
+ hp_verified tinyint(1) NOT NULL DEFAULT 0,
+ hp_expired_time bigint(20) NOT NULL DEFAULT 0,
+ hp_featured_time bigint(20) NOT NULL DEFAULT 0,
+ updated_at datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
+ hp_booking_enabled tinyint(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ UNIQUE KEY post_id (post_id)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ // Flat target — wider schema, includes hp_status/hp_vendor not in hot.
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::FLAT_TABLE . '` (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL,
+ created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ hp_price decimal(18,6) DEFAULT NULL,
+ hp_status varchar(100) DEFAULT NULL,
+ hp_featured bigint(20) DEFAULT NULL,
+ hp_verified bigint(20) DEFAULT NULL,
+ hp_vendor bigint(20) DEFAULT NULL,
+ hp_expired_time bigint(20) DEFAULT NULL,
+ hp_featured_time bigint(20) DEFAULT NULL,
+ hp_view_count bigint(20) DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_post_id (post_id)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::HOT_TABLE . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::HOT_TABLE . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT_TABLE . '`' );
+ }
+
+ private function seed_hot( int $post_id, array $cols ): void {
+ global $wpdb;
+ $wpdb->insert( self::HOT_TABLE, array_merge( array( 'post_id' => $post_id ), $cols ) );
+ }
+
+ // ── copy_legacy_hot_table() ──────────────────────────────────────────────
+
+ public function test_copy_legacy_hot_table_copies_all_rows(): void {
+ // Seed 5 rows.
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $this->seed_hot( 100 + $i, array(
+ 'hp_price' => 50.00 + $i,
+ 'hp_featured' => $i % 2,
+ 'hp_verified' => 1,
+ 'hp_expired_time' => 9999999 + $i,
+ 'hp_featured_time' => 0,
+ ) );
+ }
+
+ $result = WPDO_Post_Migration::copy_legacy_hot_table(
+ 'hp_listing',
+ self::HOT_TABLE,
+ self::FLAT_TABLE
+ );
+
+ $this->assertSame( 5, $result['copied'] );
+
+ global $wpdb;
+ $flat_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '`' );
+ $this->assertSame( 5, $flat_count );
+
+ // Verify a row's data round-trips.
+ $row = $wpdb->get_row( 'SELECT hp_price, hp_featured, hp_verified, hp_expired_time FROM `' . self::FLAT_TABLE . '` WHERE post_id = 103', ARRAY_A );
+ $this->assertSame( '53.000000', $row['hp_price'] );
+ $this->assertSame( '1', $row['hp_featured'] ); // 3 % 2 = 1
+ $this->assertSame( '1', $row['hp_verified'] );
+ $this->assertSame( '10000002', $row['hp_expired_time'] );
+ }
+
+ public function test_copy_skips_id_and_updated_at_columns(): void {
+ $this->seed_hot( 200, array(
+ 'hp_price' => 99.99,
+ 'hp_featured' => 0,
+ 'hp_verified' => 1,
+ 'hp_expired_time' => 0,
+ 'hp_featured_time' => 0,
+ ) );
+
+ WPDO_Post_Migration::copy_legacy_hot_table(
+ 'hp_listing',
+ self::HOT_TABLE,
+ self::FLAT_TABLE
+ );
+
+ global $wpdb;
+ // Flat row's id should be auto-assigned (not the hot row's id).
+ // Flat row's updated_at should be CURRENT_TIMESTAMP (not 0000-00-00).
+ $row = $wpdb->get_row( 'SELECT id, updated_at FROM `' . self::FLAT_TABLE . '` WHERE post_id = 200', ARRAY_A );
+ $this->assertNotEmpty( $row['updated_at'] );
+ $this->assertNotEquals( '0000-00-00 00:00:00', $row['updated_at'] );
+ }
+
+ public function test_copy_legacy_hot_table_is_idempotent(): void {
+ $this->seed_hot( 300, array(
+ 'hp_price' => 10.00,
+ 'hp_featured' => 0,
+ 'hp_verified' => 1,
+ 'hp_expired_time' => 0,
+ 'hp_featured_time' => 0,
+ ) );
+
+ WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
+ WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '`' );
+ $this->assertSame( 1, $count, 'Re-running copy is idempotent — UPSERT, no duplicate.' );
+ }
+
+ public function test_copy_does_not_modify_legacy_hot_table(): void {
+ $this->seed_hot( 400, array(
+ 'hp_price' => 25.00,
+ 'hp_featured' => 0,
+ 'hp_verified' => 1,
+ 'hp_expired_time' => 0,
+ 'hp_featured_time' => 0,
+ ) );
+
+ global $wpdb;
+ $before = $wpdb->get_results( 'SELECT * FROM `' . self::HOT_TABLE . '` ORDER BY id', ARRAY_A );
+
+ WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
+
+ $after = $wpdb->get_results( 'SELECT * FROM `' . self::HOT_TABLE . '` ORDER BY id', ARRAY_A );
+ $this->assertEquals( $before, $after, 'Legacy hot table must remain untouched (safety net for v3.0.0).' );
+ }
+
+ public function test_copy_returns_zero_when_hot_table_empty(): void {
+ $result = WPDO_Post_Migration::copy_legacy_hot_table(
+ 'hp_listing',
+ self::HOT_TABLE,
+ self::FLAT_TABLE
+ );
+ $this->assertSame( 0, $result['copied'] );
+ }
+
+ public function test_copy_throws_when_hot_table_missing(): void {
+ $this->expectException( RuntimeException::class );
+ WPDO_Post_Migration::copy_legacy_hot_table(
+ 'hp_listing',
+ 'wp_itest_nonexistent_hot',
+ self::FLAT_TABLE
+ );
+ }
+
+ // ── verify_legacy_cutover() ──────────────────────────────────────────────
+
+ public function test_verify_reports_match_when_counts_equal(): void {
+ for ( $i = 1; $i <= 3; $i++ ) {
+ $this->seed_hot( 500 + $i, array(
+ 'hp_price' => $i * 10,
+ 'hp_featured' => 0,
+ 'hp_verified' => 1,
+ 'hp_expired_time' => 0,
+ 'hp_featured_time' => 0,
+ ) );
+ }
+
+ WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
+
+ $verify = WPDO_Post_Migration::verify_legacy_cutover( self::HOT_TABLE, self::FLAT_TABLE );
+
+ $this->assertSame( 3, $verify['hot_rows'] );
+ $this->assertSame( 3, $verify['flat_rows'] );
+ $this->assertSame( 0, $verify['mismatched_rows'] );
+ $this->assertTrue( $verify['ok'] );
+ }
+
+ public function test_verify_reports_mismatch_when_flat_lags_hot(): void {
+ // Seed hot with 3 rows.
+ for ( $i = 1; $i <= 3; $i++ ) {
+ $this->seed_hot( 600 + $i, array(
+ 'hp_price' => $i * 10,
+ 'hp_featured' => 0,
+ 'hp_verified' => 1,
+ 'hp_expired_time' => 0,
+ 'hp_featured_time' => 0,
+ ) );
+ }
+ // Don't run copy — flat stays empty.
+
+ $verify = WPDO_Post_Migration::verify_legacy_cutover( self::HOT_TABLE, self::FLAT_TABLE );
+
+ $this->assertSame( 3, $verify['hot_rows'] );
+ $this->assertSame( 0, $verify['flat_rows'] );
+ $this->assertFalse( $verify['ok'] );
+ }
+}
diff --git a/tests/integration/PostMigrationTest.php b/tests/integration/PostMigrationTest.php
new file mode 100644
index 0000000..f10edef
--- /dev/null
+++ b/tests/integration/PostMigrationTest.php
@@ -0,0 +1,240 @@
+query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTS . '` (
+ ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_type varchar(20) NOT NULL DEFAULT \'post\',
+ PRIMARY KEY (ID),
+ KEY post_type (post_type)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ // wp_postmeta — same schema as production WP.
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY post_id (post_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ // Flat target for wc_product group (subset of full schema for test).
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::FLAT . '` (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL,
+ _price decimal(18,6) DEFAULT NULL,
+ _regular_price decimal(18,6) DEFAULT NULL,
+ _stock bigint(20) DEFAULT NULL,
+ _stock_status varchar(100) DEFAULT NULL,
+ _sku varchar(255) DEFAULT NULL,
+ created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_post_id (post_id)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ // Reset Entity Registry + register post adapter + post fields.
+ WPDO_Entity_Registry::init();
+ WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
+ WPDO_Post_Fields::register_entity_fields();
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+
+ // Reset Mode_Manager + Entity_Registry to avoid polluting later tests.
+ $ref = new ReflectionClass( WPDO_Mode_Manager::class );
+ $cache = $ref->getProperty( 'cache' );
+ $cache->setAccessible( true );
+ $cache->setValue( null, null );
+ WPDO_Entity_Registry::init();
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' );
+ }
+
+ private function seed_post( int $id, string $post_type ): void {
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array( 'ID' => $id, 'post_type' => $post_type ) );
+ }
+
+ private function seed_meta( int $post_id, string $key, string $value ): void {
+ global $wpdb;
+ $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => $key, 'meta_value' => $value ) );
+ }
+
+ // ── diagnose() ────────────────────────────────────────────────────────────
+
+ public function test_diagnose_reports_posts_postmeta_ratio(): void {
+ // 5 posts, 12 postmeta rows → ratio 2.4
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $this->seed_post( $i, 'post' );
+ }
+ for ( $i = 0; $i < 12; $i++ ) {
+ $this->seed_meta( ( $i % 5 ) + 1, 'random_key', 'v' );
+ }
+
+ $result = WPDO_Post_Migration::diagnose();
+
+ $this->assertSame( 5, $result['posts'] );
+ $this->assertSame( 12, $result['postmeta'] );
+ $this->assertSame( 2.4, $result['ratio'] );
+ }
+
+ public function test_diagnose_reports_eav_rows_per_managed_group(): void {
+ $this->seed_post( 1, 'product' );
+ $this->seed_meta( 1, '_price', '99.99' );
+ $this->seed_meta( 1, '_stock', '5' );
+ $this->seed_meta( 1, 'unrelated_key', 'x' ); // not in any group
+
+ $result = WPDO_Post_Migration::diagnose();
+
+ $this->assertArrayHasKey( 'groups', $result );
+ $this->assertArrayHasKey( 'wc_product', $result['groups'] );
+ $this->assertSame(
+ 2,
+ $result['groups']['wc_product']['eav_rows'],
+ 'wc_product group has 2 EAV rows: _price + _stock (unrelated_key excluded).'
+ );
+ }
+
+ public function test_diagnose_reports_zero_eav_for_unused_group(): void {
+ $this->seed_post( 1, 'post' );
+ $this->seed_meta( 1, 'random_key', 'v' );
+
+ $result = WPDO_Post_Migration::diagnose();
+
+ // nav_menu_item group has no postmeta seeded.
+ $this->assertSame( 0, $result['groups']['nav_menu_item']['eav_rows'] );
+ }
+
+ public function test_diagnose_reports_post_mode(): void {
+ $result = WPDO_Post_Migration::diagnose();
+
+ $this->assertArrayHasKey( 'mode', $result );
+ // Default post mode is 'disabled' per Mode_Manager defaults().
+ $this->assertSame( 'disabled', $result['mode'] );
+ }
+
+ // ── backfill_group() — bulk SQL pivot ─────────────────────────────────────
+
+ public function test_backfill_group_pivots_wc_product_keys(): void {
+ $this->seed_post( 1, 'product' );
+ $this->seed_meta( 1, '_price', '99.99' );
+ $this->seed_meta( 1, '_regular_price', '120.00' );
+ $this->seed_meta( 1, '_stock', '5' );
+ $this->seed_meta( 1, '_stock_status', 'instock' );
+ $this->seed_meta( 1, '_sku', 'SKU-001' );
+
+ $this->seed_post( 2, 'product' );
+ $this->seed_meta( 2, '_price', '49.50' );
+ $this->seed_meta( 2, '_stock_status', 'outofstock' );
+
+ $result = WPDO_Post_Migration::backfill_group( 'wc_product' );
+
+ $this->assertSame( 2, $result['migrated'], 'Two posts produce two flat rows.' );
+
+ global $wpdb;
+ $row1 = $wpdb->get_row( 'SELECT _price, _stock, _sku FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A );
+ $this->assertSame( '99.990000', $row1['_price'], 'Price decimal stored with full precision.' );
+ $this->assertSame( '5', $row1['_stock'] );
+ $this->assertSame( 'SKU-001', $row1['_sku'] );
+
+ $row2 = $wpdb->get_row( 'SELECT _price, _stock_status, _sku FROM `' . self::FLAT . '` WHERE post_id = 2', ARRAY_A );
+ $this->assertSame( '49.500000', $row2['_price'] );
+ $this->assertSame( 'outofstock', $row2['_stock_status'] );
+ $this->assertNull( $row2['_sku'], 'Unset key remains NULL in flat row.' );
+ }
+
+ public function test_backfill_group_is_idempotent(): void {
+ $this->seed_post( 1, 'product' );
+ $this->seed_meta( 1, '_price', '50.00' );
+
+ WPDO_Post_Migration::backfill_group( 'wc_product' );
+ $result_second = WPDO_Post_Migration::backfill_group( 'wc_product' );
+
+ $this->assertSame( 1, $result_second['migrated'], 'Re-running backfill is idempotent (UPSERT).' );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
+ $this->assertSame( 1, $count, 'No duplicate rows after re-run.' );
+ }
+
+ public function test_backfill_group_skips_posts_of_wrong_type(): void {
+ // _price on a non-product post should NOT migrate to wc_product flat.
+ $this->seed_post( 99, 'post' );
+ $this->seed_meta( 99, '_price', '100.00' );
+
+ $result = WPDO_Post_Migration::backfill_group( 'wc_product' );
+
+ $this->assertSame( 0, $result['migrated'], 'Posts of wrong type are excluded by post_type filter.' );
+ }
+
+ public function test_backfill_group_rejects_invalid_group(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Migration::backfill_group( 'bogus_group' );
+ }
+}
diff --git a/tests/integration/PostQueryRouterTest.php b/tests/integration/PostQueryRouterTest.php
new file mode 100644
index 0000000..762c2ef
--- /dev/null
+++ b/tests/integration/PostQueryRouterTest.php
@@ -0,0 +1,245 @@
+getProperty( 'cache' );
+ $cache->setAccessible( true );
+ $cache->setValue( null, null );
+ WPDO_Entity_Registry::init();
+ }
+
+ private function set_post_mode( string $mode ): void {
+ $ref = new ReflectionClass( WPDO_Mode_Manager::class );
+ $cache = $ref->getProperty( 'cache' );
+ $cache->setAccessible( true );
+ $cache->setValue( null, array(
+ 'post' => $mode,
+ 'user' => 'aeav_only',
+ 'term' => 'dual_write',
+ 'comment' => 'dual_write',
+ ) );
+ }
+
+ private function make_query( array $vars ): WP_Query {
+ $q = new WP_Query();
+ foreach ( $vars as $k => $v ) {
+ $q->set( $k, $v );
+ }
+ return $q;
+ }
+
+ // ── pre_get_posts gate (mode-aware) ───────────────────────────────────────
+
+ public function test_pass_through_when_mode_disabled(): void {
+ $this->set_post_mode( 'disabled' );
+
+ $router = new WPDO_Post_Query_Router();
+ $query = $this->make_query( array(
+ 'post_type' => 'product',
+ 'meta_query' => array(
+ array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
+ ),
+ ) );
+ $original = $query->get( 'meta_query' );
+
+ $router->pre_get_posts( $query );
+
+ $this->assertSame(
+ $original,
+ $query->get( 'meta_query' ),
+ 'mode=disabled: meta_query must be untouched.'
+ );
+ $this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
+ }
+
+ public function test_pass_through_when_mode_dual_write(): void {
+ $this->set_post_mode( 'dual_write' );
+
+ $router = new WPDO_Post_Query_Router();
+ $query = $this->make_query( array(
+ 'post_type' => 'product',
+ 'meta_query' => array(
+ array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
+ ),
+ ) );
+ $original = $query->get( 'meta_query' );
+
+ $router->pre_get_posts( $query );
+
+ $this->assertSame(
+ $original,
+ $query->get( 'meta_query' ),
+ 'mode=dual_write: wp_postmeta is still source-of-truth, no rewrite.'
+ );
+ }
+
+ // ── pre_get_posts rewrite (mode=aeav_only) ───────────────────────────────
+
+ public function test_rewrites_meta_query_when_mode_aeav_only(): void {
+ $this->set_post_mode( 'aeav_only' );
+
+ $router = new WPDO_Post_Query_Router();
+ $query = $this->make_query( array(
+ 'post_type' => 'product',
+ 'meta_query' => array(
+ array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
+ ),
+ ) );
+
+ $router->pre_get_posts( $query );
+
+ // Original meta_query stripped of registered keys.
+ $remaining = $query->get( 'meta_query' );
+ $this->assertEmpty(
+ $remaining,
+ 'aeav_only: registered keys removed from meta_query.'
+ );
+
+ // Routed clauses captured under wpdo_post_clauses query var.
+ $routed = $query->get( 'wpdo_post_clauses' );
+ $this->assertIsArray( $routed );
+ $this->assertNotEmpty( $routed );
+ }
+
+ public function test_keeps_unmanaged_keys_in_meta_query(): void {
+ $this->set_post_mode( 'aeav_only' );
+
+ $router = new WPDO_Post_Query_Router();
+ $query = $this->make_query( array(
+ 'post_type' => 'product',
+ 'meta_query' => array(
+ array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
+ array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
+ ),
+ ) );
+
+ $router->pre_get_posts( $query );
+
+ $remaining = $query->get( 'meta_query' );
+ $this->assertCount( 1, $remaining );
+ // Original index preserved (k=1 since k=0 was the routed _price clause).
+ $first_clause = reset( $remaining );
+ $this->assertSame(
+ 'unmanaged_attr',
+ $first_clause['key'],
+ 'Unmanaged key remains in meta_query (Hook Bus pass-through).'
+ );
+ }
+
+ public function test_skips_admin_requests(): void {
+ $this->set_post_mode( 'aeav_only' );
+
+ $prev_admin = $GLOBALS['_wp_is_admin'] ?? false;
+ $GLOBALS['_wp_is_admin'] = true;
+
+ $router = new WPDO_Post_Query_Router();
+ $query = $this->make_query( array(
+ 'post_type' => 'product',
+ 'meta_query' => array(
+ array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
+ ),
+ ) );
+ $original = $query->get( 'meta_query' );
+
+ $router->pre_get_posts( $query );
+
+ $this->assertSame(
+ $original,
+ $query->get( 'meta_query' ),
+ 'is_admin requests should not be rewritten.'
+ );
+
+ $GLOBALS['_wp_is_admin'] = $prev_admin;
+ }
+
+ // ── posts_join / posts_where (SQL emission) ──────────────────────────────
+
+ public function test_posts_join_emits_left_join_for_each_routed_post_type(): void {
+ $this->set_post_mode( 'aeav_only' );
+
+ $router = new WPDO_Post_Query_Router();
+ $query = $this->make_query( array(
+ 'post_type' => 'product',
+ 'meta_query' => array(
+ array( 'key' => '_price', 'value' => '50' ),
+ ),
+ 'wpdo_post_clauses' => array(),
+ ) );
+
+ $router->pre_get_posts( $query );
+ $join = $router->posts_join( '', $query );
+
+ $this->assertStringContainsString( 'LEFT JOIN', $join );
+ $this->assertStringContainsString( 'wpdo_post_wc_product', $join );
+ }
+
+ public function test_posts_where_appends_condition_for_routed_clause(): void {
+ $this->set_post_mode( 'aeav_only' );
+
+ $router = new WPDO_Post_Query_Router();
+ $query = $this->make_query( array(
+ 'post_type' => 'product',
+ 'meta_query' => array(
+ array( 'key' => '_price', 'value' => '99', 'compare' => '=' ),
+ ),
+ ) );
+
+ $router->pre_get_posts( $query );
+ $where = $router->posts_where( '', $query );
+
+ $this->assertStringContainsString( '`_price`', $where );
+ $this->assertStringContainsString( "'99'", $where );
+ }
+
+ public function test_pass_through_when_no_meta_query(): void {
+ $this->set_post_mode( 'aeav_only' );
+
+ $router = new WPDO_Post_Query_Router();
+ $query = $this->make_query( array( 'post_type' => 'product' ) );
+
+ $router->pre_get_posts( $query );
+
+ $this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
+ }
+}
diff --git a/tests/integration/PostShadowVerifierTest.php b/tests/integration/PostShadowVerifierTest.php
new file mode 100644
index 0000000..8767b10
--- /dev/null
+++ b/tests/integration/PostShadowVerifierTest.php
@@ -0,0 +1,330 @@
+query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTS . '` (
+ ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_type varchar(20) NOT NULL DEFAULT \'post\',
+ post_status varchar(20) NOT NULL DEFAULT \'publish\',
+ PRIMARY KEY (ID),
+ KEY post_type (post_type)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY post_id (post_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::FLAT . '` (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL,
+ _price decimal(18,6) DEFAULT NULL,
+ _stock_status varchar(100) DEFAULT NULL,
+ _sku longtext DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_post_id (post_id)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ WPDO_Entity_Registry::init();
+ WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
+ WPDO_Post_Fields::register_entity_fields();
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
+ WPDO_Entity_Registry::init();
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' );
+ }
+
+ private function seed_consistent( int $post_id, string $price, string $stock ): void {
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_price', 'meta_value' => $price ) );
+ $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_stock_status', 'meta_value' => $stock ) );
+ $wpdb->insert( self::FLAT, array( 'post_id' => $post_id, '_price' => $price, '_stock_status' => $stock ) );
+ }
+
+ private function seed_diverged( int $post_id, string $pm_price, string $flat_price ): void {
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_price', 'meta_value' => $pm_price ) );
+ $wpdb->insert( self::FLAT, array( 'post_id' => $post_id, '_price' => $flat_price ) );
+ }
+
+ // ── sample_compare() ─────────────────────────────────────────────────────
+
+ public function test_returns_expected_shape(): void {
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_price', '_stock_status' ),
+ 5
+ );
+
+ $this->assertArrayHasKey( 'sampled', $result );
+ $this->assertArrayHasKey( 'matched', $result );
+ $this->assertArrayHasKey( 'diffs', $result );
+ $this->assertArrayHasKey( 'missing_flat', $result );
+ $this->assertArrayHasKey( 'missing_postmeta', $result );
+ }
+
+ public function test_all_match_when_data_is_consistent(): void {
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $this->seed_consistent( $i, '50.00', 'instock' );
+ }
+
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_price' ),
+ 5
+ );
+
+ $this->assertSame( 5, $result['sampled'] );
+ $this->assertSame( 5, $result['matched'] );
+ $this->assertSame( 0, $result['diffs'] );
+ $this->assertSame( 0, $result['missing_flat'] );
+ }
+
+ public function test_detects_divergence_between_flat_and_postmeta(): void {
+ // Two diverged: pm has 50, flat has 60.
+ $this->seed_diverged( 1, '50', '60' );
+ $this->seed_diverged( 2, '99', '88' );
+
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_price' ),
+ 5
+ );
+
+ $this->assertSame( 2, $result['sampled'] );
+ $this->assertSame( 0, $result['matched'] );
+ $this->assertSame( 2, $result['diffs'] );
+ }
+
+ public function test_counts_missing_flat_when_flat_row_absent(): void {
+ // Post + postmeta exist, but no flat row.
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array( 'ID' => 100, 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $wpdb->insert( self::POSTMETA, array( 'post_id' => 100, 'meta_key' => '_price', 'meta_value' => '99' ) );
+
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_price' ),
+ 5
+ );
+
+ $this->assertSame( 1, $result['sampled'] );
+ $this->assertSame( 1, $result['missing_flat'] );
+ }
+
+ public function test_counts_missing_postmeta_when_pm_absent(): void {
+ // Post + flat exist, but no postmeta.
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array( 'ID' => 200, 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $wpdb->insert( self::FLAT, array( 'post_id' => 200, '_price' => '50' ) );
+
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_price' ),
+ 5
+ );
+
+ $this->assertSame( 1, $result['sampled'] );
+ $this->assertSame( 1, $result['missing_postmeta'] );
+ }
+
+ public function test_returns_zero_when_no_posts_of_type(): void {
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_price' ),
+ 5
+ );
+
+ $this->assertSame( 0, $result['sampled'] );
+ $this->assertSame( 0, $result['matched'] );
+ }
+
+ public function test_caps_sample_at_available_post_count(): void {
+ // 3 posts, ask for 10 samples → only 3 sampled.
+ $this->seed_consistent( 1, '10', 'instock' );
+ $this->seed_consistent( 2, '20', 'instock' );
+ $this->seed_consistent( 3, '30', 'instock' );
+
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_price' ),
+ 10
+ );
+
+ $this->assertSame( 3, $result['sampled'] );
+ $this->assertSame( 3, $result['matched'] );
+ }
+
+ public function test_rejects_zero_sample_size(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Shadow_Verifier::sample_compare( 'product', 'wc_product', self::FLAT, array( '_price' ), 0 );
+ }
+
+ public function test_rejects_empty_keys(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Shadow_Verifier::sample_compare( 'product', 'wc_product', self::FLAT, array(), 5 );
+ }
+
+ // ── v2.10.5: serialize vs JSON loose equality ────────────────────────────
+
+ public function test_treats_serialized_array_equal_to_json_array(): void {
+ // pm side has serialized array; flat side has JSON for the same data.
+ // post_id=10, key=_stock_status (we reuse this column to inject test values).
+ // Use a dedicated key for clarity by re-purposing the keys array.
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array( 'ID' => 10, 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $wpdb->insert( self::POSTMETA, array(
+ 'post_id' => 10,
+ 'meta_key' => '_sku',
+ 'meta_value' => serialize( array( 'a', 'b', 'c' ) ),
+ ) );
+ $wpdb->insert( self::FLAT, array(
+ 'post_id' => 10,
+ '_sku' => wp_json_encode( array( 'a', 'b', 'c' ) ),
+ ) );
+
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_sku' ),
+ 5
+ );
+
+ $this->assertSame(
+ 1,
+ $result['matched'],
+ 'serialized array vs JSON-encoded same array should match.'
+ );
+ $this->assertSame( 0, $result['diffs'], 'No false-positive diff.' );
+ }
+
+ public function test_treats_serialized_assoc_equal_to_json_assoc(): void {
+ global $wpdb;
+ $assoc = array( 'width' => 100, 'height' => 200 );
+ $wpdb->insert( self::POSTS, array( 'ID' => 11, 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $wpdb->insert( self::POSTMETA, array(
+ 'post_id' => 11,
+ 'meta_key' => '_sku',
+ 'meta_value' => serialize( $assoc ),
+ ) );
+ $wpdb->insert( self::FLAT, array(
+ 'post_id' => 11,
+ '_sku' => wp_json_encode( $assoc ),
+ ) );
+
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_sku' ),
+ 5
+ );
+
+ $this->assertSame( 1, $result['matched'] );
+ $this->assertSame( 0, $result['diffs'] );
+ }
+
+ public function test_genuine_diff_still_detected_after_loose_equal_widening(): void {
+ // Real divergence — must still be flagged even with loosened compare.
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array( 'ID' => 12, 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $wpdb->insert( self::POSTMETA, array(
+ 'post_id' => 12,
+ 'meta_key' => '_sku',
+ 'meta_value' => serialize( array( 1, 2, 3 ) ),
+ ) );
+ $wpdb->insert( self::FLAT, array(
+ 'post_id' => 12,
+ '_sku' => wp_json_encode( array( 9, 9, 9 ) ),
+ ) );
+
+ $result = WPDO_Post_Shadow_Verifier::sample_compare(
+ 'product',
+ 'wc_product',
+ self::FLAT,
+ array( '_sku' ),
+ 5
+ );
+
+ $this->assertSame( 0, $result['matched'] );
+ $this->assertSame( 1, $result['diffs'] );
+ }
+}
diff --git a/tests/integration/PostStressTesterTest.php b/tests/integration/PostStressTesterTest.php
new file mode 100644
index 0000000..79deb65
--- /dev/null
+++ b/tests/integration/PostStressTesterTest.php
@@ -0,0 +1,476 @@
+query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTS . '` (
+ ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_title text NOT NULL,
+ post_type varchar(20) NOT NULL DEFAULT \'post\',
+ post_status varchar(20) NOT NULL DEFAULT \'publish\',
+ post_date datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ post_date_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ post_modified datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ post_modified_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ post_author bigint(20) unsigned NOT NULL DEFAULT 0,
+ post_content longtext NOT NULL,
+ post_excerpt text NOT NULL,
+ comment_status varchar(20) NOT NULL DEFAULT \'open\',
+ ping_status varchar(20) NOT NULL DEFAULT \'open\',
+ post_password varchar(255) NOT NULL DEFAULT \'\',
+ post_name varchar(200) NOT NULL DEFAULT \'\',
+ to_ping text NOT NULL,
+ pinged text NOT NULL,
+ post_content_filtered longtext NOT NULL,
+ post_parent bigint(20) unsigned NOT NULL DEFAULT 0,
+ guid varchar(255) NOT NULL DEFAULT \'\',
+ menu_order int(11) NOT NULL DEFAULT 0,
+ post_mime_type varchar(100) NOT NULL DEFAULT \'\',
+ comment_count bigint(20) NOT NULL DEFAULT 0,
+ PRIMARY KEY (ID),
+ KEY post_type (post_type),
+ KEY post_title (post_title(64))
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY post_id (post_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
+ }
+
+ // ── create() ──────────────────────────────────────────────────────────────
+
+ public function test_create_inserts_requested_count_of_products(): void {
+ $result = WPDO_Post_Stress_Tester::create( 'product', 5 );
+
+ $this->assertSame( 5, $result['created'] );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'product'"
+ );
+ $this->assertSame( 5, $count );
+ }
+
+ public function test_create_uses_stress_test_prefix_in_post_title(): void {
+ WPDO_Post_Stress_Tester::create( 'product', 3 );
+
+ global $wpdb;
+ $prefix_count = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_title LIKE %s",
+ WPDO_Post_Stress_Tester::TEST_POST_PREFIX . '%'
+ )
+ );
+ $this->assertSame( 3, $prefix_count );
+ }
+
+ public function test_create_seeds_postmeta_for_each_post(): void {
+ WPDO_Post_Stress_Tester::create( 'product', 2 );
+
+ global $wpdb;
+ // Each test product should have at least the 5 critical wc_product keys.
+ $meta_count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::POSTMETA . "`"
+ );
+ $this->assertGreaterThanOrEqual( 10, $meta_count, 'At least 5 keys × 2 posts = 10 rows.' );
+
+ // Verify _price was set on every test product.
+ $price_count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = '_price'"
+ );
+ $this->assertSame( 2, $price_count );
+ }
+
+ public function test_create_supports_hp_listing_post_type(): void {
+ $result = WPDO_Post_Stress_Tester::create( 'hp_listing', 4 );
+ $this->assertSame( 4, $result['created'] );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'hp_listing'"
+ );
+ $this->assertSame( 4, $count );
+
+ // hp_listing seeds hp_price, not _price.
+ $hp_price_count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = 'hp_price'"
+ );
+ $this->assertSame( 4, $hp_price_count );
+ }
+
+ public function test_create_rejects_unsupported_post_type(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Stress_Tester::create( 'bogus_type', 3 );
+ }
+
+ public function test_create_rejects_zero_count(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Stress_Tester::create( 'product', 0 );
+ }
+
+ public function test_create_rejects_excessive_count(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Stress_Tester::create( 'product', 100001 );
+ }
+
+ // ── count_test_posts() ────────────────────────────────────────────────────
+
+ public function test_count_test_posts_returns_zero_for_empty(): void {
+ $this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() );
+ }
+
+ public function test_count_test_posts_counts_only_stress_prefix(): void {
+ // Seed 2 stress posts + 1 real post.
+ WPDO_Post_Stress_Tester::create( 'product', 2 );
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array(
+ 'ID' => 9999,
+ 'post_title' => 'Real product not from stress',
+ 'post_type' => 'product',
+ 'post_content' => '',
+ 'post_excerpt' => '',
+ 'post_content_filtered' => '',
+ 'to_ping' => '',
+ 'pinged' => '',
+ ) );
+
+ $this->assertSame( 2, WPDO_Post_Stress_Tester::count_test_posts() );
+ }
+
+ // ── cleanup() ─────────────────────────────────────────────────────────────
+
+ public function test_cleanup_removes_all_stress_posts_and_their_meta(): void {
+ WPDO_Post_Stress_Tester::create( 'product', 5 );
+
+ // Verify pre-state.
+ $this->assertSame( 5, WPDO_Post_Stress_Tester::count_test_posts() );
+
+ $result = WPDO_Post_Stress_Tester::cleanup();
+ $this->assertSame( 5, $result['deleted_posts'] );
+
+ global $wpdb;
+ $post_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTS . "`" );
+ $meta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTMETA . "`" );
+ $this->assertSame( 0, $post_count );
+ $this->assertSame( 0, $meta_count, 'cleanup() must cascade delete postmeta.' );
+ }
+
+ public function test_cleanup_preserves_non_stress_posts(): void {
+ // Real post with prefix-collision-immune title.
+ global $wpdb;
+ $wpdb->insert( self::POSTS, array(
+ 'ID' => 9999,
+ 'post_title' => 'Real product not from stress',
+ 'post_type' => 'product',
+ 'post_content' => '',
+ 'post_excerpt' => '',
+ 'post_content_filtered' => '',
+ 'to_ping' => '',
+ 'pinged' => '',
+ ) );
+ $wpdb->insert( self::POSTMETA, array(
+ 'post_id' => 9999,
+ 'meta_key' => '_price',
+ 'meta_value' => '50.00',
+ ) );
+
+ WPDO_Post_Stress_Tester::create( 'product', 3 );
+
+ $result = WPDO_Post_Stress_Tester::cleanup();
+ $this->assertSame( 3, $result['deleted_posts'] );
+
+ // Real post + its meta survive.
+ $remaining_posts = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTS . "`" );
+ $remaining_meta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTMETA . "`" );
+ $this->assertSame( 1, $remaining_posts );
+ $this->assertSame( 1, $remaining_meta );
+ }
+
+ public function test_cleanup_idempotent_on_empty_state(): void {
+ $result1 = WPDO_Post_Stress_Tester::cleanup();
+ $result2 = WPDO_Post_Stress_Tester::cleanup();
+
+ $this->assertSame( 0, $result1['deleted_posts'] );
+ $this->assertSame( 0, $result2['deleted_posts'] );
+ }
+
+ // ── v2.11.2: create_realistic() — uses wp_insert_post + update_post_meta ─
+
+ public function test_create_realistic_uses_wp_insert_post(): void {
+ $result = WPDO_Post_Stress_Tester::create_realistic( 'product', 3 );
+
+ $this->assertSame( 3, $result['created'] );
+ $this->assertSame( 'realistic', $result['mode'] ?? '' );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'product'"
+ );
+ $this->assertSame( 3, $count );
+ }
+
+ public function test_create_realistic_writes_postmeta_via_update_post_meta(): void {
+ $result = WPDO_Post_Stress_Tester::create_realistic( 'product', 2 );
+
+ $this->assertSame( 2, $result['created'] );
+
+ global $wpdb;
+ $price_count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = '_price'"
+ );
+ $this->assertSame( 2, $price_count, 'Each realistic product seeds _price.' );
+ }
+
+ public function test_create_realistic_uses_stress_test_prefix(): void {
+ WPDO_Post_Stress_Tester::create_realistic( 'product', 2 );
+
+ global $wpdb;
+ $prefixed = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_title LIKE %s",
+ WPDO_Post_Stress_Tester::TEST_POST_PREFIX . '%'
+ )
+ );
+ $this->assertSame( 2, $prefixed );
+ }
+
+ public function test_create_realistic_supports_hp_listing(): void {
+ $result = WPDO_Post_Stress_Tester::create_realistic( 'hp_listing', 4 );
+
+ $this->assertSame( 4, $result['created'] );
+
+ global $wpdb;
+ $hp_price_count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = 'hp_price'"
+ );
+ $this->assertSame( 4, $hp_price_count );
+ }
+
+ public function test_create_realistic_rejects_unsupported_post_type(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Stress_Tester::create_realistic( 'bogus_type', 2 );
+ }
+
+ public function test_create_realistic_rejects_invalid_count(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Post_Stress_Tester::create_realistic( 'product', 0 );
+ }
+
+ public function test_cleanup_removes_realistic_created_posts(): void {
+ WPDO_Post_Stress_Tester::create_realistic( 'product', 3 );
+ $this->assertSame( 3, WPDO_Post_Stress_Tester::count_test_posts() );
+
+ $cleanup = WPDO_Post_Stress_Tester::cleanup();
+ $this->assertSame( 3, $cleanup['deleted_posts'] );
+ $this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() );
+ }
+
+ // ── v2.11.4: state machine (start / cancel / get_state / get_progress / run_batch) ─
+
+ protected function tearDown(): void {
+ // Reset persisted state between state-machine tests so each test starts idle.
+ unset( $GLOBALS['_wp_options'][ WPDO_Post_Stress_Tester::OPT_STATE ] );
+ unset( $GLOBALS['_wp_transients'][ WPDO_Post_Stress_Tester::CANCEL_FLAG ] );
+ unset( $GLOBALS['_wp_transients']['wpdo_post_stress_pump_lock'] );
+ }
+
+ public function test_get_state_returns_empty_when_idle(): void {
+ $this->assertSame( array(), WPDO_Post_Stress_Tester::get_state() );
+ }
+
+ public function test_get_progress_returns_idle_when_no_state(): void {
+ $progress = WPDO_Post_Stress_Tester::get_progress( false );
+ $this->assertSame( 'idle', $progress['status'] );
+ $this->assertSame( 0, $progress['processed'] );
+ }
+
+ public function test_start_persists_state_with_running_status(): void {
+ $result = WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5 );
+
+ $this->assertTrue( $result['ok'] );
+ $state = $result['state'];
+ $this->assertSame( 'running', $state['status'] );
+ $this->assertSame( 'product', $state['post_type'] );
+ $this->assertSame( 'fast', $state['mode'] );
+ $this->assertSame( 10, $state['target'] );
+ $this->assertSame( 5, $state['batch_size'] );
+ $this->assertSame( 0, $state['processed'] );
+ }
+
+ public function test_start_rejects_unsupported_post_type(): void {
+ $result = WPDO_Post_Stress_Tester::start( 'bogus_type', 10 );
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'unsupported_post_type', $result['error'] );
+ }
+
+ public function test_start_rejects_zero_target(): void {
+ $result = WPDO_Post_Stress_Tester::start( 'product', 0 );
+ $this->assertFalse( $result['ok'] );
+ }
+
+ public function test_start_rejects_excessive_target(): void {
+ $result = WPDO_Post_Stress_Tester::start( 'product', 100001 );
+ $this->assertFalse( $result['ok'] );
+ }
+
+ public function test_start_rejects_invalid_mode(): void {
+ $result = WPDO_Post_Stress_Tester::start( 'product', 10, 'turbo' );
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'invalid mode', $result['error'] );
+ }
+
+ public function test_start_rejects_concurrent_run(): void {
+ WPDO_Post_Stress_Tester::start( 'product', 10 );
+ $result = WPDO_Post_Stress_Tester::start( 'product', 5 );
+
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'already_running', $result['error'] );
+ }
+
+ public function test_start_clamps_batch_size_above_max(): void {
+ $result = WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5000 );
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( WPDO_Post_Stress_Tester::MAX_BATCH_SIZE, $result['state']['batch_size'] );
+ }
+
+ public function test_run_batch_advances_processed_count(): void {
+ WPDO_Post_Stress_Tester::start( 'product', 6, 'fast', 3 );
+
+ WPDO_Post_Stress_Tester::run_batch();
+ $progress = WPDO_Post_Stress_Tester::get_progress( false );
+ $this->assertSame( 3, $progress['processed'] );
+ $this->assertSame( 1, $progress['batches_done'] );
+ $this->assertSame( 'running', $progress['status'] );
+
+ WPDO_Post_Stress_Tester::run_batch();
+ $progress = WPDO_Post_Stress_Tester::get_progress( false );
+ $this->assertSame( 6, $progress['processed'] );
+ $this->assertSame( 'completed', $progress['status'] );
+ }
+
+ public function test_run_batch_creates_actual_posts(): void {
+ WPDO_Post_Stress_Tester::start( 'product', 4, 'fast', 4 );
+
+ WPDO_Post_Stress_Tester::run_batch();
+
+ $this->assertSame( 4, WPDO_Post_Stress_Tester::count_test_posts() );
+ }
+
+ public function test_run_batch_finalizes_with_benchmark(): void {
+ WPDO_Post_Stress_Tester::start( 'product', 2, 'fast', 2 );
+ WPDO_Post_Stress_Tester::run_batch();
+
+ $state = WPDO_Post_Stress_Tester::get_state();
+ $this->assertSame( 'completed', $state['status'] );
+ $this->assertIsArray( $state['benchmark'] );
+ $this->assertArrayHasKey( 'write', $state['benchmark'] );
+ $this->assertArrayHasKey( 'db_sizes', $state['benchmark'] );
+ $this->assertSame( 'product', $state['benchmark']['post_type'] );
+ }
+
+ public function test_cancel_marks_state_as_cancelled(): void {
+ WPDO_Post_Stress_Tester::start( 'product', 100, 'fast', 50 );
+
+ $result = WPDO_Post_Stress_Tester::cancel();
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 'cancelled', $result['state']['status'] );
+
+ // In-flight batch run after cancel must NOT bump status back to running.
+ WPDO_Post_Stress_Tester::run_batch();
+ $state = WPDO_Post_Stress_Tester::get_state();
+ $this->assertSame( 'cancelled', $state['status'] );
+ }
+
+ public function test_cancel_returns_no_active_job_when_idle(): void {
+ $result = WPDO_Post_Stress_Tester::cancel();
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 'no_active_job', $result['message'] ?? '' );
+ }
+
+ public function test_get_progress_includes_pct_and_eta(): void {
+ WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5 );
+ WPDO_Post_Stress_Tester::run_batch();
+
+ $progress = WPDO_Post_Stress_Tester::get_progress( false );
+ $this->assertArrayHasKey( 'pct', $progress );
+ $this->assertArrayHasKey( 'rate_per_sec', $progress );
+ $this->assertArrayHasKey( 'elapsed_sec', $progress );
+ $this->assertArrayHasKey( 'eta_sec', $progress );
+ $this->assertArrayHasKey( 'test_post_count', $progress );
+ $this->assertSame( 50.0, $progress['pct'] ); // 5/10 = 50%
+ }
+
+ public function test_run_benchmark_returns_post_type_aware_query_probes(): void {
+ WPDO_Post_Stress_Tester::start( 'hp_listing', 4, 'fast', 4 );
+ WPDO_Post_Stress_Tester::run_batch();
+
+ $state = WPDO_Post_Stress_Tester::get_state();
+ $bench = $state['benchmark'];
+
+ $this->assertSame( 'hp_listing', $bench['post_type'] );
+ // query is post_type-aware; in production the flat table exists so this
+ // returns 3 probes (point/range/eav_baseline). In integration tests the
+ // flat table doesn't exist (different prefix), so we only verify the
+ // structure is present and post_type-aware.
+ $this->assertIsArray( $bench['query'] );
+ }
+
+ public function test_run_batch_realistic_uses_wp_insert_post(): void {
+ WPDO_Post_Stress_Tester::start( 'product', 3, 'realistic', 3 );
+ WPDO_Post_Stress_Tester::run_batch();
+
+ $state = WPDO_Post_Stress_Tester::get_state();
+ $this->assertSame( 'completed', $state['status'] );
+ $this->assertSame( 3, $state['processed'] );
+ $this->assertSame( 'realistic', $state['mode'] );
+ }
+}
diff --git a/tests/integration/PostmetaCleanerIntegrationTest.php b/tests/integration/PostmetaCleanerIntegrationTest.php
new file mode 100644
index 0000000..0cf1b80
--- /dev/null
+++ b/tests/integration/PostmetaCleanerIntegrationTest.php
@@ -0,0 +1,204 @@
+query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::POSTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ post_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY post_id (post_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private function seed_postmeta( array $rows ): void {
+ global $wpdb;
+ foreach ( $rows as $row ) {
+ $wpdb->insert( self::POSTMETA, $row );
+ }
+ }
+
+ // ── count_garbage() ───────────────────────────────────────────────────────
+
+ public function test_count_garbage_returns_zero_for_empty_table(): void {
+ $counts = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
+ $this->assertSame( 0, $counts['transients'] );
+ $this->assertSame( 0, $counts['wp_old_date'] );
+ $this->assertSame( 0, $counts['edit_locks'] );
+ $this->assertSame( 0, $counts['total'] );
+ }
+
+ public function test_count_garbage_counts_transients(): void {
+ $this->seed_postmeta( array(
+ array( 'post_id' => 1, 'meta_key' => '_transient_hp_models/listing/v1', 'meta_value' => 'a' ),
+ array( 'post_id' => 1, 'meta_key' => '_transient_timeout_hp_models/listing/v1', 'meta_value' => '9999' ),
+ array( 'post_id' => 2, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
+ array( 'post_id' => 2, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
+ ) );
+
+ $counts = WPDO_Postmeta_Cleaner::count_garbage( 'transients' );
+ $this->assertSame( 3, $counts['transients'] );
+ $this->assertSame( 0, $counts['wp_old_date'] );
+ $this->assertSame( 0, $counts['edit_locks'] );
+ $this->assertSame( 3, $counts['total'] );
+ }
+
+ public function test_count_garbage_counts_wp_old_date(): void {
+ $this->seed_postmeta( array(
+ array( 'post_id' => 1, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
+ array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-02-01' ),
+ array( 'post_id' => 3, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
+ ) );
+
+ $counts = WPDO_Postmeta_Cleaner::count_garbage( 'wp_old_date' );
+ $this->assertSame( 0, $counts['transients'] );
+ $this->assertSame( 2, $counts['wp_old_date'] );
+ $this->assertSame( 0, $counts['edit_locks'] );
+ $this->assertSame( 2, $counts['total'] );
+ }
+
+ public function test_count_garbage_counts_only_stale_edit_locks(): void {
+ $now = time();
+ $one_day_ago = $now - 86400 - 60; // stale by 1 day + 1 min
+ $one_hour_ago = $now - 3600; // fresh, not stale
+ $five_min_ago = $now - 300; // very fresh, not stale
+
+ $this->seed_postmeta( array(
+ array( 'post_id' => 1, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ), // stale ✓
+ array( 'post_id' => 2, 'meta_key' => '_edit_lock', 'meta_value' => $one_hour_ago . ':2' ), // fresh
+ array( 'post_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => $five_min_ago . ':3' ), // fresh
+ array( 'post_id' => 4, 'meta_key' => '_edit_last', 'meta_value' => '4' ), // not edit_lock
+ ) );
+
+ $counts = WPDO_Postmeta_Cleaner::count_garbage( 'edit_locks' );
+ $this->assertSame( 0, $counts['transients'] );
+ $this->assertSame( 0, $counts['wp_old_date'] );
+ $this->assertSame( 1, $counts['edit_locks'], 'Only stale (>24h old) _edit_lock rows count' );
+ $this->assertSame( 1, $counts['total'] );
+ }
+
+ public function test_count_garbage_target_all_unions_all_three(): void {
+ $one_day_ago = time() - 86400 - 60;
+
+ $this->seed_postmeta( array(
+ array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
+ array( 'post_id' => 2, 'meta_key' => '_transient_timeout_foo', 'meta_value' => '99' ),
+ array( 'post_id' => 3, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
+ array( 'post_id' => 4, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ),
+ array( 'post_id' => 5, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
+ ) );
+
+ $counts = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
+ $this->assertSame( 2, $counts['transients'] );
+ $this->assertSame( 1, $counts['wp_old_date'] );
+ $this->assertSame( 1, $counts['edit_locks'] );
+ $this->assertSame( 4, $counts['total'] );
+ }
+
+ public function test_count_garbage_rejects_invalid_target(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Postmeta_Cleaner::count_garbage( 'bogus' );
+ }
+
+ // ── delete_garbage() ──────────────────────────────────────────────────────
+
+ public function test_delete_garbage_removes_only_target_rows(): void {
+ $this->seed_postmeta( array(
+ array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
+ array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
+ array( 'post_id' => 3, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
+ ) );
+
+ $deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'transients' );
+ $this->assertSame( 1, $deleted['transients'] );
+ $this->assertSame( 0, $deleted['wp_old_date'] );
+ $this->assertSame( 0, $deleted['edit_locks'] );
+ $this->assertSame( 1, $deleted['total'] );
+
+ global $wpdb;
+ $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' );
+ $this->assertSame( 2, $remaining, 'Non-transient rows should remain (wp_old_date + hp_price)' );
+ }
+
+ public function test_delete_garbage_target_all_clears_all_three(): void {
+ $one_day_ago = time() - 86400 - 60;
+
+ $this->seed_postmeta( array(
+ array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
+ array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
+ array( 'post_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ),
+ array( 'post_id' => 4, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
+ ) );
+
+ $deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'all' );
+ $this->assertSame( 1, $deleted['transients'] );
+ $this->assertSame( 1, $deleted['wp_old_date'] );
+ $this->assertSame( 1, $deleted['edit_locks'] );
+ $this->assertSame( 3, $deleted['total'] );
+
+ global $wpdb;
+ $remaining = $wpdb->get_results( 'SELECT meta_key FROM `' . self::POSTMETA . '`', ARRAY_A );
+ $this->assertCount( 1, $remaining );
+ $this->assertSame( 'hp_price', $remaining[0]['meta_key'] );
+ }
+
+ public function test_delete_garbage_does_not_touch_fresh_edit_lock(): void {
+ $one_hour_ago = time() - 3600;
+
+ $this->seed_postmeta( array(
+ array( 'post_id' => 1, 'meta_key' => '_edit_lock', 'meta_value' => $one_hour_ago . ':1' ),
+ ) );
+
+ $deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'edit_locks' );
+ $this->assertSame( 0, $deleted['edit_locks'] );
+
+ global $wpdb;
+ $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' );
+ $this->assertSame( 1, $remaining, 'Fresh edit_lock must survive cleanup' );
+ }
+
+ public function test_delete_garbage_rejects_invalid_target(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Postmeta_Cleaner::delete_garbage( 'bogus' );
+ }
+}
diff --git a/tests/integration/QueryRouterIntegrationTest.php b/tests/integration/QueryRouterIntegrationTest.php
new file mode 100644
index 0000000..3ec1813
--- /dev/null
+++ b/tests/integration/QueryRouterIntegrationTest.php
@@ -0,0 +1,373 @@
+register( 'test', [
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_price',
+ 'zone' => 'hot',
+ 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
+ 'column' => 'hp_price',
+ 'indexed' => true,
+ ] );
+ $registry->register( 'test', [
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_featured',
+ 'zone' => 'hot',
+ 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
+ 'column' => 'hp_featured',
+ 'indexed' => false,
+ ] );
+ }
+
+ protected function setUp(): void {
+ $this->router = new WPDO_Query_Router();
+ // Ensure the hot_hp_listing module is in a query-active state.
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
+ }
+
+ // ── pre_get_posts ─────────────────────────────────────────────────────
+
+ public function test_pre_get_posts_extracts_hot_clause(): void {
+ $query = new WP_Query();
+ $query->set( 'post_type', 'hp_listing' );
+ $query->set( 'meta_query', [
+ [ 'key' => 'hp_price', 'value' => '100', 'compare' => '>=', 'type' => 'DECIMAL' ],
+ ] );
+
+ $this->router->pre_get_posts( $query );
+
+ $hot = $query->get( 'wpdo_hot_clauses' );
+ $this->assertIsArray( $hot );
+ $this->assertArrayHasKey( 'hp_listing', $hot );
+ $this->assertCount( 1, $hot['hp_listing'] );
+ $this->assertSame( 'hp_price', $hot['hp_listing'][0]['column'] );
+ $this->assertSame( '>=', $hot['hp_listing'][0]['compare'] );
+ $this->assertSame( '100', $hot['hp_listing'][0]['value'] );
+
+ // Extracted clause must be removed from meta_query.
+ $remaining = (array) $query->get( 'meta_query' );
+ $this->assertEmpty( $remaining );
+ }
+
+ public function test_pre_get_posts_leaves_non_registered_key_in_meta_query(): void {
+ $query = new WP_Query();
+ $query->set( 'post_type', 'hp_listing' );
+ $query->set( 'meta_query', [
+ [ 'key' => 'hp_price', 'value' => '50', 'compare' => '=' ],
+ [ 'key' => 'custom_key', 'value' => 'abc', 'compare' => '=' ],
+ ] );
+
+ $this->router->pre_get_posts( $query );
+
+ $hot = $query->get( 'wpdo_hot_clauses' );
+ $remaining = (array) $query->get( 'meta_query' );
+
+ // hp_price hot-extracted.
+ $this->assertArrayHasKey( 'hp_listing', $hot );
+ $this->assertCount( 1, $hot['hp_listing'] );
+
+ // custom_key stays in meta_query.
+ $this->assertCount( 1, $remaining );
+ $found_keys = array_column( array_values( $remaining ), 'key' );
+ $this->assertContains( 'custom_key', $found_keys );
+ }
+
+ public function test_pre_get_posts_skips_when_no_meta_query(): void {
+ $query = new WP_Query();
+ $query->set( 'post_type', 'hp_listing' );
+ // No meta_query set.
+
+ $this->router->pre_get_posts( $query );
+
+ $hot = $query->get( 'wpdo_hot_clauses' );
+ // Should not have been set at all (get returns default '').
+ $this->assertEmpty( $hot );
+ }
+
+ public function test_pre_get_posts_skips_when_no_post_type(): void {
+ $query = new WP_Query();
+ // No post_type set.
+ $query->set( 'meta_query', [
+ [ 'key' => 'hp_price', 'value' => '10', 'compare' => '=' ],
+ ] );
+
+ $this->router->pre_get_posts( $query );
+
+ $hot = $query->get( 'wpdo_hot_clauses' );
+ $this->assertEmpty( $hot );
+ }
+
+ public function test_pre_get_posts_skips_inactive_module(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
+
+ $query = new WP_Query();
+ $query->set( 'post_type', 'hp_listing' );
+ $query->set( 'meta_query', [
+ [ 'key' => 'hp_price', 'value' => '99', 'compare' => '=' ],
+ ] );
+
+ $this->router->pre_get_posts( $query );
+
+ $hot = $query->get( 'wpdo_hot_clauses' );
+ $remaining = (array) $query->get( 'meta_query' );
+
+ // No hot clauses extracted.
+ $this->assertEmpty( $hot );
+ // Original clause still in meta_query.
+ $this->assertNotEmpty( $remaining );
+ }
+
+ public function test_pre_get_posts_preserves_relation_in_remaining(): void {
+ $query = new WP_Query();
+ $query->set( 'post_type', 'hp_listing' );
+ $query->set( 'meta_query', [
+ 'relation' => 'AND',
+ [ 'key' => 'hp_price', 'value' => '50', 'compare' => '>=' ],
+ [ 'key' => 'custom_key', 'value' => '1', 'compare' => '=' ],
+ ] );
+
+ $this->router->pre_get_posts( $query );
+
+ $remaining = (array) $query->get( 'meta_query' );
+
+ // relation must be preserved because custom_key remains.
+ $this->assertArrayHasKey( 'relation', $remaining );
+ $this->assertSame( 'AND', $remaining['relation'] );
+ }
+
+ public function test_pre_get_posts_extracts_multiple_hot_fields(): void {
+ $query = new WP_Query();
+ $query->set( 'post_type', 'hp_listing' );
+ $query->set( 'meta_query', [
+ [ 'key' => 'hp_price', 'value' => '200', 'compare' => '<=' ],
+ [ 'key' => 'hp_featured', 'value' => '1', 'compare' => '=' ],
+ ] );
+
+ $this->router->pre_get_posts( $query );
+
+ $hot = $query->get( 'wpdo_hot_clauses' );
+ $this->assertCount( 2, $hot['hp_listing'] );
+
+ $columns = array_column( $hot['hp_listing'], 'column' );
+ $this->assertContains( 'hp_price', $columns );
+ $this->assertContains( 'hp_featured', $columns );
+
+ // meta_query fully cleared.
+ $this->assertEmpty( (array) $query->get( 'meta_query' ) );
+ }
+
+ // ── posts_join ────────────────────────────────────────────────────────
+
+ public function test_posts_join_generates_left_join_sql(): void {
+ $query = new WP_Query();
+ $query->set( 'wpdo_hot_clauses', [
+ 'hp_listing' => [
+ [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '100' ],
+ ],
+ ] );
+
+ $join = $this->router->posts_join( '', $query );
+
+ $this->assertStringContainsString( 'LEFT JOIN', $join );
+ // Full physical table name.
+ $this->assertStringContainsString( 'wp_itest_wpdo_hot_hp_listing', $join );
+ // Alias.
+ $this->assertStringContainsString( '`wpdo_hot_hp_listing`', $join );
+ // posts table join key.
+ $this->assertStringContainsString( '`wp_itest_posts`', $join );
+ $this->assertStringContainsString( 'post_id', $join );
+ }
+
+ public function test_posts_join_passthrough_when_no_hot_clauses(): void {
+ $query = new WP_Query();
+ $original = ' LEFT JOIN wp_postmeta ON (wp_posts.ID = wp_postmeta.post_id)';
+
+ $join = $this->router->posts_join( $original, $query );
+
+ $this->assertSame( $original, $join );
+ }
+
+ public function test_posts_join_no_duplicate_join_for_same_alias(): void {
+ $query = new WP_Query();
+ $query->set( 'wpdo_hot_clauses', [
+ 'hp_listing' => [
+ [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '100' ],
+ ],
+ ] );
+
+ // Simulate alias already present in existing join string.
+ $existing = ' LEFT JOIN `wp_itest_wpdo_hot_hp_listing` AS `wpdo_hot_hp_listing` ON (...)';
+ $join = $this->router->posts_join( $existing, $query );
+
+ // Should appear exactly once.
+ $this->assertSame( 1, substr_count( $join, '`wpdo_hot_hp_listing`' ) );
+ }
+
+ // ── posts_where ───────────────────────────────────────────────────────
+
+ /** Helper: build a WP_Query with pre-set hot_clauses. */
+ private function query_with_clauses( array $clauses ): WP_Query {
+ $query = new WP_Query();
+ $query->set( 'wpdo_hot_clauses', [ 'hp_listing' => $clauses ] );
+ return $query;
+ }
+
+ public function test_posts_where_equality_condition(): void {
+ $query = $this->query_with_clauses( [
+ [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '99.00' ],
+ ] );
+
+ $where = $this->router->posts_where( '', $query );
+
+ $this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price`', $where );
+ $this->assertStringContainsString( '=', $where );
+ $this->assertStringContainsString( "'99.00'", $where );
+ }
+
+ public function test_posts_where_numeric_type_uses_integer_placeholder(): void {
+ $query = $this->query_with_clauses( [
+ [ 'column' => 'hp_featured', 'compare' => '=', 'type' => 'NUMERIC', 'value' => 1 ],
+ ] );
+
+ $where = $this->router->posts_where( '', $query );
+
+ // %d format — integer value, not quoted.
+ $this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_featured` = 1', $where );
+ }
+
+ public function test_posts_where_in_condition(): void {
+ $query = $this->query_with_clauses( [
+ [ 'column' => 'hp_price', 'compare' => 'IN', 'type' => 'CHAR', 'value' => [ '10.00', '20.00', '30.00' ] ],
+ ] );
+
+ $where = $this->router->posts_where( '', $query );
+
+ $this->assertStringContainsString( 'IN', $where );
+ $this->assertStringContainsString( "'10.00'", $where );
+ $this->assertStringContainsString( "'20.00'", $where );
+ $this->assertStringContainsString( "'30.00'", $where );
+ }
+
+ public function test_posts_where_in_empty_array_generates_false_condition(): void {
+ $query = $this->query_with_clauses( [
+ [ 'column' => 'hp_price', 'compare' => 'IN', 'type' => 'CHAR', 'value' => [] ],
+ ] );
+
+ $where = $this->router->posts_where( '', $query );
+
+ $this->assertStringContainsString( '1=0', $where );
+ }
+
+ public function test_posts_where_between_condition(): void {
+ $query = $this->query_with_clauses( [
+ [ 'column' => 'hp_price', 'compare' => 'BETWEEN', 'type' => 'CHAR', 'value' => [ '10.00', '50.00' ] ],
+ ] );
+
+ $where = $this->router->posts_where( '', $query );
+
+ $this->assertStringContainsString( 'BETWEEN', $where );
+ $this->assertStringContainsString( "'10.00'", $where );
+ $this->assertStringContainsString( "'50.00'", $where );
+ }
+
+ public function test_posts_where_exists_generates_is_not_null(): void {
+ $query = $this->query_with_clauses( [
+ [ 'column' => 'hp_price', 'compare' => 'EXISTS', 'type' => 'CHAR', 'value' => '' ],
+ ] );
+
+ $where = $this->router->posts_where( '', $query );
+
+ $this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price` IS NOT NULL', $where );
+ }
+
+ public function test_posts_where_not_exists_generates_is_null(): void {
+ $query = $this->query_with_clauses( [
+ [ 'column' => 'hp_price', 'compare' => 'NOT EXISTS', 'type' => 'CHAR', 'value' => '' ],
+ ] );
+
+ $where = $this->router->posts_where( '', $query );
+
+ $this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price` IS NULL', $where );
+ }
+
+ public function test_posts_where_passthrough_when_no_hot_clauses(): void {
+ $query = new WP_Query();
+ $original = ' AND wp_posts.post_status = \'publish\'';
+
+ $where = $this->router->posts_where( $original, $query );
+
+ $this->assertSame( $original, $where );
+ }
+
+ public function test_posts_where_appends_to_existing_where(): void {
+ $query = $this->query_with_clauses( [
+ [ 'column' => 'hp_featured', 'compare' => '=', 'type' => 'NUMERIC', 'value' => 1 ],
+ ] );
+ $existing = " AND wp_posts.post_status = 'publish'";
+
+ $where = $this->router->posts_where( $existing, $query );
+
+ $this->assertStringStartsWith( $existing, $where );
+ $this->assertStringContainsString( 'hp_featured', $where );
+ }
+
+ // ── posts_groupby ─────────────────────────────────────────────────────
+
+ public function test_posts_groupby_sets_posts_id_when_empty(): void {
+ $query = new WP_Query();
+ $query->set( 'wpdo_hot_clauses', [
+ 'hp_listing' => [
+ [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '1' ],
+ ],
+ ] );
+
+ $groupby = $this->router->posts_groupby( '', $query );
+
+ $this->assertStringContainsString( 'wp_itest_posts', $groupby );
+ $this->assertStringContainsString( 'ID', $groupby );
+ }
+
+ public function test_posts_groupby_preserves_existing_groupby(): void {
+ $query = new WP_Query();
+ $query->set( 'wpdo_hot_clauses', [
+ 'hp_listing' => [
+ [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '1' ],
+ ],
+ ] );
+ $existing = '`wp_itest_posts`.`ID`, `wp_itest_posts`.`post_type`';
+
+ $groupby = $this->router->posts_groupby( $existing, $query );
+
+ // Existing groupby preserved unchanged (not empty, so no override).
+ $this->assertSame( $existing, $groupby );
+ }
+
+ public function test_posts_groupby_passthrough_when_no_hot_clauses(): void {
+ $query = new WP_Query();
+ $original = '`wp_itest_posts`.`ID`';
+
+ $groupby = $this->router->posts_groupby( $original, $query );
+
+ $this->assertSame( $original, $groupby );
+ }
+}
diff --git a/tests/integration/RestApiIntegrationTest.php b/tests/integration/RestApiIntegrationTest.php
new file mode 100644
index 0000000..bc2343a
--- /dev/null
+++ b/tests/integration/RestApiIntegrationTest.php
@@ -0,0 +1,322 @@
+prefix . 'wpdo_hot_restapi';
+ self::$warm_table = $wpdb->prefix . 'wpdo_warm';
+
+ // Register fields for the fake 'restapi' post type.
+ $registry = WPDO_Schema_Registry::instance();
+ $registry->register( 'integration_rest', [
+ 'post_type' => 'restapi',
+ 'meta_key' => 'rp_price',
+ 'zone' => 'hot',
+ 'column' => 'rp_price',
+ 'type' => 'decimal',
+ ] );
+ $registry->register( 'integration_rest', [
+ 'post_type' => 'restapi',
+ 'meta_key' => 'rp_featured',
+ 'zone' => 'hot',
+ 'column' => 'rp_featured',
+ 'type' => 'tinyint',
+ ] );
+ $registry->register( 'integration_rest', [
+ 'post_type' => 'restapi',
+ 'meta_key' => 'rp_description',
+ 'zone' => 'cold',
+ ] );
+
+ // Create hot table.
+ $wpdb->query(
+ "CREATE TABLE IF NOT EXISTS `" . self::$hot_table . "` (
+ post_id BIGINT UNSIGNED NOT NULL,
+ rp_price DECIMAL(10,2) DEFAULT NULL,
+ rp_featured TINYINT(1) DEFAULT NULL,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (post_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+
+ // Create warm table (needed by WPDO_Listing_Stats::get_view_count).
+ $wpdb->query(
+ "CREATE TABLE IF NOT EXISTS `" . self::$warm_table . "` (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ post_id BIGINT UNSIGNED NOT NULL,
+ meta_key VARCHAR(255) NOT NULL,
+ meta_value LONGTEXT,
+ expires_at DATETIME DEFAULT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY post_meta (post_id, meta_key)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+
+ // Seed 5 rows.
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $price = $i * 100;
+ $featured = $i % 2;
+ $wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" );
+ }
+
+ // Set module to cutover so REST API reads from Zone A.
+ WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
+ WPDO_Feature_Flags::reset( 'hot_restapi' );
+ }
+
+ protected function setUp(): void {
+ $GLOBALS['_wp_cache'] = [];
+ $GLOBALS['_wp_post_types'] = [];
+ $GLOBALS['_wp_postmeta'] = [];
+ $GLOBALS['_wp_current_user_can'] = [];
+ $GLOBALS['_wp_valid_nonces'] = [];
+ $GLOBALS['_wp_transients'] = []; // Reset rate-limit transients between tests.
+ // Note: do NOT reset _wp_options here — Feature Flags state is stored there.
+
+ // Invalidate Feature Flags static request cache so each test reads fresh.
+ $ref = new ReflectionClass( WPDO_Feature_Flags::class );
+ $prop = $ref->getProperty( 'cache' );
+ $prop->setAccessible( true );
+ $prop->setValue( null, null );
+ }
+
+ // ── GET /listings (Zone A path) ──────────────────────────────────────────
+
+ public function test_listings_returns_all_rows(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'restapi' );
+ $req->set_param( 'per_page', 10 );
+
+ $response = self::$api->get_listings( $req );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertCount( 5, $data );
+ $this->assertSame( '5', $response->get_headers()['X-WP-Total'] );
+ }
+
+ public function test_listings_returns_correct_fields(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'restapi' );
+ $req->set_param( 'per_page', 1 );
+ $req->set_param( 'orderby', 'post_id' );
+ $req->set_param( 'order', 'ASC' );
+
+ $response = self::$api->get_listings( $req );
+ $data = $response->get_data();
+
+ $this->assertSame( 1, $data[0]['id'] );
+ $this->assertSame( 'restapi', $data[0]['post_type'] );
+ $this->assertArrayHasKey( 'rp_price', $data[0] );
+ $this->assertArrayHasKey( 'rp_featured', $data[0] );
+ $this->assertArrayNotHasKey( 'post_id', $data[0] );
+ $this->assertArrayNotHasKey( 'updated_at', $data[0] );
+ }
+
+ public function test_listings_pagination(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'restapi' );
+ $req->set_param( 'per_page', 2 );
+ $req->set_param( 'page', 2 );
+ $req->set_param( 'orderby', 'post_id' );
+ $req->set_param( 'order', 'ASC' );
+
+ $response = self::$api->get_listings( $req );
+ $data = $response->get_data();
+
+ $this->assertCount( 2, $data );
+ $this->assertSame( 3, $data[0]['id'] ); // page 2 offset 2 → post_id 3
+ $this->assertSame( '3', $response->get_headers()['X-WP-TotalPages'] );
+ }
+
+ public function test_listings_per_page_clamped_to_max_100(): void {
+ // PR-0 R-4: defense-in-depth — per_page=99999 must clamp to 100, not DoS the DB.
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'restapi' );
+ $req->set_param( 'per_page', 99999 );
+
+ $response = self::$api->get_listings( $req );
+
+ // Should return at most 100 items (real dataset is 5 — capped by total).
+ $data = $response->get_data();
+ $this->assertLessThanOrEqual( 100, count( $data ) );
+ }
+
+ public function test_listings_per_page_max_filter_overridable(): void {
+ // PR-0 R-4: site owners can lower the cap via wpdo_rest_max_per_page filter.
+ // We can't fully test add_filter() in this stubbed env, but we verify the
+ // constant value is correctly read in the code path (above test exercises 100).
+ $this->assertTrue( true );
+ }
+
+ public function test_listings_filter_price_min(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'restapi' );
+ $req->set_param( 'rp_price_min', 300 );
+
+ $response = self::$api->get_listings( $req );
+ $data = $response->get_data();
+
+ // Prices are 100,200,300,400,500 → ≥300 = 3 rows.
+ $this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
+ foreach ( $data as $item ) {
+ $this->assertGreaterThanOrEqual( 300.0, (float) $item['rp_price'] );
+ }
+ }
+
+ public function test_listings_filter_price_range(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'restapi' );
+ $req->set_param( 'rp_price_min', 200 );
+ $req->set_param( 'rp_price_max', 400 );
+
+ $response = self::$api->get_listings( $req );
+
+ $this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
+ }
+
+ public function test_listings_filter_exact_value(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'restapi' );
+ $req->set_param( 'rp_featured', 1 );
+
+ $response = self::$api->get_listings( $req );
+ $data = $response->get_data();
+
+ // featured=1 for post_id 1,3,5 → 3 rows.
+ $this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
+ foreach ( $data as $item ) {
+ $this->assertSame( '1', (string) $item['rp_featured'] );
+ }
+ }
+
+ public function test_listings_order_asc(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'restapi' );
+ $req->set_param( 'orderby', 'rp_price' );
+ $req->set_param( 'order', 'ASC' );
+ $req->set_param( 'per_page', 5 );
+
+ $response = self::$api->get_listings( $req );
+ $data = $response->get_data();
+
+ $prices = array_column( $data, 'rp_price' );
+ $sorted = $prices;
+ sort( $sorted );
+ $this->assertSame( $sorted, $prices );
+ }
+
+ // ── GET /listings/{id} ───────────────────────────────────────────────────
+
+ public function test_get_listing_404_for_unknown_post(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' );
+ $req->set_param( 'id', 9999 );
+
+ $response = self::$api->get_listing( $req );
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ public function test_get_listing_merges_hot_and_postmeta_cold(): void {
+ // post_id 1 is in hot table (rp_price=100); cold zone idle → postmeta fallback.
+ $GLOBALS['_wp_post_types'][1] = 'restapi';
+ $GLOBALS['_wp_postmeta'][1]['rp_description'] = 'Integration test';
+
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/1' );
+ $req->set_param( 'id', 1 );
+
+ $response = self::$api->get_listing( $req );
+ $this->assertSame( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame( 1, $data['id'] );
+ $this->assertSame( '100.00', $data['rp_price'] );
+ $this->assertSame( 'Integration test', $data['rp_description'] );
+ }
+
+ // ── GET /stats/{id} ──────────────────────────────────────────────────────
+
+ public function test_get_stats_returns_zero_for_unknown_post(): void {
+ $GLOBALS['_wp_post_types'][77] = 'restapi';
+
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/77' );
+ $req->set_param( 'id', 77 );
+
+ $response = self::$api->get_stats( $req );
+ $this->assertSame( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame( 77, $data['post_id'] );
+ $this->assertSame( 0, $data['view_count'] );
+ }
+
+ // ── GET /status ──────────────────────────────────────────────────────────
+
+ public function test_get_status_requires_manage_options(): void {
+ $GLOBALS['_wp_current_user_can']['manage_options'] = false;
+ $this->assertFalse( self::$api->require_manage_options() );
+ }
+
+ public function test_get_status_returns_correct_engine(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
+ $response = self::$api->get_status( $req );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'mysql', $data['engine'] );
+ $this->assertArrayHasKey( 'modules', $data );
+ $this->assertSame( 'cutover', $data['modules']['hot_restapi'] );
+ }
+
+ // ── POST /listings/{id}/view ──────────────────────────────────────────────
+
+ public function test_post_view_403_without_nonce(): void {
+ $GLOBALS['_wp_post_types'][1] = 'restapi';
+ $GLOBALS['_wp_valid_nonces'] = [];
+
+ $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/1/view' );
+ $req->set_param( 'id', 1 );
+ // No nonce.
+
+ $response = self::$api->post_view( $req );
+ $this->assertSame( 403, $response->get_status() );
+ }
+
+ public function test_post_view_404_for_unknown_post(): void {
+ $nonce = wp_create_nonce( 'wp_rest' );
+
+ $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/8888/view' );
+ $req->set_param( 'id', 8888 );
+ $req->set_header( 'X-WP-Nonce', $nonce );
+
+ $response = self::$api->post_view( $req );
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+}
+
diff --git a/tests/integration/SyncBridgeEntityGuardTest.php b/tests/integration/SyncBridgeEntityGuardTest.php
new file mode 100644
index 0000000..c0d6b2d
--- /dev/null
+++ b/tests/integration/SyncBridgeEntityGuardTest.php
@@ -0,0 +1,262 @@
+query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TABLE . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `hp_price` decimal(10,2) DEFAULT NULL,
+ `hp_legacy_only` varchar(255) DEFAULT NULL,
+ `updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `post_id` (`post_id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
+ $wpdb->query(
+ 'CREATE TABLE `wp_itest_wpdo_errors` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `module` varchar(100) NOT NULL DEFAULT \'\',
+ `zone` varchar(20) NOT NULL DEFAULT \'\',
+ `hook` varchar(255) NOT NULL DEFAULT \'\',
+ `message` text NOT NULL,
+ `context` longtext,
+ `created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
+ PRIMARY KEY (`id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+
+ // Reset Schema_Registry singleton + register both keys (one will overlap with Entity_Registry).
+ $ref = new ReflectionClass( WPDO_Schema_Registry::class );
+ $inst = $ref->getProperty( 'instance' );
+ $inst->setAccessible( true );
+ $inst->setValue( null, null );
+
+ WPDO_Schema_Registry::instance()->register( 'test', array(
+ 'post_type' => self::POST_TYPE,
+ 'meta_key' => self::ENTITY_KEY,
+ 'zone' => 'hot',
+ 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
+ 'column' => self::ENTITY_KEY,
+ 'indexed' => false,
+ ) );
+ WPDO_Schema_Registry::instance()->register( 'test', array(
+ 'post_type' => self::POST_TYPE,
+ 'meta_key' => self::ZONE_ONLY_KEY,
+ 'zone' => 'hot',
+ 'data_type' => 'varchar(255) DEFAULT NULL',
+ 'column' => self::ZONE_ONLY_KEY,
+ 'indexed' => false,
+ ) );
+
+ // Register post adapter + post-fields groups (puts hp_price into Entity_Registry).
+ WPDO_Entity_Registry::init();
+ WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
+ WPDO_Post_Fields::register_entity_fields();
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
+
+ // Reset Mode_Manager cache to prevent post=dual_write leaking into
+ // later tests that share the same PHP process (e.g. SyncBridgeIntegrationTest
+ // which uses 'hp_price' as a generic test field — that key is in the post
+ // Entity_Registry once we've registered it here, so the guard would fire
+ // in those tests' assertions if mode is still cached as dual_write).
+ $ref = new ReflectionClass( WPDO_Mode_Manager::class );
+ $cache = $ref->getProperty( 'cache' );
+ $cache->setAccessible( true );
+ $cache->setValue( null, null );
+
+ // Also reset Entity_Registry so the registered post groups don't leak.
+ WPDO_Entity_Registry::init();
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
+
+ $GLOBALS['_wp_options'] = array();
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+
+ // Reset Sync_Bridge state.
+ $ref = new ReflectionClass( WPDO_Sync_Bridge::class );
+ $cache = $ref->getProperty( 'field_cache' );
+ $cache->setAccessible( true );
+ $cache->setValue( null, array() );
+ $bypass = $ref->getProperty( 'bypassing' );
+ $bypass->setAccessible( true );
+ $bypass->setValue( null, false );
+
+ // Reset Mode_Manager cache to default (post=disabled).
+ // Tests that need dual_write override via set_post_mode() helper below,
+ // which writes the cache directly (avoiding Cache_Orchestrator dep).
+ self::set_post_mode( 'disabled' );
+
+ // Seed post-type lookup.
+ $GLOBALS['_wp_post_types'] = array();
+ for ( $i = 1; $i <= 20; $i++ ) {
+ $GLOBALS['_wp_post_types'][ $i ] = self::POST_TYPE;
+ }
+
+ $GLOBALS['_wp_cache'] = array();
+
+ $this->bridge = new WPDO_Sync_Bridge();
+ }
+
+ /**
+ * Set Mode_Manager post mode by writing the static cache directly,
+ * bypassing set() which has a hard dep on WPDO_Cache_Orchestrator
+ * (out of scope for this guard test).
+ */
+ private static function set_post_mode( string $mode ): void {
+ $ref = new ReflectionClass( WPDO_Mode_Manager::class );
+ $cache = $ref->getProperty( 'cache' );
+ $cache->setAccessible( true );
+ $cache->setValue( null, array(
+ 'post' => $mode,
+ 'user' => 'aeav_only', // user mode frozen — must not change
+ 'term' => 'dual_write',
+ 'comment' => 'dual_write',
+ ) );
+ }
+
+ // ── Tests ─────────────────────────────────────────────────────────────────
+
+ /**
+ * Baseline: post mode = disabled (default) — Sync_Bridge MUST still write zone.
+ * This guarantees v2.9.1 → v2.9.2 upgrade is zero-impact for users who
+ * haven't opted in to Entity Bridge post mode.
+ */
+ public function test_zone_write_unchanged_when_post_mode_disabled(): void {
+ // post mode defaults to disabled — Mode_Manager reads from option.
+ $this->bridge->intercept_update( null, 1, self::ENTITY_KEY, '199.99', '' );
+
+ $val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, self::ENTITY_KEY );
+ $this->assertSame(
+ '199.99',
+ $val,
+ 'mode=disabled: Sync_Bridge must continue writing zone (legacy back-compat).'
+ );
+ }
+
+ /**
+ * Guard: post mode = dual_write + key registered in Entity_Registry
+ * → Sync_Bridge skips zone write (Entity Bridge will handle it).
+ */
+ public function test_zone_skipped_when_post_mode_dual_write_and_key_in_entity_registry(): void {
+ self::set_post_mode( 'dual_write' );
+
+ $this->bridge->intercept_update( null, 2, self::ENTITY_KEY, '299.99', '' );
+
+ $val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, self::ENTITY_KEY );
+ $this->assertNull(
+ $val,
+ 'mode=dual_write + Entity_Registry has key: Sync_Bridge MUST skip zone write to avoid double-write.'
+ );
+ }
+
+ /**
+ * Back-compat: post mode = dual_write + key NOT in Entity_Registry
+ * → Sync_Bridge still writes zone (only Entity_Registry-managed keys are skipped).
+ */
+ public function test_zone_write_continues_for_zone_only_key_when_post_mode_dual_write(): void {
+ self::set_post_mode( 'dual_write' );
+
+ // hp_legacy_only is in Schema_Registry only — not in Entity_Registry.
+ $this->bridge->intercept_update( null, 3, self::ZONE_ONLY_KEY, 'legacy_value', '' );
+
+ $val = WPDO_Zone_Hot::get( 3, self::POST_TYPE, self::ZONE_ONLY_KEY );
+ $this->assertSame(
+ 'legacy_value',
+ $val,
+ 'mode=dual_write but key not in Entity_Registry: Sync_Bridge must keep writing zone (back-compat).'
+ );
+ }
+
+ /**
+ * The intercept_update return value must remain null in all branches —
+ * we never short-circuit WP native postmeta in v2.9.2 (still dual_write
+ * w.r.t. wp_postmeta; cutover comes in v2.9.5).
+ */
+ public function test_intercept_returns_null_regardless_of_guard(): void {
+ self::set_post_mode( 'dual_write' );
+
+ $result_skipped = $this->bridge->intercept_update( null, 4, self::ENTITY_KEY, '50.00', '' );
+ $result_written = $this->bridge->intercept_update( null, 5, self::ZONE_ONLY_KEY, 'x', '' );
+
+ $this->assertNull( $result_skipped, 'Guard branch must still return null.' );
+ $this->assertNull( $result_written, 'Non-guard branch must still return null.' );
+ }
+
+ /**
+ * intercept_add must apply the same guard.
+ */
+ public function test_add_zone_skipped_when_entity_registry_owns_key(): void {
+ self::set_post_mode( 'dual_write' );
+
+ $this->bridge->intercept_add( null, 6, self::ENTITY_KEY, '99.99', true );
+
+ $val = WPDO_Zone_Hot::get( 6, self::POST_TYPE, self::ENTITY_KEY );
+ $this->assertNull(
+ $val,
+ 'intercept_add must apply the Entity_Registry guard symmetrically with intercept_update.'
+ );
+ }
+}
diff --git a/tests/integration/SyncBridgeIntegrationTest.php b/tests/integration/SyncBridgeIntegrationTest.php
new file mode 100644
index 0000000..8b21974
--- /dev/null
+++ b/tests/integration/SyncBridgeIntegrationTest.php
@@ -0,0 +1,272 @@
+query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TABLE . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `hp_price` decimal(10,2) DEFAULT NULL,
+ `updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `post_id` (`post_id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+
+ // Create the errors log table so WPDO_Logger::error() can write to it.
+ $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
+ $wpdb->query(
+ 'CREATE TABLE `wp_itest_wpdo_errors` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `module` varchar(100) NOT NULL DEFAULT \'\',
+ `zone` varchar(20) NOT NULL DEFAULT \'\',
+ `hook` varchar(255) NOT NULL DEFAULT \'\',
+ `message` text NOT NULL,
+ `context` longtext,
+ `created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
+ PRIMARY KEY (`id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+
+ // Reset and populate the Schema Registry singleton.
+ $ref = new ReflectionClass( WPDO_Schema_Registry::class );
+ $inst = $ref->getProperty( 'instance' );
+ $inst->setAccessible( true );
+ $inst->setValue( null, null );
+
+ WPDO_Schema_Registry::instance()->register( 'test_provider', [
+ 'post_type' => self::POST_TYPE,
+ 'meta_key' => self::FIELD,
+ 'zone' => 'hot',
+ 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
+ 'column' => self::FIELD,
+ 'indexed' => false,
+ ] );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+
+ // Wipe data before each test.
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
+
+ // Reset FeatureFlags (clears option + static cache).
+ $GLOBALS['_wp_options'] = [];
+ WPDO_Feature_Flags::set( self::MODULE, 'idle' );
+
+ // Reset SyncBridge request-level field cache.
+ $ref = new ReflectionClass( WPDO_Sync_Bridge::class );
+ $cache = $ref->getProperty( 'field_cache' );
+ $cache->setAccessible( true );
+ $cache->setValue( null, [] );
+
+ // Reset $bypassing flag.
+ $bypass = $ref->getProperty( 'bypassing' );
+ $bypass->setAccessible( true );
+ $bypass->setValue( null, false );
+
+ // Seed post-type lookup.
+ $GLOBALS['_wp_post_types'] = [];
+ for ( $i = 1; $i <= 20; $i++ ) {
+ $GLOBALS['_wp_post_types'][ $i ] = self::POST_TYPE;
+ }
+
+ // Object cache reset.
+ $GLOBALS['_wp_cache'] = [];
+
+ $this->bridge = new WPDO_Sync_Bridge();
+ }
+
+ // ── intercept_update ─────────────────────────────────────────────────────
+
+ public function test_update_dual_write_writes_value_to_hot_table(): void {
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+
+ $this->bridge->intercept_update( null, 1, self::FIELD, '199.99', '' );
+
+ $val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, self::FIELD );
+ $this->assertSame( '199.99', $val );
+ }
+
+ public function test_update_idle_does_not_write_to_hot_table(): void {
+ // Module stays in 'idle' — is_write_active() returns false.
+ $this->bridge->intercept_update( null, 2, self::FIELD, '50.00', '' );
+
+ $val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, self::FIELD );
+ $this->assertNull( $val );
+ }
+
+ public function test_update_always_returns_null_to_allow_native_write(): void {
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+
+ $result = $this->bridge->intercept_update( null, 3, self::FIELD, '100.00', '' );
+
+ // Must return null (not short-circuit) so WordPress still writes postmeta.
+ $this->assertNull( $result );
+ }
+
+ public function test_update_skips_unregistered_meta_key(): void {
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+
+ // 'hp_unregistered' is not in Schema Registry.
+ $this->bridge->intercept_update( null, 4, 'hp_unregistered', '42.00', '' );
+
+ // Hot table for test_post should still be empty.
+ $val = WPDO_Zone_Hot::get( 4, self::POST_TYPE, self::FIELD );
+ $this->assertNull( $val );
+ }
+
+ public function test_update_skips_when_post_type_unknown(): void {
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+
+ // post_id 999 not seeded in _wp_post_types → get_post_type() returns false.
+ $this->bridge->intercept_update( null, 999, self::FIELD, '77.00', '' );
+
+ // Nothing should have been written (table doesn't have post 999).
+ $val = WPDO_Zone_Hot::get( 999, self::POST_TYPE, self::FIELD );
+ $this->assertNull( $val );
+ }
+
+ // ── intercept_add ────────────────────────────────────────────────────────
+
+ public function test_add_dual_write_writes_value_to_hot_table(): void {
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+
+ $this->bridge->intercept_add( null, 5, self::FIELD, '299.00', true );
+
+ $val = WPDO_Zone_Hot::get( 5, self::POST_TYPE, self::FIELD );
+ $this->assertSame( '299.00', $val );
+ }
+
+ public function test_add_returns_null_to_allow_native_write(): void {
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+
+ $result = $this->bridge->intercept_add( null, 6, self::FIELD, '10.00', false );
+
+ $this->assertNull( $result );
+ }
+
+ // ── intercept_get ────────────────────────────────────────────────────────
+
+ public function test_get_cutover_returns_zone_value_wrapped_in_array(): void {
+ // Write directly to hot table, then verify intercept_get reads it back.
+ WPDO_Zone_Hot::set( 7, self::POST_TYPE, self::FIELD, '500.00' );
+ WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
+
+ $result = $this->bridge->intercept_get( null, 7, self::FIELD, true );
+
+ // SyncBridge wraps value in array so WP can unwrap correctly.
+ $this->assertIsArray( $result );
+ $this->assertSame( '500.00', $result[0] );
+ }
+
+ public function test_get_dual_write_returns_null_passthrough(): void {
+ WPDO_Zone_Hot::set( 8, self::POST_TYPE, self::FIELD, '123.00' );
+ // dual_write is NOT a read-custom state.
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+
+ $result = $this->bridge->intercept_get( null, 8, self::FIELD, true );
+
+ // Should pass through (return null) so WP reads from postmeta.
+ $this->assertNull( $result );
+ }
+
+ public function test_get_returns_null_when_no_zone_row(): void {
+ // cutover state but no row in hot table.
+ WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
+
+ $result = $this->bridge->intercept_get( null, 9, self::FIELD, true );
+
+ $this->assertNull( $result );
+ }
+
+ public function test_get_returns_null_for_empty_meta_key(): void {
+ WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
+
+ // Empty meta_key means "get all meta" — bridge should pass through.
+ $result = $this->bridge->intercept_get( null, 10, '', true );
+
+ $this->assertNull( $result );
+ }
+
+ // ── intercept_delete ─────────────────────────────────────────────────────
+
+ public function test_delete_zeros_out_hot_column(): void {
+ WPDO_Zone_Hot::set( 11, self::POST_TYPE, self::FIELD, '999.00' );
+ $this->assertSame( '999.00', WPDO_Zone_Hot::get( 11, self::POST_TYPE, self::FIELD ) );
+
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+ $this->bridge->intercept_delete( [ 1 ], 11, self::FIELD, '999.00' );
+
+ // delete_from_zone calls Zone_Hot::set(post_id, post_type, column, null).
+ $val = WPDO_Zone_Hot::get( 11, self::POST_TYPE, self::FIELD );
+ $this->assertNull( $val );
+ }
+
+ // ── $bypassing flag ───────────────────────────────────────────────────────
+
+ public function test_bypass_flag_prevents_intercept_get(): void {
+ WPDO_Zone_Hot::set( 12, self::POST_TYPE, self::FIELD, '777.00' );
+ WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
+
+ // Simulate internal call (e.g. migration reading postmeta).
+ $ref = new ReflectionClass( WPDO_Sync_Bridge::class );
+ $bypass = $ref->getProperty( 'bypassing' );
+ $bypass->setAccessible( true );
+ $bypass->setValue( null, true );
+
+ $result = $this->bridge->intercept_get( null, 12, self::FIELD, true );
+
+ // Should pass through immediately, ignoring zone.
+ $this->assertNull( $result );
+ }
+
+ public function test_bypass_flag_prevents_intercept_update(): void {
+ WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
+
+ $ref = new ReflectionClass( WPDO_Sync_Bridge::class );
+ $bypass = $ref->getProperty( 'bypassing' );
+ $bypass->setAccessible( true );
+ $bypass->setValue( null, true );
+
+ $this->bridge->intercept_update( null, 13, self::FIELD, '888.00', '' );
+
+ // bypassing = true → no write to hot table.
+ $val = WPDO_Zone_Hot::get( 13, self::POST_TYPE, self::FIELD );
+ $this->assertNull( $val );
+ }
+}
diff --git a/tests/integration/TermCommentGarbageFilterTest.php b/tests/integration/TermCommentGarbageFilterTest.php
new file mode 100644
index 0000000..36a69b0
--- /dev/null
+++ b/tests/integration/TermCommentGarbageFilterTest.php
@@ -0,0 +1,164 @@
+assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_user' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_post' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_term' ) );
+ }
+
+ public function test_is_shared_garbage_key_matches_2meet_demo(): void {
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demo_music' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demo_adv' ) );
+ }
+
+ public function test_is_shared_garbage_key_rejects_legitimate_keys(): void {
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_sort_order' ) );
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_default' ) );
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_rating' ) );
+ }
+
+ public function test_is_shared_garbage_key_rejects_partial_match(): void {
+ // Substring matches should NOT trigger.
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'something_wxr_import_' ) );
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_imp' ) );
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demos' ) );
+ }
+
+ public function test_is_shared_garbage_key_rejects_non_string(): void {
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( null ) );
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 123 ) );
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( array() ) );
+ }
+
+ // ── is_comment_orphan_key ────────────────────────────────────────────────
+
+ public function test_is_comment_orphan_key_matches_post_domain_keys(): void {
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_price' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_status' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_featured' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_verified' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_view_count' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_thumbnail_id' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_edit_lock' ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_edit_last' ) );
+ }
+
+ public function test_is_comment_orphan_key_rejects_legitimate_comment_keys(): void {
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( 'hp_rating' ) );
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( 'note_group' ) );
+ }
+
+ public function test_is_comment_orphan_key_requires_exact_match(): void {
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_price_extended' ) );
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_pric' ) );
+ }
+
+ // ── on_term_write callback ───────────────────────────────────────────────
+
+ public function test_on_term_write_drops_garbage_keys(): void {
+ $result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'val', false );
+ $this->assertTrue( $result, 'garbage write must short-circuit (return true)' );
+ }
+
+ public function test_on_term_write_passes_through_legitimate_keys(): void {
+ $result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, 'hp_sort_order', '5', false );
+ $this->assertNull( $result, 'legitimate write must fall through (return null)' );
+ }
+
+ public function test_on_term_write_does_not_apply_comment_orphan_rules(): void {
+ // _hp_price is comment-only orphan; for term writes it must pass through
+ $result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_hp_price', '99', false );
+ $this->assertNull( $result );
+ }
+
+ // ── on_comment_write callback ────────────────────────────────────────────
+
+ public function test_on_comment_write_drops_shared_garbage(): void {
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_wxr_import_user', 'a', false ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_2meet_demo_music', '1', false ) );
+ }
+
+ public function test_on_comment_write_drops_orphan_post_meta(): void {
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_hp_price', '99', false ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_thumbnail_id', '50', false ) );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_edit_lock', '111:1', false ) );
+ }
+
+ public function test_on_comment_write_passes_through_legitimate_keys(): void {
+ $this->assertNull( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, 'hp_rating', '5', false ) );
+ $this->assertNull( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, 'note_group', 'foo', false ) );
+ }
+
+ // ── 24h drop counter ────────────────────────────────────────────────────
+
+ public function test_drop_counter_starts_at_zero(): void {
+ $this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
+ }
+
+ public function test_drop_counter_increments_on_each_drop(): void {
+ WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'a', false );
+ WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 2, '_2meet_demo_music', '1', false );
+ WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 3, '_hp_price', '99', false );
+
+ $this->assertSame( 3, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
+ }
+
+ public function test_drop_counter_does_not_increment_on_legitimate_writes(): void {
+ WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, 'hp_sort_order', '5', false );
+ WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 2, 'hp_rating', '5', false );
+
+ $this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
+ }
+
+ public function test_drop_counter_resets_after_24h(): void {
+ // Simulate counter from 25h ago
+ update_option( WPDO_Term_Comment_Garbage_Filter::OPT_DROPPED_COUNT, 100 );
+ update_option( WPDO_Term_Comment_Garbage_Filter::OPT_DROPPED_RESET_AT, time() - 25 * HOUR_IN_SECONDS );
+
+ // get_drop_count_24h returns 0 (auto-reset semantics)
+ $this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
+
+ // First new drop after expiry resets counter to 1
+ WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'a', false );
+ $this->assertSame( 1, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
+ }
+
+ // ── is_enabled toggle ───────────────────────────────────────────────────
+
+ public function test_is_enabled_defaults_true(): void {
+ delete_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED );
+ $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_enabled() );
+ }
+
+ public function test_is_enabled_respects_zero_value(): void {
+ update_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED, '0' );
+ $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_enabled() );
+ }
+}
diff --git a/tests/integration/TermCommentMiscBucketTest.php b/tests/integration/TermCommentMiscBucketTest.php
new file mode 100644
index 0000000..2bca15c
--- /dev/null
+++ b/tests/integration/TermCommentMiscBucketTest.php
@@ -0,0 +1,211 @@
+prefix's resolution by creating tables under the
+ // itest prefix and shadowing term_table()/comment_table() via $wpdb->prefix.
+ // $wpdb->prefix is 'wp_itest_' in tests so wpdo_term_misc resolves to wp_itest_wpdo_term_misc.
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_MISC . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TERM_MISC . '` (
+ term_id bigint(20) unsigned NOT NULL,
+ meta_key varchar(191) NOT NULL,
+ meta_value longtext,
+ updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (term_id, meta_key),
+ KEY meta_key (meta_key)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_MISC . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::COMMENT_MISC . '` (
+ comment_id bigint(20) unsigned NOT NULL,
+ meta_key varchar(191) NOT NULL,
+ meta_value longtext,
+ updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (comment_id, meta_key),
+ KEY meta_key (meta_key)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_MISC . '`' );
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_MISC . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TERM_MISC . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENT_MISC . '`' );
+ }
+
+ // ── $pre preservation contract (priority chain integrity) ────────────────
+
+ public function test_on_term_read_preserves_non_null_pre(): void {
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_read( array( 'managed_value' ), 1, 'any_key', true );
+ $this->assertSame( array( 'managed_value' ), $result );
+ }
+
+ public function test_on_term_add_preserves_non_null_check(): void {
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_add( true, 1, 'any_key', 'value', false );
+ $this->assertTrue( $result, 'Must preserve $check=true (someone else handled write)' );
+ }
+
+ public function test_on_term_update_preserves_non_null_check(): void {
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_update( true, 1, 'any_key', 'value', '' );
+ $this->assertTrue( $result );
+ }
+
+ public function test_on_term_delete_preserves_non_null_check(): void {
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_delete( true, 1, 'any_key', '', false );
+ $this->assertTrue( $result );
+ }
+
+ public function test_comment_callbacks_preserve_non_null_check(): void {
+ $this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_add( true, 1, 'any', 'v', false ) );
+ $this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_update( true, 1, 'any', 'v', '' ) );
+ $this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_delete( true, 1, 'any', '', false ) );
+ $this->assertSame( array( 'v' ), WPDO_Term_Comment_Misc_Bucket::on_comment_read( array( 'v' ), 1, 'any', true ) );
+ }
+
+ // ── Catch-all behavior when $check === null ─────────────────────────────
+
+ public function test_on_term_add_writes_to_misc_table_when_unhandled(): void {
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_add( null, 5, 'note_group', 'foo', false );
+ $this->assertTrue( $result, 'Must short-circuit (return true) after writing' );
+
+ global $wpdb;
+ $value = $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT meta_value FROM `' . self::TERM_MISC . '` WHERE term_id = %d AND meta_key = %s',
+ 5,
+ 'note_group'
+ )
+ );
+ $this->assertSame( 'foo', $value );
+ }
+
+ public function test_on_term_read_returns_value_from_misc_table_when_unhandled(): void {
+ WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 7, 'unknown_key', 'bar', '' );
+
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 7, 'unknown_key', true );
+ $this->assertSame( array( 'bar' ), $result );
+ }
+
+ public function test_on_term_read_returns_pre_on_cache_miss(): void {
+ // No prior write — read should fall through (return $pre = null, letting WP query DB).
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 999, 'never_written', true );
+ $this->assertNull( $result );
+ }
+
+ public function test_upsert_replaces_existing_value(): void {
+ WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'first', '' );
+ WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'second', '' );
+
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true );
+ $this->assertSame( array( 'second' ), $result );
+
+ // Ensure exactly one row (composite PK enforces this).
+ global $wpdb;
+ $count = (int) $wpdb->get_var(
+ 'SELECT COUNT(*) FROM `' . self::TERM_MISC . "` WHERE term_id = 5 AND meta_key = 'k'"
+ );
+ $this->assertSame( 1, $count );
+ }
+
+ public function test_on_term_delete_removes_row(): void {
+ WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'v', '' );
+ $this->assertSame( array( 'v' ), WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true ) );
+
+ WPDO_Term_Comment_Misc_Bucket::on_term_delete( null, 5, 'k', '', false );
+
+ $this->assertNull( WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true ) );
+ }
+
+ // ── Comment side ─────────────────────────────────────────────────────────
+
+ public function test_on_comment_add_writes_to_misc_table(): void {
+ $result = WPDO_Term_Comment_Misc_Bucket::on_comment_add( null, 8, 'note_group', 'baz', false );
+ $this->assertTrue( $result );
+
+ global $wpdb;
+ $value = $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT meta_value FROM `' . self::COMMENT_MISC . '` WHERE comment_id = %d AND meta_key = %s',
+ 8,
+ 'note_group'
+ )
+ );
+ $this->assertSame( 'baz', $value );
+ }
+
+ public function test_term_writes_do_not_pollute_comment_table(): void {
+ WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'term_val', '' );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENT_MISC . '`' );
+ $this->assertSame( 0, $count, 'Term writes must not appear in comment misc table' );
+ }
+
+ // ── Empty meta_key guard ────────────────────────────────────────────────
+
+ public function test_empty_meta_key_returns_check_unchanged(): void {
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_add( null, 5, '', 'value', false );
+ $this->assertNull( $result, 'Empty meta_key must not be written' );
+ }
+
+ public function test_non_string_meta_key_returns_check_unchanged(): void {
+ $result = WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 123, 'value', '' );
+ $this->assertNull( $result );
+ }
+
+ // ── is_enabled toggle ───────────────────────────────────────────────────
+
+ public function test_is_enabled_defaults_true(): void {
+ delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
+ $this->assertTrue( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
+ }
+
+ public function test_is_enabled_respects_zero_value(): void {
+ update_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '0' );
+ $this->assertFalse( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
+ delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
+ }
+
+ public function test_count_rows_returns_actual_count(): void {
+ WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 1, 'a', 'x', '' );
+ WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 2, 'b', 'y', '' );
+ WPDO_Term_Comment_Misc_Bucket::on_comment_update( null, 5, 'c', 'z', '' );
+
+ $this->assertSame( 2, WPDO_Term_Comment_Misc_Bucket::count_rows( 'term' ) );
+ $this->assertSame( 1, WPDO_Term_Comment_Misc_Bucket::count_rows( 'comment' ) );
+ }
+}
diff --git a/tests/integration/TermCommentShadowVerifierTest.php b/tests/integration/TermCommentShadowVerifierTest.php
new file mode 100644
index 0000000..8428e71
--- /dev/null
+++ b/tests/integration/TermCommentShadowVerifierTest.php
@@ -0,0 +1,292 @@
+terms = self::TERMS;
+ $wpdb->termmeta = self::TERMMETA;
+ $wpdb->comments = self::COMMENTS;
+ $wpdb->commentmeta = self::COMMENTMETA;
+
+ // Source tables (terms / comments) need term_id / comment_ID columns.
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TERMS . '` (
+ term_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ name varchar(200) NOT NULL DEFAULT "",
+ PRIMARY KEY (term_id)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::COMMENTS . '` (
+ comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ comment_post_ID bigint(20) unsigned NOT NULL DEFAULT 0,
+ PRIMARY KEY (comment_ID)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TERMMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ term_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY term_id (term_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4' );
+
+ $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::COMMENTMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ comment_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY comment_id (comment_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4' );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_FLAT . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TERM_FLAT . '` (
+ term_id bigint(20) unsigned NOT NULL,
+ hp_sort_order int(11) DEFAULT NULL,
+ hp_default tinyint(1) DEFAULT NULL,
+ hp_icon varchar(64) DEFAULT NULL,
+ PRIMARY KEY (term_id)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_FLAT . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::COMMENT_FLAT . '` (
+ comment_id bigint(20) unsigned NOT NULL,
+ hp_rating tinyint(1) DEFAULT NULL,
+ PRIMARY KEY (comment_id)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ foreach ( array( self::TERMS, self::COMMENTS, self::TERM_FLAT, self::COMMENT_FLAT ) as $tbl ) {
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
+ }
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TERMS . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTS . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TERM_FLAT . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENT_FLAT . '`' );
+ }
+
+ // ── sample_compare contract ─────────────────────────────────────────────
+
+ public function test_sample_compare_invalid_entity_type_throws(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'bogus',
+ 'hp_taxonomy',
+ self::TERM_FLAT,
+ array( 'hp_sort_order' )
+ );
+ }
+
+ public function test_sample_compare_zero_sample_size_throws(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'term',
+ 'hp_taxonomy',
+ self::TERM_FLAT,
+ array( 'hp_sort_order' ),
+ 0
+ );
+ }
+
+ public function test_sample_compare_empty_keys_throws(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'term',
+ 'hp_taxonomy',
+ self::TERM_FLAT,
+ array()
+ );
+ }
+
+ public function test_sample_compare_returns_zeros_for_empty_db(): void {
+ $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'term',
+ 'hp_taxonomy',
+ self::TERM_FLAT,
+ array( 'hp_sort_order' )
+ );
+ $this->assertSame( 0, $result['sampled'] );
+ $this->assertSame( 'term', $result['entity_type'] );
+ $this->assertSame( 'hp_taxonomy', $result['group'] );
+ }
+
+ public function test_sample_compare_counts_matches_when_in_sync(): void {
+ global $wpdb;
+
+ // 3 terms, all in sync between wp_termmeta and flat table.
+ for ( $i = 1; $i <= 3; $i++ ) {
+ $wpdb->insert( self::TERMS, array( 'term_id' => $i, 'name' => 'term_' . $i ) );
+ $wpdb->insert( self::TERMMETA, array( 'term_id' => $i, 'meta_key' => 'hp_sort_order', 'meta_value' => $i * 10 ) );
+ $wpdb->insert( self::TERM_FLAT, array( 'term_id' => $i, 'hp_sort_order' => $i * 10 ) );
+ }
+
+ $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'term',
+ 'hp_taxonomy',
+ self::TERM_FLAT,
+ array( 'hp_sort_order' ),
+ 10
+ );
+ $this->assertSame( 3, $result['sampled'] );
+ $this->assertSame( 3, $result['matched'] );
+ $this->assertSame( 0, $result['diffs'] );
+ $this->assertSame( 0, $result['missing_flat'] );
+ $this->assertSame( 0, $result['missing_meta'] );
+ }
+
+ public function test_sample_compare_detects_missing_flat(): void {
+ global $wpdb;
+
+ // 2 terms with wp_termmeta but no flat row.
+ $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
+ $wpdb->insert( self::TERMS, array( 'term_id' => 2, 'name' => 'b' ) );
+ $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => 5 ) );
+ $wpdb->insert( self::TERMMETA, array( 'term_id' => 2, 'meta_key' => 'hp_sort_order', 'meta_value' => 10 ) );
+
+ $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'term',
+ 'hp_taxonomy',
+ self::TERM_FLAT,
+ array( 'hp_sort_order' ),
+ 10
+ );
+ $this->assertSame( 2, $result['sampled'] );
+ $this->assertSame( 0, $result['matched'] );
+ $this->assertSame( 2, $result['missing_flat'] );
+ }
+
+ public function test_sample_compare_detects_missing_meta(): void {
+ global $wpdb;
+
+ // 2 terms with flat rows but no wp_termmeta.
+ $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
+ $wpdb->insert( self::TERMS, array( 'term_id' => 2, 'name' => 'b' ) );
+ $wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 5 ) );
+ $wpdb->insert( self::TERM_FLAT, array( 'term_id' => 2, 'hp_sort_order' => 10 ) );
+
+ $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'term',
+ 'hp_taxonomy',
+ self::TERM_FLAT,
+ array( 'hp_sort_order' ),
+ 10
+ );
+ $this->assertSame( 2, $result['sampled'] );
+ $this->assertSame( 2, $result['missing_meta'] );
+ }
+
+ public function test_sample_compare_detects_value_diff(): void {
+ global $wpdb;
+
+ $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
+ $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) );
+ $wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 99 ) );
+
+ $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'term',
+ 'hp_taxonomy',
+ self::TERM_FLAT,
+ array( 'hp_sort_order' ),
+ 10
+ );
+ $this->assertSame( 1, $result['diffs'] );
+ $this->assertSame( 0, $result['matched'] );
+ }
+
+ public function test_sample_compare_loose_equal_matches_numeric(): void {
+ global $wpdb;
+
+ $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
+ // wp_termmeta stores '5' as string, flat stores 5 as int — should match
+ $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) );
+ $wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 5 ) );
+
+ $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'term',
+ 'hp_taxonomy',
+ self::TERM_FLAT,
+ array( 'hp_sort_order' ),
+ 10
+ );
+ $this->assertSame( 1, $result['matched'], 'String "5" must loose-equal int 5' );
+ $this->assertSame( 0, $result['diffs'] );
+ }
+
+ public function test_sample_compare_handles_comment_entity(): void {
+ global $wpdb;
+
+ $wpdb->insert( self::COMMENTS, array( 'comment_ID' => 1, 'comment_post_ID' => 100 ) );
+ $wpdb->insert( self::COMMENTMETA, array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => 5 ) );
+ $wpdb->insert( self::COMMENT_FLAT, array( 'comment_id' => 1, 'hp_rating' => 5 ) );
+
+ $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
+ 'comment',
+ 'hp_review',
+ self::COMMENT_FLAT,
+ array( 'hp_rating' ),
+ 10
+ );
+ $this->assertSame( 1, $result['sampled'] );
+ $this->assertSame( 1, $result['matched'] );
+ $this->assertSame( 'comment', $result['entity_type'] );
+ }
+
+ // ── cron_tick mode-gating ───────────────────────────────────────────────
+
+ public function test_cron_tick_no_op_when_neither_in_shadow_read(): void {
+ // Set both modes to dual_write so cron should no-op.
+ if ( class_exists( 'WPDO_Mode_Manager' ) ) {
+ WPDO_Mode_Manager::set( 'term', 'dual_write' );
+ WPDO_Mode_Manager::set( 'comment', 'dual_write' );
+ }
+
+ // cron_tick should return without error and not touch the tables.
+ WPDO_Term_Comment_Shadow_Verifier::cron_tick();
+ $this->assertTrue( true ); // No exception = no-op succeeded
+ }
+}
diff --git a/tests/integration/TermStressTesterTest.php b/tests/integration/TermStressTesterTest.php
new file mode 100644
index 0000000..452d583
--- /dev/null
+++ b/tests/integration/TermStressTesterTest.php
@@ -0,0 +1,315 @@
+terms = self::TERMS;
+ $wpdb->termmeta = self::TERMMETA;
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMS . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TERMS . '` (
+ term_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ name varchar(200) NOT NULL DEFAULT "",
+ slug varchar(200) NOT NULL DEFAULT "",
+ term_group bigint(10) NOT NULL DEFAULT 0,
+ PRIMARY KEY (term_id),
+ KEY slug (slug(191))
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_TAXONOMY . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TERM_TAXONOMY . '` (
+ term_taxonomy_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ term_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ taxonomy varchar(32) NOT NULL DEFAULT "",
+ description longtext,
+ parent bigint(20) unsigned NOT NULL DEFAULT 0,
+ count bigint(20) NOT NULL DEFAULT 0,
+ PRIMARY KEY (term_taxonomy_id),
+ KEY taxonomy (taxonomy)
+ ) DEFAULT CHARACTER SET utf8mb4'
+ );
+
+ $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TERMMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ term_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY term_id (term_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4' );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ foreach ( array( self::TERMS, self::TERM_TAXONOMY, self::TERMMETA ) as $tbl ) {
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
+ }
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TERMS . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TERM_TAXONOMY . '`' );
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' );
+ // Reset state per test so each starts idle.
+ unset( $GLOBALS['_wp_options'][ WPDO_Term_Stress_Tester::OPT_STATE ] );
+ unset( $GLOBALS['_wp_transients'][ WPDO_Term_Stress_Tester::CANCEL_FLAG ] );
+ unset( $GLOBALS['_wp_transients']['wpdo_term_stress_pump_lock'] );
+ }
+
+ // ── create() (fast-path direct SQL) ──────────────────────────────────────
+
+ public function test_create_inserts_terms_into_taxonomy(): void {
+ $result = WPDO_Term_Stress_Tester::create( 'category', 5 );
+
+ $this->assertSame( 5, $result['created'] );
+ $this->assertSame( 'category', $result['taxonomy'] );
+ $this->assertNotNull( $result['first_id'] );
+ $this->assertNotNull( $result['last_id'] );
+
+ global $wpdb;
+ $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' );
+ $this->assertSame( 5, $count );
+ $tax_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::TERM_TAXONOMY . "` WHERE taxonomy = 'category'" );
+ $this->assertSame( 5, $tax_count );
+ }
+
+ public function test_create_uses_stress_slug_prefix(): void {
+ WPDO_Term_Stress_Tester::create( 'category', 3 );
+
+ global $wpdb;
+ $prefix_count = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM `" . self::TERMS . "` WHERE slug LIKE %s",
+ WPDO_Term_Stress_Tester::TEST_TERM_PREFIX . '%'
+ )
+ );
+ $this->assertSame( 3, $prefix_count );
+ }
+
+ public function test_create_seeds_termmeta_keys(): void {
+ WPDO_Term_Stress_Tester::create( 'category', 3 );
+
+ global $wpdb;
+ $total_meta = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
+ // 3 terms × 3 keys (hp_sort_order/hp_default/hp_icon) = 9
+ $this->assertSame( 9, $total_meta );
+ }
+
+ public function test_create_rejects_empty_taxonomy(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Term_Stress_Tester::create( '', 3 );
+ }
+
+ public function test_create_rejects_zero_count(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Term_Stress_Tester::create( 'category', 0 );
+ }
+
+ public function test_create_rejects_excessive_count(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Term_Stress_Tester::create( 'category', 100001 );
+ }
+
+ // ── count_test_terms() ────────────────────────────────────────────────────
+
+ public function test_count_test_terms_returns_zero_for_empty(): void {
+ $this->assertSame( 0, WPDO_Term_Stress_Tester::count_test_terms() );
+ }
+
+ public function test_count_test_terms_counts_only_stress_prefix(): void {
+ WPDO_Term_Stress_Tester::create( 'category', 4 );
+
+ global $wpdb;
+ $wpdb->insert( self::TERMS, array( 'name' => 'Real', 'slug' => 'real-term', 'term_group' => 0 ) );
+
+ $this->assertSame( 4, WPDO_Term_Stress_Tester::count_test_terms() );
+ }
+
+ // ── cleanup() ─────────────────────────────────────────────────────────────
+
+ public function test_cleanup_removes_test_terms_and_cascade(): void {
+ WPDO_Term_Stress_Tester::create( 'category', 5 );
+ $this->assertSame( 5, WPDO_Term_Stress_Tester::count_test_terms() );
+
+ $result = WPDO_Term_Stress_Tester::cleanup();
+ $this->assertSame( 5, $result['deleted_terms'] );
+
+ global $wpdb;
+ $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' ) );
+ $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' ) );
+ $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERM_TAXONOMY . '`' ) );
+ }
+
+ public function test_cleanup_preserves_non_stress_terms(): void {
+ global $wpdb;
+ $wpdb->insert( self::TERMS, array( 'name' => 'Real', 'slug' => 'real-term', 'term_group' => 0 ) );
+ WPDO_Term_Stress_Tester::create( 'category', 3 );
+
+ $result = WPDO_Term_Stress_Tester::cleanup();
+ $this->assertSame( 3, $result['deleted_terms'] );
+
+ $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' );
+ $this->assertSame( 1, $remaining );
+ }
+
+ public function test_cleanup_idempotent_on_empty(): void {
+ $first = WPDO_Term_Stress_Tester::cleanup();
+ $second = WPDO_Term_Stress_Tester::cleanup();
+ $this->assertSame( 0, $first['deleted_terms'] );
+ $this->assertSame( 0, $second['deleted_terms'] );
+ }
+
+ // ── State machine ────────────────────────────────────────────────────────
+
+ public function test_get_state_returns_empty_when_idle(): void {
+ $this->assertSame( array(), WPDO_Term_Stress_Tester::get_state() );
+ }
+
+ public function test_get_progress_returns_idle_when_no_state(): void {
+ $progress = WPDO_Term_Stress_Tester::get_progress( false );
+ $this->assertSame( 'idle', $progress['status'] );
+ }
+
+ public function test_start_persists_state_with_running_status(): void {
+ // Stub taxonomy_exists() — fall back to true via global flag.
+ $GLOBALS['_taxonomy_exists_override'] = true;
+
+ $result = WPDO_Term_Stress_Tester::start( 'category', 10, 'fast', 5 );
+
+ $this->assertTrue( $result['ok'], 'start should succeed' );
+ $state = $result['state'];
+ $this->assertSame( 'running', $state['status'] );
+ $this->assertSame( 'category', $state['taxonomy'] );
+ $this->assertSame( 'fast', $state['mode'] );
+ $this->assertSame( 10, $state['target'] );
+ $this->assertSame( 5, $state['batch_size'] );
+
+ unset( $GLOBALS['_taxonomy_exists_override'] );
+ }
+
+ public function test_start_rejects_unknown_taxonomy(): void {
+ $GLOBALS['_taxonomy_exists_override'] = false;
+
+ $result = WPDO_Term_Stress_Tester::start( 'never_exists', 10 );
+ $this->assertFalse( $result['ok'] );
+ $this->assertStringContainsString( 'unknown_taxonomy', $result['error'] );
+
+ unset( $GLOBALS['_taxonomy_exists_override'] );
+ }
+
+ public function test_start_rejects_invalid_mode(): void {
+ $GLOBALS['_taxonomy_exists_override'] = true;
+ $result = WPDO_Term_Stress_Tester::start( 'category', 10, 'turbo' );
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'invalid mode', $result['error'] );
+ unset( $GLOBALS['_taxonomy_exists_override'] );
+ }
+
+ public function test_start_rejects_concurrent_run(): void {
+ $GLOBALS['_taxonomy_exists_override'] = true;
+ WPDO_Term_Stress_Tester::start( 'category', 10 );
+ $result = WPDO_Term_Stress_Tester::start( 'category', 5 );
+
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'already_running', $result['error'] );
+ unset( $GLOBALS['_taxonomy_exists_override'] );
+ }
+
+ public function test_run_batch_advances_processed_count(): void {
+ $GLOBALS['_taxonomy_exists_override'] = true;
+ WPDO_Term_Stress_Tester::start( 'category', 6, 'fast', 3 );
+
+ WPDO_Term_Stress_Tester::run_batch();
+ $progress = WPDO_Term_Stress_Tester::get_progress( false );
+ $this->assertSame( 3, $progress['processed'] );
+ $this->assertSame( 1, $progress['batches_done'] );
+ $this->assertSame( 'running', $progress['status'] );
+
+ WPDO_Term_Stress_Tester::run_batch();
+ $progress = WPDO_Term_Stress_Tester::get_progress( false );
+ $this->assertSame( 6, $progress['processed'] );
+ $this->assertSame( 'completed', $progress['status'] );
+ unset( $GLOBALS['_taxonomy_exists_override'] );
+ }
+
+ public function test_cancel_marks_state_as_cancelled(): void {
+ $GLOBALS['_taxonomy_exists_override'] = true;
+ WPDO_Term_Stress_Tester::start( 'category', 100, 'fast', 50 );
+
+ $result = WPDO_Term_Stress_Tester::cancel();
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 'cancelled', $result['state']['status'] );
+
+ // In-flight batch run after cancel must NOT bump status back to running.
+ WPDO_Term_Stress_Tester::run_batch();
+ $state = WPDO_Term_Stress_Tester::get_state();
+ $this->assertSame( 'cancelled', $state['status'] );
+ unset( $GLOBALS['_taxonomy_exists_override'] );
+ }
+
+ public function test_cancel_returns_no_active_job_when_idle(): void {
+ $result = WPDO_Term_Stress_Tester::cancel();
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 'no_active_job', $result['message'] ?? '' );
+ }
+
+ public function test_get_progress_includes_pct_and_eta_keys(): void {
+ $GLOBALS['_taxonomy_exists_override'] = true;
+ WPDO_Term_Stress_Tester::start( 'category', 10, 'fast', 5 );
+ WPDO_Term_Stress_Tester::run_batch();
+
+ $progress = WPDO_Term_Stress_Tester::get_progress( false );
+ $this->assertArrayHasKey( 'pct', $progress );
+ $this->assertArrayHasKey( 'rate_per_sec', $progress );
+ $this->assertArrayHasKey( 'elapsed_sec', $progress );
+ $this->assertArrayHasKey( 'eta_sec', $progress );
+ $this->assertArrayHasKey( 'test_term_count', $progress );
+ $this->assertSame( 50.0, $progress['pct'] );
+ unset( $GLOBALS['_taxonomy_exists_override'] );
+ }
+
+ public function test_run_benchmark_returns_structured_payload(): void {
+ $GLOBALS['_taxonomy_exists_override'] = true;
+ WPDO_Term_Stress_Tester::start( 'category', 4, 'fast', 4 );
+ WPDO_Term_Stress_Tester::run_batch();
+
+ $state = WPDO_Term_Stress_Tester::get_state();
+ $this->assertSame( 'completed', $state['status'] );
+ $this->assertIsArray( $state['benchmark'] );
+ $this->assertArrayHasKey( 'write', $state['benchmark'] );
+ $this->assertArrayHasKey( 'db_sizes', $state['benchmark'] );
+ $this->assertSame( 'category', $state['benchmark']['taxonomy'] );
+ unset( $GLOBALS['_taxonomy_exists_override'] );
+ }
+}
diff --git a/tests/integration/TermmetaCleanerIntegrationTest.php b/tests/integration/TermmetaCleanerIntegrationTest.php
new file mode 100644
index 0000000..4233e37
--- /dev/null
+++ b/tests/integration/TermmetaCleanerIntegrationTest.php
@@ -0,0 +1,182 @@
+termmeta to point at our test table.
+ $wpdb->termmeta = self::TERMMETA;
+
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMMETA . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TERMMETA . '` (
+ meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ term_id bigint(20) unsigned NOT NULL DEFAULT 0,
+ meta_key varchar(255) DEFAULT NULL,
+ meta_value longtext,
+ PRIMARY KEY (meta_id),
+ KEY term_id (term_id),
+ KEY meta_key (meta_key(191))
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMMETA . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' );
+ }
+
+ private function seed( array $rows ): void {
+ global $wpdb;
+ foreach ( $rows as $row ) {
+ $wpdb->insert( self::TERMMETA, $row );
+ }
+ }
+
+ // ── count_garbage ────────────────────────────────────────────────────────
+
+ public function test_count_garbage_returns_zero_for_empty_table(): void {
+ $counts = WPDO_Termmeta_Cleaner::count_garbage( 'all' );
+ $this->assertSame( 0, $counts['wxr_import'] );
+ $this->assertSame( 0, $counts['demo_data'] );
+ $this->assertSame( 0, $counts['transients'] );
+ $this->assertSame( 0, $counts['total'] );
+ }
+
+ public function test_count_garbage_counts_wxr_import(): void {
+ $this->seed( array(
+ array( 'term_id' => 1, 'meta_key' => '_wxr_import_user_xyz', 'meta_value' => 'a' ),
+ array( 'term_id' => 2, 'meta_key' => '_wxr_import_post', 'meta_value' => 'b' ),
+ array( 'term_id' => 3, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
+ ) );
+
+ $counts = WPDO_Termmeta_Cleaner::count_garbage( 'wxr_import' );
+ $this->assertSame( 2, $counts['wxr_import'] );
+ $this->assertSame( 0, $counts['demo_data'] );
+ $this->assertSame( 0, $counts['transients'] );
+ $this->assertSame( 2, $counts['total'] );
+ }
+
+ public function test_count_garbage_counts_demo_data(): void {
+ $this->seed( array(
+ array( 'term_id' => 1, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
+ array( 'term_id' => 2, 'meta_key' => '_2meet_demo_adv', 'meta_value' => '1' ),
+ array( 'term_id' => 3, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
+ ) );
+
+ $counts = WPDO_Termmeta_Cleaner::count_garbage( 'demo_data' );
+ $this->assertSame( 0, $counts['wxr_import'] );
+ $this->assertSame( 2, $counts['demo_data'] );
+ $this->assertSame( 0, $counts['transients'] );
+ $this->assertSame( 2, $counts['total'] );
+ }
+
+ public function test_count_garbage_counts_transients(): void {
+ $this->seed( array(
+ array( 'term_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
+ array( 'term_id' => 1, 'meta_key' => '_transient_timeout_foo', 'meta_value' => '9999' ),
+ array( 'term_id' => 2, 'meta_key' => 'hp_default', 'meta_value' => '1' ),
+ ) );
+
+ $counts = WPDO_Termmeta_Cleaner::count_garbage( 'transients' );
+ $this->assertSame( 2, $counts['transients'] );
+ $this->assertSame( 2, $counts['total'] );
+ }
+
+ public function test_count_garbage_all_unions_three_buckets(): void {
+ $this->seed( array(
+ array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
+ array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
+ array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
+ array( 'term_id' => 4, 'meta_key' => 'hp_icon', 'meta_value' => 'star' ),
+ array( 'term_id' => 5, 'meta_key' => 'hp_default', 'meta_value' => '1' ),
+ ) );
+
+ $counts = WPDO_Termmeta_Cleaner::count_garbage( 'all' );
+ $this->assertSame( 1, $counts['wxr_import'] );
+ $this->assertSame( 1, $counts['demo_data'] );
+ $this->assertSame( 1, $counts['transients'] );
+ $this->assertSame( 3, $counts['total'] );
+ }
+
+ // ── delete_garbage ────────────────────────────────────────────────────────
+
+ public function test_delete_garbage_removes_targeted_rows_only(): void {
+ $this->seed( array(
+ array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
+ array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
+ array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
+ array( 'term_id' => 4, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
+ ) );
+
+ $deleted = WPDO_Termmeta_Cleaner::delete_garbage( 'all' );
+ $this->assertSame( 1, $deleted['wxr_import'] );
+ $this->assertSame( 1, $deleted['demo_data'] );
+ $this->assertSame( 1, $deleted['transients'] );
+ $this->assertSame( 3, $deleted['total'] );
+
+ global $wpdb;
+ $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
+ $this->assertSame( 1, $remaining, 'hp_sort_order must survive' );
+ }
+
+ public function test_delete_garbage_target_specific_only_removes_one_bucket(): void {
+ $this->seed( array(
+ array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
+ array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
+ array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
+ ) );
+
+ $deleted = WPDO_Termmeta_Cleaner::delete_garbage( 'wxr_import' );
+ $this->assertSame( 1, $deleted['wxr_import'] );
+ $this->assertSame( 0, $deleted['demo_data'] );
+ $this->assertSame( 0, $deleted['transients'] );
+ $this->assertSame( 1, $deleted['total'] );
+
+ global $wpdb;
+ $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
+ $this->assertSame( 2, $remaining, '_2meet_demo + _transient must survive when target=wxr_import' );
+ }
+
+ public function test_delete_garbage_idempotent_on_clean_table(): void {
+ $this->seed( array(
+ array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
+ ) );
+
+ $first = WPDO_Termmeta_Cleaner::delete_garbage( 'all' );
+ $second = WPDO_Termmeta_Cleaner::delete_garbage( 'all' );
+ $this->assertSame( 0, $first['total'] );
+ $this->assertSame( 0, $second['total'] );
+
+ global $wpdb;
+ $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
+ $this->assertSame( 1, $remaining );
+ }
+
+ public function test_invalid_target_throws(): void {
+ $this->expectException( InvalidArgumentException::class );
+ WPDO_Termmeta_Cleaner::count_garbage( 'bogus' );
+ }
+}
diff --git a/tests/integration/UpgradeV2Test.php b/tests/integration/UpgradeV2Test.php
new file mode 100644
index 0000000..2a73ed1
--- /dev/null
+++ b/tests/integration/UpgradeV2Test.php
@@ -0,0 +1,134 @@
+query( "DROP TABLE IF EXISTS `{$wpdb->prefix}wpdo_{$t}`" );
+ }
+ // Drop any leftover wp_uae_* probe tables from prior test runs.
+ $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}uae_probe`" );
+ }
+
+ protected function setUp(): void {
+ // Clean options between tests for clean preconditions.
+ $GLOBALS['_wp_options'] = array();
+ }
+
+ // ── Pre-flight ──────────────────────────────────────────────────────────
+
+ public function test_pre_flight_passes_in_clean_environment(): void {
+ $checks = WPDO_V2_Upgrader::pre_flight_check();
+ $this->assertIsArray( $checks );
+ // PHP / WP / MySQL versions in this CI/dev environment must satisfy minimums.
+ $this->assertTrue( $checks['php_version'] );
+ $this->assertTrue( $checks['mysql_version'] );
+ }
+
+ public function test_pre_flight_detects_old_php(): void {
+ // Simulating an old PHP is impossible from PHP itself, so we only
+ // verify the keys are present + booleans.
+ $checks = WPDO_V2_Upgrader::pre_flight_check();
+ foreach ( array( 'php_version', 'wp_version', 'mysql_version', 'free_disk_mb', 'features_writable', 'no_active_migration' ) as $key ) {
+ $this->assertArrayHasKey( $key, $checks );
+ $this->assertIsBool( $checks[ $key ] );
+ }
+ }
+
+ // ── S1: clean install ─────────────────────────────────────────────────
+
+ public function test_s1_clean_install_creates_v2_schema(): void {
+ WPDO_V2_Upgrader::upgrade_to_v2();
+ $status = WPDO_Installer::v2_tables_status();
+ foreach ( $status as $exists ) {
+ $this->assertTrue( $exists );
+ }
+ }
+
+ // ── S2: v1.3.x → v2.0.0 (no UAE) ───────────────────────────────────────
+
+ public function test_s2_upgrade_marks_db_version(): void {
+ // Simulate v1.3.x state.
+ update_option( 'wpdo_db_version', '1.0.0' );
+
+ $ok = WPDO_V2_Upgrader::upgrade_to_v2();
+ $this->assertTrue( $ok );
+ $this->assertSame( '2.0.0', get_option( 'wpdo_db_version' ) );
+ $this->assertSame( 'complete', get_option( 'wpdo_v2_upgrade_status' ) );
+ }
+
+ public function test_s2_upgrade_seeds_entity_modules_in_features(): void {
+ update_option( 'wpdo_features', array( 'hot_hp_listing' => 'cutover' ) );
+ WPDO_V2_Upgrader::upgrade_to_v2();
+
+ $flags = get_option( 'wpdo_features' );
+ $this->assertSame( 'cutover', $flags['hot_hp_listing'], 'Pre-existing module state must be preserved' );
+
+ foreach ( array( 'entity_user', 'entity_term', 'entity_comment', 'entity_options' ) as $module ) {
+ $this->assertArrayHasKey( $module, $flags );
+ $this->assertSame( 'idle', $flags[ $module ] );
+ }
+ }
+
+ public function test_s2_upgrade_idempotent(): void {
+ WPDO_V2_Upgrader::upgrade_to_v2();
+ $ok = WPDO_V2_Upgrader::upgrade_to_v2(); // Second run must not fail.
+ $this->assertTrue( $ok );
+ }
+
+ // ── S3: UAE coexistence ───────────────────────────────────────────────
+
+ public function test_s3_detects_uae_data_when_table_present(): void {
+ global $wpdb;
+ $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}uae_probe` ( id BIGINT PRIMARY KEY ) ENGINE=InnoDB" );
+ $this->assertTrue( WPDO_V2_Upgrader::detect_uae_data() );
+
+ $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}uae_probe`" );
+ }
+
+ public function test_s3_no_uae_data_in_clean_environment(): void {
+ // dev10 case: no wp_uae_* tables.
+ $this->assertFalse( WPDO_V2_Upgrader::detect_uae_data() );
+ }
+
+ // ── Rollback ───────────────────────────────────────────────────────────
+
+ public function test_rollback_restores_features_backup(): void {
+ $original = array( 'hot_hp_listing' => 'cutover' );
+ update_option( 'wpdo_features', $original );
+
+ WPDO_V2_Upgrader::upgrade_to_v2();
+ // Simulate user wants to roll back.
+ WPDO_V2_Upgrader::rollback_v2( true );
+
+ $this->assertSame( $original, get_option( 'wpdo_features' ) );
+ $this->assertSame( '1.0.0', get_option( 'wpdo_db_version' ) );
+ $this->assertSame( 'rolled_back', get_option( 'wpdo_v2_upgrade_status' ) );
+ }
+
+ public function test_rollback_with_keep_data_preserves_v2_tables(): void {
+ WPDO_V2_Upgrader::upgrade_to_v2();
+ WPDO_V2_Upgrader::rollback_v2( true );
+
+ $status = WPDO_Installer::v2_tables_status();
+ foreach ( $status as $table => $exists ) {
+ $this->assertTrue( $exists, "{$table} must remain after rollback with --keep-data" );
+ }
+ }
+}
diff --git a/tests/integration/WarmArchiveIntegrationTest.php b/tests/integration/WarmArchiveIntegrationTest.php
new file mode 100644
index 0000000..1b1e948
--- /dev/null
+++ b/tests/integration/WarmArchiveIntegrationTest.php
@@ -0,0 +1,269 @@
+prefix . 'wpdo_warm';
+ self::$archive_table = $wpdb->prefix . 'wpdo_archive';
+ self::$errors_table = $wpdb->prefix . 'wpdo_errors';
+ self::$postmeta_table = $wpdb->postmeta;
+
+ // DROP + CREATE ensures clean schema even after interrupted prior runs.
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
+ $wpdb->query(
+ "CREATE TABLE `" . self::$warm_table . "` (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ post_id BIGINT UNSIGNED NOT NULL,
+ meta_key VARCHAR(255) NOT NULL,
+ meta_value LONGTEXT,
+ expires_at DATETIME DEFAULT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY post_meta (post_id, meta_key)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+
+ // Create archive table (matches WPDO_Installer::install_system_tables DDL).
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$archive_table . "`" );
+ $wpdb->query(
+ "CREATE TABLE `" . self::$archive_table . "` (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
+ post_type VARCHAR(20) NOT NULL DEFAULT '',
+ meta_key VARCHAR(255) NOT NULL DEFAULT '',
+ meta_value LONGTEXT,
+ compressed TINYINT(1) NOT NULL DEFAULT 0,
+ archived_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
+ original_meta_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ KEY idx_post_id (post_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+
+ // Create errors table (needed by WPDO_Logger).
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$errors_table . "`" );
+ $wpdb->query(
+ "CREATE TABLE `" . self::$errors_table . "` (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ severity VARCHAR(20) NOT NULL DEFAULT 'error',
+ module VARCHAR(100) NOT NULL DEFAULT '',
+ zone VARCHAR(50) DEFAULT NULL,
+ hook VARCHAR(100) NOT NULL DEFAULT '',
+ message TEXT NOT NULL,
+ context LONGTEXT DEFAULT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+
+ // Create postmeta table (needed by flush_views_to_postmeta batch query).
+ // Only create if it does not already exist — shared with other test classes.
+ $wpdb->query(
+ "CREATE TABLE IF NOT EXISTS `" . self::$postmeta_table . "` (
+ meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ post_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
+ meta_key VARCHAR(255) DEFAULT NULL,
+ meta_value LONGTEXT,
+ PRIMARY KEY (meta_id),
+ KEY post_id (post_id),
+ KEY meta_key (meta_key(191))
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$archive_table . "`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$errors_table . "`" );
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$postmeta_table . "`" );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( "TRUNCATE TABLE `" . self::$warm_table . "`" );
+ $wpdb->query( "TRUNCATE TABLE `" . self::$archive_table . "`" );
+ $GLOBALS['_wp_cache'] = [];
+ $GLOBALS['_wp_postmeta'] = [];
+ $GLOBALS['_wp_options'] = [];
+ }
+
+ // ── Zone B (Warm) ─────────────────────────────────────────────────────────
+
+ public function test_warm_set_and_get(): void {
+ WPDO_Zone_Warm::set( 100, 'wp_key', 'hello', null );
+ $val = WPDO_Zone_Warm::get( 100, 'wp_key' );
+ $this->assertSame( 'hello', $val );
+ }
+
+ public function test_warm_expired_returns_null(): void {
+ global $wpdb;
+ // Insert already-expired entry.
+ $wpdb->query(
+ "INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, expires_at)
+ VALUES (101, 'stale_key', 'old', '2000-01-01 00:00:00')"
+ );
+
+ $val = WPDO_Zone_Warm::get( 101, 'stale_key' );
+ $this->assertNull( $val );
+ }
+
+ public function test_warm_purge_expired(): void {
+ global $wpdb;
+ $wpdb->query(
+ "INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, expires_at)
+ VALUES (102, 'k1', 'v1', '2000-01-01 00:00:00'),
+ (103, 'k2', 'v2', DATE_ADD(NOW(), INTERVAL 1 HOUR))"
+ );
+
+ $deleted = WPDO_Zone_Warm::purge_expired();
+ $this->assertGreaterThanOrEqual( 1, $deleted );
+
+ // k2 (future TTL) should still exist.
+ $this->assertNotNull( WPDO_Zone_Warm::get( 103, 'k2' ) );
+ }
+
+ public function test_warm_delete_all_for_post(): void {
+ WPDO_Zone_Warm::set( 200, 'a', 'va', null );
+ WPDO_Zone_Warm::set( 200, 'b', 'vb', null );
+ WPDO_Zone_Warm::set( 201, 'a', 'other', null );
+
+ WPDO_Zone_Warm::delete_all( 200 );
+
+ $this->assertNull( WPDO_Zone_Warm::get( 200, 'a' ) );
+ $this->assertNull( WPDO_Zone_Warm::get( 200, 'b' ) );
+ $this->assertSame( 'other', WPDO_Zone_Warm::get( 201, 'a' ) );
+ }
+
+ // ── Zone D (Archive) ──────────────────────────────────────────────────────
+
+ /** Helper: build a row for archive_batch with required post_type. */
+ private function archive_rows( int $post_id, array $metas ): array {
+ return array_map( fn( $m ) => array_merge(
+ [ 'post_id' => $post_id, 'post_type' => 'hp_listing', 'meta_id' => 0 ],
+ $m
+ ), $metas );
+ }
+
+ public function test_archive_batch_stores_compressed(): void {
+ global $wpdb;
+ $rows = $this->archive_rows( 400, [
+ [ 'meta_key' => 'hp_price', 'meta_value' => '999' ],
+ [ 'meta_key' => 'hp_featured', 'meta_value' => '1' ],
+ ] );
+
+ WPDO_Zone_Archive::archive_batch( $rows, true );
+
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 400" );
+ $this->assertSame( 2, $count );
+
+ $comp = (int) $wpdb->get_var( "SELECT SUM(compressed) FROM `" . self::$archive_table . "` WHERE post_id = 400" );
+ $this->assertSame( 2, $comp );
+ }
+
+ public function test_archive_get_returns_values(): void {
+ $rows = $this->archive_rows( 401, [
+ [ 'meta_key' => 'hp_price', 'meta_value' => '500' ],
+ [ 'meta_key' => 'hp_verified', 'meta_value' => '1' ],
+ ] );
+ WPDO_Zone_Archive::archive_batch( $rows, false );
+
+ $result = WPDO_Zone_Archive::get( 401 );
+ $this->assertCount( 2, $result );
+
+ $by_key = array_column( $result, 'meta_value', 'meta_key' );
+ $this->assertSame( '500', $by_key['hp_price'] );
+ $this->assertSame( '1', $by_key['hp_verified'] );
+ }
+
+ public function test_archive_get_with_key_filter(): void {
+ $rows = $this->archive_rows( 402, [
+ [ 'meta_key' => 'hp_price', 'meta_value' => '250' ],
+ [ 'meta_key' => 'hp_featured', 'meta_value' => '0' ],
+ ] );
+ WPDO_Zone_Archive::archive_batch( $rows, false );
+
+ $result = WPDO_Zone_Archive::get( 402, 'hp_price' );
+ $this->assertCount( 1, $result );
+ $this->assertSame( 'hp_price', $result[0]['meta_key'] );
+ }
+
+ public function test_archive_restore_writes_postmeta(): void {
+ global $wpdb;
+ $rows = $this->archive_rows( 403, [
+ [ 'meta_key' => 'hp_price', 'meta_value' => '777' ],
+ ] );
+ WPDO_Zone_Archive::archive_batch( $rows, false );
+
+ $restored = WPDO_Zone_Archive::restore( 403 );
+ $this->assertSame( 1, $restored );
+
+ $pm = $GLOBALS['_wp_postmeta'][403]['hp_price'] ?? null;
+ $this->assertSame( '777', $pm );
+
+ $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 403" );
+ $this->assertSame( 0, $remaining );
+ }
+
+ public function test_archive_gzip_roundtrip(): void {
+ $rows = $this->archive_rows( 404, [
+ [ 'meta_key' => 'hp_description', 'meta_value' => str_repeat( 'Lorem ipsum ', 50 ) ],
+ ] );
+ WPDO_Zone_Archive::archive_batch( $rows, true );
+
+ $result = WPDO_Zone_Archive::get( 404 );
+ $this->assertCount( 1, $result );
+ $this->assertStringContainsString( 'Lorem ipsum', $result[0]['meta_value'] );
+ }
+
+ public function test_archive_delete_removes_rows(): void {
+ global $wpdb;
+ $rows = $this->archive_rows( 405, [
+ [ 'meta_key' => 'hp_price', 'meta_value' => '1' ],
+ ] );
+ WPDO_Zone_Archive::archive_batch( $rows, false );
+
+ WPDO_Zone_Archive::delete( 405 );
+
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 405" );
+ $this->assertSame( 0, $count );
+ }
+
+ public function test_archive_stats_counts_correctly(): void {
+ global $wpdb;
+ $wpdb->query( "TRUNCATE TABLE `" . self::$archive_table . "`" );
+
+ WPDO_Zone_Archive::archive_batch( $this->archive_rows( 500, [
+ [ 'meta_key' => 'k1', 'meta_value' => 'a' ],
+ [ 'meta_key' => 'k2', 'meta_value' => 'b' ],
+ ] ), true );
+ WPDO_Zone_Archive::archive_batch( $this->archive_rows( 502, [
+ [ 'meta_key' => 'k3', 'meta_value' => 'c' ],
+ ] ), false );
+
+ $stats = WPDO_Zone_Archive::stats();
+ $this->assertSame( 3, $stats['total_rows'] );
+ $this->assertSame( 2, $stats['compressed_rows'] );
+ }
+}
diff --git a/tests/integration/ZoneArchiveIntegrationTest.php b/tests/integration/ZoneArchiveIntegrationTest.php
new file mode 100644
index 0000000..1c12e21
--- /dev/null
+++ b/tests/integration/ZoneArchiveIntegrationTest.php
@@ -0,0 +1,203 @@
+query(
+ 'CREATE TABLE IF NOT EXISTS `' . self::TABLE . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `post_type` varchar(20) NOT NULL DEFAULT \'\',
+ `meta_key` varchar(255) NOT NULL DEFAULT \'\',
+ `meta_value` longtext DEFAULT NULL,
+ `compressed` tinyint(1) NOT NULL DEFAULT 0,
+ `archived_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
+ `original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ KEY `post_id` (`post_id`),
+ KEY `archived_at` (`archived_at`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
+ $GLOBALS['_wp_postmeta'] = [];
+ }
+
+ // ── archive / get ─────────────────────────────────────────────────────
+
+ public function test_archive_stores_plain_entry(): void {
+ WPDO_Zone_Archive::archive( 1, 'hp_listing', 'hp_price', '199.99' );
+
+ $rows = WPDO_Zone_Archive::get( 1 );
+ $this->assertCount( 1, $rows );
+ $this->assertSame( 'hp_price', $rows[0]['meta_key'] );
+ $this->assertSame( '199.99', $rows[0]['meta_value'] );
+ }
+
+ public function test_archive_with_compression_stores_and_decompresses(): void {
+ $original = 'large content that compresses well: ' . str_repeat( 'abcdef', 50 );
+ WPDO_Zone_Archive::archive( 2, 'hp_listing', 'hp_desc', $original, 0, true );
+
+ $rows = WPDO_Zone_Archive::get( 2, 'hp_desc' );
+ $this->assertCount( 1, $rows );
+ // get() decompresses automatically — value must match original.
+ $this->assertSame( $original, $rows[0]['meta_value'] );
+ }
+
+ public function test_get_with_meta_key_filter_returns_only_that_key(): void {
+ WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_price', '50.00' );
+ WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_featured', '1' );
+ WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_verified', '1' );
+
+ $rows = WPDO_Zone_Archive::get( 3, 'hp_price' );
+ $this->assertCount( 1, $rows );
+ $this->assertSame( 'hp_price', $rows[0]['meta_key'] );
+ }
+
+ public function test_get_without_filter_returns_all_keys(): void {
+ WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_price', '75.00' );
+ WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_featured', '0' );
+
+ $rows = WPDO_Zone_Archive::get( 4 );
+ $this->assertCount( 2, $rows );
+ }
+
+ public function test_get_returns_empty_for_missing_post(): void {
+ $rows = WPDO_Zone_Archive::get( 9999 );
+ $this->assertSame( [], $rows );
+ }
+
+ // ── archive_batch ────────────────────────────────────────────────────
+
+ public function test_archive_batch_stores_all_entries_in_transaction(): void {
+ $entries = [
+ [ 'post_id' => 5, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '100.00', 'meta_id' => 0 ],
+ [ 'post_id' => 5, 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'meta_value' => '1', 'meta_id' => 0 ],
+ [ 'post_id' => 6, 'post_type' => 'hp_vendor', 'meta_key' => 'hp_rate', 'meta_value' => '50.00', 'meta_id' => 0 ],
+ ];
+
+ WPDO_Zone_Archive::archive_batch( $entries );
+
+ $this->assertCount( 2, WPDO_Zone_Archive::get( 5 ) );
+ $this->assertCount( 1, WPDO_Zone_Archive::get( 6 ) );
+ }
+
+ public function test_archive_batch_with_compression(): void {
+ $original = str_repeat( 'x', 200 );
+ WPDO_Zone_Archive::archive_batch(
+ [ [ 'post_id' => 7, 'post_type' => 'hp_listing', 'meta_key' => 'hp_desc', 'meta_value' => $original, 'meta_id' => 0 ] ],
+ true
+ );
+
+ $rows = WPDO_Zone_Archive::get( 7, 'hp_desc' );
+ $this->assertSame( $original, $rows[0]['meta_value'] );
+ }
+
+ // ── delete ───────────────────────────────────────────────────────────
+
+ public function test_delete_removes_all_entries_for_post(): void {
+ WPDO_Zone_Archive::archive( 8, 'hp_listing', 'hp_price', '30.00' );
+ WPDO_Zone_Archive::archive( 8, 'hp_listing', 'hp_featured', '1' );
+ $this->assertCount( 2, WPDO_Zone_Archive::get( 8 ) );
+
+ WPDO_Zone_Archive::delete( 8 );
+ $this->assertSame( [], WPDO_Zone_Archive::get( 8 ) );
+ }
+
+ public function test_delete_does_not_affect_other_posts(): void {
+ WPDO_Zone_Archive::archive( 9, 'hp_listing', 'hp_price', '10.00' );
+ WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '20.00' );
+
+ WPDO_Zone_Archive::delete( 9 );
+
+ $this->assertSame( [], WPDO_Zone_Archive::get( 9 ) );
+ $this->assertCount( 1, WPDO_Zone_Archive::get( 10 ) );
+ }
+
+ // ── stats ─────────────────────────────────────────────────────────────
+
+ public function test_stats_counts_total_and_compressed_rows(): void {
+ WPDO_Zone_Archive::archive( 11, 'hp_listing', 'hp_price', '1.00', 0, false );
+ WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '2.00', 0, true );
+ WPDO_Zone_Archive::archive( 13, 'hp_listing', 'hp_price', '3.00', 0, true );
+
+ $stats = WPDO_Zone_Archive::stats();
+ $this->assertSame( 3, $stats['total_rows'] );
+ $this->assertSame( 2, $stats['compressed_rows'] );
+ }
+
+ public function test_stats_groups_by_post_type(): void {
+ WPDO_Zone_Archive::archive( 14, 'hp_listing', 'hp_price', '1.00' );
+ WPDO_Zone_Archive::archive( 15, 'hp_listing', 'hp_price', '2.00' );
+ WPDO_Zone_Archive::archive( 16, 'hp_vendor', 'hp_rate', '3.00' );
+
+ $stats = WPDO_Zone_Archive::stats();
+ $type_map = array_column( $stats['post_types'], 'cnt', 'post_type' );
+
+ $this->assertSame( '2', $type_map['hp_listing'] );
+ $this->assertSame( '1', $type_map['hp_vendor'] );
+ }
+
+ public function test_stats_returns_zeros_on_empty_table(): void {
+ $stats = WPDO_Zone_Archive::stats();
+ $this->assertSame( 0, $stats['total_rows'] );
+ $this->assertSame( 0, $stats['compressed_rows'] );
+ $this->assertSame( [], $stats['post_types'] );
+ }
+
+ // ── restore ───────────────────────────────────────────────────────────
+
+ public function test_restore_writes_to_postmeta_and_removes_from_archive(): void {
+ WPDO_Zone_Archive::archive( 17, 'hp_listing', 'hp_price', '99.00' );
+ WPDO_Zone_Archive::archive( 17, 'hp_listing', 'hp_featured', '1' );
+
+ $count = WPDO_Zone_Archive::restore( 17 );
+
+ // Two entries restored.
+ $this->assertSame( 2, $count );
+
+ // Postmeta updated via stub.
+ $this->assertSame( '99.00', $GLOBALS['_wp_postmeta'][17]['hp_price'] );
+ $this->assertSame( '1', $GLOBALS['_wp_postmeta'][17]['hp_featured'] );
+
+ // Archive cleared.
+ $this->assertSame( [], WPDO_Zone_Archive::get( 17 ) );
+ }
+
+ public function test_restore_with_meta_key_filter_only_restores_that_key(): void {
+ WPDO_Zone_Archive::archive( 18, 'hp_listing', 'hp_price', '55.00' );
+ WPDO_Zone_Archive::archive( 18, 'hp_listing', 'hp_featured', '0' );
+
+ $count = WPDO_Zone_Archive::restore( 18, 'hp_price' );
+
+ $this->assertSame( 1, $count );
+ $this->assertSame( '55.00', $GLOBALS['_wp_postmeta'][18]['hp_price'] );
+
+ // hp_featured should still be in archive.
+ $remaining = WPDO_Zone_Archive::get( 18 );
+ $this->assertCount( 1, $remaining );
+ $this->assertSame( 'hp_featured', $remaining[0]['meta_key'] );
+ }
+}
diff --git a/tests/integration/ZoneColdIntegrationTest.php b/tests/integration/ZoneColdIntegrationTest.php
new file mode 100644
index 0000000..6f8c226
--- /dev/null
+++ b/tests/integration/ZoneColdIntegrationTest.php
@@ -0,0 +1,223 @@
+prefix . 'wpdo_cold_itest' */
+ private static string $table;
+
+ // ── Fixture lifecycle ─────────────────────────────────────────────────────
+
+ public static function setUpBeforeClass(): void {
+ global $wpdb;
+ self::$table = $wpdb->prefix . 'wpdo_cold_' . self::POST_TYPE;
+
+ $wpdb->query(
+ "CREATE TABLE IF NOT EXISTS `" . self::$table . "` (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
+ data LONGTEXT NOT NULL,
+ updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
+ PRIMARY KEY (id),
+ UNIQUE KEY ui_post_id (post_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( "DROP TABLE IF EXISTS `" . self::$table . "`" );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( "TRUNCATE TABLE `" . self::$table . "`" );
+ $GLOBALS['_wp_cache'] = [];
+ }
+
+ // ── set / get ─────────────────────────────────────────────────────────────
+
+ public function test_set_and_get_single_field(): void {
+ WPDO_Zone_Cold::set( 100, self::POST_TYPE, 'hp_description', 'Hello World' );
+ $val = WPDO_Zone_Cold::get( 100, self::POST_TYPE, 'hp_description' );
+ $this->assertSame( 'Hello World', $val );
+ }
+
+ public function test_get_returns_null_for_missing_key(): void {
+ WPDO_Zone_Cold::set( 101, self::POST_TYPE, 'hp_description', 'present' );
+ $val = WPDO_Zone_Cold::get( 101, self::POST_TYPE, 'missing_key' );
+ $this->assertNull( $val );
+ }
+
+ public function test_get_returns_null_for_missing_post(): void {
+ $val = WPDO_Zone_Cold::get( 9999, self::POST_TYPE, 'hp_description' );
+ $this->assertNull( $val );
+ }
+
+ public function test_set_overwrites_existing_value(): void {
+ WPDO_Zone_Cold::set( 102, self::POST_TYPE, 'hp_website', 'http://old.example.com' );
+ WPDO_Zone_Cold::set( 102, self::POST_TYPE, 'hp_website', 'http://new.example.com' );
+ $val = WPDO_Zone_Cold::get( 102, self::POST_TYPE, 'hp_website' );
+ $this->assertSame( 'http://new.example.com', $val );
+ }
+
+ public function test_set_preserves_other_keys_in_blob(): void {
+ WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_description', 'Keep me' );
+ WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_website', 'https://keep.example.com' );
+
+ // Update only one key.
+ WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_website', 'https://updated.example.com' );
+
+ $this->assertSame( 'Keep me', WPDO_Zone_Cold::get( 103, self::POST_TYPE, 'hp_description' ) );
+ $this->assertSame( 'https://updated.example.com', WPDO_Zone_Cold::get( 103, self::POST_TYPE, 'hp_website' ) );
+ }
+
+ // ── set_many / get_blob ───────────────────────────────────────────────────
+
+ public function test_set_many_stores_multiple_fields(): void {
+ WPDO_Zone_Cold::set_many( 200, self::POST_TYPE, [
+ 'hp_description' => 'A great listing',
+ 'hp_website' => 'https://example.com',
+ 'hp_facebook' => 'https://facebook.com/test',
+ ] );
+
+ $this->assertSame( 'A great listing', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_description' ) );
+ $this->assertSame( 'https://example.com', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_website' ) );
+ $this->assertSame( 'https://facebook.com/test', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_facebook' ) );
+ }
+
+ public function test_get_blob_returns_all_fields(): void {
+ WPDO_Zone_Cold::set_many( 201, self::POST_TYPE, [
+ 'hp_description' => 'Blob test',
+ 'hp_website' => 'https://blob.example.com',
+ ] );
+
+ $blob = WPDO_Zone_Cold::get_blob( 201, self::POST_TYPE );
+ $this->assertIsArray( $blob );
+ $this->assertArrayHasKey( 'hp_description', $blob );
+ $this->assertArrayHasKey( 'hp_website', $blob );
+ $this->assertSame( 'Blob test', $blob['hp_description'] );
+ $this->assertSame( 'https://blob.example.com', $blob['hp_website'] );
+ }
+
+ public function test_get_blob_returns_empty_array_for_missing_post(): void {
+ $blob = WPDO_Zone_Cold::get_blob( 9998, self::POST_TYPE );
+ $this->assertIsArray( $blob );
+ $this->assertEmpty( $blob );
+ }
+
+ public function test_set_many_merges_with_existing_blob(): void {
+ WPDO_Zone_Cold::set_many( 202, self::POST_TYPE, [ 'hp_description' => 'First' ] );
+ WPDO_Zone_Cold::set_many( 202, self::POST_TYPE, [ 'hp_website' => 'https://merge.example.com' ] );
+
+ $this->assertSame( 'First', WPDO_Zone_Cold::get( 202, self::POST_TYPE, 'hp_description' ) );
+ $this->assertSame( 'https://merge.example.com', WPDO_Zone_Cold::get( 202, self::POST_TYPE, 'hp_website' ) );
+ }
+
+ // ── remove ────────────────────────────────────────────────────────────────
+
+ public function test_remove_key_from_blob(): void {
+ WPDO_Zone_Cold::set_many( 300, self::POST_TYPE, [
+ 'hp_description' => 'Keep me',
+ 'hp_website' => 'https://remove.example.com',
+ ] );
+
+ WPDO_Zone_Cold::remove( 300, self::POST_TYPE, 'hp_website' );
+
+ $this->assertSame( 'Keep me', WPDO_Zone_Cold::get( 300, self::POST_TYPE, 'hp_description' ) );
+ $this->assertNull( WPDO_Zone_Cold::get( 300, self::POST_TYPE, 'hp_website' ) );
+ }
+
+ public function test_remove_nonexistent_key_does_not_error(): void {
+ WPDO_Zone_Cold::set( 301, self::POST_TYPE, 'hp_description', 'Safe' );
+ WPDO_Zone_Cold::remove( 301, self::POST_TYPE, 'no_such_key' );
+ // Original key should still be intact.
+ $this->assertSame( 'Safe', WPDO_Zone_Cold::get( 301, self::POST_TYPE, 'hp_description' ) );
+ }
+
+ // ── delete ────────────────────────────────────────────────────────────────
+
+ public function test_delete_removes_row(): void {
+ global $wpdb;
+ WPDO_Zone_Cold::set( 400, self::POST_TYPE, 'hp_description', 'To be deleted' );
+
+ WPDO_Zone_Cold::delete( 400, self::POST_TYPE );
+
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$table . "` WHERE post_id = 400" );
+ $this->assertSame( 0, $count );
+ $this->assertNull( WPDO_Zone_Cold::get( 400, self::POST_TYPE, 'hp_description' ) );
+ }
+
+ public function test_delete_nonexistent_post_does_not_error(): void {
+ WPDO_Zone_Cold::delete( 9997, self::POST_TYPE );
+ $this->assertTrue( true ); // Must not throw.
+ }
+
+ // ── Object Cache ──────────────────────────────────────────────────────────
+
+ public function test_get_blob_populates_object_cache(): void {
+ WPDO_Zone_Cold::set( 500, self::POST_TYPE, 'hp_description', 'Cached value' );
+
+ // Clear cache to force a DB read on the next call.
+ $GLOBALS['_wp_cache'] = [];
+
+ // First get: reads from DB, warms the cache.
+ $val = WPDO_Zone_Cold::get( 500, self::POST_TYPE, 'hp_description' );
+ $this->assertSame( 'Cached value', $val );
+
+ // Cache entry must now exist.
+ $cached = wp_cache_get( 'cold_500', 'wpdo_cold_itest' );
+ $this->assertIsArray( $cached );
+ $this->assertSame( 'Cached value', $cached['hp_description'] );
+ }
+
+ public function test_set_invalidates_object_cache(): void {
+ WPDO_Zone_Cold::set( 501, self::POST_TYPE, 'hp_description', 'Original' );
+
+ // Warm the cache by reading once.
+ WPDO_Zone_Cold::get( 501, self::POST_TYPE, 'hp_description' );
+ $this->assertNotFalse( wp_cache_get( 'cold_501', 'wpdo_cold_itest' ) );
+
+ // Write a new value — must invalidate the cached blob.
+ WPDO_Zone_Cold::set( 501, self::POST_TYPE, 'hp_description', 'Updated' );
+ $this->assertFalse( wp_cache_get( 'cold_501', 'wpdo_cold_itest' ) );
+
+ // Subsequent read must return the updated value (from DB).
+ $val = WPDO_Zone_Cold::get( 501, self::POST_TYPE, 'hp_description' );
+ $this->assertSame( 'Updated', $val );
+ }
+
+ public function test_delete_invalidates_object_cache(): void {
+ WPDO_Zone_Cold::set( 502, self::POST_TYPE, 'hp_description', 'Will be deleted' );
+
+ // Warm the cache.
+ WPDO_Zone_Cold::get( 502, self::POST_TYPE, 'hp_description' );
+
+ // Delete — must clear cache.
+ WPDO_Zone_Cold::delete( 502, self::POST_TYPE );
+ $this->assertFalse( wp_cache_get( 'cold_502', 'wpdo_cold_itest' ) );
+ }
+
+ // ── isolation ─────────────────────────────────────────────────────────────
+
+ public function test_different_post_ids_are_independent(): void {
+ WPDO_Zone_Cold::set( 600, self::POST_TYPE, 'hp_description', 'Post 600' );
+ WPDO_Zone_Cold::set( 601, self::POST_TYPE, 'hp_description', 'Post 601' );
+
+ $this->assertSame( 'Post 600', WPDO_Zone_Cold::get( 600, self::POST_TYPE, 'hp_description' ) );
+ $this->assertSame( 'Post 601', WPDO_Zone_Cold::get( 601, self::POST_TYPE, 'hp_description' ) );
+ }
+}
diff --git a/tests/integration/ZoneHotIntegrationTest.php b/tests/integration/ZoneHotIntegrationTest.php
new file mode 100644
index 0000000..a138942
--- /dev/null
+++ b/tests/integration/ZoneHotIntegrationTest.php
@@ -0,0 +1,126 @@
+query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TABLE . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `hp_price` decimal(10,2) NOT NULL DEFAULT 0,
+ `hp_featured` tinyint(1) NOT NULL DEFAULT 0,
+ `updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `post_id` (`post_id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
+ }
+
+ // ── set / get ────────────────────────────────────────────────────────
+
+ public function test_set_and_get_single_field(): void {
+ WPDO_Zone_Hot::set( 1, self::POST_TYPE, 'hp_price', '199.99' );
+ $val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, 'hp_price' );
+ $this->assertSame( '199.99', $val );
+ }
+
+ public function test_get_returns_null_for_missing_post(): void {
+ $val = WPDO_Zone_Hot::get( 9999, self::POST_TYPE, 'hp_price' );
+ $this->assertNull( $val );
+ }
+
+ public function test_set_overwrites_existing_value(): void {
+ WPDO_Zone_Hot::set( 2, self::POST_TYPE, 'hp_price', '50.00' );
+ WPDO_Zone_Hot::set( 2, self::POST_TYPE, 'hp_price', '75.00' );
+ $val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, 'hp_price' );
+ $this->assertSame( '75.00', $val );
+ }
+
+ public function test_set_featured_integer_field(): void {
+ WPDO_Zone_Hot::set( 3, self::POST_TYPE, 'hp_featured', '1' );
+ $val = WPDO_Zone_Hot::get( 3, self::POST_TYPE, 'hp_featured' );
+ $this->assertSame( '1', $val );
+ }
+
+ // ── set_many / get_row ───────────────────────────────────────────────
+
+ public function test_set_many_stores_multiple_columns(): void {
+ WPDO_Zone_Hot::set_many( 4, self::POST_TYPE, [
+ 'hp_price' => '299.00',
+ 'hp_featured' => '1',
+ ] );
+
+ $row = WPDO_Zone_Hot::get_row( 4, self::POST_TYPE );
+ $this->assertIsArray( $row );
+ $this->assertSame( '299.00', $row['hp_price'] );
+ $this->assertSame( '1', $row['hp_featured'] );
+ }
+
+ public function test_set_many_overwrites_on_second_call(): void {
+ WPDO_Zone_Hot::set_many( 5, self::POST_TYPE, [ 'hp_price' => '100.00', 'hp_featured' => '0' ] );
+ WPDO_Zone_Hot::set_many( 5, self::POST_TYPE, [ 'hp_price' => '200.00', 'hp_featured' => '1' ] );
+
+ $row = WPDO_Zone_Hot::get_row( 5, self::POST_TYPE );
+ $this->assertSame( '200.00', $row['hp_price'] );
+ $this->assertSame( '1', $row['hp_featured'] );
+ }
+
+ public function test_get_row_returns_null_for_missing_post(): void {
+ $row = WPDO_Zone_Hot::get_row( 9998, self::POST_TYPE );
+ $this->assertNull( $row );
+ }
+
+ // ── delete ───────────────────────────────────────────────────────────
+
+ public function test_delete_removes_row(): void {
+ WPDO_Zone_Hot::set( 6, self::POST_TYPE, 'hp_price', '42.00' );
+ $this->assertNotNull( WPDO_Zone_Hot::get( 6, self::POST_TYPE, 'hp_price' ) );
+
+ WPDO_Zone_Hot::delete( 6, self::POST_TYPE );
+ $this->assertNull( WPDO_Zone_Hot::get( 6, self::POST_TYPE, 'hp_price' ) );
+ }
+
+ public function test_delete_nonexistent_post_does_not_error(): void {
+ // Should complete without throwing.
+ WPDO_Zone_Hot::delete( 9997, self::POST_TYPE );
+ $this->assertTrue( true );
+ }
+
+ // ── isolation ────────────────────────────────────────────────────────
+
+ public function test_different_post_ids_are_independent(): void {
+ WPDO_Zone_Hot::set( 10, self::POST_TYPE, 'hp_price', '10.00' );
+ WPDO_Zone_Hot::set( 11, self::POST_TYPE, 'hp_price', '11.00' );
+
+ $this->assertSame( '10.00', WPDO_Zone_Hot::get( 10, self::POST_TYPE, 'hp_price' ) );
+ $this->assertSame( '11.00', WPDO_Zone_Hot::get( 11, self::POST_TYPE, 'hp_price' ) );
+ }
+}
diff --git a/tests/integration/ZoneWarmIntegrationTest.php b/tests/integration/ZoneWarmIntegrationTest.php
new file mode 100644
index 0000000..730ad81
--- /dev/null
+++ b/tests/integration/ZoneWarmIntegrationTest.php
@@ -0,0 +1,169 @@
+query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
+ $wpdb->query(
+ 'CREATE TABLE `' . self::TABLE . '` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `meta_key` varchar(255) NOT NULL DEFAULT \'\',
+ `meta_value` longtext DEFAULT NULL,
+ `expires_at` datetime DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
+ PRIMARY KEY (`id`),
+ KEY `post_id` (`post_id`),
+ KEY `expires_at` (`expires_at`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+ }
+
+ public static function tearDownAfterClass(): void {
+ global $wpdb;
+ $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
+ }
+
+ protected function setUp(): void {
+ global $wpdb;
+ $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
+ }
+
+ // ── set / get ────────────────────────────────────────────────────────
+
+ public function test_set_and_get_basic(): void {
+ WPDO_Zone_Warm::set( 1, 'test_key', 'hello' );
+ $this->assertSame( 'hello', WPDO_Zone_Warm::get( 1, 'test_key' ) );
+ }
+
+ public function test_get_returns_null_for_missing_key(): void {
+ $this->assertNull( WPDO_Zone_Warm::get( 9999, 'no_such_key' ) );
+ }
+
+ public function test_set_overwrites_existing_value(): void {
+ WPDO_Zone_Warm::set( 2, 'counter', '1' );
+ WPDO_Zone_Warm::set( 2, 'counter', '5' );
+ $this->assertSame( '5', WPDO_Zone_Warm::get( 2, 'counter' ) );
+ }
+
+ // ── TTL / expiry ─────────────────────────────────────────────────────
+
+ public function test_set_with_future_ttl_is_readable(): void {
+ WPDO_Zone_Warm::set( 3, 'flag', 'active', 3600 ); // expires in 1 hour
+ $this->assertSame( 'active', WPDO_Zone_Warm::get( 3, 'flag' ) );
+ }
+
+ public function test_expired_entry_returns_null(): void {
+ global $wpdb;
+ // Insert directly with a past expiry timestamp.
+ $wpdb->query(
+ "INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at)
+ VALUES (4, 'old_flag', 'gone', '2000-01-01 00:00:00', '2000-01-01 00:00:00')"
+ );
+ $this->assertNull( WPDO_Zone_Warm::get( 4, 'old_flag' ) );
+ }
+
+ public function test_null_ttl_entry_never_expires(): void {
+ WPDO_Zone_Warm::set( 5, 'permanent', 'stays', null );
+ $this->assertSame( 'stays', WPDO_Zone_Warm::get( 5, 'permanent' ) );
+ }
+
+ // ── delete ───────────────────────────────────────────────────────────
+
+ public function test_delete_removes_specific_key(): void {
+ WPDO_Zone_Warm::set( 6, 'key_a', 'alpha' );
+ WPDO_Zone_Warm::set( 6, 'key_b', 'beta' );
+
+ WPDO_Zone_Warm::delete( 6, 'key_a' );
+
+ $this->assertNull( WPDO_Zone_Warm::get( 6, 'key_a' ) );
+ $this->assertSame( 'beta', WPDO_Zone_Warm::get( 6, 'key_b' ) );
+ }
+
+ public function test_delete_all_removes_all_keys_for_post(): void {
+ WPDO_Zone_Warm::set( 7, 'x', '1' );
+ WPDO_Zone_Warm::set( 7, 'y', '2' );
+ WPDO_Zone_Warm::set( 7, 'z', '3' );
+
+ WPDO_Zone_Warm::delete_all( 7 );
+
+ $this->assertNull( WPDO_Zone_Warm::get( 7, 'x' ) );
+ $this->assertNull( WPDO_Zone_Warm::get( 7, 'y' ) );
+ $this->assertNull( WPDO_Zone_Warm::get( 7, 'z' ) );
+ }
+
+ public function test_delete_all_does_not_affect_other_posts(): void {
+ WPDO_Zone_Warm::set( 8, 'shared_key', 'post_8' );
+ WPDO_Zone_Warm::set( 9, 'shared_key', 'post_9' );
+
+ WPDO_Zone_Warm::delete_all( 8 );
+
+ $this->assertNull( WPDO_Zone_Warm::get( 8, 'shared_key' ) );
+ $this->assertSame( 'post_9', WPDO_Zone_Warm::get( 9, 'shared_key' ) );
+ }
+
+ // ── purge_expired ────────────────────────────────────────────────────
+
+ public function test_purge_expired_removes_stale_entries(): void {
+ global $wpdb;
+ // One expired entry.
+ $wpdb->query(
+ "INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at)
+ VALUES (10, 'stale', 'gone', '2000-01-01 00:00:00', '2000-01-01 00:00:00')"
+ );
+ // One valid entry.
+ WPDO_Zone_Warm::set( 10, 'fresh', 'keep', 3600 );
+
+ $deleted = WPDO_Zone_Warm::purge_expired();
+
+ $this->assertSame( 1, $deleted );
+ $this->assertNull( WPDO_Zone_Warm::get( 10, 'stale' ) );
+ $this->assertSame( 'keep', WPDO_Zone_Warm::get( 10, 'fresh' ) );
+ }
+
+ public function test_purge_expired_returns_zero_when_nothing_stale(): void {
+ WPDO_Zone_Warm::set( 11, 'live', 'value', 3600 );
+ $this->assertSame( 0, WPDO_Zone_Warm::purge_expired() );
+ }
+
+ // ── get_all ──────────────────────────────────────────────────────────
+
+ public function test_get_all_returns_all_valid_keys_for_post(): void {
+ WPDO_Zone_Warm::set( 12, 'ka', 'va' );
+ WPDO_Zone_Warm::set( 12, 'kb', 'vb' );
+
+ $all = WPDO_Zone_Warm::get_all( 12 );
+ $this->assertArrayHasKey( 'ka', $all );
+ $this->assertArrayHasKey( 'kb', $all );
+ $this->assertSame( 'va', $all['ka'] );
+ $this->assertSame( 'vb', $all['kb'] );
+ }
+
+ public function test_get_all_excludes_expired_entries(): void {
+ global $wpdb;
+ WPDO_Zone_Warm::set( 13, 'live', 'yes' );
+ $wpdb->query(
+ "INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at)
+ VALUES (13, 'dead', 'no', '2000-01-01 00:00:00', '2000-01-01 00:00:00')"
+ );
+
+ $all = WPDO_Zone_Warm::get_all( 13 );
+ $this->assertArrayHasKey( 'live', $all );
+ $this->assertArrayNotHasKey( 'dead', $all );
+ }
+}
diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php
new file mode 100644
index 0000000..3745bcf
--- /dev/null
+++ b/tests/integration/bootstrap.php
@@ -0,0 +1,604 @@
+connect_error ) {
+ throw new RuntimeException( 'Integration test DB connection failed: ' . $_mysqli_init->connect_error );
+}
+
+$_db_name_quoted = '`' . str_replace( '`', '``', $_db_name ) . '`';
+if ( ! $_mysqli_init->query( "CREATE DATABASE IF NOT EXISTS {$_db_name_quoted} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" ) ) {
+ throw new RuntimeException( "Failed to create test database '{$_db_name}': " . $_mysqli_init->error );
+}
+$_mysqli_init->close();
+unset( $_mysqli_init, $_db_name_quoted );
+
+$_mysqli = new mysqli( $_db_host, $_db_user, $_db_pass, $_db_name );
+if ( $_mysqli->connect_error ) {
+ throw new RuntimeException( 'Integration test DB connection failed: ' . $_mysqli->connect_error );
+}
+$_mysqli->set_charset( 'utf8mb4' );
+
+// ── Real $wpdb ─────────────────────────────────────────────────────────────
+
+global $wpdb;
+$wpdb = new class( $_mysqli ) {
+ private mysqli $db;
+
+ public string $prefix = 'wp_itest_';
+ public string $postmeta = 'wp_itest_postmeta';
+ public string $posts = 'wp_itest_posts';
+ public string $options = 'wp_itest_options';
+ public string $usermeta = 'wp_itest_usermeta';
+ public string $users = 'wp_itest_users';
+ public string $terms = 'wp_itest_terms';
+ public string $termmeta = 'wp_itest_termmeta';
+ public string $comments = 'wp_itest_comments';
+ public string $commentmeta = 'wp_itest_commentmeta';
+ public int $insert_id = 0;
+ public string $last_error = '';
+
+ public function __construct( mysqli $db ) { $this->db = $db; }
+
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%([sdf])/', function ( $m ) use ( &$i, $args ) {
+ $val = $args[ $i++ ] ?? '';
+ if ( $m[1] === 'd' ) { return (string) (int) $val; }
+ if ( $m[1] === 'f' ) { return (string) (float) $val; }
+ return "'" . $this->db->real_escape_string( (string) $val ) . "'";
+ }, $sql );
+ }
+
+ public function get_var( string $sql ): ?string {
+ $result = $this->db->query( $sql );
+ if ( ! $result || ! ( $row = $result->fetch_row() ) ) { return null; }
+ return $row[0] !== null ? (string) $row[0] : null;
+ }
+
+ public function get_row( string $sql, $output = 'OBJECT' ) {
+ $result = $this->db->query( $sql );
+ if ( ! $result ) { return null; }
+ return ARRAY_A === $output ? ( $result->fetch_assoc() ?: null ) : ( $result->fetch_object() ?: null );
+ }
+
+ public function get_results( string $sql, $output = 'OBJECT' ): array {
+ $result = $this->db->query( $sql );
+ if ( ! $result ) { return []; }
+ $rows = [];
+ while ( $row = ( ARRAY_A === $output ? $result->fetch_assoc() : $result->fetch_object() ) ) {
+ $rows[] = $row;
+ }
+ return $rows;
+ }
+
+ public function get_col( string $sql, int $col_index = 0 ): array {
+ $result = $this->db->query( $sql );
+ if ( ! $result ) { return []; }
+ $values = [];
+ while ( $row = $result->fetch_row() ) { $values[] = $row[ $col_index ] ?? null; }
+ return $values;
+ }
+
+ public function get_charset_collate(): string {
+ return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci';
+ }
+
+ public function insert( string $table, array $data, $format = null ): int|false {
+ $cols = implode( ', ', array_map( fn( $c ) => '`' . $c . '`', array_keys( $data ) ) );
+ $vals = implode( ', ', array_map(
+ fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'",
+ array_values( $data )
+ ) );
+ $ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
+ if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; }
+ $this->last_error = (string) $this->db->error;
+ return false;
+ }
+
+ public function update( string $table, array $data, array $where, $format = null, $wf = null ): int|false {
+ $set = implode( ', ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $data ), $data ) );
+ $cond = implode( ' AND ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $where ), $where ) );
+ $ok = $this->db->query( "UPDATE `{$table}` SET {$set} WHERE {$cond}" );
+ return $ok ? $this->db->affected_rows : false;
+ }
+
+ public function delete( string $table, array $where, $format = null ): int|false {
+ $cond = implode( ' AND ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $where ), $where ) );
+ $ok = $this->db->query( "DELETE FROM `{$table}` WHERE {$cond}" );
+ return $ok ? $this->db->affected_rows : false;
+ }
+
+ public function replace( string $table, array $data, $format = null ): int|false {
+ $cols = implode( ', ', array_map( fn( $c ) => '`' . $c . '`', array_keys( $data ) ) );
+ $vals = implode( ', ', array_map( fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'", array_values( $data ) ) );
+ $ok = $this->db->query( "REPLACE INTO `{$table}` ({$cols}) VALUES ({$vals})" );
+ if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->db->affected_rows; }
+ $this->last_error = (string) $this->db->error;
+ return false;
+ }
+
+ public function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); }
+ public function flush(): void {}
+
+ public function query( string $sql ): int|bool {
+ $result = $this->db->query( $sql );
+ if ( $result instanceof mysqli_result ) { $result->free(); return true; }
+ if ( false === $result ) { return false; }
+ return $this->db->affected_rows;
+ }
+};
+
+// ── WordPress function stubs ───────────────────────────────────────────────
+
+if ( ! function_exists( 'trailingslashit' ) ) {
+ function trailingslashit( string $s ): string { return rtrim( $s, '/' ) . '/'; }
+}
+if ( ! function_exists( 'sanitize_key' ) ) {
+ function sanitize_key( string $key ): string { return strtolower( preg_replace( '/[^a-z0-9_\-]/', '', $key ) ); }
+}
+if ( ! function_exists( 'absint' ) ) {
+ function absint( $v ): int { return abs( (int) $v ); }
+}
+if ( ! function_exists( 'wp_unslash' ) ) {
+ function wp_unslash( $v ) { return is_string( $v ) ? stripslashes( $v ) : $v; }
+}
+if ( ! function_exists( 'esc_like' ) ) {
+ function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); }
+}
+if ( ! function_exists( 'current_time' ) ) {
+ function current_time( string $type, bool $gmt = false ): string|int {
+ if ( 'timestamp' === $type || 'U' === $type ) { return time(); }
+ return gmdate( 'Y-m-d H:i:s' );
+ }
+}
+if ( ! function_exists( 'wp_parse_args' ) ) {
+ function wp_parse_args( $args, array $defaults = [] ): array {
+ if ( is_string( $args ) ) { parse_str( $args, $args ); }
+ return array_merge( $defaults, (array) $args );
+ }
+}
+
+$GLOBALS['_wp_filter_callbacks'] = [];
+if ( ! function_exists( 'add_filter' ) ) {
+ function add_filter( string $hook, $cb, int $p = 10, int $a = 1 ): bool {
+ $GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb;
+ return true;
+ }
+}
+if ( ! function_exists( 'add_action' ) ) {
+ function add_action( string $hook, $cb, int $p = 10, int $a = 1 ): bool {
+ $GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb;
+ return true;
+ }
+}
+if ( ! function_exists( 'remove_filter' ) ) {
+ function remove_filter( string $hook, $cb, int $p = 10 ): bool {
+ if ( isset( $GLOBALS['_wp_filter_callbacks'][ $hook ] ) ) {
+ $GLOBALS['_wp_filter_callbacks'][ $hook ] = array_values(
+ array_filter( $GLOBALS['_wp_filter_callbacks'][ $hook ], fn( $c ) => $c !== $cb )
+ );
+ }
+ return true;
+ }
+}
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$args ) {
+ foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
+ $value = $cb( $value, ...$args );
+ }
+ return $value;
+ }
+}
+if ( ! function_exists( 'do_action' ) ) {
+ function do_action( string $hook, ...$args ): void {
+ foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
+ $cb( ...$args );
+ }
+ }
+}
+
+if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } }
+if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } }
+if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
+if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
+if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
+if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } }
+if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
+if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
+if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
+if ( ! function_exists( 'esc_html' ) ) { function esc_html( $s ): string { return htmlspecialchars( (string) $s, ENT_QUOTES, 'UTF-8' ); } }
+if ( ! function_exists( 'esc_attr' ) ) { function esc_attr( string $s ): string { return htmlspecialchars( $s, ENT_QUOTES, 'UTF-8' ); } }
+if ( ! function_exists( 'esc_url' ) ) { function esc_url( string $url ): string { return filter_var( $url, FILTER_SANITIZE_URL ) ?: ''; } }
+if ( ! function_exists( 'esc_sql' ) ) { function esc_sql( $s ): string { return addslashes( is_string( $s ) ? $s : (string) $s ); } }
+if ( ! function_exists( '_doing_it_wrong' ) ) { function _doing_it_wrong( string $fn, string $msg, string $ver ): void {} }
+if ( ! function_exists( 'wp_strip_all_tags' ) ) { function wp_strip_all_tags( string $s ): string { return strip_tags( $s ); } }
+if ( ! function_exists( 'get_option' ) ) { function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; } }
+if ( ! function_exists( 'update_option' ) ) { function update_option( string $key, $value ): bool { $GLOBALS['_wp_options'][ $key ] = $value; return true; } }
+if ( ! function_exists( 'delete_option' ) ) { function delete_option( string $key ): bool { unset( $GLOBALS['_wp_options'][ $key ] ); return true; } }
+if ( ! function_exists( 'get_transient' ) ) { function get_transient( string $key ) { return $GLOBALS['_wp_transients'][ $key ] ?? false; } }
+if ( ! function_exists( 'set_transient' ) ) { function set_transient( string $key, $value, int $exp = 0 ): bool { $GLOBALS['_wp_transients'][ $key ] = $value; return true; } }
+if ( ! function_exists( 'delete_transient' ) ) { function delete_transient( string $key ): bool { unset( $GLOBALS['_wp_transients'][ $key ] ); return true; } }
+if ( ! function_exists( 'get_post_meta' ) ) { function get_post_meta( int $post_id, string $key = '', bool $single = false ) { return $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] ?? ( $single ? '' : [] ); } }
+if ( ! function_exists( 'update_post_meta' ) ) {
+ function update_post_meta( int $post_id, string $key, $value, $prev = '' ): int|bool {
+ global $wpdb;
+ $has_table = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->postmeta ) );
+ if ( $has_table ) {
+ $existing = $wpdb->get_var( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1", $post_id, $key ) );
+ if ( $existing ) {
+ $wpdb->update( $wpdb->postmeta, array( 'meta_value' => is_scalar( $value ) ? (string) $value : maybe_serialize( $value ) ), array( 'meta_id' => $existing ) );
+ } else {
+ $wpdb->insert( $wpdb->postmeta, array( 'post_id' => $post_id, 'meta_key' => $key, 'meta_value' => is_scalar( $value ) ? (string) $value : maybe_serialize( $value ) ) );
+ }
+ }
+ $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] = $value;
+ return true;
+ }
+}
+if ( ! function_exists( 'get_user_meta' ) ) { function get_user_meta( int $uid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] ); } }
+if ( ! function_exists( 'update_user_meta' ) ) { function update_user_meta( int $uid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_usermeta'][ $uid ][ $key ] = $value; return true; } }
+if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_termmeta'][ $tid ][ $key ] ?? ( $single ? '' : [] ); } }
+if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
+if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
+if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
+if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
+if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
+if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
+ function is_post_publicly_viewable( $post_id ): bool {
+ if ( isset( $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ] ) ) { return (bool) $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ]; }
+ return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' );
+ }
+}
+if ( ! function_exists( 'current_user_can' ) ) {
+ function current_user_can( string $cap, ...$args ): bool {
+ if ( ! empty( $args ) ) { $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) ); if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) { return (bool) $GLOBALS['_wp_current_user_can'][ $key ]; } }
+ return $GLOBALS['_wp_current_user_can'][ $cap ] ?? false;
+ }
+}
+if ( ! function_exists( 'get_current_user_id' ) ) { function get_current_user_id(): int { return (int) ( $GLOBALS['_wp_current_user_id'] ?? 0 ); } }
+if ( ! function_exists( 'is_multisite' ) ) { function is_multisite(): bool { return (bool) ( $GLOBALS['_wp_is_multisite'] ?? false ); } }
+if ( ! function_exists( 'is_super_admin' ) ) { function is_super_admin( ?int $uid = null ): bool { return (bool) ( $GLOBALS['_wp_is_super_admin'] ?? false ); } }
+if ( ! function_exists( 'switch_to_blog' ) ) { function switch_to_blog( int $blog_id ): bool { $GLOBALS['_wp_current_blog_id'] = $blog_id; return true; } }
+if ( ! function_exists( 'restore_current_blog' ) ) { function restore_current_blog(): bool { unset( $GLOBALS['_wp_current_blog_id'] ); return true; } }
+if ( ! function_exists( 'get_sites' ) ) { function get_sites( array $args = [] ): array { return $GLOBALS['_wp_sites'] ?? []; } }
+if ( ! function_exists( 'is_plugin_active_for_network' ) ) { function is_plugin_active_for_network( string $plugin ): bool { return (bool) ( $GLOBALS['_wp_plugin_active_for_network'][ $plugin ] ?? false ); } }
+if ( ! function_exists( 'is_plugin_active' ) ) { function is_plugin_active( string $plugin ): bool { return false; } }
+if ( ! function_exists( 'deactivate_plugins' ) ) { function deactivate_plugins( $plugin, bool $silent = false ): void {} }
+if ( ! function_exists( 'plugin_basename' ) ) { function plugin_basename( string $file ): string { return basename( dirname( $file ) ) . '/' . basename( $file ); } }
+if ( ! function_exists( 'wp_generate_password' ) ) { function wp_generate_password( int $len = 12, bool $special = true ): string { return substr( str_replace( [ '/', '+', '=' ], '', base64_encode( random_bytes( $len ) ) ), 0, $len ); } }
+if ( ! function_exists( 'wp_json_encode' ) ) { function wp_json_encode( $data, int $flags = 0 ): string|false { return json_encode( $data, $flags ); } }
+if ( ! function_exists( 'wp_rand' ) ) { function wp_rand( int $min = 0, int $max = 0 ): int { return random_int( $min, $max ?: PHP_INT_MAX ); } }
+if ( ! function_exists( 'is_wp_error' ) ) { function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; } }
+if ( ! function_exists( 'wp_verify_nonce' ) ) { function wp_verify_nonce( $nonce, string $action = '' ) { return $GLOBALS['_wp_valid_nonces'][ (string) $nonce ] ?? false; } }
+if ( ! function_exists( 'wp_create_nonce' ) ) { function wp_create_nonce( string $action = '' ): string { $nonce = 'test_nonce_' . md5( $action ); $GLOBALS['_wp_valid_nonces'][ $nonce ] = 1; return $nonce; } }
+if ( ! function_exists( 'wp_die' ) ) { function wp_die( $message = '' ): void { throw new RuntimeException( is_string( $message ) ? $message : 'wp_die' ); } }
+if ( ! function_exists( 'maybe_serialize' ) ) { function maybe_serialize( $data ) { return is_array( $data ) || is_object( $data ) ? serialize( $data ) : $data; } }
+if ( ! function_exists( 'maybe_unserialize' ) ) { function maybe_unserialize( $value ) { if ( ! is_string( $value ) ) { return $value; } $u = @unserialize( $value ); return ( false !== $u || 'b:0;' === $value ) ? $u : $value; } }
+if ( ! function_exists( 'get_bloginfo' ) ) { function get_bloginfo( string $key ): string { return 'version' === $key ? '6.9.4' : ''; } }
+if ( ! function_exists( 'sanitize_title' ) ) { function sanitize_title( string $title ): string { return strtolower( preg_replace( '/[^a-z0-9-]+/i', '-', trim( $title ) ) ); } }
+if ( ! function_exists( 'taxonomy_exists' ) ) {
+ function taxonomy_exists( string $taxonomy ): bool {
+ if ( isset( $GLOBALS['_taxonomy_exists_override'] ) ) { return (bool) $GLOBALS['_taxonomy_exists_override']; }
+ return in_array( $taxonomy, [ 'category', 'post_tag', 'listing_category', 'listing_tag' ], true );
+ }
+}
+if ( ! function_exists( 'clean_term_cache' ) ) { function clean_term_cache( $ids, string $taxonomy = '', bool $clean_taxonomy = true ): void {} }
+if ( ! function_exists( 'get_taxonomies' ) ) {
+ function get_taxonomies( array $args = [], string $output = 'names' ): array {
+ $taxonomies = [ 'category', 'post_tag', 'listing_category', 'listing_tag' ];
+ if ( 'objects' === $output ) {
+ $out = [];
+ foreach ( $taxonomies as $slug ) { $out[ $slug ] = (object) [ 'name' => $slug, 'labels' => (object) [ 'singular_name' => ucfirst( str_replace( '_', ' ', $slug ) ) ] ]; }
+ return $out;
+ }
+ return $taxonomies;
+ }
+}
+if ( ! function_exists( 'is_serialized' ) ) { function is_serialized( $data ): bool { return is_string( $data ) && strlen( $data ) >= 4 && in_array( $data[0], [ 'a', 's', 'i', 'd', 'b', 'O', 'N' ], true ) && str_ends_with( $data, ';' ); } }
+if ( ! function_exists( 'wp_upload_dir' ) ) {
+ function wp_upload_dir( $time = null, $create_dir = true, $refresh_cache = false ): array {
+ $upload_path = sys_get_temp_dir() . '/wp-uploads-test';
+ return [
+ 'path' => $upload_path,
+ 'url' => 'http://localhost/wp-content/uploads',
+ 'subdir' => '',
+ 'basedir' => $upload_path,
+ 'baseurl' => 'http://localhost/wp-content/uploads',
+ 'error' => false,
+ ];
+ }
+}
+if ( ! function_exists( 'wp_mkdir_p' ) ) {
+ function wp_mkdir_p( string $dir ): bool {
+ if ( is_dir( $dir ) ) { return true; }
+ return mkdir( $dir, 0777, true );
+ }
+}
+
+$GLOBALS['_wp_cache'] = [];
+if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
+if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
+if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } }
+
+if ( ! class_exists( 'WP_Query' ) ) {
+ class WP_Query {
+ private array $vars = [];
+ public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; }
+ public function set( string $key, $value ): void { $this->vars[ $key ] = $value; }
+ }
+}
+if ( ! class_exists( 'WP_Error' ) ) {
+ class WP_Error {
+ private string $code;
+ private string $message;
+ public function __construct( string $code = '', string $message = '' ) { $this->code = $code; $this->message = $message; }
+ public function get_error_code(): string { return $this->code; }
+ public function get_error_message(): string { return $this->message; }
+ }
+}
+if ( ! class_exists( 'WP_REST_Request' ) ) {
+ class WP_REST_Request {
+ private array $params = []; private array $headers = [];
+ public function __construct( string $method = 'GET', string $route = '' ) {}
+ public function get_param( string $key ) { return $this->params[ $key ] ?? null; }
+ public function set_param( string $key, $value ): void { $this->params[ $key ] = $value; }
+ public function get_header( string $key ): ?string { return $this->headers[ strtolower( $key ) ] ?? null; }
+ public function set_header( string $key, string $value ): void { $this->headers[ strtolower( $key ) ] = $value; }
+ }
+}
+if ( ! class_exists( 'WP_REST_Response' ) ) {
+ class WP_REST_Response {
+ private $data; private int $status; private array $headers = [];
+ public function __construct( $data = null, int $status = 200 ) { $this->data = $data; $this->status = $status; }
+ public function get_data() { return $this->data; }
+ public function get_status(): int { return $this->status; }
+ public function header( string $k, string $v ): void { $this->headers[ $k ] = $v; }
+ public function get_headers(): array { return $this->headers; }
+ }
+}
+if ( ! class_exists( 'WP_REST_Server' ) ) {
+ class WP_REST_Server { const READABLE = 'GET'; const CREATABLE = 'POST'; }
+}
+if ( ! function_exists( 'register_rest_route' ) ) { function register_rest_route( string $ns, string $route, array $args ): bool { return true; } }
+
+if ( ! function_exists( 'dbDelta' ) ) {
+ function dbDelta( string $sql ): array {
+ global $wpdb;
+ $sql_idempotent = preg_replace( '/^CREATE TABLE/i', 'CREATE TABLE IF NOT EXISTS', trim( $sql ), 1 );
+ $wpdb->query( $sql_idempotent );
+ return [];
+ }
+}
+
+if ( ! function_exists( 'wp_insert_post' ) ) {
+ function wp_insert_post( array $postarr, bool $wp_error = false ) {
+ global $wpdb;
+ $now = current_time( 'mysql' );
+ $defaults = [ 'post_title' => '', 'post_type' => 'post', 'post_status' => 'publish', 'post_content' => '', 'post_excerpt' => '', 'post_content_filtered' => '', 'to_ping' => '', 'pinged' => '', 'post_date' => $now, 'post_date_gmt' => $now, 'post_modified' => $now, 'post_modified_gmt' => $now, 'post_name' => '', 'guid' => '' ];
+ $row = array_merge( $defaults, $postarr );
+ if ( '' === $row['post_name'] ) { $row['post_name'] = sanitize_title( (string) $row['post_title'] ); }
+ $ok = $wpdb->insert( $wpdb->posts, $row );
+ return $ok ? (int) $wpdb->insert_id : ( $wp_error ? new WP_Error( 'insert_failed', 'Insert failed' ) : 0 );
+ }
+}
+
+if ( ! function_exists( 'wp_insert_term' ) ) {
+ function wp_insert_term( string $term, string $taxonomy, array $args = [] ) {
+ global $wpdb;
+ $slug = $args['slug'] ?? sanitize_title( $term );
+ $ok = $wpdb->insert( $wpdb->terms, [ 'name' => $term, 'slug' => $slug, 'term_group' => 0 ] );
+ if ( ! $ok ) { return new WP_Error( 'insert_failed', 'terms insert failed' ); }
+ $term_id = (int) $wpdb->insert_id;
+ $wpdb->insert( $wpdb->prefix . 'term_taxonomy', [ 'term_id' => $term_id, 'taxonomy' => $taxonomy, 'description' => '', 'parent' => 0, 'count' => 0 ] );
+ return [ 'term_id' => $term_id, 'term_taxonomy_id' => (int) $wpdb->insert_id ];
+ }
+}
+
+if ( ! function_exists( 'wp_insert_comment' ) ) {
+ function wp_insert_comment( array $data ) {
+ global $wpdb;
+ $ok = $wpdb->insert( $wpdb->comments, [
+ 'comment_post_ID' => (int) ( $data['comment_post_ID'] ?? 0 ),
+ 'comment_author' => (string) ( $data['comment_author'] ?? '' ),
+ 'comment_author_email' => (string) ( $data['comment_author_email'] ?? '' ),
+ 'comment_author_url' => (string) ( $data['comment_author_url'] ?? '' ),
+ 'comment_author_IP' => (string) ( $data['comment_author_IP'] ?? '127.0.0.1' ),
+ 'comment_date' => (string) ( $data['comment_date'] ?? gmdate( 'Y-m-d H:i:s' ) ),
+ 'comment_date_gmt' => (string) ( $data['comment_date_gmt'] ?? gmdate( 'Y-m-d H:i:s' ) ),
+ 'comment_content' => (string) ( $data['comment_content'] ?? '' ),
+ 'comment_karma' => (int) ( $data['comment_karma'] ?? 0 ),
+ 'comment_approved' => (string) ( $data['comment_approved'] ?? '1' ),
+ 'comment_agent' => (string) ( $data['comment_agent'] ?? '' ),
+ 'comment_type' => (string) ( $data['comment_type'] ?? 'comment' ),
+ 'comment_parent' => (int) ( $data['comment_parent'] ?? 0 ),
+ 'user_id' => (int) ( $data['user_id'] ?? 0 ),
+ ] );
+ if ( ! $ok ) { return false; }
+ return (int) $wpdb->insert_id;
+ }
+}
+
+if ( ! class_exists( 'WP_CLI' ) ) {
+ class WP_CLI {
+ public static function log( string $msg ): void {}
+ public static function warning( string $msg ): void {}
+ public static function success( string $msg ): void {}
+ public static function error( string $msg ): void {}
+ public static function add_command( string $name, $class ): void {}
+ }
+}
+
+// ── Load plugin classes ────────────────────────────────────────────────────
+
+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/interceptors/class-tmdo-interceptor-base.php';
+require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php';
+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';
+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';
+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';
+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';
+require_once TMDO_PATH . 'includes/class-tmdo-cache-layer.php';
+require_once TMDO_PATH . 'includes/class-tmdo-zone-classifier.php';
+require_once TMDO_PATH . 'includes/class-tmdo-api.php';
+require_once TMDO_PATH . 'includes/class-tmdo-v2-upgrader.php';
+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';
+require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
+require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
+require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
+require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
+require_once TMDO_PATH . 'includes/engine/class-tmdo-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/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 . 'includes/migration/class-tmdo-migration-orchestrator.php';
+require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
+require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.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';
+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/class-tmdo-core.php';
+
+// Back-compat aliases (WPDO_* → TMDO_*).
+require_once TMDO_PATH . 'includes/class-tmdo-back-compat.php';
+
+// FSM Guard bypass for integration tests (real DB transitions should work).
+if ( ! function_exists( '__return_true' ) ) {
+ function __return_true(): bool { return true; }
+}
+add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
+
+// TMDO_Listing_Stats moved to HP AddOn; stub here for tests that reference it.
+if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
+ class TMDO_Listing_Stats {
+ public static function register(): void {}
+ public static function get_view_count( int $post_id ): int { return 0; }
+ public static function increment_view( int $post_id, string $ip = '' ): int { return 0; }
+ public static function is_rate_limited( int $post_id, string $ip ): bool { return false; }
+ public static function flush_views_to_postmeta(): int { return 0; }
+ }
+ class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
+}
diff --git a/tests/unit/Advisor/FSMAdvisorTest.php b/tests/unit/Advisor/FSMAdvisorTest.php
new file mode 100644
index 0000000..0097811
--- /dev/null
+++ b/tests/unit/Advisor/FSMAdvisorTest.php
@@ -0,0 +1,140 @@
+setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+ public string $postmeta = 'wp_postmeta';
+ public string $options = 'wp_options';
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) {
+ return (string) ( $args[ $i++ ] ?? '?' );
+ }, $sql );
+ }
+ public function get_var( string $sql ) { return '0'; } // shadow_diffs table absent
+ public function get_results( string $sql, $output = ARRAY_A ): array { return array(); }
+ };
+ }
+
+ private function set_module_state( string $module, string $state, ?int $days_ago = null ): void {
+ // Set FSM state.
+ $flags = (array) get_option( 'wpdo_features', array() );
+ $flags[ $module ] = $state;
+ update_option( 'wpdo_features', $flags, false );
+
+ // Reset Feature_Flags request cache via reflection.
+ $ref = new ReflectionClass( WPDO_Feature_Flags::class );
+ $prop = $ref->getProperty( 'cache' );
+ $prop->setAccessible( true );
+ $prop->setValue( null, null );
+
+ if ( null !== $days_ago ) {
+ $entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
+ $entered[ $module ] = array(
+ 'state' => $state,
+ 'entered_at' => gmdate( 'Y-m-d H:i:s', time() - $days_ago * 86400 ),
+ );
+ update_option( 'wpdo_fsm_state_entered', $entered, false );
+ }
+ }
+
+ public function test_idle_state_returns_HOLD(): void {
+ $this->set_module_state( 'reviews', 'idle' );
+ $advice = WPDO_FSM_Advisor::advise( 'reviews' );
+ $this->assertSame( 'HOLD', $advice['action'] );
+ $this->assertSame( 'info', $advice['level'] );
+ }
+
+ public function test_complete_state_returns_HOLD(): void {
+ $this->set_module_state( 'reviews', 'complete' );
+ $advice = WPDO_FSM_Advisor::advise( 'reviews' );
+ $this->assertSame( 'HOLD', $advice['action'] );
+ }
+
+ public function test_dual_write_with_short_soak_returns_WAIT(): void {
+ // In dual_write 0 days < 1 day min soak.
+ $this->set_module_state( 'reviews', 'dual_write', 0 );
+ $advice = WPDO_FSM_Advisor::advise( 'reviews' );
+ $this->assertSame( 'WAIT', $advice['action'] );
+ $this->assertGreaterThanOrEqual( 1, $advice['days_remaining'] );
+ }
+
+ public function test_dual_write_after_min_soak_returns_PROMOTE(): void {
+ $this->set_module_state( 'reviews', 'dual_write', 2 );
+ $advice = WPDO_FSM_Advisor::advise( 'reviews' );
+ $this->assertSame( 'PROMOTE', $advice['action'] );
+ $this->assertSame( 'backfill', $advice['next_state'] );
+ }
+
+ public function test_verify_with_long_soak_returns_PROMOTE(): void {
+ // verify needs 7 days; 10 days = should promote.
+ $this->set_module_state( 'reviews', 'verify', 10 );
+ $advice = WPDO_FSM_Advisor::advise( 'reviews' );
+ $this->assertSame( 'PROMOTE', $advice['action'] );
+ $this->assertSame( 'cutover', $advice['next_state'] );
+ }
+
+ public function test_verify_with_short_soak_returns_WAIT(): void {
+ $this->set_module_state( 'reviews', 'verify', 3 );
+ $advice = WPDO_FSM_Advisor::advise( 'reviews' );
+ $this->assertSame( 'WAIT', $advice['action'] );
+ $this->assertSame( 4, $advice['days_remaining'] );
+ }
+
+ public function test_cleanup_with_short_wash_returns_WAIT(): void {
+ $this->set_module_state( 'reviews', 'cleanup', 1 );
+ $advice = WPDO_FSM_Advisor::advise( 'reviews' );
+ $this->assertSame( 'WAIT', $advice['action'] );
+ $this->assertSame( 2, $advice['days_remaining'] );
+ }
+
+ public function test_cleanup_after_wash_returns_PROMOTE(): void {
+ $this->set_module_state( 'reviews', 'cleanup', 5 );
+ $advice = WPDO_FSM_Advisor::advise( 'reviews' );
+ $this->assertSame( 'PROMOTE', $advice['action'] );
+ $this->assertSame( 'complete', $advice['next_state'] );
+ }
+
+ public function test_advice_metrics_include_state_and_soak(): void {
+ $this->set_module_state( 'reviews', 'dual_write', 5 );
+ $advice = WPDO_FSM_Advisor::advise( 'reviews' );
+ $this->assertSame( 'dual_write', $advice['metrics']['state'] );
+ $this->assertSame( 5, $advice['metrics']['days_in_state'] );
+ $this->assertSame( 1, $advice['metrics']['min_soak_days'] );
+ }
+
+ public function test_advise_all_returns_map_for_all_modules(): void {
+ $advice = WPDO_FSM_Advisor::advise_all();
+ $this->assertNotEmpty( $advice );
+ // All known HPCT + zone modules should be present (default idle).
+ foreach ( WPDO_Feature_Flags::HPCT_MODULES as $m ) {
+ $this->assertArrayHasKey( $m, $advice );
+ }
+ }
+}
diff --git a/tests/unit/Advisor/FSMAutomatorTest.php b/tests/unit/Advisor/FSMAutomatorTest.php
new file mode 100644
index 0000000..ace3167
--- /dev/null
+++ b/tests/unit/Advisor/FSMAutomatorTest.php
@@ -0,0 +1,119 @@
+getProperty( 'cache' );
+ $prop->setAccessible( true );
+ $prop->setValue( null, null );
+ $this->setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+ public string $postmeta = 'wp_postmeta';
+ public string $options = 'wp_options';
+ public function prepare( string $sql, ...$args ): string { return $sql; }
+ public function get_var( string $sql ) { return '0'; }
+ public function get_results( string $sql, $output = ARRAY_A ): array { return array(); }
+ };
+ }
+
+ public function test_default_disabled(): void {
+ $this->assertFalse( WPDO_FSM_Automator::is_enabled() );
+ }
+
+ public function test_run_returns_disabled_when_off(): void {
+ $result = WPDO_FSM_Automator::run();
+ $this->assertFalse( $result['ok'] );
+ $this->assertContains( 'automator disabled', $result['errors'] );
+ }
+
+ public function test_run_blocked_by_critical_cool_off(): void {
+ // Enable + simulate critical alert in last run.
+ update_option( 'wpdo_automator_enabled', '1', false );
+ update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array(
+ 'critical_count' => 2,
+ 'ran_at' => gmdate( 'Y-m-d H:i:s' ),
+ ), false );
+
+ $result = WPDO_FSM_Automator::run();
+ $this->assertFalse( $result['ok'] );
+ $this->assertStringContainsString( 'cool-off', $result['errors'][0] );
+ }
+
+ public function test_run_proceeds_when_no_critical(): void {
+ update_option( 'wpdo_automator_enabled', '1', false );
+ update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array(
+ 'critical_count' => 0,
+ 'ran_at' => gmdate( 'Y-m-d H:i:s' ),
+ ), false );
+
+ $result = WPDO_FSM_Automator::run();
+ $this->assertTrue( $result['ok'] );
+ // Most modules will be in idle and Advisor recommends WAIT (soak time);
+ // expected that 0 actions are executed in baseline test.
+ $this->assertGreaterThanOrEqual( 0, $result['executed'] );
+ }
+
+ public function test_blacklist_skips_module(): void {
+ update_option( 'wpdo_automator_enabled', '1', false );
+ update_option( 'wpdo_automator_blacklist', array( 'reviews', 'wc_orders' ), false );
+ update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array(
+ 'critical_count' => 0,
+ 'ran_at' => gmdate( 'Y-m-d H:i:s' ),
+ ), false );
+ $blacklist = WPDO_FSM_Automator::blacklist();
+ $this->assertContains( 'reviews', $blacklist );
+ $this->assertContains( 'wc_orders', $blacklist );
+
+ $result = WPDO_FSM_Automator::run();
+ $this->assertTrue( $result['ok'] );
+ // Skipped at least the 2 blacklisted ones.
+ $this->assertGreaterThanOrEqual( 2, $result['skipped'] );
+ }
+
+ public function test_forbidden_transitions_constant(): void {
+ $ref = new ReflectionClass( WPDO_FSM_Automator::class );
+ $constant = $ref->getReflectionConstant( 'FORBIDDEN_TRANSITIONS' );
+ $this->assertNotNull( $constant );
+ $value = $constant->getValue();
+ $this->assertContains( array( 'verify', 'cutover' ), $value );
+ $this->assertContains( array( 'cutover', 'cleanup' ), $value );
+ $this->assertContains( array( 'cleanup', 'complete' ), $value );
+ }
+
+ public function test_options_keys_match_admin_form(): void {
+ $this->assertSame( 'wpdo_automator_enabled', WPDO_FSM_Automator::OPT_ENABLED );
+ $this->assertSame( 'wpdo_automator_blacklist', WPDO_FSM_Automator::OPT_BLACKLIST );
+ $this->assertSame( 'wpdo_automator_last_action', WPDO_FSM_Automator::OPT_LAST_ACTION );
+ }
+
+ public function test_last_actions_returns_array(): void {
+ $this->assertSame( array(), WPDO_FSM_Automator::last_actions() );
+ update_option( 'wpdo_automator_last_action', array( 'reviews' => '2026-04-28 04:30:00' ), false );
+ $last = WPDO_FSM_Automator::last_actions();
+ $this->assertSame( '2026-04-28 04:30:00', $last['reviews'] );
+ }
+}
diff --git a/tests/unit/Advisor/ModuleDetectorTest.php b/tests/unit/Advisor/ModuleDetectorTest.php
new file mode 100644
index 0000000..6be0b0c
--- /dev/null
+++ b/tests/unit/Advisor/ModuleDetectorTest.php
@@ -0,0 +1,160 @@
+getProperty( 'cache' );
+ $prop->setAccessible( true );
+ $prop->setValue( null, null );
+ $this->setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+ public string $posts = 'wp_posts';
+ public string $postmeta = 'wp_postmeta';
+ public string $options = 'wp_options';
+ public array $count_overrides = array();
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) {
+ return is_string( $args[ $i ] ?? null ) ? "'" . $args[ $i++ ] . "'" : (string) ( $args[ $i++ ] ?? '?' );
+ }, $sql );
+ }
+ public function get_var( string $sql ) {
+ // Mock by inspecting SQL pattern.
+ if ( str_contains( $sql, "post_type =" ) ) {
+ if ( preg_match( "/post_type = '([^']+)'/", $sql, $m ) ) {
+ return (string) ( $this->count_overrides[ $m[1] ] ?? 0 );
+ }
+ }
+ if ( str_contains( $sql, "post_status = 'trash'" ) ) {
+ return (string) ( $this->count_overrides['__trash__'] ?? 0 );
+ }
+ return '0';
+ }
+ public function get_results( string $sql, $output = ARRAY_A ): array { return array(); }
+ };
+ }
+
+ public function test_module_rules_known_modules_count(): void {
+ $modules = WPDO_Module_Rules::known_modules();
+ $this->assertGreaterThanOrEqual( 14, count( $modules ), '預期 ≥ 14 個 module(9 HPCT + 6 zone - 1 dup)' );
+ $this->assertContains( 'reviews', $modules );
+ $this->assertContains( 'warm', $modules );
+ $this->assertContains( 'archive', $modules );
+ $this->assertContains( 'hot_hp_listing', $modules );
+ }
+
+ public function test_module_rule_for_module_returns_array(): void {
+ $rule = WPDO_Module_Rules::for_module( 'reviews' );
+ $this->assertIsArray( $rule );
+ $this->assertArrayHasKey( 'compat_required', $rule );
+ $this->assertArrayHasKey( 'description', $rule );
+ }
+
+ public function test_module_rule_unknown_module_returns_null(): void {
+ $this->assertNull( WPDO_Module_Rules::for_module( 'totally_fake_module_xyz' ) );
+ }
+
+ public function test_detector_skip_when_module_not_idle(): void {
+ // Set reviews to dual_write.
+ WPDO_Feature_Flags::set( 'reviews', 'dual_write' );
+ $r = WPDO_Module_Detector::detect_one( 'reviews' );
+ $this->assertSame( 'skip', $r['recommendation'] );
+ $this->assertFalse( $r['available'] );
+ $this->assertNotEmpty( $r['blockers'] );
+ $this->assertSame( 'dual_write', $r['current_state'] );
+ }
+
+ public function test_detector_warm_module_recommends_for_any_site(): void {
+ // warm module's compat_required is empty + no post_type.
+ $r = WPDO_Module_Detector::detect_one( 'warm' );
+ $this->assertTrue( $r['available'] );
+ $this->assertSame( 'enable', $r['recommendation'] );
+ $this->assertGreaterThan( 0, $r['confidence'] );
+ $this->assertNotEmpty( $r['suggested_action'] );
+ $this->assertSame( 'dual_write', $r['suggested_action']['to_state'] );
+ }
+
+ public function test_detector_archive_blocked_when_no_trashed_posts(): void {
+ // trashed = 0, threshold = 50.
+ global $wpdb;
+ $wpdb->count_overrides['__trash__'] = 0;
+ $r = WPDO_Module_Detector::detect_one( 'archive' );
+ $this->assertSame( 'wait', $r['recommendation'] );
+ $this->assertFalse( $r['available'] );
+ }
+
+ public function test_detector_archive_passes_when_enough_trashed(): void {
+ global $wpdb;
+ $wpdb->count_overrides['__trash__'] = 100;
+ $r = WPDO_Module_Detector::detect_one( 'archive' );
+ $this->assertSame( 'enable', $r['recommendation'] );
+ $this->assertTrue( $r['available'] );
+ }
+
+ public function test_detector_hivepress_required_blocks_when_inactive(): void {
+ // reviews requires hivepress; Compatibility class isn't loaded in this test.
+ // Without Compatibility class, the helper returns the full required list as missing.
+ $r = WPDO_Module_Detector::detect_one( 'reviews' );
+ $this->assertSame( 'skip', $r['recommendation'] );
+ $this->assertNotEmpty( $r['blockers'] );
+ }
+
+ public function test_get_actionable_filters_by_confidence(): void {
+ $actionable = WPDO_Module_Detector::get_actionable( 0.5 );
+ $this->assertIsArray( $actionable );
+ // Each result should have available=true and recommendation=enable.
+ foreach ( $actionable as $module => $r ) {
+ $this->assertTrue( $r['available'], "$module should be available" );
+ $this->assertSame( 'enable', $r['recommendation'] );
+ $this->assertGreaterThanOrEqual( 0.5, $r['confidence'] );
+ }
+ }
+
+ public function test_get_actionable_sorts_by_confidence_desc(): void {
+ $actionable = WPDO_Module_Detector::get_actionable( 0.0 );
+ $last_confidence = 1.0;
+ foreach ( $actionable as $r ) {
+ $this->assertLessThanOrEqual( $last_confidence, (float) $r['confidence'] );
+ $last_confidence = (float) $r['confidence'];
+ }
+ }
+
+ public function test_detect_all_returns_entry_for_each_known_module(): void {
+ $all = WPDO_Module_Detector::detect_all( true );
+ $known = WPDO_Module_Rules::known_modules();
+ foreach ( $known as $m ) {
+ $this->assertArrayHasKey( $m, $all, "Detector should return entry for $m" );
+ }
+ }
+}
diff --git a/tests/unit/BackCompatTest.php b/tests/unit/BackCompatTest.php
new file mode 100644
index 0000000..bfabcc1
--- /dev/null
+++ b/tests/unit/BackCompatTest.php
@@ -0,0 +1,55 @@
+assertTrue( trait_exists( 'WPDO_Anti_EAV_Aware' ), 'WPDO_Anti_EAV_Aware trait alias must exist' );
+ }
+
+ public function test_class_using_wpdo_anti_eav_aware_is_valid(): void {
+ // Verify a class can `use WPDO_Anti_EAV_Aware` without fatal error.
+ $obj = new class {
+ use WPDO_Anti_EAV_Aware;
+ };
+ // trait_exists check via class_uses — PHP doesn't support instanceof for traits.
+ $this->assertArrayHasKey( 'WPDO_Anti_EAV_Aware', class_uses( $obj ) );
+ }
+
+ // ── Interface alias ───────────────────────────────────────────────────────
+
+ public function test_wpdo_entity_adapter_interface_exists(): void {
+ $this->assertTrue(
+ interface_exists( 'WPDO_Entity_Adapter_Interface' ),
+ 'WPDO_Entity_Adapter_Interface must exist as a back-compat alias'
+ );
+ }
+
+ public function test_tmdo_adapter_post_implements_wpdo_interface(): void {
+ // An object implementing TMDO_Entity_Adapter_Interface must also
+ // satisfy `instanceof WPDO_Entity_Adapter_Interface`.
+ $adapter = new TMDO_Adapter_Post();
+ $this->assertInstanceOf( 'WPDO_Entity_Adapter_Interface', $adapter );
+ }
+
+ // ── DB version consistency ────────────────────────────────────────────────
+
+ public function test_tmdo_db_version_constant_matches_installer_schema(): void {
+ // TMDO_DB_VERSION (plugin header constant) must equal TMDO_Installer::SCHEMA_VERSION
+ // (private). If they diverge, maybe_upgrade() either never fires or fires every boot.
+ $ref = new ReflectionClass( TMDO_Installer::class );
+ $schema_version = $ref->getConstant( 'SCHEMA_VERSION' );
+ $this->assertSame( TMDO_DB_VERSION, $schema_version,
+ 'TMDO_DB_VERSION constant must match TMDO_Installer::SCHEMA_VERSION' );
+ }
+}
diff --git a/tests/unit/CacheLayerTest.php b/tests/unit/CacheLayerTest.php
new file mode 100644
index 0000000..e76518a
--- /dev/null
+++ b/tests/unit/CacheLayerTest.php
@@ -0,0 +1,158 @@
+getProperty( 'instance' )->setValue( null, null );
+
+ $this->setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
+ $val = $args[ $i++ ] ?? '';
+ return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
+ }, $sql );
+ }
+
+ public function get_var( string $sql ): ?string { return null; }
+ public function get_row( string $sql, $output = OBJECT ) { return null; }
+ public function insert( string $table, array $data, $format = null ): int|false { return 1; }
+ public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; }
+ public function delete( string $table, array $where, $format = null ): int|false { return 1; }
+ public function query( string $sql ): int|bool { return 1; }
+
+ public function get_results( string $sql, $output = OBJECT ): array {
+ return CacheLayerTest::$db_rows;
+ }
+ };
+ }
+
+ // ── Helper: register cold field ──────────────────────────────────────────
+
+ private function register_cold_field( string $post_type = 'hp_listing', string $meta_key = 'hp_description' ): void {
+ WPDO_Schema_Registry::instance()->register( 'test', [
+ 'post_type' => $post_type,
+ 'meta_key' => $meta_key,
+ 'zone' => 'cold',
+ ] );
+ }
+
+ // ── prefetch() ───────────────────────────────────────────────────────────
+
+ public function test_prefetch_returns_early_for_empty_post_ids(): void {
+ $this->register_cold_field();
+ WPDO_Cache_Layer::prefetch( [], 'hp_listing' );
+ $this->assertEmpty( $GLOBALS['_wp_cache'] );
+ }
+
+ public function test_prefetch_returns_early_when_no_cold_keys_for_type(): void {
+ // 'unknown_type' has no registered cold fields.
+ WPDO_Cache_Layer::prefetch( [ 1, 2, 3 ], 'unknown_type' );
+ $this->assertEmpty( $GLOBALS['_wp_cache'] );
+ }
+
+ public function test_prefetch_skips_already_cached_post_ids(): void {
+ $this->register_cold_field();
+ $group = 'wpdo_cold_hp_listing';
+
+ // Pre-warm cache for post 1.
+ $GLOBALS['_wp_cache'][ $group ]['cold_1'] = [ 'hp_description' => 'cached' ];
+
+ // DB returns no extra rows — all were already cached.
+ self::$db_rows = [];
+ WPDO_Cache_Layer::prefetch( [ 1 ], 'hp_listing' );
+
+ // Cache should remain unchanged (no new entry written).
+ $this->assertSame( [ 'hp_description' => 'cached' ], $GLOBALS['_wp_cache'][ $group ]['cold_1'] );
+ }
+
+ public function test_prefetch_stores_fetched_data_in_cache(): void {
+ $this->register_cold_field();
+ self::$db_rows = [
+ [ 'post_id' => '5', 'data' => json_encode( [ 'hp_description' => 'Fetched!' ] ) ],
+ ];
+
+ WPDO_Cache_Layer::prefetch( [ 5 ], 'hp_listing' );
+
+ $group = 'wpdo_cold_hp_listing';
+ $cached = $GLOBALS['_wp_cache'][ $group ]['cold_5'] ?? false;
+ $this->assertIsArray( $cached );
+ $this->assertSame( 'Fetched!', $cached['hp_description'] );
+ }
+
+ public function test_prefetch_stores_empty_array_for_post_with_no_cold_row(): void {
+ $this->register_cold_field();
+ // DB returns nothing for post 7.
+ self::$db_rows = [];
+
+ WPDO_Cache_Layer::prefetch( [ 7 ], 'hp_listing' );
+
+ $group = 'wpdo_cold_hp_listing';
+ $cached = $GLOBALS['_wp_cache'][ $group ]['cold_7'] ?? 'NOT_SET';
+ $this->assertSame( [], $cached );
+ }
+
+ // ── warm_post() ──────────────────────────────────────────────────────────
+
+ public function test_warm_post_returns_early_when_no_cold_keys(): void {
+ // 'no_cold_type' has no cold fields → warm_post returns immediately.
+ WPDO_Cache_Layer::warm_post( 1, 'no_cold_type' );
+ $this->assertEmpty( $GLOBALS['_wp_cache'] );
+ }
+
+ public function test_warm_post_clears_stale_cache_entry(): void {
+ $this->register_cold_field();
+ $group = 'wpdo_cold_hp_listing';
+ $cache_key = 'cold_20';
+
+ // Pre-populate with stale data.
+ $GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'stale' => true ];
+
+ WPDO_Cache_Layer::warm_post( 20, 'hp_listing' );
+
+ // Stale cache must be replaced (warm_post deletes then re-reads from DB,
+ // which returns null in mock, yielding empty array).
+ $this->assertNotSame( [ 'stale' => true ], $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ?? null );
+ }
+
+ // ── get_stats() ──────────────────────────────────────────────────────────
+
+ public function test_get_stats_includes_required_keys(): void {
+ $stats = WPDO_Cache_Layer::get_stats();
+
+ $this->assertArrayHasKey( 'groups', $stats );
+ $this->assertArrayHasKey( 'prefetch_support', $stats );
+ $this->assertArrayHasKey( 'flush_support', $stats );
+ }
+
+ public function test_get_stats_groups_reflect_registered_cold_types(): void {
+ $this->register_cold_field( 'hp_listing', 'hp_description' );
+ $this->register_cold_field( 'hp_listing', 'hp_website' );
+
+ $stats = WPDO_Cache_Layer::get_stats();
+ $post_types = array_column( $stats['groups'], 'post_type' );
+ $this->assertContains( 'hp_listing', $post_types );
+ }
+}
diff --git a/tests/unit/CapabilityTest.php b/tests/unit/CapabilityTest.php
new file mode 100644
index 0000000..ec92cb1
--- /dev/null
+++ b/tests/unit/CapabilityTest.php
@@ -0,0 +1,92 @@
+ true );
+
+ $this->assertTrue( WPDO_Capability::current_user_can_admin() );
+ }
+
+ public function test_single_site_subscriber_blocked(): void {
+ $GLOBALS['_wp_is_multisite'] = false;
+ $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
+
+ $this->assertFalse( WPDO_Capability::current_user_can_admin() );
+ }
+
+ // ── Multisite per-site behaviour ─────────────────────────────────────────
+
+ public function test_multisite_site_admin_passes_with_manage_options(): void {
+ $GLOBALS['_wp_is_multisite'] = true;
+ $GLOBALS['_wp_is_super_admin'] = false;
+ $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
+
+ $this->assertTrue( WPDO_Capability::current_user_can_admin() );
+ }
+
+ public function test_multisite_subscriber_blocked(): void {
+ $GLOBALS['_wp_is_multisite'] = true;
+ $GLOBALS['_wp_is_super_admin'] = false;
+ $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
+
+ $this->assertFalse( WPDO_Capability::current_user_can_admin() );
+ }
+
+ // ── Multisite super-admin behaviour ──────────────────────────────────────
+
+ public function test_multisite_super_admin_passes_without_manage_options(): void {
+ // Pre-v2.14.0 this returned false because super admins don't auto-have
+ // `manage_options` in network admin context. v2.14.0 fixes this.
+ $GLOBALS['_wp_is_multisite'] = true;
+ $GLOBALS['_wp_is_super_admin'] = true;
+ $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
+
+ $this->assertTrue( WPDO_Capability::current_user_can_admin() );
+ }
+
+ public function test_multisite_super_admin_passes_with_manage_options(): void {
+ $GLOBALS['_wp_is_multisite'] = true;
+ $GLOBALS['_wp_is_super_admin'] = true;
+ $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
+
+ $this->assertTrue( WPDO_Capability::current_user_can_admin() );
+ }
+
+ // ── Edge: super admin flag set on single-site (shouldn't be possible
+ // but should be defensive) ────────────────────────────────────────
+
+ public function test_super_admin_flag_ignored_on_single_site(): void {
+ // Super admin only exists on multisite; on single-site fall back to
+ // manage_options check.
+ $GLOBALS['_wp_is_multisite'] = false;
+ $GLOBALS['_wp_is_super_admin'] = true;
+ $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
+
+ $this->assertFalse( WPDO_Capability::current_user_can_admin() );
+ }
+}
diff --git a/tests/unit/CliLintTest.php b/tests/unit/CliLintTest.php
new file mode 100644
index 0000000..910b4e4
--- /dev/null
+++ b/tests/unit/CliLintTest.php
@@ -0,0 +1,139 @@
+fixture_dir = sys_get_temp_dir() . '/wpdo-lint-fixture-' . uniqid();
+ mkdir( $this->fixture_dir, 0777, true );
+ }
+
+ protected function tearDown(): void {
+ // Recursive rmdir.
+ $this->rrmdir( $this->fixture_dir );
+ }
+
+ private function rrmdir( string $dir ): void {
+ if ( ! is_dir( $dir ) ) {
+ return;
+ }
+ foreach ( scandir( $dir ) as $f ) {
+ if ( '.' === $f || '..' === $f ) {
+ continue;
+ }
+ $path = $dir . '/' . $f;
+ is_dir( $path ) ? $this->rrmdir( $path ) : unlink( $path );
+ }
+ rmdir( $dir );
+ }
+
+ private function write( string $relative, string $content ): void {
+ $path = $this->fixture_dir . '/' . $relative;
+ $dir = dirname( $path );
+ if ( ! is_dir( $dir ) ) {
+ mkdir( $dir, 0777, true );
+ }
+ file_put_contents( $path, $content );
+ }
+
+ // ── happy path ──────────────────────────────────────────────────────────
+
+ public function test_clean_plugin_passes_lint(): void {
+ $this->write( 'main.php', "fixture_dir );
+ $this->assertEmpty( $findings );
+ }
+
+ // ── direct postmeta SELECT ─────────────────────────────────────────────
+
+ public function test_direct_postmeta_select_flagged(): void {
+ $this->write(
+ 'bad.php',
+ "get_results( \"SELECT meta_value FROM {\$wpdb->prefix}postmeta WHERE meta_key='foo'\" );\n"
+ );
+ $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
+ $this->assertGreaterThanOrEqual( 1, count( $findings ) );
+ $this->assertSame( 'no-direct-postmeta-select', $findings[0]['rule'] );
+ }
+
+ public function test_direct_usermeta_select_flagged(): void {
+ $this->write(
+ 'user.php',
+ "get_var( \"SELECT meta_value FROM wp_usermeta WHERE meta_key='foo'\" );\n"
+ );
+ $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
+ $this->assertGreaterThanOrEqual( 1, count( $findings ) );
+ $this->assertSame( 'no-direct-usermeta-select', $findings[0]['rule'] );
+ }
+
+ // ── autoload=yes ───────────────────────────────────────────────────────
+
+ public function test_autoload_yes_flagged(): void {
+ $this->write(
+ 'opt.php',
+ " 'yes' );\n"
+ );
+ $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
+ $autoload_findings = array_filter( $findings, static fn( $f ) => 'autoload-yes' === $f['rule'] );
+ $this->assertGreaterThanOrEqual( 1, count( $autoload_findings ) );
+ }
+
+ // ── ignore comment ─────────────────────────────────────────────────────
+
+ public function test_phpcs_ignore_comment_skips_finding(): void {
+ $this->write(
+ 'fallback.php',
+ "get_results( \"SELECT meta_value FROM wp_postmeta WHERE meta_key='x'\" );\n"
+ );
+ $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
+ $this->assertEmpty( $findings, 'phpcs:ignore WPDO.AntiEAV must suppress findings' );
+ }
+
+ // ── skip dirs ──────────────────────────────────────────────────────────
+
+ public function test_skips_vendor_and_node_modules(): void {
+ // Bad code inside vendor/ MUST be ignored.
+ $this->write(
+ 'vendor/lib/bad.php',
+ "get_results( \"SELECT meta_value FROM wp_postmeta\" );\n"
+ );
+ $this->write(
+ 'node_modules/foo/bad.php',
+ "get_results( \"SELECT meta_value FROM wp_usermeta\" );\n"
+ );
+ $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
+ $this->assertEmpty( $findings );
+ }
+
+ // ── reports file + line ────────────────────────────────────────────────
+
+ public function test_finding_includes_file_and_line(): void {
+ $this->write(
+ 'multi.php',
+ "get_var( \"SELECT meta_value FROM wp_postmeta\" );\n"
+ );
+ $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
+ $this->assertNotEmpty( $findings );
+ $this->assertStringEndsWith( 'multi.php', $findings[0]['file'] );
+ $this->assertSame( 4, $findings[0]['line'] );
+ }
+}
diff --git a/tests/unit/ConflictMonitorTest.php b/tests/unit/ConflictMonitorTest.php
new file mode 100644
index 0000000..3b7826b
--- /dev/null
+++ b/tests/unit/ConflictMonitorTest.php
@@ -0,0 +1,76 @@
+assertIsArray( $result );
+ $this->assertEmpty( $result );
+ }
+
+ public function test_get_summary_when_clean(): void {
+ $summary = WPDO_Conflict_Monitor::get_summary();
+ $this->assertSame( 0, $summary['total'] );
+ $this->assertSame( 0, $summary['hook_overlap'] );
+ $this->assertSame( 0, $summary['uaepg_overlap'] );
+ }
+
+ public function test_get_all_conflicts_lazy_scans(): void {
+ // First call populates the cache.
+ $first = WPDO_Conflict_Monitor::get_all_conflicts();
+ $this->assertIsArray( $first );
+
+ // Subsequent calls return the same reference (cached).
+ $second = WPDO_Conflict_Monitor::get_all_conflicts();
+ $this->assertSame( $first, $second );
+ }
+
+ public function test_reset_cache_forces_rescan(): void {
+ WPDO_Conflict_Monitor::scan();
+ WPDO_Conflict_Monitor::reset_cache();
+
+ // Should not throw and should return empty (still no conflicts).
+ $this->assertSame( array(), WPDO_Conflict_Monitor::scan() );
+ }
+
+ public function test_summary_keys_always_present(): void {
+ $summary = WPDO_Conflict_Monitor::get_summary();
+ $this->assertArrayHasKey( 'total', $summary );
+ $this->assertArrayHasKey( 'hook_overlap', $summary );
+ $this->assertArrayHasKey( 'uaepg_overlap', $summary );
+ }
+
+ public function test_admin_notice_is_silent_when_no_conflicts(): void {
+ ob_start();
+ WPDO_Conflict_Monitor::maybe_render_admin_notice();
+ $output = ob_get_clean();
+ $this->assertSame( '', $output );
+ }
+
+ public function test_admin_bar_is_silent_when_no_conflicts(): void {
+ // Pass a valid object stub to admin_bar handler — should no-op when count = 0.
+ $stub = new class() {
+ public array $nodes = array();
+ public function add_node( array $node ): void {
+ $this->nodes[] = $node;
+ }
+ };
+ WPDO_Conflict_Monitor::maybe_render_admin_bar( $stub );
+ $this->assertCount( 0, $stub->nodes );
+ }
+}
diff --git a/tests/unit/CryptoV2Test.php b/tests/unit/CryptoV2Test.php
new file mode 100644
index 0000000..bdd3605
--- /dev/null
+++ b/tests/unit/CryptoV2Test.php
@@ -0,0 +1,197 @@
+assertStringStartsWith( WPDO_Crypto::PREFIX_V2, $encrypted );
+ $this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
+ }
+
+ public function test_v2_round_trip_unicode(): void {
+ $plain = '中文密碼 + emoji 🔐 + special chars !@#$%^&*()';
+ $encrypted = WPDO_Crypto::encrypt( $plain );
+
+ $this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
+ }
+
+ public function test_v2_round_trip_long_string(): void {
+ $plain = str_repeat( 'A', 4096 );
+ $encrypted = WPDO_Crypto::encrypt( $plain );
+
+ $this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
+ }
+
+ public function test_v2_each_encryption_produces_unique_ciphertext(): void {
+ // Random IV → repeated encrypts of the same plaintext yield different blobs.
+ $plain = 'identical plaintext';
+ $ct1 = WPDO_Crypto::encrypt( $plain );
+ $ct2 = WPDO_Crypto::encrypt( $plain );
+
+ $this->assertNotSame( $ct1, $ct2, 'IV randomness should produce unique ciphertexts' );
+ $this->assertSame( $plain, WPDO_Crypto::decrypt( $ct1 ) );
+ $this->assertSame( $plain, WPDO_Crypto::decrypt( $ct2 ) );
+ }
+
+ // ── v2 GCM tamper detection ──────────────────────────────────────────────
+
+ public function test_v2_tampered_ciphertext_returns_original(): void {
+ $plain = 'sensitive webhook url';
+ $encrypted = WPDO_Crypto::encrypt( $plain );
+
+ // Decode the base64 payload, flip the FIRST byte of the GCM auth tag
+ // (which lives at offset 12 right after the IV), re-encode. This
+ // guarantees a real ciphertext modification regardless of base64
+ // alphabet (vs str_replace which can be a no-op for some random IVs).
+ $prefix_len = strlen( WPDO_Crypto::PREFIX_V2 );
+ $encoded = substr( $encrypted, $prefix_len );
+ $raw = base64_decode( $encoded, true );
+ $this->assertNotFalse( $raw, 'Setup precondition: ciphertext must be valid base64' );
+ $raw[12] = chr( ord( $raw[12] ) ^ 0x55 ); // flip 4 bits of the auth tag.
+ $tampered = WPDO_Crypto::PREFIX_V2 . base64_encode( $raw );
+
+ $result = WPDO_Crypto::decrypt( $tampered );
+ $this->assertNotSame( $plain, $result, 'Tampered GCM ciphertext must NOT decrypt to original plaintext' );
+ $this->assertSame( $tampered, $result, 'On auth failure decrypt() must return original blob' );
+ }
+
+ public function test_v2_truncated_blob_safe_failure(): void {
+ $encrypted = WPDO_Crypto::encrypt( 'some value' );
+ // Truncate to less than min size (12 IV + 16 tag + 1 byte ciphertext).
+ $truncated = substr( $encrypted, 0, strlen( WPDO_Crypto::PREFIX_V2 ) + 5 );
+
+ // Should not throw; should return original.
+ $result = WPDO_Crypto::decrypt( $truncated );
+ $this->assertSame( $truncated, $result );
+ }
+
+ // ── v1 CBC backward compat ───────────────────────────────────────────────
+
+ public function test_v1_legacy_blob_decrypts_successfully(): void {
+ // Hand-craft a v1 CBC blob using the same key derivation.
+ $plain = 'legacy webhook url from pre-v2.15';
+ $key = $this->derive_key();
+ $iv = random_bytes( 16 );
+ $ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
+ $blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
+
+ $this->assertSame( $plain, WPDO_Crypto::decrypt( $blob ) );
+ }
+
+ public function test_v1_blob_with_garbage_returns_original(): void {
+ $bad = WPDO_Crypto::PREFIX_V1 . 'not_valid_base64!!!';
+ $this->assertSame( $bad, WPDO_Crypto::decrypt( $bad ) );
+ }
+
+ // ── Plaintext passthrough ────────────────────────────────────────────────
+
+ public function test_plaintext_passthrough(): void {
+ $plain = 'https://example.com/raw';
+ $this->assertSame( $plain, WPDO_Crypto::decrypt( $plain ) );
+ }
+
+ public function test_empty_input(): void {
+ $this->assertSame( '', WPDO_Crypto::encrypt( '' ) );
+ $this->assertSame( '', WPDO_Crypto::decrypt( '' ) );
+ }
+
+ // ── format_version ───────────────────────────────────────────────────────
+
+ public function test_format_version_classification(): void {
+ // Use option-API stubs from bootstrap.
+ $GLOBALS['_wp_options']['test_v2_opt'] = WPDO_Crypto::encrypt( 'foo' );
+ $GLOBALS['_wp_options']['test_plain_opt'] = 'plaintext_value';
+ $GLOBALS['_wp_options']['test_empty_opt'] = '';
+
+ // Hand-craft a v1 blob.
+ $key = $this->derive_key();
+ $iv = random_bytes( 16 );
+ $ct = openssl_encrypt( 'bar', 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
+ $GLOBALS['_wp_options']['test_v1_opt'] = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
+
+ $this->assertSame( 'v2', WPDO_Crypto::format_version( 'test_v2_opt' ) );
+ $this->assertSame( 'v1', WPDO_Crypto::format_version( 'test_v1_opt' ) );
+ $this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'test_plain_opt' ) );
+ $this->assertSame( 'empty', WPDO_Crypto::format_version( 'test_empty_opt' ) );
+ $this->assertSame( 'empty', WPDO_Crypto::format_version( 'nonexistent_opt' ) );
+ }
+
+ // ── migrate_option_v1_to_v2 ──────────────────────────────────────────────
+
+ public function test_migrate_option_v1_to_v2_round_trip(): void {
+ $plain = 'webhook to migrate';
+ $key = $this->derive_key();
+ $iv = random_bytes( 16 );
+ $ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
+ $blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
+
+ $GLOBALS['_wp_options']['migrate_test'] = $blob;
+
+ $result = WPDO_Crypto::migrate_option_v1_to_v2( 'migrate_test' );
+ $this->assertSame( 'migrated', $result );
+
+ // After migration: v2 blob, decrypts to original plaintext.
+ $this->assertSame( 'v2', WPDO_Crypto::format_version( 'migrate_test' ) );
+ $this->assertSame( $plain, WPDO_Crypto::get_option( 'migrate_test' ) );
+ }
+
+ public function test_migrate_option_already_v2_is_noop(): void {
+ $GLOBALS['_wp_options']['already_v2'] = WPDO_Crypto::encrypt( 'foo' );
+ $result = WPDO_Crypto::migrate_option_v1_to_v2( 'already_v2' );
+ $this->assertSame( 'already_v2', $result );
+ }
+
+ public function test_migrate_option_plaintext_skipped(): void {
+ $GLOBALS['_wp_options']['plain_opt'] = 'just plaintext';
+ $result = WPDO_Crypto::migrate_option_v1_to_v2( 'plain_opt' );
+ $this->assertSame( 'plaintext_skipped', $result );
+ // Original value preserved.
+ $this->assertSame( 'just plaintext', $GLOBALS['_wp_options']['plain_opt'] );
+ }
+
+ public function test_migrate_option_empty_returns_empty(): void {
+ $GLOBALS['_wp_options']['empty_opt'] = '';
+ $result = WPDO_Crypto::migrate_option_v1_to_v2( 'empty_opt' );
+ $this->assertSame( 'empty', $result );
+ }
+
+ // ── Helper ───────────────────────────────────────────────────────────────
+
+ /**
+ * Replicates WPDO_Crypto::derived_key() to craft test fixtures.
+ *
+ * @return string 32 raw bytes.
+ */
+ private function derive_key(): string {
+ $salt = AUTH_KEY . SECURE_AUTH_SALT;
+ return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 );
+ }
+}
diff --git a/tests/unit/CustomTableRegistryTest.php b/tests/unit/CustomTableRegistryTest.php
new file mode 100644
index 0000000..e0c7971
--- /dev/null
+++ b/tests/unit/CustomTableRegistryTest.php
@@ -0,0 +1,261 @@
+register( '2meet-courses', array(
+ 'table_name' => '2mc_courses',
+ 'primary_key' => 'id',
+ 'post_type_link' => null,
+ ) );
+
+ $this->assertTrue( $ok );
+ $this->assertCount( 1, $registry->all() );
+ }
+
+ public function test_register_rejects_empty_table_name(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $this->assertFalse( $registry->register( '2meet-courses', array() ) );
+ $this->assertFalse( $registry->register( '2meet-courses', array( 'table_name' => '' ) ) );
+ }
+
+ public function test_register_rejects_empty_provider(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $this->assertFalse( $registry->register( '', array( 'table_name' => '2mc_courses' ) ) );
+ }
+
+ public function test_register_rejects_duplicate_provider_table_pair(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $this->assertTrue( $registry->register( '2meet-courses', array( 'table_name' => '2mc_courses' ) ) );
+ // Same provider+table → false.
+ $this->assertFalse( $registry->register( '2meet-courses', array( 'table_name' => '2mc_courses' ) ) );
+ }
+
+ public function test_register_allows_same_table_different_provider(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $this->assertTrue( $registry->register( '2meet-courses', array( 'table_name' => 'shared_t' ) ) );
+ $this->assertTrue( $registry->register( '2meet-bookings', array( 'table_name' => 'shared_t' ) ) );
+ $this->assertCount( 2, $registry->all() );
+ }
+
+ public function test_register_sanitizes_table_name(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ // WordPress sanitize_key strips non-alphanumeric/underscore/dash entirely (no replacement).
+ $registry->register( 'p', array( 'table_name' => 'My Bad-Name!' ) );
+
+ $tables = $registry->all();
+ $cfg = reset( $tables );
+ $this->assertSame( 'mybad-name', $cfg['table_name'] );
+ }
+
+ public function test_register_applies_defaults(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'p', array( 'table_name' => 't' ) );
+
+ $tables = $registry->all();
+ $cfg = reset( $tables );
+ $this->assertSame( 'id', $cfg['primary_key'] );
+ $this->assertNull( $cfg['post_type_link'] );
+ $this->assertSame( array(), $cfg['expected_columns'] );
+ }
+
+ // ── unregister() ────────────────────────────────────────────────────────
+
+ public function test_unregister_removes_table(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'p', array( 'table_name' => 't' ) );
+ $this->assertTrue( $registry->unregister( 'p', 't' ) );
+ $this->assertCount( 0, $registry->all() );
+ }
+
+ public function test_unregister_returns_false_for_unknown(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $this->assertFalse( $registry->unregister( 'unknown', 'table' ) );
+ }
+
+ // ── for_provider() / for_post_type() ────────────────────────────────────
+
+ public function test_for_provider_filters_correctly(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'a', array( 'table_name' => 't1' ) );
+ $registry->register( 'a', array( 'table_name' => 't2' ) );
+ $registry->register( 'b', array( 'table_name' => 't3' ) );
+
+ $this->assertCount( 2, $registry->for_provider( 'a' ) );
+ $this->assertCount( 1, $registry->for_provider( 'b' ) );
+ $this->assertCount( 0, $registry->for_provider( 'c' ) );
+ }
+
+ /**
+ * v2.1.3 R3 hardening — verify by_provider index stays in sync when a
+ * non-edge entry is unregistered. Previously untested per audit finding.
+ */
+ public function test_for_provider_after_unregister_middle_entry(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'p', array( 'table_name' => 'first' ) );
+ $registry->register( 'p', array( 'table_name' => 'middle' ) );
+ $registry->register( 'p', array( 'table_name' => 'last' ) );
+
+ $this->assertCount( 3, $registry->for_provider( 'p' ) );
+
+ // Remove the middle entry.
+ $ok = $registry->unregister( 'p', 'middle' );
+ $this->assertTrue( $ok );
+
+ $remaining = $registry->for_provider( 'p' );
+ $this->assertCount( 2, $remaining );
+
+ // Verify the surviving entries are correct (not 'middle').
+ $names = array_column( $remaining, 'table_name' );
+ sort( $names );
+ $this->assertSame( array( 'first', 'last' ), $names );
+
+ // Re-registering 'middle' should put it back.
+ $registry->register( 'p', array( 'table_name' => 'middle' ) );
+ $this->assertCount( 3, $registry->for_provider( 'p' ) );
+ }
+
+ /**
+ * Verify by_provider index empties (and removes the provider key entirely)
+ * when the last table for that provider is unregistered.
+ */
+ public function test_for_provider_returns_empty_after_full_unregister(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'solo', array( 'table_name' => 'only_table' ) );
+ $this->assertCount( 1, $registry->for_provider( 'solo' ) );
+
+ $registry->unregister( 'solo', 'only_table' );
+ $this->assertCount( 0, $registry->for_provider( 'solo' ) );
+ $this->assertSame( array(), $registry->for_provider( 'solo' ) );
+ }
+
+ public function test_for_post_type_filters_correctly(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'p', array( 'table_name' => 't1', 'post_type_link' => 'hp_listing' ) );
+ $registry->register( 'p', array( 'table_name' => 't2', 'post_type_link' => 'hp_listing' ) );
+ $registry->register( 'p', array( 'table_name' => 't3', 'post_type_link' => 'hp_vendor' ) );
+ $registry->register( 'p', array( 'table_name' => 't4', 'post_type_link' => null ) );
+
+ $this->assertCount( 2, $registry->for_post_type( 'hp_listing' ) );
+ $this->assertCount( 1, $registry->for_post_type( 'hp_vendor' ) );
+ $this->assertCount( 0, $registry->for_post_type( 'unknown' ) );
+ }
+
+ public function test_providers_returns_unique_list(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'a', array( 'table_name' => 't1' ) );
+ $registry->register( 'a', array( 'table_name' => 't2' ) );
+ $registry->register( 'b', array( 'table_name' => 't3' ) );
+
+ $providers = $registry->providers();
+ sort( $providers );
+ $this->assertSame( array( 'a', 'b' ), $providers );
+ }
+
+ // ── get_stats() ─────────────────────────────────────────────────────────
+
+ public function test_get_stats_counts_callbacks(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'p', array(
+ 'table_name' => 't1',
+ 'doctor_callback' => static fn() => array( 'ok' => true ),
+ 'benchmark_callback' => static fn() => array( 'duration_ms' => 1.0 ),
+ ) );
+ $registry->register( 'p', array( 'table_name' => 't2' ) );
+
+ $stats = $registry->get_stats();
+ $this->assertSame( 2, $stats['tables_count'] );
+ $this->assertSame( 1, $stats['providers_count'] );
+ $this->assertSame( 1, $stats['with_doctor'] );
+ $this->assertSame( 1, $stats['with_benchmark'] );
+ }
+
+ // ── run_doctor_checks() ─────────────────────────────────────────────────
+
+ public function test_run_doctor_checks_invokes_callbacks(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'p', array(
+ 'table_name' => 't1',
+ 'doctor_callback' => static fn() => array( 'ok' => true, 'message' => 'all good' ),
+ ) );
+ $registry->register( 'p', array(
+ 'table_name' => 't2',
+ 'doctor_callback' => static fn() => array( 'ok' => false, 'message' => 'index missing' ),
+ ) );
+ $registry->register( 'p', array( 'table_name' => 't3' ) ); // No callback → skipped.
+
+ $results = $registry->run_doctor_checks();
+ $this->assertCount( 2, $results );
+ $this->assertTrue( $results['p:t1']['ok'] );
+ $this->assertSame( 'all good', $results['p:t1']['message'] );
+ $this->assertFalse( $results['p:t2']['ok'] );
+ }
+
+ public function test_run_doctor_checks_passes_table_name_to_callback(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $received = null;
+
+ $registry->register( 'wc', array(
+ 'table_name' => 'wc_orders',
+ 'doctor_callback' => static function ( string $table_name ) use ( &$received ): array {
+ $received = $table_name;
+ return array( 'ok' => true, 'message' => "checked {$table_name}" );
+ },
+ ) );
+
+ $results = $registry->run_doctor_checks();
+
+ $this->assertSame( 'wc_orders', $received, 'callback must receive table_name as first argument' );
+ $this->assertSame( 'checked wc_orders', $results['wc:wc_orders']['message'] );
+ }
+
+ public function test_run_doctor_checks_catches_throwables(): void {
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->register( 'p', array(
+ 'table_name' => 't1',
+ 'doctor_callback' => static function () {
+ throw new RuntimeException( 'simulated failure' );
+ },
+ ) );
+
+ $results = $registry->run_doctor_checks();
+ $this->assertCount( 1, $results );
+ $this->assertFalse( $results['p:t1']['ok'] );
+ $this->assertStringContainsString( 'simulated failure', $results['p:t1']['message'] );
+ }
+
+ // ── fire_registration() idempotency ─────────────────────────────────────
+
+ public function test_fire_registration_is_idempotent(): void {
+ $count = 0;
+ add_action( 'wpdo_register_custom_tables', function () use ( &$count ) {
+ ++$count;
+ } );
+
+ $registry = WPDO_Custom_Table_Registry::instance();
+ $registry->fire_registration();
+ $registry->fire_registration();
+ $registry->fire_registration();
+
+ // Stub add_action() in unit bootstrap returns true but does NOT execute callbacks,
+ // so the meaningful assertion here is that fire_registration() doesn't throw.
+ $this->assertTrue( true );
+ }
+}
diff --git a/tests/unit/Diagnostic/HealthCronTest.php b/tests/unit/Diagnostic/HealthCronTest.php
new file mode 100644
index 0000000..7e26e7e
--- /dev/null
+++ b/tests/unit/Diagnostic/HealthCronTest.php
@@ -0,0 +1,132 @@
+code = $code;
+ $this->message = $message;
+ $this->data = (array) $data;
+ }
+ public function get_error_code(): string { return $this->code; }
+ public function get_error_message(): string { return $this->message; }
+ public function get_error_data() { return $this->data; }
+ }
+}
+
+require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
+require_once dirname( __DIR__, 3 ) . '/includes/diagnostic/class-tmdo-health-cron.php';
+
+/**
+ * Unit tests for WPDO_Health_Cron (v2.3.0 M6).
+ *
+ * Pure-logic tests — does not exercise actual cron firing or full Site Health
+ * subprocess (those covered by integration suite). Asserts on output shape +
+ * counter aggregation + alert flag setting.
+ */
+class HealthCronTest extends TestCase {
+
+ protected function setUp(): void {
+ $GLOBALS['_wp_options'] = array();
+ $this->setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+ public string $options = 'wp_options';
+ // v2.5.0:Module_Detector reads $wpdb->posts during Health_Cron
+ // integration; declared here to silence undefined-property warning.
+ public string $posts = 'wp_posts';
+ public string $postmeta = 'wp_postmeta';
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) {
+ return (string) ( $args[ $i++ ] ?? '?' );
+ }, $sql );
+ }
+ public function get_var( string $sql ) {
+ $upper = strtoupper( $sql );
+ // Table-existence probes → truthy so schema_drift / orphan_zone passes.
+ if ( str_contains( $upper, 'SHOW TABLES' ) || str_contains( $upper, 'INFORMATION_SCHEMA' ) ) {
+ return '1';
+ }
+ return '0';
+ }
+ public function get_results( string $sql, $output = ARRAY_A ): array {
+ return array();
+ }
+ public function query( string $sql ): int { return 0; }
+ public function insert( string $table, array $data ): int { return 1; }
+ };
+ }
+
+ public function test_get_last_run_returns_null_when_never_run(): void {
+ $this->assertNull( WPDO_Health_Cron::get_last_run() );
+ }
+
+ public function test_run_returns_summary_shape(): void {
+ $result = WPDO_Health_Cron::run();
+ $this->assertTrue( $result['ok'] );
+ $this->assertArrayHasKey( 'summary', $result );
+ $this->assertArrayHasKey( 'critical_count', $result );
+ $this->assertArrayHasKey( 'recommended_count', $result );
+ $this->assertArrayHasKey( 'ts', $result );
+ $this->assertArrayHasKey( 'tests', $result['summary'] );
+ $this->assertArrayHasKey( 'conflicts', $result['summary'] );
+ $this->assertArrayHasKey( 'shadow_diffs', $result['summary'] );
+ $this->assertArrayHasKey( 'autoload_bytes', $result['summary'] );
+ $this->assertArrayHasKey( 'duration_ms', $result['summary'] );
+ }
+
+ public function test_run_persists_last_run_option(): void {
+ WPDO_Health_Cron::run();
+ $last = WPDO_Health_Cron::get_last_run();
+ $this->assertIsArray( $last );
+ $this->assertArrayHasKey( 'tests', $last );
+ $this->assertArrayHasKey( 'critical_count', $last );
+ }
+
+ public function test_run_clears_alert_when_no_critical(): void {
+ // Pre-set an alert.
+ update_option( WPDO_Health_Cron::OPTION_ALERT, array( 'level' => 'critical' ), false );
+ WPDO_Health_Cron::run();
+ $this->assertFalse( get_option( WPDO_Health_Cron::OPTION_ALERT ), 'alert should be cleared on green run' );
+ }
+
+ public function test_consecutive_green_days_zero_when_no_run(): void {
+ $this->assertSame( 0, WPDO_Health_Cron::consecutive_green_days() );
+ }
+
+ public function test_consecutive_green_days_one_after_green_run(): void {
+ WPDO_Health_Cron::run();
+ $this->assertSame( 1, WPDO_Health_Cron::consecutive_green_days() );
+ }
+
+ public function test_option_keys_are_documented(): void {
+ $this->assertSame( 'wpdo_health_last_run', WPDO_Health_Cron::OPTION_LAST_RUN );
+ $this->assertSame( 'wpdo_health_alert', WPDO_Health_Cron::OPTION_ALERT );
+ }
+}
diff --git a/tests/unit/Export/CsvWriterTest.php b/tests/unit/Export/CsvWriterTest.php
new file mode 100644
index 0000000..28012b5
--- /dev/null
+++ b/tests/unit/Export/CsvWriterTest.php
@@ -0,0 +1,48 @@
+assertSame( "\xEF\xBB\xBF", substr( $out, 0, 3 ) );
+ }
+
+ public function test_simple_row_csv_output(): void {
+ $out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array( array( 'a' => '1', 'b' => '2' ) ) );
+ $this->assertStringContainsString( "a,b\r\n1,2\r\n", $out );
+ }
+
+ public function test_field_with_comma_gets_quoted(): void {
+ $out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => 'foo,bar' ) ) );
+ $this->assertStringContainsString( '"foo,bar"', $out );
+ }
+
+ public function test_field_with_quote_doubles_it(): void {
+ $out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => 'say "hi"' ) ) );
+ $this->assertStringContainsString( '"say ""hi"""', $out );
+ }
+
+ public function test_field_with_newline_gets_quoted(): void {
+ $out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => "line1\nline2" ) ) );
+ $this->assertStringContainsString( "\"line1\nline2\"", $out );
+ }
+
+ public function test_array_value_serializes_to_json(): void {
+ $out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => array( 'a', 'b' ) ) ) );
+ // Array becomes JSON; quotes inside JSON are doubled inside the CSV-quoted field.
+ $this->assertStringContainsString( '"[""a"",""b""]"', $out );
+ }
+
+ public function test_missing_field_renders_empty(): void {
+ $out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array( array( 'a' => '1' ) ) );
+ $this->assertStringContainsString( "1,\r\n", $out );
+ }
+}
diff --git a/tests/unit/FeatureFlagsTest.php b/tests/unit/FeatureFlagsTest.php
new file mode 100644
index 0000000..84f1dc8
--- /dev/null
+++ b/tests/unit/FeatureFlagsTest.php
@@ -0,0 +1,112 @@
+assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_unknown' ) );
+ }
+
+ public function test_get_returns_stored_state(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
+ $this->assertSame( 'backfill', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
+ }
+
+ // ── set() ─────────────────────────────────────────────────────────────────
+
+ public function test_set_valid_state_returns_true(): void {
+ $result = WPDO_Feature_Flags::set( 'mod', 'cutover' );
+ $this->assertTrue( $result );
+ $this->assertSame( 'cutover', WPDO_Feature_Flags::get( 'mod' ) );
+ }
+
+ public function test_set_invalid_state_returns_false(): void {
+ $result = WPDO_Feature_Flags::set( 'mod', 'invalid_state_xyz' );
+ $this->assertFalse( $result );
+ }
+
+ // ── Query-active check ──────────────────────────────────────────────────
+
+ public function test_is_query_active_true_when_cutover(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) );
+ }
+
+ public function test_is_query_active_true_when_complete(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
+ $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) );
+ }
+
+ public function test_is_query_active_false_when_backfill(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
+ $this->assertFalse( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) );
+ }
+
+ // ── is_write_active ──────────────────────────────────────────────────────
+
+ public function test_is_write_active_true_when_dual_write(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
+ $this->assertTrue( WPDO_Feature_Flags::is_write_active( 'hot_hp_listing' ) );
+ }
+
+ public function test_is_write_active_false_when_idle(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
+ $this->assertFalse( WPDO_Feature_Flags::is_write_active( 'hot_hp_listing' ) );
+ }
+
+ // ── is_read_custom ───────────────────────────────────────────────────────
+
+ public function test_is_read_custom_true_when_cutover(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ $this->assertTrue( WPDO_Feature_Flags::is_read_custom( 'hot_hp_listing' ) );
+ }
+
+ public function test_is_read_custom_false_when_backfill(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
+ $this->assertFalse( WPDO_Feature_Flags::is_read_custom( 'hot_hp_listing' ) );
+ }
+
+ // ── all() ────────────────────────────────────────────────────────────────
+
+ public function test_all_returns_array_of_states(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ WPDO_Feature_Flags::set( 'hot_hp_vendor', 'idle' );
+
+ $all = WPDO_Feature_Flags::all();
+
+ $this->assertIsArray( $all );
+ $this->assertSame( 'cutover', $all['hot_hp_listing'] );
+ $this->assertSame( 'idle', $all['hot_hp_vendor'] );
+ }
+
+ // ── reset() ─────────────────────────────────────────────────────────────
+
+ public function test_reset_returns_module_to_idle(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
+ WPDO_Feature_Flags::reset( 'hot_hp_listing' );
+ $this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
+ }
+
+ // ── is_complete ──────────────────────────────────────────────────────────
+
+ public function test_is_complete_true_when_complete(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
+ $this->assertTrue( WPDO_Feature_Flags::is_complete( 'hot_hp_listing' ) );
+ }
+
+ public function test_is_complete_false_when_cutover(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ $this->assertFalse( WPDO_Feature_Flags::is_complete( 'hot_hp_listing' ) );
+ }
+}
diff --git a/tests/unit/HookBusBridgeTest.php b/tests/unit/HookBusBridgeTest.php
new file mode 100644
index 0000000..129fd05
--- /dev/null
+++ b/tests/unit/HookBusBridgeTest.php
@@ -0,0 +1,112 @@
+assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
+ }
+
+ public function test_is_enabled_when_option_set_to_string_one(): void {
+ update_option( WPDO_Hook_Bus_Bridge::OPTION, '1' );
+ WPDO_Hook_Bus_Bridge::reset_cache();
+ $this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
+ }
+
+ public function test_is_enabled_when_option_set_to_bool_true(): void {
+ update_option( WPDO_Hook_Bus_Bridge::OPTION, true );
+ WPDO_Hook_Bus_Bridge::reset_cache();
+ $this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
+ }
+
+ public function test_is_enabled_when_option_set_to_zero(): void {
+ update_option( WPDO_Hook_Bus_Bridge::OPTION, '0' );
+ WPDO_Hook_Bus_Bridge::reset_cache();
+ $this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() );
+ }
+
+ public function test_is_enabled_caches_result(): void {
+ update_option( WPDO_Hook_Bus_Bridge::OPTION, '1' );
+ WPDO_Hook_Bus_Bridge::reset_cache();
+ $first = WPDO_Hook_Bus_Bridge::is_enabled();
+
+ // Mutate the option, but cache should retain previous value until reset.
+ update_option( WPDO_Hook_Bus_Bridge::OPTION, '0' );
+ $second = WPDO_Hook_Bus_Bridge::is_enabled();
+ $this->assertSame( $first, $second, 'Cache must be sticky within a request' );
+
+ // After explicit reset → new value visible.
+ WPDO_Hook_Bus_Bridge::reset_cache();
+ $this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() );
+ }
+
+ // ── maybe_init_hook_bus() ───────────────────────────────────────────────
+
+ public function test_maybe_init_hook_bus_noop_when_disabled(): void {
+ // Disabled → must not throw even if WPDO_Hook_Bus class missing.
+ WPDO_Hook_Bus_Bridge::maybe_init_hook_bus();
+ $this->assertTrue( true );
+ }
+
+ // ── detect_intra_wpdo_conflicts() ───────────────────────────────────────
+
+ public function test_detect_intra_wpdo_conflicts_returns_empty_when_no_filters(): void {
+ // Stub bootstrap doesn't populate $wp_filter, so result is empty array.
+ $conflicts = WPDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts();
+ $this->assertIsArray( $conflicts );
+ $this->assertEmpty( $conflicts );
+ }
+
+ public function test_detect_intra_wpdo_conflicts_flags_multiple_wpdo_callbacks(): void {
+ // Simulate $wp_filter with two WPDO_* callbacks on the same hook.
+ $GLOBALS['wp_filter'] = array(
+ 'update_post_metadata' => new class() {
+ public array $callbacks;
+ public function __construct() {
+ $this->callbacks = array(
+ 10 => array(
+ array(
+ 'function' => array(
+ new class() {
+ public function intercept_update() {}
+ },
+ 'intercept_update',
+ ),
+ ),
+ ),
+ 8 => array(
+ array(
+ 'function' => array(
+ new class() {
+ public function intercept_update() {}
+ },
+ 'intercept_update',
+ ),
+ ),
+ ),
+ );
+ }
+ },
+ );
+
+ // The anonymous classes won't have WPDO_ prefix → must not flag conflict.
+ $conflicts = WPDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts();
+ $this->assertEmpty( $conflicts, 'Non-WPDO classes must not be flagged' );
+ }
+}
diff --git a/tests/unit/LoggerTest.php b/tests/unit/LoggerTest.php
new file mode 100644
index 0000000..9b3cffa
--- /dev/null
+++ b/tests/unit/LoggerTest.php
@@ -0,0 +1,133 @@
+insert(). */
+ public static array $last_insert = [];
+
+ /** Insert call counter. */
+ public static int $insert_count = 0;
+
+ /** Value returned by $wpdb->query(). */
+ public static int $query_return = 1;
+
+ /** Rows returned by $wpdb->get_results(). */
+ public static array $get_results_return = [];
+
+ /** Last SQL passed to $wpdb->query(). */
+ public static string $last_query_sql = '';
+
+ protected function setUp(): void {
+ self::$last_insert = [];
+ self::$insert_count = 0;
+ self::$query_return = 1;
+ self::$get_results_return = [];
+ self::$last_query_sql = '';
+ $this->setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+ public string $postmeta = 'wp_postmeta';
+ public string $posts = 'wp_posts';
+ public string $options = 'wp_options';
+
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
+ $val = $args[ $i++ ] ?? '';
+ return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
+ }, $sql );
+ }
+
+ public function get_var( string $sql ): ?string {
+ return null;
+ }
+
+ public function get_row( string $sql, $output = OBJECT ) {
+ return null;
+ }
+
+ public function get_results( string $sql, $output = OBJECT ): array {
+ return LoggerTest::$get_results_return;
+ }
+
+ public function insert( string $table, array $data, $format = null ): int|false {
+ LoggerTest::$last_insert = $data;
+ LoggerTest::$insert_count++;
+ return 1;
+ }
+
+ public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
+ return 1;
+ }
+
+ public function delete( string $table, array $where, $format = null ): int|false {
+ return 1;
+ }
+
+ public function query( string $sql ): int|bool {
+ LoggerTest::$last_query_sql = $sql;
+ return LoggerTest::$query_return;
+ }
+ };
+ }
+
+ // ── error() ──────────────────────────────────────────────────────────────
+
+ public function test_error_inserts_row_into_db(): void {
+ WPDO_Logger::error( 'reviews', 'save_hook', 'Something went wrong' );
+ $this->assertSame( 1, self::$insert_count );
+ }
+
+ public function test_error_sanitizes_module_field(): void {
+ WPDO_Logger::error( 'Reviews Module!', 'some_hook', 'test message' );
+ // sanitize_key strips non-[a-z0-9_-] chars; result matches stub output.
+ $this->assertSame( sanitize_key( 'Reviews Module!' ), self::$last_insert['module'] );
+ }
+
+ public function test_error_trims_hook_to_255_chars(): void {
+ $long_hook = str_repeat( 'x', 300 );
+ WPDO_Logger::error( 'mod', $long_hook, 'msg' );
+ $this->assertLessThanOrEqual( 255, strlen( self::$last_insert['hook'] ) );
+ }
+
+ public function test_error_encodes_context_as_json(): void {
+ $context = [ 'post_id' => 42, 'extra' => 'data' ];
+ WPDO_Logger::error( 'mod', 'hook', 'msg', $context );
+ $this->assertSame( json_encode( $context, JSON_UNESCAPED_UNICODE ), self::$last_insert['context'] );
+ }
+
+ public function test_error_sets_null_context_when_empty(): void {
+ WPDO_Logger::error( 'mod', 'hook', 'msg' );
+ $this->assertNull( self::$last_insert['context'] );
+ }
+
+ // ── get_recent() ─────────────────────────────────────────────────────────
+
+ public function test_get_recent_returns_wpdb_results(): void {
+ self::$get_results_return = [
+ [ 'id' => 1, 'module' => 'reviews', 'message' => 'err1' ],
+ [ 'id' => 2, 'module' => 'hot', 'message' => 'err2' ],
+ ];
+ $results = WPDO_Logger::get_recent();
+ $this->assertCount( 2, $results );
+ }
+
+ // ── purge() ───────────────────────────────────────────────────────────────
+
+ public function test_purge_returns_query_result(): void {
+ self::$query_return = 5;
+ $deleted = WPDO_Logger::purge( 30 );
+ $this->assertSame( 5, $deleted );
+ }
+}
diff --git a/tests/unit/MemberFields/MemberFieldsRegistrationTest.php b/tests/unit/MemberFields/MemberFieldsRegistrationTest.php
new file mode 100644
index 0000000..5079b2d
--- /dev/null
+++ b/tests/unit/MemberFields/MemberFieldsRegistrationTest.php
@@ -0,0 +1,202 @@
+assertTrue( true );
+ }
+
+ public function test_all_four_groups_are_registered(): void {
+ WPDO_Member_Fields::register_entity_fields();
+
+ foreach ( array( 'membership', 'activity', 'profile', 'sso' ) as $group ) {
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', $group );
+ $this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
+ }
+ }
+
+ // ── membership group ─────────────────────────────────────────────────────
+
+ public function test_membership_group_has_six_fields(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
+ $this->assertCount( 6, $fields );
+ }
+
+ public function test_membership_level_is_enum_with_five_options(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
+ $level = $this->find_field( $fields, 'membership_level' );
+
+ $this->assertNotNull( $level );
+ $this->assertSame( 'enum', $level['type'] );
+ $this->assertCount( 5, $level['options'] );
+ $this->assertContains( 'gold', $level['options'] );
+ $this->assertContains( 'platinum', $level['options'] );
+ }
+
+ public function test_membership_level_is_searchable(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
+ $level = $this->find_field( $fields, 'membership_level' );
+
+ $this->assertTrue( (bool) $level['searchable'] );
+ }
+
+ public function test_points_balance_is_integer_searchable(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
+ $field = $this->find_field( $fields, 'points_balance' );
+
+ $this->assertSame( 'integer', $field['type'] );
+ $this->assertTrue( (bool) $field['searchable'] );
+ $this->assertSame( 0, $field['default'] );
+ }
+
+ public function test_membership_expires_at_is_datetime_searchable(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
+ $field = $this->find_field( $fields, 'membership_expires_at' );
+
+ $this->assertSame( 'datetime', $field['type'] );
+ $this->assertTrue( (bool) $field['searchable'] );
+ }
+
+ // ── activity group ───────────────────────────────────────────────────────
+
+ public function test_activity_group_has_six_fields(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' );
+ $this->assertCount( 6, $fields );
+ }
+
+ public function test_login_count_has_integer_type_and_zero_default(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' );
+ $field = $this->find_field( $fields, 'login_count' );
+
+ $this->assertSame( 'integer', $field['type'] );
+ $this->assertSame( 0, $field['default'] );
+ }
+
+ public function test_account_flags_is_integer(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' );
+ $field = $this->find_field( $fields, 'account_flags' );
+
+ $this->assertSame( 'integer', $field['type'] );
+ }
+
+ // ── profile group ────────────────────────────────────────────────────────
+
+ public function test_profile_group_has_five_fields(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' );
+ $this->assertCount( 5, $fields );
+ }
+
+ public function test_specialties_is_json_type(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' );
+ $field = $this->find_field( $fields, 'specialties' );
+
+ $this->assertSame( 'json', $field['type'] );
+ }
+
+ public function test_display_name_custom_is_fulltext_searchable(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' );
+ $field = $this->find_field( $fields, 'display_name_custom' );
+
+ $this->assertTrue( (bool) $field['searchable'] );
+ $this->assertTrue( (bool) $field['fulltext'] );
+ }
+
+ // ── sso group ────────────────────────────────────────────────────────────
+
+ public function test_sso_group_has_seven_fields(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
+ $this->assertCount( 7, $fields );
+ }
+
+ public function test_hub_global_user_id_is_searchable(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
+ $field = $this->find_field( $fields, 'hub_global_user_id' );
+
+ $this->assertTrue( (bool) $field['searchable'] );
+ }
+
+ public function test_token_expires_at_is_datetime_searchable(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
+ $field = $this->find_field( $fields, 'token_expires_at' );
+
+ $this->assertSame( 'datetime', $field['type'] );
+ $this->assertTrue( (bool) $field['searchable'] );
+ }
+
+ public function test_refresh_token_enc_is_textarea(): void {
+ WPDO_Member_Fields::register_entity_fields();
+ $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
+ $field = $this->find_field( $fields, 'refresh_token_enc' );
+
+ $this->assertSame( 'textarea', $field['type'] );
+ }
+
+ // ── register() hooks add_action ──────────────────────────────────────────
+
+ public function test_register_hooks_wpdo_register_entity_fields(): void {
+ // add_action is a no-op stub in test bootstrap; just confirm no exception.
+ $this->assertNull( WPDO_Member_Fields::register() );
+ }
+
+ // ── helper ───────────────────────────────────────────────────────────────
+
+ /**
+ * Find a field definition by key within a fields array.
+ *
+ * @param array $fields Array of field definitions.
+ * @param string $key Meta key to find.
+ * @return array|null
+ */
+ private function find_field( array $fields, string $key ): ?array {
+ foreach ( $fields as $field ) {
+ if ( $field['key'] === $key ) {
+ return $field;
+ }
+ }
+ return null;
+ }
+}
diff --git a/tests/unit/MemberFields/PointsManagerTest.php b/tests/unit/MemberFields/PointsManagerTest.php
new file mode 100644
index 0000000..393c543
--- /dev/null
+++ b/tests/unit/MemberFields/PointsManagerTest.php
@@ -0,0 +1,231 @@
+original_wpdb = $wpdb;
+
+ // Install a controllable mock that also has insert_id.
+ $wpdb = $this->make_wpdb_mock();
+ }
+
+ protected function tearDown(): void {
+ global $wpdb;
+ $wpdb = $this->original_wpdb;
+ }
+
+ // ── credit() input validation ────────────────────────────────────────────
+
+ public function test_credit_rejects_zero_delta(): void {
+ $result = WPDO_Points_Manager::credit( 1, 0 );
+
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'credit delta must be positive', $result['error'] );
+ }
+
+ public function test_credit_rejects_negative_delta(): void {
+ $result = WPDO_Points_Manager::credit( 1, -50 );
+
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'credit delta must be positive', $result['error'] );
+ }
+
+ // ── debit() input validation ─────────────────────────────────────────────
+
+ public function test_debit_rejects_zero_delta(): void {
+ $result = WPDO_Points_Manager::debit( 1, 0 );
+
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'debit delta must be positive', $result['error'] );
+ }
+
+ public function test_debit_rejects_negative_delta(): void {
+ $result = WPDO_Points_Manager::debit( 1, -10 );
+
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'debit delta must be positive', $result['error'] );
+ }
+
+ // ── debit() insufficient balance ─────────────────────────────────────────
+
+ public function test_debit_fails_when_balance_zero_and_no_overdraft(): void {
+ // $wpdb->get_var returns null → balance = 0; debit 50 → new_balance = -50 → reject.
+ $result = WPDO_Points_Manager::debit( 42, 50, 'purchase' );
+
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'insufficient_balance', $result['error'] );
+ }
+
+ public function test_debit_rollback_called_on_insufficient_balance(): void {
+ global $wpdb;
+
+ WPDO_Points_Manager::debit( 42, 50 );
+
+ $sql_log = $wpdb->queries;
+ // Expect BEGIN and ROLLBACK but NOT COMMIT.
+ $this->assertContains( 'START TRANSACTION', $sql_log );
+ $this->assertContains( 'ROLLBACK', $sql_log );
+ $this->assertNotContains( 'COMMIT', $sql_log );
+ }
+
+ // ── debit() allow_overdraft ───────────────────────────────────────────────
+
+ public function test_debit_with_allow_overdraft_succeeds_below_zero(): void {
+ $result = WPDO_Points_Manager::debit( 1, 100, 'force', 0, '', true );
+
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( -100, $result['balance'] );
+ }
+
+ // ── credit() happy path ───────────────────────────────────────────────────
+
+ public function test_credit_returns_ok_and_new_balance(): void {
+ $result = WPDO_Points_Manager::credit( 7, 200, 'signup_bonus' );
+
+ $this->assertTrue( $result['ok'] );
+ $this->assertSame( 200, $result['balance'] );
+ $this->assertArrayHasKey( 'ledger_id', $result );
+ }
+
+ public function test_credit_records_begin_and_commit(): void {
+ global $wpdb;
+
+ WPDO_Points_Manager::credit( 7, 100, 'test' );
+
+ $sql_log = $wpdb->queries;
+ $this->assertContains( 'START TRANSACTION', $sql_log );
+ $this->assertContains( 'COMMIT', $sql_log );
+ $this->assertNotContains( 'ROLLBACK', $sql_log );
+ }
+
+ public function test_credit_truncates_long_reason(): void {
+ // Reasons over 60 chars must be silently truncated (not cause DB error).
+ $long_reason = str_repeat( 'x', 100 );
+ $result = WPDO_Points_Manager::credit( 5, 10, $long_reason );
+
+ $this->assertTrue( $result['ok'] );
+ }
+
+ // ── get_balance() ─────────────────────────────────────────────────────────
+
+ public function test_get_balance_returns_zero_for_unknown_user(): void {
+ // Mock $wpdb->get_var returns null → (int) null = 0.
+ $balance = WPDO_Points_Manager::get_balance( 9999 );
+
+ $this->assertSame( 0, $balance );
+ }
+
+ // ── get_ledger() ─────────────────────────────────────────────────────────
+
+ public function test_get_ledger_returns_empty_array_when_no_rows(): void {
+ $ledger = WPDO_Points_Manager::get_ledger( 9999 );
+
+ $this->assertSame( array(), $ledger );
+ }
+
+ public function test_get_ledger_clamps_limit_between_1_and_500(): void {
+ // Just confirm no exception on extreme inputs.
+ WPDO_Points_Manager::get_ledger( 1, -5 );
+ WPDO_Points_Manager::get_ledger( 1, 9999 );
+ $this->assertTrue( true );
+ }
+
+ // ── helper ───────────────────────────────────────────────────────────────
+
+ /**
+ * Build a $wpdb mock that:
+ * - Records every SQL statement to ->queries[]
+ * - Returns null for SELECT…FOR UPDATE (simulating empty DB / no row)
+ * - After a successful UPSERT, returns the inserted delta for re-read SELECTs
+ * - Returns empty array for get_results
+ * - Returns 1 for query/insert
+ * - Has insert_id = 99
+ */
+ private function make_wpdb_mock(): object {
+ return new class {
+ public string $prefix = 'wp_';
+ public string $postmeta = 'wp_postmeta';
+ public string $posts = 'wp_posts';
+ public string $options = 'wp_options';
+ public string $usermeta = 'wp_usermeta';
+ public string $users = 'wp_users';
+ public array $queries = array();
+ public int $insert_id = 99;
+ public string $last_error = '';
+ public ?int $last_upserted_balance = null;
+
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%[sd]/', function () use ( &$i, $args ) {
+ return $args[ $i++ ] ?? '?';
+ }, $sql );
+ }
+
+ public function get_var( string $sql ): ?string {
+ $this->queries[] = $sql;
+ // SELECT … FOR UPDATE simulates an empty membership table (no row).
+ if ( false !== strpos( $sql, 'FOR UPDATE' ) ) {
+ return null;
+ }
+ // Post-UPSERT re-read returns the balance written by the last INSERT.
+ if ( null !== $this->last_upserted_balance ) {
+ return (string) $this->last_upserted_balance;
+ }
+ return null;
+ }
+
+ public function get_results( string $sql, $output = 'OBJECT' ): array {
+ $this->queries[] = $sql;
+ return array();
+ }
+
+ public function query( string $sql ): int {
+ $this->queries[] = $sql;
+ // Capture the delta from INSERT…VALUES(user_id, delta) so subsequent
+ // re-read SELECTs can return a meaningful balance (mirrors real DB).
+ if ( preg_match( '/VALUES\s*\(\s*\d+\s*,\s*(-?\d+)\s*\)/', $sql, $m ) ) {
+ $this->last_upserted_balance = (int) $m[1];
+ }
+ return 1;
+ }
+
+ public function insert( string $table, array $data, $format = null ): int {
+ $this->queries[] = "INSERT {$table}";
+ return 1;
+ }
+
+ public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int {
+ $this->queries[] = "UPDATE {$table}";
+ return 1;
+ }
+
+ public function delete( string $table, array $where, $format = null ): int {
+ $this->queries[] = "DELETE {$table}";
+ return 1;
+ }
+
+ public function replace( string $table, array $data, $format = null ): int {
+ $this->queries[] = "REPLACE {$table}";
+ return 1;
+ }
+ };
+ }
+}
diff --git a/tests/unit/MigrationEngineTest.php b/tests/unit/MigrationEngineTest.php
new file mode 100644
index 0000000..fa8d9ad
--- /dev/null
+++ b/tests/unit/MigrationEngineTest.php
@@ -0,0 +1,136 @@
+getProperty( 'migrations' );
+ $m->setAccessible( true );
+ $m->setValue( null, [] );
+ }
+
+ // ── can_transition ───────────────────────────────────────────────────────
+
+ /** @dataProvider valid_transitions_provider */
+ public function test_can_transition_returns_true_for_valid_paths( string $from, string $to ): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', $from );
+ $this->assertTrue(
+ WPDO_Migration_Engine::can_transition( 'hot_hp_listing', $to ),
+ "Expected valid transition: $from → $to"
+ );
+ }
+
+ public static function valid_transitions_provider(): array {
+ return [
+ [ 'idle', 'dual_write' ],
+ [ 'dual_write', 'backfill' ],
+ [ 'backfill', 'verify' ],
+ [ 'backfill', 'dual_write' ], // backfill can go back to dual_write.
+ [ 'verify', 'cutover' ],
+ [ 'verify', 'dual_write' ], // verify can step back.
+ [ 'cutover', 'cleanup' ],
+ [ 'cleanup', 'complete' ],
+ // Any state → idle is always allowed (rollback path).
+ [ 'cutover', 'idle' ],
+ [ 'complete', 'idle' ],
+ ];
+ }
+
+ /** @dataProvider invalid_transitions_provider */
+ public function test_can_transition_returns_false_for_invalid_paths( string $from, string $to ): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', $from );
+ $this->assertFalse(
+ WPDO_Migration_Engine::can_transition( 'hot_hp_listing', $to ),
+ "Expected invalid transition: $from → $to"
+ );
+ }
+
+ public static function invalid_transitions_provider(): array {
+ return [
+ [ 'idle', 'cutover' ], // Must traverse intermediate states.
+ [ 'complete', 'backfill' ], // Cannot go backwards except to idle.
+ [ 'idle', 'complete' ],
+ ];
+ }
+
+ // ── transition ───────────────────────────────────────────────────────────
+
+ public function test_transition_updates_state_on_valid_path(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
+ $result = WPDO_Migration_Engine::transition( 'hot_hp_listing', 'dual_write' );
+ $this->assertTrue( $result );
+ $this->assertSame( 'dual_write', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
+ }
+
+ public function test_transition_returns_false_and_preserves_state_on_invalid_path(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
+ $result = WPDO_Migration_Engine::transition( 'hot_hp_listing', 'complete' );
+ $this->assertFalse( $result );
+ $this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
+ }
+
+ // ── rollback ─────────────────────────────────────────────────────────────
+
+ public function test_rollback_resets_state_to_idle(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ $result = WPDO_Migration_Engine::rollback( 'hot_hp_listing' );
+ $this->assertSame( 'idle', $result['status'] );
+ $this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
+ }
+
+ public function test_rollback_from_idle_returns_idle_status(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
+ $result = WPDO_Migration_Engine::rollback( 'hot_hp_listing' );
+ // Engine returns idle status with "already idle" message (not error).
+ $this->assertSame( 'idle', $result['status'] );
+ }
+
+ // ── status ────────────────────────────────────────────────────────────────
+
+ public function test_status_returns_current_state(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
+ $status = WPDO_Migration_Engine::status( 'hot_hp_listing' );
+ $this->assertSame( 'backfill', $status['state'] );
+ $this->assertSame( 'hot_hp_listing', $status['module'] );
+ }
+
+ // ── cleanup / enable require prior states ───────────────────────────────
+
+ public function test_cleanup_fails_when_not_in_cutover(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
+ $result = WPDO_Migration_Engine::cleanup( 'hot_hp_listing' );
+ $this->assertSame( 'error', $result['status'] );
+ }
+
+ public function test_enable_fails_when_not_in_cleanup(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ $result = WPDO_Migration_Engine::enable( 'hot_hp_listing' );
+ $this->assertSame( 'error', $result['status'] );
+ }
+
+ public function test_enable_succeeds_from_cleanup(): void {
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cleanup' );
+ $result = WPDO_Migration_Engine::enable( 'hot_hp_listing' );
+ $this->assertSame( 'complete', $result['status'] );
+ $this->assertSame( 'complete', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
+ }
+
+ // ── migrate without registered migration ────────────────────────────────
+
+ public function test_migrate_without_registration_returns_error(): void {
+ $result = WPDO_Migration_Engine::migrate( 'hot_unregistered' );
+ $this->assertSame( 'error', $result['status'] );
+ }
+}
diff --git a/tests/unit/Notifications/EmailNotifierTest.php b/tests/unit/Notifications/EmailNotifierTest.php
new file mode 100644
index 0000000..9fb134a
--- /dev/null
+++ b/tests/unit/Notifications/EmailNotifierTest.php
@@ -0,0 +1,142 @@
+ $crit,
+ 'recommended_count' => 0,
+ 'ran_at' => '2026-04-28 03:30:00',
+ 'tests' => array(
+ 'wpdo_schema_drift' => array(
+ 'status' => 'critical',
+ 'description' => 'Missing tables: wpdo_audit',
+ ),
+ 'wpdo_error_budget' => array(
+ 'status' => 'good',
+ 'description' => 'OK',
+ ),
+ ),
+ );
+ }
+
+ public function test_default_disabled(): void {
+ $this->assertFalse( WPDO_Email_Notifier::is_enabled() );
+ }
+
+ public function test_recipient_falls_back_to_admin_email(): void {
+ update_option( 'admin_email', 'admin@example.com', false );
+ $this->assertSame( 'admin@example.com', WPDO_Email_Notifier::recipient() );
+
+ update_option( 'wpdo_alert_email', 'alerts@example.com', false );
+ $this->assertSame( 'alerts@example.com', WPDO_Email_Notifier::recipient() );
+ }
+
+ public function test_throttle_hours_clamps_to_range(): void {
+ update_option( 'wpdo_alert_throttle_hours', 0, false );
+ $this->assertSame( 1, WPDO_Email_Notifier::throttle_hours() );
+
+ update_option( 'wpdo_alert_throttle_hours', 999, false );
+ $this->assertSame( 168, WPDO_Email_Notifier::throttle_hours() );
+
+ update_option( 'wpdo_alert_throttle_hours', 12, false );
+ $this->assertSame( 12, WPDO_Email_Notifier::throttle_hours() );
+ }
+
+ public function test_maybe_send_skips_when_disabled(): void {
+ $result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() );
+ $this->assertFalse( $result );
+ $this->assertEmpty( $GLOBALS['_wpdo_mails'] );
+ }
+
+ public function test_maybe_send_sends_when_enabled(): void {
+ update_option( 'wpdo_email_alerts_enabled', '1', false );
+ update_option( 'wpdo_alert_email', 'ops@example.com', false );
+
+ $result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() );
+ $this->assertTrue( $result );
+ $this->assertCount( 1, $GLOBALS['_wpdo_mails'] );
+ $mail = $GLOBALS['_wpdo_mails'][0];
+ $this->assertSame( 'ops@example.com', $mail['to'] );
+ $this->assertStringContainsString( 'wpdo_schema_drift', $mail['body'] );
+ $this->assertStringContainsString( 'WPDO 警告', $mail['subject'] );
+ }
+
+ public function test_maybe_send_throttle_dedupes_same_fingerprint(): void {
+ update_option( 'wpdo_email_alerts_enabled', '1', false );
+ update_option( 'wpdo_alert_email', 'ops@example.com', false );
+ $summary = $this->sample_summary();
+
+ $first = WPDO_Email_Notifier::maybe_send( $summary );
+ $second = WPDO_Email_Notifier::maybe_send( $summary );
+ $this->assertTrue( $first );
+ $this->assertFalse( $second, '同 fingerprint 第 2 次應 throttle' );
+ $this->assertCount( 1, $GLOBALS['_wpdo_mails'] );
+ }
+
+ public function test_maybe_send_skips_invalid_email(): void {
+ update_option( 'wpdo_email_alerts_enabled', '1', false );
+ update_option( 'wpdo_alert_email', 'not-an-email', false );
+
+ $result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() );
+ $this->assertFalse( $result );
+ }
+
+ public function test_fingerprint_changes_with_critical_count(): void {
+ update_option( 'wpdo_email_alerts_enabled', '1', false );
+ update_option( 'wpdo_alert_email', 'ops@example.com', false );
+
+ $first = WPDO_Email_Notifier::maybe_send( $this->sample_summary( 1 ) );
+ // Different critical_count → different fingerprint → not throttled.
+ $second = WPDO_Email_Notifier::maybe_send( $this->sample_summary( 5 ) );
+ $this->assertTrue( $first );
+ $this->assertTrue( $second, '不同 critical_count 應視為不同警告' );
+ $this->assertCount( 2, $GLOBALS['_wpdo_mails'] );
+ }
+}
diff --git a/tests/unit/Notifications/MultiChannelNotifierTest.php b/tests/unit/Notifications/MultiChannelNotifierTest.php
new file mode 100644
index 0000000..3b71345
--- /dev/null
+++ b/tests/unit/Notifications/MultiChannelNotifierTest.php
@@ -0,0 +1,197 @@
+code = $code;
+ $this->message = $message;
+ $this->data = (array) $data;
+ }
+ public function get_error_code(): string { return $this->code; }
+ public function get_error_message(): string { return $this->message; }
+ public function get_error_data() { return $this->data; }
+ }
+}
+if ( ! function_exists( 'is_wp_error' ) ) {
+ function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; }
+}
+// Mock wp_remote_post — captures into $GLOBALS['_wpdo_remote_posts'] and returns simulated response.
+if ( ! function_exists( 'wp_remote_post' ) ) {
+ function wp_remote_post( $url, $args = array() ) {
+ $GLOBALS['_wpdo_remote_posts'][] = array( 'url' => $url, 'args' => $args );
+ // Default 200 OK; test can override via $GLOBALS['_wpdo_remote_status'].
+ return array( 'response' => array( 'code' => $GLOBALS['_wpdo_remote_status'] ?? 200 ) );
+ }
+}
+if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) {
+ function wp_remote_retrieve_response_code( $resp ) {
+ return $resp['response']['code'] ?? 0;
+ }
+}
+
+require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
+require_once dirname( __DIR__, 3 ) . '/includes/notifications/abstract-class-tmdo-notifier.php';
+require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-slack-notifier.php';
+require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-discord-notifier.php';
+require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-telegram-notifier.php';
+
+/**
+ * Unit tests for v2.5.0 M15 multi-channel notifiers.
+ */
+class MultiChannelNotifierTest extends TestCase {
+
+ protected function setUp(): void {
+ $GLOBALS['_wp_options'] = array();
+ $GLOBALS['_wpdo_remote_posts'] = array();
+ $GLOBALS['_wpdo_remote_status'] = 200;
+ }
+
+ private function summary(): array {
+ return array(
+ 'critical_count' => 1,
+ 'recommended_count' => 0,
+ 'ran_at' => '2026-04-28 03:30:00',
+ 'tests' => array(
+ 'wpdo_schema_drift' => array(
+ 'status' => 'critical',
+ 'description' => 'Missing tables: wpdo_audit',
+ ),
+ ),
+ );
+ }
+
+ // ─── Slack ────────────────────────────────────────────────────────
+
+ public function test_slack_default_disabled(): void {
+ $this->assertFalse( WPDO_Slack_Notifier::is_enabled() );
+ }
+
+ public function test_slack_skips_send_when_disabled(): void {
+ $result = WPDO_Slack_Notifier::maybe_send( $this->summary() );
+ $this->assertFalse( $result );
+ $this->assertEmpty( $GLOBALS['_wpdo_remote_posts'] );
+ }
+
+ public function test_slack_skips_when_webhook_invalid(): void {
+ update_option( 'wpdo_slack_enabled', '1', false );
+ update_option( 'wpdo_slack_webhook', 'http://evil.com/wh', false );
+ $result = WPDO_Slack_Notifier::maybe_send( $this->summary() );
+ $this->assertFalse( $result );
+ }
+
+ public function test_slack_sends_with_valid_webhook(): void {
+ update_option( 'wpdo_slack_enabled', '1', false );
+ update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false );
+
+ $result = WPDO_Slack_Notifier::maybe_send( $this->summary() );
+ $this->assertTrue( $result );
+ $this->assertCount( 1, $GLOBALS['_wpdo_remote_posts'] );
+ $captured = $GLOBALS['_wpdo_remote_posts'][0];
+ $this->assertSame( 'https://hooks.slack.com/services/T/B/X', $captured['url'] );
+ $payload = json_decode( (string) $captured['args']['body'], true );
+ $this->assertArrayHasKey( 'text', $payload );
+ $this->assertStringContainsString( 'WPDO 警告', $payload['text'] );
+ $this->assertStringContainsString( 'wpdo_schema_drift', $payload['text'] );
+ }
+
+ public function test_slack_throttle_dedupes(): void {
+ update_option( 'wpdo_slack_enabled', '1', false );
+ update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false );
+
+ $first = WPDO_Slack_Notifier::maybe_send( $this->summary() );
+ $second = WPDO_Slack_Notifier::maybe_send( $this->summary() );
+ $this->assertTrue( $first );
+ $this->assertFalse( $second );
+ $this->assertCount( 1, $GLOBALS['_wpdo_remote_posts'] );
+ }
+
+ // ─── Discord ─────────────────────────────────────────────────────
+
+ public function test_discord_validates_webhook_prefix(): void {
+ update_option( 'wpdo_discord_enabled', '1', false );
+ update_option( 'wpdo_discord_webhook', 'https://attack.example.com/x', false );
+ $this->assertFalse( WPDO_Discord_Notifier::maybe_send( $this->summary() ) );
+ }
+
+ public function test_discord_sends_content_payload(): void {
+ update_option( 'wpdo_discord_enabled', '1', false );
+ update_option( 'wpdo_discord_webhook', 'https://discord.com/api/webhooks/123/abc', false );
+
+ $result = WPDO_Discord_Notifier::maybe_send( $this->summary() );
+ $this->assertTrue( $result );
+ $captured = $GLOBALS['_wpdo_remote_posts'][0];
+ $payload = json_decode( (string) $captured['args']['body'], true );
+ $this->assertArrayHasKey( 'content', $payload );
+ }
+
+ // ─── Telegram ────────────────────────────────────────────────────
+
+ public function test_telegram_skips_when_token_missing(): void {
+ update_option( 'wpdo_telegram_enabled', '1', false );
+ // No token / chat_id.
+ $this->assertFalse( WPDO_Telegram_Notifier::maybe_send( $this->summary() ) );
+ }
+
+ public function test_telegram_validates_token_format(): void {
+ update_option( 'wpdo_telegram_enabled', '1', false );
+ update_option( 'wpdo_telegram_bot_token', 'not_a_token', false );
+ update_option( 'wpdo_telegram_chat_id', '123', false );
+ $this->assertFalse( WPDO_Telegram_Notifier::maybe_send( $this->summary() ) );
+ }
+
+ public function test_telegram_sends_when_valid(): void {
+ update_option( 'wpdo_telegram_enabled', '1', false );
+ update_option( 'wpdo_telegram_bot_token', '123456:ABCDEFghijklmnopqrstuvwxyz0123456789', false );
+ update_option( 'wpdo_telegram_chat_id', '-1001234567890', false );
+
+ $result = WPDO_Telegram_Notifier::maybe_send( $this->summary() );
+ $this->assertTrue( $result );
+ $captured = $GLOBALS['_wpdo_remote_posts'][0];
+ $this->assertStringStartsWith( 'https://api.telegram.org/bot', $captured['url'] );
+ $payload = json_decode( (string) $captured['args']['body'], true );
+ $this->assertSame( '-1001234567890', $payload['chat_id'] );
+ $this->assertStringContainsString( '🚨', $payload['text'] );
+ }
+
+ // ─── Severity filter ─────────────────────────────────────────────
+
+ public function test_severity_critical_only_skips_when_no_critical(): void {
+ update_option( 'wpdo_slack_enabled', '1', false );
+ update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false );
+ update_option( 'wpdo_slack_severity', 'critical_only', false );
+
+ $summary_recommended_only = array(
+ 'critical_count' => 0,
+ 'recommended_count' => 2,
+ 'ran_at' => '2026-04-28 03:30:00',
+ 'tests' => array(
+ 'wpdo_error_budget' => array( 'status' => 'recommended', 'description' => 'too many errors' ),
+ ),
+ );
+
+ $this->assertFalse( WPDO_Slack_Notifier::maybe_send( $summary_recommended_only ) );
+ }
+
+ // ─── Channel ID identity ────────────────────────────────────────
+
+ public function test_channel_ids(): void {
+ $this->assertSame( 'slack', WPDO_Slack_Notifier::channel_id() );
+ $this->assertSame( 'discord', WPDO_Discord_Notifier::channel_id() );
+ $this->assertSame( 'telegram', WPDO_Telegram_Notifier::channel_id() );
+ }
+}
diff --git a/tests/unit/PostFields/PostFieldsRegistrationTest.php b/tests/unit/PostFields/PostFieldsRegistrationTest.php
new file mode 100644
index 0000000..3d1242e
--- /dev/null
+++ b/tests/unit/PostFields/PostFieldsRegistrationTest.php
@@ -0,0 +1,178 @@
+assertTrue( true );
+ }
+
+ public function test_all_seven_groups_are_registered(): void {
+ WPDO_Post_Fields::register_entity_fields();
+
+ $expected = array(
+ 'wp_core',
+ 'attachment',
+ 'wc_product',
+ 'hp_listing_core',
+ 'hp_request_core',
+ 'hp_vendor_core',
+ 'nav_menu_item',
+ );
+
+ foreach ( $expected as $group ) {
+ $fields = WPDO_Entity_Registry::get_group_fields( 'post', $group );
+ $this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
+ }
+ }
+
+ public function test_user_entity_groups_are_not_touched(): void {
+ // 🔒 Frozen contract: post fields registration must not register
+ // any group under entity_type='user'.
+ WPDO_Post_Fields::register_entity_fields();
+ $user_groups = WPDO_Entity_Registry::get_groups_for_type( 'user' );
+ $this->assertEmpty( $user_groups, 'WPDO_Post_Fields must not touch user entity registry.' );
+ }
+
+ // ── wp_core group (cross post_type) ──────────────────────────────────────
+
+ public function test_wp_core_group_has_expected_keys(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $keys = $this->get_field_keys( 'wp_core' );
+ $this->assertContains( '_thumbnail_id', $keys );
+ $this->assertContains( '_wp_page_template', $keys );
+ $this->assertContains( '_edit_last', $keys );
+ }
+
+ public function test_wp_core_thumbnail_is_searchable(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $field = $this->find_field( 'wp_core', '_thumbnail_id' );
+ $this->assertNotNull( $field );
+ $this->assertSame( 'integer', $field['type'] );
+ $this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
+ }
+
+ // ── attachment group ─────────────────────────────────────────────────────
+
+ public function test_attachment_group_has_expected_keys(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $keys = $this->get_field_keys( 'attachment' );
+ $this->assertContains( '_wp_attached_file', $keys );
+ $this->assertContains( '_wp_attachment_metadata', $keys );
+ $this->assertContains( '_wp_attachment_image_alt', $keys );
+ }
+
+ public function test_attachment_metadata_is_json_type(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $field = $this->find_field( 'attachment', '_wp_attachment_metadata' );
+ $this->assertNotNull( $field );
+ $this->assertSame( 'json', $field['type'] );
+ }
+
+ // ── wc_product group ─────────────────────────────────────────────────────
+
+ public function test_wc_product_group_has_19_keys(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $keys = $this->get_field_keys( 'wc_product' );
+ $this->assertCount( 19, $keys, 'wc_product group should register exactly 19 keys.' );
+ }
+
+ public function test_wc_product_critical_keys_present(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $keys = $this->get_field_keys( 'wc_product' );
+ foreach ( array( '_price', '_regular_price', '_sale_price', '_stock', '_stock_status', '_sku' ) as $key ) {
+ $this->assertContains( $key, $keys, "wc_product missing critical key: {$key}" );
+ }
+ }
+
+ public function test_wc_product_price_is_searchable_decimal(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $field = $this->find_field( 'wc_product', '_price' );
+ $this->assertNotNull( $field );
+ $this->assertSame( 'decimal', $field['type'] );
+ $this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
+ }
+
+ public function test_wc_product_stock_status_is_enum_searchable(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $field = $this->find_field( 'wc_product', '_stock_status' );
+ $this->assertNotNull( $field );
+ $this->assertSame( 'enum', $field['type'] );
+ $this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
+ $this->assertContains( 'instock', $field['options'] );
+ $this->assertContains( 'outofstock', $field['options'] );
+ }
+
+ // ── hp_listing_core group ────────────────────────────────────────────────
+
+ public function test_hp_listing_core_critical_keys_present(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $keys = $this->get_field_keys( 'hp_listing_core' );
+ foreach ( array( 'hp_price', 'hp_status', 'hp_featured', 'hp_verified', 'hp_vendor' ) as $key ) {
+ $this->assertContains( $key, $keys, "hp_listing_core missing critical key: {$key}" );
+ }
+ }
+
+ public function test_hp_listing_price_is_searchable_decimal(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $field = $this->find_field( 'hp_listing_core', 'hp_price' );
+ $this->assertNotNull( $field );
+ $this->assertSame( 'decimal', $field['type'] );
+ $this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
+ }
+
+ // ── nav_menu_item group ──────────────────────────────────────────────────
+
+ public function test_nav_menu_item_has_8_keys(): void {
+ WPDO_Post_Fields::register_entity_fields();
+ $keys = $this->get_field_keys( 'nav_menu_item' );
+ $this->assertCount( 8, $keys );
+ $this->assertContains( '_menu_item_type', $keys );
+ $this->assertContains( '_menu_item_object_id', $keys );
+ $this->assertContains( '_menu_item_url', $keys );
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ /** @return string[] */
+ private function get_field_keys( string $group ): array {
+ $fields = WPDO_Entity_Registry::get_group_fields( 'post', $group );
+ return array_map( static fn( $f ) => $f['key'], $fields );
+ }
+
+ private function find_field( string $group, string $key ): ?array {
+ $fields = WPDO_Entity_Registry::get_group_fields( 'post', $group );
+ foreach ( $fields as $f ) {
+ if ( ( $f['key'] ?? '' ) === $key ) {
+ return $f;
+ }
+ }
+ return null;
+ }
+}
diff --git a/tests/unit/PublicApiTest.php b/tests/unit/PublicApiTest.php
new file mode 100644
index 0000000..629d91d
--- /dev/null
+++ b/tests/unit/PublicApiTest.php
@@ -0,0 +1,142 @@
+getProperty( 'instance' );
+ $instance->setAccessible( true );
+ $instance->setValue( null, null );
+
+ // Reset Feature_Flags caches (state pollution between tests).
+ $ref = new ReflectionClass( WPDO_Feature_Flags::class );
+ foreach ( array( 'cache', 'shadow_cache' ) as $prop ) {
+ $p = $ref->getProperty( $prop );
+ $p->setAccessible( true );
+ $p->setValue( null, null );
+ }
+
+ // Reset Entity_Registry static state.
+ WPDO_Entity_Registry::init();
+ }
+
+ // ── get_field / set_field (post entity) ─────────────────────────────────
+
+ public function test_get_field_returns_postmeta_value(): void {
+ update_post_meta( 100, 'hp_price', '199.99' );
+ $this->assertSame( '199.99', WPDO_API::get_field( 100, 'hp_price' ) );
+ }
+
+ public function test_set_field_writes_postmeta(): void {
+ WPDO_API::set_field( 200, 'hp_price', '299.99' );
+ $this->assertSame( '299.99', get_post_meta( 200, 'hp_price', true ) );
+ }
+
+ public function test_get_field_missing_returns_empty_string_when_single(): void {
+ $this->assertSame( '', WPDO_API::get_field( 9999, 'nonexistent' ) );
+ }
+
+ // ── get_entity / set_entity (multi-entity) ──────────────────────────────
+
+ public function test_get_entity_post_dispatches_correctly(): void {
+ update_post_meta( 1, 'k', 'pv' );
+ $this->assertSame( 'pv', WPDO_API::get_entity( 'post', 1, 'k' ) );
+ }
+
+ public function test_get_entity_unknown_type_returns_null(): void {
+ $this->assertNull( WPDO_API::get_entity( 'invalid', 1, 'k' ) );
+ }
+
+ public function test_set_entity_unknown_type_returns_false(): void {
+ $this->assertFalse( WPDO_API::set_entity( 'invalid', 1, 'k', 'v' ) );
+ }
+
+ // ── is_field_registered ─────────────────────────────────────────────────
+
+ public function test_is_field_registered_returns_false_for_unknown(): void {
+ $this->assertFalse( WPDO_API::is_field_registered( 'post', 'unregistered_key' ) );
+ }
+
+ public function test_is_field_registered_true_after_schema_register(): void {
+ WPDO_Schema_Registry::instance()->register(
+ 'test',
+ array(
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_price',
+ 'zone' => 'hot',
+ 'data_type' => 'decimal(10,2)',
+ 'column' => 'hp_price',
+ )
+ );
+
+ $this->assertTrue( WPDO_API::is_field_registered( 'post', 'hp_price' ) );
+ }
+
+ // ── trace_storage ──────────────────────────────────────────────────────
+
+ public function test_trace_storage_unregistered_when_no_field(): void {
+ $this->assertSame(
+ 'unregistered',
+ WPDO_API::trace_storage( 'post', 'unknown', 'hp_listing' )
+ );
+ }
+
+ public function test_trace_storage_postmeta_when_registered_but_not_cutover(): void {
+ WPDO_Schema_Registry::instance()->register(
+ 'test',
+ array(
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_price',
+ 'zone' => 'hot',
+ 'data_type' => 'decimal(10,2)',
+ 'column' => 'hp_price',
+ )
+ );
+
+ // Module starts in 'idle' → not read-custom → returns 'postmeta'.
+ $this->assertSame(
+ 'postmeta',
+ WPDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' )
+ );
+ }
+
+ public function test_trace_storage_zone_after_cutover(): void {
+ $GLOBALS['_wp_options'] = array();
+ $ref = new ReflectionClass( WPDO_Feature_Flags::class );
+ $cache = $ref->getProperty( 'cache' );
+ $cache->setAccessible( true );
+ $cache->setValue( null, null );
+
+ WPDO_Schema_Registry::instance()->register(
+ 'test',
+ array(
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_price',
+ 'zone' => 'hot',
+ 'data_type' => 'decimal(10,2)',
+ 'column' => 'hp_price',
+ )
+ );
+
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ $this->assertSame(
+ 'zone_hot',
+ WPDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' )
+ );
+ }
+}
diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php
new file mode 100644
index 0000000..a24085c
--- /dev/null
+++ b/tests/unit/RestApiTest.php
@@ -0,0 +1,296 @@
+api = new WPDO_REST_API();
+
+ // Reset globals.
+ $GLOBALS['_wp_options'] = [];
+ $GLOBALS['_wp_postmeta'] = [];
+ $GLOBALS['_wp_post_types'] = [];
+ $GLOBALS['_wp_current_user_can'] = [];
+ $GLOBALS['_wp_valid_nonces'] = [];
+ $_COOKIE = [];
+
+ // Reset Feature Flags cache via reflection.
+ $ff_ref = new ReflectionClass( WPDO_Feature_Flags::class );
+ $ff_prop = $ff_ref->getProperty( 'cache' );
+ $ff_prop->setAccessible( true );
+ $ff_prop->setValue( null, null );
+
+ // Reset Schema Registry singleton.
+ $ref = new ReflectionClass( WPDO_Schema_Registry::class );
+ $prop = $ref->getProperty( 'instance' );
+ $prop->setAccessible( true );
+ $prop->setValue( null, null );
+ }
+
+ // ── Route registration ────────────────────────────────────────────────────
+
+ public function test_register_routes_calls_register_rest_route(): void {
+ // register_rest_route is stubbed to return true — just confirm no exception.
+ $this->api->register_routes();
+ $this->assertTrue( true );
+ }
+
+ // ── Permission callback ───────────────────────────────────────────────────
+
+ public function test_require_manage_options_false_when_not_admin(): void {
+ $GLOBALS['_wp_current_user_can']['manage_options'] = false;
+ $this->assertFalse( $this->api->require_manage_options() );
+ }
+
+ public function test_require_manage_options_true_when_admin(): void {
+ $GLOBALS['_wp_current_user_can']['manage_options'] = true;
+ $this->assertTrue( $this->api->require_manage_options() );
+ }
+
+ // ── get_status ────────────────────────────────────────────────────────────
+
+ public function test_get_status_returns_version_and_engine(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
+ $response = $this->api->get_status( $req );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( WPDO_VERSION, $data['version'] );
+ $this->assertSame( 'mysql', $data['engine'] );
+ $this->assertArrayHasKey( 'fields', $data );
+ $this->assertArrayHasKey( 'modules', $data );
+ }
+
+ // ── get_listing (single) ──────────────────────────────────────────────────
+
+ public function test_get_listing_404_when_post_not_found(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' );
+ $req->set_param( 'id', 9999 );
+
+ $response = $this->api->get_listing( $req );
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ public function test_get_listing_returns_postmeta_when_zones_idle(): void {
+ $GLOBALS['_wp_post_types'][42] = 'hp_listing';
+ $GLOBALS['_wp_postmeta'][42]['hp_price'] = '500';
+ $GLOBALS['_wp_postmeta'][42]['hp_description'] = 'Test desc';
+
+ // Register hot + cold fields.
+ $registry = WPDO_Schema_Registry::instance();
+ $registry->register( 'test', [
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_price',
+ 'zone' => 'hot',
+ 'column' => 'hp_price',
+ 'type' => 'decimal',
+ ] );
+ $registry->register( 'test', [
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_description',
+ 'zone' => 'cold',
+ ] );
+
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' );
+ $req->set_param( 'id', 42 );
+
+ $response = $this->api->get_listing( $req );
+ $this->assertSame( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame( 42, $data['id'] );
+ $this->assertSame( 'hp_listing', $data['post_type'] );
+ $this->assertSame( '500', $data['hp_price'] );
+ $this->assertSame( 'Test desc', $data['hp_description'] );
+ }
+
+ // ── get_stats ─────────────────────────────────────────────────────────────
+
+ public function test_get_stats_404_when_post_not_found(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/9999' );
+ $req->set_param( 'id', 9999 );
+
+ $response = $this->api->get_stats( $req );
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ public function test_get_stats_returns_view_count(): void {
+ $GLOBALS['_wp_post_types'][55] = 'hp_listing';
+ // Warm zone idle, falls back to postmeta.
+ $GLOBALS['_wp_postmeta'][55]['hp_view_count'] = '17';
+
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/55' );
+ $req->set_param( 'id', 55 );
+
+ $response = $this->api->get_stats( $req );
+ $this->assertSame( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame( 55, $data['post_id'] );
+ $this->assertIsInt( $data['view_count'] );
+ }
+
+ // ── get_listings (WP_Query fallback) ─────────────────────────────────────
+
+ public function test_get_listings_returns_200_via_wp_query_fallback(): void {
+ // Zone idle → WP_Query path.
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'hp_listing' );
+ $req->set_param( 'per_page', 10 );
+ $req->set_param( 'page', 1 );
+
+ $response = $this->api->get_listings( $req );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertIsArray( $response->get_data() );
+ }
+
+ // ── Pagination headers ────────────────────────────────────────────────────
+
+ public function test_listings_fallback_sets_pagination_headers(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
+ $req->set_param( 'post_type', 'hp_listing' );
+ $req->set_param( 'per_page', 10 );
+ $req->set_param( 'page', 1 );
+
+ $response = $this->api->get_listings( $req );
+ $headers = $response->get_headers();
+
+ $this->assertArrayHasKey( 'X-WP-Total', $headers );
+ $this->assertArrayHasKey( 'X-WP-TotalPages', $headers );
+ }
+
+ // ── post_view ─────────────────────────────────────────────────────────────
+
+ public function test_post_view_403_without_nonce(): void {
+ $GLOBALS['_wp_post_types'][10] = 'hp_listing';
+
+ $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/10/view' );
+ $req->set_param( 'id', 10 );
+ // No nonce set.
+
+ $response = $this->api->post_view( $req );
+ $this->assertSame( 403, $response->get_status() );
+ }
+
+ public function test_post_view_403_with_invalid_nonce(): void {
+ $GLOBALS['_wp_post_types'][11] = 'hp_listing';
+
+ $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/11/view' );
+ $req->set_param( 'id', 11 );
+ $req->set_header( 'X-WP-Nonce', 'bad_nonce' );
+
+ $response = $this->api->post_view( $req );
+ $this->assertSame( 403, $response->get_status() );
+ }
+
+ public function test_post_view_404_when_post_not_found(): void {
+ $nonce = wp_create_nonce( 'wp_rest' );
+
+ $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/9999/view' );
+ $req->set_param( 'id', 9999 );
+ $req->set_header( 'X-WP-Nonce', $nonce );
+
+ $response = $this->api->post_view( $req );
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ public function test_post_view_returns_view_count(): void {
+ $GLOBALS['_wp_post_types'][20] = 'hp_listing';
+ $GLOBALS['_wp_postmeta'][20]['hp_view_count'] = '5';
+ $nonce = wp_create_nonce( 'wp_rest' );
+
+ $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/20/view' );
+ $req->set_param( 'id', 20 );
+ $req->set_header( 'X-WP-Nonce', $nonce );
+
+ $response = $this->api->post_view( $req );
+ $this->assertSame( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame( 20, $data['post_id'] );
+ $this->assertIsInt( $data['view_count'] );
+ }
+
+ // ── post_view: rate limiting ──────────────────────────────────────────────
+
+ public function test_post_view_success_sets_set_cookie_header(): void {
+ $GLOBALS['_wp_post_types'][25] = 'hp_listing';
+ $nonce = wp_create_nonce( 'wp_rest' );
+
+ $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/25/view' );
+ $req->set_param( 'id', 25 );
+ $req->set_header( 'X-WP-Nonce', $nonce );
+
+ $response = $this->api->post_view( $req );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'Set-Cookie', $response->get_headers() );
+ $this->assertStringContainsString( 'wpdo_view_25', $response->get_headers()['Set-Cookie'] );
+ }
+
+ public function test_post_view_429_when_ip_rate_limited(): void {
+ $GLOBALS['_wp_post_types'][30] = 'hp_listing';
+ $nonce = wp_create_nonce( 'wp_rest' );
+
+ // First call succeeds and sets IP transient.
+ $req1 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' );
+ $req1->set_param( 'id', 30 );
+ $req1->set_header( 'X-WP-Nonce', $nonce );
+ $resp1 = $this->api->post_view( $req1 );
+ $this->assertSame( 200, $resp1->get_status() );
+
+ // Second call (same IP, within TTL) must be rate-limited.
+ $req2 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' );
+ $req2->set_param( 'id', 30 );
+ $req2->set_header( 'X-WP-Nonce', $nonce );
+ $resp2 = $this->api->post_view( $req2 );
+ $this->assertSame( 429, $resp2->get_status() );
+ $this->assertSame( 'too_many_requests', $resp2->get_data()['code'] );
+ }
+
+ public function test_post_view_429_when_cookie_present(): void {
+ $GLOBALS['_wp_post_types'][35] = 'hp_listing';
+ $_COOKIE['wpdo_view_35'] = '1';
+ $nonce = wp_create_nonce( 'wp_rest' );
+
+ $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/35/view' );
+ $req->set_param( 'id', 35 );
+ $req->set_header( 'X-WP-Nonce', $nonce );
+
+ $response = $this->api->post_view( $req );
+ $this->assertSame( 429, $response->get_status() );
+ $this->assertSame( 'too_many_requests', $response->get_data()['code'] );
+ }
+
+ public function test_post_view_429_increments_rate_limit_stats(): void {
+ $GLOBALS['_wp_post_types'][40] = 'hp_listing';
+ $_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block.
+ $nonce = wp_create_nonce( 'wp_rest' );
+
+ $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' );
+ $req->set_param( 'id', 40 );
+ $req->set_header( 'X-WP-Nonce', $nonce );
+ $this->api->post_view( $req );
+
+ $stats = get_option( 'wpdo_rl_stats', [] );
+ $this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) );
+ }
+
+ // ── get_status: rate_limit_stats ─────────────────────────────────────────
+
+ public function test_get_status_includes_rate_limit_stats(): void {
+ $req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
+ $data = $this->api->get_status( $req )->get_data();
+
+ $this->assertArrayHasKey( 'rate_limit_stats', $data );
+ $this->assertIsArray( $data['rate_limit_stats'] );
+ }
+}
diff --git a/tests/unit/Safety/FSMGuardTest.php b/tests/unit/Safety/FSMGuardTest.php
new file mode 100644
index 0000000..eeeb6d5
--- /dev/null
+++ b/tests/unit/Safety/FSMGuardTest.php
@@ -0,0 +1,240 @@
+code = $code;
+ $this->message = $message;
+ $this->data = (array) $data;
+ }
+ public function get_error_code(): string { return $this->code; }
+ public function get_error_message(): string { return $this->message; }
+ public function get_error_data() { return $this->data; }
+ }
+}
+if ( ! function_exists( 'is_wp_error' ) ) {
+ function is_wp_error( $thing ): bool {
+ return $thing instanceof WP_Error;
+ }
+}
+if ( ! function_exists( '__' ) ) {
+ function __( string $text, string $domain = 'default' ): string {
+ return $text;
+ }
+}
+if ( ! function_exists( 'add_filter' ) ) {
+ function add_filter( string $hook, $cb, int $prio = 10, int $args = 1 ): bool {
+ $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ][] = $cb;
+ return true;
+ }
+}
+if ( ! function_exists( 'remove_filter' ) ) {
+ function remove_filter( string $hook, $cb, int $prio = 10 ): bool {
+ if ( isset( $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ] ) ) {
+ $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ] = array_values( array_filter(
+ $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ],
+ fn( $existing ) => $existing !== $cb
+ ) );
+ }
+ return true;
+ }
+}
+// Override apply_filters to honor our registry (only for the FSM-related hooks).
+if ( ! function_exists( '_wpdo_fsm_apply_filters' ) ) {
+ function _wpdo_fsm_apply_filters( string $hook, $value, ...$args ) {
+ if ( ! isset( $GLOBALS['_wpdo_fsm_filters'][ $hook ] ) ) {
+ return $value;
+ }
+ ksort( $GLOBALS['_wpdo_fsm_filters'][ $hook ] );
+ foreach ( $GLOBALS['_wpdo_fsm_filters'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $cb ) {
+ $value = call_user_func( $cb, $value, ...$args );
+ }
+ }
+ return $value;
+ }
+}
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$args ) {
+ return _wpdo_fsm_apply_filters( $hook, $value, ...$args );
+ }
+}
+if ( ! function_exists( '__return_true' ) ) {
+ function __return_true(): bool { return true; }
+}
+
+// Per-test filter / option mocks via $GLOBALS.
+if ( ! isset( $GLOBALS['_wpdo_fsm_filters'] ) ) {
+ $GLOBALS['_wpdo_fsm_filters'] = array();
+}
+
+require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
+require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
+require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-manager.php';
+require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-writer.php';
+require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-reader.php';
+require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-pruner.php';
+require_once dirname( __DIR__, 3 ) . '/includes/safety/class-tmdo-fsm-guard.php';
+
+/**
+ * Unit tests for WPDO_FSM_Guard (v2.2.0 M2).
+ *
+ * Pure logic — does not exercise actual snapshot creation (Snapshot_Manager
+ * gracefully no-ops when DB / filesystem aren't available, which is fine for
+ * these tests that focus on the transition graph + classification rules).
+ */
+class FSMGuardTest extends TestCase {
+
+ protected function setUp(): void {
+ // Clear all filter callbacks so the FSM Guard runs without bypass.
+ // Other unit tests rely on the global bypass registered in bootstrap.
+ $GLOBALS['_wp_filter_callbacks'] = [];
+ }
+
+ protected function tearDown(): void {
+ // Restore global FSM bypass for subsequent test classes.
+ $GLOBALS['_wp_filter_callbacks'] = [];
+ add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
+ }
+
+ /**
+ * Test against the real apply_filters used by FSM_Guard. Bootstrap defines
+ * a trivial passthrough; tests that need filter behavior can swap in their
+ * own mock by overriding this method.
+ */
+ private function with_bypass_filter_active( bool $active, callable $body ): void {
+ if ( $active ) {
+ add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
+ }
+ try {
+ $body();
+ } finally {
+ if ( $active ) {
+ remove_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
+ }
+ }
+ }
+
+ // ─── can_transition: forward graph ────────────────────────────────────
+
+ public function test_idle_to_dual_write_is_allowed(): void {
+ $result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'dual_write' );
+ $this->assertTrue( $result );
+ }
+
+ public function test_dual_write_to_backfill_is_allowed(): void {
+ $result = WPDO_FSM_Guard::can_transition( 'reviews', 'dual_write', 'backfill' );
+ $this->assertTrue( $result );
+ }
+
+ public function test_idle_to_cutover_is_blocked(): void {
+ $result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' );
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertSame( 'wpdo_fsm_invalid_transition', $result->get_error_code() );
+ }
+
+ public function test_idle_to_complete_is_blocked(): void {
+ $result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'complete' );
+ $this->assertInstanceOf( WP_Error::class, $result );
+ }
+
+ public function test_complete_is_terminal_only_idle_allowed(): void {
+ // Forward from complete is blocked (terminal).
+ $result_forward = WPDO_FSM_Guard::can_transition( 'reviews', 'complete', 'cleanup' );
+ $this->assertInstanceOf( WP_Error::class, $result_forward );
+ // But rewind to idle is allowed.
+ $result_rewind = WPDO_FSM_Guard::can_transition( 'reviews', 'complete', 'idle' );
+ $this->assertTrue( $result_rewind );
+ }
+
+ public function test_any_state_to_idle_is_allowed(): void {
+ foreach ( array( 'dual_write', 'backfill', 'verify', 'cutover', 'cleanup', 'complete' ) as $from ) {
+ $result = WPDO_FSM_Guard::can_transition( 'reviews', $from, 'idle' );
+ $this->assertTrue( $result, "{$from} → idle should be allowed (rewind)" );
+ }
+ }
+
+ public function test_no_op_transition_is_allowed(): void {
+ $result = WPDO_FSM_Guard::can_transition( 'reviews', 'verify', 'verify' );
+ $this->assertTrue( $result );
+ }
+
+ public function test_skipping_states_in_forward_graph_is_blocked(): void {
+ // dual_write directly to cutover (skipping backfill+verify).
+ $result = WPDO_FSM_Guard::can_transition( 'reviews', 'dual_write', 'cutover' );
+ $this->assertInstanceOf( WP_Error::class, $result );
+ }
+
+ public function test_filter_bypass_overrides_block(): void {
+ // Without bypass: idle → cutover is blocked.
+ $blocked = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' );
+ $this->assertInstanceOf( WP_Error::class, $blocked );
+
+ // The bootstrap apply_filters() is a trivial passthrough that doesn't
+ // honor our registry — full filter behavior is covered by the
+ // integration suite. Here we verify that adding a filter is non-fatal
+ // (no exception); behavioral assertion is best-effort.
+ add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
+ $result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' );
+ // In raw-PHP unit context this still returns WP_Error; in real WP it would return true.
+ $this->assertTrue( $result === true || $result instanceof WP_Error );
+ remove_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
+ }
+
+ // ─── is_destructive classification ──────────────────────────────────
+
+ public function test_cutover_to_cleanup_is_destructive(): void {
+ $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cutover', 'cleanup' ) );
+ }
+
+ public function test_cleanup_to_complete_is_destructive(): void {
+ $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cleanup', 'complete' ) );
+ }
+
+ public function test_active_state_to_idle_is_destructive(): void {
+ $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cutover', 'idle' ) );
+ $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cleanup', 'idle' ) );
+ $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'complete', 'idle' ) );
+ }
+
+ public function test_dual_write_to_idle_is_destructive(): void {
+ // dual_write is in ACTIVE_STATES, so reverting still abandons writes.
+ $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'dual_write', 'idle' ) );
+ }
+
+ public function test_idle_to_dual_write_is_NOT_destructive(): void {
+ $this->assertFalse( WPDO_FSM_Guard::is_destructive( 'idle', 'dual_write' ) );
+ }
+
+ public function test_dual_write_to_backfill_is_NOT_destructive(): void {
+ $this->assertFalse( WPDO_FSM_Guard::is_destructive( 'dual_write', 'backfill' ) );
+ }
+
+ public function test_verify_to_cutover_is_NOT_destructive(): void {
+ // cutover writes still go to both wp_*meta AND custom; nothing is purged yet.
+ $this->assertFalse( WPDO_FSM_Guard::is_destructive( 'verify', 'cutover' ) );
+ }
+
+ // ─── error message includes context ────────────────────────────────
+
+ public function test_blocked_error_includes_module_and_states(): void {
+ $result = WPDO_FSM_Guard::can_transition( 'my_module', 'idle', 'verify' );
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $msg = $result->get_error_message();
+ $this->assertStringContainsString( 'my_module', $msg );
+ $this->assertStringContainsString( 'idle', $msg );
+ $this->assertStringContainsString( 'verify', $msg );
+ $data = $result->get_error_data();
+ $this->assertSame( 'my_module', $data['module'] );
+ $this->assertSame( 'idle', $data['from'] );
+ $this->assertSame( 'verify', $data['to'] );
+ $this->assertSame( array( 'dual_write' ), $data['allowed'] );
+ }
+}
diff --git a/tests/unit/SchemaRegistryTest.php b/tests/unit/SchemaRegistryTest.php
new file mode 100644
index 0000000..73cfe93
--- /dev/null
+++ b/tests/unit/SchemaRegistryTest.php
@@ -0,0 +1,131 @@
+getProperty( 'instance' );
+ $instance->setAccessible( true );
+ $instance->setValue( null, null );
+
+ $this->registry = WPDO_Schema_Registry::instance();
+ }
+
+ // ── register() ──────────────────────────────────────────────────────────
+
+ public function test_register_single_field(): void {
+ $this->registry->register( 'test', [
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_price',
+ 'zone' => 'hot',
+ 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
+ 'column' => 'hp_price',
+ 'indexed' => true,
+ ] );
+
+ $field = $this->registry->get_field( 'hp_listing', 'hp_price' );
+ $this->assertNotNull( $field );
+ $this->assertSame( 'hot', $field['zone'] );
+ $this->assertSame( 'hp_price', $field['column'] );
+ $this->assertTrue( $field['indexed'] );
+ }
+
+ public function test_register_many_registers_all_fields(): void {
+ $this->registry->register_many( 'test', [
+ [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_featured' ],
+ [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_verified', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_verified' ],
+ [ 'post_type' => 'hp_vendor', 'meta_key' => 'hp_verified', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_verified' ],
+ ] );
+
+ $listing_hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_listing' );
+ $this->assertCount( 2, $listing_hot );
+
+ $vendor_hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_vendor' );
+ $this->assertCount( 1, $vendor_hot );
+ }
+
+ // ── get_field() ─────────────────────────────────────────────────────────
+
+ public function test_get_field_returns_null_for_unknown_key(): void {
+ $this->assertNull( $this->registry->get_field( 'hp_listing', 'hp_nonexistent' ) );
+ }
+
+ // ── get_field_zone() ────────────────────────────────────────────────────
+
+ public function test_get_field_zone_returns_correct_zone(): void {
+ $this->registry->register( 'test', [
+ 'post_type' => 'hp_vendor',
+ 'meta_key' => 'hp_description',
+ 'zone' => 'cold',
+ 'cache_group' => 'wpdo_cold',
+ 'cache_ttl' => 3600,
+ ] );
+
+ $zone = $this->registry->get_field_zone( 'hp_vendor', 'hp_description' );
+ $this->assertSame( 'cold', $zone );
+ }
+
+ public function test_get_field_zone_returns_null_for_unknown(): void {
+ $this->assertNull( $this->registry->get_field_zone( 'hp_listing', 'hp_missing' ) );
+ }
+
+ // ── get_hot_columns() ───────────────────────────────────────────────────
+
+ public function test_get_hot_columns_returns_column_to_data_type_map(): void {
+ $this->registry->register( 'test', [
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_price',
+ 'zone' => 'hot',
+ 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
+ 'column' => 'hp_price',
+ ] );
+
+ $cols = $this->registry->get_hot_columns( 'hp_listing' );
+ $this->assertArrayHasKey( 'hp_price', $cols );
+ $this->assertSame( 'decimal(10,2) NOT NULL DEFAULT 0', $cols['hp_price'] );
+ }
+
+ // ── Duplicate registration guard ────────────────────────────────────────
+
+ public function test_duplicate_registration_does_not_add_extra_entry(): void {
+ $field = [
+ 'post_type' => 'hp_listing',
+ 'meta_key' => 'hp_price',
+ 'zone' => 'hot',
+ 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
+ 'column' => 'hp_price',
+ ];
+
+ $this->registry->register( 'test', $field );
+ $this->registry->register( 'test', $field );
+
+ $hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_listing' );
+ $this->assertCount( 1, $hot );
+ }
+
+ // ── get_stats() ─────────────────────────────────────────────────────────
+
+ public function test_get_stats_reflects_registered_fields(): void {
+ $this->registry->register_many( 'test', [
+ [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', 'column' => 'hp_price' ],
+ [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_featured' ],
+ [ 'post_type' => 'hp_vendor', 'meta_key' => 'hp_desc', 'zone' => 'cold', 'cache_group' => 'g', 'cache_ttl' => 3600 ],
+ ] );
+
+ $stats = $this->registry->get_stats();
+
+ $this->assertSame( 2, $stats['hot'] );
+ $this->assertSame( 1, $stats['cold'] );
+ $this->assertSame( 0, $stats['warm'] );
+ $this->assertSame( 0, $stats['archive'] );
+ }
+}
diff --git a/tests/unit/ShadowReadFlagTest.php b/tests/unit/ShadowReadFlagTest.php
new file mode 100644
index 0000000..17940d0
--- /dev/null
+++ b/tests/unit/ShadowReadFlagTest.php
@@ -0,0 +1,80 @@
+getProperty( $prop );
+ $p->setAccessible( true );
+ $p->setValue( null, null );
+ }
+ }
+
+ public function test_default_is_inactive(): void {
+ $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
+ }
+
+ public function test_enable_then_active_only_in_verify_state(): void {
+ WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_listing' );
+
+ // idle → not active even though flag is on.
+ $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
+
+ // dual_write → still not active.
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
+ $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
+
+ // verify → active.
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'verify' );
+ $this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
+
+ // cutover → no longer active (verify-only sub-flag).
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
+ }
+
+ public function test_disable_clears_active(): void {
+ WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_vendor' );
+ WPDO_Feature_Flags::set( 'hot_hp_vendor', 'verify' );
+ $this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) );
+
+ WPDO_Feature_Flags::disable_shadow_read( 'hot_hp_vendor' );
+ $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) );
+ }
+
+ public function test_all_shadow_returns_only_enabled_modules(): void {
+ WPDO_Feature_Flags::enable_shadow_read( 'mod_a' );
+ WPDO_Feature_Flags::enable_shadow_read( 'mod_b' );
+ WPDO_Feature_Flags::disable_shadow_read( 'mod_b' );
+
+ $flags = WPDO_Feature_Flags::all_shadow();
+ $this->assertArrayHasKey( 'mod_a', $flags );
+ $this->assertArrayNotHasKey( 'mod_b', $flags );
+ }
+
+ public function test_shadow_flag_independent_per_module(): void {
+ WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_listing' );
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'verify' );
+ WPDO_Feature_Flags::set( 'hot_hp_vendor', 'verify' );
+
+ $this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
+ $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) );
+ }
+}
diff --git a/tests/unit/Snapshots/SnapshotManagerTest.php b/tests/unit/Snapshots/SnapshotManagerTest.php
new file mode 100644
index 0000000..1147079
--- /dev/null
+++ b/tests/unit/Snapshots/SnapshotManagerTest.php
@@ -0,0 +1,259 @@
+ sys_get_temp_dir() . '/wpdo-test-uploads',
+ 'baseurl' => 'http://localhost/uploads',
+ );
+ }
+}
+if ( ! function_exists( 'wp_mkdir_p' ) ) {
+ function wp_mkdir_p( string $dir ): bool {
+ if ( is_dir( $dir ) ) {
+ return true;
+ }
+ return mkdir( $dir, 0777, true );
+ }
+}
+if ( ! function_exists( 'esc_sql' ) ) {
+ function esc_sql( $s ): string {
+ return addslashes( (string) $s );
+ }
+}
+if ( ! function_exists( 'size_format' ) ) {
+ function size_format( int $bytes, int $decimals = 0 ): string {
+ return $bytes . 'B';
+ }
+}
+
+require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
+require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
+require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-manager.php';
+require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-writer.php';
+require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-reader.php';
+require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-pruner.php';
+
+/**
+ * Unit tests for WPDO_Snapshot_Manager + Writer + Reader (v2.2.0 M1).
+ *
+ * These tests exercise pure logic + filesystem-isolated paths in sys_get_temp_dir().
+ * Heavy integration cases (real DB dump + restore) are covered by the
+ * integration suite and the e2e/wp-data-optimizer/ Playwright tests.
+ */
+class SnapshotManagerTest extends TestCase {
+
+ protected function setUp(): void {
+ // Clean tmp upload dir.
+ $dir = sys_get_temp_dir() . '/wpdo-test-uploads/wpdo-backups';
+ if ( is_dir( $dir ) ) {
+ foreach ( glob( $dir . '/*' ) as $f ) {
+ if ( is_file( $f ) ) {
+ @unlink( $f ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ }
+ }
+ }
+ }
+
+ public function test_generate_id_format_and_uniqueness(): void {
+ $ref = new ReflectionClass( WPDO_Snapshot_Manager::class );
+ $method = $ref->getMethod( 'generate_id' );
+ $method->setAccessible( true );
+
+ $ids = array();
+ for ( $i = 0; $i < 50; $i++ ) {
+ $id = $method->invoke( null );
+ $this->assertMatchesRegularExpression( '/^wpdo_[a-z0-9]+_[a-f0-9]+$/', $id );
+ $ids[] = $id;
+ }
+ $this->assertCount( 50, array_unique( $ids ), '50 generated IDs should all be distinct' );
+ }
+
+ public function test_ensure_backup_dir_creates_dir_and_htaccess(): void {
+ $ok = WPDO_Snapshot_Manager::ensure_backup_dir();
+ $this->assertTrue( $ok );
+
+ $dir = WPDO_Snapshot_Manager::backup_dir();
+ $this->assertDirectoryExists( $dir );
+ $this->assertFileExists( $dir . '/.htaccess' );
+ $this->assertStringContainsString( 'Deny from all', file_get_contents( $dir . '/.htaccess' ) );
+ $this->assertFileExists( $dir . '/index.php' );
+ }
+
+ public function test_create_with_invalid_trigger_returns_error(): void {
+ $result = WPDO_Snapshot_Manager::create( 'totally_made_up_trigger', array() );
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'invalid_trigger', $result['error'] );
+ }
+
+ public function test_writer_escape_sql_value_handles_all_types(): void {
+ $w = new WPDO_Snapshot_Writer( 'wpdo_test_id', array() );
+ $ref = new ReflectionClass( WPDO_Snapshot_Writer::class );
+ $m = $ref->getMethod( 'escape_sql_value' );
+ $m->setAccessible( true );
+
+ $this->assertSame( 'NULL', $m->invoke( $w, null ) );
+ $this->assertSame( '0', $m->invoke( $w, false ) );
+ $this->assertSame( '1', $m->invoke( $w, true ) );
+ $this->assertSame( '42', $m->invoke( $w, 42 ) );
+ $this->assertSame( '3.14', $m->invoke( $w, 3.14 ) );
+ $this->assertSame( "'hello'", $m->invoke( $w, 'hello' ) );
+ $this->assertSame( "'don\\'t'", $m->invoke( $w, "don't" ) );
+
+ // Binary (non-utf8) should hex-encode.
+ $bin = "\x00\x01\xff\xfe";
+ $out = $m->invoke( $w, $bin );
+ $this->assertSame( '0x0001fffe', $out );
+ }
+
+ public function test_writer_is_safe_name_validates_table(): void {
+ global $wpdb;
+ $saved_prefix = $wpdb->prefix ?? 'wp_';
+ $wpdb->prefix = 'wp_';
+
+ $w = new WPDO_Snapshot_Writer( 'wpdo_test_id', array() );
+ $ref = new ReflectionClass( WPDO_Snapshot_Writer::class );
+ $m = $ref->getMethod( 'is_safe_name' );
+ $m->setAccessible( true );
+
+ $this->assertTrue( $m->invoke( $w, 'wp_postmeta' ) );
+ $this->assertTrue( $m->invoke( $w, 'wp_wpdo_warm' ) );
+ $this->assertFalse( $m->invoke( $w, 'foo_postmeta' ), 'wrong prefix should be rejected' );
+ $this->assertFalse( $m->invoke( $w, 'wp_post; DROP TABLE' ), 'sql injection should be rejected' );
+ $this->assertFalse( $m->invoke( $w, 'wp_post-bad' ), 'dash should be rejected' );
+
+ $wpdb->prefix = $saved_prefix;
+ }
+
+ public function test_reader_parse_summary_extracts_table_row_counts(): void {
+ $catalog_row = array(
+ 'snapshot_id' => 'wpdo_dummy',
+ 'storage' => 'inline',
+ 'size_bytes' => 100,
+ 'inline_blob' => '',
+ );
+ $reader = new WPDO_Snapshot_Reader( $catalog_row );
+ $ref = new ReflectionClass( WPDO_Snapshot_Reader::class );
+ $m = $ref->getMethod( 'parse_summary' );
+ $m->setAccessible( true );
+
+ $sql = "-- header
+INSERT INTO `wp_wpdo_warm` (`a`,`b`) VALUES (1,'x'),
+ (2,'y'),
+ (3,'z');
+
+INSERT INTO `wp_wpdo_archive` (`a`) VALUES (10);
+";
+ $summary = $m->invoke( $reader, $sql );
+ $this->assertSame( 4, $summary['total_rows'] );
+ $this->assertSame( 2, $summary['statements'] );
+ $this->assertSame( 3, $summary['tables']['wp_wpdo_warm'] );
+ $this->assertSame( 1, $summary['tables']['wp_wpdo_archive'] );
+ }
+
+ public function test_reader_verify_inline_size_match(): void {
+ $blob = "INSERT INTO `wp_wpdo_warm` VALUES (1);\n";
+ $reader = new WPDO_Snapshot_Reader( array(
+ 'snapshot_id' => 'wpdo_dummy',
+ 'storage' => 'inline',
+ 'size_bytes' => strlen( $blob ),
+ 'inline_blob' => $blob,
+ ) );
+ $result = $reader->verify();
+ $this->assertTrue( $result['ok'] );
+ $this->assertTrue( $result['size_match'] );
+ $this->assertSame( 'inline', $result['storage'] );
+ }
+
+ public function test_reader_verify_inline_size_mismatch(): void {
+ $blob = "AAA";
+ $reader = new WPDO_Snapshot_Reader( array(
+ 'snapshot_id' => 'wpdo_dummy',
+ 'storage' => 'inline',
+ 'size_bytes' => 999, // claim size that doesn't match.
+ 'inline_blob' => $blob,
+ ) );
+ $result = $reader->verify();
+ $this->assertFalse( $result['ok'] );
+ $this->assertFalse( $result['size_match'] );
+ }
+
+ public function test_reader_verify_file_missing(): void {
+ $reader = new WPDO_Snapshot_Reader( array(
+ 'snapshot_id' => 'wpdo_dummy',
+ 'storage' => 'file',
+ 'size_bytes' => 100,
+ 'file_path' => '/non/existent/path.sql.gz',
+ 'file_sha256' => str_repeat( '0', 64 ),
+ ) );
+ $result = $reader->verify();
+ $this->assertFalse( $result['ok'] );
+ $this->assertSame( 'file_missing', $result['error'] );
+ }
+
+ public function test_reader_maybe_gunzip_decompresses_real_gzip(): void {
+ $plaintext = "INSERT INTO `wp_wpdo_warm` VALUES (1);\n";
+ $gzipped = gzencode( $plaintext );
+
+ $catalog_row = array(
+ 'snapshot_id' => 'wpdo_dummy',
+ 'storage' => 'inline',
+ 'size_bytes' => strlen( $gzipped ),
+ 'inline_blob' => $gzipped,
+ );
+ $reader = new WPDO_Snapshot_Reader( $catalog_row );
+ $ref = new ReflectionClass( WPDO_Snapshot_Reader::class );
+ $m = $ref->getMethod( 'maybe_gunzip' );
+ $m->setAccessible( true );
+
+ $out = $m->invoke( $reader, $gzipped );
+ $this->assertSame( $plaintext, $out );
+
+ // Plain text should pass through untouched.
+ $out_plain = $m->invoke( $reader, $plaintext );
+ $this->assertSame( $plaintext, $out_plain );
+ }
+
+ public function test_reader_load_sql_inline_decompresses(): void {
+ $plaintext = "INSERT INTO `wp_test` VALUES (1);\n";
+ $gzipped = gzencode( $plaintext );
+ $reader = new WPDO_Snapshot_Reader( array(
+ 'snapshot_id' => 'wpdo_dummy',
+ 'storage' => 'inline',
+ 'size_bytes' => strlen( $gzipped ),
+ 'inline_blob' => $gzipped,
+ ) );
+ $ref = new ReflectionClass( WPDO_Snapshot_Reader::class );
+ $m = $ref->getMethod( 'load_sql' );
+ $m->setAccessible( true );
+
+ $out = $m->invoke( $reader );
+ $this->assertSame( $plaintext, $out );
+ }
+
+ public function test_reader_throws_on_missing_required_keys(): void {
+ $this->expectException( InvalidArgumentException::class );
+ new WPDO_Snapshot_Reader( array() );
+ }
+
+ public function test_pruner_protected_triggers_listed(): void {
+ $ref = new ReflectionClass( WPDO_Snapshot_Pruner::class );
+ $prop = $ref->getReflectionConstant( 'PROTECTED_TRIGGERS' );
+ $this->assertNotNull( $prop );
+ $value = $prop->getValue();
+ $this->assertContains( 'pre_uninstall', $value );
+ $this->assertContains( 'pre_v2_upgrade', $value );
+ }
+
+ public function test_manager_valid_triggers_constant(): void {
+ $this->assertContains( 'manual', WPDO_Snapshot_Manager::VALID_TRIGGERS );
+ $this->assertContains( 'pre_fsm_transition', WPDO_Snapshot_Manager::VALID_TRIGGERS );
+ $this->assertContains( 'pre_v2_upgrade', WPDO_Snapshot_Manager::VALID_TRIGGERS );
+ $this->assertContains( 'scheduled', WPDO_Snapshot_Manager::VALID_TRIGGERS );
+ $this->assertContains( 'pre_uninstall', WPDO_Snapshot_Manager::VALID_TRIGGERS );
+ }
+}
diff --git a/tests/unit/SyncBridgeTest.php b/tests/unit/SyncBridgeTest.php
new file mode 100644
index 0000000..bbae811
--- /dev/null
+++ b/tests/unit/SyncBridgeTest.php
@@ -0,0 +1,212 @@
+query(). */
+ public static string $last_query = '';
+
+ /** Configurable return value for $wpdb->get_var(). */
+ public static ?string $get_var_return = null;
+
+ protected function setUp(): void {
+ $this->bridge = new WPDO_Sync_Bridge();
+
+ // Reset globals.
+ $GLOBALS['_wp_options'] = [];
+ $GLOBALS['_wp_post_types'] = [];
+ self::$last_query = '';
+ self::$get_var_return = null;
+
+ // Reset private statics via reflection.
+ $ref = new ReflectionClass( WPDO_Sync_Bridge::class );
+ $ref->getProperty( 'bypassing' )->setValue( null, false );
+ $ref->getProperty( 'field_cache' )->setValue( null, [] );
+
+ // Reset Schema Registry singleton.
+ $sr = new ReflectionClass( WPDO_Schema_Registry::class );
+ $sr->getProperty( 'instance' )->setValue( null, null );
+
+ // Reset Feature Flags request cache.
+ $ff = new ReflectionClass( WPDO_Feature_Flags::class );
+ $ff->getProperty( 'cache' )->setValue( null, null );
+
+ $this->setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+ public string $postmeta = 'wp_postmeta';
+ public string $posts = 'wp_posts';
+
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
+ $val = $args[ $i++ ] ?? '';
+ return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
+ }, $sql );
+ }
+
+ public function get_var( string $sql ): ?string {
+ return SyncBridgeTest::$get_var_return;
+ }
+
+ public function get_row( string $sql, $output = OBJECT ) { return null; }
+ public function get_results( string $sql, $output = OBJECT ): array { return []; }
+ public function insert( string $table, array $data, $format = null ): int|false { return 1; }
+ public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; }
+ public function delete( string $table, array $where, $format = null ): int|false { return 1; }
+
+ public function query( string $sql ): int|bool {
+ SyncBridgeTest::$last_query = $sql;
+ return 1;
+ }
+ };
+ }
+
+ // ── Helper: register a hot field ─────────────────────────────────────────
+
+ private function register_hot_field( string $post_type = 'hp_listing', string $meta_key = 'hp_price' ): void {
+ WPDO_Schema_Registry::instance()->register( 'test', [
+ 'post_type' => $post_type,
+ 'meta_key' => $meta_key,
+ 'zone' => 'hot',
+ 'column' => $meta_key,
+ 'type' => 'decimal',
+ ] );
+ }
+
+ // ── intercept_get: early-return guards ───────────────────────────────────
+
+ public function test_intercept_get_returns_null_when_bypassing(): void {
+ $ref = new ReflectionClass( WPDO_Sync_Bridge::class );
+ $ref->getProperty( 'bypassing' )->setValue( null, true );
+
+ $result = $this->bridge->intercept_get( null, 1, 'hp_price', true );
+ $this->assertNull( $result );
+ }
+
+ public function test_intercept_get_returns_null_for_zero_post_id(): void {
+ $result = $this->bridge->intercept_get( null, 0, 'hp_price', true );
+ $this->assertNull( $result );
+ }
+
+ public function test_intercept_get_returns_null_for_empty_meta_key(): void {
+ $result = $this->bridge->intercept_get( null, 1, '', true );
+ $this->assertNull( $result );
+ }
+
+ public function test_intercept_get_returns_null_when_post_type_unknown(): void {
+ // Post ID 99 not in _wp_post_types — get_post_type returns false.
+ $result = $this->bridge->intercept_get( null, 99, 'hp_price', true );
+ $this->assertNull( $result );
+ }
+
+ public function test_intercept_get_returns_null_when_field_not_registered(): void {
+ $GLOBALS['_wp_post_types'][1] = 'hp_listing';
+ // No field registered → returns unchanged $value.
+ $result = $this->bridge->intercept_get( null, 1, 'unregistered_key', true );
+ $this->assertNull( $result );
+ }
+
+ public function test_intercept_get_returns_null_when_module_not_cutover(): void {
+ $GLOBALS['_wp_post_types'][1] = 'hp_listing';
+ $this->register_hot_field();
+ // Module stays idle (not set) → is_read_custom returns false.
+ $result = $this->bridge->intercept_get( null, 1, 'hp_price', true );
+ $this->assertNull( $result );
+ }
+
+ public function test_intercept_get_returns_zone_value_when_cutover(): void {
+ $GLOBALS['_wp_post_types'][2] = 'hp_listing';
+ $this->register_hot_field();
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ self::$get_var_return = '42';
+
+ $result = $this->bridge->intercept_get( null, 2, 'hp_price', true );
+ // Returns array-wrapped value (WordPress unwraps on $single=true).
+ $this->assertSame( [ '42' ], $result );
+ }
+
+ public function test_intercept_get_returns_null_when_zone_returns_null(): void {
+ $GLOBALS['_wp_post_types'][3] = 'hp_listing';
+ $this->register_hot_field();
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
+ self::$get_var_return = null; // Zone returns nothing.
+
+ $result = $this->bridge->intercept_get( null, 3, 'hp_price', true );
+ $this->assertNull( $result );
+ }
+
+ // ── intercept_update ─────────────────────────────────────────────────────
+
+ public function test_intercept_update_skips_when_bypassing(): void {
+ $ref = new ReflectionClass( WPDO_Sync_Bridge::class );
+ $ref->getProperty( 'bypassing' )->setValue( null, true );
+
+ $result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' );
+ $this->assertNull( $result );
+ $this->assertEmpty( self::$last_query );
+ }
+
+ public function test_intercept_update_passes_through_when_no_field_registered(): void {
+ $GLOBALS['_wp_post_types'][1] = 'hp_listing';
+ // No field registered → returns $check unchanged.
+ $result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' );
+ $this->assertNull( $result );
+ }
+
+ public function test_intercept_update_writes_to_zone_when_write_active(): void {
+ $GLOBALS['_wp_post_types'][5] = 'hp_listing';
+ $this->register_hot_field();
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
+
+ $this->bridge->intercept_update( null, 5, 'hp_price', '150', '' );
+
+ // Zone Hot set() executes an UPSERT query.
+ $this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
+ }
+
+ // ── intercept_add ────────────────────────────────────────────────────────
+
+ public function test_intercept_add_writes_when_module_write_active(): void {
+ $GLOBALS['_wp_post_types'][6] = 'hp_listing';
+ $this->register_hot_field();
+ WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
+
+ $this->bridge->intercept_add( null, 6, 'hp_price', '200', false );
+
+ $this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
+ }
+
+ // ── cleanup_post ─────────────────────────────────────────────────────────
+
+ public function test_cleanup_post_does_nothing_for_unknown_post_type(): void {
+ // Post ID 999 has no type → early return.
+ $this->bridge->cleanup_post( 999 );
+ $this->assertEmpty( self::$last_query );
+ }
+
+ public function test_cleanup_post_deletes_hot_zone_data(): void {
+ $GLOBALS['_wp_post_types'][10] = 'hp_listing';
+ $this->register_hot_field();
+
+ $this->bridge->cleanup_post( 10 );
+
+ // WPDO_Zone_Hot::delete() calls $wpdb->delete() — but our mock captures query().
+ // The hot delete uses $wpdb->delete(), not query(). Just assert no exception thrown.
+ $this->assertTrue( true );
+ }
+}
diff --git a/tests/unit/ZoneArchiveTest.php b/tests/unit/ZoneArchiveTest.php
new file mode 100644
index 0000000..61d6328
--- /dev/null
+++ b/tests/unit/ZoneArchiveTest.php
@@ -0,0 +1,302 @@
+insert() calls. */
+ public static array $store = [];
+
+ /** Arguments of the last $wpdb->delete() call. */
+ public static array $last_delete = [];
+
+ protected function setUp(): void {
+ self::$store = [];
+ self::$last_delete = [];
+ $GLOBALS['_wp_postmeta'] = [];
+ $this->setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
+ $val = $args[ $i++ ] ?? '';
+ return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
+ }, $sql );
+ }
+
+ public function insert( string $table, array $data, $format = null ): int|false {
+ ZoneArchiveTest::$store[] = $data;
+ return 1;
+ }
+
+ public function delete( string $table, array $where, $format = null ): int|false {
+ ZoneArchiveTest::$last_delete = [ 'table' => $table, 'where' => $where ];
+ // Remove matching rows (single-column where only).
+ ZoneArchiveTest::$store = array_values( array_filter(
+ ZoneArchiveTest::$store,
+ static function ( array $row ) use ( $where ): bool {
+ foreach ( $where as $col => $val ) {
+ if ( isset( $row[ $col ] ) && (string) $row[ $col ] === (string) $val ) {
+ return false; // row matches → remove.
+ }
+ }
+ return true;
+ }
+ ) );
+ return 1;
+ }
+
+ public function get_results( string $sql, $output = OBJECT ): array {
+ $flat = preg_replace( '/\s+/', ' ', $sql );
+
+ // stats() GROUP BY post_type query.
+ if ( stripos( $flat, 'GROUP BY post_type' ) !== false ) {
+ $by_type = [];
+ foreach ( ZoneArchiveTest::$store as $row ) {
+ $pt = $row['post_type'] ?? 'unknown';
+ $by_type[ $pt ] = ( $by_type[ $pt ] ?? 0 ) + 1;
+ }
+ $result = [];
+ foreach ( $by_type as $pt => $cnt ) {
+ $result[] = [ 'post_type' => $pt, 'cnt' => (string) $cnt ];
+ }
+ return $result;
+ }
+
+ // get() queries — filter by post_id and optional meta_key.
+ $post_id = null;
+ $meta_key = null;
+
+ if ( preg_match( '/post_id = (\d+)/', $flat, $m ) ) {
+ $post_id = (int) $m[1];
+ }
+ if ( preg_match( "/AND meta_key = '([^']+)'/", $flat, $m ) ) {
+ $meta_key = $m[1];
+ }
+
+ $result = [];
+ foreach ( ZoneArchiveTest::$store as $row ) {
+ if ( $post_id !== null && (int) ( $row['post_id'] ?? 0 ) !== $post_id ) {
+ continue;
+ }
+ if ( $meta_key !== null && ( $row['meta_key'] ?? '' ) !== $meta_key ) {
+ continue;
+ }
+ $result[] = [
+ 'meta_key' => $row['meta_key'] ?? '',
+ 'meta_value' => $row['meta_value'] ?? '',
+ 'compressed' => $row['compressed'] ?? 0,
+ 'archived_at' => $row['archived_at'] ?? '',
+ ];
+ }
+ return $result;
+ }
+
+ public function get_var( string $sql ): ?string {
+ $flat = preg_replace( '/\s+/', ' ', $sql );
+
+ if ( stripos( $flat, 'WHERE compressed = 1' ) !== false ) {
+ $count = count( array_filter(
+ ZoneArchiveTest::$store,
+ static fn( array $r ) => (int) ( $r['compressed'] ?? 0 ) === 1
+ ) );
+ return (string) $count;
+ }
+
+ if ( stripos( $flat, 'COUNT(*)' ) !== false ) {
+ return (string) count( ZoneArchiveTest::$store );
+ }
+
+ return null;
+ }
+
+ public function query( string $sql ): int|bool {
+ return 1; // BEGIN, COMMIT, ROLLBACK pass-through.
+ }
+ };
+ }
+
+ // ── table() ──────────────────────────────────────────────────────────────
+
+ public function test_table_returns_archive_table_name(): void {
+ $this->assertSame( 'wp_wpdo_archive', WPDO_Zone_Archive::table() );
+ }
+
+ // ── archive() ────────────────────────────────────────────────────────────
+
+ public function test_archive_inserts_row_with_correct_fields(): void {
+ WPDO_Zone_Archive::archive( 1, 'hp_listing', 'hp_price', '99.99' );
+
+ $this->assertCount( 1, self::$store );
+ $row = self::$store[0];
+ $this->assertSame( 1, $row['post_id'] );
+ $this->assertSame( 'hp_listing', $row['post_type'] );
+ $this->assertSame( 'hp_price', $row['meta_key'] );
+ $this->assertSame( '99.99', $row['meta_value'] );
+ }
+
+ public function test_archive_without_compress_keeps_plaintext_value(): void {
+ WPDO_Zone_Archive::archive( 2, 'hp_listing', 'hp_price', 'plain_value', 0, false );
+
+ $this->assertSame( 'plain_value', self::$store[0]['meta_value'] );
+ $this->assertSame( 0, self::$store[0]['compressed'] );
+ }
+
+ public function test_archive_with_compress_sets_compressed_flag(): void {
+ WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_price', 'compress_me', 0, true );
+
+ $this->assertSame( 1, self::$store[0]['compressed'] );
+ }
+
+ public function test_archive_with_compress_stores_base64_encoded_gzip(): void {
+ $original = 'hello compressed world';
+ WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_bio', $original, 0, true );
+
+ $stored = self::$store[0]['meta_value'];
+ $decoded = base64_decode( $stored, true );
+ $restored = gzdecode( $decoded );
+ $this->assertSame( $original, $restored );
+ }
+
+ public function test_archive_stores_original_meta_id(): void {
+ WPDO_Zone_Archive::archive( 5, 'hp_listing', 'hp_price', '10', 42 );
+
+ $this->assertSame( 42, self::$store[0]['original_meta_id'] );
+ }
+
+ // ── archive_batch() ──────────────────────────────────────────────────────
+
+ public function test_archive_batch_inserts_all_entries(): void {
+ WPDO_Zone_Archive::archive_batch( [
+ [ 'post_id' => 10, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '100', 'meta_id' => 0 ],
+ [ 'post_id' => 11, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '200', 'meta_id' => 0 ],
+ [ 'post_id' => 12, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '300', 'meta_id' => 0 ],
+ ] );
+
+ $this->assertCount( 3, self::$store );
+ }
+
+ public function test_archive_batch_with_empty_entries_is_safe(): void {
+ WPDO_Zone_Archive::archive_batch( [] );
+ $this->assertCount( 0, self::$store );
+ }
+
+ // ── get() ─────────────────────────────────────────────────────────────────
+
+ public function test_get_returns_empty_array_for_missing_post(): void {
+ $result = WPDO_Zone_Archive::get( 999 );
+ $this->assertSame( [], $result );
+ }
+
+ public function test_get_returns_all_entries_for_post(): void {
+ WPDO_Zone_Archive::archive( 20, 'hp_listing', 'hp_price', '50' );
+ WPDO_Zone_Archive::archive( 20, 'hp_listing', 'hp_category', '3' );
+
+ $result = WPDO_Zone_Archive::get( 20 );
+ $this->assertCount( 2, $result );
+ }
+
+ public function test_get_with_meta_key_filter_returns_only_matching(): void {
+ WPDO_Zone_Archive::archive( 21, 'hp_listing', 'hp_price', '150' );
+ WPDO_Zone_Archive::archive( 21, 'hp_listing', 'hp_category', '2' );
+
+ $result = WPDO_Zone_Archive::get( 21, 'hp_price' );
+ $this->assertCount( 1, $result );
+ $this->assertSame( 'hp_price', $result[0]['meta_key'] );
+ }
+
+ public function test_get_decompresses_gzipped_values(): void {
+ $original = 'hello decompressed world';
+ WPDO_Zone_Archive::archive( 22, 'hp_listing', 'hp_bio', $original, 0, true );
+
+ $result = WPDO_Zone_Archive::get( 22 );
+ $this->assertCount( 1, $result );
+ $this->assertSame( $original, $result[0]['meta_value'] );
+ }
+
+ public function test_get_removes_compressed_field_from_result(): void {
+ WPDO_Zone_Archive::archive( 23, 'hp_listing', 'hp_price', '10' );
+
+ $result = WPDO_Zone_Archive::get( 23 );
+ $this->assertArrayNotHasKey( 'compressed', $result[0] );
+ }
+
+ // ── restore() ────────────────────────────────────────────────────────────
+
+ public function test_restore_writes_to_post_meta(): void {
+ WPDO_Zone_Archive::archive( 30, 'hp_listing', 'hp_price', '77' );
+ WPDO_Zone_Archive::archive( 30, 'hp_listing', 'hp_category', '5' );
+
+ WPDO_Zone_Archive::restore( 30 );
+
+ $this->assertSame( '77', $GLOBALS['_wp_postmeta'][30]['hp_price'] );
+ $this->assertSame( '5', $GLOBALS['_wp_postmeta'][30]['hp_category'] );
+ }
+
+ public function test_restore_returns_correct_entry_count(): void {
+ WPDO_Zone_Archive::archive( 31, 'hp_listing', 'hp_price', '88' );
+ WPDO_Zone_Archive::archive( 31, 'hp_listing', 'hp_category', '9' );
+
+ $count = WPDO_Zone_Archive::restore( 31 );
+ $this->assertSame( 2, $count );
+ }
+
+ public function test_restore_returns_zero_for_missing_post(): void {
+ $count = WPDO_Zone_Archive::restore( 999 );
+ $this->assertSame( 0, $count );
+ }
+
+ // ── delete() ─────────────────────────────────────────────────────────────
+
+ public function test_delete_passes_correct_post_id_to_wpdb(): void {
+ WPDO_Zone_Archive::archive( 40, 'hp_listing', 'hp_price', '100' );
+ WPDO_Zone_Archive::delete( 40 );
+
+ $this->assertNotEmpty( self::$last_delete );
+ $this->assertSame( 40, self::$last_delete['where']['post_id'] );
+ }
+
+ // ── stats() ──────────────────────────────────────────────────────────────
+
+ public function test_stats_includes_required_keys(): void {
+ $stats = WPDO_Zone_Archive::stats();
+
+ $this->assertArrayHasKey( 'total_rows', $stats );
+ $this->assertArrayHasKey( 'compressed_rows', $stats );
+ $this->assertArrayHasKey( 'post_types', $stats );
+ }
+
+ public function test_stats_total_and_compressed_counts(): void {
+ WPDO_Zone_Archive::archive( 50, 'hp_listing', 'hp_price', '1', 0, true );
+ WPDO_Zone_Archive::archive( 51, 'hp_listing', 'hp_price', '2', 0, false );
+ WPDO_Zone_Archive::archive( 52, 'hp_listing', 'hp_price', '3', 0, true );
+
+ $stats = WPDO_Zone_Archive::stats();
+ $this->assertSame( 3, $stats['total_rows'] );
+ $this->assertSame( 2, $stats['compressed_rows'] );
+ }
+
+ public function test_stats_post_types_groups_by_type(): void {
+ WPDO_Zone_Archive::archive( 60, 'hp_listing', 'hp_price', '1' );
+ WPDO_Zone_Archive::archive( 61, 'hp_listing', 'hp_price', '2' );
+ WPDO_Zone_Archive::archive( 62, 'hp_vendor', 'hp_bio', 'x' );
+
+ $stats = WPDO_Zone_Archive::stats();
+ $by_type = array_column( $stats['post_types'], 'cnt', 'post_type' );
+ $this->assertSame( '2', $by_type['hp_listing'] );
+ $this->assertSame( '1', $by_type['hp_vendor'] );
+ }
+}
diff --git a/tests/unit/ZoneClassifierTest.php b/tests/unit/ZoneClassifierTest.php
new file mode 100644
index 0000000..0002eec
--- /dev/null
+++ b/tests/unit/ZoneClassifierTest.php
@@ -0,0 +1,143 @@
+getMethod( $method );
+ $m->setAccessible( true );
+ return $m->invokeArgs( null, $args );
+ }
+
+ // ── is_wp_internal() ─────────────────────────────────────────────────────
+
+ public function test_is_wp_internal_returns_true_for_edit_lock(): void {
+ $result = $this->invoke_private( 'is_wp_internal', [ '_edit_lock' ] );
+ $this->assertTrue( $result );
+ }
+
+ public function test_is_wp_internal_returns_true_for_thumbnail_id(): void {
+ $result = $this->invoke_private( 'is_wp_internal', [ '_thumbnail_id' ] );
+ $this->assertTrue( $result );
+ }
+
+ public function test_is_wp_internal_returns_false_for_hp_price(): void {
+ $result = $this->invoke_private( 'is_wp_internal', [ 'hp_price' ] );
+ $this->assertFalse( $result );
+ }
+
+ public function test_is_wp_internal_returns_false_for_custom_key(): void {
+ $result = $this->invoke_private( 'is_wp_internal', [ 'my_custom_meta' ] );
+ $this->assertFalse( $result );
+ }
+
+ // ── score_zones() — hot ──────────────────────────────────────────────────
+
+ public function test_score_zones_hot_for_numeric_short_values(): void {
+ $signals = $this->make_signals( [
+ 'avg_length' => 10,
+ 'numeric_ratio' => 0.9,
+ 'distinct_values' => 5,
+ ] );
+
+ $scores = $this->invoke_private( 'score_zones', [ $signals ] );
+
+ // Hot should outrank cold and archive.
+ $this->assertGreaterThan( $scores['cold'], $scores['hot'] );
+ $this->assertGreaterThan( $scores['archive'], $scores['hot'] );
+ }
+
+ // ── score_zones() — warm ─────────────────────────────────────────────────
+
+ public function test_score_zones_warm_for_transient_prefix(): void {
+ $signals = $this->make_signals( [ 'prefix' => 'transient' ] );
+
+ $scores = $this->invoke_private( 'score_zones', [ $signals ] );
+
+ $this->assertGreaterThanOrEqual( 0.8, $scores['warm'] );
+ }
+
+ // ── score_zones() — cold ─────────────────────────────────────────────────
+
+ public function test_score_zones_cold_for_long_json_values(): void {
+ $signals = $this->make_signals( [
+ 'avg_length' => 300,
+ 'is_json' => true,
+ ] );
+
+ $scores = $this->invoke_private( 'score_zones', [ $signals ] );
+
+ $this->assertGreaterThan( $scores['hot'], $scores['cold'] );
+ $this->assertGreaterThan( $scores['archive'], $scores['cold'] );
+ }
+
+ // ── score_zones() — archive ──────────────────────────────────────────────
+
+ public function test_score_zones_archive_for_high_trash_ratio(): void {
+ $signals = $this->make_signals( [ 'trash_ratio' => 0.7 ] );
+
+ $scores = $this->invoke_private( 'score_zones', [ $signals ] );
+
+ $this->assertGreaterThanOrEqual( 0.6, $scores['archive'] );
+ }
+
+ // ── score_zones() — default ───────────────────────────────────────────────
+
+ public function test_score_zones_defaults_cold_when_no_signals(): void {
+ $signals = $this->make_signals( [] );
+
+ $scores = $this->invoke_private( 'score_zones', [ $signals ] );
+
+ // With avg_length=0, numeric_ratio=0, etc., hot gets 0.3 (avg_length<50 is true for 0).
+ // Cold must have at least a non-zero score (either from scoring or the fallback).
+ $max = max( $scores );
+ $this->assertGreaterThan( 0.0, $max );
+ $this->assertGreaterThan( 0.0, $scores['cold'] + $scores['hot'] ); // At least one has a score.
+ }
+
+ // ── build_reasons() ──────────────────────────────────────────────────────
+
+ public function test_build_reasons_hot_includes_numeric_message(): void {
+ $signals = $this->make_signals( [
+ 'avg_length' => 10,
+ 'numeric_ratio' => 0.9,
+ 'distinct_values' => 5,
+ ] );
+
+ $reasons = $this->invoke_private( 'build_reasons', [ $signals, 'hot' ] );
+
+ $combined = implode( ' ', $reasons );
+ $this->assertStringContainsStringIgnoringCase( 'numeric', $combined );
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ /**
+ * Build a signals array with defaults, overriding specific keys.
+ */
+ private function make_signals( array $overrides ): array {
+ return array_merge( [
+ 'meta_key' => 'test_key',
+ 'row_count' => 100,
+ 'avg_length' => 0,
+ 'max_length' => 0,
+ 'distinct_values' => 0,
+ 'numeric_ratio' => 0.0,
+ 'trash_ratio' => 0.0,
+ 'is_serialized' => false,
+ 'is_json' => false,
+ 'prefix' => '',
+ ], $overrides );
+ }
+}
diff --git a/tests/unit/ZoneColdTest.php b/tests/unit/ZoneColdTest.php
new file mode 100644
index 0000000..41a33ef
--- /dev/null
+++ b/tests/unit/ZoneColdTest.php
@@ -0,0 +1,229 @@
+ json string */
+ public static array $db_store = [];
+
+ /** Control whether get_var returns the "id" existence check */
+ public static bool $row_exists = false;
+
+ protected function setUp(): void {
+ self::$db_store = [];
+ self::$row_exists = false;
+ $GLOBALS['_wp_cache'] = [];
+ $this->setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
+ $val = $args[ $i++ ] ?? '';
+ return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
+ }, $sql );
+ }
+
+ /**
+ * get_var is used for two things:
+ * 1. SELECT data ... → return JSON blob
+ * 2. SELECT id ... → return '1' if exists, else null
+ */
+ public function get_var( string $sql ): ?string {
+ $flat = preg_replace( '/\s+/', ' ', $sql );
+
+ // Existence check (save_blob path).
+ if ( stripos( $flat, 'SELECT id' ) !== false ) {
+ if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) {
+ return isset( ZoneColdTest::$db_store[ (int) $m[1] ] ) ? '1' : null;
+ }
+ return null;
+ }
+
+ // Data fetch.
+ if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) {
+ return ZoneColdTest::$db_store[ (int) $m[1] ] ?? null;
+ }
+
+ return null;
+ }
+
+ public function get_row( string $sql, $output = OBJECT ) {
+ return null;
+ }
+
+ public function get_results( string $sql, $output = OBJECT ): array {
+ return [];
+ }
+
+ public function insert( string $table, array $data, $format = null ): int|false {
+ if ( isset( $data['post_id'], $data['data'] ) ) {
+ ZoneColdTest::$db_store[ (int) $data['post_id'] ] = $data['data'];
+ }
+ return 1;
+ }
+
+ public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
+ if ( isset( $where['post_id'], $data['data'] ) ) {
+ ZoneColdTest::$db_store[ (int) $where['post_id'] ] = $data['data'];
+ }
+ return 1;
+ }
+
+ public function delete( string $table, array $where, $format = null ): int|false {
+ if ( isset( $where['post_id'] ) ) {
+ unset( ZoneColdTest::$db_store[ (int) $where['post_id'] ] );
+ }
+ return 1;
+ }
+
+ public function query( string $sql ): int|bool {
+ return 1;
+ }
+ };
+ }
+
+ // ── table() ──────────────────────────────────────────────────────────────
+
+ public function test_table_returns_prefixed_name(): void {
+ $this->assertSame( 'wp_wpdo_cold_hp_listing', WPDO_Zone_Cold::table( 'hp_listing' ) );
+ }
+
+ // ── get() ─────────────────────────────────────────────────────────────────
+
+ public function test_get_returns_null_for_missing_key(): void {
+ // Cache miss + no DB row → blob is empty array → key missing → null.
+ $result = WPDO_Zone_Cold::get( 99, 'hp_listing', 'hp_description' );
+ $this->assertNull( $result );
+ }
+
+ public function test_get_reads_from_cache_on_hit(): void {
+ // Pre-populate cache so DB should NOT be hit.
+ $group = 'wpdo_cold_hp_listing';
+ $cache_key = 'cold_1';
+ $GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'hp_bio' => 'cached value' ];
+
+ $result = WPDO_Zone_Cold::get( 1, 'hp_listing', 'hp_bio' );
+ $this->assertSame( 'cached value', $result );
+
+ // DB store should remain empty (DB was not queried for data).
+ $this->assertEmpty( self::$db_store );
+ }
+
+ // ── get_blob() ───────────────────────────────────────────────────────────
+
+ public function test_get_blob_queries_db_on_cache_miss(): void {
+ self::$db_store[5] = json_encode( [ 'hp_description' => 'Hello World', 'hp_location' => 'Paris' ] );
+ $blob = WPDO_Zone_Cold::get_blob( 5, 'hp_listing' );
+ $this->assertSame( 'Hello World', $blob['hp_description'] );
+ $this->assertSame( 'Paris', $blob['hp_location'] );
+ }
+
+ // ── set() ─────────────────────────────────────────────────────────────────
+
+ public function test_set_merges_new_key_into_blob(): void {
+ // Seed an existing blob.
+ self::$db_store[10] = json_encode( [ 'a' => 1 ] );
+
+ WPDO_Zone_Cold::set( 10, 'hp_listing', 'b', 2 );
+
+ $stored = json_decode( self::$db_store[10], true );
+ $this->assertArrayHasKey( 'a', $stored );
+ $this->assertArrayHasKey( 'b', $stored );
+ $this->assertSame( 1, $stored['a'] );
+ $this->assertSame( 2, $stored['b'] );
+ }
+
+ // ── set_many() ───────────────────────────────────────────────────────────
+
+ public function test_set_many_merges_multiple_keys(): void {
+ WPDO_Zone_Cold::set_many( 20, 'hp_listing', [
+ 'key1' => 'v1',
+ 'key2' => 'v2',
+ 'key3' => 'v3',
+ ] );
+
+ $stored = json_decode( self::$db_store[20], true );
+ $this->assertSame( 'v1', $stored['key1'] );
+ $this->assertSame( 'v2', $stored['key2'] );
+ $this->assertSame( 'v3', $stored['key3'] );
+ }
+
+ // ── remove() ─────────────────────────────────────────────────────────────
+
+ public function test_remove_deletes_key_from_blob(): void {
+ self::$db_store[30] = json_encode( [ 'keep' => 'yes', 'drop' => 'no' ] );
+
+ WPDO_Zone_Cold::remove( 30, 'hp_listing', 'drop' );
+
+ $stored = json_decode( self::$db_store[30], true );
+ $this->assertArrayHasKey( 'keep', $stored );
+ $this->assertArrayNotHasKey( 'drop', $stored );
+ }
+
+ // ── delete() ─────────────────────────────────────────────────────────────
+
+ public function test_delete_clears_cache(): void {
+ $group = 'wpdo_cold_hp_listing';
+ $cache_key = 'cold_1';
+
+ // Pre-populate cache.
+ $GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'some' => 'data' ];
+
+ WPDO_Zone_Cold::delete( 1, 'hp_listing' );
+
+ // Cache entry must be gone.
+ $this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) );
+ }
+
+ // ── Additional edge-case tests ────────────────────────────────────────────
+
+ public function test_set_invalidates_object_cache(): void {
+ $group = 'wpdo_cold_hp_listing';
+ $cache_key = 'cold_50';
+
+ // Pre-populate cache with stale data.
+ $GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'stale' => 'old_value' ];
+
+ WPDO_Zone_Cold::set( 50, 'hp_listing', 'fresh', 'new_value' );
+
+ // Cache must be invalidated after write.
+ $this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) );
+ }
+
+ public function test_get_blob_populates_cache_on_db_hit(): void {
+ // Seed the DB store so get_blob has something to fetch.
+ self::$db_store[60] = json_encode( [ 'cached_key' => 'cached_val' ] );
+
+ WPDO_Zone_Cold::get_blob( 60, 'hp_listing' );
+
+ // Cache must now contain the fetched data.
+ $group = 'wpdo_cold_hp_listing';
+ $cached = $GLOBALS['_wp_cache'][ $group ]['cold_60'] ?? false;
+ $this->assertIsArray( $cached );
+ $this->assertSame( 'cached_val', $cached['cached_key'] );
+ }
+
+ public function test_remove_nonexistent_key_is_safe(): void {
+ // Store an existing blob.
+ self::$db_store[70] = json_encode( [ 'keep' => 'this' ] );
+
+ // Remove a key that doesn't exist — should not throw.
+ WPDO_Zone_Cold::remove( 70, 'hp_listing', 'nonexistent_key' );
+
+ $stored = json_decode( self::$db_store[70], true );
+ $this->assertArrayHasKey( 'keep', $stored );
+ $this->assertArrayNotHasKey( 'nonexistent_key', $stored );
+ }
+}
diff --git a/tests/unit/ZoneHotTest.php b/tests/unit/ZoneHotTest.php
new file mode 100644
index 0000000..dd1ce1d
--- /dev/null
+++ b/tests/unit/ZoneHotTest.php
@@ -0,0 +1,161 @@
+query(). */
+ public static string $last_query = '';
+
+ /** Arguments passed to $wpdb->delete(). */
+ public static array $last_delete = [];
+
+ /** Return value for get_var mock. */
+ public static ?string $get_var_return = null;
+
+ /** Return value for get_row mock. */
+ public static mixed $get_row_return = null;
+
+ protected function setUp(): void {
+ self::$last_query = '';
+ self::$last_delete = [];
+ self::$get_var_return = null;
+ self::$get_row_return = null;
+ $this->setup_wpdb_mock();
+ }
+
+ private function setup_wpdb_mock(): void {
+ global $wpdb;
+
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
+ $val = $args[ $i++ ] ?? '';
+ return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
+ }, $sql );
+ }
+
+ public function get_var( string $sql ): ?string {
+ return ZoneHotTest::$get_var_return;
+ }
+
+ public function get_row( string $sql, $output = OBJECT ) {
+ return ZoneHotTest::$get_row_return;
+ }
+
+ public function get_results( string $sql, $output = OBJECT ): array {
+ return [];
+ }
+
+ public function insert( string $table, array $data, $format = null ): int|false {
+ return 1;
+ }
+
+ public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
+ return 1;
+ }
+
+ public function delete( string $table, array $where, $format = null ): int|false {
+ ZoneHotTest::$last_delete = [ 'table' => $table, 'where' => $where ];
+ return 1;
+ }
+
+ public function query( string $sql ): int|bool {
+ ZoneHotTest::$last_query = $sql;
+ return 1;
+ }
+ };
+ }
+
+ // ── table() ──────────────────────────────────────────────────────────────
+
+ public function test_table_returns_prefixed_name(): void {
+ $this->assertSame( 'wp_wpdo_hot_hp_listing', WPDO_Zone_Hot::table( 'hp_listing' ) );
+ }
+
+ public function test_table_sanitizes_post_type(): void {
+ // The test sanitize_key stub strips non-[a-z0-9_-] chars then lowercases.
+ // 'HP Listing!' → strip uppercase H,P + space + '!' → 'isting' → lower → 'isting'.
+ $sanitized = sanitize_key( 'HP Listing!' );
+ $this->assertSame( 'wp_wpdo_hot_' . $sanitized, WPDO_Zone_Hot::table( 'HP Listing!' ) );
+ }
+
+ // ── get() ─────────────────────────────────────────────────────────────────
+
+ public function test_get_returns_null_when_row_missing(): void {
+ self::$get_var_return = null;
+ $result = WPDO_Zone_Hot::get( 1, 'hp_listing', 'hp_price' );
+ $this->assertNull( $result );
+ }
+
+ public function test_get_returns_value_from_db(): void {
+ self::$get_var_return = '42';
+ $result = WPDO_Zone_Hot::get( 1, 'hp_listing', 'hp_price' );
+ $this->assertSame( '42', $result );
+ }
+
+ // ── get_row() ────────────────────────────────────────────────────────────
+
+ public function test_get_row_returns_null_when_missing(): void {
+ self::$get_row_return = null;
+ $result = WPDO_Zone_Hot::get_row( 1, 'hp_listing' );
+ $this->assertNull( $result );
+ }
+
+ public function test_get_row_returns_array(): void {
+ $expected = [ 'post_id' => 1, 'hp_price' => '100', 'hp_location' => 'NYC' ];
+ self::$get_row_return = $expected;
+ $result = WPDO_Zone_Hot::get_row( 1, 'hp_listing' );
+ $this->assertSame( $expected, $result );
+ }
+
+ // ── set() ─────────────────────────────────────────────────────────────────
+
+ public function test_set_executes_upsert_query(): void {
+ WPDO_Zone_Hot::set( 5, 'hp_listing', 'hp_price', '99' );
+ $this->assertNotEmpty( self::$last_query, 'Expected $wpdb->query() to be called' );
+ $this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
+ }
+
+ // ── set_many() ───────────────────────────────────────────────────────────
+
+ public function test_set_many_includes_all_columns_in_upsert(): void {
+ WPDO_Zone_Hot::set_many( 7, 'hp_listing', [
+ 'hp_price' => '150',
+ 'hp_location' => 'LA',
+ 'hp_category' => '3',
+ ] );
+ $this->assertNotEmpty( self::$last_query );
+ $this->assertStringContainsString( 'hp_price', self::$last_query );
+ $this->assertStringContainsString( 'hp_location', self::$last_query );
+ $this->assertStringContainsString( 'hp_category', self::$last_query );
+ }
+
+ // ── delete() ─────────────────────────────────────────────────────────────
+
+ public function test_delete_calls_wpdb_delete(): void {
+ WPDO_Zone_Hot::delete( 42, 'hp_listing' );
+ $this->assertNotEmpty( self::$last_delete, 'Expected $wpdb->delete() to be called' );
+ $this->assertSame( 42, self::$last_delete['where']['post_id'] );
+ }
+
+ public function test_delete_table_name_contains_post_type(): void {
+ WPDO_Zone_Hot::delete( 5, 'hp_vendor' );
+ $this->assertStringContainsString( 'hp_vendor', self::$last_delete['table'] );
+ }
+
+ // ── Additional edge cases ─────────────────────────────────────────────────
+
+ public function test_table_for_different_post_type(): void {
+ $this->assertSame( 'wp_wpdo_hot_hp_vendor', WPDO_Zone_Hot::table( 'hp_vendor' ) );
+ }
+}
diff --git a/tests/unit/ZoneWarmTest.php b/tests/unit/ZoneWarmTest.php
new file mode 100644
index 0000000..552be78
--- /dev/null
+++ b/tests/unit/ZoneWarmTest.php
@@ -0,0 +1,183 @@
+ meta_key => [value, expires_at] */
+ public static array $store = [];
+
+ protected function setUp(): void {
+ self::$store = [];
+ $this->setupWpdbMock();
+ }
+
+ private function setupWpdbMock(): void {
+ global $wpdb;
+
+ $wpdb = new class {
+ public string $prefix = 'wp_';
+
+ public function prepare( string $sql, ...$args ): string {
+ $i = 0;
+ return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
+ $val = $args[ $i++ ] ?? '';
+ return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
+ }, $sql );
+ }
+
+ public function get_var( string $sql ): ?string {
+ $store = &ZoneWarmTest::$store;
+ // Normalize whitespace so multiline SQL works with regex.
+ $flat = preg_replace( '/\s+/', ' ', $sql );
+ if ( preg_match( '/SELECT id.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
+ return isset( $store[ $m[1] ][ $m[2] ] ) ? '1' : null;
+ }
+ if ( preg_match( '/SELECT meta_value.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
+ $entry = $store[ $m[1] ][ $m[2] ] ?? null;
+ if ( ! $entry ) {
+ return null;
+ }
+ if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
+ return null;
+ }
+ return $entry['value'];
+ }
+ return null;
+ }
+
+ public function get_results( string $sql, $output = null ): array {
+ $store = ZoneWarmTest::$store;
+ $result = [];
+ foreach ( $store as $post_id => $keys ) {
+ foreach ( $keys as $meta_key => $entry ) {
+ if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
+ continue;
+ }
+ $result[] = (object) [ 'meta_key' => $meta_key, 'meta_value' => $entry['value'] ];
+ }
+ }
+ return $result;
+ }
+
+ public function insert( string $table, array $data, $format = null ): int|false {
+ ZoneWarmTest::$store[ $data['post_id'] ][ $data['meta_key'] ] = [
+ 'value' => $data['meta_value'],
+ 'expires_at' => isset( $data['expires_at'] ) ? strtotime( $data['expires_at'] ) : null,
+ ];
+ return 1;
+ }
+
+ public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
+ // For simplicity, find by scanning store.
+ foreach ( ZoneWarmTest::$store as $post_id => &$keys ) {
+ foreach ( $keys as $meta_key => &$entry ) {
+ if ( isset( $data['meta_value'] ) ) {
+ $entry['value'] = $data['meta_value'];
+ }
+ if ( isset( $data['expires_at'] ) ) {
+ $entry['expires_at'] = strtotime( $data['expires_at'] );
+ }
+ }
+ }
+ return 1;
+ }
+
+ public function delete( string $table, array $where, $format = null ): int|false {
+ $post_id = $where['post_id'] ?? null;
+ $meta_key = $where['meta_key'] ?? null;
+
+ if ( $post_id && $meta_key ) {
+ unset( ZoneWarmTest::$store[ $post_id ][ $meta_key ] );
+ } elseif ( $post_id ) {
+ unset( ZoneWarmTest::$store[ $post_id ] );
+ }
+ return 1;
+ }
+
+ public function query( string $sql ): int|bool {
+ return 0;
+ }
+ };
+ }
+
+ // ── set / get ────────────────────────────────────────────────────────────
+
+ public function test_set_and_get_basic_value(): void {
+ WPDO_Zone_Warm::set( 1, 'hp_views', '42' );
+ $this->assertSame( '42', WPDO_Zone_Warm::get( 1, 'hp_views' ) );
+ }
+
+ public function test_get_returns_null_for_missing_key(): void {
+ $this->assertNull( WPDO_Zone_Warm::get( 99, 'missing_key' ) );
+ }
+
+ public function test_set_overwrites_existing_value(): void {
+ WPDO_Zone_Warm::set( 1, 'hp_views', '10' );
+ WPDO_Zone_Warm::set( 1, 'hp_views', '20' );
+ // The store update mock replaces all entries for simplicity.
+ $this->assertNotNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) );
+ }
+
+ // ── TTL / expiry ─────────────────────────────────────────────────────────
+
+ public function test_set_with_ttl_stores_future_expiry(): void {
+ WPDO_Zone_Warm::set( 1, 'hp_views', '5', 3600 );
+ $this->assertSame( '5', WPDO_Zone_Warm::get( 1, 'hp_views' ) );
+ }
+
+ public function test_expired_entry_returns_null(): void {
+ // Insert directly with a past expiry.
+ self::$store[2]['hp_flag'] = [
+ 'value' => 'should_be_gone',
+ 'expires_at' => time() - 1, // expired 1 second ago.
+ ];
+ $this->assertNull( WPDO_Zone_Warm::get( 2, 'hp_flag' ) );
+ }
+
+ // ── delete ───────────────────────────────────────────────────────────────
+
+ public function test_delete_removes_key(): void {
+ WPDO_Zone_Warm::set( 1, 'hp_views', '7' );
+ WPDO_Zone_Warm::delete( 1, 'hp_views' );
+ $this->assertNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) );
+ }
+
+ public function test_delete_all_removes_all_post_keys(): void {
+ WPDO_Zone_Warm::set( 3, 'key_a', 'val_a' );
+ WPDO_Zone_Warm::set( 3, 'key_b', 'val_b' );
+ WPDO_Zone_Warm::delete_all( 3 );
+
+ $this->assertEmpty( self::$store[3] ?? [] );
+ }
+
+ // ── Additional tests ─────────────────────────────────────────────────────
+
+ public function test_table_returns_warm_table_name(): void {
+ $this->assertSame( 'wp_wpdo_warm', WPDO_Zone_Warm::table() );
+ }
+
+ public function test_delete_specific_key_leaves_other_keys_intact(): void {
+ WPDO_Zone_Warm::set( 4, 'key_keep', 'val_keep' );
+ WPDO_Zone_Warm::set( 4, 'key_drop', 'val_drop' );
+ WPDO_Zone_Warm::delete( 4, 'key_drop' );
+
+ $this->assertNull( WPDO_Zone_Warm::get( 4, 'key_drop' ) );
+ // key_keep should still be readable.
+ $this->assertNotNull( WPDO_Zone_Warm::get( 4, 'key_keep' ) );
+ }
+
+ public function test_different_posts_with_same_meta_key_are_independent(): void {
+ WPDO_Zone_Warm::set( 5, 'shared_key', 'value_for_5' );
+ WPDO_Zone_Warm::set( 6, 'shared_key', 'value_for_6' );
+
+ $this->assertSame( 'value_for_5', WPDO_Zone_Warm::get( 5, 'shared_key' ) );
+ $this->assertSame( 'value_for_6', WPDO_Zone_Warm::get( 6, 'shared_key' ) );
+ }
+}
diff --git a/uninstall.php b/uninstall.php
new file mode 100644
index 0000000..a572dfa
--- /dev/null
+++ b/uninstall.php
@@ -0,0 +1,244 @@
+ --apply` to recover.
+ *
+ * v2.14.0 multisite hardening: when network-uninstalled, iterate all sites
+ * via switch_to_blog so every wp_N_wpdo_* table is dropped (previously only
+ * the main site got cleaned, leaving orphans on every other site).
+ *
+ * @package WP_Data_Optimizer
+ */
+
+if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
+ exit;
+}
+
+global $wpdb;
+
+// ── Load shared cleanup logic + snapshot classes ─────────────────────────
+$bootstrap_files = array(
+ __DIR__ . '/includes/class-tmdo-logger.php',
+ __DIR__ . '/includes/class-tmdo-feature-flags.php',
+ __DIR__ . '/includes/class-tmdo-installer.php',
+ __DIR__ . '/includes/snapshots/class-tmdo-snapshot-manager.php',
+ __DIR__ . '/includes/snapshots/class-tmdo-snapshot-writer.php',
+ __DIR__ . '/includes/snapshots/class-tmdo-snapshot-reader.php',
+ __DIR__ . '/includes/snapshots/class-tmdo-snapshot-pruner.php',
+);
+$bootstrap_ok = true;
+foreach ( $bootstrap_files as $f ) {
+ if ( ! is_file( $f ) ) {
+ $bootstrap_ok = false;
+ continue;
+ }
+ require_once $f;
+}
+
+// ── v2.2.0 M1.5: pre-uninstall snapshot (main-site context only) ─────────
+// Snapshot is taken once on the main site — it captures the catalog of all
+// wpdo_snapshots rows (which is per-site so per-site snapshots survive the
+// site-level table DROP that happens later). Best-effort.
+if ( $bootstrap_ok && class_exists( 'TMDO_Snapshot_Manager' ) ) {
+ $result = TMDO_Snapshot_Manager::create(
+ 'pre_uninstall',
+ array(),
+ array(
+ 'notes' => 'Auto-snapshot taken at plugin uninstall time',
+ 'retention_days' => 365,
+ 'inline_threshold_bytes' => 0,
+ )
+ );
+ if ( ! empty( $result['ok'] ) ) {
+ $snapshot_id = $result['snapshot_id'];
+ $dir = TMDO_Snapshot_Manager::backup_dir();
+ if ( is_dir( $dir ) ) {
+ file_put_contents(
+ trailingslashit( $dir ) . 'README-pre-uninstall.txt',
+ "WPDO pre-uninstall snapshot: {$snapshot_id}\n"
+ . 'Created: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n\n"
+ . "To restore after re-installing wp-data-optimizer:\n"
+ . " 1. Re-install the plugin (it will re-create wp_wpdo_snapshots).\n"
+ . " 2. Manually re-insert this snapshot's catalog row, or use wp wpdo snapshot list to confirm.\n"
+ . " 3. Run: wp wpdo snapshot restore {$snapshot_id} --apply\n"
+ );
+ }
+ }
+}
+
+// ── v2.14.0: shared cleanup helper ───────────────────────────────────────
+// Falls back to inline DROP loop if the helper class isn't loadable
+// (e.g. partially-broken install where includes/class-tmdo-installer.php
+// is missing). The inline path is the original v2.13.x logic verbatim.
+$has_helper = class_exists( 'TMDO_Installer' )
+ && method_exists( 'TMDO_Installer', 'drop_all_tables_for_current_blog' );
+
+/**
+ * Run the cleanup for the current blog (helper-aware).
+ */
+$run_cleanup = static function () use ( $has_helper ): void {
+ global $wpdb;
+
+ if ( $has_helper ) {
+ TMDO_Installer::drop_all_tables_for_current_blog();
+ return;
+ }
+
+ // ── Fallback inline path (helper unavailable) ─────────────────────────
+ $tables = array(
+ $wpdb->prefix . 'wpdo_migrations',
+ $wpdb->prefix . 'wpdo_errors',
+ $wpdb->prefix . 'wpdo_benchmarks',
+ $wpdb->prefix . 'wpdo_warm',
+ $wpdb->prefix . 'wpdo_archive',
+ $wpdb->prefix . 'wpdo_audit',
+ $wpdb->prefix . 'wpdo_shadow_diffs',
+ $wpdb->prefix . 'wpdo_site_metrics',
+ $wpdb->prefix . 'wpdo_registry_meta',
+ $wpdb->prefix . 'wpdo_uni_options',
+ $wpdb->prefix . 'wpdo_wc_commissions',
+ $wpdb->prefix . 'wpdo_snapshots',
+ $wpdb->prefix . 'wpdo_migration_status',
+ $wpdb->prefix . 'wpdo_user_points_ledger',
+ $wpdb->prefix . 'wpdo_user_membership',
+ $wpdb->prefix . 'wpdo_user_activity',
+ $wpdb->prefix . 'wpdo_user_profile',
+ $wpdb->prefix . 'wpdo_user_sso',
+ $wpdb->prefix . 'wpdo_user_core_profile',
+ $wpdb->prefix . 'wpdo_user_social',
+ $wpdb->prefix . 'wpdo_user_commerce',
+ $wpdb->prefix . 'wpdo_user_hp_user',
+ $wpdb->prefix . 'wpdo_user_admin_prefs',
+ $wpdb->prefix . 'wpdo_post_wp_core',
+ $wpdb->prefix . 'wpdo_post_attachment',
+ $wpdb->prefix . 'wpdo_post_wc_product',
+ $wpdb->prefix . 'wpdo_post_hp_listing_core',
+ $wpdb->prefix . 'wpdo_post_hp_request_core',
+ $wpdb->prefix . 'wpdo_post_hp_vendor_core',
+ $wpdb->prefix . 'wpdo_post_nav_menu_item',
+ $wpdb->prefix . 'wpdo_hot_hp_listing',
+ $wpdb->prefix . 'wpdo_term_hp_taxonomy',
+ $wpdb->prefix . 'wpdo_comment_hp_review',
+ $wpdb->prefix . 'wpdo_term_misc',
+ $wpdb->prefix . 'wpdo_comment_misc',
+ // v3.0.0: HivePress family integration tables.
+ $wpdb->prefix . 'wpdo_hot_hp_request',
+ $wpdb->prefix . 'wpdo_hot_hp_membership',
+ $wpdb->prefix . 'wpdo_hot_hp_membership_plan',
+ $wpdb->prefix . 'wpdo_hot_hp_booking',
+ $wpdb->prefix . 'wpdo_comment_hp_message',
+ $wpdb->prefix . 'wpdo_comment_hp_favorite',
+ $wpdb->prefix . 'wpdo_comment_hp_offer',
+ $wpdb->prefix . 'wpdo_term_hp_listing_tag',
+ );
+
+ $hot_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_hot_' ) . '%';
+ $cold_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_cold_' ) . '%';
+ $entity_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_user_' ) . '%';
+ $post_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%';
+ $term_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_term_' ) . '%';
+ $comment_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_comment_' ) . '%';
+
+ if ( class_exists( 'WP_SQLite_Driver' ) ) {
+ $dynamic = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s)",
+ $hot_like,
+ $cold_like,
+ $entity_like,
+ $post_ent_like,
+ $term_ent_like,
+ $comment_ent_like
+ )
+ );
+ } else {
+ $dynamic = $wpdb->get_col(
+ $wpdb->prepare(
+ 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)',
+ $hot_like,
+ $cold_like,
+ $entity_like,
+ $post_ent_like,
+ $term_ent_like,
+ $comment_ent_like
+ )
+ );
+ }
+
+ $tables = array_unique( array_merge( $tables, $dynamic ?: array() ) );
+ $prefix = $wpdb->prefix . 'wpdo_';
+ foreach ( $tables as $table ) {
+ if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) {
+ continue;
+ }
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
+ }
+
+ delete_option( 'wpdo_db_version' );
+ delete_option( 'wpdo_features' );
+ delete_option( 'wpdo_features_shadow' );
+ delete_option( 'wpdo_hpct_imported' );
+ delete_option( 'wpdo_hook_bus_enabled' );
+ delete_option( 'wpdo_v2_features_backup' );
+ delete_option( 'wpdo_v2_upgrade_status' );
+ delete_option( 'wpdo_v2_upgrade_error' );
+ delete_option( 'wpdo_v2_upgraded_at' );
+ delete_option( 'wpdo_health_alert' );
+ delete_option( 'wpdo_rl_stats' );
+ delete_option( 'wpdo_setup_wizard_completed' );
+ delete_option( 'wpdo_first_run_at' );
+
+ wp_clear_scheduled_hook( 'wpdo_warm_cleanup' );
+ wp_clear_scheduled_hook( 'wpdo_archive_sweep' );
+ wp_clear_scheduled_hook( 'wpdo_errors_gc' );
+ wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
+ wp_clear_scheduled_hook( 'wpdo_daily_health_check' );
+ wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' );
+};
+
+// ── Run cleanup ──────────────────────────────────────────────────────────
+// v2.14.0: when network-uninstalling on multisite, iterate every site so each
+// wp_N_wpdo_* table set is dropped. Pre-v2.14.0 only the main site got cleaned.
+if ( is_multisite() ) {
+ $is_network_active = function_exists( 'is_plugin_active_for_network' )
+ && is_plugin_active_for_network( plugin_basename( __FILE__ ) );
+
+ if ( $is_network_active ) {
+ // Iterate all sites in batches.
+ $offset = 0;
+ $batch = 100;
+ do {
+ $site_ids = get_sites(
+ array(
+ 'number' => $batch,
+ 'offset' => $offset,
+ 'fields' => 'ids',
+ )
+ );
+ foreach ( $site_ids as $wpdo_blog_id ) {
+ switch_to_blog( (int) $wpdo_blog_id );
+ try {
+ $run_cleanup();
+ } finally {
+ restore_current_blog();
+ }
+ }
+ $offset += $batch;
+ $batch_size = count( $site_ids );
+ } while ( $batch_size === $batch );
+ } else {
+ // Per-site activation: clean only the current site.
+ $run_cleanup();
+ }
+} else {
+ // Single-site install: run once.
+ $run_cleanup();
+}
+ +
+ + +wpdo_comment_hp_reviewwpdo_comment_misc+ + ✅ + + ⚠️ + ' . esc_html( $current_mode ) . '' + ); + ?> + +
++ +
++-
+ ',
+ ''
+ );
+ ?>
+
+ →
+
+
+ -
+
+
+
++ + +
++ + + + +
++ +
++ +
+ ++ +
+ +
+