@tldraw/sync-core 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b9999db71010

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.
Files changed (67) hide show
  1. package/dist-cjs/index.d.ts +605 -75
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +144 -0
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/RoomSession.js +3 -0
  6. package/dist-cjs/lib/RoomSession.js.map +2 -2
  7. package/dist-cjs/lib/ServerSocketAdapter.js +23 -0
  8. package/dist-cjs/lib/ServerSocketAdapter.js.map +2 -2
  9. package/dist-cjs/lib/TLRemoteSyncError.js +8 -0
  10. package/dist-cjs/lib/TLRemoteSyncError.js.map +2 -2
  11. package/dist-cjs/lib/TLSocketRoom.js +280 -56
  12. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncClient.js +45 -2
  14. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  15. package/dist-cjs/lib/TLSyncRoom.js +161 -16
  16. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  17. package/dist-cjs/lib/chunk.js +30 -0
  18. package/dist-cjs/lib/chunk.js.map +2 -2
  19. package/dist-cjs/lib/diff.js.map +2 -2
  20. package/dist-cjs/lib/findMin.js.map +2 -2
  21. package/dist-cjs/lib/interval.js.map +2 -2
  22. package/dist-cjs/lib/protocol.js.map +2 -2
  23. package/dist-cjs/lib/server-types.js.map +1 -1
  24. package/dist-esm/index.d.mts +605 -75
  25. package/dist-esm/index.mjs +1 -1
  26. package/dist-esm/lib/ClientWebSocketAdapter.mjs +144 -0
  27. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  28. package/dist-esm/lib/RoomSession.mjs +3 -0
  29. package/dist-esm/lib/RoomSession.mjs.map +2 -2
  30. package/dist-esm/lib/ServerSocketAdapter.mjs +23 -0
  31. package/dist-esm/lib/ServerSocketAdapter.mjs.map +2 -2
  32. package/dist-esm/lib/TLRemoteSyncError.mjs +8 -0
  33. package/dist-esm/lib/TLRemoteSyncError.mjs.map +2 -2
  34. package/dist-esm/lib/TLSocketRoom.mjs +280 -56
  35. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  36. package/dist-esm/lib/TLSyncClient.mjs +45 -2
  37. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  38. package/dist-esm/lib/TLSyncRoom.mjs +161 -16
  39. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  40. package/dist-esm/lib/chunk.mjs +30 -0
  41. package/dist-esm/lib/chunk.mjs.map +2 -2
  42. package/dist-esm/lib/diff.mjs.map +2 -2
  43. package/dist-esm/lib/findMin.mjs.map +2 -2
  44. package/dist-esm/lib/interval.mjs.map +2 -2
  45. package/dist-esm/lib/protocol.mjs.map +2 -2
  46. package/package.json +6 -6
  47. package/src/lib/ClientWebSocketAdapter.test.ts +712 -129
  48. package/src/lib/ClientWebSocketAdapter.ts +240 -9
  49. package/src/lib/RoomSession.test.ts +97 -0
  50. package/src/lib/RoomSession.ts +105 -3
  51. package/src/lib/ServerSocketAdapter.test.ts +228 -0
  52. package/src/lib/ServerSocketAdapter.ts +124 -5
  53. package/src/lib/TLRemoteSyncError.ts +50 -1
  54. package/src/lib/TLSocketRoom.ts +377 -60
  55. package/src/lib/TLSyncClient.test.ts +828 -0
  56. package/src/lib/TLSyncClient.ts +251 -26
  57. package/src/lib/TLSyncRoom.ts +284 -24
  58. package/src/lib/chunk.ts +72 -1
  59. package/src/lib/diff.ts +128 -14
  60. package/src/lib/findMin.ts +6 -0
  61. package/src/lib/interval.ts +40 -0
  62. package/src/lib/protocol.ts +185 -7
  63. package/src/lib/server-types.test.ts +44 -0
  64. package/src/lib/server-types.ts +45 -1
  65. package/src/test/TLSocketRoom.test.ts +438 -29
  66. package/src/test/chunk.test.ts +200 -3
  67. package/src/test/diff.test.ts +396 -1
@@ -1,4 +1,5 @@
1
1
  import { assert } from 'tldraw'
