Skip to content

Commit 599ccf2

Browse files
authored
Real-time collaboration: Add support for syncing post meta (#72332)
* Add support for syncing post meta * Sync all meta by default * Add test for non-single meta * Add CRDT persistence meta key to disallowed set
1 parent 4984b51 commit 599ccf2

File tree

5 files changed

+208
-7
lines changed

5 files changed

+208
-7
lines changed

packages/core-data/src/sync.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CRDT_RECORD_MAP_KEY,
77
LOCAL_EDITOR_ORIGIN,
88
LOCAL_SYNC_MANAGER_ORIGIN,
9+
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
910
type SyncManager,
1011
createSyncManager,
1112
} from '@wordpress/sync';
@@ -15,6 +16,7 @@ export {
1516
CRDT_RECORD_MAP_KEY,
1617
LOCAL_EDITOR_ORIGIN,
1718
LOCAL_SYNC_MANAGER_ORIGIN,
19+
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
1820
};
1921

2022
let syncManager: SyncManager;

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

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import {
2121
} from './crdt-blocks';
2222
import { type Post } from '../entity-types/post';
2323
import { type Type } from '../entity-types';
24-
import { CRDT_DOC_META_PERSISTENCE_KEY, CRDT_RECORD_MAP_KEY } from '../sync';
24+
import {
25+
CRDT_DOC_META_PERSISTENCE_KEY,
26+
CRDT_RECORD_MAP_KEY,
27+
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
28+
} from '../sync';
2529
import type { WPBlockSelection, WPSelection } from '../types';
2630

