@tldraw/sync-core 5.4.0 → 5.5.0-canary.05a393565877
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.js +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +30 -3
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +10 -1
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +30 -3
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +10 -1
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/package.json +6 -6
- package/src/lib/SQLiteSyncStorage.test.ts +60 -1
- package/src/lib/SQLiteSyncStorage.ts +22 -14
- package/src/lib/TLSyncRoom.ts +39 -3
- package/src/lib/chunk.test.ts +34 -0
- package/src/lib/chunk.ts +20 -1
- package/src/test/TLSyncRoom.test.ts +105 -0
|
@@ -394,7 +394,6 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
|
|
|
394
394
|
const documentClock = snapshot.documentClock ?? snapshot.clock ?? 0
|
|
395
395
|
const tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? documentClock
|
|
396
396
|
|
|
397
|
-
// Clear existing data
|
|
398
397
|
this.sql.exec(`
|
|
399
398
|
DELETE FROM ${documentsTable};
|
|
400
399
|
DELETE FROM ${objectsTable};
|
|
@@ -410,14 +409,12 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
|
|
|
410
409
|
table.insert.run(doc.state.id, encodeState(doc.state), doc.lastChangedClock)
|
|
411
410
|
}
|
|
412
411
|
|
|
413
|
-
// Insert tombstones
|
|
414
412
|
if (snapshot.tombstones) {
|
|
415
413
|
for (const [id, clock] of objectMapEntries(snapshot.tombstones)) {
|
|
416
414
|
this.stmts.insertTombstone.run(id, clock)
|
|
417
415
|
}
|
|
418
416
|
}
|
|
419
417
|
|
|
420
|
-
// Insert metadata row
|
|
421
418
|
this.stmts.updateMetadata.run(
|
|
422
419
|
documentClock,
|
|
423
420
|
tombstoneHistoryStartsAtClock,
|
|
@@ -513,18 +510,29 @@ export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage
|
|
|
513
510
|
/** @internal */
|
|
514
511
|
pruneTombstones = throttle(
|
|
515
512
|
() => {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
this.stmts.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
513
|
+
// Runs from a timer, so a host that closed the database right after the last delete
|
|
514
|
+
// (e.g. `room.close(); db.close()` in onSessionRemoved) would otherwise get an uncaught
|
|
515
|
+
// throw from here; pruning is best-effort and simply runs again on the next delete.
|
|
516
|
+
try {
|
|
517
|
+
const tombstoneCount = this.stmts.countTombstones.all()[0].count as number
|
|
518
|
+
if (tombstoneCount > MAX_TOMBSTONES) {
|
|
519
|
+
// Get all tombstones sorted by clock ascending (oldest first)
|
|
520
|
+
const tombstones = this.stmts.iterateTombstones.all()
|
|
521
|
+
|
|
522
|
+
const result = computeTombstonePruning({ tombstones, documentClock: this.getClock() })
|
|
523
|
+
if (result) {
|
|
524
|
+
this.sql.transaction(() => {
|
|
525
|
+
this.stmts.setTombstoneHistoryStartsAtClock.run(
|
|
526
|
+
result.newTombstoneHistoryStartsAtClock
|
|
527
|
+
)
|
|
528
|
+
// Delete all tombstones with clock < newTombstoneHistoryStartsAtClock in one operation.
|
|
529
|
+
// This works because computeTombstonePruning ensures we never split a clock value.
|
|
530
|
+
this.stmts.deleteTombstonesBefore.run(result.newTombstoneHistoryStartsAtClock)
|
|
531
|
+
})
|
|
532
|
+
}
|
|
527
533
|
}
|
|
534
|
+
} catch (e) {
|
|
535
|
+
console.error('Failed to prune tombstones', e)
|
|
528
536
|
}
|
|
529
537
|
},
|
|
530
538
|
1000,
|
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -258,7 +258,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
258
258
|
}, 1000)
|
|
259
259
|
|
|
260
260
|
private scheduleFollowUpPrune() {
|
|
261
|
-
|
|
261
|
+
// don't leave a stray timer on a room the host has already torn down
|
|
262
|
+
if (this._isClosed || this.pruneTimer) return
|
|
262
263
|
this.pruneTimer = setTimeout(this.pruneSessions, SESSION_REMOVAL_WAIT_TIME + 100)
|
|
263
264
|
}
|
|
264
265
|
|
|
@@ -273,11 +274,27 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
273
274
|
* and stops background processes.
|
|
274
275
|
*/
|
|
275
276
|
close() {
|
|
277
|
+
this._isClosed = true
|
|
276
278
|
this.disposables.forEach((d) => d())
|
|
277
279
|
this.sessions.forEach((session) => {
|
|
278
|
-
|
|
280
|
+
this.clearDebounceTimer(session)
|
|
281
|
+
try {
|
|
282
|
+
session.socket.close()
|
|
283
|
+
} catch {
|
|
284
|
+
// noop, one bad socket must not leave the rest open
|
|
285
|
+
}
|
|
279
286
|
})
|
|
280
|
-
|
|
287
|
+
// forgetting the sessions is what makes late socket close/error events no-ops, so
|
|
288
|
+
// nothing can emit session_removed / room_became_empty on a closed room
|
|
289
|
+
this.sessions.clear()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private clearDebounceTimer(session: RoomSession<R, SessionMeta>) {
|
|
293
|
+
if (session.state === RoomSessionState.Connected && session.debounceTimer !== null) {
|
|
294
|
+
clearTimeout(session.debounceTimer)
|
|
295
|
+
session.debounceTimer = null
|
|
296
|
+
session.outstandingDataMessages = []
|
|
297
|
+
}
|
|
281
298
|
}
|
|
282
299
|
|
|
283
300
|
/**
|
|
@@ -519,6 +536,12 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
519
536
|
// place, so sockets that defer serialization don't see an emptied array
|
|
520
537
|
const data = session.outstandingDataMessages
|
|
521
538
|
session.outstandingDataMessages = []
|
|
539
|
+
if (!session.socket.isOpen) {
|
|
540
|
+
// same as the immediate-send path: send() into a closed socket throws on
|
|
541
|
+
// some runtimes (Cloudflare), and here that would be from inside a timer
|
|
542
|
+
this.cancelSession(sessionId)
|
|
543
|
+
return
|
|
544
|
+
}
|
|
522
545
|
session.socket.sendMessage({ type: 'data', data })
|
|
523
546
|
}
|
|
524
547
|
}
|
|
@@ -532,6 +555,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
532
555
|
}
|
|
533
556
|
|
|
534
557
|
this.sessions.delete(sessionId)
|
|
558
|
+
this.clearDebounceTimer(session)
|
|
535
559
|
|
|
536
560
|
try {
|
|
537
561
|
if (fatalReason) {
|
|
@@ -570,6 +594,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
570
594
|
return
|
|
571
595
|
}
|
|
572
596
|
|
|
597
|
+
this.clearDebounceTimer(session)
|
|
573
598
|
this.sessions.set(sessionId, {
|
|
574
599
|
state: RoomSessionState.AwaitingRemoval,
|
|
575
600
|
sessionId,
|
|
@@ -692,7 +717,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
692
717
|
objectAccess?: TLObjectStoreAccess
|
|
693
718
|
}) {
|
|
694
719
|
const { sessionId, socket, meta, isReadonly, objectAccess } = opts
|
|
720
|
+
// a connect racing close() would otherwise create a session nothing ever prunes
|
|
721
|
+
if (this._isClosed) {
|
|
722
|
+
socket.close()
|
|
723
|
+
return this
|
|
724
|
+
}
|
|
695
725
|
const existing = this.sessions.get(sessionId)
|
|
726
|
+
if (existing) this.clearDebounceTimer(existing)
|
|
696
727
|
this.sessions.set(sessionId, {
|
|
697
728
|
state: RoomSessionState.AwaitingConnectMessage,
|
|
698
729
|
sessionId,
|
|
@@ -741,6 +772,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
741
772
|
supportsStringAppend,
|
|
742
773
|
} = opts
|
|
743
774
|
|
|
775
|
+
if (this._isClosed) {
|
|
776
|
+
socket.close()
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
|
|
744
780
|
const migrations = this.schema.getMigrationsSince(serializedSchema)
|
|
745
781
|
const requiresDownMigrations = migrations.ok ? migrations.value.length > 0 : false
|
|
746
782
|
|
package/src/lib/chunk.test.ts
CHANGED
|
@@ -134,6 +134,40 @@ describe('chunk (CH1–CH3)', () => {
|
|
|
134
134
|
`)
|
|
135
135
|
})
|
|
136
136
|
})
|
|
137
|
+
|
|
138
|
+
describe('surrogate pairs (CH9)', () => {
|
|
139
|
+
it('[CH9] never splits a surrogate pair across two chunks', () => {
|
|
140
|
+
// WebSocket.send converts each chunk to a USVString, so a lone surrogate would arrive
|
|
141
|
+
// as U+FFFD and the reassembled message would carry corrupted text
|
|
142
|
+
const emoji = '\u{1F600}'
|
|
143
|
+
const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff
|
|
144
|
+
const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff
|
|
145
|
+
const msgs = [
|
|
146
|
+
...Array.from(
|
|
147
|
+
{ length: 8 },
|
|
148
|
+
(_, padding) => 'x'.repeat(padding) + emoji + 'y'.repeat(6) + emoji
|
|
149
|
+
),
|
|
150
|
+
// adjacent pairs: after a nudge the next boundary lands on another pair
|
|
151
|
+
'x' + emoji.repeat(3),
|
|
152
|
+
]
|
|
153
|
+
for (const msg of msgs) {
|
|
154
|
+
for (const maxSize of [3, 4, 5, 6, 7, 8]) {
|
|
155
|
+
const chunks = chunk(msg, maxSize)
|
|
156
|
+
const bodies = chunks.map((c) => c.slice(c.indexOf('_') + 1))
|
|
157
|
+
expect(bodies.join('')).toBe(msg)
|
|
158
|
+
for (const [i, body] of bodies.entries()) {
|
|
159
|
+
expect(body.length).toBeGreaterThan(0)
|
|
160
|
+
expect(isHigh(body.charCodeAt(body.length - 1))).toBe(false)
|
|
161
|
+
expect(isLow(body.charCodeAt(0))).toBe(false)
|
|
162
|
+
// keeping a pair whole may cost one character over the CH3 bound, never more
|
|
163
|
+
expect(chunks[i].length).toBeLessThanOrEqual(
|
|
164
|
+
Math.max(maxSize, chunks[i].indexOf('_') + 2) + 1
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
})
|
|
137
171
|
})
|
|
138
172
|
|
|
139
173
|
const testObject = {} as any
|
package/src/lib/chunk.ts
CHANGED
|
@@ -38,7 +38,18 @@ export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
|
|
|
38
38
|
let offset = msg.length
|
|
39
39
|
while (offset > 0) {
|
|
40
40
|
const prefix = `${chunkNumber}_`
|
|
41
|
-
|
|
41
|
+
let chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)
|
|
42
|
+
// Never split a surrogate pair across chunks: WebSocket.send converts each chunk to a
|
|
43
|
+
// USVString, turning a lone surrogate into U+FFFD, so the reassembled JSON silently
|
|
44
|
+
// carries corrupted text. Shrink by one so the pair moves whole into the preceding
|
|
45
|
+
// chunk; a single-character chunk grows by one instead (one over the CH3 bound, see CH9).
|
|
46
|
+
if (
|
|
47
|
+
chunkSize < offset &&
|
|
48
|
+
isLowSurrogate(msg.charCodeAt(offset - chunkSize)) &&
|
|
49
|
+
isHighSurrogate(msg.charCodeAt(offset - chunkSize - 1))
|
|
50
|
+
) {
|
|
51
|
+
chunkSize += chunkSize > 1 ? -1 : 1
|
|
52
|
+
}
|
|
42
53
|
chunks.unshift(prefix + msg.slice(offset - chunkSize, offset))
|
|
43
54
|
offset -= chunkSize
|
|
44
55
|
chunkNumber++
|
|
@@ -47,6 +58,14 @@ export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
|
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
60
|
|
|
61
|
+
function isHighSurrogate(code: number) {
|
|
62
|
+
return code >= 0xd800 && code <= 0xdbff
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isLowSurrogate(code: number) {
|
|
66
|
+
return code >= 0xdc00 && code <= 0xdfff
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
// The 's' flag (dotAll) makes '.' match any character including line terminators
|
|
51
70
|
// like U+2028 and U+2029, which are commonly introduced via copy/paste from Word
|
|
52
71
|
const chunkRe = /^(\d+)_(.*)$/s
|
|
@@ -1949,6 +1949,111 @@ describe('25. Messaging and broadcast (RB)', () => {
|
|
|
1949
1949
|
expect(socketB.sendMessage).not.toHaveBeenCalled()
|
|
1950
1950
|
})
|
|
1951
1951
|
|
|
1952
|
+
it('[RB3] a debounced flush into a socket that closed meanwhile cancels the session instead of sending', () => {
|
|
1953
|
+
vi.useFakeTimers()
|
|
1954
|
+
const { room, socketB } = setupTwoSessions()
|
|
1955
|
+
const newPage = makePage('page_3', 'v1')
|
|
1956
|
+
|
|
1957
|
+
room.handleMessage('a', {
|
|
1958
|
+
type: 'push',
|
|
1959
|
+
clientClock: 1,
|
|
1960
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1961
|
+
} as TLPushRequest<TLRecord>)
|
|
1962
|
+
room.handleMessage('a', {
|
|
1963
|
+
type: 'push',
|
|
1964
|
+
clientClock: 2,
|
|
1965
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
1966
|
+
} as TLPushRequest<TLRecord>)
|
|
1967
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
1968
|
+
|
|
1969
|
+
// the socket closes while the second message sits in the debounce buffer
|
|
1970
|
+
socketB.isOpen = false
|
|
1971
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
1972
|
+
|
|
1973
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
1974
|
+
expect(room.sessions.get('b')?.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
1975
|
+
})
|
|
1976
|
+
|
|
1977
|
+
it('[RC7] close() drops pending debounced flushes so nothing is sent into closed sockets afterwards', () => {
|
|
1978
|
+
vi.useFakeTimers()
|
|
1979
|
+
const { room, socketB } = setupTwoSessions()
|
|
1980
|
+
const newPage = makePage('page_3', 'v1')
|
|
1981
|
+
|
|
1982
|
+
room.handleMessage('a', {
|
|
1983
|
+
type: 'push',
|
|
1984
|
+
clientClock: 1,
|
|
1985
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
1986
|
+
} as TLPushRequest<TLRecord>)
|
|
1987
|
+
room.handleMessage('a', {
|
|
1988
|
+
type: 'push',
|
|
1989
|
+
clientClock: 2,
|
|
1990
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
1991
|
+
} as TLPushRequest<TLRecord>)
|
|
1992
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
1993
|
+
|
|
1994
|
+
room.close()
|
|
1995
|
+
expect(room.isClosed()).toBe(true)
|
|
1996
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
1997
|
+
|
|
1998
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
1999
|
+
expect(socketB.close).toHaveBeenCalled()
|
|
2000
|
+
})
|
|
2001
|
+
|
|
2002
|
+
it('[RC7] close() closes every socket even when one of them throws on close', () => {
|
|
2003
|
+
const { room, socketA, socketB } = setupTwoSessions()
|
|
2004
|
+
socketA.close.mockImplementationOnce(() => {
|
|
2005
|
+
throw new Error('already closing')
|
|
2006
|
+
})
|
|
2007
|
+
|
|
2008
|
+
expect(() => room.close()).not.toThrow()
|
|
2009
|
+
expect(room.isClosed()).toBe(true)
|
|
2010
|
+
expect(socketB.close).toHaveBeenCalled()
|
|
2011
|
+
})
|
|
2012
|
+
|
|
2013
|
+
it('[RC7] close() forgets its sessions, and late socket close events emit no lifecycle events', () => {
|
|
2014
|
+
vi.useFakeTimers()
|
|
2015
|
+
const { room, socketA } = setupTwoSessions()
|
|
2016
|
+
const removed = vi.fn()
|
|
2017
|
+
const becameEmpty = vi.fn()
|
|
2018
|
+
room.events.on('session_removed', removed)
|
|
2019
|
+
room.events.on('room_became_empty', becameEmpty)
|
|
2020
|
+
|
|
2021
|
+
// b's socket closes before close(), a's closes after: both must stay silent
|
|
2022
|
+
room.handleClose('b')
|
|
2023
|
+
room.close()
|
|
2024
|
+
expect(room.sessions.size).toBe(0)
|
|
2025
|
+
room.handleClose('a')
|
|
2026
|
+
|
|
2027
|
+
vi.advanceTimersByTime(SESSION_REMOVAL_WAIT_TIME + 2001)
|
|
2028
|
+
expect(removed).not.toHaveBeenCalled()
|
|
2029
|
+
expect(becameEmpty).not.toHaveBeenCalled()
|
|
2030
|
+
expect(socketA.sendMessage).not.toHaveBeenCalled()
|
|
2031
|
+
})
|
|
2032
|
+
|
|
2033
|
+
it('[RC7] sessions added after close() are rejected and their sockets closed', () => {
|
|
2034
|
+
const { room } = makeRoom()
|
|
2035
|
+
room.close()
|
|
2036
|
+
|
|
2037
|
+
const late = makeSocket()
|
|
2038
|
+
room.handleNewSession({ sessionId: 'late', socket: late, meta: undefined, isReadonly: false })
|
|
2039
|
+
expect(late.close).toHaveBeenCalled()
|
|
2040
|
+
|
|
2041
|
+
const resumed = makeSocket()
|
|
2042
|
+
room.handleResumedSession({
|
|
2043
|
+
sessionId: 'resumed',
|
|
2044
|
+
socket: resumed,
|
|
2045
|
+
meta: undefined,
|
|
2046
|
+
isReadonly: false,
|
|
2047
|
+
serializedSchema: room.serializedSchema,
|
|
2048
|
+
presenceId: null,
|
|
2049
|
+
presenceRecord: null,
|
|
2050
|
+
requiresLegacyRejection: false,
|
|
2051
|
+
supportsStringAppend: true,
|
|
2052
|
+
})
|
|
2053
|
+
expect(resumed.close).toHaveBeenCalled()
|
|
2054
|
+
expect(room.sessions.size).toBe(0)
|
|
2055
|
+
})
|
|
2056
|
+
|
|
1952
2057
|
it('[RB4] a per-session migration failure during broadcast rejects only the affected session', () => {
|
|
1953
2058
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1954
2059
|
try {
|