Status: Experimental feature plugin
Storage layer for Gutenberg's real-time collaborative editing.
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 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. Used internally to flag _sync_storage_active post meta; also available for third-party integrations.
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_storageships stable)
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