docs: 移植 readme.txt / CONTEXT.md / docs(backport A v3.4.6)
Tests / Integration Tests (push) Successful in 1m11s
Tests / Unit Tests (push) Failing after 11m56s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Failing after 12m7s
Tests / PHPStan (push) Failing after 14m37s
Tests / PHPCS (push) Failing after 14m46s
Tests / PHP Lint (push) Failing after 14m57s

- readme.txt(WP 外掛目錄格式,隨 ZIP 發佈):Stable tag 對齊 1.0.0,
  changelog 補 1.0.0 條目
- CONTEXT.md(領域詞彙表):Status 區塊改寫為 v1.0.0 實況;
  HPCT_INTERCEPTORS 與 HivePress Adapter 兩節標註「已搬到 AddOn,核心無此常數」
- docs/:ENTITY_ADAPTER_COOKBOOK、2 篇 ADR、INTEGRATION_PATTERN_DECISION、
  anti-eav-lint.yml.template
  - cookbook 修掉兩個死連結(ANTI_EAV_PLAYBOOK 在來源外掛就不存在)
  - INTEGRATION_PATTERN_DECISION 加 v1.0.0 後記:結論已被 AddOn 拆分取代
  - template 改 wpdev/2meet-data-optimizer + ref v1.0.0 + wp tmdo lint
- README.md 文件索引補上以上 7 個檔案

