@epicenter/sync 0.1.0 → 0.3.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.
@@ -1,212 +1,29 @@
1
1
  /**
2
2
  * Protocol Unit Tests
3
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.
4
+ * Tests the Yjs sync protocol helpers used by the server sync endpoint:
5
+ * the frame encoders (`encodeSyncStep1` / `encodeSyncUpdate`), the
6
+ * dispatcher (`handleSyncPayload`), and end-to-end synchronization.
7
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.
8
+ * A binary frame is `[sync sub-type varint][payload]`; `readFrame` below
9
+ * is the test-side decoder (production decodes inline at the transport).
11
10
  */
12
11
 
13
12
  import { describe, expect, test } from 'bun:test';
14
- import {
15
- Awareness,
16
- applyAwarenessUpdate,
17
- encodeAwarenessUpdate,
18
- } from 'y-protocols/awareness';
13
+ import * as decoding from 'lib0/decoding';
19
14
  import * as Y from 'yjs';
20
- import { RpcError } from './rpc-errors';
21
15
  import {
22
- decodeMessageType,
23
- decodeRpcMessage,
24
- decodeSyncMessage,
25
- decodeSyncStatus,
26
- encodeAwareness,
27
- encodeAwarenessStates,
28
- encodeQueryAwareness,
29
- encodeRpcRequest,
30
- encodeRpcResponse,
31
- encodeSyncStatus,
32
16
  encodeSyncStep1,
33
- encodeSyncStep2,
34
17
  encodeSyncUpdate,
35
18
  handleSyncPayload,
36
- MESSAGE_TYPE,
37
- RPC_TYPE,
38
19
  SYNC_MESSAGE_TYPE,
20
+ type SyncMessageType,
39
21
  } from './protocol';
40
22
 
41
23
  // ============================================================================
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
24
+ // SYNC_MESSAGE_TYPE Constants
100
25
  // ============================================================================
101
26
 
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
27
  describe('SYNC_MESSAGE_TYPE constants', () => {
211
28
  test('have expected numeric values', () => {
212
29
  expect(SYNC_MESSAGE_TYPE.STEP1).toBe(0);
@@ -216,416 +33,124 @@ describe('SYNC_MESSAGE_TYPE constants', () => {
216
33
  });
217
34
 
218
35
  // ============================================================================
219
- // MESSAGE_SYNC Tests
36
+ // Frame Encoders
220
37
  // ============================================================================
221
38
 
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);
39
+ describe('encodeSyncStep1', () => {
40
+ test('produces a STEP1 frame carrying the document state vector', () => {
41
+ const doc = createDoc((d) => d.getMap('test').set('foo', 'bar'));
42
+ const { syncType, payload } = readFrame(encodeSyncStep1({ doc }));
285
43
 
286
- expect(decoded.type).toBe('step2');
287
- if (decoded.type === 'step2') {
288
- expect(decoded.update.length).toBeGreaterThan(0);
289
- }
290
- });
44
+ expect(syncType).toBe(SYNC_MESSAGE_TYPE.STEP1);
45
+ expect(payload).toEqual(Y.encodeStateVector(doc));
291
46
  });
47
+ });
292
48
 
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');
49
+ describe('encodeSyncUpdate', () => {
50
+ test('produces an UPDATE frame carrying the update bytes', () => {
51
+ const doc = createDoc();
52
+ let captured: Uint8Array | null = null;
53
+ doc.on('updateV2', (update: Uint8Array) => {
54
+ captured = update;
311
55
  });
56
+ doc.getMap('data').set('key', 'value');
312
57
 
313
- test('handles empty update', () => {
314
- const message = encodeSyncUpdate({ update: new Uint8Array(0) });
58
+ if (!captured) {
59
+ throw new Error('Expected a captured update after document mutation');
60
+ }
61
+ const { syncType, payload } = readFrame(
62
+ encodeSyncUpdate({ update: captured }),
63
+ );
315
64
 
316
- expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.SYNC);
317
- });
65
+ expect(syncType).toBe(SYNC_MESSAGE_TYPE.UPDATE);
66
+ expect(payload).toEqual(captured);
318
67
  });
319
68
 
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
- });
69
+ test('handles an empty update', () => {
70
+ const { syncType, payload } = readFrame(
71
+ encodeSyncUpdate({ update: new Uint8Array(0) }),
72
+ );
388
73
 
389
- expect(serverDoc.getMap('data').get('key')).toBe('value');
390
- });
74
+ expect(syncType).toBe(SYNC_MESSAGE_TYPE.UPDATE);
75
+ expect(payload.length).toBe(0);
391
76
  });
392
77
  });
393
78
 
394
79
  // ============================================================================
395
- // MESSAGE_AWARENESS Tests
80
+ // handleSyncPayload
396
81
  // ============================================================================
397
82
 
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' });
83
+ describe('handleSyncPayload', () => {
84
+ test('responds to STEP1 with a STEP2 frame the client can apply', () => {
85
+ const serverDoc = createDoc((d) => {
86
+ d.getMap('data').set('server', 'content');
497
87
  });
88
+ const clientDoc = createDoc();
498
89
 
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);
90
+ const response = handleSyncPayload({
91
+ syncType: SYNC_MESSAGE_TYPE.STEP1,
92
+ payload: Y.encodeStateVector(clientDoc),
93
+ doc: serverDoc,
94
+ origin: 'test-client',
510
95
  });
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
96
 
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);
97
+ if (!response) {
98
+ throw new Error('Expected a STEP2 response for a STEP1 payload');
541
99
  }
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);
100
+ const { syncType, payload } = readFrame(response);
101
+ expect(syncType).toBe(SYNC_MESSAGE_TYPE.STEP2);
548
102
 
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
- }
103
+ // The client applies the STEP2 payload and converges on server content.
104
+ Y.applyUpdateV2(clientDoc, payload);
105
+ expect(clientDoc.getMap('data').get('server')).toBe('content');
554
106
  });
555
107
 
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;
108
+ test('returns null for sync step 2 (no response needed)', () => {
109
+ const serverDoc = createDoc();
110
+ const clientDoc = createDoc((d) => {
111
+ d.getMap('data').set('client', 'content');
561
112
  });
562
- doc.getMap('test').set('key', 'value');
563
113
 
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);
114
+ const response = handleSyncPayload({
115
+ syncType: SYNC_MESSAGE_TYPE.STEP2,
116
+ payload: Y.encodeStateAsUpdateV2(clientDoc),
117
+ doc: serverDoc,
118
+ origin: 'test-client',
119
+ });
569
120
 
570
- expect(decoded.type).toBe('update');
571
- if (decoded.type === 'update') {
572
- expect(decoded.update).toBeInstanceOf(Uint8Array);
573
- }
121
+ expect(response).toBeNull();
574
122
  });
575
123
 
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',
124
+ test('returns null for sync update (no response needed)', () => {
125
+ const serverDoc = createDoc();
126
+ const updateV2 = Y.encodeStateAsUpdateV2(
127
+ createDoc((d) => d.getMap('data').set('key', 'value')),
587
128
  );
588
- });
589
129
 
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']);
130
+ const response = handleSyncPayload({
131
+ syncType: SYNC_MESSAGE_TYPE.UPDATE,
132
+ payload: updateV2,
133
+ doc: serverDoc,
134
+ origin: 'test-client',
594
135
  });
595
136
 
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');
137
+ expect(response).toBeNull();
605
138
  });
606
- });
607
139
 
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
- });
140
+ test('applies update to document', () => {
141
+ const serverDoc = createDoc();
142
+ const clientDoc = createDoc((d) => {
143
+ d.getMap('data').set('key', 'value');
144
+ });
614
145
 
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],
146
+ handleSyncPayload({
147
+ syncType: SYNC_MESSAGE_TYPE.UPDATE,
148
+ payload: Y.encodeStateAsUpdateV2(clientDoc),
149
+ doc: serverDoc,
150
+ origin: 'test-client',
622
151
  });
623
- expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
624
- });
625
152
 
