From 1c04c12cc19e584a93318764966dea493b10ccb0 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 12 Dec 2025 16:28:11 -0700 Subject: [PATCH 1/2] Real-time collaboration: Fix persisted document meta and add tests --- packages/sync/src/test/utils.ts | 190 ++++++++++++++++++++++++++++++++ packages/sync/src/utils.ts | 13 ++- 2 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 packages/sync/src/test/utils.ts diff --git a/packages/sync/src/test/utils.ts b/packages/sync/src/test/utils.ts new file mode 100644 index 00000000000000..886fcdba994840 --- /dev/null +++ b/packages/sync/src/test/utils.ts @@ -0,0 +1,190 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; +import * as buffer from 'lib0/buffer'; +import { describe, expect, it, beforeEach } from '@jest/globals'; + +/** + * Internal dependencies + */ +import { createYjsDoc, serializeCrdtDoc, deserializeCrdtDoc } from '../utils'; +import { + CRDT_DOC_META_PERSISTENCE_KEY, + CRDT_DOC_VERSION, + CRDT_STATE_MAP_KEY, + CRDT_STATE_VERSION_KEY, +} from '../config'; + +describe( 'utils', () => { + describe( 'createYjsDoc', () => { + it( 'creates a Y.Doc with metadata', () => { + const documentMeta = { + userId: '123', + entityType: 'post', + }; + + const ydoc = createYjsDoc( documentMeta ); + + expect( ydoc ).toBeInstanceOf( Y.Doc ); + expect( ydoc.meta ).toBeDefined(); + expect( ydoc.meta?.get( 'userId' ) ).toBe( '123' ); + expect( ydoc.meta?.get( 'entityType' ) ).toBe( 'post' ); + } ); + + it( 'creates a Y.Doc with empty metadata', () => { + const ydoc = createYjsDoc(); + + expect( ydoc ).toBeInstanceOf( Y.Doc ); + expect( ydoc.meta ).toBeDefined(); + expect( ydoc.meta?.size ).toBe( 0 ); + } ); + + it( 'sets the CRDT document version in the state map', () => { + const ydoc = createYjsDoc( {} ); + const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); + + expect( stateMap.get( CRDT_STATE_VERSION_KEY ) ).toBe( + CRDT_DOC_VERSION + ); + } ); + } ); + + describe( 'serializeCrdtDoc', () => { + let testDoc: Y.Doc; + + beforeEach( () => { + testDoc = createYjsDoc(); + } ); + + it( 'serializes a CRDT doc with data', () => { + const ymap = testDoc.getMap( 'testMap' ); + ymap.set( 'title', 'Test Title' ); + ymap.set( 'content', 'Test Content' ); + + const serialized = serializeCrdtDoc( testDoc ); + const parsed = JSON.parse( serialized ); + + expect( parsed ).toHaveProperty( 'document' ); + expect( typeof parsed.document ).toBe( 'string' ); + expect( parsed.document.length ).toBeGreaterThan( 0 ); + } ); + } ); + + describe( 'deserializeCrdtDoc', () => { + let originalDoc: Y.Doc; + let serialized: string; + + beforeEach( () => { + originalDoc = createYjsDoc(); + const ymap = originalDoc.getMap( 'testMap' ); + ymap.set( 'title', 'Test Title' ); + ymap.set( 'count', 42 ); + serialized = serializeCrdtDoc( originalDoc ); + } ); + + it( 'restores the data from the serialized doc', () => { + const deserialized = deserializeCrdtDoc( serialized ); + + expect( deserialized ).toBeInstanceOf( Y.Doc ); + + const ymap = deserialized!.getMap( 'testMap' ); + expect( ymap.get( 'title' ) ).toBe( 'Test Title' ); + expect( ymap.get( 'count' ) ).toBe( 42 ); + } ); + + it( 'marks the document as from persistence', () => { + const deserialized = deserializeCrdtDoc( serialized ); + + expect( deserialized ).toBeInstanceOf( Y.Doc ); + expect( deserialized!.meta ).toBeDefined(); + expect( + deserialized!.meta?.get( CRDT_DOC_META_PERSISTENCE_KEY ) + ).toBe( true ); + } ); + + it( 'assigns a random client ID to the deserialized document', () => { + const deserialized = deserializeCrdtDoc( serialized ); + + expect( deserialized ).toBeInstanceOf( Y.Doc ); + + // Client ID should not match the original. + expect( deserialized!.clientID ).not.toBe( originalDoc.clientID ); + } ); + + it( 'returns null for invalid JSON', () => { + const result = deserializeCrdtDoc( 'invalid json {' ); + + expect( result ).toBeNull(); + } ); + + it( 'returns null for JSON missing document property', () => { + const invalidSerialized = JSON.stringify( { data: 'test' } ); + const result = deserializeCrdtDoc( invalidSerialized ); + + expect( result ).toBeNull(); + } ); + + it( 'returns null for corrupted CRDT data', () => { + const corruptedSerialized = JSON.stringify( { + document: buffer.toBase64( + new Uint8Array( [ 1, 2, 3, 4, 5 ] ) + ), + } ); + const result = deserializeCrdtDoc( corruptedSerialized ); + + expect( result ).toBeNull(); + } ); + + it( 'preserves the CRDT state version', () => { + const deserialized = deserializeCrdtDoc( serialized ); + + expect( deserialized ).toBeInstanceOf( Y.Doc ); + + const stateMap = deserialized!.getMap( CRDT_STATE_MAP_KEY ); + expect( stateMap.get( CRDT_STATE_VERSION_KEY ) ).toBe( + CRDT_DOC_VERSION + ); + } ); + } ); + + describe( 'serialization round-trip', () => { + it( 'maintains data integrity through serialize/deserialize cycle', () => { + const originalDoc = createYjsDoc( {} ); + const ymap = originalDoc.getMap( 'data' ); + ymap.set( 'string', 'value' ); + ymap.set( 'number', 123 ); + ymap.set( 'boolean', true ); + + const serialized = serializeCrdtDoc( originalDoc ); + const deserialized = deserializeCrdtDoc( serialized ); + + expect( deserialized ).not.toBeNull(); + + const deserializedMap = deserialized!.getMap( 'data' ); + expect( deserializedMap.get( 'string' ) ).toBe( 'value' ); + expect( deserializedMap.get( 'number' ) ).toBe( 123 ); + expect( deserializedMap.get( 'boolean' ) ).toBe( true ); + } ); + + it( 'handles multiple serialize/deserialize cycles', () => { + const doc = createYjsDoc(); + doc.getMap( 'test' ).set( 'value', 'original' ); + + // Cycle 1 + let serialized = serializeCrdtDoc( doc ); + let deserialized = deserializeCrdtDoc( serialized ); + expect( deserialized ).not.toBeNull(); + + // Cycle 2 + serialized = serializeCrdtDoc( deserialized! ); + deserialized = deserializeCrdtDoc( serialized ); + expect( deserialized ).not.toBeNull(); + + // Verify data is still intact + expect( deserialized!.getMap( 'test' ).get( 'value' ) ).toBe( + 'original' + ); + } ); + } ); +} ); diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts index 29edd79acbb8ee..8cda0b0e649f00 100644 --- a/packages/sync/src/utils.ts +++ b/packages/sync/src/utils.ts @@ -15,7 +15,11 @@ import { } from './config'; import type { CRDTDoc } from './types'; -export function createYjsDoc( documentMeta: Record< string, unknown > ): Y.Doc { +interface DocumentMeta { + [ key: string ]: boolean | number | string; +} + +export function createYjsDoc( documentMeta: DocumentMeta = {} ): Y.Doc { // Meta is not synced and does not get persisted with the document. const metaMap = new Map< string, unknown >( Object.entries( documentMeta ) @@ -42,11 +46,12 @@ export function deserializeCrdtDoc( const { document } = JSON.parse( serializedCrdtDoc ); // Mark this document as from persistence. - const docMetaMap = new Map< string, boolean >(); - docMetaMap.set( CRDT_DOC_META_PERSISTENCE_KEY, true ); + const docMeta: DocumentMeta = { + [ CRDT_DOC_META_PERSISTENCE_KEY ]: true, + }; // Apply the document as an update against a new (temporary) Y.Doc. - const ydoc = createYjsDoc( { meta: docMetaMap } ); + const ydoc = createYjsDoc( docMeta ); const yupdate = buffer.fromBase64( document ); Y.applyUpdateV2( ydoc, yupdate ); From 2beb2f49b1df24b669561e41a147f77130879335 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 15 Dec 2025 10:15:19 -0700 Subject: [PATCH 2/2] Add clarifying comment and simplify type --- packages/sync/src/utils.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts index 8cda0b0e649f00..0f2260237ce775 100644 --- a/packages/sync/src/utils.ts +++ b/packages/sync/src/utils.ts @@ -15,13 +15,16 @@ import { } from './config'; import type { CRDTDoc } from './types'; -interface DocumentMeta { - [ key: string ]: boolean | number | string; -} +// An object representation of CRDT document metadata. +type DocumentMeta = Record< string, DocumentMetaValue >; +type DocumentMetaValue = boolean | number | string; export function createYjsDoc( documentMeta: DocumentMeta = {} ): Y.Doc { - // Meta is not synced and does not get persisted with the document. - const metaMap = new Map< string, unknown >( + // Convert the object representation of CRDT document metadata to a map. + // Document metadata is passed to the Y.Doc constructor and stored in its + // `meta` property. It is not synced to peers or persisted with the document. + // It is just a place to store transient information about this doc instance. + const metaMap = new Map< string, DocumentMetaValue >( Object.entries( documentMeta ) );