From 54c964797fe3f598a390755e8f4cbee75965df3e Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 9 Feb 2026 23:04:28 -0500 Subject: [PATCH 01/44] Add setting to enable real-time collaboration --- src/wp-admin/includes/schema.php | 3 +++ src/wp-admin/options-writing.php | 7 +++++++ src/wp-admin/options.php | 1 + src/wp-includes/collaboration.php | 24 ++++++++++++++++++++++++ src/wp-includes/default-filters.php | 3 +++ src/wp-includes/option.php | 12 ++++++++++++ src/wp-settings.php | 1 + 7 files changed, 51 insertions(+) create mode 100644 src/wp-includes/collaboration.php diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 795060f28b83e..911314ea2cbf3 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -563,6 +563,9 @@ function populate_options( array $options = array() ) { // 6.9.0 'wp_notes_notify' => 1, + + // 7.0.0 + 'enable_real_time_collaboration' => 0, ); // 3.3.0 diff --git a/src/wp-admin/options-writing.php b/src/wp-admin/options-writing.php index 6f85b54679327..f535ec7a092ab 100644 --- a/src/wp-admin/options-writing.php +++ b/src/wp-admin/options-writing.php @@ -109,6 +109,13 @@ + + + + /> + + + diff --git a/src/wp-admin/options.php b/src/wp-admin/options.php index 8db5cf50f2ec9..57c22be86d367 100644 --- a/src/wp-admin/options.php +++ b/src/wp-admin/options.php @@ -153,6 +153,7 @@ 'default_email_category', 'default_link_category', 'default_post_format', + 'enable_real_time_collaboration', ), ); $allowed_options['misc'] = array(); diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php new file mode 100644 index 0000000000000..51fb45bd0efff --- /dev/null +++ b/src/wp-includes/collaboration.php @@ -0,0 +1,24 @@ + 'boolean', + 'description' => __( 'Enable Real-Time Collaboration' ), + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => false, + 'show_in_rest' => true, + ) + ); + register_setting( 'reading', 'posts_per_page', diff --git a/src/wp-settings.php b/src/wp-settings.php index b437ae153b792..70e79df0838a6 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -300,6 +300,7 @@ 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.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php'; From 79c7f35387777e769115a22ed125e78893af9499 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 9 Feb 2026 23:05:11 -0500 Subject: [PATCH 02/44] Register collaboration post meta --- src/wp-includes/collaboration.php | 35 +++++++++++++++++++++++++++++ src/wp-includes/default-filters.php | 1 + 2 files changed, 36 insertions(+) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 51fb45bd0efff..752aeefd5281b 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -6,6 +6,41 @@ * @since 7.0.0 */ +/** + * Registers collaboration-related post meta. + * + * @since 7.0.0 + * + * @access private + */ +function wp_collaboration_register_meta() { + $persisted_crdt_post_meta_key = '_crdt_document'; + + register_meta( + 'post', + $persisted_crdt_post_meta_key, + array( + 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { + return user_can( $user_id, 'edit_post', $object_id ); + }, + /* + * Revisions must be disabled because we always want to preserve + * the latest persisted CRDT document, even when a revision is restored. + * This ensures that we can continue to apply updates to a shared document + * and peers can simply merge the restored revision like any other incoming + * update. + * + * If we want to persist CRDT documents alongside revisions in the + * future, we should do so in a separate meta key. + */ + 'revisions_enabled' => false, + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) + ); +} + /** * Injects the real-time collaboration setting into a global variable. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 301b846343ee2..72b8462734ca2 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -125,6 +125,7 @@ add_action( 'added_post_meta', 'wp_cache_set_posts_last_changed' ); add_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed' ); add_action( 'deleted_post_meta', 'wp_cache_set_posts_last_changed' ); +add_action( 'init', 'wp_collaboration_register_meta' ); // User meta. add_action( 'added_user_meta', 'wp_cache_set_users_last_changed' ); From 82a4b364f43fbd2a29d13f8174d0f4fb67fbfd96 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 9 Feb 2026 23:06:08 -0500 Subject: [PATCH 03/44] Add sync provider endpoints --- .../class-wp-http-polling-sync-server.php | 512 ++++++++++++++++++ .../class-wp-sync-post-meta-storage.php | 206 +++++++ .../interface-wp-sync-storage.php | 64 +++ src/wp-includes/rest-api.php | 5 + src/wp-settings.php | 3 + 5 files changed, 790 insertions(+) create mode 100644 src/wp-includes/collaboration/class-wp-http-polling-sync-server.php create mode 100644 src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php create mode 100644 src/wp-includes/collaboration/interface-wp-sync-storage.php 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 new file mode 100644 index 0000000000000..ce9da0771f6c6 --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php @@ -0,0 +1,512 @@ +storage = $storage; + } + + /** + * Registers REST API routes. + * + * @since 7.0.0 + */ + public function register_routes(): void { + $typed_update_args = array( + 'properties' => array( + 'data' => array( + 'type' => 'string', + 'required' => true, + ), + 'type' => array( + 'type' => 'string', + 'required' => true, + 'enum' => array( + self::UPDATE_TYPE_COMPACTION, + self::UPDATE_TYPE_SYNC_STEP1, + self::UPDATE_TYPE_SYNC_STEP2, + self::UPDATE_TYPE_UPDATE, + ), + ), + ), + 'required' => true, + 'type' => 'object', + ); + + $room_args = array( + 'after' => array( + 'minimum' => 0, + 'required' => true, + 'type' => 'integer', + ), + 'awareness' => array( + 'required' => true, + 'type' => 'object', + ), + 'client_id' => array( + 'minimum' => 1, + 'required' => true, + 'type' => 'integer', + ), + 'room' => array( + 'sanitize_callback' => 'sanitize_text_field', + 'required' => true, + 'type' => 'string', + ), + 'updates' => array( + 'items' => $typed_update_args, + 'minItems' => 0, + 'required' => true, + 'type' => 'array', + ), + ); + + register_rest_route( + self::REST_NAMESPACE, + '/updates', + 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', + ), + ), + ) + ); + } + + /** + * Checks if the current user has permission to access a room. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request The REST request. + * @return bool|WP_Error True if user has permission, otherwise WP_Error with details. + */ + public function check_permissions( WP_REST_Request $request ) { + $rooms = $request->get_param( 'rooms' ); + + foreach ( $rooms as $room ) { + $room = $room['room']; + $type_parts = explode( '/', $room, 2 ); + $object_parts = explode( ':', $type_parts[1] ?? '', 2 ); + + if ( 2 !== count( $type_parts ) ) { + return new WP_Error( + 'invalid_room_format', + 'Invalid room format. Expected: entity_kind/entity_name or entity_kind/entity_name:id', + array( 'status' => 400 ) + ); + } + + $entity_kind = $type_parts[0]; + $entity_name = $object_parts[0]; + $object_id = null; + + if ( isset( $object_parts[1] ) ) { + $object_id = $object_parts[1]; + } + + if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { + return new WP_Error( + 'forbidden', + sprintf( 'You do not have permission to sync this entity: %s.', $room ), + array( 'status' => 401 ) + ); + } + } + + return true; + } + + /** + * Handles request: stores sync updates and awareness data, and returns + * updates the client is missing. + * + * @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_request( WP_REST_Request $request ) { + $rooms = $request->get_param( 'rooms' ); + $response = array( + 'rooms' => array(), + ); + + foreach ( $rooms as $room_request ) { + $awareness = $room_request['awareness']; + $client_id = $room_request['client_id']; + $cursor = $room_request['after']; + $room = $room_request['room']; + + // Merge awareness state. + $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness ); + + // The lowest client ID is nominated to perform compaction when needed. + $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id; + + // Process each update according to its type. + foreach ( $room_request['updates'] as $update ) { + $this->process_sync_update( $room, $client_id, $cursor, $update ); + } + + // Get updates for this client. + $room_response = $this->get_updates_after( $room, $client_id, $cursor, $is_compactor ); + $room_response['awareness'] = $merged_awareness; + + $response['rooms'][] = $room_response; + } + + return new WP_REST_Response( $response, 200 ); + } + + /** + * Checks if the current user can sync a specific entity type. + * + * @since 7.0.0 + * + * @param string $entity_kind The entity kind. + * @param string $entity_name The entity name. + * @param string|null $object_id The object ID (if applicable). + * @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 { + // Handle post type entities. + if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { + return current_user_can( 'edit_post', absint( $object_id ) ); + } + + // All of the remaining checks are for collections. If an object ID is + // provided, reject the request. + if ( null !== $object_id ) { + return false; + } + + // Collection syncing 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( + 'postType', + 'root', + 'taxonomy', + ); + + return in_array( $entity_kind, $allowed_collection_entity_kinds, true ); + } + + /** + * Processes and stores an awareness update from a client. + * + * @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 Updated awareness state for the room. + */ + 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_IN_S ) { + 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, + ); + } + + $this->storage->set_awareness_state( $room, $updated_awareness ); + + // Convert to client_id => state map for response. + $response = array(); + foreach ( $updated_awareness as $entry ) { + $response[ $entry['client_id'] ] = (object) $entry['state']; + } + + return $response; + } + + /** + * Processes a sync 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 $update Sync update with 'type' and 'data' fields. + */ + private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void { + $data = $update['data']; + $type = $update['type']; + + switch ( $type ) { + case self::UPDATE_TYPE_COMPACTION: + /* + * 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. + * + * The remove_updates_before_cursor method returns false if there + * is a newer compaction update already stored. + */ + if ( $this->remove_updates_before_cursor( $room, $cursor ) ) { + $this->add_update( $room, $client_id, $type, $data ); + } + break; + + case self::UPDATE_TYPE_SYNC_STEP1: + /* + * Sync step 1 announces a client's state vector. Other clients need + * to see it so they can respond with sync_step2 containing missing + * updates. The cursor-based filtering prevents re-delivery. + */ + $this->add_update( $room, $client_id, $type, $data ); + break; + + case self::UPDATE_TYPE_SYNC_STEP2: + // Sync step 2 contains updates for a specific client. + $this->add_update( $room, $client_id, $type, $data ); + break; + + case self::UPDATE_TYPE_UPDATE: + // Regular document updates are stored persistently. + $this->add_update( $room, $client_id, $type, $data ); + break; + } + } + + /** + * Adds an update to a room's update list. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param string $type Update type (sync_step1, sync_step2, update, compaction). + * @param string $data Base64-encoded update data. + */ + private function add_update( string $room, int $client_id, string $type, string $data ): void { + $update_envelope = array( + 'client_id' => $client_id, + 'type' => $type, + 'data' => $data, + 'timestamp' => $this->get_time_marker(), + ); + + $this->storage->add_update( $room, $update_envelope ); + } + + /** + * 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 floor( microtime( true ) * 1000 ); + } + + /** + * Gets sync updates from a room after a given cursor. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param int $cursor Return updates after this cursor. + * @param bool $is_compactor True if this client is nominated to perform compaction. + * @return array Response data for this room. + */ + private function get_updates_after( string $room, int $client_id, int $cursor, bool $is_compactor ): array { + $end_cursor = $this->get_time_marker() - 100; // Small buffer to ensure consistency. + $all_updates = $this->storage->get_all_updates( $room ); + $total_updates = count( $all_updates ); + $updates = array(); + + foreach ( $all_updates as $update ) { + // Skip updates from this client, unless they are compaction updates. + if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) { + continue; + } + + // Skip updates before our cursor. + if ( $update['timestamp'] > $cursor ) { + $updates[] = $update; + } + } + + // Sort by update timestamp to ensure order. + usort( + $updates, + function ( $a, $b ) { + return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); + } + ); + + // Convert to typed update format for response. + $typed_updates = array(); + foreach ( $updates as $update ) { + $typed_updates[] = array( + 'data' => $update['data'], + 'type' => $update['type'], + ); + } + + // Determine if this client should perform compaction. + $compaction_request = null; + if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { + $compaction_request = $all_updates; + } + + return array( + 'compaction_request' => $compaction_request, + 'end_cursor' => $end_cursor, + 'room' => $room, + 'total_updates' => $total_updates, + 'updates' => $typed_updates, + ); + } + + /** + * Removes updates from a room that are older than the given compaction marker. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with markers < this cursor. + * @return bool True if this compaction is the latest, false if a newer compaction update exists. + */ + private function remove_updates_before_cursor( string $room, int $cursor ): bool { + $all_updates = $this->storage->get_all_updates( $room ); + $this->storage->remove_all_updates( $room ); + + $is_latest_compaction = true; + $updates_to_keep = array(); + + foreach ( $all_updates as $update ) { + if ( $update['timestamp'] >= $cursor ) { + $updates_to_keep[] = $update; + + if ( self::UPDATE_TYPE_COMPACTION === $update['type'] ) { + $is_latest_compaction = false; + } + } + } + + // Replace all updates with filtered list. + foreach ( $updates_to_keep as $update ) { + $this->storage->add_update( $room, $update ); + } + + return $is_latest_compaction; + } +} 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 new file mode 100644 index 0000000000000..c32417c100fde --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -0,0 +1,206 @@ + 'Sync Storage', + 'public' => false, + 'publicly_queryable' => false, + 'show_in_menu' => false, + 'show_in_rest' => false, + 'show_ui' => false, + 'supports' => array( 'custom-fields' ), + ) + ); + } + + /** + * Adds a sync update to a given room. + * + * @since 6.8.0 + * + * @param string $room Room identifier. + * @param array $update Sync update. + */ + public function add_update( string $room, array $update ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + add_post_meta( $post_id, $meta_key, $update, false ); + } + + /** + * Retrieves sync updates for a given room. + * + * @since 6.8.0 + * + * @param string $room Room identifier. + * @return array Array of sync updates. + */ + public function get_all_updates( string $room ): array { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + $updates = get_post_meta( $post_id, $meta_key, false ); + + if ( ! is_array( $updates ) ) { + $updates = array(); + } + + return $updates; + } + + /** + * Gets awareness state for a given room. + * + * @since 6.8.0 + * + * @param string $room Room identifier. + * @return array Merged awareness state. + */ + public function get_awareness_state( string $room ): array { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_awareness_meta_key( $room ); + $awareness = get_post_meta( $post_id, $meta_key, true ); + + if ( ! is_array( $awareness ) ) { + return array(); + } + + return $awareness; + } + + /** + * Removes all sync updates for a given room. + * + * @since 6.8.0 + * + * @param string $room Room identifier. + */ + public function remove_all_updates( string $room ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + delete_post_meta( $post_id, $meta_key ); + } + + /** + * Sets awareness state for a given room. + * + * @since 6.8.0 + * + * @param string $room Room identifier. + * @param array $awareness Merged awareness state. + */ + public function set_awareness_state( string $room, array $awareness ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_awareness_meta_key( $room ); + + update_post_meta( $post_id, $meta_key, $awareness ); + } + + /** + * Gets the meta key for a room's awareness state. + * + * @since 6.8.0 + * + * @param string $room Room identifier. + * @return string Meta key. + */ + private function get_awareness_meta_key( string $room ): string { + return 'sync_awareness_' . md5( $room ); + } + + /** + * Gets the meta key for a room's updates. + * + * @since 6.8.0 + * + * @param string $room Room identifier. + * @return string Meta key. + */ + private function get_room_meta_key( string $room ): string { + return 'sync_update_' . md5( $room ); + } + + /** + * Gets or creates the singleton post for storing sync data. + * + * @since 6.8.0 + * + * @return int Post ID. + */ + private function get_storage_post_id(): int { + if ( is_int( self::$storage_post_id ) ) { + return self::$storage_post_id; + } + + // Try to find existing post. + $posts = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'publish', + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + if ( ! empty( $posts ) ) { + self::$storage_post_id = $posts[0]->ID; + return self::$storage_post_id; + } + + // Create new post if none exists. + $post_id = wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => 'Sync Storage', + ) + ); + + if ( ! is_wp_error( $post_id ) ) { + self::$storage_post_id = $post_id; + } + + return self::$storage_post_id; + } +} diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php new file mode 100644 index 0000000000000..bb1893c1daed8 --- /dev/null +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -0,0 +1,64 @@ +register_routes(); + + // Collaboration. + $sync_storage = new WP_Sync_Post_Meta_Storage(); + $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); + $sync_server->register_routes(); } /** diff --git a/src/wp-settings.php b/src/wp-settings.php index 70e79df0838a6..e90be87754bb1 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -300,6 +300,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-post-meta-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php'; require ABSPATH . WPINC . '/collaboration.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; From 9a2578f7132e1fc9f534efba833a688ade31f9c7 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 9 Feb 2026 23:06:33 -0500 Subject: [PATCH 04/44] Bugfix: When collaboration is enabled, always target autosave revision --- .../class-wp-rest-autosaves-controller.php | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index 8cb0a4987ff3d..b47a614c873d6 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -232,7 +232,31 @@ public function create_item( $request ) { $post_lock = wp_check_post_lock( $post->ID ); $is_draft = 'draft' === $post->post_status || 'auto-draft' === $post->post_status; - if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock ) { + /* + * In the context of real-time collaboration, all peers are effectively + * authors and we don't want to vary behavior based on whether they are the + * original author. Always target an autosave revision. + * + * This avoids the following issue when real-time collaboration is enabled: + * + * - Autosaves from the original author (if they have the post lock) will + * target the saved post. + * + * - Autosaves from other users are applied to a post revision. + * + * - If any user reloads a post, they load changes from the author's autosave. + * + * - The saved post has now diverged from the persisted CRDT document. The + * content (and/or title or excerpt) are now "ahead" of the persisted CRDT + * document. + * + * - When the persisted CRDT document is loaded, a diff is computed against + * the saved post. This diff is then applied to the in-memory CRDT + * document, which can lead to duplicate inserts or deletions. + */ + $is_collaboration_enabled = get_option( 'enable_real_time_collaboration' ); + + if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) { /* * Draft posts for the same author: autosaving updates the post and does not create a revision. * Convert the post object to an array and add slashes, wp_update_post() expects escaped array. From 2c1e87d623ec4c3b53b08d84971fdcb574048f7b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 13:22:35 -0500 Subject: [PATCH 05/44] Move post meta registration to wp_create_initial_post_meta --- src/wp-includes/collaboration.php | 35 ----------------------------- src/wp-includes/default-filters.php | 1 - src/wp-includes/post.php | 25 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 36 deletions(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 752aeefd5281b..51fb45bd0efff 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -6,41 +6,6 @@ * @since 7.0.0 */ -/** - * Registers collaboration-related post meta. - * - * @since 7.0.0 - * - * @access private - */ -function wp_collaboration_register_meta() { - $persisted_crdt_post_meta_key = '_crdt_document'; - - register_meta( - 'post', - $persisted_crdt_post_meta_key, - array( - 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { - return user_can( $user_id, 'edit_post', $object_id ); - }, - /* - * Revisions must be disabled because we always want to preserve - * the latest persisted CRDT document, even when a revision is restored. - * This ensures that we can continue to apply updates to a shared document - * and peers can simply merge the restored revision like any other incoming - * update. - * - * If we want to persist CRDT documents alongside revisions in the - * future, we should do so in a separate meta key. - */ - 'revisions_enabled' => false, - 'show_in_rest' => true, - 'single' => true, - 'type' => 'string', - ) - ); -} - /** * Injects the real-time collaboration setting into a global variable. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 72b8462734ca2..301b846343ee2 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -125,7 +125,6 @@ add_action( 'added_post_meta', 'wp_cache_set_posts_last_changed' ); add_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed' ); add_action( 'deleted_post_meta', 'wp_cache_set_posts_last_changed' ); -add_action( 'init', 'wp_collaboration_register_meta' ); // User meta. add_action( 'added_user_meta', 'wp_cache_set_users_last_changed' ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 1b36ad58fc5e3..1460a9749045a 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8611,6 +8611,7 @@ function use_block_editor_for_post_type( $post_type ) { * Registers any additional post meta fields. * * @since 6.3.0 Adds `wp_pattern_sync_status` meta field to the wp_block post type so an unsynced option can be added. + * @since 7.0.0 Adds `_crdt_document` meta field to post types so that CRDT documents can be persisted. * * @link https://github.com/WordPress/gutenberg/pull/51144 */ @@ -8630,4 +8631,28 @@ function wp_create_initial_post_meta() { ), ) ); + + register_meta( + 'post', + '_crdt_document', + array( + 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { + return user_can( $user_id, 'edit_post', $object_id ); + }, + /* + * Revisions must be disabled because we always want to preserve + * the latest persisted CRDT document, even when a revision is restored. + * This ensures that we can continue to apply updates to a shared document + * and peers can simply merge the restored revision like any other incoming + * update. + * + * If we want to persist CRDT documents alongside revisions in the + * future, we should do so in a separate meta key. + */ + 'revisions_enabled' => false, + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) + ); } From b9d2caf074cac7b0027330108be66f3348f33fcc Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 13:50:17 -0500 Subject: [PATCH 06/44] Move post type registration to create_initial_post_types --- .../class-wp-sync-post-meta-storage.php | 17 ++-------- src/wp-includes/post.php | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 15 deletions(-) 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 index c32417c100fde..105ac97d486ee 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -33,24 +33,11 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { private static $storage_post_id = null; /** - * Registers the custom post type for sync storage. + * Initializer. * * @since 6.8.0 */ - public function init(): void { - register_post_type( - self::POST_TYPE, - array( - 'label' => 'Sync Storage', - 'public' => false, - 'publicly_queryable' => false, - 'show_in_menu' => false, - 'show_in_rest' => false, - 'show_ui' => false, - 'supports' => array( 'custom-fields' ), - ) - ); - } + public function init(): void {} /** * Adds a sync update to a given room. diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 1460a9749045a..4d2c89a320480 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -657,6 +657,39 @@ function create_initial_post_types() { ) ); + 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( From 312e94433776a8318a2f3a71a472bb0dda2e142b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 15:02:53 -0500 Subject: [PATCH 07/44] Update REST namespace to match convention --- .../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 ce9da0771f6c6..4440a41a260d6 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 @@ -18,7 +18,7 @@ class WP_HTTP_Polling_Sync_Server { * @since 7.0.0 * @var string */ - const REST_NAMESPACE = 'wp/v2/sync'; + const REST_NAMESPACE = 'wp-sync/v1'; /** * Awareness timeout in seconds. Clients that haven't updated From c7aff9ea6dc6b676a4015799046147cafd387408 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 15:07:43 -0500 Subject: [PATCH 08/44] Add localization --- .../collaboration/class-wp-http-polling-sync-server.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 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 4440a41a260d6..44870192f98b1 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 @@ -183,7 +183,7 @@ public function check_permissions( WP_REST_Request $request ) { if ( 2 !== count( $type_parts ) ) { return new WP_Error( 'invalid_room_format', - 'Invalid room format. Expected: entity_kind/entity_name or entity_kind/entity_name:id', + __( 'Invalid room format. Expected: entity_kind/entity_name or entity_kind/entity_name:id' ), array( 'status' => 400 ) ); } @@ -199,7 +199,11 @@ public function check_permissions( WP_REST_Request $request ) { if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( 'forbidden', - sprintf( 'You do not have permission to sync this entity: %s.', $room ), + sprintf( + /* translators: %s: The room name encodes the current entity being synced. */ + __( 'You do not have permission to sync this entity: %s.' ), + $room + ), array( 'status' => 401 ) ); } From 9a3351b6324faef2f50a2a643d29c463822a2167 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 15:09:11 -0500 Subject: [PATCH 09/44] Use array property notation instead of get_param --- .../collaboration/class-wp-http-polling-sync-server.php | 4 ++-- 1 file changed, 2 insertions(+), 2 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 44870192f98b1..cb86da97a0c7a 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 @@ -173,7 +173,7 @@ public function register_routes(): void { * @return bool|WP_Error True if user has permission, otherwise WP_Error with details. */ public function check_permissions( WP_REST_Request $request ) { - $rooms = $request->get_param( 'rooms' ); + $rooms = $request['rooms']; foreach ( $rooms as $room ) { $room = $room['room']; @@ -222,7 +222,7 @@ public function check_permissions( WP_REST_Request $request ) { * @return WP_REST_Response|WP_Error Response object or error. */ public function handle_request( WP_REST_Request $request ) { - $rooms = $request->get_param( 'rooms' ); + $rooms = $request['rooms']; $response = array( 'rooms' => array(), ); From 43bc9898387d50a433ee699f8e448144cb445ab7 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 15:22:12 -0500 Subject: [PATCH 10/44] Miscellaneous type fixes --- .../class-wp-http-polling-sync-server.php | 8 ++------ .../class-wp-sync-post-meta-storage.php | 15 ++++++++------- 2 files changed, 10 insertions(+), 13 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 cb86da97a0c7a..988fa4d8dd597 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 @@ -190,11 +190,7 @@ public function check_permissions( WP_REST_Request $request ) { $entity_kind = $type_parts[0]; $entity_name = $object_parts[0]; - $object_id = null; - - if ( isset( $object_parts[1] ) ) { - $object_id = $object_parts[1]; - } + $object_id = $object_parts[1] ?? null; if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( @@ -428,7 +424,7 @@ private function get_time_marker(): int { * @param int $client_id Client identifier. * @param int $cursor Return updates after this cursor. * @param bool $is_compactor True if this client is nominated to perform compaction. - * @return array Response data for this room. + * @return array Response data for this room. */ private function get_updates_after( string $room, int $client_id, int $cursor, bool $is_compactor ): array { $end_cursor = $this->get_time_marker() - 100; // Small buffer to ensure consistency. 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 index 105ac97d486ee..1217ef49077c0 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -30,7 +30,7 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { * @since 6.8.0 * @var int|null */ - private static $storage_post_id = null; + private static ?int $storage_post_id = null; /** * Initializer. @@ -152,9 +152,9 @@ private function get_room_meta_key( string $room ): string { * * @since 6.8.0 * - * @return int Post ID. + * @return int|null Post ID. */ - private function get_storage_post_id(): int { + private function get_storage_post_id(): ?int { if ( is_int( self::$storage_post_id ) ) { return self::$storage_post_id; } @@ -165,13 +165,14 @@ private function get_storage_post_id(): int { 'post_type' => self::POST_TYPE, 'posts_per_page' => 1, 'post_status' => 'publish', - 'orderby' => 'ID', + 'fields' => 'ids', 'order' => 'ASC', ) ); - if ( ! empty( $posts ) ) { - self::$storage_post_id = $posts[0]->ID; + $post_id = array_first( $posts ); + if ( is_int( $post_id ) ) { + self::$storage_post_id = $post_id; return self::$storage_post_id; } @@ -184,7 +185,7 @@ private function get_storage_post_id(): int { ) ); - if ( ! is_wp_error( $post_id ) ) { + if ( is_int( $post_id ) ) { self::$storage_post_id = $post_id; } From 757091d804254b48b50fb68ef517d447e917277a Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 16:28:00 -0500 Subject: [PATCH 11/44] Update storage mechanism --- .../class-wp-http-polling-sync-server.php | 154 +++++---------- .../class-wp-sync-post-meta-storage.php | 186 ++++++++++++++---- .../interface-wp-sync-storage.php | 62 ++++-- 3 files changed, 234 insertions(+), 168 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 988fa4d8dd597..2397733bfa171 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 @@ -241,7 +241,7 @@ public function handle_request( WP_REST_Request $request ) { } // Get updates for this client. - $room_response = $this->get_updates_after( $room, $client_id, $cursor, $is_compactor ); + $room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor ); $room_response['awareness'] = $merged_awareness; $response['rooms'][] = $room_response; @@ -253,8 +253,6 @@ public function handle_request( WP_REST_Request $request ) { /** * Checks if the current user can sync a specific entity type. * - * @since 7.0.0 - * * @param string $entity_kind The entity kind. * @param string $entity_name The entity name. * @param string|null $object_id The object ID (if applicable). @@ -287,12 +285,10 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ /** * Processes and stores an awareness update from a client. * - * @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 Updated awareness state for the room. + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param array|null $awareness_update Awareness state sent by the client. + * @return array> Updated awareness state for the room. */ private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { $existing_awareness = $this->storage->get_awareness_state( $room ); @@ -336,12 +332,10 @@ private function process_awareness_update( string $room, int $client_id, ?array /** * Processes a sync 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 $update Sync update with 'type' and 'data' fields. + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param int $cursor Client cursor (marker of last seen update). + * @param array $update Sync update with 'type' and 'data' fields. */ private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void { $data = $update['data']; @@ -354,39 +348,44 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * updates with markers before the client's cursor to preserve updates * that arrived since the client's last sync. * - * The remove_updates_before_cursor method returns false if there - * is a newer compaction update already stored. + * Check for a newer compaction update first. If one exists, skip this + * compaction to avoid overwriting it. */ - if ( $this->remove_updates_before_cursor( $room, $cursor ) ) { + $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); + $has_newer_compaction = false; + + foreach ( $updates_after_cursor as $existing ) { + if ( self::UPDATE_TYPE_COMPACTION === $existing['type'] ) { + $has_newer_compaction = true; + break; + } + } + + if ( ! $has_newer_compaction ) { + $this->storage->remove_updates_before_cursor( $room, $cursor ); $this->add_update( $room, $client_id, $type, $data ); } break; case self::UPDATE_TYPE_SYNC_STEP1: + case self::UPDATE_TYPE_SYNC_STEP2: + case self::UPDATE_TYPE_UPDATE: /* * Sync step 1 announces a client's state vector. Other clients need * to see it so they can respond with sync_step2 containing missing * updates. The cursor-based filtering prevents re-delivery. + * + * Sync step 2 contains updates for a specific client. + * + * All updates are stored persistently. */ $this->add_update( $room, $client_id, $type, $data ); break; - - case self::UPDATE_TYPE_SYNC_STEP2: - // Sync step 2 contains updates for a specific client. - $this->add_update( $room, $client_id, $type, $data ); - break; - - case self::UPDATE_TYPE_UPDATE: - // Regular document updates are stored persistently. - $this->add_update( $room, $client_id, $type, $data ); - break; } } /** - * Adds an update to a room's update list. - * - * @since 7.0.0 + * Adds an update to a room's update list via storage. * * @param string $room Room identifier. * @param int $client_id Client identifier. @@ -394,31 +393,20 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * @param string $data Base64-encoded update data. */ private function add_update( string $room, int $client_id, string $type, string $data ): void { - $update_envelope = array( + $update = array( 'client_id' => $client_id, - 'type' => $type, 'data' => $data, - 'timestamp' => $this->get_time_marker(), + 'type' => $type, ); - $this->storage->add_update( $room, $update_envelope ); + $this->storage->add_update( $room, $update ); } /** - * Gets the current time in milliseconds as a comparable time marker. + * Gets sync updates for a specific client from a room after a given cursor. * - * @since 7.0.0 - * - * @return int Current time in milliseconds. - */ - private function get_time_marker(): int { - return floor( microtime( true ) * 1000 ); - } - - /** - * Gets sync updates from a room after a given cursor. - * - * @since 7.0.0 + * Delegates cursor-based retrieval to the storage layer, then applies + * client-specific filtering and compaction logic. * * @param string $room Room identifier. * @param int $client_id Client identifier. @@ -426,35 +414,17 @@ private function get_time_marker(): int { * @param bool $is_compactor True if this client is nominated to perform compaction. * @return array Response data for this room. */ - private function get_updates_after( string $room, int $client_id, int $cursor, bool $is_compactor ): array { - $end_cursor = $this->get_time_marker() - 100; // Small buffer to ensure consistency. - $all_updates = $this->storage->get_all_updates( $room ); - $total_updates = count( $all_updates ); - $updates = array(); - - foreach ( $all_updates as $update ) { - // Skip updates from this client, unless they are compaction updates. + private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array { + $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); + $total_updates = $this->storage->get_update_count( $room ); + + // Filter out this client's updates, except compaction updates. + $typed_updates = array(); + foreach ( $updates_after_cursor as $update ) { if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) { continue; } - // Skip updates before our cursor. - if ( $update['timestamp'] > $cursor ) { - $updates[] = $update; - } - } - - // Sort by update timestamp to ensure order. - usort( - $updates, - function ( $a, $b ) { - return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); - } - ); - - // Convert to typed update format for response. - $typed_updates = array(); - foreach ( $updates as $update ) { $typed_updates[] = array( 'data' => $update['data'], 'type' => $update['type'], @@ -464,49 +434,15 @@ function ( $a, $b ) { // Determine if this client should perform compaction. $compaction_request = null; if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { - $compaction_request = $all_updates; + $compaction_request = $updates_after_cursor; } return array( 'compaction_request' => $compaction_request, - 'end_cursor' => $end_cursor, + 'end_cursor' => $this->storage->get_cursor( $room ), 'room' => $room, 'total_updates' => $total_updates, 'updates' => $typed_updates, ); } - - /** - * Removes updates from a room that are older than the given compaction marker. - * - * @since 7.0.0 - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. - * @return bool True if this compaction is the latest, false if a newer compaction update exists. - */ - private function remove_updates_before_cursor( string $room, int $cursor ): bool { - $all_updates = $this->storage->get_all_updates( $room ); - $this->storage->remove_all_updates( $room ); - - $is_latest_compaction = true; - $updates_to_keep = array(); - - foreach ( $all_updates as $update ) { - if ( $update['timestamp'] >= $cursor ) { - $updates_to_keep[] = $update; - - if ( self::UPDATE_TYPE_COMPACTION === $update['type'] ) { - $is_latest_compaction = false; - } - } - } - - // Replace all updates with filtered list. - foreach ( $updates_to_keep as $update ) { - $this->storage->add_update( $room, $update ); - } - - return $is_latest_compaction; - } } 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 index 1217ef49077c0..a6d7b4bef263c 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -11,7 +11,7 @@ * * Data is stored as post meta on a singleton post of a custom post type. * - * @since 6.8.0 + * @since 7.0.0 * * @access private */ @@ -19,15 +19,28 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { /** * Post type for sync storage. * - * @since 6.8.0 + * @since 7.0.0 * @var string */ const POST_TYPE = 'wp_sync_storage'; + /** + * Cache of cursors by room. + * + * @var array + */ + private $room_cursors = array(); + + /** + * Cache of update counts by room. + * + * @var array + */ + private $room_update_counts = array(); + /** * Singleton post ID for storing sync data. * - * @since 6.8.0 * @var int|null */ private static ?int $storage_post_id = null; @@ -35,34 +48,40 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { /** * Initializer. * - * @since 6.8.0 + * @since 7.0.0 */ public function init(): void {} /** * Adds a sync update to a given room. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $room Room identifier. - * @param array $update Sync update. + * @param mixed $update Sync update. */ - public function add_update( string $room, array $update ): void { + public function add_update( string $room, mixed $update ): void { $post_id = $this->get_storage_post_id(); $meta_key = $this->get_room_meta_key( $room ); - add_post_meta( $post_id, $meta_key, $update, false ); + // Create an envelope and stamp each update to enable cursor-based filtering. + $envelope = array( + 'timestamp' => $this->get_time_marker(), + 'value' => $update, + ); + + add_post_meta( $post_id, $meta_key, $envelope, false ); } /** - * Retrieves sync updates for a given room. - * - * @since 6.8.0 + * Retrieve all sync updates for a given room. * * @param string $room Room identifier. - * @return array Array of sync updates. + * @return array Array of sync updates. */ - public function get_all_updates( string $room ): array { + 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(); $meta_key = $this->get_room_meta_key( $room ); $updates = get_post_meta( $post_id, $meta_key, false ); @@ -71,16 +90,18 @@ public function get_all_updates( string $room ): array { $updates = array(); } + $this->room_update_counts[ $room ] = count( $updates ); + return $updates; } /** * Gets awareness state for a given room. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $room Room identifier. - * @return array Merged awareness state. + * @return array Awareness state. */ public function get_awareness_state( string $room ): array { $post_id = $this->get_storage_post_id(); @@ -94,29 +115,15 @@ public function get_awareness_state( string $room ): array { return $awareness; } - /** - * Removes all sync updates for a given room. - * - * @since 6.8.0 - * - * @param string $room Room identifier. - */ - public function remove_all_updates( string $room ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); - - delete_post_meta( $post_id, $meta_key ); - } - /** * Sets awareness state for a given room. * - * @since 6.8.0 + * @since 7.0.0 * - * @param string $room Room identifier. - * @param array $awareness Merged awareness state. + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. */ - public function set_awareness_state( string $room, array $awareness ): void { + public function set_awareness_state( string $room, mixed $awareness ): void { $post_id = $this->get_storage_post_id(); $meta_key = $this->get_awareness_meta_key( $room ); @@ -126,32 +133,42 @@ public function set_awareness_state( string $room, array $awareness ): void { /** * Gets the meta key for a room's awareness state. * - * @since 6.8.0 - * * @param string $room Room identifier. * @return string Meta key. */ private function get_awareness_meta_key( string $room ): string { - return 'sync_awareness_' . md5( $room ); + return 'wp_sync_awareness_' . md5( $room ); } /** - * Gets the meta key for a room's updates. + * Gets the current cursor for a given room. * - * @since 6.8.0 + * 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 the meta key for a room's updates. * * @param string $room Room identifier. * @return string Meta key. */ private function get_room_meta_key( string $room ): string { - return 'sync_update_' . md5( $room ); + return 'wp_sync_update_' . md5( $room ); } /** * Gets or creates the singleton post for storing sync data. * - * @since 6.8.0 - * * @return int|null Post ID. */ private function get_storage_post_id(): ?int { @@ -191,4 +208,91 @@ private function get_storage_post_id(): ?int { return self::$storage_post_id; } + + /** + * Gets the current time in milliseconds as a comparable time marker. + * + * @return int Current time in milliseconds. + */ + private function get_time_marker(): int { + return 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 Array of 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, + function ( $a, $b ) { + return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); + } + ); + + return wp_list_pluck( $updates, 'value' ); + } + + /** + * Removes all sync updates for a given room. + * + * @param string $room Room identifier. + */ + private function remove_all_updates( string $room ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + delete_post_meta( $post_id, $meta_key ); + } + + /** + * 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. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): void { + $all_updates = $this->get_all_updates( $room ); + $this->remove_all_updates( $room ); + + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + // Re-store envelopes directly to avoid double-wrapping by add_update(). + foreach ( $all_updates as $envelope ) { + if ( $envelope['timestamp'] >= $cursor ) { + add_post_meta( $post_id, $meta_key, $envelope, false ); + } + } + } } diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php index bb1893c1daed8..9687bfb33fc8e 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -9,56 +9,82 @@ interface WP_Sync_Storage { /** * Initializes the storage mechanism. * - * @since 6.8.0 + * @since 7.0.0 */ public function init(): void; /** * Adds a sync update to a given room. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $room Room identifier. - * @param array $update Sync update. + * @param mixed $update Serializable sync update, opaque to the storage implementation. */ - public function add_update( string $room, array $update ): void; + public function add_update( string $room, mixed $update ): void; /** - * Retrieves sync updates for a given room. + * Gets awareness state for a given room. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $room Room identifier. - * @return array Array of sync updates. + * @return array Awareness state. */ - public function get_all_updates( string $room ): array; + public function get_awareness_state( string $room ): array; /** - * Gets awareness state for a given room. + * Get the current cursor for a given room. This should return a monotonically + * increasing integer that represents the last update that was returned for the + * room during the current request. This allows clients to retrieve updates + * after a specific cursor on subsequent requests. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $room Room identifier. - * @return array Merged awareness state. + * @return int Current cursor for the room. */ - public function get_awareness_state( string $room ): array; + public function get_cursor( string $room ): int; /** - * Removes all updates for a given room. + * Gets the total number of stored updates for a given room. * - * @since 6.8.0 + * @since 7.0.0 * * @param string $room Room identifier. + * @return int Total number of updates. + */ + 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. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @return array Array of sync updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array; + + /** + * Removes updates from a room that are older than the provided cursor. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with markers < this cursor. */ - public function remove_all_updates( string $room ): void; + public function remove_updates_before_cursor( string $room, int $cursor ): void; /** * Sets awareness state for a given room. * - * @since 6.8.0 + * @since 7.0.0 * - * @param string $room Room identifier. - * @param array $awareness Merged awareness state. + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. */ public function set_awareness_state( string $room, array $awareness ): void; } From b503cef24e014c86a82f43d69eec158dbde18d19 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 13 Feb 2026 14:11:28 -0500 Subject: [PATCH 12/44] Fix type hint --- .../collaboration/class-wp-sync-post-meta-storage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a6d7b4bef263c..fa9463fe60297 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -123,7 +123,7 @@ public function get_awareness_state( string $room ): array { * @param string $room Room identifier. * @param array $awareness Serializable awareness state. */ - public function set_awareness_state( string $room, mixed $awareness ): void { + public function set_awareness_state( string $room, array $awareness ): void { $post_id = $this->get_storage_post_id(); $meta_key = $this->get_awareness_meta_key( $room ); From e8b2c0d6974994987c7069562e21d70611b76932 Mon Sep 17 00:00:00 2001 From: Paul Kevan Date: Tue, 17 Feb 2026 11:59:37 +0000 Subject: [PATCH 13/44] Tests: Add enable_real_time_collaboration to REST settings test expected values The test_get_items test in WP_Test_REST_Settings_Controller hardcodes the expected list of settings exposed via the REST API. The new enable_real_time_collaboration setting was registered with show_in_rest but not added to this expected list, causing the test to fail. --- tests/phpunit/tests/rest-api/rest-settings-controller.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index e8f90b53f20f1..eee2605531cc9 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -119,6 +119,7 @@ public function test_get_items() { 'default_ping_status', 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php + 'enable_real_time_collaboration', ); if ( ! is_multisite() ) { From 5608aa17446bba4a4c260249658cb32f55c43c1f Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 16 Feb 2026 19:36:10 -0500 Subject: [PATCH 14/44] Type hint and annotation updates --- .../class-wp-http-polling-sync-server.php | 13 +++++++------ .../class-wp-sync-post-meta-storage.php | 8 ++++---- .../collaboration/interface-wp-sync-storage.php | 4 ++-- 3 files changed, 13 insertions(+), 12 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 2397733bfa171..0b86ec55b146a 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 @@ -73,9 +73,8 @@ class WP_HTTP_Polling_Sync_Server { * Storage backend for sync updates. * * @since 7.0.0 - * @var WP_Sync_Storage */ - private $storage; + private WP_Sync_Storage $storage; /** * Constructor. @@ -253,13 +252,15 @@ public function handle_request( WP_REST_Request $request ) { /** * Checks if the current user can sync a specific entity type. * - * @param string $entity_kind The entity kind. - * @param string $entity_name The entity name. - * @param string|null $object_id The object ID (if applicable). + * @since 7.0.0 + * + * @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'. + * @param string $entity_name The entity name, e.g. 'post', 'category', 'site'. + * @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 { - // Handle post type entities. + // Handle single post type entities with a defined object ID. if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { return current_user_can( 'edit_post', absint( $object_id ) ); } 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 index fa9463fe60297..72d8e4e007381 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -29,14 +29,14 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { * * @var array */ - private $room_cursors = array(); + private array $room_cursors = array(); /** * Cache of update counts by room. * * @var array */ - private $room_update_counts = array(); + private array $room_update_counts = array(); /** * Singleton post ID for storing sync data. @@ -215,7 +215,7 @@ private function get_storage_post_id(): ?int { * @return int Current time in milliseconds. */ private function get_time_marker(): int { - return floor( microtime( true ) * 1000 ); + return (int) floor( microtime( true ) * 1000 ); } /** @@ -238,7 +238,7 @@ public function get_update_count( string $room ): int { * * @param string $room Room identifier. * @param int $cursor Return updates after this cursor. - * @return array Array of sync updates. + * @return array Sync updates. */ public function get_updates_after_cursor( string $room, int $cursor ): array { $all_updates = $this->get_all_updates( $room ); diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php index 9687bfb33fc8e..a8a7e346d0562 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -21,7 +21,7 @@ public function init(): void; * @param string $room Room identifier. * @param mixed $update Serializable sync update, opaque to the storage implementation. */ - public function add_update( string $room, mixed $update ): void; + public function add_update( string $room, $update ): void; /** * Gets awareness state for a given room. @@ -64,7 +64,7 @@ public function get_update_count( string $room ): int; * * @param string $room Room identifier. * @param int $cursor Return updates after this cursor. - * @return array Array of sync updates. + * @return array Sync updates. */ public function get_updates_after_cursor( string $room, int $cursor ): array; From d7038cfe4a17b03691b7ac4848f0bf68a1f8c420 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 16 Feb 2026 19:44:28 -0500 Subject: [PATCH 15/44] Add missing label --- src/wp-admin/options-writing.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/options-writing.php b/src/wp-admin/options-writing.php index f535ec7a092ab..1943f42176714 100644 --- a/src/wp-admin/options-writing.php +++ b/src/wp-admin/options-writing.php @@ -113,7 +113,7 @@ /> - + Date: Mon, 16 Feb 2026 19:45:35 -0500 Subject: [PATCH 16/44] Improve room input validation --- .../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 0b86ec55b146a..95e08dd1f120b 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,9 @@ public function register_routes(): void { 'type' => 'integer', ), 'room' => array( - 'sanitize_callback' => 'sanitize_text_field', 'required' => true, 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\d+)?$', ), 'updates' => array( 'items' => $typed_update_args, From 81b9a8d1ce1e8c7b2b76ea4da7418427948d4d81 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 16 Feb 2026 19:47:15 -0500 Subject: [PATCH 17/44] Change const name to remove unit --- .../collaboration/class-wp-http-polling-sync-server.php | 4 ++-- 1 file changed, 2 insertions(+), 2 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 95e08dd1f120b..7c86f1aee059e 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 @@ -27,7 +27,7 @@ class WP_HTTP_Polling_Sync_Server { * @since 7.0.0 * @var int */ - const AWARENESS_TIMEOUT_IN_S = 30; + const AWARENESS_TIMEOUT = 30; /** * Threshold used to signal clients to send a compaction update. @@ -303,7 +303,7 @@ private function process_awareness_update( string $room, int $client_id, ?array } // Remove entries that have expired. - if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT_IN_S ) { + if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) { continue; } From 374181ba87f5ecb1a976749a76829d8e96c3c7fa Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 16 Feb 2026 21:31:02 -0500 Subject: [PATCH 18/44] Add static keyword --- src/wp-includes/post.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 4d2c89a320480..c5e2abb75c3a5 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8669,7 +8669,7 @@ function wp_create_initial_post_meta() { 'post', '_crdt_document', array( - 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { + 'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { return user_can( $user_id, 'edit_post', $object_id ); }, /* From 2becd6c87aec29676e7c9ee8c4c888e3f11b320e Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 16 Feb 2026 21:52:53 -0500 Subject: [PATCH 19/44] Remove object coercion --- .../collaboration/class-wp-http-polling-sync-server.php | 4 ++-- 1 file changed, 2 insertions(+), 2 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 7c86f1aee059e..4fafa9bfd2053 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 @@ -289,7 +289,7 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ * @param string $room Room identifier. * @param int $client_id Client identifier. * @param array|null $awareness_update Awareness state sent by the client. - * @return array> Updated awareness state for the room. + * @return array>> Updated awareness state for the room. */ private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { $existing_awareness = $this->storage->get_awareness_state( $room ); @@ -324,7 +324,7 @@ private function process_awareness_update( string $room, int $client_id, ?array // Convert to client_id => state map for response. $response = array(); foreach ( $updated_awareness as $entry ) { - $response[ $entry['client_id'] ] = (object) $entry['state']; + $response[ $entry['client_id'] ] = $entry['state']; } return $response; From 9f1339bee8bdad9c953521dbf3a463b3b305ecbb Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 08:52:38 -0500 Subject: [PATCH 20/44] Return boolean from storage class methods that mutate data --- .../class-wp-http-polling-sync-server.php | 44 +++++++++-- .../class-wp-sync-post-meta-storage.php | 73 ++++++++++++------- .../interface-wp-sync-storage.php | 9 ++- 3 files changed, 90 insertions(+), 36 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 4fafa9bfd2053..c82a65d1effdf 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 @@ -230,13 +230,19 @@ public function handle_request( WP_REST_Request $request ) { // Merge awareness state. $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 = min( array_keys( $merged_awareness ) ) === $client_id; // Process each update according to its type. foreach ( $room_request['updates'] as $update ) { - $this->process_sync_update( $room, $client_id, $cursor, $update ); + $result = $this->process_sync_update( $room, $client_id, $cursor, $update ); + if ( is_wp_error( $result ) ) { + return $result; + } } // Get updates for this client. @@ -319,6 +325,7 @@ private function process_awareness_update( string $room, int $client_id, ?array ); } + // 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. @@ -337,8 +344,9 @@ private function process_awareness_update( string $room, int $client_id, ?array * @param int $client_id Client identifier. * @param int $cursor Client cursor (marker of last seen update). * @param array $update Sync update with 'type' and 'data' fields. + * @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 ): void { + private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) { $data = $update['data']; $type = $update['type']; @@ -363,8 +371,15 @@ private function process_sync_update( string $room, int $client_id, int $cursor, } if ( ! $has_newer_compaction ) { - $this->storage->remove_updates_before_cursor( $room, $cursor ); - $this->add_update( $room, $client_id, $type, $data ); + if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + return new WP_Error( + 'sync_storage_error', + __( 'Failed to remove updates during compaction.' ), + array( 'status' => 500 ) + ); + } + + return $this->add_update( $room, $client_id, $type, $data ); } break; @@ -380,9 +395,15 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * * All updates are stored persistently. */ - $this->add_update( $room, $client_id, $type, $data ); + return $this->add_update( $room, $client_id, $type, $data ); break; } + + return new WP_Error( + 'invalid_update_type', + __( 'Invalid sync update type.' ), + array( 'status' => 400 ) + ); } /** @@ -392,15 +413,24 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * @param int $client_id Client identifier. * @param string $type Update type (sync_step1, sync_step2, update, compaction). * @param string $data Base64-encoded update data. + * @return bool|WP_Error True on success, WP_Error on storage failure. */ - private function add_update( string $room, int $client_id, string $type, string $data ): void { + private function add_update( string $room, int $client_id, string $type, string $data ) { $update = array( 'client_id' => $client_id, 'data' => $data, 'type' => $type, ); - $this->storage->add_update( $room, $update ); + if ( ! $this->storage->add_update( $room, $update ) ) { + return new WP_Error( + 'sync_storage_error', + __( 'Failed to store sync update.' ), + array( 'status' => 500 ) + ); + } + + return true; } /** 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 index 72d8e4e007381..bdb963cddc910 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -59,9 +59,14 @@ public function init(): void {} * * @param string $room Room identifier. * @param mixed $update Sync update. + * @return bool True on success, false on failure. */ - public function add_update( string $room, mixed $update ): void { - $post_id = $this->get_storage_post_id(); + public function add_update( string $room, $update ): bool { + $post_id = $this->get_storage_post_id(); + if ( null === $post_id ) { + return false; + } + $meta_key = $this->get_room_meta_key( $room ); // Create an envelope and stamp each update to enable cursor-based filtering. @@ -70,19 +75,25 @@ public function add_update( string $room, mixed $update ): void { 'value' => $update, ); - add_post_meta( $post_id, $meta_key, $envelope, false ); + return (bool) add_post_meta( $post_id, $meta_key, $envelope, false ); } /** * Retrieve all sync updates for a given room. * + * @since 7.0.0 + * * @param string $room Room identifier. - * @return array Array of sync updates. + * @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(); + $post_id = $this->get_storage_post_id(); + if ( null === $post_id ) { + return array(); + } + $meta_key = $this->get_room_meta_key( $room ); $updates = get_post_meta( $post_id, $meta_key, false ); @@ -104,7 +115,11 @@ private function get_all_updates( string $room ): array { * @return array Awareness state. */ public function get_awareness_state( string $room ): array { - $post_id = $this->get_storage_post_id(); + $post_id = $this->get_storage_post_id(); + if ( null === $post_id ) { + return array(); + } + $meta_key = $this->get_awareness_meta_key( $room ); $awareness = get_post_meta( $post_id, $meta_key, true ); @@ -122,12 +137,19 @@ public function get_awareness_state( string $room ): array { * * @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 ): void { - $post_id = $this->get_storage_post_id(); + public function set_awareness_state( string $room, array $awareness ): bool { + $post_id = $this->get_storage_post_id(); + if ( null === $post_id ) { + return false; + } + $meta_key = $this->get_awareness_meta_key( $room ); + // update_post_meta returns false if the value is the same as the existing value. update_post_meta( $post_id, $meta_key, $awareness ); + return true; } /** @@ -261,18 +283,6 @@ function ( $a, $b ) { return wp_list_pluck( $updates, 'value' ); } - /** - * Removes all sync updates for a given room. - * - * @param string $room Room identifier. - */ - private function remove_all_updates( string $room ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); - - delete_post_meta( $post_id, $meta_key ); - } - /** * Removes updates from a room that are older than the given cursor. * @@ -280,19 +290,30 @@ private function remove_all_updates( string $room ): void { * * @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 ): void { + public function remove_updates_before_cursor( string $room, int $cursor ): bool { + $post_id = $this->get_storage_post_id(); + if ( null === $post_id ) { + return false; + } + $all_updates = $this->get_all_updates( $room ); - $this->remove_all_updates( $room ); + $meta_key = $this->get_room_meta_key( $room ); - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); + // Remove all updates for the room and re-store only those that are newer than the cursor. + if ( ! delete_post_meta( $post_id, $meta_key ) ) { + return false; + } // Re-store envelopes directly to avoid double-wrapping by add_update(). + $add_result = true; foreach ( $all_updates as $envelope ) { - if ( $envelope['timestamp'] >= $cursor ) { - add_post_meta( $post_id, $meta_key, $envelope, false ); + if ( $add_result && $envelope['timestamp'] >= $cursor ) { + $add_result = (bool) add_post_meta( $post_id, $meta_key, $envelope, false ); } } + + return $add_result; } } diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php index a8a7e346d0562..1f0dfb7e2e64d 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -20,8 +20,9 @@ public function init(): void; * * @param string $room Room identifier. * @param mixed $update Serializable sync update, opaque to the storage implementation. + * @return bool True on success, false on failure. */ - public function add_update( string $room, $update ): void; + public function add_update( string $room, $update ): bool; /** * Gets awareness state for a given room. @@ -75,8 +76,9 @@ public function get_updates_after_cursor( string $room, int $cursor ): array; * * @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 ): void; + public function remove_updates_before_cursor( string $room, int $cursor ): bool; /** * Sets awareness state for a given room. @@ -85,6 +87,7 @@ public function remove_updates_before_cursor( string $room, int $cursor ): void; * * @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 ): void; + public function set_awareness_state( string $room, array $awareness ): bool; } From 0b3d1a7653d56917ea1695e498f93cdc420ac8f8 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 09:13:05 -0500 Subject: [PATCH 21/44] Improve entity handling around object IDs --- .../class-wp-http-polling-sync-server.php | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 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 c82a65d1effdf..74ff2461cd9f7 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,9 @@ public function register_routes(): void { 'type' => 'integer', ), 'room' => array( - 'required' => true, - 'type' => 'string', - 'pattern' => '^[^/]+/[^/:]+(?::\\d+)?$', + 'required' => true, + 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', ), 'updates' => array( 'items' => $typed_update_args, @@ -179,17 +179,9 @@ public function check_permissions( WP_REST_Request $request ) { $type_parts = explode( '/', $room, 2 ); $object_parts = explode( ':', $type_parts[1] ?? '', 2 ); - if ( 2 !== count( $type_parts ) ) { - return new WP_Error( - 'invalid_room_format', - __( 'Invalid room format. Expected: entity_kind/entity_name or entity_kind/entity_name:id' ), - array( 'status' => 400 ) - ); - } - $entity_kind = $type_parts[0]; $entity_name = $object_parts[0]; - $object_id = $object_parts[1] ?? null; + $object_id = isset( $object_parts[1] ) ? (int) $object_parts[1] : null; if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( @@ -277,6 +269,16 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ return false; } + // For postType collections, check if the user can edit posts of this type. + if ( 'postType' === $entity_kind ) { + $post_type_object = get_post_type_object( $entity_name ); + if ( ! isset( $post_type_object->cap->edit_posts ) ) { + return false; + } + + return current_user_can( $post_type_object->cap->edit_posts ); + } + // Collection syncing 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. From 0226f6a50fd19d1b0639d36d11286183b37d34b3 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 09:15:13 -0500 Subject: [PATCH 22/44] Account for empty awareness array --- .../collaboration/class-wp-http-polling-sync-server.php | 5 ++++- 1 file changed, 4 insertions(+), 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 74ff2461cd9f7..8776e122778fc 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 @@ -227,7 +227,10 @@ public function handle_request( WP_REST_Request $request ) { } // The lowest client ID is nominated to perform compaction when needed. - $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id; + $is_compactor = false; + if ( count( $merged_awareness ) > 0 ) { + $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id; + } // Process each update according to its type. foreach ( $room_request['updates'] as $update ) { From d2abae1e0cdfda6482e582ada14e792004dd23a8 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 09:18:27 -0500 Subject: [PATCH 23/44] Simplify timestamp sort --- .../collaboration/class-wp-sync-post-meta-storage.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index bdb963cddc910..056d35d3c0b82 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -275,9 +275,7 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Sort by timestamp to ensure order. usort( $updates, - function ( $a, $b ) { - return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); - } + fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp'] ); return wp_list_pluck( $updates, 'value' ); From 93f97f3bc96d1cb7c69090eaba8dab24ff86bc4a Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 09:23:43 -0500 Subject: [PATCH 24/44] Add missing @since markers --- src/wp-includes/collaboration.php | 2 +- .../class-wp-http-polling-sync-server.php | 8 ++++++++ .../collaboration/class-wp-sync-post-meta-storage.php | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 51fb45bd0efff..1da7f2c367140 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -9,7 +9,7 @@ /** * Injects the real-time collaboration setting into a global variable. * - * @since 6.8.0 + * @since 7.0.0 * * @access private */ 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 8776e122778fc..90808dbb99a2b 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 @@ -297,6 +297,8 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ /** * Processes and stores an awareness update from a client. * + * @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. @@ -345,6 +347,8 @@ private function process_awareness_update( string $room, int $client_id, ?array /** * Processes a sync 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). @@ -414,6 +418,8 @@ private function process_sync_update( string $room, int $client_id, int $cursor, /** * Adds an update to a room's update list via storage. * + * @since 7.0.0 + * * @param string $room Room identifier. * @param int $client_id Client identifier. * @param string $type Update type (sync_step1, sync_step2, update, compaction). @@ -444,6 +450,8 @@ private function add_update( string $room, int $client_id, string $type, string * Delegates cursor-based retrieval to the storage layer, then applies * client-specific filtering and compaction logic. * + * @since 7.0.0 + * * @param string $room Room identifier. * @param int $client_id Client identifier. * @param int $cursor Return updates after this cursor. 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 index 056d35d3c0b82..e3aa32eabcd6a 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -27,6 +27,7 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { /** * Cache of cursors by room. * + * @since 7.0.0 * @var array */ private array $room_cursors = array(); @@ -34,6 +35,7 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { /** * Cache of update counts by room. * + * @since 7.0.0 * @var array */ private array $room_update_counts = array(); @@ -41,6 +43,7 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { /** * Singleton post ID for storing sync data. * + * @since 7.0.0 * @var int|null */ private static ?int $storage_post_id = null; @@ -155,6 +158,8 @@ public function set_awareness_state( string $room, array $awareness ): bool { /** * Gets the meta key for a room's awareness state. * + * @since 7.0.0 + * * @param string $room Room identifier. * @return string Meta key. */ @@ -181,6 +186,8 @@ public function get_cursor( string $room ): int { /** * Gets the meta key for a room's updates. * + * @since 7.0.0 + * * @param string $room Room identifier. * @return string Meta key. */ @@ -191,6 +198,8 @@ private function get_room_meta_key( string $room ): string { /** * Gets or creates the singleton post for storing sync data. * + * @since 7.0.0 + * * @return int|null Post ID. */ private function get_storage_post_id(): ?int { @@ -234,6 +243,8 @@ private function get_storage_post_id(): ?int { /** * 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 { From ba49b8a3d1cb86b52970534b63bcc89535176287 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 09:24:09 -0500 Subject: [PATCH 25/44] Remove unused storage initializer --- .../collaboration/class-wp-sync-post-meta-storage.php | 7 ------- .../collaboration/interface-wp-sync-storage.php | 7 ------- 2 files changed, 14 deletions(-) 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 index e3aa32eabcd6a..00f3cb29b3ca4 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -48,13 +48,6 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { */ private static ?int $storage_post_id = null; - /** - * Initializer. - * - * @since 7.0.0 - */ - public function init(): void {} - /** * Adds a sync update to a given room. * diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php index 1f0dfb7e2e64d..026f2508790c7 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -6,13 +6,6 @@ */ interface WP_Sync_Storage { - /** - * Initializes the storage mechanism. - * - * @since 7.0.0 - */ - public function init(): void; - /** * Adds a sync update to a given room. * From 0ab36f5e248878bd04042282767b8f030214d074 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 09:35:36 -0500 Subject: [PATCH 26/44] Update test mocks --- tests/qunit/fixtures/wp-api-generated.js | 115 ++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 1623eca0c0f47..9b52afda20a0c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -20,7 +20,8 @@ mockedApiResponse.Schema = { "wp/v2", "wp-site-health/v1", "wp-block-editor/v1", - "wp-abilities/v1" + "wp-abilities/v1", + "wp-sync/v1" ], "authentication": { "application-passwords": { @@ -11085,6 +11086,12 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "enable_real_time_collaboration": { + "title": "", + "description": "Enable Real-Time Collaboration", + "type": "boolean", + "required": false + }, "posts_per_page": { "title": "Maximum posts per page", "description": "Blog pages show at most.", @@ -12692,6 +12699,111 @@ mockedApiResponse.Schema = { } } ] + }, + "/wp-sync/v1": { + "namespace": "wp-sync/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-sync/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-sync/v1" + } + ] + } + }, + "/wp-sync/v1/updates": { + "namespace": "wp-sync/v1", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "rooms": { + "items": { + "properties": { + "after": { + "minimum": 0, + "required": true, + "type": "integer" + }, + "awareness": { + "required": true, + "type": "object" + }, + "client_id": { + "minimum": 1, + "required": true, + "type": "integer" + }, + "room": { + "required": true, + "type": "string", + "pattern": "^[^/]+/[^/:]+(?::\\S+)?$" + }, + "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-sync/v1/updates" + } + ] + } } }, "site_logo": 0, @@ -14546,6 +14658,7 @@ mockedApiResponse.settings = { "use_smilies": true, "default_category": 1, "default_post_format": "0", + "enable_real_time_collaboration": false, "posts_per_page": 10, "show_on_front": "posts", "page_on_front": 0, From ec89531ed48b31b18e58aa4c9e77c434cc5f6672 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 09:49:31 -0500 Subject: [PATCH 27/44] Improve return types --- .../class-wp-http-polling-sync-server.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 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 90808dbb99a2b..656893f4db69f 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 @@ -302,7 +302,7 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ * @param string $room Room identifier. * @param int $client_id Client identifier. * @param array|null $awareness_update Awareness state sent by the client. - * @return array>> Updated awareness state for the room. + * @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 ); @@ -456,7 +456,13 @@ private function add_update( string $room, int $client_id, string $type, string * @param int $client_id Client identifier. * @param int $cursor Return updates after this cursor. * @param bool $is_compactor True if this client is nominated to perform compaction. - * @return array Response data for this room. + * @return array{ + * compaction_request: array|null, + * end_cursor: int, + * room: string, + * total_updates: int, + * updates: array + * } Response data for this room. */ private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array { $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); From bc1245f0b549ddaaa9fa468ab7c98ed05c248201 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 21:14:46 -0500 Subject: [PATCH 28/44] Add unit tests --- .../tests/rest-api/rest-sync-server.php | 671 ++++++++++++++++++ 1 file changed, 671 insertions(+) create mode 100644 tests/phpunit/tests/rest-api/rest-sync-server.php diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php new file mode 100644 index 0000000000000..f89c5c7cd27cf --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -0,0 +1,671 @@ +user->create( array( 'role' => 'editor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$editor_id ); + self::delete_user( self::$subscriber_id ); + wp_delete_post( self::$post_id, true ); + } + + 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_id' ); + $reflection->setAccessible( true ); + $reflection->setValue( null, null ); + } + + /** + * Builds a room request array for the sync endpoint. + * + * @param string $room Room identifier. + * @param int $client_id Client ID. + * @param int $cursor Cursor value for the 'after' parameter. + * @param array $awareness Awareness state. + * @param array $updates Array of updates. + * @return array Room request data. + */ + private function build_room( $room, $client_id = 1, $cursor = 0, $awareness = array(), $updates = array() ) { + if ( empty( $awareness ) ) { + $awareness = array( 'user' => 'test' ); + } + + return array( + 'after' => $cursor, + 'awareness' => $awareness, + 'client_id' => $client_id, + 'room' => $room, + 'updates' => $updates, + ); + } + + /** + * Dispatches a sync request with the given rooms. + * + * @param array $rooms Array of room request data. + * @return WP_REST_Response Response object. + */ + private function dispatch_sync( $rooms ) { + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_body_params( array( 'rooms' => $rooms ) ); + return rest_get_server()->dispatch( $request ); + } + + /** + * Returns the default room identifier for the test post. + * + * @return string Room identifier. + */ + private function get_post_room() { + return 'postType/post:' . self::$post_id; + } + + /* + * Required abstract method implementations. + * + * The sync 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 ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Not applicable for sync endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // Not applicable for sync endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item() { + // Not applicable for sync endpoint. + } + + public function test_create_item() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Not applicable for sync endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Not applicable for sync endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Not applicable for sync endpoint. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Not applicable for sync endpoint. + } + + /* + * Permission tests. + */ + + public function test_sync_requires_authentication() { + wp_set_current_user( 0 ); + + $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertErrorResponse( 'forbidden', $response, 401 ); + } + + public function test_sync_post_requires_edit_capability() { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertErrorResponse( 'forbidden', $response, 401 ); + } + + public function test_sync_post_allowed_with_edit_capability() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( 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() { + wp_set_current_user( self::$subscriber_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) ); + + $this->assertErrorResponse( 'forbidden', $response, 401 ); + } + + public function test_sync_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' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_sync_root_collection_allowed() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'root/site' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_sync_taxonomy_collection_allowed() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + public function test_sync_unknown_collection_kind_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'unknown/entity' ) ) ); + + $this->assertErrorResponse( 'forbidden', $response, 401 ); + } + + public function test_sync_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' ) ) ); + + $this->assertErrorResponse( 'forbidden', $response, 401 ); + } + + public function test_sync_nonexistent_post_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:999999' ) ) ); + + $this->assertErrorResponse( 'forbidden', $response, 401 ); + } + + public function test_sync_permission_checked_per_room() { + wp_set_current_user( self::$editor_id ); + + // First room is allowed, second room is forbidden. + $response = $this->dispatch_sync( + array( + $this->build_room( $this->get_post_room() ), + $this->build_room( 'unknown/entity' ), + ) + ); + + $this->assertErrorResponse( 'forbidden', $response, 401 ); + } + + /* + * Validation tests. + */ + + public function test_sync_invalid_room_format_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( + array( + $this->build_room( 'invalid-room-format' ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + } + + /* + * Response format tests. + */ + + public function test_sync_response_structure() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'rooms', $data ); + $this->assertCount( 1, $data['rooms'] ); + + $room_data = $data['rooms'][0]; + $this->assertArrayHasKey( 'room', $room_data ); + $this->assertArrayHasKey( 'awareness', $room_data ); + $this->assertArrayHasKey( 'updates', $room_data ); + $this->assertArrayHasKey( 'end_cursor', $room_data ); + $this->assertArrayHasKey( 'total_updates', $room_data ); + $this->assertArrayHasKey( 'compaction_request', $room_data ); + } + + public function test_sync_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 ) ) ); + + $data = $response->get_data(); + $this->assertSame( $room, $data['rooms'][0]['room'] ); + } + + public function test_sync_end_cursor_is_positive_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'] ); + } + + public function test_sync_empty_updates_returns_zero_total() { + 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]['total_updates'] ); + $this->assertEmpty( $data['rooms'][0]['updates'] ); + } + + /* + * Update tests. + */ + + public function test_sync_update_delivered_to_other_client() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdCBkYXRh', + ); + + // Client 1 sends an update. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 requests updates from the beginning. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0 ), + ) + ); + + $data = $response->get_data(); + $updates = $data['rooms'][0]['updates']; + + $this->assertNotEmpty( $updates ); + + $types = wp_list_pluck( $updates, 'type' ); + $this->assertContains( 'update', $types ); + } + + public function test_sync_own_updates_not_returned() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'b3duIGRhdGE=', + ); + + // Client 1 sends an update. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + $data = $response->get_data(); + $updates = $data['rooms'][0]['updates']; + + // Client 1 should not see its own non-compaction update. + $this->assertEmpty( $updates ); + } + + public function test_sync_step1_update_stored_and_returned() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'sync_step1', + 'data' => 'c3RlcDE=', + ); + + // Client 1 sends sync_step1. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 should see the sync_step1 update. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0 ), + ) + ); + + $data = $response->get_data(); + $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); + $this->assertContains( 'sync_step1', $types ); + } + + public function test_sync_step2_update_stored_and_returned() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'sync_step2', + 'data' => 'c3RlcDI=', + ); + + // Client 1 sends sync_step2. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 should see the sync_step2 update. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0 ), + ) + ); + + $data = $response->get_data(); + $types = wp_list_pluck( $data['rooms'][0]['updates'], 'type' ); + $this->assertContains( 'sync_step2', $types ); + } + + public function test_sync_multiple_updates_in_single_request() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $updates = array( + array( + 'type' => 'sync_step1', + 'data' => 'c3RlcDE=', + ), + array( + 'type' => 'update', + 'data' => 'dXBkYXRl', + ), + ); + + // Client 1 sends multiple updates. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), $updates ), + ) + ); + + // Client 2 should see both updates. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0 ), + ) + ); + + $data = $response->get_data(); + $room_updates = $data['rooms'][0]['updates']; + + $this->assertCount( 2, $room_updates ); + $this->assertSame( 2, $data['rooms'][0]['total_updates'] ); + } + + public function test_sync_update_data_preserved() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'cHJlc2VydmVkIGRhdGE=', + ); + + // Client 1 sends an update. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 should receive the exact same data. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0 ), + ) + ); + + $data = $response->get_data(); + $room_updates = $data['rooms'][0]['updates']; + + $this->assertSame( 'cHJlc2VydmVkIGRhdGE=', $room_updates[0]['data'] ); + $this->assertSame( 'update', $room_updates[0]['type'] ); + } + + public function test_sync_total_updates_increments() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Send three updates from different clients. + $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 ) ), + ) + ); + $this->dispatch_sync( + array( + $this->build_room( $room, 3, 0, array( 'user' => 'c3' ), array( $update ) ), + ) + ); + + // Any client should see total_updates = 3. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 4, 0 ), + ) + ); + + $data = $response->get_data(); + $this->assertSame( 3, $data['rooms'][0]['total_updates'] ); + } + + /* + * Awareness tests. + */ + + public function test_sync_awareness_returned() { + wp_set_current_user( self::$editor_id ); + + $awareness = array( 'name' => 'Editor' ); + $response = $this->dispatch_sync( + array( + $this->build_room( $this->get_post_room(), 1, 0, $awareness ), + ) + ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 1, $data['rooms'][0]['awareness'] ); + $this->assertSame( $awareness, $data['rooms'][0]['awareness'][1] ); + } + + public function test_sync_awareness_shows_multiple_clients() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + + // Client 1 connects. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'name' => 'Client 1' ) ), + ) + ); + + // Client 2 connects. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0, array( 'name' => 'Client 2' ) ), + ) + ); + + $data = $response->get_data(); + $awareness = $data['rooms'][0]['awareness']; + + $this->assertArrayHasKey( 1, $awareness ); + $this->assertArrayHasKey( 2, $awareness ); + $this->assertSame( array( 'name' => 'Client 1' ), $awareness[1] ); + $this->assertSame( array( 'name' => 'Client 2' ), $awareness[2] ); + } + + public function test_sync_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( + array( + $this->build_room( $room, 1, 0, array( 'cursor' => 'start' ) ), + ) + ); + + // Client 1 updates its awareness. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'cursor' => 'updated' ) ), + ) + ); + + $data = $response->get_data(); + $awareness = $data['rooms'][0]['awareness']; + + // Should have exactly one entry for client 1 with updated state. + $this->assertCount( 1, $awareness ); + $this->assertSame( array( 'cursor' => 'updated' ), $awareness[1] ); + } + + /* + * Multiple rooms tests. + */ + + public function test_sync_multiple_rooms_in_single_request() { + wp_set_current_user( self::$editor_id ); + + $room1 = $this->get_post_room(); + $room2 = 'taxonomy/category'; + + $response = $this->dispatch_sync( + array( + $this->build_room( $room1 ), + $this->build_room( $room2 ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 2, $data['rooms'] ); + $this->assertSame( $room1, $data['rooms'][0]['room'] ); + $this->assertSame( $room2, $data['rooms'][1]['room'] ); + } + + public function test_sync_rooms_are_isolated() { + wp_set_current_user( self::$editor_id ); + + $post_id_2 = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $room1 = $this->get_post_room(); + $room2 = 'postType/post:' . $post_id_2; + + $update = array( + 'type' => 'update', + 'data' => 'cm9vbTEgb25seQ==', + ); + + // Client 1 sends an update to room 1 only. + $this->dispatch_sync( + array( + $this->build_room( $room1, 1, 0, array( 'user' => 'client1' ), array( $update ) ), + ) + ); + + // Client 2 queries both rooms. + $response = $this->dispatch_sync( + array( + $this->build_room( $room1, 2, 0 ), + $this->build_room( $room2, 2, 0 ), + ) + ); + + $data = $response->get_data(); + + // Room 1 should have the update. + $this->assertNotEmpty( $data['rooms'][0]['updates'] ); + + // Room 2 should have no updates. + $this->assertEmpty( $data['rooms'][1]['updates'] ); + } +} From 00f63ccae18e883d64fc053a3a247ee9d851d197 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 17 Feb 2026 22:14:32 -0500 Subject: [PATCH 29/44] Guard setAccessible() --- tests/phpunit/tests/rest-api/rest-sync-server.php | 4 +++- 1 file changed, 3 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 f89c5c7cd27cf..6d0eef4440f4c 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -30,7 +30,9 @@ public function set_up() { // Reset storage post ID cache to ensure clean state after transaction rollback. $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_id' ); - $reflection->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection->setAccessible( true ); + } $reflection->setValue( null, null ); } From c254d4df85011a36bcf08111bda0e416d9bde9c6 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 09:22:04 -0500 Subject: [PATCH 30/44] Backport should_compact change from Gutenberg https://github.com/WordPress/gutenberg/pull/75682 --- .../class-wp-http-polling-sync-server.php | 18 ++-- .../tests/rest-api/rest-sync-server.php | 93 ++++++++++++++++++- 2 files changed, 99 insertions(+), 12 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 656893f4db69f..1cdd48d9cdede 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 @@ -457,8 +457,8 @@ private function add_update( string $room, int $client_id, string $type, string * @param int $cursor Return updates after this cursor. * @param bool $is_compactor True if this client is nominated to perform compaction. * @return array{ - * compaction_request: array|null, * end_cursor: int, + * should_compact: bool, * room: string, * total_updates: int, * updates: array @@ -481,18 +481,14 @@ private function get_updates( string $room, int $client_id, int $cursor, bool $i ); } - // Determine if this client should perform compaction. - $compaction_request = null; - if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { - $compaction_request = $updates_after_cursor; - } + $should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD; return array( - 'compaction_request' => $compaction_request, - 'end_cursor' => $this->storage->get_cursor( $room ), - 'room' => $room, - 'total_updates' => $total_updates, - 'updates' => $typed_updates, + 'end_cursor' => $this->storage->get_cursor( $room ), + 'room' => $room, + 'should_compact' => $should_compact, + 'total_updates' => $total_updates, + 'updates' => $typed_updates, ); } } diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index 6d0eef4440f4c..c7be36a208aaa 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -285,7 +285,7 @@ public function test_sync_response_structure() { $this->assertArrayHasKey( 'updates', $room_data ); $this->assertArrayHasKey( 'end_cursor', $room_data ); $this->assertArrayHasKey( 'total_updates', $room_data ); - $this->assertArrayHasKey( 'compaction_request', $room_data ); + $this->assertArrayHasKey( 'should_compact', $room_data ); } public function test_sync_response_room_matches_request() { @@ -536,6 +536,97 @@ public function test_sync_total_updates_increments() { $this->assertSame( 3, $data['rooms'][0]['total_updates'] ); } + /* + * Compaction tests. + */ + + public function test_sync_should_compact_is_false_below_threshold() { + wp_set_current_user( self::$editor_id ); + + $room = $this->get_post_room(); + $update = array( + 'type' => 'update', + 'data' => 'dGVzdA==', + ); + + // Client 1 sends a single update. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), array( $update ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'] ); + } + + public function test_sync_should_compact_is_true_above_threshold_for_compactor() { + 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. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + // Client 2 (lowest connected client) should be told to compact. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['rooms'][0]['should_compact'] ); + } + + public function test_sync_should_compact_is_false_for_non_compactor() { + 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. + $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ), $updates ), + ) + ); + + // Connect client 2 (lower ID) so client 3 is not the compactor. + $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), + ) + ); + + // Client 3 (higher ID) should not be told to compact. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 3, 0, array( 'user' => 'c3' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'] ); + } + /* * Awareness tests. */ From 76699839e968750d36e8a75b246e30d47c9a7e07 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 09:23:30 -0500 Subject: [PATCH 31/44] Backport cap check from Gutenberg https://github.com/WordPress/gutenberg/pull/75681 --- .../class-wp-http-polling-sync-server.php | 9 +++++++++ .../tests/rest-api/rest-sync-server.php | 18 +++++++----------- 2 files changed, 16 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 1cdd48d9cdede..2bf1b0e85ba96 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 @@ -172,6 +172,15 @@ public function register_routes(): void { * @return bool|WP_Error True if user has permission, otherwise WP_Error with details. */ public function check_permissions( WP_REST_Request $request ) { + // Minimum cap check. Is user logged in with a contributor role or higher? + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to perform this action', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + $rooms = $request['rooms']; foreach ( $rooms as $room ) { diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index c7be36a208aaa..deaf6a2e1a5e0 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -14,6 +14,8 @@ class WP_Test_REST_Sync_Server extends WP_Test_REST_Controller_Testcase { protected static $post_id; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + update_option( 'enable_real_time_collaboration', true ); + self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); @@ -23,6 +25,7 @@ public static function wpTearDownAfterClass() { self::delete_user( self::$editor_id ); self::delete_user( self::$subscriber_id ); wp_delete_post( self::$post_id, true ); + delete_option( 'enable_real_time_collaboration' ); } public function set_up() { @@ -579,10 +582,10 @@ public function test_sync_should_compact_is_true_above_threshold_for_compactor() ) ); - // Client 2 (lowest connected client) should be told to compact. + // Client 1 polls again. It is the lowest (only) client, so it is the compactor. $response = $this->dispatch_sync( array( - $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ) ), ) ); @@ -609,17 +612,10 @@ public function test_sync_should_compact_is_false_for_non_compactor() { ) ); - // Connect client 2 (lower ID) so client 3 is not the compactor. - $this->dispatch_sync( - array( - $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), - ) - ); - - // Client 3 (higher ID) should not be told to compact. + // Client 2 (higher ID than client 1) should not be the compactor. $response = $this->dispatch_sync( array( - $this->build_room( $room, 3, 0, array( 'user' => 'c3' ) ), + $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), ) ); From 12cf2f1c4da8b7eff72475f507d220e0d668c82d Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 09:24:56 -0500 Subject: [PATCH 32/44] Guard registration of post type and REST endpoints if option is not enabled --- src/wp-includes/post.php | 114 ++++++++++++++++++----------------- src/wp-includes/rest-api.php | 8 ++- 2 files changed, 64 insertions(+), 58 deletions(-) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index c5e2abb75c3a5..eefdaafb0f10d 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -657,38 +657,40 @@ function create_initial_post_types() { ) ); - 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' ), - ) - ); + if ( get_option( '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', @@ -8665,27 +8667,29 @@ function wp_create_initial_post_meta() { ) ); - register_meta( - 'post', - '_crdt_document', - array( - 'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { - return user_can( $user_id, 'edit_post', $object_id ); - }, - /* - * Revisions must be disabled because we always want to preserve - * the latest persisted CRDT document, even when a revision is restored. - * This ensures that we can continue to apply updates to a shared document - * and peers can simply merge the restored revision like any other incoming - * update. - * - * If we want to persist CRDT documents alongside revisions in the - * future, we should do so in a separate meta key. - */ - 'revisions_enabled' => false, - 'show_in_rest' => true, - 'single' => true, - 'type' => 'string', - ) - ); + if ( get_option( 'enable_real_time_collaboration' ) ) { + register_meta( + 'post', + '_crdt_document', + array( + 'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { + return user_can( $user_id, 'edit_post', $object_id ); + }, + /* + * Revisions must be disabled because we always want to preserve + * the latest persisted CRDT document, even when a revision is restored. + * This ensures that we can continue to apply updates to a shared document + * and peers can simply merge the restored revision like any other incoming + * update. + * + * If we want to persist CRDT documents alongside revisions in the + * future, we should do so in a separate meta key. + */ + 'revisions_enabled' => false, + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) + ); + } } diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 5510d63b0bee9..f144957286d7c 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -430,9 +430,11 @@ function create_initial_rest_routes() { $icons_controller->register_routes(); // Collaboration. - $sync_storage = new WP_Sync_Post_Meta_Storage(); - $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); - $sync_server->register_routes(); + if ( get_option( 'enable_real_time_collaboration' ) ) { + $sync_storage = new WP_Sync_Post_Meta_Storage(); + $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); + $sync_server->register_routes(); + } } /** From f0015527cde78e210b87a2c309321cce1ad04e1c Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 09:27:42 -0500 Subject: [PATCH 33/44] Remove unnecessary is_wp_error check --- .../collaboration/class-wp-http-polling-sync-server.php | 3 --- 1 file changed, 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 2bf1b0e85ba96..8bfaf25aa401b 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 @@ -231,9 +231,6 @@ public function handle_request( WP_REST_Request $request ) { // Merge awareness state. $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; From 3e8e22bfe789b8d7ed8dafa3199d408774958b70 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 09:29:46 -0500 Subject: [PATCH 34/44] Improvements from static analysis --- .../collaboration/class-wp-http-polling-sync-server.php | 5 ++--- .../collaboration/class-wp-sync-post-meta-storage.php | 2 +- src/wp-includes/collaboration/interface-wp-sync-storage.php | 2 +- 3 files changed, 4 insertions(+), 5 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 8bfaf25aa401b..65917537bd235 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 @@ -190,7 +190,7 @@ public function check_permissions( WP_REST_Request $request ) { $entity_kind = $type_parts[0]; $entity_name = $object_parts[0]; - $object_id = isset( $object_parts[1] ) ? (int) $object_parts[1] : null; + $object_id = $object_parts[1] ?? null; if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( @@ -411,7 +411,6 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * All updates are stored persistently. */ return $this->add_update( $room, $client_id, $type, $data ); - break; } return new WP_Error( @@ -430,7 +429,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * @param int $client_id Client identifier. * @param string $type Update type (sync_step1, sync_step2, update, compaction). * @param string $data Base64-encoded update data. - * @return bool|WP_Error True on success, WP_Error on storage failure. + * @return true|WP_Error True on success, WP_Error on storage failure. */ private function add_update( string $room, int $client_id, string $type, string $data ) { $update = array( 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 index 00f3cb29b3ca4..63514da07a309 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -264,7 +264,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 Sync updates. */ public function get_updates_after_cursor( string $room, int $cursor ): array { $all_updates = $this->get_all_updates( $room ); diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php index 026f2508790c7..b43169b3ba66a 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -58,7 +58,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 Sync updates. */ public function get_updates_after_cursor( string $room, int $cursor ): array; From 0c2cdaded8db939394edf524ed6e2daf7fb71280 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 11:10:21 -0500 Subject: [PATCH 35/44] Update fixture now that endpoint is optional --- tests/qunit/fixtures/wp-api-generated.js | 108 +---------------------- 1 file changed, 1 insertion(+), 107 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 9b52afda20a0c..5caf39e3b4fdf 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -20,8 +20,7 @@ mockedApiResponse.Schema = { "wp/v2", "wp-site-health/v1", "wp-block-editor/v1", - "wp-abilities/v1", - "wp-sync/v1" + "wp-abilities/v1" ], "authentication": { "application-passwords": { @@ -12699,111 +12698,6 @@ mockedApiResponse.Schema = { } } ] - }, - "/wp-sync/v1": { - "namespace": "wp-sync/v1", - "methods": [ - "GET" - ], - "endpoints": [ - { - "methods": [ - "GET" - ], - "args": { - "namespace": { - "default": "wp-sync/v1", - "required": false - }, - "context": { - "default": "view", - "required": false - } - } - } - ], - "_links": { - "self": [ - { - "href": "http://example.org/index.php?rest_route=/wp-sync/v1" - } - ] - } - }, - "/wp-sync/v1/updates": { - "namespace": "wp-sync/v1", - "methods": [ - "POST" - ], - "endpoints": [ - { - "methods": [ - "POST" - ], - "args": { - "rooms": { - "items": { - "properties": { - "after": { - "minimum": 0, - "required": true, - "type": "integer" - }, - "awareness": { - "required": true, - "type": "object" - }, - "client_id": { - "minimum": 1, - "required": true, - "type": "integer" - }, - "room": { - "required": true, - "type": "string", - "pattern": "^[^/]+/[^/:]+(?::\\S+)?$" - }, - "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-sync/v1/updates" - } - ] - } } }, "site_logo": 0, From 5c5821f8d7fc77759b2f15948680c54084c2eb6d Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 15:44:42 -0500 Subject: [PATCH 36/44] Remove text domain --- .../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 65917537bd235..226fbd5ae5aa3 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 @@ -176,7 +176,7 @@ public function check_permissions( WP_REST_Request $request ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'forbidden', - __( 'You do not have permission to perform this action', 'gutenberg' ), + __( 'You do not have permission to perform this action' ), array( 'status' => 401 ) ); } From 3e641962b977dc1ad159dc6a10be46b14eb1c1df Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 15:57:34 -0500 Subject: [PATCH 37/44] Error code updates --- .../class-wp-http-polling-sync-server.php | 14 +++++++------- tests/phpunit/tests/rest-api/rest-sync-server.php | 14 +++++++------- 2 files changed, 14 insertions(+), 14 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 226fbd5ae5aa3..ecd0b96f551d7 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 @@ -175,9 +175,9 @@ public function check_permissions( WP_REST_Request $request ) { // Minimum cap check. Is user logged in with a contributor role or higher? if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( - 'forbidden', + 'rest_cannot_edit', __( 'You do not have permission to perform this action' ), - array( 'status' => 401 ) + array( 'status' => rest_authorization_required_code() ) ); } @@ -194,13 +194,13 @@ public function check_permissions( WP_REST_Request $request ) { if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( - 'forbidden', + '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.' ), $room ), - array( 'status' => 401 ) + array( 'status' => rest_authorization_required_code() ) ); } } @@ -388,7 +388,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( - 'sync_storage_error', + 'rest_sync_storage_error', __( 'Failed to remove updates during compaction.' ), array( 'status' => 500 ) ); @@ -414,7 +414,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, } return new WP_Error( - 'invalid_update_type', + 'rest_invalid_update_type', __( 'Invalid sync update type.' ), array( 'status' => 400 ) ); @@ -440,7 +440,7 @@ private function add_update( string $room, int $client_id, string $type, string if ( ! $this->storage->add_update( $room, $update ) ) { return new WP_Error( - 'sync_storage_error', + 'rest_sync_storage_error', __( 'Failed to store sync update.' ), array( 'status' => 500 ) ); diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index deaf6a2e1a5e0..d6b5a6830e250 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -162,7 +162,7 @@ public function test_sync_requires_authentication() { $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - $this->assertErrorResponse( 'forbidden', $response, 401 ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); } public function test_sync_post_requires_edit_capability() { @@ -170,7 +170,7 @@ public function test_sync_post_requires_edit_capability() { $response = $this->dispatch_sync( array( $this->build_room( $this->get_post_room() ) ) ); - $this->assertErrorResponse( 'forbidden', $response, 401 ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } public function test_sync_post_allowed_with_edit_capability() { @@ -186,7 +186,7 @@ public function test_sync_post_type_collection_requires_edit_posts_capability() $response = $this->dispatch_sync( array( $this->build_room( 'postType/post' ) ) ); - $this->assertErrorResponse( 'forbidden', $response, 401 ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } public function test_sync_post_type_collection_allowed_with_edit_posts_capability() { @@ -218,7 +218,7 @@ public function test_sync_unknown_collection_kind_rejected() { $response = $this->dispatch_sync( array( $this->build_room( 'unknown/entity' ) ) ); - $this->assertErrorResponse( 'forbidden', $response, 401 ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } public function test_sync_non_posttype_entity_with_object_id_rejected() { @@ -226,7 +226,7 @@ public function test_sync_non_posttype_entity_with_object_id_rejected() { $response = $this->dispatch_sync( array( $this->build_room( 'root/site:123' ) ) ); - $this->assertErrorResponse( 'forbidden', $response, 401 ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } public function test_sync_nonexistent_post_rejected() { @@ -234,7 +234,7 @@ public function test_sync_nonexistent_post_rejected() { $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:999999' ) ) ); - $this->assertErrorResponse( 'forbidden', $response, 401 ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } public function test_sync_permission_checked_per_room() { @@ -248,7 +248,7 @@ public function test_sync_permission_checked_per_room() { ) ); - $this->assertErrorResponse( 'forbidden', $response, 401 ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } /* From d95ee6c92056756b48ebf781f76db647f0295943 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 15:58:12 -0500 Subject: [PATCH 38/44] Filter and validate updates from post meta --- .../collaboration/class-wp-sync-post-meta-storage.php | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 63514da07a309..20dc06e8753b6 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -97,6 +97,14 @@ private function get_all_updates( string $room ): array { $updates = array(); } + // Filter out any updates that don't have the expected structure. + $updates = array_filter( + $updates, + static function ( $update ): bool { + return isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); + } + ); + $this->room_update_counts[ $room ] = count( $updates ); return $updates; From 392bd7cd0f178082ee8597646b33dd9823d8720b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 16:00:46 -0500 Subject: [PATCH 39/44] Prefer (int) coercion instead of absint --- .../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 ecd0b96f551d7..7c5531f2fc154 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 @@ -269,7 +269,7 @@ public function handle_request( WP_REST_Request $request ) { private function can_user_sync_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', absint( $object_id ) ); + return current_user_can( 'edit_post', (int) $object_id ); } // All of the remaining checks are for collections. If an object ID is From 20cfe6c47b5780f8cef5421fb339117b9ac6e73a Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 16:01:37 -0500 Subject: [PATCH 40/44] Improve docblocks --- .../class-wp-http-polling-sync-server.php | 14 +++++++------- .../class-wp-sync-post-meta-storage.php | 6 +++--- .../collaboration/interface-wp-sync-storage.php | 2 +- 3 files changed, 11 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 7c5531f2fc154..a4d2b280a0516 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 @@ -272,8 +272,8 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ return current_user_can( 'edit_post', (int) $object_id ); } - // All of the remaining checks are for collections. If an object ID is - // provided, reject the request. + // All the remaining checks are for collections. If an object ID is provided, + // reject the request. if ( null !== $object_id ) { return false; } @@ -355,10 +355,10 @@ private function process_awareness_update( string $room, int $client_id, ?array * * @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 $update Sync update with 'type' and 'data' fields. + * @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. * @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 ) { @@ -466,7 +466,7 @@ private function add_update( string $room, int $client_id, string $type, string * should_compact: bool, * room: string, * total_updates: int, - * updates: array + * updates: array, * } Response data for this room. */ private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array { 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 index 20dc06e8753b6..7637cfa10f0bb 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -75,7 +75,7 @@ public function add_update( string $room, $update ): bool { } /** - * Retrieve all sync updates for a given room. + * Retrieves all sync updates for a given room. * * @since 7.0.0 * @@ -208,7 +208,7 @@ private function get_storage_post_id(): ?int { return self::$storage_post_id; } - // Try to find existing post. + // Try to find an existing post. $posts = get_posts( array( 'post_type' => self::POST_TYPE, @@ -225,7 +225,7 @@ private function get_storage_post_id(): ?int { return self::$storage_post_id; } - // Create new post if none exists. + // Create new post since none exists. $post_id = wp_insert_post( array( 'post_type' => self::POST_TYPE, diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php index b43169b3ba66a..d84dbeb1e4aae 100644 --- a/src/wp-includes/collaboration/interface-wp-sync-storage.php +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -28,7 +28,7 @@ public function add_update( string $room, $update ): bool; public function get_awareness_state( string $room ): array; /** - * Get the current cursor for a given room. This should return a monotonically + * Gets the current cursor for a given room. This should return a monotonically * increasing integer that represents the last update that was returned for the * room during the current request. This allows clients to retrieve updates * after a specific cursor on subsequent requests. From f6fdbd3fb81a3b71f7e91dcca1aa4d84764e5869 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 17:50:24 -0500 Subject: [PATCH 41/44] Create one sync storage post per room --- .../class-wp-sync-post-meta-storage.php | 72 ++++++++++--------- .../tests/rest-api/rest-sync-server.php | 4 +- 2 files changed, 41 insertions(+), 35 deletions(-) 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 index 7637cfa10f0bb..bd85747baec7c 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -9,7 +9,7 @@ * Core class that provides an interface for storing and retrieving sync * updates and awareness data during a collaborative session. * - * Data is stored as post meta on a singleton post of a custom post type. + * Data is stored as post meta on a dedicated post per room of a custom post type. * * @since 7.0.0 * @@ -41,12 +41,12 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { private array $room_update_counts = array(); /** - * Singleton post ID for storing sync data. + * Cache of storage post IDs by room hash. * * @since 7.0.0 - * @var int|null + * @var array */ - private static ?int $storage_post_id = null; + private static array $storage_post_ids = array(); /** * Adds a sync update to a given room. @@ -58,12 +58,12 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { * @return bool True on success, false on failure. */ public function add_update( string $room, $update ): bool { - $post_id = $this->get_storage_post_id(); + $post_id = $this->get_storage_post_id( $room ); if ( null === $post_id ) { return false; } - $meta_key = $this->get_room_meta_key( $room ); + $meta_key = $this->get_room_meta_key(); // Create an envelope and stamp each update to enable cursor-based filtering. $envelope = array( @@ -85,12 +85,12 @@ public function add_update( string $room, $update ): bool { 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(); + $post_id = $this->get_storage_post_id( $room ); if ( null === $post_id ) { return array(); } - $meta_key = $this->get_room_meta_key( $room ); + $meta_key = $this->get_room_meta_key(); $updates = get_post_meta( $post_id, $meta_key, false ); if ( ! is_array( $updates ) ) { @@ -119,12 +119,12 @@ static function ( $update ): bool { * @return array Awareness state. */ public function get_awareness_state( string $room ): array { - $post_id = $this->get_storage_post_id(); + $post_id = $this->get_storage_post_id( $room ); if ( null === $post_id ) { return array(); } - $meta_key = $this->get_awareness_meta_key( $room ); + $meta_key = $this->get_awareness_meta_key(); $awareness = get_post_meta( $post_id, $meta_key, true ); if ( ! is_array( $awareness ) ) { @@ -144,12 +144,12 @@ public function get_awareness_state( string $room ): array { * @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(); + $post_id = $this->get_storage_post_id( $room ); if ( null === $post_id ) { return false; } - $meta_key = $this->get_awareness_meta_key( $room ); + $meta_key = $this->get_awareness_meta_key(); // update_post_meta returns false if the value is the same as the existing value. update_post_meta( $post_id, $meta_key, $awareness ); @@ -157,15 +157,14 @@ public function set_awareness_state( string $room, array $awareness ): bool { } /** - * Gets the meta key for a room's awareness state. + * Gets the meta key for awareness state. * * @since 7.0.0 * - * @param string $room Room identifier. * @return string Meta key. */ - private function get_awareness_meta_key( string $room ): string { - return 'wp_sync_awareness_' . md5( $room ); + private function get_awareness_meta_key(): string { + return 'wp_sync_awareness'; } /** @@ -185,60 +184,67 @@ public function get_cursor( string $room ): int { } /** - * Gets the meta key for a room's updates. + * Gets the meta key for sync updates. * * @since 7.0.0 * - * @param string $room Room identifier. * @return string Meta key. */ - private function get_room_meta_key( string $room ): string { - return 'wp_sync_update_' . md5( $room ); + private function get_room_meta_key(): string { + return 'wp_sync_update'; } /** - * Gets or creates the singleton post for storing sync data. + * 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(): ?int { - if ( is_int( self::$storage_post_id ) ) { - return self::$storage_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. + // 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', - 'order' => 'ASC', ) ); $post_id = array_first( $posts ); if ( is_int( $post_id ) ) { - self::$storage_post_id = $post_id; - return self::$storage_post_id; + self::$storage_post_ids[ $room_hash ] = $post_id; + return $post_id; } - // Create new post since none exists. + // 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_id = $post_id; + self::$storage_post_ids[ $room_hash ] = $post_id; + return $post_id; } - return self::$storage_post_id; + return null; } /** @@ -303,13 +309,13 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { * @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(); + $post_id = $this->get_storage_post_id( $room ); if ( null === $post_id ) { return false; } $all_updates = $this->get_all_updates( $room ); - $meta_key = $this->get_room_meta_key( $room ); + $meta_key = $this->get_room_meta_key(); // Remove all updates for the room and re-store only those that are newer than the cursor. if ( ! delete_post_meta( $post_id, $meta_key ) ) { diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index d6b5a6830e250..0180f02ca3b45 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -32,11 +32,11 @@ 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_id' ); + $reflection = new ReflectionProperty( 'WP_Sync_Post_Meta_Storage', 'storage_post_ids' ); if ( PHP_VERSION_ID < 80100 ) { $reflection->setAccessible( true ); } - $reflection->setValue( null, null ); + $reflection->setValue( null, array() ); } /** From 571ecd28210d50ee32470b0c4373b256a83855e7 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 22:24:26 -0500 Subject: [PATCH 42/44] Add cap check for single taxonomy term entities --- .../collaboration/class-wp-http-polling-sync-server.php | 6 ++++++ 1 file changed, 6 insertions(+) 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 a4d2b280a0516..bc9d208f096be 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 @@ -272,6 +272,12 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ return current_user_can( 'edit_post', (int) $object_id ); } + // Handle single taxonomy term entities with a defined object ID. + if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) { + $taxonomy = get_taxonomy( $entity_name ); + return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms ); + } + // All the remaining checks are for collections. If an object ID is provided, // reject the request. if ( null !== $object_id ) { From 04782a0d50885d6cfb9a52d6001711c8ce4c5ead Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 22:34:56 -0500 Subject: [PATCH 43/44] PHPStan level 9 --- .../collaboration/class-wp-sync-post-meta-storage.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 7637cfa10f0bb..c831bddf9d49c 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -101,7 +101,7 @@ private function get_all_updates( string $room ): array { $updates = array_filter( $updates, static function ( $update ): bool { - return isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); + return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); } ); @@ -131,7 +131,7 @@ public function get_awareness_state( string $room ): array { return array(); } - return $awareness; + return array_values( $awareness ); } /** From c90ca322d7f6e75f3adfa3bb4d11bbee83bb82a8 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 18 Feb 2026 23:19:38 -0500 Subject: [PATCH 44/44] Prefer const for static value --- .../class-wp-sync-post-meta-storage.php | 57 +++++++------------ 1 file changed, 22 insertions(+), 35 deletions(-) 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 index bd85747baec7c..8a9d7c4c5426a 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -24,6 +24,22 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { */ const POST_TYPE = 'wp_sync_storage'; + /** + * Meta key for awareness state. + * + * @since 7.0.0 + * @var string + */ + const AWARENESS_META_KEY = 'wp_sync_awareness'; + + /** + * Meta key for sync updates. + * + * @since 7.0.0 + * @var string + */ + const SYNC_UPDATE_META_KEY = 'wp_sync_update'; + /** * Cache of cursors by room. * @@ -63,15 +79,13 @@ public function add_update( string $room, $update ): bool { return false; } - $meta_key = $this->get_room_meta_key(); - // 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, $meta_key, $envelope, false ); + return (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); } /** @@ -90,8 +104,7 @@ private function get_all_updates( string $room ): array { return array(); } - $meta_key = $this->get_room_meta_key(); - $updates = get_post_meta( $post_id, $meta_key, false ); + $updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false ); if ( ! is_array( $updates ) ) { $updates = array(); @@ -124,8 +137,7 @@ public function get_awareness_state( string $room ): array { return array(); } - $meta_key = $this->get_awareness_meta_key(); - $awareness = get_post_meta( $post_id, $meta_key, true ); + $awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true ); if ( ! is_array( $awareness ) ) { return array(); @@ -149,24 +161,11 @@ public function set_awareness_state( string $room, array $awareness ): bool { return false; } - $meta_key = $this->get_awareness_meta_key(); - // update_post_meta returns false if the value is the same as the existing value. - update_post_meta( $post_id, $meta_key, $awareness ); + update_post_meta( $post_id, self::AWARENESS_META_KEY, $awareness ); return true; } - /** - * Gets the meta key for awareness state. - * - * @since 7.0.0 - * - * @return string Meta key. - */ - private function get_awareness_meta_key(): string { - return 'wp_sync_awareness'; - } - /** * Gets the current cursor for a given room. * @@ -183,17 +182,6 @@ public function get_cursor( string $room ): int { return $this->room_cursors[ $room ] ?? 0; } - /** - * Gets the meta key for sync updates. - * - * @since 7.0.0 - * - * @return string Meta key. - */ - private function get_room_meta_key(): string { - return 'wp_sync_update'; - } - /** * Gets or creates the storage post for a given room. * @@ -315,10 +303,9 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool } $all_updates = $this->get_all_updates( $room ); - $meta_key = $this->get_room_meta_key(); // Remove all updates for the room and re-store only those that are newer than the cursor. - if ( ! delete_post_meta( $post_id, $meta_key ) ) { + if ( ! delete_post_meta( $post_id, self::SYNC_UPDATE_META_KEY ) ) { return false; } @@ -326,7 +313,7 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool $add_result = true; foreach ( $all_updates as $envelope ) { if ( $add_result && $envelope['timestamp'] >= $cursor ) { - $add_result = (bool) add_post_meta( $post_id, $meta_key, $envelope, false ); + $add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); } }