From 317a26e8db271fbdc5a5e78437af7c33410dae08 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 26 Feb 2026 17:07:31 -0500 Subject: [PATCH 01/82] feat: replace post meta sync storage with dedicated sync_updates table --- src/wp-admin/includes/schema.php | 8 + src/wp-admin/includes/upgrade.php | 12 +- src/wp-includes/class-wpdb.php | 10 + .../class-wp-sync-post-meta-storage.php | 322 ------------------ .../class-wp-sync-table-storage.php | 221 ++++++++++++ src/wp-includes/post.php | 35 -- src/wp-includes/rest-api.php | 14 +- src/wp-includes/version.php | 2 +- src/wp-settings.php | 2 +- .../tests/rest-api/rest-sync-server.php | 285 +++++++++++++++- 10 files changed, 541 insertions(+), 370 deletions(-) delete mode 100644 src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php create mode 100644 src/wp-includes/collaboration/class-wp-sync-table-storage.php diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 7a95f65ad80cc..bc1952261859e 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -186,6 +186,14 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { KEY post_parent (post_parent), KEY post_author (post_author), KEY type_status_author (post_type,post_status,post_author) +) $charset_collate; +CREATE TABLE $wpdb->sync_updates ( + id bigint(20) unsigned NOT NULL auto_increment, + room_hash char(32) NOT NULL, + update_value longtext NOT NULL, + created_at datetime NOT NULL default '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY room_hash (room_hash,id) ) $charset_collate;\n"; // Single site users table. The multisite flavor of the users table is handled below. diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 6adb0521ff295..487e898eb4490 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -886,7 +886,7 @@ function upgrade_all() { upgrade_682(); } - if ( $wp_current_db_version < 61644 ) { + if ( $wp_current_db_version < 61697 ) { upgrade_700(); } @@ -2508,6 +2508,16 @@ function upgrade_700() { ) ); } + + // Clean up orphaned wp_sync_storage posts from the beta1 post-meta storage approach. + if ( $wp_current_db_version < 61697 ) { + $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => 'wp_sync_update' ) ); + $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => 'wp_sync_awareness' ) ); + $wpdb->delete( + $wpdb->posts, + array( 'post_type' => 'wp_sync_storage' ) + ); + } } /** diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 23c865b87d817..b66be95487181 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -299,6 +299,7 @@ class wpdb { 'term_relationships', 'termmeta', 'commentmeta', + 'sync_updates', ); /** @@ -404,6 +405,15 @@ class wpdb { */ public $posts; + /** + * WordPress Sync Updates table. + * + * @since 7.0.0 + * + * @var string + */ + public $sync_updates; + /** * WordPress Terms table. * diff --git a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php deleted file mode 100644 index c605fa48699b7..0000000000000 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ /dev/null @@ -1,322 +0,0 @@ - - */ - private array $room_cursors = array(); - - /** - * Cache of update counts by room. - * - * @since 7.0.0 - * @var array - */ - private array $room_update_counts = array(); - - /** - * Cache of storage post IDs by room hash. - * - * @since 7.0.0 - * @var array - */ - private static array $storage_post_ids = array(); - - /** - * Adds a sync update to a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param mixed $update Sync update. - * @return bool True on success, false on failure. - */ - public function add_update( string $room, $update ): bool { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // Create an envelope and stamp each update to enable cursor-based filtering. - $envelope = array( - 'timestamp' => $this->get_time_marker(), - 'value' => $update, - ); - - return (bool) add_post_meta( $post_id, wp_slash( self::SYNC_UPDATE_META_KEY ), wp_slash( $envelope ), false ); - } - - /** - * Retrieves all sync updates for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return array Sync updates. - */ - private function get_all_updates( string $room ): array { - $this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency. - - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return array(); - } - - $updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false ); - - if ( ! is_array( $updates ) ) { - $updates = array(); - } - - // Filter out any updates that don't have the expected structure. - $updates = array_filter( - $updates, - static function ( $update ): bool { - return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); - } - ); - - $this->room_update_counts[ $room ] = count( $updates ); - - return $updates; - } - - /** - * Gets awareness state for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return array Awareness state. - */ - public function get_awareness_state( string $room ): array { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return array(); - } - - $awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true ); - - if ( ! is_array( $awareness ) ) { - return array(); - } - - return array_values( $awareness ); - } - - /** - * Sets awareness state for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param array $awareness Serializable awareness state. - * @return bool True on success, false on failure. - */ - public function set_awareness_state( string $room, array $awareness ): bool { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - // update_post_meta returns false if the value is the same as the existing value. - update_post_meta( $post_id, wp_slash( self::AWARENESS_META_KEY ), wp_slash( $awareness ) ); - return true; - } - - /** - * Gets the current cursor for a given room. - * - * The cursor is set during get_updates_after_cursor() and represents the - * point in time just before the updates were retrieved, with a small buffer - * to ensure consistency. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Current cursor for the room. - */ - public function get_cursor( string $room ): int { - return $this->room_cursors[ $room ] ?? 0; - } - - /** - * Gets or creates the storage post for a given room. - * - * Each room gets its own dedicated post so that post meta cache - * invalidation is scoped to a single room rather than all of them. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int|null Post ID. - */ - private function get_storage_post_id( string $room ): ?int { - $room_hash = md5( $room ); - - if ( isset( self::$storage_post_ids[ $room_hash ] ) ) { - return self::$storage_post_ids[ $room_hash ]; - } - - // Try to find an existing post for this room. - $posts = get_posts( - array( - 'post_type' => self::POST_TYPE, - 'posts_per_page' => 1, - 'post_status' => 'publish', - 'name' => $room_hash, - 'fields' => 'ids', - ) - ); - - $post_id = array_first( $posts ); - if ( is_int( $post_id ) ) { - self::$storage_post_ids[ $room_hash ] = $post_id; - return $post_id; - } - - // Create new post for this room. - $post_id = wp_insert_post( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => 'publish', - 'post_title' => 'Sync Storage', - 'post_name' => $room_hash, - ) - ); - - if ( is_int( $post_id ) ) { - self::$storage_post_ids[ $room_hash ] = $post_id; - return $post_id; - } - - return null; - } - - /** - * Gets the current time in milliseconds as a comparable time marker. - * - * @since 7.0.0 - * - * @return int Current time in milliseconds. - */ - private function get_time_marker(): int { - return (int) floor( microtime( true ) * 1000 ); - } - - /** - * Gets the number of updates stored for a given room. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @return int Number of updates stored for the room. - */ - public function get_update_count( string $room ): int { - return $this->room_update_counts[ $room ] ?? 0; - } - - /** - * Retrieves sync updates from a room for a given client and cursor. Updates - * from the specified client should be excluded. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Return updates after this cursor. - * @return array Sync updates. - */ - public function get_updates_after_cursor( string $room, int $cursor ): array { - $all_updates = $this->get_all_updates( $room ); - $updates = array(); - - foreach ( $all_updates as $update ) { - if ( $update['timestamp'] > $cursor ) { - $updates[] = $update; - } - } - - // Sort by timestamp to ensure order. - usort( - $updates, - fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp'] - ); - - return wp_list_pluck( $updates, 'value' ); - } - - /** - * Removes updates from a room that are older than the given cursor. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. - * @return bool True on success, false on failure. - */ - public function remove_updates_before_cursor( string $room, int $cursor ): bool { - $post_id = $this->get_storage_post_id( $room ); - if ( null === $post_id ) { - return false; - } - - $all_updates = $this->get_all_updates( $room ); - - // Remove all updates for the room and re-store only those that are newer than the cursor. - if ( ! delete_post_meta( $post_id, wp_slash( self::SYNC_UPDATE_META_KEY ) ) ) { - return false; - } - - // Re-store envelopes directly to avoid double-wrapping by add_update(). - $add_result = true; - foreach ( $all_updates as $envelope ) { - if ( $add_result && $envelope['timestamp'] >= $cursor ) { - $add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); - } - } - - return $add_result; - } -} diff --git a/src/wp-includes/collaboration/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-sync-table-storage.php new file mode 100644 index 0000000000000..ce9d5699c59e3 --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-sync-table-storage.php @@ -0,0 +1,221 @@ + + */ + private array $room_cursors = array(); + + /** + * Cache of update counts by room. + * + * @since 7.0.0 + * @var array + */ + private array $room_update_counts = array(); + + /** + * Adds a sync update to a given room. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param mixed $update Sync update. + * @return bool True on success, false on failure. + */ + public function add_update( string $room, $update ): bool { + global $wpdb; + + $result = $wpdb->insert( + $wpdb->sync_updates, + array( + 'room_hash' => md5( $room ), + 'update_value' => wp_json_encode( $update ), + 'created_at' => current_time( 'mysql', true ), + ), + array( '%s', '%s', '%s' ) + ); + + return false !== $result; + } + + /** + * Gets awareness state for a given room. + * + * Awareness is ephemeral and stored as a transient rather than + * in the sync_updates table. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return array Awareness state. + */ + public function get_awareness_state( string $room ): array { + $awareness = get_transient( 'sync_awareness_' . md5( $room ) ); + + if ( ! is_array( $awareness ) ) { + return array(); + } + + return array_values( $awareness ); + } + + /** + * Gets the current cursor for a given room. + * + * The cursor is set during get_updates_after_cursor() and represents the + * maximum row ID at the time updates were retrieved. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Current cursor for the room. + */ + public function get_cursor( string $room ): int { + return $this->room_cursors[ $room ] ?? 0; + } + + /** + * Gets the number of updates stored for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Number of updates stored for the room. + */ + public function get_update_count( string $room ): int { + return $this->room_update_counts[ $room ] ?? 0; + } + + /** + * Retrieves sync updates from a room after a given cursor. + * + * Uses a snapshot approach: captures MAX(id) first, then fetches rows + * WHERE id > cursor AND id <= max_id. Updates arriving after the snapshot + * are deferred to the next poll, never lost. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @return array Sync updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array { + global $wpdb; + + $room_hash = md5( $room ); + + // Snapshot the current max ID for this room to define a stable upper bound. + $max_id = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->sync_updates} WHERE room_hash = %s", + $room_hash + ) + ); + + $this->room_cursors[ $room ] = $max_id; + + if ( 0 === $max_id || $max_id <= $cursor ) { + $this->room_update_counts[ $room ] = 0; + return array(); + } + + // Count total updates for this room (used by compaction threshold logic). + // Bounded by max_id to stay consistent with the snapshot window above. + $this->room_update_counts[ $room ] = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->sync_updates} WHERE room_hash = %s AND id <= %d", + $room_hash, + $max_id + ) + ); + + // Fetch updates after the cursor up to the snapshot boundary. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT update_value FROM {$wpdb->sync_updates} WHERE room_hash = %s AND id > %d AND id <= %d ORDER BY id ASC", + $room_hash, + $cursor, + $max_id + ) + ); + + if ( ! is_array( $rows ) ) { + return array(); + } + + $updates = array(); + foreach ( $rows as $row ) { + $updates[] = json_decode( $row->update_value, true ); + } + + return $updates; + } + + /** + * Removes updates from a room that are older than the given cursor. + * + * Uses a single atomic DELETE query, avoiding the race-prone + * "delete all, re-add some" pattern. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with id < this cursor. + * @return bool True on success, false on failure. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): bool { + global $wpdb; + + $result = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->sync_updates} WHERE room_hash = %s AND id < %d", + md5( $room ), + $cursor + ) + ); + + return false !== $result; + } + + /** + * Sets awareness state for a given room. + * + * Awareness is ephemeral and stored as a transient with a short timeout. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. + * @return bool True on success, false on failure. + */ + public function set_awareness_state( string $room, array $awareness ): bool { + // Awareness is high-frequency, short-lived data (cursor positions, selections) + // that doesn't need cursor-based history. Transients avoid row churn in the table. + return set_transient( 'sync_awareness_' . md5( $room ), $awareness, MINUTE_IN_SECONDS ); + } +} diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 896142603278b..55d934518d5f0 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -657,41 +657,6 @@ function create_initial_post_types() { ) ); - if ( get_option( 'wp_enable_real_time_collaboration' ) ) { - register_post_type( - 'wp_sync_storage', - array( - 'labels' => array( - 'name' => __( 'Sync Updates' ), - 'singular_name' => __( 'Sync Update' ), - ), - 'public' => false, - '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ - 'hierarchical' => false, - 'capabilities' => array( - 'read' => 'do_not_allow', - 'read_private_posts' => 'do_not_allow', - 'create_posts' => 'do_not_allow', - 'publish_posts' => 'do_not_allow', - 'edit_posts' => 'do_not_allow', - 'edit_others_posts' => 'do_not_allow', - 'edit_published_posts' => 'do_not_allow', - 'delete_posts' => 'do_not_allow', - 'delete_others_posts' => 'do_not_allow', - 'delete_published_posts' => 'do_not_allow', - ), - 'map_meta_cap' => false, - 'publicly_queryable' => false, - 'query_var' => false, - 'rewrite' => false, - 'show_in_menu' => false, - 'show_in_rest' => false, - 'show_ui' => false, - 'supports' => array( 'custom-fields' ), - ) - ); - } - register_post_status( 'publish', array( diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index df7f262d3aa58..42edfad42e230 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -431,8 +431,18 @@ function create_initial_rest_routes() { // Collaboration. if ( get_option( 'wp_enable_real_time_collaboration' ) ) { - $sync_storage = new WP_Sync_Post_Meta_Storage(); - $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); + $sync_storage = new WP_Sync_Table_Storage(); + + /** + * Filters the sync storage backend used for real-time collaboration. + * + * @since 7.0.0 + * + * @param WP_Sync_Storage $sync_storage Storage backend instance. + */ + $sync_storage = apply_filters( 'wp_sync_storage', $sync_storage ); + + $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); $sync_server->register_routes(); } } diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 6e6f87cc193fc..f7dc90413edde 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61696; +$wp_db_version = 61697; /** * Holds the TinyMCE version. diff --git a/src/wp-settings.php b/src/wp-settings.php index 023cdccd5ecc9..f4db57392965c 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -310,7 +310,7 @@ require ABSPATH . WPINC . '/abilities-api.php'; require ABSPATH . WPINC . '/abilities.php'; require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php'; -require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-sync-table-storage.php'; require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php'; require ABSPATH . WPINC . '/collaboration.php'; require ABSPATH . WPINC . '/rest-api.php'; diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index a09b256115f48..05e535fba9cff 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -31,12 +31,10 @@ public static function wpTearDownAfterClass() { public function set_up() { parent::set_up(); - // Reset storage post ID cache to ensure clean state after transaction rollback. - $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' ); - if ( PHP_VERSION_ID < 80100 ) { - $reflection->setAccessible( true ); - } - $reflection->setValue( null, array() ); + // Uses DELETE (not TRUNCATE) to preserve transaction rollback support + // in the test suite. TRUNCATE implicitly commits the transaction. + global $wpdb; + $wpdb->query( "DELETE FROM {$wpdb->sync_updates}" ); } /** @@ -301,14 +299,18 @@ public function test_sync_response_room_matches_request() { $this->assertSame( $room, $data['rooms'][0]['room'] ); } - public function test_sync_end_cursor_is_positive_integer() { + /** + * @ticket 64696 + */ + public function test_sync_end_cursor_is_non_negative_integer() { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); $data = $response->get_data(); $this->assertIsInt( $data['rooms'][0]['end_cursor'] ); - $this->assertGreaterThan( 0, $data['rooms'][0]['end_cursor'] ); + // Cursor is 0 for an empty room (no rows in the table yet). + $this->assertGreaterThanOrEqual( 0, $data['rooms'][0]['end_cursor'] ); } public function test_sync_empty_updates_returns_zero_total() { @@ -757,4 +759,271 @@ public function test_sync_rooms_are_isolated() { // Room 2 should have no updates. $this->assertEmpty( $data['rooms'][1]['updates'] ); } + + /* + * Cursor tests. + */ + + /** + * @ticket 64696 + */ + public function test_sync_empty_room_cursor_is_zero() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + + $data = $response->get_data(); + $this->assertSame( 0, $data['rooms'][0]['end_cursor'] ); + } + + /** + * @ticket 64696 + */ + public function test_sync_cursor_advances_monotonically() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // First request. + $response1 = $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + $cursor1 = $response1->get_data()['rooms'][0]['end_cursor']; + + // Second request with more updates. + $response2 = $this->dispatch_sync( + array( + $this->build_room( $room, 2, $cursor1, array( 'user' => 'c2' ), array( $update ) ), + ) + ); + $cursor2 = $response2->get_data()['rooms'][0]['end_cursor']; + + $this->assertGreaterThan( $cursor1, $cursor2, 'Cursor should advance monotonically with new updates.' ); + } + + /** + * @ticket 64696 + */ + public function test_sync_cursor_prevents_re_delivery() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'first-batch' ), + ); + + // Client 1 sends an update. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + // Client 2 fetches updates and gets a cursor. + $response1 = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), + ) + ); + $data1 = $response1->get_data(); + $cursor1 = $data1['rooms'][0]['end_cursor']; + + $this->assertNotEmpty( $data1['rooms'][0]['updates'], 'First poll should return updates.' ); + + // Client 2 polls again using the cursor from the first poll, with no new updates. + $response2 = $this->dispatch_sync( + array( + $this->build_room( $room, 2, $cursor1, array( 'user' => 'c2' ) ), + ) + ); + $data2 = $response2->get_data(); + + $this->assertEmpty( $data2['rooms'][0]['updates'], 'Second poll with cursor should not re-deliver updates.' ); + } + + /* + * Cache thrashing tests. + */ + + /** + * @ticket 64696 + */ + public function test_sync_operations_do_not_affect_posts_last_changed() { + wp_set_current_user( self::$editor_id ); + + // Prime the posts last changed cache. + wp_cache_set_posts_last_changed(); + $last_changed_before = wp_cache_get_last_changed( 'posts' ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Perform several sync operations. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0, array( 'user' => 'c2' ), array( $update ) ), + ) + ); + + $last_changed_after = wp_cache_get_last_changed( 'posts' ); + + $this->assertSame( $last_changed_before, $last_changed_after, 'Sync operations should not invalidate the posts last changed cache.' ); + } + + /* + * Race condition tests. + */ + + /** + * @ticket 64696 + */ + public function test_sync_compaction_does_not_lose_concurrent_updates() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sends an initial batch of updates. + $initial_updates = array(); + for ( $i = 0; $i < 5; $i++ ) { + $initial_updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "initial-$i" ), + ); + } + + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $initial_updates ), + ) + ); + + $data = $response->get_data(); + $cursor = $data['rooms'][0]['end_cursor']; + + // Client 2 sends a new update (simulating a concurrent write). + $concurrent_update = array( + 'type' => 'update', + 'data' => base64_encode( 'concurrent' ), + ); + $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0, array( 'user' => 'c2' ), array( $concurrent_update ) ), + ) + ); + + // Client 1 sends a compaction update using its cursor. + $compaction_update = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted-state' ), + ); + $this->dispatch_sync( + array( + $this->build_room( $room, 1, $cursor, array( 'user' => 'c1' ), array( $compaction_update ) ), + ) + ); + + // Client 3 requests all updates from the beginning. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 3, 0, array( 'user' => 'c3' ) ), + ) + ); + + $data = $response->get_data(); + $room_updates = $data['rooms'][0]['updates']; + $update_data = wp_list_pluck( $room_updates, 'data' ); + + // The concurrent update must not be lost. + $this->assertContains( base64_encode( 'concurrent' ), $update_data, 'Concurrent update should not be lost during compaction.' ); + + // The compaction update should be present. + $this->assertContains( base64_encode( 'compacted-state' ), $update_data, 'Compaction update should be present.' ); + } + + /** + * @ticket 64696 + */ + public function test_sync_compaction_reduces_total_updates() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 10; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends 10 updates. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + $data = $response->get_data(); + $cursor = $data['rooms'][0]['end_cursor']; + + // Client 1 sends a compaction to replace the 10 updates. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted' ), + ); + $this->dispatch_sync( + array( + $this->build_room( $room, 1, $cursor, array( 'user' => 'c1' ), array( $compaction ) ), + ) + ); + + // Client 2 checks the state. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertLessThan( 10, $data['rooms'][0]['total_updates'], 'Compaction should reduce the total update count.' ); + } + + /* + * Storage filter tests. + */ + + /** + * @ticket 64696 + */ + public function test_sync_storage_filter_is_applied() { + $filter_called = false; + + add_filter( + 'wp_sync_storage', + static function ( $storage ) use ( &$filter_called ) { + $filter_called = true; + return $storage; + } + ); + + // Re-trigger route registration to invoke the filter. + $server = rest_get_server(); + do_action( 'rest_api_init', $server ); + + $this->assertTrue( $filter_called, 'The wp_sync_storage filter should be applied during route registration.' ); + } } From 1bbfc48e557eff62026c999fb8533a9e5738caaa Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 09:12:45 -0500 Subject: [PATCH 02/82] Update src/wp-admin/includes/upgrade.php Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- src/wp-admin/includes/upgrade.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 487e898eb4490..3373240037a5e 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -2508,16 +2508,6 @@ function upgrade_700() { ) ); } - - // Clean up orphaned wp_sync_storage posts from the beta1 post-meta storage approach. - if ( $wp_current_db_version < 61697 ) { - $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => 'wp_sync_update' ) ); - $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => 'wp_sync_awareness' ) ); - $wpdb->delete( - $wpdb->posts, - array( 'post_type' => 'wp_sync_storage' ) - ); - } } /** From bd80f59480271efa36c2555793b9edd26014b3d1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 10:36:51 -0500 Subject: [PATCH 03/82] Guard sync route registration against missing table Adds a db_version check before registering the collaboration REST routes so sites that haven't run the upgrade routine yet don't hit a fatal error from the missing sync_updates table. --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 42edfad42e230..70de8f16f28ce 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -430,7 +430,7 @@ function create_initial_rest_routes() { $icons_controller->register_routes(); // Collaboration. - if ( get_option( 'wp_enable_real_time_collaboration' ) ) { + if ( get_option( 'wp_enable_real_time_collaboration' ) && get_option( 'db_version' ) >= 61697 ) { $sync_storage = new WP_Sync_Table_Storage(); /** From 5fbf56ecc471b1d43318e1ed9e19c9ae5c7fb56e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 13:54:58 -0500 Subject: [PATCH 04/82] Add WP-Cron cleanup for wp_sync_updates table Schedule a daily cron job to delete sync update rows older than 1 day, preventing unbounded table growth from abandoned collaborative editing sessions. The expiration is filterable via the wp_sync_updates_expiration hook. --- src/wp-admin/admin.php | 5 +++++ src/wp-includes/collaboration.php | 31 +++++++++++++++++++++++++++++ src/wp-includes/default-filters.php | 1 + 3 files changed, 37 insertions(+) diff --git a/src/wp-admin/admin.php b/src/wp-admin/admin.php index 1186f9bedce21..c92e8874cd83c 100644 --- a/src/wp-admin/admin.php +++ b/src/wp-admin/admin.php @@ -113,6 +113,11 @@ wp_schedule_event( time(), 'daily', 'delete_expired_transients' ); } +// Schedule sync updates cleanup. +if ( ! wp_next_scheduled( 'wp_delete_old_sync_updates' ) && ! wp_installing() ) { + wp_schedule_event( time(), 'daily', 'wp_delete_old_sync_updates' ); +} + set_screen_options(); $date_format = __( 'F j, Y' ); diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 6fdbe2889ba7a..4c1a4befabda1 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -22,3 +22,34 @@ function wp_collaboration_inject_setting() { ); } } + +/** + * Deletes sync updates older than 1 day from the wp_sync_updates table. + * + * Rows left behind by abandoned collaborative editing sessions are cleaned up + * to prevent unbounded table growth. + * + * @since 7.0.0 + */ +function wp_delete_old_sync_updates() { + global $wpdb; + + /** + * Filters the lifetime, in seconds, of a sync update row. + * + * By default, the lifetime is 1 day. Once a row reaches that age, it will + * automatically be deleted by a cron job. + * + * @since 7.0.0 + * + * @param int $expiration The expiration age of a sync update row, in seconds. + */ + $expiration = apply_filters( 'wp_sync_updates_expiration', DAY_IN_SECONDS ); + + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->sync_updates} WHERE created_at < %s", + gmdate( 'Y-m-d H:i:s', time() - $expiration ) + ) + ); +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 8ce256e046f84..904d2bf4f9006 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -454,6 +454,7 @@ add_action( 'importer_scheduled_cleanup', 'wp_delete_attachment' ); add_action( 'upgrader_scheduled_cleanup', 'wp_delete_attachment' ); add_action( 'delete_expired_transients', 'delete_expired_transients' ); +add_action( 'wp_delete_old_sync_updates', 'wp_delete_old_sync_updates' ); // Navigation menu actions. add_action( 'delete_post', '_wp_delete_post_menu_item' ); From a78fceac5b08ac702f388d2fdb8dd3933c30d2b6 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 14:42:07 -0500 Subject: [PATCH 05/82] Replace room_hash with human-readable room column in sync_updates table Store the room identifier string directly instead of its md5 hash. Room strings like "postType/post:42" are short, already validated by the REST API schema, and storing them verbatim improves debuggability. --- src/wp-admin/includes/schema.php | 4 ++-- src/wp-admin/includes/upgrade.php | 2 +- .../class-wp-sync-table-storage.php | 24 +++++++++---------- src/wp-includes/rest-api.php | 2 +- src/wp-includes/version.php | 2 +- 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index bc1952261859e..2472530d2bb8c 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -189,11 +189,11 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { ) $charset_collate; CREATE TABLE $wpdb->sync_updates ( id bigint(20) unsigned NOT NULL auto_increment, - room_hash char(32) NOT NULL, + room varchar(255) NOT NULL, update_value longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), - KEY room_hash (room_hash,id) + KEY room (room,id) ) $charset_collate;\n"; // Single site users table. The multisite flavor of the users table is handled below. diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 3373240037a5e..e49c3b7d1e6c0 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -886,7 +886,7 @@ function upgrade_all() { upgrade_682(); } - if ( $wp_current_db_version < 61697 ) { + if ( $wp_current_db_version < 61698 ) { upgrade_700(); } diff --git a/src/wp-includes/collaboration/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-sync-table-storage.php index ce9d5699c59e3..2c02f6c431122 100644 --- a/src/wp-includes/collaboration/class-wp-sync-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-table-storage.php @@ -49,7 +49,7 @@ public function add_update( string $room, $update ): bool { $result = $wpdb->insert( $wpdb->sync_updates, array( - 'room_hash' => md5( $room ), + 'room' => $room, 'update_value' => wp_json_encode( $update ), 'created_at' => current_time( 'mysql', true ), ), @@ -71,7 +71,7 @@ public function add_update( string $room, $update ): bool { * @return array Awareness state. */ public function get_awareness_state( string $room ): array { - $awareness = get_transient( 'sync_awareness_' . md5( $room ) ); + $awareness = get_transient( 'sync_awareness_' . $room ); if ( ! is_array( $awareness ) ) { return array(); @@ -125,13 +125,11 @@ public function get_update_count( string $room ): int { public function get_updates_after_cursor( string $room, int $cursor ): array { global $wpdb; - $room_hash = md5( $room ); - // Snapshot the current max ID for this room to define a stable upper bound. $max_id = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->sync_updates} WHERE room_hash = %s", - $room_hash + "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->sync_updates} WHERE room = %s", + $room ) ); @@ -146,8 +144,8 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Bounded by max_id to stay consistent with the snapshot window above. $this->room_update_counts[ $room ] = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->sync_updates} WHERE room_hash = %s AND id <= %d", - $room_hash, + "SELECT COUNT(*) FROM {$wpdb->sync_updates} WHERE room = %s AND id <= %d", + $room, $max_id ) ); @@ -155,8 +153,8 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Fetch updates after the cursor up to the snapshot boundary. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT update_value FROM {$wpdb->sync_updates} WHERE room_hash = %s AND id > %d AND id <= %d ORDER BY id ASC", - $room_hash, + "SELECT update_value FROM {$wpdb->sync_updates} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", + $room, $cursor, $max_id ) @@ -193,8 +191,8 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool $result = $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->sync_updates} WHERE room_hash = %s AND id < %d", - md5( $room ), + "DELETE FROM {$wpdb->sync_updates} WHERE room = %s AND id < %d", + $room, $cursor ) ); @@ -216,6 +214,6 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool public function set_awareness_state( string $room, array $awareness ): bool { // Awareness is high-frequency, short-lived data (cursor positions, selections) // that doesn't need cursor-based history. Transients avoid row churn in the table. - return set_transient( 'sync_awareness_' . md5( $room ), $awareness, MINUTE_IN_SECONDS ); + return set_transient( 'sync_awareness_' . $room, $awareness, MINUTE_IN_SECONDS ); } } diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 70de8f16f28ce..dfc42ea769f96 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -430,7 +430,7 @@ function create_initial_rest_routes() { $icons_controller->register_routes(); // Collaboration. - if ( get_option( 'wp_enable_real_time_collaboration' ) && get_option( 'db_version' ) >= 61697 ) { + if ( get_option( 'wp_enable_real_time_collaboration' ) && get_option( 'db_version' ) >= 61698 ) { $sync_storage = new WP_Sync_Table_Storage(); /** diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index f7dc90413edde..0159650bc8ed8 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61697; +$wp_db_version = 61698; /** * Holds the TinyMCE version. From 9535e36aeba2aae8836bcd9333341f4e7a544c74 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 15:09:57 -0500 Subject: [PATCH 06/82] Increase sync_updates cron expiration to 7 days and drop filter Hardcode WEEK_IN_SECONDS for cron cleanup, matching the auto-draft cleanup precedent in core. Remove the wp_sync_updates_expiration filter to keep the API surface minimal for v1. --- src/wp-includes/collaboration.php | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 4c1a4befabda1..89be0fb73ff53 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -24,7 +24,7 @@ function wp_collaboration_inject_setting() { } /** - * Deletes sync updates older than 1 day from the wp_sync_updates table. + * Deletes sync updates older than 7 days from the wp_sync_updates table. * * Rows left behind by abandoned collaborative editing sessions are cleaned up * to prevent unbounded table growth. @@ -34,22 +34,10 @@ function wp_collaboration_inject_setting() { function wp_delete_old_sync_updates() { global $wpdb; - /** - * Filters the lifetime, in seconds, of a sync update row. - * - * By default, the lifetime is 1 day. Once a row reaches that age, it will - * automatically be deleted by a cron job. - * - * @since 7.0.0 - * - * @param int $expiration The expiration age of a sync update row, in seconds. - */ - $expiration = apply_filters( 'wp_sync_updates_expiration', DAY_IN_SECONDS ); - $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->sync_updates} WHERE created_at < %s", - gmdate( 'Y-m-d H:i:s', time() - $expiration ) + gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) ) ); } From 547bc5a9dba435b44068046c5550ce405a892a81 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 15:38:19 -0500 Subject: [PATCH 07/82] Escape room name in sync permission error message The room string is already constrained by REST schema regex validation, but esc_html() future-proofs against the regex loosening or the message being reused in an HTML context. --- .../collaboration/class-wp-http-polling-sync-server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php index 533b23fdb4e34..aded5fb5ccb87 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php @@ -198,7 +198,7 @@ public function check_permissions( WP_REST_Request $request ) { sprintf( /* translators: %s: The room name encodes the current entity being synced. */ __( 'You do not have permission to sync this entity: %s.' ), - $room + esc_html( $room ) ), array( 'status' => rest_authorization_required_code() ) ); From df7b21eb65016768773b2c6ad2c9af939cf70526 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 15:52:58 -0500 Subject: [PATCH 08/82] Add tests for cron cleanup, hook registration, and db_version guard Cover gaps in sync_updates test coverage: - Cron deletes rows older than 7 days - Cron preserves rows within the 7-day window - Cron boundary behavior at exactly 7 days - Cron selective deletion with mixed old and recent rows - Cleanup hook registered in default-filters.php - Sync routes not registered when db_version is below 61698 --- .../tests/rest-api/rest-sync-server.php | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index 05e535fba9cff..6819f5cef523a 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -1026,4 +1026,132 @@ static function ( $storage ) use ( &$filter_called ) { $this->assertTrue( $filter_called, 'The wp_sync_storage filter should be applied during route registration.' ); } + + /* + * Cron cleanup tests. + */ + + /** + * Inserts a row directly into the sync_updates table with a given age. + * + * @param int $age_in_seconds How old the row should be. + * @param string $label A label stored in the update_value for identification. + */ + private function insert_sync_row( $age_in_seconds, $label = 'test' ) { + global $wpdb; + + $wpdb->insert( + $wpdb->sync_updates, + array( + 'room' => $this->get_post_room(), + 'update_value' => wp_json_encode( array( 'type' => 'update', 'data' => $label ) ), + 'created_at' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), + ), + array( '%s', '%s', '%s' ) + ); + } + + /** + * Returns the number of rows in the sync_updates table. + * + * @return int Row count. + */ + private function get_sync_row_count() { + global $wpdb; + + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->sync_updates}" ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_deletes_old_rows() { + $this->insert_sync_row( 8 * DAY_IN_SECONDS ); + + $this->assertSame( 1, $this->get_sync_row_count() ); + + wp_delete_old_sync_updates(); + + $this->assertSame( 0, $this->get_sync_row_count() ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_preserves_recent_rows() { + $this->insert_sync_row( DAY_IN_SECONDS ); + + wp_delete_old_sync_updates(); + + $this->assertSame( 1, $this->get_sync_row_count() ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_boundary_at_exactly_seven_days() { + $this->insert_sync_row( WEEK_IN_SECONDS + 1, 'expired' ); + $this->insert_sync_row( WEEK_IN_SECONDS - 1, 'just-inside' ); + + wp_delete_old_sync_updates(); + + global $wpdb; + $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->sync_updates}" ); + + $this->assertCount( 1, $remaining, 'Only the row within the 7-day window should remain.' ); + $this->assertStringContainsString( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_selectively_deletes_mixed_rows() { + // 3 expired rows. + $this->insert_sync_row( 10 * DAY_IN_SECONDS ); + $this->insert_sync_row( 10 * DAY_IN_SECONDS ); + $this->insert_sync_row( 10 * DAY_IN_SECONDS ); + + // 2 recent rows. + $this->insert_sync_row( HOUR_IN_SECONDS ); + $this->insert_sync_row( HOUR_IN_SECONDS ); + + $this->assertSame( 5, $this->get_sync_row_count() ); + + wp_delete_old_sync_updates(); + + $this->assertSame( 2, $this->get_sync_row_count(), 'Only the 2 recent rows should survive cleanup.' ); + } + + /** + * @ticket 64696 + */ + public function test_cron_cleanup_hook_is_registered() { + $this->assertSame( + 10, + has_action( 'wp_delete_old_sync_updates', 'wp_delete_old_sync_updates' ), + 'The wp_delete_old_sync_updates action should be hooked in default-filters.php.' + ); + } + + /* + * Route registration guard tests. + */ + + /** + * @ticket 64696 + */ + public function test_sync_routes_not_registered_when_db_version_is_old() { + update_option( 'db_version', 61697 ); + + // Reset the global REST server so rest_get_server() builds a fresh instance. + $GLOBALS['wp_rest_server'] = null; + + $server = rest_get_server(); + $routes = $server->get_routes(); + + $this->assertArrayNotHasKey( '/wp-sync/v1/updates', $routes, 'Sync routes should not be registered when db_version is below 61698.' ); + + // Reset again so subsequent tests get a server with the correct db_version. + $GLOBALS['wp_rest_server'] = null; + } } From 8ed3dd27292e22ef4f8e581ab1492d0da8825434 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 16:06:34 -0500 Subject: [PATCH 09/82] Extract awareness transient key into helper with md5 safeguard --- .../class-wp-sync-table-storage.php | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-sync-table-storage.php index 2c02f6c431122..d523fa1d03cf2 100644 --- a/src/wp-includes/collaboration/class-wp-sync-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-table-storage.php @@ -71,7 +71,7 @@ public function add_update( string $room, $update ): bool { * @return array Awareness state. */ public function get_awareness_state( string $room ): array { - $awareness = get_transient( 'sync_awareness_' . $room ); + $awareness = get_transient( $this->get_awareness_transient_key( $room ) ); if ( ! is_array( $awareness ) ) { return array(); @@ -211,9 +211,25 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool * @param array $awareness Serializable awareness state. * @return bool True on success, false on failure. */ + /** + * Returns the transient key used to store awareness state for a room. + * + * The room name is hashed with md5 to guarantee the key stays within + * the 172-character limit imposed by the wp_options option_name column + * (varchar 191 minus the 19-character `_transient_timeout_` prefix). + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return string Transient key. + */ + private function get_awareness_transient_key( string $room ): string { + return 'sync_awareness_' . md5( $room ); + } + public function set_awareness_state( string $room, array $awareness ): bool { // Awareness is high-frequency, short-lived data (cursor positions, selections) // that doesn't need cursor-based history. Transients avoid row churn in the table. - return set_transient( 'sync_awareness_' . $room, $awareness, MINUTE_IN_SECONDS ); + return set_transient( $this->get_awareness_transient_key( $room ), $awareness, MINUTE_IN_SECONDS ); } } From 42ddcf8d0da6730530e0e734fad6b35be3846940 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 16:13:09 -0500 Subject: [PATCH 10/82] Update tests/phpunit/tests/rest-api/rest-sync-server.php Co-authored-by: Weston Ruter --- tests/phpunit/tests/rest-api/rest-sync-server.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index 6819f5cef523a..8a7369646e3c2 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -1044,7 +1044,12 @@ private function insert_sync_row( $age_in_seconds, $label = 'test' ) { $wpdb->sync_updates, array( 'room' => $this->get_post_room(), - 'update_value' => wp_json_encode( array( 'type' => 'update', 'data' => $label ) ), + 'update_value' => wp_json_encode( + array( + 'type' => 'update', + 'data' => $label, + ) + ), 'created_at' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), ), array( '%s', '%s', '%s' ) From fea57399c79ab4a36e19e254121609cda0f54524 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 16:42:07 -0500 Subject: [PATCH 11/82] Apply suggestions from code review Co-authored-by: Weston Ruter --- .../tests/rest-api/rest-sync-server.php | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index 8a7369646e3c2..cecc26e016bae 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -767,7 +767,7 @@ public function test_sync_rooms_are_isolated() { /** * @ticket 64696 */ - public function test_sync_empty_room_cursor_is_zero() { + public function test_sync_empty_room_cursor_is_zero(): void { wp_set_current_user( self::$editor_id ); $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); @@ -779,7 +779,7 @@ public function test_sync_empty_room_cursor_is_zero() { /** * @ticket 64696 */ - public function test_sync_cursor_advances_monotonically() { + public function test_sync_cursor_advances_monotonically(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -810,7 +810,7 @@ public function test_sync_cursor_advances_monotonically() { /** * @ticket 64696 */ - public function test_sync_cursor_prevents_re_delivery() { + public function test_sync_cursor_prevents_re_delivery(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -855,7 +855,7 @@ public function test_sync_cursor_prevents_re_delivery() { /** * @ticket 64696 */ - public function test_sync_operations_do_not_affect_posts_last_changed() { + public function test_sync_operations_do_not_affect_posts_last_changed(): void { wp_set_current_user( self::$editor_id ); // Prime the posts last changed cache. @@ -892,7 +892,7 @@ public function test_sync_operations_do_not_affect_posts_last_changed() { /** * @ticket 64696 */ - public function test_sync_compaction_does_not_lose_concurrent_updates() { + public function test_sync_compaction_does_not_lose_concurrent_updates(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -958,7 +958,7 @@ public function test_sync_compaction_does_not_lose_concurrent_updates() { /** * @ticket 64696 */ - public function test_sync_compaction_reduces_total_updates() { + public function test_sync_compaction_reduces_total_updates(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -1009,13 +1009,13 @@ public function test_sync_compaction_reduces_total_updates() { /** * @ticket 64696 */ - public function test_sync_storage_filter_is_applied() { - $filter_called = false; + public function test_sync_storage_filter_is_applied(): void { + $filtered_storage = null; add_filter( 'wp_sync_storage', - static function ( $storage ) use ( &$filter_called ) { - $filter_called = true; + static function ( WP_Sync_Storage $storage ) use ( &$filtered_storage ): WP_Sync_Storage { + $filtered_storage = $storage; return $storage; } ); @@ -1024,7 +1024,7 @@ static function ( $storage ) use ( &$filter_called ) { $server = rest_get_server(); do_action( 'rest_api_init', $server ); - $this->assertTrue( $filter_called, 'The wp_sync_storage filter should be applied during route registration.' ); + $this->assertInstanceOf( WP_Sync_Storage::class, $filtered_storage, 'The wp_sync_storage filter should be applied during route registration.' ); } /* @@ -1034,10 +1034,10 @@ static function ( $storage ) use ( &$filter_called ) { /** * Inserts a row directly into the sync_updates table with a given age. * - * @param int $age_in_seconds How old the row should be. - * @param string $label A label stored in the update_value for identification. + * @param positive-int $age_in_seconds How old the row should be. + * @param string $label A label stored in the update_value for identification. */ - private function insert_sync_row( $age_in_seconds, $label = 'test' ) { + private function insert_sync_row( int $age_in_seconds, string $label = 'test' ): void { global $wpdb; $wpdb->insert( @@ -1059,9 +1059,9 @@ private function insert_sync_row( $age_in_seconds, $label = 'test' ) { /** * Returns the number of rows in the sync_updates table. * - * @return int Row count. + * @return positive-int Row count. */ - private function get_sync_row_count() { + private function get_sync_row_count(): int { global $wpdb; return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->sync_updates}" ); @@ -1070,7 +1070,7 @@ private function get_sync_row_count() { /** * @ticket 64696 */ - public function test_cron_cleanup_deletes_old_rows() { + public function test_cron_cleanup_deletes_old_rows(): void { $this->insert_sync_row( 8 * DAY_IN_SECONDS ); $this->assertSame( 1, $this->get_sync_row_count() ); @@ -1083,7 +1083,7 @@ public function test_cron_cleanup_deletes_old_rows() { /** * @ticket 64696 */ - public function test_cron_cleanup_preserves_recent_rows() { + public function test_cron_cleanup_preserves_recent_rows(): void { $this->insert_sync_row( DAY_IN_SECONDS ); wp_delete_old_sync_updates(); @@ -1094,7 +1094,7 @@ public function test_cron_cleanup_preserves_recent_rows() { /** * @ticket 64696 */ - public function test_cron_cleanup_boundary_at_exactly_seven_days() { + public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { $this->insert_sync_row( WEEK_IN_SECONDS + 1, 'expired' ); $this->insert_sync_row( WEEK_IN_SECONDS - 1, 'just-inside' ); @@ -1110,7 +1110,7 @@ public function test_cron_cleanup_boundary_at_exactly_seven_days() { /** * @ticket 64696 */ - public function test_cron_cleanup_selectively_deletes_mixed_rows() { + public function test_cron_cleanup_selectively_deletes_mixed_rows(): void { // 3 expired rows. $this->insert_sync_row( 10 * DAY_IN_SECONDS ); $this->insert_sync_row( 10 * DAY_IN_SECONDS ); @@ -1130,7 +1130,7 @@ public function test_cron_cleanup_selectively_deletes_mixed_rows() { /** * @ticket 64696 */ - public function test_cron_cleanup_hook_is_registered() { + public function test_cron_cleanup_hook_is_registered(): void { $this->assertSame( 10, has_action( 'wp_delete_old_sync_updates', 'wp_delete_old_sync_updates' ), @@ -1145,7 +1145,7 @@ public function test_cron_cleanup_hook_is_registered() { /** * @ticket 64696 */ - public function test_sync_routes_not_registered_when_db_version_is_old() { + public function test_sync_routes_not_registered_when_db_version_is_old(): void { update_option( 'db_version', 61697 ); // Reset the global REST server so rest_get_server() builds a fresh instance. From f7d5118473b630b92f096a4945cbe279700519d2 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 27 Feb 2026 16:50:19 -0500 Subject: [PATCH 12/82] Add maxLength validation for sync room name to match DB column size --- .../collaboration/class-wp-http-polling-sync-server.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php index aded5fb5ccb87..6d96aa3ffab61 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php @@ -130,9 +130,10 @@ public function register_routes(): void { 'type' => 'integer', ), 'room' => array( - 'required' => true, - 'type' => 'string', - 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'required' => true, + 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'maxLength' => 255, // The size of the wp_sync_updates.room column. ), 'updates' => array( 'items' => $typed_update_args, From 4ba33c4f08ab638888c24c48e823701674684d27 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 2 Mar 2026 19:40:53 -0500 Subject: [PATCH 13/82] Add wp_is_collaboration_enabled() helper Extract the db_version >= 61698 and option check into a named function, following the precedent set by wp_check_term_meta_support_prefilter() in taxonomy.php. --- src/wp-includes/collaboration.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 89be0fb73ff53..c059bf09ac7e4 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -6,6 +6,21 @@ * @since 7.0.0 */ +/** + * Checks whether real-time collaboration is enabled. + * + * The feature requires both the site option and the database schema + * introduced in db_version 61698. + * + * @since 7.0.0 + * + * @return bool True if collaboration is enabled, false otherwise. + */ +function wp_is_collaboration_enabled() { + return get_option( 'wp_enable_real_time_collaboration' ) + && get_option( 'db_version' ) >= 61698; +} + /** * Injects the real-time collaboration setting into a global variable. * From ad6d6ac2c37a3f8011b3364e04d4382c5ece9f3f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 2 Mar 2026 21:36:22 -0500 Subject: [PATCH 14/82] Replace inline collaboration checks with wp_is_collaboration_enabled() Use the new helper at all remaining call sites: the script injection in collaboration.php, the cron cleanup guard, the REST route registration in rest-api.php, and the cron scheduling in admin.php. The cron handler now also checks the option, which is correct: if collaboration is disabled, cleanup should be skipped too. --- src/wp-admin/admin.php | 5 ++++- src/wp-includes/collaboration.php | 6 +++++- src/wp-includes/rest-api.php | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/admin.php b/src/wp-admin/admin.php index c92e8874cd83c..aa58ee42911b7 100644 --- a/src/wp-admin/admin.php +++ b/src/wp-admin/admin.php @@ -114,7 +114,10 @@ } // Schedule sync updates cleanup. -if ( ! wp_next_scheduled( 'wp_delete_old_sync_updates' ) && ! wp_installing() ) { +if ( wp_is_collaboration_enabled() + && ! wp_next_scheduled( 'wp_delete_old_sync_updates' ) + && ! wp_installing() +) { wp_schedule_event( time(), 'daily', 'wp_delete_old_sync_updates' ); } diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index c059bf09ac7e4..6d2415ccde925 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -29,7 +29,7 @@ function wp_is_collaboration_enabled() { * @access private */ function wp_collaboration_inject_setting() { - if ( get_option( 'wp_enable_real_time_collaboration' ) ) { + if ( wp_is_collaboration_enabled() ) { wp_add_inline_script( 'wp-core-data', 'window._wpCollaborationEnabled = true;', @@ -47,6 +47,10 @@ function wp_collaboration_inject_setting() { * @since 7.0.0 */ function wp_delete_old_sync_updates() { + if ( ! wp_is_collaboration_enabled() ) { + return; + } + global $wpdb; $wpdb->query( diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index dfc42ea769f96..9c3a939970638 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -430,7 +430,7 @@ function create_initial_rest_routes() { $icons_controller->register_routes(); // Collaboration. - if ( get_option( 'wp_enable_real_time_collaboration' ) && get_option( 'db_version' ) >= 61698 ) { + if ( wp_is_collaboration_enabled() ) { $sync_storage = new WP_Sync_Table_Storage(); /** From c67550b469b8c924d0aba19a4c0699348012e94f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 2 Mar 2026 21:37:44 -0500 Subject: [PATCH 15/82] Add E2E tests for collaborative editing Add Playwright tests covering presence awareness, real-time sync, and undo/redo for the collaboration feature, along with shared test fixtures and utilities. --- .../collaboration-presence.test.js | 108 +++++ .../collaboration/collaboration-sync.test.js | 390 ++++++++++++++++++ .../collaboration-undo-redo.test.js | 216 ++++++++++ .../fixtures/collaboration-utils.js | 324 +++++++++++++++ .../e2e/specs/collaboration/fixtures/index.js | 29 ++ 5 files changed, 1067 insertions(+) create mode 100644 tests/e2e/specs/collaboration/collaboration-presence.test.js create mode 100644 tests/e2e/specs/collaboration/collaboration-sync.test.js create mode 100644 tests/e2e/specs/collaboration/collaboration-undo-redo.test.js create mode 100644 tests/e2e/specs/collaboration/fixtures/collaboration-utils.js create mode 100644 tests/e2e/specs/collaboration/fixtures/index.js diff --git a/tests/e2e/specs/collaboration/collaboration-presence.test.js b/tests/e2e/specs/collaboration/collaboration-presence.test.js new file mode 100644 index 0000000000000..81dafc16dee19 --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-presence.test.js @@ -0,0 +1,108 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'Collaboration - Presence', () => { + test( 'All 3 collaborator avatars are visible', async ( { + collaborationUtils, + requestUtils, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Presence Test - 3 Users', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { page2, page3 } = collaborationUtils; + + // Each user sees the collaborators list button (indicates others are present). + await expect( + page.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: 10000 } ); + + await expect( + page2.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: 10000 } ); + + await expect( + page3.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: 10000 } ); + } ); + + test( 'Collaborator names appear in popover', async ( { + collaborationUtils, + requestUtils, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Presence Test - Names', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + // User A opens the collaborators popover. + const presenceButton = page.getByRole( 'button', { + name: /Collaborators list/, + } ); + await expect( presenceButton ).toBeVisible( { timeout: 10000 } ); + await presenceButton.click(); + + // The popover should list both collaborators by name. + await expect( + page.getByText( 'Test Collaborator' ) + ).toBeVisible(); + + await expect( + page.getByText( 'Another Collaborator' ) + ).toBeVisible(); + } ); + + test( 'User C leaves, A and B see updated presence', async ( { + collaborationUtils, + requestUtils, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Presence Test - Leave', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { page2 } = collaborationUtils; + + // Verify all 3 users see the collaborators button initially. + await expect( + page.getByRole( 'button', { name: /Collaborators list/ } ) + ).toBeVisible( { timeout: 10000 } ); + + // Close User C's context to simulate leaving. + await collaborationUtils.page3.close(); + + // After the awareness timeout (30s), User A and B should see + // the collaborators list update. The button may still be visible + // but should reflect only 1 remaining collaborator. + // We verify by opening the popover and checking that User C's + // name is no longer listed. + await expect( async () => { + const presenceButton = page.getByRole( 'button', { + name: /Collaborators list/, + } ); + await presenceButton.click(); + + // "Another Collaborator" (User C) should no longer appear. + await expect( + page.getByText( 'Another Collaborator' ) + ).not.toBeVisible(); + + // "Test Collaborator" (User B) should still be listed. + await expect( + page.getByText( 'Test Collaborator' ) + ).toBeVisible(); + } ).toPass( { timeout: 45000 } ); + } ); +} ); diff --git a/tests/e2e/specs/collaboration/collaboration-sync.test.js b/tests/e2e/specs/collaboration/collaboration-sync.test.js new file mode 100644 index 0000000000000..044bd7eebb407 --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-sync.test.js @@ -0,0 +1,390 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'Collaboration - Sync', () => { + test( 'User A adds a paragraph block, Users B and C both see it', async ( { + collaborationUtils, + requestUtils, + editor, + } ) => { + const post = await requestUtils.createPost( { + title: 'Sync Test - Fan Out', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, editor3 } = collaborationUtils; + + // User A inserts a paragraph block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Hello from User A' }, + } ); + + // User B should see the paragraph after sync propagation. + await expect + .poll( () => editor2.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User A' }, + }, + ] ); + + // User C should also see the paragraph. + await expect + .poll( () => editor3.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User A' }, + }, + ] ); + } ); + + test( 'User C adds a paragraph block, Users A and B see it', async ( { + collaborationUtils, + requestUtils, + editor, + } ) => { + const post = await requestUtils.createPost( { + title: 'Sync Test - C to A and B', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, page3 } = collaborationUtils; + + // User C inserts a paragraph block via the data API. + await page3.evaluate( () => { + const block = window.wp.blocks.createBlock( 'core/paragraph', { + content: 'Hello from User C', + } ); + window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); + } ); + + // User A should see the paragraph. + await expect + .poll( () => editor.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User C' }, + }, + ] ); + + // User B should also see the paragraph. + await expect + .poll( () => editor2.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Hello from User C' }, + }, + ] ); + } ); + + test( 'All 3 users add blocks simultaneously, all changes appear everywhere', async ( { + collaborationUtils, + requestUtils, + editor, + } ) => { + const post = await requestUtils.createPost( { + title: 'Sync Test - 3-Way Merge', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, editor3, page2, page3 } = collaborationUtils; + + // All 3 users insert blocks concurrently. + await Promise.all( [ + editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'From User A' }, + } ), + page2.evaluate( () => { + const block = window.wp.blocks.createBlock( 'core/paragraph', { + content: 'From User B', + } ); + window.wp.data + .dispatch( 'core/block-editor' ) + .insertBlock( block ); + } ), + page3.evaluate( () => { + const block = window.wp.blocks.createBlock( 'core/paragraph', { + content: 'From User C', + } ); + window.wp.data + .dispatch( 'core/block-editor' ) + .insertBlock( block ); + } ), + ] ); + + // All 3 users should eventually see all 3 blocks. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect( async () => { + const blocks = await ed.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + expect( contents ).toContain( 'From User A' ); + expect( contents ).toContain( 'From User B' ); + expect( contents ).toContain( 'From User C' ); + } ).toPass( { timeout: 10000 } ); + } + } ); + + test( 'Title change from User A propagates to B and C', async ( { + collaborationUtils, + requestUtils, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Sync Test - Title', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { page2, page3 } = collaborationUtils; + + // User A changes the title. + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/editor' ) + .editPost( { title: 'New Title from User A' } ); + } ); + + // User B should see the updated title. + await expect + .poll( + () => + page2.evaluate( () => + window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'title' ) + ), + { timeout: 10000 } + ) + .toBe( 'New Title from User A' ); + + // User C should also see the updated title. + await expect + .poll( + () => + page3.evaluate( () => + window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'title' ) + ), + { timeout: 10000 } + ) + .toBe( 'New Title from User A' ); + } ); + + test( 'User C joins late and sees existing content from A and B', async ( { + collaborationUtils, + requestUtils, + editor, + } ) => { + const post = await requestUtils.createPost( { + title: 'Sync Test - Late Join', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { page2, page3, editor3 } = collaborationUtils; + + // Navigate User C away from the editor to simulate not being + // present while A and B make edits. + await page3.goto( '/wp-admin/' ); + + // User A and B each add a block while User C is away. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Block from A (early)' }, + } ); + + await page2.evaluate( () => { + const block = window.wp.blocks.createBlock( 'core/paragraph', { + content: 'Block from B (early)', + } ); + window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); + } ); + + // Wait for A and B to sync with each other. + await expect( async () => { + const blocksA = await editor.getBlocks(); + const contentsA = blocksA.map( ( b ) => b.attributes.content ); + expect( contentsA ).toContain( 'Block from A (early)' ); + expect( contentsA ).toContain( 'Block from B (early)' ); + } ).toPass( { timeout: 10000 } ); + + // Now User C joins late by navigating back to the editor. + await collaborationUtils.navigateToEditor( page3, post.id ); + await collaborationUtils.waitForCollaborationReady( page3 ); + + // User C should see all existing blocks from A and B after sync. + await expect( async () => { + const blocks = await editor3.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + expect( contents ).toContain( 'Block from A (early)' ); + expect( contents ).toContain( 'Block from B (early)' ); + } ).toPass( { timeout: 10000 } ); + } ); + + test( 'Block deletion syncs to all users', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Sync Test - Block Deletion', + status: 'draft', + content: + '

Block to delete

', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, editor3 } = collaborationUtils; + + // Wait for all users to see the seeded block. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Block to delete' }, + }, + ] ); + } + + // User A removes the block. + await page.evaluate( () => { + const blocks = window.wp.data + .select( 'core/block-editor' ) + .getBlocks(); + window.wp.data + .dispatch( 'core/block-editor' ) + .removeBlock( blocks[ 0 ].clientId ); + } ); + + // Users B and C should see 0 blocks after sync. + await expect + .poll( () => editor2.getBlocks(), { timeout: 10000 } ) + .toHaveLength( 0 ); + + await expect + .poll( () => editor3.getBlocks(), { timeout: 10000 } ) + .toHaveLength( 0 ); + } ); + + test( 'Editing existing block content syncs to all users', async ( { + collaborationUtils, + requestUtils, + editor, + } ) => { + const post = await requestUtils.createPost( { + title: 'Sync Test - Edit Content', + status: 'draft', + content: + '

Original text

', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, editor3, page2 } = collaborationUtils; + + // Wait for all users to see the seeded block. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Original text' }, + }, + ] ); + } + + // User B updates the block content. + await page2.evaluate( () => { + const blocks = window.wp.data + .select( 'core/block-editor' ) + .getBlocks(); + window.wp.data + .dispatch( 'core/block-editor' ) + .updateBlockAttributes( blocks[ 0 ].clientId, { + content: 'Edited by User B', + } ); + } ); + + // Users A and C should see the updated content. + await expect + .poll( () => editor.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Edited by User B' }, + }, + ] ); + + await expect + .poll( () => editor3.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Edited by User B' }, + }, + ] ); + } ); + + test( 'Non-paragraph block type syncs to all users', async ( { + collaborationUtils, + requestUtils, + editor, + } ) => { + const post = await requestUtils.createPost( { + title: 'Sync Test - Heading Block', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, editor3 } = collaborationUtils; + + // User A inserts a heading block. + await editor.insertBlock( { + name: 'core/heading', + attributes: { content: 'Synced Heading', level: 3 }, + } ); + + // User B should see the heading with correct attributes. + await expect + .poll( () => editor2.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/heading', + attributes: { content: 'Synced Heading', level: 3 }, + }, + ] ); + + // User C should also see the heading with correct attributes. + await expect + .poll( () => editor3.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/heading', + attributes: { content: 'Synced Heading', level: 3 }, + }, + ] ); + } ); +} ); diff --git a/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js new file mode 100644 index 0000000000000..7744f096c1ddb --- /dev/null +++ b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js @@ -0,0 +1,216 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'Collaboration - Undo/Redo', () => { + test( 'User A undo only affects their own changes, B and C blocks remain', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Undo Test - 3 Users', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, editor3, page2, page3 } = collaborationUtils; + + // User B adds a block. + await page2.evaluate( () => { + const block = window.wp.blocks.createBlock( 'core/paragraph', { + content: 'From User B', + } ); + window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); + } ); + + // User C adds a block. + await page3.evaluate( () => { + const block = window.wp.blocks.createBlock( 'core/paragraph', { + content: 'From User C', + } ); + window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); + } ); + + // Wait for both blocks to appear on User A. + await expect( async () => { + const blocks = await editor.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + expect( contents ).toContain( 'From User B' ); + expect( contents ).toContain( 'From User C' ); + } ).toPass( { timeout: 10000 } ); + + // User A adds their own block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'From User A' }, + } ); + + // Wait for all 3 blocks to appear on all editors. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect( async () => { + const blocks = await ed.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + expect( contents ).toContain( 'From User A' ); + expect( contents ).toContain( 'From User B' ); + expect( contents ).toContain( 'From User C' ); + } ).toPass( { timeout: 10000 } ); + } + + // User A performs undo via the data API. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).undo(); + } ); + + // User A should see only B and C's blocks (their own is undone). + await expect( async () => { + const blocks = await editor.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + expect( contents ).not.toContain( 'From User A' ); + expect( contents ).toContain( 'From User B' ); + expect( contents ).toContain( 'From User C' ); + } ).toPass( { timeout: 10000 } ); + + // User B should also see the undo result. + await expect( async () => { + const blocks = await editor2.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + expect( contents ).not.toContain( 'From User A' ); + expect( contents ).toContain( 'From User B' ); + expect( contents ).toContain( 'From User C' ); + } ).toPass( { timeout: 10000 } ); + + // User C should also see the undo result. + await expect( async () => { + const blocks = await editor3.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + expect( contents ).not.toContain( 'From User A' ); + expect( contents ).toContain( 'From User B' ); + expect( contents ).toContain( 'From User C' ); + } ).toPass( { timeout: 10000 } ); + } ); + + test( 'Redo restores the undone change across all users', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Redo Test - 3 Users', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, editor3 } = collaborationUtils; + + // User A adds a block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Undoable content' }, + } ); + + // Verify the block exists on all editors. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Undoable content' }, + }, + ] ); + } + + // Undo via data API. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).undo(); + } ); + + await expect + .poll( () => editor.getBlocks(), { timeout: 10000 } ) + .toHaveLength( 0 ); + + // Redo via data API. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).redo(); + } ); + + // All users should see the restored block. + for ( const ed of [ editor, editor2, editor3 ] ) { + await expect + .poll( () => ed.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Undoable content' }, + }, + ] ); + } + } ); + + test( 'Bystander sees correct state after undo', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Undo Test - Bystander', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor3, page2 } = collaborationUtils; + + // User B adds a block. + await page2.evaluate( () => { + const block = window.wp.blocks.createBlock( 'core/paragraph', { + content: 'From User B', + } ); + window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); + } ); + + // Wait for User B's block to appear on User A. + await expect + .poll( () => editor.getBlocks(), { timeout: 10000 } ) + .toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'From User B' }, + }, + ] ); + + // User A adds a block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'From User A' }, + } ); + + // Wait for both blocks to appear on the bystander (User C). + await expect( async () => { + const blocks = await editor3.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + expect( contents ).toContain( 'From User A' ); + expect( contents ).toContain( 'From User B' ); + } ).toPass( { timeout: 10000 } ); + + // User A undoes their own block. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/editor' ).undo(); + } ); + + // Bystander (User C) should see only User B's block. + await expect( async () => { + const blocks = await editor3.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + expect( contents ).not.toContain( 'From User A' ); + expect( contents ).toContain( 'From User B' ); + } ).toPass( { timeout: 10000 } ); + } ); +} ); diff --git a/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js new file mode 100644 index 0000000000000..946741cdee41d --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js @@ -0,0 +1,324 @@ +/** + * External dependencies + */ +import { expect } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import { Editor } from '@wordpress/e2e-test-utils-playwright'; + +export const SECOND_USER = { + username: 'collaborator', + email: 'collaborator@example.com', + firstName: 'Test', + lastName: 'Collaborator', + password: 'password', + roles: [ 'editor' ], +}; + +export const THIRD_USER = { + username: 'collaborator2', + email: 'collaborator2@example.com', + firstName: 'Another', + lastName: 'Collaborator', + password: 'password', + roles: [ 'editor' ], +}; + +const BASE_URL = process.env.WP_BASE_URL || 'http://localhost:8889'; + +export default class CollaborationUtils { + constructor( { admin, editor, requestUtils, page } ) { + this.admin = admin; + this.editor = editor; + this.requestUtils = requestUtils; + this.primaryPage = page; + + this._secondContext = null; + this._secondPage = null; + this._secondEditor = null; + + this._thirdContext = null; + this._thirdPage = null; + this._thirdEditor = null; + } + + /** + * Set the real-time collaboration WordPress setting. + * + * Uses the form-based approach because this setting is registered + * on admin_init in the "writing" group and is not exposed via + * /wp/v2/settings. + * + * @param {boolean} enabled Whether to enable or disable collaboration. + */ + async setCollaboration( enabled ) { + const response = await this.requestUtils.request.get( + '/wp-admin/options-writing.php' + ); + const html = await response.text(); + const nonce = html.match( /name="_wpnonce" value="([^"]+)"/ )[ 1 ]; + + const formData = { + option_page: 'writing', + action: 'update', + _wpnonce: nonce, + _wp_http_referer: '/wp-admin/options-writing.php', + submit: 'Save Changes', + default_category: 1, + default_post_format: 0, + }; + + if ( enabled ) { + formData.wp_enable_real_time_collaboration = 1; + } + + await this.requestUtils.request.post( '/wp-admin/options.php', { + form: formData, + failOnStatusCode: true, + } ); + } + + /** + * Log a user into WordPress via the login form on a given page. + * + * @param {import('@playwright/test').Page} page The page to log in on. + * @param {Object} userInfo User credentials. + */ + async loginUser( page, userInfo ) { + await page.goto( '/wp-login.php' ); + + // Retry filling if the page resets during a cold Docker start. + await expect( async () => { + await page.locator( '#user_login' ).fill( userInfo.username ); + await page.locator( '#user_pass' ).fill( userInfo.password ); + await expect( page.locator( '#user_pass' ) ).toHaveValue( + userInfo.password + ); + } ).toPass( { timeout: 15_000 } ); + + await page.getByRole( 'button', { name: 'Log In' } ).click(); + await page.waitForURL( '**/wp-admin/**' ); + } + + /** + * Set up a new browser context for a collaborator user. + * + * @param {Object} userInfo User credentials and info. + * @return {Object} An object with context, page, and editor. + */ + async setupCollaboratorContext( userInfo ) { + const context = await this.admin.browser.newContext( { + baseURL: BASE_URL, + } ); + const page = await context.newPage(); + + await this.loginUser( page, userInfo ); + + return { context, page }; + } + + /** + * Navigate a page to the post editor and dismiss the welcome guide. + * + * @param {import('@playwright/test').Page} page The page to navigate. + * @param {number} postId The post ID to edit. + */ + async navigateToEditor( page, postId ) { + await page.goto( + `/wp-admin/post.php?post=${ postId }&action=edit` + ); + await page.waitForFunction( + () => window?.wp?.data && window?.wp?.blocks + ); + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'welcomeGuide', false ); + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'fullscreenMode', false ); + } ); + } + + /** + * Open a collaborative editing session where all 3 users are editing + * the same post. + * + * @param {number} postId The post ID to collaboratively edit. + */ + async openCollaborativeSession( postId ) { + // Set up the second and third browser contexts. + const second = await this.setupCollaboratorContext( SECOND_USER ); + this._secondContext = second.context; + this._secondPage = second.page; + + const third = await this.setupCollaboratorContext( THIRD_USER ); + this._thirdContext = third.context; + this._thirdPage = third.page; + + // Navigate User 1 (admin) to the post editor. + await this.admin.visitAdminPage( + 'post.php', + `post=${ postId }&action=edit` + ); + await this.editor.setPreferences( 'core/edit-post', { + welcomeGuide: false, + fullscreenMode: false, + } ); + + // Wait for collaboration to be enabled on User 1's page. + await this.waitForCollaborationReady( this.primaryPage ); + + // Navigate User 2 and User 3 to the same post editor. + await this.navigateToEditor( this._secondPage, postId ); + await this.navigateToEditor( this._thirdPage, postId ); + + // Create Editor instances for the additional pages. + this._secondEditor = new Editor( { page: this._secondPage } ); + this._thirdEditor = new Editor( { page: this._thirdPage } ); + + // Wait for collaboration to be enabled on all pages. + await Promise.all( [ + this.waitForCollaborationReady( this._secondPage ), + this.waitForCollaborationReady( this._thirdPage ), + ] ); + + // Wait for all users to discover each other via awareness. + await Promise.all( [ + this.primaryPage + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 15000 } ), + this._secondPage + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 15000 } ), + this._thirdPage + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 15000 } ), + ] ); + + // Allow a full round of polling after awareness is established + // so all CRDT docs are synchronized. + await this.waitForAllSynced(); + } + + /** + * Wait for the collaboration runtime to be ready on a page. + * + * @param {import('@playwright/test').Page} page The Playwright page to wait on. + */ + async waitForCollaborationReady( page ) { + await page.waitForFunction( + () => + window._wpCollaborationEnabled === true && + window?.wp?.data && + window?.wp?.blocks, + { timeout: 15000 } + ); + } + + /** + * Wait for sync polling cycles to complete on the given page. + * + * @param {import('@playwright/test').Page} page The page to wait on. + * @param {number} cycles Number of sync responses to wait for. + */ + async waitForSyncCycle( page, cycles = 3 ) { + for ( let i = 0; i < cycles; i++ ) { + await page.waitForResponse( + ( response ) => + response.url().includes( 'wp-sync' ) && + response.status() === 200, + { timeout: 10000 } + ); + } + } + + /** + * Wait for sync cycles on all 3 pages in parallel. + * + * @param {number} cycles Number of sync responses to wait for per page. + */ + async waitForAllSynced( cycles = 3 ) { + const pages = [ this.primaryPage ]; + if ( this._secondPage ) { + pages.push( this._secondPage ); + } + if ( this._thirdPage ) { + pages.push( this._thirdPage ); + } + await Promise.all( + pages.map( ( page ) => this.waitForSyncCycle( page, cycles ) ) + ); + } + + /** + * Get the second user's Page instance. + */ + get page2() { + if ( ! this._secondPage ) { + throw new Error( + 'Second page not available. Call openCollaborativeSession() first.' + ); + } + return this._secondPage; + } + + /** + * Get the second user's Editor instance. + */ + get editor2() { + if ( ! this._secondEditor ) { + throw new Error( + 'Second editor not available. Call openCollaborativeSession() first.' + ); + } + return this._secondEditor; + } + + /** + * Get the third user's Page instance. + */ + get page3() { + if ( ! this._thirdPage ) { + throw new Error( + 'Third page not available. Call openCollaborativeSession() first.' + ); + } + return this._thirdPage; + } + + /** + * Get the third user's Editor instance. + */ + get editor3() { + if ( ! this._thirdEditor ) { + throw new Error( + 'Third editor not available. Call openCollaborativeSession() first.' + ); + } + return this._thirdEditor; + } + + /** + * Clean up: close extra browser contexts, disable collaboration, + * delete test users. + */ + async teardown() { + if ( this._thirdContext ) { + await this._thirdContext.close(); + this._thirdContext = null; + this._thirdPage = null; + this._thirdEditor = null; + } + if ( this._secondContext ) { + await this._secondContext.close(); + this._secondContext = null; + this._secondPage = null; + this._secondEditor = null; + } + await this.setCollaboration( false ); + await this.requestUtils.deleteAllUsers(); + } +} diff --git a/tests/e2e/specs/collaboration/fixtures/index.js b/tests/e2e/specs/collaboration/fixtures/index.js new file mode 100644 index 0000000000000..ae47d22c97817 --- /dev/null +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { test as base } from '@wordpress/e2e-test-utils-playwright'; +export { expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import CollaborationUtils, { SECOND_USER, THIRD_USER } from './collaboration-utils'; + +export const test = base.extend( { + collaborationUtils: async ( + { admin, editor, requestUtils, page }, + use + ) => { + const utils = new CollaborationUtils( { + admin, + editor, + requestUtils, + page, + } ); + await utils.setCollaboration( true ); + await requestUtils.createUser( SECOND_USER ); + await requestUtils.createUser( THIRD_USER ); + await use( utils ); + await utils.teardown(); + }, +} ); From fc1a56c00925c5016e573487e5022b7dbccce63b Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 2 Mar 2026 21:51:13 -0500 Subject: [PATCH 16/82] Add production-ready docblocks to collaboration files Adds missing @since tags to file headers, fixes the orphaned set_awareness_state docblock in WP_Sync_Table_Storage, adds @var to the $storage property, adds interface-level @since, and adds JSDoc file headers to all E2E test and fixture files. --- .../class-wp-http-polling-sync-server.php | 2 ++ .../class-wp-sync-table-storage.php | 23 +++++++------- .../interface-wp-sync-storage.php | 6 ++++ .../collaboration-presence.test.js | 10 +++++++ .../collaboration/collaboration-sync.test.js | 11 +++++++ .../collaboration-undo-redo.test.js | 10 +++++++ .../fixtures/collaboration-utils.js | 30 +++++++++++++++++++ .../e2e/specs/collaboration/fixtures/index.js | 10 +++++++ 8 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php index 6d96aa3ffab61..d3ca6c1ff5440 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php @@ -3,6 +3,7 @@ * WP_HTTP_Polling_Sync_Server class * * @package WordPress + * @since 7.0.0 */ /** @@ -73,6 +74,7 @@ class WP_HTTP_Polling_Sync_Server { * Storage backend for sync updates. * * @since 7.0.0 + * @var WP_Sync_Storage */ private WP_Sync_Storage $storage; diff --git a/src/wp-includes/collaboration/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-sync-table-storage.php index d523fa1d03cf2..4b6dd074d5d3a 100644 --- a/src/wp-includes/collaboration/class-wp-sync-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-table-storage.php @@ -3,6 +3,7 @@ * WP_Sync_Table_Storage class * * @package WordPress + * @since 7.0.0 */ /** @@ -200,17 +201,6 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool return false !== $result; } - /** - * Sets awareness state for a given room. - * - * Awareness is ephemeral and stored as a transient with a short timeout. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param array $awareness Serializable awareness state. - * @return bool True on success, false on failure. - */ /** * Returns the transient key used to store awareness state for a room. * @@ -227,6 +217,17 @@ private function get_awareness_transient_key( string $room ): string { return 'sync_awareness_' . md5( $room ); } + /** + * Sets awareness state for a given room. + * + * Awareness is ephemeral and stored as a transient with a short timeout. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. + * @return bool True on success, false on failure. + */ public function set_awareness_state( string $room, array $awareness ): bool { // Awareness is high-frequency, short-lived data (cursor positions, selections) // that doesn't need cursor-based history. Transients avoid row churn in the table. diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php index d84dbeb1e4aae..4e1e6b5c404d6 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -3,8 +3,14 @@ * WP_Sync_Storage interface * * @package WordPress + * @since 7.0.0 */ +/** + * Interface for sync storage backends used by the collaborative editing server. + * + * @since 7.0.0 + */ interface WP_Sync_Storage { /** * Adds a sync update to a given room. diff --git a/tests/e2e/specs/collaboration/collaboration-presence.test.js b/tests/e2e/specs/collaboration/collaboration-presence.test.js index 81dafc16dee19..11789fdb860cd 100644 --- a/tests/e2e/specs/collaboration/collaboration-presence.test.js +++ b/tests/e2e/specs/collaboration/collaboration-presence.test.js @@ -1,3 +1,13 @@ +/** + * Tests for collaborative editing presence (awareness). + * + * Verifies that collaborator avatars, names, and leave events + * propagate correctly between three concurrent users. + * + * @package WordPress + * @since 7.0.0 + */ + /** * Internal dependencies */ diff --git a/tests/e2e/specs/collaboration/collaboration-sync.test.js b/tests/e2e/specs/collaboration/collaboration-sync.test.js index 044bd7eebb407..a81659ad6b345 100644 --- a/tests/e2e/specs/collaboration/collaboration-sync.test.js +++ b/tests/e2e/specs/collaboration/collaboration-sync.test.js @@ -1,3 +1,14 @@ +/** + * Tests for collaborative editing sync (CRDT document replication). + * + * Verifies that block insertions, deletions, edits, title changes, + * and late-join state transfer propagate correctly between three + * concurrent users. + * + * @package WordPress + * @since 7.0.0 + */ + /** * Internal dependencies */ diff --git a/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js index 7744f096c1ddb..e2bc6954a2c95 100644 --- a/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js +++ b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js @@ -1,3 +1,13 @@ +/** + * Tests for collaborative editing undo/redo. + * + * Verifies that undo and redo operations affect only the originating + * user's changes while preserving other collaborators' edits. + * + * @package WordPress + * @since 7.0.0 + */ + /** * Internal dependencies */ diff --git a/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js index 946741cdee41d..93704bf8cd7ed 100644 --- a/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js +++ b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js @@ -1,3 +1,13 @@ +/** + * Collaboration E2E test utilities. + * + * Provides helpers for setting up multi-user collaborative editing + * sessions, managing browser contexts, and waiting for sync cycles. + * + * @package WordPress + * @since 7.0.0 + */ + /** * External dependencies */ @@ -8,6 +18,12 @@ import { expect } from '@playwright/test'; */ import { Editor } from '@wordpress/e2e-test-utils-playwright'; +/** + * Credentials for the second collaborator user. + * + * @since 7.0.0 + * @type {Object} + */ export const SECOND_USER = { username: 'collaborator', email: 'collaborator@example.com', @@ -17,6 +33,12 @@ export const SECOND_USER = { roles: [ 'editor' ], }; +/** + * Credentials for the third collaborator user. + * + * @since 7.0.0 + * @type {Object} + */ export const THIRD_USER = { username: 'collaborator2', email: 'collaborator2@example.com', @@ -28,6 +50,14 @@ export const THIRD_USER = { const BASE_URL = process.env.WP_BASE_URL || 'http://localhost:8889'; +/** + * Manages multi-user collaborative editing sessions for E2E tests. + * + * Handles browser context creation, user login, editor navigation, + * and sync-cycle waiting for up to three concurrent users. + * + * @since 7.0.0 + */ export default class CollaborationUtils { constructor( { admin, editor, requestUtils, page } ) { this.admin = admin; diff --git a/tests/e2e/specs/collaboration/fixtures/index.js b/tests/e2e/specs/collaboration/fixtures/index.js index ae47d22c97817..bac6480b0b2ef 100644 --- a/tests/e2e/specs/collaboration/fixtures/index.js +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -1,3 +1,13 @@ +/** + * Collaboration E2E test fixtures. + * + * Extends the base Playwright test with a `collaborationUtils` fixture + * that provisions three users and enables real-time collaboration. + * + * @package WordPress + * @since 7.0.0 + */ + /** * WordPress dependencies */ From 255cde9491eb21fa21542a306764510a6649f4ad Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 3 Mar 2026 07:00:15 -0500 Subject: [PATCH 17/82] Refactor collaboration E2E tests to use shared utility helpers Extract repeated patterns across collaboration test files into CollaborationUtils methods: createCollaborativePost, insertBlockViaEvaluate, assertEditorHasContent, and assertAllEditorsHaveContent. Replace hardcoded timeout values with the exported SYNC_TIMEOUT constant. Fix docblock for get_updates_after_cursor in the WP_Sync_Storage interface. --- .../interface-wp-sync-storage.php | 3 +- .../collaboration-presence.test.js | 34 ++-- .../collaboration/collaboration-sync.test.js | 162 ++++++------------ .../collaboration-undo-redo.test.js | 139 +++++---------- .../fixtures/collaboration-utils.js | 99 ++++++++++- .../e2e/specs/collaboration/fixtures/index.js | 3 +- 6 files changed, 216 insertions(+), 224 deletions(-) diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php index 4e1e6b5c404d6..62dbf170c3edd 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -57,8 +57,7 @@ public function get_cursor( string $room ): int; public function get_update_count( string $room ): int; /** - * Retrieves sync updates from a room for a given client and cursor. Updates - * from the specified client should be excluded. + * Retrieves sync updates from a room after the given cursor. * * @since 7.0.0 * diff --git a/tests/e2e/specs/collaboration/collaboration-presence.test.js b/tests/e2e/specs/collaboration/collaboration-presence.test.js index 11789fdb860cd..820a84065d543 100644 --- a/tests/e2e/specs/collaboration/collaboration-presence.test.js +++ b/tests/e2e/specs/collaboration/collaboration-presence.test.js @@ -11,54 +11,48 @@ /** * Internal dependencies */ -import { test, expect } from './fixtures'; +import { test, expect, SYNC_TIMEOUT } from './fixtures'; test.describe( 'Collaboration - Presence', () => { test( 'All 3 collaborator avatars are visible', async ( { collaborationUtils, - requestUtils, page, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Presence Test - 3 Users', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { page2, page3 } = collaborationUtils; // Each user sees the collaborators list button (indicates others are present). await expect( page.getByRole( 'button', { name: /Collaborators list/ } ) - ).toBeVisible( { timeout: 10000 } ); + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); await expect( page2.getByRole( 'button', { name: /Collaborators list/ } ) - ).toBeVisible( { timeout: 10000 } ); + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); await expect( page3.getByRole( 'button', { name: /Collaborators list/ } ) - ).toBeVisible( { timeout: 10000 } ); + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); } ); test( 'Collaborator names appear in popover', async ( { collaborationUtils, - requestUtils, page, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Presence Test - Names', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); // User A opens the collaborators popover. const presenceButton = page.getByRole( 'button', { name: /Collaborators list/, } ); - await expect( presenceButton ).toBeVisible( { timeout: 10000 } ); + await expect( presenceButton ).toBeVisible( { + timeout: SYNC_TIMEOUT, + } ); await presenceButton.click(); // The popover should list both collaborators by name. @@ -73,22 +67,16 @@ test.describe( 'Collaboration - Presence', () => { test( 'User C leaves, A and B see updated presence', async ( { collaborationUtils, - requestUtils, page, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Presence Test - Leave', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); - - const { page2 } = collaborationUtils; // Verify all 3 users see the collaborators button initially. await expect( page.getByRole( 'button', { name: /Collaborators list/ } ) - ).toBeVisible( { timeout: 10000 } ); + ).toBeVisible( { timeout: SYNC_TIMEOUT } ); // Close User C's context to simulate leaving. await collaborationUtils.page3.close(); diff --git a/tests/e2e/specs/collaboration/collaboration-sync.test.js b/tests/e2e/specs/collaboration/collaboration-sync.test.js index a81659ad6b345..5bf51d2a979fe 100644 --- a/tests/e2e/specs/collaboration/collaboration-sync.test.js +++ b/tests/e2e/specs/collaboration/collaboration-sync.test.js @@ -12,20 +12,16 @@ /** * Internal dependencies */ -import { test, expect } from './fixtures'; +import { test, expect, SYNC_TIMEOUT } from './fixtures'; test.describe( 'Collaboration - Sync', () => { test( 'User A adds a paragraph block, Users B and C both see it', async ( { collaborationUtils, - requestUtils, editor, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Sync Test - Fan Out', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { editor2, editor3 } = collaborationUtils; @@ -37,7 +33,7 @@ test.describe( 'Collaboration - Sync', () => { // User B should see the paragraph after sync propagation. await expect - .poll( () => editor2.getBlocks(), { timeout: 10000 } ) + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -47,7 +43,7 @@ test.describe( 'Collaboration - Sync', () => { // User C should also see the paragraph. await expect - .poll( () => editor3.getBlocks(), { timeout: 10000 } ) + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -58,29 +54,24 @@ test.describe( 'Collaboration - Sync', () => { test( 'User C adds a paragraph block, Users A and B see it', async ( { collaborationUtils, - requestUtils, editor, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Sync Test - C to A and B', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { editor2, page3 } = collaborationUtils; // User C inserts a paragraph block via the data API. - await page3.evaluate( () => { - const block = window.wp.blocks.createBlock( 'core/paragraph', { - content: 'Hello from User C', - } ); - window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); - } ); + await collaborationUtils.insertBlockViaEvaluate( + page3, + 'core/paragraph', + { content: 'Hello from User C' } + ); // User A should see the paragraph. await expect - .poll( () => editor.getBlocks(), { timeout: 10000 } ) + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -90,7 +81,7 @@ test.describe( 'Collaboration - Sync', () => { // User B should also see the paragraph. await expect - .poll( () => editor2.getBlocks(), { timeout: 10000 } ) + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -101,17 +92,13 @@ test.describe( 'Collaboration - Sync', () => { test( 'All 3 users add blocks simultaneously, all changes appear everywhere', async ( { collaborationUtils, - requestUtils, editor, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Sync Test - 3-Way Merge', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); - const { editor2, editor3, page2, page3 } = collaborationUtils; + const { page2, page3 } = collaborationUtils; // All 3 users insert blocks concurrently. await Promise.all( [ @@ -119,47 +106,33 @@ test.describe( 'Collaboration - Sync', () => { name: 'core/paragraph', attributes: { content: 'From User A' }, } ), - page2.evaluate( () => { - const block = window.wp.blocks.createBlock( 'core/paragraph', { - content: 'From User B', - } ); - window.wp.data - .dispatch( 'core/block-editor' ) - .insertBlock( block ); - } ), - page3.evaluate( () => { - const block = window.wp.blocks.createBlock( 'core/paragraph', { - content: 'From User C', - } ); - window.wp.data - .dispatch( 'core/block-editor' ) - .insertBlock( block ); - } ), + collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'From User B' } + ), + collaborationUtils.insertBlockViaEvaluate( + page3, + 'core/paragraph', + { content: 'From User C' } + ), ] ); // All 3 users should eventually see all 3 blocks. - for ( const ed of [ editor, editor2, editor3 ] ) { - await expect( async () => { - const blocks = await ed.getBlocks(); - const contents = blocks.map( ( b ) => b.attributes.content ); - expect( contents ).toContain( 'From User A' ); - expect( contents ).toContain( 'From User B' ); - expect( contents ).toContain( 'From User C' ); - } ).toPass( { timeout: 10000 } ); - } + await collaborationUtils.assertAllEditorsHaveContent( [ + 'From User A', + 'From User B', + 'From User C', + ] ); } ); test( 'Title change from User A propagates to B and C', async ( { collaborationUtils, - requestUtils, page, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Sync Test - Title', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { page2, page3 } = collaborationUtils; @@ -179,7 +152,7 @@ test.describe( 'Collaboration - Sync', () => { .select( 'core/editor' ) .getEditedPostAttribute( 'title' ) ), - { timeout: 10000 } + { timeout: SYNC_TIMEOUT } ) .toBe( 'New Title from User A' ); @@ -192,22 +165,18 @@ test.describe( 'Collaboration - Sync', () => { .select( 'core/editor' ) .getEditedPostAttribute( 'title' ) ), - { timeout: 10000 } + { timeout: SYNC_TIMEOUT } ) .toBe( 'New Title from User A' ); } ); test( 'User C joins late and sees existing content from A and B', async ( { collaborationUtils, - requestUtils, editor, } ) => { - const post = await requestUtils.createPost( { + const post = await collaborationUtils.createCollaborativePost( { title: 'Sync Test - Late Join', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { page2, page3, editor3 } = collaborationUtils; @@ -221,55 +190,46 @@ test.describe( 'Collaboration - Sync', () => { attributes: { content: 'Block from A (early)' }, } ); - await page2.evaluate( () => { - const block = window.wp.blocks.createBlock( 'core/paragraph', { - content: 'Block from B (early)', - } ); - window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); - } ); + await collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'Block from B (early)' } + ); // Wait for A and B to sync with each other. - await expect( async () => { - const blocksA = await editor.getBlocks(); - const contentsA = blocksA.map( ( b ) => b.attributes.content ); - expect( contentsA ).toContain( 'Block from A (early)' ); - expect( contentsA ).toContain( 'Block from B (early)' ); - } ).toPass( { timeout: 10000 } ); + await collaborationUtils.assertEditorHasContent( editor, [ + 'Block from A (early)', + 'Block from B (early)', + ] ); // Now User C joins late by navigating back to the editor. await collaborationUtils.navigateToEditor( page3, post.id ); await collaborationUtils.waitForCollaborationReady( page3 ); // User C should see all existing blocks from A and B after sync. - await expect( async () => { - const blocks = await editor3.getBlocks(); - const contents = blocks.map( ( b ) => b.attributes.content ); - expect( contents ).toContain( 'Block from A (early)' ); - expect( contents ).toContain( 'Block from B (early)' ); - } ).toPass( { timeout: 10000 } ); + await collaborationUtils.assertEditorHasContent( editor3, [ + 'Block from A (early)', + 'Block from B (early)', + ] ); } ); test( 'Block deletion syncs to all users', async ( { collaborationUtils, - requestUtils, editor, page, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Sync Test - Block Deletion', - status: 'draft', content: '

Block to delete

', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { editor2, editor3 } = collaborationUtils; // Wait for all users to see the seeded block. for ( const ed of [ editor, editor2, editor3 ] ) { await expect - .poll( () => ed.getBlocks(), { timeout: 10000 } ) + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -290,34 +250,30 @@ test.describe( 'Collaboration - Sync', () => { // Users B and C should see 0 blocks after sync. await expect - .poll( () => editor2.getBlocks(), { timeout: 10000 } ) + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toHaveLength( 0 ); await expect - .poll( () => editor3.getBlocks(), { timeout: 10000 } ) + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toHaveLength( 0 ); } ); test( 'Editing existing block content syncs to all users', async ( { collaborationUtils, - requestUtils, editor, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Sync Test - Edit Content', - status: 'draft', content: '

Original text

', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { editor2, editor3, page2 } = collaborationUtils; // Wait for all users to see the seeded block. for ( const ed of [ editor, editor2, editor3 ] ) { await expect - .poll( () => ed.getBlocks(), { timeout: 10000 } ) + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -340,7 +296,7 @@ test.describe( 'Collaboration - Sync', () => { // Users A and C should see the updated content. await expect - .poll( () => editor.getBlocks(), { timeout: 10000 } ) + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -349,7 +305,7 @@ test.describe( 'Collaboration - Sync', () => { ] ); await expect - .poll( () => editor3.getBlocks(), { timeout: 10000 } ) + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -360,15 +316,11 @@ test.describe( 'Collaboration - Sync', () => { test( 'Non-paragraph block type syncs to all users', async ( { collaborationUtils, - requestUtils, editor, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Sync Test - Heading Block', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { editor2, editor3 } = collaborationUtils; @@ -380,7 +332,7 @@ test.describe( 'Collaboration - Sync', () => { // User B should see the heading with correct attributes. await expect - .poll( () => editor2.getBlocks(), { timeout: 10000 } ) + .poll( () => editor2.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/heading', @@ -390,7 +342,7 @@ test.describe( 'Collaboration - Sync', () => { // User C should also see the heading with correct attributes. await expect - .poll( () => editor3.getBlocks(), { timeout: 10000 } ) + .poll( () => editor3.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/heading', diff --git a/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js index e2bc6954a2c95..dce4e5b2e548b 100644 --- a/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js +++ b/tests/e2e/specs/collaboration/collaboration-undo-redo.test.js @@ -11,47 +11,39 @@ /** * Internal dependencies */ -import { test, expect } from './fixtures'; +import { test, expect, SYNC_TIMEOUT } from './fixtures'; test.describe( 'Collaboration - Undo/Redo', () => { test( 'User A undo only affects their own changes, B and C blocks remain', async ( { collaborationUtils, - requestUtils, editor, page, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Undo Test - 3 Users', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); - const { editor2, editor3, page2, page3 } = collaborationUtils; + const { page2, page3 } = collaborationUtils; // User B adds a block. - await page2.evaluate( () => { - const block = window.wp.blocks.createBlock( 'core/paragraph', { - content: 'From User B', - } ); - window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); - } ); + await collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'From User B' } + ); // User C adds a block. - await page3.evaluate( () => { - const block = window.wp.blocks.createBlock( 'core/paragraph', { - content: 'From User C', - } ); - window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); - } ); + await collaborationUtils.insertBlockViaEvaluate( + page3, + 'core/paragraph', + { content: 'From User C' } + ); // Wait for both blocks to appear on User A. - await expect( async () => { - const blocks = await editor.getBlocks(); - const contents = blocks.map( ( b ) => b.attributes.content ); - expect( contents ).toContain( 'From User B' ); - expect( contents ).toContain( 'From User C' ); - } ).toPass( { timeout: 10000 } ); + await collaborationUtils.assertEditorHasContent( editor, [ + 'From User B', + 'From User C', + ] ); // User A adds their own block. await editor.insertBlock( { @@ -60,61 +52,32 @@ test.describe( 'Collaboration - Undo/Redo', () => { } ); // Wait for all 3 blocks to appear on all editors. - for ( const ed of [ editor, editor2, editor3 ] ) { - await expect( async () => { - const blocks = await ed.getBlocks(); - const contents = blocks.map( ( b ) => b.attributes.content ); - expect( contents ).toContain( 'From User A' ); - expect( contents ).toContain( 'From User B' ); - expect( contents ).toContain( 'From User C' ); - } ).toPass( { timeout: 10000 } ); - } + await collaborationUtils.assertAllEditorsHaveContent( [ + 'From User A', + 'From User B', + 'From User C', + ] ); // User A performs undo via the data API. await page.evaluate( () => { window.wp.data.dispatch( 'core/editor' ).undo(); } ); - // User A should see only B and C's blocks (their own is undone). - await expect( async () => { - const blocks = await editor.getBlocks(); - const contents = blocks.map( ( b ) => b.attributes.content ); - expect( contents ).not.toContain( 'From User A' ); - expect( contents ).toContain( 'From User B' ); - expect( contents ).toContain( 'From User C' ); - } ).toPass( { timeout: 10000 } ); - - // User B should also see the undo result. - await expect( async () => { - const blocks = await editor2.getBlocks(); - const contents = blocks.map( ( b ) => b.attributes.content ); - expect( contents ).not.toContain( 'From User A' ); - expect( contents ).toContain( 'From User B' ); - expect( contents ).toContain( 'From User C' ); - } ).toPass( { timeout: 10000 } ); - - // User C should also see the undo result. - await expect( async () => { - const blocks = await editor3.getBlocks(); - const contents = blocks.map( ( b ) => b.attributes.content ); - expect( contents ).not.toContain( 'From User A' ); - expect( contents ).toContain( 'From User B' ); - expect( contents ).toContain( 'From User C' ); - } ).toPass( { timeout: 10000 } ); + // All users should see only B and C's blocks (A's is undone). + await collaborationUtils.assertAllEditorsHaveContent( + [ 'From User B', 'From User C' ], + { not: [ 'From User A' ] } + ); } ); test( 'Redo restores the undone change across all users', async ( { collaborationUtils, - requestUtils, editor, page, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Redo Test - 3 Users', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { editor2, editor3 } = collaborationUtils; @@ -127,7 +90,7 @@ test.describe( 'Collaboration - Undo/Redo', () => { // Verify the block exists on all editors. for ( const ed of [ editor, editor2, editor3 ] ) { await expect - .poll( () => ed.getBlocks(), { timeout: 10000 } ) + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -142,7 +105,7 @@ test.describe( 'Collaboration - Undo/Redo', () => { } ); await expect - .poll( () => editor.getBlocks(), { timeout: 10000 } ) + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toHaveLength( 0 ); // Redo via data API. @@ -153,7 +116,7 @@ test.describe( 'Collaboration - Undo/Redo', () => { // All users should see the restored block. for ( const ed of [ editor, editor2, editor3 ] ) { await expect - .poll( () => ed.getBlocks(), { timeout: 10000 } ) + .poll( () => ed.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -165,30 +128,25 @@ test.describe( 'Collaboration - Undo/Redo', () => { test( 'Bystander sees correct state after undo', async ( { collaborationUtils, - requestUtils, editor, page, } ) => { - const post = await requestUtils.createPost( { + await collaborationUtils.createCollaborativePost( { title: 'Undo Test - Bystander', - status: 'draft', - date_gmt: new Date().toISOString(), } ); - await collaborationUtils.openCollaborativeSession( post.id ); const { editor3, page2 } = collaborationUtils; // User B adds a block. - await page2.evaluate( () => { - const block = window.wp.blocks.createBlock( 'core/paragraph', { - content: 'From User B', - } ); - window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); - } ); + await collaborationUtils.insertBlockViaEvaluate( + page2, + 'core/paragraph', + { content: 'From User B' } + ); // Wait for User B's block to appear on User A. await expect - .poll( () => editor.getBlocks(), { timeout: 10000 } ) + .poll( () => editor.getBlocks(), { timeout: SYNC_TIMEOUT } ) .toMatchObject( [ { name: 'core/paragraph', @@ -203,12 +161,10 @@ test.describe( 'Collaboration - Undo/Redo', () => { } ); // Wait for both blocks to appear on the bystander (User C). - await expect( async () => { - const blocks = await editor3.getBlocks(); - const contents = blocks.map( ( b ) => b.attributes.content ); - expect( contents ).toContain( 'From User A' ); - expect( contents ).toContain( 'From User B' ); - } ).toPass( { timeout: 10000 } ); + await collaborationUtils.assertEditorHasContent( editor3, [ + 'From User A', + 'From User B', + ] ); // User A undoes their own block. await page.evaluate( () => { @@ -216,11 +172,10 @@ test.describe( 'Collaboration - Undo/Redo', () => { } ); // Bystander (User C) should see only User B's block. - await expect( async () => { - const blocks = await editor3.getBlocks(); - const contents = blocks.map( ( b ) => b.attributes.content ); - expect( contents ).not.toContain( 'From User A' ); - expect( contents ).toContain( 'From User B' ); - } ).toPass( { timeout: 10000 } ); + await collaborationUtils.assertEditorHasContent( + editor3, + [ 'From User B' ], + { not: [ 'From User A' ] } + ); } ); } ); diff --git a/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js index 93704bf8cd7ed..4d42507afca63 100644 --- a/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js +++ b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js @@ -50,6 +50,14 @@ export const THIRD_USER = { const BASE_URL = process.env.WP_BASE_URL || 'http://localhost:8889'; +/** + * Default timeout (ms) for sync-related assertions. + * + * @since 7.0.0 + * @type {number} + */ +export const SYNC_TIMEOUT = 10_000; + /** * Manages multi-user collaborative editing sessions for E2E tests. * @@ -260,7 +268,7 @@ export default class CollaborationUtils { ( response ) => response.url().includes( 'wp-sync' ) && response.status() === 200, - { timeout: 10000 } + { timeout: SYNC_TIMEOUT } ); } } @@ -331,6 +339,95 @@ export default class CollaborationUtils { return this._thirdEditor; } + /** + * Create a draft post and open a collaborative session on it. + * + * @since 7.0.0 + * + * @param {Object} options Options forwarded to `requestUtils.createPost()`. + * @return {Object} The created post object. + */ + async createCollaborativePost( options = {} ) { + const post = await this.requestUtils.createPost( { + status: 'draft', + date_gmt: new Date().toISOString(), + ...options, + } ); + await this.openCollaborativeSession( post.id ); + return post; + } + + /** + * Insert a block on a secondary page via `page.evaluate()`. + * + * @since 7.0.0 + * + * @param {import('@playwright/test').Page} page The page to insert on. + * @param {string} blockName Block name, e.g. 'core/paragraph'. + * @param {Object} attributes Block attributes. + */ + async insertBlockViaEvaluate( page, blockName, attributes ) { + await page.evaluate( + ( { name, attrs } ) => { + const block = window.wp.blocks.createBlock( name, attrs ); + window.wp.data + .dispatch( 'core/block-editor' ) + .insertBlock( block ); + }, + { name: blockName, attrs: attributes } + ); + } + + /** + * Assert that an editor contains (or does not contain) blocks with + * the given content strings. + * + * @since 7.0.0 + * + * @param {Editor} ed Editor instance to check. + * @param {string[]} expected Content strings that must be present. + * @param {Object} options + * @param {string[]} options.not Content strings that must NOT be present. + * @param {number} options.timeout Assertion timeout in ms. + */ + async assertEditorHasContent( + ed, + expected, + { not: notExpected = [], timeout = SYNC_TIMEOUT } = {} + ) { + await expect( async () => { + const blocks = await ed.getBlocks(); + const contents = blocks.map( ( b ) => b.attributes.content ); + for ( const item of expected ) { + expect( contents ).toContain( item ); + } + for ( const item of notExpected ) { + expect( contents ).not.toContain( item ); + } + } ).toPass( { timeout } ); + } + + /** + * Assert content across all open editors (primary + collaborators). + * + * @since 7.0.0 + * + * @param {string[]} expected Content strings that must be present. + * @param {Object} options Options forwarded to `assertEditorHasContent()`. + */ + async assertAllEditorsHaveContent( expected, options = {} ) { + const editors = [ this.editor ]; + if ( this._secondEditor ) { + editors.push( this._secondEditor ); + } + if ( this._thirdEditor ) { + editors.push( this._thirdEditor ); + } + for ( const ed of editors ) { + await this.assertEditorHasContent( ed, expected, options ); + } + } + /** * Clean up: close extra browser contexts, disable collaboration, * delete test users. diff --git a/tests/e2e/specs/collaboration/fixtures/index.js b/tests/e2e/specs/collaboration/fixtures/index.js index bac6480b0b2ef..cf8b4aad2987e 100644 --- a/tests/e2e/specs/collaboration/fixtures/index.js +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -17,7 +17,8 @@ export { expect } from '@wordpress/e2e-test-utils-playwright'; /** * Internal dependencies */ -import CollaborationUtils, { SECOND_USER, THIRD_USER } from './collaboration-utils'; +import CollaborationUtils, { SECOND_USER, THIRD_USER, SYNC_TIMEOUT } from './collaboration-utils'; +export { SYNC_TIMEOUT }; export const test = base.extend( { collaborationUtils: async ( From 3296d5d74e97641f249c3d5e49cb6d365d175118 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 3 Mar 2026 14:18:21 -0500 Subject: [PATCH 18/82] Apply suggestion from @westonruter Co-authored-by: Weston Ruter --- src/wp-admin/includes/schema.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 2472530d2bb8c..92dc70bf040ad 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -193,7 +193,8 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { update_value longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), - KEY room (room,id) + KEY room (room,id), + KEY created_at (created_at) ) $charset_collate;\n"; // Single site users table. The multisite flavor of the users table is handled below. From c920b97da72ada2f195656aab099343be719793f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 3 Mar 2026 15:04:13 -0500 Subject: [PATCH 19/82] Collaboration: Split update_value column into client_id, type, and data. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the opaque JSON blob with dedicated columns so the sync_updates table is legible and queryable without JSON parsing. Removes the json_encode/json_decode round-trip on every write and read. No migration needed — the table has not shipped in a stable release. Props mindctrl. --- src/wp-admin/includes/schema.php | 4 +++- .../class-wp-sync-table-storage.php | 18 ++++++++++------ .../tests/rest-api/rest-sync-server.php | 21 ++++++++----------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 2472530d2bb8c..3d47ca6eb6963 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -190,7 +190,9 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { CREATE TABLE $wpdb->sync_updates ( id bigint(20) unsigned NOT NULL auto_increment, room varchar(255) NOT NULL, - update_value longtext NOT NULL, + client_id bigint(20) unsigned NOT NULL default 0, + type varchar(20) NOT NULL default '', + data longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), KEY room (room,id) diff --git a/src/wp-includes/collaboration/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-sync-table-storage.php index 4b6dd074d5d3a..2877773aebcf0 100644 --- a/src/wp-includes/collaboration/class-wp-sync-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-table-storage.php @@ -50,11 +50,13 @@ public function add_update( string $room, $update ): bool { $result = $wpdb->insert( $wpdb->sync_updates, array( - 'room' => $room, - 'update_value' => wp_json_encode( $update ), - 'created_at' => current_time( 'mysql', true ), + 'room' => $room, + 'client_id' => $update['client_id'], + 'type' => $update['type'], + 'data' => $update['data'], + 'created_at' => current_time( 'mysql', true ), ), - array( '%s', '%s', '%s' ) + array( '%s', '%d', '%s', '%s', '%s' ) ); return false !== $result; @@ -154,7 +156,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Fetch updates after the cursor up to the snapshot boundary. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT update_value FROM {$wpdb->sync_updates} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", + "SELECT client_id, type, data FROM {$wpdb->sync_updates} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", $room, $cursor, $max_id @@ -167,7 +169,11 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $updates = array(); foreach ( $rows as $row ) { - $updates[] = json_decode( $row->update_value, true ); + $updates[] = array( + 'client_id' => (int) $row->client_id, + 'type' => $row->type, + 'data' => $row->data, + ); } return $updates; diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index cecc26e016bae..9a9ab5023983e 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -1035,7 +1035,7 @@ static function ( WP_Sync_Storage $storage ) use ( &$filtered_storage ): WP_Sync * Inserts a row directly into the sync_updates table with a given age. * * @param positive-int $age_in_seconds How old the row should be. - * @param string $label A label stored in the update_value for identification. + * @param string $label A label stored in the data column for identification. */ private function insert_sync_row( int $age_in_seconds, string $label = 'test' ): void { global $wpdb; @@ -1043,16 +1043,13 @@ private function insert_sync_row( int $age_in_seconds, string $label = 'test' ): $wpdb->insert( $wpdb->sync_updates, array( - 'room' => $this->get_post_room(), - 'update_value' => wp_json_encode( - array( - 'type' => 'update', - 'data' => $label, - ) - ), - 'created_at' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), + 'room' => $this->get_post_room(), + 'client_id' => 0, + 'type' => 'update', + 'data' => $label, + 'created_at' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), ), - array( '%s', '%s', '%s' ) + array( '%s', '%d', '%s', '%s', '%s' ) ); } @@ -1101,10 +1098,10 @@ public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { wp_delete_old_sync_updates(); global $wpdb; - $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->sync_updates}" ); + $remaining = $wpdb->get_col( "SELECT data FROM {$wpdb->sync_updates}" ); $this->assertCount( 1, $remaining, 'Only the row within the 7-day window should remain.' ); - $this->assertStringContainsString( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); + $this->assertSame( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); } /** From ceccbb41987ecc57fafb9d3ea0e28ac3f5a326d8 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 3 Mar 2026 15:37:38 -0500 Subject: [PATCH 20/82] Tests: Add storage-layer round-trip test for sync_updates column split. --- .../tests/rest-api/rest-sync-server.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index 9a9ab5023983e..ddefcf4ef9be5 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -504,6 +504,28 @@ public function test_sync_update_data_preserved() { $this->assertSame( 'update', $room_updates[0]['type'] ); } + /** + * @ticket 64696 + */ + public function test_sync_storage_round_trip_preserves_all_columns() { + $storage = new WP_Sync_Table_Storage(); + $room = $this->get_post_room(); + $update = array( + 'client_id' => 42, + 'type' => 'update', + 'data' => 'cm91bmQgdHJpcA==', + ); + + $storage->add_update( $room, $update ); + + $updates = $storage->get_updates_after_cursor( $room, 0 ); + + $this->assertCount( 1, $updates ); + $this->assertSame( 42, $updates[0]['client_id'] ); + $this->assertSame( 'update', $updates[0]['type'] ); + $this->assertSame( 'cm91bmQgdHJpcA==', $updates[0]['data'] ); + } + public function test_sync_total_updates_increments() { wp_set_current_user( self::$editor_id ); From 8b7115382114087e52c688a34e6abc3afee9544d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 3 Mar 2026 16:19:18 -0500 Subject: [PATCH 21/82] Apply suggestion from @westonruter Co-authored-by: Weston Ruter --- src/wp-admin/includes/schema.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 0276bc17e3e80..77c27d383e4db 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -191,7 +191,7 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { id bigint(20) unsigned NOT NULL auto_increment, room varchar(255) NOT NULL, client_id bigint(20) unsigned NOT NULL default 0, - type varchar(20) NOT NULL default '', + type varchar(10) NOT NULL default '', data longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), From d1f802634e7af638fa248e5965416af39969ed57 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 3 Mar 2026 21:26:51 -0500 Subject: [PATCH 22/82] Collaboration: Restore update_value as opaque blob in sync_updates table. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the column split from c920b97 and removes the round-trip test from ceccbb4. The sync_updates table is an append-only event log and should remain protocol-agnostic — the storage layer stores and orders; the sync server interprets. This reverts commit c920b97da72ada2f195656aab099343be719793f. This reverts commit ceccbb41987ecc57fafb9d3ea0e28ac3f5a326d8. --- src/wp-admin/includes/schema.php | 4 +- .../class-wp-sync-table-storage.php | 18 +++----- .../tests/rest-api/rest-sync-server.php | 43 ++++++------------- 3 files changed, 19 insertions(+), 46 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 77c27d383e4db..92dc70bf040ad 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -190,9 +190,7 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { CREATE TABLE $wpdb->sync_updates ( id bigint(20) unsigned NOT NULL auto_increment, room varchar(255) NOT NULL, - client_id bigint(20) unsigned NOT NULL default 0, - type varchar(10) NOT NULL default '', - data longtext NOT NULL, + update_value longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), KEY room (room,id), diff --git a/src/wp-includes/collaboration/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-sync-table-storage.php index 2877773aebcf0..4b6dd074d5d3a 100644 --- a/src/wp-includes/collaboration/class-wp-sync-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-table-storage.php @@ -50,13 +50,11 @@ public function add_update( string $room, $update ): bool { $result = $wpdb->insert( $wpdb->sync_updates, array( - 'room' => $room, - 'client_id' => $update['client_id'], - 'type' => $update['type'], - 'data' => $update['data'], - 'created_at' => current_time( 'mysql', true ), + 'room' => $room, + 'update_value' => wp_json_encode( $update ), + 'created_at' => current_time( 'mysql', true ), ), - array( '%s', '%d', '%s', '%s', '%s' ) + array( '%s', '%s', '%s' ) ); return false !== $result; @@ -156,7 +154,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Fetch updates after the cursor up to the snapshot boundary. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT client_id, type, data FROM {$wpdb->sync_updates} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", + "SELECT update_value FROM {$wpdb->sync_updates} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", $room, $cursor, $max_id @@ -169,11 +167,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $updates = array(); foreach ( $rows as $row ) { - $updates[] = array( - 'client_id' => (int) $row->client_id, - 'type' => $row->type, - 'data' => $row->data, - ); + $updates[] = json_decode( $row->update_value, true ); } return $updates; diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index ddefcf4ef9be5..cecc26e016bae 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -504,28 +504,6 @@ public function test_sync_update_data_preserved() { $this->assertSame( 'update', $room_updates[0]['type'] ); } - /** - * @ticket 64696 - */ - public function test_sync_storage_round_trip_preserves_all_columns() { - $storage = new WP_Sync_Table_Storage(); - $room = $this->get_post_room(); - $update = array( - 'client_id' => 42, - 'type' => 'update', - 'data' => 'cm91bmQgdHJpcA==', - ); - - $storage->add_update( $room, $update ); - - $updates = $storage->get_updates_after_cursor( $room, 0 ); - - $this->assertCount( 1, $updates ); - $this->assertSame( 42, $updates[0]['client_id'] ); - $this->assertSame( 'update', $updates[0]['type'] ); - $this->assertSame( 'cm91bmQgdHJpcA==', $updates[0]['data'] ); - } - public function test_sync_total_updates_increments() { wp_set_current_user( self::$editor_id ); @@ -1057,7 +1035,7 @@ static function ( WP_Sync_Storage $storage ) use ( &$filtered_storage ): WP_Sync * Inserts a row directly into the sync_updates table with a given age. * * @param positive-int $age_in_seconds How old the row should be. - * @param string $label A label stored in the data column for identification. + * @param string $label A label stored in the update_value for identification. */ private function insert_sync_row( int $age_in_seconds, string $label = 'test' ): void { global $wpdb; @@ -1065,13 +1043,16 @@ private function insert_sync_row( int $age_in_seconds, string $label = 'test' ): $wpdb->insert( $wpdb->sync_updates, array( - 'room' => $this->get_post_room(), - 'client_id' => 0, - 'type' => 'update', - 'data' => $label, - 'created_at' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), + 'room' => $this->get_post_room(), + 'update_value' => wp_json_encode( + array( + 'type' => 'update', + 'data' => $label, + ) + ), + 'created_at' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), ), - array( '%s', '%d', '%s', '%s', '%s' ) + array( '%s', '%s', '%s' ) ); } @@ -1120,10 +1101,10 @@ public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { wp_delete_old_sync_updates(); global $wpdb; - $remaining = $wpdb->get_col( "SELECT data FROM {$wpdb->sync_updates}" ); + $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->sync_updates}" ); $this->assertCount( 1, $remaining, 'Only the row within the 7-day window should remain.' ); - $this->assertSame( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); + $this->assertStringContainsString( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); } /** From 90a7ced4d21188cba34c3abae60986f6d2996d65 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Wed, 4 Mar 2026 12:02:43 -0500 Subject: [PATCH 23/82] Update src/wp-includes/collaboration/class-wp-sync-table-storage.php Co-authored-by: John Parris --- .../collaboration/class-wp-sync-table-storage.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-sync-table-storage.php index 4b6dd074d5d3a..db8604585f5ee 100644 --- a/src/wp-includes/collaboration/class-wp-sync-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-table-storage.php @@ -167,7 +167,10 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $updates = array(); foreach ( $rows as $row ) { - $updates[] = json_decode( $row->update_value, true ); + $decoded = json_decode( $row->update_value, true ); + if ( is_array( $decoded ) ) { + $updates[] = $decoded; + } } return $updates; From 42839ac466d693d0ddc46eb8bcec428618931075 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 07:06:37 -0500 Subject: [PATCH 24/82] Collaboration: Add data integrity proof for sync compaction. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a script that demonstrates the data loss window in post meta compaction. The post meta approach deletes all updates then re-inserts the newest, leaving a gap where concurrent readers see nothing. The table approach deletes only old rows — kept updates are never removed. Run: npm run test:performance:sync:prove --- package.json | 1 + .../scripts/sync-perf/prove-data-loss.php | 282 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 tools/local-env/scripts/sync-perf/prove-data-loss.php diff --git a/package.json b/package.json index acbe679d2f787..12398cc179b3a 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "env:logs": "node ./tools/local-env/scripts/docker.js logs", "env:pull": "node ./tools/local-env/scripts/docker.js pull", "test:performance": "wp-scripts test-playwright --config tests/performance/playwright.config.js", + "test:performance:sync:prove": "npm run env:cli -- eval-file tools/local-env/scripts/sync-perf/prove-data-loss.php", "test:php": "node ./tools/local-env/scripts/docker.js run --rm php ./vendor/bin/phpunit", "test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt", "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", diff --git a/tools/local-env/scripts/sync-perf/prove-data-loss.php b/tools/local-env/scripts/sync-perf/prove-data-loss.php new file mode 100644 index 0000000000000..a12e229c3ac74 --- /dev/null +++ b/tools/local-env/scripts/sync-perf/prove-data-loss.php @@ -0,0 +1,282 @@ +query( "TRUNCATE TABLE {$wpdb->sync_updates}" ); +for ( $i = 0; $i < $total; $i++ ) { + $s = new WP_Sync_Table_Storage(); + $s->add_update( $room, array( 'edit' => $i ) ); +} + +// Post meta backend needs a storage post. +if ( ! post_type_exists( 'wp_sync_storage' ) ) { + register_post_type( 'wp_sync_storage', array( 'public' => false ) ); +} +$post_id = wp_insert_post( array( + 'post_type' => 'wp_sync_storage', + 'post_status' => 'publish', + 'post_name' => md5( $room ), +) ); +for ( $i = 0; $i < $total; $i++ ) { + add_post_meta( $post_id, 'wp_sync_update', array( + 'timestamp' => 1000 + $i, + 'value' => array( 'edit' => $i ), + ) ); +} + +// ===================================================================== +// Table compaction — the correct behavior. +// +// This is the exact code path from: +// WP_Sync_Table_Storage::remove_updates_before_cursor() +// +// DELETE FROM wp_sync_updates WHERE room = %s AND id < %d +// +// One query. Only the 8 oldest rows are removed. The 2 newest rows +// are never deleted, never absent, always readable by other editors. +// There is no step 2. +// ===================================================================== + +// Cursor that keeps the $keep newest rows. +$cursor = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$wpdb->sync_updates} WHERE room = %s ORDER BY id DESC LIMIT 1 OFFSET %d", + $room, + $keep - 1 +) ); +$table = new WP_Sync_Table_Storage(); +$table->remove_updates_before_cursor( $room, $cursor ); + +// *** Read immediately after — what a second editor's poll would see. *** +$reader = new WP_Sync_Table_Storage(); +$table_visible = $reader->get_updates_after_cursor( $room, 0 ); +$table_count = count( $table_visible ); + +// ===================================================================== +// Post Meta compaction — the bug. +// +// This is the exact code path from: +// WP_Sync_Post_Meta_Storage::remove_updates_before_cursor() +// +// $all_updates = $this->get_all_updates( $room ); +// delete_post_meta( $post_id, self::SYNC_UPDATE_META_KEY ); ← ALL rows gone +// foreach ( $all_updates as $envelope ) { +// if ( $envelope['timestamp'] >= $cursor ) { +// add_post_meta( ... ); ← re-inserted one by one +// } +// } +// +// Between the delete and the first re-insert, the sync history is empty. +// ===================================================================== + +// Step 1 of 2: delete ALL updates (this is the production code path). +delete_post_meta( $post_id, 'wp_sync_update' ); + +// *** Read between step 1 and step 2 — what a second editor's poll would see. *** +wp_cache_delete( $post_id, 'post_meta' ); +$meta_visible = get_post_meta( $post_id, 'wp_sync_update', false ); +$meta_count = count( array_filter( $meta_visible, 'is_array' ) ); + +// Step 2 of 2 (re-insert kept updates) would happen here, but the gap already occurred. + +// ===================================================================== +// Gap at scale. +// +// The gap is the time between delete_post_meta() and the last +// add_post_meta() — the window where all updates are missing. +// More updates to keep = more add_post_meta() calls = wider gap. +// +// Table compaction has no gap. The kept rows are never removed. +// ===================================================================== + +$gap_scales = array( 10, 50, 200, 500 ); +$gap_results = array(); +$progress = WP_CLI\Utils\make_progress_bar( 'Measuring data loss window at scale', count( $gap_scales ) ); + +foreach ( $gap_scales as $gap_total ) { + $gap_discard = (int) ( $gap_total * 0.8 ); + $gap_keep = $gap_total - $gap_discard; + $gap_room = "postType/post:gap-{$gap_total}"; + + // Seed post meta for this scale. + $gap_post_id = wp_insert_post( array( + 'post_type' => 'wp_sync_storage', + 'post_status' => 'publish', + 'post_name' => md5( $gap_room ), + ) ); + for ( $i = 0; $i < $gap_total; $i++ ) { + add_post_meta( $gap_post_id, 'wp_sync_update', array( + 'timestamp' => 1000 + $i, + 'value' => array( 'edit' => $i ), + ) ); + } + + // The cursor: timestamp of the first update to keep. + $gap_cursor = 1000 + $gap_discard; + + // Read all updates before deleting (same as production code path). + $all_updates = get_post_meta( $gap_post_id, 'wp_sync_update', false ); + + // Measure the full gap: delete all, then re-insert each kept update. + $gap_start = microtime( true ); + delete_post_meta( $gap_post_id, 'wp_sync_update' ); + foreach ( $all_updates as $envelope ) { + if ( is_array( $envelope ) && $envelope['timestamp'] >= $gap_cursor ) { + add_post_meta( $gap_post_id, 'wp_sync_update', $envelope ); + } + } + $gap_ms = ( microtime( true ) - $gap_start ) * 1000; + + $gap_results[] = array( + 'total' => $gap_total, + 'keep' => $gap_keep, + 'gap_ms' => $gap_ms, + ); + + // Cleanup this scale. + wp_delete_post( $gap_post_id, true ); + $progress->tick(); +} + +$progress->finish(); + +// ===================================================================== +// Results. +// ===================================================================== + +$separator = str_repeat( '─', 60 ); + +WP_CLI::log( '' ); +WP_CLI::log( WP_CLI::colorize( '%_Sync Compaction Data Integrity Test%n' ) ); +WP_CLI::log( 'Run: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC' ); +WP_CLI::log( '' ); +WP_CLI::log( "{$total} sync updates, {$keep} editors. Compaction keeps {$keep} newest, discards {$discard} oldest." ); +WP_CLI::log( WP_CLI::colorize( "%_Expected: {$keep} newest updates remain visible to all editors.%n" ) ); + +WP_CLI::log( '' ); +WP_CLI::log( $separator ); +WP_CLI::log( WP_CLI::colorize( '%_Table (proposed)%n' ) ); +WP_CLI::log( $separator ); +WP_CLI::log( " DELETE WHERE id < cutoff — only the {$discard} oldest removed." ); +WP_CLI::log( '' ); +WP_CLI::log( ' Second editor polls after the DELETE:' ); +$table_verdict = $table_count >= $keep + ? WP_CLI::colorize( " %G→ {$table_count} of {$keep} visible — OK%n" ) + : WP_CLI::colorize( " %R→ {$table_count} of {$keep} visible — UNEXPECTED%n" ); +WP_CLI::log( $table_verdict ); + +WP_CLI::log( '' ); +WP_CLI::log( $separator ); +WP_CLI::log( WP_CLI::colorize( '%_Post Meta (current beta 1)%n' ) ); +WP_CLI::log( $separator ); +WP_CLI::log( " delete_post_meta() removes all {$total}, then add_post_meta() re-inserts {$keep}." ); +WP_CLI::log( '' ); +WP_CLI::log( ' Second editor polls between delete and re-insert:' ); +$meta_verdict = 0 === $meta_count + ? WP_CLI::colorize( " %R→ {$meta_count} of {$keep} visible — DATA LOSS%n" ) + : WP_CLI::colorize( " %G→ {$meta_count} of {$keep} visible — OK%n" ); +WP_CLI::log( $meta_verdict ); + +WP_CLI::log( '' ); +WP_CLI::log( $separator ); +WP_CLI::log( WP_CLI::colorize( '%_Post Meta gap at scale%n' ) ); +WP_CLI::log( $separator ); +WP_CLI::log( ' Duration where all updates are missing:' ); +WP_CLI::log( '' ); + +$scale_items = array(); +foreach ( $gap_results as $gap ) { + $scale_items[] = array( + 'Updates (keep 20%)' => sprintf( '%d (keep %d)', $gap['total'], $gap['keep'] ), + 'Gap' => sprintf( '%.1f ms', $gap['gap_ms'] ), + ); +} +WP_CLI\Utils\format_items( 'table', $scale_items, array( 'Updates (keep 20%)', 'Gap' ) ); + +WP_CLI::log( '' ); +WP_CLI::log( WP_CLI::colorize( ' %GTable: no gap at any scale.%n' ) ); + +// Cleanup. +wp_delete_post( $post_id, true ); +$wpdb->query( "TRUNCATE TABLE {$wpdb->sync_updates}" ); From 72df885a6ba9774f7ebe75671e113f8f209a47e5 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 07:16:16 -0500 Subject: [PATCH 25/82] Update src/wp-includes/collaboration/class-wp-sync-table-storage.php Co-authored-by: Weston Ruter --- src/wp-includes/collaboration/class-wp-sync-table-storage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-sync-table-storage.php index db8604585f5ee..1dc924f44d258 100644 --- a/src/wp-includes/collaboration/class-wp-sync-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-table-storage.php @@ -168,7 +168,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $updates = array(); foreach ( $rows as $row ) { $decoded = json_decode( $row->update_value, true ); - if ( is_array( $decoded ) ) { + if ( json_last_error() === JSON_ERROR_NONE ) { $updates[] = $decoded; } } From 5ed5a6b48cf5f514807cdfc64def5a9c28c2cb88 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 07:23:23 -0500 Subject: [PATCH 26/82] Collaboration: Remove wp_sync_storage filter for v1. Removes premature filter surface for the sync storage backend. Can be reintroduced in a future release if needed. --- src/wp-includes/rest-api.php | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 9c3a939970638..8322926791404 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -432,17 +432,7 @@ function create_initial_rest_routes() { // Collaboration. if ( wp_is_collaboration_enabled() ) { $sync_storage = new WP_Sync_Table_Storage(); - - /** - * Filters the sync storage backend used for real-time collaboration. - * - * @since 7.0.0 - * - * @param WP_Sync_Storage $sync_storage Storage backend instance. - */ - $sync_storage = apply_filters( 'wp_sync_storage', $sync_storage ); - - $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); + $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); $sync_server->register_routes(); } } From 97f346c56db06ac32e89553a23a552269a3fe434 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 07:32:33 -0500 Subject: [PATCH 27/82] Collaboration: Use production compaction threshold in data integrity proof Updates the test to use 50 updates (the actual compaction trigger) instead of an artificial 10. Removes unsupported "two editors" framing from output and comments. --- .../scripts/sync-perf/prove-data-loss.php | 88 +++++-------------- 1 file changed, 20 insertions(+), 68 deletions(-) diff --git a/tools/local-env/scripts/sync-perf/prove-data-loss.php b/tools/local-env/scripts/sync-perf/prove-data-loss.php index a12e229c3ac74..f10f321df95dc 100644 --- a/tools/local-env/scripts/sync-perf/prove-data-loss.php +++ b/tools/local-env/scripts/sync-perf/prove-data-loss.php @@ -1,86 +1,38 @@ remove_updates_before_cursor( $room, $cursor ); -// *** Read immediately after — what a second editor's poll would see. *** +// *** Read immediately after compaction. *** $reader = new WP_Sync_Table_Storage(); $table_visible = $reader->get_updates_after_cursor( $room, 0 ); $table_count = count( $table_visible ); @@ -153,7 +105,7 @@ // Step 1 of 2: delete ALL updates (this is the production code path). delete_post_meta( $post_id, 'wp_sync_update' ); -// *** Read between step 1 and step 2 — what a second editor's poll would see. *** +// *** Read between step 1 and step 2. *** wp_cache_delete( $post_id, 'post_meta' ); $meta_visible = get_post_meta( $post_id, 'wp_sync_update', false ); $meta_count = count( array_filter( $meta_visible, 'is_array' ) ); @@ -170,7 +122,7 @@ // Table compaction has no gap. The kept rows are never removed. // ===================================================================== -$gap_scales = array( 10, 50, 200, 500 ); +$gap_scales = array( 50, 200, 500, 1000 ); $gap_results = array(); $progress = WP_CLI\Utils\make_progress_bar( 'Measuring data loss window at scale', count( $gap_scales ) ); @@ -231,8 +183,8 @@ WP_CLI::log( WP_CLI::colorize( '%_Sync Compaction Data Integrity Test%n' ) ); WP_CLI::log( 'Run: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC' ); WP_CLI::log( '' ); -WP_CLI::log( "{$total} sync updates, {$keep} editors. Compaction keeps {$keep} newest, discards {$discard} oldest." ); -WP_CLI::log( WP_CLI::colorize( "%_Expected: {$keep} newest updates remain visible to all editors.%n" ) ); +WP_CLI::log( "Compaction triggers at {$total} updates. Keeps {$keep} newest, discards {$discard} oldest." ); +WP_CLI::log( WP_CLI::colorize( "%_Expected: {$keep} newest updates remain visible after compaction.%n" ) ); WP_CLI::log( '' ); WP_CLI::log( $separator ); @@ -240,7 +192,7 @@ WP_CLI::log( $separator ); WP_CLI::log( " DELETE WHERE id < cutoff — only the {$discard} oldest removed." ); WP_CLI::log( '' ); -WP_CLI::log( ' Second editor polls after the DELETE:' ); +WP_CLI::log( ' Read immediately after compaction:' ); $table_verdict = $table_count >= $keep ? WP_CLI::colorize( " %G→ {$table_count} of {$keep} visible — OK%n" ) : WP_CLI::colorize( " %R→ {$table_count} of {$keep} visible — UNEXPECTED%n" ); @@ -252,7 +204,7 @@ WP_CLI::log( $separator ); WP_CLI::log( " delete_post_meta() removes all {$total}, then add_post_meta() re-inserts {$keep}." ); WP_CLI::log( '' ); -WP_CLI::log( ' Second editor polls between delete and re-insert:' ); +WP_CLI::log( ' Read between delete and re-insert:' ); $meta_verdict = 0 === $meta_count ? WP_CLI::colorize( " %R→ {$meta_count} of {$keep} visible — DATA LOSS%n" ) : WP_CLI::colorize( " %G→ {$meta_count} of {$keep} visible — OK%n" ); From 33666e1f34c683a3ed9319eeadaffb12612bf8ae Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 07:43:57 -0500 Subject: [PATCH 28/82] Collaboration: Resolve merge conflict and update data integrity proof. Resolves unresolved merge conflict in rest-sync-server.php from the trunk merge. Removes test for the wp_sync_storage filter removed in 5ed5a6b48c. Updates prove-data-loss.php to use the production compaction threshold (50) instead of an artificial 10. --- .../tests/rest-api/rest-sync-server.php | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index 958f5437323e0..59eb87cef6bd8 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -28,22 +28,10 @@ public static function wpTearDownAfterClass() { public function set_up() { parent::set_up(); -<<<<<<< feature/sync-updates-table // Uses DELETE (not TRUNCATE) to preserve transaction rollback support // in the test suite. TRUNCATE implicitly commits the transaction. global $wpdb; $wpdb->query( "DELETE FROM {$wpdb->sync_updates}" ); -======= - // Enable option for tests. - add_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); - - // Reset storage post ID cache to ensure clean state after transaction rollback. - $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' ); - if ( PHP_VERSION_ID < 80100 ) { - $reflection->setAccessible( true ); - } - $reflection->setValue( null, array() ); ->>>>>>> trunk } /** @@ -1093,31 +1081,6 @@ public function test_sync_compaction_reduces_total_updates(): void { $this->assertLessThan( 10, $data['rooms'][0]['total_updates'], 'Compaction should reduce the total update count.' ); } - /* - * Storage filter tests. - */ - - /** - * @ticket 64696 - */ - public function test_sync_storage_filter_is_applied(): void { - $filtered_storage = null; - - add_filter( - 'wp_sync_storage', - static function ( WP_Sync_Storage $storage ) use ( &$filtered_storage ): WP_Sync_Storage { - $filtered_storage = $storage; - return $storage; - } - ); - - // Re-trigger route registration to invoke the filter. - $server = rest_get_server(); - do_action( 'rest_api_init', $server ); - - $this->assertInstanceOf( WP_Sync_Storage::class, $filtered_storage, 'The wp_sync_storage filter should be applied during route registration.' ); - } - /* * Cron cleanup tests. */ From 183a3672ba12e204ba219c2bdb9d335f7ea4e4ca Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 10:21:31 -0500 Subject: [PATCH 29/82] Collaboration: Add sync table performance benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a WP-CLI-based benchmark that measures WP_Sync_Table_Storage at 100, 1,000, 10,000, and 100,000 rows — answering whether the table queries hold up at scale. Benchmarks idle poll, catch-up poll, and compaction with median, P95, STD, and MAD statistics. Includes EXPLAIN analysis for all query paths including LIKE prefix scans on the room index. Uses wp eval-file via the existing local env CLI container. Supports --format=json for machine-readable output. --- package.json | 1 + tools/local-env/scripts/sync-perf/run.php | 146 ++++++++ tools/local-env/scripts/sync-perf/utils.php | 392 ++++++++++++++++++++ 3 files changed, 539 insertions(+) create mode 100644 tools/local-env/scripts/sync-perf/run.php create mode 100644 tools/local-env/scripts/sync-perf/utils.php diff --git a/package.json b/package.json index 03f5e2759a1ad..12bddacf9bd0c 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "env:logs": "node ./tools/local-env/scripts/docker.js logs", "env:pull": "node ./tools/local-env/scripts/docker.js pull", "test:performance": "wp-scripts test-playwright --config tests/performance/playwright.config.js", + "test:performance:sync": "npm run env:cli -- eval-file tools/local-env/scripts/sync-perf/run.php", "test:performance:sync:prove": "npm run env:cli -- eval-file tools/local-env/scripts/sync-perf/prove-data-loss.php", "test:php": "node ./tools/local-env/scripts/docker.js run --rm php ./vendor/bin/phpunit", "test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt", diff --git a/tools/local-env/scripts/sync-perf/run.php b/tools/local-env/scripts/sync-perf/run.php new file mode 100644 index 0000000000000..d9c7e082a7f27 --- /dev/null +++ b/tools/local-env/scripts/sync-perf/run.php @@ -0,0 +1,146 @@ + $measured_iterations, + 'warmup_iterations' => $warmup_iterations, + 'compaction_iterations' => $compaction_iterations, + 'compaction_delete_ratio' => $compaction_delete_ratio, + 'rooms' => $rooms_per_scale, +); + +// ============================================================ +// Preflight check +// ============================================================ + +$table_name = $wpdb->prefix . 'sync_updates'; +$has_table = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ); + +if ( ! $has_table ) { + WP_CLI::error( "{$table_name} table not found. This script must run on the feature branch." ); +} + +// ============================================================ +// Benchmark runner +// ============================================================ + +$results = array(); + +WP_CLI::log( '' ); +WP_CLI::log( WP_CLI::colorize( '%_Sync Storage Performance Benchmark%n' ) ); +WP_CLI::log( "Backend: WP_Sync_Table_Storage" ); +WP_CLI::log( "Iterations: {$measured_iterations} measured + {$warmup_iterations} warm-up" ); +WP_CLI::log( "Compaction: {$compaction_iterations} measured (re-seed each)" ); +WP_CLI::log( '' ); + +foreach ( $scales as $scale ) { + $per_room = (int) ceil( $scale / $rooms_per_scale ); + $label = number_format( $scale ); + WP_CLI::log( "Scale: {$label} total rows ({$per_room} per room)" ); + + WP_CLI::log( ' Seeding table...' ); + sync_perf_seed_table( $scale, $rooms_per_scale ); + + $primer = new WP_Sync_Table_Storage(); + $primer->get_updates_after_cursor( $target_room, 0 ); + $table_idle_cursor = $primer->get_cursor( $target_room ); + + // Idle poll. + WP_CLI::log( ' Idle poll...' ); + $results['idle_poll'][ $scale ] = sync_perf_stats( + function () use ( $target_room, $table_idle_cursor ) { + $s = new WP_Sync_Table_Storage(); + $s->get_updates_after_cursor( $target_room, $table_idle_cursor ); + }, + $measured_iterations, + $warmup_iterations + ); + + // Catch-up poll. + WP_CLI::log( ' Catch-up poll...' ); + $results['catchup_poll'][ $scale ] = sync_perf_stats( + function () use ( $target_room ) { + $s = new WP_Sync_Table_Storage(); + $s->get_updates_after_cursor( $target_room, 0 ); + }, + $measured_iterations, + $warmup_iterations + ); + + // Compaction. + WP_CLI::log( ' Compaction...' ); + $compaction_times = array(); + + for ( $ci = 0; $ci < $compaction_iterations; $ci++ ) { + sync_perf_seed_table( $scale, $rooms_per_scale ); + + $compaction_cursor_id = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$wpdb->sync_updates} WHERE room = %s ORDER BY id ASC LIMIT 1 OFFSET %d", + $target_room, + max( 0, (int) floor( $per_room * $compaction_delete_ratio ) ) + ) ); + + $s = new WP_Sync_Table_Storage(); + $start = microtime( true ); + $s->remove_updates_before_cursor( $target_room, $compaction_cursor_id ); + $compaction_times[] = ( microtime( true ) - $start ) * 1000; + } + + $results['compaction'][ $scale ] = sync_perf_compute_stats( $compaction_times ); +} + +// ============================================================ +// EXPLAIN analysis at largest scale +// ============================================================ + +WP_CLI::log( 'Collecting EXPLAIN analysis...' ); +$explain_data = sync_perf_collect_explains( $target_room, end( $scales ), $rooms_per_scale ); + +// ============================================================ +// Cleanup +// ============================================================ + +$wpdb->query( "TRUNCATE TABLE {$wpdb->sync_updates}" ); + +// ============================================================ +// Output +// ============================================================ + +sync_perf_print_output( $results, $explain_data, $config, $scales, $format ); diff --git a/tools/local-env/scripts/sync-perf/utils.php b/tools/local-env/scripts/sync-perf/utils.php new file mode 100644 index 0000000000000..24b181d3038e2 --- /dev/null +++ b/tools/local-env/scripts/sync-perf/utils.php @@ -0,0 +1,392 @@ + abs( $v - $med ), $arr ); + return sync_perf_median( $deviations ); +} + +/** + * Computes the 95th percentile of an array of numbers. + * + * @param float[] $arr Array of numbers. + * @return float 95th percentile value. + */ +function sync_perf_p95( array $arr ): float { + sort( $arr ); + $index = (int) ceil( 0.95 * count( $arr ) ) - 1; + return $arr[ max( 0, $index ) ]; +} + +/** + * Computes median, P95, standard deviation, MAD, and preserves raw times. + * + * @param float[] $times Array of durations in milliseconds. + * @return array{ median: float, p95: float, sd: float, mad: float, times: float[] } + */ +function sync_perf_compute_stats( array $times ): array { + return array( + 'median' => sync_perf_median( $times ), + 'p95' => sync_perf_p95( $times ), + 'sd' => sync_perf_sd( $times ), + 'mad' => sync_perf_mad( $times ), + 'times' => $times, + ); +} + +/** + * Runs warm-up iterations, then measures $measured iterations of $callback. + * + * @param callable $callback Function to benchmark. + * @param int $measured Number of measured iterations. + * @param int $warmup Number of warm-up iterations (discarded). + * @param callable|null $setup Optional function called before each iteration (not timed). + * @return array{ median: float, p95: float, sd: float, mad: float, times: float[] } + */ +function sync_perf_stats( callable $callback, int $measured, int $warmup = 5, ?callable $setup = null ): array { + for ( $i = 0; $i < $warmup; $i++ ) { + if ( $setup ) { + $setup(); + } + $callback(); + } + + $times = array(); + for ( $i = 0; $i < $measured; $i++ ) { + if ( $setup ) { + $setup(); + } + $start = microtime( true ); + $callback(); + $end = microtime( true ); + $times[] = ( $end - $start ) * 1000; + } + + return sync_perf_compute_stats( $times ); +} + +/** + * Runs EXPLAIN on a SQL query and returns result rows. + * + * @param string $sql The query to explain. + * @return array EXPLAIN result rows. + */ +function sync_perf_explain( string $sql ): array { + global $wpdb; + return $wpdb->get_results( "EXPLAIN {$sql}", ARRAY_A ); +} + +/** + * Formats a millisecond value with unit suffix. + * + * @param float $value Duration in milliseconds. + * @return string Formatted value, e.g. "0.04 ms". + */ +function sync_perf_format_ms( float $value ): string { + return sprintf( '%.2f ms', $value ); +} + +/** + * Converts an EXPLAIN result set into a one-line prose summary. + * + * @param array $row Single EXPLAIN result row (associative array). + * @return string Prose summary. + */ +function sync_perf_explain_access( array $row ): string { + $extra = $row['Extra'] ?? $row['extra'] ?? ''; + $index = $row['key'] ?? $row['Key'] ?? null; + $access_type = $row['type'] ?? $row['Type'] ?? null; + $estimated = $row['rows'] ?? $row['Rows'] ?? null; + + if ( false !== stripos( $extra, 'Select tables optimized away' ) || null === $access_type ) { + return 'Optimized away (no table access)'; + } + + return sprintf( '%s (%s), ~%s rows', $index, $access_type, $estimated ); +} + +/** + * Seeds the wp_sync_updates table via bulk INSERT. + * + * @param int $total_rows Total rows to insert. + * @param int $rooms Number of rooms to distribute across. + */ +function sync_perf_seed_table( int $total_rows, int $rooms ): void { + global $wpdb; + + $wpdb->query( "TRUNCATE TABLE {$wpdb->sync_updates}" ); + + $rows_per_room = (int) ceil( $total_rows / $rooms ); + $batch_size = 500; + $now = gmdate( 'Y-m-d H:i:s' ); + $inserted = 0; + + for ( $r = 1; $r <= $rooms; $r++ ) { + $room = "postType/post:{$r}"; + $room_count = min( $rows_per_room, $total_rows - $inserted ); + + for ( $offset = 0; $offset < $room_count; $offset += $batch_size ) { + $chunk = min( $batch_size, $room_count - $offset ); + $values = array(); + + for ( $i = 0; $i < $chunk; $i++ ) { + $json = $wpdb->prepare( '%s', wp_json_encode( array( + 'client_id' => wp_generate_uuid4(), + 'type' => 'sync_step1', + 'data' => 'AQLsAxgC', + ) ) ); + $room_esc = $wpdb->prepare( '%s', $room ); + $now_esc = $wpdb->prepare( '%s', $now ); + $values[] = "({$room_esc}, {$json}, {$now_esc})"; + } + + $wpdb->query( + "INSERT INTO {$wpdb->sync_updates} (room, update_value, created_at) VALUES " . implode( ',', $values ) + ); + + $inserted += $chunk; + } + } +} + +/** + * Collects EXPLAIN analysis at a given scale and returns structured results. + * + * Runs ANALYZE TABLE first to ensure the optimizer has up-to-date statistics + * after bulk INSERT seeding. + * + * @param string $target_room Room to query against. + * @param int $scale Total row count. + * @param int $rooms Number of rooms. + * @return array[] EXPLAIN entries with label, sql, and access summary. + */ +function sync_perf_collect_explains( string $target_room, int $scale, int $rooms ): array { + global $wpdb; + + sync_perf_seed_table( $scale, $rooms ); + $wpdb->query( "ANALYZE TABLE {$wpdb->sync_updates}" ); + + $table_max_id = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->sync_updates} WHERE room = %s", + $target_room + ) ); + + $queries = array( + array( + 'label' => 'Idle poll (MAX cursor)', + 'sql' => "SELECT COALESCE(MAX(id), 0) FROM {$wpdb->sync_updates} WHERE room = %s", + 'args' => array( $target_room ), + ), + array( + 'label' => 'Idle poll (COUNT)', + 'sql' => "SELECT COUNT(*) FROM {$wpdb->sync_updates} WHERE room = %s AND id <= %d", + 'args' => array( $target_room, $table_max_id ), + ), + array( + 'label' => 'Catch-up poll (SELECT)', + 'sql' => "SELECT update_value FROM {$wpdb->sync_updates} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", + 'args' => array( $target_room, 0, $table_max_id ), + ), + array( + 'label' => 'Compaction (DELETE)', + 'sql' => "DELETE FROM {$wpdb->sync_updates} WHERE room = %s AND id < %d", + 'args' => array( $target_room, $table_max_id ), + ), + array( + 'label' => 'LIKE prefix scan', + 'sql' => "SELECT id, room FROM {$wpdb->sync_updates} WHERE room LIKE %s ORDER BY room, id ASC", + 'args' => array( 'postType/post:%' ), + ), + ); + + $explains = array(); + foreach ( $queries as $query ) { + $prepared = $wpdb->prepare( $query['sql'], ...$query['args'] ); + $rows = sync_perf_explain( $prepared ); + + $explains[] = array( + 'Query' => $query['label'], + 'Access' => ! empty( $rows ) ? sync_perf_explain_access( $rows[0] ) : 'No EXPLAIN output', + ); + } + + return $explains; +} + +/** + * Builds the result rows for a benchmark section as format_items-compatible arrays. + * + * @param array $op_results Results for this operation keyed by [$scale]. + * @param int[] $scales Scale values. + * @param int $rooms Rooms per scale. + * @return array[] Rows with 'Rows per room', 'Median', 'P95', 'STD', 'MAD' keys. + */ +function sync_perf_build_section_rows( array $op_results, array $scales, int $rooms ): array { + $rows = array(); + + foreach ( $scales as $scale ) { + $per_room = (int) ceil( $scale / $rooms ); + $stats = $op_results[ $scale ]; + + $rows[] = array( + 'Rows per room' => number_format( $per_room ), + 'Median' => sync_perf_format_ms( $stats['median'] ), + 'P95' => sync_perf_format_ms( $stats['p95'] ), + 'STD' => sync_perf_format_ms( $stats['sd'] ), + 'MAD' => sync_perf_format_ms( $stats['mad'] ), + ); + } + + return $rows; +} + +/** + * Prints all benchmark results using WP-CLI formatting. + * + * @param array $results Benchmark results keyed by operation/scale. + * @param array $explain_data Return value from sync_perf_collect_explains(). + * @param array $config Benchmark configuration. + * @param int[] $scales Scale values. + * @param string $format Output format (table, json, csv, yaml). + */ +function sync_perf_print_output( array $results, array $explain_data, array $config, array $scales, string $format ): void { + global $wp_version, $wpdb; + + $fields = array( 'Rows per room', 'Median', 'P95', 'STD', 'MAD' ); + + if ( 'table' === $format ) { + $separator = str_repeat( '─', 60 ); + + WP_CLI::log( '' ); + WP_CLI::log( WP_CLI::colorize( '%_Sync Storage Performance%n' ) ); + WP_CLI::log( sprintf( + 'WordPress %s, MySQL %s, PHP %s, Docker (local dev)', + $wp_version, + $wpdb->db_version(), + phpversion() + ) ); + WP_CLI::log( sprintf( + '%d measured iterations (%d warm-up discarded), fresh instance per iteration', + $config['measured_iterations'], + $config['warmup_iterations'] + ) ); + + $sections = array( + 'idle_poll' => array( + 'title' => 'Idle Poll', + 'desc' => 'Checks for new updates when none exist. Called every second per open editor tab.', + ), + 'catchup_poll' => array( + 'title' => 'Catch-up Poll', + 'desc' => 'Fetches all updates from cursor 0. Called when an editor opens or reconnects.', + ), + 'compaction' => array( + 'title' => 'Compaction', + 'desc' => sprintf( + 'Removes old updates. Deletes ~80%% of rows (%d measured iterations, re-seeded each).', + $config['compaction_iterations'] + ), + ), + ); + + foreach ( $sections as $op_key => $section ) { + WP_CLI::log( '' ); + WP_CLI::log( $separator ); + WP_CLI::log( WP_CLI::colorize( "%_{$section['title']}%n" ) ); + WP_CLI::log( $separator ); + WP_CLI::log( $section['desc'] ); + WP_CLI::log( '' ); + + $rows = sync_perf_build_section_rows( $results[ $op_key ], $scales, $config['rooms'] ); + WP_CLI\Utils\format_items( 'table', $rows, $fields ); + } + + WP_CLI::log( '' ); + WP_CLI::log( $separator ); + WP_CLI::log( WP_CLI::colorize( '%_MySQL EXPLAIN Analysis%n' ) ); + WP_CLI::log( $separator ); + WP_CLI::log( '' ); + + WP_CLI\Utils\format_items( 'table', $explain_data, array( 'Query', 'Access' ) ); + + WP_CLI::log( '' ); + WP_CLI::success( 'Benchmark complete.' ); + } else { + $json_data = array( + 'environment' => array( + 'wordpress' => $wp_version, + 'mysql' => $wpdb->db_version(), + 'php' => phpversion(), + ), + 'config' => array( + 'measured_iterations' => $config['measured_iterations'], + 'warmup_iterations' => $config['warmup_iterations'], + 'compaction_iterations' => $config['compaction_iterations'], + 'rooms' => $config['rooms'], + 'scales' => $scales, + ), + 'results' => array(), + 'explain' => $explain_data, + ); + + foreach ( array( 'idle_poll', 'catchup_poll', 'compaction' ) as $op ) { + foreach ( $scales as $scale ) { + $stats = $results[ $op ][ $scale ]; + $json_data['results'][ $op ][ $scale ] = array( + 'median' => $stats['median'], + 'p95' => $stats['p95'], + 'sd' => $stats['sd'], + 'mad' => $stats['mad'], + 'times' => $stats['times'], + ); + } + } + + WP_CLI\Utils\format_items( $format, array( $json_data ), array_keys( $json_data ) ); + } +} From a5b54b03211d249e86037d2abdb8057782e17ce8 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 10:45:22 -0500 Subject: [PATCH 30/82] Collaboration: Clean up sync benchmark output and remove dead code Switches to WP-CLI formatting (format_items, colorize, log) for consistent output. Removes unused JSON format support, $setup parameter, raw times array, and compaction_delete_ratio from config. --- tools/local-env/scripts/sync-perf/run.php | 14 +- tools/local-env/scripts/sync-perf/utils.php | 153 +++++++------------- 2 files changed, 55 insertions(+), 112 deletions(-) diff --git a/tools/local-env/scripts/sync-perf/run.php b/tools/local-env/scripts/sync-perf/run.php index d9c7e082a7f27..4430aeb78dbc3 100644 --- a/tools/local-env/scripts/sync-perf/run.php +++ b/tools/local-env/scripts/sync-perf/run.php @@ -7,7 +7,6 @@ * * Usage: * npm run test:performance:sync - * npm run test:performance:sync -- --format=json * * @package WordPress */ @@ -28,21 +27,10 @@ $compaction_delete_ratio = 0.8; $target_room = 'postType/post:1'; -// Parse --format flag from WP-CLI args. -$format = 'table'; -if ( ! empty( $args ) ) { - foreach ( $args as $arg ) { - if ( 0 === strpos( $arg, '--format=' ) ) { - $format = substr( $arg, 9 ); - } - } -} - $config = array( 'measured_iterations' => $measured_iterations, 'warmup_iterations' => $warmup_iterations, 'compaction_iterations' => $compaction_iterations, - 'compaction_delete_ratio' => $compaction_delete_ratio, 'rooms' => $rooms_per_scale, ); @@ -143,4 +131,4 @@ function () use ( $target_room ) { // Output // ============================================================ -sync_perf_print_output( $results, $explain_data, $config, $scales, $format ); +sync_perf_print_output( $results, $explain_data, $config, $scales ); diff --git a/tools/local-env/scripts/sync-perf/utils.php b/tools/local-env/scripts/sync-perf/utils.php index 24b181d3038e2..8460abcb79b24 100644 --- a/tools/local-env/scripts/sync-perf/utils.php +++ b/tools/local-env/scripts/sync-perf/utils.php @@ -64,10 +64,10 @@ function sync_perf_p95( array $arr ): float { } /** - * Computes median, P95, standard deviation, MAD, and preserves raw times. + * Computes median, P95, standard deviation, and MAD. * * @param float[] $times Array of durations in milliseconds. - * @return array{ median: float, p95: float, sd: float, mad: float, times: float[] } + * @return array{ median: float, p95: float, sd: float, mad: float } */ function sync_perf_compute_stats( array $times ): array { return array( @@ -75,36 +75,27 @@ function sync_perf_compute_stats( array $times ): array { 'p95' => sync_perf_p95( $times ), 'sd' => sync_perf_sd( $times ), 'mad' => sync_perf_mad( $times ), - 'times' => $times, ); } /** * Runs warm-up iterations, then measures $measured iterations of $callback. * - * @param callable $callback Function to benchmark. - * @param int $measured Number of measured iterations. - * @param int $warmup Number of warm-up iterations (discarded). - * @param callable|null $setup Optional function called before each iteration (not timed). - * @return array{ median: float, p95: float, sd: float, mad: float, times: float[] } + * @param callable $callback Function to benchmark. + * @param int $measured Number of measured iterations. + * @param int $warmup Number of warm-up iterations (discarded). + * @return array{ median: float, p95: float, sd: float, mad: float } */ -function sync_perf_stats( callable $callback, int $measured, int $warmup = 5, ?callable $setup = null ): array { +function sync_perf_stats( callable $callback, int $measured, int $warmup = 5 ): array { for ( $i = 0; $i < $warmup; $i++ ) { - if ( $setup ) { - $setup(); - } $callback(); } $times = array(); for ( $i = 0; $i < $measured; $i++ ) { - if ( $setup ) { - $setup(); - } $start = microtime( true ); $callback(); - $end = microtime( true ); - $times[] = ( $end - $start ) * 1000; + $times[] = ( microtime( true ) - $start ) * 1000; } return sync_perf_compute_stats( $times ); @@ -292,101 +283,65 @@ function sync_perf_build_section_rows( array $op_results, array $scales, int $ro * @param array $explain_data Return value from sync_perf_collect_explains(). * @param array $config Benchmark configuration. * @param int[] $scales Scale values. - * @param string $format Output format (table, json, csv, yaml). */ -function sync_perf_print_output( array $results, array $explain_data, array $config, array $scales, string $format ): void { +function sync_perf_print_output( array $results, array $explain_data, array $config, array $scales ): void { global $wp_version, $wpdb; - $fields = array( 'Rows per room', 'Median', 'P95', 'STD', 'MAD' ); + $fields = array( 'Rows per room', 'Median', 'P95', 'STD', 'MAD' ); + $separator = str_repeat( '─', 60 ); - if ( 'table' === $format ) { - $separator = str_repeat( '─', 60 ); + WP_CLI::log( '' ); + WP_CLI::log( WP_CLI::colorize( '%_Sync Storage Performance%n' ) ); + WP_CLI::log( sprintf( + 'WordPress %s, MySQL %s, PHP %s, Docker (local dev)', + $wp_version, + $wpdb->db_version(), + phpversion() + ) ); + WP_CLI::log( sprintf( + '%d measured iterations (%d warm-up discarded), fresh instance per iteration', + $config['measured_iterations'], + $config['warmup_iterations'] + ) ); - WP_CLI::log( '' ); - WP_CLI::log( WP_CLI::colorize( '%_Sync Storage Performance%n' ) ); - WP_CLI::log( sprintf( - 'WordPress %s, MySQL %s, PHP %s, Docker (local dev)', - $wp_version, - $wpdb->db_version(), - phpversion() - ) ); - WP_CLI::log( sprintf( - '%d measured iterations (%d warm-up discarded), fresh instance per iteration', - $config['measured_iterations'], - $config['warmup_iterations'] - ) ); - - $sections = array( - 'idle_poll' => array( - 'title' => 'Idle Poll', - 'desc' => 'Checks for new updates when none exist. Called every second per open editor tab.', - ), - 'catchup_poll' => array( - 'title' => 'Catch-up Poll', - 'desc' => 'Fetches all updates from cursor 0. Called when an editor opens or reconnects.', - ), - 'compaction' => array( - 'title' => 'Compaction', - 'desc' => sprintf( - 'Removes old updates. Deletes ~80%% of rows (%d measured iterations, re-seeded each).', - $config['compaction_iterations'] - ), + $sections = array( + 'idle_poll' => array( + 'title' => 'Idle Poll', + 'desc' => 'Checks for new updates when none exist. Called every second per open editor tab.', + ), + 'catchup_poll' => array( + 'title' => 'Catch-up Poll', + 'desc' => 'Fetches all updates from cursor 0. Called when an editor opens or reconnects.', + ), + 'compaction' => array( + 'title' => 'Compaction', + 'desc' => sprintf( + 'Removes old updates. Deletes ~80%% of rows (%d measured iterations, re-seeded each).', + $config['compaction_iterations'] ), - ); - - foreach ( $sections as $op_key => $section ) { - WP_CLI::log( '' ); - WP_CLI::log( $separator ); - WP_CLI::log( WP_CLI::colorize( "%_{$section['title']}%n" ) ); - WP_CLI::log( $separator ); - WP_CLI::log( $section['desc'] ); - WP_CLI::log( '' ); - - $rows = sync_perf_build_section_rows( $results[ $op_key ], $scales, $config['rooms'] ); - WP_CLI\Utils\format_items( 'table', $rows, $fields ); - } + ), + ); + foreach ( $sections as $op_key => $section ) { WP_CLI::log( '' ); WP_CLI::log( $separator ); - WP_CLI::log( WP_CLI::colorize( '%_MySQL EXPLAIN Analysis%n' ) ); + WP_CLI::log( WP_CLI::colorize( "%_{$section['title']}%n" ) ); WP_CLI::log( $separator ); + WP_CLI::log( $section['desc'] ); WP_CLI::log( '' ); - WP_CLI\Utils\format_items( 'table', $explain_data, array( 'Query', 'Access' ) ); + $rows = sync_perf_build_section_rows( $results[ $op_key ], $scales, $config['rooms'] ); + WP_CLI\Utils\format_items( 'table', $rows, $fields ); + } - WP_CLI::log( '' ); - WP_CLI::success( 'Benchmark complete.' ); - } else { - $json_data = array( - 'environment' => array( - 'wordpress' => $wp_version, - 'mysql' => $wpdb->db_version(), - 'php' => phpversion(), - ), - 'config' => array( - 'measured_iterations' => $config['measured_iterations'], - 'warmup_iterations' => $config['warmup_iterations'], - 'compaction_iterations' => $config['compaction_iterations'], - 'rooms' => $config['rooms'], - 'scales' => $scales, - ), - 'results' => array(), - 'explain' => $explain_data, - ); + WP_CLI::log( '' ); + WP_CLI::log( $separator ); + WP_CLI::log( WP_CLI::colorize( '%_MySQL EXPLAIN Analysis%n' ) ); + WP_CLI::log( $separator ); + WP_CLI::log( '' ); - foreach ( array( 'idle_poll', 'catchup_poll', 'compaction' ) as $op ) { - foreach ( $scales as $scale ) { - $stats = $results[ $op ][ $scale ]; - $json_data['results'][ $op ][ $scale ] = array( - 'median' => $stats['median'], - 'p95' => $stats['p95'], - 'sd' => $stats['sd'], - 'mad' => $stats['mad'], - 'times' => $stats['times'], - ); - } - } + WP_CLI\Utils\format_items( 'table', $explain_data, array( 'Query', 'Access' ) ); - WP_CLI\Utils\format_items( $format, array( $json_data ), array_keys( $json_data ) ); - } + WP_CLI::log( '' ); + WP_CLI::success( 'Benchmark complete.' ); } From 8560568bf66ec26c6f313a4b04919f6023a4fc20 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 11:49:43 -0500 Subject: [PATCH 31/82] Collaboration: Rename database table and storage layer from sync to collaboration --- src/wp-admin/includes/schema.php | 2 +- src/wp-includes/class-wpdb.php | 6 ++-- ... class-wp-collaboration-table-storage.php} | 28 +++++++++---------- ...=> interface-wp-collaboration-storage.php} | 14 +++++----- src/wp-settings.php | 6 ++-- 5 files changed, 28 insertions(+), 28 deletions(-) rename src/wp-includes/collaboration/{class-wp-sync-table-storage.php => class-wp-collaboration-table-storage.php} (88%) rename src/wp-includes/collaboration/{interface-wp-sync-storage.php => interface-wp-collaboration-storage.php} (84%) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index a324598d6a4bb..b03e5079e0cd6 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -187,7 +187,7 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { KEY post_author (post_author), KEY type_status_author (post_type,post_status,post_author) ) $charset_collate; -CREATE TABLE $wpdb->sync_updates ( +CREATE TABLE $wpdb->collaboration ( id bigint(20) unsigned NOT NULL auto_increment, room varchar(255) NOT NULL, update_value longtext NOT NULL, diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index b66be95487181..f4da31dc57b39 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -299,7 +299,7 @@ class wpdb { 'term_relationships', 'termmeta', 'commentmeta', - 'sync_updates', + 'collaboration', ); /** @@ -406,13 +406,13 @@ class wpdb { public $posts; /** - * WordPress Sync Updates table. + * WordPress Collaboration table. * * @since 7.0.0 * * @var string */ - public $sync_updates; + public $collaboration; /** * WordPress Terms table. diff --git a/src/wp-includes/collaboration/class-wp-sync-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php similarity index 88% rename from src/wp-includes/collaboration/class-wp-sync-table-storage.php rename to src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 1dc924f44d258..86a65153e9158 100644 --- a/src/wp-includes/collaboration/class-wp-sync-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -1,22 +1,22 @@ insert( - $wpdb->sync_updates, + $wpdb->collaboration, array( 'room' => $room, 'update_value' => wp_json_encode( $update ), @@ -64,7 +64,7 @@ public function add_update( string $room, $update ): bool { * Gets awareness state for a given room. * * Awareness is ephemeral and stored as a transient rather than - * in the sync_updates table. + * in the collaboration table. * * @since 7.0.0 * @@ -109,7 +109,7 @@ public function get_update_count( string $room ): int { } /** - * Retrieves sync updates from a room after a given cursor. + * Retrieves updates from a room after a given cursor. * * Uses a snapshot approach: captures MAX(id) first, then fetches rows * WHERE id > cursor AND id <= max_id. Updates arriving after the snapshot @@ -121,7 +121,7 @@ public function get_update_count( string $room ): int { * * @param string $room Room identifier. * @param int $cursor Return updates after this cursor. - * @return array Sync updates. + * @return array Updates. */ public function get_updates_after_cursor( string $room, int $cursor ): array { global $wpdb; @@ -129,7 +129,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Snapshot the current max ID for this room to define a stable upper bound. $max_id = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->sync_updates} WHERE room = %s", + "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->collaboration} WHERE room = %s", $room ) ); @@ -145,7 +145,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Bounded by max_id to stay consistent with the snapshot window above. $this->room_update_counts[ $room ] = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->sync_updates} WHERE room = %s AND id <= %d", + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND id <= %d", $room, $max_id ) @@ -154,7 +154,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Fetch updates after the cursor up to the snapshot boundary. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT update_value FROM {$wpdb->sync_updates} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", + "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", $room, $cursor, $max_id @@ -195,7 +195,7 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool $result = $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->sync_updates} WHERE room = %s AND id < %d", + "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND id < %d", $room, $cursor ) diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-collaboration-storage.php similarity index 84% rename from src/wp-includes/collaboration/interface-wp-sync-storage.php rename to src/wp-includes/collaboration/interface-wp-collaboration-storage.php index 62dbf170c3edd..dd7c28fb78155 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-collaboration-storage.php @@ -1,24 +1,24 @@ Sync updates. + * @return array Updates. */ public function get_updates_after_cursor( string $room, int $cursor ): array; diff --git a/src/wp-settings.php b/src/wp-settings.php index f4db57392965c..b43927aa20ad8 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -309,9 +309,9 @@ require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; require ABSPATH . WPINC . '/abilities-api.php'; require ABSPATH . WPINC . '/abilities.php'; -require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php'; -require ABSPATH . WPINC . '/collaboration/class-wp-sync-table-storage.php'; -require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php'; +require ABSPATH . WPINC . '/collaboration/interface-wp-collaboration-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-collaboration-table-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-collaboration-server.php'; require ABSPATH . WPINC . '/collaboration.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; From c287bf198022bf0a21cb36388959afc6751a8033 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 11:49:52 -0500 Subject: [PATCH 32/82] Collaboration: Rename server class and add deprecated wp-sync/v1 route alias --- ...-wp-http-polling-collaboration-server.php} | 87 ++++++++++++++----- src/wp-includes/rest-api.php | 6 +- 2 files changed, 68 insertions(+), 25 deletions(-) rename src/wp-includes/collaboration/{class-wp-http-polling-sync-server.php => class-wp-http-polling-collaboration-server.php} (87%) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php similarity index 87% rename from src/wp-includes/collaboration/class-wp-http-polling-sync-server.php rename to src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 4cd56bae9d201..2431e19422c78 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -1,6 +1,6 @@ storage = $storage; } @@ -135,7 +143,7 @@ public function register_routes(): void { 'required' => true, 'type' => 'string', 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', - 'maxLength' => 255, // The size of the wp_sync_updates.room column. + 'maxLength' => 255, // The size of the wp_collaboration.room column. ), 'updates' => array( 'items' => $typed_update_args, @@ -145,23 +153,38 @@ public function register_routes(): void { ), ); + $route_args = array( + 'methods' => array( WP_REST_Server::CREATABLE ), + 'callback' => array( $this, 'handle_request' ), + 'permission_callback' => array( $this, 'check_permissions' ), + 'args' => array( + 'rooms' => array( + 'items' => array( + 'properties' => $room_args, + 'type' => 'object', + ), + 'required' => true, + 'type' => 'array', + ), + ), + ); + + // Primary route. register_rest_route( self::REST_NAMESPACE, '/updates', + $route_args + ); + + // Deprecated alias for backward compatibility. + register_rest_route( + self::DEPRECATED_REST_NAMESPACE, + '/updates', array( 'methods' => array( WP_REST_Server::CREATABLE ), - 'callback' => array( $this, 'handle_request' ), + 'callback' => array( $this, 'handle_deprecated_request' ), 'permission_callback' => array( $this, 'check_permissions' ), - 'args' => array( - 'rooms' => array( - 'items' => array( - 'properties' => $room_args, - 'type' => 'object', - ), - 'required' => true, - 'type' => 'array', - ), - ), + 'args' => $route_args['args'], ) ); } @@ -227,7 +250,7 @@ public function check_permissions( WP_REST_Request $request ) { } /** - * Handles request: stores sync updates and awareness data, and returns + * Handles request: stores updates and awareness data, and returns * updates the client is missing. * * @since 7.0.0 @@ -274,6 +297,26 @@ public function handle_request( WP_REST_Request $request ) { return new WP_REST_Response( $response, 200 ); } + /** + * Handles a request to the deprecated wp-sync/v1 route. + * + * Delegates to handle_request() and adds a deprecation header. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request The REST request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function handle_deprecated_request( WP_REST_Request $request ) { + $result = $this->handle_request( $request ); + + if ( $result instanceof WP_REST_Response ) { + $result->header( 'X-WP-Deprecated', 'wp-sync/v1 is deprecated, use wp-collaboration/v1' ); + } + + return $result; + } + /** * Checks if the current user can sync a specific entity type. * @@ -483,7 +526,7 @@ private function add_update( string $room, int $client_id, string $type, string } /** - * Gets sync updates for a specific client from a room after a given cursor. + * Gets updates for a specific client from a room after a given cursor. * * Delegates cursor-based retrieval to the storage layer, then applies * client-specific filtering and compaction logic. diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 8322926791404..a1e7fd2cea7de 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -431,9 +431,9 @@ function create_initial_rest_routes() { // Collaboration. if ( wp_is_collaboration_enabled() ) { - $sync_storage = new WP_Sync_Table_Storage(); - $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); - $sync_server->register_routes(); + $collaboration_storage = new WP_Collaboration_Table_Storage(); + $collaboration_server = new WP_HTTP_Polling_Collaboration_Server( $collaboration_storage ); + $collaboration_server->register_routes(); } } From 44caa1fc3e27eeafdbf09e983712920c9ff50580 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 11:50:02 -0500 Subject: [PATCH 33/82] Collaboration: Rename cron cleanup function and hooks --- src/wp-admin/admin.php | 6 +++--- src/wp-includes/collaboration.php | 6 +++--- src/wp-includes/default-filters.php | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-admin/admin.php b/src/wp-admin/admin.php index aa58ee42911b7..0ec3c80516094 100644 --- a/src/wp-admin/admin.php +++ b/src/wp-admin/admin.php @@ -113,12 +113,12 @@ wp_schedule_event( time(), 'daily', 'delete_expired_transients' ); } -// Schedule sync updates cleanup. +// Schedule collaboration data cleanup. if ( wp_is_collaboration_enabled() - && ! wp_next_scheduled( 'wp_delete_old_sync_updates' ) + && ! wp_next_scheduled( 'wp_delete_old_collaboration_data' ) && ! wp_installing() ) { - wp_schedule_event( time(), 'daily', 'wp_delete_old_sync_updates' ); + wp_schedule_event( time(), 'daily', 'wp_delete_old_collaboration_data' ); } set_screen_options(); diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 6d2415ccde925..be4f95117fcbc 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -39,14 +39,14 @@ function wp_collaboration_inject_setting() { } /** - * Deletes sync updates older than 7 days from the wp_sync_updates table. + * Deletes collaboration data older than 7 days from the collaboration table. * * Rows left behind by abandoned collaborative editing sessions are cleaned up * to prevent unbounded table growth. * * @since 7.0.0 */ -function wp_delete_old_sync_updates() { +function wp_delete_old_collaboration_data() { if ( ! wp_is_collaboration_enabled() ) { return; } @@ -55,7 +55,7 @@ function wp_delete_old_sync_updates() { $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->sync_updates} WHERE created_at < %s", + "DELETE FROM {$wpdb->collaboration} WHERE created_at < %s", gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) ) ); diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index b70a4f514dfcd..27090d35eff7b 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -454,7 +454,7 @@ add_action( 'importer_scheduled_cleanup', 'wp_delete_attachment' ); add_action( 'upgrader_scheduled_cleanup', 'wp_delete_attachment' ); add_action( 'delete_expired_transients', 'delete_expired_transients' ); -add_action( 'wp_delete_old_sync_updates', 'wp_delete_old_sync_updates' ); +add_action( 'wp_delete_old_collaboration_data', 'wp_delete_old_collaboration_data' ); // Navigation menu actions. add_action( 'delete_post', '_wp_delete_post_menu_item' ); From 62ed487ccaf830c97ec397d066a0cce42755745a Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 11:50:11 -0500 Subject: [PATCH 34/82] Collaboration: Rename tests and add deprecated route coverage --- ...rver.php => rest-collaboration-server.php} | 146 ++++++++++++------ 1 file changed, 101 insertions(+), 45 deletions(-) rename tests/phpunit/tests/rest-api/{rest-sync-server.php => rest-collaboration-server.php} (87%) diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php similarity index 87% rename from tests/phpunit/tests/rest-api/rest-sync-server.php rename to tests/phpunit/tests/rest-api/rest-collaboration-server.php index 59eb87cef6bd8..324b1ce5f7ff8 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1,13 +1,13 @@ query( "DELETE FROM {$wpdb->sync_updates}" ); + $wpdb->query( "DELETE FROM {$wpdb->collaboration}" ); } /** - * Builds a room request array for the sync endpoint. + * Builds a room request array for the collaboration endpoint. * * @param string $room Room identifier. * @param int $client_id Client ID. @@ -59,13 +59,14 @@ private function build_room( $room, $client_id = 1, $cursor = 0, $awareness = ar } /** - * Dispatches a sync request with the given rooms. + * Dispatches a collaboration request with the given rooms. * - * @param array $rooms Array of room request data. + * @param array $rooms Array of room request data. + * @param string $namespace REST namespace to use. Defaults to the primary namespace. * @return WP_REST_Response Response object. */ - private function dispatch_sync( $rooms ) { - $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + private function dispatch_sync( $rooms, $namespace = 'wp-collaboration/v1' ) { + $request = new WP_REST_Request( 'POST', '/' . $namespace . '/updates' ); $request->set_body_params( array( 'rooms' => $rooms ) ); return rest_get_server()->dispatch( $request ); } @@ -82,34 +83,35 @@ private function get_post_room() { /* * Required abstract method implementations. * - * The sync endpoint is a single POST endpoint, not a standard CRUD controller. + * The collaboration endpoint is a single POST endpoint, not a standard CRUD controller. * Methods that don't apply are stubbed with @doesNotPerformAssertions. */ public function test_register_routes() { $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( '/wp-sync/v1/updates', $routes ); + $this->assertArrayHasKey( '/wp-collaboration/v1/updates', $routes ); + $this->assertArrayHasKey( '/wp-sync/v1/updates', $routes, 'Deprecated wp-sync/v1 route should still be registered.' ); } /** * @doesNotPerformAssertions */ public function test_context_param() { - // Not applicable for sync endpoint. + // Not applicable for collaboration endpoint. } /** * @doesNotPerformAssertions */ public function test_get_items() { - // Not applicable for sync endpoint. + // Not applicable for collaboration endpoint. } /** * @doesNotPerformAssertions */ public function test_get_item() { - // Not applicable for sync endpoint. + // Not applicable for collaboration endpoint. } public function test_create_item() { @@ -124,28 +126,28 @@ public function test_create_item() { * @doesNotPerformAssertions */ public function test_update_item() { - // Not applicable for sync endpoint. + // Not applicable for collaboration endpoint. } /** * @doesNotPerformAssertions */ public function test_delete_item() { - // Not applicable for sync endpoint. + // Not applicable for collaboration endpoint. } /** * @doesNotPerformAssertions */ public function test_prepare_item() { - // Not applicable for sync endpoint. + // Not applicable for collaboration endpoint. } /** * @doesNotPerformAssertions */ public function test_get_item_schema() { - // Not applicable for sync endpoint. + // Not applicable for collaboration endpoint. } /* @@ -1086,16 +1088,16 @@ public function test_sync_compaction_reduces_total_updates(): void { */ /** - * Inserts a row directly into the sync_updates table with a given age. + * Inserts a row directly into the collaboration table with a given age. * * @param positive-int $age_in_seconds How old the row should be. * @param string $label A label stored in the update_value for identification. */ - private function insert_sync_row( int $age_in_seconds, string $label = 'test' ): void { + private function insert_collaboration_row( int $age_in_seconds, string $label = 'test' ): void { global $wpdb; $wpdb->insert( - $wpdb->sync_updates, + $wpdb->collaboration, array( 'room' => $this->get_post_room(), 'update_value' => wp_json_encode( @@ -1111,51 +1113,51 @@ private function insert_sync_row( int $age_in_seconds, string $label = 'test' ): } /** - * Returns the number of rows in the sync_updates table. + * Returns the number of rows in the collaboration table. * * @return positive-int Row count. */ - private function get_sync_row_count(): int { + private function get_collaboration_row_count(): int { global $wpdb; - return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->sync_updates}" ); + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->collaboration}" ); } /** * @ticket 64696 */ public function test_cron_cleanup_deletes_old_rows(): void { - $this->insert_sync_row( 8 * DAY_IN_SECONDS ); + $this->insert_collaboration_row( 8 * DAY_IN_SECONDS ); - $this->assertSame( 1, $this->get_sync_row_count() ); + $this->assertSame( 1, $this->get_collaboration_row_count() ); - wp_delete_old_sync_updates(); + wp_delete_old_collaboration_data(); - $this->assertSame( 0, $this->get_sync_row_count() ); + $this->assertSame( 0, $this->get_collaboration_row_count() ); } /** * @ticket 64696 */ public function test_cron_cleanup_preserves_recent_rows(): void { - $this->insert_sync_row( DAY_IN_SECONDS ); + $this->insert_collaboration_row( DAY_IN_SECONDS ); - wp_delete_old_sync_updates(); + wp_delete_old_collaboration_data(); - $this->assertSame( 1, $this->get_sync_row_count() ); + $this->assertSame( 1, $this->get_collaboration_row_count() ); } /** * @ticket 64696 */ public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { - $this->insert_sync_row( WEEK_IN_SECONDS + 1, 'expired' ); - $this->insert_sync_row( WEEK_IN_SECONDS - 1, 'just-inside' ); + $this->insert_collaboration_row( WEEK_IN_SECONDS + 1, 'expired' ); + $this->insert_collaboration_row( WEEK_IN_SECONDS - 1, 'just-inside' ); - wp_delete_old_sync_updates(); + wp_delete_old_collaboration_data(); global $wpdb; - $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->sync_updates}" ); + $remaining = $wpdb->get_col( "SELECT update_value FROM {$wpdb->collaboration}" ); $this->assertCount( 1, $remaining, 'Only the row within the 7-day window should remain.' ); $this->assertStringContainsString( 'just-inside', $remaining[0], 'The surviving row should be the one inside the window.' ); @@ -1166,19 +1168,19 @@ public function test_cron_cleanup_boundary_at_exactly_seven_days(): void { */ public function test_cron_cleanup_selectively_deletes_mixed_rows(): void { // 3 expired rows. - $this->insert_sync_row( 10 * DAY_IN_SECONDS ); - $this->insert_sync_row( 10 * DAY_IN_SECONDS ); - $this->insert_sync_row( 10 * DAY_IN_SECONDS ); + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); + $this->insert_collaboration_row( 10 * DAY_IN_SECONDS ); // 2 recent rows. - $this->insert_sync_row( HOUR_IN_SECONDS ); - $this->insert_sync_row( HOUR_IN_SECONDS ); + $this->insert_collaboration_row( HOUR_IN_SECONDS ); + $this->insert_collaboration_row( HOUR_IN_SECONDS ); - $this->assertSame( 5, $this->get_sync_row_count() ); + $this->assertSame( 5, $this->get_collaboration_row_count() ); - wp_delete_old_sync_updates(); + wp_delete_old_collaboration_data(); - $this->assertSame( 2, $this->get_sync_row_count(), 'Only the 2 recent rows should survive cleanup.' ); + $this->assertSame( 2, $this->get_collaboration_row_count(), 'Only the 2 recent rows should survive cleanup.' ); } /** @@ -1187,8 +1189,8 @@ public function test_cron_cleanup_selectively_deletes_mixed_rows(): void { public function test_cron_cleanup_hook_is_registered(): void { $this->assertSame( 10, - has_action( 'wp_delete_old_sync_updates', 'wp_delete_old_sync_updates' ), - 'The wp_delete_old_sync_updates action should be hooked in default-filters.php.' + has_action( 'wp_delete_old_collaboration_data', 'wp_delete_old_collaboration_data' ), + 'The wp_delete_old_collaboration_data action should be hooked in default-filters.php.' ); } @@ -1208,9 +1210,63 @@ public function test_sync_routes_not_registered_when_db_version_is_old(): void { $server = rest_get_server(); $routes = $server->get_routes(); - $this->assertArrayNotHasKey( '/wp-sync/v1/updates', $routes, 'Sync routes should not be registered when db_version is below 61698.' ); + $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes, 'Collaboration routes should not be registered when db_version is below 61698.' ); + $this->assertArrayNotHasKey( '/wp-sync/v1/updates', $routes, 'Deprecated sync routes should not be registered when db_version is below 61698.' ); // Reset again so subsequent tests get a server with the correct db_version. $GLOBALS['wp_rest_server'] = null; } + + /* + * Deprecated route tests. + */ + + /** + * @ticket 64696 + */ + public function test_deprecated_sync_route_returns_deprecation_header(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( + array( $this->build_room( $this->get_post_room() ) ), + 'wp-sync/v1' + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + 'wp-sync/v1 is deprecated, use wp-collaboration/v1', + $response->get_headers()['X-WP-Deprecated'] + ); + } + + /** + * @ticket 64696 + */ + public function test_deprecated_sync_route_functions_correctly(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Send update via deprecated route. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), + ), + 'wp-sync/v1' + ); + + // Retrieve via primary route. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0 ), + ) + ); + + $data = $response->get_data(); + $this->assertNotEmpty( $data['rooms'][0]['updates'], 'Updates sent via deprecated route should be retrievable via primary route.' ); + } } From f5eb9710d81967d34cdea7d8b5f7d2dccb7dafec Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 11:50:20 -0500 Subject: [PATCH 35/82] Collaboration: Rename benchmark scripts and directory --- package.json | 3 +-- .../DO_NOT_RELEASE_prove-data-loss.php} | 18 ++++++++--------- .../{sync-perf => collaboration-perf}/run.php | 20 +++++++++---------- .../utils.php | 20 +++++++++---------- 4 files changed, 30 insertions(+), 31 deletions(-) rename tools/local-env/scripts/{sync-perf/prove-data-loss.php => collaboration-perf/DO_NOT_RELEASE_prove-data-loss.php} (93%) rename tools/local-env/scripts/{sync-perf => collaboration-perf}/run.php (87%) rename tools/local-env/scripts/{sync-perf => collaboration-perf}/utils.php (92%) diff --git a/package.json b/package.json index c2cc568d76e73..b9c78ddc0f313 100644 --- a/package.json +++ b/package.json @@ -132,8 +132,7 @@ "env:logs": "node ./tools/local-env/scripts/docker.js logs", "env:pull": "node ./tools/local-env/scripts/docker.js pull", "test:performance": "wp-scripts test-playwright --config tests/performance/playwright.config.js", - "test:performance:sync": "npm run env:cli -- eval-file tools/local-env/scripts/sync-perf/run.php", - "test:performance:sync:prove": "npm run env:cli -- eval-file tools/local-env/scripts/sync-perf/prove-data-loss.php", + "test:performance:collaboration": "npm run env:cli -- eval-file tools/local-env/scripts/collaboration-perf/run.php", "test:php": "node ./tools/local-env/scripts/docker.js run --rm php ./vendor/bin/phpunit", "test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt", "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", diff --git a/tools/local-env/scripts/sync-perf/prove-data-loss.php b/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-data-loss.php similarity index 93% rename from tools/local-env/scripts/sync-perf/prove-data-loss.php rename to tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-data-loss.php index f10f321df95dc..b469a04517418 100644 --- a/tools/local-env/scripts/sync-perf/prove-data-loss.php +++ b/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-data-loss.php @@ -19,7 +19,7 @@ * should still exist. * * Usage: - * npm run test:performance:sync:prove + * npm run env:cli -- eval-file tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-data-loss.php * * @package WordPress */ @@ -36,9 +36,9 @@ // ===================================================================== // Table backend. -$wpdb->query( "TRUNCATE TABLE {$wpdb->sync_updates}" ); +$wpdb->query( "TRUNCATE TABLE {$wpdb->collaboration}" ); for ( $i = 0; $i < $total; $i++ ) { - $s = new WP_Sync_Table_Storage(); + $s = new WP_Collaboration_Table_Storage(); $s->add_update( $room, array( 'edit' => $i ) ); } @@ -62,9 +62,9 @@ // Table compaction — the correct behavior. // // This is the exact code path from: -// WP_Sync_Table_Storage::remove_updates_before_cursor() +// WP_Collaboration_Table_Storage::remove_updates_before_cursor() // -// DELETE FROM wp_sync_updates WHERE room = %s AND id < %d +// DELETE FROM wp_collaboration WHERE room = %s AND id < %d // // One query. Only the 40 oldest rows are removed. The 10 newest rows // are never deleted, never absent, always readable. @@ -73,15 +73,15 @@ // Cursor that keeps the $keep newest rows. $cursor = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT id FROM {$wpdb->sync_updates} WHERE room = %s ORDER BY id DESC LIMIT 1 OFFSET %d", + "SELECT id FROM {$wpdb->collaboration} WHERE room = %s ORDER BY id DESC LIMIT 1 OFFSET %d", $room, $keep - 1 ) ); -$table = new WP_Sync_Table_Storage(); +$table = new WP_Collaboration_Table_Storage(); $table->remove_updates_before_cursor( $room, $cursor ); // *** Read immediately after compaction. *** -$reader = new WP_Sync_Table_Storage(); +$reader = new WP_Collaboration_Table_Storage(); $table_visible = $reader->get_updates_after_cursor( $room, 0 ); $table_count = count( $table_visible ); @@ -231,4 +231,4 @@ // Cleanup. wp_delete_post( $post_id, true ); -$wpdb->query( "TRUNCATE TABLE {$wpdb->sync_updates}" ); +$wpdb->query( "TRUNCATE TABLE {$wpdb->collaboration}" ); diff --git a/tools/local-env/scripts/sync-perf/run.php b/tools/local-env/scripts/collaboration-perf/run.php similarity index 87% rename from tools/local-env/scripts/sync-perf/run.php rename to tools/local-env/scripts/collaboration-perf/run.php index 4430aeb78dbc3..0b45780de7f5b 100644 --- a/tools/local-env/scripts/sync-perf/run.php +++ b/tools/local-env/scripts/collaboration-perf/run.php @@ -1,12 +1,12 @@ prefix . 'sync_updates'; +$table_name = $wpdb->prefix . 'collaboration'; $has_table = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ); if ( ! $has_table ) { @@ -53,7 +53,7 @@ WP_CLI::log( '' ); WP_CLI::log( WP_CLI::colorize( '%_Sync Storage Performance Benchmark%n' ) ); -WP_CLI::log( "Backend: WP_Sync_Table_Storage" ); +WP_CLI::log( "Backend: WP_Collaboration_Table_Storage" ); WP_CLI::log( "Iterations: {$measured_iterations} measured + {$warmup_iterations} warm-up" ); WP_CLI::log( "Compaction: {$compaction_iterations} measured (re-seed each)" ); WP_CLI::log( '' ); @@ -66,7 +66,7 @@ WP_CLI::log( ' Seeding table...' ); sync_perf_seed_table( $scale, $rooms_per_scale ); - $primer = new WP_Sync_Table_Storage(); + $primer = new WP_Collaboration_Table_Storage(); $primer->get_updates_after_cursor( $target_room, 0 ); $table_idle_cursor = $primer->get_cursor( $target_room ); @@ -74,7 +74,7 @@ WP_CLI::log( ' Idle poll...' ); $results['idle_poll'][ $scale ] = sync_perf_stats( function () use ( $target_room, $table_idle_cursor ) { - $s = new WP_Sync_Table_Storage(); + $s = new WP_Collaboration_Table_Storage(); $s->get_updates_after_cursor( $target_room, $table_idle_cursor ); }, $measured_iterations, @@ -85,7 +85,7 @@ function () use ( $target_room, $table_idle_cursor ) { WP_CLI::log( ' Catch-up poll...' ); $results['catchup_poll'][ $scale ] = sync_perf_stats( function () use ( $target_room ) { - $s = new WP_Sync_Table_Storage(); + $s = new WP_Collaboration_Table_Storage(); $s->get_updates_after_cursor( $target_room, 0 ); }, $measured_iterations, @@ -100,12 +100,12 @@ function () use ( $target_room ) { sync_perf_seed_table( $scale, $rooms_per_scale ); $compaction_cursor_id = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT id FROM {$wpdb->sync_updates} WHERE room = %s ORDER BY id ASC LIMIT 1 OFFSET %d", + "SELECT id FROM {$wpdb->collaboration} WHERE room = %s ORDER BY id ASC LIMIT 1 OFFSET %d", $target_room, max( 0, (int) floor( $per_room * $compaction_delete_ratio ) ) ) ); - $s = new WP_Sync_Table_Storage(); + $s = new WP_Collaboration_Table_Storage(); $start = microtime( true ); $s->remove_updates_before_cursor( $target_room, $compaction_cursor_id ); $compaction_times[] = ( microtime( true ) - $start ) * 1000; @@ -125,7 +125,7 @@ function () use ( $target_room ) { // Cleanup // ============================================================ -$wpdb->query( "TRUNCATE TABLE {$wpdb->sync_updates}" ); +$wpdb->query( "TRUNCATE TABLE {$wpdb->collaboration}" ); // ============================================================ // Output diff --git a/tools/local-env/scripts/sync-perf/utils.php b/tools/local-env/scripts/collaboration-perf/utils.php similarity index 92% rename from tools/local-env/scripts/sync-perf/utils.php rename to tools/local-env/scripts/collaboration-perf/utils.php index 8460abcb79b24..6cefead3c3f26 100644 --- a/tools/local-env/scripts/sync-perf/utils.php +++ b/tools/local-env/scripts/collaboration-perf/utils.php @@ -142,7 +142,7 @@ function sync_perf_explain_access( array $row ): string { } /** - * Seeds the wp_sync_updates table via bulk INSERT. + * Seeds the wp_collaboration table via bulk INSERT. * * @param int $total_rows Total rows to insert. * @param int $rooms Number of rooms to distribute across. @@ -150,7 +150,7 @@ function sync_perf_explain_access( array $row ): string { function sync_perf_seed_table( int $total_rows, int $rooms ): void { global $wpdb; - $wpdb->query( "TRUNCATE TABLE {$wpdb->sync_updates}" ); + $wpdb->query( "TRUNCATE TABLE {$wpdb->collaboration}" ); $rows_per_room = (int) ceil( $total_rows / $rooms ); $batch_size = 500; @@ -177,7 +177,7 @@ function sync_perf_seed_table( int $total_rows, int $rooms ): void { } $wpdb->query( - "INSERT INTO {$wpdb->sync_updates} (room, update_value, created_at) VALUES " . implode( ',', $values ) + "INSERT INTO {$wpdb->collaboration} (room, update_value, created_at) VALUES " . implode( ',', $values ) ); $inserted += $chunk; @@ -200,37 +200,37 @@ function sync_perf_collect_explains( string $target_room, int $scale, int $rooms global $wpdb; sync_perf_seed_table( $scale, $rooms ); - $wpdb->query( "ANALYZE TABLE {$wpdb->sync_updates}" ); + $wpdb->query( "ANALYZE TABLE {$wpdb->collaboration}" ); $table_max_id = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->sync_updates} WHERE room = %s", + "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->collaboration} WHERE room = %s", $target_room ) ); $queries = array( array( 'label' => 'Idle poll (MAX cursor)', - 'sql' => "SELECT COALESCE(MAX(id), 0) FROM {$wpdb->sync_updates} WHERE room = %s", + 'sql' => "SELECT COALESCE(MAX(id), 0) FROM {$wpdb->collaboration} WHERE room = %s", 'args' => array( $target_room ), ), array( 'label' => 'Idle poll (COUNT)', - 'sql' => "SELECT COUNT(*) FROM {$wpdb->sync_updates} WHERE room = %s AND id <= %d", + 'sql' => "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND id <= %d", 'args' => array( $target_room, $table_max_id ), ), array( 'label' => 'Catch-up poll (SELECT)', - 'sql' => "SELECT update_value FROM {$wpdb->sync_updates} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", + 'sql' => "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", 'args' => array( $target_room, 0, $table_max_id ), ), array( 'label' => 'Compaction (DELETE)', - 'sql' => "DELETE FROM {$wpdb->sync_updates} WHERE room = %s AND id < %d", + 'sql' => "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND id < %d", 'args' => array( $target_room, $table_max_id ), ), array( 'label' => 'LIKE prefix scan', - 'sql' => "SELECT id, room FROM {$wpdb->sync_updates} WHERE room LIKE %s ORDER BY room, id ASC", + 'sql' => "SELECT id, room FROM {$wpdb->collaboration} WHERE room LIKE %s ORDER BY room, id ASC", 'args' => array( 'postType/post:%' ), ), ); From 900714626ee2e62c5ca0d53c0ca954994134b0cf Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 12:16:23 -0500 Subject: [PATCH 36/82] Collaboration: Add collaboration route fixtures to REST API schema test The collaboration routes (wp-collaboration/v1 and wp-sync/v1) were never added to the REST API schema fixture. The is_builtin_route filter silently excluded them, so the omission went unnoticed. --- .../tests/rest-api/rest-schema-setup.php | 11 +- tests/qunit/fixtures/wp-api-generated.js | 113 +++++++++++++++++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 0e0e00b934359..3b7a8c99e4e97 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -16,6 +16,9 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase { public function set_up() { parent::set_up(); + // Ensure collaboration routes are registered. + add_filter( 'pre_option_wp_enable_real_time_collaboration', '__return_true' ); + /** @var WP_REST_Server $wp_rest_server */ global $wp_rest_server; $wp_rest_server = new Spy_REST_Server(); @@ -204,6 +207,10 @@ public function test_expected_routes_in_schema() { '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+?)/run', '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+)', '/wp-abilities/v1/abilities', + '/wp-collaboration/v1', + '/wp-collaboration/v1/updates', + '/wp-sync/v1', + '/wp-sync/v1/updates', ); $this->assertSameSets( $expected_routes, $routes ); @@ -215,7 +222,9 @@ private function is_builtin_route( $route ) { preg_match( '#^/oembed/1\.0(/.+)?$#', $route ) || preg_match( '#^/wp/v2(/.+)?$#', $route ) || preg_match( '#^/wp-site-health/v1(/.+)?$#', $route ) || - preg_match( '#^/wp-abilities/v1(/.+)?$#', $route ) + preg_match( '#^/wp-abilities/v1(/.+)?$#', $route ) || + preg_match( '#^/wp-collaboration/v1(/.+)?$#', $route ) || + preg_match( '#^/wp-sync/v1(/.+)?$#', $route ) ); } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index a8e8c6280600c..58a2871c22a74 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -21,6 +21,7 @@ mockedApiResponse.Schema = { "wp-site-health/v1", "wp-block-editor/v1", "wp-abilities/v1", + "wp-collaboration/v1", "wp-sync/v1" ], "authentication": { @@ -12772,6 +12773,115 @@ mockedApiResponse.Schema = { } ] }, + "/wp-collaboration/v1": { + "namespace": "wp-collaboration/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-collaboration/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-collaboration/v1" + } + ] + } + }, + "/wp-collaboration/v1/updates": { + "namespace": "wp-collaboration/v1", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "rooms": { + "items": { + "properties": { + "after": { + "minimum": 0, + "required": true, + "type": "integer" + }, + "awareness": { + "required": true, + "type": [ + "object", + "null" + ] + }, + "client_id": { + "minimum": 1, + "required": true, + "type": "integer" + }, + "room": { + "required": true, + "type": "string", + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 255 + }, + "updates": { + "items": { + "properties": { + "data": { + "type": "string", + "required": true + }, + "type": { + "type": "string", + "required": true, + "enum": [ + "compaction", + "sync_step1", + "sync_step2", + "update" + ] + } + }, + "required": true, + "type": "object" + }, + "minItems": 0, + "required": true, + "type": "array" + } + }, + "type": "object" + }, + "type": "array", + "required": true + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-collaboration/v1/updates" + } + ] + } + }, "/wp-sync/v1": { "namespace": "wp-sync/v1", "methods": [ @@ -12836,7 +12946,8 @@ mockedApiResponse.Schema = { "room": { "required": true, "type": "string", - "pattern": "^[^/]+/[^/:]+(?::\\S+)?$" + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$", + "maxLength": 255 }, "updates": { "items": { From 7a55168cdc8bcdf0c2e59e7e0242f5c02083fe69 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 12:16:34 -0500 Subject: [PATCH 37/82] Collaboration: Improve deprecated wp-sync/v1 route documentation Expand docblocks and inline comments to explain that the deprecated wp-sync/v1 route exists for Gutenberg plugin compatibility during its transition to the wp-collaboration/v1 namespace. --- .../class-wp-http-polling-collaboration-server.php | 10 +++++++++- .../tests/rest-api/rest-collaboration-server.php | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 2431e19422c78..62c69bc7f0bff 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -24,6 +24,10 @@ class WP_HTTP_Polling_Collaboration_Server { /** * Deprecated REST API namespace. * + * Retained for backward compatibility with the Gutenberg plugin, which + * still registers requests under the `wp-sync/v1` namespace during its + * transition to `wp-collaboration/v1`. + * * @since 7.0.0 * @var string */ @@ -176,7 +180,8 @@ public function register_routes(): void { $route_args ); - // Deprecated alias for backward compatibility. + // Deprecated alias kept for the Gutenberg plugin, which still uses the + // wp-sync/v1 namespace during its transition to wp-collaboration/v1. register_rest_route( self::DEPRECATED_REST_NAMESPACE, '/updates', @@ -301,6 +306,9 @@ public function handle_request( WP_REST_Request $request ) { * Handles a request to the deprecated wp-sync/v1 route. * * Delegates to handle_request() and adds a deprecation header. + * This route exists for backward compatibility with the Gutenberg plugin, + * which still uses the wp-sync/v1 namespace during its transition to + * wp-collaboration/v1. * * @since 7.0.0 * diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 324b1ce5f7ff8..5772bc2f9bbe2 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1219,9 +1219,16 @@ public function test_sync_routes_not_registered_when_db_version_is_old(): void { /* * Deprecated route tests. + * + * The wp-sync/v1 namespace is retained as a deprecated alias for + * backward compatibility with the Gutenberg plugin, which still + * uses it during its transition to wp-collaboration/v1. */ /** + * Verifies the deprecated wp-sync/v1 route includes a deprecation header + * so Gutenberg plugin consumers are informed of the namespace change. + * * @ticket 64696 */ public function test_deprecated_sync_route_returns_deprecation_header(): void { @@ -1240,6 +1247,9 @@ public function test_deprecated_sync_route_returns_deprecation_header(): void { } /** + * Verifies the deprecated wp-sync/v1 route still processes requests + * correctly, ensuring Gutenberg plugin compatibility during the transition. + * * @ticket 64696 */ public function test_deprecated_sync_route_functions_correctly(): void { From e7fd1cd97583b29438cd378db868f5b1ce20bd7d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 12:24:18 -0500 Subject: [PATCH 38/82] Collaboration: Rename remaining sync references in server class Rename private methods `process_sync_update` and `can_user_sync_entity_type` to use collaboration terminology. Update error codes from `rest_sync_storage_error` to `rest_collaboration_storage_error`, error messages, and docblocks. --- ...s-wp-http-polling-collaboration-server.php | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 62c69bc7f0bff..f19d2ad1c15bc 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -51,7 +51,7 @@ class WP_HTTP_Polling_Collaboration_Server { const COMPACTION_THRESHOLD = 50; /** - * Sync update type: compaction. + * Collaboration update type: compaction. * * @since 7.0.0 * @var string @@ -59,7 +59,7 @@ class WP_HTTP_Polling_Collaboration_Server { const UPDATE_TYPE_COMPACTION = 'compaction'; /** - * Sync update type: sync step 1. + * Collaboration update type: sync step 1. * * @since 7.0.0 * @var string @@ -67,7 +67,7 @@ class WP_HTTP_Polling_Collaboration_Server { const UPDATE_TYPE_SYNC_STEP1 = 'sync_step1'; /** - * Sync update type: sync step 2. + * Collaboration update type: sync step 2. * * @since 7.0.0 * @var string @@ -75,7 +75,7 @@ class WP_HTTP_Polling_Collaboration_Server { const UPDATE_TYPE_SYNC_STEP2 = 'sync_step2'; /** - * Sync update type: regular update. + * Collaboration update type: regular update. * * @since 7.0.0 * @var string @@ -238,7 +238,7 @@ public function check_permissions( WP_REST_Request $request ) { $entity_name = $object_parts[0]; $object_id = $object_parts[1] ?? null; - if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { + if ( ! $this->can_user_collaborate_on_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( 'rest_cannot_edit', sprintf( @@ -286,7 +286,7 @@ public function handle_request( WP_REST_Request $request ) { // Process each update according to its type. foreach ( $room_request['updates'] as $update ) { - $result = $this->process_sync_update( $room, $client_id, $cursor, $update ); + $result = $this->process_collaboration_update( $room, $client_id, $cursor, $update ); if ( is_wp_error( $result ) ) { return $result; } @@ -326,7 +326,7 @@ public function handle_deprecated_request( WP_REST_Request $request ) { } /** - * Checks if the current user can sync a specific entity type. + * Checks if the current user can collaborate on a specific entity type. * * @since 7.0.0 * @@ -335,7 +335,7 @@ public function handle_deprecated_request( WP_REST_Request $request ) { * @param string|null $object_id The object ID / entity key for single entities, null for collections. * @return bool True if user has permission, otherwise false. */ - private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { + private function can_user_collaborate_on_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { // Handle single post type entities with a defined object ID. if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { return current_user_can( 'edit_post', (int) $object_id ); @@ -432,17 +432,17 @@ private function process_awareness_update( string $room, int $client_id, ?array } /** - * Processes a sync update based on its type. + * Processes a collaboration update based on its type. * * @since 7.0.0 * * @param string $room Room identifier. * @param int $client_id Client identifier. * @param int $cursor Client cursor (marker of last seen update). - * @param array{data: string, type: string} $update Sync update. + * @param array{data: string, type: string} $update Collaboration update. * @return true|WP_Error True on success, WP_Error on storage failure. */ - private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) { + private function process_collaboration_update( string $room, int $client_id, int $cursor, array $update ) { $data = $update['data']; $type = $update['type']; @@ -469,7 +469,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, if ( ! $has_newer_compaction ) { if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { return new WP_Error( - 'rest_sync_storage_error', + 'rest_collaboration_storage_error', __( 'Failed to remove updates during compaction.' ), array( 'status' => 500 ) ); @@ -499,7 +499,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, return new WP_Error( 'rest_invalid_update_type', - __( 'Invalid sync update type.' ), + __( 'Invalid collaboration update type.' ), array( 'status' => 400 ) ); } @@ -524,8 +524,8 @@ private function add_update( string $room, int $client_id, string $type, string if ( ! $this->storage->add_update( $room, $update ) ) { return new WP_Error( - 'rest_sync_storage_error', - __( 'Failed to store sync update.' ), + 'rest_collaboration_storage_error', + __( 'Failed to store collaboration update.' ), array( 'status' => 500 ) ); } From 7633b377f20cc97d8829df80cca253cc5a80c92e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 12:24:39 -0500 Subject: [PATCH 39/82] Collaboration: Rename awareness transient key prefix Rename the transient key prefix from `sync_awareness_` to `collaboration_awareness_`. The 1-minute TTL means any active sessions will seamlessly transition on the next poll cycle. --- .../collaboration/class-wp-collaboration-table-storage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 86a65153e9158..45d8eb9c153dc 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -217,7 +217,7 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool * @return string Transient key. */ private function get_awareness_transient_key( string $room ): string { - return 'sync_awareness_' . md5( $room ); + return 'collaboration_awareness_' . md5( $room ); } /** From 66927e584bbe1c86cbbe0da0597c55e903a65c7d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 12:24:57 -0500 Subject: [PATCH 40/82] Collaboration: Rename benchmark function prefix and labels Rename the `sync_perf_` function prefix to `collaboration_perf_` and update CLI output labels to match the collaboration naming. --- .../scripts/collaboration-perf/run.php | 16 ++--- .../scripts/collaboration-perf/utils.php | 62 +++++++++---------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/tools/local-env/scripts/collaboration-perf/run.php b/tools/local-env/scripts/collaboration-perf/run.php index 0b45780de7f5b..b92e7a85695c7 100644 --- a/tools/local-env/scripts/collaboration-perf/run.php +++ b/tools/local-env/scripts/collaboration-perf/run.php @@ -52,7 +52,7 @@ $results = array(); WP_CLI::log( '' ); -WP_CLI::log( WP_CLI::colorize( '%_Sync Storage Performance Benchmark%n' ) ); +WP_CLI::log( WP_CLI::colorize( '%_Collaboration Storage Performance Benchmark%n' ) ); WP_CLI::log( "Backend: WP_Collaboration_Table_Storage" ); WP_CLI::log( "Iterations: {$measured_iterations} measured + {$warmup_iterations} warm-up" ); WP_CLI::log( "Compaction: {$compaction_iterations} measured (re-seed each)" ); @@ -64,7 +64,7 @@ WP_CLI::log( "Scale: {$label} total rows ({$per_room} per room)" ); WP_CLI::log( ' Seeding table...' ); - sync_perf_seed_table( $scale, $rooms_per_scale ); + collaboration_perf_seed_table( $scale, $rooms_per_scale ); $primer = new WP_Collaboration_Table_Storage(); $primer->get_updates_after_cursor( $target_room, 0 ); @@ -72,7 +72,7 @@ // Idle poll. WP_CLI::log( ' Idle poll...' ); - $results['idle_poll'][ $scale ] = sync_perf_stats( + $results['idle_poll'][ $scale ] = collaboration_perf_stats( function () use ( $target_room, $table_idle_cursor ) { $s = new WP_Collaboration_Table_Storage(); $s->get_updates_after_cursor( $target_room, $table_idle_cursor ); @@ -83,7 +83,7 @@ function () use ( $target_room, $table_idle_cursor ) { // Catch-up poll. WP_CLI::log( ' Catch-up poll...' ); - $results['catchup_poll'][ $scale ] = sync_perf_stats( + $results['catchup_poll'][ $scale ] = collaboration_perf_stats( function () use ( $target_room ) { $s = new WP_Collaboration_Table_Storage(); $s->get_updates_after_cursor( $target_room, 0 ); @@ -97,7 +97,7 @@ function () use ( $target_room ) { $compaction_times = array(); for ( $ci = 0; $ci < $compaction_iterations; $ci++ ) { - sync_perf_seed_table( $scale, $rooms_per_scale ); + collaboration_perf_seed_table( $scale, $rooms_per_scale ); $compaction_cursor_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->collaboration} WHERE room = %s ORDER BY id ASC LIMIT 1 OFFSET %d", @@ -111,7 +111,7 @@ function () use ( $target_room ) { $compaction_times[] = ( microtime( true ) - $start ) * 1000; } - $results['compaction'][ $scale ] = sync_perf_compute_stats( $compaction_times ); + $results['compaction'][ $scale ] = collaboration_perf_compute_stats( $compaction_times ); } // ============================================================ @@ -119,7 +119,7 @@ function () use ( $target_room ) { // ============================================================ WP_CLI::log( 'Collecting EXPLAIN analysis...' ); -$explain_data = sync_perf_collect_explains( $target_room, end( $scales ), $rooms_per_scale ); +$explain_data = collaboration_perf_collect_explains( $target_room, end( $scales ), $rooms_per_scale ); // ============================================================ // Cleanup @@ -131,4 +131,4 @@ function () use ( $target_room ) { // Output // ============================================================ -sync_perf_print_output( $results, $explain_data, $config, $scales ); +collaboration_perf_print_output( $results, $explain_data, $config, $scales ); diff --git a/tools/local-env/scripts/collaboration-perf/utils.php b/tools/local-env/scripts/collaboration-perf/utils.php index 6cefead3c3f26..382eeecf1b529 100644 --- a/tools/local-env/scripts/collaboration-perf/utils.php +++ b/tools/local-env/scripts/collaboration-perf/utils.php @@ -1,6 +1,6 @@ abs( $v - $med ), $arr ); - return sync_perf_median( $deviations ); + return collaboration_perf_median( $deviations ); } /** @@ -57,7 +57,7 @@ function sync_perf_mad( array $arr ): float { * @param float[] $arr Array of numbers. * @return float 95th percentile value. */ -function sync_perf_p95( array $arr ): float { +function collaboration_perf_p95( array $arr ): float { sort( $arr ); $index = (int) ceil( 0.95 * count( $arr ) ) - 1; return $arr[ max( 0, $index ) ]; @@ -69,12 +69,12 @@ function sync_perf_p95( array $arr ): float { * @param float[] $times Array of durations in milliseconds. * @return array{ median: float, p95: float, sd: float, mad: float } */ -function sync_perf_compute_stats( array $times ): array { +function collaboration_perf_compute_stats( array $times ): array { return array( - 'median' => sync_perf_median( $times ), - 'p95' => sync_perf_p95( $times ), - 'sd' => sync_perf_sd( $times ), - 'mad' => sync_perf_mad( $times ), + 'median' => collaboration_perf_median( $times ), + 'p95' => collaboration_perf_p95( $times ), + 'sd' => collaboration_perf_sd( $times ), + 'mad' => collaboration_perf_mad( $times ), ); } @@ -86,7 +86,7 @@ function sync_perf_compute_stats( array $times ): array { * @param int $warmup Number of warm-up iterations (discarded). * @return array{ median: float, p95: float, sd: float, mad: float } */ -function sync_perf_stats( callable $callback, int $measured, int $warmup = 5 ): array { +function collaboration_perf_stats( callable $callback, int $measured, int $warmup = 5 ): array { for ( $i = 0; $i < $warmup; $i++ ) { $callback(); } @@ -98,7 +98,7 @@ function sync_perf_stats( callable $callback, int $measured, int $warmup = 5 ): $times[] = ( microtime( true ) - $start ) * 1000; } - return sync_perf_compute_stats( $times ); + return collaboration_perf_compute_stats( $times ); } /** @@ -107,7 +107,7 @@ function sync_perf_stats( callable $callback, int $measured, int $warmup = 5 ): * @param string $sql The query to explain. * @return array EXPLAIN result rows. */ -function sync_perf_explain( string $sql ): array { +function collaboration_perf_explain( string $sql ): array { global $wpdb; return $wpdb->get_results( "EXPLAIN {$sql}", ARRAY_A ); } @@ -118,7 +118,7 @@ function sync_perf_explain( string $sql ): array { * @param float $value Duration in milliseconds. * @return string Formatted value, e.g. "0.04 ms". */ -function sync_perf_format_ms( float $value ): string { +function collaboration_perf_format_ms( float $value ): string { return sprintf( '%.2f ms', $value ); } @@ -128,7 +128,7 @@ function sync_perf_format_ms( float $value ): string { * @param array $row Single EXPLAIN result row (associative array). * @return string Prose summary. */ -function sync_perf_explain_access( array $row ): string { +function collaboration_perf_explain_access( array $row ): string { $extra = $row['Extra'] ?? $row['extra'] ?? ''; $index = $row['key'] ?? $row['Key'] ?? null; $access_type = $row['type'] ?? $row['Type'] ?? null; @@ -147,7 +147,7 @@ function sync_perf_explain_access( array $row ): string { * @param int $total_rows Total rows to insert. * @param int $rooms Number of rooms to distribute across. */ -function sync_perf_seed_table( int $total_rows, int $rooms ): void { +function collaboration_perf_seed_table( int $total_rows, int $rooms ): void { global $wpdb; $wpdb->query( "TRUNCATE TABLE {$wpdb->collaboration}" ); @@ -196,10 +196,10 @@ function sync_perf_seed_table( int $total_rows, int $rooms ): void { * @param int $rooms Number of rooms. * @return array[] EXPLAIN entries with label, sql, and access summary. */ -function sync_perf_collect_explains( string $target_room, int $scale, int $rooms ): array { +function collaboration_perf_collect_explains( string $target_room, int $scale, int $rooms ): array { global $wpdb; - sync_perf_seed_table( $scale, $rooms ); + collaboration_perf_seed_table( $scale, $rooms ); $wpdb->query( "ANALYZE TABLE {$wpdb->collaboration}" ); $table_max_id = (int) $wpdb->get_var( $wpdb->prepare( @@ -238,11 +238,11 @@ function sync_perf_collect_explains( string $target_room, int $scale, int $rooms $explains = array(); foreach ( $queries as $query ) { $prepared = $wpdb->prepare( $query['sql'], ...$query['args'] ); - $rows = sync_perf_explain( $prepared ); + $rows = collaboration_perf_explain( $prepared ); $explains[] = array( 'Query' => $query['label'], - 'Access' => ! empty( $rows ) ? sync_perf_explain_access( $rows[0] ) : 'No EXPLAIN output', + 'Access' => ! empty( $rows ) ? collaboration_perf_explain_access( $rows[0] ) : 'No EXPLAIN output', ); } @@ -257,7 +257,7 @@ function sync_perf_collect_explains( string $target_room, int $scale, int $rooms * @param int $rooms Rooms per scale. * @return array[] Rows with 'Rows per room', 'Median', 'P95', 'STD', 'MAD' keys. */ -function sync_perf_build_section_rows( array $op_results, array $scales, int $rooms ): array { +function collaboration_perf_build_section_rows( array $op_results, array $scales, int $rooms ): array { $rows = array(); foreach ( $scales as $scale ) { @@ -266,10 +266,10 @@ function sync_perf_build_section_rows( array $op_results, array $scales, int $ro $rows[] = array( 'Rows per room' => number_format( $per_room ), - 'Median' => sync_perf_format_ms( $stats['median'] ), - 'P95' => sync_perf_format_ms( $stats['p95'] ), - 'STD' => sync_perf_format_ms( $stats['sd'] ), - 'MAD' => sync_perf_format_ms( $stats['mad'] ), + 'Median' => collaboration_perf_format_ms( $stats['median'] ), + 'P95' => collaboration_perf_format_ms( $stats['p95'] ), + 'STD' => collaboration_perf_format_ms( $stats['sd'] ), + 'MAD' => collaboration_perf_format_ms( $stats['mad'] ), ); } @@ -280,18 +280,18 @@ function sync_perf_build_section_rows( array $op_results, array $scales, int $ro * Prints all benchmark results using WP-CLI formatting. * * @param array $results Benchmark results keyed by operation/scale. - * @param array $explain_data Return value from sync_perf_collect_explains(). + * @param array $explain_data Return value from collaboration_perf_collect_explains(). * @param array $config Benchmark configuration. * @param int[] $scales Scale values. */ -function sync_perf_print_output( array $results, array $explain_data, array $config, array $scales ): void { +function collaboration_perf_print_output( array $results, array $explain_data, array $config, array $scales ): void { global $wp_version, $wpdb; $fields = array( 'Rows per room', 'Median', 'P95', 'STD', 'MAD' ); $separator = str_repeat( '─', 60 ); WP_CLI::log( '' ); - WP_CLI::log( WP_CLI::colorize( '%_Sync Storage Performance%n' ) ); + WP_CLI::log( WP_CLI::colorize( '%_Collaboration Storage Performance%n' ) ); WP_CLI::log( sprintf( 'WordPress %s, MySQL %s, PHP %s, Docker (local dev)', $wp_version, @@ -330,7 +330,7 @@ function sync_perf_print_output( array $results, array $explain_data, array $con WP_CLI::log( $section['desc'] ); WP_CLI::log( '' ); - $rows = sync_perf_build_section_rows( $results[ $op_key ], $scales, $config['rooms'] ); + $rows = collaboration_perf_build_section_rows( $results[ $op_key ], $scales, $config['rooms'] ); WP_CLI\Utils\format_items( 'table', $rows, $fields ); } From 3c413439d43778176239b7de9d95a8b9ca87f4eb Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 12:45:53 -0500 Subject: [PATCH 41/82] Collaboration: Replace remaining sync terminology in comments and strings Rename stale "sync" references in the server class and tests that were not related to the deprecated wp-sync/v1 route or the Yjs sync_step wire protocol. --- .../class-wp-http-polling-collaboration-server.php | 8 ++++---- .../phpunit/tests/rest-api/rest-collaboration-server.php | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index f19d2ad1c15bc..6c07c9a9ca003 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -242,8 +242,8 @@ public function check_permissions( WP_REST_Request $request ) { return new WP_Error( 'rest_cannot_edit', sprintf( - /* translators: %s: The room name encodes the current entity being synced. */ - __( 'You do not have permission to sync this entity: %s.' ), + /* translators: %s: The room name identifying the collaborative editing session. */ + __( 'You do not have permission to collaborate on this entity: %s.' ), esc_html( $room ) ), array( 'status' => rest_authorization_required_code() ) @@ -368,7 +368,7 @@ private function can_user_collaborate_on_entity_type( string $entity_kind, strin return current_user_can( $post_type_object->cap->edit_posts ); } - // Collection syncing does not exchange entity data. It only signals if + // Collection collaboration does not exchange entity data. It only signals if // another user has updated an entity in the collection. Therefore, we only // compare against an allow list of collection types. $allowed_collection_entity_kinds = array( @@ -451,7 +451,7 @@ private function process_collaboration_update( string $room, int $client_id, int /* * Compaction replaces updates the client has already seen. Only remove * updates with markers before the client's cursor to preserve updates - * that arrived since the client's last sync. + * that arrived since the client's last poll. * * Check for a newer compaction update first. If one exists, skip this * compaction to avoid overwriting it. diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 5772bc2f9bbe2..160d633cca7d8 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -949,7 +949,7 @@ public function test_sync_operations_do_not_affect_posts_last_changed(): void { 'data' => 'dGVzdA==', ); - // Perform several sync operations. + // Perform several collaboration operations. $this->dispatch_sync( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), @@ -963,7 +963,7 @@ public function test_sync_operations_do_not_affect_posts_last_changed(): void { $last_changed_after = wp_cache_get_last_changed( 'posts' ); - $this->assertSame( $last_changed_before, $last_changed_after, 'Sync operations should not invalidate the posts last changed cache.' ); + $this->assertSame( $last_changed_before, $last_changed_after, 'Collaboration operations should not invalidate the posts last changed cache.' ); } /* From 8fb1eda58081a4f94197385f05604a3f07d2bb50 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 13:11:40 -0500 Subject: [PATCH 42/82] Rename test method names from sync to collaboration Renames all test_sync_* methods to test_collaboration_* and the dispatch_sync helper to dispatch_collaboration for consistency with the subsystem rename. Deprecated route test names are intentionally preserved as test_deprecated_sync_route_*. --- .../rest-api/rest-collaboration-server.php | 220 +++++++++--------- 1 file changed, 110 insertions(+), 110 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 160d633cca7d8..fb3bfa4192305 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -65,7 +65,7 @@ private function build_room( $room, $client_id = 1, $cursor = 0, $awareness = ar * @param string $namespace REST namespace to use. Defaults to the primary namespace. * @return WP_REST_Response Response object. */ - private function dispatch_sync( $rooms, $namespace = 'wp-collaboration/v1' ) { + private function dispatch_collaboration( $rooms, $namespace = 'wp-collaboration/v1' ) { $request = new WP_REST_Request( 'POST', '/' . $namespace . '/updates' ); $request->set_body_params( array( 'rooms' => $rooms ) ); return rest_get_server()->dispatch( $request ); @@ -117,7 +117,7 @@ public function test_get_item() { public function test_create_item() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); $this->assertSame( 200, $response->get_status() ); } @@ -154,91 +154,91 @@ public function test_get_item_schema() { * Permission tests. */ - public function test_sync_requires_authentication() { + public function test_collaboration_requires_authentication() { wp_set_current_user( 0 ); - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); } - public function test_sync_post_requires_edit_capability() { + public function test_collaboration_post_requires_edit_capability() { wp_set_current_user( self::$subscriber_id ); - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_sync_post_allowed_with_edit_capability() { + public function test_collaboration_post_allowed_with_edit_capability() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); $this->assertSame( 200, $response->get_status() ); } - public function test_sync_post_type_collection_requires_edit_posts_capability() { + public function test_collaboration_post_type_collection_requires_edit_posts_capability() { wp_set_current_user( self::$subscriber_id ); - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_sync_post_type_collection_allowed_with_edit_posts_capability() { + public function test_collaboration_post_type_collection_allowed_with_edit_posts_capability() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post' ) ) ); $this->assertSame( 200, $response->get_status() ); } - public function test_sync_root_collection_allowed() { + public function test_collaboration_root_collection_allowed() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( 'root/site' ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site' ) ) ); $this->assertSame( 200, $response->get_status() ); } - public function test_sync_taxonomy_collection_allowed() { + public function test_collaboration_taxonomy_collection_allowed() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category' ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( 'taxonomy/category' ) ) ); $this->assertSame( 200, $response->get_status() ); } - public function test_sync_unknown_collection_kind_rejected() { + public function test_collaboration_unknown_collection_kind_rejected() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( 'unknown/entity' ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( 'unknown/entity' ) ) ); $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_sync_non_posttype_entity_with_object_id_rejected() { + public function test_collaboration_non_posttype_entity_with_object_id_rejected() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( 'root/site:123' ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( 'root/site:123' ) ) ); $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_sync_nonexistent_post_rejected() { + public function test_collaboration_nonexistent_post_rejected() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:999999' ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( 'postType/post:999999' ) ) ); $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } - public function test_sync_permission_checked_per_room() { + public function test_collaboration_permission_checked_per_room() { wp_set_current_user( self::$editor_id ); // First room is allowed, second room is forbidden. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ), $this->build_room( 'unknown/entity' ), @@ -252,10 +252,10 @@ public function test_sync_permission_checked_per_room() { * Validation tests. */ - public function test_sync_invalid_room_format_rejected() { + public function test_collaboration_invalid_room_format_rejected() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( 'invalid-room-format' ), ) @@ -268,10 +268,10 @@ public function test_sync_invalid_room_format_rejected() { * Response format tests. */ - public function test_sync_response_structure() { + public function test_collaboration_response_structure() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); $this->assertSame( 200, $response->get_status() ); @@ -288,11 +288,11 @@ public function test_sync_response_structure() { $this->assertArrayHasKey( 'should_compact', $room_data ); } - public function test_sync_response_room_matches_request() { + public function test_collaboration_response_room_matches_request() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); - $response = $this->dispatch_sync( array( $this->build_room( $room ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( $room ) ) ); $data = $response->get_data(); $this->assertSame( $room, $data['rooms'][0]['room'] ); @@ -301,10 +301,10 @@ public function test_sync_response_room_matches_request() { /** * @ticket 64696 */ - public function test_sync_end_cursor_is_non_negative_integer() { + public function test_collaboration_end_cursor_is_non_negative_integer() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); $data = $response->get_data(); $this->assertIsInt( $data['rooms'][0]['end_cursor'] ); @@ -312,10 +312,10 @@ public function test_sync_end_cursor_is_non_negative_integer() { $this->assertGreaterThanOrEqual( 0, $data['rooms'][0]['end_cursor'] ); } - public function test_sync_empty_updates_returns_zero_total() { + public function test_collaboration_empty_updates_returns_zero_total() { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); $data = $response->get_data(); $this->assertSame( 0, $data['rooms'][0]['total_updates'] ); @@ -326,7 +326,7 @@ public function test_sync_empty_updates_returns_zero_total() { * Update tests. */ - public function test_sync_update_delivered_to_other_client() { + public function test_collaboration_update_delivered_to_other_client() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -336,14 +336,14 @@ public function test_sync_update_delivered_to_other_client() { ); // Client 1 sends an update. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), ) ); // Client 2 requests updates from the beginning. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0 ), ) @@ -358,7 +358,7 @@ public function test_sync_update_delivered_to_other_client() { $this->assertContains( 'update', $types ); } - public function test_sync_own_updates_not_returned() { + public function test_collaboration_own_updates_not_returned() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -368,7 +368,7 @@ public function test_sync_own_updates_not_returned() { ); // Client 1 sends an update. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), ) @@ -381,7 +381,7 @@ public function test_sync_own_updates_not_returned() { $this->assertEmpty( $updates ); } - public function test_sync_step1_update_stored_and_returned() { + public function test_collaboration_step1_update_stored_and_returned() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -391,14 +391,14 @@ public function test_sync_step1_update_stored_and_returned() { ); // Client 1 sends sync_step1. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), ) ); // Client 2 should see the sync_step1 update. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0 ), ) @@ -409,7 +409,7 @@ public function test_sync_step1_update_stored_and_returned() { $this->assertContains( 'sync_step1', $types ); } - public function test_sync_step2_update_stored_and_returned() { + public function test_collaboration_step2_update_stored_and_returned() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -419,14 +419,14 @@ public function test_sync_step2_update_stored_and_returned() { ); // Client 1 sends sync_step2. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), ) ); // Client 2 should see the sync_step2 update. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0 ), ) @@ -437,7 +437,7 @@ public function test_sync_step2_update_stored_and_returned() { $this->assertContains( 'sync_step2', $types ); } - public function test_sync_multiple_updates_in_single_request() { + public function test_collaboration_multiple_updates_in_single_request() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -453,14 +453,14 @@ public function test_sync_multiple_updates_in_single_request() { ); // Client 1 sends multiple updates. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), $updates ), ) ); // Client 2 should see both updates. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0 ), ) @@ -473,7 +473,7 @@ public function test_sync_multiple_updates_in_single_request() { $this->assertSame( 2, $data['rooms'][0]['total_updates'] ); } - public function test_sync_update_data_preserved() { + public function test_collaboration_update_data_preserved() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -483,14 +483,14 @@ public function test_sync_update_data_preserved() { ); // Client 1 sends an update. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), ) ); // Client 2 should receive the exact same data. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0 ), ) @@ -503,7 +503,7 @@ public function test_sync_update_data_preserved() { $this->assertSame( 'update', $room_updates[0]['type'] ); } - public function test_sync_total_updates_increments() { + public function test_collaboration_total_updates_increments() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -513,24 +513,24 @@ public function test_sync_total_updates_increments() { ); // Send three updates from different clients. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), ) ); - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0, array( 'user' => 'c2' ), array( $update ) ), ) ); - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 3, 0, array( 'user' => 'c3' ), array( $update ) ), ) ); // Any client should see total_updates = 3. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 4, 0 ), ) @@ -544,7 +544,7 @@ public function test_sync_total_updates_increments() { * Compaction tests. */ - public function test_sync_should_compact_is_false_below_threshold() { + public function test_collaboration_should_compact_is_false_below_threshold() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -554,7 +554,7 @@ public function test_sync_should_compact_is_false_below_threshold() { ); // Client 1 sends a single update. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), ) @@ -564,7 +564,7 @@ public function test_sync_should_compact_is_false_below_threshold() { $this->assertFalse( $data['rooms'][0]['should_compact'] ); } - public function test_sync_should_compact_is_true_above_threshold_for_compactor() { + public function test_collaboration_should_compact_is_true_above_threshold_for_compactor() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -577,14 +577,14 @@ public function test_sync_should_compact_is_true_above_threshold_for_compactor() } // Client 1 sends enough updates to exceed the compaction threshold. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), ) ); // Client 1 polls again. It is the lowest (only) client, so it is the compactor. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ) ), ) @@ -594,7 +594,7 @@ public function test_sync_should_compact_is_true_above_threshold_for_compactor() $this->assertTrue( $data['rooms'][0]['should_compact'] ); } - public function test_sync_should_compact_is_false_for_non_compactor() { + public function test_collaboration_should_compact_is_false_for_non_compactor() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -607,14 +607,14 @@ public function test_sync_should_compact_is_false_for_non_compactor() { } // Client 1 sends enough updates to exceed the compaction threshold. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), ) ); // Client 2 (higher ID than client 1) should not be the compactor. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), ) @@ -624,7 +624,7 @@ public function test_sync_should_compact_is_false_for_non_compactor() { $this->assertFalse( $data['rooms'][0]['should_compact'] ); } - public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists() { + public function test_collaboration_stale_compaction_succeeds_when_newer_compaction_exists() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -634,7 +634,7 @@ public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists ); // Client 1 sends an update to seed the room. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), ) @@ -648,7 +648,7 @@ public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists 'data' => 'Y29tcGFjdGVk', ); - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 2, $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ), ) @@ -661,7 +661,7 @@ public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists 'type' => 'compaction', 'data' => 'c3RhbGU=', ); - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 3, 0, array( 'user' => 'c3' ), array( $stale_compaction ) ), ) @@ -670,7 +670,7 @@ public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists $this->assertSame( 200, $response->get_status() ); // Verify the newer compaction is preserved and the stale one was not stored. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 4, 0, array( 'user' => 'c4' ) ), ) @@ -685,11 +685,11 @@ public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists * Awareness tests. */ - public function test_sync_awareness_returned() { + public function test_collaboration_awareness_returned() { wp_set_current_user( self::$editor_id ); $awareness = array( 'name' => 'Editor' ); - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room(), 1, 0, $awareness ), ) @@ -700,20 +700,20 @@ public function test_sync_awareness_returned() { $this->assertSame( $awareness, $data['rooms'][0]['awareness'][1] ); } - public function test_sync_awareness_shows_multiple_clients() { + public function test_collaboration_awareness_shows_multiple_clients() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); // Client 1 connects. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'name' => 'Client 1' ) ), ) ); // Client 2 connects. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0, array( 'name' => 'Client 2' ) ), ) @@ -728,20 +728,20 @@ public function test_sync_awareness_shows_multiple_clients() { $this->assertSame( array( 'name' => 'Client 2' ), $awareness[2] ); } - public function test_sync_awareness_updates_existing_client() { + public function test_collaboration_awareness_updates_existing_client() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); // Client 1 connects with initial awareness. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'cursor' => 'start' ) ), ) ); // Client 1 updates its awareness. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'cursor' => 'updated' ) ), ) @@ -755,13 +755,13 @@ public function test_sync_awareness_updates_existing_client() { $this->assertSame( array( 'cursor' => 'updated' ), $awareness[1] ); } - public function test_sync_awareness_client_id_cannot_be_used_by_another_user() { + public function test_collaboration_awareness_client_id_cannot_be_used_by_another_user() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); // Editor establishes awareness with client_id 1. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'name' => 'Editor' ) ), ) @@ -771,7 +771,7 @@ public function test_sync_awareness_client_id_cannot_be_used_by_another_user() { $editor_id_2 = self::factory()->user->create( array( 'role' => 'editor' ) ); wp_set_current_user( $editor_id_2 ); - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'name' => 'Impostor' ) ), ) @@ -784,13 +784,13 @@ public function test_sync_awareness_client_id_cannot_be_used_by_another_user() { * Multiple rooms tests. */ - public function test_sync_multiple_rooms_in_single_request() { + public function test_collaboration_multiple_rooms_in_single_request() { wp_set_current_user( self::$editor_id ); $room1 = $this->get_post_room(); $room2 = 'taxonomy/category'; - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room1 ), $this->build_room( $room2 ), @@ -805,7 +805,7 @@ public function test_sync_multiple_rooms_in_single_request() { $this->assertSame( $room2, $data['rooms'][1]['room'] ); } - public function test_sync_rooms_are_isolated() { + public function test_collaboration_rooms_are_isolated() { wp_set_current_user( self::$editor_id ); $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); @@ -818,14 +818,14 @@ public function test_sync_rooms_are_isolated() { ); // Client 1 sends an update to room 1 only. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room1, 1, 0, array( 'user' => 'client1' ), array( $update ) ), ) ); // Client 2 queries both rooms. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room1, 2, 0 ), $this->build_room( $room2, 2, 0 ), @@ -848,10 +848,10 @@ public function test_sync_rooms_are_isolated() { /** * @ticket 64696 */ - public function test_sync_empty_room_cursor_is_zero(): void { + public function test_collaboration_empty_room_cursor_is_zero(): void { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ) ); $data = $response->get_data(); $this->assertSame( 0, $data['rooms'][0]['end_cursor'] ); @@ -860,7 +860,7 @@ public function test_sync_empty_room_cursor_is_zero(): void { /** * @ticket 64696 */ - public function test_sync_cursor_advances_monotonically(): void { + public function test_collaboration_cursor_advances_monotonically(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -870,7 +870,7 @@ public function test_sync_cursor_advances_monotonically(): void { ); // First request. - $response1 = $this->dispatch_sync( + $response1 = $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), ) @@ -878,7 +878,7 @@ public function test_sync_cursor_advances_monotonically(): void { $cursor1 = $response1->get_data()['rooms'][0]['end_cursor']; // Second request with more updates. - $response2 = $this->dispatch_sync( + $response2 = $this->dispatch_collaboration( array( $this->build_room( $room, 2, $cursor1, array( 'user' => 'c2' ), array( $update ) ), ) @@ -891,7 +891,7 @@ public function test_sync_cursor_advances_monotonically(): void { /** * @ticket 64696 */ - public function test_sync_cursor_prevents_re_delivery(): void { + public function test_collaboration_cursor_prevents_re_delivery(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -901,14 +901,14 @@ public function test_sync_cursor_prevents_re_delivery(): void { ); // Client 1 sends an update. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), ) ); // Client 2 fetches updates and gets a cursor. - $response1 = $this->dispatch_sync( + $response1 = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), ) @@ -919,7 +919,7 @@ public function test_sync_cursor_prevents_re_delivery(): void { $this->assertNotEmpty( $data1['rooms'][0]['updates'], 'First poll should return updates.' ); // Client 2 polls again using the cursor from the first poll, with no new updates. - $response2 = $this->dispatch_sync( + $response2 = $this->dispatch_collaboration( array( $this->build_room( $room, 2, $cursor1, array( 'user' => 'c2' ) ), ) @@ -936,7 +936,7 @@ public function test_sync_cursor_prevents_re_delivery(): void { /** * @ticket 64696 */ - public function test_sync_operations_do_not_affect_posts_last_changed(): void { + public function test_collaboration_operations_do_not_affect_posts_last_changed(): void { wp_set_current_user( self::$editor_id ); // Prime the posts last changed cache. @@ -950,12 +950,12 @@ public function test_sync_operations_do_not_affect_posts_last_changed(): void { ); // Perform several collaboration operations. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), ) ); - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0, array( 'user' => 'c2' ), array( $update ) ), ) @@ -973,7 +973,7 @@ public function test_sync_operations_do_not_affect_posts_last_changed(): void { /** * @ticket 64696 */ - public function test_sync_compaction_does_not_lose_concurrent_updates(): void { + public function test_collaboration_compaction_does_not_lose_concurrent_updates(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -987,7 +987,7 @@ public function test_sync_compaction_does_not_lose_concurrent_updates(): void { ); } - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $initial_updates ), ) @@ -1001,7 +1001,7 @@ public function test_sync_compaction_does_not_lose_concurrent_updates(): void { 'type' => 'update', 'data' => base64_encode( 'concurrent' ), ); - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0, array( 'user' => 'c2' ), array( $concurrent_update ) ), ) @@ -1012,14 +1012,14 @@ public function test_sync_compaction_does_not_lose_concurrent_updates(): void { 'type' => 'compaction', 'data' => base64_encode( 'compacted-state' ), ); - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, $cursor, array( 'user' => 'c1' ), array( $compaction_update ) ), ) ); // Client 3 requests all updates from the beginning. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 3, 0, array( 'user' => 'c3' ) ), ) @@ -1039,7 +1039,7 @@ public function test_sync_compaction_does_not_lose_concurrent_updates(): void { /** * @ticket 64696 */ - public function test_sync_compaction_reduces_total_updates(): void { + public function test_collaboration_compaction_reduces_total_updates(): void { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -1052,7 +1052,7 @@ public function test_sync_compaction_reduces_total_updates(): void { } // Client 1 sends 10 updates. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), ) @@ -1066,14 +1066,14 @@ public function test_sync_compaction_reduces_total_updates(): void { 'type' => 'compaction', 'data' => base64_encode( 'compacted' ), ); - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, $cursor, array( 'user' => 'c1' ), array( $compaction ) ), ) ); // Client 2 checks the state. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), ) @@ -1201,7 +1201,7 @@ public function test_cron_cleanup_hook_is_registered(): void { /** * @ticket 64696 */ - public function test_sync_routes_not_registered_when_db_version_is_old(): void { + public function test_collaboration_routes_not_registered_when_db_version_is_old(): void { update_option( 'db_version', 61697 ); // Reset the global REST server so rest_get_server() builds a fresh instance. @@ -1234,7 +1234,7 @@ public function test_sync_routes_not_registered_when_db_version_is_old(): void { public function test_deprecated_sync_route_returns_deprecation_header(): void { wp_set_current_user( self::$editor_id ); - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $this->get_post_room() ) ), 'wp-sync/v1' ); @@ -1262,7 +1262,7 @@ public function test_deprecated_sync_route_functions_correctly(): void { ); // Send update via deprecated route. - $this->dispatch_sync( + $this->dispatch_collaboration( array( $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), ), @@ -1270,7 +1270,7 @@ public function test_deprecated_sync_route_functions_correctly(): void { ); // Retrieve via primary route. - $response = $this->dispatch_sync( + $response = $this->dispatch_collaboration( array( $this->build_room( $room, 2, 0 ), ) From fa30d7b093b399a3fb68cef172aecf954602d805 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 13:24:15 -0500 Subject: [PATCH 43/82] Rename data loss proof script for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DO_NOT_RELEASE_prove-data-loss.php → DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php --- ...oss.php => DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tools/local-env/scripts/collaboration-perf/{DO_NOT_RELEASE_prove-data-loss.php => DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php} (100%) diff --git a/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-data-loss.php b/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php similarity index 100% rename from tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-data-loss.php rename to tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php From ff1e9480dc9b39df4b66be8b50540b7c0d882087 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 13:50:33 -0500 Subject: [PATCH 44/82] Update src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php Co-authored-by: Weston Ruter --- .../class-wp-http-polling-collaboration-server.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 6c07c9a9ca003..0a92b110a6938 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -21,18 +21,6 @@ class WP_HTTP_Polling_Collaboration_Server { */ const REST_NAMESPACE = 'wp-collaboration/v1'; - /** - * Deprecated REST API namespace. - * - * Retained for backward compatibility with the Gutenberg plugin, which - * still registers requests under the `wp-sync/v1` namespace during its - * transition to `wp-collaboration/v1`. - * - * @since 7.0.0 - * @var string - */ - const DEPRECATED_REST_NAMESPACE = 'wp-sync/v1'; - /** * Awareness timeout in seconds. Clients that haven't updated * their awareness state within this time are considered disconnected. From b9d7b1aeebc118db174b9e6fee54c03e38f737cc Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 13:51:21 -0500 Subject: [PATCH 45/82] Update tests/phpunit/tests/rest-api/rest-collaboration-server.php Co-authored-by: Weston Ruter --- .../phpunit/tests/rest-api/rest-collaboration-server.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index fb3bfa4192305..5b4b35c342ebb 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -61,12 +61,12 @@ private function build_room( $room, $client_id = 1, $cursor = 0, $awareness = ar /** * Dispatches a collaboration request with the given rooms. * - * @param array $rooms Array of room request data. - * @param string $namespace REST namespace to use. Defaults to the primary namespace. + * @param array $rooms Array of room request data. + * @param string $_namespace REST namespace to use. Defaults to the primary namespace. * @return WP_REST_Response Response object. */ - private function dispatch_collaboration( $rooms, $namespace = 'wp-collaboration/v1' ) { - $request = new WP_REST_Request( 'POST', '/' . $namespace . '/updates' ); + private function dispatch_collaboration( $rooms, $_namespace = 'wp-collaboration/v1' ) { + $request = new WP_REST_Request( 'POST', '/' . $_namespace . '/updates' ); $request->set_body_params( array( 'rooms' => $rooms ) ); return rest_get_server()->dispatch( $request ); } From 90e9566d5392973167e9e0345025b99d6920707e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 13:54:08 -0500 Subject: [PATCH 46/82] Update src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php Co-authored-by: Weston Ruter --- .../class-wp-http-polling-collaboration-server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 0a92b110a6938..af7802060b833 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -171,7 +171,7 @@ public function register_routes(): void { // Deprecated alias kept for the Gutenberg plugin, which still uses the // wp-sync/v1 namespace during its transition to wp-collaboration/v1. register_rest_route( - self::DEPRECATED_REST_NAMESPACE, + 'wp-sync/v1', '/updates', array( 'methods' => array( WP_REST_Server::CREATABLE ), From de3d35635681404384b3577c90790be27f626079 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 14:38:55 -0500 Subject: [PATCH 47/82] Fix collaboration E2E tests failing due to inline script timing wp_collaboration_inject_setting was hooked to admin_init, but wp-core-data is not registered at that point, causing wp_add_inline_script to silently fail. Move the hook to enqueue_block_editor_assets where the handle is guaranteed to exist. Also replace the fragile form-scraping setCollaboration helper with requestUtils.updateSiteSettings and tolerate pre-existing test users so interrupted runs do not break subsequent test executions. --- src/wp-includes/default-filters.php | 2 +- .../fixtures/collaboration-utils.js | 29 ++----------------- .../e2e/specs/collaboration/fixtures/index.js | 12 ++++++-- 3 files changed, 13 insertions(+), 30 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 27090d35eff7b..efe1cf748d812 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -796,7 +796,7 @@ add_action( 'init', '_wp_register_default_font_collections' ); // Collaboration. -add_action( 'admin_init', 'wp_collaboration_inject_setting' ); +add_action( 'enqueue_block_editor_assets', 'wp_collaboration_inject_setting' ); // Add ignoredHookedBlocks metadata attribute to the template and template part post types. add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' ); diff --git a/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js index 4d42507afca63..3e81552f4ae2b 100644 --- a/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js +++ b/tests/e2e/specs/collaboration/fixtures/collaboration-utils.js @@ -85,36 +85,11 @@ export default class CollaborationUtils { /** * Set the real-time collaboration WordPress setting. * - * Uses the form-based approach because this setting is registered - * on admin_init in the "writing" group and is not exposed via - * /wp/v2/settings. - * * @param {boolean} enabled Whether to enable or disable collaboration. */ async setCollaboration( enabled ) { - const response = await this.requestUtils.request.get( - '/wp-admin/options-writing.php' - ); - const html = await response.text(); - const nonce = html.match( /name="_wpnonce" value="([^"]+)"/ )[ 1 ]; - - const formData = { - option_page: 'writing', - action: 'update', - _wpnonce: nonce, - _wp_http_referer: '/wp-admin/options-writing.php', - submit: 'Save Changes', - default_category: 1, - default_post_format: 0, - }; - - if ( enabled ) { - formData.wp_enable_real_time_collaboration = 1; - } - - await this.requestUtils.request.post( '/wp-admin/options.php', { - form: formData, - failOnStatusCode: true, + await this.requestUtils.updateSiteSettings( { + wp_enable_real_time_collaboration: enabled, } ); } diff --git a/tests/e2e/specs/collaboration/fixtures/index.js b/tests/e2e/specs/collaboration/fixtures/index.js index cf8b4aad2987e..446e6e88c459c 100644 --- a/tests/e2e/specs/collaboration/fixtures/index.js +++ b/tests/e2e/specs/collaboration/fixtures/index.js @@ -32,8 +32,16 @@ export const test = base.extend( { page, } ); await utils.setCollaboration( true ); - await requestUtils.createUser( SECOND_USER ); - await requestUtils.createUser( THIRD_USER ); + await requestUtils.createUser( SECOND_USER ).catch( ( error ) => { + if ( error?.code !== 'existing_user_login' ) { + throw error; + } + } ); + await requestUtils.createUser( THIRD_USER ).catch( ( error ) => { + if ( error?.code !== 'existing_user_login' ) { + throw error; + } + } ); await use( utils ); await utils.teardown(); }, From 529623869c3634c2dc591bd1fd084bdc8ff3c10d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Thu, 5 Mar 2026 14:43:58 -0500 Subject: [PATCH 48/82] Replace progress bar with log lines in data loss proof script Use WP_CLI::log status lines consistent with the performance benchmark script instead of a progress bar. --- .../DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php b/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php index b469a04517418..bf942ad3c9806 100644 --- a/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php +++ b/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php @@ -124,13 +124,17 @@ $gap_scales = array( 50, 200, 500, 1000 ); $gap_results = array(); -$progress = WP_CLI\Utils\make_progress_bar( 'Measuring data loss window at scale', count( $gap_scales ) ); + +WP_CLI::log( '' ); +WP_CLI::log( 'Measuring data loss window at scale...' ); foreach ( $gap_scales as $gap_total ) { $gap_discard = (int) ( $gap_total * 0.8 ); $gap_keep = $gap_total - $gap_discard; $gap_room = "postType/post:gap-{$gap_total}"; + WP_CLI::log( " Scale: {$gap_total} updates (keep {$gap_keep})" ); + // Seed post meta for this scale. $gap_post_id = wp_insert_post( array( 'post_type' => 'wp_sync_storage', @@ -168,11 +172,8 @@ // Cleanup this scale. wp_delete_post( $gap_post_id, true ); - $progress->tick(); } -$progress->finish(); - // ===================================================================== // Results. // ===================================================================== From 41c4227420a107dd6031a23af9b60beabab8fdc5 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 00:50:03 -0500 Subject: [PATCH 49/82] Use atomic upsert for per-client awareness rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the (room, event_type, client_id) index to UNIQUE and replace the DELETE + INSERT pair in set_awareness_state() with a single INSERT … ON DUPLICATE KEY UPDATE. The row is never absent between statements, which eliminates the window where a concurrent get_awareness_state() could see zero rows for a client and prevents duplicate rows from two concurrent set_awareness_state() calls. --- src/wp-admin/includes/schema.php | 5 +- .../class-wp-collaboration-table-storage.php | 125 ++++++++++++------ 2 files changed, 89 insertions(+), 41 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index b03e5079e0cd6..4901bfc3eebfc 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -190,11 +190,14 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { CREATE TABLE $wpdb->collaboration ( id bigint(20) unsigned NOT NULL auto_increment, room varchar(255) NOT NULL, + event_type varchar(20) NOT NULL default 'sync_update', + client_id bigint(20) unsigned NOT NULL default 0, update_value longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), KEY room (room,id), - KEY created_at (created_at) + KEY created_at (created_at), + UNIQUE KEY awareness (room,event_type,client_id) ) $charset_collate;\n"; // Single site users table. The multisite flavor of the users table is handled below. diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 45d8eb9c153dc..98de229b34706 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -51,10 +51,11 @@ public function add_update( string $room, $update ): bool { $wpdb->collaboration, array( 'room' => $room, + 'event_type' => 'sync_update', 'update_value' => wp_json_encode( $update ), 'created_at' => current_time( 'mysql', true ), ), - array( '%s', '%s', '%s' ) + array( '%s', '%s', '%s', '%s' ) ); return false !== $result; @@ -63,22 +64,57 @@ public function add_update( string $room, $update ): bool { /** * Gets awareness state for a given room. * - * Awareness is ephemeral and stored as a transient rather than - * in the collaboration table. + * Retrieves per-client awareness rows from the collaboration table, + * cleaning up expired entries inline. * * @since 7.0.0 * - * @param string $room Room identifier. - * @return array Awareness state. + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $room Room identifier. + * @param int $timeout Seconds before an awareness entry is considered expired. + * @return array Awareness entries. */ - public function get_awareness_state( string $room ): array { - $awareness = get_transient( $this->get_awareness_transient_key( $room ) ); + public function get_awareness_state( string $room, int $timeout = 30 ): array { + global $wpdb; + + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); + + // Clean up expired awareness rows for this room. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'awareness' AND created_at < %s", + $room, + $cutoff + ) + ); - if ( ! is_array( $awareness ) ) { + // Fetch active awareness rows. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT client_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'awareness' AND created_at >= %s", + $room, + $cutoff + ) + ); + + if ( ! is_array( $rows ) ) { return array(); } - return array_values( $awareness ); + $entries = array(); + foreach ( $rows as $row ) { + $decoded = json_decode( $row->update_value, true ); + if ( json_last_error() === JSON_ERROR_NONE ) { + $entries[] = array( + 'client_id' => (int) $row->client_id, + 'state' => $decoded['state'], + 'wp_user_id' => $decoded['wp_user_id'], + ); + } + } + + return $entries; } /** @@ -126,10 +162,10 @@ public function get_update_count( string $room ): int { public function get_updates_after_cursor( string $room, int $cursor ): array { global $wpdb; - // Snapshot the current max ID for this room to define a stable upper bound. + // Snapshot the current max ID for sync_update rows in this room. $max_id = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->collaboration} WHERE room = %s", + "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'sync_update'", $room ) ); @@ -141,20 +177,19 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { return array(); } - // Count total updates for this room (used by compaction threshold logic). - // Bounded by max_id to stay consistent with the snapshot window above. + // Count total sync_update rows for this room (used by compaction threshold logic). $this->room_update_counts[ $room ] = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND id <= %d", + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'sync_update' AND id <= %d", $room, $max_id ) ); - // Fetch updates after the cursor up to the snapshot boundary. + // Fetch sync updates after the cursor up to the snapshot boundary. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", + "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'sync_update' AND id > %d AND id <= %d ORDER BY id ASC", $room, $cursor, $max_id @@ -195,7 +230,7 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool $result = $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND id < %d", + "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'sync_update' AND id < %d", $room, $cursor ) @@ -205,35 +240,45 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool } /** - * Returns the transient key used to store awareness state for a room. + * Sets awareness state for a given client in a room. * - * The room name is hashed with md5 to guarantee the key stays within - * the 172-character limit imposed by the wp_options option_name column - * (varchar 191 minus the 19-character `_transient_timeout_` prefix). + * Uses INSERT … ON DUPLICATE KEY UPDATE so the row is never absent — + * it is either inserted or updated atomically. Each client writes only + * its own row, eliminating the race condition inherent in shared-state + * approaches. * * @since 7.0.0 * - * @param string $room Room identifier. - * @return string Transient key. - */ - private function get_awareness_transient_key( string $room ): string { - return 'collaboration_awareness_' . md5( $room ); - } - - /** - * Sets awareness state for a given room. - * - * Awareness is ephemeral and stored as a transient with a short timeout. - * - * @since 7.0.0 + * @global wpdb $wpdb WordPress database abstraction object. * - * @param string $room Room identifier. - * @param array $awareness Serializable awareness state. + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param array $state Serializable awareness state for this client. + * @param int $wp_user_id WordPress user ID that owns this client. * @return bool True on success, false on failure. */ - public function set_awareness_state( string $room, array $awareness ): bool { - // Awareness is high-frequency, short-lived data (cursor positions, selections) - // that doesn't need cursor-based history. Transients avoid row churn in the table. - return set_transient( $this->get_awareness_transient_key( $room ), $awareness, MINUTE_IN_SECONDS ); + public function set_awareness_state( string $room, int $client_id, array $state, int $wp_user_id ): bool { + global $wpdb; + + $update_value = wp_json_encode( + array( + 'state' => $state, + 'wp_user_id' => $wp_user_id, + ) + ); + + $result = $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->collaboration} (room, event_type, client_id, update_value, created_at) + VALUES (%s, 'awareness', %d, %s, %s) + ON DUPLICATE KEY UPDATE update_value = VALUES(update_value), created_at = VALUES(created_at)", + $room, + $client_id, + $update_value, + current_time( 'mysql', true ) + ) + ); + + return false !== $result; } } From 9572d1445e6759fed18547484f01d37ea35d8946 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 00:50:30 -0500 Subject: [PATCH 50/82] Remove write side effects from awareness read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the inline DELETE from get_awareness_state(). The WHERE clause already filters expired rows from results, so the DELETE was redundant for correctness. Removing it makes the read path truly read-only, which matters because check_permissions() calls get_awareness_state() on every poll — permission callbacks should not perform writes. Cleanup of expired rows is handled by cron (wp_delete_old_collaboration_data) and natural overwrite via the UPSERT in set_awareness_state(). --- .../class-wp-collaboration-table-storage.php | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 98de229b34706..cdeadbfe454e2 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -64,8 +64,9 @@ public function add_update( string $room, $update ): bool { /** * Gets awareness state for a given room. * - * Retrieves per-client awareness rows from the collaboration table, - * cleaning up expired entries inline. + * Retrieves per-client awareness rows from the collaboration table. + * Expired rows are filtered by the WHERE clause; actual deletion is + * handled by cron via wp_delete_old_collaboration_data(). * * @since 7.0.0 * @@ -80,16 +81,6 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); - // Clean up expired awareness rows for this room. - $wpdb->query( - $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'awareness' AND created_at < %s", - $room, - $cutoff - ) - ); - - // Fetch active awareness rows. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT client_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'awareness' AND created_at >= %s", From b2a26bcb391bd1828cd8063d21bbe628d0d40736 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 00:51:20 -0500 Subject: [PATCH 51/82] Clean up awareness implementation details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update wp_delete_old_collaboration_data() docblock to mention awareness row cleanup alongside sync-update cleanup. - Add inline comment explaining the 60-second threshold (2× the 30-second awareness timeout). - Rename test to test_collaboration_awareness_preserved_across_separate_upserts to accurately describe what sequential dispatch proves. - Update test_collaboration_expired_awareness_rows_cleaned_up to verify that expired rows are filtered from results (not returned) rather than deleted inline, and separately assert that wp_delete_old_collaboration_data() handles actual deletion. --- src/wp-includes/collaboration.php | 22 +- .../rest-api/rest-collaboration-server.php | 243 +++++++++++++++++- 2 files changed, 255 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index be4f95117fcbc..559802f599688 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -10,7 +10,7 @@ * Checks whether real-time collaboration is enabled. * * The feature requires both the site option and the database schema - * introduced in db_version 61698. + * introduced in db_version 61699. * * @since 7.0.0 * @@ -18,7 +18,7 @@ */ function wp_is_collaboration_enabled() { return get_option( 'wp_enable_real_time_collaboration' ) - && get_option( 'db_version' ) >= 61698; + && get_option( 'db_version' ) >= 61699; } /** @@ -39,10 +39,11 @@ function wp_collaboration_inject_setting() { } /** - * Deletes collaboration data older than 7 days from the collaboration table. + * Deletes stale collaboration data from the collaboration table. * - * Rows left behind by abandoned collaborative editing sessions are cleaned up - * to prevent unbounded table growth. + * Removes sync-update rows older than 7 days and awareness rows older than + * 60 seconds. Rows left behind by abandoned collaborative editing sessions + * are cleaned up to prevent unbounded table growth. * * @since 7.0.0 */ @@ -53,10 +54,19 @@ function wp_delete_old_collaboration_data() { global $wpdb; + // Clean up sync update rows older than 7 days. $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE created_at < %s", + "DELETE FROM {$wpdb->collaboration} WHERE event_type = 'sync_update' AND created_at < %s", gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) ) ); + + // Clean up awareness rows older than 60 seconds (2× the 30-second awareness timeout as a buffer). + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->collaboration} WHERE event_type = 'awareness' AND created_at < %s", + gmdate( 'Y-m-d H:i:s', time() - 60 ) + ) + ); } diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 5b4b35c342ebb..35ddde0d304cc 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1100,6 +1100,7 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = $wpdb->collaboration, array( 'room' => $this->get_post_room(), + 'event_type' => 'sync_update', 'update_value' => wp_json_encode( array( 'type' => 'update', @@ -1108,7 +1109,7 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = ), 'created_at' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), ), - array( '%s', '%s', '%s' ) + array( '%s', '%s', '%s', '%s' ) ); } @@ -1202,7 +1203,7 @@ public function test_cron_cleanup_hook_is_registered(): void { * @ticket 64696 */ public function test_collaboration_routes_not_registered_when_db_version_is_old(): void { - update_option( 'db_version', 61697 ); + update_option( 'db_version', 61698 ); // Reset the global REST server so rest_get_server() builds a fresh instance. $GLOBALS['wp_rest_server'] = null; @@ -1210,8 +1211,8 @@ public function test_collaboration_routes_not_registered_when_db_version_is_old( $server = rest_get_server(); $routes = $server->get_routes(); - $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes, 'Collaboration routes should not be registered when db_version is below 61698.' ); - $this->assertArrayNotHasKey( '/wp-sync/v1/updates', $routes, 'Deprecated sync routes should not be registered when db_version is below 61698.' ); + $this->assertArrayNotHasKey( '/wp-collaboration/v1/updates', $routes, 'Collaboration routes should not be registered when db_version is below 61699.' ); + $this->assertArrayNotHasKey( '/wp-sync/v1/updates', $routes, 'Deprecated sync routes should not be registered when db_version is below 61699.' ); // Reset again so subsequent tests get a server with the correct db_version. $GLOBALS['wp_rest_server'] = null; @@ -1279,4 +1280,238 @@ public function test_deprecated_sync_route_functions_correctly(): void { $data = $response->get_data(); $this->assertNotEmpty( $data['rooms'][0]['updates'], 'Updates sent via deprecated route should be retrievable via primary route.' ); } + + /* + * Awareness race condition tests. + */ + + /** + * Awareness state set by separate clients should be preserved across sequential dispatches. + * + * @ticket 64696 + */ + public function test_collaboration_awareness_preserved_across_separate_upserts(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // Client 2 sets awareness (simulating a concurrent request). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 2, 0, array( 'cursor' => 'pos-b' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + + $this->assertArrayHasKey( 1, $awareness, 'Client 1 awareness should be present.' ); + $this->assertArrayHasKey( 2, $awareness, 'Client 2 awareness should be present.' ); + $this->assertSame( array( 'cursor' => 'pos-a' ), $awareness[1] ); + $this->assertSame( array( 'cursor' => 'pos-b' ), $awareness[2] ); + } + + /** + * Awareness rows should not affect get_updates_after_cursor() or get_cursor(). + * + * @ticket 64696 + */ + public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets awareness (creates awareness row in table). + $response1 = $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // With no sync updates, cursor should be 0. + $data1 = $response1->get_data(); + $this->assertSame( 0, $data1['rooms'][0]['end_cursor'], 'Awareness rows should not affect the cursor.' ); + $this->assertSame( 0, $data1['rooms'][0]['total_updates'], 'Awareness rows should not count as updates.' ); + $this->assertEmpty( $data1['rooms'][0]['updates'], 'Awareness rows should not appear as updates.' ); + + // Now add a sync update. + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + $response2 = $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, 0, array( 'cursor' => 'pos-a' ), array( $update ) ), + ) + ); + + $data2 = $response2->get_data(); + $this->assertSame( 1, $data2['rooms'][0]['total_updates'], 'Only sync updates should count toward total.' ); + } + + /** + * Compaction (remove_updates_before_cursor) should not delete awareness rows. + * + * @ticket 64696 + */ + public function test_collaboration_compaction_does_not_delete_awareness_rows(): void { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 sets awareness. + $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + // Client 2 sends updates. + $updates = array(); + for ( $i = 0; $i < 5; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 2, 0, array( 'cursor' => 'pos-b' ), $updates ), + ) + ); + + $cursor = $response->get_data()['rooms'][0]['end_cursor']; + + // Client 2 sends a compaction. + $compaction = array( + 'type' => 'compaction', + 'data' => base64_encode( 'compacted' ), + ); + $this->dispatch_collaboration( + array( + $this->build_room( $room, 2, $cursor, array( 'cursor' => 'pos-b' ), array( $compaction ) ), + ) + ); + + // Client 3 checks awareness — client 1 should still be present. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 3, 0, array( 'cursor' => 'pos-c' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayHasKey( 1, $awareness, 'Client 1 awareness should survive compaction.' ); + } + + /** + * Expired awareness rows should be filtered from results but remain in the + * table until cron cleanup runs. + * + * @ticket 64696 + */ + public function test_collaboration_expired_awareness_rows_cleaned_up(): void { + wp_set_current_user( self::$editor_id ); + + global $wpdb; + + $room = $this->get_post_room(); + + // Insert an awareness row with an old timestamp directly. + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $room, + 'event_type' => 'awareness', + 'client_id' => 99, + 'update_value' => wp_json_encode( + array( + 'state' => array( 'cursor' => 'stale' ), + 'wp_user_id' => self::$editor_id, + ) + ), + 'created_at' => gmdate( 'Y-m-d H:i:s', time() - 60 ), + ), + array( '%s', '%s', '%d', '%s', '%s' ) + ); + + // Client 1 polls — the expired row should not appear in results. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, 0, array( 'cursor' => 'pos-a' ) ), + ) + ); + + $awareness = $response->get_data()['rooms'][0]['awareness']; + $this->assertArrayNotHasKey( 99, $awareness, 'Expired awareness entry should not appear.' ); + $this->assertArrayHasKey( 1, $awareness, 'Fresh client awareness should appear.' ); + + // The expired row still exists in the table (no inline DELETE on the read path). + $expired_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'awareness' AND client_id = %d", + $room, + 99 + ) + ); + $this->assertSame( 1, $expired_count, 'Expired awareness row should still exist in the table until cron runs.' ); + + // Cron cleanup removes the expired row. + wp_delete_old_collaboration_data(); + + $post_cron_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'awareness' AND client_id = %d", + $room, + 99 + ) + ); + $this->assertSame( 0, $post_cron_count, 'Expired awareness row should be deleted after cron cleanup.' ); + } + + /** + * Cron cleanup should remove expired awareness rows. + * + * @ticket 64696 + */ + public function test_cron_cleanup_deletes_expired_awareness_rows(): void { + global $wpdb; + + // Insert an awareness row older than 60 seconds. + $wpdb->insert( + $wpdb->collaboration, + array( + 'room' => $this->get_post_room(), + 'event_type' => 'awareness', + 'client_id' => 42, + 'update_value' => wp_json_encode( + array( + 'state' => array( 'cursor' => 'old' ), + 'wp_user_id' => self::$editor_id, + ) + ), + 'created_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ), + ), + array( '%s', '%s', '%d', '%s', '%s' ) + ); + + // Insert a recent sync update row (should survive). + $this->insert_collaboration_row( HOUR_IN_SECONDS ); + + $this->assertSame( 2, $this->get_collaboration_row_count() ); + + wp_delete_old_collaboration_data(); + + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Only the recent sync update row should survive cron cleanup.' ); + + // Verify the surviving row is the sync update, not the awareness row. + $surviving = $wpdb->get_var( "SELECT event_type FROM {$wpdb->collaboration}" ); + $this->assertSame( 'sync_update', $surviving, 'The surviving row should be the sync update.' ); + } } From 1010f37b20312c50aa87421d4a35072166d78c6f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 00:53:43 -0500 Subject: [PATCH 52/82] Refactor awareness to per-client storage rows --- src/wp-admin/includes/upgrade.php | 2 +- ...s-wp-http-polling-collaboration-server.php | 33 ++----------------- .../interface-wp-collaboration-storage.php | 19 +++++++---- src/wp-includes/version.php | 2 +- 4 files changed, 17 insertions(+), 39 deletions(-) diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index e49c3b7d1e6c0..a9591fac353c7 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -886,7 +886,7 @@ function upgrade_all() { upgrade_682(); } - if ( $wp_current_db_version < 61698 ) { + if ( $wp_current_db_version < 61699 ) { upgrade_700(); } diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index af7802060b833..e0053ed381ed3 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -379,40 +379,13 @@ private function can_user_collaborate_on_entity_type( string $entity_kind, strin * @return array> Map of client ID to awareness state. */ private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { - $existing_awareness = $this->storage->get_awareness_state( $room ); - $updated_awareness = array(); - $current_time = time(); - - foreach ( $existing_awareness as $entry ) { - // Remove this client's entry (it will be updated below). - if ( $client_id === $entry['client_id'] ) { - continue; - } - - // Remove entries that have expired. - if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) { - continue; - } - - $updated_awareness[] = $entry; - } - - // Add this client's awareness state. if ( null !== $awareness_update ) { - $updated_awareness[] = array( - 'client_id' => $client_id, - 'state' => $awareness_update, - 'updated_at' => $current_time, - 'wp_user_id' => get_current_user_id(), - ); + $this->storage->set_awareness_state( $room, $client_id, $awareness_update, get_current_user_id() ); } - // This action can fail, but it shouldn't fail the entire request. - $this->storage->set_awareness_state( $room, $updated_awareness ); - - // Convert to client_id => state map for response. + $entries = $this->storage->get_awareness_state( $room, self::AWARENESS_TIMEOUT ); $response = array(); - foreach ( $updated_awareness as $entry ) { + foreach ( $entries as $entry ) { $response[ $entry['client_id'] ] = $entry['state']; } diff --git a/src/wp-includes/collaboration/interface-wp-collaboration-storage.php b/src/wp-includes/collaboration/interface-wp-collaboration-storage.php index dd7c28fb78155..9550384da540e 100644 --- a/src/wp-includes/collaboration/interface-wp-collaboration-storage.php +++ b/src/wp-includes/collaboration/interface-wp-collaboration-storage.php @@ -26,12 +26,15 @@ public function add_update( string $room, $update ): bool; /** * Gets awareness state for a given room. * + * Returns entries that have been updated within the timeout window. + * * @since 7.0.0 * - * @param string $room Room identifier. - * @return array Awareness state. + * @param string $room Room identifier. + * @param int $timeout Seconds before an awareness entry is considered expired. + * @return array Awareness entries. */ - public function get_awareness_state( string $room ): array; + public function get_awareness_state( string $room, int $timeout = 30 ): array; /** * Gets the current cursor for a given room. This should return a monotonically @@ -79,13 +82,15 @@ public function get_updates_after_cursor( string $room, int $cursor ): array; public function remove_updates_before_cursor( string $room, int $cursor ): bool; /** - * Sets awareness state for a given room. + * Sets awareness state for a given client in a room. * * @since 7.0.0 * - * @param string $room Room identifier. - * @param array $awareness Serializable awareness state. + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param array $state Serializable awareness state for this client. + * @param int $wp_user_id WordPress user ID that owns this client. * @return bool True on success, false on failure. */ - public function set_awareness_state( string $room, array $awareness ): bool; + public function set_awareness_state( string $room, int $client_id, array $state, int $wp_user_id ): bool; } diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index fa886d2762f8c..55f5ebd1eeea4 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61698; +$wp_db_version = 61699; /** * Holds the TinyMCE version. From cd67aef4a7d9dd168a2151fdaefc303ed8795f84 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 15:33:13 -0500 Subject: [PATCH 53/82] Add wp_awareness table and clean up wp_collaboration schema Split awareness into its own table so the two concerns have independent schemas matching their actual usage: wp_collaboration is append-only sync updates (no client_id, no event_type), wp_awareness is per-client upsert rows with a UNIQUE KEY on (room, client_id). --- src/wp-admin/includes/schema.php | 15 +++++++++++---- src/wp-includes/class-wpdb.php | 10 ++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 4901bfc3eebfc..28cd61b395d6e 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -190,14 +190,21 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { CREATE TABLE $wpdb->collaboration ( id bigint(20) unsigned NOT NULL auto_increment, room varchar(255) NOT NULL, - event_type varchar(20) NOT NULL default 'sync_update', - client_id bigint(20) unsigned NOT NULL default 0, update_value longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), KEY room (room,id), - KEY created_at (created_at), - UNIQUE KEY awareness (room,event_type,client_id) + KEY created_at (created_at) +) $charset_collate; +CREATE TABLE $wpdb->awareness ( + id bigint(20) unsigned NOT NULL auto_increment, + room varchar(255) NOT NULL, + client_id bigint(20) unsigned NOT NULL, + update_value longtext NOT NULL, + created_at datetime NOT NULL default '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY room_client (room,client_id), + KEY created_at (created_at) ) $charset_collate;\n"; // Single site users table. The multisite flavor of the users table is handled below. diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index f4da31dc57b39..a97de7e6f9076 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -300,6 +300,7 @@ class wpdb { 'termmeta', 'commentmeta', 'collaboration', + 'awareness', ); /** @@ -414,6 +415,15 @@ class wpdb { */ public $collaboration; + /** + * WordPress Awareness table. + * + * @since 7.0.0 + * + * @var string + */ + public $awareness; + /** * WordPress Terms table. * From cdf364f20bbf67c170d10677cde90e3bdd2a5a73 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 15:33:19 -0500 Subject: [PATCH 54/82] Move awareness storage operations to wp_awareness table Update all storage class methods to target the correct table: awareness reads/writes go to wp_awareness, sync update queries drop the now-unnecessary event_type filter. Cron cleanup splits its DELETEs across both tables with their respective retention windows. --- src/wp-includes/collaboration.php | 4 +-- .../class-wp-collaboration-table-storage.php | 27 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 559802f599688..f700784220df2 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -57,7 +57,7 @@ function wp_delete_old_collaboration_data() { // Clean up sync update rows older than 7 days. $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE event_type = 'sync_update' AND created_at < %s", + "DELETE FROM {$wpdb->collaboration} WHERE created_at < %s", gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) ) ); @@ -65,7 +65,7 @@ function wp_delete_old_collaboration_data() { // Clean up awareness rows older than 60 seconds (2× the 30-second awareness timeout as a buffer). $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE event_type = 'awareness' AND created_at < %s", + "DELETE FROM {$wpdb->awareness} WHERE created_at < %s", gmdate( 'Y-m-d H:i:s', time() - 60 ) ) ); diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index cdeadbfe454e2..5ee56e0ddbc47 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -10,7 +10,7 @@ * Core class that provides an interface for storing and retrieving * updates and awareness data during a collaborative session. * - * Data is stored in the dedicated `collaboration` database table. + * Data is stored in the `collaboration` and `awareness` database tables. * * @since 7.0.0 * @@ -51,11 +51,10 @@ public function add_update( string $room, $update ): bool { $wpdb->collaboration, array( 'room' => $room, - 'event_type' => 'sync_update', 'update_value' => wp_json_encode( $update ), 'created_at' => current_time( 'mysql', true ), ), - array( '%s', '%s', '%s', '%s' ) + array( '%s', '%s', '%s' ) ); return false !== $result; @@ -64,7 +63,7 @@ public function add_update( string $room, $update ): bool { /** * Gets awareness state for a given room. * - * Retrieves per-client awareness rows from the collaboration table. + * Retrieves per-client awareness rows from the awareness table. * Expired rows are filtered by the WHERE clause; actual deletion is * handled by cron via wp_delete_old_collaboration_data(). * @@ -83,7 +82,7 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT client_id, update_value FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'awareness' AND created_at >= %s", + "SELECT client_id, update_value FROM {$wpdb->awareness} WHERE room = %s AND created_at >= %s", $room, $cutoff ) @@ -153,10 +152,10 @@ public function get_update_count( string $room ): int { public function get_updates_after_cursor( string $room, int $cursor ): array { global $wpdb; - // Snapshot the current max ID for sync_update rows in this room. + // Snapshot the current max ID for this room. $max_id = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'sync_update'", + "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->collaboration} WHERE room = %s", $room ) ); @@ -168,19 +167,19 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { return array(); } - // Count total sync_update rows for this room (used by compaction threshold logic). + // Count total rows for this room (used by compaction threshold logic). $this->room_update_counts[ $room ] = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'sync_update' AND id <= %d", + "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND id <= %d", $room, $max_id ) ); - // Fetch sync updates after the cursor up to the snapshot boundary. + // Fetch updates after the cursor up to the snapshot boundary. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'sync_update' AND id > %d AND id <= %d ORDER BY id ASC", + "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", $room, $cursor, $max_id @@ -221,7 +220,7 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool $result = $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'sync_update' AND id < %d", + "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND id < %d", $room, $cursor ) @@ -260,8 +259,8 @@ public function set_awareness_state( string $room, int $client_id, array $state, $result = $wpdb->query( $wpdb->prepare( - "INSERT INTO {$wpdb->collaboration} (room, event_type, client_id, update_value, created_at) - VALUES (%s, 'awareness', %d, %s, %s) + "INSERT INTO {$wpdb->awareness} (room, client_id, update_value, created_at) + VALUES (%s, %d, %s, %s) ON DUPLICATE KEY UPDATE update_value = VALUES(update_value), created_at = VALUES(created_at)", $room, $client_id, From 2cb8b5365fe772e32f39e607d717be49f657e70a Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 15:33:26 -0500 Subject: [PATCH 55/82] Update collaboration tests for awareness table split Clear wp_awareness in setUp, remove event_type from test inserts, add get_awareness_row_count() helper, and update cross-table assertions to count each table independently. --- .../rest-api/rest-collaboration-server.php | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 35ddde0d304cc..3175057b8f3e6 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -32,6 +32,7 @@ public function set_up() { // in the test suite. TRUNCATE implicitly commits the transaction. global $wpdb; $wpdb->query( "DELETE FROM {$wpdb->collaboration}" ); + $wpdb->query( "DELETE FROM {$wpdb->awareness}" ); } /** @@ -1100,7 +1101,6 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = $wpdb->collaboration, array( 'room' => $this->get_post_room(), - 'event_type' => 'sync_update', 'update_value' => wp_json_encode( array( 'type' => 'update', @@ -1109,7 +1109,7 @@ private function insert_collaboration_row( int $age_in_seconds, string $label = ), 'created_at' => gmdate( 'Y-m-d H:i:s', time() - $age_in_seconds ), ), - array( '%s', '%s', '%s', '%s' ) + array( '%s', '%s', '%s' ) ); } @@ -1124,6 +1124,17 @@ private function get_collaboration_row_count(): int { return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->collaboration}" ); } + /** + * Returns the number of rows in the awareness table. + * + * @return positive-int Row count. + */ + private function get_awareness_row_count(): int { + global $wpdb; + + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->awareness}" ); + } + /** * @ticket 64696 */ @@ -1423,12 +1434,11 @@ public function test_collaboration_expired_awareness_rows_cleaned_up(): void { $room = $this->get_post_room(); - // Insert an awareness row with an old timestamp directly. + // Insert an awareness row clearly older than the 60-second cron threshold. $wpdb->insert( - $wpdb->collaboration, + $wpdb->awareness, array( 'room' => $room, - 'event_type' => 'awareness', 'client_id' => 99, 'update_value' => wp_json_encode( array( @@ -1436,9 +1446,9 @@ public function test_collaboration_expired_awareness_rows_cleaned_up(): void { 'wp_user_id' => self::$editor_id, ) ), - 'created_at' => gmdate( 'Y-m-d H:i:s', time() - 60 ), + 'created_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ), ), - array( '%s', '%s', '%d', '%s', '%s' ) + array( '%s', '%d', '%s', '%s' ) ); // Client 1 polls — the expired row should not appear in results. @@ -1455,7 +1465,7 @@ public function test_collaboration_expired_awareness_rows_cleaned_up(): void { // The expired row still exists in the table (no inline DELETE on the read path). $expired_count = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'awareness' AND client_id = %d", + "SELECT COUNT(*) FROM {$wpdb->awareness} WHERE room = %s AND client_id = %d", $room, 99 ) @@ -1467,7 +1477,7 @@ public function test_collaboration_expired_awareness_rows_cleaned_up(): void { $post_cron_count = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND event_type = 'awareness' AND client_id = %d", + "SELECT COUNT(*) FROM {$wpdb->awareness} WHERE room = %s AND client_id = %d", $room, 99 ) @@ -1485,10 +1495,9 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { // Insert an awareness row older than 60 seconds. $wpdb->insert( - $wpdb->collaboration, + $wpdb->awareness, array( 'room' => $this->get_post_room(), - 'event_type' => 'awareness', 'client_id' => 42, 'update_value' => wp_json_encode( array( @@ -1498,20 +1507,18 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { ), 'created_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ), ), - array( '%s', '%s', '%d', '%s', '%s' ) + array( '%s', '%d', '%s', '%s' ) ); // Insert a recent sync update row (should survive). $this->insert_collaboration_row( HOUR_IN_SECONDS ); - $this->assertSame( 2, $this->get_collaboration_row_count() ); + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Collaboration table should have 1 sync update row.' ); + $this->assertSame( 1, $this->get_awareness_row_count(), 'Awareness table should have 1 awareness row.' ); wp_delete_old_collaboration_data(); $this->assertSame( 1, $this->get_collaboration_row_count(), 'Only the recent sync update row should survive cron cleanup.' ); - - // Verify the surviving row is the sync update, not the awareness row. - $surviving = $wpdb->get_var( "SELECT event_type FROM {$wpdb->collaboration}" ); - $this->assertSame( 'sync_update', $surviving, 'The surviving row should be the sync update.' ); + $this->assertSame( 0, $this->get_awareness_row_count(), 'Expired awareness row should be deleted after cron cleanup.' ); } } From 593deca86c7d5a7224665d02b935164e74d0cc57 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 18:09:13 -0500 Subject: [PATCH 56/82] Add wp_user_id column to wp_awareness table --- src/wp-admin/includes/schema.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 28cd61b395d6e..be5f1c003b238 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -200,6 +200,7 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { id bigint(20) unsigned NOT NULL auto_increment, room varchar(255) NOT NULL, client_id bigint(20) unsigned NOT NULL, + wp_user_id bigint(20) unsigned NOT NULL, update_value longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), From 3d4043fac0f57684e75983f510c1e6445bfc787f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 18:09:34 -0500 Subject: [PATCH 57/82] Promote wp_user_id from awareness blob to column --- .../class-wp-collaboration-table-storage.php | 20 ++++++++----------- .../rest-api/rest-collaboration-server.php | 20 ++++++------------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 5ee56e0ddbc47..894777e6086b0 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -82,7 +82,7 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT client_id, update_value FROM {$wpdb->awareness} WHERE room = %s AND created_at >= %s", + "SELECT client_id, wp_user_id, update_value FROM {$wpdb->awareness} WHERE room = %s AND created_at >= %s", $room, $cutoff ) @@ -98,8 +98,8 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { if ( json_last_error() === JSON_ERROR_NONE ) { $entries[] = array( 'client_id' => (int) $row->client_id, - 'state' => $decoded['state'], - 'wp_user_id' => $decoded['wp_user_id'], + 'state' => $decoded, + 'wp_user_id' => (int) $row->wp_user_id, ); } } @@ -250,20 +250,16 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool public function set_awareness_state( string $room, int $client_id, array $state, int $wp_user_id ): bool { global $wpdb; - $update_value = wp_json_encode( - array( - 'state' => $state, - 'wp_user_id' => $wp_user_id, - ) - ); + $update_value = wp_json_encode( $state ); $result = $wpdb->query( $wpdb->prepare( - "INSERT INTO {$wpdb->awareness} (room, client_id, update_value, created_at) - VALUES (%s, %d, %s, %s) - ON DUPLICATE KEY UPDATE update_value = VALUES(update_value), created_at = VALUES(created_at)", + "INSERT INTO {$wpdb->awareness} (room, client_id, wp_user_id, update_value, created_at) + VALUES (%s, %d, %d, %s, %s) + ON DUPLICATE KEY UPDATE wp_user_id = VALUES(wp_user_id), update_value = VALUES(update_value), created_at = VALUES(created_at)", $room, $client_id, + $wp_user_id, $update_value, current_time( 'mysql', true ) ) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 3175057b8f3e6..a19238f92c9a8 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1440,15 +1440,11 @@ public function test_collaboration_expired_awareness_rows_cleaned_up(): void { array( 'room' => $room, 'client_id' => 99, - 'update_value' => wp_json_encode( - array( - 'state' => array( 'cursor' => 'stale' ), - 'wp_user_id' => self::$editor_id, - ) - ), + 'wp_user_id' => self::$editor_id, + 'update_value' => wp_json_encode( array( 'cursor' => 'stale' ) ), 'created_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ), ), - array( '%s', '%d', '%s', '%s' ) + array( '%s', '%d', '%d', '%s', '%s' ) ); // Client 1 polls — the expired row should not appear in results. @@ -1499,15 +1495,11 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { array( 'room' => $this->get_post_room(), 'client_id' => 42, - 'update_value' => wp_json_encode( - array( - 'state' => array( 'cursor' => 'old' ), - 'wp_user_id' => self::$editor_id, - ) - ), + 'wp_user_id' => self::$editor_id, + 'update_value' => wp_json_encode( array( 'cursor' => 'old' ) ), 'created_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ), ), - array( '%s', '%d', '%s', '%s' ) + array( '%s', '%d', '%d', '%s', '%s' ) ); // Insert a recent sync update row (should survive). From 99f351c2d47f558ba96ed898e63f2cde1f12760d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 18:15:27 -0500 Subject: [PATCH 58/82] Add default values to collaboration and awareness schema columns --- src/wp-admin/includes/schema.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index be5f1c003b238..71e88a5598813 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -189,7 +189,7 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { ) $charset_collate; CREATE TABLE $wpdb->collaboration ( id bigint(20) unsigned NOT NULL auto_increment, - room varchar(255) NOT NULL, + room varchar(255) NOT NULL default '', update_value longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), @@ -198,9 +198,9 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { ) $charset_collate; CREATE TABLE $wpdb->awareness ( id bigint(20) unsigned NOT NULL auto_increment, - room varchar(255) NOT NULL, - client_id bigint(20) unsigned NOT NULL, - wp_user_id bigint(20) unsigned NOT NULL, + room varchar(255) NOT NULL default '', + client_id bigint(20) unsigned NOT NULL default '0', + wp_user_id bigint(20) unsigned NOT NULL default '0', update_value longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), From 83d81e927acca2a4d345c318eed3d9e30c1b22fb Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 18:18:31 -0500 Subject: [PATCH 59/82] Limit room column to varchar($max_index_length) for utf8mb4 index compatibility --- src/wp-admin/includes/schema.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 71e88a5598813..6457626e999f4 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -189,7 +189,7 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { ) $charset_collate; CREATE TABLE $wpdb->collaboration ( id bigint(20) unsigned NOT NULL auto_increment, - room varchar(255) NOT NULL default '', + room varchar($max_index_length) NOT NULL default '', update_value longtext NOT NULL, created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), @@ -198,7 +198,7 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { ) $charset_collate; CREATE TABLE $wpdb->awareness ( id bigint(20) unsigned NOT NULL auto_increment, - room varchar(255) NOT NULL default '', + room varchar($max_index_length) NOT NULL default '', client_id bigint(20) unsigned NOT NULL default '0', wp_user_id bigint(20) unsigned NOT NULL default '0', update_value longtext NOT NULL, From 0166643f3e1b1d85eb83a28b718bead1f9954388 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 21:27:42 -0500 Subject: [PATCH 60/82] Fix presence e2e test locators and leave-detection timing --- .../collaboration-presence.test.js | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/tests/e2e/specs/collaboration/collaboration-presence.test.js b/tests/e2e/specs/collaboration/collaboration-presence.test.js index 820a84065d543..4ad5e1cc40c32 100644 --- a/tests/e2e/specs/collaboration/collaboration-presence.test.js +++ b/tests/e2e/specs/collaboration/collaboration-presence.test.js @@ -56,12 +56,13 @@ test.describe( 'Collaboration - Presence', () => { await presenceButton.click(); // The popover should list both collaborators by name. + // Use the presence list item class to avoid matching snackbar toasts. await expect( - page.getByText( 'Test Collaborator' ) + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Test Collaborator' } ) ).toBeVisible(); await expect( - page.getByText( 'Another Collaborator' ) + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Another Collaborator' } ) ).toBeVisible(); } ); @@ -78,29 +79,31 @@ test.describe( 'Collaboration - Presence', () => { page.getByRole( 'button', { name: /Collaborators list/ } ) ).toBeVisible( { timeout: SYNC_TIMEOUT } ); - // Close User C's context to simulate leaving. - await collaborationUtils.page3.close(); - - // After the awareness timeout (30s), User A and B should see - // the collaborators list update. The button may still be visible - // but should reflect only 1 remaining collaborator. - // We verify by opening the popover and checking that User C's - // name is no longer listed. - await expect( async () => { - const presenceButton = page.getByRole( 'button', { - name: /Collaborators list/, - } ); - await presenceButton.click(); - - // "Another Collaborator" (User C) should no longer appear. - await expect( - page.getByText( 'Another Collaborator' ) - ).not.toBeVisible(); - - // "Test Collaborator" (User B) should still be listed. - await expect( - page.getByText( 'Test Collaborator' ) - ).toBeVisible(); - } ).toPass( { timeout: 45000 } ); + // Navigate User C away from the editor to stop their polling. + // Avoids closing the context directly which corrupts Playwright state. + await collaborationUtils.page3.goto( '/wp-admin/' ); + + // Wait for User C's awareness entry to expire on the server (30s timeout) + // by watching the button label drop from 3 to 2 collaborators. + const presenceButton = page.getByRole( 'button', { + name: /Collaborators list/, + } ); + await expect( presenceButton ).toHaveAccessibleName( + /1 online/, + { timeout: 45000 } + ); + + // Open the popover once, then verify the list contents. + await presenceButton.click(); + + // "Another Collaborator" (User C) should no longer appear in the presence list. + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Another Collaborator' } ) + ).not.toBeVisible(); + + // "Test Collaborator" (User B) should still be listed. + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { hasText: 'Test Collaborator' } ) + ).toBeVisible(); } ); } ); From dea267217ce2d972950909ca046d524fc45e2919 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 21:31:04 -0500 Subject: [PATCH 61/82] Tighten awareness PHPDoc types with phpstan type aliases --- .../class-wp-collaboration-table-storage.php | 13 ++++++++----- .../interface-wp-collaboration-storage.php | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 894777e6086b0..8c62cc1ab0470 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -15,6 +15,8 @@ * @since 7.0.0 * * @access private + * + * @phpstan-import-type AwarenessState from WP_Collaboration_Storage */ class WP_Collaboration_Table_Storage implements WP_Collaboration_Storage { /** @@ -73,7 +75,8 @@ public function add_update( string $room, $update ): bool { * * @param string $room Room identifier. * @param int $timeout Seconds before an awareness entry is considered expired. - * @return array Awareness entries. + * @return array Awareness entries. + * @phpstan-return array */ public function get_awareness_state( string $room, int $timeout = 30 ): array { global $wpdb; @@ -241,10 +244,10 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool * * @global wpdb $wpdb WordPress database abstraction object. * - * @param string $room Room identifier. - * @param int $client_id Client identifier. - * @param array $state Serializable awareness state for this client. - * @param int $wp_user_id WordPress user ID that owns this client. + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param array $state Serializable awareness state for this client. + * @param int $wp_user_id WordPress user ID that owns this client. * @return bool True on success, false on failure. */ public function set_awareness_state( string $room, int $client_id, array $state, int $wp_user_id ): bool { diff --git a/src/wp-includes/collaboration/interface-wp-collaboration-storage.php b/src/wp-includes/collaboration/interface-wp-collaboration-storage.php index 9550384da540e..13dec0d8da96a 100644 --- a/src/wp-includes/collaboration/interface-wp-collaboration-storage.php +++ b/src/wp-includes/collaboration/interface-wp-collaboration-storage.php @@ -10,6 +10,8 @@ * Interface for collaboration storage backends used by the collaborative editing server. * * @since 7.0.0 + * + * @phpstan-type AwarenessState array{client_id: int, state: array, wp_user_id: int} */ interface WP_Collaboration_Storage { /** @@ -32,7 +34,8 @@ public function add_update( string $room, $update ): bool; * * @param string $room Room identifier. * @param int $timeout Seconds before an awareness entry is considered expired. - * @return array Awareness entries. + * @return array Awareness entries. + * @phpstan-return array */ public function get_awareness_state( string $room, int $timeout = 30 ): array; @@ -86,10 +89,10 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool; * * @since 7.0.0 * - * @param string $room Room identifier. - * @param int $client_id Client identifier. - * @param array $state Serializable awareness state for this client. - * @param int $wp_user_id WordPress user ID that owns this client. + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param array $state Serializable awareness state for this client. + * @param int $wp_user_id WordPress user ID that owns this client. * @return bool True on success, false on failure. */ public function set_awareness_state( string $room, int $client_id, array $state, int $wp_user_id ): bool; From e02238041fdb18a1055430705288227962397fde Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 21:32:45 -0500 Subject: [PATCH 62/82] Use is_array() for awareness state validation instead of json_last_error() --- .../collaboration/class-wp-collaboration-table-storage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 8c62cc1ab0470..7fa513d5de43a 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -98,7 +98,7 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { $entries = array(); foreach ( $rows as $row ) { $decoded = json_decode( $row->update_value, true ); - if ( json_last_error() === JSON_ERROR_NONE ) { + if ( is_array( $decoded ) ) { $entries[] = array( 'client_id' => (int) $row->client_id, 'state' => $decoded, From 55e94dec383fd53a1fbe675f2f54a65039771ebe Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 21:33:02 -0500 Subject: [PATCH 63/82] Update src/wp-includes/collaboration/class-wp-collaboration-table-storage.php Co-authored-by: Weston Ruter --- .../collaboration/class-wp-collaboration-table-storage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 894777e6086b0..30209209c190c 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -95,7 +95,7 @@ public function get_awareness_state( string $room, int $timeout = 30 ): array { $entries = array(); foreach ( $rows as $row ) { $decoded = json_decode( $row->update_value, true ); - if ( json_last_error() === JSON_ERROR_NONE ) { + if ( is_array( $decoded ) ) { $entries[] = array( 'client_id' => (int) $row->client_id, 'state' => $decoded, From 3388e7e7ff6bd870f741499dea435bc22179c9a4 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 21:35:26 -0500 Subject: [PATCH 64/82] Fix PHPCS equals sign alignment in collaboration tests --- tests/phpunit/tests/rest-api/rest-collaboration-server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index a19238f92c9a8..295178fa97e78 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1352,7 +1352,7 @@ public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { $this->assertEmpty( $data1['rooms'][0]['updates'], 'Awareness rows should not appear as updates.' ); // Now add a sync update. - $update = array( + $update = array( 'type' => 'update', 'data' => 'dGVzdA==', ); From 6a57da5afddb699f1159037c57c79bd5edeb0da8 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 21:54:16 -0500 Subject: [PATCH 65/82] Bump db_version to 61700 for awareness schema changes --- src/wp-includes/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 55f5ebd1eeea4..f91211e6baa89 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61699; +$wp_db_version = 61700; /** * Holds the TinyMCE version. From d15ef62cdeef36918d92fb95f2cf9c921850ebee Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 6 Mar 2026 22:51:44 -0500 Subject: [PATCH 66/82] Fix room maxLength mismatch and add collaboration test coverage Replace the hardcoded maxLength schema property with an inline length check in check_permissions() that matches $max_index_length (191) from wp-admin/includes/schema.php. Add tests for wp_user_id column storage, is_array() awareness guard, room name rejection, and null awareness. --- ...s-wp-http-polling-collaboration-server.php | 16 ++- .../rest-api/rest-collaboration-server.php | 133 ++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index e0053ed381ed3..433d852fd64bd 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -132,10 +132,9 @@ public function register_routes(): void { 'type' => 'integer', ), 'room' => array( - 'required' => true, - 'type' => 'string', - 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', - 'maxLength' => 255, // The size of the wp_collaboration.room column. + 'required' => true, + 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', ), 'updates' => array( 'items' => $typed_update_args, @@ -207,6 +206,15 @@ public function check_permissions( WP_REST_Request $request ) { $client_id = $room['client_id']; $room = $room['room']; + // Matches $max_index_length in wp-admin/includes/schema.php. + if ( mb_strlen( $room ) > 191 ) { + return new WP_Error( + 'rest_collaboration_room_too_long', + __( 'Room name is too long.' ), + array( 'status' => 400 ) + ); + } + // Check that the client_id is not already owned by another user. $existing_awareness = $this->storage->get_awareness_state( $room ); foreach ( $existing_awareness as $entry ) { diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 295178fa97e78..6202e1f112791 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1513,4 +1513,137 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { $this->assertSame( 1, $this->get_collaboration_row_count(), 'Only the recent sync update row should survive cron cleanup.' ); $this->assertSame( 0, $this->get_awareness_row_count(), 'Expired awareness row should be deleted after cron cleanup.' ); } + + /** + * Verifies that wp_user_id is stored as a dedicated column, + * not embedded inside the update_value JSON blob. + * + * @ticket 63 + */ + public function test_collaboration_awareness_wp_user_id_round_trip() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $rooms = array( $this->build_room( $room, 1, 0, array( 'cursor' => array( 'x' => 10 ) ) ) ); + + $response = $this->dispatch_collaboration( $rooms ); + $this->assertSame( 200, $response->get_status(), 'Dispatch should succeed.' ); + + // Query the awareness table directly. + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT wp_user_id, update_value FROM {$wpdb->awareness} WHERE room = %s AND client_id = %d", + $room, + 1 + ) + ); + + $this->assertNotNull( $row, 'Awareness row should exist.' ); + $this->assertSame( self::$editor_id, (int) $row->wp_user_id, 'wp_user_id column should match the editor.' ); + $this->assertStringNotContainsString( 'wp_user_id', $row->update_value, 'update_value should not contain wp_user_id.' ); + } + + /** + * Verifies that the is_array() guard in get_awareness_state() skips + * rows where update_value contains valid JSON that is not an array. + * + * @ticket 63 + */ + public function test_collaboration_awareness_non_array_json_ignored() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Insert a malformed awareness row with a JSON string (not an array). + $wpdb->insert( + $wpdb->awareness, + array( + 'room' => $room, + 'client_id' => 99, + 'wp_user_id' => self::$editor_id, + 'update_value' => '"hello"', + 'created_at' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%d', '%d', '%s', '%s' ) + ); + + // Dispatch as a different client so the response includes other clients' awareness. + $rooms = array( $this->build_room( $room, 2, 0, array( 'cursor' => 'here' ) ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + + $awareness = $data['rooms'][0]['awareness']; + + $this->assertArrayNotHasKey( 99, $awareness, 'Non-array JSON row should not appear in awareness.' ); + $this->assertArrayHasKey( 2, $awareness, 'The dispatching client should appear in awareness.' ); + } + + /** + * Validates that REST rejects room names exceeding the column width (191 chars). + * + * @ticket 63 + */ + public function test_collaboration_room_name_max_length_rejected() { + wp_set_current_user( self::$editor_id ); + + // 192 characters: 'postType/' (9) + 183 chars. + $long_room = 'postType/' . str_repeat( 'a', 183 ); + $this->assertSame( 192, strlen( $long_room ), 'Room name should be 192 characters.' ); + + $rooms = array( $this->build_room( $long_room ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 400, $response->get_status(), 'REST should reject room names exceeding 191 characters.' ); + } + + /** + * Verifies that sending awareness as null reads existing state without writing. + * + * @ticket 63 + */ + public function test_collaboration_null_awareness_skips_write() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 dispatches with awareness state (writes a row). + $rooms = array( $this->build_room( $room, 1, 0, array( 'cursor' => 'active' ) ) ); + $this->dispatch_collaboration( $rooms ); + + // Client 2 dispatches with awareness = null (should not write). + $request = new WP_REST_Request( 'POST', '/wp-collaboration/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => null, + 'client_id' => 2, + 'room' => $room, + 'updates' => array(), + ), + ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'Null awareness dispatch should succeed.' ); + + // Assert awareness table has exactly 1 row (client 1 only). + $row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->awareness}" ); + $this->assertSame( 1, $row_count, 'Only client 1 should have an awareness row.' ); + + // Assert response still contains client 1's awareness (read still works). + $data = $response->get_data(); + $awareness = $data['rooms'][0]['awareness']; + $this->assertArrayHasKey( 1, $awareness, 'Client 1 awareness should be readable by client 2.' ); + $this->assertSame( array( 'cursor' => 'active' ), $awareness[1], 'Client 1 awareness state should match.' ); + } } From 2d1f4ad538147b5a3724e8a65f65f4cb1b2dffb2 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 09:01:45 -0500 Subject: [PATCH 67/82] Move room length validation to REST schema and add boundary test Use maxLength in the REST schema instead of an inline check in check_permissions. Schema validation runs before both permission and request callbacks, which is the correct layer for input validation. Add a boundary test confirming 191-char room names are accepted. --- ...ss-wp-http-polling-collaboration-server.php | 16 ++++------------ .../rest-api/rest-collaboration-server.php | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 433d852fd64bd..f9a917ad44fd7 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -132,9 +132,10 @@ public function register_routes(): void { 'type' => 'integer', ), 'room' => array( - 'required' => true, - 'type' => 'string', - 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'required' => true, + 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + 'maxLength' => 191, // Matches $max_index_length in wp-admin/includes/schema.php. ), 'updates' => array( 'items' => $typed_update_args, @@ -206,15 +207,6 @@ public function check_permissions( WP_REST_Request $request ) { $client_id = $room['client_id']; $room = $room['room']; - // Matches $max_index_length in wp-admin/includes/schema.php. - if ( mb_strlen( $room ) > 191 ) { - return new WP_Error( - 'rest_collaboration_room_too_long', - __( 'Room name is too long.' ), - array( 'status' => 400 ) - ); - } - // Check that the client_id is not already owned by another user. $existing_awareness = $this->storage->get_awareness_state( $room ); foreach ( $existing_awareness as $entry ) { diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 6202e1f112791..b89fdab6ce278 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1584,6 +1584,24 @@ public function test_collaboration_awareness_non_array_json_ignored() { $this->assertArrayHasKey( 2, $awareness, 'The dispatching client should appear in awareness.' ); } + /** + * Validates that REST accepts room names at the column width boundary (191 chars). + * + * @ticket 63 + */ + public function test_collaboration_room_name_at_max_length_accepted() { + wp_set_current_user( self::$editor_id ); + + // 191 characters using a collection room: 'root/' (5) + 186 chars. + $room = 'root/' . str_repeat( 'a', 186 ); + $this->assertSame( 191, strlen( $room ), 'Room name should be 191 characters.' ); + + $rooms = array( $this->build_room( $room ) ); + $response = $this->dispatch_collaboration( $rooms ); + + $this->assertSame( 200, $response->get_status(), 'REST should accept room names at 191 characters.' ); + } + /** * Validates that REST rejects room names exceeding the column width (191 chars). * From bb00912bd684c2e6da3cf4eb1837ed9d1b66cee8 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 09:05:49 -0500 Subject: [PATCH 68/82] Fix @ticket annotations to use correct Trac ticket 64696 --- .../tests/rest-api/rest-collaboration-server.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index b89fdab6ce278..75c2a8252233f 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1518,7 +1518,7 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { * Verifies that wp_user_id is stored as a dedicated column, * not embedded inside the update_value JSON blob. * - * @ticket 63 + * @ticket 64696 */ public function test_collaboration_awareness_wp_user_id_round_trip() { global $wpdb; @@ -1549,7 +1549,7 @@ public function test_collaboration_awareness_wp_user_id_round_trip() { * Verifies that the is_array() guard in get_awareness_state() skips * rows where update_value contains valid JSON that is not an array. * - * @ticket 63 + * @ticket 64696 */ public function test_collaboration_awareness_non_array_json_ignored() { global $wpdb; @@ -1587,7 +1587,7 @@ public function test_collaboration_awareness_non_array_json_ignored() { /** * Validates that REST accepts room names at the column width boundary (191 chars). * - * @ticket 63 + * @ticket 64696 */ public function test_collaboration_room_name_at_max_length_accepted() { wp_set_current_user( self::$editor_id ); @@ -1605,7 +1605,7 @@ public function test_collaboration_room_name_at_max_length_accepted() { /** * Validates that REST rejects room names exceeding the column width (191 chars). * - * @ticket 63 + * @ticket 64696 */ public function test_collaboration_room_name_max_length_rejected() { wp_set_current_user( self::$editor_id ); @@ -1623,7 +1623,7 @@ public function test_collaboration_room_name_max_length_rejected() { /** * Verifies that sending awareness as null reads existing state without writing. * - * @ticket 63 + * @ticket 64696 */ public function test_collaboration_null_awareness_skips_write() { global $wpdb; From 0a7c6beedb6d2dddb9be03cf8f73c7fb7dccd80d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 09:25:33 -0500 Subject: [PATCH 69/82] Bump db_version to 61834 for collaboration schema changes --- src/wp-includes/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 095edd7dda7f5..fc611efea3f9d 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61833; +$wp_db_version = 61834; /** * Holds the TinyMCE version. From 8ce0822e4ac2abe2be232e30a87ae734d50b6d36 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 10:14:01 -0500 Subject: [PATCH 70/82] Collaboration: Merge MAX and COUNT into a single snapshot query get_updates_after_cursor() ran a MAX(id) query on every call, followed by a separate COUNT(*) query only when updates existed (max_id > cursor). Both scan the same index range and can be combined into a single SELECT that returns both values unconditionally. Adds a null guard on the get_row() result to handle DB failures safely. Saves 1 query per room when updates exist. --- .../class-wp-collaboration-table-storage.php | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 7fa513d5de43a..086cac92a93bd 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -140,9 +140,9 @@ public function get_update_count( string $room ): int { /** * Retrieves updates from a room after a given cursor. * - * Uses a snapshot approach: captures MAX(id) first, then fetches rows - * WHERE id > cursor AND id <= max_id. Updates arriving after the snapshot - * are deferred to the next poll, never lost. + * Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single + * query, then fetches rows WHERE id > cursor AND id <= max_id. Updates + * arriving after the snapshot are deferred to the next poll, never lost. * * @since 7.0.0 * @@ -155,14 +155,23 @@ public function get_update_count( string $room ): int { public function get_updates_after_cursor( string $room, int $cursor ): array { global $wpdb; - // Snapshot the current max ID for this room. - $max_id = (int) $wpdb->get_var( + // Snapshot the current max ID and total row count in a single query. + $snapshot = $wpdb->get_row( $wpdb->prepare( - "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->collaboration} WHERE room = %s", + "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s", $room ) ); + if ( ! $snapshot ) { + $this->room_cursors[ $room ] = 0; + $this->room_update_counts[ $room ] = 0; + return array(); + } + + $max_id = (int) $snapshot->max_id; + $total = (int) $snapshot->total; + $this->room_cursors[ $room ] = $max_id; if ( 0 === $max_id || $max_id <= $cursor ) { @@ -170,14 +179,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { return array(); } - // Count total rows for this room (used by compaction threshold logic). - $this->room_update_counts[ $room ] = (int) $wpdb->get_var( - $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND id <= %d", - $room, - $max_id - ) - ); + $this->room_update_counts[ $room ] = $total; // Fetch updates after the cursor up to the snapshot boundary. $rows = $wpdb->get_results( From 0d77c08c0070a8e9dff2239553645bcd9d25197f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 10:14:13 -0500 Subject: [PATCH 71/82] Collaboration: Deduplicate awareness query for client_id ownership check check_permissions() called get_awareness_state() to validate client_id ownership, then process_awareness_update() called the exact same query to build the response. Both used the same 30s timeout. Moves the ownership check into process_awareness_update() so a single get_awareness_state() call serves both purposes. The method now returns WP_Error on ownership violations, checked in handle_request(). The current client's awareness state is run through wp_json_encode / json_decode to match the round-trip path other clients' states take through the DB, preventing inconsistencies from encoding normalization. Note: ownership validation now runs per-room inside handle_request() rather than for all rooms upfront in check_permissions(). In a multi-room request where room 2 has a conflict, room 1's awareness upsert will have already completed. This is acceptable because awareness upserts are idempotent heartbeats. Saves 1 query per room per poll. --- ...s-wp-http-polling-collaboration-server.php | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index f9a917ad44fd7..3c3b615f46c55 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -200,24 +200,10 @@ public function check_permissions( WP_REST_Request $request ) { ); } - $rooms = $request['rooms']; - $wp_user_id = get_current_user_id(); + $rooms = $request['rooms']; foreach ( $rooms as $room ) { - $client_id = $room['client_id']; - $room = $room['room']; - - // Check that the client_id is not already owned by another user. - $existing_awareness = $this->storage->get_awareness_state( $room ); - foreach ( $existing_awareness as $entry ) { - if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['wp_user_id'] ) { - return new WP_Error( - 'rest_cannot_edit', - __( 'Client ID is already in use by another user.' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - } + $room = $room['room']; $type_parts = explode( '/', $room, 2 ); $object_parts = explode( ':', $type_parts[1] ?? '', 2 ); @@ -263,9 +249,13 @@ public function handle_request( WP_REST_Request $request ) { $cursor = $room_request['after']; $room = $room_request['room']; - // Merge awareness state. + // Merge awareness state (also validates client_id ownership). $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness ); + if ( is_wp_error( $merged_awareness ) ) { + return $merged_awareness; + } + // The lowest client ID is nominated to perform compaction when needed. $is_compactor = false; if ( count( $merged_awareness ) > 0 ) { @@ -371,24 +361,51 @@ private function can_user_collaborate_on_entity_type( string $entity_kind, strin /** * Processes and stores an awareness update from a client. * + * Also validates that the client_id is not already owned by another user. + * This check uses the same get_awareness_state() query that builds the + * response, eliminating a duplicate query that was previously performed + * in check_permissions(). + * * @since 7.0.0 * * @param string $room Room identifier. * @param int $client_id Client identifier. * @param array|null $awareness_update Awareness state sent by the client. - * @return array> Map of client ID to awareness state. + * @return array>|WP_Error Map of client ID to awareness state, or WP_Error if client_id is owned by another user. */ - private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { + private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ) { + $wp_user_id = get_current_user_id(); + + // Check ownership before upserting so a hijacked client_id is rejected. + $entries = $this->storage->get_awareness_state( $room, self::AWARENESS_TIMEOUT ); + + foreach ( $entries as $entry ) { + if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['wp_user_id'] ) { + return new WP_Error( + 'rest_cannot_edit', + __( 'Client ID is already in use by another user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + if ( null !== $awareness_update ) { - $this->storage->set_awareness_state( $room, $client_id, $awareness_update, get_current_user_id() ); + $this->storage->set_awareness_state( $room, $client_id, $awareness_update, $wp_user_id ); } - $entries = $this->storage->get_awareness_state( $room, self::AWARENESS_TIMEOUT ); $response = array(); foreach ( $entries as $entry ) { $response[ $entry['client_id'] ] = $entry['state']; } + // Other clients' states were decoded from the DB. Run the current + // client's state through the same encode/decode path so the response + // is consistent — wp_json_encode may normalize values (e.g. strip + // invalid UTF-8) that would otherwise differ on the next poll. + if ( null !== $awareness_update ) { + $response[ $client_id ] = json_decode( wp_json_encode( $awareness_update ), true ); + } + return $response; } From f27568a0413a8cac66581ece93114245a903f411 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 10:14:27 -0500 Subject: [PATCH 72/82] Collaboration: Add composite index for awareness room+created_at query The awareness SELECT filters on WHERE room = %s AND created_at >= %s. The existing UNIQUE KEY room_client covers room but not created_at, requiring a row filter after the index lookup. Adds KEY room_created_at (room, created_at) so both conditions are satisfied directly from the index. The existing KEY created_at is retained for cron cleanup queries that filter on created_at alone. Bumps db_version to 61835. --- src/wp-admin/includes/schema.php | 1 + src/wp-includes/version.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 6457626e999f4..d72788ce14db6 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -205,6 +205,7 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { created_at datetime NOT NULL default '0000-00-00 00:00:00', PRIMARY KEY (id), UNIQUE KEY room_client (room,client_id), + KEY room_created_at (room,created_at), KEY created_at (created_at) ) $charset_collate;\n"; diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index fc611efea3f9d..6a3d8b997ec72 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61834; +$wp_db_version = 61835; /** * Holds the TinyMCE version. From 8f642683a252546d59e5871358b66fe6bc21cbaf Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 10:14:34 -0500 Subject: [PATCH 73/82] Tests: Add idle poll query count assertion for collaboration endpoint Asserts that an idle poll (no new updates) uses at most 3 queries per room: awareness upsert, awareness read with ownership check, and the combined MAX+COUNT snapshot query. --- .../rest-api/rest-collaboration-server.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 75c2a8252233f..c2c64e812a336 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1664,4 +1664,52 @@ public function test_collaboration_null_awareness_skips_write() { $this->assertArrayHasKey( 1, $awareness, 'Client 1 awareness should be readable by client 2.' ); $this->assertSame( array( 'cursor' => 'active' ), $awareness[1], 'Client 1 awareness state should match.' ); } + + /* + * Query count tests. + */ + + /** + * An idle poll (no new updates) should use at most 3 queries per room: + * 1. INSERT … ON DUPLICATE KEY UPDATE (awareness upsert) + * 2. SELECT … FROM awareness (awareness read + ownership check) + * 3. SELECT MAX(id), COUNT(*) FROM collaboration (snapshot + count) + * + * @ticket 64696 + */ + public function test_collaboration_idle_poll_query_count(): void { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Prime awareness so subsequent polls are idle heartbeats. + $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'test' ) ), + ) + ); + + $cursor = 0; + + // Count queries for an idle poll (no updates to fetch). + $queries_before = $wpdb->num_queries; + + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, $cursor, array( 'user' => 'test' ) ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Idle poll should succeed.' ); + + $query_count = $wpdb->num_queries - $queries_before; + + $this->assertLessThanOrEqual( + 3, + $query_count, + sprintf( 'Idle poll should use at most 3 queries per room, used %d.', $query_count ) + ); + } } From 540a003b271db56965656a40bbaea7260a60da03 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 10:14:44 -0500 Subject: [PATCH 74/82] Collaboration: Simplify benchmark output and fix stale EXPLAIN queries - Replace per-step log lines with a WP-CLI progress bar - Drop STD/MAD columns and their helper functions; Median+P95 suffices - Remove 100k scale (unrealistic given compaction threshold of 50) - Update EXPLAIN section to reflect the merged MAX+COUNT query - Add buffer pool priming after compaction re-seeds to eliminate cold start variance in measured iterations - Shorten section headers with plain-English descriptions --- .../scripts/collaboration-perf/run.php | 35 +++--- .../scripts/collaboration-perf/utils.php | 111 ++++-------------- 2 files changed, 45 insertions(+), 101 deletions(-) diff --git a/tools/local-env/scripts/collaboration-perf/run.php b/tools/local-env/scripts/collaboration-perf/run.php index b92e7a85695c7..3f1d95ae75d69 100644 --- a/tools/local-env/scripts/collaboration-perf/run.php +++ b/tools/local-env/scripts/collaboration-perf/run.php @@ -2,8 +2,8 @@ /** * Performance benchmark for WP_Collaboration_Table_Storage at scale. * - * Measures idle poll, catch-up poll, and compaction at 100, 1,000, 10,000, - * and 100,000 rows to verify that queries hold up under load. + * Measures idle poll, catch-up poll, and compaction at 100, 1,000, and + * 10,000 rows to verify that queries hold up under load. * * Usage: * npm run test:performance:collaboration @@ -19,7 +19,7 @@ // Configuration // ============================================================ -$scales = array( 100, 1000, 10000, 100000 ); +$scales = array( 100, 1000, 10000 ); $rooms_per_scale = 10; $measured_iterations = 50; $warmup_iterations = 5; @@ -52,26 +52,26 @@ $results = array(); WP_CLI::log( '' ); -WP_CLI::log( WP_CLI::colorize( '%_Collaboration Storage Performance Benchmark%n' ) ); -WP_CLI::log( "Backend: WP_Collaboration_Table_Storage" ); -WP_CLI::log( "Iterations: {$measured_iterations} measured + {$warmup_iterations} warm-up" ); -WP_CLI::log( "Compaction: {$compaction_iterations} measured (re-seed each)" ); +WP_CLI::log( WP_CLI::colorize( '%_Collaboration Storage Benchmark%n' ) ); +WP_CLI::log( 'Measures database speed for real-time collaborative editing.' ); +WP_CLI::log( "Each row = one edit stored for a post being co-edited." ); +WP_CLI::log( "{$measured_iterations} iterations ({$warmup_iterations} warm-up), {$compaction_iterations} compaction (re-seeded)" ); WP_CLI::log( '' ); +$total_steps = count( $scales ) * 4; // seed + idle + catch-up + compaction per scale. +$progress = \WP_CLI\Utils\make_progress_bar( 'Benchmarking', $total_steps ); + foreach ( $scales as $scale ) { $per_room = (int) ceil( $scale / $rooms_per_scale ); - $label = number_format( $scale ); - WP_CLI::log( "Scale: {$label} total rows ({$per_room} per room)" ); - WP_CLI::log( ' Seeding table...' ); collaboration_perf_seed_table( $scale, $rooms_per_scale ); + $progress->tick(); $primer = new WP_Collaboration_Table_Storage(); $primer->get_updates_after_cursor( $target_room, 0 ); $table_idle_cursor = $primer->get_cursor( $target_room ); // Idle poll. - WP_CLI::log( ' Idle poll...' ); $results['idle_poll'][ $scale ] = collaboration_perf_stats( function () use ( $target_room, $table_idle_cursor ) { $s = new WP_Collaboration_Table_Storage(); @@ -80,9 +80,9 @@ function () use ( $target_room, $table_idle_cursor ) { $measured_iterations, $warmup_iterations ); + $progress->tick(); // Catch-up poll. - WP_CLI::log( ' Catch-up poll...' ); $results['catchup_poll'][ $scale ] = collaboration_perf_stats( function () use ( $target_room ) { $s = new WP_Collaboration_Table_Storage(); @@ -91,14 +91,19 @@ function () use ( $target_room ) { $measured_iterations, $warmup_iterations ); + $progress->tick(); // Compaction. - WP_CLI::log( ' Compaction...' ); $compaction_times = array(); for ( $ci = 0; $ci < $compaction_iterations; $ci++ ) { collaboration_perf_seed_table( $scale, $rooms_per_scale ); + // Prime the buffer pool after re-seed so the DELETE measures + // query speed, not cold-cache warming. + $primer = new WP_Collaboration_Table_Storage(); + $primer->get_updates_after_cursor( $target_room, 0 ); + $compaction_cursor_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->collaboration} WHERE room = %s ORDER BY id ASC LIMIT 1 OFFSET %d", $target_room, @@ -112,13 +117,15 @@ function () use ( $target_room ) { } $results['compaction'][ $scale ] = collaboration_perf_compute_stats( $compaction_times ); + $progress->tick(); } +$progress->finish(); + // ============================================================ // EXPLAIN analysis at largest scale // ============================================================ -WP_CLI::log( 'Collecting EXPLAIN analysis...' ); $explain_data = collaboration_perf_collect_explains( $target_room, end( $scales ), $rooms_per_scale ); // ============================================================ diff --git a/tools/local-env/scripts/collaboration-perf/utils.php b/tools/local-env/scripts/collaboration-perf/utils.php index 382eeecf1b529..0be20d23ea60e 100644 --- a/tools/local-env/scripts/collaboration-perf/utils.php +++ b/tools/local-env/scripts/collaboration-perf/utils.php @@ -2,9 +2,6 @@ /** * Shared statistics, formatting, seeding, and cleanup utilities for collaboration storage benchmarks. * - * PHP equivalents of the functions in tests/performance/utils.js - * (median, standardDeviation, medianAbsoluteDeviation). - * * @package WordPress */ @@ -23,34 +20,6 @@ function collaboration_perf_median( array $arr ): float { : $arr[ $mid ]; } -/** - * Computes the standard deviation of an array of numbers. - * - * @param float[] $arr Array of numbers. - * @return float Standard deviation. - */ -function collaboration_perf_sd( array $arr ): float { - $count = count( $arr ); - $mean = array_sum( $arr ) / $count; - $sum_sq = 0.0; - foreach ( $arr as $v ) { - $sum_sq += ( $v - $mean ) ** 2; - } - return sqrt( $sum_sq / $count ); -} - -/** - * Computes the median absolute deviation of an array of numbers. - * - * @param float[] $arr Array of numbers. - * @return float Median absolute deviation. - */ -function collaboration_perf_mad( array $arr ): float { - $med = collaboration_perf_median( $arr ); - $deviations = array_map( fn( $v ) => abs( $v - $med ), $arr ); - return collaboration_perf_median( $deviations ); -} - /** * Computes the 95th percentile of an array of numbers. * @@ -64,17 +33,15 @@ function collaboration_perf_p95( array $arr ): float { } /** - * Computes median, P95, standard deviation, and MAD. + * Computes median and P95. * * @param float[] $times Array of durations in milliseconds. - * @return array{ median: float, p95: float, sd: float, mad: float } + * @return array{ median: float, p95: float } */ function collaboration_perf_compute_stats( array $times ): array { return array( 'median' => collaboration_perf_median( $times ), 'p95' => collaboration_perf_p95( $times ), - 'sd' => collaboration_perf_sd( $times ), - 'mad' => collaboration_perf_mad( $times ), ); } @@ -84,7 +51,7 @@ function collaboration_perf_compute_stats( array $times ): array { * @param callable $callback Function to benchmark. * @param int $measured Number of measured iterations. * @param int $warmup Number of warm-up iterations (discarded). - * @return array{ median: float, p95: float, sd: float, mad: float } + * @return array{ median: float, p95: float } */ function collaboration_perf_stats( callable $callback, int $measured, int $warmup = 5 ): array { for ( $i = 0; $i < $warmup; $i++ ) { @@ -202,22 +169,19 @@ function collaboration_perf_collect_explains( string $target_room, int $scale, i collaboration_perf_seed_table( $scale, $rooms ); $wpdb->query( "ANALYZE TABLE {$wpdb->collaboration}" ); - $table_max_id = (int) $wpdb->get_var( $wpdb->prepare( - "SELECT COALESCE( MAX( id ), 0 ) FROM {$wpdb->collaboration} WHERE room = %s", + $snapshot = $wpdb->get_row( $wpdb->prepare( + "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s", $target_room ) ); + $table_max_id = (int) $snapshot->max_id; + $queries = array( array( - 'label' => 'Idle poll (MAX cursor)', - 'sql' => "SELECT COALESCE(MAX(id), 0) FROM {$wpdb->collaboration} WHERE room = %s", + 'label' => 'Snapshot (MAX + COUNT)', + 'sql' => "SELECT COALESCE(MAX(id), 0) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s", 'args' => array( $target_room ), ), - array( - 'label' => 'Idle poll (COUNT)', - 'sql' => "SELECT COUNT(*) FROM {$wpdb->collaboration} WHERE room = %s AND id <= %d", - 'args' => array( $target_room, $table_max_id ), - ), array( 'label' => 'Catch-up poll (SELECT)', 'sql' => "SELECT update_value FROM {$wpdb->collaboration} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", @@ -255,7 +219,7 @@ function collaboration_perf_collect_explains( string $target_room, int $scale, i * @param array $op_results Results for this operation keyed by [$scale]. * @param int[] $scales Scale values. * @param int $rooms Rooms per scale. - * @return array[] Rows with 'Rows per room', 'Median', 'P95', 'STD', 'MAD' keys. + * @return array[] Rows with 'Rows', 'Median', 'P95' keys. */ function collaboration_perf_build_section_rows( array $op_results, array $scales, int $rooms ): array { $rows = array(); @@ -265,11 +229,9 @@ function collaboration_perf_build_section_rows( array $op_results, array $scales $stats = $op_results[ $scale ]; $rows[] = array( - 'Rows per room' => number_format( $per_room ), - 'Median' => collaboration_perf_format_ms( $stats['median'] ), - 'P95' => collaboration_perf_format_ms( $stats['p95'] ), - 'STD' => collaboration_perf_format_ms( $stats['sd'] ), - 'MAD' => collaboration_perf_format_ms( $stats['mad'] ), + 'Rows' => number_format( $per_room ), + 'Median' => collaboration_perf_format_ms( $stats['median'] ), + 'P95' => collaboration_perf_format_ms( $stats['p95'] ), ); } @@ -287,61 +249,36 @@ function collaboration_perf_build_section_rows( array $op_results, array $scales function collaboration_perf_print_output( array $results, array $explain_data, array $config, array $scales ): void { global $wp_version, $wpdb; - $fields = array( 'Rows per room', 'Median', 'P95', 'STD', 'MAD' ); - $separator = str_repeat( '─', 60 ); + $fields = array( 'Rows', 'Median', 'P95' ); WP_CLI::log( '' ); - WP_CLI::log( WP_CLI::colorize( '%_Collaboration Storage Performance%n' ) ); WP_CLI::log( sprintf( - 'WordPress %s, MySQL %s, PHP %s, Docker (local dev)', + 'WP %s | MySQL %s | PHP %s', $wp_version, $wpdb->db_version(), phpversion() ) ); - WP_CLI::log( sprintf( - '%d measured iterations (%d warm-up discarded), fresh instance per iteration', - $config['measured_iterations'], - $config['warmup_iterations'] - ) ); + WP_CLI::log( 'Median = typical response, P95 = slowest 5%' ); $sections = array( - 'idle_poll' => array( - 'title' => 'Idle Poll', - 'desc' => 'Checks for new updates when none exist. Called every second per open editor tab.', - ), - 'catchup_poll' => array( - 'title' => 'Catch-up Poll', - 'desc' => 'Fetches all updates from cursor 0. Called when an editor opens or reconnects.', - ), - 'compaction' => array( - 'title' => 'Compaction', - 'desc' => sprintf( - 'Removes old updates. Deletes ~80%% of rows (%d measured iterations, re-seeded each).', - $config['compaction_iterations'] - ), - ), + 'idle_poll' => 'Idle Poll — editor open, no new changes', + 'catchup_poll' => 'Catch-up Poll — opening a post or reconnecting', + 'compaction' => 'Compaction — pruning old edits (deletes ~80%)', ); - foreach ( $sections as $op_key => $section ) { - WP_CLI::log( '' ); - WP_CLI::log( $separator ); - WP_CLI::log( WP_CLI::colorize( "%_{$section['title']}%n" ) ); - WP_CLI::log( $separator ); - WP_CLI::log( $section['desc'] ); + foreach ( $sections as $op_key => $title ) { WP_CLI::log( '' ); + WP_CLI::log( WP_CLI::colorize( "%_{$title}%n" ) ); $rows = collaboration_perf_build_section_rows( $results[ $op_key ], $scales, $config['rooms'] ); WP_CLI\Utils\format_items( 'table', $rows, $fields ); } WP_CLI::log( '' ); - WP_CLI::log( $separator ); - WP_CLI::log( WP_CLI::colorize( '%_MySQL EXPLAIN Analysis%n' ) ); - WP_CLI::log( $separator ); - WP_CLI::log( '' ); - + $explain_scale = number_format( end( $scales ) ); + WP_CLI::log( WP_CLI::colorize( "%_Query Plan ({$explain_scale} rows)%n" ) ); WP_CLI\Utils\format_items( 'table', $explain_data, array( 'Query', 'Access' ) ); WP_CLI::log( '' ); - WP_CLI::success( 'Benchmark complete.' ); + WP_CLI::success( 'Done.' ); } From 93adae475b538883883a82d2ae48a0a01fae8f04 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 10:41:14 -0500 Subject: [PATCH 75/82] Collaboration: Fix labels and add table verification to data loss proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix usage comment to match actual filename - Update beta references from "beta 1" to "beta 1–3" - Rename "Table (proposed)" to "Table (this PR)" - Replace "Gap" column with side-by-side comparison that measures both backends at each scale: post meta shows data loss window, table shows rows visible after compaction - Drop "Sync" prefix from output header --- ...ELEASE_prove-beta3-post_meta_data_loss.php | 83 ++++++++++++------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php b/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php index bf942ad3c9806..b1dcc91168753 100644 --- a/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php +++ b/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php @@ -10,7 +10,7 @@ * * The 10 newest rows are never deleted, never absent. There is no step 2. * - * Post meta compaction (beta 1) does this: + * Post meta compaction (beta 1–3) does this: * 1. delete_post_meta() — removes ALL 50 updates in one call. * 2. add_post_meta() — re-inserts the 10 newest updates one at a time. * @@ -19,7 +19,7 @@ * should still exist. * * Usage: - * npm run env:cli -- eval-file tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-data-loss.php + * npm run env:cli -- eval-file tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php * * @package WordPress */ @@ -113,13 +113,14 @@ // Step 2 of 2 (re-insert kept updates) would happen here, but the gap already occurred. // ===================================================================== -// Gap at scale. +// Compaction at scale. // -// The gap is the time between delete_post_meta() and the last -// add_post_meta() — the window where all updates are missing. -// More updates to keep = more add_post_meta() calls = wider gap. +// Post meta: the data loss window is the time between +// delete_post_meta() and the last add_post_meta() — more updates +// to keep = more add_post_meta() calls = wider window. // -// Table compaction has no gap. The kept rows are never removed. +// Table: rows are never absent — verify by reading immediately +// after compaction at each scale. // ===================================================================== $gap_scales = array( 50, 200, 500, 1000 ); @@ -135,7 +136,8 @@ WP_CLI::log( " Scale: {$gap_total} updates (keep {$gap_keep})" ); - // Seed post meta for this scale. + // --- Post meta: measure the data loss window. --- + $gap_post_id = wp_insert_post( array( 'post_type' => 'wp_sync_storage', 'post_status' => 'publish', @@ -148,13 +150,9 @@ ) ); } - // The cursor: timestamp of the first update to keep. - $gap_cursor = 1000 + $gap_discard; - - // Read all updates before deleting (same as production code path). + $gap_cursor = 1000 + $gap_discard; $all_updates = get_post_meta( $gap_post_id, 'wp_sync_update', false ); - // Measure the full gap: delete all, then re-insert each kept update. $gap_start = microtime( true ); delete_post_meta( $gap_post_id, 'wp_sync_update' ); foreach ( $all_updates as $envelope ) { @@ -162,16 +160,36 @@ add_post_meta( $gap_post_id, 'wp_sync_update', $envelope ); } } - $gap_ms = ( microtime( true ) - $gap_start ) * 1000; + $meta_gap_ms = ( microtime( true ) - $gap_start ) * 1000; + + wp_delete_post( $gap_post_id, true ); + + // --- Table: verify rows are never absent. --- + + $wpdb->query( "TRUNCATE TABLE {$wpdb->collaboration}" ); + $storage = new WP_Collaboration_Table_Storage(); + for ( $i = 0; $i < $gap_total; $i++ ) { + $storage->add_update( $gap_room, array( 'edit' => $i ) ); + } + + $table_cursor = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$wpdb->collaboration} WHERE room = %s ORDER BY id DESC LIMIT 1 OFFSET %d", + $gap_room, + $gap_keep - 1 + ) ); + + $storage->remove_updates_before_cursor( $gap_room, $table_cursor ); + + $reader = new WP_Collaboration_Table_Storage(); + $table_after = $reader->get_updates_after_cursor( $gap_room, 0 ); + $table_visible_count = count( $table_after ); $gap_results[] = array( - 'total' => $gap_total, - 'keep' => $gap_keep, - 'gap_ms' => $gap_ms, + 'total' => $gap_total, + 'keep' => $gap_keep, + 'meta_gap_ms' => $meta_gap_ms, + 'table_visible' => $table_visible_count, ); - - // Cleanup this scale. - wp_delete_post( $gap_post_id, true ); } // ===================================================================== @@ -181,7 +199,7 @@ $separator = str_repeat( '─', 60 ); WP_CLI::log( '' ); -WP_CLI::log( WP_CLI::colorize( '%_Sync Compaction Data Integrity Test%n' ) ); +WP_CLI::log( WP_CLI::colorize( '%_Compaction Data Integrity Test%n' ) ); WP_CLI::log( 'Run: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC' ); WP_CLI::log( '' ); WP_CLI::log( "Compaction triggers at {$total} updates. Keeps {$keep} newest, discards {$discard} oldest." ); @@ -189,7 +207,7 @@ WP_CLI::log( '' ); WP_CLI::log( $separator ); -WP_CLI::log( WP_CLI::colorize( '%_Table (proposed)%n' ) ); +WP_CLI::log( WP_CLI::colorize( '%_Table (this PR)%n' ) ); WP_CLI::log( $separator ); WP_CLI::log( " DELETE WHERE id < cutoff — only the {$discard} oldest removed." ); WP_CLI::log( '' ); @@ -201,7 +219,7 @@ WP_CLI::log( '' ); WP_CLI::log( $separator ); -WP_CLI::log( WP_CLI::colorize( '%_Post Meta (current beta 1)%n' ) ); +WP_CLI::log( WP_CLI::colorize( '%_Post Meta (beta 1–3)%n' ) ); WP_CLI::log( $separator ); WP_CLI::log( " delete_post_meta() removes all {$total}, then add_post_meta() re-inserts {$keep}." ); WP_CLI::log( '' ); @@ -213,22 +231,23 @@ WP_CLI::log( '' ); WP_CLI::log( $separator ); -WP_CLI::log( WP_CLI::colorize( '%_Post Meta gap at scale%n' ) ); +WP_CLI::log( WP_CLI::colorize( '%_Compaction at scale%n' ) ); WP_CLI::log( $separator ); -WP_CLI::log( ' Duration where all updates are missing:' ); WP_CLI::log( '' ); -$scale_items = array(); +$scale_fields = array( 'Updates (keep 20%)', 'Post meta data loss window', 'Table rows visible' ); +$scale_items = array(); foreach ( $gap_results as $gap ) { + $table_label = $gap['table_visible'] >= $gap['keep'] + ? "{$gap['table_visible']} of {$gap['keep']} — OK" + : "{$gap['table_visible']} of {$gap['keep']} — UNEXPECTED"; $scale_items[] = array( - 'Updates (keep 20%)' => sprintf( '%d (keep %d)', $gap['total'], $gap['keep'] ), - 'Gap' => sprintf( '%.1f ms', $gap['gap_ms'] ), + 'Updates (keep 20%)' => sprintf( '%d (keep %d)', $gap['total'], $gap['keep'] ), + 'Post meta data loss window' => sprintf( '%.1f ms', $gap['meta_gap_ms'] ), + 'Table rows visible' => $table_label, ); } -WP_CLI\Utils\format_items( 'table', $scale_items, array( 'Updates (keep 20%)', 'Gap' ) ); - -WP_CLI::log( '' ); -WP_CLI::log( WP_CLI::colorize( ' %GTable: no gap at any scale.%n' ) ); +WP_CLI\Utils\format_items( 'table', $scale_items, $scale_fields ); // Cleanup. wp_delete_post( $post_id, true ); From 80ed66671eb66f874d067cbe742261e58fd6ed47 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 10:48:41 -0500 Subject: [PATCH 76/82] Collaboration: Replace leftover sync terminology in comments Replaces "sync update" and "sync data" with "collaboration" or "update" in comments and test assertion messages across source, tests, and benchmark scripts. Code and method names are unchanged. --- src/wp-includes/collaboration.php | 4 ++-- .../tests/rest-api/rest-collaboration-server.php | 12 ++++++------ ...O_NOT_RELEASE_prove-beta3-post_meta_data_loss.php | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index f700784220df2..e975303cdaccf 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -41,7 +41,7 @@ function wp_collaboration_inject_setting() { /** * Deletes stale collaboration data from the collaboration table. * - * Removes sync-update rows older than 7 days and awareness rows older than + * Removes collaboration rows older than 7 days and awareness rows older than * 60 seconds. Rows left behind by abandoned collaborative editing sessions * are cleaned up to prevent unbounded table growth. * @@ -54,7 +54,7 @@ function wp_delete_old_collaboration_data() { global $wpdb; - // Clean up sync update rows older than 7 days. + // Clean up collaboration rows older than 7 days. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->collaboration} WHERE created_at < %s", diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index c2c64e812a336..671b95dd9f38e 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1345,13 +1345,13 @@ public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { ) ); - // With no sync updates, cursor should be 0. + // With no updates, cursor should be 0. $data1 = $response1->get_data(); $this->assertSame( 0, $data1['rooms'][0]['end_cursor'], 'Awareness rows should not affect the cursor.' ); $this->assertSame( 0, $data1['rooms'][0]['total_updates'], 'Awareness rows should not count as updates.' ); $this->assertEmpty( $data1['rooms'][0]['updates'], 'Awareness rows should not appear as updates.' ); - // Now add a sync update. + // Now add an update. $update = array( 'type' => 'update', 'data' => 'dGVzdA==', @@ -1363,7 +1363,7 @@ public function test_collaboration_awareness_rows_do_not_affect_cursor(): void { ); $data2 = $response2->get_data(); - $this->assertSame( 1, $data2['rooms'][0]['total_updates'], 'Only sync updates should count toward total.' ); + $this->assertSame( 1, $data2['rooms'][0]['total_updates'], 'Only updates should count toward total.' ); } /** @@ -1502,15 +1502,15 @@ public function test_cron_cleanup_deletes_expired_awareness_rows(): void { array( '%s', '%d', '%d', '%s', '%s' ) ); - // Insert a recent sync update row (should survive). + // Insert a recent collaboration row (should survive). $this->insert_collaboration_row( HOUR_IN_SECONDS ); - $this->assertSame( 1, $this->get_collaboration_row_count(), 'Collaboration table should have 1 sync update row.' ); + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Collaboration table should have 1 row.' ); $this->assertSame( 1, $this->get_awareness_row_count(), 'Awareness table should have 1 awareness row.' ); wp_delete_old_collaboration_data(); - $this->assertSame( 1, $this->get_collaboration_row_count(), 'Only the recent sync update row should survive cron cleanup.' ); + $this->assertSame( 1, $this->get_collaboration_row_count(), 'Only the recent collaboration row should survive cron cleanup.' ); $this->assertSame( 0, $this->get_awareness_row_count(), 'Expired awareness row should be deleted after cron cleanup.' ); } diff --git a/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php b/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php index b1dcc91168753..528af3e54df31 100644 --- a/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php +++ b/tools/local-env/scripts/collaboration-perf/DO_NOT_RELEASE_prove-beta3-post_meta_data_loss.php @@ -1,6 +1,6 @@ Date: Sat, 7 Mar 2026 11:00:20 -0500 Subject: [PATCH 77/82] Collaboration: Harden storage layer and REST schema - Document intentional omission of hooks/filters in storage class (polling frequency makes hook overhead unacceptable) - Document capability model in check_permissions: access follows existing edit capabilities, no dedicated collaborate cap - Replace current_time('mysql', true) with gmdate() in storage to match cron cleanup and avoid pre_get_current_time filter drift - Include $wpdb->last_error in storage failure WP_Error responses so DB-level failures are diagnosable without SAVEQUERIES - Add maxLength (1 MB) to the update data field in REST schema to prevent unbounded payloads on a high-frequency endpoint - Fix missing period in permission error message --- .../class-wp-collaboration-table-storage.php | 8 +++++-- ...s-wp-http-polling-collaboration-server.php | 24 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 086cac92a93bd..634981e4d6d00 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -12,6 +12,10 @@ * * Data is stored in the `collaboration` and `awareness` database tables. * + * This class intentionally fires no actions or filters. Collaboration + * queries run on every poll (0.5–1 s per editor tab), so hook overhead + * would degrade the real-time editing loop for all active sessions. + * * @since 7.0.0 * * @access private @@ -54,7 +58,7 @@ public function add_update( string $room, $update ): bool { array( 'room' => $room, 'update_value' => wp_json_encode( $update ), - 'created_at' => current_time( 'mysql', true ), + 'created_at' => gmdate( 'Y-m-d H:i:s' ), ), array( '%s', '%s', '%s' ) ); @@ -266,7 +270,7 @@ public function set_awareness_state( string $room, int $client_id, array $state, $client_id, $wp_user_id, $update_value, - current_time( 'mysql', true ) + gmdate( 'Y-m-d H:i:s' ) ) ); diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 3c3b615f46c55..4caa9f4392c10 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -98,8 +98,9 @@ public function register_routes(): void { $typed_update_args = array( 'properties' => array( 'data' => array( - 'type' => 'string', - 'required' => true, + 'type' => 'string', + 'required' => true, + 'maxLength' => 1048576, // 1 MB — generous ceiling for base64-encoded Yjs updates. ), 'type' => array( 'type' => 'string', @@ -185,6 +186,11 @@ public function register_routes(): void { /** * Checks if the current user has permission to access a room. * + * Requires `edit_posts` (contributor+), then delegates to + * can_user_collaborate_on_entity_type() for per-entity checks. + * There is no dedicated `collaborate` capability; access follows + * existing edit capabilities for the entity type. + * * @since 7.0.0 * * @param WP_REST_Request $request The REST request. @@ -195,7 +201,7 @@ public function check_permissions( WP_REST_Request $request ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'rest_cannot_edit', - __( 'You do not have permission to perform this action' ), + __( 'You do not have permission to perform this action.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -446,10 +452,14 @@ private function process_collaboration_update( string $room, int $client_id, int if ( ! $has_newer_compaction ) { if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + global $wpdb; return new WP_Error( 'rest_collaboration_storage_error', __( 'Failed to remove updates during compaction.' ), - array( 'status' => 500 ) + array( + 'status' => 500, + 'db_error' => $wpdb->last_error, + ) ); } @@ -501,10 +511,14 @@ private function add_update( string $room, int $client_id, string $type, string ); if ( ! $this->storage->add_update( $room, $update ) ) { + global $wpdb; return new WP_Error( 'rest_collaboration_storage_error', __( 'Failed to store collaboration update.' ), - array( 'status' => 500 ) + array( + 'status' => 500, + 'db_error' => $wpdb->last_error, + ) ); } From a178258eb2f463fae6e336b6c2ec0a578273f0fb Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sat, 7 Mar 2026 11:05:18 -0500 Subject: [PATCH 78/82] Collaboration: Gate db_error on WP_DEBUG and fix JSON decode inconsistency - Only include $wpdb->last_error in REST error responses when WP_DEBUG is true, matching core REST API conventions and preventing table/column name leakage on production sites - Normalize JSON decode validation in get_updates_after_cursor() to use is_array() instead of json_last_error(), consistent with get_awareness_state() in the same class --- .../class-wp-collaboration-table-storage.php | 2 +- ...ss-wp-http-polling-collaboration-server.php | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 634981e4d6d00..29c431cf79be8 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -202,7 +202,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $updates = array(); foreach ( $rows as $row ) { $decoded = json_decode( $row->update_value, true ); - if ( json_last_error() === JSON_ERROR_NONE ) { + if ( is_array( $decoded ) ) { $updates[] = $decoded; } } diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 4caa9f4392c10..09fa97f986aaa 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -453,13 +453,14 @@ private function process_collaboration_update( string $room, int $client_id, int if ( ! $has_newer_compaction ) { if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { global $wpdb; + $data = array( 'status' => 500 ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $data['db_error'] = $wpdb->last_error; + } return new WP_Error( 'rest_collaboration_storage_error', __( 'Failed to remove updates during compaction.' ), - array( - 'status' => 500, - 'db_error' => $wpdb->last_error, - ) + $data ); } @@ -512,13 +513,14 @@ private function add_update( string $room, int $client_id, string $type, string if ( ! $this->storage->add_update( $room, $update ) ) { global $wpdb; + $data = array( 'status' => 500 ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $data['db_error'] = $wpdb->last_error; + } return new WP_Error( 'rest_collaboration_storage_error', __( 'Failed to store collaboration update.' ), - array( - 'status' => 500, - 'db_error' => $wpdb->last_error, - ) + $data ); } From 6ba0275dc7563f3782eb08d00f2aa75b756c15f2 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sun, 8 Mar 2026 18:10:46 -0400 Subject: [PATCH 79/82] Collaboration: Fix compaction never triggering for idle rooms The update count cache was set to zero when no new updates existed since the client's last cursor, discarding the real row count from the snapshot query. This prevented the server from signaling compaction after all editors stopped typing, leaving accumulated rows in the collaboration table until activity resumed or the seven-day cron cleanup ran. Preserve the actual total so compaction triggers correctly even when no new updates have arrived. --- .../class-wp-collaboration-table-storage.php | 5 ++- .../rest-api/rest-collaboration-server.php | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 29c431cf79be8..6a0f19e47d9d7 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -179,7 +179,10 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $this->room_cursors[ $room ] = $max_id; if ( 0 === $max_id || $max_id <= $cursor ) { - $this->room_update_counts[ $room ] = 0; + // Preserve the real row count so the server can still + // trigger compaction when updates have accumulated but + // no new ones arrived since the client's last poll. + $this->room_update_counts[ $room ] = $total; return array(); } diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 6a87d849291d3..6ab6d2cecc28d 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -617,6 +617,51 @@ public function test_collaboration_should_compact_is_true_above_threshold_for_co $this->assertTrue( $data['rooms'][0]['should_compact'] ); } + /** + * Verifies that a caught-up compactor client still receives the + * should_compact signal when the room has accumulated updates + * beyond the compaction threshold. + * + * Regression test: the update count was previously cached as 0 + * when the cursor matched the latest update ID, preventing + * compaction from ever triggering for idle rooms. + * + * @ticket 64696 + */ + public function test_collaboration_should_compact_when_compactor_is_caught_up() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array(); + for ( $i = 0; $i < 51; $i++ ) { + $updates[] = array( + 'type' => 'update', + 'data' => base64_encode( "update-$i" ), + ); + } + + // Client 1 sends enough updates to exceed the compaction threshold. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + // Grab the end_cursor so the client is fully caught up. + $data = $response->get_data(); + $end_cursor = $data['rooms'][0]['end_cursor']; + + // Client 1 polls again with cursor = end_cursor (caught up, no new updates). + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, $end_cursor, array( 'user' => 'c1' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['rooms'][0]['should_compact'], 'Compactor should receive should_compact even when caught up.' ); + } + public function test_collaboration_should_compact_is_false_for_non_compactor() { wp_set_current_user( self::$editor_id ); From faa99266687d9b026e50b2e6cddc572a5b9fd9c2 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sun, 8 Mar 2026 22:33:56 -0400 Subject: [PATCH 80/82] Collaboration: Harden compaction against race conditions Two fixes to the compaction path: Insert the compaction row before deleting old rows. The previous order (delete then insert) left a window where a client joining with cursor=0 would see an empty room for one poll cycle, potentially forking the document. The compaction row always receives a higher auto-increment ID than the deleted rows, so cursor-based filtering is unaffected by the reorder. Change the compaction DELETE from `id < cursor` to `id <= cursor`. The row at the cursor position is already incorporated into the compaction data, so keeping it around caused every subsequent client to apply it twice. Yjs handles the duplicate gracefully, but it is unnecessary bandwidth and storage. --- .../class-wp-collaboration-table-storage.php | 4 ++-- ...s-wp-http-polling-collaboration-server.php | 19 +++++++++++++++---- .../interface-wp-collaboration-storage.php | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php index 6a0f19e47d9d7..57438756434d4 100644 --- a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php @@ -224,7 +224,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { * @global wpdb $wpdb WordPress database abstraction object. * * @param string $room Room identifier. - * @param int $cursor Remove updates with id < this cursor. + * @param int $cursor Remove updates with id <= this cursor. * @return bool True on success, false on failure. */ public function remove_updates_before_cursor( string $room, int $cursor ): bool { @@ -232,7 +232,7 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool $result = $wpdb->query( $wpdb->prepare( - "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND id < %d", + "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND id <= %d", $room, $cursor ) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php index 09fa97f986aaa..62a09d211b072 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php @@ -451,20 +451,31 @@ private function process_collaboration_update( string $room, int $client_id, int } if ( ! $has_newer_compaction ) { + // Insert the compaction row before deleting old rows. + // Reversing the order closes a race window where a + // client joining with cursor=0 between the DELETE and + // INSERT would see an empty room for one poll cycle. + // The compaction row always has a higher ID than the + // deleted rows, so cursor-based filtering is unaffected. + $insert_result = $this->add_update( $room, $client_id, $type, $data ); + if ( is_wp_error( $insert_result ) ) { + return $insert_result; + } + if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { global $wpdb; - $data = array( 'status' => 500 ); + $error_data = array( 'status' => 500 ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - $data['db_error'] = $wpdb->last_error; + $error_data['db_error'] = $wpdb->last_error; } return new WP_Error( 'rest_collaboration_storage_error', __( 'Failed to remove updates during compaction.' ), - $data + $error_data ); } - return $this->add_update( $room, $client_id, $type, $data ); + return true; } // Reaching this point means there's a newer compaction, so we can diff --git a/src/wp-includes/collaboration/interface-wp-collaboration-storage.php b/src/wp-includes/collaboration/interface-wp-collaboration-storage.php index 13dec0d8da96a..34f8b13a290d9 100644 --- a/src/wp-includes/collaboration/interface-wp-collaboration-storage.php +++ b/src/wp-includes/collaboration/interface-wp-collaboration-storage.php @@ -79,7 +79,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array; * @since 7.0.0 * * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. + * @param int $cursor Remove updates with markers <= this cursor. * @return bool True on success, false on failure. */ public function remove_updates_before_cursor( string $room, int $cursor ): bool; From 9934c9e70066437732fa6bc37790103df39c562e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Sun, 8 Mar 2026 22:51:44 -0400 Subject: [PATCH 81/82] Collaboration: Add collaboration_storage filter for pluggable storage backends Allow hosts and plugins to replace the default database-backed storage with a custom WP_Collaboration_Storage implementation via mu-plugin. Combined with the existing sync.providers JS filter, hosts can now provide WebSocket-based collaboration without patching core. Includes an in-memory storage implementation and integration test verifying the filter correctly replaces the backend. --- src/wp-includes/rest-api.php | 13 +- .../rest-api/rest-collaboration-server.php | 162 ++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index a1e7fd2cea7de..c147588999dd6 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -431,7 +431,18 @@ function create_initial_rest_routes() { // Collaboration. if ( wp_is_collaboration_enabled() ) { - $collaboration_storage = new WP_Collaboration_Table_Storage(); + /** + * Filters the storage backend used for collaborative editing. + * + * Allows hosts and plugins to replace the default database-backed + * storage with a custom implementation (e.g. Redis, Memcached). + * The returned object must implement WP_Collaboration_Storage. + * + * @since 7.0.0 + * + * @param WP_Collaboration_Storage $storage Storage backend instance. + */ + $collaboration_storage = apply_filters( 'collaboration_storage', new WP_Collaboration_Table_Storage() ); $collaboration_server = new WP_HTTP_Polling_Collaboration_Server( $collaboration_storage ); $collaboration_server->register_routes(); } diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 6ab6d2cecc28d..36bc8b6b9abab 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1779,4 +1779,166 @@ public function test_collaboration_idle_poll_query_count(): void { sprintf( 'Idle poll should use at most 3 queries per room, used %d.', $query_count ) ); } + + /** + * Verifies that the collaboration_storage filter allows replacing the + * default storage backend with a custom implementation. + * + * @ticket 64696 + */ + public function test_collaboration_storage_filter_replaces_backend() { + wp_set_current_user( self::$editor_id ); + + $custom_storage = new WP_Test_In_Memory_Collaboration_Storage(); + + add_filter( + 'collaboration_storage', + static function () use ( $custom_storage ) { + return $custom_storage; + } + ); + + // Reset the REST server so routes are re-registered with the filtered storage. + global $wp_rest_server; + $wp_rest_server = null; + rest_get_server(); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => base64_encode( 'custom-storage-test' ), + ); + + // Client 1 sends an update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + $this->assertSame( 200, $response->get_status(), 'Custom storage should handle updates.' ); + + // Client 2 polls and should receive client 1's update. + $response = $this->dispatch_collaboration( + array( + $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), + ) + ); + + $data = $response->get_data(); + $update_data = wp_list_pluck( $data['rooms'][0]['updates'], 'data' ); + + $this->assertContains( + base64_encode( 'custom-storage-test' ), + $update_data, + 'Client 2 should receive the update stored by the custom backend.' + ); + + $this->assertTrue( $custom_storage->was_used, 'The custom storage backend should have been called.' ); + + remove_all_filters( 'collaboration_storage' ); + } +} + +/** + * In-memory collaboration storage for testing the collaboration_storage filter. + * + * Stores all data in arrays with no database interaction. + */ +class WP_Test_In_Memory_Collaboration_Storage implements WP_Collaboration_Storage { + public $was_used = false; + + private $updates = array(); + private $awareness = array(); + private $next_id = 1; + private $cursors = array(); + private $counts = array(); + + public function add_update( string $room, $update ): bool { + $this->was_used = true; + $this->updates[ $room ][] = array( + 'id' => $this->next_id++, + 'update' => $update, + ); + return true; + } + + public function get_awareness_state( string $room, int $timeout = 30 ): array { + $this->was_used = true; + $cutoff = time() - $timeout; + $entries = array(); + foreach ( $this->awareness[ $room ] ?? array() as $entry ) { + if ( $entry['time'] >= $cutoff ) { + $entries[] = array( + 'client_id' => $entry['client_id'], + 'state' => $entry['state'], + 'wp_user_id' => $entry['wp_user_id'], + ); + } + } + return $entries; + } + + public function get_cursor( string $room ): int { + return $this->cursors[ $room ] ?? 0; + } + + public function get_update_count( string $room ): int { + return $this->counts[ $room ] ?? 0; + } + + public function get_updates_after_cursor( string $room, int $cursor ): array { + $this->was_used = true; + $updates = array(); + $max_id = 0; + $total = count( $this->updates[ $room ] ?? array() ); + + foreach ( $this->updates[ $room ] ?? array() as $entry ) { + if ( $entry['id'] > $max_id ) { + $max_id = $entry['id']; + } + if ( $entry['id'] > $cursor ) { + $updates[] = $entry['update']; + } + } + + $this->cursors[ $room ] = $max_id; + $this->counts[ $room ] = $total; + return $updates; + } + + public function remove_updates_before_cursor( string $room, int $cursor ): bool { + if ( ! isset( $this->updates[ $room ] ) ) { + return true; + } + $this->updates[ $room ] = array_values( + array_filter( + $this->updates[ $room ], + static function ( $entry ) use ( $cursor ) { + return $entry['id'] > $cursor; + } + ) + ); + return true; + } + + public function set_awareness_state( string $room, int $client_id, array $state, int $wp_user_id ): bool { + $this->was_used = true; + // Remove existing entry for this client. + $this->awareness[ $room ] = array_values( + array_filter( + $this->awareness[ $room ] ?? array(), + static function ( $entry ) use ( $client_id ) { + return $entry['client_id'] !== $client_id; + } + ) + ); + $this->awareness[ $room ][] = array( + 'client_id' => $client_id, + 'state' => $state, + 'wp_user_id' => $wp_user_id, + 'time' => time(), + ); + return true; + } } From 694b6194c471b5beb76ba22935b9e6aa4d261f8b Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 9 Mar 2026 12:32:42 -0400 Subject: [PATCH 82/82] Revert "Collaboration: Add collaboration_storage filter for pluggable storage backends" This reverts commit 9934c9e70066437732fa6bc37790103df39c562e. --- src/wp-includes/rest-api.php | 13 +- .../rest-api/rest-collaboration-server.php | 162 ------------------ 2 files changed, 1 insertion(+), 174 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index c147588999dd6..a1e7fd2cea7de 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -431,18 +431,7 @@ function create_initial_rest_routes() { // Collaboration. if ( wp_is_collaboration_enabled() ) { - /** - * Filters the storage backend used for collaborative editing. - * - * Allows hosts and plugins to replace the default database-backed - * storage with a custom implementation (e.g. Redis, Memcached). - * The returned object must implement WP_Collaboration_Storage. - * - * @since 7.0.0 - * - * @param WP_Collaboration_Storage $storage Storage backend instance. - */ - $collaboration_storage = apply_filters( 'collaboration_storage', new WP_Collaboration_Table_Storage() ); + $collaboration_storage = new WP_Collaboration_Table_Storage(); $collaboration_server = new WP_HTTP_Polling_Collaboration_Server( $collaboration_storage ); $collaboration_server->register_routes(); } diff --git a/tests/phpunit/tests/rest-api/rest-collaboration-server.php b/tests/phpunit/tests/rest-api/rest-collaboration-server.php index 36bc8b6b9abab..6ab6d2cecc28d 100644 --- a/tests/phpunit/tests/rest-api/rest-collaboration-server.php +++ b/tests/phpunit/tests/rest-api/rest-collaboration-server.php @@ -1779,166 +1779,4 @@ public function test_collaboration_idle_poll_query_count(): void { sprintf( 'Idle poll should use at most 3 queries per room, used %d.', $query_count ) ); } - - /** - * Verifies that the collaboration_storage filter allows replacing the - * default storage backend with a custom implementation. - * - * @ticket 64696 - */ - public function test_collaboration_storage_filter_replaces_backend() { - wp_set_current_user( self::$editor_id ); - - $custom_storage = new WP_Test_In_Memory_Collaboration_Storage(); - - add_filter( - 'collaboration_storage', - static function () use ( $custom_storage ) { - return $custom_storage; - } - ); - - // Reset the REST server so routes are re-registered with the filtered storage. - global $wp_rest_server; - $wp_rest_server = null; - rest_get_server(); - - $room = $this->get_post_room(); - $update = array( - 'type' => 'update', - 'data' => base64_encode( 'custom-storage-test' ), - ); - - // Client 1 sends an update. - $response = $this->dispatch_collaboration( - array( - $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), - ) - ); - - $this->assertSame( 200, $response->get_status(), 'Custom storage should handle updates.' ); - - // Client 2 polls and should receive client 1's update. - $response = $this->dispatch_collaboration( - array( - $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), - ) - ); - - $data = $response->get_data(); - $update_data = wp_list_pluck( $data['rooms'][0]['updates'], 'data' ); - - $this->assertContains( - base64_encode( 'custom-storage-test' ), - $update_data, - 'Client 2 should receive the update stored by the custom backend.' - ); - - $this->assertTrue( $custom_storage->was_used, 'The custom storage backend should have been called.' ); - - remove_all_filters( 'collaboration_storage' ); - } -} - -/** - * In-memory collaboration storage for testing the collaboration_storage filter. - * - * Stores all data in arrays with no database interaction. - */ -class WP_Test_In_Memory_Collaboration_Storage implements WP_Collaboration_Storage { - public $was_used = false; - - private $updates = array(); - private $awareness = array(); - private $next_id = 1; - private $cursors = array(); - private $counts = array(); - - public function add_update( string $room, $update ): bool { - $this->was_used = true; - $this->updates[ $room ][] = array( - 'id' => $this->next_id++, - 'update' => $update, - ); - return true; - } - - public function get_awareness_state( string $room, int $timeout = 30 ): array { - $this->was_used = true; - $cutoff = time() - $timeout; - $entries = array(); - foreach ( $this->awareness[ $room ] ?? array() as $entry ) { - if ( $entry['time'] >= $cutoff ) { - $entries[] = array( - 'client_id' => $entry['client_id'], - 'state' => $entry['state'], - 'wp_user_id' => $entry['wp_user_id'], - ); - } - } - return $entries; - } - - public function get_cursor( string $room ): int { - return $this->cursors[ $room ] ?? 0; - } - - public function get_update_count( string $room ): int { - return $this->counts[ $room ] ?? 0; - } - - public function get_updates_after_cursor( string $room, int $cursor ): array { - $this->was_used = true; - $updates = array(); - $max_id = 0; - $total = count( $this->updates[ $room ] ?? array() ); - - foreach ( $this->updates[ $room ] ?? array() as $entry ) { - if ( $entry['id'] > $max_id ) { - $max_id = $entry['id']; - } - if ( $entry['id'] > $cursor ) { - $updates[] = $entry['update']; - } - } - - $this->cursors[ $room ] = $max_id; - $this->counts[ $room ] = $total; - return $updates; - } - - public function remove_updates_before_cursor( string $room, int $cursor ): bool { - if ( ! isset( $this->updates[ $room ] ) ) { - return true; - } - $this->updates[ $room ] = array_values( - array_filter( - $this->updates[ $room ], - static function ( $entry ) use ( $cursor ) { - return $entry['id'] > $cursor; - } - ) - ); - return true; - } - - public function set_awareness_state( string $room, int $client_id, array $state, int $wp_user_id ): bool { - $this->was_used = true; - // Remove existing entry for this client. - $this->awareness[ $room ] = array_values( - array_filter( - $this->awareness[ $room ] ?? array(), - static function ( $entry ) use ( $client_id ) { - return $entry['client_id'] !== $client_id; - } - ) - ); - $this->awareness[ $room ][] = array( - 'client_id' => $client_id, - 'state' => $state, - 'wp_user_id' => $wp_user_id, - 'time' => time(), - ); - return true; - } }