@tldraw/sync-core 4.5.2 → 4.6.0-canary.00a8c03b5687

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 (46) hide show
  1. package/dist-cjs/index.d.ts +86 -0
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js +1 -1
  5. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  6. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +2 -2
  7. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  8. package/dist-cjs/lib/ServerSocketAdapter.js +1 -1
  9. package/dist-cjs/lib/ServerSocketAdapter.js.map +1 -1
  10. package/dist-cjs/lib/TLSocketRoom.js +137 -1
  11. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  12. package/dist-cjs/lib/TLSyncClient.js +1 -1
  13. package/dist-cjs/lib/TLSyncClient.js.map +1 -1
  14. package/dist-cjs/lib/TLSyncRoom.js +71 -5
  15. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  16. package/dist-cjs/lib/protocol.js.map +1 -1
  17. package/dist-esm/index.d.mts +86 -0
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/ClientWebSocketAdapter.mjs +1 -1
  21. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  22. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +2 -2
  23. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  24. package/dist-esm/lib/ServerSocketAdapter.mjs +1 -1
  25. package/dist-esm/lib/ServerSocketAdapter.mjs.map +1 -1
  26. package/dist-esm/lib/TLSocketRoom.mjs +137 -1
  27. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  28. package/dist-esm/lib/TLSyncClient.mjs +1 -1
  29. package/dist-esm/lib/TLSyncClient.mjs.map +1 -1
  30. package/dist-esm/lib/TLSyncRoom.mjs +73 -6
  31. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  32. package/dist-esm/lib/protocol.mjs.map +1 -1
  33. package/package.json +7 -7
  34. package/src/index.ts +3 -0
  35. package/src/lib/ClientWebSocketAdapter.ts +5 -5
  36. package/src/lib/DurableObjectSqliteSyncWrapper.ts +1 -2
  37. package/src/lib/InMemorySyncStorage.ts +3 -3
  38. package/src/lib/ServerSocketAdapter.ts +6 -6
  39. package/src/lib/TLSocketRoom.ts +193 -6
  40. package/src/lib/TLSyncClient.test.ts +4 -7
  41. package/src/lib/TLSyncClient.ts +1 -1
  42. package/src/lib/TLSyncRoom.ts +96 -4
  43. package/src/lib/protocol.ts +2 -0
  44. package/src/test/RandomSource.ts +1 -1
  45. package/src/test/TLSocketRoom.test.ts +527 -13
  46. package/src/test/TestSocketPair.ts +1 -1
@@ -1,5 +1,5 @@
1
- import type { StoreSchema, UnknownRecord } from '@tldraw/store'
2
- import { createTLSchema, TLStoreSnapshot } from '@tldraw/tlschema'
1
+ import type { SerializedSchema, StoreSchema, UnknownRecord } from '@tldraw/store'
2
+ import { createTLSchema, TLInstancePresence, TLStoreSnapshot } from '@tldraw/tlschema'
3
3
  import { getOwnProperty, hasOwnProperty, isEqual, structuredClone } from '@tldraw/utils'
4
4
  import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from './InMemorySyncStorage'
5
5
  import { RoomSessionState } from './RoomSession'
