diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index dbce53e..02de79a 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -44,6 +44,15 @@ final class TMDO_Hook_Bus { // ── 刪除攔截 ────────────────────────────────────────── add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 ); + // ── dual_write / shadow_read:原生寫入完成後,flat 從原生同步 ── + // Native is the source of truth in these modes; flat follows what + // native actually became (WP may skip or refuse a write). + add_action( "added_{$type}_meta", array( self::class, 'on_meta_added' ), 1, 4 ); + add_action( "updated_{$type}_meta", array( self::class, 'on_meta_updated' ), 1, 4 ); + add_action( "delete_{$type}_meta", array( self::class, 'before_meta_deleted' ), 1, 4 ); + add_action( "deleted_{$type}_meta", array( self::class, 'on_meta_deleted' ), 1, 4 ); + add_filter( "update_{$type}_metadata_by_mid", array( self::class, 'before_update_by_mid' ), 1, 4 ); + // ── 實體刪除時自動清理 ─────────────────────────────── add_action( $adapter->get_delete_hook(), @@ -70,13 +79,13 @@ final class TMDO_Hook_Bus { */ public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) { - // 已在短路中 → 避免遞迴 - if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) { + $type = self::resolve_type_from_current_filter(); + if ( ! $type ) { return $check; } - $type = self::resolve_type_from_current_filter(); - if ( ! $type ) { + // 已在短路中 → 避免遞迴 + if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) { return $check; } @@ -86,10 +95,10 @@ final class TMDO_Hook_Bus { } // ── 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) + // disabled : 完全放行給 WP 原生 meta(回 null / $check) + // dual_write : 放行 WP 寫 EAV;updated_/added_ action 再從原生同步 flat + // shadow_read: 同 dual_write + // aeav_only : 寫 flat,return true 短路 WP(不寫 EAV) if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) { return $check; // disabled } @@ -105,6 +114,13 @@ final class TMDO_Hook_Bus { return true; } + // dual_write / shadow_read: native first. Writing flat here, before WP + // decides whether native changes at all (same value, non-matching + // prev_value), let flat drift; sync_from_native() runs after the write. + if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return null; + } + // 截取 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' ); @@ -113,23 +129,18 @@ final class TMDO_Hook_Bus { 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 ] ) ) { + $type = self::resolve_type_from_current_filter(); + if ( ! $type ) { return $check; } - $type = self::resolve_type_from_current_filter(); - if ( ! $type ) { + // 已在短路中 → 避免遞迴 + if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) { return $check; } @@ -150,6 +161,12 @@ final class TMDO_Hook_Bus { return true; } + // dual_write / shadow_read: native first (add($unique) may be refused); + // sync_from_native() runs on added_{type}_meta. + if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return null; + } + // 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' ); @@ -158,11 +175,225 @@ final class TMDO_Hook_Bus { 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; + } + + // ───────────────────────────────────────────────────────── + // dual_write / shadow_read:原生寫入後同步 flat + // ───────────────────────────────────────────────────────── + + /** + * Entity IDs whose native rows a pending delete removes, keyed "type:key". + * + * @var array + */ + private static array $pending_deletes = array(); + + /** + * Old keys of rows renamed through update_metadata_by_mid, keyed by meta ID. + * + * @var array + */ + private static array $pending_renames = array(); + + /** + * Copy the entity's current native value for a key into its flat column + * (NULL when native has no row). Runs in dual_write / shadow_read only, + * after WP wrote native, so flat always ends up equal to native — also + * when WP skipped or refused the write, and when an earlier direct SQL + * write had left flat stale. + * + * @param string $type Entity type. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Value the caller wrote (for listeners). + * @param string $op 'add' | 'update' | 'delete'. + * @param bool $delete_all Whether the delete was a delete_all. + * @return void + */ + public static function sync_from_native( string $type, int $object_id, string $meta_key, $meta_value, string $op, bool $delete_all = false ): void { + if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return; + } + $field_def = self::field_for( $type, $object_id, $meta_key ); + if ( ! $field_def || 'flat' !== self::decide_route( $op, $type, $object_id, $meta_key, $meta_value ) ) { + return; } - return $flat_result; + $before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op ); + $native = self::native_first_value( $type, $object_id, $meta_key ); + $result = self::perform_upsert( $type, $object_id, $meta_key, null === $native ? null : maybe_unserialize( $native ), $field_def ); + + if ( 'delete' === $op ) { + do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before ); + } else { + do_action( 'wpdo_after_write', $type, $object_id, $meta_key, $meta_value, $result, $op, $before ); + } + } + + /** + * `added_{type}_meta` action. + * + * @param int $meta_id Meta ID. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + */ + public static function on_meta_added( $meta_id, $object_id, $meta_key, $meta_value ): void { + $type = self::resolve_type_from_meta_hook(); + if ( $type ) { + self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'add' ); + } + } + + /** + * `updated_{type}_meta` action; also re-syncs the old key of a row renamed + * through update_metadata_by_mid. + * + * @param int $meta_id Meta ID. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + */ + public static function on_meta_updated( $meta_id, $object_id, $meta_key, $meta_value ): void { + $type = self::resolve_type_from_meta_hook(); + if ( ! $type ) { + return; + } + self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'update' ); + + $rename_key = $type . ':' . (int) $meta_id; + if ( isset( self::$pending_renames[ $rename_key ] ) ) { + $old = self::$pending_renames[ $rename_key ]; + unset( self::$pending_renames[ $rename_key ] ); + self::sync_from_native( $type, $old['id'], $old['key'], null, 'delete' ); + } + } + + /** + * `update_{type}_metadata_by_mid` filter: remember the old key when a row + * is renamed, so its flat column can follow. Never changes $check. + * + * @param mixed $check Short-circuit value. + * @param int $meta_id Meta ID. + * @param mixed $meta_value New value. + * @param string|false $meta_key New key, or false to keep it. + * @return mixed + */ + public static function before_update_by_mid( $check, $meta_id, $meta_value, $meta_key ) { + $type = self::resolve_type_from_meta_hook(); + if ( ! $type || false === $meta_key ) { + return $check; + } + $row = self::native_row_by_mid( $type, (int) $meta_id ); + if ( $row && $row['key'] !== $meta_key && TMDO_Entity_Registry::get_field( $type, $row['key'] ) ) { + self::$pending_renames[ $type . ':' . (int) $meta_id ] = array( + 'type' => $type, + 'id' => $row['id'], + 'key' => $row['key'], + ); + } + return $check; + } + + /** + * `delete_{type}_meta` action (rows still exist): record which entities a + * delete touches — a delete_all passes object ID 0. + * + * @param int[] $meta_ids Meta IDs to delete. + * @param int $object_id Entity ID (0 for delete_all). + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + */ + public static function before_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void { + $type = self::resolve_type_from_meta_hook(); + if ( ! $type || ! TMDO_Entity_Registry::get_field( $type, (string) $meta_key ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return; + } + $ids = array(); + foreach ( (array) $meta_ids as $mid ) { + $row = self::native_row_by_mid( $type, (int) $mid ); + if ( $row ) { + $ids[ $row['id'] ] = $row['id']; + } + } + self::$pending_deletes[ $type . ':' . $meta_key ] = array_values( $ids ); + } + + /** + * `deleted_{type}_meta` action: re-sync every entity the delete touched. + * + * @param int[] $meta_ids Deleted meta IDs. + * @param int $object_id Entity ID (0 for delete_all). + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + */ + public static function on_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void { + $type = self::resolve_type_from_meta_hook(); + if ( ! $type ) { + return; + } + $pending_key = $type . ':' . $meta_key; + $ids = self::$pending_deletes[ $pending_key ] ?? array( (int) $object_id ); + unset( self::$pending_deletes[ $pending_key ] ); + + foreach ( $ids as $id ) { + if ( $id > 0 ) { + self::sync_from_native( $type, (int) $id, (string) $meta_key, $meta_value, 'delete', 0 === (int) $object_id ); + } + } + } + + /** + * First native value of a key (the one get_*_meta( $id, $key, true ) returns), + * read with SQL so no meta filter runs. Null when the entity has no row. + * + * @param string $type Entity type. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @return string|null + */ + private static function native_first_value( string $type, int $object_id, string $meta_key ): ?string { + global $wpdb; + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + if ( ! $adapter ) { + return null; + } + $table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + $pk = 'user' === $type ? 'umeta_id' : 'meta_id'; + // get_results(): get_var() turns an empty string into null. + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$pk}` LIMIT 1", $object_id, $meta_key ), + ARRAY_A + ); + return $rows ? (string) $rows[0]['meta_value'] : null; + } + + /** + * Entity ID and key of a native meta row. + * + * @param string $type Entity type. + * @param int $meta_id Meta ID. + * @return array{id:int, key:string}|null + */ + private static function native_row_by_mid( string $type, int $meta_id ): ?array { + global $wpdb; + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + if ( ! $adapter ) { + return null; + } + $table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + $pk = 'user' === $type ? 'umeta_id' : 'meta_id'; + $row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key AS mkey FROM `{$table}` WHERE `{$pk}` = %d", $meta_id ), ARRAY_A ); + if ( ! $row ) { + return null; + } + return array( + 'id' => (int) $row['id'], + 'key' => (string) $row['mkey'], + ); } /** @@ -322,8 +553,8 @@ final class TMDO_Hook_Bus { $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; + // 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖) + $lock_key = $type . ':' . $entity_id . ':' . $meta_key; self::$internal_ops[ $lock_key ] = true; try { @@ -517,6 +748,12 @@ final class TMDO_Hook_Bus { return true; } + // dual_write / shadow_read: WP deletes native (also delete_all); + // deleted_{type}_meta re-syncs every touched entity from native. + if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return null; + } + // 截取 before value(v1.3.1) $before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' ); @@ -601,12 +838,7 @@ final class TMDO_Hook_Bus { 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; + return $result !== false; // aeav_only } // ───────────────────────────────────────────────────────── @@ -666,6 +898,17 @@ final class TMDO_Hook_Bus { return null; } + /** + * Entity type from the meta action / by-mid filter being run + * (added_user_meta, deleted_post_meta, update_term_metadata_by_mid, …). + */ + private static function resolve_type_from_meta_hook(): ?string { + if ( preg_match( '/^(?:(?:added|updated|delete|deleted)_(post|user|term|comment)_meta|update_(post|user|term|comment)_metadata_by_mid)$/', current_filter(), $matches ) ) { + return '' !== $matches[1] ? $matches[1] : $matches[2]; + } + return null; + } + /** * 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。 * diff --git a/tests/integration/HookBusIntegrationTest.php b/tests/integration/HookBusIntegrationTest.php index 7704bf5..fd66c4a 100644 --- a/tests/integration/HookBusIntegrationTest.php +++ b/tests/integration/HookBusIntegrationTest.php @@ -202,14 +202,71 @@ class HookBusIntegrationTest extends TestCase { $this->assertTrue( $result, 'aeav_only must return true to short-circuit EAV write' ); } - public function test_update_writes_flat_and_returns_null_in_dual_write_mode(): void { + /** + * dual_write: the pre-filter only lets WP write native; flat is synced + * from native afterwards (updated_/added_ actions → sync_from_native). + */ + public function test_update_leaves_flat_to_the_native_sync_in_dual_write_mode(): void { + global $wpdb; self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE ); $GLOBALS['_wp_current_filter'] = 'update_user_metadata'; $result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' ); - $this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) ); $this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' ); + $this->assertNull( $this->read_flat_first_name( 3 ), 'no flat write before native is written' ); + + $this->with_usermeta( + function () use ( $wpdb ) { + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 3, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ) ); + + WPDO_Hook_Bus::sync_from_native( 'user', 3, 'first_name', 'Carol', 'update' ); + + $this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) ); + } + ); + } + + /** + * delete_all passes object ID 0: the delete_ action records the touched + * users from the meta IDs, the deleted_ action re-syncs each to NULL. + */ + public function test_delete_all_resyncs_every_touched_entity_in_dual_write_mode(): void { + global $wpdb; + self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE ); + $wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 21, 'first_name' => 'A' ) ); + $wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 22, 'first_name' => 'B' ) ); + + $this->with_usermeta( + function () use ( $wpdb ) { + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 21, 'meta_key' => 'first_name', 'meta_value' => 'A' ) ); + $mid_a = (int) $wpdb->insert_id; + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 22, 'meta_key' => 'first_name', 'meta_value' => 'B' ) ); + $mid_b = (int) $wpdb->insert_id; + + $GLOBALS['_wp_current_filter'] = 'delete_user_meta'; + WPDO_Hook_Bus::before_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' ); + $wpdb->query( "DELETE FROM `{$wpdb->usermeta}` WHERE meta_key = 'first_name'" ); + $GLOBALS['_wp_current_filter'] = 'deleted_user_meta'; + WPDO_Hook_Bus::on_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' ); + + $this->assertNull( $this->read_flat_first_name( 21 ) ); + $this->assertNull( $this->read_flat_first_name( 22 ) ); + } + ); + } + + /** + * Run $fn with a native usermeta table, dropped afterwards. + */ + private function with_usermeta( callable $fn ): void { + global $wpdb; + $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id) ) ENGINE=InnoDB" ); + try { + $fn(); + } finally { + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" ); + } } public function test_update_passthrough_for_unregistered_key(): void { diff --git a/tests/integration/PostStatusSyncTest.php b/tests/integration/PostStatusSyncTest.php index f4f08f7..b4b4310 100644 --- a/tests/integration/PostStatusSyncTest.php +++ b/tests/integration/PostStatusSyncTest.php @@ -160,7 +160,8 @@ class PostStatusSyncTest extends TestCase { TMDO_Post_Status_Sync::run(); $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); $mode->setAccessible( true ); - $mode->setValue( null, array( 'post' => 'dual_write', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); + // aeav_only: the pre-filter itself writes flat (dual_write syncs from native after the write). + $mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); $GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' ); $GLOBALS['_wp_current_filter'] = 'update_post_metadata'; diff --git a/tests/live/README.md b/tests/live/README.md index a98aa7c..0025e07 100644 --- a/tests/live/README.md +++ b/tests/live/README.md @@ -38,6 +38,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test: |---|---| | `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) | | `serialized` | array meta values are stored serialized in flat tables, never "Array" | +| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) | | `zero_side_effect` | zone tables that were never created are never queried on save / delete | ## Tools (`tools/`) diff --git a/tests/live/lib/fixtures.php b/tests/live/lib/fixtures.php index 1c0fe4c..985b0bd 100644 --- a/tests/live/lib/fixtures.php +++ b/tests/live/lib/fixtures.php @@ -113,6 +113,29 @@ final class TMDO_Live_Fixtures { return (int) $id; } + /** + * Create a throwaway comment (on a throwaway post of $post_type). + * + * @param string $post_type Post type of the parent post. + * @param string $comment_type Comment type. + */ + public static function comment( string $post_type, string $comment_type = 'comment' ): int { + $post_id = self::post( $post_type ); + $id = wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'tmdo-live', + 'comment_type' => $comment_type, + 'comment_approved' => 1, + ) + ); + if ( ! $id ) { + throw new TMDO_Live_Failure( 'fixture comment could not be created' ); + } + self::$created[] = array( 'comment', (int) $id ); + return (int) $id; + } + /** * Native meta values for a key, read with SQL (no filters, no cache). * @@ -191,6 +214,8 @@ final class TMDO_Live_Fixtures { wp_delete_term( $c[1], $c[2] ); } elseif ( 'post' === $c[0] ) { wp_delete_post( $c[1], true ); + } elseif ( 'comment' === $c[0] ) { + wp_delete_comment( $c[1], true ); } } self::$created = array(); diff --git a/tests/live/suites/resync.php b/tests/live/suites/resync.php new file mode 100644 index 0000000..649b815 --- /dev/null +++ b/tests/live/suites/resync.php @@ -0,0 +1,112 @@ + + */ +function tmdo_live_resync_cases(): array { + return array( + 'user first_name' => array( 'user', 'first_name', array( 'TMDO_Live_Fixtures', 'user' ), '' ), + 'hp_listing price' => array( 'post', 'hp_price', static fn() => TMDO_Live_Fixtures::post( 'hp_listing' ), 'hp_listing' ), + 'hp term icon' => array( 'term', 'hp_icon', static fn() => TMDO_Live_Fixtures::term( 'hp_listing_category' ), '' ), + 'hp review rating' => array( 'comment', 'hp_rating', static fn() => TMDO_Live_Fixtures::comment( 'hp_listing', 'hp_review' ), '' ), + ); +} + +/** + * Assert flat equals the first native value (numeric-aware; missing native = NULL flat). + */ +function tmdo_live_assert_in_step( string $type, int $id, string $key, string $post_type, string $context ): void { + $native = TMDO_Live_Fixtures::native( $type, $id, $key ); + $flat = TMDO_Live_Fixtures::flat( $type, $id, $key, $post_type ); + $expect = $native ? $native[0] : null; + if ( 'NO-ROW' === $flat ) { + $flat = null; + } + $same = ( null === $expect || null === $flat ) + ? $expect === $flat + : ( is_numeric( $expect ) && is_numeric( $flat ) ? (float) $expect === (float) $flat : $expect === $flat ); + tmdo_live_assert_true( $same, sprintf( '%s — native %s, flat %s', $context, var_export( $expect, true ), var_export( $flat, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export +} + +foreach ( array( 'dual_write', 'shadow_read' ) as $tmdo_live_mode ) { + foreach ( tmdo_live_resync_cases() as $tmdo_live_label => $tmdo_live_case ) { + list( $type, $key, $make, $pt ) = $tmdo_live_case; + + TMDO_Live::test( + "{$tmdo_live_mode} {$tmdo_live_label}: update, same value, delete", + static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) { + TMDO_Live_Fixtures::field( $type, $key, $pt ); + TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) ); + $id = $make(); + + update_metadata( $type, $id, $key, '11' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'new key' ); + update_metadata( $type, $id, $key, '12' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'update' ); + update_metadata( $type, $id, $key, '12' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'same value' ); + delete_metadata( $type, $id, $key, '12' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'delete with value' ); + } + ); + + TMDO_Live::test( + "{$tmdo_live_mode} {$tmdo_live_label}: writes WP refuses leave flat alone", + static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) { + TMDO_Live_Fixtures::field( $type, $key, $pt ); + TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) ); + $id = $make(); + update_metadata( $type, $id, $key, '21' ); + + update_metadata( $type, $id, $key, '22', 'no-such-previous-value' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'non-matching prev_value' ); + add_metadata( $type, $id, $key, '23', true ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'add unique on existing key' ); + update_metadata( $type, $id, $key, '24', '21' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'matching prev_value' ); + } + ); + + TMDO_Live::test( + "{$tmdo_live_mode} {$tmdo_live_label}: stale flat heals on the next write, key change by meta_id", + static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) { + global $wpdb; + TMDO_Live_Fixtures::field( $type, $key, $pt ); + TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) ); + $id = $make(); + update_metadata( $type, $id, $key, '31' ); + + $table = _get_meta_table( $type ); + $id_col = $type . '_id'; + $pk = 'user' === $type ? 'umeta_id' : 'meta_id'; + + // A direct SQL write (as WooCommerce does for stock) leaves flat stale. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names. + $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET meta_value = '32' WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) ); + wp_cache_delete( $id, $type . '_meta' ); + update_metadata( $type, $id, $key, '33' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'next write after a direct SQL change' ); + + // Renaming the key by meta_id removes it from native: flat must follow. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names. + $mid = (int) $wpdb->get_var( $wpdb->prepare( "SELECT {$pk} FROM {$table} WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) ); + update_metadata_by_mid( $type, $mid, '34', 'tmdo_live_renamed' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'key renamed by meta_id' ); + } + ); + } +}