Skip to content

Commit 4baafca

Browse files
committed
Real-time collaboration: Refetch entity when it is saved by a peer
1 parent ce99ef5 commit 4baafca

File tree

9 files changed

+172
-6
lines changed

9 files changed

+172
-6
lines changed

packages/core-data/src/actions.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,20 @@ export const saveEntityRecord =
712712
true,
713713
edits
714714
);
715+
if (
716+
window.__experimentalEnableSync &&
717+
entityConfig.syncConfig
718+
) {
719+
if ( globalThis.IS_GUTENBERG_PLUGIN ) {
720+
getSyncManager()?.update(
721+
`${ kind }/${ name }`,
722+
recordId,
723+
updatedRecord,
724+
LOCAL_EDITOR_ORIGIN,
725+
true // isSave
726+
);
727+
}
728+
}
715729
}
716730
} catch ( _error ) {
717731
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+
// Refect 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
@@ -175,6 +175,7 @@ describe( 'getEntityRecord', () => {
175175
{
176176
editRecord: expect.any( Function ),
177177
getEditedRecord: expect.any( Function ),
178+
refetchRecord: expect.any( Function ),
178179
saveRecord: expect.any( Function ),
179180
}
180181
);
@@ -229,6 +230,7 @@ describe( 'getEntityRecord', () => {
229230
{
230231
editRecord: expect.any( Function ),
231232
getEditedRecord: expect.any( Function ),
233+
refetchRecord: expect.any( Function ),
232234
saveRecord: expect.any( Function ),
233235
}
234236
);

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';
@@ -75,6 +78,8 @@ export function createSyncManager(): SyncManager {
7578

7679
const ydoc = createYjsDoc( { objectType } );
7780
const recordMap = ydoc.getMap( RECORD_KEY );
81+
const recordMetaMap = ydoc.getMap( RECORD_METADATA_KEY );
82+
const now = Date.now();
7883

7984
// Clean up providers and in-memory state when the entity is unloaded.
8085
const unload = (): void => {
@@ -100,6 +105,28 @@ export function createSyncManager(): SyncManager {
100105
void updateEntityRecord( objectType, objectId );
101106
};
102107

108+
const onRecordMetaUpdate = (
109+
event: Y.YMapEvent< unknown >,
110+
transaction: Y.Transaction
111+
) => {
112+
if ( transaction.local ) {
113+
return;
114+
}
115+
116+
event.keysChanged.forEach( ( key ) => {
117+
switch ( key ) {
118+
case SAVED_AT_KEY:
119+
const newValue = recordMetaMap.get( SAVED_AT_KEY );
120+
if ( 'number' === typeof newValue && newValue > now ) {
121+
// Another peer has saved the record. Refetch it so that we have
122+
// a correct understanding of our own unsaved edits.
123+
void handlers.refetchRecord().catch( () => {} );
124+
}
125+
break;
126+
}
127+
} );
128+
};
129+
103130
if ( syncConfig.supports?.undo ) {
104131
undoManager.addToScope( recordMap );
105132
}
@@ -128,6 +155,7 @@ export function createSyncManager(): SyncManager {
128155

129156
// Attach observers.
130157
recordMap.observeDeep( onRecordUpdate );
158+
recordMetaMap.observe( onRecordMetaUpdate );
131159

132160
// Get and apply the persisted CRDT document, if it exists.
133161
const isInvalid = applyPersistedCrdtDoc( syncConfig, ydoc, record );
@@ -226,12 +254,14 @@ export function createSyncManager(): SyncManager {
226254
* @param {ObjectID} objectId Object ID.
227255
* @param {Partial< ObjectData >} changes Updates to make.
228256
* @param {string} origin The source of change.
257+
* @param {boolean} isSave Whether this update is part of a save operation.
229258
*/
230259
function updateCRDTDoc(
231260
objectType: ObjectType,
232261
objectId: ObjectID,
233262
changes: Partial< ObjectData >,
234-
origin: string
263+
origin: string,
264+
isSave: boolean = false
235265
): void {
236266
const entityId = getEntityId( objectType, objectId );
237267
const entityState = entityStates.get( entityId );
@@ -244,6 +274,13 @@ export function createSyncManager(): SyncManager {
244274

245275
ydoc.transact( () => {
246276
syncConfig.applyChangesToCRDTDoc( ydoc, changes );
277+
278+
if ( isSave ) {
279+
// Mark the document as saved in the record metadata map.
280+
const recordMeta = ydoc.getMap( RECORD_METADATA_KEY );
281+
recordMeta.set( SAVED_AT_KEY, Date.now() );
282+
recordMeta.set( SAVED_BY_KEY, ydoc.clientID );
283+
}
247284
}, origin );
248285
}
249286

packages/sync/src/test/manager.ts

Lines changed: 69 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';
@@ -540,6 +543,19 @@ describe( 'SyncManager', () => {
540543

541544
describe( 'update', () => {
542545
it( 'updates CRDT document with local changes', async () => {
546+
// Capture the Y.Doc from provider creator
547+
let capturedDoc: Y.Doc | null = null;
548+
mockProviderCreator.mockImplementation(
549+
async (
550+
_objectType: string,
551+
_objectId: string,
552+
ydoc: Y.Doc
553+
) => {
554+
capturedDoc = ydoc;
555+
return mockProviderResult;
556+
}
557+
);
558+
543559
const manager = createSyncManager();
544560

545561
await manager.load(
@@ -555,10 +571,17 @@ describe( 'SyncManager', () => {
555571
const changes = { title: 'Updated Title' };
556572
manager.update( 'post', '123', changes, 'local-editor' );
557573

574+
// Verify that applyChangesToCRDTDoc was called with the changes.
558575
expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith(
559576
expect.any( Y.Doc ),
560577
changes
561578
);
579+
580+
// Verify that the record metadata was not updated.
581+
const ydoc = capturedDoc as unknown as Y.Doc;
582+
const metadataMap = ydoc.getMap( RECORD_METADATA_MAP_KEY );
583+
expect( metadataMap.get( SAVED_AT_KEY ) ).toBeUndefined();
584+
expect( metadataMap.get( SAVED_BY_KEY ) ).toBeUndefined();
562585
} );
563586

564587
it( 'does not update when entity is not loaded', () => {
@@ -615,6 +638,52 @@ describe( 'SyncManager', () => {
615638
customOrigin
616639
);
617640
} );
641+
642+
it( 'updates the record metadata when the update is associated with a save', async () => {
643+
// Capture the Y.Doc from provider creator.
644+
let capturedDoc: Y.Doc | null = null;
645+
mockProviderCreator.mockImplementation(
646+
async (
647+
_objectType: string,
648+
_objectId: string,
649+
ydoc: Y.Doc
650+
) => {
651+
capturedDoc = ydoc;
652+
return mockProviderResult;
653+
}
654+
);
655+
656+
const manager = createSyncManager();
657+
658+
await manager.load(
659+
mockSyncConfig,
660+
'post',
661+
'123',
662+
mockRecord,
663+
mockHandlers
664+
);
665+
666+
jest.clearAllMocks();
667+
668+
const changes = { title: 'Updated Title' };
669+
const now = Date.now();
670+
671+
manager.update( 'post', '123', changes, 'local-editor', true );
672+
673+
// Verify that applyChangesToCRDTDoc was called with the changes.
674+
expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith(
675+
expect.any( Y.Doc ),
676+
changes
677+
);
678+
679+
// Verify that the record metadata was updated.
680+
const ydoc = capturedDoc as unknown as Y.Doc;
681+
const metadataMap = ydoc.getMap( RECORD_METADATA_MAP_KEY );
682+
expect( metadataMap.get( SAVED_AT_KEY ) ).toBeGreaterThanOrEqual(
683+
now
684+
);
685+
expect( metadataMap.get( SAVED_BY_KEY ) ).toBe( ydoc.clientID );
686+
} );
618687
} );
619688

620689
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

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

0 commit comments

Comments
 (0)