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