@tldraw/sync-core 4.3.0-canary.cf5673a789a1 → 4.3.0-canary.d428e9e9a7c6

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 (51) hide show
  1. package/dist-cjs/index.d.ts +239 -57
  2. package/dist-cjs/index.js +7 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/TLSocketRoom.js +117 -69
  8. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncClient.js +7 -0
  10. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncRoom.js +357 -688
  12. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  13. package/dist-cjs/lib/TLSyncStorage.js +76 -0
  14. package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
  15. package/dist-cjs/lib/recordDiff.js +52 -0
  16. package/dist-cjs/lib/recordDiff.js.map +7 -0
  17. package/dist-esm/index.d.mts +239 -57
  18. package/dist-esm/index.mjs +12 -5
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/TLSocketRoom.mjs +121 -70
  24. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncClient.mjs +7 -0
  26. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncRoom.mjs +370 -702
  28. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  29. package/dist-esm/lib/TLSyncStorage.mjs +56 -0
  30. package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
  31. package/dist-esm/lib/recordDiff.mjs +32 -0
  32. package/dist-esm/lib/recordDiff.mjs.map +7 -0
  33. package/package.json +6 -6
  34. package/src/index.ts +21 -3
  35. package/src/lib/InMemorySyncStorage.ts +357 -0
  36. package/src/lib/RoomSession.test.ts +1 -0
  37. package/src/lib/RoomSession.ts +2 -0
  38. package/src/lib/TLSocketRoom.ts +228 -114
  39. package/src/lib/TLSyncClient.ts +12 -0
  40. package/src/lib/TLSyncRoom.ts +473 -913
  41. package/src/lib/TLSyncStorage.ts +216 -0
  42. package/src/lib/recordDiff.ts +73 -0
  43. package/src/test/InMemorySyncStorage.test.ts +1674 -0
  44. package/src/test/TLSocketRoom.test.ts +255 -49
  45. package/src/test/TLSyncRoom.test.ts +1021 -533
  46. package/src/test/TestServer.ts +12 -1
  47. package/src/test/customMessages.test.ts +1 -1
  48. package/src/test/presenceMode.test.ts +6 -6
  49. package/src/test/upgradeDowngrade.test.ts +282 -8
  50. package/src/test/validation.test.ts +10 -10
  51. package/src/test/pruneTombstones.test.ts +0 -178
@@ -1,55 +1,55 @@
1
- import { transact, transaction } from '@tldraw/state'
2
1
  import {
3
2
  AtomMap,
4
- IdOf,
5
3
  MigrationFailureReason,
6
4
  RecordType,
7
5
  SerializedSchema,
8
6
  StoreSchema,
9
7
  UnknownRecord,
10
8
  } from '@tldraw/store'
11
- import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from '@tldraw/tlschema'
12
9
  import {
13
- IndexKey,
14
- Result,
15
10
  assert,
16
11
  assertExists,
17
12
  exhaustiveSwitchError,
18
13
  getOwnProperty,
19
- hasOwnProperty,
20
14
  isEqual,
21
15
  isNativeStructuredClone,
22
16
  objectMapEntriesIterable,
23
- structuredClone,
17
+ Result,
24
18
  } from '@tldraw/utils'
25
19
  import { createNanoEvents } from 'nanoevents'
26
20
  import {
27
- RoomSession,
28
- RoomSessionState,
29
- SESSION_IDLE_TIMEOUT,
30
- SESSION_REMOVAL_WAIT_TIME,
31
- SESSION_START_WAIT_TIME,
32
- } from './RoomSession'
33
- import { TLSyncLog } from './TLSocketRoom'
34
- import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from './TLSyncClient'
35
- import {
21
+ applyObjectDiff,
22
+ diffRecord,
36
23
  NetworkDiff,
37
24
  ObjectDiff,
38
25
  RecordOp,
39
26
  RecordOpType,
40
27
  ValueOpType,
41
- applyObjectDiff,
42
- diffRecord,
43
28
  } from './diff'
44
- import { findMin } from './findMin'
45
29
  import { interval } from './interval'
46
30
  import {
31
+ getTlsyncProtocolVersion,
47
32
  TLIncompatibilityReason,
48
33
  TLSocketClientSentEvent,
49
34
  TLSocketServerSentDataEvent,
50
35
  TLSocketServerSentEvent,
51
- getTlsyncProtocolVersion,
52
36
  } from './protocol'
37
+ import { applyAndDiffRecord, diffAndValidateRecord, validateRecord } from './recordDiff'
38
+ import {
39
+ RoomSession,
40
+ RoomSessionState,
41
+ SESSION_IDLE_TIMEOUT,
42
+ SESSION_REMOVAL_WAIT_TIME,
43
+ SESSION_START_WAIT_TIME,
44
+ } from './RoomSession'
45
+ import { TLSyncLog } from './TLSocketRoom'
46
+ import { TLSyncError, TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from './TLSyncClient'
47
+ import {
48
+ TLSyncForwardDiff,
49
+ TLSyncStorage,
50
+ TLSyncStorageTransaction,
51
+ toNetworkDiff,
52
+ } from './TLSyncStorage'
53
53
 
54
54
  /**
55
55
  * WebSocket interface for server-side room connections. This defines the contract
@@ -77,20 +77,6 @@ export interface TLRoomSocket<R extends UnknownRecord> {
77
77
  close(code?: number, reason?: string): void
78
78
  }
79
79
 
80
- /**
81
- * The maximum number of tombstone records to keep in memory. Tombstones track
82
- * deleted records to prevent resurrection during sync operations.
83
- * @public
84
- */
85
- export const MAX_TOMBSTONES = 3000
86
-
87
- /**
88
- * The number of tombstones to delete when pruning occurs after reaching MAX_TOMBSTONES.
89
- * This buffer prevents frequent pruning operations.
90
- * @public
91
- */
92
- export const TOMBSTONE_PRUNE_BUFFER_SIZE = 300
93
-
94
80
  /**
95
81
  * The minimum time interval (in milliseconds) between sending batched data messages
96
82
  * to clients. This debouncing prevents overwhelming clients with rapid updates.
@@ -100,97 +86,6 @@ export const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1000 / 60
100
86
 
101
87
  const timeSince = (time: number) => Date.now() - time
102
88
 
103
- /**
104
- * Represents the state of a document record within a sync room, including
105
- * its current data and the clock value when it was last modified.
106
- *
107
- * @internal
108
- */
109
- export class DocumentState<R extends UnknownRecord> {
110
- /**
111
- * Create a DocumentState instance without validating the record data.
112
- * Used for performance when validation has already been performed.
113
- *
114
- * @param state - The record data
115
- * @param lastChangedClock - Clock value when this record was last modified
116
- * @param recordType - The record type definition for validation
117
- * @returns A new DocumentState instance
118
- */
119
- static createWithoutValidating<R extends UnknownRecord>(
120
- state: R,
121
- lastChangedClock: number,
122
- recordType: RecordType<R, any>
123
- ): DocumentState<R> {
124
- return new DocumentState(state, lastChangedClock, recordType)
125
- }
126
-
127
- /**
128
- * Create a DocumentState instance with validation of the record data.
129
- *
130
- * @param state - The record data to validate
131
- * @param lastChangedClock - Clock value when this record was last modified
132
- * @param recordType - The record type definition for validation
133
- * @returns Result containing the DocumentState or validation error
134
- */
135
- static createAndValidate<R extends UnknownRecord>(
136
- state: R,
137
- lastChangedClock: number,
138
- recordType: RecordType<R, any>
139
- ): Result<DocumentState<R>, Error> {
140
- try {
141
- recordType.validate(state)
142
- } catch (error: any) {
143
- return Result.err(error)
144
- }
145
- return Result.ok(new DocumentState(state, lastChangedClock, recordType))
146
- }
147
-
148
- private constructor(
149
- public readonly state: R,
150
- public readonly lastChangedClock: number,
151
- private readonly recordType: RecordType<R, any>
152
- ) {}
153
-
154
- /**
155
- * Replace the current state with new state and calculate the diff.
156
- *
157
- * @param state - The new record state
158
- * @param clock - The new clock value
159
- * @param legacyAppendMode - If true, string append operations will be converted to Put operations
160
- * @returns Result containing the diff and new DocumentState, or null if no changes, or validation error
161
- */
162
- replaceState(
163
- state: R,
164
- clock: number,
165
- legacyAppendMode = false
166
- ): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
167
- const diff = diffRecord(this.state, state, legacyAppendMode)
168
- if (!diff) return Result.ok(null)
169
- try {
170
- this.recordType.validate(state)
171
- } catch (error: any) {
172
- return Result.err(error)
173
- }
174
- return Result.ok([diff, new DocumentState(state, clock, this.recordType)])
175
- }
176
- /**
177
- * Apply a diff to the current state and return the resulting changes.
178
- *
179
- * @param diff - The object diff to apply
180
- * @param clock - The new clock value
181
- * @param legacyAppendMode - If true, string append operations will be converted to Put operations
182
- * @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error
183
- */
184
- mergeDiff(
185
- diff: ObjectDiff,
186
- clock: number,
187
- legacyAppendMode = false
188
- ): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
189
- const newState = applyObjectDiff(this.state, diff)
190
- return this.replaceState(newState, clock, legacyAppendMode)
191
- }
192
- }
193
-
194
89
  /**
195
90
  * Snapshot of a room's complete state that can be persisted and restored.
196
91
  * Contains all documents, tombstones, and metadata needed to reconstruct the room.
@@ -201,7 +96,7 @@ export interface RoomSnapshot {
201
96
  /**
202
97
  * The current logical clock value for the room
203
98
  */
