@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
@@ -19,26 +19,108 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  var TLSyncRoom_exports = {};
20
20
  __export(TLSyncRoom_exports, {
21
21
  DATA_MESSAGE_DEBOUNCE_INTERVAL: () => DATA_MESSAGE_DEBOUNCE_INTERVAL,
22
- PresenceStore: () => PresenceStore,
23
- TLSyncRoom: () => TLSyncRoom
22
+ DocumentState: () => DocumentState,
23
+ MAX_TOMBSTONES: () => MAX_TOMBSTONES,
24
+ TLSyncRoom: () => TLSyncRoom,
25
+ TOMBSTONE_PRUNE_BUFFER_SIZE: () => TOMBSTONE_PRUNE_BUFFER_SIZE
24
26
  });
25
27
  module.exports = __toCommonJS(TLSyncRoom_exports);
28
+ var import_state = require("@tldraw/state");
26
29
  var import_store = require("@tldraw/store");
30
+ var import_tlschema = require("@tldraw/tlschema");
27
31
  var import_utils = require("@tldraw/utils");
28
32
  var import_nanoevents = require("nanoevents");
33
+ var import_RoomSession = require("./RoomSession");
34
+ var import_TLSyncClient = require("./TLSyncClient");
29
35
  var import_diff = require("./diff");
36
+ var import_findMin = require("./findMin");
30
37
  var import_interval = require("./interval");
31
38
  var import_protocol = require("./protocol");
32
- var import_recordDiff = require("./recordDiff");
33
- var import_RoomSession = require("./RoomSession");
34
- var import_TLSyncClient = require("./TLSyncClient");
35
- var import_TLSyncStorage = require("./TLSyncStorage");
39
+ const MAX_TOMBSTONES = 3e3;
40
+ const TOMBSTONE_PRUNE_BUFFER_SIZE = 300;
36
41
  const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1e3 / 60;
