@tldraw/sync-core 4.3.0-canary.c7096a59bf3b → 4.3.0-canary.d039f3a1ab8f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist-cjs/index.d.ts +239 -57
  2. package/dist-cjs/index.js +7 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/TLSocketRoom.js +117 -69
  8. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncClient.js +7 -0
  10. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncRoom.js +357 -688
  12. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  13. package/dist-cjs/lib/TLSyncStorage.js +76 -0
  14. package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
  15. package/dist-cjs/lib/recordDiff.js +52 -0
  16. package/dist-cjs/lib/recordDiff.js.map +7 -0
  17. package/dist-esm/index.d.mts +239 -57
  18. package/dist-esm/index.mjs +12 -5
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/TLSocketRoom.mjs +121 -70
  24. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncClient.mjs +7 -0
  26. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncRoom.mjs +370 -702
  28. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  29. package/dist-esm/lib/TLSyncStorage.mjs +56 -0
  30. package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
  31. package/dist-esm/lib/recordDiff.mjs +32 -0
  32. package/dist-esm/lib/recordDiff.mjs.map +7 -0
  33. package/package.json +6 -6
  34. package/src/index.ts +21 -3
  35. package/src/lib/InMemorySyncStorage.ts +357 -0
  36. package/src/lib/RoomSession.test.ts +1 -0
  37. package/src/lib/RoomSession.ts +2 -0
  38. package/src/lib/TLSocketRoom.ts +228 -114
  39. package/src/lib/TLSyncClient.ts +12 -0
  40. package/src/lib/TLSyncRoom.ts +473 -913
  41. package/src/lib/TLSyncStorage.ts +216 -0
  42. package/src/lib/recordDiff.ts +73 -0
  43. package/src/test/InMemorySyncStorage.test.ts +1674 -0
  44. package/src/test/TLSocketRoom.test.ts +255 -49
  45. package/src/test/TLSyncRoom.test.ts +1021 -533
  46. package/src/test/TestServer.ts +12 -1
  47. package/src/test/customMessages.test.ts +1 -1
  48. package/src/test/presenceMode.test.ts +6 -6
  49. package/src/test/upgradeDowngrade.test.ts +282 -8
  50. package/src/test/validation.test.ts +10 -10
  51. package/src/test/pruneTombstones.test.ts +0 -178
@@ -19,108 +19,26 @@ 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
- DocumentState: () => DocumentState,
23
- MAX_TOMBSTONES: () => MAX_TOMBSTONES,
24
- TLSyncRoom: () => TLSyncRoom,
25
- TOMBSTONE_PRUNE_BUFFER_SIZE: () => TOMBSTONE_PRUNE_BUFFER_SIZE
22
+ PresenceStore: () => PresenceStore,
23
+ TLSyncRoom: () => TLSyncRoom
26
24
  });
27
25
  module.exports = __toCommonJS(TLSyncRoom_exports);
28
- var import_state = require("@tldraw/state");
29
26
  var import_store = require("@tldraw/store");
30
- var import_tlschema = require("@tldraw/tlschema");
31
27
  var import_utils = require("@tldraw/utils");
32
28
  var import_nanoevents = require("nanoevents");
33
- var import_RoomSession = require("./RoomSession");
34
- var import_TLSyncClient = require("./TLSyncClient");
35
29
  var import_diff = require("./diff");
36
- var import_findMin = require("./findMin");
37
30
  var import_interval = require("./interval");
38
31
  var import_protocol = require("./protocol");
39
- const MAX_TOMBSTONES = 3e3;
40
- const TOMBSTONE_PRUNE_BUFFER_SIZE = 300;
32
+ var import_recordDiff = require("./recordDiff");
33
+ var import_RoomSession = require("./RoomSession");
34
+ var import_TLSyncClient = require("./TLSyncClient");
35
+ var import_TLSyncStorage = require("./TLSyncStorage");
41
36
  const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1e3 / 60;
42
37
  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