204
- clock: number
99
+ clock?: number
205
100
  /**
206
101
  * Clock value when document data was last changed (optional for backwards compatibility)
207
102
  */
@@ -224,20 +119,6 @@ export interface RoomSnapshot {
224
119
  schema?: SerializedSchema
225
120
  }
226
121
 
227
- function getDocumentClock(snapshot: RoomSnapshot) {
228
- if (typeof snapshot.documentClock === 'number') {
229
- return snapshot.documentClock
230
- }
231
- let max = 0
232
- for (const doc of snapshot.documents) {
233
- max = Math.max(max, doc.lastChangedClock)
234
- }
235
- for (const tombstone of Object.values(snapshot.tombstones ?? {})) {
236
- max = Math.max(max, tombstone)
237
- }
238
- return max
239
- }
240
-
241
122
  /**
242
123
  * A collaborative workspace that manages multiple client sessions and synchronizes
243
124
  * document changes between them. The room serves as the authoritative source for
@@ -267,6 +148,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
267
148
  // A table of connected clients
268
149
  readonly sessions = new Map<string, RoomSession<R, SessionMeta>>()
269
150
 
151
+ private lastDocumentClock = 0
152
+
270
153
  // eslint-disable-next-line local/prefer-class-methods
271
154
  pruneSessions = () => {
272
155
  for (const client of this.sessions.values()) {
@@ -300,6 +183,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
300
183
  }
301
184
  }
302
185
 
186
+ readonly presenceStore = new PresenceStore<R>()
187
+
303
188
  private disposables: Array<() => void> = [interval(this.pruneSessions, 2000)]
304
189
 
305
190
  private _isClosed = false
@@ -330,18 +215,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
330
215
  session_removed(args: { sessionId: string; meta: SessionMeta }): void
331
216
  }>()
332
217
 
333
- // Values associated with each uid (must be serializable).
334
- /** @internal */
335
- documents: AtomMap<string, DocumentState<R>>
336
- tombstones: AtomMap<string, number>
337
-
338
- // this clock should start higher than the client, to make sure that clients who sync with their
339
- // initial lastServerClock value get the full state
340
- // in this case clients will start with 0, and the server will start with 1
341
- clock: number
342
- documentClock: number
343
- tombstoneHistoryStartsAtClock: number
344
- // map from record id to clock upon deletion
218
+ // Storage layer for documents, tombstones, and clocks
219
+ private readonly storage: TLSyncStorage<R>
345
220
 
346
221
  readonly serializedSchema: SerializedSchema
347
222
 
@@ -349,21 +224,18 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
349
224
  readonly presenceType: RecordType<R, any> | null
350
225
  private log?: TLSyncLog
351
226
  public readonly schema: StoreSchema<R, any>
352
- private onDataChange?(): void
353
227
  private onPresenceChange?(): void
354
228
 
