@tldraw/sync-core 4.2.2 → 4.2.3

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 (84) hide show
  1. package/dist-cjs/index.d.ts +58 -483
  2. package/dist-cjs/index.js +3 -13
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/RoomSession.js.map +1 -1
  5. package/dist-cjs/lib/TLSocketRoom.js +69 -117
  6. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncClient.js +0 -7
  8. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncRoom.js +688 -357
  10. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  11. package/dist-cjs/lib/chunk.js +2 -2
  12. package/dist-cjs/lib/chunk.js.map +1 -1
  13. package/dist-esm/index.d.mts +58 -483
  14. package/dist-esm/index.mjs +5 -20
  15. package/dist-esm/index.mjs.map +2 -2
  16. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  17. package/dist-esm/lib/TLSocketRoom.mjs +70 -121
  18. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  19. package/dist-esm/lib/TLSyncClient.mjs +0 -7
  20. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  21. package/dist-esm/lib/TLSyncRoom.mjs +702 -370
  22. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  23. package/dist-esm/lib/chunk.mjs +2 -2
  24. package/dist-esm/lib/chunk.mjs.map +1 -1
  25. package/package.json +11 -12
  26. package/src/index.ts +3 -32
  27. package/src/lib/ClientWebSocketAdapter.test.ts +0 -3
  28. package/src/lib/RoomSession.test.ts +0 -1
  29. package/src/lib/RoomSession.ts +0 -2
  30. package/src/lib/TLSocketRoom.ts +114 -228
  31. package/src/lib/TLSyncClient.ts +0 -12
  32. package/src/lib/TLSyncRoom.ts +913 -473
  33. package/src/lib/chunk.ts +2 -2
  34. package/src/test/FuzzEditor.ts +5 -4
  35. package/src/test/TLSocketRoom.test.ts +49 -255
  36. package/src/test/TLSyncRoom.test.ts +534 -1024
  37. package/src/test/TestServer.ts +1 -12
  38. package/src/test/customMessages.test.ts +1 -1
  39. package/src/test/presenceMode.test.ts +6 -6
  40. package/src/test/pruneTombstones.test.ts +178 -0
  41. package/src/test/syncFuzz.test.ts +4 -2
  42. package/src/test/upgradeDowngrade.test.ts +8 -290
  43. package/src/test/validation.test.ts +10 -15
  44. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +0 -55
  45. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +0 -7
  46. package/dist-cjs/lib/InMemorySyncStorage.js +0 -287
  47. package/dist-cjs/lib/InMemorySyncStorage.js.map +0 -7
  48. package/dist-cjs/lib/MicrotaskNotifier.js +0 -50
  49. package/dist-cjs/lib/MicrotaskNotifier.js.map +0 -7
  50. package/dist-cjs/lib/NodeSqliteWrapper.js +0 -48
  51. package/dist-cjs/lib/NodeSqliteWrapper.js.map +0 -7
  52. package/dist-cjs/lib/SQLiteSyncStorage.js +0 -428
  53. package/dist-cjs/lib/SQLiteSyncStorage.js.map +0 -7
  54. package/dist-cjs/lib/TLSyncStorage.js +0 -76
  55. package/dist-cjs/lib/TLSyncStorage.js.map +0 -7
  56. package/dist-cjs/lib/recordDiff.js +0 -52
  57. package/dist-cjs/lib/recordDiff.js.map +0 -7
  58. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +0 -35
  59. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +0 -7
  60. package/dist-esm/lib/InMemorySyncStorage.mjs +0 -272
  61. package/dist-esm/lib/InMemorySyncStorage.mjs.map +0 -7
  62. package/dist-esm/lib/MicrotaskNotifier.mjs +0 -30
  63. package/dist-esm/lib/MicrotaskNotifier.mjs.map +0 -7
  64. package/dist-esm/lib/NodeSqliteWrapper.mjs +0 -28
  65. package/dist-esm/lib/NodeSqliteWrapper.mjs.map +0 -7
  66. package/dist-esm/lib/SQLiteSyncStorage.mjs +0 -414
  67. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +0 -7
  68. package/dist-esm/lib/TLSyncStorage.mjs +0 -56
  69. package/dist-esm/lib/TLSyncStorage.mjs.map +0 -7
  70. package/dist-esm/lib/recordDiff.mjs +0 -32
  71. package/dist-esm/lib/recordDiff.mjs.map +0 -7
  72. package/src/lib/DurableObjectSqliteSyncWrapper.ts +0 -95
  73. package/src/lib/InMemorySyncStorage.ts +0 -387
  74. package/src/lib/MicrotaskNotifier.test.ts +0 -429
  75. package/src/lib/MicrotaskNotifier.ts +0 -38
  76. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  77. package/src/lib/NodeSqliteSyncWrapper.test.ts +0 -272
  78. package/src/lib/NodeSqliteWrapper.ts +0 -99
  79. package/src/lib/SQLiteSyncStorage.ts +0 -627
  80. package/src/lib/TLSyncStorage.ts +0 -216
  81. package/src/lib/computeTombstonePruning.test.ts +0 -352
  82. package/src/lib/recordDiff.ts +0 -73
  83. package/src/test/InMemorySyncStorage.test.ts +0 -1684
  84. package/src/test/SQLiteSyncStorage.test.ts +0 -1378
