@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,45 +1,126 @@
1
+ import { transact, transaction } from "@tldraw/state";
1
2
  import {
2
- AtomMap
3
+ AtomMap,
4
+ MigrationFailureReason
3
5
  } from "@tldraw/store";
6
+ import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from "@tldraw/tlschema";
4
7
  import {
8
+ Result,
5
9
  assert,
6
10
  assertExists,
7
11
  exhaustiveSwitchError,
8
12
  getOwnProperty,
13
+ hasOwnProperty,
9
14
  isEqual,
10
15
  isNativeStructuredClone,
11
16
  objectMapEntriesIterable,
12
- Result
17
+ structuredClone
13
18
  } from "@tldraw/utils";
14
19
  import { createNanoEvents } from "nanoevents";
15
- import {
16
- applyObjectDiff,
17
- diffRecord,
18
- RecordOpType,
19
- ValueOpType
20
- } from "./diff.mjs";
21
- import { interval } from "./interval.mjs";
22
- import {
23
- getTlsyncProtocolVersion,
24
- TLIncompatibilityReason
25
- } from "./protocol.mjs";
26
- import { applyAndDiffRecord, diffAndValidateRecord, validateRecord } from "./recordDiff.mjs";
27
20
  import {
28
21
  RoomSessionState,
29
22
  SESSION_IDLE_TIMEOUT,
30
23
  SESSION_REMOVAL_WAIT_TIME,
31
24
  SESSION_START_WAIT_TIME
32
25
  } from "./RoomSession.mjs";
33
- import { TLSyncError, TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from "./TLSyncClient.mjs";
26
+ import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from "./TLSyncClient.mjs";
34
27
  import {
35
- toNetworkDiff
36
- } from "./TLSyncStorage.mjs";
28
+ RecordOpType,
29
+ ValueOpType,
30
+ applyObjectDiff,
31
+ diffRecord
32
+ } from "./diff.mjs";
33
+ import { findMin } from "./findMin.mjs";
34
+ import { interval } from "./interval.mjs";
35
+ import {
36
+ TLIncompatibilityReason,
37
+ getTlsyncProtocolVersion
38
+ } from "./protocol.mjs";
39
+ const MAX_TOMBSTONES = 3e3;
40
+ const TOMBSTONE_PRUNE_BUFFER_SIZE = 300;
37
41
  const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1e3 / 60;
38
42
  const timeSince = (time) => Date.now() - time;
