diff --git a/patches/05-custom-tables-with-presence.patch b/patches/05-custom-tables-with-presence.patch new file mode 100644 index 0000000..485a962 --- /dev/null +++ b/patches/05-custom-tables-with-presence.patch @@ -0,0 +1,1831 @@ +diff --git a/src/wp-admin/admin.php b/src/wp-admin/admin.php +index 82ab6b93ac99e..3634c8c29c20d 100644 +--- a/src/wp-admin/admin.php ++++ b/src/wp-admin/admin.php +@@ -113,6 +113,14 @@ + wp_schedule_event( time(), 'daily', 'delete_expired_transients' ); + } + ++// Schedule collaboration data cleanup. ++if ( wp_is_collaboration_enabled() ++ && ! wp_next_scheduled( 'wp_delete_old_collaboration_data' ) ++ && ! wp_installing() ++) { ++ wp_schedule_event( time(), 'daily', 'wp_delete_old_collaboration_data' ); ++} ++ + set_screen_options(); + + $date_format = __( 'F j, Y' ); +diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php +index 340bdebac71eb..c8293a550a89d 100644 +--- a/src/wp-admin/includes/schema.php ++++ b/src/wp-admin/includes/schema.php +@@ -186,6 +186,29 @@ function wp_get_db_schema( $scope = 'all', $blog_id = null ) { + KEY post_parent (post_parent), + KEY post_author (post_author), + KEY type_status_author (post_type,post_status,post_author) ++) $charset_collate; ++CREATE TABLE $wpdb->collaboration ( ++ id bigint(20) unsigned NOT NULL auto_increment, ++ room varchar($max_index_length) NOT NULL default '', ++ client_id varchar(32) NOT NULL default '', ++ user_id bigint(20) unsigned NOT NULL default '0', ++ data longtext NOT NULL, ++ date_gmt datetime NOT NULL default '0000-00-00 00:00:00', ++ PRIMARY KEY (id), ++ KEY room (room,id), ++ KEY date_gmt (date_gmt) ++) $charset_collate; ++CREATE TABLE $wpdb->presence ( ++ id bigint(20) unsigned NOT NULL auto_increment, ++ room varchar($max_index_length) NOT NULL default '', ++ client_id varchar($max_index_length) NOT NULL default '', ++ user_id bigint(20) unsigned NOT NULL default '0', ++ data text NOT NULL, ++ date_gmt datetime NOT NULL default '0000-00-00 00:00:00', ++ PRIMARY KEY (id), ++ UNIQUE KEY room_client (room,client_id), ++ KEY date_gmt (date_gmt), ++ KEY user_id (user_id) + ) $charset_collate;\n"; + + // Single site users table. The multisite flavor of the users table is handled below. +diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php +index 914113bde00d0..f52adbc692d6a 100644 +--- a/src/wp-admin/includes/upgrade.php ++++ b/src/wp-admin/includes/upgrade.php +@@ -886,7 +886,7 @@ function upgrade_all() { + upgrade_682(); + } + +- if ( $wp_current_db_version < 61644 ) { ++ if ( $wp_current_db_version < 61843 ) { + upgrade_700(); + } + +diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php +index e5300e6d75122..7107c296733c0 100644 +--- a/src/wp-includes/class-wpdb.php ++++ b/src/wp-includes/class-wpdb.php +@@ -299,6 +299,8 @@ class wpdb { + 'term_relationships', + 'termmeta', + 'commentmeta', ++ 'collaboration', ++ 'presence', + ); + + /** +@@ -404,6 +406,24 @@ class wpdb { + */ + public $posts; + ++ /** ++ * WordPress Collaboration table. ++ * ++ * @since 7.0.0 ++ * ++ * @var string ++ */ ++ public $collaboration; ++ ++ /** ++ * WordPress Presence table. ++ * ++ * @since 7.0.0 ++ * ++ * @var string ++ */ ++ public $presence; ++ + /** + * WordPress Terms table. + * +diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php +index 11698a2ac78f4..1c9737ce4f294 100644 +--- a/src/wp-includes/collaboration.php ++++ b/src/wp-includes/collaboration.php +@@ -11,7 +11,8 @@ + * + * If the WP_ALLOW_COLLABORATION constant is false, + * collaboration is always disabled regardless of the database option. +- * Otherwise, falls back to the 'wp_collaboration_enabled' option. ++ * Otherwise, the feature requires both the 'wp_collaboration_enabled' ++ * option and the database schema introduced in db_version 61841. + * + * @since 7.0.0 + * +@@ -20,7 +21,8 @@ + function wp_is_collaboration_enabled() { + return ( + wp_is_collaboration_allowed() && +- (bool) get_option( 'wp_collaboration_enabled' ) ++ get_option( 'wp_collaboration_enabled' ) && ++ get_option( 'db_version' ) >= 61843 + ); + } + +@@ -34,7 +36,7 @@ function wp_is_collaboration_enabled() { + * + * @since 7.0.0 + * +- * @return bool Whether real-time collaboration is enabled. ++ * @return bool Whether real-time collaboration is allowed. + */ + function wp_is_collaboration_allowed() { + if ( ! defined( 'WP_ALLOW_COLLABORATION' ) ) { +@@ -83,3 +85,35 @@ function wp_collaboration_inject_setting() { + 'after' + ); + } ++ ++/** ++ * Deletes stale collaboration data from the collaboration table. ++ * ++ * Removes update rows older than 7 days. Presence data is cleaned ++ * separately via wp_delete_expired_presence_data(). ++ * ++ * @since 7.0.0 ++ */ ++function wp_delete_old_collaboration_data() { ++ global $wpdb; ++ ++ if ( ! wp_is_collaboration_enabled() ) { ++ /* ++ * Collaboration was enabled in the past but has since been disabled. ++ * Unschedule the cron job prior to clean up so this callback does not ++ * continue to run. ++ */ ++ wp_clear_scheduled_hook( 'wp_delete_old_collaboration_data' ); ++ } ++ ++ /* Clean up update rows older than 7 days. */ ++ $wpdb->query( ++ $wpdb->prepare( ++ "DELETE FROM {$wpdb->collaboration} WHERE date_gmt < %s", ++ gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) ++ ) ++ ); ++ ++ /* Clean up expired presence entries. */ ++ wp_delete_expired_presence_data(); ++} +diff --git a/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +new file mode 100644 +index 0000000000000..24ee812f0215d +--- /dev/null ++++ b/src/wp-includes/collaboration/class-wp-collaboration-table-storage.php +@@ -0,0 +1,236 @@ ++ ++ */ ++ private array $room_cursors = array(); ++ ++ /** ++ * Cache of update counts by room. ++ * ++ * @since 7.0.0 ++ * @var array ++ */ ++ private array $room_update_counts = array(); ++ ++ /** ++ * Adds an update to a given room. ++ * ++ * @since 7.0.0 ++ * ++ * @global wpdb $wpdb WordPress database abstraction object. ++ * ++ * @param string $room Room identifier. ++ * @param mixed $update Update data. ++ * @return bool True on success, false on failure. ++ */ ++ public function add_update( string $room, $update ): bool { ++ global $wpdb; ++ ++ if ( '' === $room || empty( $update['type'] ) || empty( $update['client_id'] ) ) { ++ return false; ++ } ++ ++ $result = $wpdb->insert( ++ $wpdb->collaboration, ++ array( ++ 'room' => $room, ++ 'client_id' => $update['client_id'] ?? '', ++ 'data' => wp_json_encode( $update ), ++ 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), ++ 'user_id' => get_current_user_id(), ++ ), ++ array( '%s', '%s', '%s', '%s', '%d' ) ++ ); ++ ++ return false !== $result; ++ } ++ ++ /** ++ * Gets awareness state for a given room. ++ * ++ * Delegates to the Presence API which uses a dedicated table with ++ * atomic upserts via UNIQUE KEY (room, client_id). ++ * ++ * @since 7.0.0 ++ * ++ * @param string $room Room identifier. ++ * @param int $timeout Seconds before an awareness entry is considered expired. ++ * @return array Awareness entries. ++ */ ++ public function get_awareness_state( string $room, int $timeout = 30 ): array { ++ return wp_get_presence( $room, $timeout ); ++ } ++ ++ /** ++ * Gets the current cursor for a given room. ++ * ++ * The cursor is set during get_updates_after_cursor() and represents the ++ * maximum row ID at the time updates were retrieved. ++ * ++ * @since 7.0.0 ++ * ++ * @param string $room Room identifier. ++ * @return int Current cursor for the room. ++ */ ++ public function get_cursor( string $room ): int { ++ return $this->room_cursors[ $room ] ?? 0; ++ } ++ ++ /** ++ * Gets the number of updates stored for a given room. ++ * ++ * @since 7.0.0 ++ * ++ * @param string $room Room identifier. ++ * @return int Number of updates stored for the room. ++ */ ++ public function get_update_count( string $room ): int { ++ return $this->room_update_counts[ $room ] ?? 0; ++ } ++ ++ /** ++ * Retrieves updates from a room after a given cursor. ++ * ++ * @since 7.0.0 ++ * ++ * @global wpdb $wpdb WordPress database abstraction object. ++ * ++ * @param string $room Room identifier. ++ * @param int $cursor Return updates after this cursor. ++ * @return array Updates. ++ */ ++ public function get_updates_after_cursor( string $room, int $cursor ): array { ++ global $wpdb; ++ ++ /* ++ * Uses a snapshot approach: captures MAX(id) and COUNT(*) in a single ++ * query, then fetches rows WHERE id > cursor AND id <= max_id. Updates ++ * arriving after the snapshot are deferred to the next poll, never lost. ++ */ ++ ++ /* Snapshot the current max ID and total row count in a single query. */ ++ $snapshot = $wpdb->get_row( ++ $wpdb->prepare( ++ "SELECT COALESCE( MAX( id ), 0 ) AS max_id, COUNT(*) AS total FROM {$wpdb->collaboration} WHERE room = %s", ++ $room ++ ) ++ ); ++ ++ if ( ! $snapshot ) { ++ $this->room_cursors[ $room ] = 0; ++ $this->room_update_counts[ $room ] = 0; ++ return array(); ++ } ++ ++ $max_id = (int) $snapshot->max_id; ++ $total = (int) $snapshot->total; ++ ++ $this->room_cursors[ $room ] = $max_id; ++ ++ if ( 0 === $max_id || $max_id <= $cursor ) { ++ /* ++ * Preserve the real row count so the server can still ++ * trigger compaction when updates have accumulated but ++ * no new ones arrived since the client's last poll. ++ */ ++ $this->room_update_counts[ $room ] = $total; ++ return array(); ++ } ++ ++ $this->room_update_counts[ $room ] = $total; ++ ++ /* Fetch updates after the cursor up to the snapshot boundary. */ ++ $rows = $wpdb->get_results( ++ $wpdb->prepare( ++ "SELECT data FROM {$wpdb->collaboration} WHERE room = %s AND id > %d AND id <= %d ORDER BY id ASC", ++ $room, ++ $cursor, ++ $max_id ++ ) ++ ); ++ ++ if ( ! is_array( $rows ) ) { ++ return array(); ++ } ++ ++ $updates = array(); ++ foreach ( $rows as $row ) { ++ $decoded = json_decode( $row->data, true ); ++ if ( is_array( $decoded ) ) { ++ $updates[] = $decoded; ++ } ++ } ++ ++ return $updates; ++ } ++ ++ /** ++ * Removes updates from a room up to and including the given cursor. ++ * ++ * @since 7.0.0 ++ * ++ * @global wpdb $wpdb WordPress database abstraction object. ++ * ++ * @param string $room Room identifier. ++ * @param int $cursor Remove updates up to and including this cursor. ++ * @return bool True on success, false on failure. ++ */ ++ public function remove_updates_through_cursor( string $room, int $cursor ): bool { ++ global $wpdb; ++ ++ $result = $wpdb->query( ++ $wpdb->prepare( ++ "DELETE FROM {$wpdb->collaboration} WHERE room = %s AND id <= %d", ++ $room, ++ $cursor ++ ) ++ ); ++ ++ return false !== $result; ++ } ++ ++ /** ++ * Sets awareness state for a given client in a room. ++ * ++ * Delegates to the Presence API which uses INSERT ... ON DUPLICATE KEY UPDATE ++ * for atomic upserts, eliminating race conditions. ++ * ++ * @since 7.0.0 ++ * ++ * @param string $room Room identifier. ++ * @param string $client_id Client identifier. ++ * @param array $state Serializable awareness state for this client. ++ * @param int $user_id WordPress user ID that owns this client. ++ * @return bool True on success, false on failure. ++ */ ++ public function set_awareness_state( string $room, string $client_id, array $state, int $user_id ): bool { ++ return wp_set_presence( $room, $client_id, $state, $user_id ); ++ } ++} +diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +similarity index 54% +rename from src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +rename to src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +index a90821ab78d3e..b3a7907cb021c 100644 +--- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php ++++ b/src/wp-includes/collaboration/class-wp-http-polling-collaboration-server.php +@@ -1,8 +1,9 @@ + storage = $storage; + } + +@@ -149,15 +151,26 @@ public function register_routes(): void { + 'required' => true, + 'type' => array( 'object', 'null' ), + ), ++ /* ++ * client_id accepts both string and integer values: ++ * - 'minimum' bounds the integer form. ++ * - 'minLength' / 'maxLength' bound the string form. ++ */ + 'client_id' => array( +- 'minimum' => 1, +- 'required' => true, +- 'type' => 'integer', ++ 'minimum' => 1, ++ 'minLength' => 1, ++ 'maxLength' => 32, // Matches the client_id column width in wp-admin/includes/schema.php. ++ 'required' => true, ++ 'type' => array( 'string', 'integer' ), ++ 'sanitize_callback' => function ( $value ) { ++ return (string) $value; ++ }, + ), + 'room' => array( +- 'required' => true, +- 'type' => 'string', +- 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', ++ 'required' => true, ++ 'type' => 'string', ++ 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', ++ 'maxLength' => 191, // Matches $max_index_length in wp-admin/includes/schema.php. + ), + 'updates' => array( + 'items' => $typed_update_args, +@@ -167,32 +180,53 @@ public function register_routes(): void { + ), + ); + ++ $route_args = array( ++ 'methods' => array( WP_REST_Server::CREATABLE ), ++ 'callback' => array( $this, 'handle_request' ), ++ 'permission_callback' => array( $this, 'check_permissions' ), ++ 'validate_callback' => array( $this, 'validate_request' ), ++ 'args' => array( ++ 'rooms' => array( ++ 'items' => array( ++ 'properties' => $room_args, ++ 'type' => 'object', ++ ), ++ 'maxItems' => self::MAX_ROOMS_PER_REQUEST, ++ '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' ), +- 'validate_callback' => array( $this, 'validate_request' ), +- 'args' => array( +- 'rooms' => array( +- 'items' => array( +- 'properties' => $room_args, +- 'type' => 'object', +- ), +- 'maxItems' => self::MAX_ROOMS_PER_REQUEST, +- 'required' => true, +- 'type' => 'array', +- ), +- ), +- ) ++ $route_args ++ ); ++ ++ /* ++ * Backward-compatible alias so that the Gutenberg plugin's ++ * bundled sync package (which still uses wp-sync/v1) continues ++ * to work against WordPress 7.0+. ++ * ++ * @todo Remove once the Gutenberg plugin has transitioned to ++ * the wp-collaboration/v1 namespace. ++ */ ++ register_rest_route( ++ 'wp-sync/v1', ++ '/updates', ++ $route_args + ); + } + + /** + * Checks if the current user has permission to access a room. + * ++ * Requires `edit_posts` (contributor+), then delegates to ++ * can_user_collaborate_on_entity_type() for per-entity checks. ++ * There is no dedicated `collaborate` capability; access follows ++ * existing edit capabilities for the entity type. ++ * + * @since 7.0.0 + * + * @param WP_REST_Request $request The REST request. +@@ -203,29 +237,15 @@ public function check_permissions( WP_REST_Request $request ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'rest_cannot_edit', +- __( 'You do not have permission to perform this action' ), ++ __( 'You do not have permission to perform this action.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + +- $rooms = $request['rooms']; +- $wp_user_id = get_current_user_id(); ++ $rooms = $request['rooms']; + + foreach ( $rooms as $room ) { +- $client_id = $room['client_id']; +- $room = $room['room']; +- +- // Check that the client_id is not already owned by another user. +- $existing_awareness = $this->storage->get_awareness_state( $room ); +- foreach ( $existing_awareness as $entry ) { +- if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['wp_user_id'] ) { +- return new WP_Error( +- 'rest_cannot_edit', +- __( 'Client ID is already in use by another user.' ), +- array( 'status' => rest_authorization_required_code() ) +- ); +- } +- } ++ $room = $room['room']; + + $type_parts = explode( '/', $room, 2 ); + $object_parts = explode( ':', $type_parts[1] ?? '', 2 ); +@@ -234,13 +254,13 @@ public function check_permissions( WP_REST_Request $request ) { + $entity_name = $object_parts[0]; + $object_id = $object_parts[1] ?? null; + +- if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { ++ if ( ! $this->can_user_collaborate_on_entity_type( $entity_kind, $entity_name, $object_id ) ) { + return new WP_Error( + 'rest_cannot_edit', + sprintf( +- /* translators: %s: The room name encodes the current entity being synced. */ +- __( 'You do not have permission to sync this entity: %s.' ), +- $room ++ /* translators: %s: The room name identifying the collaborative editing session. */ ++ __( 'You do not have permission to collaborate on this entity: %s.' ), ++ esc_html( $room ) + ), + array( 'status' => rest_authorization_required_code() ) + ); +@@ -251,31 +271,29 @@ public function check_permissions( WP_REST_Request $request ) { + } + + /** +- * Validates that the request body does not exceed the maximum allowed size. ++ * Validates the incoming REST request. + * +- * Runs as the route-level validate_callback, after per-arg schema +- * validation has already passed. ++ * Checks that the raw request body does not exceed the maximum allowed size. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request The REST request. +- * @return true|WP_Error True if valid, WP_Error if the body is too large. ++ * @return true|WP_Error True if valid, WP_Error if body is too large. + */ + public function validate_request( WP_REST_Request $request ) { + $body = $request->get_body(); + if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) { + return new WP_Error( +- 'rest_sync_body_too_large', ++ 'rest_collaboration_body_too_large', + __( 'Request body is too large.' ), + array( 'status' => 413 ) + ); + } +- + return true; + } + + /** +- * Handles request: stores sync updates and awareness data, and returns ++ * Handles request: stores updates and awareness data, and returns + * updates the client is missing. + * + * @since 7.0.0 +@@ -295,18 +313,22 @@ public function handle_request( WP_REST_Request $request ) { + $cursor = $room_request['after']; + $room = $room_request['room']; + +- // Merge awareness state. ++ // Merge awareness state (also validates client_id ownership). + $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness ); + ++ if ( is_wp_error( $merged_awareness ) ) { ++ return $merged_awareness; ++ } ++ + // The lowest client ID is nominated to perform compaction when needed. + $is_compactor = false; + if ( count( $merged_awareness ) > 0 ) { +- $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id; ++ $is_compactor = (string) min( array_keys( $merged_awareness ) ) === (string) $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 ); ++ $result = $this->process_collaboration_update( $room, $client_id, $cursor, $update ); + if ( is_wp_error( $result ) ) { + return $result; + } +@@ -323,57 +345,46 @@ public function handle_request( WP_REST_Request $request ) { + } + + /** +- * Checks if the current user can sync a specific entity type. ++ * Checks if the current user can collaborate on a specific entity type. + * + * @since 7.0.0 + * + * @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 numeric object ID / entity key for single entities, null for collections. ++ * @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 { +- if ( is_string( $object_id ) ) { +- if ( ! ctype_digit( $object_id ) ) { +- return false; +- } +- $object_id = (int) $object_id; +- } +- if ( null !== $object_id && $object_id <= 0 ) { +- // Object ID must be numeric if provided. ++ private function can_user_collaborate_on_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { ++ // Reject non-numeric object IDs early. ++ if ( ! is_null( $object_id ) && ! is_numeric( $object_id ) ) { + return false; + } + +- // Validate permissions for the provided object ID. +- if ( is_int( $object_id ) ) { +- // Handle single post type entities with a defined object ID. +- if ( 'postType' === $entity_kind ) { +- if ( get_post_type( $object_id ) !== $entity_name ) { +- // Post is not of the specified post type. +- return false; +- } +- return current_user_can( 'edit_post', $object_id ); ++ // Handle single post type entities with a defined object ID. ++ if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { ++ if ( get_post_type( $object_id ) !== $entity_name ) { ++ return false; + } ++ return current_user_can( 'edit_post', (int) $object_id ); ++ } + +- // Handle single taxonomy term entities with a defined object ID. +- if ( 'taxonomy' === $entity_kind ) { +- $term_exists = term_exists( $object_id, $entity_name ); +- if ( ! is_array( $term_exists ) || ! isset( $term_exists['term_id'] ) ) { +- // Either term doesn't exist OR term is not in specified taxonomy. +- return false; +- } +- +- return current_user_can( 'edit_term', $object_id ); ++ // Handle single taxonomy term entities with a defined object ID. ++ if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) { ++ if ( ! term_exists( (int) $object_id, $entity_name ) ) { ++ return false; + } ++ return current_user_can( 'assign_term', (int) $object_id ); ++ } + +- // Handle single comment entities with a defined object ID. +- if ( 'root' === $entity_kind && 'comment' === $entity_name ) { +- return current_user_can( 'edit_comment', $object_id ); +- } ++ // Handle single comment entities with a defined object ID. ++ if ( 'root' === $entity_kind && 'comment' === $entity_name && is_numeric( $object_id ) ) { ++ return current_user_can( 'edit_comment', (int) $object_id ); + } + +- // All the remaining checks are for collections. If an object ID is provided, +- // reject the request. ++ /* ++ * All the remaining checks are for collections. If an object ID is ++ * provided, reject the request. ++ */ + if ( null !== $object_id ) { + return false; + } +@@ -388,9 +399,11 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ + 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. ++ /* ++ * Collection collaboration does not exchange entity data. It only ++ * signals if another user has updated an entity in the collection. ++ * Therefore, we only compare against an allow list of collection types. ++ */ + $allowed_collection_entity_kinds = array( + 'postType', + 'root', +@@ -403,66 +416,68 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ + /** + * Processes and stores an awareness update from a client. + * ++ * Also validates that the client_id is not already owned by another user. ++ * This check uses the same get_awareness_state() query that builds the ++ * response, eliminating a duplicate query that was previously performed ++ * in check_permissions(). ++ * + * @since 7.0.0 + * + * @param string $room Room identifier. +- * @param int $client_id Client identifier. ++ * @param string $client_id Client identifier. + * @param array|null $awareness_update Awareness state sent by the client. +- * @return array> Map of client ID to awareness state. ++ * @return array>|WP_Error Map of client ID to awareness state, or WP_Error if client_id is owned by another user. + */ +- private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { +- $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; +- } ++ private function process_awareness_update( string $room, string $client_id, ?array $awareness_update ) { ++ $wp_user_id = get_current_user_id(); + +- // Remove entries that have expired. +- if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) { +- continue; +- } ++ // Check ownership before upserting so a hijacked client_id is rejected. ++ $entries = $this->storage->get_awareness_state( $room, self::AWARENESS_TIMEOUT ); + +- $updated_awareness[] = $entry; ++ foreach ( $entries as $entry ) { ++ if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['user_id'] ) { ++ return new WP_Error( ++ 'rest_cannot_edit', ++ __( 'Client ID is already in use by another user.' ), ++ array( 'status' => rest_authorization_required_code() ) ++ ); ++ } + } + +- // Add this client's awareness state. + if ( null !== $awareness_update ) { +- $updated_awareness[] = array( +- 'client_id' => $client_id, +- 'state' => $awareness_update, +- 'updated_at' => $current_time, +- 'wp_user_id' => get_current_user_id(), +- ); ++ $this->storage->set_awareness_state( $room, $client_id, $awareness_update, $wp_user_id ); + } + +- // This action can fail, but it shouldn't fail the entire request. +- $this->storage->set_awareness_state( $room, $updated_awareness ); +- +- // Convert to client_id => state map for response. + $response = array(); +- foreach ( $updated_awareness as $entry ) { ++ foreach ( $entries as $entry ) { + $response[ $entry['client_id'] ] = $entry['state']; + } + ++ /* ++ * Other clients' states were decoded from the DB. Run the current ++ * client's state through the same encode/decode path so the response ++ * is consistent — wp_json_encode may normalize values (e.g. strip ++ * invalid UTF-8) that would otherwise differ on the next poll. ++ */ ++ if ( null !== $awareness_update ) { ++ $response[ $client_id ] = json_decode( wp_json_encode( $awareness_update ), true ); ++ } ++ + return $response; + } + + /** +- * Processes a sync update based on its type. ++ * Processes a collaboration update based on its type. + * + * @since 7.0.0 + * + * @param string $room Room identifier. +- * @param int $client_id Client identifier. ++ * @param string $client_id Client identifier. + * @param int $cursor Client cursor (marker of last seen update). +- * @param array{data: string, type: string} $update Sync update. ++ * @param array{data: string, type: string} $update Collaboration update. + * @return true|WP_Error True on success, WP_Error on storage failure. + */ +- private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) { ++ private function process_collaboration_update( string $room, string $client_id, int $cursor, array $update ) { + $data = $update['data']; + $type = $update['type']; + +@@ -471,7 +486,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, + /* + * Compaction replaces updates the client has already seen. Only remove + * updates with markers before the client's cursor to preserve updates +- * that arrived since the client's last sync. ++ * that arrived since the client's last poll. + * + * Check for a newer compaction update first. If one exists, skip this + * compaction to avoid overwriting it. +@@ -487,19 +502,39 @@ private function process_sync_update( string $room, int $client_id, int $cursor, + } + + if ( ! $has_newer_compaction ) { +- if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { ++ /* ++ * Insert the compaction row before deleting old rows. ++ * Reversing the order closes a race window where a ++ * client joining with cursor=0 between the DELETE and ++ * INSERT would see an empty room for one poll cycle. ++ * The compaction row always has a higher ID than the ++ * deleted rows, so cursor-based filtering is unaffected. ++ */ ++ $insert_result = $this->add_update( $room, $client_id, $type, $data ); ++ if ( is_wp_error( $insert_result ) ) { ++ return $insert_result; ++ } ++ ++ if ( ! $this->storage->remove_updates_through_cursor( $room, $cursor ) ) { ++ global $wpdb; ++ $error_data = array( 'status' => 500 ); ++ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { ++ $error_data['db_error'] = $wpdb->last_error; ++ } + return new WP_Error( +- 'rest_sync_storage_error', ++ 'rest_collaboration_storage_error', + __( 'Failed to remove updates during compaction.' ), +- array( 'status' => 500 ) ++ $error_data + ); + } + +- return $this->add_update( $room, $client_id, $type, $data ); ++ return true; + } + +- // Reaching this point means there's a newer compaction, so we can +- // silently ignore this one. ++ /* ++ * Reaching this point means there's a newer compaction, ++ * so we can silently ignore this one. ++ */ + return true; + + case self::UPDATE_TYPE_SYNC_STEP1: +@@ -519,7 +554,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, + + return new WP_Error( + 'rest_invalid_update_type', +- __( 'Invalid sync update type.' ), ++ __( 'Invalid collaboration update type.' ), + array( 'status' => 400 ) + ); + } +@@ -530,12 +565,12 @@ private function process_sync_update( string $room, int $client_id, int $cursor, + * @since 7.0.0 + * + * @param string $room Room identifier. +- * @param int $client_id Client identifier. ++ * @param string $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 ) { ++ private function add_update( string $room, string $client_id, string $type, string $data ) { + $update = array( + 'client_id' => $client_id, + 'data' => $data, +@@ -543,10 +578,15 @@ private function add_update( string $room, int $client_id, string $type, string + ); + + if ( ! $this->storage->add_update( $room, $update ) ) { ++ global $wpdb; ++ $data = array( 'status' => 500 ); ++ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { ++ $data['db_error'] = $wpdb->last_error; ++ } + return new WP_Error( +- 'rest_sync_storage_error', +- __( 'Failed to store sync update.' ), +- array( 'status' => 500 ) ++ 'rest_collaboration_storage_error', ++ __( 'Failed to store collaboration update.' ), ++ $data + ); + } + +@@ -554,7 +594,7 @@ private function add_update( string $room, int $client_id, string $type, string + } + + /** +- * Gets sync updates for a specific client from a room after a given cursor. ++ * Gets updates for a specific client from a room after a given cursor. + * + * Delegates cursor-based retrieval to the storage layer, then applies + * client-specific filtering and compaction logic. +@@ -562,7 +602,7 @@ private function add_update( string $room, int $client_id, string $type, string + * @since 7.0.0 + * + * @param string $room Room identifier. +- * @param int $client_id Client identifier. ++ * @param string $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{ +@@ -573,7 +613,7 @@ private function add_update( string $room, int $client_id, string $type, string + * updates: array, + * } Response data for this room. + */ +- private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array { ++ private function get_updates( string $room, string $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 ); + +diff --git a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +deleted file mode 100644 +index 658a9b65539dd..0000000000000 +--- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php ++++ /dev/null +@@ -1,378 +0,0 @@ +- +- */ +- private array $room_cursors = array(); +- +- /** +- * Cache of update counts by room. +- * +- * @since 7.0.0 +- * @var array +- */ +- private array $room_update_counts = array(); +- +- /** +- * Cache of storage post IDs by room hash. +- * +- * @since 7.0.0 +- * @var array +- */ +- private static array $storage_post_ids = array(); +- +- /** +- * Adds a sync update to a given room. +- * +- * @since 7.0.0 +- * +- * @global wpdb $wpdb WordPress database abstraction object. +- * +- * @param string $room Room identifier. +- * @param mixed $update Sync update. +- * @return bool True on success, false on failure. +- */ +- public function add_update( string $room, $update ): bool { +- global $wpdb; +- +- $post_id = $this->get_storage_post_id( $room ); +- if ( null === $post_id ) { +- return false; +- } +- +- // Use direct database operation to avoid cache invalidation performed by +- // post meta functions (`wp_cache_set_posts_last_changed()` and direct +- // `wp_cache_delete()` calls). +- return (bool) $wpdb->insert( +- $wpdb->postmeta, +- array( +- 'post_id' => $post_id, +- 'meta_key' => self::SYNC_UPDATE_META_KEY, +- 'meta_value' => wp_json_encode( $update ), +- ), +- array( '%d', '%s', '%s' ) +- ); +- } +- +- /** +- * Gets awareness state for a given room. +- * +- * @since 7.0.0 +- * +- * @global wpdb $wpdb WordPress database abstraction object. +- * +- * @param string $room Room identifier. +- * @return array Awareness state. +- */ +- public function get_awareness_state( string $room ): array { +- global $wpdb; +- +- $post_id = $this->get_storage_post_id( $room ); +- if ( null === $post_id ) { +- return array(); +- } +- +- // Use direct database operation to avoid updating the post meta cache. +- // ORDER BY meta_id DESC ensures the latest row wins if duplicates exist +- // from a past race condition in set_awareness_state(). +- $meta_value = $wpdb->get_var( +- $wpdb->prepare( +- "SELECT meta_value FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1", +- $post_id, +- self::AWARENESS_META_KEY +- ) +- ); +- +- if ( null === $meta_value ) { +- return array(); +- } +- +- $awareness = json_decode( $meta_value, true ); +- +- if ( ! is_array( $awareness ) ) { +- return array(); +- } +- +- return array_values( $awareness ); +- } +- +- /** +- * Sets awareness state for a given room. +- * +- * @since 7.0.0 +- * +- * @global wpdb $wpdb WordPress database abstraction object. +- * +- * @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 { +- global $wpdb; +- +- $post_id = $this->get_storage_post_id( $room ); +- if ( null === $post_id ) { +- return false; +- } +- +- // Use direct database operation to avoid cache invalidation performed by +- // post meta functions (`wp_cache_set_posts_last_changed()` and direct +- // `wp_cache_delete()` calls). +- // +- // If two concurrent requests both see no row and both INSERT, the +- // duplicate is harmless: get_awareness_state() reads the latest row +- // (ORDER BY meta_id DESC). +- $meta_id = $wpdb->get_var( +- $wpdb->prepare( +- "SELECT meta_id FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1", +- $post_id, +- self::AWARENESS_META_KEY +- ) +- ); +- +- if ( $meta_id ) { +- return (bool) $wpdb->update( +- $wpdb->postmeta, +- array( 'meta_value' => wp_json_encode( $awareness ) ), +- array( 'meta_id' => $meta_id ), +- array( '%s' ), +- array( '%d' ) +- ); +- } +- +- return (bool) $wpdb->insert( +- $wpdb->postmeta, +- array( +- 'post_id' => $post_id, +- 'meta_key' => self::AWARENESS_META_KEY, +- 'meta_value' => wp_json_encode( $awareness ), +- ), +- array( '%d', '%s', '%s' ) +- ); +- } +- +- /** +- * Gets the current cursor for a given room. +- * +- * The cursor is set during get_updates_after_cursor() and represents the +- * highest meta_id seen for the room's sync updates. +- * +- * @since 7.0.0 +- * +- * @param string $room Room identifier. +- * @return int Current cursor for the room. +- */ +- public function get_cursor( string $room ): int { +- return $this->room_cursors[ $room ] ?? 0; +- } +- +- /** +- * Gets or creates the storage post for a given room. +- * +- * Each room gets its own dedicated post so that post meta cache +- * invalidation is scoped to a single room rather than all of them. +- * +- * @since 7.0.0 +- * +- * @param string $room Room identifier. +- * @return int|null Post ID. +- */ +- private function get_storage_post_id( string $room ): ?int { +- $room_hash = md5( $room ); +- +- if ( isset( self::$storage_post_ids[ $room_hash ] ) ) { +- return self::$storage_post_ids[ $room_hash ]; +- } +- +- // Try to find an existing post for this room. +- $posts = get_posts( +- array( +- 'post_type' => self::POST_TYPE, +- 'posts_per_page' => 1, +- 'post_status' => 'publish', +- 'name' => $room_hash, +- 'fields' => 'ids', +- 'orderby' => 'ID', +- 'order' => 'ASC', +- ) +- ); +- +- $post_id = array_first( $posts ); +- if ( is_int( $post_id ) ) { +- self::$storage_post_ids[ $room_hash ] = $post_id; +- return $post_id; +- } +- +- // Create new post for this room. +- $post_id = wp_insert_post( +- array( +- 'post_type' => self::POST_TYPE, +- 'post_status' => 'publish', +- 'post_title' => 'Sync Storage', +- 'post_name' => $room_hash, +- ) +- ); +- +- if ( is_int( $post_id ) ) { +- self::$storage_post_ids[ $room_hash ] = $post_id; +- return $post_id; +- } +- +- return null; +- } +- +- /** +- * Gets the number of updates stored for a given room. +- * +- * @since 7.0.0 +- * +- * @param string $room Room identifier. +- * @return int Number of updates stored for the room. +- */ +- public function get_update_count( string $room ): int { +- return $this->room_update_counts[ $room ] ?? 0; +- } +- +- /** +- * Retrieves sync updates from a room after the given cursor. +- * +- * @since 7.0.0 +- * +- * @global wpdb $wpdb WordPress database abstraction object. +- * +- * @param string $room Room identifier. +- * @param int $cursor Return updates after this cursor (meta_id). +- * @return array Sync updates. +- */ +- public function get_updates_after_cursor( string $room, int $cursor ): array { +- global $wpdb; +- +- $post_id = $this->get_storage_post_id( $room ); +- if ( null === $post_id ) { +- $this->room_cursors[ $room ] = 0; +- $this->room_update_counts[ $room ] = 0; +- return array(); +- } +- +- // Capture the current room state first so the returned cursor is race-safe. +- $stats = $wpdb->get_row( +- $wpdb->prepare( +- "SELECT COUNT(*) AS total_updates, COALESCE( MAX(meta_id), 0 ) AS max_meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s", +- $post_id, +- self::SYNC_UPDATE_META_KEY +- ) +- ); +- +- $total_updates = $stats ? (int) $stats->total_updates : 0; +- $max_meta_id = $stats ? (int) $stats->max_meta_id : 0; +- +- $this->room_update_counts[ $room ] = $total_updates; +- $this->room_cursors[ $room ] = $max_meta_id; +- +- if ( $max_meta_id <= $cursor ) { +- return array(); +- } +- +- $rows = $wpdb->get_results( +- $wpdb->prepare( +- "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id > %d AND meta_id <= %d ORDER BY meta_id ASC", +- $post_id, +- self::SYNC_UPDATE_META_KEY, +- $cursor, +- $max_meta_id +- ) +- ); +- +- if ( ! $rows ) { +- return array(); +- } +- +- $updates = array(); +- foreach ( $rows as $row ) { +- $decoded = json_decode( $row->meta_value, true ); +- if ( null !== $decoded ) { +- $updates[] = $decoded; +- } +- } +- +- return $updates; +- } +- +- /** +- * Removes updates from a room that are older than the given cursor. +- * +- * @since 7.0.0 +- * +- * @global wpdb $wpdb WordPress database abstraction object. +- * +- * @param string $room Room identifier. +- * @param int $cursor Remove updates with meta_id < this cursor. +- * @return bool True on success, false on failure. +- */ +- public function remove_updates_before_cursor( string $room, int $cursor ): bool { +- global $wpdb; +- +- $post_id = $this->get_storage_post_id( $room ); +- if ( null === $post_id ) { +- return false; +- } +- +- $deleted_rows = $wpdb->query( +- $wpdb->prepare( +- "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id < %d", +- $post_id, +- self::SYNC_UPDATE_META_KEY, +- $cursor +- ) +- ); +- +- if ( false === $deleted_rows ) { +- return false; +- } +- +- return true; +- } +-} +diff --git a/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php +deleted file mode 100644 +index d84dbeb1e4aae..0000000000000 +--- a/src/wp-includes/collaboration/interface-wp-sync-storage.php ++++ /dev/null +@@ -1,86 +0,0 @@ +- 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 4b6d9de25fa11..17c1695e6d72d 100644 +--- a/src/wp-includes/default-filters.php ++++ b/src/wp-includes/default-filters.php +@@ -454,6 +454,7 @@ + add_action( 'importer_scheduled_cleanup', 'wp_delete_attachment' ); + add_action( 'upgrader_scheduled_cleanup', 'wp_delete_attachment' ); + add_action( 'delete_expired_transients', 'delete_expired_transients' ); ++add_action( 'wp_delete_old_collaboration_data', 'wp_delete_old_collaboration_data' ); + + // Navigation menu actions. + add_action( 'delete_post', '_wp_delete_post_menu_item' ); +diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php +index b225d35c48b2a..938a6efb6ae3b 100644 +--- a/src/wp-includes/post.php ++++ b/src/wp-includes/post.php +@@ -657,42 +657,6 @@ function create_initial_post_types() { + ) + ); + +- if ( wp_is_collaboration_enabled() ) { +- 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, +- 'can_export' => false, +- 'supports' => array( 'custom-fields' ), +- ) +- ); +- } +- + register_post_status( + 'publish', + array( +diff --git a/src/wp-includes/presence.php b/src/wp-includes/presence.php +new file mode 100644 +index 0000000000000..27a5377ec4bbf +--- /dev/null ++++ b/src/wp-includes/presence.php +@@ -0,0 +1,171 @@ ++get_results( ++ $wpdb->prepare( ++ "SELECT client_id, user_id, data, date_gmt FROM {$wpdb->presence} WHERE room = %s AND date_gmt > %s ORDER BY client_id ASC", ++ $room, ++ $cutoff ++ ) ++ ); ++ ++ if ( ! $results ) { ++ return array(); ++ } ++ ++ $entries = array(); ++ foreach ( $results as $row ) { ++ $decoded = json_decode( $row->data, true ); ++ if ( is_array( $decoded ) ) { ++ $entries[] = array( ++ 'client_id' => $row->client_id, ++ 'state' => $decoded, ++ 'user_id' => (int) $row->user_id, ++ ); ++ } ++ } ++ ++ return $entries; ++} ++ ++/** ++ * Upserts a client's presence state in a room. ++ * ++ * Uses INSERT ... ON DUPLICATE KEY UPDATE for atomic upserts ++ * via the UNIQUE KEY (room, client_id). This eliminates race ++ * conditions inherent in read-modify-write patterns. ++ * ++ * @since 7.0.0 ++ * ++ * @global wpdb $wpdb WordPress database abstraction object. ++ * ++ * @param string $room The room identifier. ++ * @param string $client_id The client identifier. ++ * @param array $state The presence state data. ++ * @param int $user_id Optional. The user ID. Default 0. ++ * @return bool True on success, false on failure. ++ */ ++function wp_set_presence( $room, $client_id, $state, $user_id = 0 ) { ++ global $wpdb; ++ ++ if ( '' === $room || '' === $client_id ) { ++ return false; ++ } ++ ++ $data_json = wp_json_encode( $state ); ++ $now = gmdate( 'Y-m-d H:i:s' ); ++ ++ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching ++ $result = $wpdb->query( ++ $wpdb->prepare( ++ "INSERT INTO {$wpdb->presence} (room, client_id, user_id, data, date_gmt) ++ VALUES (%s, %s, %d, %s, %s) ++ ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), data = VALUES(data), date_gmt = VALUES(date_gmt)", ++ $room, ++ $client_id, ++ $user_id, ++ $data_json, ++ $now ++ ) ++ ); ++ ++ return false !== $result; ++} ++ ++/** ++ * Removes a client from a room. ++ * ++ * @since 7.0.0 ++ * ++ * @global wpdb $wpdb WordPress database abstraction object. ++ * ++ * @param string $room The room identifier. ++ * @param string $client_id The client identifier. ++ * @return bool True on success, false on failure. ++ */ ++function wp_remove_presence( $room, $client_id ) { ++ global $wpdb; ++ ++ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching ++ $result = $wpdb->delete( ++ $wpdb->presence, ++ array( ++ 'room' => $room, ++ 'client_id' => $client_id, ++ ), ++ array( '%s', '%s' ) ++ ); ++ ++ return false !== $result; ++} ++ ++/** ++ * Removes all presence entries for a given user across all rooms. ++ * ++ * @since 7.0.0 ++ * ++ * @global wpdb $wpdb WordPress database abstraction object. ++ * ++ * @param int $user_id The user ID. ++ * @return bool True on success, false on failure. ++ */ ++function wp_remove_user_presence( $user_id ) { ++ global $wpdb; ++ ++ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching ++ $result = $wpdb->delete( ++ $wpdb->presence, ++ array( 'user_id' => $user_id ), ++ array( '%d' ) ++ ); ++ ++ return false !== $result; ++} ++ ++/** ++ * Deletes stale presence entries older than the given timeout. ++ * ++ * @since 7.0.0 ++ * ++ * @global wpdb $wpdb WordPress database abstraction object. ++ * ++ * @param int $timeout Optional. Timeout in seconds. Default 60. ++ */ ++function wp_delete_expired_presence_data( $timeout = 60 ) { ++ global $wpdb; ++ ++ $cutoff = gmdate( 'Y-m-d H:i:s', time() - $timeout ); ++ ++ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching ++ $wpdb->query( ++ $wpdb->prepare( ++ "DELETE FROM {$wpdb->presence} WHERE date_gmt < %s", ++ $cutoff ++ ) ++ ); ++} +diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php +index c524f9e22a12f..688b236556fdf 100644 +--- a/src/wp-includes/rest-api.php ++++ b/src/wp-includes/rest-api.php +@@ -431,9 +431,9 @@ function create_initial_rest_routes() { + + // Collaboration. + if ( wp_is_collaboration_enabled() ) { +- $sync_storage = new WP_Sync_Post_Meta_Storage(); +- $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); +- $sync_server->register_routes(); ++ $collaboration_storage = new WP_Collaboration_Table_Storage(); ++ $collaboration_server = new WP_HTTP_Polling_Collaboration_Server( $collaboration_storage ); ++ $collaboration_server->register_routes(); + } + } + +diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php +index 934e5d0bb5369..4c42e5242f5dd 100644 +--- a/src/wp-includes/version.php ++++ b/src/wp-includes/version.php +@@ -23,7 +23,7 @@ + * + * @global int $wp_db_version + */ +-$wp_db_version = 61833; ++$wp_db_version = 61843; + + /** + * Holds the TinyMCE version. +diff --git a/src/wp-settings.php b/src/wp-settings.php +index dab1d8fd4c0de..532c5681cedc7 100644 +--- a/src/wp-settings.php ++++ b/src/wp-settings.php +@@ -310,9 +310,9 @@ + require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; + require ABSPATH . WPINC . '/abilities-api.php'; + require ABSPATH . WPINC . '/abilities.php'; +-require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php'; +-require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php'; +-require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php'; ++require ABSPATH . WPINC . '/presence.php'; ++require ABSPATH . WPINC . '/collaboration/class-wp-collaboration-table-storage.php'; ++require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-collaboration-server.php'; + require ABSPATH . WPINC . '/collaboration.php'; + require ABSPATH . WPINC . '/rest-api.php'; + require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; diff --git a/patches/README.md b/patches/README.md index 307be1e..526a5c8 100644 --- a/patches/README.md +++ b/patches/README.md @@ -10,6 +10,7 @@ is the RC2 baseline and requires no patch. | 2 | `02-custom-table.patch` | Custom table for all data | [#11256](https://github.com/WordPress/wordpress-develop/pull/11256) | Yes — adds `wp_collaboration` | | 3 | `03-post-meta-transients.patch` | Post meta + transients for awareness | [#11348](https://github.com/WordPress/wordpress-develop/pull/11348) | No | | 4 | `04-custom-table-with-transients.patch` | Custom table + object cache for awareness | [#11599](https://github.com/WordPress/wordpress-develop/pull/11599) | Yes — adds `wp_collaboration` | +| 5 | `05-custom-tables-with-presence.patch` | Custom table + dedicated presence table | [#11609](https://github.com/WordPress/wordpress-develop/pull/11609) | Yes — adds `wp_collaboration` + `wp_presence` | ## How patches were generated @@ -39,6 +40,9 @@ patch -R -p1 < /path/to/patches/02-custom-table.patch # If the patch added the wp_collaboration table, drop it after reversing: wp db query "DROP TABLE IF EXISTS $(wp db prefix)collaboration;" + +# If the patch also added the wp_presence table (approach 5): +wp db query "DROP TABLE IF EXISTS $(wp db prefix)presence;" ``` ## Regenerating patches diff --git a/rtc-test.sh b/rtc-test.sh index fbe1291..802fdd3 100755 --- a/rtc-test.sh +++ b/rtc-test.sh @@ -467,6 +467,7 @@ approach_patch_file() { custom-table) printf '%s' "${SCRIPT_DIR}/patches/02-custom-table.patch" ;; post-meta-transients) printf '%s' "${SCRIPT_DIR}/patches/03-post-meta-transients.patch" ;; custom-table-with-transients) printf '%s' "${SCRIPT_DIR}/patches/04-custom-table-with-transients.patch" ;; + custom-tables-with-presence) printf '%s' "${SCRIPT_DIR}/patches/05-custom-tables-with-presence.patch" ;; *) printf '' ;; # post-meta (RC2 baseline) or empty esac } @@ -475,7 +476,7 @@ approach_patch_file() { # wp_collaboration table (requires wp core update-db and table teardown). approach_has_schema_change() { case "$1" in - custom-table|custom-table-with-transients) return 0 ;; + custom-table|custom-table-with-transients|custom-tables-with-presence) return 0 ;; *) return 1 ;; esac } @@ -511,6 +512,14 @@ _clear_rtc_data() { echo " Collaboration table truncated.\n"; } + // Truncate the presence table if it exists (custom-tables-with-presence approach). + $presence = $wpdb->prefix . "presence"; + $exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $presence ) ); + if ( $exists ) { + $wpdb->query( "TRUNCATE TABLE `{$presence}`" ); + echo " Presence table truncated.\n"; + } + // Remove awareness transients (post-meta-transients approach). $deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->options} @@ -527,11 +536,11 @@ _clear_rtc_data() { cmd_apply_approach() { local approach="${1:-}" [ -n "${approach}" ] || die "Usage: bash rtc-test.sh apply-approach - Approaches: post-meta custom-table post-meta-transients custom-table-with-transients" + Approaches: post-meta custom-table post-meta-transients custom-table-with-transients custom-tables-with-presence" case "${approach}" in - post-meta|custom-table|post-meta-transients|custom-table-with-transients) ;; - *) die "Unknown approach '${approach}'. Valid: post-meta custom-table post-meta-transients custom-table-with-transients" ;; + post-meta|custom-table|post-meta-transients|custom-table-with-transients|custom-tables-with-presence) ;; + *) die "Unknown approach '${approach}'. Valid: post-meta custom-table post-meta-transients custom-table-with-transients custom-tables-with-presence" ;; esac print_header "apply-approach (${approach})" @@ -599,7 +608,7 @@ cmd_apply_approach() { # Step 5: Run DB upgrade again if the approach introduces a schema change (adds the # wp_collaboration table). wp_is_collaboration_enabled() requires db_version >= 61841. if approach_has_schema_change "${approach}"; then - printf 'Running database upgrade (adds collaboration table)...\n' + printf 'Running database upgrade (adds collaboration table and/or presence table)...\n' wp "${WP_FLAGS[@]}" core update-db || die "Database upgrade failed." local db_ver db_ver="$(wp "${WP_FLAGS[@]}" option get db_version 2>/dev/null)" diff --git a/run.sh b/run.sh index 8f1c017..3a78aaa 100755 --- a/run.sh +++ b/run.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # run.sh -- Sets up the test environment, runs the full RTC performance suite -# across all four storage approaches, prints a combined report, and submits +# across all five storage approaches, prints a combined report, and submits # results to the reporter endpoint. # # Prerequisites: WP_PATH (and optionally REPORTER_URL + reporter credentials) @@ -31,7 +31,7 @@ bash "${RTC}" env # For each approach: patch WP, reset RTC state, run the scenario suite. # APPROACH is passed as an env var so every log entry is tagged with the name. -APPROACHES="post-meta custom-table post-meta-transients custom-table-with-transients" +APPROACHES="post-meta custom-table post-meta-transients custom-table-with-transients custom-tables-with-presence" for approach in ${APPROACHES}; do bash "${RTC}" apply-approach "${approach}"