diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index ce6be65..46f001a 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -951,8 +951,20 @@ final class TMDO_Hook_Bus { if ( null !== $field_def && 'post' === $type ) { $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) ); } - // A group whose table upgrade waits for WP-CLI / WP-Cron stays native. - return null !== $field_def && TMDO_Schema_Manager::is_deferred( $type, (string) $field_def['group'] ) ? null : $field_def; + // A group whose table upgrade waits for WP-CLI / WP-Cron stays native + // (not in aeav_only, where native is stale; the router agrees). + return null !== $field_def && self::deferred( $type, (string) $field_def['group'] ) ? null : $field_def; + } + + /** + * Whether a group is deferred and so read and written natively. + * + * @param string $type Entity type. + * @param string $group Group name. + * @return bool + */ + private static function deferred( string $type, string $group ): bool { + return TMDO_Schema_Manager::is_deferred( $type, $group ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $type ); } /** @@ -970,10 +982,13 @@ final class TMDO_Hook_Bus { // 回傳所有群組的所有欄位 $result = array(); foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) { - $row = self::get_or_load_row( $type, $entity_id, $group ); + $deferred = self::deferred( $type, $group ); + $row = $deferred ? array() : self::get_or_load_row( $type, $entity_id, $group ); foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) { $col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] ); - $result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field ); + $result[ $field['key'] ] = $deferred + ? get_metadata( $type, $entity_id, $field['key'], true ) + : TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field ); } } return $result; @@ -981,7 +996,8 @@ final class TMDO_Hook_Bus { $field_def = self::field_for( $type, $entity_id, $key ); if ( ! $field_def ) { - return null; + // A deferred group's value is the native one. + return null !== TMDO_Entity_Registry::get_field( $type, $key ) ? get_metadata( $type, $entity_id, $key, true ) : null; } $row = self::get_or_load_row( $type, $entity_id, $field_def['group'] ); diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index c6d5ca8..004c267 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -88,6 +88,8 @@ final class TMDO_Schema_Manager { $stored_hash = self::get_stored_schema_hash( $entity_type, $group_name ); if ( $stored_hash === $schema_hash ) { + // Upgraded meanwhile, or the definition went back to the stored one. + self::end_deferral( $entity_type, $group_name ); return true; } @@ -102,7 +104,16 @@ final class TMDO_Schema_Manager { return false; } if ( ! self::table_exists( $table ) ) { - return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ); + if ( ! self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ) ) { + return false; + } + // A new table starts empty while native meta may already hold the + // group's values: native until the drift fix has filled it in. + if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ) ) { + self::record_deferred( "{$entity_type}/{$group_name}" ); + self::end_deferral( $entity_type, $group_name ); + } + return true; } // An existing table is altered by one process at a time (anonymous @@ -134,6 +145,7 @@ final class TMDO_Schema_Manager { */ private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool { global $wpdb; + $existed = self::table_exists( $table ); $charset = $wpdb->get_charset_collate(); $id_col = $adapter->get_entity_id_column(); @@ -204,9 +216,27 @@ final class TMDO_Schema_Manager { // Bust the request-scoped table_exists cache so subsequent calls see the new table. self::$table_exists_cache[ $table ] = true; + // dbDelta reports no failure: record the definition only when every column is there. + $columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); + $missing = array_diff( array_map( static fn( $field ) => self::sanitize_column_name( (string) $field['key'] ), $field_definitions ), $columns ); + if ( $missing ) { + unset( self::$table_exists_cache[ $table ] ); + TMDO_Logger::warning( + 'schema.upgrade_failed', + array( + 'table' => $table, + 'missing' => array_values( $missing ), + 'error' => $wpdb->last_error, + ) + ); + return false; + } + // 記錄 Schema 版本與定義 self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions ); - self::end_deferral( $entity_type, $group_name ); + if ( $existed ) { + self::end_deferral( $entity_type, $group_name ); + } /** * Action: 表建立/升級完成 @@ -294,11 +324,20 @@ final class TMDO_Schema_Manager { } /** - * INDEXES_HOOK handler: add the indexes a visitor request could not. + * INDEXES_HOOK handler: the table upgrades and indexes a web request left. + * The cron request's init usually did them already; not with + * ALTERNATE_WP_CRON, where the event runs inside a visitor's request. * * @return void */ public static function ensure_pending_indexes(): void { + foreach ( array_keys( (array) get_option( self::DEFERRED_OPTION, array() ) ) as $id ) { + list( $entity_type, $group_name ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' ); + $fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); + if ( $fields ) { + self::create_or_upgrade_table( $entity_type, $group_name, $fields ); + } + } foreach ( self::pending_indexes() as $id ) { list( $entity_type, $group_name ) = explode( '/', $id, 2 ); self::ensure_group_indexes( $entity_type, $group_name ); @@ -341,15 +380,25 @@ final class TMDO_Schema_Manager { * @return void */ private static function defer_upgrade( string $id ): void { + self::record_deferred( $id ); + if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) { + wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade. + } + } + + /** + * Record a group as deferred (native until resynced()). + * + * @param string $id "entity/group". + * @return void + */ + private static function record_deferred( string $id ): void { $deferred = get_option( self::DEFERRED_OPTION, array() ); $deferred = is_array( $deferred ) ? $deferred : array(); if ( ! isset( $deferred[ $id ] ) ) { $deferred[ $id ] = time(); update_option( self::DEFERRED_OPTION, $deferred, true ); } - if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) { - wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade. - } } /** diff --git a/tests/integration/HookBusIntegrationTest.php b/tests/integration/HookBusIntegrationTest.php index 5d60c23..e10c28f 100644 --- a/tests/integration/HookBusIntegrationTest.php +++ b/tests/integration/HookBusIntegrationTest.php @@ -243,6 +243,7 @@ class HookBusIntegrationTest extends TestCase { WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' ); $this->assertNull( $this->read_flat_first_name( 4 ) ); + $this->assertSame( 'Dora', WPDO_Hook_Bus::direct_read( 'user', 4, 'first_name' ), 'direct reads return the native value' ); } ); } finally { diff --git a/tests/integration/SchemaUpgradeDeferralTest.php b/tests/integration/SchemaUpgradeDeferralTest.php index 4891d91..be9ee4f 100644 --- a/tests/integration/SchemaUpgradeDeferralTest.php +++ b/tests/integration/SchemaUpgradeDeferralTest.php @@ -139,4 +139,39 @@ class SchemaUpgradeDeferralTest extends TestCase { $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) ); $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) ); } + + /** + * The definition went back to the stored one (or another process upgraded + * the table) while the group was deferred (correctness review F2). + */ + public function test_a_deferred_group_whose_definition_matches_again_is_resynced_then_released(): void { + global $wpdb; + $current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) ); + $wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) ); + update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) ); + + $this->assertTrue( $this->upgrade() ); + + $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'dual_write: native until the drift fix' ); + $this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] ); + + self::set_user_mode( 'disabled' ); + $this->upgrade(); + $this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'no flat writes: nothing to rebuild' ); + } + + /** + * The cron event upgrades deferred groups itself: with ALTERNATE_WP_CRON + * it runs inside a visitor's request, after init (correctness review F3). + */ + public function test_the_cron_event_handles_deferred_groups(): void { + global $wpdb; + $current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) ); + $wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) ); + update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) ); + + TMDO_Schema_Manager::ensure_pending_indexes(); + + $this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] ); + } } diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 1c9cbcc..99679d6 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -299,6 +299,19 @@ if ( ! function_exists( 'do_action_ref_array' ) ) { if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } } if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } } if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } } +if ( ! function_exists( 'get_metadata' ) ) { + // Like get_metadata(): the first stored value of a key (single), read from the real meta table. + function get_metadata( string $type, $object_id, string $key = '', bool $single = false ) { + global $wpdb; + $table = $wpdb->{$type . 'meta'}; + $id = 'user' === $type ? 'umeta_id' : 'meta_id'; + $value = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$type}_id` = %d AND meta_key = %s ORDER BY `{$id}` LIMIT 1", $object_id, $key ) ); + if ( $single ) { + return null === $value ? '' : maybe_unserialize( $value ); + } + return null === $value ? array() : array( maybe_unserialize( $value ) ); + } +} if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } } if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } } if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }