@tldraw/sync-core 5.4.0-next.ef99ab47e5ce → 5.5.0-canary.04d1e6c4daff

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 (41) hide show
  1. package/dist-cjs/index.d.ts +17 -13
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/NodeSqliteWrapper.js +1 -1
  4. package/dist-cjs/lib/NodeSqliteWrapper.js.map +2 -2
  5. package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
  6. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncClient.js +20 -1
  8. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncRoom.js +30 -3
  10. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncStorage.js +1 -1
  12. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  13. package/dist-cjs/lib/chunk.js +10 -1
  14. package/dist-cjs/lib/chunk.js.map +2 -2
  15. package/dist-esm/index.d.mts +17 -13
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/NodeSqliteWrapper.mjs +1 -1
  18. package/dist-esm/lib/NodeSqliteWrapper.mjs.map +2 -2
  19. package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
  20. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  21. package/dist-esm/lib/TLSyncClient.mjs +20 -1
  22. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  23. package/dist-esm/lib/TLSyncRoom.mjs +30 -3
  24. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncStorage.mjs +1 -1
  26. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  27. package/dist-esm/lib/chunk.mjs +10 -1
  28. package/dist-esm/lib/chunk.mjs.map +2 -2
  29. package/package.json +14 -7
  30. package/src/lib/NodeSqliteWrapper.test.ts +29 -0
  31. package/src/lib/NodeSqliteWrapper.ts +4 -1
  32. package/src/lib/SQLiteSyncStorage.test.ts +60 -1
  33. package/src/lib/SQLiteSyncStorage.ts +22 -14
  34. package/src/lib/TLSyncClient.test.ts +64 -6
  35. package/src/lib/TLSyncClient.ts +38 -2
  36. package/src/lib/TLSyncRoom.ts +39 -3
  37. package/src/lib/TLSyncStorage.test.ts +37 -0
  38. package/src/lib/TLSyncStorage.ts +4 -2
  39. package/src/lib/chunk.test.ts +34 -0
  40. package/src/lib/chunk.ts +20 -1
  41. package/src/test/TLSyncRoom.test.ts +105 -0
@@ -272,5 +272,34 @@ describe('NodeSqliteWrapper', () => {
272
272
  // the write was rolled back
273
273
  expect(wrapper.prepare('SELECT * FROM test').all()).toEqual([])
274
274
  })
275
+
276
+ it('[NW2] rolls back when COMMIT itself fails, so the connection is not left inside a transaction', () => {
277
+ // simulate a COMMIT failure (SQLITE_BUSY, I/O error) at the driver level
278
+ const failingDb = {
279
+ exec: (sql: string) => {
280
+ if (sql === 'COMMIT') throw new Error('SQLITE_BUSY: database is locked')
281
+ return db.exec(sql)
282
+ },
283
+ prepare: (sql: string) => db.prepare(sql),
284
+ }
285
+ const failingWrapper = new NodeSqliteWrapper(failingDb as any)
286
+
287
+ expect(() =>
288
+ failingWrapper.transaction(() => {
289
+ failingWrapper
290
+ .prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
291
+ .run(1, 'alice', 100)
292
+ })
293
+ ).toThrow('SQLITE_BUSY')
294
+
295
+ // rolled back, and the next transaction can BEGIN normally
296
+ expect(wrapper.prepare('SELECT * FROM test').all()).toEqual([])
297
+ expect(() =>
298
+ wrapper.transaction(() => {
299
+ wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
300
+ })
301
+ ).not.toThrow()
302
+ expect(wrapper.prepare<{ id: number }>('SELECT id FROM test').all()).toEqual([{ id: 2 }])
303
+ })
275
304
  })
276
305
  })
@@ -89,11 +89,14 @@ export class NodeSqliteWrapper implements TLSyncSqliteWrapper {
89
89
  let result: T
90
90
  try {
91
91
  result = callback()
92
+ // COMMIT itself can fail (SQLITE_BUSY from another connection, I/O errors); without a
93
+ // ROLLBACK the connection would stay inside the transaction and every later BEGIN
94
+ // would throw "cannot start a transaction within a transaction"
95
+ this.db.exec('COMMIT')
92
96
  } catch (e) {
93
97
  this.db.exec('ROLLBACK')
94
98
  throw e
95
99
  }
96
- this.db.exec('COMMIT')
97
100
  return result
98
101
  }