355
229
  constructor(opts: {
356
230
  log?: TLSyncLog
357
231
  schema: StoreSchema<R, any>
358
- snapshot?: RoomSnapshot
359
- onDataChange?(): void
360
232
  onPresenceChange?(): void
233
+ storage: TLSyncStorage<R>
361
234
  }) {
362
235
  this.schema = opts.schema
363
- let snapshot = opts.snapshot
364
236
  this.log = opts.log
365
- this.onDataChange = opts.onDataChange
366
237
  this.onPresenceChange = opts.onPresenceChange
238
+ this.storage = opts.storage
367
239
 
368
240
  assert(
369
241
  isNativeStructuredClone,
@@ -392,231 +264,34 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
392
264
 
393
265
  this.presenceType = presenceTypes.values().next()?.value ?? null
394
266
 
395
- if (!snapshot) {
396
- snapshot = {
397
- clock: 0,
398
- documentClock: 0,
399
- documents: [
400
- {
401
- state: DocumentRecordType.create({ id: TLDOCUMENT_ID }),
402
- lastChangedClock: 0,
403
- },
404
- {
405
- state: PageRecordType.create({ name: 'Page 1', index: 'a1' as IndexKey }),
406
- lastChangedClock: 0,
407
- },
408
- ],
409
- }
410
- }
411
-
412
- this.clock = snapshot.clock
413
-
414
- let didIncrementClock = false
415
- const ensureClockDidIncrement = (_reason: string) => {
416
- if (!didIncrementClock) {
417
- didIncrementClock = true
418
- this.clock++
419
- }
420
- }
421
-
422
- this.tombstones = new AtomMap(
423
- 'room tombstones',
424
- objectMapEntriesIterable(snapshot.tombstones ?? {})
425
- )
426
- this.documents = new AtomMap(
427
- 'room documents',
428
- function* (this: TLSyncRoom<R, SessionMeta>) {
429
- for (const doc of snapshot.documents) {
430
- if (this.documentTypes.has(doc.state.typeName)) {
431
- yield [
432
- doc.state.id,
433
- DocumentState.createWithoutValidating<R>(
434
- doc.state as R,
435
- doc.lastChangedClock,
436
- assertExists(getOwnProperty(this.schema.types, doc.state.typeName))
437
- ),
438
- ] as const
439
- } else {
440
- ensureClockDidIncrement('doc type was not doc type')
441
- this.tombstones.set(doc.state.id, this.clock)
442
- }
443
- }
444
- }.call(this)
445
- )
446
-
447
- this.tombstoneHistoryStartsAtClock =
448
- snapshot.tombstoneHistoryStartsAtClock ?? findMin(this.tombstones.values()) ?? this.clock
449
-
450
- if (this.tombstoneHistoryStartsAtClock === 0) {
451
- // Before this comment was added, new clients would send '0' as their 'lastServerClock'
452
- // which was technically an error because clocks start at 0, but the error didn't manifest
453
- // because we initialized tombstoneHistoryStartsAtClock to 1 and then never updated it.
454
- // Now that we handle tombstoneHistoryStartsAtClock properly we need to increment it here to make sure old
455
- // clients still get data when they connect. This if clause can be deleted after a few months.
456
- this.tombstoneHistoryStartsAtClock++
457
- }
458
-
459
- transact(() => {
460
- // eslint-disable-next-line @typescript-eslint/no-deprecated
461
- const schema = snapshot.schema ?? this.schema.serializeEarliestVersion()
462
-
463
- const migrationsToApply = this.schema.getMigrationsSince(schema)
464
- assert(migrationsToApply.ok, 'Failed to get migrations')
465
-
466
- if (migrationsToApply.value.length > 0) {
467
- // only bother allocating a snapshot if there are migrations to apply
468
- const store = {} as Record<IdOf<R>, R>
469
- for (const [k, v] of this.documents.entries()) {
470
- store[k as IdOf<R>] = v.state
471
- }
472
-
473
- const migrationResult = this.schema.migrateStoreSnapshot(
474
- { store, schema },
475
- { mutateInputStore: true }
476
- )
477
-
478
- if (migrationResult.type === 'error') {
479
- // TODO: Fault tolerance
480
- throw new Error('Failed to migrate: ' + migrationResult.reason)
481
- }
482
-
483
- // use for..in to iterate over the keys of the object because it consumes less memory than
484
- // Object.entries
485
- for (const id in migrationResult.value) {
486
- if (!Object.prototype.hasOwnProperty.call(migrationResult.value, id)) {
487
- continue
488
- }
489
- const r = migrationResult.value[id as keyof typeof migrationResult.value]
490
- const existing = this.documents.get(id)
491
- if (!existing || !isEqual(existing.state, r)) {
492
- // record was added or updated during migration
493
- ensureClockDidIncrement('record was added or updated during migration')
494
- this.documents.set(
495
- r.id,
496
- DocumentState.createWithoutValidating(
497
- r,
498
- this.clock,
499
- assertExists(getOwnProperty(this.schema.types, r.typeName)) as any
500
- )
501
- )
502
- }
503
- }
504
-
505
- for (const id of this.documents.keys()) {
506
- if (!migrationResult.value[id as keyof typeof migrationResult.value]) {
507
- // record was removed during migration
508
- ensureClockDidIncrement('record was removed during migration')
509
- this.tombstones.set(id, this.clock)
510
- this.documents.delete(id)
511
- }
512
- }
513
- }
514
-
515
- this.pruneTombstones()
267
+ const { documentClock } = this.storage.transaction((txn) => {
268
+ this.schema.migrateStorage(txn)
516
269
  })
517
270
 
518
- if (didIncrementClock) {
519
- this.documentClock = this.clock
520
- opts.onDataChange?.()
521
- } else {
522
- this.documentClock = getDocumentClock(snapshot)
523
- }
524
- }
525
-
526
- private didSchedulePrune = true
527
- // eslint-disable-next-line local/prefer-class-methods
528
- private pruneTombstones = () => {
529
- this.didSchedulePrune = false
530
- // avoid blocking any pending responses
531
- if (this.tombstones.size > MAX_TOMBSTONES) {
532
- const entries = Array.from(this.tombstones.entries())
533
- // sort entries in ascending order by clock
534
- entries.sort((a, b) => a[1] - b[1])
535
- let idx = entries.length - 1 - MAX_TOMBSTONES + TOMBSTONE_PRUNE_BUFFER_SIZE
536
- const cullClock = entries[idx++][1]
537
- while (idx < entries.length && entries[idx][1] === cullClock) {
538
- idx++
539
- }
540
- // trim off the first bunch
541
- const keysToDelete = entries.slice(0, idx).map(([key]) => key)
542
-
543
- this.tombstoneHistoryStartsAtClock = cullClock + 1
544
- this.tombstones.deleteMany(keysToDelete)
545
- }
546
- }
547
-
548
- private getDocument(id: string) {
549
- return this.documents.get(id)
550
- }
271
+ this.lastDocumentClock = documentClock
551
272
 
552
- private addDocument(id: string, state: R, clock: number): Result<void, Error> {
553
- if (this.tombstones.has(id)) {
554
- this.tombstones.delete(id)
555
- }
556
- const createResult = DocumentState.createAndValidate(
557
- state,
558
- clock,
559
- assertExists(getOwnProperty(this.schema.types, state.typeName))
273
+ this.disposables.push(
274
+ this.storage.onChange(({ id }) => {
275
+ if (id !== this.internalTxnId) {
276
+ this.broadcastExternalStorageChanges()
277
+ }
278
+ })
560
279
  )
561
- if (!createResult.ok) return createResult
562
- this.documents.set(id, createResult.value)
563
- return Result.ok(undefined)
564
280
  }
565
-
566
- private removeDocument(id: string, clock: number) {
567
- this.documents.delete(id)
568
- this.tombstones.set(id, clock)
569
- if (!this.didSchedulePrune) {
570
- this.didSchedulePrune = true
571
- setTimeout(this.pruneTombstones, 0)
572
- }
573
- }
574
-
575
- /**
576
- * Get a complete snapshot of the current room state that can be persisted
577
- * and later used to restore the room.
578
- *
579
- * @returns Room snapshot containing all documents, tombstones, and metadata
580
- * @example
581
- * ```ts
582
- * const snapshot = room.getSnapshot()
583
- * await database.saveRoomSnapshot(roomId, snapshot)
584
- *
585
- * // Later, restore from snapshot
586
- * const restoredRoom = new TLSyncRoom({
587
- * schema: mySchema,
588
- * snapshot: snapshot
589
- * })
590
- * ```
591
- */
592
- getSnapshot(): RoomSnapshot {
593
- const tombstones = Object.fromEntries(this.tombstones.entries())
594
- const documents = []
595
- for (const doc of this.documents.values()) {
596
- if (this.documentTypes.has(doc.state.typeName)) {
597
- documents.push({
598
- state: doc.state,
599
- lastChangedClock: doc.lastChangedClock,
600
- })
601
- }
602
- }
603
- return {
604
- clock: this.clock,
605
- documentClock: this.documentClock,
606
- tombstones,
607
- tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock,
608
- schema: this.serializedSchema,
609
- documents,
610
- }
281
+ private broadcastExternalStorageChanges() {
282
+ this.storage.transaction((txn) => {
283
+ this.broadcastChanges(txn)
284
+ this.lastDocumentClock = txn.getClock()
285
+ }) // no id needed because this only reads, no writes.
611
286
  }
612
287
 
613
288
  /**
614
289
  * Send a message to a particular client. Debounces data events
615
290
  *
616
291
  * @param sessionId - The id of the session to send the message to.
617
- * @param message - The message to send.
292
+ * @param message - The message to send. UNSAFE Any diffs must have been downgraded already if necessary
618
293
  */
619
- private sendMessage(
294
+ private _unsafe_sendMessage(
620
295
  sessionId: string,
621
296
  message: TLSocketServerSentEvent<R> | TLSocketServerSentDataEvent<R>
622
297
  ) {
@@ -683,8 +358,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
683
358
 
684
359
  this.sessions.delete(sessionId)
685
360
 
686
- const presence = this.getDocument(session.presenceId ?? '')
687
-
688
361
  try {
689
362
  if (fatalReason) {
690
363
  session.socket.close(TLSyncErrorCloseEventCode, fatalReason)
@@ -695,12 +368,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
695
368
  // noop, calling .close() multiple times is fine
696
369
  }
697
370
 
371
+ const presence = this.presenceStore.get(session.presenceId ?? '')
698
372
  if (presence) {
699
- this.documents.delete(session.presenceId!)
700
-
373
+ this.presenceStore.delete(session.presenceId!)
374
+ // Broadcast presence removal - use RecordsDiff with the removed record
701
375
  this.broadcastPatch({
702
- diff: { [session.presenceId!]: [RecordOpType.Remove] },
703
- sourceSessionId: sessionId,
376
+ puts: {},
377
+ deletes: [session.presenceId!],
704
378
  })
705
379
  }
706
380
 
@@ -740,24 +414,25 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
740
414
  }
741
415
  }
742
416
 
417
+ readonly internalTxnId = 'TLSyncRoom.txn'
418
+
743
419
  /**
744
420
  * Broadcast a patch to all connected clients except the one with the sessionId provided.
745
- * Automatically handles schema migration for clients on different versions.
746
421
  *
747
- * @param message - The broadcast message
748
- * - diff - The network diff to broadcast to all clients
749
- * - sourceSessionId - Optional ID of the session that originated this change (excluded from broadcast)
750
- * @returns This room instance for method chaining
751
- * @example
752
- * ```ts
753
- * room.broadcastPatch({
754
- * diff: { 'shape:123': [RecordOpType.Put, newShapeData] },
755
- * sourceSessionId: 'user-456' // This user won't receive the broadcast
756
- * })
757
- * ```
422
+ * @param diff - The TLSyncForwardDiff with full records (used for migration)
423
+ * @param networkDiff - Optional pre-computed NetworkDiff for sessions not needing migration.
424
+ * If not provided, will be computed from recordsDiff.
425
+ * @param sourceSessionId - Optional session ID to exclude from the broadcast
758
426
  */
759
- broadcastPatch(message: { diff: NetworkDiff<R>; sourceSessionId?: string }) {
760
- const { diff, sourceSessionId } = message
427
+ private broadcastPatch(
428
+ diff: TLSyncForwardDiff<R>,
429
+ networkDiff?: NetworkDiff<R> | null,
430
+ sourceSessionId?: string
431
+ ) {
432
+ // Pre-compute network diff if not provided
433
+ const unmigrated = networkDiff ?? toNetworkDiff(diff)
434
+ if (!unmigrated) return this
435
+
761
436
  this.sessions.forEach((session) => {
762
437
  if (session.state !== RoomSessionState.Connected) return
763
438
  if (sourceSessionId === session.sessionId) return
@@ -766,23 +441,18 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
766
441
  return
767
442
  }
768
443
 
769
- const res = this.migrateDiffForSession(session.serializedSchema, diff)
770
-
771
- if (!res.ok) {
772
- // disconnect client and send incompatibility error
773
- this.rejectSession(
774
- session.sessionId,
775
- res.error === MigrationFailureReason.TargetVersionTooNew
776
- ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
777
- : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
778
- )
779
- return
780
- }
444
+ const diffResult = this.migrateDiffOrRejectSession(
445
+ session.sessionId,
446
+ session.serializedSchema,
447
+ session.requiresDownMigrations,
448
+ diff
449
+ )
450
+ if (!diffResult.ok) return
781
451
 
782
- this.sendMessage(session.sessionId, {
452
+ this._unsafe_sendMessage(session.sessionId, {
783
453
  type: 'patch',
784
- diff: res.value,
785
- serverClock: this.clock,
454
+ diff: diffResult.value,
455
+ serverClock: this.lastDocumentClock,
786
456
  })
787
457
  })
788
458
  return this
@@ -811,7 +481,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
811
481
  * ```
812
482
  */
813
483
  sendCustomMessage(sessionId: string, data: any): void {
814
- this.sendMessage(sessionId, { type: 'custom', data })
484
+ this._unsafe_sendMessage(sessionId, { type: 'custom', data })
815
485
  }
816
486
 
817
487
  /**
@@ -878,45 +548,67 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
878
548
 
879
549
  /**
880
550
  * When we send a diff to a client, if that client is on a lower version than us, we need to make
881
- * the diff compatible with their version. At the moment this means migrating each affected record
882
- * to the client's version and sending the whole record again. We can optimize this later by
883
- * keeping the previous versions of records around long enough to recalculate these diffs for
884
- * older client versions.
551
+ * the diff compatible with their version. This method takes a TLSyncForwardDiff (which has full
552
+ * records) and migrates all records down to the client's schema version, returning a NetworkDiff.
553
+ *
554
+ * For updates (entries with [before, after] tuples), both records are migrated and a patch is
555
+ * computed from the migrated versions, preserving efficient patch semantics even across versions.
556
+ *
557
+ * If a migration fails, the session will be rejected.
558
+ *
559
+ * @param sessionId - The session ID (for rejection on migration failure)
560
+ * @param serializedSchema - The client's schema to migrate to
561
+ * @param requiresDownMigrations - Whether the client needs down migrations
562
+ * @param diff - The TLSyncForwardDiff containing full records to migrate
563
+ * @param unmigrated - Optional pre-computed NetworkDiff for when no migration is needed
564
+ * @returns A NetworkDiff with migrated records, or a migration failure
885
565
  */
886
- private migrateDiffForSession(
566
+ private migrateDiffOrRejectSession(
567
+ sessionId: string,
887
568
  serializedSchema: SerializedSchema,
888
- diff: NetworkDiff<R>
569
+ requiresDownMigrations: boolean,
570
+ diff: TLSyncForwardDiff<R>,
571
+ unmigrated?: NetworkDiff<R>
889
572
  ): Result<NetworkDiff<R>, MigrationFailureReason> {
890
- // TODO: optimize this by recalculating patches using the previous versions of records
891
-
892
- // when the client connects we check whether the schema is identical and make sure
893
- // to use the same object reference so that === works on this line
894
- if (serializedSchema === this.serializedSchema) {
895
- return Result.ok(diff)
573
+ if (!requiresDownMigrations) {
574
+ return Result.ok(unmigrated ?? toNetworkDiff(diff) ?? {})
896
575
  }
897
576
 
898
577
  const result: NetworkDiff<R> = {}
899
- for (const [id, op] of objectMapEntriesIterable(diff)) {
900
- if (op[0] === RecordOpType.Remove) {
901
- result[id] = op
902
- continue
903
- }
904
578
 
905
- const doc = this.getDocument(id)
906
- if (!doc) {
907
- return Result.err(MigrationFailureReason.TargetVersionTooNew)
908
- }
909
- const migrationResult = this.schema.migratePersistedRecord(
910
- doc.state,
911
- serializedSchema,
912
- 'down'
913
- )
914
-
915
- if (migrationResult.type === 'error') {
916
- return Result.err(migrationResult.reason)
579
+ // Migrate puts (either adds or updates)
580
+ for (const [id, put] of objectMapEntriesIterable(diff.puts)) {
581
+ if (Array.isArray(put)) {
582
+ // Update: [before, after] tuple - migrate both and compute patch
583
+ const [from, to] = put
584
+ const fromResult = this.schema.migratePersistedRecord(from, serializedSchema, 'down')
585
+ if (fromResult.type === 'error') {
586
+ this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
587
+ return Result.err(fromResult.reason)
588
+ }
589
+ const toResult = this.schema.migratePersistedRecord(to, serializedSchema, 'down')
590
+ if (toResult.type === 'error') {
591
+ this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
592
+ return Result.err(toResult.reason)
593
+ }
594
+ const patch = diffRecord(fromResult.value, toResult.value)
595
+ if (patch) {
596
+ result[id] = [RecordOpType.Patch, patch]
597
+ }
598
+ } else {
599
+ // Add: single record - migrate and put
600
+ const migrationResult = this.schema.migratePersistedRecord(put, serializedSchema, 'down')
601
+ if (migrationResult.type === 'error') {
602
+ this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
603
+ return Result.err(migrationResult.reason)
604
+ }
605
+ result[id] = [RecordOpType.Put, migrationResult.value]
917
606
  }
607
+ }
918
608
 
919
- result[id] = [RecordOpType.Put, migrationResult.value]
609
+ // Deletes don't need migration
610
+ for (const id of diff.deletes) {
611
+ result[id] = [RecordOpType.Remove]
920
612
  }
921
613
 
922
614
  return Result.ok(result)
@@ -943,21 +635,30 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
943
635
  this.log?.warn?.('Received message from unknown session')
944
636
  return
945
637
  }
946
- switch (message.type) {
947
- case 'connect': {
948
- return this.handleConnectRequest(session, message)
949
- }
950
- case 'push': {
951
- return this.handlePushRequest(session, message)
952
- }
953
- case 'ping': {
954
- if (session.state === RoomSessionState.Connected) {
955
- session.lastInteractionTime = Date.now()
638
+ try {
639
+ switch (message.type) {
640
+ case 'connect': {
641
+ return this.handleConnectRequest(session, message)
642
+ }
643
+ case 'push': {
644
+ return this.handlePushRequest(session, message)
645
+ }
646
+ case 'ping': {
647
+ if (session.state === RoomSessionState.Connected) {
648
+ session.lastInteractionTime = Date.now()
649
+ }
650
+ return this._unsafe_sendMessage(session.sessionId, { type: 'pong' })
651
+ }
652
+ default: {
653
+ exhaustiveSwitchError(message)
956
654
  }
957
- return this.sendMessage(session.sessionId, { type: 'pong' })
958
655
  }
959
- default: {
960
- exhaustiveSwitchError(message)
656
+ } catch (e) {
657
+ if (e instanceof TLSyncError) {
658
+ this.rejectSession(session.sessionId, e.reason)
659
+ } else {
660
+ // log error and reboot the room?
661
+ throw e
961
662
  }
962
663
  }
963
664
  }
@@ -1022,6 +723,26 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1022
723
  }
1023
724
  }
1024
725
 
726
+ private forceAllReconnect() {
727
+ for (const session of this.sessions.values()) {
728
+ this.removeSession(session.sessionId)
729
+ }
730
+ }
731
+
732
+ private broadcastChanges(txn: TLSyncStorageTransaction<R>) {
733
+ const changes = txn.getChangesSince(this.lastDocumentClock)
734
+ if (!changes) return
735
+ const { wipeAll, diff } = changes
736
+ this.lastDocumentClock = txn.getClock()
737
+ if (wipeAll) {
738
+ // If this happens it means we'd need to broadcast a wipe_all message to all clients,
739
+ // which is not part of the protocol yet, so we need to force all clients to reconnect instead.
740
+ this.forceAllReconnect()
741
+ return
742
+ }
743
+ this.broadcastPatch(diff)
744
+ }
745
+
1025
746
  private handleConnectRequest(
1026
747
  session: RoomSession<R, SessionMeta>,
1027
748
  message: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }>
@@ -1059,7 +780,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1059
780
  }
1060
781
  const migrations = this.schema.getMigrationsSince(message.schema)
1061
782
  // if the client's store is at a different version to ours, we can't support them
1062
- if (!migrations.ok || migrations.value.some((m) => m.scope === 'store' || !m.down)) {
783
+ if (!migrations.ok || migrations.value.some((m) => m.scope !== 'record' || !m.down)) {
1063
784
  this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
1064
785
  return
1065
786
  }
@@ -1068,6 +789,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1068
789
  ? this.serializedSchema
1069
790
  : message.schema
1070
791
 
792
+ const requiresDownMigrations = migrations.value.length > 0
793
+
1071
794
  const connect = async (msg: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) => {
1072
795
  this.sessions.set(session.sessionId, {
1073
796
  state: RoomSessionState.Connected,
@@ -1075,6 +798,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1075
798
  presenceId: session.presenceId,
1076
799
  socket: session.socket,
1077
800
  serializedSchema: sessionSchema,
801
+ requiresDownMigrations,
1078
802
  lastInteractionTime: Date.now(),
1079
803
  debounceTimer: null,
1080
804
  outstandingDataMessages: [],
@@ -1083,85 +807,54 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1083
807
  isReadonly: session.isReadonly,
1084
808
  requiresLegacyRejection: session.requiresLegacyRejection,
1085
809
  })
1086
- this.sendMessage(session.sessionId, msg)
810
+ this._unsafe_sendMessage(session.sessionId, msg)
1087
811
  }
1088
812
 
1089
- transaction((rollback) => {
1090
- if (
1091
- // if the client requests changes since a time before we have tombstone history, send them the full state
1092
- message.lastServerClock < this.tombstoneHistoryStartsAtClock ||
1093
- // similarly, if they ask for a time we haven't reached yet, send them the full state
1094
- // this will only happen if the DB is reset (or there is no db) and the server restarts
1095
- // or if the server exits/crashes with unpersisted changes
1096
- message.lastServerClock > this.clock
1097
- ) {
1098
- const diff: NetworkDiff<R> = {}
1099
- for (const [id, doc] of this.documents.entries()) {
1100
- if (id !== session.presenceId) {
1101
- diff[id] = [RecordOpType.Put, doc.state]
1102
- }
1103
- }
1104
- const migrated = this.migrateDiffForSession(sessionSchema, diff)
1105
- if (!migrated.ok) {
1106
- rollback()
1107
- this.rejectSession(
1108
- session.sessionId,
1109
- migrated.error === MigrationFailureReason.TargetVersionTooNew
1110
- ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
1111
- : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
1112
- )
1113
- return
1114
- }
1115
- connect({
1116
- type: 'connect',
1117
- connectRequestId: message.connectRequestId,
1118
- hydrationType: 'wipe_all',
1119
- protocolVersion: getTlsyncProtocolVersion(),
1120
- schema: this.schema.serialize(),
1121
- serverClock: this.clock,
1122
- diff: migrated.value,
1123
- isReadonly: session.isReadonly,
1124
- })
1125
- } else {
1126
- // calculate the changes since the time the client last saw
1127
- const diff: NetworkDiff<R> = {}
1128
- for (const doc of this.documents.values()) {
1129
- if (doc.lastChangedClock > message.lastServerClock) {
1130
- diff[doc.state.id] = [RecordOpType.Put, doc.state]
1131
- } else if (this.presenceType?.isId(doc.state.id) && doc.state.id !== session.presenceId) {
1132
- diff[doc.state.id] = [RecordOpType.Put, doc.state]
1133
- }
1134
- }
1135
- for (const [id, deletedAtClock] of this.tombstones.entries()) {
1136
- if (deletedAtClock > message.lastServerClock) {
1137
- diff[id] = [RecordOpType.Remove]
1138
- }
1139
- }
1140
-
1141
- const migrated = this.migrateDiffForSession(sessionSchema, diff)
1142
- if (!migrated.ok) {
1143
- rollback()
1144
- this.rejectSession(
1145
- session.sessionId,
1146
- migrated.error === MigrationFailureReason.TargetVersionTooNew
1147
- ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
1148
- : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
1149
- )
1150
- return
813
+ const { documentClock, result } = this.storage.transaction((txn) => {
814
+ this.broadcastChanges(txn)
815
+ const docChanges = txn.getChangesSince(message.lastServerClock)
816
+ const presenceDiff = this.migrateDiffOrRejectSession(
817
+ session.sessionId,
818
+ sessionSchema,
819
+ requiresDownMigrations,
820
+ {
821
+ puts: Object.fromEntries([...this.presenceStore.values()].map((p) => [p.id, p])),
822
+ deletes: [],
1151
823
  }
824
+ )
825
+ if (!presenceDiff.ok) return null
1152
826
 
1153
- connect({
1154
- type: 'connect',
1155
- connectRequestId: message.connectRequestId,
1156
- hydrationType: 'wipe_presence',
1157
- schema: this.schema.serialize(),
1158
- protocolVersion: getTlsyncProtocolVersion(),
1159
- serverClock: this.clock,
1160
- diff: migrated.value,
1161
- isReadonly: session.isReadonly,
1162
- })
827
+ // Migrate the diff if needed, or use the pre-computed network diff
828
+ let docDiff: NetworkDiff<R> | null = null
829
+ if (docChanges && sessionSchema !== this.serializedSchema) {
830
+ const migrated = this.migrateDiffOrRejectSession(
831
+ session.sessionId,
832
+ sessionSchema,
833
+ requiresDownMigrations,
834
+ docChanges.diff
835
+ )
836
+ if (!migrated.ok) return null
837
+ docDiff = migrated.value
838
+ } else if (docChanges) {
839
+ docDiff = toNetworkDiff(docChanges.diff)
1163
840
  }
1164
- })
841
+ return {
842
+ type: 'connect',
843
+ connectRequestId: message.connectRequestId,
844
+ hydrationType: docChanges?.wipeAll ? 'wipe_all' : 'wipe_presence',
845
+ protocolVersion: getTlsyncProtocolVersion(),
846
+ schema: this.schema.serialize(),
847
+ serverClock: txn.getClock(),
848
+ diff: { ...presenceDiff.value, ...docDiff },
849
+ isReadonly: session.isReadonly,
850
+ } satisfies Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>
851
+ }) // no id needed because this only reads, no writes.
852
+
853
+ this.lastDocumentClock = documentClock
854
+
855
+ if (result) {
856
+ connect(result)
857
+ }
1165
858
  }
1166
859
 
1167
860
  private handlePushRequest(
@@ -1172,294 +865,282 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1172
865
  if (session && session.state !== RoomSessionState.Connected) {
1173
866
  return
1174
867
  }
1175
-
1176
868
  // update the last interaction time
1177
869
  if (session) {
1178
870
  session.lastInteractionTime = Date.now()
1179
871
  }
1180
872
 
1181
- // increment the clock for this push
1182
- this.clock++
1183
-
1184
- const initialDocumentClock = this.documentClock
1185
- let didPresenceChange = false
1186
- transaction((rollback) => {
1187
- const legacyAppendMode = !this.getCanEmitStringAppend()
1188
- // collect actual ops that resulted from the push
1189
- // these will be broadcast to other users
1190
- interface ActualChanges {
1191
- diff: NetworkDiff<R> | null
1192
- }
1193
- const docChanges: ActualChanges = { diff: null }
1194
- const presenceChanges: ActualChanges = { diff: null }
873
+ const legacyAppendMode = !this.getCanEmitStringAppend()
1195
874
 
1196
- const propagateOp = (changes: ActualChanges, id: string, op: RecordOp<R>) => {
1197
- if (!changes.diff) changes.diff = {}
1198
- changes.diff[id] = op
1199
- }
875
+ interface ActualChanges {
876
+ diffs: {
877
+ networkDiff: NetworkDiff<R>
878
+ diff: TLSyncForwardDiff<R>
879
+ } | null
880
+ }
1200
881
 
1201
- const fail = (
1202
- reason: TLSyncErrorCloseEventReason,
1203
- underlyingError?: Error
1204
- ): Result<void, void> => {
1205
- rollback()
1206
- if (session) {
1207
- this.rejectSession(session.sessionId, reason)
1208
- } else {
1209
- throw new Error('failed to apply changes: ' + reason, underlyingError)
1210
- }
1211
- if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'test') {
1212
- this.log?.error?.('failed to apply push', reason, message, underlyingError)
1213
- }
1214
- return Result.err(undefined)
882
+ const propagateOp = (
883
+ changes: ActualChanges,
884
+ id: string,
885
+ op: RecordOp<R>,
886
+ before: R | undefined,
887
+ after: R | undefined
888
+ ) => {
889
+ if (!changes.diffs) changes.diffs = { networkDiff: {}, diff: { puts: {}, deletes: [] } }
890
+ changes.diffs.networkDiff[id] = op
891
+ switch (op[0]) {
892
+ case RecordOpType.Put:
893
+ changes.diffs.diff.puts[id] = op[1]
894
+ break
895
+ case RecordOpType.Patch:
896
+ assert(before && after, 'before and after are required for patches')
897
+ changes.diffs.diff.puts[id] = [before, after]
898
+ break
899
+ case RecordOpType.Remove:
900
+ changes.diffs.diff.deletes.push(id)
901
+ break
902
+ default:
903
+ exhaustiveSwitchError(op[0])
1215
904
  }
905
+ }
1216
906
 
1217
- const addDocument = (changes: ActualChanges, id: string, _state: R): Result<void, void> => {
1218
- const res = session
1219
- ? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
1220
- : { type: 'success' as const, value: _state }
1221
- if (res.type === 'error') {
1222
- return fail(
1223
- res.reason === MigrationFailureReason.TargetVersionTooOld // target version is our version
1224
- ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
1225
- : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
1226
- )
907
+ const addDocument = (
908
+ storage: MinimalDocStore<R>,
909
+ changes: ActualChanges,
910
+ id: string,
911
+ _state: R
912
+ ): Result<void, void> => {
913
+ const res = session
914
+ ? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
915
+ : { type: 'success' as const, value: _state }
916
+ if (res.type === 'error') {
917
+ throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
918
+ }
919
+ const { value: state } = res
920
+
921
+ // Get the existing document, if any
922
+ const doc = storage.get(id) as R | undefined
923
+
924
+ if (doc) {
925
+ // If there's an existing document, replace it with the new state
926
+ // but propagate a diff rather than the entire value
927
+ const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
928
+ const diff = diffAndValidateRecord(doc, state, recordType)
929
+ if (diff) {
930
+ storage.set(id, state)
931
+ propagateOp(changes, id, [RecordOpType.Patch, diff], doc, state)
1227
932
  }
1228
- const { value: state } = res
1229
-
1230
- // Get the existing document, if any
1231
- const doc = this.getDocument(id)
933
+ } else {
934
+ // Otherwise, if we don't already have a document with this id
935
+ // create the document and propagate the put op
936
+ // set automatically clears tombstones if they exist
937
+ const recordType = assertExists(getOwnProperty(this.schema.types, state.typeName))
938
+ validateRecord(state, recordType)
939
+ storage.set(id, state)
940
+ propagateOp(changes, id, [RecordOpType.Put, state], undefined, undefined)
941
+ }
1232
942
 
1233
- if (doc) {
1234
- // If there's an existing document, replace it with the new state
1235
- // but propagate a diff rather than the entire value
1236
- const diff = doc.replaceState(state, this.clock, legacyAppendMode)
1237
- if (!diff.ok) {
1238
- return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
1239
- }
1240
- if (diff.value) {
1241
- this.documents.set(id, diff.value[1])
1242
- propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]])
1243
- }
1244
- } else {
1245
- // Otherwise, if we don't already have a document with this id
1246
- // create the document and propagate the put op
1247
- const result = this.addDocument(id, state, this.clock)
1248
- if (!result.ok) {
1249
- return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
1250
- }
1251
- propagateOp(changes, id, [RecordOpType.Put, state])
1252
- }
943
+ return Result.ok(undefined)
944
+ }
1253
945
 
1254
- return Result.ok(undefined)
946
+ const patchDocument = (
947
+ storage: MinimalDocStore<R>,
948
+ changes: ActualChanges,
949
+ id: string,
950
+ patch: ObjectDiff
951
+ ) => {
952
+ // if it was already deleted, there's no need to apply the patch
953
+ const doc = storage.get(id) as R | undefined
954
+ if (!doc) return
955
+
956
+ const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
957
+ // If the client's version of the record is older than ours,
958
+ // we apply the patch to the downgraded version of the record
959
+ const downgraded = session
960
+ ? this.schema.migratePersistedRecord(doc, session.serializedSchema, 'down')
961
+ : { type: 'success' as const, value: doc }
962
+ if (downgraded.type === 'error') {
963
+ throw new TLSyncError(downgraded.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
1255
964
  }
1256
965
 
1257
- const patchDocument = (
1258
- changes: ActualChanges,
1259
- id: string,
1260
- patch: ObjectDiff
1261
- ): Result<void, void> => {
1262
- // if it was already deleted, there's no need to apply the patch
1263
- const doc = this.getDocument(id)
1264
- if (!doc) return Result.ok(undefined)
1265
- // If the client's version of the record is older than ours,
1266
- // we apply the patch to the downgraded version of the record
1267
- const downgraded = session
1268
- ? this.schema.migratePersistedRecord(doc.state, session.serializedSchema, 'down')
1269
- : { type: 'success' as const, value: doc.state }
1270
- if (downgraded.type === 'error') {
1271
- return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
966
+ if (downgraded.value === doc) {
967
+ // If the versions are compatible, apply the patch and propagate the patch op
968
+ const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode)
969
+ if (diff) {
970
+ storage.set(id, diff[1])
971
+ propagateOp(changes, id, [RecordOpType.Patch, diff[0]], doc, diff[1])
1272
972
  }
1273
-
1274
- if (downgraded.value === doc.state) {
1275
- // If the versions are compatible, apply the patch and propagate the patch op
1276
- const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode)
1277
- if (!diff.ok) {
1278
- return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
1279
- }
1280
- if (diff.value) {
1281
- this.documents.set(id, diff.value[1])
1282
- propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]])
1283
- }
1284
- } else {
1285
- // need to apply the patch to the downgraded version and then upgrade it
1286
-
1287
- // apply the patch to the downgraded version
1288
- const patched = applyObjectDiff(downgraded.value, patch)
1289
- // then upgrade the patched version and use that as the new state
1290
- const upgraded = session
1291
- ? this.schema.migratePersistedRecord(patched, session.serializedSchema, 'up')
1292
- : { type: 'success' as const, value: patched }
1293
- // If the client's version is too old, we'll hit an error
1294
- if (upgraded.type === 'error') {
1295
- return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
1296
- }
1297
- // replace the state with the upgraded version and propagate the patch op
1298
- const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode)
1299
- if (!diff.ok) {
1300
- return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
1301
- }
1302
- if (diff.value) {
1303
- this.documents.set(id, diff.value[1])
1304
- propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]])
1305
- }
973
+ } else {
974
+ // need to apply the patch to the downgraded version and then upgrade it
975
+
976
+ // apply the patch to the downgraded version
977
+ const patched = applyObjectDiff(downgraded.value, patch)
978
+ // then upgrade the patched version and use that as the new state
979
+ const upgraded = session
980
+ ? this.schema.migratePersistedRecord(patched, session.serializedSchema, 'up')
981
+ : { type: 'success' as const, value: patched }
982
+ // If the client's version is too old, we'll hit an error
983
+ if (upgraded.type === 'error') {
984
+ throw new TLSyncError(upgraded.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
1306
985
  }
1307
-
1308
- return Result.ok(undefined)
1309
- }
1310
-
1311
- const { clientClock } = message
1312
-
1313
- if (this.presenceType && session?.presenceId && 'presence' in message && message.presence) {
1314
- if (!session) throw new Error('session is required for presence pushes')
1315
- // The push request was for the presence scope.
1316
- const id = session.presenceId
1317
- const [type, val] = message.presence
1318
- const { typeName } = this.presenceType
1319
- switch (type) {
1320
- case RecordOpType.Put: {
1321
- // Try to put the document. If it fails, stop here.
1322
- const res = addDocument(presenceChanges, id, { ...val, id, typeName })
1323
- // if res.ok is false here then we already called `fail` and we should stop immediately
1324
- if (!res.ok) return
1325
- break
1326
- }
1327
- case RecordOpType.Patch: {
1328
- // Try to patch the document. If it fails, stop here.
1329
- const res = patchDocument(presenceChanges, id, {
1330
- ...val,
1331
- id: [ValueOpType.Put, id],
1332
- typeName: [ValueOpType.Put, typeName],
1333
- })
1334
- // if res.ok is false here then we already called `fail` and we should stop immediately
1335
- if (!res.ok) return
1336
- break
1337
- }
986
+ // replace the state with the upgraded version and propagate the patch op
987
+ const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode)
988
+ if (diff) {
989
+ storage.set(id, upgraded.value)
990
+ propagateOp(changes, id, [RecordOpType.Patch, diff], doc, upgraded.value)
1338
991
  }
1339
992
  }
1340
- if (message.diff && !session?.isReadonly) {
1341
- // The push request was for the document scope.
1342
- for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
1343
- switch (op[0]) {
993
+ }
994
+
995
+ const { result, documentClock, changes } = this.storage.transaction(
996
+ (txn) => {
997
+ this.broadcastChanges(txn)
998
+ // collect actual ops that resulted from the push
999
+ // these will be broadcast to other users
1000
+
1001
+ const docChanges: ActualChanges = { diffs: null }
1002
+ const presenceChanges: ActualChanges = { diffs: null }
1003
+
1004
+ if (this.presenceType && session?.presenceId && 'presence' in message && message.presence) {
1005
+ if (!session) throw new Error('session is required for presence pushes')
1006
+ // The push request was for the presence scope.
1007
+ const id = session.presenceId
1008
+ const [type, val] = message.presence
1009
+ const { typeName } = this.presenceType
1010
+ switch (type) {
1344
1011
  case RecordOpType.Put: {
1345
- // Try to add the document.
1346
- // If we're putting a record with a type that we don't recognize, fail
1347
- if (!this.documentTypes.has(op[1].typeName)) {
1348
- return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
1349
- }
1350
- const res = addDocument(docChanges, id, op[1])
1351
- // if res.ok is false here then we already called `fail` and we should stop immediately
1352
- if (!res.ok) return
1012
+ // Try to put the document. If it fails, stop here.
1013
+ addDocument(this.presenceStore, presenceChanges, id, {
1014
+ ...val,
1015
+ id,
1016
+ typeName,
1017
+ })
1353
1018
  break
1354
1019
  }
1355
1020
  case RecordOpType.Patch: {
1356
1021
  // Try to patch the document. If it fails, stop here.
1357
- const res = patchDocument(docChanges, id, op[1])
1358
- // if res.ok is false here then we already called `fail` and we should stop immediately
1359
- if (!res.ok) return
1022
+ patchDocument(this.presenceStore, presenceChanges, id, {
1023
+ ...val,
1024
+ id: [ValueOpType.Put, id],
1025
+ typeName: [ValueOpType.Put, typeName],
1026
+ })
1360
1027
  break
1361
1028
  }
1362
- case RecordOpType.Remove: {
1363
- const doc = this.getDocument(id)
1364
- if (!doc) {
1365
- // If the doc was already deleted, don't do anything, no need to propagate a delete op
1366
- continue
1029
+ }
1030
+ }
1031
+ if (message.diff && !session?.isReadonly) {
1032
+ // The push request was for the document scope.
1033
+ for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
1034
+ switch (op[0]) {
1035
+ case RecordOpType.Put: {
1036
+ // Try to add the document.
1037
+ // If we're putting a record with a type that we don't recognize, fail
1038
+ if (!this.documentTypes.has(op[1].typeName)) {
1039
+ throw new TLSyncError(
1040
+ 'invalid record',
1041
+ TLSyncErrorCloseEventReason.INVALID_RECORD
1042
+ )
1043
+ }
1044
+ addDocument(txn, docChanges, id, op[1])
1045
+ break
1046
+ }
1047
+ case RecordOpType.Patch: {
1048
+ // Try to patch the document. If it fails, stop here.
1049
+ patchDocument(txn, docChanges, id, op[1])
1050
+ break
1051
+ }
1052
+ case RecordOpType.Remove: {
1053
+ const doc = txn.get(id)
1054
+ if (!doc) {
1055
+ // If the doc was already deleted, don't do anything, no need to propagate a delete op
1056
+ continue
1057
+ }
1058
+
1059
+ // Delete the document and propagate the delete op
1060
+ // delete automatically creates tombstones
1061
+ txn.delete(id)
1062
+ propagateOp(docChanges, id, op, doc, undefined)
1063
+ break
1367
1064
  }
1368
-
1369
- // Delete the document and propagate the delete op
1370
- this.removeDocument(id, this.clock)
1371
- // Schedule a pruneTombstones call to happen on the next call stack
1372
- propagateOp(docChanges, id, op)
1373
- break
1374
1065
  }
1375
1066
  }
1376
1067
  }
1377
- }
1378
1068
 
1379
- // Let the client know what action to take based on the results of the push
1380
- if (
1381
- // if there was only a presence push, the client doesn't need to do anything aside from
1382
- // shift the push request.
1383
- !message.diff ||
1384
- isEqual(docChanges.diff, message.diff)
1385
- ) {
1386
- // COMMIT
1387
- // Applying the client's changes had the exact same effect on the server as
1388
- // they had on the client, so the client should keep the diff
1389
- if (session) {
1390
- this.sendMessage(session.sessionId, {
1391
- type: 'push_result',
1392
- serverClock: this.clock,
1393
- clientClock,
1394
- action: 'commit',
1395
- })
1396
- }
1397
- } else if (!docChanges.diff) {
1398
- // DISCARD
1399
- // Applying the client's changes had no effect, so the client should drop the diff
1400
- if (session) {
1401
- this.sendMessage(session.sessionId, {
1402
- type: 'push_result',
1403
- serverClock: this.clock,
1404
- clientClock,
1405
- action: 'discard',
1406
- })
1407
- }
1408
- } else {
1409
- // REBASE
1410
- // Applying the client's changes had a different non-empty effect on the server,
1411
- // so the client should rebase with our gold-standard / authoritative diff.
1412
- // First we need to migrate the diff to the client's version
1413
- if (session) {
1414
- const migrateResult = this.migrateDiffForSession(
1415
- session.serializedSchema,
1416
- docChanges.diff
1417
- )
1418
- if (!migrateResult.ok) {
1419
- return fail(
1420
- migrateResult.error === MigrationFailureReason.TargetVersionTooNew
1421
- ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
1422
- : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
1423
- )
1424
- }
1425
- // If the migration worked, send the rebased diff to the client
1426
- this.sendMessage(session.sessionId, {
1427
- type: 'push_result',
1428
- serverClock: this.clock,
1429
- clientClock,
1430
- action: { rebaseWithDiff: migrateResult.value },
1431
- })
1432
- }
1433
- }
1069
+ return { docChanges, presenceChanges }
1070
+ },
1071
+ { id: this.internalTxnId, emitChanges: 'when-different' }
1072
+ )
1434
1073
 
1435
- // If there are merged changes, broadcast them to all other clients
1436
- if (docChanges.diff || presenceChanges.diff) {
1437
- this.broadcastPatch({
1438
- sourceSessionId: session?.sessionId,
1439
- diff: {
1440
- ...docChanges.diff,
1441
- ...presenceChanges.diff,
1442
- },
1443
- })
1444
- }
1074
+ this.lastDocumentClock = documentClock
1445
1075
 
1446
- if (docChanges.diff) {
1447
- this.documentClock = this.clock
1076
+ let pushResult: TLSocketServerSentEvent<R> | undefined
1077
+ if (changes && session) {
1078
+ // txn did not apply verbatim so we should broadcast the actual changes
1079
+ result.docChanges.diffs = { networkDiff: toNetworkDiff(changes) ?? {}, diff: changes }
1080
+ }
1081
+
1082
+ if (isEqual(result.docChanges.diffs?.networkDiff, message.diff)) {
1083
+ pushResult = {
1084
+ type: 'push_result',
1085
+ clientClock: message.clientClock,
1086
+ serverClock: documentClock,
1087
+ action: 'commit',
1448
1088
  }
1449
- if (presenceChanges.diff) {
1450
- didPresenceChange = true
1089
+ } else if (!result.docChanges.diffs?.networkDiff) {
1090
+ pushResult = {
1091
+ type: 'push_result',
1092
+ clientClock: message.clientClock,
1093
+ serverClock: documentClock,
1094
+ action: 'discard',
1451
1095
  }
1096
+ } else if (session) {
1097
+ // if recordsDiff is null but diff is not, then there are no clients that need down migrations
1098
+ // so we can just use the diff directly
1099
+ const diff = this.migrateDiffOrRejectSession(
1100
+ session.sessionId,
1101
+ session.serializedSchema,
1102
+ session.requiresDownMigrations,
1103
+ result.docChanges.diffs.diff,
1104
+ result.docChanges.diffs.networkDiff
1105
+ )
1106
+ if (diff.ok) {
1107
+ pushResult = {
1108
+ type: 'push_result',
1109
+ clientClock: message.clientClock,
1110
+ serverClock: documentClock,
1111
+ action: { rebaseWithDiff: diff.value },
1112
+ }
1113
+ }
1114
+ // if the difff was not ok then the session was rejected and it's ok to continue without a push result
1115
+ }
1452
1116
 
1453
- return
1454
- })
1455
-
1456
- // if it threw the changes will have been rolled back and the document clock will not have been incremented
1457
- if (this.documentClock !== initialDocumentClock) {
1458
- this.onDataChange?.()
1117
+ if (session && pushResult) {
1118
+ this._unsafe_sendMessage(session.sessionId, pushResult)
1119
+ }
1120
+ if (result.docChanges.diffs || result.presenceChanges.diffs) {
1121
+ this.broadcastPatch(
1122
+ {
1123
+ puts: {
1124
+ ...result.docChanges.diffs?.diff.puts,
1125
+ ...result.presenceChanges.diffs?.diff.puts,
1126
+ },
1127
+ deletes: [
1128
+ ...(result.docChanges.diffs?.diff.deletes ?? []),
1129
+ ...(result.presenceChanges.diffs?.diff.deletes ?? []),
1130
+ ],
1131
+ },
1132
+ {
1133
+ ...result.docChanges.diffs?.networkDiff,
1134
+ ...result.presenceChanges.diffs?.networkDiff,
1135
+ },
1136
+ session?.sessionId
1137
+ )
1459
1138
  }
1460
1139
 
1461
- if (didPresenceChange) {
1462
- this.onPresenceChange?.()
1140
+ if (result.presenceChanges.diffs) {
1141
+ queueMicrotask(() => {
1142
+ this.onPresenceChange?.()
1143
+ })
1463
1144
  }
1464
1145
  }
1465
1146
 
@@ -1478,153 +1159,32 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1478
1159
  handleClose(sessionId: string) {
1479
1160
  this.cancelSession(sessionId)
1480
1161
  }
1481
-
1482
- /**
1483
- * Apply changes to the room's store in a transactional way. Changes are
1484
- * automatically synchronized to all connected clients.
1485
- *
1486
- * @param updater - Function that receives store methods to make changes
1487
- * @returns Promise that resolves when the transaction is complete
1488
- * @example
1489
- * ```ts
1490
- * // Add multiple shapes atomically
1491
- * await room.updateStore((store) => {
1492
- * store.put(createShape({ type: 'geo', x: 100, y: 100 }))
1493
- * store.put(createShape({ type: 'text', x: 200, y: 200 }))
1494
- * })
1495
- *
1496
- * // Async operations are supported
1497
- * await room.updateStore(async (store) => {
1498
- * const template = await loadTemplate()
1499
- * template.shapes.forEach(shape => store.put(shape))
1500
- * })
1501
- * ```
1502
- */
1503
- async updateStore(updater: (store: RoomStoreMethods<R>) => void | Promise<void>) {
1504
- if (this._isClosed) {
1505
- throw new Error('Cannot update store on a closed room')
1506
- }
1507
- const context = new StoreUpdateContext<R>(
1508
- Object.fromEntries(this.getSnapshot().documents.map((d) => [d.state.id, d.state]))
1509
- )
1510
- try {
1511
- await updater(context)
1512
- } finally {
1513
- context.close()
1514
- }
1515
-
1516
- const diff = context.toDiff()
1517
- if (Object.keys(diff).length === 0) {
1518
- return
1519
- }
1520
-
1521
- this.handlePushRequest(null, { type: 'push', diff, clientClock: 0 })
1522
- }
1523
1162
  }
1524
1163
 
1525
- /**
1526
- * Interface for making transactional changes to room store data. Used within
1527
- * updateStore transactions to modify documents atomically.
1528
- *
1529
- * @example
1530
- * ```ts
1531
- * await room.updateStore((store) => {
1532
- * const shape = store.get('shape:123')
1533
- * if (shape) {
1534
- * store.put({ ...shape, x: shape.x + 10 })
1535
- * }
1536
- * store.delete('shape:456')
1537
- * })
1538
- * ```
1539
- *
1540
- * @public
1541
- */
1542
- export interface RoomStoreMethods<R extends UnknownRecord = UnknownRecord> {
1543
- /**
1544
- * Add or update a record in the store.
1545
- *
1546
- * @param record - The record to store
1547
- */
1548
- put(record: R): void
1549
- /**
1550
- * Delete a record from the store.
1551
- *
1552
- * @param recordOrId - The record or record ID to delete
1553
- */
1554
- delete(recordOrId: R | string): void
1555
- /**
1556
- * Get a record by its ID.
1557
- *
1558
- * @param id - The record ID
1559
- * @returns The record or null if not found
1560
- */
1561
- get(id: string): R | null
1562
- /**
1563
- * Get all records in the store.
1564
- *
1565
- * @returns Array of all records
1566
- */
1567
- getAll(): R[]
1164
+ /** @internal */
1165
+ export interface MinimalDocStore<R extends UnknownRecord> {
1166
+ get(id: string): UnknownRecord | undefined
1167
+ set(id: string, record: R): void
1168
+ delete(id: string): void
1568
1169
  }
1569
1170
 
1570
- class StoreUpdateContext<R extends UnknownRecord> implements RoomStoreMethods<R> {
1571
- constructor(private readonly snapshot: Record<string, UnknownRecord>) {}
1572
- private readonly updates = {
1573
- puts: {} as Record<string, UnknownRecord>,
1574
- deletes: new Set<string>(),
1575
- }
1576
- put(record: R): void {
1577
- if (this._isClosed) throw new Error('StoreUpdateContext is closed')
1578
- if (record.id in this.snapshot && isEqual(this.snapshot[record.id], record)) {
1579
- delete this.updates.puts[record.id]
1580
- } else {
1581
- this.updates.puts[record.id] = structuredClone(record)
1582
- }
1583
- this.updates.deletes.delete(record.id)
1584
- }
1585
- delete(recordOrId: R | string): void {
1586
- if (this._isClosed) throw new Error('StoreUpdateContext is closed')
1587
- const id = typeof recordOrId === 'string' ? recordOrId : recordOrId.id
1588
- delete this.updates.puts[id]
1589
- if (this.snapshot[id]) {
1590
- this.updates.deletes.add(id)
1591
- }
1592
- }
1593
- get(id: string): R | null {
1594
- if (this._isClosed) throw new Error('StoreUpdateContext is closed')
1595
- if (hasOwnProperty(this.updates.puts, id)) {
1596
- return structuredClone(this.updates.puts[id]) as R
1597
- }
1598
- if (this.updates.deletes.has(id)) {
1599
- return null
1600
- }
1601
- return structuredClone(this.snapshot[id] ?? null) as R
1171
+ /** @internal */
1172
+ export class PresenceStore<R extends UnknownRecord> implements MinimalDocStore<R> {
1173
+ private readonly presences = new AtomMap<string, R>('presences')
1174
+
1175
+ get(id: string): UnknownRecord | undefined {
1176
+ return this.presences.get(id)
1602
1177
  }
1603
1178
 
1604
- getAll(): R[] {
1605
- if (this._isClosed) throw new Error('StoreUpdateContext is closed')
1606
- const result = Object.values(this.updates.puts)
1607
- for (const [id, record] of Object.entries(this.snapshot)) {
1608
- if (!this.updates.deletes.has(id) && !hasOwnProperty(this.updates.puts, id)) {
1609
- result.push(record)
1610
- }
1611
- }
1612
- return structuredClone(result) as R[]
1179
+ set(id: string, state: R): void {
1180
+ this.presences.set(id, state)
1613
1181
  }
1614
1182
 
1615
- toDiff(): NetworkDiff<any> {
1616
- const diff: NetworkDiff<R> = {}
1617
- for (const [id, record] of Object.entries(this.updates.puts)) {
1618
- diff[id] = [RecordOpType.Put, record as R]
1619
- }
1620
- for (const id of this.updates.deletes) {
1621
- diff[id] = [RecordOpType.Remove]
1622
- }
1623
- return diff
1183
+ delete(id: string): void {
1184
+ this.presences.delete(id)
1624
1185
  }
1625
1186
 
1626
- private _isClosed = false
1627
- close() {
1628
- this._isClosed = true
1187
+ values() {
1188
+ return this.presences.values()
1629
1189
  }
1630
1190
  }