Skip to content

Commit 8235970

Browse files
Real-time collaboration: Fix persisted document meta and add tests (#72853)
* Real-time collaboration: Fix persisted document meta and add tests * Never sync a floating date * Explicit type on assignment Co-authored-by: Max Schmeling <max@maxschmeling.com> --------- Co-authored-by: Max Schmeling <max@maxschmeling.com>
1 parent bb128f8 commit 8235970

File tree

4 files changed

+244
-10
lines changed

4 files changed

+244
-10
lines changed

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,16 +310,16 @@ export function getPostChangesFromCRDTDoc(
310310
}
311311

312312
case 'date': {
313-
// Do not sync an empty date if our current value is a "floating" date.
314-
// Borrowing logic from the isEditedPostDateFloating selector.
313+
// Do not overwrite a "floating" date. Borrowing logic from the
314+
// isEditedPostDateFloating selector.
315315
const currentDateIsFloating =
316316
[ 'draft', 'auto-draft', 'pending' ].includes(
317317
ymap.get( 'status' ) as string
318318
) &&
319319
( null === currentValue ||
320320
editedRecord.modified === currentValue );
321321

322-
if ( ! newValue && currentDateIsFloating ) {
322+
if ( currentDateIsFloating ) {
323323
return false;
324324
}
325325

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ describe( 'crdt', () => {
278278
expect( changes ).not.toHaveProperty( 'status' );
279279
} );
280280

281-
it( 'does not sync empty date for floating dates', () => {
281+
it( 'does not overwrite null floating date', () => {
282282
map.set( 'status', 'draft' );
283283
map.set( 'date', '' );
284284

@@ -288,13 +288,52 @@ describe( 'crdt', () => {
288288
modified: '2025-01-01',
289289
} as unknown as Post;
290290

291-
const changes = getPostChangesFromCRDTDoc(
291+
const changesWithEmptyDate = getPostChangesFromCRDTDoc(
292+
doc,
293+
editedRecord,
294+
mockPostType
295+
);
296+
297+
expect( changesWithEmptyDate ).not.toHaveProperty( 'date' );
298+
299+
map.set( 'date', '2025-01-02' );
300+
301+
const changesWithDefinedDate = getPostChangesFromCRDTDoc(
302+
doc,
303+
editedRecord,
304+
mockPostType
305+
);
306+
307+
expect( changesWithDefinedDate ).not.toHaveProperty( 'date' );
308+
} );
309+
310+
it( 'does not overwrite defined floating date', () => {
311+
map.set( 'status', 'draft' );
312+
map.set( 'date', '' );
313+
314+
const editedRecord = {
315+
status: 'draft',
316+
date: '2025-01-01', // matches modified
317+
modified: '2025-01-01',
318+
} as unknown as Post;
319+
320+
const changesWithEmptyDate = getPostChangesFromCRDTDoc(
321+
doc,
322+
editedRecord,
323+
mockPostType
324+
);
325+
326+
expect( changesWithEmptyDate ).not.toHaveProperty( 'date' );
327+
328+
map.set( 'date', '2025-01-02' );
329+
330+
const changesWithDefinedDate = getPostChangesFromCRDTDoc(
292331
doc,
293332
editedRecord,
294333
mockPostType
295334
);
296335

297-
expect( changes ).not.toHaveProperty( 'date' );
336+
expect( changesWithDefinedDate ).not.toHaveProperty( 'date' );
298337
} );
299338

300339
it( 'includes blocks in changes', () => {

packages/sync/src/test/utils.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import * as Y from 'yjs';
5+
import * as buffer from 'lib0/buffer';
6+
import { describe, expect, it, beforeEach } from '@jest/globals';
7+
8+
/**
9+
* Internal dependencies
10+
*/
11+
import { createYjsDoc, serializeCrdtDoc, deserializeCrdtDoc } from '../utils';
12+
import {
13+
CRDT_DOC_META_PERSISTENCE_KEY,
14+
CRDT_DOC_VERSION,
15+
CRDT_STATE_MAP_KEY,
16+
CRDT_STATE_VERSION_KEY,
17+
} from '../config';
18+
19+
describe( 'utils', () => {
20+
describe( 'createYjsDoc', () => {
21+
it( 'creates a Y.Doc with metadata', () => {
22+
const documentMeta = {
23+
userId: '123',
24+
entityType: 'post',
25+
};
26+
27+
const ydoc = createYjsDoc( documentMeta );
28+
29+
expect( ydoc ).toBeInstanceOf( Y.Doc );
30+
expect( ydoc.meta ).toBeDefined();
31+
expect( ydoc.meta?.get( 'userId' ) ).toBe( '123' );
32+
expect( ydoc.meta?.get( 'entityType' ) ).toBe( 'post' );
33+
} );
34+
35+
it( 'creates a Y.Doc with empty metadata', () => {
36+
const ydoc = createYjsDoc();
37+
38+
expect( ydoc ).toBeInstanceOf( Y.Doc );
39+
expect( ydoc.meta ).toBeDefined();
40+
expect( ydoc.meta?.size ).toBe( 0 );
41+
} );
42+
43+
it( 'sets the CRDT document version in the state map', () => {
44+
const ydoc = createYjsDoc( {} );
45+
const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY );
46+
47+
expect( stateMap.get( CRDT_STATE_VERSION_KEY ) ).toBe(
48+
CRDT_DOC_VERSION
49+
);
50+
} );
51+
} );
52+
53+
describe( 'serializeCrdtDoc', () => {
54+
let testDoc: Y.Doc;
55+
56+
beforeEach( () => {
57+
testDoc = createYjsDoc();
58+
} );
59+
60+
it( 'serializes a CRDT doc with data', () => {
61+
const ymap = testDoc.getMap( 'testMap' );
62+
ymap.set( 'title', 'Test Title' );
63+
ymap.set( 'content', 'Test Content' );
64+
65+
const serialized = serializeCrdtDoc( testDoc );
66+
const parsed = JSON.parse( serialized );
67+
68+
expect( parsed ).toHaveProperty( 'document' );
69+
expect( typeof parsed.document ).toBe( 'string' );
70+
expect( parsed.document.length ).toBeGreaterThan( 0 );
71+
} );
72+
} );
73+
74+
describe( 'deserializeCrdtDoc', () => {
75+
let originalDoc: Y.Doc;
76+
let serialized: string;
77+
78+
beforeEach( () => {
79+
originalDoc = createYjsDoc();
80+
const ymap = originalDoc.getMap( 'testMap' );
81+
ymap.set( 'title', 'Test Title' );
82+
ymap.set( 'count', 42 );
83+
serialized = serializeCrdtDoc( originalDoc );
84+
} );
85+
86+
it( 'restores the data from the serialized doc', () => {
87+
const deserialized = deserializeCrdtDoc( serialized );
88+
89+
expect( deserialized ).toBeInstanceOf( Y.Doc );
90+
91+
const ymap = deserialized!.getMap( 'testMap' );
92+
expect( ymap.get( 'title' ) ).toBe( 'Test Title' );
93+
expect( ymap.get( 'count' ) ).toBe( 42 );
94+
} );
95+
96+
it( 'marks the document as from persistence', () => {
97+
const deserialized = deserializeCrdtDoc( serialized );
98+
99+
expect( deserialized ).toBeInstanceOf( Y.Doc );
100+
expect( deserialized!.meta ).toBeDefined();
101+
expect(
102+
deserialized!.meta?.get( CRDT_DOC_META_PERSISTENCE_KEY )
103+
).toBe( true );
104+
} );
105+
106+
it( 'assigns a random client ID to the deserialized document', () => {
107+
const deserialized = deserializeCrdtDoc( serialized );
108+
109+
expect( deserialized ).toBeInstanceOf( Y.Doc );
110+
111+
// Client ID should not match the original.
112+
expect( deserialized!.clientID ).not.toBe( originalDoc.clientID );
113+
} );
114+
115+
it( 'returns null for invalid JSON', () => {
116+
const result = deserializeCrdtDoc( 'invalid json {' );
117+
118+
expect( result ).toBeNull();
119+
} );
120+
121+
it( 'returns null for JSON missing document property', () => {
122+
const invalidSerialized = JSON.stringify( { data: 'test' } );
123+
const result = deserializeCrdtDoc( invalidSerialized );
124+
125+
expect( result ).toBeNull();
126+
} );
127+
128+
it( 'returns null for corrupted CRDT data', () => {
129+
const corruptedSerialized = JSON.stringify( {
130+
document: buffer.toBase64(
131+
new Uint8Array( [ 1, 2, 3, 4, 5 ] )
132+
),
133+
} );
134+
const result = deserializeCrdtDoc( corruptedSerialized );
135+
136+
expect( result ).toBeNull();
137+
} );
138+
139+
it( 'preserves the CRDT state version', () => {
140+
const deserialized = deserializeCrdtDoc( serialized );
141+
142+
expect( deserialized ).toBeInstanceOf( Y.Doc );
143+
144+
const stateMap = deserialized!.getMap( CRDT_STATE_MAP_KEY );
145+
expect( stateMap.get( CRDT_STATE_VERSION_KEY ) ).toBe(
146+
CRDT_DOC_VERSION
147+
);
148+
} );
149+
} );
150+
151+
describe( 'serialization round-trip', () => {
152+
it( 'maintains data integrity through serialize/deserialize cycle', () => {
153+
const originalDoc = createYjsDoc( {} );
154+
const ymap = originalDoc.getMap( 'data' );
155+
ymap.set( 'string', 'value' );
156+
ymap.set( 'number', 123 );
157+
ymap.set( 'boolean', true );
158+
159+
const serialized = serializeCrdtDoc( originalDoc );
160+
const deserialized = deserializeCrdtDoc( serialized );
161+
162+
expect( deserialized ).not.toBeNull();
163+
164+
const deserializedMap = deserialized!.getMap( 'data' );
165+
expect( deserializedMap.get( 'string' ) ).toBe( 'value' );
166+
expect( deserializedMap.get( 'number' ) ).toBe( 123 );
167+
expect( deserializedMap.get( 'boolean' ) ).toBe( true );
168+
} );
169+
170+
it( 'handles multiple serialize/deserialize cycles', () => {
171+
const doc = createYjsDoc();
172+
doc.getMap( 'test' ).set( 'value', 'original' );
173+
174+
// Cycle 1
175+
let serialized = serializeCrdtDoc( doc );
176+
let deserialized = deserializeCrdtDoc( serialized );
177+
expect( deserialized ).not.toBeNull();
178+
179+
// Cycle 2
180+
serialized = serializeCrdtDoc( deserialized! );
181+
deserialized = deserializeCrdtDoc( serialized );
182+
expect( deserialized ).not.toBeNull();
183+
184+
// Verify data is still intact
185+
expect( deserialized!.getMap( 'test' ).get( 'value' ) ).toBe(
186+
'original'
187+
);
188+
} );
189+
} );
190+
} );

packages/sync/src/utils.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import {
1515
} from './config';
1616
import type { CRDTDoc } from './types';
1717

18-
export function createYjsDoc( documentMeta: Record< string, unknown > ): Y.Doc {
18+
interface DocumentMeta {
19+
[ key: string ]: boolean | number | string;
20+
}
21+
22+
export function createYjsDoc( documentMeta: DocumentMeta = {} ): Y.Doc {
1923
// Meta is not synced and does not get persisted with the document.
2024
const metaMap = new Map< string, unknown >(
2125
Object.entries( documentMeta )
@@ -42,11 +46,12 @@ export function deserializeCrdtDoc(
4246
const { document } = JSON.parse( serializedCrdtDoc );
4347

4448
// Mark this document as from persistence.
45-
const docMetaMap = new Map< string, boolean >();
46-
docMetaMap.set( CRDT_DOC_META_PERSISTENCE_KEY, true );
49+
const docMeta: DocumentMeta = {
50+
[ CRDT_DOC_META_PERSISTENCE_KEY ]: true,
51+
};
4752

4853
// Apply the document as an update against a new (temporary) Y.Doc.
49-
const ydoc = createYjsDoc( { meta: docMetaMap } );
54+
const ydoc = createYjsDoc( docMeta );
5055
const yupdate = buffer.fromBase64( document );
5156
Y.applyUpdateV2( ydoc, yupdate );
5257

0 commit comments

Comments
 (0)