Skip to content

Commit 2d8b226

Browse files
authored
Real-time collaboration: Implement CRDT persistence for collaborative editing (#72373)
* Implement CRDT persistence * Add tests for SyncManager and CRDT persistence
1 parent 58ed738 commit 2d8b226

15 files changed

Lines changed: 1017 additions & 17 deletions

File tree

lib/experimental/synchronization.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,39 @@ function gutenberg_rest_api_init_collaborative_editing() {
2222
wp_add_inline_script( 'wp-sync', 'window.__experimentalCollaborativeEditingSecret = "' . $collaborative_editing_secret . '";', 'before' );
2323
}
2424
add_action( 'admin_init', 'gutenberg_rest_api_init_collaborative_editing' );
25+
26+
/**
27+
* Registers post meta for persisting CRDT documents.
28+
*/
29+
function gutenberg_rest_api_crdt_post_meta() {
30+
$gutenberg_experiments = get_option( 'gutenberg-experiments' );
31+
if ( ! $gutenberg_experiments || ! array_key_exists( 'gutenberg-sync-collaboration', $gutenberg_experiments ) ) {
32+
return;
33+
}
34+
35+
// This string must match WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE in @wordpress/sync.
36+
$persisted_crdt_post_meta_key = '_crdt_document';
37+
38+
register_meta(
39+
'post',
40+
$persisted_crdt_post_meta_key,
41+
array(
42+
'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool {
43+
return user_can( $user_id, 'edit_post', $object_id );
44+
},
45+
// IMPORTANT: Revisions must be disabled because we always want to preserve
46+
// the latest persisted CRDT document, even when a revision is restored.
47+
// This ensures that we can continue to apply updates to a shared document
48+
// and peers can simply merge the restored revision like any other incoming
49+
// update.
50+
//
51+
// If we want to persist CRDT documents alongisde revisions in the
52+
// future, we should do so in a separate meta key.
53+
'revisions_enabled' => false,
54+
'show_in_rest' => true,
55+
'single' => true,
56+
'type' => 'string',
57+
)
58+
);
59+
}
60+
add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' );

packages/core-data/src/entities.js

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { __ } from '@wordpress/i18n';
1313
/**
1414
* Internal dependencies
1515
*/
16+
import { getSyncManager } from './sync';
1617
import {
1718
applyPostChangesToCRDTDoc,
1819
defaultApplyChangesToCRDTDoc,
@@ -236,16 +237,23 @@ export const additionalEntityConfigLoaders = [
236237
];
237238

238239
/**
239-
* Returns a function to be used to retrieve extra edits to apply before persisting a post type.
240+
* Apply extra edits before persisting a post type.
240241
*
241-
* @param {Object} persistedRecord Already persisted Post
242-
* @param {Object} edits Edits.
242+
* @param {Object} persistedRecord Already persisted Post
243+
* @param {Object} edits Edits.
244+
* @param {string} name Post type name.
245+
* @param {boolean} isTemplate Whether the post type is a template.
243246
* @return {Object} Updated edits.
244247
*/
245-
export const prePersistPostType = ( persistedRecord, edits ) => {
248+
export const prePersistPostType = (
249+
persistedRecord,
250+
edits,
251+
name,
252+
isTemplate
253+
) => {
246254
const newEdits = {};
247255

248-
if ( persistedRecord?.status === 'auto-draft' ) {
256+
if ( ! isTemplate && persistedRecord?.status === 'auto-draft' ) {
249257
// Saving an auto-draft should create a draft by default.
250258
if ( ! edits.status && ! newEdits.status ) {
251259
newEdits.status = 'draft';
@@ -262,6 +270,19 @@ export const prePersistPostType = ( persistedRecord, edits ) => {
262270
}
263271
}
264272

273+
// Add meta for persisted CRDT document.
274+
if ( persistedRecord && window.__experimentalEnableSync ) {
275+
if ( globalThis.IS_GUTENBERG_PLUGIN ) {
276+
const objectType = `postType/${ name }`;
277+
const objectId = persistedRecord.id;
278+
const meta = getSyncManager()?.createMeta( objectType, objectId );
279+
newEdits.meta = {
280+
...edits.meta,
281+
...meta,
282+
};
283+
}
284+
}
285+
265286
return newEdits;
266287
};
267288

@@ -298,7 +319,8 @@ async function loadPostTypeEntities() {
298319
( isTemplate
299320
? capitalCase( record.slug ?? '' )
300321
: String( record.id ) ),
301-
__unstablePrePersist: isTemplate ? undefined : prePersistPostType,
322+
__unstablePrePersist: ( persistedRecord, edits ) =>
323+
prePersistPostType( persistedRecord, edits, name, isTemplate ),
302324
__unstable_rest_base: postType.rest_base,
303325
supportsPagination: true,
304326
getRevisionsUrl: ( parentId, revisionId ) =>
@@ -347,7 +369,9 @@ async function loadPostTypeEntities() {
347369
*
348370
* @type {Record< string, boolean >}
349371
*/
350-
supports: {},
372+
supports: {
373+
crdtPersistence: true,
374+
},
351375
};
352376
}
353377
}

packages/core-data/src/resolvers.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ export const getEntityRecord =
215215
name,
216216
key
217217
),
218+
// Save the current entity record's unsaved edits.
219+
saveRecord: () => {
220+
dispatch.saveEditedEntityRecord(
221+
kind,
222+
name,
223+
key
224+
);
225+
},
218226
}
219227
);
220228
}