@@ -14,6 +14,22 @@ import {
14
14
  import { JsonChunkAssembler } from './chunk'
15
15
  import { TLSocketServerSentEvent } from './protocol'
16
16
 
17
+ /**
18
+ * Strip potentially large fields from a tldraw instance_presence record so the
19
+ * snapshot stays small when stored in WebSocket attachments (e.g. for hibernation).
20
+ * Keeps cursor, selection, page, and user identity; clears scribbles, chatMessage, brush.
21
+ */
22
+ function stripPresenceForSnapshot(record: UnknownRecord): UnknownRecord {
23
+ if (record.typeName !== 'instance_presence') return record
24
+ const stripped = { ...record } as TLInstancePresence
25
+ stripped.scribbles = []
26
+ stripped.chatMessage = ''
27
+ stripped.selectedShapeIds = []
28
+ stripped.brush = null
29
+
30
+ return stripped as unknown as UnknownRecord
31
+ }
32
+
17
33
  /**
18
34
  * Logging interface for TLSocketRoom operations. Provides optional methods
19
35
  * for warning and error logging during synchronization operations.
@@ -43,6 +59,24 @@ export interface TLSyncLog {
43
59
  error?(...args: any[]): void
44
60
  }
45
61
 
62
+ /**
63
+ * A snapshot of per-session state that can be persisted and used to resume a session
64
+ * after the server restarts (e.g., after Cloudflare Durable Object hibernation).
65
+ *
66
+ * Obtain via {@link TLSocketRoom.getSessionSnapshot} and restore via
67
+ * {@link TLSocketRoom.handleSocketResume}.
68
+ *
69
+ * @public
70
+ */
71
+ export interface SessionStateSnapshot {
72
+ serializedSchema: SerializedSchema
73
+ isReadonly: boolean
74
+ presenceId: string | null
75
+ presenceRecord: UnknownRecord | null
76
+ requiresLegacyRejection: boolean
77
+ supportsStringAppend: boolean
78
+ }
79
+
46
80
  /**
47
81
  * Base options for TLSocketRoom.
48
82
  * @public
@@ -62,13 +96,13 @@ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
62
96
  clientTimeout?: number
63
97
  log?: TLSyncLog
64
98
  // a callback that is called when a client is disconnected
65
- // eslint-disable-next-line @typescript-eslint/method-signature-style
99
+ // eslint-disable-next-line tldraw/method-signature-style
66
100
  onSessionRemoved?: (
67
101
  room: TLSocketRoom<R, SessionMeta>,
68
102
  args: { sessionId: string; numSessionsRemaining: number; meta: SessionMeta }
69
103
  ) => void
70
104
  // a callback that is called whenever a message is sent
71
- // eslint-disable-next-line @typescript-eslint/method-signature-style
105
+ // eslint-disable-next-line tldraw/method-signature-style
72
106
  onBeforeSendMessage?: (args: {
73
107
  sessionId: string
74
108
  /** @internal keep the protocol private for now */
@@ -76,7 +110,7 @@ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
76
110
  stringified: string
77
111
  meta: SessionMeta
78
112
  }) => void
79
- // eslint-disable-next-line @typescript-eslint/method-signature-style
113
+ // eslint-disable-next-line tldraw/method-signature-style
80
114
  onAfterReceiveMessage?: (args: {
81
115
  sessionId: string
82
116
  /** @internal keep the protocol private for now */
@@ -86,6 +120,14 @@ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
86
120
  }) => void
87
121
  /** @internal */
88
122
  onPresenceChange?(): void
123
+ /**
124
+ * When set, the room will call {@link TLSocketRoom.getSessionSnapshot} after
125
+ * no message activity for a session for 5s and pass the result to this callback.
126
+ * Use for persisting snapshots to WebSocket attachments (e.g. Cloudflare hibernation).
127
+ * The room clears any pending snapshot when the session closes.
128
+ */
129
+ // eslint-disable-next-line tldraw/method-signature-style
130
+ onSessionSnapshot?: (sessionId: string, snapshot: SessionStateSnapshot) => void
89
131
  }
90
132
 