2
+ import { describe, expect, it } from 'vitest'
2
3
  import { JsonChunkAssembler, chunk } from '../lib/chunk'
3
4
 
4
5
  describe('chunk', () => {
@@ -102,7 +103,6 @@ describe('json unchunker', () => {
102
103
  expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
103
104
  })
104
105
 
105
- // todo: test error cases
106
106
  it('returns an error if the json is whack', () => {
107
107
  const chunks = chunk('{"hello": world"}', 5)
108
108
  const unchunker = new JsonChunkAssembler()
@@ -123,7 +123,6 @@ describe('json unchunker', () => {
123
123
  })
124
124
  it('returns an error if one of the chunks was missing', () => {
125
125
  const chunks = chunk('{"hello": world"}', 10)
126
- expect(chunks).toHaveLength(3)
127
126
 
128
127
  const unchunker = new JsonChunkAssembler()
129
128
  expect(unchunker.handleMessage(chunks[0])).toBeNull()
@@ -138,7 +137,6 @@ describe('json unchunker', () => {
138
137
 
139
138
  it('returns an error if the chunk stream ends abruptly', () => {
140
139
  const chunks = chunk('{"hello": world"}', 10)
141
- expect(chunks).toHaveLength(3)
142
140
 
143
141
  const unchunker = new JsonChunkAssembler()
144
142
  expect(unchunker.handleMessage(chunks[0])).toBeNull()
@@ -166,4 +164,203 @@ describe('json unchunker', () => {
166
164
  // and the next one should be fine
167
165
  expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
168
166
  })
167
+
168
+ it('handles empty string', () => {
169
+ const unchunker = new JsonChunkAssembler()
170
+ const result = unchunker.handleMessage('{}')
171
+ expect(result).toMatchObject({ data: {}, stringified: '{}' })
172
+ })
173
+
174
+ it('handles complex nested JSON objects', () => {
175
+ const complexObject = {
176
+ array: [1, 2, { nested: true }],
177
+ null: null,
178
+ boolean: false,
179
+ number: 3.14,
180
+ string: 'hello world',
181
+ unicode: '🎨🗼️📐',
182
+ deep: {
183
+ nested: {
184
+ object: {
185
+ with: 'many levels',
186
+ },
187
+ },
188
+ },
189
+ }
190
+
191
+ const chunks = chunk(JSON.stringify(complexObject), 50)
192
+ const unchunker = new JsonChunkAssembler()
193
+
194
+ for (const chunk of chunks.slice(0, -1)) {
195
+ expect(unchunker.handleMessage(chunk)).toBeNull()
196
+ }
197
+
198
+ const result = unchunker.handleMessage(chunks[chunks.length - 1])
199
+ expect(result).toMatchObject({ data: complexObject })
200
+ })
201
+
202
+ it('handles state reset after error', () => {
203
+ const unchunker = new JsonChunkAssembler()
204
+
205
+ // Start a chunk sequence
206
+ expect(unchunker.handleMessage('1_hello')).toBeNull()
207
+
208
+ // Send malformed chunk to trigger error
209
+ const result = unchunker.handleMessage('invalid_chunk_format')
210
+ assert(result && 'error' in result, 'expected error result')
211
+ expect(result.error.message).toContain('Invalid chunk')
212
+
213
+ // Should be able to process normal messages again
214
+ expect(unchunker.handleMessage('{"test": true}')).toMatchObject({ data: { test: true } })
215
+ })
216
+
217
+ it('returns error for invalid chunk number format', () => {
218
+ const unchunker = new JsonChunkAssembler()
219
+ const result = unchunker.handleMessage('abc_invalid_number')
220
+ assert(result && 'error' in result, 'expected error result')
221
+ expect(result.error.message).toContain('Invalid chunk')
222
+ })
223
+
224
+ it('handles single chunk with number prefix correctly', () => {
225
+ const unchunker = new JsonChunkAssembler()
226
+ const result = unchunker.handleMessage('0_{"single": "chunk"}')
227
+ expect(result).toMatchObject({
228
+ data: { single: 'chunk' },
229
+ stringified: '{"single": "chunk"}',
230
+ })
231
+ })
232
+
233
+ it('handles chunks with empty data parts', () => {
234
+ const unchunker = new JsonChunkAssembler()
235
+
236
+ expect(unchunker.handleMessage('1_')).toBeNull() // empty first chunk
237
+ const result = unchunker.handleMessage('0_{"test": true}')
238
+ expect(result).toMatchObject({ data: { test: true } })
239
+ })
240
+
241
+ it('handles non-JSON string messages that are not chunks', () => {
242
+ const unchunker = new JsonChunkAssembler()
243
+ const result = unchunker.handleMessage('not_json_and_not_chunk')
244
+ assert(result && 'error' in result, 'expected error result')
245
+ expect(result.error.message).toContain('Invalid chunk')
246
+ })
247
+
248
+ it('handles chunk sequence interrupted by JSON message', () => {
249
+ const unchunker = new JsonChunkAssembler()
250
+
251
+ // Start chunk sequence
252
+ expect(unchunker.handleMessage('2_hello')).toBeNull()
253
+
254
+ // Interrupt with JSON message - should trigger error and reset state
255
+ const result = unchunker.handleMessage('{"interrupt": true}')
256
+ assert(result && 'error' in result, 'expected error result')
257
+ expect(result.error.message).toBe('Unexpected non-chunk message')
258
+
259
+ // Should be able to process messages normally again
260
+ expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
261
+ })
262
+
263
+ it('handles duplicate or out-of-order chunk numbers', () => {
264
+ const unchunker = new JsonChunkAssembler()
265
+
266
+ // Start with first chunk
267
+ expect(unchunker.handleMessage('2_part1')).toBeNull()
268
+
269
+ // Send chunk with wrong number (should be 1, not 0)
270
+ const result = unchunker.handleMessage('0_part3')
271
+ assert(result && 'error' in result, 'expected error result')
272
+ expect(result.error.message).toBe('Chunks received in wrong order')
273
+ })
274
+
275
+ it('handles JSON parse error in completed chunk sequence', () => {
276
+ const unchunker = new JsonChunkAssembler()
277
+
278
+ // Send chunks that form invalid JSON when combined
279
+ expect(unchunker.handleMessage('1_{"invalid":')).toBeNull()
280
+ const result = unchunker.handleMessage('0_ }')
281
+
282
+ assert(result && 'error' in result, 'expected error result')
283
+ expect(result.error).toBeInstanceOf(Error)
284
+ })
285
+ })
286
+
287
+ describe('chunk function edge cases', () => {
288
+ it('handles empty strings', () => {
289
+ const result = chunk('', 100)
290
+ expect(result).toEqual([''])
291
+ })
292
+
293
+ it('handles single character strings', () => {
294
+ const result = chunk('a', 100)
295
+ expect(result).toEqual(['a'])
296
+ })
297
+
298
+ it('uses default maxSafeMessageSize when not provided', () => {
299
+ // Create a string longer than default max size to test chunking
300
+ const longString = 'x'.repeat(262145) // Larger than 262144 default
301
+ const result = chunk(longString)
302
+
303
+ expect(result.length).toBeGreaterThan(1)
304
+ expect(result[0]).toMatch(/^\d+_x+$/)
305
+ })
306
+
307
+ it('handles strings exactly at the boundary', () => {
308
+ const boundaryString = 'x'.repeat(9) // 9 chars fits in 10 char limit
309
+ const result = chunk(boundaryString, 10)
310
+
311
+ expect(result).toEqual([boundaryString])
312
+ })
313
+
314
+ it('handles strings one character over the boundary', () => {
315
+ const overBoundaryString = 'x'.repeat(11)
316
+ const result = chunk(overBoundaryString, 10)
317
+
318
+ expect(result.length).toBeGreaterThan(1)
319
+ expect(result[0]).toMatch(/^\d+_x+$/)
320
+ })
321
+
322
+ it('preserves unicode characters correctly', () => {
323
+ const unicodeString = '🎨'.repeat(10) + '📐'.repeat(10) + '🗼️'.repeat(10)
324
+ const result = chunk(unicodeString, 20)
325
+
326
+ // Verify chunking works with unicode
327
+ expect(result.length).toBeGreaterThan(1)
328
+
329
+ // Reconstruct and verify
330
+ const reconstructed = result
331
+ .map((chunk) => {
332
+ const match = /^(\d+)_(.*)$/.exec(chunk)
333
+ return match ? match[2] : chunk
334
+ })
335
+ .join('')
336
+
337
+ expect(reconstructed).toEqual(unicodeString)
338
+ })
339
+
340
+ it('ensures no chunk exceeds maxSafeMessageSize', () => {
341
+ const maxSize = 15
342
+ const testString = 'hello world this is a long message'
343
+ const result = chunk(testString, maxSize)
344
+
345
+ for (const chunk of result) {
346
+ expect(chunk.length).toBeLessThanOrEqual(maxSize)
347
+ }
348
+ })
349
+
350
+ it('handles very large strings efficiently', () => {
351
+ const veryLargeString = 'a'.repeat(1000000) // 1MB string
352
+ const result = chunk(veryLargeString, 10000)
353
+
354
+ expect(result.length).toBeGreaterThan(1)
355
+
356
+ // Verify reconstruction works
357
+ const reconstructed = result
358
+ .map((chunk) => {
359
+ const match = /^(\d+)_(.*)$/.exec(chunk)
360
+ return match ? match[2] : chunk
361
+ })
362
+ .join('')
363
+
364
+ expect(reconstructed).toEqual(veryLargeString)
365
+ })
169
366
  })
@@ -1,4 +1,12 @@
1
- import { applyObjectDiff, diffRecord } from '../lib/diff'
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ applyObjectDiff,
4
+ diffRecord,
5
+ getNetworkDiff,
6
+ RecordOpType,
7
+ ValueOpType,
8
+ type ObjectDiff,
9
+ } from '../lib/diff'
2
10
 
3
11
  describe('nested arrays', () => {
4
12
  it('should be patchable at the end', () => {
@@ -187,3 +195,390 @@ test('adding things things to a record', () => {
187
195
 
188
196
  expect(applyObjectDiff(a, patch!)).toEqual(b)
189
197
  })
198
+
199
+ describe('getNetworkDiff', () => {
200
+ it('should return null for empty diff', () => {
201
+ const diff = { added: {}, updated: {}, removed: {} }
202
+ expect(getNetworkDiff(diff)).toBeNull()
203
+ })
204
+
205
+ it('should handle added records', () => {
206
+ const record = { id: 'test:1', type: 'test', data: 'value' }
207
+ const diff = {
208
+ added: { 'test:1': record },
209
+ updated: {},
210
+ removed: {},
211
+ }
212
+
213
+ const networkDiff = getNetworkDiff(diff)
214
+ expect(networkDiff).toEqual({
215
+ 'test:1': [RecordOpType.Put, record],
216
+ })
217
+ })
218
+
219
+ it('should handle removed records', () => {
220
+ const diff = {
221
+ added: {},
222
+ updated: {},
223
+ removed: { 'test:1': { id: 'test:1', type: 'test' } },
224
+ }
225
+
226
+ const networkDiff = getNetworkDiff(diff)
227
+ expect(networkDiff).toEqual({
228
+ 'test:1': [RecordOpType.Remove],
229
+ })
230
+ })
231
+
232
+ it('should handle updated records with patches', () => {
233
+ const prev = { id: 'test:1', type: 'test', x: 100, y: 200 }
234
+ const next = { id: 'test:1', type: 'test', x: 150, y: 200 }
235
+ const diff = {
236
+ added: {},
237
+ updated: { 'test:1': [prev, next] },
238
+ removed: {},
239
+ }
240
+
241
+ const networkDiff = getNetworkDiff(diff)
242
+ expect(networkDiff).toEqual({
243
+ 'test:1': [RecordOpType.Patch, { x: [ValueOpType.Put, 150] }],
244
+ })
245
+ })
246
+
247
+ it('should skip updates when no diff exists', () => {
248
+ const record = { id: 'test:1', type: 'test', x: 100 }
249
+ const diff = {
250
+ added: {},
251
+ updated: { 'test:1': [record, record] },
252
+ removed: {},
253
+ }
254
+
255
+ const networkDiff = getNetworkDiff(diff)
256
+ expect(networkDiff).toBeNull()
257
+ })
258
+
259
+ it('should handle mixed operations', () => {
260
+ const addedRecord = { id: 'test:1', type: 'test', data: 'new' }
261
+ const prevRecord = { id: 'test:2', type: 'test', x: 100 }
262
+ const nextRecord = { id: 'test:2', type: 'test', x: 200 }
263
+ const removedRecord = { id: 'test:3', type: 'test' }
264
+
265
+ const diff = {
266
+ added: { 'test:1': addedRecord },
267
+ updated: { 'test:2': [prevRecord, nextRecord] },
268
+ removed: { 'test:3': removedRecord },
269
+ }
270
+
271
+ const networkDiff = getNetworkDiff(diff)
272
+ expect(networkDiff).toEqual({
273
+ 'test:1': [RecordOpType.Put, addedRecord],
274
+ 'test:2': [RecordOpType.Patch, { x: [ValueOpType.Put, 200] }],
275
+ 'test:3': [RecordOpType.Remove],
276
+ })
277
+ })
278
+ })
279
+
280
+ describe('diffRecord comprehensive tests', () => {
281
+ it('should return null for identical records', () => {
282
+ const record = { id: 'test:1', x: 100, y: 200 }
283
+ expect(diffRecord(record, record)).toBeNull()
284
+ })
285
+
286
+ it('should handle simple property changes', () => {
287
+ const prev = { id: 'test:1', x: 100, y: 200 }
288
+ const next = { id: 'test:1', x: 150, y: 200 }
289
+
290
+ expect(diffRecord(prev, next)).toEqual({
291
+ x: [ValueOpType.Put, 150],
292
+ })
293
+ })
294
+
295
+ it('should handle nested props changes', () => {
296
+ const prev = { id: 'test:1', props: { color: 'red', size: 'medium' } }
297
+ const next = { id: 'test:1', props: { color: 'blue', size: 'medium' } }
298
+
299
+ expect(diffRecord(prev, next)).toEqual({
300
+ props: [ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }],
301
+ })
302
+ })
303
+
304
+ it('should handle adding nested props', () => {
305
+ const prev = { id: 'test:1', props: { color: 'red' } }
306
+ const next = { id: 'test:1', props: { color: 'red', size: 'large' } }
307
+
308
+ expect(diffRecord(prev, next)).toEqual({
309
+ props: [ValueOpType.Patch, { size: [ValueOpType.Put, 'large'] }],
310
+ })
311
+ })
312
+
313
+ it('should handle removing nested props', () => {
314
+ const prev = { id: 'test:1', props: { color: 'red', size: 'large' } }
315
+ const next = { id: 'test:1', props: { color: 'red' } }
316
+
317
+ expect(diffRecord(prev, next)).toEqual({
318
+ props: [ValueOpType.Patch, { size: [ValueOpType.Delete] }],
319
+ })
320
+ })
321
+
322
+ it('should handle multiple property changes', () => {
323
+ const prev = { id: 'test:1', x: 100, y: 200, rotation: 0 }
324
+ const next = { id: 'test:1', x: 150, y: 250, rotation: 45 }
325
+
326
+ expect(diffRecord(prev, next)).toEqual({
327
+ x: [ValueOpType.Put, 150],
328
+ y: [ValueOpType.Put, 250],
329
+ rotation: [ValueOpType.Put, 45],
330
+ })
331
+ })
332
+
333
+ it('should handle null and undefined values', () => {
334
+ const prev = { id: 'test:1', optional: 'value', nullable: null }
335
+ const next = { id: 'test:1', optional: undefined, nullable: 'value' }
336
+
337
+ const diff = diffRecord(prev, next)
338
+ expect(diff).toBeTruthy()
339
+ expect(diff!.optional).toEqual([ValueOpType.Put, undefined])
340
+ expect(diff!.nullable).toEqual([ValueOpType.Put, 'value'])
341
+ })
342
+ })
343
+
344
+ describe('array diffing comprehensive', () => {
345
+ describe('simple arrays', () => {
346
+ it('should handle identical arrays', () => {
347
+ const prev = { arr: [1, 2, 3] }
348
+ const next = { arr: [1, 2, 3] }
349
+
350
+ expect(diffRecord(prev, next)).toBeNull()
351
+ })
352
+
353
+ it('should handle array appends', () => {
354
+ const prev = { arr: [1, 2, 3] }
355
+ const next = { arr: [1, 2, 3, 4, 5] }
356
+
357
+ expect(diffRecord(prev, next)).toEqual({
358
+ arr: [ValueOpType.Append, [4, 5], 3],
359
+ })
360
+ })
361
+
362
+ it('should replace array when prefix changes', () => {
363
+ const prev = { arr: [1, 2, 3] }
364
+ const next = { arr: [1, 3, 4] }
365
+
366
+ expect(diffRecord(prev, next)).toEqual({
367
+ arr: [ValueOpType.Put, [1, 3, 4]],
368
+ })
369
+ })
370
+
371
+ it('should patch few items in same-length arrays', () => {
372
+ const prev = { arr: [1, 2, 3, 4, 5] }
373
+ const next = { arr: [1, 9, 3, 4, 5] }
374
+
375
+ expect(diffRecord(prev, next)).toEqual({
376
+ arr: [ValueOpType.Patch, { '1': [ValueOpType.Put, 9] }],
377
+ })
378
+ })
379
+
380
+ it('should replace array when too many items change', () => {
381
+ const prev = { arr: [1, 2, 3, 4, 5] }
382
+ const next = { arr: [6, 7, 8, 9, 10] }
383
+
384
+ expect(diffRecord(prev, next)).toEqual({
385
+ arr: [ValueOpType.Put, [6, 7, 8, 9, 10]],
386
+ })
387
+ })
388
+ })
389
+
390
+ describe('empty arrays', () => {
391
+ it('should handle empty to non-empty', () => {
392
+ const prev = { arr: [] }
393
+ const next = { arr: [1, 2, 3] }
394
+
395
+ expect(diffRecord(prev, next)).toEqual({
396
+ arr: [ValueOpType.Append, [1, 2, 3], 0],
397
+ })
398
+ })
399
+
400
+ it('should handle non-empty to empty', () => {
401
+ const prev = { arr: [1, 2, 3] }
402
+ const next = { arr: [] }
403
+
404
+ expect(diffRecord(prev, next)).toEqual({
405
+ arr: [ValueOpType.Put, []],
406
+ })
407
+ })
408
+ })
409
+ })
410
+
411
+ describe('applyObjectDiff comprehensive', () => {
412
+ describe('basic operations', () => {
413
+ it('should create new object when changes are needed', () => {
414
+ const obj = { a: 1, b: 2 }
415
+ const diff: ObjectDiff = { a: [ValueOpType.Put, 5] }
416
+
417
+ const result = applyObjectDiff(obj, diff)
418
+ expect(result).not.toBe(obj)
419
+ expect(result).toEqual({ a: 5, b: 2 })
420
+ })
421
+
422
+ it('should handle put operations', () => {
423
+ const obj = { a: 1, b: 2 }
424
+ const diff: ObjectDiff = {
425
+ a: [ValueOpType.Put, 10],
426
+ c: [ValueOpType.Put, 30],
427
+ }
428
+
429
+ const result = applyObjectDiff(obj, diff)
430
+ expect(result).toEqual({ a: 10, b: 2, c: 30 })
431
+ })
432
+
433
+ it('should handle delete operations', () => {
434
+ const obj = { a: 1, b: 2, c: 3 }
435
+ const diff: ObjectDiff = { b: [ValueOpType.Delete] }
436
+
437
+ const result = applyObjectDiff(obj, diff)
438
+ expect(result).toEqual({ a: 1, c: 3 })
439
+ expect('b' in result).toBe(false)
440
+ })
441
+ })
442
+
443
+ describe('nested patch operations', () => {
444
+ it('should handle nested object patches', () => {
445
+ const obj = { a: 1, nested: { x: 10, y: 20 } }
446
+ const diff: ObjectDiff = {
447
+ nested: [ValueOpType.Patch, { x: [ValueOpType.Put, 100] }],
448
+ }
449
+
450
+ const result = applyObjectDiff(obj, diff)
451
+ expect(result).toEqual({ a: 1, nested: { x: 100, y: 20 } })
452
+ expect(result.nested).not.toBe(obj.nested)
453
+ })
454
+
455
+ it('should handle deeply nested patches', () => {
456
+ const obj = {
457
+ level1: {
458
+ level2: {
459
+ level3: { value: 'old' },
460
+ },
461
+ },
462
+ }
463
+ const diff: ObjectDiff = {
464
+ level1: [
465
+ ValueOpType.Patch,
466
+ {
467
+ level2: [
468
+ ValueOpType.Patch,
469
+ {
470
+ level3: [
471
+ ValueOpType.Patch,
472
+ {
473
+ value: [ValueOpType.Put, 'new'],
474
+ },
475
+ ],
476
+ },
477
+ ],
478
+ },
479
+ ],
480
+ }
481
+
482
+ const result = applyObjectDiff(obj, diff)
483
+ expect(result.level1.level2.level3.value).toBe('new')
484
+ })
485
+ })
486
+
487
+ describe('array operations', () => {
488
+ it('should handle array append operations', () => {
489
+ const obj = { arr: [1, 2, 3] }
490
+ const diff: ObjectDiff = {
491
+ arr: [ValueOpType.Append, [4, 5], 3],
492
+ }
493
+
494
+ const result = applyObjectDiff(obj, diff)
495
+ expect(result).toEqual({ arr: [1, 2, 3, 4, 5] })
496
+ })
497
+
498
+ it('should handle array patch operations', () => {
499
+ const obj = { arr: [{ a: 1 }, { b: 2 }, { c: 3 }] }
500
+ const diff: ObjectDiff = {
501
+ arr: [
502
+ ValueOpType.Patch,
503
+ {
504
+ '1': [ValueOpType.Patch, { b: [ValueOpType.Put, 20] }],
505
+ },
506
+ ],
507
+ }
508
+
509
+ const result = applyObjectDiff(obj, diff)
510
+ expect(result.arr[1]).toEqual({ b: 20 })
511
+ expect(result.arr[0]).toBe(obj.arr[0]) // Unchanged items should be same reference
512
+ })
513
+ })
514
+
515
+ describe('edge cases', () => {
516
+ it('should handle empty diffs', () => {
517
+ const obj = { a: 1, b: 2 }
518
+ const diff: ObjectDiff = {}
519
+
520
+ const result = applyObjectDiff(obj, diff)
521
+ expect(result).toBe(obj) // Should be same reference
522
+ })
523
+ })
524
+ })
525
+
526
+ describe('complex scenarios', () => {
527
+ it('should handle shape-like record updates', () => {
528
+ const prev = {
529
+ id: 'shape:123',
530
+ type: 'geo',
531
+ x: 100,
532
+ y: 200,
533
+ props: {
534
+ color: 'red',
535
+ size: 'medium',
536
+ geo: 'rectangle',
537
+ },
538
+ meta: {},
539
+ }
540
+
541
+ const next = {
542
+ id: 'shape:123',
543
+ type: 'geo',
544
+ x: 150,
545
+ y: 200,
546
+ props: {
547
+ color: 'blue',
548
+ size: 'medium',
549
+ geo: 'rectangle',
550
+ },
551
+ meta: { timestamp: Date.now() },
552
+ }
553
+
554
+ const diff = diffRecord(prev, next)
555
+ expect(diff).toBeTruthy()
556
+ expect(diff!.x).toEqual([ValueOpType.Put, 150])
557
+ expect(diff!.props).toEqual([ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }])
558
+ expect(diff!.meta).toBeTruthy()
559
+
560
+ // Apply the diff and verify result
561
+ const result = applyObjectDiff(prev, diff!)
562
+ expect(result).toEqual(next)
563
+ })
564
+
565
+ it('should handle complete network diff workflow', () => {
566
+ const shape1 = { id: 'shape:1', type: 'geo', x: 100 }
567
+ const shape2prev = { id: 'shape:2', type: 'geo', x: 200 }
568
+ const shape2next = { id: 'shape:2', type: 'geo', x: 300 }
569
+ const shape3 = { id: 'shape:3', type: 'geo', x: 400 }
570
+
571
+ const recordsDiff = {
572
+ added: { 'shape:1': shape1 },
573
+ updated: { 'shape:2': [shape2prev, shape2next] },
574
+ removed: { 'shape:3': shape3 },
575
+ }
576
+
577
+ const networkDiff = getNetworkDiff(recordsDiff)
578
+ expect(networkDiff).toEqual({
579
+ 'shape:1': [RecordOpType.Put, shape1],
580
+ 'shape:2': [RecordOpType.Patch, { x: [ValueOpType.Put, 300] }],
581
+ 'shape:3': [RecordOpType.Remove],
582
+ })
583
+ })
584
+ })