@tldraw/sync-core 5.2.0-next.5d769d321393 → 5.2.0-next.7b677d60c152

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 (54) hide show
  1. package/DOCS.md +662 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js +7 -1
  5. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  6. package/dist-cjs/lib/TLSocketRoom.js +4 -1
  7. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  8. package/dist-cjs/lib/TLSyncClient.js +2 -3
  9. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  10. package/dist-cjs/lib/TLSyncRoom.js +27 -3
  11. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  12. package/dist-esm/index.mjs +1 -1
  13. package/dist-esm/lib/ClientWebSocketAdapter.mjs +7 -1
  14. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  15. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  16. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  17. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  18. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  19. package/dist-esm/lib/TLSyncRoom.mjs +27 -3
  20. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  21. package/package.json +12 -8
  22. package/src/lib/ClientWebSocketAdapter.test.ts +502 -505
  23. package/src/lib/ClientWebSocketAdapter.ts +7 -1
  24. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  25. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  26. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  27. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  28. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  29. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  30. package/src/lib/TLSocketRoom.ts +6 -1
  31. package/src/lib/TLSyncClient.test.ts +1127 -846
  32. package/src/lib/TLSyncClient.ts +4 -4
  33. package/src/lib/TLSyncRoom.ts +34 -4
  34. package/src/lib/TLSyncStorage.test.ts +225 -0
  35. package/src/lib/chunk.test.ts +372 -0
  36. package/src/lib/diff.test.ts +885 -0
  37. package/src/lib/interval.test.ts +43 -0
  38. package/src/lib/protocol.test.ts +43 -0
  39. package/src/lib/recordDiff.test.ts +159 -0
  40. package/src/test/TLSocketRoom.test.ts +1109 -894
  41. package/src/test/TLSyncRoom.test.ts +1754 -870
  42. package/src/test/storageContractSuite.ts +618 -0
  43. package/src/test/upgradeDowngrade.test.ts +140 -40
  44. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  45. package/src/lib/RoomSession.test.ts +0 -101
  46. package/src/lib/computeTombstonePruning.test.ts +0 -352
  47. package/src/lib/server-types.test.ts +0 -44
  48. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  49. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  50. package/src/test/chunk.test.ts +0 -385
  51. package/src/test/customMessages.test.ts +0 -36
  52. package/src/test/diff.test.ts +0 -784
  53. package/src/test/presenceMode.test.ts +0 -149
  54. package/src/test/validation.test.ts +0 -186