91
133
  /**
@@ -152,7 +194,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
152
194
  private room: TLSyncRoom<R, SessionMeta>
153
195
  private readonly sessions = new Map<
154
196
  string,
155
- // eslint-disable-next-line @typescript-eslint/method-signature-style
197
+ // eslint-disable-next-line tldraw/method-signature-style
156
198
  { assembler: JsonChunkAssembler; socket: WebSocketMinimal; unlisten: () => void }
157
199
  >()
158
200
  readonly log?: TLSyncLog
@@ -160,6 +202,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
160
202
  public storage: TLSyncStorage<R>
161
203
 
162
204
  private disposables = new Set<() => void>()
205
+ private readonly snapshotTimers = new Map<string, ReturnType<typeof setTimeout>>()
163
206
 
164
207
  /**
165
208
  * Creates a new TLSocketRoom instance for managing collaborative document synchronization.
@@ -203,9 +246,11 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
203
246
  schema: opts.schema ?? (createTLSchema() as any),
204
247
  log: opts.log,
205
248
  storage,
249
+ clientTimeout: opts.clientTimeout,
206
250
  })
207
251
  this.storage = storage
208
252
  this.room.events.on('session_removed', (args) => {
253
+ this.clearSnapshotTimer(args.sessionId)
209
254
  this.sessions.delete(args.sessionId)
210
255
  if (this.opts.onSessionRemoved) {
211
256
  this.opts.onSessionRemoved(this, {
@@ -306,6 +351,27 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
306
351
  socket.addEventListener?.('error', handleSocketError)
307
352
  }
308
353
 
354
+ private clearSnapshotTimer(sessionId: string) {
355
+ const t = this.snapshotTimers.get(sessionId)
356
+ if (t) {
357
+ clearTimeout(t)
358
+ this.snapshotTimers.delete(sessionId)
359
+ }
360
+ }
361
+
362
+ private scheduleDebouncedSnapshot(sessionId: string) {
363
+ if (!this.opts.onSessionSnapshot) return
364
+ this.clearSnapshotTimer(sessionId)
365
+ this.snapshotTimers.set(
366
+ sessionId,
367
+ setTimeout(() => {
368
+ this.snapshotTimers.delete(sessionId)
369
+ const snapshot = this.getSessionSnapshot(sessionId)
370
+ if (snapshot) this.opts.onSessionSnapshot!(sessionId, snapshot)
371
+ }, 5000)
372
+ )
373
+ }
374
+
309
375
  /**
310
376
  * Processes a message received from a client WebSocket. Use this method in server
311
377
  * environments where WebSocket event listeners cannot be attached directly to socket
@@ -362,6 +428,8 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
362
428
  }
363
429
 
364
430
  this.room.handleMessage(sessionId, res.data as any)
431
+ this.room.pruneSessions()
432
+ this.scheduleDebouncedSnapshot(sessionId)
365
433
  } else {
366
434
  this.log?.error?.('Error assembling message', res.error)
367
435
  // close the socket to reset the connection
@@ -391,6 +459,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
391
459
  * ```
392
460
  */
393
461
  handleSocketError(sessionId: string) {
462
+ this.clearSnapshotTimer(sessionId)
394
463
  this.room.handleClose(sessionId)
395
464
  }
396
465
 