99
102
  }
@@ -1,7 +1,7 @@
1
1
  import { DatabaseSync } from 'node:sqlite'
2
2
  import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
3
3
  import { ZERO_INDEX_KEY } from '@tldraw/utils'
4
- import { describe, expect, it } from 'vitest'
4
+ import { describe, expect, it, vi } from 'vitest'
5
5
  import {
6
6
  contractRecords,
7
7
  contractSchema,
@@ -9,6 +9,7 @@ import {
9
9
  makePage,
10
10
  registerStorageContractTests,
11
11
  } from '../test/storageContractSuite'
12
+ import { MAX_TOMBSTONES } from './InMemorySyncStorage'
12
13
  import { NodeSqliteWrapper } from './NodeSqliteWrapper'
13
14
  import { SQLiteSyncStorage } from './SQLiteSyncStorage'
14
15
 
@@ -263,4 +264,62 @@ describe('SQLiteSyncStorage', () => {
263
264
  expect(row?.migrationVersion).toBe(3)
264
265
  })
265
266
  })
267
+
268
+ describe('tombstone pruning robustness', () => {
269
+ const makeTombstones = (count: number) => {
270
+ const tombstones: Record<string, number> = {}
271
+ for (let i = 0; i < count; i++) tombstones[`shape:doc${i}`] = i + 1
272
+ return tombstones
273
+ }
274
+ function makeOverfullStorage() {
275
+ const db = new DatabaseSync(':memory:')
276
+ const storage = new SQLiteSyncStorage<TLRecord>({
277
+ sql: new NodeSqliteWrapper(db),
278
+ snapshot: makeContractSnapshot(contractRecords, {
279
+ tombstones: makeTombstones(MAX_TOMBSTONES + 500),
280
+ documentClock: MAX_TOMBSTONES + 501,
281
+ tombstoneHistoryStartsAtClock: 0,
282
+ }),
283
+ })
284
+ return { db, storage, prune: (storage as any).pruneTombstones as { (): void; flush(): void } }
285
+ }
286
+
287
+ it('[SS18] a prune that fires after the database was closed does not throw', () => {
288
+ const { db, storage, prune } = makeOverfullStorage()
289
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
290
+ try {
291
+ // a delete schedules the throttled prune; the host then closes everything
292
+ storage.transaction((txn) => txn.delete(contractRecords[1].id))
293
+ db.close()
294
+ // the throttle timer fires later, from outside any caller that could catch it
295
+ expect(() => prune.flush()).not.toThrow()
296
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to prune tombstones', expect.anything())
297
+ } finally {
298
+ consoleSpy.mockRestore()
299
+ }
300
+ })
301
+
302
+ it('[SS18] a prune whose delete fails leaves the history clock where it was', () => {
303
+ const { storage, prune } = makeOverfullStorage()
304
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
305
+ try {
306
+ const stmts = (storage as any).stmts
307
+ const originalRun = stmts.deleteTombstonesBefore.run
308
+ stmts.deleteTombstonesBefore.run = () => {
309
+ throw new Error('SQLITE_BUSY: database is locked')
310
+ }
311
+ prune()
312
+ expect(() => prune.flush()).not.toThrow()
313
+ stmts.deleteTombstonesBefore.run = originalRun
314
+
315
+ // neither half of the prune applied: the history start was not advanced past
316
+ // tombstones that still exist
317
+ const snapshot = storage.getSnapshot()
318
+ expect(snapshot.tombstoneHistoryStartsAtClock).toBe(0)
319
+ expect(Object.keys(snapshot.tombstones!).length).toBe(MAX_TOMBSTONES + 500)
320
+ } finally {
321
+ consoleSpy.mockRestore()
322
+ }
323
+ })
324
+ })
266
325
  })
