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,