Status: Experimental feature plugin
Storage layer for Gutenberg's real-time collaborative editing.
The Playground demo installs Gutenberg from the plugin directory, and no tagged Gutenberg release carries the __unstable_wp_sync_storage filter yet, so it boots all three plugins and warns in wp-admin instead of demonstrating the storage swap. Until that release lands, run locally against a trunk build to see the real thing.
Gutenberg's real-time collaboration needs somewhere to keep two kinds of data: ephemeral awareness (who's in the room, cursor position) and a persistent log of CRDT updates for each document. Storing either as post meta means every write invalidates post caches site-wide (#64696). This plugin implements Gutenberg's WP_Sync_Storage interface to keep both out of post meta entirely: awareness is delegated to Presence API's wp_presence table, and CRDT updates go into a dedicated wp_collaboration table.
git clone https://github.com/WordPress/sync-storage.git
cd sync-storage
npm install
npm run env:startThen open localhost:8888/wp-admin/ (admin / password).
The first run builds Gutenberg from trunk, since the __unstable_wp_sync_storage filter this plugin hooks hasn't shipped in a tagged Gutenberg release yet. That takes a few minutes; subsequent runs reuse the build.
lib/ is three layers, and dependencies point one way — rtc/ calls store/, and store/ never calls back.
| Directory | Holds | Knows about |
|---|---|---|
lib/store/ |
The wp_collaboration table: schema, every query against it, and the daily expiry sweep. A room-scoped, append-only, expiring log of opaque payloads. |
Nothing above it |
lib/rtc/ |
The adapter that makes that store Gutenberg's collaborative editing backend: room naming and access rules, cursor bookkeeping, awareness delegated to Presence API. | store/, Gutenberg, Presence API |
lib/site/ |
What activating the plugin implies for a site's settings. No storage logic. | WordPress options |
Two rules follow from that, and reviews should hold them:
$wpdboutsidelib/store/is a layering mistake.Sync_Storage_Storeis the only place that touches the table.lib/store/stays free of Gutenberg, Presence API and Yjs vocabulary. A room is a string and a payload is opaque.tests/test-store.phpexercises the store with non-post rooms and no capability checks specifically to keep that honest.
The loader enforces the same split. Only the store, its install and migration paths, and the Presence API listeners load with the plugin; Sync_Storage_Provider, the filter and the experiment opt-in wait for plugins_loaded and load only if WP_Sync_Storage is declared. The interface is the condition rather than GUTENBERG_VERSION because it is the actual dependency, and it moves to core with the feature. tests/test-bootstrap.php reads the file-scope require_once calls back out of sync-storage.php and fails if anything naming an editor symbol crosses into them.
The split is internal. These are not separate plugins, and shouldn't be until something other than real-time collaboration actually needs the store.
Awareness
- Gutenberg calls
set_awareness_state( $room, $awareness ) - Each entry is forwarded to Presence API's
wp_set_presence() - Reads go through
get_awareness_state( $room ), which callswp_get_presence()and reshapes the result into Gutenberg's expected format
CRDT updates
- Gutenberg calls
add_update( $room, $update ) - The update is inserted into
wp_collaborationas an opaque, JSON-encoded row - Gutenberg polls
get_updates_after_cursor( $room, $cursor )to fetch anything new remove_updates_before_cursor()deletes compacted rows once Gutenberg confirms they're no longer needed
Both paths validate that the current user can edit_post the room's underlying post before touching storage.
| Pattern | Example |
|---|---|
postType/{type}:{id} |
postType/post:42 |
This plugin implements Gutenberg's WP_Sync_Storage interface. These are the methods Sync_Storage_Provider provides; there's no separate global-function API like Presence API's.
// Read awareness state for a room, reshaped from Presence API's format.
$entries = $storage->get_awareness_state( $room );
// Write each client's awareness state, delegated to wp_set_presence().
$storage->set_awareness_state( $room, $awareness );
// Append an opaque CRDT update to wp_collaboration.
$storage->add_update( $room, $update );
// Last update id returned to this request for a room (0 if none yet).
$cursor = $storage->get_cursor( $room );
// Number of stored updates for a room.
$count = $storage->get_update_count( $room );
// Updates with id > $cursor, ordered by id.
$updates = $storage->get_updates_after_cursor( $room, $cursor );
// Delete compacted updates with id < $cursor.
$storage->remove_updates_before_cursor( $room, $cursor );| Column | Type | Purpose |
|---|---|---|
| id | BIGINT UNSIGNED | Auto-increment cursor for polling |
| room | VARCHAR(191) | Room identifier, e.g. postType/post:42 |
| type | VARCHAR(20) | Reserved for future update classification; always NULL today |
| data | LONGTEXT | JSON-encoded opaque payload |
| timestamp | BIGINT UNSIGNED | Milliseconds since epoch, matching Yjs. Used by cleanup |
Indexes: PRIMARY KEY (id), KEY room_id (room, id) for polling, KEY room_timestamp (room, timestamp) for cleanup.
A daily cron removes rows older than 7 days.
Fired when a room's collaborator count crosses the 1-to-2 threshold, as reported by Presence API. Nothing in the plugin listens; they exist so integrations can react to collaboration starting and stopping.
add_action( 'sync_storage_room_active', function ( $post_id, $entries ) {
// A second collaborator just joined $post_id.
}, 10, 2 );
add_action( 'sync_storage_room_inactive', function ( $post_id, $entries ) {
// Back down to a single editor (or none).
}, 10, 2 );Gutenberg's own filter, hooked by this plugin to replace its default post-meta-backed storage with Sync_Storage_Provider. Any plugin can hook this filter to supply a different WP_Sync_Storage implementation (Redis, WebSocket-backed, etc.) without patching Gutenberg.
- WordPress 7.0+
- PHP 7.4+
- Presence API
Gutenberg trunk (or a future release once __unstable_wp_sync_storage ships stable) is what consumes this storage, not what it needs to run. Without it the table, its cleanup and Sync_Storage_Store all install and work; the collaboration integration stays unloaded and says so in wp-admin.
Sponsored by the Core team. Discussion happens in #feature-realtime-collaboration on WordPress Slack and on Trac #64696.
Questions and bug reports: GitHub Issues.
GPL-2.0-or-later