packages/core-data/src/sync.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22
* WordPress dependencies
33
*/
44
import {
5+
CRDT_DOC_META_PERSISTENCE_KEY,
56
CRDT_RECORD_MAP_KEY,
67
LOCAL_EDITOR_ORIGIN,
78
LOCAL_SYNC_MANAGER_ORIGIN,
89
type SyncManager,
910
createSyncManager,
1011
} from '@wordpress/sync';
1112

12-
export { CRDT_RECORD_MAP_KEY, LOCAL_EDITOR_ORIGIN, LOCAL_SYNC_MANAGER_ORIGIN };
13+
export {
14+
CRDT_DOC_META_PERSISTENCE_KEY,
15+
CRDT_RECORD_MAP_KEY,
16+
LOCAL_EDITOR_ORIGIN,
17+
LOCAL_SYNC_MANAGER_ORIGIN,
18+
};
1319

1420
let syncManager: SyncManager;
1521

packages/core-data/src/test/entities.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,21 +50,23 @@ describe( 'prePersistPostType', () => {
5050
status: 'auto-draft',
5151
};
5252
const edits = {};
53-
expect( prePersistPostType( record, edits ) ).toEqual( {
53+
expect( prePersistPostType( record, edits, 'post', false ) ).toEqual( {
5454
status: 'draft',
5555
title: '',
5656
} );
5757

5858
record = {
5959
status: 'publish',
6060
};
61-
expect( prePersistPostType( record, edits ) ).toEqual( {} );
61+
expect( prePersistPostType( record, edits, 'post', false ) ).toEqual(
62+
{}
63+
);
6264

6365
record = {
6466
status: 'auto-draft',
6567
title: 'Auto Draft',
6668
};
67-
expect( prePersistPostType( record, edits ) ).toEqual( {
69+
expect( prePersistPostType( record, edits, 'post', false ) ).toEqual( {
6870
status: 'draft',
6971
title: '',
7072
} );
@@ -73,7 +75,20 @@ describe( 'prePersistPostType', () => {
7375
status: 'publish',
7476
title: 'My Title',
7577
};
76-
expect( prePersistPostType( record, edits ) ).toEqual( {} );
78+
expect( prePersistPostType( record, edits, 'post', false ) ).toEqual(
79+
{}
80+
);
81+
} );
82+
83+
it( 'does not set the status to draft and empty the title when saving templates', () => {
84+
const record = {
85+
status: 'auto-draft',
86+
title: 'Auto Draft',
87+
};
88+
const edits = {};
89+
expect( prePersistPostType( record, edits, 'post', true ) ).toEqual(
90+
{}
91+
);
7792
} );
7893
} );
7994

packages/core-data/src/test/resolvers.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ describe( 'getEntityRecord', () => {
175175
{
176176
editRecord: expect.any( Function ),
177177
getEditedRecord: expect.any( Function ),
178+
saveRecord: expect.any( Function ),
178179
}
179180
);
180181
} );
@@ -228,6 +229,7 @@ describe( 'getEntityRecord', () => {
228229
{
229230
editRecord: expect.any( Function ),
230231
getEditedRecord: expect.any( Function ),
232+
saveRecord: expect.any( Function ),
231233
}
232234
);
233235
} );

