@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.
- package/dist-cjs/index.d.ts +605 -75
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +144 -0
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js +3 -0
- package/dist-cjs/lib/RoomSession.js.map +2 -2
- package/dist-cjs/lib/ServerSocketAdapter.js +23 -0
- package/dist-cjs/lib/ServerSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/TLRemoteSyncError.js +8 -0
- package/dist-cjs/lib/TLRemoteSyncError.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +280 -56
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +45 -2
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +161 -16
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +30 -0
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-cjs/lib/findMin.js.map +2 -2
- package/dist-cjs/lib/interval.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-cjs/lib/server-types.js.map +1 -1
- package/dist-esm/index.d.mts +605 -75
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +144 -0
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs +3 -0
- package/dist-esm/lib/RoomSession.mjs.map +2 -2
- package/dist-esm/lib/ServerSocketAdapter.mjs +23 -0
- package/dist-esm/lib/ServerSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/TLRemoteSyncError.mjs +8 -0
- package/dist-esm/lib/TLRemoteSyncError.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +280 -56
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +45 -2
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +161 -16
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +30 -0
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/dist-esm/lib/findMin.mjs.map +2 -2
- package/dist-esm/lib/interval.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/lib/ClientWebSocketAdapter.test.ts +712 -129
- package/src/lib/ClientWebSocketAdapter.ts +240 -9
- package/src/lib/RoomSession.test.ts +97 -0
- package/src/lib/RoomSession.ts +105 -3
- package/src/lib/ServerSocketAdapter.test.ts +228 -0
- package/src/lib/ServerSocketAdapter.ts +124 -5
- package/src/lib/TLRemoteSyncError.ts +50 -1
- package/src/lib/TLSocketRoom.ts +377 -60
- package/src/lib/TLSyncClient.test.ts +828 -0
- package/src/lib/TLSyncClient.ts +251 -26
- package/src/lib/TLSyncRoom.ts +284 -24
- package/src/lib/chunk.ts +72 -1
- package/src/lib/diff.ts +128 -14
- package/src/lib/findMin.ts +6 -0
- package/src/lib/interval.ts +40 -0
- package/src/lib/protocol.ts +185 -7
- package/src/lib/server-types.test.ts +44 -0
- package/src/lib/server-types.ts +45 -1
- package/src/test/TLSocketRoom.test.ts +438 -29
- package/src/test/chunk.test.ts +200 -3
- package/src/test/diff.test.ts +396 -1
package/src/test/chunk.test.ts
CHANGED
|
@@ -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
|
})
|
package/src/test/diff.test.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
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
|
+
})
|