@epicenter/sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,776 @@
1
+ /**
2
+ * Protocol Unit Tests
3
+ *
4
+ * Tests y-websocket-compatible protocol helpers used by the server sync endpoint.
5
+ * Coverage focuses on message encoding/decoding, compatibility with y-protocols,
6
+ * and end-to-end synchronization behavior under common and edge conditions.
7
+ *
8
+ * Key behaviors:
9
+ * - Sync and awareness frames encode/decode with expected wire formats.
10
+ * - Handshake and incremental updates converge document state across peers.
11
+ */
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+ import {
15
+ Awareness,
16
+ applyAwarenessUpdate,
17
+ encodeAwarenessUpdate,
18
+ } from 'y-protocols/awareness';
19
+ import * as Y from 'yjs';
20
+ import { RpcError } from './rpc-errors';
21
+ import {
22
+ decodeMessageType,
23
+ decodeRpcMessage,
24
+ decodeSyncMessage,
25
+ decodeSyncStatus,
26
+ encodeAwareness,
27
+ encodeAwarenessStates,
28
+ encodeQueryAwareness,
29
+ encodeRpcRequest,
30
+ encodeRpcResponse,
31
+ encodeSyncStatus,
32
+ encodeSyncStep1,
33
+ encodeSyncStep2,
34
+ encodeSyncUpdate,
35
+ handleSyncPayload,
36
+ MESSAGE_TYPE,
37
+ RPC_TYPE,
38
+ SYNC_MESSAGE_TYPE,
39
+ } from './protocol';
40
+
41
+ // ============================================================================
42
+ // MESSAGE_TYPE Constants
43
+ // ============================================================================
44
+
45
+ describe('MESSAGE_TYPE constants', () => {
46
+ test('match y-websocket protocol values', () => {
47
+ // These values are defined by y-websocket and must not change
48
+ expect(MESSAGE_TYPE.SYNC).toBe(0);
49
+ expect(MESSAGE_TYPE.AWARENESS).toBe(1);
50
+ expect(MESSAGE_TYPE.AUTH).toBe(2);
51
+ expect(MESSAGE_TYPE.QUERY_AWARENESS).toBe(3);
52
+ });
53
+
54
+ test('SYNC_STATUS is 100 (custom extension for version tracking)', () => {
55
+ expect(MESSAGE_TYPE.SYNC_STATUS).toBe(100);
56
+ });
57
+ });
58
+
59
+ // ============================================================================
60
+ // SYNC_STATUS Encode/Decode Tests
61
+ // ============================================================================
62
+
63
+ describe('SYNC_STATUS encode/decode', () => {
64
+ test('encodeSyncStatus produces correct message type', () => {
65
+ const message = encodeSyncStatus(42);
66
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.SYNC_STATUS);
67
+ });
68
+
69
+ test('round-trip: encode then decode preserves localVersion', () => {
70
+ const version = 12345;
71
+ const encoded = encodeSyncStatus(version);
72
+ const decoded = decodeSyncStatus(encoded);
73
+ expect(decoded).toBe(version);
74
+ });
75
+
76
+ test('round-trip with version 0', () => {
77
+ const encoded = encodeSyncStatus(0);
78
+ const decoded = decodeSyncStatus(encoded);
79
+ expect(decoded).toBe(0);
80
+ });
81
+
82
+ test('round-trip with large version number', () => {
83
+ const version = 1_000_000;
84
+ const encoded = encodeSyncStatus(version);
85
+ const decoded = decodeSyncStatus(encoded);
86
+ expect(decoded).toBe(version);
87
+ });
88
+
89
+ test('decodeSyncStatus throws on non-SYNC_STATUS message', () => {
90
+ const doc = createDoc();
91
+ const syncMessage = encodeSyncStep1({ doc });
92
+ expect(() => decodeSyncStatus(syncMessage)).toThrow(
93
+ 'Expected SYNC_STATUS message (100), got 0',
94
+ );
95
+ });
96
+ });
97
+
98
+ // ============================================================================
99
+ // RPC Encode/Decode Tests
100
+ // ============================================================================
101
+
102
+ describe('RPC protocol', () => {
103
+ test('MESSAGE_TYPE.RPC is 101', () => {
104
+ expect(MESSAGE_TYPE.RPC).toBe(101);
105
+ });
106
+
107
+ test('RPC_TYPE constants', () => {
108
+ expect(RPC_TYPE.REQUEST).toBe(0);
109
+ expect(RPC_TYPE.RESPONSE).toBe(1);
110
+ });
111
+
112
+ test('round-trip: encode/decode RPC REQUEST', () => {
113
+ const encoded = encodeRpcRequest({
114
+ requestId: 42,
115
+ targetClientId: 100,
116
+ requesterClientId: 200,
117
+ action: 'tabs.close',
118
+ input: { tabIds: [1, 2, 3] },
119
+ });
120
+
121
+ expect(decodeMessageType(encoded)).toBe(MESSAGE_TYPE.RPC);
122
+
123
+ const decoded = decodeRpcMessage(encoded);
124
+ expect(decoded.type).toBe('request');
125
+ if (decoded.type === 'request') {
126
+ expect(decoded.requestId).toBe(42);
127
+ expect(decoded.targetClientId).toBe(100);
128
+ expect(decoded.requesterClientId).toBe(200);
129
+ expect(decoded.action).toBe('tabs.close');
130
+ expect(decoded.input).toEqual({ tabIds: [1, 2, 3] });
131
+ }
132
+ });
133
+
134
+ test('round-trip: encode/decode RPC RESPONSE', () => {
135
+ const encoded = encodeRpcResponse({
136
+ requestId: 42,
137
+ requesterClientId: 200,
138
+ result: { data: { closedCount: 3 }, error: null },
139
+ });
140
+
141
+ expect(decodeMessageType(encoded)).toBe(MESSAGE_TYPE.RPC);
142
+
143
+ const decoded = decodeRpcMessage(encoded);
144
+ expect(decoded.type).toBe('response');
145
+ if (decoded.type === 'response') {
146
+ expect(decoded.requestId).toBe(42);
147
+ expect(decoded.requesterClientId).toBe(200);
148
+ expect(decoded.result).toEqual({ data: { closedCount: 3 }, error: null });
149
+ }
150
+ });
151
+
152
+ test('REQUEST with null input', () => {
153
+ const encoded = encodeRpcRequest({
154
+ requestId: 0,
155
+ targetClientId: 50,
156
+ requesterClientId: 60,
157
+ action: 'devices.list',
158
+ });
159
+
160
+ const decoded = decodeRpcMessage(encoded);
161
+ expect(decoded.type).toBe('request');
162
+ if (decoded.type === 'request') {
163
+ expect(decoded.input).toBeNull();
164
+ }
165
+ });
166
+
167
+ test('RESPONSE with error', () => {
168
+ const encoded = encodeRpcResponse({
169
+ requestId: 7,
170
+ requesterClientId: 300,
171
+ result: RpcError.PeerOffline(),
172
+ });
173
+
174
+ const decoded = decodeRpcMessage(encoded);
175
+ expect(decoded.type).toBe('response');
176
+ if (decoded.type === 'response') {
177
+ expect(decoded.result.data).toBeNull();
178
+ expect(decoded.result.error).toMatchObject({
179
+ name: 'PeerOffline',
180
+ message: 'Target peer is not connected',
181
+ });
182
+ }
183
+ });
184
+
185
+ test('decodeRpcMessage discriminates REQUEST vs RESPONSE', () => {
186
+ const request = encodeRpcRequest({
187
+ requestId: 1,
188
+ targetClientId: 10,
189
+ requesterClientId: 20,
190
+ action: 'test',
191
+ });
192
+ const response = encodeRpcResponse({
193
+ requestId: 1,
194
+ requesterClientId: 20,
195
+ result: { data: 'ok', error: null },
196
+ });
197
+
198
+ expect(decodeRpcMessage(request).type).toBe('request');
199
+ expect(decodeRpcMessage(response).type).toBe('response');
200
+ });
201
+
202
+ test('decodeRpcMessage throws on non-RPC message', () => {
203
+ const syncMessage = encodeSyncStep1({ doc: new Y.Doc() });
204
+ expect(() => decodeRpcMessage(syncMessage)).toThrow(
205
+ 'Expected RPC message (101), got 0',
206
+ );
207
+ });
208
+ });
209
+
210
+ describe('SYNC_MESSAGE_TYPE constants', () => {
211
+ test('have expected numeric values', () => {
212
+ expect(SYNC_MESSAGE_TYPE.STEP1).toBe(0);
213
+ expect(SYNC_MESSAGE_TYPE.STEP2).toBe(1);
214
+ expect(SYNC_MESSAGE_TYPE.UPDATE).toBe(2);
215
+ });
216
+ });
217
+
218
+ // ============================================================================
219
+ // MESSAGE_SYNC Tests
220
+ // ============================================================================
221
+
222
+ describe('MESSAGE_SYNC', () => {
223
+ describe('encodeSyncStep1', () => {
224
+ test('encodes empty document', () => {
225
+ const doc = createDoc();
226
+ const message = encodeSyncStep1({ doc });
227
+ const decoded = decodeSyncMessage(message);
228
+
229
+ expect(decoded.type).toBe('step1');
230
+ });
231
+
232
+ test('encodes document with content', () => {
233
+ const doc = createDoc((d) => {
234
+ d.getMap('data').set('key', 'value');
235
+ });
236
+ const message = encodeSyncStep1({ doc });
237
+ const decoded = decodeSyncMessage(message);
238
+
239
+ expect(decoded.type).toBe('step1');
240
+ });
241
+
242
+ test('state vector changes after modification', () => {
243
+ const doc = createDoc();
244
+ const message1 = encodeSyncStep1({ doc });
245
+
246
+ doc.getMap('data').set('key', 'value');
247
+ const message2 = encodeSyncStep1({ doc });
248
+
249
+ // Different state vectors = different messages
250
+ expect(message1).not.toEqual(message2);
251
+ });
252
+
253
+ test('can be decoded by y-protocols', () => {
254
+ const doc = createDoc((d) => {
255
+ d.getMap('test').set('foo', 'bar');
256
+ });
257
+ const message = encodeSyncStep1({ doc });
258
+ const decoded = decodeSyncMessage(message);
259
+
260
+ expect(decoded.type).toBe('step1');
261
+ if (decoded.type === 'step1') {
262
+ expect(decoded.stateVector).toBeInstanceOf(Uint8Array);
263
+ expect(decoded.stateVector.length).toBeGreaterThan(0);
264
+ }
265
+ });
266
+ });
267
+
268
+ describe('encodeSyncStep2', () => {
269
+ test('encodes document diff', () => {
270
+ const doc = createDoc((d) => {
271
+ d.getMap('data').set('key', 'value');
272
+ });
273
+ const message = encodeSyncStep2({ doc });
274
+ const decoded = decodeSyncMessage(message);
275
+
276
+ expect(decoded.type).toBe('step2');
277
+ });
278
+
279
+ test('contains update data', () => {
280
+ const doc = createDoc((d) => {
281
+ d.getMap('data').set('key', 'value');
282
+ });
283
+ const message = encodeSyncStep2({ doc });
284
+ const decoded = decodeSyncMessage(message);
285
+
286
+ expect(decoded.type).toBe('step2');
287
+ if (decoded.type === 'step2') {
288
+ expect(decoded.update.length).toBeGreaterThan(0);
289
+ }
290
+ });
291
+ });
292
+
293
+ describe('encodeSyncUpdate', () => {
294
+ test('encodes incremental update', () => {
295
+ const doc = createDoc();
296
+ let capturedUpdate: Uint8Array | null = null;
297
+
298
+ doc.on('updateV2', (update: Uint8Array) => {
299
+ capturedUpdate = update;
300
+ });
301
+ doc.getMap('data').set('key', 'value');
302
+
303
+ expect(capturedUpdate).not.toBeNull();
304
+ if (!capturedUpdate) {
305
+ throw new Error('Expected captured update after document mutation');
306
+ }
307
+ const message = encodeSyncUpdate({ update: capturedUpdate });
308
+ const decoded = decodeSyncMessage(message);
309
+
310
+ expect(decoded.type).toBe('update');
311
+ });
312
+
313
+ test('handles empty update', () => {
314
+ const message = encodeSyncUpdate({ update: new Uint8Array(0) });
315
+
316
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.SYNC);
317
+ });
318
+ });
319
+
320
+ describe('handleSyncPayload', () => {
321
+ test('responds to sync step 1 with sync step 2', () => {
322
+ const serverDoc = createDoc((d) => {
323
+ d.getMap('data').set('server', 'content');
324
+ });
325
+ const clientDoc = createDoc();
326
+
327
+ const response = handleSyncPayload({
328
+ syncType: SYNC_MESSAGE_TYPE.STEP1,
329
+ payload: Y.encodeStateVector(clientDoc),
330
+ doc: serverDoc,
331
+ origin: 'test-client',
332
+ });
333
+
334
+ expect(response).not.toBeNull();
335
+ if (!response) {
336
+ throw new Error(
337
+ 'Expected sync step 2 response for sync step 1 payload',
338
+ );
339
+ }
340
+ const decoded = decodeSyncMessage(response);
341
+ expect(decoded.type).toBe('step2');
342
+ });
343
+
344
+ test('returns null for sync step 2 (no response needed)', () => {
345
+ const serverDoc = createDoc();
346
+ const clientDoc = createDoc((d) => {
347
+ d.getMap('data').set('client', 'content');
348
+ });
349
+
350
+ const response = handleSyncPayload({
351
+ syncType: SYNC_MESSAGE_TYPE.STEP2,
352
+ payload: Y.encodeStateAsUpdateV2(clientDoc),
353
+ doc: serverDoc,
354
+ origin: 'test-client',
355
+ });
356
+
357
+ expect(response).toBeNull();
358
+ });
359
+
360
+ test('returns null for sync update (no response needed)', () => {
361
+ const serverDoc = createDoc();
362
+ const updateV2 = Y.encodeStateAsUpdateV2(
363
+ createDoc((d) => d.getMap('data').set('key', 'value')),
364
+ );
365
+
366
+ const response = handleSyncPayload({
367
+ syncType: SYNC_MESSAGE_TYPE.UPDATE,
368
+ payload: updateV2,
369
+ doc: serverDoc,
370
+ origin: 'test-client',
371
+ });
372
+
373
+ expect(response).toBeNull();
374
+ });
375
+
376
+ test('applies update to document', () => {
377
+ const serverDoc = createDoc();
378
+ const clientDoc = createDoc((d) => {
379
+ d.getMap('data').set('key', 'value');
380
+ });
381
+
382
+ handleSyncPayload({
383
+ syncType: SYNC_MESSAGE_TYPE.UPDATE,
384
+ payload: Y.encodeStateAsUpdateV2(clientDoc),
385
+ doc: serverDoc,
386
+ origin: 'test-client',
387
+ });
388
+
389
+ expect(serverDoc.getMap('data').get('key')).toBe('value');
390
+ });
391
+ });
392
+ });
393
+
394
+ // ============================================================================
395
+ // MESSAGE_AWARENESS Tests
396
+ // ============================================================================
397
+
398
+ describe('MESSAGE_AWARENESS', () => {
399
+ describe('encodeAwarenessStates', () => {
400
+ test('encodes single client state', () => {
401
+ const doc = createDoc();
402
+ const awareness = new Awareness(doc);
403
+ awareness.setLocalState({ name: 'User 1', cursor: { x: 10, y: 20 } });
404
+
405
+ const message = encodeAwarenessStates({
406
+ awareness,
407
+ clients: [awareness.clientID],
408
+ });
409
+
410
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
411
+ });
412
+
413
+ test('encodes complex nested state', () => {
414
+ const doc = createDoc();
415
+ const awareness = new Awareness(doc);
416
+ awareness.setLocalState({
417
+ user: { name: 'Test', color: '#ff0000' },
418
+ cursor: { position: { x: 100, y: 200 }, selection: [0, 10] },
419
+ metadata: { version: 1, flags: ['active'] },
420
+ });
421
+
422
+ const message = encodeAwarenessStates({
423
+ awareness,
424
+ clients: [awareness.clientID],
425
+ });
426
+
427
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
428
+ });
429
+
430
+ test('handles special characters in state', () => {
431
+ const doc = createDoc();
432
+ const awareness = new Awareness(doc);
433
+ awareness.setLocalState({
434
+ name: 'User with "quotes" and \'apostrophes\'',
435
+ emoji: '🎉🚀',
436
+ newlines: 'line1\nline2',
437
+ });
438
+
439
+ const message = encodeAwarenessStates({
440
+ awareness,
441
+ clients: [awareness.clientID],
442
+ });
443
+
444
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
445
+ });
446
+
447
+ test('handles large awareness state', () => {
448
+ const doc = createDoc();
449
+ const awareness = new Awareness(doc);
450
+ awareness.setLocalState({
451
+ largeArray: Array(1000).fill('item'),
452
+ largeString: 'x'.repeat(10000),
453
+ });
454
+
455
+ const message = encodeAwarenessStates({
456
+ awareness,
457
+ clients: [awareness.clientID],
458
+ });
459
+
460
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
461
+ expect(message.length).toBeGreaterThan(10000);
462
+ });
463
+ });
464
+
465
+ describe('encodeAwareness', () => {
466
+ test('wraps raw awareness update', () => {
467
+ const doc = createDoc();
468
+ const awareness = new Awareness(doc);
469
+ awareness.setLocalState({ name: 'Test' });
470
+
471
+ const update = encodeAwarenessUpdate(awareness, [awareness.clientID]);
472
+ const message = encodeAwareness({ update });
473
+
474
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
475
+ });
476
+ });
477
+
478
+ describe('awareness protocol compatibility', () => {
479
+ test('encoded awareness can be applied to another instance', () => {
480
+ const doc1 = createDoc();
481
+ const awareness1 = new Awareness(doc1);
482
+ awareness1.setLocalState({ name: 'User 1' });
483
+
484
+ const doc2 = createDoc();
485
+ const awareness2 = new Awareness(doc2);
486
+
487
+ // Encode from awareness1
488
+ const update = encodeAwarenessUpdate(awareness1, [awareness1.clientID]);
489
+
490
+ // Apply to awareness2
491
+ applyAwarenessUpdate(awareness2, update, 'remote');
492
+
493
+ // awareness2 should have awareness1's state
494
+ const states = awareness2.getStates();
495
+ expect(states.has(awareness1.clientID)).toBe(true);
496
+ expect(states.get(awareness1.clientID)).toEqual({ name: 'User 1' });
497
+ });
498
+
499
+ test('null state removes client (disconnect)', () => {
500
+ const doc = createDoc();
501
+ const awareness = new Awareness(doc);
502
+ awareness.setLocalState({ name: 'User' });
503
+
504
+ expect(awareness.getStates().has(awareness.clientID)).toBe(true);
505
+
506
+ // Setting null removes the state
507
+ awareness.setLocalState(null);
508
+
509
+ expect(awareness.getStates().has(awareness.clientID)).toBe(false);
510
+ });
511
+ });
512
+ });
513
+
514
+ // ============================================================================
515
+ // MESSAGE_QUERY_AWARENESS Tests
516
+ // ============================================================================
517
+
518
+ describe('MESSAGE_QUERY_AWARENESS', () => {
519
+ test('query awareness message is single byte', () => {
520
+ const message = encodeQueryAwareness();
521
+
522
+ expect(message.length).toBe(1);
523
+ expect(message[0]).toBe(MESSAGE_TYPE.QUERY_AWARENESS);
524
+ });
525
+ });
526
+
527
+ // ============================================================================
528
+ // Decoder Tests
529
+ // ============================================================================
530
+
531
+ describe('decodeSyncMessage', () => {
532
+ test('decodes sync step 1 message', () => {
533
+ const doc = createDoc((d) => d.getMap('test').set('key', 'value'));
534
+ const encoded = encodeSyncStep1({ doc });
535
+ const decoded = decodeSyncMessage(encoded);
536
+
537
+ expect(decoded.type).toBe('step1');
538
+ if (decoded.type === 'step1') {
539
+ expect(decoded.stateVector).toBeInstanceOf(Uint8Array);
540
+ expect(decoded.stateVector.length).toBeGreaterThan(0);
541
+ }
542
+ });
543
+
544
+ test('decodes sync step 2 message', () => {
545
+ const doc = createDoc((d) => d.getMap('test').set('key', 'value'));
546
+ const encoded = encodeSyncStep2({ doc });
547
+ const decoded = decodeSyncMessage(encoded);
548
+
549
+ expect(decoded.type).toBe('step2');
550
+ if (decoded.type === 'step2') {
551
+ expect(decoded.update).toBeInstanceOf(Uint8Array);
552
+ expect(decoded.update.length).toBeGreaterThan(0);
553
+ }
554
+ });
555
+
556
+ test('decodes sync update message', () => {
557
+ const doc = createDoc();
558
+ let capturedUpdate: Uint8Array | null = null;
559
+ doc.on('updateV2', (update: Uint8Array) => {
560
+ capturedUpdate = update;
561
+ });
562
+ doc.getMap('test').set('key', 'value');
563
+
564
+ if (!capturedUpdate) {
565
+ throw new Error('Expected captured update after document mutation');
566
+ }
567
+ const encoded = encodeSyncUpdate({ update: capturedUpdate });
568
+ const decoded = decodeSyncMessage(encoded);
569
+
570
+ expect(decoded.type).toBe('update');
571
+ if (decoded.type === 'update') {
572
+ expect(decoded.update).toBeInstanceOf(Uint8Array);
573
+ }
574
+ });
575
+
576
+ test('throws on non-SYNC message type', () => {
577
+ const doc = createDoc();
578
+ const awareness = new Awareness(doc);
579
+ awareness.setLocalState({ name: 'Test' });
580
+ const awarenessMessage = encodeAwarenessStates({
581
+ awareness,
582
+ clients: [awareness.clientID],
583
+ });
584
+
585
+ expect(() => decodeSyncMessage(awarenessMessage)).toThrow(
586
+ 'Expected SYNC message (0), got 1',
587
+ );
588
+ });
589
+
590
+ test('roundtrip: encode then decode preserves data', () => {
591
+ const doc = createDoc((d) => {
592
+ d.getMap('users').set('alice', { name: 'Alice', age: 30 });
593
+ d.getArray('items').push(['item1', 'item2']);
594
+ });
595
+
596
+ // Test step 1 roundtrip
597
+ const step1 = encodeSyncStep1({ doc });
598
+ const decodedStep1 = decodeSyncMessage(step1);
599
+ expect(decodedStep1.type).toBe('step1');
600
+
601
+ // Test step 2 roundtrip
602
+ const step2 = encodeSyncStep2({ doc });
603
+ const decodedStep2 = decodeSyncMessage(step2);
604
+ expect(decodedStep2.type).toBe('step2');
605
+ });
606
+ });
607
+
608
+ describe('decodeMessageType', () => {
609
+ test('decodes SYNC message type', () => {
610
+ const doc = createDoc();
611
+ const message = encodeSyncStep1({ doc });
612
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.SYNC);
613
+ });
614
+
615
+ test('decodes AWARENESS message type', () => {
616
+ const doc = createDoc();
617
+ const awareness = new Awareness(doc);
618
+ awareness.setLocalState({ name: 'Test' });
619
+ const message = encodeAwarenessStates({
620
+ awareness,
621
+ clients: [awareness.clientID],
622
+ });
623
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
624
+ });
625
+
626
+ test('decodes QUERY_AWARENESS message type', () => {
627
+ const message = encodeQueryAwareness();
628
+ expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.QUERY_AWARENESS);
629
+ });
630
+ });
631
+
632
+ // ============================================================================
633
+ // Full Sync Protocol Tests
634
+ // ============================================================================
635
+
636
+ describe('full sync protocol', () => {
637
+ test('complete handshake syncs server content to client', () => {
638
+ const serverDoc = createDoc((d) => {
639
+ d.getMap('notes').set('note1', 'Hello from server');
640
+ });
641
+ const clientDoc = createDoc();
642
+
643
+ // Server handles client's state vector and responds with sync step 2 (V2 update)
644
+ const serverResponse = handleSyncPayload({
645
+ syncType: SYNC_MESSAGE_TYPE.STEP1,
646
+ payload: Y.encodeStateVector(clientDoc),
647
+ doc: serverDoc,
648
+ origin: 'client',
649
+ });
650
+
651
+ expect(serverResponse).not.toBeNull();
652
+ if (!serverResponse) {
653
+ throw new Error('Expected server sync response during handshake');
654
+ }
655
+
656
+ // Client applies server's V2 response
657
+ const decoded = decodeSyncMessage(serverResponse);
658
+ expect(decoded.type).toBe('step2');
659
+ if (decoded.type === 'step2') {
660
+ Y.applyUpdateV2(clientDoc, decoded.update, 'server');
661
+ }
662
+
663
+ // Client should have server's content
664
+ expect(clientDoc.getMap('notes').get('note1')).toBe('Hello from server');
665
+ });
666
+
667
+ test('bidirectional sync merges both documents', () => {
668
+ const doc1 = createDoc((d) => d.getMap('data').set('from1', 'value1'));
669
+ const doc2 = createDoc((d) => d.getMap('data').set('from2', 'value2'));
670
+
671
+ // Full bidirectional sync using Yjs V2 pattern
672
+ syncDocs(doc1, doc2);
673
+
674
+ expect(doc1.getMap('data').get('from1')).toBe('value1');
675
+ expect(doc1.getMap('data').get('from2')).toBe('value2');
676
+ expect(doc2.getMap('data').get('from1')).toBe('value1');
677
+ expect(doc2.getMap('data').get('from2')).toBe('value2');
678
+ });
679
+
680
+ test('incremental updates are applied correctly', () => {
681
+ const doc1 = createDoc();
682
+ const doc2 = createDoc();
683
+
684
+ // Capture V2 updates from doc1
685
+ const updates: Uint8Array[] = [];
686
+ doc1.on('updateV2', (update: Uint8Array) => {
687
+ updates.push(update);
688
+ });
689
+
690
+ // Make changes
691
+ doc1.getMap('data').set('key1', 'value1');
692
+ doc1.getMap('data').set('key2', 'value2');
693
+ doc1.getArray('list').push(['item1', 'item2']);
694
+
695
+ // Apply V2 updates to doc2
696
+ for (const update of updates) {
697
+ Y.applyUpdateV2(doc2, update);
698
+ }
699
+
700
+ expect(doc2.getMap('data').get('key1')).toBe('value1');
701
+ expect(doc2.getMap('data').get('key2')).toBe('value2');
702
+ expect(doc2.getArray('list').toArray()).toEqual(['item1', 'item2']);
703
+ });
704
+ });
705
+
706
+ // ============================================================================
707
+ // Edge Cases
708
+ // ============================================================================
709
+
710
+ describe('edge cases', () => {
711
+ test('handles large document (1000+ operations)', () => {
712
+ const doc = createDoc((d) => {
713
+ const arr = d.getArray<string>('items');
714
+ for (let i = 0; i < 1000; i++) {
715
+ arr.push([`item-${i}`]);
716
+ }
717
+ });
718
+
719
+ // Sync step 1 contains state vector (compact), not full content
720
+ const syncStep1 = encodeSyncStep1({ doc });
721
+ expect(decodeSyncMessage(syncStep1).type).toBe('step1');
722
+
723
+ // Sync step 2 contains actual document content
724
+ const syncStep2 = encodeSyncStep2({ doc });
725
+ expect(decodeSyncMessage(syncStep2).type).toBe('step2');
726
+ expect(syncStep2.length).toBeGreaterThan(1000);
727
+ });
728
+
729
+ test('handles concurrent modifications (CRDT merge)', () => {
730
+ const doc1 = createDoc();
731
+ const doc2 = createDoc();
732
+
733
+ // Both modify same key concurrently
734
+ doc1.getMap('data').set('key', 'value1');
735
+ doc2.getMap('data').set('key', 'value2');
736
+
737
+ // Sync should resolve deterministically
738
+ syncDocs(doc1, doc2);
739
+
740
+ // Both should have same value (CRDT resolution)
741
+ const val1 = doc1.getMap('data').get('key');
742
+ const val2 = doc2.getMap('data').get('key');
743
+ expect(val1).toBe(val2);
744
+ });
745
+
746
+ test('empty document produces valid sync step 1', () => {
747
+ const doc = createDoc();
748
+ const message = encodeSyncStep1({ doc });
749
+ const decoded = decodeSyncMessage(message);
750
+
751
+ expect(decoded.type).toBe('step1');
752
+ if (decoded.type === 'step1') {
753
+ // Even empty docs have a state vector (contains clientID info)
754
+ expect(decoded.stateVector).toBeInstanceOf(Uint8Array);
755
+ }
756
+ });
757
+ });
758
+
759
+ // ============================================================================
760
+ // Test Utilities (hoisted - placed at bottom for readability)
761
+ // ============================================================================
762
+
763
+ /** Create a Y.Doc with optional initial content */
764
+ function createDoc(init?: (doc: Y.Doc) => void): Y.Doc {
765
+ const doc = new Y.Doc();
766
+ if (init) init(doc);
767
+ return doc;
768
+ }
769
+
770
+ /** Sync two documents bidirectionally (standard Yjs test pattern, V2) */
771
+ function syncDocs(doc1: Y.Doc, doc2: Y.Doc): void {
772
+ const state1 = Y.encodeStateAsUpdateV2(doc1);
773
+ const state2 = Y.encodeStateAsUpdateV2(doc2);
774
+ Y.applyUpdateV2(doc1, state2);
775
+ Y.applyUpdateV2(doc2, state1);
776
+ }