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