@tldraw/sync-core 5.5.0 → 5.6.0-canary.5bfc37740468
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 +1 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +5 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +30 -44
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js +2 -2
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/diff.js +15 -7
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-esm/index.d.mts +1 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +5 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +30 -44
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs +16 -8
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/package.json +18 -18
- package/src/lib/InMemorySyncStorage.ts +2 -2
- package/src/lib/SQLiteSyncStorage.ts +2 -2
- package/src/lib/TLSyncClient.test.ts +61 -0
- package/src/lib/TLSyncClient.ts +9 -0
- package/src/lib/TLSyncRoom.ts +51 -48
- package/src/lib/TLSyncStorage.ts +7 -2
- package/src/lib/diff.test.ts +91 -1
- package/src/lib/diff.ts +30 -9
- package/src/test/TLSyncRoom.test.ts +124 -0
- package/src/test/upgradeDowngrade.test.ts +21 -0
package/src/lib/diff.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RecordsDiff, UnknownRecord } from '@tldraw/store'
|
|
2
|
-
import { isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'
|
|
2
|
+
import { hasOwnProperty, isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Constants representing the types of operations that can be applied to records in network diffs.
|
|
@@ -200,15 +200,21 @@ function diffObject(
|
|
|
200
200
|
return null
|
|
201
201
|
}
|
|
202
202
|
let result: ObjectDiff | null = null
|
|
203
|
+
// Own-property checks throughout: `in` would see Object.prototype members, so a user key named
|
|
204
|
+
// e.g. 'constructor' would never diff as an add or delete. And a key whose value is `undefined`
|
|
205
|
+
// is treated as absent: `undefined` doesn't survive JSON, so `['put', undefined]` would arrive
|
|
206
|
+
// as `['put', null]` and fail validators that accept only the missing key.
|
|
203
207
|
for (const key of Object.keys(prev)) {
|
|
204
|
-
//
|
|
205
|
-
if (
|
|
208
|
+
// `result['__proto__'] = op` would set the diff's prototype, and applyObjectDiff drops the key (AD8)
|
|
209
|
+
if (key === '__proto__') continue
|
|
210
|
+
const prevValue = (prev as any)[key]
|
|
211
|
+
if (prevValue === undefined) continue
|
|
212
|
+
const nextValue = hasOwnProperty(next, key) ? (next as any)[key] : undefined
|
|
213
|
+
if (nextValue === undefined) {
|
|
206
214
|
if (!result) result = {}
|
|
207
215
|
result[key] = [ValueOpType.Delete]
|
|
208
216
|
continue
|
|
209
217
|
}
|
|
210
|
-
const prevValue = (prev as any)[key]
|
|
211
|
-
const nextValue = (next as any)[key]
|
|
212
218
|
if (
|
|
213
219
|
nestedKeys?.has(key) ||
|
|
214
220
|
(Array.isArray(prevValue) && Array.isArray(nextValue)) ||
|
|
@@ -226,10 +232,13 @@ function diffObject(
|
|
|
226
232
|
}
|
|
227
233
|
}
|
|
228
234
|
for (const key of Object.keys(next)) {
|
|
235
|
+
if (key === '__proto__') continue
|
|
236
|
+
const nextValue = (next as any)[key]
|
|
237
|
+
if (nextValue === undefined) continue
|
|
229
238
|
// if key is in next but not in prev then it was added
|
|
230
|
-
if (!(key
|
|
239
|
+
if (!hasOwnProperty(prev, key) || (prev as any)[key] === undefined) {
|
|
231
240
|
if (!result) result = {}
|
|
232
|
-
result[key] = [ValueOpType.Put,
|
|
241
|
+
result[key] = [ValueOpType.Put, nextValue]
|
|
233
242
|
}
|
|
234
243
|
}
|
|
235
244
|
return result
|
|
@@ -245,7 +254,15 @@ function diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean):
|
|
|
245
254
|
return [ValueOpType.Append, appendedText, valueA.length]
|
|
246
255
|
}
|
|
247
256
|
return [ValueOpType.Put, valueB]
|
|
248
|
-
} else if (
|
|
257
|
+
} else if (
|
|
258
|
+
!valueA ||
|
|
259
|
+
!valueB ||
|
|
260
|
+
typeof valueA !== 'object' ||
|
|
261
|
+
typeof valueB !== 'object' ||
|
|
262
|
+
// an array on one side and a plain object on the other can't be expressed as a patch:
|
|
263
|
+
// index deletes and named puts don't apply cleanly to either shape
|
|
264
|
+
Array.isArray(valueA) !== Array.isArray(valueB)
|
|
265
|
+
) {
|
|
249
266
|
return isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]
|
|
250
267
|
} else {
|
|
251
268
|
const diff = diffObject(valueA, valueB, undefined, legacyAppendMode)
|
|
@@ -348,6 +365,10 @@ export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectD
|
|
|
348
365
|
}
|
|
349
366
|
}
|
|
350
367
|
for (const [key, op] of Object.entries(objectDiff)) {
|
|
368
|
+
// Diffs come off the wire from untrusted peers. `newObject['__proto__'] = v` would set the
|
|
369
|
+
// record's prototype (JSON.parse creates '__proto__' as an own key), so a client could give
|
|
370
|
+
// server-held records attacker-controlled inherited fields; skip such keys outright.
|
|
371
|
+
if (key === '__proto__') continue
|
|
351
372
|
switch (op[0]) {
|
|
352
373
|
case ValueOpType.Put: {
|
|
353
374
|
const value = op[1]
|
|
@@ -383,7 +404,7 @@ export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectD
|
|
|
383
404
|
break
|
|
384
405
|
}
|
|
385
406
|
case ValueOpType.Delete: {
|
|
386
|
-
if (key
|
|
407
|
+
if (hasOwnProperty(object, key)) {
|
|
387
408
|
if (!newObject) {
|
|
388
409
|
if (isArray) {
|
|
389
410
|
console.error("Can't delete array item yet (this should never happen)")
|
|
@@ -2482,6 +2482,130 @@ describe('26. Session lifecycle (SES)', () => {
|
|
|
2482
2482
|
expect(socket.__lastMessage).toEqual({ type: 'pong' })
|
|
2483
2483
|
})
|
|
2484
2484
|
|
|
2485
|
+
it('[SES5] no session receives string-append ops while a client without append support is connected', () => {
|
|
2486
|
+
vi.useFakeTimers()
|
|
2487
|
+
const { room } = makeRoom()
|
|
2488
|
+
const v8 = connectSession(room, 'v8', { protocolVersion: getTlsyncProtocolVersion() })
|
|
2489
|
+
const v7 = connectSession(room, 'v7', { protocolVersion: 7 })
|
|
2490
|
+
expect(room.getCanEmitStringAppend()).toBe(false)
|
|
2491
|
+
|
|
2492
|
+
const hasAppend = (msg: any) => JSON.stringify(msg).includes('"append"')
|
|
2493
|
+
|
|
2494
|
+
// a full put over an existing record whose only change is a string append
|
|
2495
|
+
room.handleMessage('v8', {
|
|
2496
|
+
type: 'push',
|
|
2497
|
+
clientClock: 1,
|
|
2498
|
+
diff: { [pageRecord.id]: ['put', { ...pageRecord, name: pageRecord.name + ' more' }] },
|
|
2499
|
+
} as TLPushRequest<TLRecord>)
|
|
2500
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
2501
|
+
expect(sentDataMessages(v7)).toEqual([
|
|
2502
|
+
{
|
|
2503
|
+
type: 'patch',
|
|
2504
|
+
diff: { [pageRecord.id]: ['patch', { name: ['put', pageRecord.name + ' more'] }] },
|
|
2505
|
+
serverClock: 1,
|
|
2506
|
+
},
|
|
2507
|
+
])
|
|
2508
|
+
expect(sentDataMessages(v8).some(hasAppend)).toBe(false)
|
|
2509
|
+
clearSocket(v7)
|
|
2510
|
+
clearSocket(v8)
|
|
2511
|
+
|
|
2512
|
+
// a patch whose recomputed broadcast would otherwise be an append
|
|
2513
|
+
room.handleMessage('v8', {
|
|
2514
|
+
type: 'push',
|
|
2515
|
+
clientClock: 2,
|
|
2516
|
+
diff: { [pageRecord.id]: ['patch', { name: ['put', pageRecord.name + ' more and more'] }] },
|
|
2517
|
+
} as TLPushRequest<TLRecord>)
|
|
2518
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
2519
|
+
expect(sentDataMessages(v7)).toEqual([
|
|
2520
|
+
{
|
|
2521
|
+
type: 'patch',
|
|
2522
|
+
diff: {
|
|
2523
|
+
[pageRecord.id]: ['patch', { name: ['put', pageRecord.name + ' more and more'] }],
|
|
2524
|
+
},
|
|
2525
|
+
serverClock: 2,
|
|
2526
|
+
},
|
|
2527
|
+
])
|
|
2528
|
+
expect(sentDataMessages(v8)).toEqual([
|
|
2529
|
+
{ type: 'push_result', clientClock: 2, serverClock: 2, action: 'commit' },
|
|
2530
|
+
])
|
|
2531
|
+
})
|
|
2532
|
+
|
|
2533
|
+
it('[SES6] handleResumedSession rejects a session whose schema the server can no longer reconcile', () => {
|
|
2534
|
+
const { room } = makeRoom()
|
|
2535
|
+
const socket = makeSocket()
|
|
2536
|
+
const newerSchema: SerializedSchemaV2 = {
|
|
2537
|
+
schemaVersion: 2,
|
|
2538
|
+
sequences: {
|
|
2539
|
+
...(room.serializedSchema as SerializedSchemaV2).sequences,
|
|
2540
|
+
'com.tldraw.store': 999,
|
|
2541
|
+
},
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
room.handleResumedSession({
|
|
2545
|
+
sessionId: 'resumed',
|
|
2546
|
+
socket,
|
|
2547
|
+
meta: undefined,
|
|
2548
|
+
isReadonly: false,
|
|
2549
|
+
serializedSchema: newerSchema,
|
|
2550
|
+
presenceId: null,
|
|
2551
|
+
presenceRecord: null,
|
|
2552
|
+
requiresLegacyRejection: false,
|
|
2553
|
+
supportsStringAppend: true,
|
|
2554
|
+
})
|
|
2555
|
+
|
|
2556
|
+
expect(room.sessions.has('resumed')).toBe(false)
|
|
2557
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
2558
|
+
TLSyncErrorCloseEventCode,
|
|
2559
|
+
TLSyncErrorCloseEventReason.SERVER_TOO_OLD
|
|
2560
|
+
)
|
|
2561
|
+
})
|
|
2562
|
+
|
|
2563
|
+
it('[SES6] rejecting a resumed session removes its restored presence for other sessions', () => {
|
|
2564
|
+
vi.useFakeTimers()
|
|
2565
|
+
const { room } = makeRoom()
|
|
2566
|
+
const makePresence = (name: string) =>
|
|
2567
|
+
InstancePresenceRecordType.create({
|
|
2568
|
+
id: InstancePresenceRecordType.createId(name),
|
|
2569
|
+
currentPageId: pageRecord.id,
|
|
2570
|
+
userId: createUserId(name),
|
|
2571
|
+
userName: name,
|
|
2572
|
+
})
|
|
2573
|
+
const resume = (sessionId: string, serializedSchema: SerializedSchema) => {
|
|
2574
|
+
const socket = makeSocket()
|
|
2575
|
+
const presenceRecord = makePresence(sessionId)
|
|
2576
|
+
room.handleResumedSession({
|
|
2577
|
+
sessionId,
|
|
2578
|
+
socket,
|
|
2579
|
+
meta: undefined,
|
|
2580
|
+
isReadonly: false,
|
|
2581
|
+
serializedSchema,
|
|
2582
|
+
presenceId: presenceRecord.id,
|
|
2583
|
+
presenceRecord,
|
|
2584
|
+
requiresLegacyRejection: false,
|
|
2585
|
+
supportsStringAppend: true,
|
|
2586
|
+
})
|
|
2587
|
+
return { socket, presenceId: presenceRecord.id }
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
const other = resume('other', room.serializedSchema)
|
|
2591
|
+
const rejected = resume('rejected', {
|
|
2592
|
+
schemaVersion: 2,
|
|
2593
|
+
sequences: {
|
|
2594
|
+
...(room.serializedSchema as SerializedSchemaV2).sequences,
|
|
2595
|
+
'com.tldraw.store': 999,
|
|
2596
|
+
},
|
|
2597
|
+
})
|
|
2598
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL * 2)
|
|
2599
|
+
|
|
2600
|
+
expect(room.sessions.has('rejected')).toBe(false)
|
|
2601
|
+
expect(room.presenceStore.get(rejected.presenceId)).toBeUndefined()
|
|
2602
|
+
// other clients still hold this presence from before the socket slept
|
|
2603
|
+
expect(sentDataMessages(other.socket).at(-1)).toMatchObject({
|
|
2604
|
+
type: 'patch',
|
|
2605
|
+
diff: { [rejected.presenceId]: [RecordOpType.Remove] },
|
|
2606
|
+
})
|
|
2607
|
+
})
|
|
2608
|
+
|
|
2485
2609
|
it('[SES7] a message from an unknown session id logs a warning and is ignored', () => {
|
|
2486
2610
|
const warn = vi.fn()
|
|
2487
2611
|
const { room } = makeRoom({ log: { warn } })
|
|
@@ -413,6 +413,27 @@ it('[HS3] out-of-date clients will receive incompatibility errors', () => {
|
|
|
413
413
|
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
414
414
|
})
|
|
415
415
|
|
|
416
|
+
it.each([
|
|
417
|
+
['a v2 schema without sequences', { schemaVersion: 2 }],
|
|
418
|
+
['a v2 schema with null sequences', { schemaVersion: 2, sequences: null }],
|
|
419
|
+
['a null schema', null],
|
|
420
|
+
])('[HS3] clients sending %s are rejected as too old', (_, schema) => {
|
|
421
|
+
const server = new TestServer(schemaV3)
|
|
422
|
+
const id = 'test_malformed_schema'
|
|
423
|
+
const socket = mockSocket()
|
|
424
|
+
|
|
425
|
+
server.room.handleNewSession({ sessionId: id, socket, meta: undefined, isReadonly: false })
|
|
426
|
+
server.room.handleMessage(id, {
|
|
427
|
+
type: 'connect',
|
|
428
|
+
connectRequestId: 'test',
|
|
429
|
+
lastServerClock: 0,
|
|
430
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
431
|
+
schema: schema as any,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
435
|
+
})
|
|
436
|
+
|
|
416
437
|
it('[HS2] clients using an out-of-date protocol will receive compatibility errors', () => {
|
|
417
438
|
const actualVersion = getTlsyncProtocolVersion()
|
|
418
439
|
mockGetTlsyncProtocolVersion.mockReturnValue(actualVersion + 1)
|