# Entity Adapter Cookbook How partner plugins integrate with `2meet-data-optimizer v1.0.0+` to gain anti-EAV benefits without modifying their existing data model. This is the practical guide. For the canonical vocabulary see `CONTEXT.md`; for the two standing architectural decisions see `docs/adr-001-post-entity-source-of-truth.md` and `docs/adr-002-dual-write-naming-collision.md`. --- ## ⚡ TL;DR Decision Tree (read this first — v2.1.2 reordered) The most common mistake is picking Tier 1-3 when Tier 5 was the right answer. Ask these questions in order: ``` Q1. Does the partner plugin have its OWN custom tables / lookup tables that already provide anti-EAV? (HPOS, wc_product_meta_lookup, BuddyPress activity tables, EDD payments, etc.) ├── YES → 🟢 TIER 5 (integrate, don't duplicate). STOP HERE. │ Register awareness only. Do not migrate. │ See: TMDO_WooCommerce reference. │ └── NO → continue to Q2. Q2. Is this a brand-new plugin you control end-to-end? ├── YES → 🟢 TIER 4 (greenfield, anti-EAV from day 1). │ Use `wp tmdo register-stub ` for boilerplate. │ See: 2meet-inquiries reference. │ └── NO (existing plugin with postmeta) → continue to Q3. Q3. Is the relevant postmeta key heavily queried (filter / sort / search)? Benchmark: > 10k rows OR > 3-condition meta_query OR sort by meta_value. ├── YES → 🟢 TIER 1-3 (migrate to a Zone). │ Tier 1 (5 min) for read-only optimization. │ Tier 2 (30 min) for full dual-write. │ Tier 3 (1-2 days) for new entity type. │ └── NO → 🟢 LEAVE AS POSTMETA. Premature optimization. Re-evaluate when scale crosses Q3 thresholds. ``` **Why Tier 5 is FIRST**: at v2.1.2 audit time, every mature plugin we surveyed (WC / BuddyPress potential / EDD potential / GravityForms) has its own anti-EAV. Defaulting to Tier 1-3 risks the catastrophic "two sources of truth" failure mode. See `docs/INTEGRATION_PATTERN_DECISION.md` for the principle. ### How to tell if a partner plugin already has anti-EAV (Tier 5 candidate) Check these signals in order — any ONE is sufficient for Tier 5: | Signal | Where to look | Examples | |--------|---------------|----------| | **Lookup tables** with name pattern `*_lookup` / `*_meta_lookup` / `*_index` | `SHOW TABLES LIKE 'wp_{prefix}_%lookup%'` | `wp_wc_product_meta_lookup`, `wp_wc_customer_lookup` | | **HPOS-style migration toggle** (custom table replaces postmeta) | Plugin's settings → "High Performance" or "Custom Tables" feature | WooCommerce HPOS, EDD 3.0 payments | | **Dedicated columns instead of meta** in main entity table | `DESC wp_{plugin}_entities` shows `price`, `status` etc. as columns | BuddyPress activity table, MemberPress subscriptions | | **Plugin's own search/filter API** that bypasses `meta_query` | `wc_get_products()`, `bp_activity_get()`, `edd_get_payments()` | WC, BuddyPress, EDD all have native APIs | | **`*_stats` / `*_aggregate` tables** for analytics queries | `wp_wc_order_stats`, `wp_*_lookup` | WC analytics, MonsterInsights | | **db_version option** that hits `dbDelta` migration on plugin update | `wp option get {plugin}_db_version` returns a non-trivial version | Indicates the plugin has its own schema migration story | If you see **2+ signals** → definitely Tier 5. If you see **0 signals** but the plugin has heavy postmeta usage → Tier 1-3. If you see **0 signals** and postmeta is light → leave it (Q3 = NO). **Quick command-line audit**: ```bash # List custom tables for a plugin wp db query "SHOW TABLES LIKE 'wp_{prefix}_%'" # Count postmeta keys the plugin owns (low = likely Tier 5; high = candidate Tier 1-3) wp db query "SELECT COUNT(DISTINCT meta_key) FROM wp_postmeta WHERE meta_key LIKE '\\_{prefix}_%'" # If both numbers are non-trivial → Tier 5 is correct (plugin uses both, but # its tables are the truth and postmeta is legacy/secondary). ``` --- ## Three integration tiers Pick the one that matches your plugin's data model: ### Tier 1 — `TMDO_API` facade(最少改動,5 分鐘) If your plugin reads/writes `*_meta()` directly today, swap to the facade. This gives you future-proofing for free — the day the field migrates to a zone or entity adapter, your plugin needs zero changes. **Before:** ```php $token = get_post_meta( $vendor_id, 'tmeetic_ical_token', true ); update_post_meta( $vendor_id, 'tmeetic_ical_token', $new_token ); ``` **After:** ```php $token = class_exists( 'TMDO_API' ) ? TMDO_API::get_field( $vendor_id, 'tmeetic_ical_token' ) : get_post_meta( $vendor_id, 'tmeetic_ical_token', true ); if ( class_exists( 'TMDO_API' ) ) { TMDO_API::set_field( $vendor_id, 'tmeetic_ical_token', $new_token ); } else { update_post_meta( $vendor_id, 'tmeetic_ical_token', $new_token ); } ``` **Real example:** `2meet-courses/includes/class-2meetic-ical.php` (Wave 2 改造). **Cross-entity:** ```php TMDO_API::get_entity( 'user', $user_id, 'points' ); TMDO_API::get_entity( 'term', $term_id, 'usage_count' ); TMDO_API::get_entity( 'comment', $comment_id, 'helpful_count' ); TMDO_API::set_entity( 'user', $uid, 'points', 50 ); ``` **Helper:** ```php TMDO_API::is_field_registered( 'post', 'hp_price' ); // bool TMDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' ); // 'zone_hot' | 'postmeta' | ... ``` ### Tier 2 — Schema Registry 註冊(中等改動,30 分鐘) If your plugin owns specific meta_keys that benefit from Hot zone (search/filter), Cold zone (display/JSON), or Warm zone (TTL counter) treatment. **Implementation:** create one integration class. ```php // my-plugin/includes/class-tmdo-myplugin.php final class TMDO_MyPlugin { public static function register(): void { // Detect partner plugin (2meet-data-optimizer) — no-op when absent. if ( ! class_exists( 'TMDO_Schema_Registry' ) ) { return; } add_action( 'wpdo_register_fields', array( __CLASS__, 'register_fields' ) ); add_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_tables' ) ); } public static function register_fields( TMDO_Schema_Registry $registry ): void { $registry->register_many( 'my-plugin', array( // Hot zone (search/filter): flat 1NF column with index. array( 'post_type' => 'my_post_type', 'meta_key' => 'my_price', 'zone' => 'hot', 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', 'column' => 'my_price', 'indexed' => true, ), // Cold zone (description / JSON / display). array( 'post_type' => 'my_post_type', 'meta_key' => 'my_description', 'zone' => 'cold', 'cache_group' => 'wpdo_cold_my_post_type', 'cache_ttl' => HOUR_IN_SECONDS, ), ) ); } public static function register_tables( TMDO_Custom_Table_Registry $registry ): void { $registry->register( 'my-plugin', array( 'table_name' => 'my_custom_table', 'primary_key' => 'id', 'post_type_link' => 'my_post_type', 'doctor_callback' => array( __CLASS__, 'doctor_my_table' ), ) ); } public static function doctor_my_table(): array { global $wpdb; $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}my_custom_table`" ); return array( 'ok' => true, 'message' => "rows: {$count}" ); } } // In your plugin's bootstrap: add_action( 'plugins_loaded', array( 'TMDO_MyPlugin', 'register' ), 5 ); ``` **Real examples:** - `2meet-data-optimizer/includes/integrations/class-tmdo-infocards.php` — 9 fields + 3 tables - `2meet-data-optimizer/includes/integrations/class-tmdo-bookings.php` — 7 tables only ### Tier 3 — Custom Entity Adapter(大改動,1-2 天) If you need cross-entity behaviour (e.g. counter that works on user / term / comment uniformly), use the demo entity counter pattern. **Real example:** `2meet-data-optimizer/includes/integrations/class-tmdo-demo-entity-counter.php`. **Pattern:** ```php final class My_Counter { public const MODULE = 'entity_my_counter'; public const TABLE = 'wpdo_my_counters'; public static function install_table(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( "CREATE TABLE {$wpdb->prefix}" . self::TABLE . " ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, entity_type varchar(20) NOT NULL, entity_id bigint(20) unsigned NOT NULL, counter_key varchar(100) NOT NULL, counter_value bigint(20) NOT NULL DEFAULT 0, updated_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY ui_entity_counter (entity_type, entity_id, counter_key), KEY idx_lookup (entity_type, counter_key, counter_value) ) {$wpdb->get_charset_collate()};" ); } public static function set( string $entity_type, int $entity_id, string $key, int $value ): void { // Always write native (durability anchor). TMDO_API::set_entity( $entity_type, $entity_id, $key, $value ); // Conditional dual-write to zone table. if ( TMDO_Feature_Flags::is_write_active( self::MODULE ) ) { TMDO_DB::upsert( $GLOBALS['wpdb']->prefix . self::TABLE, array( 'entity_type' => $entity_type, 'entity_id' => $entity_id, 'counter_key' => $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' ) ); } } public static function get( string $entity_type, int $entity_id, string $key ): int { if ( TMDO_Feature_Flags::is_read_custom( self::MODULE ) ) { // Read from zone table; fallback to native if row missing. $val = $GLOBALS['wpdb']->get_var( $GLOBALS['wpdb']->prepare( "SELECT counter_value FROM `{$GLOBALS['wpdb']->prefix}" . self::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s", $entity_type, $entity_id, $key ) ); if ( null !== $val ) { return (int) $val; } } return (int) TMDO_API::get_entity( $entity_type, $entity_id, $key ); } public static function top_n( string $entity_type, string $key, int $n = 10 ): array { // Killer query that postmeta cannot do efficiently. return $GLOBALS['wpdb']->get_results( $GLOBALS['wpdb']->prepare( "SELECT entity_id, counter_value FROM `{$GLOBALS['wpdb']->prefix}" . self::TABLE . "` WHERE entity_type = %s AND counter_key = %s ORDER BY counter_value DESC LIMIT %d", $entity_type, $key, $n ), ARRAY_A ); } } ``` --- ## FSM lifecycle reference For Tier 3 entity adapters, drive the 7+1 state machine via CLI: ```bash # 1. Install schema (one-time) wp tmdo doctor # verify base tables # 2. Initial state: idle (do not register feature flag) wp tmdo mode-audit | grep entity_my_counter # should show 'idle' # 3. Begin dual_write — both native + zone get writes wp tmdo mode-set entity_my_counter dual_write # 4. Run backfill (if you have existing data) # (custom script or wp tmdo migrate) # 5. Verify with shadow_read for 7 days wp tmdo mode-set entity_my_counter verify wp tmdo shadow-enable entity_my_counter # Check for diffs wp eval 'echo (int) $GLOBALS["wpdb"]->get_var("SELECT COUNT(*) FROM {$GLOBALS[\"wpdb\"]->prefix}wpdo_shadow_diffs WHERE entity_type=\"user\"");' # 6. Cutover — reads switch to zone wp tmdo shadow-disable entity_my_counter wp tmdo mode-set entity_my_counter cutover # 7. After 7 more days, cleanup native rows wp tmdo mode-set entity_my_counter cleanup # 8. Final state — no fallback, zone is source of truth wp tmdo mode-set entity_my_counter complete # Rollback at any time wp tmdo mode-set entity_my_counter idle ``` --- ## Anti-EAV lint exemptions Some patterns require direct SQL by design (one-off migration scans, cron token expiry checks). Mark them with `phpcs:ignore` so `wp tmdo lint --strict` accepts them: ```php // phpcs:ignore WPDO.AntiEAV.PostmetaScan -- Cron sweeps postmeta to find expiring IG tokens. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'tmeetic_ig_token_expiry' AND CAST(meta_value AS UNSIGNED) < %d", time() + 31 * DAY_IN_SECONDS ) ); ``` **Available rules:** - `WPDO.AntiEAV.PostmetaScan` — cross-postmeta scan in migration / cron - `WPDO.AntiEAV.UsermetaScan` — same, usermeta - `WPDO.AntiEAV.PostmetaFallback` — fallback path during graceful degradation The exemption stays line-local. The lint cannot be silenced for an entire file. --- ## Verification checklist (before merge) ```bash # 1. Lint passes strict wp tmdo lint --plugin=$(pwd) --strict # 2. Conflict scan clean wp tmdo conflict-scan # 3. Doctor check (your custom tables registered + healthy) wp tmdo doctor # 4. Tests pass (if your plugin has them) ./vendor/bin/phpunit ``` --- ## Real-world results (dev10, 2026-04-25) Production benchmarks measured with 50 hp_listing posts: | Path | Latency (n=100) | Speedup | |---|---|---| | Native `get_post_meta()` | 205ms | 1.0x baseline | | `TMDO_Listing_Stats::get_view_count()` (Zone B Warm + fallback) | 81ms | **2.51x** | | `TMDO_Demo_Entity_Counter::top_n()` (Zone via 1 LEFT JOIN) | 0.19ms / call | **postmeta cannot do efficiently** | The `top_n` example is the killer use case — sorting 1000s of users by point count via postmeta requires a full meta_value scan + filesort. The zone table serves it from a covering index in sub-millisecond. --- ## Tier 4: Greenfield plugin — `2meet-inquiries` (v0.5.0) The cleanest case: a plugin written **from scratch** to be anti-EAV from day 1. Use this as a template for all new 2meet-* plugins. ### Why this is the gold standard | Pattern | What `2meet-inquiries` does | What `bookings`/`courses`/`infocards` had to retrofit | |---|---|---| | Large structured config | `wp_2mqi_forms.config_json LONGTEXT` (custom table) | Originally postmeta `_eh_inquiry_config` (anti-EAV violation) | | Hot-zone meta on hp_vendor | `wpdo_register_fields` — `_tmqi_default_form_id` (bigint, indexed) | Some retrofitted via Schema_Registry Hot Wave 1; others still WP_Query-driven | | Sensitive PII | `customer_email_enc BLOB` + `customer_email_hash CHAR(64)` for indexed lookup | Originally separate plugins each rolled own AES wrapper | | Audit-trail rows | Dedicated `wp_2mqi_responses` table (1 row per submission) | Older plugins used `wp_postmeta` rows-per-field → EAV blow-up | | Analytics events | Dedicated `wp_2mqi_analytics` (event_type, stage_index, session_id) | N/A — most plugins didn't have analytics, would've gone to postmeta if they did | | Webhook config | `wp_options` per vendor (autoload=no, low cardinality) | Same | ### Anatomy: 4 custom tables, 0 plugin-owned postmeta keys ``` wp_2mqi_forms — form definitions (config_json + counters + slug) wp_2mqi_responses — submitted inquiries (encrypted PII + payload_json) wp_2mqi_drafts — in-progress submissions (token + 14-day expire) wp_2mqi_analytics — funnel events (view, stage_*, submit) ``` Plus 2 `hp_vendor` fields registered to Schema_Registry Hot zone: ```php $registry->register_many( '2meet-inquiries', array( array( 'post_type' => 'hp_vendor', 'meta_key' => '_tmqi_default_form_id', 'zone' => 'hot', 'data_type' => 'bigint(20) NOT NULL DEFAULT 0', 'column' => '_tmqi_default_form_id', 'indexed' => true, ), array( 'post_type' => 'hp_vendor', 'meta_key' => '_tmqi_inquiries_enabled', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => '_tmqi_inquiries_enabled', 'indexed' => true, ), ) ); ``` ### Bootstrap pattern (recommended) ```php final class TMQI_Plugin { use TMDO_Anti_EAV_Aware; // ← strict contract; fails to load without it public function run(): void { // Register tables on the canonical action. add_action( 'wpdo_register_fields', array( __CLASS__, 'register_wpdo_fields' ) ); add_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_custom_tables' ) ); // ... } public static function register_wpdo_fields(): void { /* hot-zone fields */ } public static function register_custom_tables( $registry = null ): void { TMQI_WPDO_Integration::register_tables( $registry ); } } ``` ### Ground rules followed ✅ **No `update_post_meta()` calls anywhere** — even for hp_vendor metas, we call `TMDO_API::set_field()` ✅ **No direct `SELECT FROM wp_postmeta`** — `wpdo lint --strict` exits 0 ✅ **All large JSON in custom tables** — `config_json` and `payload_json` columns, never postmeta ✅ **Sensitive data encrypted** — reuse `TMEETIC_Crypto::encrypt()` (don't roll your own) ✅ **Indexed search on encrypted columns** — store SHA-256 hash alongside ciphertext ✅ **Single `do_action( 'tmqi/submitted' )`** — downstream notifiers, analytics, webhook all hook here ### Verification (run on dev10 right now) ```bash # Custom tables registered wp eval 'echo count(TMDO_Custom_Table_Registry::instance()->for_provider("2meet-inquiries"));' # → 4 # Strict lint passes wp tmdo lint --plugin=$(wp plugin path 2meet-inquiries) --strict # → Success: Anti-EAV lint passed # Conflict scan wp tmdo conflict-scan # → 0 conflicts detected ``` ### Takeaway for Wave 2/3 retrofits When refactoring an existing plugin to be anti-EAV, the question is not "how do we shoehorn this into postmeta less?" — it's "**what does the data look like if we redesign it like 2meet-inquiries from day 1?**" Then plot a migration path. For most plugins the answer is: **replace one big postmeta key with one custom table row, and register a small number of hot-zone hp_vendor fields for search.** --- ## Tier 5: Integrating with a plugin that has its OWN anti-EAV — `TMDO_WooCommerce` (v2.1.0) The hardest case: WC core already has anti-EAV (HPOS for orders, `wp_wc_product_meta_lookup` for products). WPDO's job is to **integrate, not duplicate**. ### Why this is different from Tiers 1-4 | Aspect | Tier 1-4 (we own the data) | Tier 5 (WC owns it) | |--------|---------------------------|----------------------| | Custom tables | We define + create | WC defines + creates | | Hot-zone fields | Migrated from postmeta to our Hot zone | Already in WC's lookup tables; we just **register awareness** | | Doctor probes | We control existence + schema | We probe but don't fix | | Schema drift | Our migration tooling | WC's update_db_*() handles | | Conflict | None (single owner) | **Risk: shadow lookup tables** | ### Anti-pattern: ❌ DON'T duplicate WC's lookup tables ```php // WRONG — creates a parallel system that drifts from WC's truth $schema->register( 'woocommerce', array( 'post_type' => 'product', 'meta_key' => '_price', 'zone' => 'hot', // ... migrate _price into our wpdo_hot_product table ) ); // Now `_price` lives in BOTH wp_wc_product_meta_lookup AND our hot zone. // Updates touch one but not the other. Catastrophe. ``` ### Right pattern: ✅ Register awareness, defer to WC's anti-EAV ```php // In TMDO_WooCommerce::register_custom_tables(): foreach ( WC_CORE_TABLES as $name => $meta ) { $registry->register( 'woocommerce', array( 'table_name' => $name, // wp_wc_product_meta_lookup 'description' => $meta['description'], 'doctor_callback' => array( __CLASS__, 'doctor_check' ), ) ); } // In TMDO_WooCommerce::register_schema_fields(): // Register postmeta keys WC STILL uses (the ones not yet migrated to lookups). // When `_price` is also in wc_product_meta_lookup, registering doesn't migrate // to OUR hot zone — it's just a hint to TMDO_API consumers about "this is hot". $schema->register_many( 'woocommerce', array( array( 'post_type' => 'product', 'meta_key' => '_price', 'zone' => 'hot', ... ), // ... ) ); ``` ### Decision tree for new partner integrations ``` Does the partner plugin store data in postmeta? ├── NO → already on custom tables → Tier 4 (register tables + done) └── YES → does the partner have its own lookup/cache table? ├── NO → Tier 1-3 (we manage migration) └── YES → Tier 5: register awareness only, never duplicate ``` ### Tier 5 checklist for `TMDO_WooCommerce` - [x] Register all 20 `wp_wc_*` tables to Custom_Table_Registry - [x] Add `doctor_callback` that probes existence + row count (not schema diff) - [x] Register hot-zone postmeta fields (legacy path only) — WC's lookup is the truth - [x] Detect HPOS state via `OrderUtil::custom_orders_table_usage_is_enabled()` - [x] When HPOS off → register order postmeta hot fields (`_order_total`, etc.) - [x] When HPOS on → DON'T register order postmeta (would be stale) - [x] Customer usermeta hot fields registered unconditionally (WC always uses usermeta for these) - [x] Subscription product fields conditional on `WC_Subscriptions` OR `wc-linepay-subscription` - [x] Own custom table (`wpdo_wc_commissions`) for vendor marketplace tracking - [x] Admin notice recommends HPOS when legacy order count > threshold - [x] Admin dashboard surfaces commission stats + table health ### What this DOESN'T do (and shouldn't) - ❌ Migrate `_price` into our hot zone (WC already has wc_product_meta_lookup) - ❌ Mirror `wc_orders` into our archive zone (WC handles its own archive) - ❌ Intercept `update_post_meta` for product meta (interferes with WC's lookup sync) - ❌ Create products / orders / customers (WC's domain) ### When to revisit - WC drops a lookup table (unlikely but possible) → migrate that field path to Tier 3 - HPOS becomes default-on → audit our order postmeta registrations and remove - New WC subextension introduces meta keys we should register → add to `register_subscription_fields()`