@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
|
@@ -19,26 +19,108 @@ 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
|
-
|
|
22
|
+
DocumentState: () => DocumentState,
|
|
23
|
+
MAX_TOMBSTONES: () => MAX_TOMBSTONES,
|
|
24
|
+
TLSyncRoom: () => TLSyncRoom,
|
|
25
|
+
TOMBSTONE_PRUNE_BUFFER_SIZE: () => TOMBSTONE_PRUNE_BUFFER_SIZE
|
|
24
26
|
});
|
|
25
27
|
module.exports = __toCommonJS(TLSyncRoom_exports);
|
|
28
|
+
var import_state = require("@tldraw/state");
|
|
26
29
|
var import_store = require("@tldraw/store");
|
|
30
|
+
var import_tlschema = require("@tldraw/tlschema");
|
|
27
31
|
var import_utils = require("@tldraw/utils");
|
|
28
32
|
var import_nanoevents = require("nanoevents");
|
|
33
|
+
var import_RoomSession = require("./RoomSession");
|
|
34
|
+
var import_TLSyncClient = require("./TLSyncClient");
|
|
29
35
|
var import_diff = require("./diff");
|
|
36
|
+
var import_findMin = require("./findMin");
|
|
30
37
|
var import_interval = require("./interval");
|
|
31
38
|
var import_protocol = require("./protocol");
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
var import_TLSyncClient = require("./TLSyncClient");
|
|
35
|
-
var import_TLSyncStorage = require("./TLSyncStorage");
|
|
39
|
+
const MAX_TOMBSTONES = 3e3;
|
|
40
|
+
const TOMBSTONE_PRUNE_BUFFER_SIZE = 300;
|
|
36
41
|
const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1e3 / 60;
|
|
37
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 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
|
+
}
|
|
38
121
|
class TLSyncRoom {
|
|
39
122
|
// A table of connected clients
|
|
40
123
|
sessions = /* @__PURE__ */ new Map();
|
|
41
|
-
lastDocumentClock = 0;
|
|
42
124
|
// eslint-disable-next-line local/prefer-class-methods
|
|
43
125
|
pruneSessions = () => {
|
|
44
126
|
for (const client of this.sessions.values()) {
|
|
@@ -70,7 +152,6 @@ class TLSyncRoom {
|
|
|
70
152
|
}
|
|
71
153
|
}
|
|
72
154
|
};
|
|
73
|
-
presenceStore = new PresenceStore();
|
|
74
155
|
disposables = [(0, import_interval.interval)(this.pruneSessions, 2e3)];
|
|
75
156
|
_isClosed = false;
|
|
76
157
|
/**
|
|
@@ -93,8 +174,17 @@ class TLSyncRoom {
|
|
|
93
174
|
return this._isClosed;
|
|
94
175
|
}
|
|
95
176
|
events = (0, import_nanoevents.createNanoEvents)();
|
|
96
|
-
//
|
|
97
|
-
|
|
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
|
|
98
188
|
serializedSchema;
|
|
99
189
|
documentTypes;
|
|
100
190
|
presenceType;
|
|
@@ -102,9 +192,10 @@ class TLSyncRoom {
|
|
|
102
192
|
schema;
|
|
103
193
|
constructor(opts) {
|
|
104
194
|
this.schema = opts.schema;
|
|
195
|
+
let snapshot = opts.snapshot;
|
|
105
196
|
this.log = opts.log;
|
|
197
|
+
this.onDataChange = opts.onDataChange;
|
|
106
198
|
this.onPresenceChange = opts.onPresenceChange;
|
|
107
|
-
this.storage = opts.storage;
|
|
108
199
|
(0, import_utils.assert)(
|
|
109
200
|
import_utils.isNativeStructuredClone,
|
|
110
201
|
"TLSyncRoom is supposed to run either on Cloudflare Workersor on a 18+ version of Node.js, which both support the native structuredClone API"
|
|
@@ -122,31 +213,194 @@ class TLSyncRoom {
|
|
|
122
213
|
);
|
|
123
214
|
}
|
|
124
215
|
this.presenceType = presenceTypes.values().next()?.value ?? null;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
216
|
+
if (!snapshot) {
|
|
217
|
+
snapshot = {
|
|
218
|
+
clock: 0,
|
|
219
|
+
documentClock: 0,
|
|
220
|
+
documents: [
|
|
221
|
+
{
|
|
222
|
+
state: import_tlschema.DocumentRecordType.create({ id: import_tlschema.TLDOCUMENT_ID }),
|
|
223
|
+
lastChangedClock: 0
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
state: import_tlschema.PageRecordType.create({ name: "Page 1", index: "a1" }),
|
|
227
|
+
lastChangedClock: 0
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
this.clock = snapshot.clock;
|
|
233
|
+
let didIncrementClock = false;
|
|
234
|
+
const ensureClockDidIncrement = (_reason) => {
|
|
235
|
+
if (!didIncrementClock) {
|
|
236
|
+
didIncrementClock = true;
|
|
237
|
+
this.clock++;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
this.tombstones = new import_store.AtomMap(
|
|
241
|
+
"room tombstones",
|
|
242
|
+
(0, import_utils.objectMapEntriesIterable)(snapshot.tombstones ?? {})
|
|
243
|
+
);
|
|
244
|
+
this.documents = new import_store.AtomMap(
|
|
245
|
+
"room documents",
|
|
246
|
+
function* () {
|
|
247
|
+
for (const doc of snapshot.documents) {
|
|
248
|
+
if (this.documentTypes.has(doc.state.typeName)) {
|
|
249
|
+
yield [
|
|
250
|
+
doc.state.id,
|
|
251
|
+
DocumentState.createWithoutValidating(
|
|
252
|
+
doc.state,
|
|
253
|
+
doc.lastChangedClock,
|
|
254
|
+
(0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, doc.state.typeName))
|
|
255
|
+
)
|
|
256
|
+
];
|
|
257
|
+
} else {
|
|
258
|
+
ensureClockDidIncrement("doc type was not doc type");
|
|
259
|
+
this.tombstones.set(doc.state.id, this.clock);
|
|
260
|
+
}
|
|
133
261
|
}
|
|
134
|
-
})
|
|
262
|
+
}.call(this)
|
|
135
263
|
);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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();
|
|
141
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
|
+
(0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, state.typeName))
|
|
347
|
+
);
|
|
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
|
+
}
|
|
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
|
+
};
|
|
142
396
|
}
|
|
143
397
|
/**
|
|
144
398
|
* Send a message to a particular client. Debounces data events
|
|
145
399
|
*
|
|
146
400
|
* @param sessionId - The id of the session to send the message to.
|
|
147
|
-
* @param message - The message to send.
|
|
401
|
+
* @param message - The message to send.
|
|
148
402
|
*/
|
|
149
|
-
|
|
403
|
+
sendMessage(sessionId, message) {
|
|
150
404
|
const session = this.sessions.get(sessionId);
|
|
151
405
|
if (!session) {
|
|
152
406
|
this.log?.warn?.("Tried to send message to unknown session", message.type);
|
|
@@ -198,6 +452,7 @@ class TLSyncRoom {
|
|
|
198
452
|
return;
|
|
199
453
|
}
|
|
200
454
|
this.sessions.delete(sessionId);
|
|
455
|
+
const presence = this.getDocument(session.presenceId ?? "");
|
|
201
456
|
try {
|
|
202
457
|
if (fatalReason) {
|
|
203
458
|
session.socket.close(import_TLSyncClient.TLSyncErrorCloseEventCode, fatalReason);
|
|
@@ -206,12 +461,11 @@ class TLSyncRoom {
|
|
|
206
461
|
}
|
|
207
462
|
} catch {
|
|
208
463
|
}
|
|
209
|
-
const presence = this.presenceStore.get(session.presenceId ?? "");
|
|
210
464
|
if (presence) {
|
|
211
|
-
this.
|
|
465
|
+
this.documents.delete(session.presenceId);
|
|
212
466
|
this.broadcastPatch({
|
|
213
|
-
|
|
214
|
-
|
|
467
|
+
diff: { [session.presenceId]: [import_diff.RecordOpType.Remove] },
|
|
468
|
+
sourceSessionId: sessionId
|
|
215
469
|
});
|
|
216
470
|
}
|
|
217
471
|
this.events.emit("session_removed", { sessionId, meta: session.meta });
|
|
@@ -244,18 +498,24 @@ class TLSyncRoom {
|
|
|
244
498
|
} catch {
|
|
245
499
|
}
|
|
246
500
|
}
|
|
247
|
-
internalTxnId = "TLSyncRoom.txn";
|
|
248
501
|
/**
|
|
249
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.
|
|
250
504
|
*
|
|
251
|
-
* @param
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* @
|
|
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
|
+
* ```
|
|
255
516
|
*/
|
|
256
|
-
broadcastPatch(
|
|
257
|
-
const
|
|
258
|
-
if (!unmigrated) return this;
|
|
517
|
+
broadcastPatch(message) {
|
|
518
|
+
const { diff, sourceSessionId } = message;
|
|
259
519
|
this.sessions.forEach((session) => {
|
|
260
520
|
if (session.state !== import_RoomSession.RoomSessionState.Connected) return;
|
|
261
521
|
if (sourceSessionId === session.sessionId) return;
|
|
@@ -263,17 +523,18 @@ class TLSyncRoom {
|
|
|
263
523
|
this.cancelSession(session.sessionId);
|
|
264
524
|
return;
|
|
265
525
|
}
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
526
|
+
const res = this.migrateDiffForSession(session.serializedSchema, diff);
|
|
527
|
+
if (!res.ok) {
|
|
528
|
+
this.rejectSession(
|
|
529
|
+
session.sessionId,
|
|
530
|
+
res.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
531
|
+
);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.sendMessage(session.sessionId, {
|
|
274
535
|
type: "patch",
|
|
275
|
-
diff:
|
|
276
|
-
serverClock: this.
|
|
536
|
+
diff: res.value,
|
|
537
|
+
serverClock: this.clock
|
|
277
538
|
});
|
|
278
539
|
});
|
|
279
540
|
return this;
|
|
@@ -301,7 +562,7 @@ class TLSyncRoom {
|
|
|
301
562
|
* ```
|
|
302
563
|
*/
|
|
303
564
|
sendCustomMessage(sessionId, data) {
|
|
304
|
-
this.
|
|
565
|
+
this.sendMessage(sessionId, { type: "custom", data });
|
|
305
566
|
}
|
|
306
567
|
/**
|
|
307
568
|
* Register a new client session with the room. The session will be in an awaiting
|
|
@@ -360,54 +621,34 @@ class TLSyncRoom {
|
|
|
360
621
|
}
|
|
361
622
|
/**
|
|
362
623
|
* When we send a diff to a client, if that client is on a lower version than us, we need to make
|
|
363
|
-
* the diff compatible with their version.
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
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
|
|
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.
|
|
377
628
|
*/
|
|
378
|
-
|
|
379
|
-
if (
|
|
380
|
-
return import_utils.Result.ok(
|
|
629
|
+
migrateDiffForSession(serializedSchema, diff) {
|
|
630
|
+
if (serializedSchema === this.serializedSchema) {
|
|
631
|
+
return import_utils.Result.ok(diff);
|
|
381
632
|
}
|
|
382
633
|
const result = {};
|
|
383
|
-
for (const [id,
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
|
|
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];
|
|
634
|
+
for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(diff)) {
|
|
635
|
+
if (op[0] === import_diff.RecordOpType.Remove) {
|
|
636
|
+
result[id] = op;
|
|
637
|
+
continue;
|
|
407
638
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
639
|
+
const doc = this.getDocument(id);
|
|
640
|
+
if (!doc) {
|
|
641
|
+
return import_utils.Result.err(import_store.MigrationFailureReason.TargetVersionTooNew);
|
|
642
|
+
}
|
|
643
|
+
const migrationResult = this.schema.migratePersistedRecord(
|
|
644
|
+
doc.state,
|
|
645
|
+
serializedSchema,
|
|
646
|
+
"down"
|
|
647
|
+
);
|
|
648
|
+
if (migrationResult.type === "error") {
|
|
649
|
+
return import_utils.Result.err(migrationResult.reason);
|
|
650
|
+
}
|
|
651
|
+
result[id] = [import_diff.RecordOpType.Put, migrationResult.value];
|
|
411
652
|
}
|
|
412
653
|
return import_utils.Result.ok(result);
|
|
413
654
|
}
|
|
@@ -432,29 +673,21 @@ class TLSyncRoom {
|
|
|
432
673
|
this.log?.warn?.("Received message from unknown session");
|
|
433
674
|
return;
|
|
434
675
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
session.lastInteractionTime = Date.now();
|
|
446
|
-
}
|
|
447
|
-
return this._unsafe_sendMessage(session.sessionId, { type: "pong" });
|
|
448
|
-
}
|
|
449
|
-
default: {
|
|
450
|
-
(0, import_utils.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 === import_RoomSession.RoomSessionState.Connected) {
|
|
685
|
+
session.lastInteractionTime = Date.now();
|
|
451
686
|
}
|
|
687
|
+
return this.sendMessage(session.sessionId, { type: "pong" });
|
|
452
688
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
this.rejectSession(session.sessionId, e.reason);
|
|
456
|
-
} else {
|
|
457
|
-
throw e;
|
|
689
|
+
default: {
|
|
690
|
+
(0, import_utils.exhaustiveSwitchError)(message);
|
|
458
691
|
}
|
|
459
692
|
}
|
|
460
693
|
}
|
|
@@ -511,22 +744,6 @@ class TLSyncRoom {
|
|
|
511
744
|
this.removeSession(sessionId, fatalReason);
|
|
512
745
|
}
|
|
513
746
|
}
|
|
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
|
-
}
|
|
530
747
|
handleConnectRequest(session, message) {
|
|
531
748
|
let theirProtocolVersion = message.protocolVersion;
|
|
532
749
|
if (theirProtocolVersion === 5) {
|
|
@@ -552,12 +769,11 @@ class TLSyncRoom {
|
|
|
552
769
|
return;
|
|
553
770
|
}
|
|
554
771
|
const migrations = this.schema.getMigrationsSince(message.schema);
|
|
555
|
-
if (!migrations.ok || migrations.value.some((m) => m.scope
|
|
772
|
+
if (!migrations.ok || migrations.value.some((m) => m.scope === "store" || !m.down)) {
|
|
556
773
|
this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
557
774
|
return;
|
|
558
775
|
}
|
|
559
776
|
const sessionSchema = (0, import_utils.isEqual)(message.schema, this.serializedSchema) ? this.serializedSchema : message.schema;
|
|
560
|
-
const requiresDownMigrations = migrations.value.length > 0;
|
|
561
777
|
const connect = async (msg) => {
|
|
562
778
|
this.sessions.set(session.sessionId, {
|
|
563
779
|
state: import_RoomSession.RoomSessionState.Connected,
|
|
@@ -565,7 +781,6 @@ class TLSyncRoom {
|
|
|
565
781
|
presenceId: session.presenceId,
|
|
566
782
|
socket: session.socket,
|
|
567
783
|
serializedSchema: sessionSchema,
|
|
568
|
-
requiresDownMigrations,
|
|
569
784
|
lastInteractionTime: Date.now(),
|
|
570
785
|
debounceTimer: null,
|
|
571
786
|
outstandingDataMessages: [],
|
|
@@ -574,49 +789,76 @@ class TLSyncRoom {
|
|
|
574
789
|
isReadonly: session.isReadonly,
|
|
575
790
|
requiresLegacyRejection: session.requiresLegacyRejection
|
|
576
791
|
});
|
|
577
|
-
this.
|
|
792
|
+
this.sendMessage(session.sessionId, msg);
|
|
578
793
|
};
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
794
|
+
(0, import_state.transaction)((rollback) => {
|
|
795
|
+
if (
|
|
796
|
+
// if the client requests changes since a time before we have tombstone history, send them the full state
|
|
797
|
+
message.lastServerClock < this.tombstoneHistoryStartsAtClock || // similarly, if they ask for a time we haven't reached yet, send them the full state
|
|
798
|
+
// this will only happen if the DB is reset (or there is no db) and the server restarts
|
|
799
|
+
// or if the server exits/crashes with unpersisted changes
|
|
800
|
+
message.lastServerClock > this.clock
|
|
801
|
+
) {
|
|
802
|
+
const diff = {};
|
|
803
|
+
for (const [id, doc] of this.documents.entries()) {
|
|
804
|
+
if (id !== session.presenceId) {
|
|
805
|
+
diff[id] = [import_diff.RecordOpType.Put, doc.state];
|
|
806
|
+
}
|
|
589
807
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
+
}
|
|
835
|
+
}
|
|
836
|
+
for (const [id, deletedAtClock] of this.tombstones.entries()) {
|
|
837
|
+
if (deletedAtClock > message.lastServerClock) {
|
|
838
|
+
diff[id] = [import_diff.RecordOpType.Remove];
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const migrated = this.migrateDiffForSession(sessionSchema, diff);
|
|
842
|
+
if (!migrated.ok) {
|
|
843
|
+
rollback();
|
|
844
|
+
this.rejectSession(
|
|
845
|
+
session.sessionId,
|
|
846
|
+
migrated.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
847
|
+
);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
connect({
|
|
851
|
+
type: "connect",
|
|
852
|
+
connectRequestId: message.connectRequestId,
|
|
853
|
+
hydrationType: "wipe_presence",
|
|
854
|
+
schema: this.schema.serialize(),
|
|
855
|
+
protocolVersion: (0, import_protocol.getTlsyncProtocolVersion)(),
|
|
856
|
+
serverClock: this.clock,
|
|
857
|
+
diff: migrated.value,
|
|
858
|
+
isReadonly: session.isReadonly
|
|
859
|
+
});
|
|
604
860
|
}
|
|
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
|
-
};
|
|
615
861
|
});
|
|
616
|
-
this.lastDocumentClock = documentClock;
|
|
617
|
-
if (result) {
|
|
618
|
-
connect(result);
|
|
619
|
-
}
|
|
620
862
|
}
|
|
621
863
|
handlePushRequest(session, message) {
|
|
622
864
|
if (session && session.state !== import_RoomSession.RoomSessionState.Connected) {
|
|
@@ -625,198 +867,203 @@ class TLSyncRoom {
|
|
|
625
867
|
if (session) {
|
|
626
868
|
session.lastInteractionTime = Date.now();
|
|
627
869
|
}
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
(
|
|
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);
|
|
870
|
+
this.clock++;
|
|
871
|
+
const initialDocumentClock = this.documentClock;
|
|
872
|
+
let didPresenceChange = false;
|
|
873
|
+
(0, import_state.transaction)((rollback) => {
|
|
874
|
+
const legacyAppendMode = !this.getCanEmitStringAppend();
|
|
875
|
+
const docChanges = { diff: null };
|
|
876
|
+
const presenceChanges = { diff: null };
|
|
877
|
+
const propagateOp = (changes, id, op) => {
|
|
878
|
+
if (!changes.diff) changes.diff = {};
|
|
879
|
+
changes.diff[id] = op;
|
|
880
|
+
};
|
|
881
|
+
const fail = (reason, underlyingError) => {
|
|
882
|
+
rollback();
|
|
883
|
+
if (session) {
|
|
884
|
+
this.rejectSession(session.sessionId, reason);
|
|
885
|
+
} else {
|
|
886
|
+
throw new Error("failed to apply changes: " + reason, underlyingError);
|
|
660
887
|
}
|
|
661
|
-
|
|
662
|
-
|
|
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]);
|
|
888
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
889
|
+
this.log?.error?.("failed to apply push", reason, message, underlyingError);
|
|
682
890
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
891
|
+
return import_utils.Result.err(void 0);
|
|
892
|
+
};
|
|
893
|
+
const addDocument = (changes, id, _state) => {
|
|
894
|
+
const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, "up") : { type: "success", value: _state };
|
|
895
|
+
if (res.type === "error") {
|
|
896
|
+
return fail(
|
|
897
|
+
res.reason === import_store.MigrationFailureReason.TargetVersionTooOld ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
const { value: state } = res;
|
|
901
|
+
const doc = this.getDocument(id);
|
|
902
|
+
if (doc) {
|
|
903
|
+
const diff = doc.replaceState(state, this.clock, legacyAppendMode);
|
|
904
|
+
if (!diff.ok) {
|
|
905
|
+
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
906
|
+
}
|
|
907
|
+
if (diff.value) {
|
|
908
|
+
this.documents.set(id, diff.value[1]);
|
|
909
|
+
propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]);
|
|
910
|
+
}
|
|
911
|
+
} else {
|
|
912
|
+
const result = this.addDocument(id, state, this.clock);
|
|
913
|
+
if (!result.ok) {
|
|
914
|
+
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
915
|
+
}
|
|
916
|
+
propagateOp(changes, id, [import_diff.RecordOpType.Put, state]);
|
|
917
|
+
}
|
|
918
|
+
return import_utils.Result.ok(void 0);
|
|
919
|
+
};
|
|
920
|
+
const patchDocument = (changes, id, patch) => {
|
|
921
|
+
const doc = this.getDocument(id);
|
|
922
|
+
if (!doc) return import_utils.Result.ok(void 0);
|
|
923
|
+
const downgraded = session ? this.schema.migratePersistedRecord(doc.state, session.serializedSchema, "down") : { type: "success", value: doc.state };
|
|
924
|
+
if (downgraded.type === "error") {
|
|
925
|
+
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
926
|
+
}
|
|
927
|
+
if (downgraded.value === doc.state) {
|
|
928
|
+
const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode);
|
|
929
|
+
if (!diff.ok) {
|
|
930
|
+
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
931
|
+
}
|
|
932
|
+
if (diff.value) {
|
|
933
|
+
this.documents.set(id, diff.value[1]);
|
|
934
|
+
propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]);
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
const patched = (0, import_diff.applyObjectDiff)(downgraded.value, patch);
|
|
938
|
+
const upgraded = session ? this.schema.migratePersistedRecord(patched, session.serializedSchema, "up") : { type: "success", value: patched };
|
|
939
|
+
if (upgraded.type === "error") {
|
|
940
|
+
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
|
|
941
|
+
}
|
|
942
|
+
const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode);
|
|
943
|
+
if (!diff.ok) {
|
|
944
|
+
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
945
|
+
}
|
|
946
|
+
if (diff.value) {
|
|
947
|
+
this.documents.set(id, diff.value[1]);
|
|
948
|
+
propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]);
|
|
949
|
+
}
|
|
688
950
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
951
|
+
return import_utils.Result.ok(void 0);
|
|
952
|
+
};
|
|
953
|
+
const { clientClock } = message;
|
|
954
|
+
if (this.presenceType && session?.presenceId && "presence" in message && message.presence) {
|
|
955
|
+
if (!session) throw new Error("session is required for presence pushes");
|
|
956
|
+
const id = session.presenceId;
|
|
957
|
+
const [type, val] = message.presence;
|
|
958
|
+
const { typeName } = this.presenceType;
|
|
959
|
+
switch (type) {
|
|
960
|
+
case import_diff.RecordOpType.Put: {
|
|
961
|
+
const res = addDocument(presenceChanges, id, { ...val, id, typeName });
|
|
962
|
+
if (!res.ok) return;
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
case import_diff.RecordOpType.Patch: {
|
|
966
|
+
const res = patchDocument(presenceChanges, id, {
|
|
967
|
+
...val,
|
|
968
|
+
id: [import_diff.ValueOpType.Put, id],
|
|
969
|
+
typeName: [import_diff.ValueOpType.Put, typeName]
|
|
970
|
+
});
|
|
971
|
+
if (!res.ok) return;
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
693
974
|
}
|
|
694
975
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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) {
|
|
976
|
+
if (message.diff && !session?.isReadonly) {
|
|
977
|
+
for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(message.diff)) {
|
|
978
|
+
switch (op[0]) {
|
|
707
979
|
case import_diff.RecordOpType.Put: {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
980
|
+
if (!this.documentTypes.has(op[1].typeName)) {
|
|
981
|
+
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
|
|
982
|
+
}
|
|
983
|
+
const res = addDocument(docChanges, id, op[1]);
|
|
984
|
+
if (!res.ok) return;
|
|
713
985
|
break;
|
|
714
986
|
}
|
|
715
987
|
case import_diff.RecordOpType.Patch: {
|
|
716
|
-
patchDocument(
|
|
717
|
-
|
|
718
|
-
id: [import_diff.ValueOpType.Put, id],
|
|
719
|
-
typeName: [import_diff.ValueOpType.Put, typeName]
|
|
720
|
-
});
|
|
988
|
+
const res = patchDocument(docChanges, id, op[1]);
|
|
989
|
+
if (!res.ok) return;
|
|
721
990
|
break;
|
|
722
991
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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;
|
|
992
|
+
case import_diff.RecordOpType.Remove: {
|
|
993
|
+
const doc = this.getDocument(id);
|
|
994
|
+
if (!doc) {
|
|
995
|
+
continue;
|
|
750
996
|
}
|
|
997
|
+
this.removeDocument(id, this.clock);
|
|
998
|
+
propagateOp(docChanges, id, op);
|
|
999
|
+
break;
|
|
751
1000
|
}
|
|
752
1001
|
}
|
|
753
1002
|
}
|
|
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
|
-
};
|
|
792
1003
|
}
|
|
1004
|
+
if (
|
|
1005
|
+
// if there was only a presence push, the client doesn't need to do anything aside from
|
|
1006
|
+
// shift the push request.
|
|
1007
|
+
!message.diff || (0, import_utils.isEqual)(docChanges.diff, message.diff)
|
|
1008
|
+
) {
|
|
1009
|
+
if (session) {
|
|
1010
|
+
this.sendMessage(session.sessionId, {
|
|
1011
|
+
type: "push_result",
|
|
1012
|
+
serverClock: this.clock,
|
|
1013
|
+
clientClock,
|
|
1014
|
+
action: "commit"
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
} else if (!docChanges.diff) {
|
|
1018
|
+
if (session) {
|
|
1019
|
+
this.sendMessage(session.sessionId, {
|
|
1020
|
+
type: "push_result",
|
|
1021
|
+
serverClock: this.clock,
|
|
1022
|
+
clientClock,
|
|
1023
|
+
action: "discard"
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
if (session) {
|
|
1028
|
+
const migrateResult = this.migrateDiffForSession(
|
|
1029
|
+
session.serializedSchema,
|
|
1030
|
+
docChanges.diff
|
|
1031
|
+
);
|
|
1032
|
+
if (!migrateResult.ok) {
|
|
1033
|
+
return fail(
|
|
1034
|
+
migrateResult.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
1035
|
+
);
|
|
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?.();
|
|
793
1064
|
}
|
|
794
|
-
if (
|
|
795
|
-
this.
|
|
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
|
-
});
|
|
1065
|
+
if (didPresenceChange) {
|
|
1066
|
+
this.onPresenceChange?.();
|
|
820
1067
|
}
|
|
821
1068
|
}
|
|
822
1069
|
/**
|
|
@@ -834,20 +1081,104 @@ class TLSyncRoom {
|
|
|
834
1081
|
handleClose(sessionId) {
|
|
835
1082
|
this.cancelSession(sessionId);
|
|
836
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
|
+
}
|
|
837
1123
|
}
|
|
838
|
-
class
|
|
839
|
-
|
|
1124
|
+
class StoreUpdateContext {
|
|
1125
|
+
constructor(snapshot) {
|
|
1126
|
+
this.snapshot = snapshot;
|
|
1127
|
+
}
|
|
1128
|
+
updates = {
|
|
1129
|
+
puts: {},
|
|
1130
|
+
deletes: /* @__PURE__ */ new Set()
|
|
1131
|
+
};
|
|
1132
|
+
put(record) {
|
|
1133
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
1134
|
+
if (record.id in this.snapshot && (0, import_utils.isEqual)(this.snapshot[record.id], record)) {
|
|
1135
|
+
delete this.updates.puts[record.id];
|
|
1136
|
+
} else {
|
|
1137
|
+
this.updates.puts[record.id] = (0, import_utils.structuredClone)(record);
|
|
1138
|
+
}
|
|
1139
|
+
this.updates.deletes.delete(record.id);
|
|
1140
|
+
}
|
|
1141
|
+
delete(recordOrId) {
|
|
1142
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
1143
|
+
const id = typeof recordOrId === "string" ? recordOrId : recordOrId.id;
|
|
1144
|
+
delete this.updates.puts[id];
|
|
1145
|
+
if (this.snapshot[id]) {
|
|
1146
|
+
this.updates.deletes.add(id);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
840
1149
|
get(id) {
|
|
841
|
-
|
|
1150
|
+
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
|
|
1151
|
+
if ((0, import_utils.hasOwnProperty)(this.updates.puts, id)) {
|
|
1152
|
+
return (0, import_utils.structuredClone)(this.updates.puts[id]);
|
|
1153
|
+
}
|
|
1154
|
+
if (this.updates.deletes.has(id)) {
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
return (0, import_utils.structuredClone)(this.snapshot[id] ?? null);
|
|
842
1158
|
}
|
|
843
|
-
|
|
844
|
-
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) && !(0, import_utils.hasOwnProperty)(this.updates.puts, id)) {
|
|
1164
|
+
result.push(record);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return (0, import_utils.structuredClone)(result);
|
|
845
1168
|
}
|
|
846
|
-
|
|
847
|
-
|
|
1169
|
+
toDiff() {
|
|
1170
|
+
const diff = {};
|
|
1171
|
+
for (const [id, record] of Object.entries(this.updates.puts)) {
|
|
1172
|
+
diff[id] = [import_diff.RecordOpType.Put, record];
|
|
1173
|
+
}
|
|
1174
|
+
for (const id of this.updates.deletes) {
|
|
1175
|
+
diff[id] = [import_diff.RecordOpType.Remove];
|
|
1176
|
+
}
|
|
1177
|
+
return diff;
|
|
848
1178
|
}
|
|
849
|
-
|
|
850
|
-
|
|
1179
|
+
_isClosed = false;
|
|
1180
|
+
close() {
|
|
1181
|
+
this._isClosed = true;
|
|
851
1182
|
}
|
|
852
1183
|
}
|
|
853
1184
|
//# sourceMappingURL=TLSyncRoom.js.map
|