@tldraw/sync-core 4.2.2 → 4.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +58 -483
- package/dist-cjs/index.js +3 -13
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +69 -117
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +0 -7
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +688 -357
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/chunk.js +2 -2
- package/dist-cjs/lib/chunk.js.map +1 -1
- package/dist-esm/index.d.mts +58 -483
- package/dist-esm/index.mjs +5 -20
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +70 -121
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +0 -7
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +702 -370
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/chunk.mjs +2 -2
- package/dist-esm/lib/chunk.mjs.map +1 -1
- package/package.json +11 -12
- package/src/index.ts +3 -32
- package/src/lib/ClientWebSocketAdapter.test.ts +0 -3
- package/src/lib/RoomSession.test.ts +0 -1
- package/src/lib/RoomSession.ts +0 -2
- package/src/lib/TLSocketRoom.ts +114 -228
- package/src/lib/TLSyncClient.ts +0 -12
- package/src/lib/TLSyncRoom.ts +913 -473
- package/src/lib/chunk.ts +2 -2
- package/src/test/FuzzEditor.ts +5 -4
- package/src/test/TLSocketRoom.test.ts +49 -255
- package/src/test/TLSyncRoom.test.ts +534 -1024
- package/src/test/TestServer.ts +1 -12
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/pruneTombstones.test.ts +178 -0
- package/src/test/syncFuzz.test.ts +4 -2
- package/src/test/upgradeDowngrade.test.ts +8 -290
- package/src/test/validation.test.ts +10 -15
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +0 -55
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +0 -7
- package/dist-cjs/lib/InMemorySyncStorage.js +0 -287
- package/dist-cjs/lib/InMemorySyncStorage.js.map +0 -7
- package/dist-cjs/lib/MicrotaskNotifier.js +0 -50
- package/dist-cjs/lib/MicrotaskNotifier.js.map +0 -7
- package/dist-cjs/lib/NodeSqliteWrapper.js +0 -48
- package/dist-cjs/lib/NodeSqliteWrapper.js.map +0 -7
- package/dist-cjs/lib/SQLiteSyncStorage.js +0 -428
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +0 -7
- package/dist-cjs/lib/TLSyncStorage.js +0 -76
- package/dist-cjs/lib/TLSyncStorage.js.map +0 -7
- package/dist-cjs/lib/recordDiff.js +0 -52
- package/dist-cjs/lib/recordDiff.js.map +0 -7
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +0 -35
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +0 -7
- package/dist-esm/lib/InMemorySyncStorage.mjs +0 -272
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +0 -7
- package/dist-esm/lib/MicrotaskNotifier.mjs +0 -30
- package/dist-esm/lib/MicrotaskNotifier.mjs.map +0 -7
- package/dist-esm/lib/NodeSqliteWrapper.mjs +0 -28
- package/dist-esm/lib/NodeSqliteWrapper.mjs.map +0 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs +0 -414
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +0 -7
- package/dist-esm/lib/TLSyncStorage.mjs +0 -56
- package/dist-esm/lib/TLSyncStorage.mjs.map +0 -7
- package/dist-esm/lib/recordDiff.mjs +0 -32
- package/dist-esm/lib/recordDiff.mjs.map +0 -7
- package/src/lib/DurableObjectSqliteSyncWrapper.ts +0 -95
- package/src/lib/InMemorySyncStorage.ts +0 -387
- package/src/lib/MicrotaskNotifier.test.ts +0 -429
- package/src/lib/MicrotaskNotifier.ts +0 -38
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
- package/src/lib/NodeSqliteSyncWrapper.test.ts +0 -272
- package/src/lib/NodeSqliteWrapper.ts +0 -99
- package/src/lib/SQLiteSyncStorage.ts +0 -627
- package/src/lib/TLSyncStorage.ts +0 -216
- package/src/lib/computeTombstonePruning.test.ts +0 -352
- package/src/lib/recordDiff.ts +0 -73
- package/src/test/InMemorySyncStorage.test.ts +0 -1684
- package/src/test/SQLiteSyncStorage.test.ts +0 -1378
|
@@ -1,45 +1,126 @@
|
|
|
1
|
+
import { transact, transaction } from "@tldraw/state";
|
|
1
2
|
import {
|
|
2
|
-
AtomMap
|
|
3
|
+
AtomMap,
|
|
4
|
+
MigrationFailureReason
|
|
3
5
|
} from "@tldraw/store";
|
|
6
|
+
import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from "@tldraw/tlschema";
|
|
4
7
|
import {
|
|
8
|
+
Result,
|
|
5
9
|
assert,
|
|
6
10
|
assertExists,
|
|
7
11
|
exhaustiveSwitchError,
|
|
8
12
|
getOwnProperty,
|
|
13
|
+
hasOwnProperty,
|
|
9
14
|
isEqual,
|
|
10
15
|
isNativeStructuredClone,
|
|
11
16
|
objectMapEntriesIterable,
|
|
12
|
-
|
|
17
|
+
structuredClone
|
|
13
18
|
} from "@tldraw/utils";
|
|
14
19
|
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";
|
|
27
20
|
import {
|
|
28
21
|
RoomSessionState,
|
|
29
22
|
SESSION_IDLE_TIMEOUT,
|
|
30
23
|
SESSION_REMOVAL_WAIT_TIME,
|
|
31
24
|
SESSION_START_WAIT_TIME
|
|
32
25
|
} from "./RoomSession.mjs";
|
|
33
|
-
import {
|
|
26
|
+
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from "./TLSyncClient.mjs";
|
|
34
27
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
RecordOpType,
|
|
29
|
+
ValueOpType,
|
|
30
|
+
applyObjectDiff,
|
|
31
|
+
diffRecord
|
|
32
|
+
} from "./diff.mjs";
|
|
33
|
+
import { findMin } from "./findMin.mjs";
|
|
34
|
+
import { interval } from "./interval.mjs";
|
|
35
|
+
import {
|
|
36
|
+
TLIncompatibilityReason,
|
|
37
|
+
getTlsyncProtocolVersion
|
|
38
|
+
} from "./protocol.mjs";
|
|
39
|
+
const MAX_TOMBSTONES = 3e3;
|
|
40
|
+
const TOMBSTONE_PRUNE_BUFFER_SIZE = 300;
|
|
37
41
|
const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1e3 / 60;
|
|
38
42
|
const timeSince = (time) => Date.now() - time;
|
|
43
|
+
class DocumentState {
|
|
44
|
+
constructor(state, lastChangedClock, recordType) {
|
|
45
|
+
this.state = state;
|
|
46
|
+
this.lastChangedClock = lastChangedClock;
|
|
47
|
+
this.recordType = recordType;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Create a DocumentState instance without validating the record data.
|
|
51
|
+
* Used for performance when validation has already been performed.
|
|
52
|
+
*
|
|
53
|
+
* @param state - The record data
|
|
54
|
+
* @param lastChangedClock - Clock value when this record was last modified
|
|
55
|
+
* @param recordType - The record type definition for validation
|
|
56
|
+
* @returns A new DocumentState instance
|
|
57
|
+
*/
|
|
58
|
+
static createWithoutValidating(state, lastChangedClock, recordType) {
|
|
59
|
+
return new DocumentState(state, lastChangedClock, recordType);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Create a DocumentState instance with validation of the record data.
|
|
63
|
+
*
|
|
64
|
+
* @param state - The record data to validate
|
|
65
|
+
* @param lastChangedClock - Clock value when this record was last modified
|
|
66
|
+
* @param recordType - The record type definition for validation
|
|
67
|
+
* @returns Result containing the DocumentState or validation error
|
|
68
|
+
*/
|
|
69
|
+
static createAndValidate(state, lastChangedClock, recordType) {
|
|
70
|
+
try {
|
|
71
|
+
recordType.validate(state);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return 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
|
+
}
|
|
39
121
|
class TLSyncRoom {
|
|
40
122
|
// A table of connected clients
|
|
41
123
|
sessions = /* @__PURE__ */ new Map();
|
|
42
|
-
lastDocumentClock = 0;
|
|
43
124
|
// eslint-disable-next-line local/prefer-class-methods
|
|
44
125
|
pruneSessions = () => {
|
|
45
126
|
for (const client of this.sessions.values()) {
|
|
@@ -71,7 +152,6 @@ class TLSyncRoom {
|
|
|
71
152
|
}
|
|
72
153
|
}
|
|
73
154
|
};
|
|
74
|
-
presenceStore = new PresenceStore();
|
|
75
155
|
disposables = [interval(this.pruneSessions, 2e3)];
|
|
76
156
|
_isClosed = false;
|
|
77
157
|
/**
|
|
@@ -94,8 +174,17 @@ class TLSyncRoom {
|
|
|
94
174
|
return this._isClosed;
|
|
95
175
|
}
|
|
96
176
|
events = createNanoEvents();
|
|
97
|
-
//
|
|
98
|
-
|
|
177
|
+
// Values associated with each uid (must be serializable).
|
|
178
|
+
/** @internal */
|
|
179
|
+
documents;
|
|
180
|
+
tombstones;
|
|
181
|
+
// this clock should start higher than the client, to make sure that clients who sync with their
|
|
182
|
+
// initial lastServerClock value get the full state
|
|
183
|
+
// in this case clients will start with 0, and the server will start with 1
|
|
184
|
+
clock;
|
|
185
|
+
documentClock;
|
|
186
|
+
tombstoneHistoryStartsAtClock;
|
|
187
|
+
// map from record id to clock upon deletion
|
|
99
188
|
serializedSchema;
|
|
100
189
|
documentTypes;
|
|
101
190
|
presenceType;
|
|
@@ -103,9 +192,10 @@ class TLSyncRoom {
|
|
|
103
192
|
schema;
|
|
104
193
|
constructor(opts) {
|
|
105
194
|
this.schema = opts.schema;
|
|
195
|
+
let snapshot = opts.snapshot;
|
|
106
196
|
this.log = opts.log;
|
|
197
|
+
this.onDataChange = opts.onDataChange;
|
|
107
198
|
this.onPresenceChange = opts.onPresenceChange;
|
|
108
|
-
this.storage = opts.storage;
|
|
109
199
|
assert(
|
|
110
200
|
isNativeStructuredClone,
|
|
111
201
|
"TLSyncRoom is supposed to run either on Cloudflare Workersor on a 18+ version of Node.js, which both support the native structuredClone API"
|
|
@@ -123,31 +213,194 @@ class TLSyncRoom {
|
|
|
123
213
|
);
|
|
124
214
|
}
|
|
125
215
|
this.presenceType = presenceTypes.values().next()?.value ?? null;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
216
|
+
if (!snapshot) {
|
|
217
|
+
snapshot = {
|
|
218
|
+
clock: 0,
|
|
219
|
+
documentClock: 0,
|
|
220
|
+
documents: [
|
|
221
|
+
{
|
|
222
|
+
state: DocumentRecordType.create({ id: TLDOCUMENT_ID }),
|
|
223
|
+
lastChangedClock: 0
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
state: PageRecordType.create({ name: "Page 1", index: "a1" }),
|
|
227
|
+
lastChangedClock: 0
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
this.clock = snapshot.clock;
|
|
233
|
+
let didIncrementClock = false;
|
|
234
|
+
const ensureClockDidIncrement = (_reason) => {
|
|
235
|
+
if (!didIncrementClock) {
|
|
236
|
+
didIncrementClock = true;
|
|
237
|
+
this.clock++;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
this.tombstones = new AtomMap(
|
|
241
|
+
"room tombstones",
|
|
242
|
+
objectMapEntriesIterable(snapshot.tombstones ?? {})
|
|
243
|
+
);
|
|
244
|
+
this.documents = new AtomMap(
|
|
245
|
+
"room documents",
|
|
246
|
+
function* () {
|
|
247
|
+
for (const doc of snapshot.documents) {
|
|
248
|
+
if (this.documentTypes.has(doc.state.typeName)) {
|
|
249
|
+
yield [
|
|
250
|
+
doc.state.id,
|
|
251
|
+
DocumentState.createWithoutValidating(
|
|
252
|
+
doc.state,
|
|
253
|
+
doc.lastChangedClock,
|
|
254
|
+
assertExists(getOwnProperty(this.schema.types, doc.state.typeName))
|
|
255
|
+
)
|
|
256
|
+
];
|
|
257
|
+
} else {
|
|
258
|
+
ensureClockDidIncrement("doc type was not doc type");
|
|
259
|
+
this.tombstones.set(doc.state.id, this.clock);
|
|
260
|
+
}
|
|
134
261
|
}
|
|
135
|
-
})
|
|
262
|
+
}.call(this)
|
|
136
263
|
);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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();
|
|
142
311
|
});
|
|
312
|
+
if (didIncrementClock) {
|
|
313
|
+
this.documentClock = this.clock;
|
|
314
|
+
opts.onDataChange?.();
|
|
315
|
+
} else {
|
|
316
|
+
this.documentClock = getDocumentClock(snapshot);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
didSchedulePrune = true;
|
|
320
|
+
// eslint-disable-next-line local/prefer-class-methods
|
|
321
|
+
pruneTombstones = () => {
|
|
322
|
+
this.didSchedulePrune = false;
|
|
323
|
+
if (this.tombstones.size > MAX_TOMBSTONES) {
|
|
324
|
+
const entries = Array.from(this.tombstones.entries());
|
|
325
|
+
entries.sort((a, b) => a[1] - b[1]);
|
|
326
|
+
let idx = entries.length - 1 - MAX_TOMBSTONES + TOMBSTONE_PRUNE_BUFFER_SIZE;
|
|
327
|
+
const cullClock = entries[idx++][1];
|
|
328
|
+
while (idx < entries.length && entries[idx][1] === cullClock) {
|
|
329
|
+
idx++;
|
|
330
|
+
}
|
|
331
|
+
const keysToDelete = entries.slice(0, idx).map(([key]) => key);
|
|
332
|
+
this.tombstoneHistoryStartsAtClock = cullClock + 1;
|
|
333
|
+
this.tombstones.deleteMany(keysToDelete);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
getDocument(id) {
|
|
337
|
+
return this.documents.get(id);
|
|
338
|
+
}
|
|
339
|
+
addDocument(id, state, clock) {
|
|
340
|
+
if (this.tombstones.has(id)) {
|
|
341
|
+
this.tombstones.delete(id);
|
|
342
|
+
}
|
|
343
|
+
const createResult = DocumentState.createAndValidate(
|
|
344
|
+
state,
|
|
345
|
+
clock,
|
|
346
|
+
assertExists(getOwnProperty(this.schema.types, state.typeName))
|
|
347
|
+
);
|
|
348
|
+
if (!createResult.ok) return createResult;
|
|
349
|
+
this.documents.set(id, createResult.value);
|
|
350
|
+
return Result.ok(void 0);
|
|
351
|
+
}
|
|
352
|
+
removeDocument(id, clock) {
|
|
353
|
+
this.documents.delete(id);
|
|
354
|
+
this.tombstones.set(id, clock);
|
|
355
|
+
if (!this.didSchedulePrune) {
|
|
356
|
+
this.didSchedulePrune = true;
|
|
357
|
+
setTimeout(this.pruneTombstones, 0);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Get a complete snapshot of the current room state that can be persisted
|
|
362
|
+
* and later used to restore the room.
|
|
363
|
+
*
|
|
364
|
+
* @returns Room snapshot containing all documents, tombstones, and metadata
|
|
365
|
+
* @example
|
|
366
|
+
* ```ts
|
|
367
|
+
* const snapshot = room.getSnapshot()
|
|
368
|
+
* await database.saveRoomSnapshot(roomId, snapshot)
|
|
369
|
+
*
|
|
370
|
+
* // Later, restore from snapshot
|
|
371
|
+
* const restoredRoom = new TLSyncRoom({
|
|
372
|
+
* schema: mySchema,
|
|
373
|
+
* snapshot: snapshot
|
|
374
|
+
* })
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
getSnapshot() {
|
|
378
|
+
const tombstones = Object.fromEntries(this.tombstones.entries());
|
|
379
|
+
const documents = [];
|
|
380
|
+
for (const doc of this.documents.values()) {
|
|
381
|
+
if (this.documentTypes.has(doc.state.typeName)) {
|
|
382
|
+
documents.push({
|
|
383
|
+
state: doc.state,
|
|
384
|
+
lastChangedClock: doc.lastChangedClock
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
clock: this.clock,
|
|
390
|
+
documentClock: this.documentClock,
|
|
391
|
+
tombstones,
|
|
392
|
+
tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock,
|
|
393
|
+
schema: this.serializedSchema,
|
|
394
|
+
documents
|
|
395
|
+
};
|
|
143
396
|
}
|
|
144
397
|
/**
|
|
145
398
|
* Send a message to a particular client. Debounces data events
|
|
146
399
|
*
|
|
147
400
|
* @param sessionId - The id of the session to send the message to.
|
|
148
|
-
* @param message - The message to send.
|
|
401
|
+
* @param message - The message to send.
|
|
149
402
|
*/
|
|
150
|
-
|
|
403
|
+
sendMessage(sessionId, message) {
|
|
151
404
|
const session = this.sessions.get(sessionId);
|
|
152
405
|
if (!session) {
|
|
153
406
|
this.log?.warn?.("Tried to send message to unknown session", message.type);
|
|
@@ -199,6 +452,7 @@ class TLSyncRoom {
|
|
|
199
452
|
return;
|
|
200
453
|
}
|
|
201
454
|
this.sessions.delete(sessionId);
|
|
455
|
+
const presence = this.getDocument(session.presenceId ?? "");
|
|
202
456
|
try {
|
|
203
457
|
if (fatalReason) {
|
|
204
458
|
session.socket.close(TLSyncErrorCloseEventCode, fatalReason);
|
|
@@ -207,12 +461,11 @@ class TLSyncRoom {
|
|
|
207
461
|
}
|
|
208
462
|
} catch {
|
|
209
463
|
}
|
|
210
|
-
const presence = this.presenceStore.get(session.presenceId ?? "");
|
|
211
464
|
if (presence) {
|
|
212
|
-
this.
|
|
465
|
+
this.documents.delete(session.presenceId);
|
|
213
466
|
this.broadcastPatch({
|
|
214
|
-
|
|
215
|
-
|
|
467
|
+
diff: { [session.presenceId]: [RecordOpType.Remove] },
|
|
468
|
+
sourceSessionId: sessionId
|
|
216
469
|
});
|
|
217
470
|
}
|
|
218
471
|
this.events.emit("session_removed", { sessionId, meta: session.meta });
|
|
@@ -245,18 +498,24 @@ class TLSyncRoom {
|
|
|
245
498
|
} catch {
|
|
246
499
|
}
|
|
247
500
|
}
|
|
248
|
-
internalTxnId = "TLSyncRoom.txn";
|
|
249
501
|
/**
|
|
250
502
|
* Broadcast a patch to all connected clients except the one with the sessionId provided.
|
|
503
|
+
* Automatically handles schema migration for clients on different versions.
|
|
251
504
|
*
|
|
252
|
-
* @param
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
* @
|
|
505
|
+
* @param message - The broadcast message
|
|
506
|
+
* - diff - The network diff to broadcast to all clients
|
|
507
|
+
* - sourceSessionId - Optional ID of the session that originated this change (excluded from broadcast)
|
|
508
|
+
* @returns This room instance for method chaining
|
|
509
|
+
* @example
|
|
510
|
+
* ```ts
|
|
511
|
+
* room.broadcastPatch({
|
|
512
|
+
* diff: { 'shape:123': [RecordOpType.Put, newShapeData] },
|
|
513
|
+
* sourceSessionId: 'user-456' // This user won't receive the broadcast
|
|
514
|
+
* })
|
|
515
|
+
* ```
|
|
256
516
|
*/
|
|
257
|
-
broadcastPatch(
|
|
258
|
-
const
|
|
259
|
-
if (!unmigrated) return this;
|
|
517
|
+
broadcastPatch(message) {
|
|
518
|
+
const { diff, sourceSessionId } = message;
|
|
260
519
|
this.sessions.forEach((session) => {
|
|
261
520
|
if (session.state !== RoomSessionState.Connected) return;
|
|
262
521
|
if (sourceSessionId === session.sessionId) return;
|
|
@@ -264,17 +523,18 @@ class TLSyncRoom {
|
|
|
264
523
|
this.cancelSession(session.sessionId);
|
|
265
524
|
return;
|
|
266
525
|
}
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
526
|
+
const res = this.migrateDiffForSession(session.serializedSchema, diff);
|
|
527
|
+
if (!res.ok) {
|
|
528
|
+
this.rejectSession(
|
|
529
|
+
session.sessionId,
|
|
530
|
+
res.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
531
|
+
);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.sendMessage(session.sessionId, {
|
|
275
535
|
type: "patch",
|
|
276
|
-
diff:
|
|
277
|
-
serverClock: this.
|
|
536
|
+
diff: res.value,
|
|
537
|
+
serverClock: this.clock
|
|
278
538
|
});
|
|
279
539
|
});
|
|
280
540
|
return this;
|
|
@@ -302,7 +562,7 @@ class TLSyncRoom {
|
|
|
302
562
|
* ```
|
|
303
563
|
*/
|
|
304
564
|
sendCustomMessage(sessionId, data) {
|
|
305
|
-
this.
|
|
565
|
+
this.sendMessage(sessionId, { type: "custom", data });
|
|
306
566
|
}
|
|
307
567
|
/**
|
|
308
568
|
* Register a new client session with the room. The session will be in an awaiting
|
|
@@ -361,54 +621,34 @@ class TLSyncRoom {
|
|
|
361
621
|
}
|
|
362
622
|
/**
|
|
363
623
|
* When we send a diff to a client, if that client is on a lower version than us, we need to make
|
|
364
|
-
* the diff compatible with their version.
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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
|
|
624
|
+
* the diff compatible with their version. At the moment this means migrating each affected record
|
|
625
|
+
* to the client's version and sending the whole record again. We can optimize this later by
|
|
626
|
+
* keeping the previous versions of records around long enough to recalculate these diffs for
|
|
627
|
+
* older client versions.
|
|
378
628
|
*/
|
|
379
|
-
|
|
380
|
-
if (
|
|
381
|
-
return Result.ok(
|
|
629
|
+
migrateDiffForSession(serializedSchema, diff) {
|
|
630
|
+
if (serializedSchema === this.serializedSchema) {
|
|
631
|
+
return Result.ok(diff);
|
|
382
632
|
}
|
|
383
633
|
const result = {};
|
|
384
|
-
for (const [id,
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
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];
|
|
634
|
+
for (const [id, op] of objectMapEntriesIterable(diff)) {
|
|
635
|
+
if (op[0] === RecordOpType.Remove) {
|
|
636
|
+
result[id] = op;
|
|
637
|
+
continue;
|
|
408
638
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
639
|
+
const doc = this.getDocument(id);
|
|
640
|
+
if (!doc) {
|
|
641
|
+
return Result.err(MigrationFailureReason.TargetVersionTooNew);
|
|
642
|
+
}
|
|
643
|
+
const migrationResult = this.schema.migratePersistedRecord(
|
|
644
|
+
doc.state,
|
|
645
|
+
serializedSchema,
|
|
646
|
+
"down"
|
|
647
|
+
);
|
|
648
|
+
if (migrationResult.type === "error") {
|
|
649
|
+
return Result.err(migrationResult.reason);
|
|
650
|
+
}
|
|
651
|
+
result[id] = [RecordOpType.Put, migrationResult.value];
|
|
412
652
|
}
|
|
413
653
|
return Result.ok(result);
|
|
414
654
|
}
|
|
@@ -433,29 +673,21 @@ class TLSyncRoom {
|
|
|
433
673
|
this.log?.warn?.("Received message from unknown session");
|
|
434
674
|
return;
|
|
435
675
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
session.lastInteractionTime = Date.now();
|
|
447
|
-
}
|
|
448
|
-
return this._unsafe_sendMessage(session.sessionId, { type: "pong" });
|
|
449
|
-
}
|
|
450
|
-
default: {
|
|
451
|
-
exhaustiveSwitchError(message);
|
|
676
|
+
switch (message.type) {
|
|
677
|
+
case "connect": {
|
|
678
|
+
return this.handleConnectRequest(session, message);
|
|
679
|
+
}
|
|
680
|
+
case "push": {
|
|
681
|
+
return this.handlePushRequest(session, message);
|
|
682
|
+
}
|
|
683
|
+
case "ping": {
|
|
684
|
+
if (session.state === RoomSessionState.Connected) {
|
|
685
|
+
session.lastInteractionTime = Date.now();
|
|
452
686
|
}
|
|
687
|
+
return this.sendMessage(session.sessionId, { type: "pong" });
|
|
453
688
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
this.rejectSession(session.sessionId, e.reason);
|
|
457
|
-
} else {
|
|
458
|
-
throw e;
|
|
689
|
+
default: {
|
|
690
|
+
exhaustiveSwitchError(message);
|
|
459
691
|
}
|
|
460
692
|
}
|
|
461
693
|
}
|
|
@@ -512,22 +744,6 @@ class TLSyncRoom {
|
|
|
512
744
|
this.removeSession(sessionId, fatalReason);
|
|
513
745
|
}
|
|
514
746
|
}
|
|
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
|
-
}
|
|
531
747
|
handleConnectRequest(session, message) {
|
|
532
748
|
let theirProtocolVersion = message.protocolVersion;
|
|
533
749
|
if (theirProtocolVersion === 5) {
|
|
@@ -553,12 +769,11 @@ class TLSyncRoom {
|
|
|
553
769
|
return;
|
|
554
770
|
}
|
|
555
771
|
const migrations = this.schema.getMigrationsSince(message.schema);
|
|
556
|
-
if (!migrations.ok || migrations.value.some((m) => m.scope
|
|
772
|
+
if (!migrations.ok || migrations.value.some((m) => m.scope === "store" || !m.down)) {
|
|
557
773
|
this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
558
774
|
return;
|
|
559
775
|
}
|
|
560
776
|
const sessionSchema = isEqual(message.schema, this.serializedSchema) ? this.serializedSchema : message.schema;
|
|
561
|
-
const requiresDownMigrations = migrations.value.length > 0;
|
|
562
777
|
const connect = async (msg) => {
|
|
563
778
|
this.sessions.set(session.sessionId, {
|
|
564
779
|
state: RoomSessionState.Connected,
|
|
@@ -566,7 +781,6 @@ class TLSyncRoom {
|
|
|
566
781
|
presenceId: session.presenceId,
|
|
567
782
|
socket: session.socket,
|
|
568
783
|
serializedSchema: sessionSchema,
|
|
569
|
-
requiresDownMigrations,
|
|
570
784
|
lastInteractionTime: Date.now(),
|
|
571
785
|
debounceTimer: null,
|
|
572
786
|
outstandingDataMessages: [],
|
|
@@ -575,49 +789,76 @@ class TLSyncRoom {
|
|
|
575
789
|
isReadonly: session.isReadonly,
|
|
576
790
|
requiresLegacyRejection: session.requiresLegacyRejection
|
|
577
791
|
});
|
|
578
|
-
this.
|
|
792
|
+
this.sendMessage(session.sessionId, msg);
|
|
579
793
|
};
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
794
|
+
transaction((rollback) => {
|
|
795
|
+
if (
|
|
796
|
+
// if the client requests changes since a time before we have tombstone history, send them the full state
|
|
797
|
+
message.lastServerClock < this.tombstoneHistoryStartsAtClock || // similarly, if they ask for a time we haven't reached yet, send them the full state
|
|
798
|
+
// this will only happen if the DB is reset (or there is no db) and the server restarts
|
|
799
|
+
// or if the server exits/crashes with unpersisted changes
|
|
800
|
+
message.lastServerClock > this.clock
|
|
801
|
+
) {
|
|
802
|
+
const diff = {};
|
|
803
|
+
for (const [id, doc] of this.documents.entries()) {
|
|
804
|
+
if (id !== session.presenceId) {
|
|
805
|
+
diff[id] = [RecordOpType.Put, doc.state];
|
|
806
|
+
}
|
|
590
807
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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;
|
|
849
|
+
}
|
|
850
|
+
connect({
|
|
851
|
+
type: "connect",
|
|
852
|
+
connectRequestId: message.connectRequestId,
|
|
853
|
+
hydrationType: "wipe_presence",
|
|
854
|
+
schema: this.schema.serialize(),
|
|
855
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
856
|
+
serverClock: this.clock,
|
|
857
|
+
diff: migrated.value,
|
|
858
|
+
isReadonly: session.isReadonly
|
|
859
|
+
});
|
|
605
860
|
}
|
|
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
|
-
};
|
|
616
861
|
});
|
|
617
|
-
this.lastDocumentClock = documentClock;
|
|
618
|
-
if (result) {
|
|
619
|
-
connect(result);
|
|
620
|
-
}
|
|
621
862
|
}
|
|
622
863
|
handlePushRequest(session, message) {
|
|
623
864
|
if (session && session.state !== RoomSessionState.Connected) {
|
|
@@ -626,198 +867,203 @@ class TLSyncRoom {
|
|
|
626
867
|
if (session) {
|
|
627
868
|
session.lastInteractionTime = Date.now();
|
|
628
869
|
}
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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);
|
|
870
|
+
this.clock++;
|
|
871
|
+
const initialDocumentClock = this.documentClock;
|
|
872
|
+
let didPresenceChange = false;
|
|
873
|
+
transaction((rollback) => {
|
|
874
|
+
const legacyAppendMode = !this.getCanEmitStringAppend();
|
|
875
|
+
const docChanges = { diff: null };
|
|
876
|
+
const presenceChanges = { diff: null };
|
|
877
|
+
const propagateOp = (changes, id, op) => {
|
|
878
|
+
if (!changes.diff) changes.diff = {};
|
|
879
|
+
changes.diff[id] = op;
|
|
880
|
+
};
|
|
881
|
+
const fail = (reason, underlyingError) => {
|
|
882
|
+
rollback();
|
|
883
|
+
if (session) {
|
|
884
|
+
this.rejectSession(session.sessionId, reason);
|
|
885
|
+
} else {
|
|
886
|
+
throw new Error("failed to apply changes: " + reason, underlyingError);
|
|
661
887
|
}
|
|
662
|
-
|
|
663
|
-
|
|
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]);
|
|
888
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
889
|
+
this.log?.error?.("failed to apply push", reason, message, underlyingError);
|
|
683
890
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
891
|
+
return Result.err(void 0);
|
|
892
|
+
};
|
|
893
|
+
const addDocument = (changes, id, _state) => {
|
|
894
|
+
const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, "up") : { type: "success", value: _state };
|
|
895
|
+
if (res.type === "error") {
|
|
896
|
+
return fail(
|
|
897
|
+
res.reason === MigrationFailureReason.TargetVersionTooOld ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
898
|
+
);
|
|
689
899
|
}
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
900
|
+
const { value: state } = res;
|
|
901
|
+
const doc = this.getDocument(id);
|
|
902
|
+
if (doc) {
|
|
903
|
+
const diff = doc.replaceState(state, this.clock, legacyAppendMode);
|
|
904
|
+
if (!diff.ok) {
|
|
905
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
906
|
+
}
|
|
907
|
+
if (diff.value) {
|
|
908
|
+
this.documents.set(id, diff.value[1]);
|
|
909
|
+
propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]]);
|
|
910
|
+
}
|
|
911
|
+
} else {
|
|
912
|
+
const result = this.addDocument(id, state, this.clock);
|
|
913
|
+
if (!result.ok) {
|
|
914
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
915
|
+
}
|
|
916
|
+
propagateOp(changes, id, [RecordOpType.Put, state]);
|
|
917
|
+
}
|
|
918
|
+
return Result.ok(void 0);
|
|
919
|
+
};
|
|
920
|
+
const patchDocument = (changes, id, patch) => {
|
|
921
|
+
const doc = this.getDocument(id);
|
|
922
|
+
if (!doc) return Result.ok(void 0);
|
|
923
|
+
const downgraded = session ? this.schema.migratePersistedRecord(doc.state, session.serializedSchema, "down") : { type: "success", value: doc.state };
|
|
924
|
+
if (downgraded.type === "error") {
|
|
925
|
+
return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
926
|
+
}
|
|
927
|
+
if (downgraded.value === doc.state) {
|
|
928
|
+
const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode);
|
|
929
|
+
if (!diff.ok) {
|
|
930
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
931
|
+
}
|
|
932
|
+
if (diff.value) {
|
|
933
|
+
this.documents.set(id, diff.value[1]);
|
|
934
|
+
propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]]);
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
const patched = applyObjectDiff(downgraded.value, patch);
|
|
938
|
+
const upgraded = session ? this.schema.migratePersistedRecord(patched, session.serializedSchema, "up") : { type: "success", value: patched };
|
|
939
|
+
if (upgraded.type === "error") {
|
|
940
|
+
return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
941
|
+
}
|
|
942
|
+
const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode);
|
|
943
|
+
if (!diff.ok) {
|
|
944
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
945
|
+
}
|
|
946
|
+
if (diff.value) {
|
|
947
|
+
this.documents.set(id, diff.value[1]);
|
|
948
|
+
propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]]);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return Result.ok(void 0);
|
|
952
|
+
};
|
|
953
|
+
const { clientClock } = message;
|
|
954
|
+
if (this.presenceType && session?.presenceId && "presence" in message && message.presence) {
|
|
955
|
+
if (!session) throw new Error("session is required for presence pushes");
|
|
956
|
+
const id = session.presenceId;
|
|
957
|
+
const [type, val] = message.presence;
|
|
958
|
+
const { typeName } = this.presenceType;
|
|
959
|
+
switch (type) {
|
|
960
|
+
case RecordOpType.Put: {
|
|
961
|
+
const res = addDocument(presenceChanges, id, { ...val, id, typeName });
|
|
962
|
+
if (!res.ok) return;
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
case RecordOpType.Patch: {
|
|
966
|
+
const res = patchDocument(presenceChanges, id, {
|
|
967
|
+
...val,
|
|
968
|
+
id: [ValueOpType.Put, id],
|
|
969
|
+
typeName: [ValueOpType.Put, typeName]
|
|
970
|
+
});
|
|
971
|
+
if (!res.ok) return;
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
694
974
|
}
|
|
695
975
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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) {
|
|
976
|
+
if (message.diff && !session?.isReadonly) {
|
|
977
|
+
for (const [id, op] of objectMapEntriesIterable(message.diff)) {
|
|
978
|
+
switch (op[0]) {
|
|
708
979
|
case RecordOpType.Put: {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
980
|
+
if (!this.documentTypes.has(op[1].typeName)) {
|
|
981
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
982
|
+
}
|
|
983
|
+
const res = addDocument(docChanges, id, op[1]);
|
|
984
|
+
if (!res.ok) return;
|
|
714
985
|
break;
|
|
715
986
|
}
|
|
716
987
|
case RecordOpType.Patch: {
|
|
717
|
-
patchDocument(
|
|
718
|
-
|
|
719
|
-
id: [ValueOpType.Put, id],
|
|
720
|
-
typeName: [ValueOpType.Put, typeName]
|
|
721
|
-
});
|
|
988
|
+
const res = patchDocument(docChanges, id, op[1]);
|
|
989
|
+
if (!res.ok) return;
|
|
722
990
|
break;
|
|
723
991
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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;
|
|
992
|
+
case RecordOpType.Remove: {
|
|
993
|
+
const doc = this.getDocument(id);
|
|
994
|
+
if (!doc) {
|
|
995
|
+
continue;
|
|
751
996
|
}
|
|
997
|
+
this.removeDocument(id, this.clock);
|
|
998
|
+
propagateOp(docChanges, id, op);
|
|
999
|
+
break;
|
|
752
1000
|
}
|
|
753
1001
|
}
|
|
754
1002
|
}
|
|
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
|
-
};
|
|
793
1003
|
}
|
|
1004
|
+
if (
|
|
1005
|
+
// if there was only a presence push, the client doesn't need to do anything aside from
|
|
1006
|
+
// shift the push request.
|
|
1007
|
+
!message.diff || isEqual(docChanges.diff, message.diff)
|
|
1008
|
+
) {
|
|
1009
|
+
if (session) {
|
|
1010
|
+
this.sendMessage(session.sessionId, {
|
|
1011
|
+
type: "push_result",
|
|
1012
|
+
serverClock: this.clock,
|
|
1013
|
+
clientClock,
|
|
1014
|
+
action: "commit"
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
} else if (!docChanges.diff) {
|
|
1018
|
+
if (session) {
|
|
1019
|
+
this.sendMessage(session.sessionId, {
|
|
1020
|
+
type: "push_result",
|
|
1021
|
+
serverClock: this.clock,
|
|
1022
|
+
clientClock,
|
|
1023
|
+
action: "discard"
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
if (session) {
|
|
1028
|
+
const migrateResult = this.migrateDiffForSession(
|
|
1029
|
+
session.serializedSchema,
|
|
1030
|
+
docChanges.diff
|
|
1031
|
+
);
|
|
1032
|
+
if (!migrateResult.ok) {
|
|
1033
|
+
return fail(
|
|
1034
|
+
migrateResult.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
this.sendMessage(session.sessionId, {
|
|
1038
|
+
type: "push_result",
|
|
1039
|
+
serverClock: this.clock,
|
|
1040
|
+
clientClock,
|
|
1041
|
+
action: { rebaseWithDiff: migrateResult.value }
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (docChanges.diff || presenceChanges.diff) {
|
|
1046
|
+
this.broadcastPatch({
|
|
1047
|
+
sourceSessionId: session?.sessionId,
|
|
1048
|
+
diff: {
|
|
1049
|
+
...docChanges.diff,
|
|
1050
|
+
...presenceChanges.diff
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
if (docChanges.diff) {
|
|
1055
|
+
this.documentClock = this.clock;
|
|
1056
|
+
}
|
|
1057
|
+
if (presenceChanges.diff) {
|
|
1058
|
+
didPresenceChange = true;
|
|
1059
|
+
}
|
|
1060
|
+
return;
|
|
1061
|
+
});
|
|
1062
|
+
if (this.documentClock !== initialDocumentClock) {
|
|
1063
|
+
this.onDataChange?.();
|
|
794
1064
|
}
|
|
795
|
-
if (
|
|
796
|
-
this.
|
|
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
|
-
});
|
|
1065
|
+
if (didPresenceChange) {
|
|
1066
|
+
this.onPresenceChange?.();
|
|
821
1067
|
}
|
|
822
1068
|
}
|
|
823
1069
|
/**
|
|
@@ -835,25 +1081,111 @@ class TLSyncRoom {
|
|
|
835
1081
|
handleClose(sessionId) {
|
|
836
1082
|
this.cancelSession(sessionId);
|
|
837
1083
|
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Apply changes to the room's store in a transactional way. Changes are
|
|
1086
|
+
* automatically synchronized to all connected clients.
|
|
1087
|
+
*
|
|
1088
|
+
* @param updater - Function that receives store methods to make changes
|
|
1089
|
+
* @returns Promise that resolves when the transaction is complete
|
|
1090
|
+
* @example
|
|
1091
|
+
* ```ts
|
|
1092
|
+
* // Add multiple shapes atomically
|
|
1093
|
+
* await room.updateStore((store) => {
|
|
1094
|
+
* store.put(createShape({ type: 'geo', x: 100, y: 100 }))
|
|
1095
|
+
* store.put(createShape({ type: 'text', x: 200, y: 200 }))
|
|
1096
|
+
* })
|
|
1097
|
+
*
|
|
1098
|
+
* // Async operations are supported
|
|
1099
|
+
* await room.updateStore(async (store) => {
|
|
1100
|
+
* const template = await loadTemplate()
|
|
1101
|
+
* template.shapes.forEach(shape => store.put(shape))
|
|
1102
|
+
* })
|
|
1103
|
+
* ```
|
|
1104
|
+
*/
|
|
1105
|
+
async updateStore(updater) {
|
|
1106
|
+
if (this._isClosed) {
|
|
1107
|
+
throw new Error("Cannot update store on a closed room");
|
|
1108
|
+
}
|
|
1109
|
+
const context = new StoreUpdateContext(
|
|
1110
|
+
Object.fromEntries(this.getSnapshot().documents.map((d) => [d.state.id, d.state]))
|
|
1111
|
+
);
|
|
1112
|
+
try {
|
|
1113
|
+
await updater(context);
|
|
1114
|
+
} finally {
|
|
1115
|
+
context.close();
|
|
1116
|
+
}
|
|
1117
|
+
const diff = context.toDiff();
|
|
1118
|
+
if (Object.keys(diff).length === 0) {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
this.handlePushRequest(null, { type: "push", diff, clientClock: 0 });
|
|
1122
|
+
}
|
|
838
1123
|
}
|
|
839
|
-
class
|
|
840
|
-
|
|
1124
|
+
class StoreUpdateContext {
|
|
1125
|
+
constructor(snapshot) {
|
|
1126
|
+
this.snapshot = snapshot;
|
|
1127
|
+
}
|
|
1128
|
+
updates = {
|
|
1129
|
+
puts: {},
|
|
1130
|
+
deletes: /* @__PURE__ */ new Set()
|
|
1131
|
+
};
|
|
1132
|
+
put(record) {
|
|
1133
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
1134
|
+
if (record.id in this.snapshot && isEqual(this.snapshot[record.id], record)) {
|
|
1135
|
+
delete this.updates.puts[record.id];
|
|
1136
|
+
} else {
|
|
1137
|
+
this.updates.puts[record.id] = structuredClone(record);
|
|
1138
|
+
}
|
|
1139
|
+
this.updates.deletes.delete(record.id);
|
|
1140
|
+
}
|
|
1141
|
+
delete(recordOrId) {
|
|
1142
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
1143
|
+
const id = typeof recordOrId === "string" ? recordOrId : recordOrId.id;
|
|
1144
|
+
delete this.updates.puts[id];
|
|
1145
|
+
if (this.snapshot[id]) {
|
|
1146
|
+
this.updates.deletes.add(id);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
841
1149
|
get(id) {
|
|
842
|
-
|
|
1150
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
1151
|
+
if (hasOwnProperty(this.updates.puts, id)) {
|
|
1152
|
+
return structuredClone(this.updates.puts[id]);
|
|
1153
|
+
}
|
|
1154
|
+
if (this.updates.deletes.has(id)) {
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
return structuredClone(this.snapshot[id] ?? null);
|
|
843
1158
|
}
|
|
844
|
-
|
|
845
|
-
this.
|
|
1159
|
+
getAll() {
|
|
1160
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
1161
|
+
const result = Object.values(this.updates.puts);
|
|
1162
|
+
for (const [id, record] of Object.entries(this.snapshot)) {
|
|
1163
|
+
if (!this.updates.deletes.has(id) && !hasOwnProperty(this.updates.puts, id)) {
|
|
1164
|
+
result.push(record);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return structuredClone(result);
|
|
846
1168
|
}
|
|
847
|
-
|
|
848
|
-
|
|
1169
|
+
toDiff() {
|
|
1170
|
+
const diff = {};
|
|
1171
|
+
for (const [id, record] of Object.entries(this.updates.puts)) {
|
|
1172
|
+
diff[id] = [RecordOpType.Put, record];
|
|
1173
|
+
}
|
|
1174
|
+
for (const id of this.updates.deletes) {
|
|
1175
|
+
diff[id] = [RecordOpType.Remove];
|
|
1176
|
+
}
|
|
1177
|
+
return diff;
|
|
849
1178
|
}
|
|
850
|
-
|
|
851
|
-
|
|
1179
|
+
_isClosed = false;
|
|
1180
|
+
close() {
|
|
1181
|
+
this._isClosed = true;
|
|
852
1182
|
}
|
|
853
1183
|
}
|
|
854
1184
|
export {
|
|
855
1185
|
DATA_MESSAGE_DEBOUNCE_INTERVAL,
|
|
856
|
-
|
|
857
|
-
|
|
1186
|
+
DocumentState,
|
|
1187
|
+
MAX_TOMBSTONES,
|
|
1188
|
+
TLSyncRoom,
|
|
1189
|
+
TOMBSTONE_PRUNE_BUFFER_SIZE
|
|
858
1190
|
};
|
|
859
1191
|
//# sourceMappingURL=TLSyncRoom.mjs.map
|