2731
export type PostChanges = Partial< Post > & {
@@ -44,6 +48,7 @@ const allowedPostProperties = new Set< string >( [
4448
'featured_media',
4549
'format',
4650
'ping_status',
51+
'meta',
4752
'slug',
4853
'status',
4954
'sticky',
@@ -52,6 +57,11 @@ const allowedPostProperties = new Set< string >( [
5257
'title',
5358
] );
5459

60+
// Post meta keys that should *not* be synced.
61+
const disallowedPostMetaKeys = new Set< string >( [
62+
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
63+
] );
64+
5565
/**
5666
* Given a set of local changes to a generic entity record, apply those changes
5767
* to the local Y.Doc.
@@ -94,13 +104,13 @@ export function defaultApplyChangesToCRDTDoc(
94104
*
95105
* @param {CRDTDoc} ydoc
96106
* @param {PostChanges} changes
97-
* @param {Type} postType
107+
* @param {Type} _postType
98108
* @return {void}
99109
*/
100110
export function applyPostChangesToCRDTDoc(
101111
ydoc: CRDTDoc,
102112
changes: PostChanges,
103-
postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
113+
_postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
104114
): void {
105115
const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
106116

@@ -148,6 +158,36 @@ export function applyPostChangesToCRDTDoc(
148158
break;
149159
}
150160

161+
// "Meta" is overloaded term; here, it refers to post meta.
162+
case 'meta': {
163+
let metaMap = ymap.get( 'meta' ) as Y.Map< unknown >;
164+
165+
// Initialize.
166+
if ( ! ( metaMap instanceof Y.Map ) ) {
167+
metaMap = new Y.Map();
168+
setValue( metaMap );
169+
}
170+
171+
// Iterate over each meta property in the new value and merge it if it
172+
// should be synced.
173+
Object.entries( newValue ?? {} ).forEach(
174+
( [ metaKey, metaValue ] ) => {
175+
if ( disallowedPostMetaKeys.has( metaKey ) ) {
176+
return;
177+
}
178+
179+
mergeValue(
180+
metaMap.get( metaKey ), // current value in CRDT
181+
metaValue, // new value from changes
182+
( updatedMetaValue: unknown ): void => {
183+
metaMap.set( metaKey, updatedMetaValue );
184+
}
185+
);
186+
}
187+
);
188+
break;
189+
}
190+
151191
case 'slug': {
152192
// Do not sync an empty slug. This indicates that the post is using
153193
// the default auto-generated slug.
@@ -200,17 +240,19 @@ export function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData {
200240
*
201241
* @param {CRDTDoc} ydoc
202242
* @param {Post} editedRecord
203-
* @param {Type} postType
243+
* @param {Type} _postType
204244
* @return {Partial<PostChanges>} The changes that should be applied to the local record.
205245
*/
206246
export function getPostChangesFromCRDTDoc(
207247
ydoc: CRDTDoc,
208248
editedRecord: Post,
209-
postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
249+
_postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
210250
): PostChanges {
211251
const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
212252

213-
return Object.fromEntries(
253+
let allowedMetaChanges: Post[ 'meta' ] = {};
254+
255+
const changes = Object.fromEntries(
214256
Object.entries( ymap.toJSON() ).filter( ( [ key, newValue ] ) => {
215257
if ( ! allowedPostProperties.has( key ) ) {
216258
return false;
@@ -271,6 +313,24 @@ export function getPostChangesFromCRDTDoc(
271313
return haveValuesChanged( currentValue, newValue );
272314
}
273315

316+
case 'meta': {
317+
allowedMetaChanges = Object.fromEntries(
318+
Object.entries( newValue ?? {} ).filter(
319+
( [ metaKey ] ) =>
320+
! disallowedPostMetaKeys.has( metaKey )
321+
)
322+
);
323+
324+
// Merge the allowed meta changes with the current meta values since
325+
// not all meta properties are synced.
326+
const mergedValue = {
327+
...( currentValue as PostChanges[ 'meta' ] ),
328+
...allowedMetaChanges,
329+
};
330+
331+
return haveValuesChanged( currentValue, mergedValue );
332+
}
333+
274334
case 'status': {
275335
// Do not sync an invalid status.
276336
if ( 'auto-draft' === newValue ) {
@@ -296,6 +356,17 @@ export function getPostChangesFromCRDTDoc(
296356
}
297357
} )
298358
);
359+
360+
// Meta changes must be merged with the edited record since not all meta
361+
// properties are synced.
362+
if ( 'object' === typeof changes.meta ) {
363+
changes.meta = {
364+
...editedRecord.meta,
365+
...allowedMetaChanges,
366+
};
367+
}
368+
369+
return changes;
299370
}
300371

301372
/**

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

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/**
22
* WordPress dependencies
33
*/
4-
import { CRDT_RECORD_MAP_KEY, Y } from '@wordpress/sync';
4+
import {
5+
CRDT_RECORD_MAP_KEY,
6+
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
7+
Y,
8+
} from '@wordpress/sync';
59

610
/**
711
* External dependencies
@@ -152,6 +156,55 @@ describe( 'crdt', () => {
152156
const blocks = map.get( 'blocks' );
153157
expect( blocks ).toBeInstanceOf( Y.Array );
154158
} );
159+
160+
it( 'syncs meta fields', () => {
161+
const changes = {
162+
meta: {
163+
some_meta: 'new value',
164+
},
165+
};
166+
167+
const metaMap = new Y.Map< unknown >();
168+
metaMap.set( 'some_meta', 'old value' );
169+
map.set( 'meta', metaMap );
170+
171+
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
172+
173+
expect( metaMap.get( 'some_meta' ) ).toBe( 'new value' );
174+
} );
175+
176+
it( 'syncs non-single meta fields', () => {
177+
const changes = {
178+
meta: {
179+
some_meta: [ 'value', 'value 2' ],
180+
},
181+
};
182+
183+
const metaMap = new Y.Map< unknown >();
184+
metaMap.set( 'some_meta', 'old value' );
185+
map.set( 'meta', metaMap );
186+
187+
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
188+
189+
expect( metaMap.get( 'some_meta' ) ).toStrictEqual( [
190+
'value',
191+
'value 2',
192+
] );
193+
} );
194+
195+
it( 'initializes meta as Y.Map when not present', () => {
196+
const changes = {
197+
meta: {
198+
custom_field: 'value',
199+
},
200+
};
201+
202+
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
203+
204+
const metaMap = map.get( 'meta' ) as Y.Map< unknown >;
205+
expect( metaMap ).toBeInstanceOf( Y.Map );
206+
expect( metaMap.get( 'custom_field' ) ).toBe( 'value' );
207+
} );
155208
} );
156209

157210
describe( 'getPostChangesFromCRDTDoc', () => {
@@ -250,5 +303,75 @@ describe( 'crdt', () => {
250303

251304
expect( changes ).toHaveProperty( 'blocks' );
252305
} );
306+
307+
it( 'includes meta in changes', () => {
308+
map.set( 'meta', {
309+
public_meta: 'new value',
310+
} );
311+
312+
const editedRecord = {
313+
meta: {
314+
public_meta: 'old value',
315+
},
316+
} as unknown as Post;
317+
318+
const changes = getPostChangesFromCRDTDoc(
319+
doc,
320+
editedRecord,
321+
mockPostType
322+
);
323+
324+
expect( changes.meta ).toEqual( {
325+
public_meta: 'new value', // from CRDT
326+
} );
327+
} );
328+
329+
it( 'includes non-single meta in changes', () => {
330+
map.set( 'meta', {
331+
public_meta: [ 'value', 'value 2' ],
332+
} );
333+
334+
const editedRecord = {
335+
meta: {
336+
public_meta: 'value',
337+
},
338+
} as unknown as Post;
339+
340+
const changes = getPostChangesFromCRDTDoc(
341+
doc,
342+
editedRecord,
343+
mockPostType
344+
);
345+
346+
expect( changes.meta ).toEqual( {
347+
public_meta: [ 'value', 'value 2' ], // from CRDT
348+
} );
349+
} );
350+
351+
it( 'excludes disallowed meta keys in changes', () => {
352+
map.set( 'meta', {
353+
public_meta: 'new value',
354+
[ WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'exclude me',
355+
} );
356+
357+
const editedRecord = {
358+
meta: {
359+
public_meta: 'old value',
360+
},
361+
} as unknown as Post;
362+
363+
const changes = getPostChangesFromCRDTDoc(
364+
doc,
365+
editedRecord,
366+
mockPostType
367+
);
368+
369+
expect( changes.meta ).toEqual( {
370+
public_meta: 'new value', // from CRDT
371+
} );
372+
expect( changes.meta ).not.toHaveProperty(
373+
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE
374+
);
375+
} );
253376
} );
254377
} );

packages/sync/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ Origin string for CRDT document changes originating from the local editor.
3434

3535
Origin string for CRDT document changes originating from the sync manager.
3636

37+
### WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE
38+
39+
WordPress meta key used to persist the CRDT document for an entity.
40+
3741
### Y
3842

3943
Exported copy of Yjs so that consumers of this package don't need to install it.

packages/sync/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export {
1616
CRDT_RECORD_MAP_KEY,
1717
LOCAL_EDITOR_ORIGIN,
1818
LOCAL_SYNC_MANAGER_ORIGIN,
19+
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
1920
} from './config';
2021
export { createSyncManager } from './manager';
2122
export type * from './types';

0 commit comments

Comments
 (0)