@@ -410,9 +479,124 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
410
479
  * ```
411
480
  */
412
481
  handleSocketClose(sessionId: string) {
482
+ this.clearSnapshotTimer(sessionId)
413
483
  this.room.handleClose(sessionId)
414
484
  }
415
485
 
486
+ /**
487
+ * Resumes a previously-connected session directly into `Connected` state, bypassing
488
+ * the connect handshake. Use this after server hibernation (e.g., Cloudflare Durable
489
+ * Object hibernation) when WebSocket connections survived but all in-memory state was lost.
490
+ *
491
+ * The session is restored using a {@link SessionStateSnapshot} previously obtained
492
+ * via {@link TLSocketRoom.getSessionSnapshot}. The client is unaware the server restarted and
493
+ * continues sending messages normally.
494
+ *
495
+ * Unlike {@link TLSocketRoom.handleSocketConnect}, this method does NOT attach WebSocket event
496
+ * listeners. In hibernation environments, events are delivered via class methods
497
+ * (e.g., `webSocketMessage`) rather than `addEventListener`.
498
+ *
499
+ * @param opts - Resume options
500
+ * - sessionId - Unique identifier for the client session
501
+ * - socket - WebSocket-like object for client communication
502
+ * - snapshot - Session state snapshot from {@link TLSocketRoom.getSessionSnapshot}
503
+ * - meta - Additional session metadata (required if SessionMeta is not void)
504
+ *
505
+ * @example
506
+ * ```ts
507
+ * // After Cloudflare DO hibernation wake
508
+ * for (const ws of ctx.getWebSockets()) {
509
+ * const data = ws.deserializeAttachment()
510
+ * room.handleSocketResume({
511
+ * sessionId: data.sessionId,
512
+ * socket: ws,
513
+ * snapshot: data.snapshot,
514
+ * })
515
+ * }
516
+ * ```
517
+ */
518
+ handleSocketResume(
519
+ opts: {
520
+ sessionId: string
521
+ socket: WebSocketMinimal
522
+ snapshot: SessionStateSnapshot
523
+ } & (SessionMeta extends void ? object : { meta: SessionMeta })
524
+ ) {
525
+ const { sessionId, socket, snapshot } = opts
526
+
527
+ this.sessions.set(sessionId, {
528
+ assembler: new JsonChunkAssembler(),
529
+ socket,
530
+ unlisten: () => {
531
+ // no-op: hibernation environments use class methods, not addEventListener
532
+ },
533
+ })
534
+
535
+ this.room.handleResumedSession({
536
+ sessionId,
537
+ isReadonly: snapshot.isReadonly,
538
+ serializedSchema: snapshot.serializedSchema,
539
+ presenceId: snapshot.presenceId,
540
+ presenceRecord: snapshot.presenceRecord,
541
+ requiresLegacyRejection: snapshot.requiresLegacyRejection,
542
+ supportsStringAppend: snapshot.supportsStringAppend,
543
+ socket: new ServerSocketAdapter({
544
+ ws: socket,
545
+ onBeforeSendMessage: this.opts.onBeforeSendMessage
546
+ ? (message, stringified) =>
547
+ this.opts.onBeforeSendMessage!({
548
+ sessionId,
549
+ message,
550
+ stringified,
551
+ meta: this.room.sessions.get(sessionId)?.meta as SessionMeta,
552
+ })
553
+ : undefined,
554
+ }),
555
+ meta: 'meta' in opts ? (opts.meta as any) : undefined,
556
+ })
557
+ }
558
+
559
+ /**
560
+ * Returns a snapshot of a connected session's state that can be persisted and later
561
+ * used with {@link TLSocketRoom.handleSocketResume} to restore the session after hibernation.
562
+ *
563
+ * Returns `null` if the session doesn't exist or isn't in the `Connected` state.
564
+ *
565
+ * @param sessionId - The session to snapshot
566
+ *
567
+ * @example
568
+ * ```ts
569
+ * // Store snapshot in a Cloudflare WebSocket attachment
570
+ * const snapshot = room.getSessionSnapshot(sessionId)
571
+ * if (snapshot) {
572
+ * ws.serializeAttachment({ sessionId, snapshot })
573
+ * }
574
+ * ```
575
+ */
576
+ getSessionSnapshot(sessionId: string): SessionStateSnapshot | null {
577
+ const session = this.room.sessions.get(sessionId)
578
+ if (!session || session.state !== RoomSessionState.Connected) {
579
+ return null
580
+ }
581
+
582
+ let presenceRecord: UnknownRecord | null = null
583
+ if (session.presenceId) {
584
+ const record = this.room.presenceStore.get(session.presenceId)
585
+ if (record) {
586
+ presenceRecord = stripPresenceForSnapshot(record as UnknownRecord)
587
+ }
588
+ }
589
+
590
+ return {
591
+ serializedSchema: session.serializedSchema,
592
+ isReadonly: session.isReadonly,
593
+ presenceId: session.presenceId,
594
+ presenceRecord,
595
+ requiresLegacyRejection: session.requiresLegacyRejection,
596
+ supportsStringAppend: session.supportsStringAppend,
597
+ }
598
+ }
599
+
416
600
  /**
417
601
  * Returns the current document clock value. The clock is a monotonically increasing
418
602
  * integer that increments with each document change, providing a consistent ordering
@@ -702,6 +886,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
702
886
  */
703
887
  close() {
704
888
  this.room.close()
889
+ for (const sessionId of this.snapshotTimers.keys()) {
890
+ this.clearSnapshotTimer(sessionId)
891
+ }
705
892
  this.disposables.forEach((d) => d())
706
893
  this.disposables.clear()
707
894
  }
@@ -30,13 +30,10 @@ const protocolVersion = getTlsyncProtocolVersion()
30
30
  type TestRecord = TLRecord
31
31
 
32
32
  // Mock socket implementation for testing
33
- class MockSocket
34
- implements
35
- TLPersistentClientSocket<
36
- TLSocketClientSentEvent<TestRecord>,
37
- TLSocketServerSentEvent<TestRecord>
38
- >
39
- {
33
+ class MockSocket implements TLPersistentClientSocket<
34
+ TLSocketClientSentEvent<TestRecord>,
35
+ TLSocketServerSentEvent<TestRecord>
36
+ > {
40
37
  connectionStatus: 'online' | 'offline' | 'error' = 'offline'
41
38
  private messageListeners: Array<(msg: TLSocketServerSentEvent<TestRecord>) => void> = []
42
39
  private statusListeners: Array<(event: TLSocketStatusChangeEvent) => void> = []
@@ -903,7 +903,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
903
903
  }
904
904
  }
905
905
 
906
- // eslint-disable-next-line local/prefer-class-methods
906
+ // eslint-disable-next-line tldraw/prefer-class-methods
907
907
  private rebase = () => {
908
908
  // need to make sure that our speculative changes are in sync with the actual store instance before
909
909
  // proceeding, to avoid inconsistency bugs.
@@ -15,6 +15,7 @@ import {
15
15
  isNativeStructuredClone,
16
16
  objectMapEntriesIterable,
17
17
  Result,
18
+ throttle,
18
19
  } from '@tldraw/utils'
19
20
  import { createNanoEvents } from 'nanoevents'
20
21
  import {
@@ -150,12 +151,17 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
150
151
 
151
152
  private lastDocumentClock = 0
152
153
 
153
- // eslint-disable-next-line local/prefer-class-methods
154
- pruneSessions = () => {
154
+ private pruneTimer: ReturnType<typeof setTimeout> | null = null
155
+
156
+ pruneSessions = throttle(() => {
157
+ if (this.pruneTimer) {
158
+ clearTimeout(this.pruneTimer)
159
+ this.pruneTimer = null
160
+ }
155
161
  for (const client of this.sessions.values()) {
156
162
  switch (client.state) {
157
163
  case RoomSessionState.Connected: {
158
- const hasTimedOut = timeSince(client.lastInteractionTime) > SESSION_IDLE_TIMEOUT
164
+ const hasTimedOut = timeSince(client.lastInteractionTime) > this.sessionIdleTimeout
159
165
  if (hasTimedOut || !client.socket.isOpen) {
160
166
  this.cancelSession(client.sessionId)
161
167
  }
@@ -166,6 +172,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
166
172
  if (hasTimedOut || !client.socket.isOpen) {
167
173
  // remove immediately
168
174
  this.removeSession(client.sessionId)
175
+ } else {
176
+ this.scheduleFollowUpPrune()
169
177
  }
170
178
  break
171
179
  }
@@ -173,6 +181,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
173
181
  const hasTimedOut = timeSince(client.cancellationTime) > SESSION_REMOVAL_WAIT_TIME
174
182
  if (hasTimedOut) {
175
183
  this.removeSession(client.sessionId)
184
+ } else {
185
+ this.scheduleFollowUpPrune()
176
186
  }
177
187
  break
178
188
  }
@@ -181,11 +191,16 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
181
191
  }
182
192
  }
183
193
  }
194
+ }, 1000)
195
+
196
+ private scheduleFollowUpPrune() {
197
+ if (this.pruneTimer) return
198
+ this.pruneTimer = setTimeout(this.pruneSessions, SESSION_REMOVAL_WAIT_TIME + 100)
184
199
  }
185
200
 
186
201
  readonly presenceStore = new PresenceStore<R>()
187
202
 
188
- private disposables: Array<() => void> = [interval(this.pruneSessions, 2000)]
203
+ private disposables: Array<() => void> = []
189
204
 
190
205
  private _isClosed = false
191
206
 
@@ -225,17 +240,20 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
225
240
  private log?: TLSyncLog
226
241
  public readonly schema: StoreSchema<R, any>
227
242
  private onPresenceChange?(): void
243
+ private readonly sessionIdleTimeout: number
228
244
 
229
245
  constructor(opts: {
230
246
  log?: TLSyncLog
231
247
  schema: StoreSchema<R, any>
232
248
  onPresenceChange?(): void
233
249
  storage: TLSyncStorage<R>
250
+ clientTimeout?: number
234
251
  }) {
235
252
  this.schema = opts.schema
236
253
  this.log = opts.log
237
254
  this.onPresenceChange = opts.onPresenceChange
238
255
  this.storage = opts.storage
256
+ this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
239
257
 
240
258
  assert(
241
259
  isNativeStructuredClone,
@@ -277,6 +295,24 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
277
295
  }
278
296
  })
279
297
  )
298
+
299
+ this.disposables.push(() => {
300
+ this.pruneSessions.cancel()
301
+ if (this.pruneTimer) {
302
+ clearTimeout(this.pruneTimer)
303
+ this.pruneTimer = null
304
+ }
305
+ })
306
+
307
+ // When clientTimeout is finite, run periodic pruning so idle sessions are
308
+ // cleaned up even with no traffic. When Infinity or 0 we skip the interval
309
+ // (e.g. for hibernation); without it, pruning only runs on message or when
310
+ // socket close/error triggers cancelSession, so pruning idle sessions
311
+ // reliably depends on the runtime delivering those events.
312
+ if (Number.isFinite(this.sessionIdleTimeout) && this.sessionIdleTimeout > 0) {
313
+ const pruneIntervalMs = Math.min(2000, Math.floor(this.sessionIdleTimeout / 4))
314
+ this.disposables.push(interval(() => this.pruneSessions(), pruneIntervalMs))
315
+ }
280
316
  }
281
317
  private broadcastExternalStorageChanges() {
282
318
  this.storage.transaction((txn) => {
@@ -412,6 +448,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
412
448
  } catch {
413
449
  // noop, calling .close() multiple times is fine
414
450
  }
451
+
452
+ this.scheduleFollowUpPrune()
415
453
  }
416
454
 
417
455
  readonly internalTxnId = 'TLSyncRoom.txn'
@@ -529,6 +567,60 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
529
567
  return this
530
568
  }
531
569
 
570
+ /**
571
+ * Resume a previously-connected session directly into `Connected` state, bypassing the
572
+ * connect handshake. Used after server hibernation when the WebSocket is still alive but
573
+ * all in-memory state has been lost.
574
+ *
575
+ * @internal
576
+ */
577
+ handleResumedSession(opts: {
578
+ sessionId: string
579
+ socket: TLRoomSocket<R>
580
+ meta: SessionMeta
581
+ isReadonly: boolean
582
+ serializedSchema: SerializedSchema
583
+ presenceId: string | null
584
+ presenceRecord: UnknownRecord | null
585
+ requiresLegacyRejection: boolean
586
+ supportsStringAppend: boolean
587
+ }) {
588
+ const {
589
+ sessionId,
590
+ socket,
591
+ meta,
592
+ isReadonly,
593
+ serializedSchema,
594
+ presenceId,
595
+ presenceRecord,
596
+ requiresLegacyRejection,
597
+ supportsStringAppend,
598
+ } = opts
599
+
600
+ const migrations = this.schema.getMigrationsSince(serializedSchema)
601
+ const requiresDownMigrations = migrations.ok ? migrations.value.length > 0 : false
602
+
603
+ this.sessions.set(sessionId, {
604
+ state: RoomSessionState.Connected,
605
+ sessionId,
606
+ socket,
607
+ presenceId: presenceId ?? this.presenceType?.createId() ?? null,
608
+ serializedSchema,
609
+ requiresDownMigrations,
610
+ lastInteractionTime: Date.now(),
611
+ debounceTimer: null,
612
+ outstandingDataMessages: [],
613
+ meta,
614
+ isReadonly,
615
+ requiresLegacyRejection,
616
+ supportsStringAppend,
617
+ })
618
+
619
+ if (presenceRecord && presenceId) {
620
+ this.presenceStore.set(presenceId, presenceRecord as R)
621
+ }
622
+ }
623
+
532
624
  /**
533
625
  * Checks if all connected sessions support string append operations (protocol version 8+).
534
626
  * If any client is on an older version, returns false to enable legacy append mode.
@@ -69,8 +69,10 @@ export const TLIncompatibilityReason = {
69
69
  * @internal
70
70
  * @deprecated replaced by websocket .close status/reason
71
71
  */
72
+ /* eslint-disable @typescript-eslint/no-deprecated */
72
73
  export type TLIncompatibilityReason =
73
74
  (typeof TLIncompatibilityReason)[keyof typeof TLIncompatibilityReason]
75
+ /* eslint-enable @typescript-eslint/no-deprecated */
74
76
 
75
77
  /**
76
78
  * Union type representing all possible message types that can be sent from server to client.
@@ -19,7 +19,7 @@ export class RandomSource {
19
19
  }
20
20
 
21
21
  randomAction<Result>(
22
- // eslint-disable-next-line @typescript-eslint/method-signature-style
22
+ // eslint-disable-next-line tldraw/method-signature-style
23
23
  choices: Array<(() => Result) | { weight: number; do: () => any }>,
24
24
  randomWeights?: boolean
25
25
  ): Result {