@@ -0,0 +1,885 @@
1
+ import {
2
+ applyObjectDiff,
3
+ diffRecord,
4
+ getNetworkDiff,
5
+ RecordOpType,
6
+ ValueOpType,
7
+ type ObjectDiff,
8
+ } from './diff'
9
+
10
+ describe('computing diffs (D)', () => {
11
+ describe('no changes (D1)', () => {
12
+ it('[D1] returns null when prev === next', () => {
13
+ const record = { id: 'test:1', x: 100, y: 200 }
14
+ expect(diffRecord(record, record)).toBeNull()
15
+ })
16
+
17
+ it('[D1] returns null when there is nothing to change', () => {
18
+ const prev = { id: 'test:1', x: 100, props: { color: 'red' }, arr: [1, 2, 3], text: 'hi' }
19
+ const next = { id: 'test:1', x: 100, props: { color: 'red' }, arr: [1, 2, 3], text: 'hi' }
20
+ expect(diffRecord(prev, next)).toBeNull()
21
+ })
22
+ })
23
+
24
+ describe('added and deleted keys (D2)', () => {
25
+ it('[D2] produces a delete op for keys missing from next', () => {
26
+ const a = { a: 1, b: 2, c: 3 }
27
+ const b = { a: 1, b: 2 }
28
+
29
+ const patch = diffRecord(a, b)
30
+ expect(patch).toEqual({ c: [ValueOpType.Delete] })
31
+ expect(applyObjectDiff(a, patch!)).toEqual(b)
32
+ })
33
+
34
+ it('[D2] produces a put op for keys added in next', () => {
35
+ const a = { a: 1, b: 2 }
36
+ const b = { a: 1, b: 2, c: 3 }
37
+
38
+ const patch = diffRecord(a, b)
39
+ expect(patch).toEqual({ c: [ValueOpType.Put, 3] })
40
+ expect(applyObjectDiff(a, patch!)).toEqual(b)
41
+ })
42
+ })
43
+
44
+ describe('top-level keys vs props/meta (D3)', () => {
45
+ it('[D3] puts changed top-level primitive values', () => {
46
+ const prev = { id: 'test:1', x: 100, y: 200 }
47
+ const next = { id: 'test:1', x: 150, y: 200 }
48
+
49
+ expect(diffRecord(prev, next)).toEqual({
50
+ x: [ValueOpType.Put, 150],
51
+ })
52
+ })
53
+
54
+ it('[D3] puts multiple changed top-level values', () => {
55
+ const prev = { id: 'test:1', x: 100, y: 200, rotation: 0 }
56
+ const next = { id: 'test:1', x: 150, y: 250, rotation: 45 }
57
+
58
+ expect(diffRecord(prev, next)).toEqual({
59
+ x: [ValueOpType.Put, 150],
60
+ y: [ValueOpType.Put, 250],
61
+ rotation: [ValueOpType.Put, 45],
62
+ })
63
+ })
64
+
65
+ it('[D3] a top-level key (not props/meta) holding a plain object gets a whole-value put, never a patch', () => {
66
+ const prev = { id: 'test:1', custom: { a: 1, b: 2 } }
67
+ const next = { id: 'test:1', custom: { a: 1, b: 3 } }
68
+
69
+ expect(diffRecord(prev, next)).toEqual({
70
+ custom: [ValueOpType.Put, { a: 1, b: 3 }],
71
+ })
72
+ })
73
+
74
+ it('[D3] a deep-equal top-level plain object produces no diff', () => {
75
+ const prev = { id: 'test:1', custom: { a: 1, b: 2 } }
76
+ const next = { id: 'test:1', custom: { a: 1, b: 2 } }
77
+
78
+ expect(diffRecord(prev, next)).toBeNull()
79
+ })
80
+
81
+ it('[D3] changes inside props are expressed as patch ops', () => {
82
+ const prev = { id: 'test:1', props: { color: 'red', size: 'medium' } }
83
+ const next = { id: 'test:1', props: { color: 'blue', size: 'medium' } }
84
+
85
+ expect(diffRecord(prev, next)).toEqual({
86
+ props: [ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }],
87
+ })
88
+ })
89
+
90
+ it('[D3] changes inside meta are expressed as patch ops', () => {
91
+ const prev = { id: 'test:1', meta: { tag: 'a' } }
92
+ const next = { id: 'test:1', meta: { tag: 'b' } }
93
+
94
+ expect(diffRecord(prev, next)).toEqual({
95
+ meta: [ValueOpType.Patch, { tag: [ValueOpType.Put, 'b'] }],
96
+ })
97
+ })
98
+
99
+ it('[D3] handles null and undefined values at the top level', () => {
100
+ const prev = { id: 'test:1', optional: 'value', nullable: null }
101
+ const next = { id: 'test:1', optional: undefined, nullable: 'value' }
102
+
103
+ const diff = diffRecord(prev, next)
104
+ expect(diff).toBeTruthy()
105
+ expect(diff!.optional).toEqual([ValueOpType.Put, undefined])
106
+ expect(diff!.nullable).toEqual([ValueOpType.Put, 'value'])
107
+ })
108
+ })
109
+
110
+ describe('nested diffs (D4)', () => {
111
+ it('[D4] recursively patches object values inside props', () => {
112
+ const prev = { id: 'test:1', props: { color: 'red' } }
113
+ const next = { id: 'test:1', props: { color: 'red', size: 'large' } }
114
+
115
+ expect(diffRecord(prev, next)).toEqual({
116
+ props: [ValueOpType.Patch, { size: [ValueOpType.Put, 'large'] }],
117
+ })
118
+ })
119
+
120
+ it('[D4] deletes keys removed inside props', () => {
121
+ const prev = { id: 'test:1', props: { color: 'red', size: 'large' } }
122
+ const next = { id: 'test:1', props: { color: 'red' } }
123
+
124
+ expect(diffRecord(prev, next)).toEqual({
125
+ props: [ValueOpType.Patch, { size: [ValueOpType.Delete] }],
126
+ })
127
+ })
128
+
129
+ it('[D4] puts primitive values when the props key holds a primitive', () => {
130
+ const prev = { id: 'shape:1', props: 'hello' }
131
+ const next = { id: 'shape:1', props: 'world' }
132
+
133
+ expect(diffRecord(prev, next)).toEqual({
134
+ props: [ValueOpType.Put, 'world'],
135
+ })
136
+ })
137
+
138
+ it('[D4] [D5] appends when the props key holds a growing string', () => {
139
+ const prev = { id: 'shape:1', props: 'hello' }
140
+ const next = { id: 'shape:1', props: 'hello world' }
141
+
142
+ expect(diffRecord(prev, next)).toEqual({
143
+ props: [ValueOpType.Append, ' world', 5],
144
+ })
145
+ })
146
+
147
+ it('[D4] puts number values when the props key holds a number', () => {
148
+ const prev = { id: 'shape:1', props: 42 }
149
+ const next = { id: 'shape:1', props: 100 }
150
+
151
+ expect(diffRecord(prev, next)).toEqual({
152
+ props: [ValueOpType.Put, 100],
153
+ })
154
+ })
155
+
156
+ it('[D4] still patches object values inside props normally', () => {
157
+ const prev = { id: 'shape:1', props: { color: 'red' } }
158
+ const next = { id: 'shape:1', props: { color: 'blue' } }
159
+
160
+ expect(diffRecord(prev, next)).toEqual({
161
+ props: [ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }],
162
+ })
163
+ })
164
+ })
165
+
166
+ describe('string appends (D5)', () => {
167
+ it('[D5] top-level string fields outside props get append ops', () => {
168
+ expect(diffRecord({ id: 'a', text: 'hello' }, { id: 'a', text: 'hello world' })).toEqual({
169
+ text: [ValueOpType.Append, ' world', 5],
170
+ })
171
+ })
172
+
173
+ it('[D5] appends from an empty string', () => {
174
+ const prev = { text: '' }
175
+ const next = { text: 'Hello' }
176
+
177
+ expect(diffRecord(prev, next)).toEqual({
178
+ text: [ValueOpType.Append, 'Hello', 0],
179
+ })
180
+ })
181
+
182
+ it('[D5] uses put when the string is replaced rather than appended', () => {
183
+ const prev = { text: 'Hello' }
184
+ const next = { text: 'Goodbye' }
185
+
186
+ expect(diffRecord(prev, next)).toEqual({
187
+ text: [ValueOpType.Put, 'Goodbye'],
188
+ })
189
+ })
190
+
191
+ it('[D5] uses put when the string is shortened', () => {
192
+ const prev = { text: 'Hello world' }
193
+ const next = { text: 'Hello' }
194
+
195
+ expect(diffRecord(prev, next)).toEqual({
196
+ text: [ValueOpType.Put, 'Hello'],
197
+ })
198
+ })
199
+
200
+ it('[D5] produces no diff for identical strings', () => {
201
+ const prev = { text: 'Hello' }
202
+ const next = { text: 'Hello' }
203
+
204
+ expect(diffRecord(prev, next)).toBeNull()
205
+ })
206
+
207
+ it('[D5] handles large text appends', () => {
208
+ const prev = { text: 'Start' }
209
+ const longText = ' '.repeat(1000) + 'end'
210
+ const next = { text: 'Start' + longText }
211
+
212
+ expect(diffRecord(prev, next)).toEqual({
213
+ text: [ValueOpType.Append, longText, 5],
214
+ })
215
+ })
216
+
217
+ it('[D5] appends strings nested inside props', () => {
218
+ const prev = { id: 'test:1', props: { label: 'Hello' } }
219
+ const next = { id: 'test:1', props: { label: 'Hello world' } }
220
+
221
+ expect(diffRecord(prev, next)).toEqual({
222
+ props: [ValueOpType.Patch, { label: [ValueOpType.Append, ' world', 5] }],
223
+ })
224
+ })
225
+
226
+ it('[D5] combines string appends with other property changes', () => {
227
+ const prev = { text: 'Hello', x: 100 }
228
+ const next = { text: 'Hello world', x: 200 }
229
+
230
+ expect(diffRecord(prev, next)).toEqual({
231
+ text: [ValueOpType.Append, ' world', 5],
232
+ x: [ValueOpType.Put, 200],
233
+ })
234
+ })
235
+
236
+ it('[D5] legacyAppendMode turns string appends into puts', () => {
237
+ const prev = { id: 'a', text: 'hello' }
238
+ const next = { id: 'a', text: 'hello world' }
239
+
240
+ expect(diffRecord(prev, next, true)).toEqual({
241
+ text: [ValueOpType.Put, 'hello world'],
242
+ })
243
+ })
244
+
245
+ it('[D5] legacyAppendMode turns nested string appends into puts', () => {
246
+ const prev = { id: 'a', props: { label: 'hello' } }
247
+ const next = { id: 'a', props: { label: 'hello world' } }
248
+
249
+ expect(diffRecord(prev, next, true)).toEqual({
250
+ props: [ValueOpType.Patch, { label: [ValueOpType.Put, 'hello world'] }],
251
+ })
252
+ })
253
+
254
+ it('[D5] [D7] legacyAppendMode does not affect array appends', () => {
255
+ const prev = { arr: [1, 2, 3] }
256
+ const next = { arr: [1, 2, 3, 4, 5] }
257
+
258
+ expect(diffRecord(prev, next, true)).toEqual({
259
+ arr: [ValueOpType.Append, [4, 5], 3],
260
+ })
261
+ })
262
+ })
263
+
264
+ describe('same-length arrays (D6)', () => {
265
+ it('[D6] produces no op when no items changed', () => {
266
+ const prev = { arr: [1, 2, 3] }
267
+ const next = { arr: [1, 2, 3] }
268
+
269
+ expect(diffRecord(prev, next)).toBeNull()
270
+ })
271
+
272
+ it('[D6] patches when at most max(length/5, 1) items changed', () => {
273
+ const prev = { arr: [1, 2, 3, 4, 5] }
274
+ const next = { arr: [1, 9, 3, 4, 5] }
275
+
276
+ expect(diffRecord(prev, next)).toEqual({
277
+ arr: [ValueOpType.Patch, { '1': [ValueOpType.Put, 9] }],
278
+ })
279
+ })
280
+
281
+ it('[D6] puts the whole array when more than max(length/5, 1) items changed', () => {
282
+ // length 5 => threshold is max(1, 1) = 1, so 2 changes exceed it
283
+ const prev = { arr: [1, 2, 3, 4, 5] }
284
+ const next = { arr: [1, 9, 9, 4, 5] }
285
+
286
+ expect(diffRecord(prev, next)).toEqual({
287
+ arr: [ValueOpType.Put, [1, 9, 9, 4, 5]],
288
+ })
289
+ })
290
+
291
+ it('[D6] patches exactly at the threshold for longer arrays', () => {
292
+ // length 10 => threshold is max(10/5, 1) = 2, so 2 changes still patch
293
+ const prev = { arr: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }
294
+ const next = { arr: [1, 20, 3, 4, 5, 6, 7, 80, 9, 10] }
295
+
296
+ expect(diffRecord(prev, next)).toEqual({
297
+ arr: [ValueOpType.Patch, { '1': [ValueOpType.Put, 20], '7': [ValueOpType.Put, 80] }],
298
+ })
299
+ })
300
+
301
+ it('[D6] puts the whole array just above the threshold for longer arrays', () => {
302
+ // length 10 => threshold 2, so 3 changes bail out to a whole-array put
303
+ const prev = { arr: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }
304
+ const next = { arr: [1, 20, 30, 40, 5, 6, 7, 8, 9, 10] }
305
+
306
+ expect(diffRecord(prev, next)).toEqual({
307
+ arr: [ValueOpType.Put, [1, 20, 30, 40, 5, 6, 7, 8, 9, 10]],
308
+ })
309
+ })
310
+
311
+ it('[D6] puts the whole array when all items changed', () => {
312
+ const prev = { arr: [1, 2, 3, 4, 5] }
313
+ const next = { arr: [6, 7, 8, 9, 10] }
314
+
315
+ expect(diffRecord(prev, next)).toEqual({
316
+ arr: [ValueOpType.Put, [6, 7, 8, 9, 10]],
317
+ })
318
+ })
319
+
320
+ it('[D6] recursively diffs changed indexes when both items are truthy objects', () => {
321
+ const a = {
322
+ arr: [
323
+ { a: 1, b: 2, c: 3 },
324
+ { a: 4, b: 5, c: 6 },
325
+ ],
326
+ }
327
+ const b = {
328
+ arr: [
329
+ { a: 1, b: 2, c: 3 },
330
+ { a: 4, b: 5, c: 7 },
331
+ ],
332
+ }
333
+
334
+ expect(diffRecord(a, b)).toEqual({
335
+ arr: [ValueOpType.Patch, { '1': [ValueOpType.Patch, { c: [ValueOpType.Put, 7] }] }],
336
+ })
337
+ })
338
+
339
+ it('[D6] puts the whole array when too many object items changed', () => {
340
+ const a = {
341
+ arr: [
342
+ { a: 1, b: 2, c: 3 },
343
+ { a: 4, b: 5, c: 6 },
344
+ ],
345
+ }
346
+ const b = {
347
+ arr: [
348
+ { a: 1, b: 2, c: 5 },
349
+ { a: 4, b: 5, c: 7 },
350
+ ],
351
+ }
352
+
353
+ expect(diffRecord(a, b)).toEqual({
354
+ arr: [
355
+ ValueOpType.Put,
356
+ [
357
+ { a: 1, b: 2, c: 5 },
358
+ { a: 4, b: 5, c: 7 },
359
+ ],
360
+ ],
361
+ })
362
+ })
363
+
364
+ it('[D6] puts a changed index when the old item is falsy', () => {
365
+ const prev = { arr: [null, { a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] }
366
+ const next = { arr: [{ z: 9 }, { a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] }
367
+
368
+ expect(diffRecord(prev, next)).toEqual({
369
+ arr: [ValueOpType.Patch, { '0': [ValueOpType.Put, { z: 9 }] }],
370
+ })
371
+ })
372
+
373
+ it('[D6] puts a changed index when the new item is falsy', () => {
374
+ const prev = { arr: [{ z: 9 }, { a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] }
375
+ const next = { arr: [null, { a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] }
376
+
377
+ expect(diffRecord(prev, next)).toEqual({
378
+ arr: [ValueOpType.Patch, { '0': [ValueOpType.Put, null] }],
379
+ })
380
+ })
381
+ })
382
+
383
+ describe('different-length arrays (D7)', () => {
384
+ it('[D7] appends when the array grew with an unchanged prefix', () => {
385
+ const prev = { arr: [1, 2, 3] }
386
+ const next = { arr: [1, 2, 3, 4, 5] }
387
+
388
+ expect(diffRecord(prev, next)).toEqual({
389
+ arr: [ValueOpType.Append, [4, 5], 3],
390
+ })
391
+ })
392
+
393
+ it('[D7] appends from an empty array', () => {
394
+ const prev = { arr: [] }
395
+ const next = { arr: [1, 2, 3] }
396
+
397
+ expect(diffRecord(prev, next)).toEqual({
398
+ arr: [ValueOpType.Append, [1, 2, 3], 0],
399
+ })
400
+ })
401
+
402
+ it('[D7] puts the whole array on truncation', () => {
403
+ const prev = { arr: [1, 2, 3] }
404
+ const next = { arr: [1, 2] }
405
+
406
+ expect(diffRecord(prev, next)).toEqual({
407
+ arr: [ValueOpType.Put, [1, 2]],
408
+ })
409
+ })
410
+
411
+ it('[D7] puts the whole array when truncated to empty', () => {
412
+ const prev = { arr: [1, 2, 3] }
413
+ const next = { arr: [] }
414
+
415
+ expect(diffRecord(prev, next)).toEqual({
416
+ arr: [ValueOpType.Put, []],
417
+ })
418
+ })
419
+
420
+ it('[D7] puts the whole array when the shared prefix changed while growing', () => {
421
+ const prev = { arr: [1, 2, 3] }
422
+ const next = { arr: [1, 9, 3, 4, 5] }
423
+
424
+ expect(diffRecord(prev, next)).toEqual({
425
+ arr: [ValueOpType.Put, [1, 9, 3, 4, 5]],
426
+ })
427
+ })
428
+
429
+ it('[D7] puts the whole array when the prefix changed at the same growth point', () => {
430
+ const prev = { arr: [1, 2, 3] }
431
+ const next = { arr: [1, 3, 4] }
432
+
433
+ expect(diffRecord(prev, next)).toEqual({
434
+ arr: [ValueOpType.Put, [1, 3, 4]],
435
+ })
436
+ })
437
+
438
+ it('[D6] [D7] nested arrays are patchable at the end', () => {
439
+ const a = {
440
+ arr: [
441
+ [1, 2, 3],
442
+ [4, 5, 6],
443
+ ],
444
+ }
445
+ const b = {
446
+ arr: [
447
+ [1, 2, 3],
448
+ [4, 5, 6, 7, 8],
449
+ ],
450
+ }
451
+
452
+ expect(diffRecord(a, b)).toEqual({
453
+ arr: [ValueOpType.Patch, { '1': [ValueOpType.Append, [7, 8], 3] }],
454
+ })
455
+ })
456
+
457
+ it('[D6] [D7] nested arrays are patchable at the beginning', () => {
458
+ const a = {
459
+ arr: [
460
+ [1, 2, 3],
461
+ [4, 5, 6],
462
+ ],
463
+ }
464
+ const b = {
465
+ arr: [
466
+ [1, 2, 3, 4, 5, 6],
467
+ [4, 5, 6],
468
+ ],
469
+ }
470
+
471
+ expect(diffRecord(a, b)).toEqual({
472
+ arr: [ValueOpType.Patch, { '0': [ValueOpType.Append, [4, 5, 6], 3] }],
473
+ })
474
+ })
475
+ })
476
+
477
+ describe('complex scenarios', () => {
478
+ it('[D3] [D4] [D5] handles shape-like record updates', () => {
479
+ const prev = {
480
+ id: 'shape:123',
481
+ type: 'geo',
482
+ x: 100,
483
+ y: 200,
484
+ props: {
485
+ color: 'red',
486
+ size: 'medium',
487
+ geo: 'rectangle',
488
+ },
489
+ meta: {},
490
+ }
491
+
492
+ const next = {
493
+ id: 'shape:123',
494
+ type: 'geo',
495
+ x: 150,
496
+ y: 200,
497
+ props: {
498
+ color: 'blue',
499
+ size: 'medium',
500
+ geo: 'rectangle',
501
+ },
502
+ meta: { timestamp: Date.now() },
503
+ }
504
+
505
+ const diff = diffRecord(prev, next)
506
+ expect(diff).toBeTruthy()
507
+ expect(diff!.x).toEqual([ValueOpType.Put, 150])
508
+ expect(diff!.props).toEqual([ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }])
509
+ expect(diff!.meta).toBeTruthy()
510
+
511
+ // Apply the diff and verify result
512
+ const result = applyObjectDiff(prev, diff!)
513
+ expect(result).toEqual(next)
514
+ })
515
+ })
516
+ })
517
+
518
+ describe('applying diffs (AD)', () => {
519
+ describe('immutability (AD1)', () => {
520
+ it('[AD1] never mutates its input and returns a new object when changes apply', () => {
521
+ const obj = { a: 1, b: 2 }
522
+ const diff: ObjectDiff = { a: [ValueOpType.Put, 5] }
523
+
524
+ const result = applyObjectDiff(obj, diff)
525
+ expect(result).not.toBe(obj)
526
+ expect(result).toEqual({ a: 5, b: 2 })
527
+ expect(obj).toEqual({ a: 1, b: 2 })
528
+ })
529
+
530
+ it('[AD1] returns the same reference when no op had an effect', () => {
531
+ const obj = { a: 1, b: 2 }
532
+ expect(applyObjectDiff(obj, {})).toBe(obj)
533
+ })
534
+
535
+ it('[AD1] [AD2] returns the same reference when a put matches the current value', () => {
536
+ const obj = { a: 1, nested: { x: 1 } }
537
+ const diff: ObjectDiff = {
538
+ a: [ValueOpType.Put, 1],
539
+ nested: [ValueOpType.Put, { x: 1 }],
540
+ }
541
+
542
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
543
+ })
544
+
545
+ it('[AD1] unchanged nested values keep their identity in the copy', () => {
546
+ const obj = { changed: { x: 1 }, untouched: { y: 2 } }
547
+ const diff: ObjectDiff = {
548
+ changed: [ValueOpType.Patch, { x: [ValueOpType.Put, 100] }],
549
+ }
550
+
551
+ const result = applyObjectDiff(obj, diff)
552
+ expect(result).not.toBe(obj)
553
+ expect(result.untouched).toBe(obj.untouched)
554
+ expect(result.changed).not.toBe(obj.changed)
555
+ expect(result).toEqual({ changed: { x: 100 }, untouched: { y: 2 } })
556
+ })
557
+
558
+ it('[AD1] [AD7] unchanged array items keep their identity in the copy', () => {
559
+ const obj = { arr: [{ a: 1 }, { b: 2 }, { c: 3 }] }
560
+ const diff: ObjectDiff = {
561
+ arr: [ValueOpType.Patch, { '1': [ValueOpType.Patch, { b: [ValueOpType.Put, 20] }] }],
562
+ }
563
+
564
+ const result = applyObjectDiff(obj, diff)
565
+ expect(result.arr[1]).toEqual({ b: 20 })
566
+ expect(result.arr[0]).toBe(obj.arr[0])
567
+ expect(result.arr[2]).toBe(obj.arr[2])
568
+ })
569
+ })
570
+
571
+ describe('put ops (AD2)', () => {
572
+ it('[AD2] applies puts that change values and adds new keys', () => {
573
+ const obj = { a: 1, b: 2 }
574
+ const diff: ObjectDiff = {
575
+ a: [ValueOpType.Put, 10],
576
+ c: [ValueOpType.Put, 30],
577
+ }
578
+
579
+ const result = applyObjectDiff(obj, diff)
580
+ expect(result).toEqual({ a: 10, b: 2, c: 30 })
581
+ })
582
+
583
+ it('[AD2] skips puts whose value is deep-equal to the current value', () => {
584
+ const obj = { a: { deep: [1, 2, 3] } }
585
+ const diff: ObjectDiff = { a: [ValueOpType.Put, { deep: [1, 2, 3] }] }
586
+
587
+ const result = applyObjectDiff(obj, diff)
588
+ expect(result).toBe(obj)
589
+ expect(result.a).toBe(obj.a)
590
+ })
591
+ })
592
+
593
+ describe('append ops (AD3)', () => {
594
+ it('[AD3] applies string appends with a matching offset', () => {
595
+ const obj = { text: 'Hello' }
596
+ const diff: ObjectDiff = {
597
+ text: [ValueOpType.Append, ' world', 5],
598
+ }
599
+
600
+ const result = applyObjectDiff(obj, diff)
601
+ expect(result).toEqual({ text: 'Hello world' })
602
+ expect(result).not.toBe(obj)
603
+ })
604
+
605
+ it('[AD3] applies appends from an empty string', () => {
606
+ const obj = { text: '' }
607
+ const diff: ObjectDiff = {
608
+ text: [ValueOpType.Append, 'Hello', 0],
609
+ }
610
+
611
+ expect(applyObjectDiff(obj, diff)).toEqual({ text: 'Hello' })
612
+ })
613
+
614
+ it('[AD3] [AD7] applies array appends with a matching offset', () => {
615
+ const obj = { arr: [1, 2, 3] }
616
+ const diff: ObjectDiff = {
617
+ arr: [ValueOpType.Append, [4, 5], 3],
618
+ }
619
+
620
+ expect(applyObjectDiff(obj, diff)).toEqual({ arr: [1, 2, 3, 4, 5] })
621
+ })
622
+
623
+ it('[AD3] ignores append ops with a mismatched offset', () => {
624
+ const obj = { text: 'Hello' }
625
+ const diff: ObjectDiff = {
626
+ text: [ValueOpType.Append, ' world', 10], // wrong offset
627
+ }
628
+
629
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
630
+ })
631
+
632
+ it('[AD3] ignores array append ops with a mismatched offset', () => {
633
+ const obj = { arr: [1, 2, 3] }
634
+ const diff: ObjectDiff = {
635
+ arr: [ValueOpType.Append, [4, 5], 5], // wrong offset
636
+ }
637
+
638
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
639
+ })
640
+
641
+ it('[AD3] ignores append ops on a value of mismatched type', () => {
642
+ const obj = { text: 123 }
643
+ const diff: ObjectDiff = {
644
+ text: [ValueOpType.Append, ' world', 3],
645
+ }
646
+
647
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
648
+ })
649
+
650
+ it('[AD3] ignores a string append op targeting an array', () => {
651
+ const obj = { value: [1, 2, 3] }
652
+ const diff: ObjectDiff = {
653
+ value: [ValueOpType.Append, 'abc', 3],
654
+ }
655
+
656
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
657
+ })
658
+
659
+ it('[AD3] applies multiple append ops in one diff', () => {
660
+ const obj = { a: 'Hello', b: 'Foo' }
661
+ const diff: ObjectDiff = {
662
+ a: [ValueOpType.Append, ' world', 5],
663
+ b: [ValueOpType.Append, 'bar', 3],
664
+ }
665
+
666
+ expect(applyObjectDiff(obj, diff)).toEqual({ a: 'Hello world', b: 'Foobar' })
667
+ })
668
+ })
669
+
670
+ describe('patch ops (AD4)', () => {
671
+ it('[AD4] applies nested object patches', () => {
672
+ const obj = { a: 1, nested: { x: 10, y: 20 } }
673
+ const diff: ObjectDiff = {
674
+ nested: [ValueOpType.Patch, { x: [ValueOpType.Put, 100] }],
675
+ }
676
+
677
+ const result = applyObjectDiff(obj, diff)
678
+ expect(result).toEqual({ a: 1, nested: { x: 100, y: 20 } })
679
+ expect(result.nested).not.toBe(obj.nested)
680
+ })
681
+
682
+ it('[AD4] applies deeply nested patches', () => {
683
+ const obj = {
684
+ level1: {
685
+ level2: {
686
+ level3: { value: 'old' },
687
+ },
688
+ },
689
+ }
690
+ const diff: ObjectDiff = {
691
+ level1: [
692
+ ValueOpType.Patch,
693
+ {
694
+ level2: [
695
+ ValueOpType.Patch,
696
+ {
697
+ level3: [
698
+ ValueOpType.Patch,
699
+ {
700
+ value: [ValueOpType.Put, 'new'],
701
+ },
702
+ ],
703
+ },
704
+ ],
705
+ },
706
+ ],
707
+ }
708
+
709
+ const result = applyObjectDiff(obj, diff)
710
+ expect(result.level1.level2.level3.value).toBe('new')
711
+ })
712
+
713
+ it('[AD4] ignores patches on missing keys', () => {
714
+ const obj = { a: 1 }
715
+ const diff: ObjectDiff = {
716
+ missing: [ValueOpType.Patch, { x: [ValueOpType.Put, 1] }],
717
+ }
718
+
719
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
720
+ })
721
+
722
+ it('[AD4] ignores patches on primitive values', () => {
723
+ const obj = { a: 1, s: 'hello', n: null }
724
+ const diff: ObjectDiff = {
725
+ a: [ValueOpType.Patch, { x: [ValueOpType.Put, 1] }],
726
+ s: [ValueOpType.Patch, { x: [ValueOpType.Put, 1] }],
727
+ n: [ValueOpType.Patch, { x: [ValueOpType.Put, 1] }],
728
+ }
729
+
730
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
731
+ })
732
+ })
733
+
734
+ describe('delete ops (AD5)', () => {
735
+ it('[AD5] removes a key when present', () => {
736
+ const obj = { a: 1, b: 2, c: 3 }
737
+ const diff: ObjectDiff = { b: [ValueOpType.Delete] }
738
+
739
+ const result = applyObjectDiff(obj, diff)
740
+ expect(result).toEqual({ a: 1, c: 3 })
741
+ expect('b' in result).toBe(false)
742
+ })
743
+
744
+ it('[AD5] deleting an absent key has no effect', () => {
745
+ const obj = { a: 1 }
746
+ const diff: ObjectDiff = { b: [ValueOpType.Delete] }
747
+
748
+ expect(applyObjectDiff(obj, diff)).toBe(obj)
749
+ })
750
+ })
751
+
752
+ describe('patching non-objects (AD6)', () => {
753
+ it('[AD6] patching null returns the input unchanged', () => {
754
+ const diff: ObjectDiff = { a: [ValueOpType.Put, 1] }
755
+ expect(applyObjectDiff(null as any, diff)).toBe(null)
756
+ })
757
+
758
+ it('[AD6] patching a primitive returns the input unchanged', () => {
759
+ const diff: ObjectDiff = { a: [ValueOpType.Put, 1] }
760
+ expect(applyObjectDiff('hello' as any, diff)).toBe('hello')
761
+ expect(applyObjectDiff(42 as any, diff)).toBe(42)
762
+ })
763
+ })
764
+
765
+ describe('arrays (AD7)', () => {
766
+ it('[AD7] clones arrays as arrays and indexes ops by numeric string keys', () => {
767
+ const obj = { arr: [{ a: 1 }, { b: 2 }, { c: 3 }] }
768
+ const diff: ObjectDiff = {
769
+ arr: [
770
+ ValueOpType.Patch,
771
+ {
772
+ '1': [ValueOpType.Patch, { b: [ValueOpType.Put, 20] }],
773
+ },
774
+ ],
775
+ }
776
+
777
+ const result = applyObjectDiff(obj, diff)
778
+ expect(Array.isArray(result.arr)).toBe(true)
779
+ expect(result.arr).toEqual([{ a: 1 }, { b: 20 }, { c: 3 }])
780
+ expect(result.arr).not.toBe(obj.arr)
781
+ })
782
+
783
+ it('[AD7] puts values into array indexes by numeric string key', () => {
784
+ const obj = { arr: [1, 2, 3] }
785
+ const diff: ObjectDiff = {
786
+ arr: [ValueOpType.Patch, { '1': [ValueOpType.Put, 9] }],
787
+ }
788
+
789
+ const result = applyObjectDiff(obj, diff)
790
+ expect(Array.isArray(result.arr)).toBe(true)
791
+ expect(result.arr).toEqual([1, 9, 3])
792
+ })
793
+ })
794
+ })
795
+
796
+ describe('getNetworkDiff (ND1)', () => {
797
+ it('[ND1] returns null for an empty records diff', () => {
798
+ const diff = { added: {}, updated: {}, removed: {} }
799
+ expect(getNetworkDiff(diff)).toBeNull()
800
+ })
801
+
802
+ it('[ND1] maps added records to put ops', () => {
803
+ const record = { id: 'test:1', type: 'test', data: 'value' }
804
+ const diff = {
805
+ added: { 'test:1': record },
806
+ updated: {},
807
+ removed: {},
808
+ }
809
+
810
+ expect(getNetworkDiff(diff)).toEqual({
811
+ 'test:1': [RecordOpType.Put, record],
812
+ })
813
+ })
814
+
815
+ it('[ND1] maps removed records to remove ops', () => {
816
+ const diff = {
817
+ added: {},
818
+ updated: {},
819
+ removed: { 'test:1': { id: 'test:1', type: 'test' } },
820
+ }
821
+
822
+ expect(getNetworkDiff(diff)).toEqual({
823
+ 'test:1': [RecordOpType.Remove],
824
+ })
825
+ })
826
+
827
+ it('[ND1] maps updated records to patch ops computed with diffRecord', () => {
828
+ const prev = { id: 'test:1', type: 'test', x: 100, y: 200 }
829
+ const next = { id: 'test:1', type: 'test', x: 150, y: 200 }
830
+ const diff = {
831
+ added: {},
832
+ updated: { 'test:1': [prev, next] },
833
+ removed: {},
834
+ }
835
+
836
+ expect(getNetworkDiff(diff)).toEqual({
837
+ 'test:1': [RecordOpType.Patch, { x: [ValueOpType.Put, 150] }],
838
+ })
839
+ })
840
+
841
+ it('[ND1] omits updated entries that compute to no diff, returning null when nothing remains', () => {
842
+ const record = { id: 'test:1', type: 'test', x: 100 }
843
+ const diff = {
844
+ added: {},
845
+ updated: { 'test:1': [record, record] },
846
+ removed: {},
847
+ }
848
+
849
+ expect(getNetworkDiff(diff)).toBeNull()
850
+ })
851
+
852
+ it('[ND1] handles mixed operations', () => {
853
+ const addedRecord = { id: 'test:1', type: 'test', data: 'new' }
854
+ const prevRecord = { id: 'test:2', type: 'test', x: 100 }
855
+ const nextRecord = { id: 'test:2', type: 'test', x: 200 }
856
+ const removedRecord = { id: 'test:3', type: 'test' }
857
+
858
+ const diff = {
859
+ added: { 'test:1': addedRecord },
860
+ updated: { 'test:2': [prevRecord, nextRecord] },
861
+ removed: { 'test:3': removedRecord },
862
+ }
863
+
864
+ expect(getNetworkDiff(diff)).toEqual({
865
+ 'test:1': [RecordOpType.Put, addedRecord],
866
+ 'test:2': [RecordOpType.Patch, { x: [ValueOpType.Put, 200] }],
867
+ 'test:3': [RecordOpType.Remove],
868
+ })
869
+ })
870
+
871
+ it('[ND1] [D5] produces append patches for string growth in updates', () => {
872
+ const prev = { id: 'shape:1', type: 'text', text: 'Hello' }
873
+ const next = { id: 'shape:1', type: 'text', text: 'Hello world' }
874
+
875
+ const recordsDiff = {
876
+ added: {},
877
+ updated: { 'shape:1': [prev, next] },
878
+ removed: {},
879
+ }
880
+
881
+ expect(getNetworkDiff(recordsDiff)).toEqual({
882
+ 'shape:1': [RecordOpType.Patch, { text: [ValueOpType.Append, ' world', 5] }],
883
+ })
884
+ })
885
+ })