前綴改寫刻意只動類別/函式/slug(WPDO_→TMDO_、wp-data-optimizer→2meet-...),
wpdo_ option/cron/hook/表名與 wpdo/v1 REST namespace 一律保留 —— 這是資料層
零遷移的前提。
This commit is contained in:
2026-07-31 10:06:03 +08:00
parent 2203bc471c
commit b63ab46f54
8 changed files with 1564 additions and 0 deletions
+560
View File
@@ -0,0 +1,560 @@
# 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 <slug>` 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()`
+118
View File
@@ -0,0 +1,118 @@
# Integration Pattern Decision — 2026-04-25
> **v1.0.0 後記(2026-07-31):本文結論已被架構拆分取代。**
> 當時的兩個選項是「集中在核心」vs「各外掛自帶 bridge」。v1.0.0 走的是第三條路:
> 每個夥伴外掛對應**一個獨立 AddOn 外掛**`2meet-data-optimizer-<partner>-addon`,共 11 個),
> 核心完全不認識夥伴外掛。下文的 `class-tmdo-<partner>.php` 一律已搬進對應 AddOn。
> 本文保留是為了記錄「為什麼不選 per-plugin bridge 檔」這段推理 —— 該理由對 AddOn 邊界同樣適用。
**Trigger**: Step BB audit revealed two parallel patterns for partner plugin integration; need to commit to one.
---
## What I found
| Plugin | Centralized in 2meet-data-optimizer? | Own bridge file? |
|--------|-----------------------------------|------------------|
| 2meet-infocards | ✅ `class-tmdo-infocards.php` | ❌ |
| 2meet-bookings | ✅ `class-tmdo-bookings.php` | ❌ |
| 2meet-quotation | ✅ `class-tmdo-quotation.php` | ❌ |
| 2meet-events | ✅ `class-tmdo-events.php` | ✅ `class-tmevents-wpdo-bridge.php` (**duplicate!**) |
| 2meet-collab | ✅ `class-tmdo-collab.php` | ❌ |
| 2meet-mobile-bridge | ✅ `class-tmdo-mobile-bridge.php` | ❌ |
| 2meet-playlist | ✅ `class-tmdo-playlist.php` | ❌ |
| 2meet-courses | ❌ | ✅ `class-2meetic-courses-wpdo.php` (NEW today, P step) |
| 2meet-inquiries | ❌ | ✅ `class-tmqi-wpdo-integration.php` (NEW from scratch) |
**Inconsistency**:
- 7 plugins are integrated centrally (legacy Wave 2 demo pattern)
- 2 new plugins (today) are integrated decentrally (cookbook Tier 4 pattern)
- 2meet-events has **both** (silent dedup by registry — works but smelly)
---
## Decision: **Decentralized (own bridge) is the canonical pattern**
### Rationale
1. **Cookbook Tier 4 documents it as the standard** for new plugins (already published)
2. **Each plugin owns its own data contract** — no cross-plugin coupling in 2meet-data-optimizer
3. **Easier to ship** — partner plugin can update its registration without bumping 2meet-data-optimizer
4. **Simpler mental model** — "where do tables get registered? In the plugin that owns them"
### Why we're NOT migrating today
1. **Freeze**: Per `FREEZE_2026-04-25.md`, no risky refactors during freeze
2. **Working**: All 7 centralized integrations work; registry dedup handles the events double-pattern
3. **No vendor demand**: Nobody has reported confusion or bugs from the dual pattern
4. **Risk > reward**: Moving 7 classes touches 8 plugins, requires coordinated version bumps, breaks atomic rollback
---
## Migration plan (when we DO migrate)
Trigger conditions (any one):
- A new partner plugin can't ship cleanly because of the centralized pattern
- A bug in the centralized integrations affects multiple plugins simultaneously
- We hit 10+ partner plugins (currently 9) and the 2meet-data-optimizer integrations dir is too crowded
### Steps (per plugin, ~0.5 day each)
```
1. Copy /2meet-data-optimizer/includes/integrations/class-tmdo-{slug}.php
to /{plugin-slug}/includes/integrations/class-{prefix}-wpdo.php
2. Rename class:
- TMDO_Bookings → TMB_WPDO
- TMDO_Quotation → TMQUO_WPDO (etc.)
- Keep registration logic identical
3. Wire in {plugin-slug} bootstrap (after main classes load):
require_once $dir . 'includes/integrations/class-{prefix}-wpdo.php';
{prefix}_WPDO::register();
4. In 2meet-data-optimizer:
- Remove require_once line
- Remove class name from the partner array (line ~208 of main file)
- Bump WPDO version (patch)
5. Verify:
wp eval 'echo count(TMDO_Custom_Table_Registry::instance()->for_provider("{slug}"));'
→ should still match the original count
6. Bump partner plugin version (minor, since it now has new dependency)
Update Requires Plugins header to mention 2meet-data-optimizer ≥ X.Y.Z
```
### Special case: 2meet-events double-pattern
Already has both. Migration = remove the centralized `class-tmdo-events.php` (the bridge in 2meet-events stays). This is the **simplest first migration** because the partner plugin's own bridge is already proven.
---
## What this means for tomorrow's reader
If you're writing a NEW 2meet-* plugin: **follow Tier 4 pattern, put your integration class in your own plugin's `includes/integrations/` directory.**
If you're maintaining an EXISTING centralized integration in 2meet-data-optimizer: **leave it alone unless one of the migration triggers fires.**
If you see the dual pattern in 2meet-events and are confused: **it's intentional, registry dedups, will be cleaned up later.**
---
## Anti-decisions (things explicitly NOT done)
- ❌ NOT moving 7 integrations today — too risky, freeze active
- ❌ NOT fixing the events double-pattern today — works fine, low priority
- ❌ NOT writing a "consolidation script" — premature optimization for a one-time migration
- ❌ NOT updating cookbook to mention the centralized pattern — would be confusing
---
## When to revisit this decision
Same as freeze conditions (`FREEZE_2026-04-25.md`):
- vendor reports confusion / bug
- 30 days passed with no action needed
- New plugin (#10+) onboarded
- Production critical event involves the pattern
@@ -0,0 +1,85 @@
# ADR-001: Post Entity Source-of-Truth Contract
**Status:** Accepted
**Date:** 2026-05-15
**Deciders:** wpdev
---
## Context
Two code paths can intercept `update_post_metadata` / `add_post_metadata`:
1. **TMDO_Sync_Bridge** — the original Zone interceptor that dual-writes to
Hot (Zone A) and Cold (Zone C) flat tables.
2. **TMDO_Hook_Bus** — the Entity Bridge write path added in v2.9.x that writes
to `wp_wpdo_post_*` flat tables via Entity Registry groups.
When both are active without a clear contract, a single `update_post_meta()` call
can fan out to three distinct write paths (wp_postmeta + Zone table + entity flat
table), producing divergent row counts and confusing `TMDO_API::trace_storage()`
output.
The defensive patch `TMDO_Sync_Bridge::is_owned_by_entity_bridge()` (v2.9.2)
was added to prevent double-writes but left the authoritative contract undocumented.
---
## Decision
**When `post` entity mode is `dual_write`, `shadow_read`, or `aeav_only`,
Entity Bridge (TMDO_Hook_Bus) is the sole source of truth for keys registered
in TMDO_Entity_Registry under the `post` entity type.**
Sync_Bridge defers to Entity Bridge for those keys via `is_owned_by_entity_bridge()`:
```php
// TMDO_Sync_Bridge — intercept_update() guard:
if ( self::is_owned_by_entity_bridge( $meta_key ) ) {
return $check; // pass-through — Entity Bridge owns this key
}
```
`is_owned_by_entity_bridge()` returns `true` when both conditions hold:
- `TMDO_Mode_Manager::writes_to_flat('post')` — mode is at least dual_write
- `TMDO_Entity_Registry::get_field('post', $meta_key)` — key is registered
Keys **not** in Entity Registry continue to be owned by Sync_Bridge (Zone path).
### Invariants
| Condition | Owner |
|-----------|-------|
| post mode = `disabled` or `idle` | wp_postmeta (no interception) |
| post mode ≥ `dual_write` AND key in Entity Registry | **Entity Bridge** |
| post mode ≥ `dual_write` AND key not in Entity Registry | **Sync_Bridge** (Zone) |
| post mode = `aeav_only` | Entity Bridge for registered keys; unregistered keys fallthrough to wp_postmeta |
---
## Consequences
**Good:**
- Developers adding a new post meta key can determine its owner in O(1): check
whether the key is in `wpdo_register_fields` under `post` entity. If yes →
Entity Bridge owns it; if no → Zone Sync_Bridge handles it.
- `TMDO_API::trace_storage()` output reflects this: Entity Bridge keys show the
flat entity table; Zone keys show the zone table.
**Bad / Watch out for:**
- A key registered in **both** Schema_Registry (Zone) and Entity_Registry (Entity
Bridge groups) will be captured by Entity Bridge and silently dropped by
Sync_Bridge. The duplicate registration is a misconfiguration — caught by
`TMDO_Sync_Bridge` guard and validated by `wp tmdo conflict-scan`.
- If `TMDO_Mode_Manager` or `TMDO_Entity_Registry` are unavailable (e.g. very
early bootstrap), `is_owned_by_entity_bridge()` returns `false` and all writes
fall through to the Zone path — safe degradation.
---
## Related
- `includes/interceptors/class-tmdo-sync-bridge.php``is_owned_by_entity_bridge()` (v2.9.2)
- `includes/engine/class-tmdo-hook-bus.php` — Entity Bridge write path
- `TMDO_API::trace_storage()` — human-readable storage path diagnostics
- `wp tmdo conflict-scan` — detects keys registered in both paths
@@ -0,0 +1,65 @@
# ADR-002: Acknowledge 'dual_write' naming collision between Mode_Manager and Feature_Flags FSMs
**Status:** Accepted — deferred rename
**Date:** 2026-05-15
**Deciders:** wpdev
---
## Context
Two distinct FSMs in the codebase both use the string `'dual_write'`:
| FSM | Class | Constant | Stored in | Semantics |
|-----|-------|----------|-----------|-----------|
| Entity Bridge | `TMDO_Mode_Manager` | `MODE_DUAL_WRITE` | `wpdo_bridge_modes` | Writes go to both UAE flat table AND `wp_*meta` |
| Zone Migration | `TMDO_Feature_Flags` | `STATUS_DUAL_WRITE` | `wpdo_features` | Writes go to both Zone A/B/C table AND `wp_postmeta` |
The two FSMs are orthogonal — a post type can be in Zone `dual_write`
(actively migrating) while the Entity Bridge is in `aeav_only` mode, or vice
versa. The collision was introduced when the Entity Bridge FSM was added in
v2.5.x alongside the pre-existing Zone Migration FSM.
---
## Decision
**Defer the rename. Document the collision instead.**
Renaming either constant (e.g. Mode_Manager → `bridge_dual`) would require:
1. Updating ~85 call sites across 30+ files.
2. Writing a DB migration to translate stored option values (`'dual_write'`
`'bridge_dual'` in `wp_options['wpdo_bridge_modes']`).
3. Updating all WP-CLI commands that accept mode strings as user input.
4. Updating all admin UI dropdowns and confirmation messages.
5. Handling sites that run the old code against a DB that has already been
migrated (or the reverse — new code on an un-migrated DB).
The risk of introducing bugs via a mechanical rename outweighs the naming
improvement at the current stage of the project.
The collision is mitigated by:
- A disambiguating docblock in `class-tmdo-mode-manager.php` (added 2026-05-15)
- This ADR, which explains the overlap to future developers
- The two FSMs operating on different option keys and being unreachable from
each other's code paths
---
## Consequences
- **Future rename path**: When a DB migration is warranted (e.g. alongside
another schema change), rename `MODE_DUAL_WRITE → 'bridge_dual'` and add a
migration in `TMDO_Installer::maybe_upgrade()` that rewrites the stored string.
- **Linter**: If PHPStan or a custom rule ever flags string literal comparisons
across FSMs, this ADR is the canonical explanation for why the overlap is
intentional.
---
## Related
- `includes/engine/class-tmdo-mode-manager.php` — naming note in class docblock
- `includes/class-tmdo-feature-flags.php` — Zone FSM (7 states)
- P1-10 from full-review report (2026-05-15)
+63
View File
@@ -0,0 +1,63 @@
name: Anti-EAV Strict Lint
# Drop this file into each partner plugin's `.github/workflows/anti-eav-lint.yml`.
# Required: `2meet-data-optimizer` v1.0.0+ available either via:
# (a) Composer dev dependency on the plugin repo, OR
# (b) Side-by-side checkout in the same workspace.
on:
pull_request:
branches: [ main, master, develop ]
push:
branches: [ main, master ]
jobs:
anti-eav-lint:
name: Anti-EAV Strict Lint (wp tmdo lint --strict)
runs-on: ubuntu-latest
steps:
- name: Checkout this plugin
uses: actions/checkout@v4
with:
path: this-plugin
- name: Checkout 2meet-data-optimizer
uses: actions/checkout@v4
with:
repository: wpdev/2meet-data-optimizer
path: 2meet-data-optimizer
ref: v1.0.0
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2, wp-cli
- name: Install 2meet-data-optimizer dependencies
working-directory: 2meet-data-optimizer
run: composer install --no-interaction --no-dev --prefer-dist
- name: Bootstrap minimal WP for wp-cli
run: |
# Install a throwaway WordPress so wp-cli has runtime context.
wp core download --path=/tmp/wp --skip-content
wp config create --path=/tmp/wp --dbname=wp_lint --dbuser=root --dbpass=root --dbhost=127.0.0.1
# Symlink 2meet-data-optimizer into the wp-content/plugins dir so its CLI loads.
mkdir -p /tmp/wp/wp-content/plugins
ln -s "$GITHUB_WORKSPACE/2meet-data-optimizer" /tmp/wp/wp-content/plugins/2meet-data-optimizer
- name: Run wp tmdo lint --strict
run: |
cd /tmp/wp
wp --skip-themes --skip-plugins=all tmdo lint \
--plugin="$GITHUB_WORKSPACE/this-plugin" \
--strict \
--max-autoload=30
# Exit non-zero on:
# - Direct SELECT FROM wp_postmeta / wp_usermeta / wp_termmeta / wp_commentmeta
# - update_post_meta() on a field already registered to the TMDO Schema Registry
# - autoload=yes options exceeding --max-autoload (default 30)
# - meta_query with ≥3 conditions but no wpdo_register_fields
# Bypass: phpcs:ignore WPDO.AntiEAV.<rule> -- <reason>