Skip to content

Commit be1c20e

Browse files
authored
Real-time collaboration: Refetch entity when it is saved by a peer (#74637)
* Refetch entity when it is saved by a peer * tch
1 parent 2469059 commit be1c20e

9 files changed

Lines changed: 170 additions & 6 deletions

File tree

packages/core-data/src/actions.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,17 @@ export const saveEntityRecord =
714714
true,
715715
edits
716716
);
717+
if ( globalThis.IS_GUTENBERG_PLUGIN ) {
718+
if ( entityConfig.syncConfig ) {
719+
getSyncManager()?.update(
720+
`${ kind }/${ name }`,
721+
recordId,
722+
updatedRecord,
723+
LOCAL_EDITOR_ORIGIN,
724+
true // isSave
725+
);
726+
}
727+
}
717728
}
718729
} catch ( _error ) {
719730
hasError = true;

packages/core-data/src/resolvers.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,15 @@ export const getEntityRecord =
215215
name,
216216
key
217217
),
218+
// Refetch the current entity record from the database.
219+
refetchRecord: async () => {
220+
dispatch.receiveEntityRecords(
221+
kind,
222+
name,
223+
await apiFetch( { path, parse: true } ),
224+
query
225+
);
226+
},
218227
// Save the current entity record's unsaved edits.
219228
saveRecord: () => {
220229
dispatch.saveEditedEntityRecord(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ describe( 'getEntityRecord', () => {
170170
{
171171
editRecord: expect.any( Function ),
172172
getEditedRecord: expect.any( Function ),
173+
refetchRecord: expect.any( Function ),
173174
saveRecord: expect.any( Function ),
174175
}
175176
);
@@ -222,6 +223,7 @@ describe( 'getEntityRecord', () => {
222223
{
223224
editRecord: expect.any( Function ),
224225
getEditedRecord: expect.any( Function ),
226+
refetchRecord: expect.any( Function ),
225227
saveRecord: expect.any( Function ),
226228
}
227229
);

packages/sync/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,19 @@ CRDT documents can hold meta information in a map. This map exists only in memor
2020

2121
### CRDT_RECORD_MAP_KEY
2222

23-
Root-level key for the CRDT document that holds the entity record data.
23+
Root-level key for the map that holds the entity record data.
24+
25+
### CRDT_RECORD_METADATA_MAP_KEY
26+
27+
Root-level key for the map that holds entity record metadata. This map should only contain metadata that is not represented by the entity record itself.
28+
29+
### CRDT_RECORD_METADATA_SAVED_AT_KEY
30+
31+
Y.Map key representing the timestamp of the last save operation.
32+
33+
### CRDT_RECORD_METADATA_SAVED_BY_KEY
34+
35+
Y.Map key representing the Y.Doc client ID of the user who performed the last save operation.
2436

2537
### createSyncManager
2638

packages/sync/src/config.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,31 @@ export const CRDT_DOC_VERSION = 1;
1313
export const CRDT_DOC_META_PERSISTENCE_KEY = 'fromPersistence';
1414

1515
/**
16-
* Root-level key for the CRDT document that holds the entity record data.
16+
* Root-level key for the map that holds the entity record data.
1717
*/
1818
export const CRDT_RECORD_MAP_KEY = 'document';
1919

2020
/**
21-
* Root-level key for the CRDT document that holds the state descriptors (see
22-
* below).
21+
* Root-level key for the map that holds entity record metadata. This map should
22+
* only contain metadata that is not represented by the entity record itself.
23+
*/
24+
export const CRDT_RECORD_METADATA_MAP_KEY = 'documentMeta';
25+
26+
/**
27+
* Y.Map key representing the timestamp of the last save operation.
28+
*/
29+
export const CRDT_RECORD_METADATA_SAVED_AT_KEY = 'savedAt';
30+
31+
/**
32+
* Y.Map key representing the Y.Doc client ID of the user who performed the last
33+
* save operation.
34+
*/
35+
export const CRDT_RECORD_METADATA_SAVED_BY_KEY = 'savedBy';
36+
37+
/**
38+
* Root-level key for the map that holds the state information about the CRDT
39+
* document itself. It should not contain information related to the entity
40+
* record.
2341
*/
2442
export const CRDT_STATE_MAP_KEY = 'state';
2543

packages/sync/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export { default as Delta } from './quill-delta/Delta';
1919
export {
2020
CRDT_DOC_META_PERSISTENCE_KEY,
2121
CRDT_RECORD_MAP_KEY,
22+
CRDT_RECORD_METADATA_MAP_KEY,
23+
CRDT_RECORD_METADATA_SAVED_AT_KEY,
24+
CRDT_RECORD_METADATA_SAVED_BY_KEY,
2225
LOCAL_EDITOR_ORIGIN,
2326
LOCAL_SYNC_MANAGER_ORIGIN,
2427
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,

packages/sync/src/manager.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import * as Y from 'yjs';
99
import {
1010
CRDT_RECORD_MAP_KEY as RECORD_KEY,
1111
LOCAL_SYNC_MANAGER_ORIGIN,
12+
CRDT_RECORD_METADATA_MAP_KEY as RECORD_METADATA_KEY,
13+
CRDT_RECORD_METADATA_SAVED_AT_KEY as SAVED_AT_KEY,
14+
CRDT_RECORD_METADATA_SAVED_BY_KEY as SAVED_BY_KEY,
1215
} from './config';
1316
import { createPersistedCRDTDoc, getPersistedCrdtDoc } from './persistence';
1417
import { getProviderCreators } from './providers';
@@ -105,6 +108,8 @@ export function createSyncManager(): SyncManager {
105108

106109
const ydoc = createYjsDoc( { objectType } );
107110
const recordMap = ydoc.getMap( RECORD_KEY );
111+
const recordMetaMap = ydoc.getMap( RECORD_METADATA_KEY );
112+
const now = Date.now();
108113

109114
// Clean up providers and in-memory state when the entity is unloaded.
110115
const unload = (): void => {
@@ -130,6 +135,28 @@ export function createSyncManager(): SyncManager {
130135
void updateEntityRecord( objectType, objectId );
131136
};
132137

138+
const onRecordMetaUpdate = (
139+
event: Y.YMapEvent< unknown >,
140+
transaction: Y.Transaction
141+
) => {
142+
if ( transaction.local ) {
143+
return;
144+
}
145+
146+
event.keysChanged.forEach( ( key ) => {
147+
switch ( key ) {
148+
case SAVED_AT_KEY:
149+
const newValue = recordMetaMap.get( SAVED_AT_KEY );
150+
if ( 'number' === typeof newValue && newValue > now ) {
151+
// Another peer has saved the record. Refetch it so that we have
152+
// a correct understanding of our own unsaved edits.
153+
void handlers.refetchRecord().catch( () => {} );
154+
}
155+
break;
156+
}
157+
} );
158+
};
159+
133160
// Lazily create the undo manager when the first entity is loaded.
134161
if ( ! undoManager ) {
135162
undoManager = createUndoManager();
@@ -159,6 +186,7 @@ export function createSyncManager(): SyncManager {
159186

160187
// Attach observers.
161188
recordMap.observeDeep( onRecordUpdate );
189+
recordMetaMap.observe( onRecordMetaUpdate );
162190

163191
// Get and apply the persisted CRDT document, if it exists.
164192
const isInvalid = applyPersistedCrdtDoc( syncConfig, ydoc, record );
@@ -257,12 +285,14 @@ export function createSyncManager(): SyncManager {
257285
* @param {ObjectID} objectId Object ID.
258286
* @param {Partial< ObjectData >} changes Updates to make.
259287
* @param {string} origin The source of change.
288+
* @param {boolean} isSave Whether this update is part of a save operation.
260289
*/
261290
function updateCRDTDoc(
262291
objectType: ObjectType,
263292
objectId: ObjectID,
264293
changes: Partial< ObjectData >,
265-
origin: string
294+
origin: string,
295+
isSave: boolean = false
266296
): void {
267297
const entityId = getEntityId( objectType, objectId );
268298
const entityState = entityStates.get( entityId );
@@ -275,6 +305,13 @@ export function createSyncManager(): SyncManager {
275305

276306
ydoc.transact( () => {
277307
syncConfig.applyChangesToCRDTDoc( ydoc, changes );
308+
309+
if ( isSave ) {
310+
// Mark the document as saved in the record metadata map.
311+
const recordMeta = ydoc.getMap( RECORD_METADATA_KEY );
312+
recordMeta.set( SAVED_AT_KEY, Date.now() );
313+
recordMeta.set( SAVED_BY_KEY, ydoc.clientID );
314+
}
278315
}, origin );
279316
}
280317

packages/sync/src/test/manager.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
import { createSyncManager } from '../manager';
2020
import {
2121
CRDT_RECORD_MAP_KEY,
22+
CRDT_RECORD_METADATA_MAP_KEY as RECORD_METADATA_MAP_KEY,
23+
CRDT_RECORD_METADATA_SAVED_AT_KEY as SAVED_AT_KEY,
24+
CRDT_RECORD_METADATA_SAVED_BY_KEY as SAVED_BY_KEY,
2225
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
2326
} from '../config';
2427
import { createPersistedCRDTDoc } from '../persistence';
@@ -88,6 +91,7 @@ describe( 'SyncManager', () => {
8891
getEditedRecord: jest.fn( async () =>
8992
Promise.resolve( mockRecord )
9093
),
94+
refetchRecord: jest.fn( async () => Promise.resolve() ),
9195
saveRecord: jest.fn( async () => Promise.resolve() ),
9296
};
9397
} );
@@ -520,6 +524,19 @@ describe( 'SyncManager', () => {
520524

521525
describe( 'update', () => {
522526
it( 'updates CRDT document with local changes', async () => {
527+
// Capture the Y.Doc from provider creator
528+
let capturedDoc: Y.Doc | null = null;
529+
mockProviderCreator.mockImplementation(
530+
async (
531+
_objectType: string,
532+
_objectId: string,
533+
ydoc: Y.Doc
534+
) => {
535+
capturedDoc = ydoc;
536+
return mockProviderResult;
537+
}
538+
);
539+
523540
const manager = createSyncManager();
524541

525542
await manager.load(
@@ -535,10 +552,17 @@ describe( 'SyncManager', () => {
535552
const changes = { title: 'Updated Title' };
536553
manager.update( 'post', '123', changes, 'local-editor' );
537554

555+
// Verify that applyChangesToCRDTDoc was called with the changes.
538556
expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith(
539557
expect.any( Y.Doc ),
540558
changes
541559
);
560+
561+
// Verify that the record metadata was not updated.
562+
const ydoc = capturedDoc as unknown as Y.Doc;
563+
const metadataMap = ydoc.getMap( RECORD_METADATA_MAP_KEY );
564+
expect( metadataMap.get( SAVED_AT_KEY ) ).toBeUndefined();
565+
expect( metadataMap.get( SAVED_BY_KEY ) ).toBeUndefined();
542566
} );
543567

544568
it( 'does not update when entity is not loaded', () => {
@@ -595,6 +619,52 @@ describe( 'SyncManager', () => {
595619
customOrigin
596620
);
597621
} );
622+
623+
it( 'updates the record metadata when the update is associated with a save', async () => {
624+
// Capture the Y.Doc from provider creator.
625+
let capturedDoc: Y.Doc | null = null;
626+
mockProviderCreator.mockImplementation(
627+
async (
628+
_objectType: string,
629+
_objectId: string,
630+
ydoc: Y.Doc
631+
) => {
632+
capturedDoc = ydoc;
633+
return mockProviderResult;
634+
}
635+
);
636+
637+
const manager = createSyncManager();
638+
639+
await manager.load(
640+
mockSyncConfig,
641+
'post',
642+
'123',
643+
mockRecord,
644+
mockHandlers
645+
);
646+
647+
jest.clearAllMocks();
648+
649+
const changes = { title: 'Updated Title' };
650+
const now = Date.now();
651+
652+
manager.update( 'post', '123', changes, 'local-editor', true );
653+
654+
// Verify that applyChangesToCRDTDoc was called with the changes.
655+
expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith(
656+
expect.any( Y.Doc ),
657+
changes
658+
);
659+
660+
// Verify that the record metadata was updated.
661+
const ydoc = capturedDoc as unknown as Y.Doc;
662+
const metadataMap = ydoc.getMap( RECORD_METADATA_MAP_KEY );
663+
expect( metadataMap.get( SAVED_AT_KEY ) ).toBeGreaterThanOrEqual(
664+
now
665+
);
666+
expect( metadataMap.get( SAVED_BY_KEY ) ).toBe( ydoc.clientID );
667+
} );
598668
} );
599669

600670
describe( 'CRDT doc observation', () => {

packages/sync/src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export type ProviderCreator = (
6363
export interface RecordHandlers {
6464
editRecord: ( data: Partial< ObjectData > ) => void;
6565
getEditedRecord: () => Promise< ObjectData >;
66+
refetchRecord: () => Promise< void >;
6667
saveRecord: () => Promise< void >;
6768
}
6869

@@ -97,7 +98,8 @@ export interface SyncManager {
9798
objectType: ObjectType,
9899
objectId: ObjectID,
99100
changes: Partial< ObjectData >,
100-
origin: string
101+
origin: string,
102+
isSave?: boolean
101103
) => void;
102104
}
103105

0 commit comments

Comments
 (0)