packages/core-data/src/utils/crdt.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import fastDeepEqual from 'fast-deep-equal/es6';
66
/**
77
* WordPress dependencies
88
*/
9+
// @ts-expect-error No exported types.
10+
import { __unstableSerializeAndClean } from '@wordpress/blocks';
911
import { type CRDTDoc, type ObjectData, Y } from '@wordpress/sync';
1012

1113
/**
@@ -19,7 +21,7 @@ import {
1921
} from './crdt-blocks';
2022
import { type Post } from '../entity-types/post';
2123
import { type Type } from '../entity-types';
22-
import { CRDT_RECORD_MAP_KEY } from '../sync';
24+
import { CRDT_DOC_META_PERSISTENCE_KEY, CRDT_RECORD_MAP_KEY } from '../sync';
2325
import type { WPBlockSelection, WPSelection } from '../types';
2426

2527
export type PostChanges = Partial< Post > & {
@@ -218,6 +220,35 @@ export function getPostChangesFromCRDTDoc(
218220

219221
switch ( key ) {
220222
case 'blocks': {
223+
// When we are passed a persisted CRDT document, make a special
224+
// comparison of the content and blocks.
225+
//
226+
// When other fields (besides `blocks`) are mutated outside the block
227+
// editor, the change is caught by an equality check (see other cases
228+
// in this `switch` statement). As a transient property, `blocks`
229+
// cannot be directly mutated outside the block editor -- only
230+
// `content` can.
231+
//
232+
// Therefore, for this special comparison, we serialize the `blocks`
233+
// from the persisted CRDT document and compare that to the content
234+
// from the persisted record. If they differ, we know that the content
235+
// in the database has changed, and therefore the blocks have changed.
236+
//
237+
// We cannot directly compare the `blocks` from the CRDT document to
238+
// the `blocks` derived from the `content` in the persisted record,
239+
// because the latter will have different client IDs.
240+
if (
241+
ydoc.meta?.get( CRDT_DOC_META_PERSISTENCE_KEY ) &&
242+
editedRecord.content
243+
) {
244+
const blocks = ymap.get( 'blocks' ) as YBlocks;
245+
return (
246+
__unstableSerializeAndClean(
247+
blocks.toJSON()
248+
).trim() !== editedRecord.content.raw.trim()
249+
);
250+
}
251+
221252
// The consumers of blocks have memoization that renders optimization
222253
// here unnecessary.
223254
return true;

packages/sync/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ npm install @wordpress/sync --save
1414

1515
<!-- START TOKEN(Autogenerated API docs) -->
1616

17+
### CRDT_DOC_META_PERSISTENCE_KEY
18+
19+
CRDT documents can hold meta information in a map. This map exists only in memory and is not synced or persisted. This key can be used to indicate that a (temporary) document has been loaded from persistence.
20+
1721
### CRDT_RECORD_MAP_KEY
1822

1923
Root-level key for the CRDT document that holds the entity record data.

packages/sync/src/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
*/
66
export const CRDT_DOC_VERSION = 1;
77

8+
/**
9+
* CRDT documents can hold meta information in a map. This map exists only in
10+
* memory and is not synced or persisted. This key can be used to indicate that
11+
* a (temporary) document has been loaded from persistence.
12+
*/
13+
export const CRDT_DOC_META_PERSISTENCE_KEY = 'fromPersistence';
14+
815
/**
916
* Root-level key for the CRDT document that holds the entity record data.
1017
*/
@@ -28,3 +35,8 @@ export const LOCAL_EDITOR_ORIGIN = 'gutenberg';
2835
* Origin string for CRDT document changes originating from the sync manager.
2936
*/
3037
export const LOCAL_SYNC_MANAGER_ORIGIN = 'syncManager';
38+
39+
/**
40+
* WordPress meta key used to persist the CRDT document for an entity.
41+
*/
42+
export const WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE = '_crdt_document';

packages/sync/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
export * as Y from 'yjs';
1313

1414
export {
15+
CRDT_DOC_META_PERSISTENCE_KEY,
1516
CRDT_RECORD_MAP_KEY,
1617
LOCAL_EDITOR_ORIGIN,
1718
LOCAL_SYNC_MANAGER_ORIGIN,

0 commit comments

Comments
 (0)