@tldraw/sync-core 4.3.0-canary.cf5673a789a1 → 4.3.0-canary.d428e9e9a7c6
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.
- package/dist-cjs/index.d.ts +239 -57
- package/dist-cjs/index.js +7 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +117 -69
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +7 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +357 -688
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js +76 -0
- package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
- package/dist-cjs/lib/recordDiff.js +52 -0
- package/dist-cjs/lib/recordDiff.js.map +7 -0
- package/dist-esm/index.d.mts +239 -57
- package/dist-esm/index.mjs +12 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +121 -70
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +7 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +370 -702
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs +56 -0
- package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
- package/dist-esm/lib/recordDiff.mjs +32 -0
- package/dist-esm/lib/recordDiff.mjs.map +7 -0
- package/package.json +6 -6
- package/src/index.ts +21 -3
- package/src/lib/InMemorySyncStorage.ts +357 -0
- package/src/lib/RoomSession.test.ts +1 -0
- package/src/lib/RoomSession.ts +2 -0
- package/src/lib/TLSocketRoom.ts +228 -114
- package/src/lib/TLSyncClient.ts +12 -0
- package/src/lib/TLSyncRoom.ts +473 -913
- package/src/lib/TLSyncStorage.ts +216 -0
- package/src/lib/recordDiff.ts +73 -0
- package/src/test/InMemorySyncStorage.test.ts +1674 -0
- package/src/test/TLSocketRoom.test.ts +255 -49
- package/src/test/TLSyncRoom.test.ts +1021 -533
- package/src/test/TestServer.ts +12 -1
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/upgradeDowngrade.test.ts +282 -8
- package/src/test/validation.test.ts +10 -10
- 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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
178
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
353
|
-
this.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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.
|
|
212
|
+
this.presenceStore.delete(session.presenceId);
|
|
466
213
|
this.broadcastPatch({
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
* @
|
|
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(
|
|
518
|
-
const
|
|
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
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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:
|
|
537
|
-
serverClock: this.
|
|
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.
|
|
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.
|
|
625
|
-
* to the client's version
|
|
626
|
-
*
|
|
627
|
-
*
|
|
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
|
-
|
|
630
|
-
if (
|
|
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,
|
|
635
|
-
if (
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
session.
|
|
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
|
-
|
|
690
|
-
|
|
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
|
|
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.
|
|
578
|
+
this._unsafe_sendMessage(session.sessionId, msg);
|
|
793
579
|
};
|
|
794
|
-
transaction((
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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.
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
709
|
+
addDocument(this.presenceStore, presenceChanges, id, {
|
|
710
|
+
...val,
|
|
711
|
+
id,
|
|
712
|
+
typeName
|
|
713
|
+
});
|
|
985
714
|
break;
|
|
986
715
|
}
|
|
987
716
|
case RecordOpType.Patch: {
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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 (
|
|
1066
|
-
this.
|
|
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
|
|
1125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1160
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
TLSyncRoom,
|
|
1189
|
-
TOMBSTONE_PRUNE_BUFFER_SIZE
|
|
856
|
+
PresenceStore,
|
|
857
|
+
TLSyncRoom
|
|
1190
858
|
};
|
|
1191
859
|
//# sourceMappingURL=TLSyncRoom.mjs.map
|