@@ -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
- const tombstoneCount = this.stmts.countTombstones.all()[0].count as number
517
- if (tombstoneCount > MAX_TOMBSTONES) {
518
- // Get all tombstones sorted by clock ascending (oldest first)
519
- const tombstones = this.stmts.iterateTombstones.all()
520
-
521
- const result = computeTombstonePruning({ tombstones, documentClock: this.getClock() })
522
- if (result) {
523
- this.stmts.setTombstoneHistoryStartsAtClock.run(result.newTombstoneHistoryStartsAtClock)
524
- // Delete all tombstones with clock < newTombstoneHistoryStartsAtClock in one operation.
525
- // This works because computeTombstonePruning ensures we never split a clock value.
526
- this.stmts.deleteTombstonesBefore.run(result.newTombstoneHistoryStartsAtClock)
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,
@@ -598,19 +598,55 @@ describe('TLSyncClient', () => {
598
598
  expect(socket.getSentMessages().filter((m) => m.type === 'ping')).toHaveLength(2)
599
599
  })
600
600
 
601
- it('[CL9] warns and resets the connection after 10 seconds without server interaction', () => {
601
+ it('[CL9][CL14] resets when pings keep being sent but nothing comes back (half-open socket)', () => {
602
602
  const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
603
603
  client = createClient()
604
604
  socket.mockServerMessage(createConnectMessage())
605
605
 
606
- // advance time beyond the health check threshold
607
- vi.advanceTimersByTime(15000)
606
+ // pings at 5s/10s go unanswered but the sends still "succeed"
607
+ vi.advanceTimersByTime(10_000)
608
+ // health tick at 10s: the oldest unanswered ping (t=5s) is only 5s old — not yet overdue
609
+ expect(client.isConnectedToRoom).toBe(true)
608
610
 
611
+ vi.advanceTimersByTime(10_000)
612
+ // health tick at 20s: the t=5s ping is 15s old, past PONG_TIMEOUT — evidence of a dead socket
613
+ expect(client.isConnectedToRoom).toBe(false)
609
614
  expect(consoleSpy).toHaveBeenCalledWith(
610
- expect.stringContaining("Haven't heard from the server in a while")
615
+ expect.stringContaining("Haven't heard from the server"),
616
+ expect.objectContaining({ pingOutstandingMs: expect.any(Number) })
611
617
  )
618
+ consoleSpy.mockRestore()
619
+ })
620
+
621
+ it('[CL14] a pong landing in the same millisecond as the ping still counts as an answer', () => {
622
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
623
+ client = createClient()
624
+ socket.mockServerMessage(createConnectMessage())
625
+
626
+ // three ping/pong rounds where the pong arrives in the same ms as the ping send
627
+ for (let i = 0; i < 3; i++) {
628
+ vi.advanceTimersByTime(5000)
629
+ socket.mockServerMessage({ type: 'pong' })
630
+ }
631
+ expect(client.isConnectedToRoom).toBe(true)
632
+
633
+ // then the socket dies: the t=20s ping goes unanswered and must still convict.
634
+ // Guards the >= in the marker-advance rule: with > the same-ms pongs above would
635
+ // freeze the marker and the dead socket would never reset.
636
+ vi.advanceTimersByTime(25_000)
612
637
  expect(client.isConnectedToRoom).toBe(false)
638
+ consoleSpy.mockRestore()
639
+ })
613
640
 
641
+ it('[CL14] the unanswered-ping marker clears on connection reset', () => {
642
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
643
+ client = createClient()
644
+ socket.mockServerMessage(createConnectMessage())
645
+
646
+ // drive a reset via a dead socket
647
+ vi.advanceTimersByTime(20_000)
648
+ expect(client.isConnectedToRoom).toBe(false)
649
+ expect((client as any).firstUnansweredPingAt).toBeNull()
614
650
  consoleSpy.mockRestore()
615
651
  })
616
652
 
@@ -626,13 +662,35 @@ describe('TLSyncClient', () => {
626
662
  vi.advanceTimersByTime(4000)
627
663
  expect(client.isConnectedToRoom).toBe(true)
628
664
 
629
- // at t=20s nothing has been heard since the pong, so the connection resets
630
- vi.advanceTimersByTime(10000)
665
+ // at t=15s the oldest unanswered ping (t=10s) is 5s old — not yet overdue
666
+ vi.advanceTimersByTime(5000)
667
+ expect(client.isConnectedToRoom).toBe(true)
668
+
669
+ // at t=20s it is exactly PONG_TIMEOUT old — reset
670
+ vi.advanceTimersByTime(5000)
631
671
  expect(client.isConnectedToRoom).toBe(false)
632
672
 
633
673
  consoleSpy.mockRestore()
634
674
  })
635
675
 
676
+ it('[CL9] does not reset a healthy connection when timers wake infrequently (hidden-tab throttling)', () => {
677
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
678
+ client = createClient()
679
+ socket.mockServerMessage(createConnectMessage())
680
+
681
+ // Chrome batches a hidden tab's timers to ~1/min wakes: the clock jumps, then the
682
+ // delayed ping + health ticks fire together, and the pong arrives right after.
683
+ for (let i = 0; i < 3; i++) {
684
+ vi.setSystemTime(Date.now() + 60_000)
685
+ vi.advanceTimersByTime(10_000)
686
+ expect(client.isConnectedToRoom).toBe(true)
687
+ socket.mockServerMessage({ type: 'pong' })
688
+ }
689
+
690
+ expect(consoleSpy).not.toHaveBeenCalled()
691
+ consoleSpy.mockRestore()
692
+ })
693
+
636
694
  it('[CL11] closes instead of processing the next event when didCancel returns true', () => {
637
695
  let cancelled = false
638
696
  client = createClient({ didCancel: () => cancelled })
@@ -286,6 +286,12 @@ export interface TLPersistentClientSocket<
286
286
 
287
287
  const PING_INTERVAL = 5000
288
288
  const MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING_INTERVAL * 2
289
+ /**
290
+ * How long an unanswered ping may stay outstanding before the connection is considered dead.
291
+ * Tuned together with MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION:
292
+ * a reset needs both stale interaction and a ping overdue by this much.
293
+ */
294
+ const PONG_TIMEOUT = PING_INTERVAL * 2
289
295
 
290
296
  // Should connect support chunking the response to allow for large payloads?
291
297
 
@@ -367,6 +373,11 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
367
373
  /** The last clock time from the most recent server update */
368
374
  private lastServerClock = -1
369
375
  private lastServerInteractionTimestamp = Date.now()
376
+ /**
377
+ * Send time of the oldest outstanding ping; answered iff `lastServerInteractionTimestamp >= it`
378
+ * (any server message counts). Null until the first ping after (re)connect.
379
+ */
380
+ private firstUnansweredPingAt: number | null = null
370
381
 
371
382
  /** The queue of in-flight push requests that have not yet been acknowledged by the server */
372
383
  private pendingPushRequests: TLPushRequest<R>[] = []
@@ -600,6 +611,14 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
600
611
  this.debug('ping loop', { isConnectedToRoom: this.isConnectedToRoom })
601
612
  if (!this.isConnectedToRoom) return
602
613
  try {
614
+ // advance the marker only when the previous ping was answered: a half-open socket
615
+ // that accepts sends but returns nothing must not keep refreshing its own alibi
616
+ if (
617
+ this.firstUnansweredPingAt === null ||
618
+ this.lastServerInteractionTimestamp >= this.firstUnansweredPingAt
619
+ ) {
620
+ this.firstUnansweredPingAt = Date.now()
621
+ }
603
622
  this.socket.sendMessage({ type: 'ping' })
604
623
  } catch (error) {
605
624
  console.warn('ping failed, resetting', error)
@@ -618,11 +637,27 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
618
637
  MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION
619
638
  ) {
620
639
  this.debug('health check passed', { timeSinceLastServerInteraction })
621
- // last ping was recent, so no need to take any action
622
640
  return
623
641
  }
624
642
 
625
- console.warn(`Haven't heard from the server in a while, resetting connection...`)
643
+ // Silence alone is not evidence of a dead socket: in a throttled hidden tab our own ping
644
+ // loop stops, so the server had nothing to answer. Only reset on an unanswered ping.
645
+ const firstUnansweredPingAt = this.firstUnansweredPingAt
646
+ const pingOverdue =
647
+ firstUnansweredPingAt !== null &&
648
+ this.lastServerInteractionTimestamp < firstUnansweredPingAt &&
649
+ Date.now() - firstUnansweredPingAt >= PONG_TIMEOUT
650
+ if (!pingOverdue) {
651
+ this.debug('health check stale but no overdue ping', { timeSinceLastServerInteraction })
652
+ return
653
+ }
654
+
655
+ // A frozen tab can deliver a queued pong only after these catch-up ticks run, so one
656
+ // spurious reset on unfreeze is still possible — rare, and the reconnect is clean.
657
+ console.warn(`Haven't heard from the server in a while, resetting connection...`, {
658
+ timeSinceLastServerInteraction,
659
+ pingOutstandingMs: Date.now() - firstUnansweredPingAt!,
660
+ })
626
661
  this.resetConnection()
627
662
  }, PING_INTERVAL * 2)
628
663
  )
@@ -684,6 +719,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
684
719
  }
685
720
  this.lastPushedPresenceState = null
686
721
  this.isConnectedToRoom = false
722
+ this.firstUnansweredPingAt = null
687
723
  this.pendingPushRequests = []
688
724
  this.incomingDiffBuffer = []
689
725
  this.unsentChanges.nextDiff = undefined
@@ -258,7 +258,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
258
258
  }, 1000)
