diff --git a/2meet-data-optimizer-hivepress-addon.php b/2meet-data-optimizer-hivepress-addon.php
index 5ef54fb..35cc1c6 100644
--- a/2meet-data-optimizer-hivepress-addon.php
+++ b/2meet-data-optimizer-hivepress-addon.php
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer — Hivepress AddOn
* Plugin URI: https://2meet.io/2meet-data-optimizer/hivepress
* Description: HivePress + 12 擴充整合:13 adapter (core/reviews/bookings/messages/memberships/requests/favorites/statistics/tags/seo/social-links/blocks/marketplace) + 7 HPCT interceptor + 5 query interceptor + admin tab + CLI namespace
- * Version: 0.2.0
+ * Version: 0.3.0
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
@@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
-define( 'TMDO_HIVEPRESS_VERSION', '0.2.0' );
+define( 'TMDO_HIVEPRESS_VERSION', '0.3.0' );
define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) );
define( 'TMDO_HIVEPRESS_FILE', __FILE__ );
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03aae0e..f57af6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,41 @@ Versioning follows [Semantic Versioning](https://semver.org/).
---
+## [0.3.0] — 2026-09-27
+
+Released with 2meet-data-optimizer 1.2.0 (hardening 2.2).
+
+### Changed
+
+- With a persistent object cache (`wp_using_ext_object_cache()`), the
+ transient filter keeps HivePress's per-post transient pairs in the
+ `tmdo_hp_transient` cache group instead of `wp_options`, where they piled
+ up (1.15 M pairs on dev36). The cache is then the only store: rows written
+ before are neither read nor deleted (HivePress rebuilds those caches once),
+ because a `get_option()` miss adds the name to WordPress's `notoptions` for
+ good and a hit keeps the old row in the `options` cache group with no expiry.
+ Without an object cache nothing changes. After upgrading, remove the rows
+ left behind with core's `wp tmdo cleanup-hp-transients --options --confirm`.
+
+### Fixed
+
+- The detected / bound status cells of the HivePress admin table go through
+ `esc_html()` (2.1 audit); output unchanged.
+
+### Docs
+
+- `docs/REGISTERED-KEYS.md`: the six registered keys HivePress never writes
+ as meta, because its models alias them to core columns (listing
+ `hp_vendor` → `post_parent`, `hp_status` → `post_status`; request
+ `hp_status`, `hp_user`; vendor `hp_user`; review `hp_rating` →
+ `comment_karma`). The schema stays; their flat columns are always NULL.
+- `docs/HIVEPRESS-COVERAGE.md`: dev36 inventory of the meta each HivePress
+ post type holds, where the extensions keep their data and the query shapes
+ the front end produces. Attribute auto-registration is not needed now:
+ every meta key HivePress queries is registered.
+
+---
+
## [0.2.0] — 2026-09-26
Released with 2meet-data-optimizer 1.1.0 (hardening 2.1, search-index mode).
diff --git a/README.md b/README.md
index 0cce256..2a5f5de 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,9 @@ reviews / messages / memberships / requests / listing-meta
---
+Registered keys HivePress keeps in core columns instead of meta (`hp_vendor`, `hp_status`, …): see [docs/REGISTERED-KEYS.md](docs/REGISTERED-KEYS.md).
+What the HivePress extensions write and how they query it (dev36 inventory, D04 decision): see [docs/HIVEPRESS-COVERAGE.md](docs/HIVEPRESS-COVERAGE.md).
+
## Requirements
- WordPress ≥ 6.0
diff --git a/admin/class-tmdo-admin-hivepress.php b/admin/class-tmdo-admin-hivepress.php
index ebe1c68..c1638a4 100644
--- a/admin/class-tmdo-admin-hivepress.php
+++ b/admin/class-tmdo-admin-hivepress.php
@@ -85,9 +85,9 @@ if ( ! class_exists( 'TMDO_Admin_HivePress' ) ) {
|
|
- |
+ |
|
- |
+ |
|
diff --git a/docs/HIVEPRESS-COVERAGE.md b/docs/HIVEPRESS-COVERAGE.md
new file mode 100644
index 0000000..bf7d8ee
--- /dev/null
+++ b/docs/HIVEPRESS-COVERAGE.md
@@ -0,0 +1,61 @@
+# HivePress coverage: what the extensions write and how HivePress queries it
+
+Read-only inventory on **dev36** (2026-09-27): HivePress 1.7 with blocks, bookings,
+favorites, marketplace, memberships, messages, requests, reviews, seo, social-links and
+tags; 101,175 listings, 1,499 vendors, 709 requests. The data is seeded, so features the
+seed never used show no rows — noted per line. Account pages (favorites, messages,
+memberships) were not crawled (login).
+
+## Meta keys written (rows, `hp_*`)
+
+| Post type | Key | Rows | Registered group | Queried? |
+|---|---|---|---|---|
+| hp_listing | `hp_hourly_rate` | 101,157 | `hp_listing_core` | yes — filter BETWEEN, sort |
+| hp_listing | `hp_price` | 100,437 | `hp_listing_core` | yes — filter BETWEEN, sort |
+| hp_listing | `hp_rating`, `hp_rating_count` | 53,299 | `hp_listing_core` | `hp_rating` sort |
+| hp_listing | `hp_verified` | 31,985 | `hp_listing_core` | no |
+| hp_listing | `hp_featured` | 5,131 | `hp_listing_core` | yes — featured block (`= CHAR`, `meta_key` + `rand`) |
+| hp_listing | `hp_warranty` | 100,401 | — | no (seed data: values are not warranty term ids; see below) |
+| hp_vendor | `hp_rating`, `hp_hourly_rate`, `hp_rating_count`, `hp_verified` | 761–1,499 | `hp_vendor_core` | `hp_hourly_rate` filter, `hp_rating` sort |
+| hp_vendor | `hp_availability` | 4 | — | no |
+| hp_request | `hp_budget` | 709 | `hp_request_core` | yes — filter BETWEEN, `meta_value_num` sort |
+| hp_request | `hp_due_date` | 709 | — | no |
+| hp_booking | `hp_start_time`, `hp_end_time` | 1 | `hp_booking_core` | no (1 booking) |
+| `*_attribute` | `hp_edit_field_*`, `hp_filterable`, … | 3 each | — | attribute settings, read by ID |
+
+User meta and comment meta: no `hp_*` rows. Term meta: `hp_sort_order` 170, `hp_icon`
+153 (`hp_taxonomy`).
+
+## Where the extensions keep their data
+
+- **Reviews** — `hp_review` comments (5,324); rating in `comment_karma` (see
+ [REGISTERED-KEYS.md](REGISTERED-KEYS.md)).
+- **Requests / offers** — `hp_request` posts; offers are `hp_offer` comments (48), no meta.
+- **Messages** — `hp_message` comments (0 on dev36).
+- **Favorites** — 4 user meta rows (no `hp_` prefix); nothing to flatten.
+- **Bookings** — `hp_booking` posts (1); **memberships** — no plans on dev36.
+- **Tags / categories / select attributes** — taxonomies (`hp_listing_tags` 2,160,
+ `hp_listing_category` 101,175, `hp_listing_warranty` 720 relationships). A select
+ filter (`warranty=28`) becomes `tax_query hp_listing_warranty IN`, never a meta query.
+- **SEO, social links** — no rows on dev36.
+
+## Query shapes seen (front-end crawl, 18 URLs)
+
+- Search filters on number attributes: `hp_price` / `hp_hourly_rate` / `hp_budget`
+ `BETWEEN DECIMAL` — routed.
+- Sort: the named shape `(NOT EXISTS OR EXISTS)` on `hp_price`, `hp_hourly_rate`,
+ `hp_rating` with `orderby=__order` — routed, flat-first since core 1.2.0;
+ requests sort by `meta_key=hp_budget` + `meta_value_num` (a secondary query here) —
+ a shape the router handles.
+- Featured block: `meta_key=hp_featured` + `orderby=rand`, and `hp_featured = CHAR`.
+- Every front-end search carries WooCommerce's `product_visibility NOT IN` tax query.
+- `HivePress\Components\Attribute::set_search_query` runs its own `get_posts()` inside
+ `pre_get_posts` (suppress_filters): not routable (~100 ms on dev36).
+
+## D04 (auto-register HivePress attributes): not now
+
+Every meta key HivePress queries on dev36 is already registered; select attributes are
+taxonomies; the unregistered keys that are written (`hp_due_date`, `hp_availability`,
+`hp_warranty`) are never queried. Auto-registration would only pay off on a site whose
+admin adds **filterable or sortable number / date attributes** — revisit then (a doctor
+check listing such attributes without a registered key would flag it).
diff --git a/docs/REGISTERED-KEYS.md b/docs/REGISTERED-KEYS.md
new file mode 100644
index 0000000..c02c94c
--- /dev/null
+++ b/docs/REGISTERED-KEYS.md
@@ -0,0 +1,38 @@
+# Registered keys HivePress never writes
+
+Some keys the AddOn registers in its flat groups are **aliases of core columns** in the
+HivePress models: HivePress reads and writes the column, never a meta row. Their flat
+columns therefore stay `NULL`, and a meta query on them finds nothing — natively and
+routed alike.
+
+| Entity / group | Key | HivePress model field | Stored in |
+|---|---|---|---|
+| post / `hp_listing_core` | `hp_vendor` | Listing `vendor` | `wp_posts.post_parent` |
+| post / `hp_listing_core` | `hp_status` | Listing `status` | `wp_posts.post_status` |
+| post / `hp_request_core` | `hp_status` | Request `status` | `wp_posts.post_status` |
+| post / `hp_request_core` | `hp_user` | Request `user` | `wp_posts.post_author` |
+| post / `hp_vendor_core` | `hp_user` | Vendor `user` | `wp_posts.post_author` |
+| comment / `hp_review` | `hp_rating` | Review `rating` (hivepress-reviews) | `wp_comments.comment_karma` |
+
+Sources: `'_alias' => …` in `hivepress/includes/models/class-listing.php`,
+`class-vendor.php`, `hivepress-requests/includes/models/class-request.php` and
+`hivepress-reviews/includes/models/class-review.php`.
+
+## Checked on dev36 (2026-09-27, 101,175 listings)
+
+- Native meta rows: `hp_vendor` 0, `hp_status` 0, `hp_user` 0, comment `hp_rating` 0.
+- Instead: 101,175 / 101,175 listings have `post_parent > 0`; listing statuses are
+ `publish` 91,804 / `draft` 9,371; 5,244 of 5,324 `hp_review` comments carry
+ `comment_karma`.
+
+## What this means
+
+- Filters and sorts HivePress builds on these fields use the core columns, never
+ `meta_query`, so the router has nothing to route for them.
+- Drift checks report nothing for them: native and flat are both empty.
+- **No schema change** (plan decision D03, 2.2): the columns cost only NULLs, and
+ dropping them would need a flat-table migration on every site.
+
+Not in this list: `hp_expired_time` and `hp_featured_time` are real meta fields of the
+Listing model (no alias); they are empty on dev36 only because its seeded listings
+never expire or get featured.
diff --git a/includes/class-tmdo-hivepress-transient-filter.php b/includes/class-tmdo-hivepress-transient-filter.php
index 92c9fec..8adb2d5 100644
--- a/includes/class-tmdo-hivepress-transient-filter.php
+++ b/includes/class-tmdo-hivepress-transient-filter.php
@@ -77,6 +77,9 @@ final class TMDO_Hivepress_Transient_Filter {
/** Namespace prefix for translated wp_options entries. */
public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_';
+ /** Object cache group used instead of wp_options when a persistent cache is present. */
+ public const CACHE_GROUP = 'tmdo_hp_transient';
+
/**
* Register the four metadata filters on `init`. Idempotent — safe to call
* multiple times.
@@ -164,7 +167,16 @@ final class TMDO_Hivepress_Transient_Filter {
// HivePress cache layer reads the timeout *first*, then decides whether
// to read the value. We must preserve raw stored values until HivePress
// itself orders deletion.
- $value = get_option( $option_name, null );
+ if ( wp_using_ext_object_cache() ) {
+ // The object cache is the only store. Rows left in wp_options from
+ // before are not read (`wp tmdo cleanup-hp-transients --options`
+ // removes them): a get_option() miss would add the name to
+ // `notoptions` for good, as later writes never reach wp_options.
+ $value = wp_cache_get( $option_name, self::CACHE_GROUP );
+ $value = false === $value ? null : $value;
+ } else {
+ $value = get_option( $option_name, null );
+ }
if ( null === $value ) {
// Cache miss in our store → fall through to normal postmeta path
@@ -239,16 +251,24 @@ final class TMDO_Hivepress_Transient_Filter {
$translated = self::translate_key( (int) $object_id, (string) $meta_key );
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
+ if ( wp_using_ext_object_cache() ) {
+ wp_cache_delete( $option_name, self::CACHE_GROUP );
+ return true;
+ }
delete_option( $option_name );
return true;
}
/**
- * Internal: persist value or timeout to wp_options.
+ * Internal: persist value or timeout to the object cache when a persistent
+ * one is in use (Redis: a TTL cache belongs there, not in wp_options), else
+ * to wp_options.
*
- * Both halves of the transient pair are written with `autoload=no` (they
- * are read on-demand only — never via wp_load_alloptions() — so eagerly
- * loading them on every page would defeat the point of the move).
+ * In wp_options both halves of the transient pair are written with
+ * `autoload=no` (they are read on-demand only — never via
+ * wp_load_alloptions() — so eagerly loading them on every page would defeat
+ * the point of the move). HivePress checks the timeout itself, so the
+ * cache entries get no TTL of their own.
*
* @param int $post_id Post ID.
* @param string $meta_key Original `_transient_*` meta_key.
@@ -260,6 +280,10 @@ final class TMDO_Hivepress_Transient_Filter {
$translated = self::translate_key( $post_id, $meta_key );
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
+ if ( wp_using_ext_object_cache() ) {
+ wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP );
+ return;
+ }
update_option( $option_name, $meta_value, false );
}
diff --git a/tests/integration/HivepressTransientFilterTest.php b/tests/integration/HivepressTransientFilterTest.php
index 827d907..3547f20 100644
--- a/tests/integration/HivepressTransientFilterTest.php
+++ b/tests/integration/HivepressTransientFilterTest.php
@@ -44,8 +44,10 @@ class HivepressTransientFilterTest extends TestCase {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
- // Reset wp_options state for filter tests
- $GLOBALS['_wp_options'] = array();
+ // Reset wp_options / object cache state for filter tests
+ $GLOBALS['_wp_options'] = array();
+ $GLOBALS['_wp_cache'] = array();
+ $GLOBALS['_wp_using_ext_object_cache'] = false;
// Default the toggle to enabled
update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' );
@@ -195,6 +197,46 @@ class HivepressTransientFilterTest extends TestCase {
$this->assertSame( array( array( 'serialized', 'data' ) ), $value );
}
+ // ── External object cache (2.2 PR-081) ──────────────────────────────────
+
+ public function test_with_an_object_cache_writes_skip_wp_options(): void {
+ $GLOBALS['_wp_using_ext_object_cache'] = true;
+ $key = '_transient_hp_models/cat/v1';
+ $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
+
+ $this->assertTrue( WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ) );
+ WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', '99999999', '' );
+
+ $this->assertFalse( get_option( '_transient_' . $translated ), 'no wp_options row' );
+ $this->assertFalse( get_option( '_transient_timeout_' . $translated ), 'no wp_options row' );
+ $this->assertSame( array( 'val' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) );
+ $this->assertSame( array( '99999999' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_transient_timeout_hp_models/cat/v1', true ) );
+ }
+
+ public function test_with_an_object_cache_a_value_left_in_wp_options_is_not_read(): void {
+ // A miss must not fall back to get_option(): the option is absent after
+ // cleanup and WordPress would add its name to `notoptions` for good.
+ $key = '_transient_hp_models/cat/v1';
+ $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
+ update_option( '_transient_' . $translated, 'from-before' );
+ $GLOBALS['_wp_using_ext_object_cache'] = true;
+
+ $this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) );
+ }
+
+ public function test_with_an_object_cache_delete_leaves_wp_options_alone(): void {
+ $key = '_transient_hp_models/cat/v1';
+ $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
+ update_option( '_transient_' . $translated, 'from-before' );
+ $GLOBALS['_wp_using_ext_object_cache'] = true;
+ WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' );
+
+ WPDO_Hivepress_Transient_Filter::on_delete( null, 100, $key, '', false );
+
+ $this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) );
+ $this->assertSame( 'from-before', get_option( '_transient_' . $translated ), 'left for cleanup-hp-transients --options' );
+ }
+
// ── count/purge legacy ──────────────────────────────────────────────────
public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void {
diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php
index b34bf9d..c5be687 100644
--- a/tests/integration/bootstrap.php
+++ b/tests/integration/bootstrap.php
@@ -23,6 +23,13 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) {
}
require_once $tmdo_core_bootstrap;
+// External object cache switch for the transient filter tests (Redis on real sites).
+if ( ! function_exists( 'wp_using_ext_object_cache' ) ) {
+ function wp_using_ext_object_cache( $using = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
+ return ! empty( $GLOBALS['_wp_using_ext_object_cache'] );
+ }
+}
+
define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__, 2 ) . '/' );
define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' );
define( 'TMDO_HIVEPRESS_FILE', TMDO_HIVEPRESS_PATH . '2meet-data-optimizer-hivepress-addon.php' );