diff --git a/package.json b/package.json
index 766e241ff8d6d..7be6ab6f2bd6b 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"url": "https://develop.svn.wordpress.org/trunk"
},
"gutenberg": {
- "ref": "59a08c5496008ca88f4b6b86f38838c3612d88c8"
+ "ref": "2ab68995bfe6f038d0d0851ad2023007d04730f7"
},
"engines": {
"node": ">=20.10.0",
diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php
index 795060f28b83e..911314ea2cbf3 100644
--- a/src/wp-admin/includes/schema.php
+++ b/src/wp-admin/includes/schema.php
@@ -563,6 +563,9 @@ function populate_options( array $options = array() ) {
// 6.9.0
'wp_notes_notify' => 1,
+
+ // 7.0.0
+ 'enable_real_time_collaboration' => 0,
);
// 3.3.0
diff --git a/src/wp-admin/options-writing.php b/src/wp-admin/options-writing.php
index 6f85b54679327..f535ec7a092ab 100644
--- a/src/wp-admin/options-writing.php
+++ b/src/wp-admin/options-writing.php
@@ -109,6 +109,13 @@
+
+ |
+
+ />
+
+ |
+
diff --git a/src/wp-admin/options.php b/src/wp-admin/options.php
index 8db5cf50f2ec9..57c22be86d367 100644
--- a/src/wp-admin/options.php
+++ b/src/wp-admin/options.php
@@ -153,6 +153,7 @@
'default_email_category',
'default_link_category',
'default_post_format',
+ 'enable_real_time_collaboration',
),
);
$allowed_options['misc'] = array();
diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php
new file mode 100644
index 0000000000000..51fb45bd0efff
--- /dev/null
+++ b/src/wp-includes/collaboration.php
@@ -0,0 +1,24 @@
+storage = $storage;
+ }
+
+ /**
+ * Registers REST API routes.
+ *
+ * @since 7.0.0
+ */
+ public function register_routes(): void {
+ $typed_update_args = array(
+ 'properties' => array(
+ 'data' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'type' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'enum' => array(
+ self::UPDATE_TYPE_COMPACTION,
+ self::UPDATE_TYPE_SYNC_STEP1,
+ self::UPDATE_TYPE_SYNC_STEP2,
+ self::UPDATE_TYPE_UPDATE,
+ ),
+ ),
+ ),
+ 'required' => true,
+ 'type' => 'object',
+ );
+
+ $room_args = array(
+ 'after' => array(
+ 'minimum' => 0,
+ 'required' => true,
+ 'type' => 'integer',
+ ),
+ 'awareness' => array(
+ 'required' => true,
+ 'type' => 'object',
+ ),
+ 'client_id' => array(
+ 'minimum' => 1,
+ 'required' => true,
+ 'type' => 'integer',
+ ),
+ 'room' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'required' => true,
+ 'type' => 'string',
+ ),
+ 'updates' => array(
+ 'items' => $typed_update_args,
+ 'minItems' => 0,
+ 'required' => true,
+ 'type' => 'array',
+ ),
+ );
+
+ register_rest_route(
+ self::REST_NAMESPACE,
+ '/updates',
+ array(
+ 'methods' => array( WP_REST_Server::CREATABLE ),
+ 'callback' => array( $this, 'handle_request' ),
+ 'permission_callback' => array( $this, 'check_permissions' ),
+ 'args' => array(
+ 'rooms' => array(
+ 'items' => array(
+ 'properties' => $room_args,
+ 'type' => 'object',
+ ),
+ 'required' => true,
+ 'type' => 'array',
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Checks if the current user has permission to access a room.
+ *
+ * @since 7.0.0
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @return bool|WP_Error True if user has permission, otherwise WP_Error with details.
+ */
+ public function check_permissions( WP_REST_Request $request ) {
+ $rooms = $request['rooms'];
+
+ foreach ( $rooms as $room ) {
+ $room = $room['room'];
+ $type_parts = explode( '/', $room, 2 );
+ $object_parts = explode( ':', $type_parts[1] ?? '', 2 );
+
+ if ( 2 !== count( $type_parts ) ) {
+ return new WP_Error(
+ 'invalid_room_format',
+ __( 'Invalid room format. Expected: entity_kind/entity_name or entity_kind/entity_name:id' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $entity_kind = $type_parts[0];
+ $entity_name = $object_parts[0];
+ $object_id = $object_parts[1] ?? null;
+
+ if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) {
+ return new WP_Error(
+ 'forbidden',
+ sprintf(
+ /* translators: %s: The room name encodes the current entity being synced. */
+ __( 'You do not have permission to sync this entity: %s.' ),
+ $room
+ ),
+ array( 'status' => 401 )
+ );
+ }
+ }
+
+ 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 = min( array_keys( $merged_awareness ) ) === $client_id;
+
+ // Process each update according to its type.
+ foreach ( $room_request['updates'] as $update ) {
+ $this->process_sync_update( $room, $client_id, $cursor, $update );
+ }
+
+ // Get updates for this client.
+ $room_response = $this->get_updates( $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.
+ *
+ * @param string $entity_kind The entity kind.
+ * @param string $entity_name The entity name.
+ * @param string|null $object_id The object ID (if applicable).
+ * @return bool True if user has permission, otherwise false.
+ */
+ private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool {
+ // Handle post type entities.
+ if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) {
+ return current_user_can( 'edit_post', absint( $object_id ) );
+ }
+
+ // All of the remaining checks are for collections. If an object ID is
+ // provided, reject the request.
+ if ( null !== $object_id ) {
+ return false;
+ }
+
+ // Collection syncing does not exchange entity data. It only signals if
+ // another user has updated an entity in the collection. Therefore, we only
+ // compare against an allow list of collection types.
+ $allowed_collection_entity_kinds = array(
+ 'postType',
+ 'root',
+ 'taxonomy',
+ );
+
+ return in_array( $entity_kind, $allowed_collection_entity_kinds, true );
+ }
+
+ /**
+ * Processes and stores an awareness update from a client.
+ *
+ * @param string $room Room identifier.
+ * @param int $client_id Client identifier.
+ * @param array|null $awareness_update Awareness state sent by the client.
+ * @return array> Updated awareness state for the room.
+ */
+ private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array {
+ $existing_awareness = $this->storage->get_awareness_state( $room );
+ $updated_awareness = array();
+ $current_time = time();
+
+ foreach ( $existing_awareness as $entry ) {
+ // Remove this client's entry (it will be updated below).
+ if ( $client_id === $entry['client_id'] ) {
+ continue;
+ }
+
+ // Remove entries that have expired.
+ if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT_IN_S ) {
+ continue;
+ }
+
+ $updated_awareness[] = $entry;
+ }
+
+ // Add this client's awareness state.
+ if ( null !== $awareness_update ) {
+ $updated_awareness[] = array(
+ 'client_id' => $client_id,
+ 'state' => $awareness_update,
+ 'updated_at' => $current_time,
+ );
+ }
+
+ $this->storage->set_awareness_state( $room, $updated_awareness );
+
+ // Convert to client_id => state map for response.
+ $response = array();
+ foreach ( $updated_awareness as $entry ) {
+ $response[ $entry['client_id'] ] = (object) $entry['state'];
+ }
+
+ return $response;
+ }
+
+ /**
+ * Processes a sync update based on its type.
+ *
+ * @param string $room Room identifier.
+ * @param int $client_id Client identifier.
+ * @param int $cursor Client cursor (marker of last seen update).
+ * @param array $update Sync update with 'type' and 'data' fields.
+ */
+ private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void {
+ $data = $update['data'];
+ $type = $update['type'];
+
+ switch ( $type ) {
+ case self::UPDATE_TYPE_COMPACTION:
+ /*
+ * Compaction replaces updates the client has already seen. Only remove
+ * updates with markers before the client's cursor to preserve updates
+ * that arrived since the client's last sync.
+ *
+ * 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 ) {
+ $this->storage->remove_updates_before_cursor( $room, $cursor );
+ $this->add_update( $room, $client_id, $type, $data );
+ }
+ break;
+
+ case self::UPDATE_TYPE_SYNC_STEP1:
+ case self::UPDATE_TYPE_SYNC_STEP2:
+ case self::UPDATE_TYPE_UPDATE:
+ /*
+ * Sync step 1 announces a client's state vector. Other clients need
+ * to see it so they can respond with sync_step2 containing missing
+ * updates. The cursor-based filtering prevents re-delivery.
+ *
+ * Sync step 2 contains updates for a specific client.
+ *
+ * All updates are stored persistently.
+ */
+ $this->add_update( $room, $client_id, $type, $data );
+ break;
+ }
+ }
+
+ /**
+ * Adds an update to a room's update list via storage.
+ *
+ * @param string $room Room identifier.
+ * @param int $client_id Client identifier.
+ * @param string $type Update type (sync_step1, sync_step2, update, compaction).
+ * @param string $data Base64-encoded update data.
+ */
+ private function add_update( string $room, int $client_id, string $type, string $data ): void {
+ $update = array(
+ 'client_id' => $client_id,
+ 'data' => $data,
+ 'type' => $type,
+ );
+
+ $this->storage->add_update( $room, $update );
+ }
+
+ /**
+ * 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.
+ *
+ * @param string $room Room identifier.
+ * @param int $client_id Client identifier.
+ * @param int $cursor Return updates after this cursor.
+ * @param bool $is_compactor True if this client is nominated to perform compaction.
+ * @return array Response data for this room.
+ */
+ private function get_updates( 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'],
+ );
+ }
+
+ // Determine if this client should perform compaction.
+ $compaction_request = null;
+ if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) {
+ $compaction_request = $updates_after_cursor;
+ }
+
+ return array(
+ 'compaction_request' => $compaction_request,
+ 'end_cursor' => $this->storage->get_cursor( $room ),
+ 'room' => $room,
+ '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..fa9463fe60297
--- /dev/null
+++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php
@@ -0,0 +1,298 @@
+
+ */
+ private $room_cursors = array();
+
+ /**
+ * Cache of update counts by room.
+ *
+ * @var array
+ */
+ private $room_update_counts = array();
+
+ /**
+ * Singleton post ID for storing sync data.
+ *
+ * @var int|null
+ */
+ private static ?int $storage_post_id = null;
+
+ /**
+ * Initializer.
+ *
+ * @since 7.0.0
+ */
+ public function init(): void {}
+
+ /**
+ * Adds a sync update to a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param mixed $update Sync update.
+ */
+ public function add_update( string $room, mixed $update ): void {
+ $post_id = $this->get_storage_post_id();
+ $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,
+ );
+
+ add_post_meta( $post_id, $meta_key, $envelope, false );
+ }
+
+ /**
+ * Retrieve all sync updates for a given room.
+ *
+ * @param string $room Room identifier.
+ * @return array Array of 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();
+ $meta_key = $this->get_room_meta_key( $room );
+ $updates = get_post_meta( $post_id, $meta_key, false );
+
+ if ( ! is_array( $updates ) ) {
+ $updates = array();
+ }
+
+ $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();
+ $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.
+ */
+ public function set_awareness_state( string $room, array $awareness ): void {
+ $post_id = $this->get_storage_post_id();
+ $meta_key = $this->get_awareness_meta_key( $room );
+
+ update_post_meta( $post_id, $meta_key, $awareness );
+ }
+
+ /**
+ * Gets the meta key for a room's awareness state.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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 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 if 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.
+ *
+ * @return int Current time in milliseconds.
+ */
+ private function get_time_marker(): int {
+ return floor( microtime( true ) * 1000 );
+ }
+
+ /**
+ * Gets the number of updates stored for a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @return int Number of updates stored for the room.
+ */
+ public function get_update_count( string $room ): int {
+ return $this->room_update_counts[ $room ] ?? 0;
+ }
+
+ /**
+ * Retrieves sync updates from a room for a given client and cursor. Updates
+ * from the specified client should be excluded.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $cursor Return updates after this cursor.
+ * @return array Array of sync updates.
+ */
+ public function get_updates_after_cursor( string $room, int $cursor ): array {
+ $all_updates = $this->get_all_updates( $room );
+ $updates = array();
+
+ foreach ( $all_updates as $update ) {
+ if ( $update['timestamp'] > $cursor ) {
+ $updates[] = $update;
+ }
+ }
+
+ // Sort by timestamp to ensure order.
+ usort(
+ $updates,
+ function ( $a, $b ) {
+ return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 );
+ }
+ );
+
+ return wp_list_pluck( $updates, 'value' );
+ }
+
+ /**
+ * Removes all sync updates for a given room.
+ *
+ * @param string $room Room identifier.
+ */
+ private function remove_all_updates( string $room ): void {
+ $post_id = $this->get_storage_post_id();
+ $meta_key = $this->get_room_meta_key( $room );
+
+ delete_post_meta( $post_id, $meta_key );
+ }
+
+ /**
+ * Removes updates from a room that are older than the given cursor.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $cursor Remove updates with markers < this cursor.
+ */
+ public function remove_updates_before_cursor( string $room, int $cursor ): void {
+ $all_updates = $this->get_all_updates( $room );
+ $this->remove_all_updates( $room );
+
+ $post_id = $this->get_storage_post_id();
+ $meta_key = $this->get_room_meta_key( $room );
+
+ // Re-store envelopes directly to avoid double-wrapping by add_update().
+ foreach ( $all_updates as $envelope ) {
+ if ( $envelope['timestamp'] >= $cursor ) {
+ add_post_meta( $post_id, $meta_key, $envelope, false );
+ }
+ }
+ }
+}
diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php
new file mode 100644
index 0000000000000..9687bfb33fc8e
--- /dev/null
+++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php
@@ -0,0 +1,90 @@
+ Awareness state.
+ */
+ public function get_awareness_state( string $room ): array;
+
+ /**
+ * Get the current cursor for a given room. This should return a monotonically
+ * increasing integer that represents the last update that was returned for the
+ * room during the current request. This allows clients to retrieve updates
+ * after a specific cursor on subsequent requests.
+ *
+ * @since 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 Array of sync updates.
+ */
+ public function get_updates_after_cursor( string $room, int $cursor ): array;
+
+ /**
+ * Removes updates from a room that are older than the provided cursor.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $cursor Remove updates with markers < this cursor.
+ */
+ public function remove_updates_before_cursor( string $room, int $cursor ): void;
+
+ /**
+ * Sets awareness state for a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param array $awareness Serializable awareness state.
+ */
+ public function set_awareness_state( string $room, array $awareness ): void;
+}
diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index de0b374ef4b56..98cd8ddd127ec 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -785,6 +785,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 8ccc4bb36df2a..8b0e4d302e9fb 100644
--- a/src/wp-includes/post.php
+++ b/src/wp-includes/post.php
@@ -657,6 +657,39 @@ function create_initial_post_types() {
)
);
+ register_post_type(
+ 'wp_sync_storage',
+ array(
+ 'labels' => array(
+ 'name' => __( 'Sync Updates' ),
+ 'singular_name' => __( 'Sync Update' ),
+ ),
+ 'public' => false,
+ '_builtin' => true, /* internal use only. don't use this when registering your own post type. */
+ 'hierarchical' => false,
+ 'capabilities' => array(
+ 'read' => 'do_not_allow',
+ 'read_private_posts' => 'do_not_allow',
+ 'create_posts' => 'do_not_allow',
+ 'publish_posts' => 'do_not_allow',
+ 'edit_posts' => 'do_not_allow',
+ 'edit_others_posts' => 'do_not_allow',
+ 'edit_published_posts' => 'do_not_allow',
+ 'delete_posts' => 'do_not_allow',
+ 'delete_others_posts' => 'do_not_allow',
+ 'delete_published_posts' => 'do_not_allow',
+ ),
+ 'map_meta_cap' => false,
+ 'publicly_queryable' => false,
+ 'query_var' => false,
+ 'rewrite' => false,
+ 'show_in_menu' => false,
+ 'show_in_rest' => false,
+ 'show_ui' => false,
+ 'supports' => array( 'custom-fields' ),
+ )
+ );
+
register_post_status(
'publish',
array(
@@ -8611,6 +8644,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 +8664,28 @@ function wp_create_initial_post_meta() {
),
)
);
+
+ register_meta(
+ 'post',
+ '_crdt_document',
+ array(
+ 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool {
+ return user_can( $user_id, 'edit_post', $object_id );
+ },
+ /*
+ * Revisions must be disabled because we always want to preserve
+ * the latest persisted CRDT document, even when a revision is restored.
+ * This ensures that we can continue to apply updates to a shared document
+ * and peers can simply merge the restored revision like any other incoming
+ * update.
+ *
+ * If we want to persist CRDT documents alongside revisions in the
+ * future, we should do so in a separate meta key.
+ */
+ 'revisions_enabled' => false,
+ 'show_in_rest' => true,
+ 'single' => true,
+ 'type' => 'string',
+ )
+ );
}
diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php
index c4fce5a43e7d8..76241d470b4a2 100644
--- a/src/wp-includes/rest-api.php
+++ b/src/wp-includes/rest-api.php
@@ -424,6 +424,11 @@ function create_initial_rest_routes() {
$abilities_run_controller->register_routes();
$abilities_list_controller = new WP_REST_Abilities_V1_List_Controller();
$abilities_list_controller->register_routes();
+
+ // Collaboration.
+ $sync_storage = new WP_Sync_Post_Meta_Storage();
+ $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage );
+ $sync_server->register_routes();
}
/**
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 60c220100f539..f1a475c0eb093 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -299,6 +299,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() ) {