43
+ class DocumentState {
44
+ constructor(state, lastChangedClock, recordType) {
45
+ this.state = state;
46
+ this.lastChangedClock = lastChangedClock;
47
+ this.recordType = recordType;
48
+ }
49
+ /**
50
+ * Create a DocumentState instance without validating the record data.
51
+ * Used for performance when validation has already been performed.
52
+ *
53
+ * @param state - The record data
54
+ * @param lastChangedClock - Clock value when this record was last modified
55
+ * @param recordType - The record type definition for validation
56
+ * @returns A new DocumentState instance
57
+ */
58
+ static createWithoutValidating(state, lastChangedClock, recordType) {
59
+ return new DocumentState(state, lastChangedClock, recordType);
60
+ }
61
+ /**
62
+ * Create a DocumentState instance with validation of the record data.
63
+ *
64
+ * @param state - The record data to validate
65
+ * @param lastChangedClock - Clock value when this record was last modified
66
+ * @param recordType - The record type definition for validation
67
+ * @returns Result containing the DocumentState or validation error
68
+ */
69
+ static createAndValidate(state, lastChangedClock, recordType) {
70
+ try {
71
+ recordType.validate(state);
72
+ } catch (error) {
73
+ return Result.err(error);
74
+ }
75
+ return Result.ok(new DocumentState(state, lastChangedClock, recordType));
76
+ }
77
+ /**
78
+ * Replace the current state with new state and calculate the diff.
79
+ *
80
+ * @param state - The new record state
81
+ * @param clock - The new clock value
82
+ * @param legacyAppendMode - If true, string append operations will be converted to Put operations
83
+ * @returns Result containing the diff and new DocumentState, or null if no changes, or validation error
84
+ */
85
+ replaceState(state, clock, legacyAppendMode = false) {
86
+ const diff = diffRecord(this.state, state, legacyAppendMode);
87
+ if (!diff) return Result.ok(null);
88
+ try {
89
+ this.recordType.validate(state);
90
+ } catch (error) {
91
+ return Result.err(error);
92
+ }
93
+ return Result.ok([diff, new DocumentState(state, clock, this.recordType)]);
94
+ }
95
+ /**
96
+ * Apply a diff to the current state and return the resulting changes.
97
+ *
98
+ * @param diff - The object diff to apply
99
+ * @param clock - The new clock value
100
+ * @param legacyAppendMode - If true, string append operations will be converted to Put operations
101
+ * @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error
102
+ */
103
+ mergeDiff(diff, clock, legacyAppendMode = false) {
104
+ const newState = applyObjectDiff(this.state, diff);
105
+ return this.replaceState(newState, clock, legacyAppendMode);
106
+ }
107
+ }
108
+ function getDocumentClock(snapshot) {
109
+ if (typeof snapshot.documentClock === "number") {
110
+ return snapshot.documentClock;
111
+ }
112
+ let max = 0;
113
+ for (const doc of snapshot.documents) {
114
+ max = Math.max(max, doc.lastChangedClock);
115
+ }
116
+ for (const tombstone of Object.values(snapshot.tombstones ?? {})) {
117
+ max = Math.max(max, tombstone);
118
+ }
119
+ return max;
120
+ }
39
121
  class TLSyncRoom {
40
122
  // A table of connected clients
41
123
  sessions = /* @__PURE__ */ new Map();
42
- lastDocumentClock = 0;
43
124
  // eslint-disable-next-line local/prefer-class-methods
44
125
  pruneSessions = () => {
45
126
  for (const client of this.sessions.values()) {
@@ -71,7 +152,6 @@ class TLSyncRoom {
71
152
  }
72
153
  }
73
154
  };
74
- presenceStore = new PresenceStore();
75
155
  disposables = [interval(this.pruneSessions, 2e3)];
76
156
  _isClosed = false;
77
157
  /**
@@ -94,8 +174,17 @@ class TLSyncRoom {
94
174
  return this._isClosed;
95
175
  }
96
176
  events = createNanoEvents();
97
- // Storage layer for documents, tombstones, and clocks
98
- storage;
177
+ // Values associated with each uid (must be serializable).
178
+ /** @internal */
179
+ documents;
180
+ tombstones;
181
+ // this clock should start higher than the client, to make sure that clients who sync with their
182
+ // initial lastServerClock value get the full state
183
+ // in this case clients will start with 0, and the server will start with 1
184
+ clock;
185
+ documentClock;
186
+ tombstoneHistoryStartsAtClock;
187
+ // map from record id to clock upon deletion
99
188
  serializedSchema;
100
189
  documentTypes;
101
190
  presenceType;
@@ -103,9 +192,10 @@ class TLSyncRoom {
103
192
  schema;
104
193
  constructor(opts) {
105
194
  this.schema = opts.schema;
195
+ let snapshot = opts.snapshot;
106
196
  this.log = opts.log;
197
+ this.onDataChange = opts.onDataChange;
107
198
  this.onPresenceChange = opts.onPresenceChange;
108
- this.storage = opts.storage;
109
199
  assert(
110
200
  isNativeStructuredClone,
111
201
  "TLSyncRoom is supposed to run either on Cloudflare Workersor on a 18+ version of Node.js, which both support the native structuredClone API"
@@ -123,31 +213,194 @@ class TLSyncRoom {
123
213
  );
124
214
  }
125
215
  this.presenceType = presenceTypes.values().next()?.value ?? null;
126
- const { documentClock } = this.storage.transaction((txn) => {
127
- this.schema.migrateStorage(txn);
128
- });
129
- this.lastDocumentClock = documentClock;
130
- this.disposables.push(
131
- this.storage.onChange(({ id }) => {
132
- if (id !== this.internalTxnId) {
133
- this.broadcastExternalStorageChanges();
216
+ if (!snapshot) {
217
+ snapshot = {
218
+ clock: 0,
219
+ documentClock: 0,
220
+ documents: [
221
+ {
222
+ state: DocumentRecordType.create({ id: TLDOCUMENT_ID }),
223
+ lastChangedClock: 0
224
+ },
225
+ {
226
+ state: PageRecordType.create({ name: "Page 1", index: "a1" }),
227
+ lastChangedClock: 0
228
+ }
229
+ ]
230
+ };
231
+ }
232
+ this.clock = snapshot.clock;
233
+ let didIncrementClock = false;
234
+ const ensureClockDidIncrement = (_reason) => {
235
+ if (!didIncrementClock) {
236
+ didIncrementClock = true;
237
+ this.clock++;
238
+ }
239
+ };
240
+ this.tombstones = new AtomMap(
241
+ "room tombstones",
242
+ objectMapEntriesIterable(snapshot.tombstones ?? {})
243
+ );
244
+ this.documents = new AtomMap(
245
+ "room documents",
246
+ function* () {
247
+ for (const doc of snapshot.documents) {
248
+ if (this.documentTypes.has(doc.state.typeName)) {
249
+ yield [
250
+ doc.state.id,
251
+ DocumentState.createWithoutValidating(
252
+ doc.state,
253
+ doc.lastChangedClock,
254
+ assertExists(getOwnProperty(this.schema.types, doc.state.typeName))
255
+ )
256
+ ];
257
+ } else {
258
+ ensureClockDidIncrement("doc type was not doc type");
259
+ this.tombstones.set(doc.state.id, this.clock);
260
+ }
134
261
  }
135
- })
262
+ }.call(this)
136
263
  );
137
- }
138
- broadcastExternalStorageChanges() {
139
- this.storage.transaction((txn) => {
140
- this.broadcastChanges(txn);
141
- this.lastDocumentClock = txn.getClock();
264
+ this.tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? findMin(this.tombstones.values()) ?? this.clock;
265
+ if (this.tombstoneHistoryStartsAtClock === 0) {
266
+ this.tombstoneHistoryStartsAtClock++;
267
+ }
268
+ transact(() => {
269
+ const schema = snapshot.schema ?? this.schema.serializeEarliestVersion();
270
+ const migrationsToApply = this.schema.getMigrationsSince(schema);
271
+ assert(migrationsToApply.ok, "Failed to get migrations");
272
+ if (migrationsToApply.value.length > 0) {
273
+ const store = {};
274
+ for (const [k, v] of this.documents.entries()) {
275
+ store[k] = v.state;
276
+ }
277
+ const migrationResult = this.schema.migrateStoreSnapshot(
278
+ { store, schema },
279
+ { mutateInputStore: true }
280
+ );
281
+ if (migrationResult.type === "error") {
282
+ throw new Error("Failed to migrate: " + migrationResult.reason);
283
+ }
284
+ for (const id in migrationResult.value) {
285
+ if (!Object.prototype.hasOwnProperty.call(migrationResult.value, id)) {
286
+ continue;
287
+ }
288
+ const r = migrationResult.value[id];
289
+ const existing = this.documents.get(id);
290
+ if (!existing || !isEqual(existing.state, r)) {
291
+ ensureClockDidIncrement("record was added or updated during migration");
292
+ this.documents.set(
293
+ r.id,
294
+ DocumentState.createWithoutValidating(
295
+ r,
296
+ this.clock,
297
+ assertExists(getOwnProperty(this.schema.types, r.typeName))
298
+ )
299
+ );
300
+ }
301
+ }
302
+ for (const id of this.documents.keys()) {
303
+ if (!migrationResult.value[id]) {
304
+ ensureClockDidIncrement("record was removed during migration");
305
+ this.tombstones.set(id, this.clock);
306
+ this.documents.delete(id);
307
+ }
308
+ }
309
+ }
310
+ this.pruneTombstones();
142
311
  });
312
+ if (didIncrementClock) {
313
+ this.documentClock = this.clock;
314
+ opts.onDataChange?.();
315
+ } else {
316
+ this.documentClock = getDocumentClock(snapshot);
317
+ }
318
+ }
319
+ didSchedulePrune = true;
320
+ // eslint-disable-next-line local/prefer-class-methods
321
+ pruneTombstones = () => {
322
+ this.didSchedulePrune = false;
323
+ if (this.tombstones.size > MAX_TOMBSTONES) {
324
+ const entries = Array.from(this.tombstones.entries());
325
+ entries.sort((a, b) => a[1] - b[1]);
326
+ let idx = entries.length - 1 - MAX_TOMBSTONES + TOMBSTONE_PRUNE_BUFFER_SIZE;
327
+ const cullClock = entries[idx++][1];
328
+ while (idx < entries.length && entries[idx][1] === cullClock) {
329
+ idx++;
330
+ }
331
+ const keysToDelete = entries.slice(0, idx).map(([key]) => key);
332
+ this.tombstoneHistoryStartsAtClock = cullClock + 1;
333
+ this.tombstones.deleteMany(keysToDelete);
334
+ }
335
+ };
336
+ getDocument(id) {
337
+ return this.documents.get(id);
338
+ }
339
+ addDocument(id, state, clock) {
340
+ if (this.tombstones.has(id)) {
341
+ this.tombstones.delete(id);
342
+ }
343
+ const createResult = DocumentState.createAndValidate(
344
+ state,
345
+ clock,
346
+ assertExists(getOwnProperty(this.schema.types, state.typeName))
347
+ );
348
+ if (!createResult.ok) return createResult;
349
+ this.documents.set(id, createResult.value);
350
+ return Result.ok(void 0);
351
+ }
352
+ removeDocument(id, clock) {
353
+ this.documents.delete(id);
354
+ this.tombstones.set(id, clock);
355
+ if (!this.didSchedulePrune) {
356
+ this.didSchedulePrune = true;
357
+ setTimeout(this.pruneTombstones, 0);
358
+ }
359
+ }
360
+ /**
361
+ * Get a complete snapshot of the current room state that can be persisted
362
+ * and later used to restore the room.
363
+ *
364
+ * @returns Room snapshot containing all documents, tombstones, and metadata
365
+ * @example
366
+ * ```ts
367
+ * const snapshot = room.getSnapshot()
368
+ * await database.saveRoomSnapshot(roomId, snapshot)
369
+ *
370
+ * // Later, restore from snapshot
371
+ * const restoredRoom = new TLSyncRoom({
372
+ * schema: mySchema,
373
+ * snapshot: snapshot
374
+ * })
375
+ * ```
376
+ */
377
+ getSnapshot() {
378
+ const tombstones = Object.fromEntries(this.tombstones.entries());
379
+ const documents = [];
380
+ for (const doc of this.documents.values()) {
381
+ if (this.documentTypes.has(doc.state.typeName)) {
382
+ documents.push({
383
+ state: doc.state,
384
+ lastChangedClock: doc.lastChangedClock
385
+ });
386
+ }
387
+ }
388
+ return {
389
+ clock: this.clock,
390
+ documentClock: this.documentClock,
391
+ tombstones,
392
+ tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock,
393
+ schema: this.serializedSchema,
394
+ documents
395
+ };
143
396
  }
144
397
  /**
145
398
  * Send a message to a particular client. Debounces data events
146
399
  *
147
400
  * @param sessionId - The id of the session to send the message to.
148
- * @param message - The message to send. UNSAFE Any diffs must have been downgraded already if necessary
401
+ * @param message - The message to send.
149
402
  */
150
- _unsafe_sendMessage(sessionId, message) {
403
+ sendMessage(sessionId, message) {
151
404
  const session = this.sessions.get(sessionId);
152
405
  if (!session) {
153
406
  this.log?.warn?.("Tried to send message to unknown session", message.type);
@@ -199,6 +452,7 @@ class TLSyncRoom {
199
452
  return;
200
453
  }
201
454
  this.sessions.delete(sessionId);
455
+ const presence = this.getDocument(session.presenceId ?? "");
202
456
  try {
203
457
  if (fatalReason) {
204
458
  session.socket.close(TLSyncErrorCloseEventCode, fatalReason);
@@ -207,12 +461,11 @@ class TLSyncRoom {
207
461
  }
208
462
  } catch {
209
463
  }
210
- const presence = this.presenceStore.get(session.presenceId ?? "");
211
464
  if (presence) {
212
- this.presenceStore.delete(session.presenceId);
465
+ this.documents.delete(session.presenceId);
213
466
  this.broadcastPatch({
214
- puts: {},
215
- deletes: [session.presenceId]
467
+ diff: { [session.presenceId]: [RecordOpType.Remove] },
468
+ sourceSessionId: sessionId
216
469
  });
217
470
  }
218
471
  this.events.emit("session_removed", { sessionId, meta: session.meta });
@@ -245,18 +498,24 @@ class TLSyncRoom {
245
498
  } catch {
246
499
  }
247
500
  }
248
- internalTxnId = "TLSyncRoom.txn";
249
501
  /**
250
502
  * Broadcast a patch to all connected clients except the one with the sessionId provided.
503
+ * Automatically handles schema migration for clients on different versions.
251
504
  *
252
- * @param diff - The TLSyncForwardDiff with full records (used for migration)
253
- * @param networkDiff - Optional pre-computed NetworkDiff for sessions not needing migration.
254
- * If not provided, will be computed from recordsDiff.
255
- * @param sourceSessionId - Optional session ID to exclude from the broadcast
505
+ * @param message - The broadcast message
506
+ * - diff - The network diff to broadcast to all clients
507
+ * - sourceSessionId - Optional ID of the session that originated this change (excluded from broadcast)
508
+ * @returns This room instance for method chaining
509
+ * @example
510
+ * ```ts
511
+ * room.broadcastPatch({
512
+ * diff: { 'shape:123': [RecordOpType.Put, newShapeData] },
513
+ * sourceSessionId: 'user-456' // This user won't receive the broadcast
514
+ * })
515
+ * ```
256
516
  */
257
- broadcastPatch(diff, networkDiff, sourceSessionId) {
258
- const unmigrated = networkDiff ?? toNetworkDiff(diff);
259
- if (!unmigrated) return this;
517
+ broadcastPatch(message) {
518
+ const { diff, sourceSessionId } = message;
260
519
  this.sessions.forEach((session) => {
261
520
  if (session.state !== RoomSessionState.Connected) return;
262
521
  if (sourceSessionId === session.sessionId) return;
@@ -264,17 +523,18 @@ class TLSyncRoom {
264
523
  this.cancelSession(session.sessionId);
265
524
  return;
266
525
  }
267
- const diffResult = this.migrateDiffOrRejectSession(
268
- session.sessionId,
269
- session.serializedSchema,
270
- session.requiresDownMigrations,
271
- diff
272
- );
273
- if (!diffResult.ok) return;
274
- this._unsafe_sendMessage(session.sessionId, {
526
+ const res = this.migrateDiffForSession(session.serializedSchema, diff);
527
+ if (!res.ok) {
528
+ this.rejectSession(
529
+ session.sessionId,
530
+ res.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
531
+ );
532
+ return;
533
+ }
534
+ this.sendMessage(session.sessionId, {
275
535
  type: "patch",
276
- diff: diffResult.value,
277
- serverClock: this.lastDocumentClock
536
+ diff: res.value,
537
+ serverClock: this.clock
278
538
  });
279
539
  });
280
540
  return this;
@@ -302,7 +562,7 @@ class TLSyncRoom {
302
562
  * ```
303
563
  */
304
564
  sendCustomMessage(sessionId, data) {
305
- this._unsafe_sendMessage(sessionId, { type: "custom", data });
565
+ this.sendMessage(sessionId, { type: "custom", data });
306
566
  }
307
567
  /**
308
568
  * Register a new client session with the room. The session will be in an awaiting
@@ -361,54 +621,34 @@ class TLSyncRoom {
361
621
  }
362
622
  /**
363
623
  * When we send a diff to a client, if that client is on a lower version than us, we need to make
364
- * the diff compatible with their version. This method takes a TLSyncForwardDiff (which has full
365
- * records) and migrates all records down to the client's schema version, returning a NetworkDiff.
366
- *
367
- * For updates (entries with [before, after] tuples), both records are migrated and a patch is
368
- * computed from the migrated versions, preserving efficient patch semantics even across versions.
369
- *
370
- * If a migration fails, the session will be rejected.
371
- *
372
- * @param sessionId - The session ID (for rejection on migration failure)
373
- * @param serializedSchema - The client's schema to migrate to
374
- * @param requiresDownMigrations - Whether the client needs down migrations
375
- * @param diff - The TLSyncForwardDiff containing full records to migrate
376
- * @param unmigrated - Optional pre-computed NetworkDiff for when no migration is needed
377
- * @returns A NetworkDiff with migrated records, or a migration failure
624
+ * the diff compatible with their version. At the moment this means migrating each affected record
625
+ * to the client's version and sending the whole record again. We can optimize this later by
626
+ * keeping the previous versions of records around long enough to recalculate these diffs for
627
+ * older client versions.
378
628
  */
379
- migrateDiffOrRejectSession(sessionId, serializedSchema, requiresDownMigrations, diff, unmigrated) {
380
- if (!requiresDownMigrations) {
381
- return Result.ok(unmigrated ?? toNetworkDiff(diff) ?? {});
629
+ migrateDiffForSession(serializedSchema, diff) {
630
+ if (serializedSchema === this.serializedSchema) {
631
+ return Result.ok(diff);
382
632
  }
383
633
  const result = {};
384
- for (const [id, put] of objectMapEntriesIterable(diff.puts)) {
385
- if (Array.isArray(put)) {
386
- const [from, to] = put;
387
- const fromResult = this.schema.migratePersistedRecord(from, serializedSchema, "down");
388
- if (fromResult.type === "error") {
389
- this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
390
- return Result.err(fromResult.reason);
391
- }
392
- const toResult = this.schema.migratePersistedRecord(to, serializedSchema, "down");
393
- if (toResult.type === "error") {
394
- this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
395
- return Result.err(toResult.reason);
396
- }
397
- const patch = diffRecord(fromResult.value, toResult.value);
398
- if (patch) {
399
- result[id] = [RecordOpType.Patch, patch];
400
- }
401
- } else {
402
- const migrationResult = this.schema.migratePersistedRecord(put, serializedSchema, "down");
403
- if (migrationResult.type === "error") {
404
- this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
405
- return Result.err(migrationResult.reason);
406
- }
407
- result[id] = [RecordOpType.Put, migrationResult.value];
634
+ for (const [id, op] of objectMapEntriesIterable(diff)) {
635
+ if (op[0] === RecordOpType.Remove) {
636
+ result[id] = op;
637
+ continue;
408
638
  }
409
- }
410
- for (const id of diff.deletes) {
411
- result[id] = [RecordOpType.Remove];
639
+ const doc = this.getDocument(id);
640
+ if (!doc) {
641
+ return Result.err(MigrationFailureReason.TargetVersionTooNew);
642
+ }
643
+ const migrationResult = this.schema.migratePersistedRecord(
644
+ doc.state,
645
+ serializedSchema,
646
+ "down"
647
+ );
648
+ if (migrationResult.type === "error") {
649
+ return Result.err(migrationResult.reason);
650
+ }
651
+ result[id] = [RecordOpType.Put, migrationResult.value];
412
652
  }
413
653
  return Result.ok(result);
414
654
  }
@@ -433,29 +673,21 @@ class TLSyncRoom {
433
673
  this.log?.warn?.("Received message from unknown session");
434
674
  return;
435
675
  }
436
- try {
437
- switch (message.type) {
438
- case "connect": {
439
- return this.handleConnectRequest(session, message);
440
- }
441
- case "push": {
442
- return this.handlePushRequest(session, message);
443
- }
444
- case "ping": {
445
- if (session.state === RoomSessionState.Connected) {
446
- session.lastInteractionTime = Date.now();
447
- }
448
- return this._unsafe_sendMessage(session.sessionId, { type: "pong" });
449
- }
450
- default: {
451
- exhaustiveSwitchError(message);
676
+ switch (message.type) {
677
+ case "connect": {
678
+ return this.handleConnectRequest(session, message);
679
+ }
680
+ case "push": {
681
+ return this.handlePushRequest(session, message);
682
+ }
683
+ case "ping": {
684
+ if (session.state === RoomSessionState.Connected) {
685
+ session.lastInteractionTime = Date.now();
452
686
  }
687
+ return this.sendMessage(session.sessionId, { type: "pong" });
453
688
  }
454
- } catch (e) {
455
- if (e instanceof TLSyncError) {
456
- this.rejectSession(session.sessionId, e.reason);
457
- } else {
458
- throw e;
689
+ default: {
690
+ exhaustiveSwitchError(message);
459
691
  }
460
692
  }
461
693
  }
@@ -512,22 +744,6 @@ class TLSyncRoom {
512
744
  this.removeSession(sessionId, fatalReason);
513
745
  }
514
746
  }
515
- forceAllReconnect() {
516
- for (const session of this.sessions.values()) {
517
- this.removeSession(session.sessionId);
518
- }
519
- }
520
- broadcastChanges(txn) {
521
- const changes = txn.getChangesSince(this.lastDocumentClock);
522
- if (!changes) return;
523
- const { wipeAll, diff } = changes;
524
- this.lastDocumentClock = txn.getClock();
525
- if (wipeAll) {
526
- this.forceAllReconnect();
527
- return;
528
- }
529
- this.broadcastPatch(diff);
530
- }
531
747
  handleConnectRequest(session, message) {
532
748
  let theirProtocolVersion = message.protocolVersion;
533
749
  if (theirProtocolVersion === 5) {
@@ -553,12 +769,11 @@ class TLSyncRoom {
553
769
  return;
554
770
  }
555
771
  const migrations = this.schema.getMigrationsSince(message.schema);
556
- if (!migrations.ok || migrations.value.some((m) => m.scope !== "record" || !m.down)) {
772
+ if (!migrations.ok || migrations.value.some((m) => m.scope === "store" || !m.down)) {
557
773
  this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
558
774
  return;
559
775
  }
560
776
  const sessionSchema = isEqual(message.schema, this.serializedSchema) ? this.serializedSchema : message.schema;
561
- const requiresDownMigrations = migrations.value.length > 0;
562
777
  const connect = async (msg) => {
563
778
  this.sessions.set(session.sessionId, {
564
779
  state: RoomSessionState.Connected,
@@ -566,7 +781,6 @@ class TLSyncRoom {
566
781
  presenceId: session.presenceId,
567
782
  socket: session.socket,
568
783
  serializedSchema: sessionSchema,
569
- requiresDownMigrations,
570
784
  lastInteractionTime: Date.now(),
571
785
  debounceTimer: null,
572
786
  outstandingDataMessages: [],
@@ -575,49 +789,76 @@ class TLSyncRoom {
575
789
  isReadonly: session.isReadonly,
576
790
  requiresLegacyRejection: session.requiresLegacyRejection
577
791
  });
578
- this._unsafe_sendMessage(session.sessionId, msg);
792
+ this.sendMessage(session.sessionId, msg);
579
793
  };
580
- const { documentClock, result } = this.storage.transaction((txn) => {
581
- this.broadcastChanges(txn);
582
- const docChanges = txn.getChangesSince(message.lastServerClock);
583
- const presenceDiff = this.migrateDiffOrRejectSession(
584
- session.sessionId,
585
- sessionSchema,
586
- requiresDownMigrations,
587
- {
588
- puts: Object.fromEntries([...this.presenceStore.values()].map((p) => [p.id, p])),
589
- deletes: []
794
+ transaction((rollback) => {
795
+ if (
796
+ // if the client requests changes since a time before we have tombstone history, send them the full state
797
+ message.lastServerClock < this.tombstoneHistoryStartsAtClock || // similarly, if they ask for a time we haven't reached yet, send them the full state
798
+ // this will only happen if the DB is reset (or there is no db) and the server restarts
799
+ // or if the server exits/crashes with unpersisted changes
800
+ message.lastServerClock > this.clock
801
+ ) {
802
+ const diff = {};
803
+ for (const [id, doc] of this.documents.entries()) {
804
+ if (id !== session.presenceId) {
805
+ diff[id] = [RecordOpType.Put, doc.state];
806
+ }
590
807
  }
591
- );
592
- if (!presenceDiff.ok) return null;
593
- let docDiff = null;
594
- if (docChanges && sessionSchema !== this.serializedSchema) {
595
- const migrated = this.migrateDiffOrRejectSession(
596
- session.sessionId,
597
- sessionSchema,
598
- requiresDownMigrations,
599
- docChanges.diff
600
- );
601
- if (!migrated.ok) return null;
602
- docDiff = migrated.value;
603
- } else if (docChanges) {
604
- docDiff = toNetworkDiff(docChanges.diff);
808
+ const migrated = this.migrateDiffForSession(sessionSchema, diff);
809
+ if (!migrated.ok) {
810
+ rollback();
811
+ this.rejectSession(
812
+ session.sessionId,
813
+ migrated.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
814
+ );
815
+ return;
816
+ }
817
+ connect({
818
+ type: "connect",
819
+ connectRequestId: message.connectRequestId,
820
+ hydrationType: "wipe_all",
821
+ protocolVersion: getTlsyncProtocolVersion(),
822
+ schema: this.schema.serialize(),
823
+ serverClock: this.clock,
824
+ diff: migrated.value,
825
+ isReadonly: session.isReadonly
826
+ });
827
+ } else {
828
+ const diff = {};
829
+ for (const doc of this.documents.values()) {
830
+ if (doc.lastChangedClock > message.lastServerClock) {
831
+ diff[doc.state.id] = [RecordOpType.Put, doc.state];
832
+ } else if (this.presenceType?.isId(doc.state.id) && doc.state.id !== session.presenceId) {
833
+ diff[doc.state.id] = [RecordOpType.Put, doc.state];
834
+ }
835
+ }
836
+ for (const [id, deletedAtClock] of this.tombstones.entries()) {
837
+ if (deletedAtClock > message.lastServerClock) {
838
+ diff[id] = [RecordOpType.Remove];
839
+ }
840
+ }
841
+ const migrated = this.migrateDiffForSession(sessionSchema, diff);
842
+ if (!migrated.ok) {
843
+ rollback();
844
+ this.rejectSession(
845
+ session.sessionId,
846
+ migrated.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
847
+ );
848
+ return;
849
+ }
850
+ connect({
851
+ type: "connect",
852
+ connectRequestId: message.connectRequestId,
853
+ hydrationType: "wipe_presence",
854
+ schema: this.schema.serialize(),
855
+ protocolVersion: getTlsyncProtocolVersion(),
856
+ serverClock: this.clock,
857
+ diff: migrated.value,
858
+ isReadonly: session.isReadonly
859
+ });
605
860
  }
606
- return {
607
- type: "connect",
608
- connectRequestId: message.connectRequestId,
609
- hydrationType: docChanges?.wipeAll ? "wipe_all" : "wipe_presence",
610
- protocolVersion: getTlsyncProtocolVersion(),
611
- schema: this.schema.serialize(),
612
- serverClock: txn.getClock(),
613
- diff: { ...presenceDiff.value, ...docDiff },
614
- isReadonly: session.isReadonly
615
- };
616
861
  });
617
- this.lastDocumentClock = documentClock;
618
- if (result) {
619
- connect(result);
620
- }
621
862
  }
622
863
  handlePushRequest(session, message) {
623
864
  if (session && session.state !== RoomSessionState.Connected) {
@@ -626,198 +867,203 @@ class TLSyncRoom {
626
867
  if (session) {
627
868
  session.lastInteractionTime = Date.now();
628
869
  }
629
- const legacyAppendMode = !this.getCanEmitStringAppend();
630
- const propagateOp = (changes2, id, op, before, after) => {
631
- if (!changes2.diffs) changes2.diffs = { networkDiff: {}, diff: { puts: {}, deletes: [] } };
632
- changes2.diffs.networkDiff[id] = op;
633
- switch (op[0]) {
634
- case RecordOpType.Put:
635
- changes2.diffs.diff.puts[id] = op[1];
636
- break;
637
- case RecordOpType.Patch:
638
- assert(before && after, "before and after are required for patches");
639
- changes2.diffs.diff.puts[id] = [before, after];
640
- break;
641
- case RecordOpType.Remove:
642
- changes2.diffs.diff.deletes.push(id);
643
- break;
644
- default:
645
- exhaustiveSwitchError(op[0]);
646
- }
647
- };
648
- const addDocument = (storage, changes2, id, _state) => {
649
- const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, "up") : { type: "success", value: _state };
650
- if (res.type === "error") {
651
- throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
652
- }
653
- const { value: state } = res;
654
- const doc = storage.get(id);
655
- if (doc) {
656
- const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName));
657
- const diff = diffAndValidateRecord(doc, state, recordType);
658
- if (diff) {
659
- storage.set(id, state);
660
- propagateOp(changes2, id, [RecordOpType.Patch, diff], doc, state);
870
+ this.clock++;
871
+ const initialDocumentClock = this.documentClock;
872
+ let didPresenceChange = false;
873
+ transaction((rollback) => {
874
+ const legacyAppendMode = !this.getCanEmitStringAppend();
875
+ const docChanges = { diff: null };
876
+ const presenceChanges = { diff: null };
877
+ const propagateOp = (changes, id, op) => {
878
+ if (!changes.diff) changes.diff = {};
879
+ changes.diff[id] = op;
880
+ };
881
+ const fail = (reason, underlyingError) => {
882
+ rollback();
883
+ if (session) {
884
+ this.rejectSession(session.sessionId, reason);
885
+ } else {
886
+ throw new Error("failed to apply changes: " + reason, underlyingError);
661
887
  }
662
- } else {
663
- const recordType = assertExists(getOwnProperty(this.schema.types, state.typeName));
664
- validateRecord(state, recordType);
665
- storage.set(id, state);
666
- propagateOp(changes2, id, [RecordOpType.Put, state], void 0, void 0);
667
- }
668
- return Result.ok(void 0);
669
- };
670
- const patchDocument = (storage, changes2, id, patch) => {
671
- const doc = storage.get(id);
672
- if (!doc) return;
673
- const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName));
674
- const downgraded = session ? this.schema.migratePersistedRecord(doc, session.serializedSchema, "down") : { type: "success", value: doc };
675
- if (downgraded.type === "error") {
676
- throw new TLSyncError(downgraded.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
677
- }
678
- if (downgraded.value === doc) {
679
- const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode);
680
- if (diff) {
681
- storage.set(id, diff[1]);
682
- propagateOp(changes2, id, [RecordOpType.Patch, diff[0]], doc, diff[1]);
888
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
889
+ this.log?.error?.("failed to apply push", reason, message, underlyingError);
683
890
  }
684
- } else {
685
- const patched = applyObjectDiff(downgraded.value, patch);
686
- const upgraded = session ? this.schema.migratePersistedRecord(patched, session.serializedSchema, "up") : { type: "success", value: patched };
687
- if (upgraded.type === "error") {
688
- throw new TLSyncError(upgraded.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
891
+ return Result.err(void 0);
892
+ };
893
+ const addDocument = (changes, id, _state) => {
894
+ const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, "up") : { type: "success", value: _state };
895
+ if (res.type === "error") {
896
+ return fail(
897
+ res.reason === MigrationFailureReason.TargetVersionTooOld ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
898
+ );
689
899
  }
690
- const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode);
691
- if (diff) {
692
- storage.set(id, upgraded.value);
693
- propagateOp(changes2, id, [RecordOpType.Patch, diff], doc, upgraded.value);
900
+ const { value: state } = res;
901
+ const doc = this.getDocument(id);
902
+ if (doc) {
903
+ const diff = doc.replaceState(state, this.clock, legacyAppendMode);
904
+ if (!diff.ok) {
905
+ return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
906
+ }
907
+ if (diff.value) {
908
+ this.documents.set(id, diff.value[1]);
909
+ propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]]);
910
+ }
911
+ } else {
912
+ const result = this.addDocument(id, state, this.clock);
913
+ if (!result.ok) {
914
+ return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
915
+ }
916
+ propagateOp(changes, id, [RecordOpType.Put, state]);
917
+ }
918
+ return Result.ok(void 0);
919
+ };
920
+ const patchDocument = (changes, id, patch) => {
921
+ const doc = this.getDocument(id);
922
+ if (!doc) return Result.ok(void 0);
923
+ const downgraded = session ? this.schema.migratePersistedRecord(doc.state, session.serializedSchema, "down") : { type: "success", value: doc.state };
924
+ if (downgraded.type === "error") {
925
+ return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
926
+ }
927
+ if (downgraded.value === doc.state) {
928
+ const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode);
929
+ if (!diff.ok) {
930
+ return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
931
+ }
932
+ if (diff.value) {
933
+ this.documents.set(id, diff.value[1]);
934
+ propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]]);
935
+ }
936
+ } else {
937
+ const patched = applyObjectDiff(downgraded.value, patch);
938
+ const upgraded = session ? this.schema.migratePersistedRecord(patched, session.serializedSchema, "up") : { type: "success", value: patched };
939
+ if (upgraded.type === "error") {
940
+ return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
941
+ }
942
+ const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode);
943
+ if (!diff.ok) {
944
+ return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
945
+ }
946
+ if (diff.value) {
947
+ this.documents.set(id, diff.value[1]);
948
+ propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]]);
949
+ }
950
+ }
951
+ return Result.ok(void 0);
952
+ };
953
+ const { clientClock } = message;
954
+ if (this.presenceType && session?.presenceId && "presence" in message && message.presence) {
955
+ if (!session) throw new Error("session is required for presence pushes");
956
+ const id = session.presenceId;
957
+ const [type, val] = message.presence;
958
+ const { typeName } = this.presenceType;
959
+ switch (type) {
960
+ case RecordOpType.Put: {
961
+ const res = addDocument(presenceChanges, id, { ...val, id, typeName });
962
+ if (!res.ok) return;
963
+ break;
964
+ }
965
+ case RecordOpType.Patch: {
966
+ const res = patchDocument(presenceChanges, id, {
967
+ ...val,
968
+ id: [ValueOpType.Put, id],
969
+ typeName: [ValueOpType.Put, typeName]
970
+ });
971
+ if (!res.ok) return;
972
+ break;
973
+ }
694
974
  }
695
975
  }
696
- };
697
- const { result, documentClock, changes } = this.storage.transaction(
698
- (txn) => {
699
- this.broadcastChanges(txn);
700
- const docChanges = { diffs: null };
701
- const presenceChanges = { diffs: null };
702
- if (this.presenceType && session?.presenceId && "presence" in message && message.presence) {
703
- if (!session) throw new Error("session is required for presence pushes");
704
- const id = session.presenceId;
705
- const [type, val] = message.presence;
706
- const { typeName } = this.presenceType;
707
- switch (type) {
976
+ if (message.diff && !session?.isReadonly) {
977
+ for (const [id, op] of objectMapEntriesIterable(message.diff)) {
978
+ switch (op[0]) {
708
979
  case RecordOpType.Put: {
709
- addDocument(this.presenceStore, presenceChanges, id, {
710
- ...val,
711
- id,
712
- typeName
713
- });
980
+ if (!this.documentTypes.has(op[1].typeName)) {
981
+ return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
982
+ }
983
+ const res = addDocument(docChanges, id, op[1]);
984
+ if (!res.ok) return;
714
985
  break;
715
986
  }
716
987
  case RecordOpType.Patch: {
717
- patchDocument(this.presenceStore, presenceChanges, id, {
718
- ...val,
719
- id: [ValueOpType.Put, id],
720
- typeName: [ValueOpType.Put, typeName]
721
- });
988
+ const res = patchDocument(docChanges, id, op[1]);
989
+ if (!res.ok) return;
722
990
  break;
723
991
  }
724
- }
725
- }
726
- if (message.diff && !session?.isReadonly) {
727
- for (const [id, op] of objectMapEntriesIterable(message.diff)) {
728
- switch (op[0]) {
729
- case RecordOpType.Put: {
730
- if (!this.documentTypes.has(op[1].typeName)) {
731
- throw new TLSyncError(
732
- "invalid record",
733
- TLSyncErrorCloseEventReason.INVALID_RECORD
734
- );
735
- }
736
- addDocument(txn, docChanges, id, op[1]);
737
- break;
738
- }
739
- case RecordOpType.Patch: {
740
- patchDocument(txn, docChanges, id, op[1]);
741
- break;
742
- }
743
- case RecordOpType.Remove: {
744
- const doc = txn.get(id);
745
- if (!doc) {
746
- continue;
747
- }
748
- txn.delete(id);
749
- propagateOp(docChanges, id, op, doc, void 0);
750
- break;
992
+ case RecordOpType.Remove: {
993
+ const doc = this.getDocument(id);
994
+ if (!doc) {
995
+ continue;
751
996
  }
997
+ this.removeDocument(id, this.clock);
998
+ propagateOp(docChanges, id, op);
999
+ break;
752
1000
  }
753
1001
  }
754
1002
  }
755
- return { docChanges, presenceChanges };
756
- },
757
- { id: this.internalTxnId, emitChanges: "when-different" }
758
- );
759
- this.lastDocumentClock = documentClock;
760
- let pushResult;
761
- if (changes && session) {
762
- result.docChanges.diffs = { networkDiff: toNetworkDiff(changes) ?? {}, diff: changes };
763
- }
764
- if (isEqual(result.docChanges.diffs?.networkDiff, message.diff)) {
765
- pushResult = {
766
- type: "push_result",
767
- clientClock: message.clientClock,
768
- serverClock: documentClock,
769
- action: "commit"
770
- };
771
- } else if (!result.docChanges.diffs?.networkDiff) {
772
- pushResult = {
773
- type: "push_result",
774
- clientClock: message.clientClock,
775
- serverClock: documentClock,
776
- action: "discard"
777
- };
778
- } else if (session) {
779
- const diff = this.migrateDiffOrRejectSession(
780
- session.sessionId,
781
- session.serializedSchema,
782
- session.requiresDownMigrations,
783
- result.docChanges.diffs.diff,
784
- result.docChanges.diffs.networkDiff
785
- );
786
- if (diff.ok) {
787
- pushResult = {
788
- type: "push_result",
789
- clientClock: message.clientClock,
790
- serverClock: documentClock,
791
- action: { rebaseWithDiff: diff.value }
792
- };
793
1003
  }
1004
+ if (
1005
+ // if there was only a presence push, the client doesn't need to do anything aside from
1006
+ // shift the push request.
1007
+ !message.diff || isEqual(docChanges.diff, message.diff)
1008
+ ) {
1009
+ if (session) {
1010
+ this.sendMessage(session.sessionId, {
1011
+ type: "push_result",
1012
+ serverClock: this.clock,
1013
+ clientClock,
1014
+ action: "commit"
1015
+ });
1016
+ }
1017
+ } else if (!docChanges.diff) {
1018
+ if (session) {
1019
+ this.sendMessage(session.sessionId, {
1020
+ type: "push_result",
1021
+ serverClock: this.clock,
1022
+ clientClock,
1023
+ action: "discard"
1024
+ });
1025
+ }
1026
+ } else {
1027
+ if (session) {
1028
+ const migrateResult = this.migrateDiffForSession(
1029
+ session.serializedSchema,
1030
+ docChanges.diff
1031
+ );
1032
+ if (!migrateResult.ok) {
1033
+ return fail(
1034
+ migrateResult.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
1035
+ );
1036
+ }
1037
+ this.sendMessage(session.sessionId, {
1038
+ type: "push_result",
1039
+ serverClock: this.clock,
1040
+ clientClock,
1041
+ action: { rebaseWithDiff: migrateResult.value }
1042
+ });
1043
+ }
1044
+ }
1045
+ if (docChanges.diff || presenceChanges.diff) {
1046
+ this.broadcastPatch({
1047
+ sourceSessionId: session?.sessionId,
1048
+ diff: {
1049
+ ...docChanges.diff,
1050
+ ...presenceChanges.diff
1051
+ }
1052
+ });
1053
+ }
1054
+ if (docChanges.diff) {
1055
+ this.documentClock = this.clock;
1056
+ }
1057
+ if (presenceChanges.diff) {
1058
+ didPresenceChange = true;
1059
+ }
1060
+ return;
1061
+ });
1062
+ if (this.documentClock !== initialDocumentClock) {
1063
+ this.onDataChange?.();
794
1064
  }
795
- if (session && pushResult) {
796
- this._unsafe_sendMessage(session.sessionId, pushResult);
797
- }
798
- if (result.docChanges.diffs || result.presenceChanges.diffs) {
799
- this.broadcastPatch(
800
- {
801
- puts: {
802
- ...result.docChanges.diffs?.diff.puts,
803
- ...result.presenceChanges.diffs?.diff.puts
804
- },
805
- deletes: [
806
- ...(result.docChanges.diffs?.diff.deletes ?? []),
807
- ...(result.presenceChanges.diffs?.diff.deletes ?? [])
808
- ]
809
- },
810
- {
811
- ...result.docChanges.diffs?.networkDiff,
812
- ...result.presenceChanges.diffs?.networkDiff
813
- },
814
- session?.sessionId
815
- );
816
- }
817
- if (result.presenceChanges.diffs) {
818
- queueMicrotask(() => {
819
- this.onPresenceChange?.();
820
- });
1065
+ if (didPresenceChange) {
1066
+ this.onPresenceChange?.();
821
1067
  }
822
1068
  }
823
1069
  /**
@@ -835,25 +1081,111 @@ class TLSyncRoom {
835
1081
  handleClose(sessionId) {
836
1082
  this.cancelSession(sessionId);
837
1083
  }
1084
+ /**
1085
+ * Apply changes to the room's store in a transactional way. Changes are
1086
+ * automatically synchronized to all connected clients.
1087
+ *
1088
+ * @param updater - Function that receives store methods to make changes
1089
+ * @returns Promise that resolves when the transaction is complete
1090
+ * @example
1091
+ * ```ts
1092
+ * // Add multiple shapes atomically
1093
+ * await room.updateStore((store) => {
1094
+ * store.put(createShape({ type: 'geo', x: 100, y: 100 }))
1095
+ * store.put(createShape({ type: 'text', x: 200, y: 200 }))
1096
+ * })
1097
+ *
1098
+ * // Async operations are supported
1099
+ * await room.updateStore(async (store) => {
1100
+ * const template = await loadTemplate()
1101
+ * template.shapes.forEach(shape => store.put(shape))
1102
+ * })
1103
+ * ```
1104
+ */
1105
+ async updateStore(updater) {
1106
+ if (this._isClosed) {
1107
+ throw new Error("Cannot update store on a closed room");
1108
+ }
1109
+ const context = new StoreUpdateContext(
1110
+ Object.fromEntries(this.getSnapshot().documents.map((d) => [d.state.id, d.state]))
1111
+ );
1112
+ try {
1113
+ await updater(context);
1114
+ } finally {
1115
+ context.close();
1116
+ }
1117
+ const diff = context.toDiff();
1118
+ if (Object.keys(diff).length === 0) {
1119
+ return;
1120
+ }
1121
+ this.handlePushRequest(null, { type: "push", diff, clientClock: 0 });
1122
+ }
838
1123
  }
839
- class PresenceStore {
840
- presences = new AtomMap("presences");
1124
+ class StoreUpdateContext {
1125
+ constructor(snapshot) {
1126
+ this.snapshot = snapshot;
1127
+ }
1128
+ updates = {
1129
+ puts: {},
1130
+ deletes: /* @__PURE__ */ new Set()
1131
+ };
1132
+ put(record) {
1133
+ if (this._isClosed) throw new Error("StoreUpdateContext is closed");
1134
+ if (record.id in this.snapshot && isEqual(this.snapshot[record.id], record)) {
1135
+ delete this.updates.puts[record.id];
1136
+ } else {
1137
+ this.updates.puts[record.id] = structuredClone(record);
1138
+ }
1139
+ this.updates.deletes.delete(record.id);
1140
+ }
1141
+ delete(recordOrId) {
1142
+ if (this._isClosed) throw new Error("StoreUpdateContext is closed");
1143
+ const id = typeof recordOrId === "string" ? recordOrId : recordOrId.id;
1144
+ delete this.updates.puts[id];
1145
+ if (this.snapshot[id]) {
1146
+ this.updates.deletes.add(id);
1147
+ }
1148
+ }
841
1149
  get(id) {
842
- return this.presences.get(id);
1150
+ if (this._isClosed) throw new Error("StoreUpdateContext is closed");
1151
+ if (hasOwnProperty(this.updates.puts, id)) {
1152
+ return structuredClone(this.updates.puts[id]);
1153
+ }
1154
+ if (this.updates.deletes.has(id)) {
1155
+ return null;
1156
+ }
1157
+ return structuredClone(this.snapshot[id] ?? null);
843
1158
  }
844
- set(id, state) {
845
- this.presences.set(id, state);
1159
+ getAll() {
1160
+ if (this._isClosed) throw new Error("StoreUpdateContext is closed");
1161
+ const result = Object.values(this.updates.puts);
1162
+ for (const [id, record] of Object.entries(this.snapshot)) {
1163
+ if (!this.updates.deletes.has(id) && !hasOwnProperty(this.updates.puts, id)) {
1164
+ result.push(record);
1165
+ }
1166
+ }
1167
+ return structuredClone(result);
846
1168
  }
847
- delete(id) {
848
- this.presences.delete(id);
1169
+ toDiff() {
1170
+ const diff = {};
1171
+ for (const [id, record] of Object.entries(this.updates.puts)) {
1172
+ diff[id] = [RecordOpType.Put, record];
1173
+ }
1174
+ for (const id of this.updates.deletes) {
1175
+ diff[id] = [RecordOpType.Remove];
1176
+ }
1177
+ return diff;
849
1178
  }
850
- values() {
851
- return this.presences.values();
1179
+ _isClosed = false;
1180
+ close() {
1181
+ this._isClosed = true;
852
1182
  }
853
1183
  }
854
1184
  export {
855
1185
  DATA_MESSAGE_DEBOUNCE_INTERVAL,
856
- PresenceStore,
857
- TLSyncRoom
1186
+ DocumentState,
1187
+ MAX_TOMBSTONES,
1188
+ TLSyncRoom,
1189
+ TOMBSTONE_PRUNE_BUFFER_SIZE
858
1190
  };
859
1191
  //# sourceMappingURL=TLSyncRoom.mjs.map