259
259
 
260
260
  private scheduleFollowUpPrune() {
261
- if (this.pruneTimer) return
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
- session.socket.close()
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
- this._isClosed = true
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
 
@@ -122,6 +122,43 @@ describe('loadSnapshotIntoStorage', () => {
122
122
  expect(storage.getClock()).toBe(5)
123
123
  })
124
124
 
125
+ it('[SL3] does not delete while a key iterator is open (better-sqlite3 rejects that)', () => {
126
+ const extraPage = makePage('extra', 'Extra')
127
+ const storage = new InMemorySyncStorage<TLRecord>({
128
+ snapshot: makeContractSnapshot([...contractRecords, extraPage]),
129
+ })
130
+
131
+ storage.transaction((txn) => {
132
+ // better-sqlite3 throws on any write while a statement iterator is still open; mimic
133
+ // that so a delete issued from inside `for (const id of txn.keys())` fails loudly
134
+ let openIterators = 0
135
+ const guarded: typeof txn = Object.create(txn, {
136
+ keys: {
137
+ value: function* () {
138
+ openIterators++
139
+ try {
140
+ yield* Array.from(txn.keys())
141
+ } finally {
142
+ openIterators--
143
+ }
144
+ },
145
+ },
146
+ delete: {
147
+ value: (id: string) => {
148
+ if (openIterators > 0) {
149
+ throw new Error('This database connection is busy executing a query')
150
+ }
151
+ return txn.delete(id)
152
+ },
153
+ },
154
+ })
155
+ loadSnapshotIntoStorage(guarded, contractSchema, makeContractSnapshot(contractRecords))
156
+ })
157
+
158
+ expect(storage.documents.has(extraPage.id)).toBe(false)
159
+ expect(storage.tombstones.has(extraPage.id)).toBe(true)
160
+ })
161
+
125
162
  it('[SL4] persists the snapshot schema and migrates loaded records to the current version', () => {
126
163
  interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
127
164
  value: number
@@ -1,6 +1,6 @@
1
1
  import { StoreSchema, SynchronousStorage, UnknownRecord } from '@tldraw/store'
2
+ import { TLStoreSnapshot } from '@tldraw/tlschema'
2
3
  import { assert, isEqual, objectMapEntriesIterable, objectMapValues } from '@tldraw/utils'
3
- import { TLStoreSnapshot } from 'tldraw'
4
4
  import { diffRecord, NetworkDiff, RecordOpType } from './diff'
5
5
  import { RoomSnapshot } from './TLSyncRoom'
6
6
 
@@ -205,7 +205,9 @@ export function loadSnapshotIntoStorage<R extends UnknownRecord>(
205
205
  if (isEqual(existing, doc.state)) continue
206
206
  txn.set(doc.state.id, doc.state as R)
207
207
  }
208
- for (const id of txn.keys()) {
208
+ // materialize the keys first: some SQLite drivers (better-sqlite3) refuse writes while a
209
+ // statement iterator is open, so deleting during `keys()` would throw
210
+ for (const id of Array.from(txn.keys())) {
209
211
  if (!docIds.has(id)) {
210
212
  txn.delete(id)
211
213
  }
@@ -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
- const chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)
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