@@ -1,55 +1,55 @@
1
+ import { transact, transaction } from '@tldraw/state'
1
2
  import {
2
3
  AtomMap,
4
+ IdOf,
3
5
  MigrationFailureReason,
4
6
  RecordType,
5
7
  SerializedSchema,
6
8
  StoreSchema,
7
9
  UnknownRecord,
8
10
  } from '@tldraw/store'
11
+ import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from '@tldraw/tlschema'
9
12
  import {
13
+ IndexKey,
14
+ Result,
10
15
  assert,
11
16
  assertExists,
12
17
  exhaustiveSwitchError,
13
18
  getOwnProperty,
19
+ hasOwnProperty,
14
20
  isEqual,
15
21
  isNativeStructuredClone,
16
22
  objectMapEntriesIterable,
17
- Result,
23
+ structuredClone,
18
24
  } from '@tldraw/utils'
19
25
  import { createNanoEvents } from 'nanoevents'
20
26
  import {
21
- applyObjectDiff,
22
- diffRecord,
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 {
23
36
  NetworkDiff,
24
37
  ObjectDiff,
25
38
  RecordOp,
26
39
  RecordOpType,
27
40
  ValueOpType,
41
+ applyObjectDiff,
42
+ diffRecord,
28
43
  } from './diff'
44
+ import { findMin } from './findMin'
29
45
  import { interval } from './interval'
30
46
  import {
31
- getTlsyncProtocolVersion,
32
47
  TLIncompatibilityReason,
33
48
  TLSocketClientSentEvent,
34
49
  TLSocketServerSentDataEvent,
35
50
  TLSocketServerSentEvent,
51
+ getTlsyncProtocolVersion,
36
52
  } 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,6 +77,20 @@ 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
+
80
94
  /**
81
95
  * The minimum time interval (in milliseconds) between sending batched data messages
82
96
  * to clients. This debouncing prevents overwhelming clients with rapid updates.
@@ -86,6 +100,97 @@ export const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1000 / 60
86
100
 
87
101
  const timeSince = (time: number) => Date.now() - time
88
102
 
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
+
89
194
  /**
90
195
  * Snapshot of a room's complete state that can be persisted and restored.
91
196
  * Contains all documents, tombstones, and metadata needed to reconstruct the room.
@@ -96,7 +201,7 @@ export interface RoomSnapshot {
96
201
  /**
97
202
  * The current logical clock value for the room
98
203
  */
99
- clock?: number
204
+ clock: number
100
205
  /**
101
206
  * Clock value when document data was last changed (optional for backwards compatibility)
102
207
  */
@@ -119,6 +224,20 @@ export interface RoomSnapshot {
119
224
  schema?: SerializedSchema
120
225
  }
121
226
 
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
+
122
241
  /**
123
242
  * A collaborative workspace that manages multiple client sessions and synchronizes
124
243
  * document changes between them. The room serves as the authoritative source for
@@ -148,8 +267,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
148
267
  // A table of connected clients
149
268
  readonly sessions = new Map<string, RoomSession<R, SessionMeta>>()
150
269
 
151
- private lastDocumentClock = 0
152
-
153
270
  // eslint-disable-next-line local/prefer-class-methods
154
271
  pruneSessions = () => {
155
272
  for (const client of this.sessions.values()) {
@@ -183,8 +300,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
183
300
  }
184
301
  }
185
302
 
186
- readonly presenceStore = new PresenceStore<R>()
187
-
188
303
  private disposables: Array<() => void> = [interval(this.pruneSessions, 2000)]
189
304
 
190
305
  private _isClosed = false
@@ -215,8 +330,18 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
215
330
  session_removed(args: { sessionId: string; meta: SessionMeta }): void
216
331
  }>()
217
332
 
218
- // Storage layer for documents, tombstones, and clocks
219
- private readonly storage: TLSyncStorage<R>
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
220
345
 
221
346
  readonly serializedSchema: SerializedSchema
222
347
 
@@ -224,18 +349,21 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
224
349
  readonly presenceType: RecordType<R, any> | null
225
350
  private log?: TLSyncLog
226
351
  public readonly schema: StoreSchema<R, any>
352
+ private onDataChange?(): void
227
353
  private onPresenceChange?(): void
228
354
 
229
355
  constructor(opts: {
230
356
  log?: TLSyncLog
231
357
  schema: StoreSchema<R, any>
358
+ snapshot?: RoomSnapshot
359
+ onDataChange?(): void
232
360
  onPresenceChange?(): void
233
- storage: TLSyncStorage<R>
234
361
  }) {
235
362
  this.schema = opts.schema
363
+ let snapshot = opts.snapshot
236
364
  this.log = opts.log
365
+ this.onDataChange = opts.onDataChange
237
366
  this.onPresenceChange = opts.onPresenceChange
238
- this.storage = opts.storage
239
367
 
240
368
  assert(
241
369
  isNativeStructuredClone,
@@ -264,34 +392,231 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
264
392
 
265
393
  this.presenceType = presenceTypes.values().next()?.value ?? null
266
394
 
267
- const { documentClock } = this.storage.transaction((txn) => {
268
- this.schema.migrateStorage(txn)
269
- })
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
+ }
270
411
 
271
- this.lastDocumentClock = documentClock
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
+ }
272
421
 
273
- this.disposables.push(
274
- this.storage.onChange(({ id }) => {
275
- if (id !== this.internalTxnId) {
276
- this.broadcastExternalStorageChanges()
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
+ }
277
443
  }
278
- })
444
+ }.call(this)
279
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()
516
+ })
517
+
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
+ }
551
+
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))
560
+ )
561
+ if (!createResult.ok) return createResult
562
+ this.documents.set(id, createResult.value)
563
+ return Result.ok(undefined)
280
564
  }
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.
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
+ }
286
611
  }
287
612
 
288
613
  /**
289
614
  * Send a message to a particular client. Debounces data events
290
615
  *
291
616
  * @param sessionId - The id of the session to send the message to.
292
- * @param message - The message to send. UNSAFE Any diffs must have been downgraded already if necessary
617
+ * @param message - The message to send.
293
618
  */
294
- private _unsafe_sendMessage(
619
+ private sendMessage(
295
620
  sessionId: string,
296
621
  message: TLSocketServerSentEvent<R> | TLSocketServerSentDataEvent<R>
297
622
  ) {
@@ -358,6 +683,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
358
683
 
359
684
  this.sessions.delete(sessionId)
360
685
 
686
+ const presence = this.getDocument(session.presenceId ?? '')
687
+
361
688
  try {
362
689
  if (fatalReason) {
363
690
  session.socket.close(TLSyncErrorCloseEventCode, fatalReason)
@@ -368,13 +695,12 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
368
695
  // noop, calling .close() multiple times is fine
369
696
  }
370
697
 
371
- const presence = this.presenceStore.get(session.presenceId ?? '')
372
698
  if (presence) {
373
- this.presenceStore.delete(session.presenceId!)
374
- // Broadcast presence removal - use RecordsDiff with the removed record
699
+ this.documents.delete(session.presenceId!)
700
+
375
701
  this.broadcastPatch({
376
- puts: {},
377
- deletes: [session.presenceId!],
702
+ diff: { [session.presenceId!]: [RecordOpType.Remove] },
703
+ sourceSessionId: sessionId,
378
704
  })
379
705
  }
380
706
 
@@ -414,25 +740,24 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
414
740
  }
415
741
  }
416
742
 
417
- readonly internalTxnId = 'TLSyncRoom.txn'
418
-
419
743
  /**
420
744
  * Broadcast a patch to all connected clients except the one with the sessionId provided.
745
+ * Automatically handles schema migration for clients on different versions.
421
746
  *
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
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
+ * ```
426
758
  */
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
-
759
+ broadcastPatch(message: { diff: NetworkDiff<R>; sourceSessionId?: string }) {
760
+ const { diff, sourceSessionId } = message
436
761
  this.sessions.forEach((session) => {
437
762
  if (session.state !== RoomSessionState.Connected) return
438
763
  if (sourceSessionId === session.sessionId) return
@@ -441,18 +766,23 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
441
766
  return
442
767
  }
443
768
 
444
- const diffResult = this.migrateDiffOrRejectSession(
445
- session.sessionId,
446
- session.serializedSchema,
447
- session.requiresDownMigrations,
448
- diff
449
- )
450
- if (!diffResult.ok) return
769
+ const res = this.migrateDiffForSession(session.serializedSchema, diff)
451
770
 
452
- this._unsafe_sendMessage(session.sessionId, {
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
+ }
781
+
782
+ this.sendMessage(session.sessionId, {
453
783
  type: 'patch',
454
- diff: diffResult.value,
455
- serverClock: this.lastDocumentClock,
784
+ diff: res.value,
785
+ serverClock: this.clock,
456
786
  })
457
787
  })
458
788
  return this
@@ -481,7 +811,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
481
811
  * ```
482
812
  */
483
813
  sendCustomMessage(sessionId: string, data: any): void {
484
- this._unsafe_sendMessage(sessionId, { type: 'custom', data })
814
+ this.sendMessage(sessionId, { type: 'custom', data })
485
815
  }
486
816
 
487
817
  /**
@@ -548,67 +878,45 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
548
878
 
549
879
  /**
550
880
  * When we send a diff to a client, if that client is on a lower version than us, we need to make
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
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.
565
885
  */
566
- private migrateDiffOrRejectSession(
567
- sessionId: string,
886
+ private migrateDiffForSession(
568
887
  serializedSchema: SerializedSchema,
569
- requiresDownMigrations: boolean,
570
- diff: TLSyncForwardDiff<R>,
571
- unmigrated?: NetworkDiff<R>
888
+ diff: NetworkDiff<R>
572
889
  ): Result<NetworkDiff<R>, MigrationFailureReason> {
573
- if (!requiresDownMigrations) {
574
- return Result.ok(unmigrated ?? toNetworkDiff(diff) ?? {})
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)
575
896
  }
576
897
 
577
898
  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
+ }
578
904
 
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]
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)
606
917
  }
607
- }
608
918
 
609
- // Deletes don't need migration
610
- for (const id of diff.deletes) {
611
- result[id] = [RecordOpType.Remove]
919
+ result[id] = [RecordOpType.Put, migrationResult.value]
612
920
  }
613
921
 
614
922
  return Result.ok(result)
@@ -635,30 +943,21 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
635
943
  this.log?.warn?.('Received message from unknown session')
636
944
  return
637
945
  }
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)
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()
654
956
  }
957
+ return this.sendMessage(session.sessionId, { type: 'pong' })
655
958
  }
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
959
+ default: {
960
+ exhaustiveSwitchError(message)
662
961
  }
663
962
  }
664
963
  }
@@ -723,26 +1022,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
723
1022
  }
724
1023
  }
725
1024
 
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
-
746
1025
  private handleConnectRequest(
747
1026
  session: RoomSession<R, SessionMeta>,
748
1027
  message: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }>
@@ -780,7 +1059,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
780
1059
  }
781
1060
  const migrations = this.schema.getMigrationsSince(message.schema)
782
1061
  // if the client's store is at a different version to ours, we can't support them
783
- if (!migrations.ok || migrations.value.some((m) => m.scope !== 'record' || !m.down)) {
1062
+ if (!migrations.ok || migrations.value.some((m) => m.scope === 'store' || !m.down)) {
784
1063
  this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
785
1064
  return
786
1065
  }
@@ -789,8 +1068,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
789
1068
  ? this.serializedSchema
790
1069
  : message.schema
791
1070
 
792
- const requiresDownMigrations = migrations.value.length > 0
793
-
794
1071
  const connect = async (msg: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) => {
795
1072
  this.sessions.set(session.sessionId, {
796
1073
  state: RoomSessionState.Connected,
@@ -798,7 +1075,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
798
1075
  presenceId: session.presenceId,
799
1076
  socket: session.socket,
800
1077
  serializedSchema: sessionSchema,
801
- requiresDownMigrations,
802
1078
  lastInteractionTime: Date.now(),
803
1079
  debounceTimer: null,
804
1080
  outstandingDataMessages: [],
@@ -807,54 +1083,85 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
807
1083
  isReadonly: session.isReadonly,
808
1084
  requiresLegacyRejection: session.requiresLegacyRejection,
809
1085
  })
810
- this._unsafe_sendMessage(session.sessionId, msg)
1086
+ this.sendMessage(session.sessionId, msg)
811
1087
  }
812
1088
 
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: [],
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
+ }
823
1139
  }
824
- )
825
- if (!presenceDiff.ok) return null
826
-
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)
840
- }
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
1140
 
853
- this.lastDocumentClock = documentClock
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
1151
+ }
854
1152
 
855
- if (result) {
856
- connect(result)
857
- }
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
+ })
1163
+ }
1164
+ })
858
1165
  }
859
1166
 
860
1167
  private handlePushRequest(
@@ -865,282 +1172,294 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
865
1172
  if (session && session.state !== RoomSessionState.Connected) {
866
1173
  return
867
1174
  }
1175
+
868
1176
  // update the last interaction time
869
1177
  if (session) {
870
1178
  session.lastInteractionTime = Date.now()
871
1179
  }
872
1180
 
873
- const legacyAppendMode = !this.getCanEmitStringAppend()
874
-
875
- interface ActualChanges {
876
- diffs: {
877
- networkDiff: NetworkDiff<R>
878
- diff: TLSyncForwardDiff<R>
879
- } | null
880
- }
881
-
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])
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
904
1192
  }
905
- }
1193
+ const docChanges: ActualChanges = { diff: null }
1194
+ const presenceChanges: ActualChanges = { diff: null }
906
1195
 
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)
1196
+ const propagateOp = (changes: ActualChanges, id: string, op: RecordOp<R>) => {
1197
+ if (!changes.diff) changes.diff = {}
1198
+ changes.diff[id] = op
918
1199
  }
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)
1200
+
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)
932
1210
  }
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)
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)
941
1215
  }
942
1216
 
943
- return Result.ok(undefined)
944
- }
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
+ )
1227
+ }
1228
+ const { value: state } = res
945
1229
 
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)
964
- }
1230
+ // Get the existing document, if any
1231
+ const doc = this.getDocument(id)
965
1232
 
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])
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])
972
1252
  }
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)
1253
+
1254
+ return Result.ok(undefined)
1255
+ }
1256
+
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)
985
1272
  }
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)
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
+ }
991
1306
  }
1307
+
1308
+ return Result.ok(undefined)
992
1309
  }
993
- }
994
1310
 
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) {
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
+ }
1338
+ }
1339
+ }
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]) {
1011
1344
  case RecordOpType.Put: {
1012
- // Try to put the document. If it fails, stop here.
1013
- addDocument(this.presenceStore, presenceChanges, id, {
1014
- ...val,
1015
- id,
1016
- typeName,
1017
- })
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
1018
1353
  break
1019
1354
  }
1020
1355
  case RecordOpType.Patch: {
1021
1356
  // Try to patch the document. If it fails, stop here.
1022
- patchDocument(this.presenceStore, presenceChanges, id, {
1023
- ...val,
1024
- id: [ValueOpType.Put, id],
1025
- typeName: [ValueOpType.Put, typeName],
1026
- })
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
1027
1360
  break
1028
1361
  }
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
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
1064
1367
  }
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
1065
1374
  }
1066
1375
  }
1067
1376
  }
1377
+ }
1068
1378
 
1069
- return { docChanges, presenceChanges }
1070
- },
1071
- { id: this.internalTxnId, emitChanges: 'when-different' }
1072
- )
1073
-
1074
- this.lastDocumentClock = documentClock
1075
-
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
- }
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
+ }
1081
1434
 
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',
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
+ })
1088
1444
  }
1089
- } else if (!result.docChanges.diffs?.networkDiff) {
1090
- pushResult = {
1091
- type: 'push_result',
1092
- clientClock: message.clientClock,
1093
- serverClock: documentClock,
1094
- action: 'discard',
1445
+
1446
+ if (docChanges.diff) {
1447
+ this.documentClock = this.clock
1095
1448
  }
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
- }
1449
+ if (presenceChanges.diff) {
1450
+ didPresenceChange = true
1113
1451
  }
1114
- // if the difff was not ok then the session was rejected and it's ok to continue without a push result
1115
- }
1116
1452
 
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
- )
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?.()
1138
1459
  }
1139
1460
 
1140
- if (result.presenceChanges.diffs) {
1141
- queueMicrotask(() => {
1142
- this.onPresenceChange?.()
1143
- })
1461
+ if (didPresenceChange) {
1462
+ this.onPresenceChange?.()
1144
1463
  }
1145
1464
  }
1146
1465
 
@@ -1159,32 +1478,153 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1159
1478
  handleClose(sessionId: string) {
1160
1479
  this.cancelSession(sessionId)
1161
1480
  }
1162
- }
1163
1481
 
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
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
+ }
1169
1523
  }
1170
1524
 
1171
- /** @internal */
1172
- export class PresenceStore<R extends UnknownRecord> implements MinimalDocStore<R> {
1173
- private readonly presences = new AtomMap<string, R>('presences')
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[]
1568
+ }
1174
1569
 
1175
- get(id: string): UnknownRecord | undefined {
1176
- return this.presences.get(id)
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
1177
1602
  }
1178
1603
 
1179
- set(id: string, state: R): void {
1180
- this.presences.set(id, state)
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[]
1181
1613
  }
1182
1614
 
1183
- delete(id: string): void {
1184
- this.presences.delete(id)
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
1185
1624
  }
1186
1625
 
1187
- values() {
1188
- return this.presences.values()
1626
+ private _isClosed = false
1627
+ close() {
1628
+ this._isClosed = true
1189
1629
  }
1190
1630
  }