- }
121
38
  class TLSyncRoom {
122
39
  // A table of connected clients
123
40
  sessions = /* @__PURE__ */ new Map();
41
+ lastDocumentClock = 0;
124
42
  // eslint-disable-next-line local/prefer-class-methods
125
43
  pruneSessions = () => {
126
44
  for (const client of this.sessions.values()) {
@@ -152,6 +70,7 @@ class TLSyncRoom {
152
70
  }
153
71
  }
154
72
  };
73
+ presenceStore = new PresenceStore();
155
74
  disposables = [(0, import_interval.interval)(this.pruneSessions, 2e3)];
156
75
  _isClosed = false;
157
76
  /**
@@ -174,17 +93,8 @@ class TLSyncRoom {
174
93
  return this._isClosed;
175
94
  }
176
95
  events = (0, import_nanoevents.createNanoEvents)();
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
96
+ // Storage layer for documents, tombstones, and clocks
97
+ storage;
188
98
  serializedSchema;
189
99
  documentTypes;
190
100
  presenceType;
@@ -192,10 +102,9 @@ class TLSyncRoom {
192
102
  schema;
193
103
  constructor(opts) {
194
104
  this.schema = opts.schema;
195
- let snapshot = opts.snapshot;
196
105
  this.log = opts.log;
197
- this.onDataChange = opts.onDataChange;
198
106
  this.onPresenceChange = opts.onPresenceChange;
107
+ this.storage = opts.storage;
199
108
  (0, import_utils.assert)(
200
109
  import_utils.isNativeStructuredClone,
201
110
  "TLSyncRoom is supposed to run either on Cloudflare Workersor on a 18+ version of Node.js, which both support the native structuredClone API"
@@ -213,194 +122,31 @@ class TLSyncRoom {
213
122
  );
214
123
  }
215
124
  this.presenceType = presenceTypes.values().next()?.value ?? null;
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
- }
261
- }
262
- }.call(this)
263
- );
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();
125
+ const { documentClock } = this.storage.transaction((txn) => {
126
+ this.schema.migrateStorage(txn);
311
127
  });
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))
128
+ this.lastDocumentClock = documentClock;
129
+ this.disposables.push(
130
+ this.storage.onChange(({ id }) => {
131
+ if (id !== this.internalTxnId) {
132
+ this.broadcastExternalStorageChanges();
133
+ }
134
+ })
347
135
  );
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
136
  }
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
- };
137
+ broadcastExternalStorageChanges() {
138
+ this.storage.transaction((txn) => {
139
+ this.broadcastChanges(txn);
140
+ this.lastDocumentClock = txn.getClock();
141
+ });
396
142
  }
397
143
  /**
398
144
  * Send a message to a particular client. Debounces data events
399
145
  *
400
146
  * @param sessionId - The id of the session to send the message to.
401
- * @param message - The message to send.
147
+ * @param message - The message to send. UNSAFE Any diffs must have been downgraded already if necessary
402
148
  */
403
- sendMessage(sessionId, message) {
149
+ _unsafe_sendMessage(sessionId, message) {
404
150
  const session = this.sessions.get(sessionId);
405
151
  if (!session) {
406
152
  this.log?.warn?.("Tried to send message to unknown session", message.type);
@@ -452,7 +198,6 @@ class TLSyncRoom {
452
198
  return;
453
199
  }
454
200
  this.sessions.delete(sessionId);
455
- const presence = this.getDocument(session.presenceId ?? "");
456
201
  try {
457
202
  if (fatalReason) {
458
203
  session.socket.close(import_TLSyncClient.TLSyncErrorCloseEventCode, fatalReason);
@@ -461,11 +206,12 @@ class TLSyncRoom {
461
206
  }
462
207
  } catch {
463
208
  }
209
+ const presence = this.presenceStore.get(session.presenceId ?? "");
464
210
  if (presence) {
465
- this.documents.delete(session.presenceId);
211
+ this.presenceStore.delete(session.presenceId);
466
212
  this.broadcastPatch({
467
- diff: { [session.presenceId]: [import_diff.RecordOpType.Remove] },
468
- sourceSessionId: sessionId
213
+ puts: {},
214
+ deletes: [session.presenceId]
469
215
  });
470
216
  }
471
217
  this.events.emit("session_removed", { sessionId, meta: session.meta });
@@ -498,24 +244,18 @@ class TLSyncRoom {
498
244
  } catch {
499
245
  }
500
246
  }
247
+ internalTxnId = "TLSyncRoom.txn";
501
248
  /**
502
249
  * Broadcast a patch to all connected clients except the one with the sessionId provided.
503
- * Automatically handles schema migration for clients on different versions.
504
250
  *
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
- * ```
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
516
255
  */
517
- broadcastPatch(message) {
518
- const { diff, sourceSessionId } = message;
256
+ broadcastPatch(diff, networkDiff, sourceSessionId) {
257
+ const unmigrated = networkDiff ?? (0, import_TLSyncStorage.toNetworkDiff)(diff);
258
+ if (!unmigrated) return this;
519
259
  this.sessions.forEach((session) => {
520
260
  if (session.state !== import_RoomSession.RoomSessionState.Connected) return;
521
261
  if (sourceSessionId === session.sessionId) return;
@@ -523,18 +263,17 @@ class TLSyncRoom {
523
263
  this.cancelSession(session.sessionId);
524
264
  return;
525
265
  }
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, {
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, {
535
274
  type: "patch",
536
- diff: res.value,
537
- serverClock: this.clock
275
+ diff: diffResult.value,
276
+ serverClock: this.lastDocumentClock
538
277
  });
539
278
  });
540
279
  return this;
@@ -562,7 +301,7 @@ class TLSyncRoom {
562
301
  * ```
563
302
  */
564
303
  sendCustomMessage(sessionId, data) {
565
- this.sendMessage(sessionId, { type: "custom", data });
304
+ this._unsafe_sendMessage(sessionId, { type: "custom", data });
566
305
  }
567
306
  /**
568
307
  * Register a new client session with the room. The session will be in an awaiting
@@ -621,34 +360,54 @@ class TLSyncRoom {
621
360
  }
622
361
  /**
623
362
  * When we send a diff to a client, if that client is on a lower version than us, we need to make
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.
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
628
377
  */
629
- migrateDiffForSession(serializedSchema, diff) {
630
- if (serializedSchema === this.serializedSchema) {
631
- return import_utils.Result.ok(diff);
378
+ migrateDiffOrRejectSession(sessionId, serializedSchema, requiresDownMigrations, diff, unmigrated) {
379
+ if (!requiresDownMigrations) {
380
+ return import_utils.Result.ok(unmigrated ?? (0, import_TLSyncStorage.toNetworkDiff)(diff) ?? {});
632
381
  }
633
382
  const result = {};
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;
638
- }
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);
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];
650
407
  }
651
- result[id] = [import_diff.RecordOpType.Put, migrationResult.value];
408
+ }
409
+ for (const id of diff.deletes) {
410
+ result[id] = [import_diff.RecordOpType.Remove];
652
411
  }
653
412
  return import_utils.Result.ok(result);
654
413
  }
@@ -673,21 +432,29 @@ class TLSyncRoom {
673
432
  this.log?.warn?.("Received message from unknown session");
674
433
  return;
675
434
  }
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();
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);
686
451
  }
687
- return this.sendMessage(session.sessionId, { type: "pong" });
688
452
  }
689
- default: {
690
- (0, import_utils.exhaustiveSwitchError)(message);
453
+ } catch (e) {
454
+ if (e instanceof import_TLSyncClient.TLSyncError) {
455
+ this.rejectSession(session.sessionId, e.reason);
456
+ } else {
457
+ throw e;
691
458
  }
692
459
  }
693
460
  }
@@ -744,6 +511,22 @@ class TLSyncRoom {
744
511
  this.removeSession(sessionId, fatalReason);
745
512
  }
746
513
  }
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
+ }
747
530
  handleConnectRequest(session, message) {
748
531
  let theirProtocolVersion = message.protocolVersion;
749
532
  if (theirProtocolVersion === 5) {
@@ -769,11 +552,12 @@ class TLSyncRoom {
769
552
  return;
770
553
  }
771
554
  const migrations = this.schema.getMigrationsSince(message.schema);
772
- if (!migrations.ok || migrations.value.some((m) => m.scope === "store" || !m.down)) {
555
+ if (!migrations.ok || migrations.value.some((m) => m.scope !== "record" || !m.down)) {
773
556
  this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
774
557
  return;
775
558
  }
776
559
  const sessionSchema = (0, import_utils.isEqual)(message.schema, this.serializedSchema) ? this.serializedSchema : message.schema;
560
+ const requiresDownMigrations = migrations.value.length > 0;
777
561
  const connect = async (msg) => {
778
562
  this.sessions.set(session.sessionId, {
779
563
  state: import_RoomSession.RoomSessionState.Connected,
@@ -781,6 +565,7 @@ class TLSyncRoom {
781
565
  presenceId: session.presenceId,
782
566
  socket: session.socket,
783
567
  serializedSchema: sessionSchema,
568
+ requiresDownMigrations,
784
569
  lastInteractionTime: Date.now(),
785
570
  debounceTimer: null,
786
571
  outstandingDataMessages: [],
@@ -789,76 +574,49 @@ class TLSyncRoom {
789
574
  isReadonly: session.isReadonly,
790
575
  requiresLegacyRejection: session.requiresLegacyRejection
791
576
  });
792
- this.sendMessage(session.sessionId, msg);
577
+ this._unsafe_sendMessage(session.sessionId, msg);
793
578
  };
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
- }
807
- }
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
- }
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: []
835
589
  }
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
- });
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);
860
604
  }
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
+ };
861
615
  });
616
+ this.lastDocumentClock = documentClock;
617
+ if (result) {
618
+ connect(result);
619
+ }
862
620
  }
863
621
  handlePushRequest(session, message) {
864
622
  if (session && session.state !== import_RoomSession.RoomSessionState.Connected) {
@@ -867,203 +625,198 @@ class TLSyncRoom {
867
625
  if (session) {
868
626
  session.lastInteractionTime = Date.now();
869
627
  }
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);
887
- }
888
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
889
- this.log?.error?.("failed to apply push", reason, message, underlyingError);
890
- }
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]);
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);
917
660
  }
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);
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]);
926
682
  }
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
- }
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);
950
688
  }
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
- }
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);
974
693
  }
975
694
  }
976
- if (message.diff && !session?.isReadonly) {
977
- for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(message.diff)) {
978
- switch (op[0]) {
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) {
979
707
  case import_diff.RecordOpType.Put: {
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;
708
+ addDocument(this.presenceStore, presenceChanges, id, {
709
+ ...val,
710
+ id,
711
+ typeName
712
+ });
985
713
  break;
986
714
  }
987
715
  case import_diff.RecordOpType.Patch: {
988
- const res = patchDocument(docChanges, id, op[1]);
989
- if (!res.ok) return;
990
- break;
991
- }
992
- case import_diff.RecordOpType.Remove: {
993
- const doc = this.getDocument(id);
994
- if (!doc) {
995
- continue;
996
- }
997
- this.removeDocument(id, this.clock);
998
- propagateOp(docChanges, id, op);
716
+ patchDocument(this.presenceStore, presenceChanges, id, {
717
+ ...val,
718
+ id: [import_diff.ValueOpType.Put, id],
719
+ typeName: [import_diff.ValueOpType.Put, typeName]
720
+ });
999
721
  break;
1000
722
  }
1001
723
  }
1002
724
  }
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
- );
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;
750
+ }
751
+ }
1036
752
  }
1037
- this.sendMessage(session.sessionId, {
1038
- type: "push_result",
1039
- serverClock: this.clock,
1040
- clientClock,
1041
- action: { rebaseWithDiff: migrateResult.value }
1042
- });
1043
753
  }
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
+ };
1044
792
  }
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?.();
1064
793
  }
1065
- if (didPresenceChange) {
1066
- this.onPresenceChange?.();
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
+ });
1067
820
  }
1068
821
  }
1069
822
  /**
@@ -1081,104 +834,20 @@ class TLSyncRoom {
1081
834
  handleClose(sessionId) {
1082
835
  this.cancelSession(sessionId);
1083
836
  }
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
- }
1123
837
  }
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
- }
838
+ class PresenceStore {
839
+ presences = new import_store.AtomMap("presences");
1149
840
  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);
841
+ return this.presences.get(id);
1158
842
  }
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);
843
+ set(id, state) {
844
+ this.presences.set(id, state);
1168
845
  }
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;
846
+ delete(id) {
847
+ this.presences.delete(id);
1178
848
  }
1179
- _isClosed = false;
1180
- close() {
1181
- this._isClosed = true;
849
+ values() {
850
+ return this.presences.values();
1182
851
  }
1183
852
  }
1184
853
  //# sourceMappingURL=TLSyncRoom.js.map