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..1943f42176714 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..1da7f2c367140 --- /dev/null +++ b/src/wp-includes/collaboration.php @@ -0,0 +1,24 @@ +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( + 'required' => true, + 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', + ), + '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 ) { + // Minimum cap check. Is user logged in with a contributor role or higher? + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'rest_cannot_edit', + __( 'You do not have permission to perform this action' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $rooms = $request['rooms']; + + foreach ( $rooms as $room ) { + $room = $room['room']; + $type_parts = explode( '/', $room, 2 ); + $object_parts = explode( ':', $type_parts[1] ?? '', 2 ); + + $entity_kind = $type_parts[0]; + $entity_name = $object_parts[0]; + $object_id = $object_parts[1] ?? null; + + if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { + return new WP_Error( + 'rest_cannot_edit', + sprintf( + /* translators: %s: The room name encodes the current entity being synced. */ + __( 'You do not have permission to sync this entity: %s.' ), + $room + ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + + 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['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 = 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 ) { + $result = $this->process_sync_update( $room, $client_id, $cursor, $update ); + if ( is_wp_error( $result ) ) { + return $result; + } + } + + // Get updates for this client. + $room_response = $this->get_updates( $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, 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 single post type entities with a defined object ID. + if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { + return current_user_can( 'edit_post', (int) $object_id ); + } + + // All the remaining checks are for collections. If an object ID is provided, + // reject the request. + if ( null !== $object_id ) { + 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. + $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> Map of client ID to awareness state. + */ + private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { + $existing_awareness = $this->storage->get_awareness_state( $room ); + $updated_awareness = array(); + $current_time = time(); + + foreach ( $existing_awareness as $entry ) { + // Remove this client's entry (it will be updated below). + if ( $client_id === $entry['client_id'] ) { + continue; + } + + // Remove entries that have expired. + if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) { + continue; + } + + $updated_awareness[] = $entry; + } + + // Add this client's awareness state. + if ( null !== $awareness_update ) { + $updated_awareness[] = array( + 'client_id' => $client_id, + 'state' => $awareness_update, + 'updated_at' => $current_time, + ); + } + + // 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. + $response = array(); + foreach ( $updated_awareness as $entry ) { + $response[ $entry['client_id'] ] = $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{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 ) { + $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. + * + * Check for a newer compaction update first. If one exists, skip this + * compaction to avoid overwriting it. + */ + $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 ) { + if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + return new WP_Error( + 'rest_sync_storage_error', + __( 'Failed to remove updates during compaction.' ), + array( 'status' => 500 ) + ); + } + + return $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. + */ + return $this->add_update( $room, $client_id, $type, $data ); + } + + return new WP_Error( + 'rest_invalid_update_type', + __( 'Invalid sync update type.' ), + array( 'status' => 400 ) + ); + } + + /** + * 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). + * @param string $data Base64-encoded update data. + * @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( + 'client_id' => $client_id, + 'data' => $data, + 'type' => $type, + ); + + if ( ! $this->storage->add_update( $room, $update ) ) { + return new WP_Error( + 'rest_sync_storage_error', + __( 'Failed to store sync update.' ), + array( 'status' => 500 ) + ); + } + + return true; + } + + /** + * Gets sync updates for a specific client from a room after a given cursor. + * + * Delegates cursor-based retrieval to the storage layer, then applies + * client-specific filtering and compaction logic. + * + * @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{ + * end_cursor: int, + * should_compact: bool, + * 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 ); + $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; + } + + $typed_updates[] = array( + 'data' => $update['data'], + 'type' => $update['type'], + ); + } + + $should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD; + + return array( + 'end_cursor' => $this->storage->get_cursor( $room ), + 'room' => $room, + 'should_compact' => $should_compact, + 'total_updates' => $total_updates, + 'updates' => $typed_updates, + ); + } +} 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..7637cfa10f0bb --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -0,0 +1,329 @@ + + */ + private array $room_cursors = array(); + + /** + * Cache of update counts by room. + * + * @since 7.0.0 + * @var array + */ + private array $room_update_counts = array(); + + /** + * Singleton post ID for storing sync data. + * + * @since 7.0.0 + * @var int|null + */ + private static ?int $storage_post_id = null; + + /** + * Adds a sync update to a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param mixed $update Sync update. + * @return bool True on success, false on failure. + */ + public function add_update( string $room, $update ): bool { + $post_id = $this->get_storage_post_id(); + 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. + $envelope = array( + 'timestamp' => $this->get_time_marker(), + 'value' => $update, + ); + + return (bool) add_post_meta( $post_id, $meta_key, $envelope, false ); + } + + /** + * Retrieves all sync updates for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return array Sync updates. + */ + private function get_all_updates( string $room ): array { + $this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency. + + $post_id = $this->get_storage_post_id(); + if ( null === $post_id ) { + return array(); + } + + $meta_key = $this->get_room_meta_key( $room ); + $updates = get_post_meta( $post_id, $meta_key, false ); + + if ( ! is_array( $updates ) ) { + $updates = array(); + } + + // Filter out any updates that don't have the expected structure. + $updates = array_filter( + $updates, + static function ( $update ): bool { + return isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); + } + ); + + $this->room_update_counts[ $room ] = count( $updates ); + + return $updates; + } + + /** + * Gets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return array Awareness state. + */ + public function get_awareness_state( string $room ): array { + $post_id = $this->get_storage_post_id(); + if ( null === $post_id ) { + return array(); + } + + $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; + } + + /** + * Sets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. + * @return bool True on success, false on failure. + */ + public function set_awareness_state( string $room, array $awareness ): bool { + $post_id = $this->get_storage_post_id(); + 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; + } + + /** + * Gets the meta key for a room's 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 ); + } + + /** + * Gets the current cursor for a given room. + * + * The cursor is set during get_updates_after_cursor() and represents the + * point in time just before the updates were retrieved, with a small buffer + * to ensure consistency. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Current cursor for the room. + */ + public function get_cursor( string $room ): int { + return $this->room_cursors[ $room ] ?? 0; + } + + /** + * Gets the meta key for a room's 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 ); + } + + /** + * 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 { + if ( is_int( self::$storage_post_id ) ) { + return self::$storage_post_id; + } + + // Try to find an existing post. + $posts = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'publish', + '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; + } + + // Create new post since none exists. + $post_id = wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => 'Sync Storage', + ) + ); + + if ( is_int( $post_id ) ) { + self::$storage_post_id = $post_id; + } + + return self::$storage_post_id; + } + + /** + * Gets the current time in milliseconds as a comparable time marker. + * + * @since 7.0.0 + * + * @return int Current time in milliseconds. + */ + private function get_time_marker(): int { + return (int) floor( microtime( true ) * 1000 ); + } + + /** + * Gets the number of updates stored for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Number of updates stored for the room. + */ + public function get_update_count( string $room ): int { + return $this->room_update_counts[ $room ] ?? 0; + } + + /** + * Retrieves sync updates from a room for a given client and cursor. Updates + * from the specified client should be excluded. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @return array Sync updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array { + $all_updates = $this->get_all_updates( $room ); + $updates = array(); + + foreach ( $all_updates as $update ) { + if ( $update['timestamp'] > $cursor ) { + $updates[] = $update; + } + } + + // Sort by timestamp to ensure order. + usort( + $updates, + fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp'] + ); + + return wp_list_pluck( $updates, 'value' ); + } + + /** + * Removes updates from a room that are older than the given cursor. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with markers < this cursor. + * @return bool True on success, false on failure. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): bool { + $post_id = $this->get_storage_post_id(); + if ( null === $post_id ) { + return false; + } + + $all_updates = $this->get_all_updates( $room ); + $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 ( $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 new file mode 100644 index 0000000000000..d84dbeb1e4aae --- /dev/null +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -0,0 +1,86 @@ + Awareness state. + */ + public function get_awareness_state( string $room ): array; + + /** + * 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. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Current cursor for the room. + */ + public function get_cursor( string $room ): int; + + /** + * Gets the total number of stored updates for a given room. + * + * @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 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. + * @return bool True on success, false on failure. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): bool; + + /** + * Sets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. + * @return bool True on success, false on failure. + */ + public function set_awareness_state( string $room, array $awareness ): bool; +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index cc010a7b62202..301b846343ee2 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -786,6 +786,9 @@ add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 ); add_action( 'init', '_wp_register_default_font_collections' ); +// Collaboration. +add_action( 'admin_init', 'wp_collaboration_inject_setting' ); + // Add ignoredHookedBlocks metadata attribute to the template and template part post types. add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' ); add_filter( 'rest_pre_insert_wp_template_part', 'inject_ignored_hooked_blocks_metadata_attributes' ); diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 8a9a2c3c89ece..22695444cde35 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2897,6 +2897,18 @@ function register_initial_settings() { ) ); + register_setting( + 'writing', + 'enable_real_time_collaboration', + array( + 'type' => '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-includes/post.php b/src/wp-includes/post.php index 1b36ad58fc5e3..eefdaafb0f10d 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -657,6 +657,41 @@ function create_initial_post_types() { ) ); + 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', array( @@ -8611,6 +8646,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 +8666,30 @@ function wp_create_initial_post_meta() { ), ) ); + + 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 981892025c2a3..f144957286d7c 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -428,6 +428,13 @@ function create_initial_rest_routes() { // Icons. $icons_controller = new WP_REST_Icons_Controller(); $icons_controller->register_routes(); + + // Collaboration. + 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(); + } } /** 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. diff --git a/src/wp-settings.php b/src/wp-settings.php index b437ae153b792..e90be87754bb1 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -300,6 +300,10 @@ 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'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php'; 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() ) { 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..d6b5a6830e250 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -0,0 +1,760 @@ +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 ); + delete_option( 'enable_real_time_collaboration' ); + } + + 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' ); + if ( PHP_VERSION_ID < 80100 ) { + $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( 'rest_cannot_edit', $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( 'rest_cannot_edit', $response, 403 ); + } + + 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( 'rest_cannot_edit', $response, 403 ); + } + + 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( 'rest_cannot_edit', $response, 403 ); + } + + 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( 'rest_cannot_edit', $response, 403 ); + } + + 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( 'rest_cannot_edit', $response, 403 ); + } + + 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( 'rest_cannot_edit', $response, 403 ); + } + + /* + * 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( 'should_compact', $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'] ); + } + + /* + * 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 1 polls again. It is the lowest (only) client, so it is the compactor. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 1, 0, array( 'user' => 'c1' ) ), + ) + ); + + $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 ), + ) + ); + + // Client 2 (higher ID than client 1) should not be the compactor. + $response = $this->dispatch_sync( + array( + $this->build_room( $room, 2, 0, array( 'user' => 'c2' ) ), + ) + ); + + $data = $response->get_data(); + $this->assertFalse( $data['rooms'][0]['should_compact'] ); + } + + /* + * 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'] ); + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 1623eca0c0f47..5caf39e3b4fdf 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11085,6 +11085,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.", @@ -14546,6 +14552,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,