626
- test('decodes QUERY_AWARENESS message type', () => {
627
- const message = encodeQueryAwareness();
628
- expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.QUERY_AWARENESS);
153
+ expect(serverDoc.getMap('data').get('key')).toBe('value');
629
154
  });
630
155
  });
631
156
 
@@ -640,67 +165,24 @@ describe('full sync protocol', () => {
640
165
  });
641
166
  const clientDoc = createDoc();
642
167
 
643
- // Server handles client's state vector and responds with sync step 2 (V2 update)
168
+ // Server handles the client's state vector and responds with STEP2.
644
169
  const serverResponse = handleSyncPayload({
645
170
  syncType: SYNC_MESSAGE_TYPE.STEP1,
646
171
  payload: Y.encodeStateVector(clientDoc),
647
172
  doc: serverDoc,
648
173
  origin: 'client',
649
174
  });
650
-
651
- expect(serverResponse).not.toBeNull();
652
175
  if (!serverResponse) {
653
- throw new Error('Expected server sync response during handshake');
176
+ throw new Error('Expected a server sync response during handshake');
654
177
  }
655
178
 
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
- }
179
+ // Client decodes the STEP2 frame and applies it through the same path.
180
+ const { syncType, payload } = readFrame(serverResponse);
181
+ expect(syncType).toBe(SYNC_MESSAGE_TYPE.STEP2);
182
+ handleSyncPayload({ syncType, payload, doc: clientDoc, origin: 'server' });
662
183
 
663
- // Client should have server's content
664
184
  expect(clientDoc.getMap('notes').get('note1')).toBe('Hello from server');
665
185
  });
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
186
  });
705
187
 
706
188
  // ============================================================================
@@ -708,51 +190,28 @@ describe('full sync protocol', () => {
708
190
  // ============================================================================
709
191
 
710
192
  describe('edge cases', () => {
711
- test('handles large document (1000+ operations)', () => {
712
- const doc = createDoc((d) => {
193
+ test('handshake syncs a large document (1000+ operations)', () => {
194
+ const serverDoc = createDoc((d) => {
713
195
  const arr = d.getArray<string>('items');
714
196
  for (let i = 0; i < 1000; i++) {
715
197
  arr.push([`item-${i}`]);
716
198
  }
717
199
  });
200
+ const clientDoc = createDoc();
718
201
 
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);
202
+ const response = handleSyncPayload({
203
+ syncType: SYNC_MESSAGE_TYPE.STEP1,
204
+ payload: Y.encodeStateVector(clientDoc),
205
+ doc: serverDoc,
206
+ origin: 'client',
207
+ });
208
+ if (!response) {
209
+ throw new Error('Expected a STEP2 response for the handshake');
755
210
  }
211
+ const { syncType, payload } = readFrame(response);
212
+ handleSyncPayload({ syncType, payload, doc: clientDoc, origin: 'server' });
213
+
214
+ expect(clientDoc.getArray('items').length).toBe(1000);
756
215
  });
757
216
  });
758
217
 
@@ -767,10 +226,18 @@ function createDoc(init?: (doc: Y.Doc) => void): Y.Doc {
767
226
  return doc;
768
227
  }
769
228
 
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);
229
+ /**
230
+ * Decode a binary sync frame into its sub-type and payload. Mirrors the
231
+ * production transport decode (`Room.webSocketMessage` / `sync-supervisor`),
232
+ * which asserts the sub-type varint as `SyncMessageType`.
233
+ */
234
+ function readFrame(data: Uint8Array): {
235
+ syncType: SyncMessageType;
236
+ payload: Uint8Array;
237
+ } {
238
+ const decoder = decoding.createDecoder(data);
239
+ return {
240
+ syncType: decoding.readVarUint(decoder) as SyncMessageType,
241
+ payload: decoding.readVarUint8Array(decoder),
242
+ };
776
243
  }