37
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 import_utils.Result.err(error);
74
+ }
75
+ return import_utils.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 = (0, import_diff.diffRecord)(this.state, state, legacyAppendMode);
87
+ if (!diff) return import_utils.Result.ok(null);
88
+ try {
89
+ this.recordType.validate(state);
90
+ } catch (error) {
91
+ return import_utils.Result.err(error);
92
+ }
93
+ return import_utils.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 = (0, import_diff.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
+ }
38
121
  class TLSyncRoom {
39
122
  // A table of connected clients
40
123
  sessions = /* @__PURE__ */ new Map();
41
- lastDocumentClock = 0;
42
124
  // eslint-disable-next-line local/prefer-class-methods
43
125
  pruneSessions = () => {
44
126
  for (const client of this.sessions.values()) {
@@ -70,7 +152,6 @@ class TLSyncRoom {
70
152
  }
71
153
  }
72
154
  };
73
- presenceStore = new PresenceStore();
74
155
  disposables = [(0, import_interval.interval)(this.pruneSessions, 2e3)];
75
156
  _isClosed = false;
76
157
  /**
@@ -93,8 +174,17 @@ class TLSyncRoom {
93
174
  return this._isClosed;
94
175
  }
95
176
  events = (0, import_nanoevents.createNanoEvents)();
96
- // Storage layer for documents, tombstones, and clocks
97
- 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
98
188
  serializedSchema;
99
189
  documentTypes;
100
190
  presenceType;
@@ -102,9 +192,10 @@ class TLSyncRoom {
102
192
  schema;
103
193
  constructor(opts) {
104
194
  this.schema = opts.schema;
195
+ let snapshot = opts.snapshot;
105
196
  this.log = opts.log;
197
+ this.onDataChange = opts.onDataChange;
106
198
  this.onPresenceChange = opts.onPresenceChange;
107
- this.storage = opts.storage;
108
199
  (0, import_utils.assert)(
109
200
  import_utils.isNativeStructuredClone,
110
201
  "TLSyncRoom is supposed to run either on Cloudflare Workersor on a 18+ version of Node.js, which both support the native structuredClone API"
@@ -122,31 +213,194 @@ class TLSyncRoom {
122
213
  );
123
214
  }
124
215
  this.presenceType = presenceTypes.values().next()?.value ?? null;
125
- const { documentClock } = this.storage.transaction((txn) => {
126
- this.schema.migrateStorage(txn);
127
- });
128
- this.lastDocumentClock = documentClock;
129
- this.disposables.push(
130
- this.storage.onChange(({ id }) => {
131
- if (id !== this.internalTxnId) {
132
- this.broadcastExternalStorageChanges();
216
+ if (!snapshot) {
217
+ snapshot = {
218
+ clock: 0,
219
+ documentClock: 0,
220
+ documents: [
221
+ {
222
+ state: import_tlschema.DocumentRecordType.create({ id: import_tlschema.TLDOCUMENT_ID }),
223
+ lastChangedClock: 0
224
+ },
225
+ {
226
+ state: import_tlschema.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 import_store.AtomMap(
241
+ "room tombstones",
242
+ (0, import_utils.objectMapEntriesIterable)(snapshot.tombstones ?? {})
243
+ );
244
+ this.documents = new import_store.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
+ (0, import_utils.assertExists)((0, import_utils.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
+ }
133
261
  }
134
- })
262
+ }.call(this)
135
263
  );
136
- }
137
- broadcastExternalStorageChanges() {
138
- this.storage.transaction((txn) => {
139
- this.broadcastChanges(txn);
140
- this.lastDocumentClock = txn.getClock();
264
+ this.tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? (0, import_findMin.findMin)(this.tombstones.values()) ?? this.clock;
265
+ if (this.tombstoneHistoryStartsAtClock === 0) {
266
+ this.tombstoneHistoryStartsAtClock++;
267
+ }
268
+ (0, import_state.transact)(() => {
269
+ const schema = snapshot.schema ?? this.schema.serializeEarliestVersion();
270
+ const migrationsToApply = this.schema.getMigrationsSince(schema);
271
+ (0, import_utils.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 || !(0, import_utils.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
+ (0, import_utils.assertExists)((0, import_utils.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();
141
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
+ (0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, state.typeName))
347
+ );
348
+ if (!createResult.ok) return createResult;
349
+ this.documents.set(id, createResult.value);
350
+ return import_utils.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
+ };
142
396
  }
143
397
  /**
144
398
  * Send a message to a particular client. Debounces data events
145
399
  *
146
400
  * @param sessionId - The id of the session to send the message to.
147
- * @param message - The message to send. UNSAFE Any diffs must have been downgraded already if necessary
401
+ * @param message - The message to send.
148
402
  */
149
- _unsafe_sendMessage(sessionId, message) {
403
+ sendMessage(sessionId, message) {
150
404
  const session = this.sessions.get(sessionId);
151
405
  if (!session) {
152
406
  this.log?.warn?.("Tried to send message to unknown session", message.type);
@@ -198,6 +452,7 @@ class TLSyncRoom {
198
452
  return;
199
453
  }
200
454
  this.sessions.delete(sessionId);
455
+ const presence = this.getDocument(session.presenceId ?? "");
201
456
  try {
202
457
  if (fatalReason) {
203
458
  session.socket.close(import_TLSyncClient.TLSyncErrorCloseEventCode, fatalReason);
@@ -206,12 +461,11 @@ class TLSyncRoom {
206
461
  }
207
462
  } catch {
208
463
  }
209
- const presence = this.presenceStore.get(session.presenceId ?? "");
210
464
  if (presence) {
211
- this.presenceStore.delete(session.presenceId);
465
+ this.documents.delete(session.presenceId);
212
466
  this.broadcastPatch({
213
- puts: {},
214
- deletes: [session.presenceId]
467
+ diff: { [session.presenceId]: [import_diff.RecordOpType.Remove] },
468
+ sourceSessionId: sessionId
215
469
  });
216
470
  }
217
471
  this.events.emit("session_removed", { sessionId, meta: session.meta });
@@ -244,18 +498,24 @@ class TLSyncRoom {
244
498
  } catch {
245
499
  }
246
500
  }
247
- internalTxnId = "TLSyncRoom.txn";
248
501
  /**
249
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.
250
504
  *
251
- * @param diff - The TLSyncForwardDiff with full records (used for migration)
252
- * @param networkDiff - Optional pre-computed NetworkDiff for sessions not needing migration.
253
- * If not provided, will be computed from recordsDiff.
254
- * @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
+ * ```
255
516
  */
256
- broadcastPatch(diff, networkDiff, sourceSessionId) {
257
- const unmigrated = networkDiff ?? (0, import_TLSyncStorage.toNetworkDiff)(diff);
258
- if (!unmigrated) return this;
517
+ broadcastPatch(message) {
518
+ const { diff, sourceSessionId } = message;
259
519
  this.sessions.forEach((session) => {
260
520
  if (session.state !== import_RoomSession.RoomSessionState.Connected) return;
261
521
  if (sourceSessionId === session.sessionId) return;
@@ -263,17 +523,18 @@ class TLSyncRoom {
263
523
  this.cancelSession(session.sessionId);
264
524
  return;
265
525
  }
266
- const diffResult = this.migrateDiffOrRejectSession(
267
- session.sessionId,
268
- session.serializedSchema,
269
- session.requiresDownMigrations,
270
- diff
271
- );
272
- if (!diffResult.ok) return;
273
- 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 === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
531
+ );
532
+ return;
533
+ }
534
+ this.sendMessage(session.sessionId, {
274
535
  type: "patch",
275
- diff: diffResult.value,
276
- serverClock: this.lastDocumentClock
536
+ diff: res.value,
537
+ serverClock: this.clock
277
538
  });
278
539
  });
279
540
  return this;
@@ -301,7 +562,7 @@ class TLSyncRoom {
301
562
  * ```
302
563
  */
303
564
  sendCustomMessage(sessionId, data) {
304
- this._unsafe_sendMessage(sessionId, { type: "custom", data });
565
+ this.sendMessage(sessionId, { type: "custom", data });
305
566
  }
306
567
  /**
307
568
  * Register a new client session with the room. The session will be in an awaiting
@@ -360,54 +621,34 @@ class TLSyncRoom {
360
621
  }
361
622
  /**
362
623
  * When we send a diff to a client, if that client is on a lower version than us, we need to make
363
- * the diff compatible with their version. This method takes a TLSyncForwardDiff (which has full
364
- * records) and migrates all records down to the client's schema version, returning a NetworkDiff.
365
- *
366
- * For updates (entries with [before, after] tuples), both records are migrated and a patch is
367
- * computed from the migrated versions, preserving efficient patch semantics even across versions.
368
- *
369
- * If a migration fails, the session will be rejected.
370
- *
371
- * @param sessionId - The session ID (for rejection on migration failure)
372
- * @param serializedSchema - The client's schema to migrate to
373
- * @param requiresDownMigrations - Whether the client needs down migrations
374
- * @param diff - The TLSyncForwardDiff containing full records to migrate
375
- * @param unmigrated - Optional pre-computed NetworkDiff for when no migration is needed
376
- * @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.
377
628
  */
378
- migrateDiffOrRejectSession(sessionId, serializedSchema, requiresDownMigrations, diff, unmigrated) {
379
- if (!requiresDownMigrations) {
380
- return import_utils.Result.ok(unmigrated ?? (0, import_TLSyncStorage.toNetworkDiff)(diff) ?? {});
629
+ migrateDiffForSession(serializedSchema, diff) {
630
+ if (serializedSchema === this.serializedSchema) {
631
+ return import_utils.Result.ok(diff);
381
632
  }
382
633
  const result = {};
383
- for (const [id, put] of (0, import_utils.objectMapEntriesIterable)(diff.puts)) {
384
- if (Array.isArray(put)) {
385
- const [from, to] = put;
386
- const fromResult = this.schema.migratePersistedRecord(from, serializedSchema, "down");
387
- if (fromResult.type === "error") {
388
- this.rejectSession(sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
389
- return import_utils.Result.err(fromResult.reason);
390
- }
391
- const toResult = this.schema.migratePersistedRecord(to, serializedSchema, "down");
392
- if (toResult.type === "error") {
393
- this.rejectSession(sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
394
- return import_utils.Result.err(toResult.reason);
395
- }
396
- const patch = (0, import_diff.diffRecord)(fromResult.value, toResult.value);
397
- if (patch) {
398
- result[id] = [import_diff.RecordOpType.Patch, patch];
399
- }
400
- } else {
401
- const migrationResult = this.schema.migratePersistedRecord(put, serializedSchema, "down");
402
- if (migrationResult.type === "error") {
403
- this.rejectSession(sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
404
- return import_utils.Result.err(migrationResult.reason);
405
- }
406
- result[id] = [import_diff.RecordOpType.Put, migrationResult.value];
634
+ for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(diff)) {
635
+ if (op[0] === import_diff.RecordOpType.Remove) {
636
+ result[id] = op;
637
+ continue;
407
638
  }
408
- }
409
- for (const id of diff.deletes) {
410
- result[id] = [import_diff.RecordOpType.Remove];
639
+ const doc = this.getDocument(id);
640
+ if (!doc) {
641
+ return import_utils.Result.err(import_store.MigrationFailureReason.TargetVersionTooNew);
642
+ }
643
+ const migrationResult = this.schema.migratePersistedRecord(
644
+ doc.state,
645
+ serializedSchema,
646
+ "down"
647
+ );
648
+ if (migrationResult.type === "error") {
649
+ return import_utils.Result.err(migrationResult.reason);
650
+ }
651
+ result[id] = [import_diff.RecordOpType.Put, migrationResult.value];
411
652
  }
412
653
  return import_utils.Result.ok(result);
413
654
  }
@@ -432,29 +673,21 @@ class TLSyncRoom {
432
673
  this.log?.warn?.("Received message from unknown session");
433
674
  return;
434
675
  }
435
- try {
436
- switch (message.type) {
437
- case "connect": {
438
- return this.handleConnectRequest(session, message);
439
- }
440
- case "push": {
441
- return this.handlePushRequest(session, message);
442
- }
443
- case "ping": {
444
- if (session.state === import_RoomSession.RoomSessionState.Connected) {
445
- session.lastInteractionTime = Date.now();
446
- }
447
- return this._unsafe_sendMessage(session.sessionId, { type: "pong" });
448
- }
449
- default: {
450
- (0, import_utils.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 === import_RoomSession.RoomSessionState.Connected) {
685
+ session.lastInteractionTime = Date.now();
451
686
  }
687
+ return this.sendMessage(session.sessionId, { type: "pong" });
452
688
  }
453
- } catch (e) {
454
- if (e instanceof import_TLSyncClient.TLSyncError) {
455
- this.rejectSession(session.sessionId, e.reason);
456
- } else {
457
- throw e;
689
+ default: {
690
+ (0, import_utils.exhaustiveSwitchError)(message);
458
691
  }
459
692
  }
460
693
  }
@@ -511,22 +744,6 @@ class TLSyncRoom {
511
744
  this.removeSession(sessionId, fatalReason);
512
745
  }
513
746
  }
514
- forceAllReconnect() {
515
- for (const session of this.sessions.values()) {
516
- this.removeSession(session.sessionId);
517
- }
518
- }
519
- broadcastChanges(txn) {
520
- const changes = txn.getChangesSince(this.lastDocumentClock);
521
- if (!changes) return;
522
- const { wipeAll, diff } = changes;
523
- this.lastDocumentClock = txn.getClock();
524
- if (wipeAll) {
525
- this.forceAllReconnect();
526
- return;
527
- }
528
- this.broadcastPatch(diff);
529
- }
530
747
  handleConnectRequest(session, message) {
531
748
  let theirProtocolVersion = message.protocolVersion;
532
749
  if (theirProtocolVersion === 5) {
@@ -552,12 +769,11 @@ class TLSyncRoom {
552
769
  return;
553
770
  }
554
771
  const migrations = this.schema.getMigrationsSince(message.schema);
555
- 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)) {
556
773
  this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
557
774
  return;
558
775
  }
559
776
  const sessionSchema = (0, import_utils.isEqual)(message.schema, this.serializedSchema) ? this.serializedSchema : message.schema;
560
- const requiresDownMigrations = migrations.value.length > 0;
561
777
  const connect = async (msg) => {
562
778
  this.sessions.set(session.sessionId, {
563
779
  state: import_RoomSession.RoomSessionState.Connected,
@@ -565,7 +781,6 @@ class TLSyncRoom {
565
781
  presenceId: session.presenceId,
566
782
  socket: session.socket,
567
783
  serializedSchema: sessionSchema,
568
- requiresDownMigrations,
569
784
  lastInteractionTime: Date.now(),
570
785
  debounceTimer: null,
571
786
  outstandingDataMessages: [],
@@ -574,49 +789,76 @@ class TLSyncRoom {
574
789
  isReadonly: session.isReadonly,
575
790
  requiresLegacyRejection: session.requiresLegacyRejection
576
791
  });
577
- this._unsafe_sendMessage(session.sessionId, msg);
792
+ this.sendMessage(session.sessionId, msg);
578
793
  };
579
- const { documentClock, result } = this.storage.transaction((txn) => {
580
- this.broadcastChanges(txn);
581
- const docChanges = txn.getChangesSince(message.lastServerClock);
582
- const presenceDiff = this.migrateDiffOrRejectSession(
583
- session.sessionId,
584
- sessionSchema,
585
- requiresDownMigrations,
586
- {
587
- puts: Object.fromEntries([...this.presenceStore.values()].map((p) => [p.id, p])),
588
- deletes: []
794
+ (0, import_state.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] = [import_diff.RecordOpType.Put, doc.state];
806
+ }
589
807
  }
590
- );
591
- if (!presenceDiff.ok) return null;
592
- let docDiff = null;
593
- if (docChanges && sessionSchema !== this.serializedSchema) {
594
- const migrated = this.migrateDiffOrRejectSession(
595
- session.sessionId,
596
- sessionSchema,
597
- requiresDownMigrations,
598
- docChanges.diff
599
- );
600
- if (!migrated.ok) return null;
601
- docDiff = migrated.value;
602
- } else if (docChanges) {
603
- docDiff = (0, import_TLSyncStorage.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 === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
814
+ );
815
+ return;
816
+ }
817
+ connect({
818
+ type: "connect",
819
+ connectRequestId: message.connectRequestId,
820
+ hydrationType: "wipe_all",
821
+ protocolVersion: (0, import_protocol.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] = [import_diff.RecordOpType.Put, doc.state];
832
+ } else if (this.presenceType?.isId(doc.state.id) && doc.state.id !== session.presenceId) {
833
+ diff[doc.state.id] = [import_diff.RecordOpType.Put, doc.state];
834
+ }
835
+ }
836
+ for (const [id, deletedAtClock] of this.tombstones.entries()) {
837
+ if (deletedAtClock > message.lastServerClock) {
838
+ diff[id] = [import_diff.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 === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.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: (0, import_protocol.getTlsyncProtocolVersion)(),
856
+ serverClock: this.clock,
857
+ diff: migrated.value,
858
+ isReadonly: session.isReadonly
859
+ });
604
860
  }
605
- return {
606
- type: "connect",
607
- connectRequestId: message.connectRequestId,
608
- hydrationType: docChanges?.wipeAll ? "wipe_all" : "wipe_presence",
609
- protocolVersion: (0, import_protocol.getTlsyncProtocolVersion)(),
610
- schema: this.schema.serialize(),
611
- serverClock: txn.getClock(),
612
- diff: { ...presenceDiff.value, ...docDiff },
613
- isReadonly: session.isReadonly
614
- };
615
861
  });
616
- this.lastDocumentClock = documentClock;
617
- if (result) {
618
- connect(result);
619
- }
620
862
  }
621
863
  handlePushRequest(session, message) {
622
864
  if (session && session.state !== import_RoomSession.RoomSessionState.Connected) {
@@ -625,198 +867,203 @@ class TLSyncRoom {
625
867
  if (session) {
626
868
  session.lastInteractionTime = Date.now();
627
869
  }
628
- const legacyAppendMode = !this.getCanEmitStringAppend();
629
- const propagateOp = (changes2, id, op, before, after) => {
630
- if (!changes2.diffs) changes2.diffs = { networkDiff: {}, diff: { puts: {}, deletes: [] } };
631
- changes2.diffs.networkDiff[id] = op;
632
- switch (op[0]) {
633
- case import_diff.RecordOpType.Put:
634
- changes2.diffs.diff.puts[id] = op[1];
635
- break;
636
- case import_diff.RecordOpType.Patch:
637
- (0, import_utils.assert)(before && after, "before and after are required for patches");
638
- changes2.diffs.diff.puts[id] = [before, after];
639
- break;
640
- case import_diff.RecordOpType.Remove:
641
- changes2.diffs.diff.deletes.push(id);
642
- break;
643
- default:
644
- (0, import_utils.exhaustiveSwitchError)(op[0]);
645
- }
646
- };
647
- const addDocument = (storage, changes2, id, _state) => {
648
- const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, "up") : { type: "success", value: _state };
649
- if (res.type === "error") {
650
- throw new import_TLSyncClient.TLSyncError(res.reason, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
651
- }
652
- const { value: state } = res;
653
- const doc = storage.get(id);
654
- if (doc) {
655
- const recordType = (0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, doc.typeName));
656
- const diff = (0, import_recordDiff.diffAndValidateRecord)(doc, state, recordType);
657
- if (diff) {
658
- storage.set(id, state);
659
- propagateOp(changes2, id, [import_diff.RecordOpType.Patch, diff], doc, state);
870
+ this.clock++;
871
+ const initialDocumentClock = this.documentClock;
872
+ let didPresenceChange = false;
873
+ (0, import_state.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);
660
887
  }
661
- } else {
662
- const recordType = (0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, state.typeName));
663
- (0, import_recordDiff.validateRecord)(state, recordType);
664
- storage.set(id, state);
665
- propagateOp(changes2, id, [import_diff.RecordOpType.Put, state], void 0, void 0);
666
- }
667
- return import_utils.Result.ok(void 0);
668
- };
669
- const patchDocument = (storage, changes2, id, patch) => {
670
- const doc = storage.get(id);
671
- if (!doc) return;
672
- const recordType = (0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, doc.typeName));
673
- const downgraded = session ? this.schema.migratePersistedRecord(doc, session.serializedSchema, "down") : { type: "success", value: doc };
674
- if (downgraded.type === "error") {
675
- throw new import_TLSyncClient.TLSyncError(downgraded.reason, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
676
- }
677
- if (downgraded.value === doc) {
678
- const diff = (0, import_recordDiff.applyAndDiffRecord)(doc, patch, recordType, legacyAppendMode);
679
- if (diff) {
680
- storage.set(id, diff[1]);
681
- propagateOp(changes2, id, [import_diff.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);
682
890
  }
683
- } else {
684
- const patched = (0, import_diff.applyObjectDiff)(downgraded.value, patch);
685
- const upgraded = session ? this.schema.migratePersistedRecord(patched, session.serializedSchema, "up") : { type: "success", value: patched };
686
- if (upgraded.type === "error") {
687
- throw new import_TLSyncClient.TLSyncError(upgraded.reason, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
891
+ return import_utils.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 === import_store.MigrationFailureReason.TargetVersionTooOld ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
898
+ );
899
+ }
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(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
906
+ }
907
+ if (diff.value) {
908
+ this.documents.set(id, diff.value[1]);
909
+ propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]);
910
+ }
911
+ } else {
912
+ const result = this.addDocument(id, state, this.clock);
913
+ if (!result.ok) {
914
+ return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
915
+ }
916
+ propagateOp(changes, id, [import_diff.RecordOpType.Put, state]);
917
+ }
918
+ return import_utils.Result.ok(void 0);
919
+ };
920
+ const patchDocument = (changes, id, patch) => {
921
+ const doc = this.getDocument(id);
922
+ if (!doc) return import_utils.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(import_TLSyncClient.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(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
931
+ }
932
+ if (diff.value) {
933
+ this.documents.set(id, diff.value[1]);
934
+ propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]);
935
+ }
936
+ } else {
937
+ const patched = (0, import_diff.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(import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
941
+ }
942
+ const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode);
943
+ if (!diff.ok) {
944
+ return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
945
+ }
946
+ if (diff.value) {
947
+ this.documents.set(id, diff.value[1]);
948
+ propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]);
949
+ }
688
950
  }
689
- const diff = (0, import_recordDiff.diffAndValidateRecord)(doc, upgraded.value, recordType, legacyAppendMode);
690
- if (diff) {
691
- storage.set(id, upgraded.value);
692
- propagateOp(changes2, id, [import_diff.RecordOpType.Patch, diff], doc, upgraded.value);
951
+ return import_utils.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 import_diff.RecordOpType.Put: {
961
+ const res = addDocument(presenceChanges, id, { ...val, id, typeName });
962
+ if (!res.ok) return;
963
+ break;
964
+ }
965
+ case import_diff.RecordOpType.Patch: {
966
+ const res = patchDocument(presenceChanges, id, {
967
+ ...val,
968
+ id: [import_diff.ValueOpType.Put, id],
969
+ typeName: [import_diff.ValueOpType.Put, typeName]
970
+ });
971
+ if (!res.ok) return;
972
+ break;
973
+ }
693
974
  }
694
975
  }
695
- };
696
- const { result, documentClock, changes } = this.storage.transaction(
697
- (txn) => {
698
- this.broadcastChanges(txn);
699
- const docChanges = { diffs: null };
700
- const presenceChanges = { diffs: null };
701
- if (this.presenceType && session?.presenceId && "presence" in message && message.presence) {
702
- if (!session) throw new Error("session is required for presence pushes");
703
- const id = session.presenceId;
704
- const [type, val] = message.presence;
705
- const { typeName } = this.presenceType;
706
- switch (type) {
976
+ if (message.diff && !session?.isReadonly) {
977
+ for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(message.diff)) {
978
+ switch (op[0]) {
707
979
  case import_diff.RecordOpType.Put: {
708
- addDocument(this.presenceStore, presenceChanges, id, {
709
- ...val,
710
- id,
711
- typeName
712
- });
980
+ if (!this.documentTypes.has(op[1].typeName)) {
981
+ return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
982
+ }
983
+ const res = addDocument(docChanges, id, op[1]);
984
+ if (!res.ok) return;
713
985
  break;
714
986
  }
715
987
  case import_diff.RecordOpType.Patch: {
716
- patchDocument(this.presenceStore, presenceChanges, id, {
717
- ...val,
718
- id: [import_diff.ValueOpType.Put, id],
719
- typeName: [import_diff.ValueOpType.Put, typeName]
720
- });
988
+ const res = patchDocument(docChanges, id, op[1]);
989
+ if (!res.ok) return;
721
990
  break;
722
991
  }
723
- }
724
- }
725
- if (message.diff && !session?.isReadonly) {
726
- for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(message.diff)) {
727
- switch (op[0]) {
728
- case import_diff.RecordOpType.Put: {
729
- if (!this.documentTypes.has(op[1].typeName)) {
730
- throw new import_TLSyncClient.TLSyncError(
731
- "invalid record",
732
- import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD
733
- );
734
- }
735
- addDocument(txn, docChanges, id, op[1]);
736
- break;
737
- }
738
- case import_diff.RecordOpType.Patch: {
739
- patchDocument(txn, docChanges, id, op[1]);
740
- break;
741
- }
742
- case import_diff.RecordOpType.Remove: {
743
- const doc = txn.get(id);
744
- if (!doc) {
745
- continue;
746
- }
747
- txn.delete(id);
748
- propagateOp(docChanges, id, op, doc, void 0);
749
- break;
992
+ case import_diff.RecordOpType.Remove: {
993
+ const doc = this.getDocument(id);
994
+ if (!doc) {
995
+ continue;
750
996
  }
997
+ this.removeDocument(id, this.clock);
998
+ propagateOp(docChanges, id, op);
999
+ break;
751
1000
  }
752
1001
  }
753
1002
  }
754
- return { docChanges, presenceChanges };
755
- },
756
- { id: this.internalTxnId, emitChanges: "when-different" }
757
- );
758
- this.lastDocumentClock = documentClock;
759
- let pushResult;
760
- if (changes && session) {
761
- result.docChanges.diffs = { networkDiff: (0, import_TLSyncStorage.toNetworkDiff)(changes) ?? {}, diff: changes };
762
- }
763
- if ((0, import_utils.isEqual)(result.docChanges.diffs?.networkDiff, message.diff)) {
764
- pushResult = {
765
- type: "push_result",
766
- clientClock: message.clientClock,
767
- serverClock: documentClock,
768
- action: "commit"
769
- };
770
- } else if (!result.docChanges.diffs?.networkDiff) {
771
- pushResult = {
772
- type: "push_result",
773
- clientClock: message.clientClock,
774
- serverClock: documentClock,
775
- action: "discard"
776
- };
777
- } else if (session) {
778
- const diff = this.migrateDiffOrRejectSession(
779
- session.sessionId,
780
- session.serializedSchema,
781
- session.requiresDownMigrations,
782
- result.docChanges.diffs.diff,
783
- result.docChanges.diffs.networkDiff
784
- );
785
- if (diff.ok) {
786
- pushResult = {
787
- type: "push_result",
788
- clientClock: message.clientClock,
789
- serverClock: documentClock,
790
- action: { rebaseWithDiff: diff.value }
791
- };
792
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 || (0, import_utils.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 === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.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?.();
793
1064
  }
794
- if (session && pushResult) {
795
- this._unsafe_sendMessage(session.sessionId, pushResult);
796
- }
797
- if (result.docChanges.diffs || result.presenceChanges.diffs) {
798
- this.broadcastPatch(
799
- {
800
- puts: {
801
- ...result.docChanges.diffs?.diff.puts,
802
- ...result.presenceChanges.diffs?.diff.puts
803
- },
804
- deletes: [
805
- ...result.docChanges.diffs?.diff.deletes ?? [],
806
- ...result.presenceChanges.diffs?.diff.deletes ?? []
807
- ]
808
- },
809
- {
810
- ...result.docChanges.diffs?.networkDiff,
811
- ...result.presenceChanges.diffs?.networkDiff
812
- },
813
- session?.sessionId
814
- );
815
- }
816
- if (result.presenceChanges.diffs) {
817
- queueMicrotask(() => {
818
- this.onPresenceChange?.();
819
- });
1065
+ if (didPresenceChange) {
1066
+ this.onPresenceChange?.();
820
1067
  }
821
1068
  }
822
1069
  /**
@@ -834,20 +1081,104 @@ class TLSyncRoom {
834
1081
  handleClose(sessionId) {
835
1082
  this.cancelSession(sessionId);
836
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
+ }
837
1123
  }
838
- class PresenceStore {
839
- presences = new import_store.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 && (0, import_utils.isEqual)(this.snapshot[record.id], record)) {
1135
+ delete this.updates.puts[record.id];
1136
+ } else {
1137
+ this.updates.puts[record.id] = (0, import_utils.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
+ }
840
1149
  get(id) {
841
- return this.presences.get(id);
1150
+ if (this._isClosed) throw new Error("StoreUpdateContext is closed");
1151
+ if ((0, import_utils.hasOwnProperty)(this.updates.puts, id)) {
1152
+ return (0, import_utils.structuredClone)(this.updates.puts[id]);
1153
+ }
1154
+ if (this.updates.deletes.has(id)) {
1155
+ return null;
1156
+ }
1157
+ return (0, import_utils.structuredClone)(this.snapshot[id] ?? null);
842
1158
  }
843
- set(id, state) {
844
- 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) && !(0, import_utils.hasOwnProperty)(this.updates.puts, id)) {
1164
+ result.push(record);
1165
+ }
1166
+ }
1167
+ return (0, import_utils.structuredClone)(result);
845
1168
  }
846
- delete(id) {
847
- this.presences.delete(id);
1169
+ toDiff() {
1170
+ const diff = {};
1171
+ for (const [id, record] of Object.entries(this.updates.puts)) {
1172
+ diff[id] = [import_diff.RecordOpType.Put, record];
1173
+ }
1174
+ for (const id of this.updates.deletes) {
1175
+ diff[id] = [import_diff.RecordOpType.Remove];
1176
+ }
1177
+ return diff;
848
1178
  }
849
- values() {
850
- return this.presences.values();
1179
+ _isClosed = false;
1180
+ close() {
1181
+ this._isClosed = true;
851
1182
  }
852
1183
  }
853
1184
  //# sourceMappingURL=TLSyncRoom.js.map