@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
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
+
import { transact, transaction } from '@tldraw/state'
|
|
1
2
|
import {
|
|
2
3
|
AtomMap,
|
|
4
|
+
IdOf,
|
|
3
5
|
MigrationFailureReason,
|
|
4
6
|
RecordType,
|
|
5
7
|
SerializedSchema,
|
|
6
8
|
StoreSchema,
|
|
7
9
|
UnknownRecord,
|
|
8
10
|
} from '@tldraw/store'
|
|
11
|
+
import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from '@tldraw/tlschema'
|
|
9
12
|
import {
|
|
13
|
+
IndexKey,
|
|
14
|
+
Result,
|
|
10
15
|
assert,
|
|
11
16
|
assertExists,
|
|
12
17
|
exhaustiveSwitchError,
|
|
13
18
|
getOwnProperty,
|
|
19
|
+
hasOwnProperty,
|
|
14
20
|
isEqual,
|
|
15
21
|
isNativeStructuredClone,
|
|
16
22
|
objectMapEntriesIterable,
|
|
17
|
-
|
|
23
|
+
structuredClone,
|
|
18
24
|
} from '@tldraw/utils'
|
|
19
25
|
import { createNanoEvents } from 'nanoevents'
|
|
20
26
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
RoomSession,
|
|
28
|
+
RoomSessionState,
|
|
29
|
+
SESSION_IDLE_TIMEOUT,
|
|
30
|
+
SESSION_REMOVAL_WAIT_TIME,
|
|
31
|
+
SESSION_START_WAIT_TIME,
|
|
32
|
+
} from './RoomSession'
|
|
33
|
+
import { TLSyncLog } from './TLSocketRoom'
|
|
34
|
+
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from './TLSyncClient'
|
|
35
|
+
import {
|
|
23
36
|
NetworkDiff,
|
|
24
37
|
ObjectDiff,
|
|
25
38
|
RecordOp,
|
|
26
39
|
RecordOpType,
|
|
27
40
|
ValueOpType,
|
|
41
|
+
applyObjectDiff,
|
|
42
|
+
diffRecord,
|
|
28
43
|
} from './diff'
|
|
44
|
+
import { findMin } from './findMin'
|
|
29
45
|
import { interval } from './interval'
|
|
30
46
|
import {
|
|
31
|
-
getTlsyncProtocolVersion,
|
|
32
47
|
TLIncompatibilityReason,
|
|
33
48
|
TLSocketClientSentEvent,
|
|
34
49
|
TLSocketServerSentDataEvent,
|
|
35
50
|
TLSocketServerSentEvent,
|
|
51
|
+
getTlsyncProtocolVersion,
|
|
36
52
|
} from './protocol'
|
|
37
|
-
import { applyAndDiffRecord, diffAndValidateRecord, validateRecord } from './recordDiff'
|
|
38
|
-
import {
|
|
39
|
-
RoomSession,
|
|
40
|
-
RoomSessionState,
|
|
41
|
-
SESSION_IDLE_TIMEOUT,
|
|
42
|
-
SESSION_REMOVAL_WAIT_TIME,
|
|
43
|
-
SESSION_START_WAIT_TIME,
|
|
44
|
-
} from './RoomSession'
|
|
45
|
-
import { TLSyncLog } from './TLSocketRoom'
|
|
46
|
-
import { TLSyncError, TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from './TLSyncClient'
|
|
47
|
-
import {
|
|
48
|
-
TLSyncForwardDiff,
|
|
49
|
-
TLSyncStorage,
|
|
50
|
-
TLSyncStorageTransaction,
|
|
51
|
-
toNetworkDiff,
|
|
52
|
-
} from './TLSyncStorage'
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* WebSocket interface for server-side room connections. This defines the contract
|
|
@@ -77,6 +77,20 @@ export interface TLRoomSocket<R extends UnknownRecord> {
|
|
|
77
77
|
close(code?: number, reason?: string): void
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The maximum number of tombstone records to keep in memory. Tombstones track
|
|
82
|
+
* deleted records to prevent resurrection during sync operations.
|
|
83
|
+
* @public
|
|
84
|
+
*/
|
|
85
|
+
export const MAX_TOMBSTONES = 3000
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The number of tombstones to delete when pruning occurs after reaching MAX_TOMBSTONES.
|
|
89
|
+
* This buffer prevents frequent pruning operations.
|
|
90
|
+
* @public
|
|
91
|
+
*/
|
|
92
|
+
export const TOMBSTONE_PRUNE_BUFFER_SIZE = 300
|
|
93
|
+
|
|
80
94
|
/**
|
|
81
95
|
* The minimum time interval (in milliseconds) between sending batched data messages
|
|
82
96
|
* to clients. This debouncing prevents overwhelming clients with rapid updates.
|
|
@@ -86,6 +100,97 @@ export const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1000 / 60
|
|
|
86
100
|
|
|
87
101
|
const timeSince = (time: number) => Date.now() - time
|
|
88
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Represents the state of a document record within a sync room, including
|
|
105
|
+
* its current data and the clock value when it was last modified.
|
|
106
|
+
*
|
|
107
|
+
* @internal
|
|
108
|
+
*/
|
|
109
|
+
export class DocumentState<R extends UnknownRecord> {
|
|
110
|
+
/**
|
|
111
|
+
* Create a DocumentState instance without validating the record data.
|
|
112
|
+
* Used for performance when validation has already been performed.
|
|
113
|
+
*
|
|
114
|
+
* @param state - The record data
|
|
115
|
+
* @param lastChangedClock - Clock value when this record was last modified
|
|
116
|
+
* @param recordType - The record type definition for validation
|
|
117
|
+
* @returns A new DocumentState instance
|
|
118
|
+
*/
|
|
119
|
+
static createWithoutValidating<R extends UnknownRecord>(
|
|
120
|
+
state: R,
|
|
121
|
+
lastChangedClock: number,
|
|
122
|
+
recordType: RecordType<R, any>
|
|
123
|
+
): DocumentState<R> {
|
|
124
|
+
return new DocumentState(state, lastChangedClock, recordType)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a DocumentState instance with validation of the record data.
|
|
129
|
+
*
|
|
130
|
+
* @param state - The record data to validate
|
|
131
|
+
* @param lastChangedClock - Clock value when this record was last modified
|
|
132
|
+
* @param recordType - The record type definition for validation
|
|
133
|
+
* @returns Result containing the DocumentState or validation error
|
|
134
|
+
*/
|
|
135
|
+
static createAndValidate<R extends UnknownRecord>(
|
|
136
|
+
state: R,
|
|
137
|
+
lastChangedClock: number,
|
|
138
|
+
recordType: RecordType<R, any>
|
|
139
|
+
): Result<DocumentState<R>, Error> {
|
|
140
|
+
try {
|
|
141
|
+
recordType.validate(state)
|
|
142
|
+
} catch (error: any) {
|
|
143
|
+
return Result.err(error)
|
|
144
|
+
}
|
|
145
|
+
return Result.ok(new DocumentState(state, lastChangedClock, recordType))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private constructor(
|
|
149
|
+
public readonly state: R,
|
|
150
|
+
public readonly lastChangedClock: number,
|
|
151
|
+
private readonly recordType: RecordType<R, any>
|
|
152
|
+
) {}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Replace the current state with new state and calculate the diff.
|
|
156
|
+
*
|
|
157
|
+
* @param state - The new record state
|
|
158
|
+
* @param clock - The new clock value
|
|
159
|
+
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
|
|
160
|
+
* @returns Result containing the diff and new DocumentState, or null if no changes, or validation error
|
|
161
|
+
*/
|
|
162
|
+
replaceState(
|
|
163
|
+
state: R,
|
|
164
|
+
clock: number,
|
|
165
|
+
legacyAppendMode = false
|
|
166
|
+
): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
|
|
167
|
+
const diff = diffRecord(this.state, state, legacyAppendMode)
|
|
168
|
+
if (!diff) return Result.ok(null)
|
|
169
|
+
try {
|
|
170
|
+
this.recordType.validate(state)
|
|
171
|
+
} catch (error: any) {
|
|
172
|
+
return Result.err(error)
|
|
173
|
+
}
|
|
174
|
+
return Result.ok([diff, new DocumentState(state, clock, this.recordType)])
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Apply a diff to the current state and return the resulting changes.
|
|
178
|
+
*
|
|
179
|
+
* @param diff - The object diff to apply
|
|
180
|
+
* @param clock - The new clock value
|
|
181
|
+
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
|
|
182
|
+
* @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error
|
|
183
|
+
*/
|
|
184
|
+
mergeDiff(
|
|
185
|
+
diff: ObjectDiff,
|
|
186
|
+
clock: number,
|
|
187
|
+
legacyAppendMode = false
|
|
188
|
+
): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
|
|
189
|
+
const newState = applyObjectDiff(this.state, diff)
|
|
190
|
+
return this.replaceState(newState, clock, legacyAppendMode)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
89
194
|
/**
|
|
90
195
|
* Snapshot of a room's complete state that can be persisted and restored.
|
|
91
196
|
* Contains all documents, tombstones, and metadata needed to reconstruct the room.
|
|
@@ -96,7 +201,7 @@ export interface RoomSnapshot {
|
|
|
96
201
|
/**
|
|
97
202
|
* The current logical clock value for the room
|
|
98
203
|
*/
|
|
99
|
-
clock
|
|
204
|
+
clock: number
|
|
100
205
|
/**
|
|
101
206
|
* Clock value when document data was last changed (optional for backwards compatibility)
|
|
102
207
|
*/
|
|
@@ -119,6 +224,20 @@ export interface RoomSnapshot {
|
|
|
119
224
|
schema?: SerializedSchema
|
|
120
225
|
}
|
|
121
226
|
|
|
227
|
+
function getDocumentClock(snapshot: RoomSnapshot) {
|
|
228
|
+
if (typeof snapshot.documentClock === 'number') {
|
|
229
|
+
return snapshot.documentClock
|
|
230
|
+
}
|
|
231
|
+
let max = 0
|
|
232
|
+
for (const doc of snapshot.documents) {
|
|
233
|
+
max = Math.max(max, doc.lastChangedClock)
|
|
234
|
+
}
|
|
235
|
+
for (const tombstone of Object.values(snapshot.tombstones ?? {})) {
|
|
236
|
+
max = Math.max(max, tombstone)
|
|
237
|
+
}
|
|
238
|
+
return max
|
|
239
|
+
}
|
|
240
|
+
|
|
122
241
|
/**
|
|
123
242
|
* A collaborative workspace that manages multiple client sessions and synchronizes
|
|
124
243
|
* document changes between them. The room serves as the authoritative source for
|
|
@@ -148,8 +267,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
148
267
|
// A table of connected clients
|
|
149
268
|
readonly sessions = new Map<string, RoomSession<R, SessionMeta>>()
|
|
150
269
|
|
|
151
|
-
private lastDocumentClock = 0
|
|
152
|
-
|
|
153
270
|
// eslint-disable-next-line local/prefer-class-methods
|
|
154
271
|
pruneSessions = () => {
|
|
155
272
|
for (const client of this.sessions.values()) {
|
|
@@ -183,8 +300,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
183
300
|
}
|
|
184
301
|
}
|
|
185
302
|
|
|
186
|
-
readonly presenceStore = new PresenceStore<R>()
|
|
187
|
-
|
|
188
303
|
private disposables: Array<() => void> = [interval(this.pruneSessions, 2000)]
|
|
189
304
|
|
|
190
305
|
private _isClosed = false
|
|
@@ -215,8 +330,18 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
215
330
|
session_removed(args: { sessionId: string; meta: SessionMeta }): void
|
|
216
331
|
}>()
|
|
217
332
|
|
|
218
|
-
//
|
|
219
|
-
|
|
333
|
+
// Values associated with each uid (must be serializable).
|
|
334
|
+
/** @internal */
|
|
335
|
+
documents: AtomMap<string, DocumentState<R>>
|
|
336
|
+
tombstones: AtomMap<string, number>
|
|
337
|
+
|
|
338
|
+
// this clock should start higher than the client, to make sure that clients who sync with their
|
|
339
|
+
// initial lastServerClock value get the full state
|
|
340
|
+
// in this case clients will start with 0, and the server will start with 1
|
|
341
|
+
clock: number
|
|
342
|
+
documentClock: number
|
|
343
|
+
tombstoneHistoryStartsAtClock: number
|
|
344
|
+
// map from record id to clock upon deletion
|
|
220
345
|
|
|
221
346
|
readonly serializedSchema: SerializedSchema
|
|
222
347
|
|
|
@@ -224,18 +349,21 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
224
349
|
readonly presenceType: RecordType<R, any> | null
|
|
225
350
|
private log?: TLSyncLog
|
|
226
351
|
public readonly schema: StoreSchema<R, any>
|
|
352
|
+
private onDataChange?(): void
|
|
227
353
|
private onPresenceChange?(): void
|
|
228
354
|
|
|
229
355
|
constructor(opts: {
|
|
230
356
|
log?: TLSyncLog
|
|
231
357
|
schema: StoreSchema<R, any>
|
|
358
|
+
snapshot?: RoomSnapshot
|
|
359
|
+
onDataChange?(): void
|
|
232
360
|
onPresenceChange?(): void
|
|
233
|
-
storage: TLSyncStorage<R>
|
|
234
361
|
}) {
|
|
235
362
|
this.schema = opts.schema
|
|
363
|
+
let snapshot = opts.snapshot
|
|
236
364
|
this.log = opts.log
|
|
365
|
+
this.onDataChange = opts.onDataChange
|
|
237
366
|
this.onPresenceChange = opts.onPresenceChange
|
|
238
|
-
this.storage = opts.storage
|
|
239
367
|
|
|
240
368
|
assert(
|
|
241
369
|
isNativeStructuredClone,
|
|
@@ -264,34 +392,231 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
264
392
|
|
|
265
393
|
this.presenceType = presenceTypes.values().next()?.value ?? null
|
|
266
394
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
395
|
+
if (!snapshot) {
|
|
396
|
+
snapshot = {
|
|
397
|
+
clock: 0,
|
|
398
|
+
documentClock: 0,
|
|
399
|
+
documents: [
|
|
400
|
+
{
|
|
401
|
+
state: DocumentRecordType.create({ id: TLDOCUMENT_ID }),
|
|
402
|
+
lastChangedClock: 0,
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
state: PageRecordType.create({ name: 'Page 1', index: 'a1' as IndexKey }),
|
|
406
|
+
lastChangedClock: 0,
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
}
|
|
410
|
+
}
|
|
270
411
|
|
|
271
|
-
this.
|
|
412
|
+
this.clock = snapshot.clock
|
|
413
|
+
|
|
414
|
+
let didIncrementClock = false
|
|
415
|
+
const ensureClockDidIncrement = (_reason: string) => {
|
|
416
|
+
if (!didIncrementClock) {
|
|
417
|
+
didIncrementClock = true
|
|
418
|
+
this.clock++
|
|
419
|
+
}
|
|
420
|
+
}
|
|
272
421
|
|
|
273
|
-
this.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
422
|
+
this.tombstones = new AtomMap(
|
|
423
|
+
'room tombstones',
|
|
424
|
+
objectMapEntriesIterable(snapshot.tombstones ?? {})
|
|
425
|
+
)
|
|
426
|
+
this.documents = new AtomMap(
|
|
427
|
+
'room documents',
|
|
428
|
+
function* (this: TLSyncRoom<R, SessionMeta>) {
|
|
429
|
+
for (const doc of snapshot.documents) {
|
|
430
|
+
if (this.documentTypes.has(doc.state.typeName)) {
|
|
431
|
+
yield [
|
|
432
|
+
doc.state.id,
|
|
433
|
+
DocumentState.createWithoutValidating<R>(
|
|
434
|
+
doc.state as R,
|
|
435
|
+
doc.lastChangedClock,
|
|
436
|
+
assertExists(getOwnProperty(this.schema.types, doc.state.typeName))
|
|
437
|
+
),
|
|
438
|
+
] as const
|
|
439
|
+
} else {
|
|
440
|
+
ensureClockDidIncrement('doc type was not doc type')
|
|
441
|
+
this.tombstones.set(doc.state.id, this.clock)
|
|
442
|
+
}
|
|
277
443
|
}
|
|
278
|
-
})
|
|
444
|
+
}.call(this)
|
|
279
445
|
)
|
|
446
|
+
|
|
447
|
+
this.tombstoneHistoryStartsAtClock =
|
|
448
|
+
snapshot.tombstoneHistoryStartsAtClock ?? findMin(this.tombstones.values()) ?? this.clock
|
|
449
|
+
|
|
450
|
+
if (this.tombstoneHistoryStartsAtClock === 0) {
|
|
451
|
+
// Before this comment was added, new clients would send '0' as their 'lastServerClock'
|
|
452
|
+
// which was technically an error because clocks start at 0, but the error didn't manifest
|
|
453
|
+
// because we initialized tombstoneHistoryStartsAtClock to 1 and then never updated it.
|
|
454
|
+
// Now that we handle tombstoneHistoryStartsAtClock properly we need to increment it here to make sure old
|
|
455
|
+
// clients still get data when they connect. This if clause can be deleted after a few months.
|
|
456
|
+
this.tombstoneHistoryStartsAtClock++
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
transact(() => {
|
|
460
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
461
|
+
const schema = snapshot.schema ?? this.schema.serializeEarliestVersion()
|
|
462
|
+
|
|
463
|
+
const migrationsToApply = this.schema.getMigrationsSince(schema)
|
|
464
|
+
assert(migrationsToApply.ok, 'Failed to get migrations')
|
|
465
|
+
|
|
466
|
+
if (migrationsToApply.value.length > 0) {
|
|
467
|
+
// only bother allocating a snapshot if there are migrations to apply
|
|
468
|
+
const store = {} as Record<IdOf<R>, R>
|
|
469
|
+
for (const [k, v] of this.documents.entries()) {
|
|
470
|
+
store[k as IdOf<R>] = v.state
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const migrationResult = this.schema.migrateStoreSnapshot(
|
|
474
|
+
{ store, schema },
|
|
475
|
+
{ mutateInputStore: true }
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
if (migrationResult.type === 'error') {
|
|
479
|
+
// TODO: Fault tolerance
|
|
480
|
+
throw new Error('Failed to migrate: ' + migrationResult.reason)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// use for..in to iterate over the keys of the object because it consumes less memory than
|
|
484
|
+
// Object.entries
|
|
485
|
+
for (const id in migrationResult.value) {
|
|
486
|
+
if (!Object.prototype.hasOwnProperty.call(migrationResult.value, id)) {
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
const r = migrationResult.value[id as keyof typeof migrationResult.value]
|
|
490
|
+
const existing = this.documents.get(id)
|
|
491
|
+
if (!existing || !isEqual(existing.state, r)) {
|
|
492
|
+
// record was added or updated during migration
|
|
493
|
+
ensureClockDidIncrement('record was added or updated during migration')
|
|
494
|
+
this.documents.set(
|
|
495
|
+
r.id,
|
|
496
|
+
DocumentState.createWithoutValidating(
|
|
497
|
+
r,
|
|
498
|
+
this.clock,
|
|
499
|
+
assertExists(getOwnProperty(this.schema.types, r.typeName)) as any
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const id of this.documents.keys()) {
|
|
506
|
+
if (!migrationResult.value[id as keyof typeof migrationResult.value]) {
|
|
507
|
+
// record was removed during migration
|
|
508
|
+
ensureClockDidIncrement('record was removed during migration')
|
|
509
|
+
this.tombstones.set(id, this.clock)
|
|
510
|
+
this.documents.delete(id)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
this.pruneTombstones()
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
if (didIncrementClock) {
|
|
519
|
+
this.documentClock = this.clock
|
|
520
|
+
opts.onDataChange?.()
|
|
521
|
+
} else {
|
|
522
|
+
this.documentClock = getDocumentClock(snapshot)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private didSchedulePrune = true
|
|
527
|
+
// eslint-disable-next-line local/prefer-class-methods
|
|
528
|
+
private pruneTombstones = () => {
|
|
529
|
+
this.didSchedulePrune = false
|
|
530
|
+
// avoid blocking any pending responses
|
|
531
|
+
if (this.tombstones.size > MAX_TOMBSTONES) {
|
|
532
|
+
const entries = Array.from(this.tombstones.entries())
|
|
533
|
+
// sort entries in ascending order by clock
|
|
534
|
+
entries.sort((a, b) => a[1] - b[1])
|
|
535
|
+
let idx = entries.length - 1 - MAX_TOMBSTONES + TOMBSTONE_PRUNE_BUFFER_SIZE
|
|
536
|
+
const cullClock = entries[idx++][1]
|
|
537
|
+
while (idx < entries.length && entries[idx][1] === cullClock) {
|
|
538
|
+
idx++
|
|
539
|
+
}
|
|
540
|
+
// trim off the first bunch
|
|
541
|
+
const keysToDelete = entries.slice(0, idx).map(([key]) => key)
|
|
542
|
+
|
|
543
|
+
this.tombstoneHistoryStartsAtClock = cullClock + 1
|
|
544
|
+
this.tombstones.deleteMany(keysToDelete)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private getDocument(id: string) {
|
|
549
|
+
return this.documents.get(id)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private addDocument(id: string, state: R, clock: number): Result<void, Error> {
|
|
553
|
+
if (this.tombstones.has(id)) {
|
|
554
|
+
this.tombstones.delete(id)
|
|
555
|
+
}
|
|
556
|
+
const createResult = DocumentState.createAndValidate(
|
|
557
|
+
state,
|
|
558
|
+
clock,
|
|
559
|
+
assertExists(getOwnProperty(this.schema.types, state.typeName))
|
|
560
|
+
)
|
|
561
|
+
if (!createResult.ok) return createResult
|
|
562
|
+
this.documents.set(id, createResult.value)
|
|
563
|
+
return Result.ok(undefined)
|
|
280
564
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
565
|
+
|
|
566
|
+
private removeDocument(id: string, clock: number) {
|
|
567
|
+
this.documents.delete(id)
|
|
568
|
+
this.tombstones.set(id, clock)
|
|
569
|
+
if (!this.didSchedulePrune) {
|
|
570
|
+
this.didSchedulePrune = true
|
|
571
|
+
setTimeout(this.pruneTombstones, 0)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Get a complete snapshot of the current room state that can be persisted
|
|
577
|
+
* and later used to restore the room.
|
|
578
|
+
*
|
|
579
|
+
* @returns Room snapshot containing all documents, tombstones, and metadata
|
|
580
|
+
* @example
|
|
581
|
+
* ```ts
|
|
582
|
+
* const snapshot = room.getSnapshot()
|
|
583
|
+
* await database.saveRoomSnapshot(roomId, snapshot)
|
|
584
|
+
*
|
|
585
|
+
* // Later, restore from snapshot
|
|
586
|
+
* const restoredRoom = new TLSyncRoom({
|
|
587
|
+
* schema: mySchema,
|
|
588
|
+
* snapshot: snapshot
|
|
589
|
+
* })
|
|
590
|
+
* ```
|
|
591
|
+
*/
|
|
592
|
+
getSnapshot(): RoomSnapshot {
|
|
593
|
+
const tombstones = Object.fromEntries(this.tombstones.entries())
|
|
594
|
+
const documents = []
|
|
595
|
+
for (const doc of this.documents.values()) {
|
|
596
|
+
if (this.documentTypes.has(doc.state.typeName)) {
|
|
597
|
+
documents.push({
|
|
598
|
+
state: doc.state,
|
|
599
|
+
lastChangedClock: doc.lastChangedClock,
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
clock: this.clock,
|
|
605
|
+
documentClock: this.documentClock,
|
|
606
|
+
tombstones,
|
|
607
|
+
tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock,
|
|
608
|
+
schema: this.serializedSchema,
|
|
609
|
+
documents,
|
|
610
|
+
}
|
|
286
611
|
}
|
|
287
612
|
|
|
288
613
|
/**
|
|
289
614
|
* Send a message to a particular client. Debounces data events
|
|
290
615
|
*
|
|
291
616
|
* @param sessionId - The id of the session to send the message to.
|
|
292
|
-
* @param message - The message to send.
|
|
617
|
+
* @param message - The message to send.
|
|
293
618
|
*/
|
|
294
|
-
private
|
|
619
|
+
private sendMessage(
|
|
295
620
|
sessionId: string,
|
|
296
621
|
message: TLSocketServerSentEvent<R> | TLSocketServerSentDataEvent<R>
|
|
297
622
|
) {
|
|
@@ -358,6 +683,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
358
683
|
|
|
359
684
|
this.sessions.delete(sessionId)
|
|
360
685
|
|
|
686
|
+
const presence = this.getDocument(session.presenceId ?? '')
|
|
687
|
+
|
|
361
688
|
try {
|
|
362
689
|
if (fatalReason) {
|
|
363
690
|
session.socket.close(TLSyncErrorCloseEventCode, fatalReason)
|
|
@@ -368,13 +695,12 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
368
695
|
// noop, calling .close() multiple times is fine
|
|
369
696
|
}
|
|
370
697
|
|
|
371
|
-
const presence = this.presenceStore.get(session.presenceId ?? '')
|
|
372
698
|
if (presence) {
|
|
373
|
-
this.
|
|
374
|
-
|
|
699
|
+
this.documents.delete(session.presenceId!)
|
|
700
|
+
|
|
375
701
|
this.broadcastPatch({
|
|
376
|
-
|
|
377
|
-
|
|
702
|
+
diff: { [session.presenceId!]: [RecordOpType.Remove] },
|
|
703
|
+
sourceSessionId: sessionId,
|
|
378
704
|
})
|
|
379
705
|
}
|
|
380
706
|
|
|
@@ -414,25 +740,24 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
414
740
|
}
|
|
415
741
|
}
|
|
416
742
|
|
|
417
|
-
readonly internalTxnId = 'TLSyncRoom.txn'
|
|
418
|
-
|
|
419
743
|
/**
|
|
420
744
|
* Broadcast a patch to all connected clients except the one with the sessionId provided.
|
|
745
|
+
* Automatically handles schema migration for clients on different versions.
|
|
421
746
|
*
|
|
422
|
-
* @param
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
* @
|
|
747
|
+
* @param message - The broadcast message
|
|
748
|
+
* - diff - The network diff to broadcast to all clients
|
|
749
|
+
* - sourceSessionId - Optional ID of the session that originated this change (excluded from broadcast)
|
|
750
|
+
* @returns This room instance for method chaining
|
|
751
|
+
* @example
|
|
752
|
+
* ```ts
|
|
753
|
+
* room.broadcastPatch({
|
|
754
|
+
* diff: { 'shape:123': [RecordOpType.Put, newShapeData] },
|
|
755
|
+
* sourceSessionId: 'user-456' // This user won't receive the broadcast
|
|
756
|
+
* })
|
|
757
|
+
* ```
|
|
426
758
|
*/
|
|
427
|
-
|
|
428
|
-
diff
|
|
429
|
-
networkDiff?: NetworkDiff<R> | null,
|
|
430
|
-
sourceSessionId?: string
|
|
431
|
-
) {
|
|
432
|
-
// Pre-compute network diff if not provided
|
|
433
|
-
const unmigrated = networkDiff ?? toNetworkDiff(diff)
|
|
434
|
-
if (!unmigrated) return this
|
|
435
|
-
|
|
759
|
+
broadcastPatch(message: { diff: NetworkDiff<R>; sourceSessionId?: string }) {
|
|
760
|
+
const { diff, sourceSessionId } = message
|
|
436
761
|
this.sessions.forEach((session) => {
|
|
437
762
|
if (session.state !== RoomSessionState.Connected) return
|
|
438
763
|
if (sourceSessionId === session.sessionId) return
|
|
@@ -441,18 +766,23 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
441
766
|
return
|
|
442
767
|
}
|
|
443
768
|
|
|
444
|
-
const
|
|
445
|
-
session.sessionId,
|
|
446
|
-
session.serializedSchema,
|
|
447
|
-
session.requiresDownMigrations,
|
|
448
|
-
diff
|
|
449
|
-
)
|
|
450
|
-
if (!diffResult.ok) return
|
|
769
|
+
const res = this.migrateDiffForSession(session.serializedSchema, diff)
|
|
451
770
|
|
|
452
|
-
|
|
771
|
+
if (!res.ok) {
|
|
772
|
+
// disconnect client and send incompatibility error
|
|
773
|
+
this.rejectSession(
|
|
774
|
+
session.sessionId,
|
|
775
|
+
res.error === MigrationFailureReason.TargetVersionTooNew
|
|
776
|
+
? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
|
|
777
|
+
: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
778
|
+
)
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
this.sendMessage(session.sessionId, {
|
|
453
783
|
type: 'patch',
|
|
454
|
-
diff:
|
|
455
|
-
serverClock: this.
|
|
784
|
+
diff: res.value,
|
|
785
|
+
serverClock: this.clock,
|
|
456
786
|
})
|
|
457
787
|
})
|
|
458
788
|
return this
|
|
@@ -481,7 +811,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
481
811
|
* ```
|
|
482
812
|
*/
|
|
483
813
|
sendCustomMessage(sessionId: string, data: any): void {
|
|
484
|
-
this.
|
|
814
|
+
this.sendMessage(sessionId, { type: 'custom', data })
|
|
485
815
|
}
|
|
486
816
|
|
|
487
817
|
/**
|
|
@@ -548,67 +878,45 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
548
878
|
|
|
549
879
|
/**
|
|
550
880
|
* When we send a diff to a client, if that client is on a lower version than us, we need to make
|
|
551
|
-
* the diff compatible with their version.
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
* computed from the migrated versions, preserving efficient patch semantics even across versions.
|
|
556
|
-
*
|
|
557
|
-
* If a migration fails, the session will be rejected.
|
|
558
|
-
*
|
|
559
|
-
* @param sessionId - The session ID (for rejection on migration failure)
|
|
560
|
-
* @param serializedSchema - The client's schema to migrate to
|
|
561
|
-
* @param requiresDownMigrations - Whether the client needs down migrations
|
|
562
|
-
* @param diff - The TLSyncForwardDiff containing full records to migrate
|
|
563
|
-
* @param unmigrated - Optional pre-computed NetworkDiff for when no migration is needed
|
|
564
|
-
* @returns A NetworkDiff with migrated records, or a migration failure
|
|
881
|
+
* the diff compatible with their version. At the moment this means migrating each affected record
|
|
882
|
+
* to the client's version and sending the whole record again. We can optimize this later by
|
|
883
|
+
* keeping the previous versions of records around long enough to recalculate these diffs for
|
|
884
|
+
* older client versions.
|
|
565
885
|
*/
|
|
566
|
-
private
|
|
567
|
-
sessionId: string,
|
|
886
|
+
private migrateDiffForSession(
|
|
568
887
|
serializedSchema: SerializedSchema,
|
|
569
|
-
|
|
570
|
-
diff: TLSyncForwardDiff<R>,
|
|
571
|
-
unmigrated?: NetworkDiff<R>
|
|
888
|
+
diff: NetworkDiff<R>
|
|
572
889
|
): Result<NetworkDiff<R>, MigrationFailureReason> {
|
|
573
|
-
|
|
574
|
-
|
|
890
|
+
// TODO: optimize this by recalculating patches using the previous versions of records
|
|
891
|
+
|
|
892
|
+
// when the client connects we check whether the schema is identical and make sure
|
|
893
|
+
// to use the same object reference so that === works on this line
|
|
894
|
+
if (serializedSchema === this.serializedSchema) {
|
|
895
|
+
return Result.ok(diff)
|
|
575
896
|
}
|
|
576
897
|
|
|
577
898
|
const result: NetworkDiff<R> = {}
|
|
899
|
+
for (const [id, op] of objectMapEntriesIterable(diff)) {
|
|
900
|
+
if (op[0] === RecordOpType.Remove) {
|
|
901
|
+
result[id] = op
|
|
902
|
+
continue
|
|
903
|
+
}
|
|
578
904
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
592
|
-
return Result.err(toResult.reason)
|
|
593
|
-
}
|
|
594
|
-
const patch = diffRecord(fromResult.value, toResult.value)
|
|
595
|
-
if (patch) {
|
|
596
|
-
result[id] = [RecordOpType.Patch, patch]
|
|
597
|
-
}
|
|
598
|
-
} else {
|
|
599
|
-
// Add: single record - migrate and put
|
|
600
|
-
const migrationResult = this.schema.migratePersistedRecord(put, serializedSchema, 'down')
|
|
601
|
-
if (migrationResult.type === 'error') {
|
|
602
|
-
this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
603
|
-
return Result.err(migrationResult.reason)
|
|
604
|
-
}
|
|
605
|
-
result[id] = [RecordOpType.Put, migrationResult.value]
|
|
905
|
+
const doc = this.getDocument(id)
|
|
906
|
+
if (!doc) {
|
|
907
|
+
return Result.err(MigrationFailureReason.TargetVersionTooNew)
|
|
908
|
+
}
|
|
909
|
+
const migrationResult = this.schema.migratePersistedRecord(
|
|
910
|
+
doc.state,
|
|
911
|
+
serializedSchema,
|
|
912
|
+
'down'
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
if (migrationResult.type === 'error') {
|
|
916
|
+
return Result.err(migrationResult.reason)
|
|
606
917
|
}
|
|
607
|
-
}
|
|
608
918
|
|
|
609
|
-
|
|
610
|
-
for (const id of diff.deletes) {
|
|
611
|
-
result[id] = [RecordOpType.Remove]
|
|
919
|
+
result[id] = [RecordOpType.Put, migrationResult.value]
|
|
612
920
|
}
|
|
613
921
|
|
|
614
922
|
return Result.ok(result)
|
|
@@ -635,30 +943,21 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
635
943
|
this.log?.warn?.('Received message from unknown session')
|
|
636
944
|
return
|
|
637
945
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
session.lastInteractionTime = Date.now()
|
|
649
|
-
}
|
|
650
|
-
return this._unsafe_sendMessage(session.sessionId, { type: 'pong' })
|
|
651
|
-
}
|
|
652
|
-
default: {
|
|
653
|
-
exhaustiveSwitchError(message)
|
|
946
|
+
switch (message.type) {
|
|
947
|
+
case 'connect': {
|
|
948
|
+
return this.handleConnectRequest(session, message)
|
|
949
|
+
}
|
|
950
|
+
case 'push': {
|
|
951
|
+
return this.handlePushRequest(session, message)
|
|
952
|
+
}
|
|
953
|
+
case 'ping': {
|
|
954
|
+
if (session.state === RoomSessionState.Connected) {
|
|
955
|
+
session.lastInteractionTime = Date.now()
|
|
654
956
|
}
|
|
957
|
+
return this.sendMessage(session.sessionId, { type: 'pong' })
|
|
655
958
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
this.rejectSession(session.sessionId, e.reason)
|
|
659
|
-
} else {
|
|
660
|
-
// log error and reboot the room?
|
|
661
|
-
throw e
|
|
959
|
+
default: {
|
|
960
|
+
exhaustiveSwitchError(message)
|
|
662
961
|
}
|
|
663
962
|
}
|
|
664
963
|
}
|
|
@@ -723,26 +1022,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
723
1022
|
}
|
|
724
1023
|
}
|
|
725
1024
|
|
|
726
|
-
private forceAllReconnect() {
|
|
727
|
-
for (const session of this.sessions.values()) {
|
|
728
|
-
this.removeSession(session.sessionId)
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
private broadcastChanges(txn: TLSyncStorageTransaction<R>) {
|
|
733
|
-
const changes = txn.getChangesSince(this.lastDocumentClock)
|
|
734
|
-
if (!changes) return
|
|
735
|
-
const { wipeAll, diff } = changes
|
|
736
|
-
this.lastDocumentClock = txn.getClock()
|
|
737
|
-
if (wipeAll) {
|
|
738
|
-
// If this happens it means we'd need to broadcast a wipe_all message to all clients,
|
|
739
|
-
// which is not part of the protocol yet, so we need to force all clients to reconnect instead.
|
|
740
|
-
this.forceAllReconnect()
|
|
741
|
-
return
|
|
742
|
-
}
|
|
743
|
-
this.broadcastPatch(diff)
|
|
744
|
-
}
|
|
745
|
-
|
|
746
1025
|
private handleConnectRequest(
|
|
747
1026
|
session: RoomSession<R, SessionMeta>,
|
|
748
1027
|
message: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }>
|
|
@@ -780,7 +1059,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
780
1059
|
}
|
|
781
1060
|
const migrations = this.schema.getMigrationsSince(message.schema)
|
|
782
1061
|
// if the client's store is at a different version to ours, we can't support them
|
|
783
|
-
if (!migrations.ok || migrations.value.some((m) => m.scope
|
|
1062
|
+
if (!migrations.ok || migrations.value.some((m) => m.scope === 'store' || !m.down)) {
|
|
784
1063
|
this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
785
1064
|
return
|
|
786
1065
|
}
|
|
@@ -789,8 +1068,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
789
1068
|
? this.serializedSchema
|
|
790
1069
|
: message.schema
|
|
791
1070
|
|
|
792
|
-
const requiresDownMigrations = migrations.value.length > 0
|
|
793
|
-
|
|
794
1071
|
const connect = async (msg: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) => {
|
|
795
1072
|
this.sessions.set(session.sessionId, {
|
|
796
1073
|
state: RoomSessionState.Connected,
|
|
@@ -798,7 +1075,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
798
1075
|
presenceId: session.presenceId,
|
|
799
1076
|
socket: session.socket,
|
|
800
1077
|
serializedSchema: sessionSchema,
|
|
801
|
-
requiresDownMigrations,
|
|
802
1078
|
lastInteractionTime: Date.now(),
|
|
803
1079
|
debounceTimer: null,
|
|
804
1080
|
outstandingDataMessages: [],
|
|
@@ -807,54 +1083,85 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
807
1083
|
isReadonly: session.isReadonly,
|
|
808
1084
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
809
1085
|
})
|
|
810
|
-
this.
|
|
1086
|
+
this.sendMessage(session.sessionId, msg)
|
|
811
1087
|
}
|
|
812
1088
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1089
|
+
transaction((rollback) => {
|
|
1090
|
+
if (
|
|
1091
|
+
// if the client requests changes since a time before we have tombstone history, send them the full state
|
|
1092
|
+
message.lastServerClock < this.tombstoneHistoryStartsAtClock ||
|
|
1093
|
+
// similarly, if they ask for a time we haven't reached yet, send them the full state
|
|
1094
|
+
// this will only happen if the DB is reset (or there is no db) and the server restarts
|
|
1095
|
+
// or if the server exits/crashes with unpersisted changes
|
|
1096
|
+
message.lastServerClock > this.clock
|
|
1097
|
+
) {
|
|
1098
|
+
const diff: NetworkDiff<R> = {}
|
|
1099
|
+
for (const [id, doc] of this.documents.entries()) {
|
|
1100
|
+
if (id !== session.presenceId) {
|
|
1101
|
+
diff[id] = [RecordOpType.Put, doc.state]
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
const migrated = this.migrateDiffForSession(sessionSchema, diff)
|
|
1105
|
+
if (!migrated.ok) {
|
|
1106
|
+
rollback()
|
|
1107
|
+
this.rejectSession(
|
|
1108
|
+
session.sessionId,
|
|
1109
|
+
migrated.error === MigrationFailureReason.TargetVersionTooNew
|
|
1110
|
+
? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
|
|
1111
|
+
: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
1112
|
+
)
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
connect({
|
|
1116
|
+
type: 'connect',
|
|
1117
|
+
connectRequestId: message.connectRequestId,
|
|
1118
|
+
hydrationType: 'wipe_all',
|
|
1119
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1120
|
+
schema: this.schema.serialize(),
|
|
1121
|
+
serverClock: this.clock,
|
|
1122
|
+
diff: migrated.value,
|
|
1123
|
+
isReadonly: session.isReadonly,
|
|
1124
|
+
})
|
|
1125
|
+
} else {
|
|
1126
|
+
// calculate the changes since the time the client last saw
|
|
1127
|
+
const diff: NetworkDiff<R> = {}
|
|
1128
|
+
for (const doc of this.documents.values()) {
|
|
1129
|
+
if (doc.lastChangedClock > message.lastServerClock) {
|
|
1130
|
+
diff[doc.state.id] = [RecordOpType.Put, doc.state]
|
|
1131
|
+
} else if (this.presenceType?.isId(doc.state.id) && doc.state.id !== session.presenceId) {
|
|
1132
|
+
diff[doc.state.id] = [RecordOpType.Put, doc.state]
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
for (const [id, deletedAtClock] of this.tombstones.entries()) {
|
|
1136
|
+
if (deletedAtClock > message.lastServerClock) {
|
|
1137
|
+
diff[id] = [RecordOpType.Remove]
|
|
1138
|
+
}
|
|
823
1139
|
}
|
|
824
|
-
)
|
|
825
|
-
if (!presenceDiff.ok) return null
|
|
826
|
-
|
|
827
|
-
// Migrate the diff if needed, or use the pre-computed network diff
|
|
828
|
-
let docDiff: NetworkDiff<R> | null = null
|
|
829
|
-
if (docChanges && sessionSchema !== this.serializedSchema) {
|
|
830
|
-
const migrated = this.migrateDiffOrRejectSession(
|
|
831
|
-
session.sessionId,
|
|
832
|
-
sessionSchema,
|
|
833
|
-
requiresDownMigrations,
|
|
834
|
-
docChanges.diff
|
|
835
|
-
)
|
|
836
|
-
if (!migrated.ok) return null
|
|
837
|
-
docDiff = migrated.value
|
|
838
|
-
} else if (docChanges) {
|
|
839
|
-
docDiff = toNetworkDiff(docChanges.diff)
|
|
840
|
-
}
|
|
841
|
-
return {
|
|
842
|
-
type: 'connect',
|
|
843
|
-
connectRequestId: message.connectRequestId,
|
|
844
|
-
hydrationType: docChanges?.wipeAll ? 'wipe_all' : 'wipe_presence',
|
|
845
|
-
protocolVersion: getTlsyncProtocolVersion(),
|
|
846
|
-
schema: this.schema.serialize(),
|
|
847
|
-
serverClock: txn.getClock(),
|
|
848
|
-
diff: { ...presenceDiff.value, ...docDiff },
|
|
849
|
-
isReadonly: session.isReadonly,
|
|
850
|
-
} satisfies Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>
|
|
851
|
-
}) // no id needed because this only reads, no writes.
|
|
852
1140
|
|
|
853
|
-
|
|
1141
|
+
const migrated = this.migrateDiffForSession(sessionSchema, diff)
|
|
1142
|
+
if (!migrated.ok) {
|
|
1143
|
+
rollback()
|
|
1144
|
+
this.rejectSession(
|
|
1145
|
+
session.sessionId,
|
|
1146
|
+
migrated.error === MigrationFailureReason.TargetVersionTooNew
|
|
1147
|
+
? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
|
|
1148
|
+
: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
1149
|
+
)
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
854
1152
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1153
|
+
connect({
|
|
1154
|
+
type: 'connect',
|
|
1155
|
+
connectRequestId: message.connectRequestId,
|
|
1156
|
+
hydrationType: 'wipe_presence',
|
|
1157
|
+
schema: this.schema.serialize(),
|
|
1158
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
1159
|
+
serverClock: this.clock,
|
|
1160
|
+
diff: migrated.value,
|
|
1161
|
+
isReadonly: session.isReadonly,
|
|
1162
|
+
})
|
|
1163
|
+
}
|
|
1164
|
+
})
|
|
858
1165
|
}
|
|
859
1166
|
|
|
860
1167
|
private handlePushRequest(
|
|
@@ -865,282 +1172,294 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
865
1172
|
if (session && session.state !== RoomSessionState.Connected) {
|
|
866
1173
|
return
|
|
867
1174
|
}
|
|
1175
|
+
|
|
868
1176
|
// update the last interaction time
|
|
869
1177
|
if (session) {
|
|
870
1178
|
session.lastInteractionTime = Date.now()
|
|
871
1179
|
}
|
|
872
1180
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
id: string,
|
|
885
|
-
op: RecordOp<R>,
|
|
886
|
-
before: R | undefined,
|
|
887
|
-
after: R | undefined
|
|
888
|
-
) => {
|
|
889
|
-
if (!changes.diffs) changes.diffs = { networkDiff: {}, diff: { puts: {}, deletes: [] } }
|
|
890
|
-
changes.diffs.networkDiff[id] = op
|
|
891
|
-
switch (op[0]) {
|
|
892
|
-
case RecordOpType.Put:
|
|
893
|
-
changes.diffs.diff.puts[id] = op[1]
|
|
894
|
-
break
|
|
895
|
-
case RecordOpType.Patch:
|
|
896
|
-
assert(before && after, 'before and after are required for patches')
|
|
897
|
-
changes.diffs.diff.puts[id] = [before, after]
|
|
898
|
-
break
|
|
899
|
-
case RecordOpType.Remove:
|
|
900
|
-
changes.diffs.diff.deletes.push(id)
|
|
901
|
-
break
|
|
902
|
-
default:
|
|
903
|
-
exhaustiveSwitchError(op[0])
|
|
1181
|
+
// increment the clock for this push
|
|
1182
|
+
this.clock++
|
|
1183
|
+
|
|
1184
|
+
const initialDocumentClock = this.documentClock
|
|
1185
|
+
let didPresenceChange = false
|
|
1186
|
+
transaction((rollback) => {
|
|
1187
|
+
const legacyAppendMode = !this.getCanEmitStringAppend()
|
|
1188
|
+
// collect actual ops that resulted from the push
|
|
1189
|
+
// these will be broadcast to other users
|
|
1190
|
+
interface ActualChanges {
|
|
1191
|
+
diff: NetworkDiff<R> | null
|
|
904
1192
|
}
|
|
905
|
-
|
|
1193
|
+
const docChanges: ActualChanges = { diff: null }
|
|
1194
|
+
const presenceChanges: ActualChanges = { diff: null }
|
|
906
1195
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
id: string,
|
|
911
|
-
_state: R
|
|
912
|
-
): Result<void, void> => {
|
|
913
|
-
const res = session
|
|
914
|
-
? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
|
|
915
|
-
: { type: 'success' as const, value: _state }
|
|
916
|
-
if (res.type === 'error') {
|
|
917
|
-
throw new TLSyncError(res.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
1196
|
+
const propagateOp = (changes: ActualChanges, id: string, op: RecordOp<R>) => {
|
|
1197
|
+
if (!changes.diff) changes.diff = {}
|
|
1198
|
+
changes.diff[id] = op
|
|
918
1199
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
if (diff) {
|
|
930
|
-
storage.set(id, state)
|
|
931
|
-
propagateOp(changes, id, [RecordOpType.Patch, diff], doc, state)
|
|
1200
|
+
|
|
1201
|
+
const fail = (
|
|
1202
|
+
reason: TLSyncErrorCloseEventReason,
|
|
1203
|
+
underlyingError?: Error
|
|
1204
|
+
): Result<void, void> => {
|
|
1205
|
+
rollback()
|
|
1206
|
+
if (session) {
|
|
1207
|
+
this.rejectSession(session.sessionId, reason)
|
|
1208
|
+
} else {
|
|
1209
|
+
throw new Error('failed to apply changes: ' + reason, underlyingError)
|
|
932
1210
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
const recordType = assertExists(getOwnProperty(this.schema.types, state.typeName))
|
|
938
|
-
validateRecord(state, recordType)
|
|
939
|
-
storage.set(id, state)
|
|
940
|
-
propagateOp(changes, id, [RecordOpType.Put, state], undefined, undefined)
|
|
1211
|
+
if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'test') {
|
|
1212
|
+
this.log?.error?.('failed to apply push', reason, message, underlyingError)
|
|
1213
|
+
}
|
|
1214
|
+
return Result.err(undefined)
|
|
941
1215
|
}
|
|
942
1216
|
|
|
943
|
-
|
|
944
|
-
|
|
1217
|
+
const addDocument = (changes: ActualChanges, id: string, _state: R): Result<void, void> => {
|
|
1218
|
+
const res = session
|
|
1219
|
+
? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up')
|
|
1220
|
+
: { type: 'success' as const, value: _state }
|
|
1221
|
+
if (res.type === 'error') {
|
|
1222
|
+
return fail(
|
|
1223
|
+
res.reason === MigrationFailureReason.TargetVersionTooOld // target version is our version
|
|
1224
|
+
? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
|
|
1225
|
+
: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
1226
|
+
)
|
|
1227
|
+
}
|
|
1228
|
+
const { value: state } = res
|
|
945
1229
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
changes: ActualChanges,
|
|
949
|
-
id: string,
|
|
950
|
-
patch: ObjectDiff
|
|
951
|
-
) => {
|
|
952
|
-
// if it was already deleted, there's no need to apply the patch
|
|
953
|
-
const doc = storage.get(id) as R | undefined
|
|
954
|
-
if (!doc) return
|
|
955
|
-
|
|
956
|
-
const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
|
|
957
|
-
// If the client's version of the record is older than ours,
|
|
958
|
-
// we apply the patch to the downgraded version of the record
|
|
959
|
-
const downgraded = session
|
|
960
|
-
? this.schema.migratePersistedRecord(doc, session.serializedSchema, 'down')
|
|
961
|
-
: { type: 'success' as const, value: doc }
|
|
962
|
-
if (downgraded.type === 'error') {
|
|
963
|
-
throw new TLSyncError(downgraded.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
964
|
-
}
|
|
1230
|
+
// Get the existing document, if any
|
|
1231
|
+
const doc = this.getDocument(id)
|
|
965
1232
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1233
|
+
if (doc) {
|
|
1234
|
+
// If there's an existing document, replace it with the new state
|
|
1235
|
+
// but propagate a diff rather than the entire value
|
|
1236
|
+
const diff = doc.replaceState(state, this.clock, legacyAppendMode)
|
|
1237
|
+
if (!diff.ok) {
|
|
1238
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1239
|
+
}
|
|
1240
|
+
if (diff.value) {
|
|
1241
|
+
this.documents.set(id, diff.value[1])
|
|
1242
|
+
propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]])
|
|
1243
|
+
}
|
|
1244
|
+
} else {
|
|
1245
|
+
// Otherwise, if we don't already have a document with this id
|
|
1246
|
+
// create the document and propagate the put op
|
|
1247
|
+
const result = this.addDocument(id, state, this.clock)
|
|
1248
|
+
if (!result.ok) {
|
|
1249
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1250
|
+
}
|
|
1251
|
+
propagateOp(changes, id, [RecordOpType.Put, state])
|
|
972
1252
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
//
|
|
983
|
-
|
|
984
|
-
|
|
1253
|
+
|
|
1254
|
+
return Result.ok(undefined)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const patchDocument = (
|
|
1258
|
+
changes: ActualChanges,
|
|
1259
|
+
id: string,
|
|
1260
|
+
patch: ObjectDiff
|
|
1261
|
+
): Result<void, void> => {
|
|
1262
|
+
// if it was already deleted, there's no need to apply the patch
|
|
1263
|
+
const doc = this.getDocument(id)
|
|
1264
|
+
if (!doc) return Result.ok(undefined)
|
|
1265
|
+
// If the client's version of the record is older than ours,
|
|
1266
|
+
// we apply the patch to the downgraded version of the record
|
|
1267
|
+
const downgraded = session
|
|
1268
|
+
? this.schema.migratePersistedRecord(doc.state, session.serializedSchema, 'down')
|
|
1269
|
+
: { type: 'success' as const, value: doc.state }
|
|
1270
|
+
if (downgraded.type === 'error') {
|
|
1271
|
+
return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
985
1272
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1273
|
+
|
|
1274
|
+
if (downgraded.value === doc.state) {
|
|
1275
|
+
// If the versions are compatible, apply the patch and propagate the patch op
|
|
1276
|
+
const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode)
|
|
1277
|
+
if (!diff.ok) {
|
|
1278
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1279
|
+
}
|
|
1280
|
+
if (diff.value) {
|
|
1281
|
+
this.documents.set(id, diff.value[1])
|
|
1282
|
+
propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]])
|
|
1283
|
+
}
|
|
1284
|
+
} else {
|
|
1285
|
+
// need to apply the patch to the downgraded version and then upgrade it
|
|
1286
|
+
|
|
1287
|
+
// apply the patch to the downgraded version
|
|
1288
|
+
const patched = applyObjectDiff(downgraded.value, patch)
|
|
1289
|
+
// then upgrade the patched version and use that as the new state
|
|
1290
|
+
const upgraded = session
|
|
1291
|
+
? this.schema.migratePersistedRecord(patched, session.serializedSchema, 'up')
|
|
1292
|
+
: { type: 'success' as const, value: patched }
|
|
1293
|
+
// If the client's version is too old, we'll hit an error
|
|
1294
|
+
if (upgraded.type === 'error') {
|
|
1295
|
+
return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
1296
|
+
}
|
|
1297
|
+
// replace the state with the upgraded version and propagate the patch op
|
|
1298
|
+
const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode)
|
|
1299
|
+
if (!diff.ok) {
|
|
1300
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1301
|
+
}
|
|
1302
|
+
if (diff.value) {
|
|
1303
|
+
this.documents.set(id, diff.value[1])
|
|
1304
|
+
propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]])
|
|
1305
|
+
}
|
|
991
1306
|
}
|
|
1307
|
+
|
|
1308
|
+
return Result.ok(undefined)
|
|
992
1309
|
}
|
|
993
|
-
}
|
|
994
1310
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1311
|
+
const { clientClock } = message
|
|
1312
|
+
|
|
1313
|
+
if (this.presenceType && session?.presenceId && 'presence' in message && message.presence) {
|
|
1314
|
+
if (!session) throw new Error('session is required for presence pushes')
|
|
1315
|
+
// The push request was for the presence scope.
|
|
1316
|
+
const id = session.presenceId
|
|
1317
|
+
const [type, val] = message.presence
|
|
1318
|
+
const { typeName } = this.presenceType
|
|
1319
|
+
switch (type) {
|
|
1320
|
+
case RecordOpType.Put: {
|
|
1321
|
+
// Try to put the document. If it fails, stop here.
|
|
1322
|
+
const res = addDocument(presenceChanges, id, { ...val, id, typeName })
|
|
1323
|
+
// if res.ok is false here then we already called `fail` and we should stop immediately
|
|
1324
|
+
if (!res.ok) return
|
|
1325
|
+
break
|
|
1326
|
+
}
|
|
1327
|
+
case RecordOpType.Patch: {
|
|
1328
|
+
// Try to patch the document. If it fails, stop here.
|
|
1329
|
+
const res = patchDocument(presenceChanges, id, {
|
|
1330
|
+
...val,
|
|
1331
|
+
id: [ValueOpType.Put, id],
|
|
1332
|
+
typeName: [ValueOpType.Put, typeName],
|
|
1333
|
+
})
|
|
1334
|
+
// if res.ok is false here then we already called `fail` and we should stop immediately
|
|
1335
|
+
if (!res.ok) return
|
|
1336
|
+
break
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
if (message.diff && !session?.isReadonly) {
|
|
1341
|
+
// The push request was for the document scope.
|
|
1342
|
+
for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
|
|
1343
|
+
switch (op[0]) {
|
|
1011
1344
|
case RecordOpType.Put: {
|
|
1012
|
-
// Try to
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1345
|
+
// Try to add the document.
|
|
1346
|
+
// If we're putting a record with a type that we don't recognize, fail
|
|
1347
|
+
if (!this.documentTypes.has(op[1].typeName)) {
|
|
1348
|
+
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1349
|
+
}
|
|
1350
|
+
const res = addDocument(docChanges, id, op[1])
|
|
1351
|
+
// if res.ok is false here then we already called `fail` and we should stop immediately
|
|
1352
|
+
if (!res.ok) return
|
|
1018
1353
|
break
|
|
1019
1354
|
}
|
|
1020
1355
|
case RecordOpType.Patch: {
|
|
1021
1356
|
// Try to patch the document. If it fails, stop here.
|
|
1022
|
-
patchDocument(
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
typeName: [ValueOpType.Put, typeName],
|
|
1026
|
-
})
|
|
1357
|
+
const res = patchDocument(docChanges, id, op[1])
|
|
1358
|
+
// if res.ok is false here then we already called `fail` and we should stop immediately
|
|
1359
|
+
if (!res.ok) return
|
|
1027
1360
|
break
|
|
1028
1361
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
switch (op[0]) {
|
|
1035
|
-
case RecordOpType.Put: {
|
|
1036
|
-
// Try to add the document.
|
|
1037
|
-
// If we're putting a record with a type that we don't recognize, fail
|
|
1038
|
-
if (!this.documentTypes.has(op[1].typeName)) {
|
|
1039
|
-
throw new TLSyncError(
|
|
1040
|
-
'invalid record',
|
|
1041
|
-
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
1042
|
-
)
|
|
1043
|
-
}
|
|
1044
|
-
addDocument(txn, docChanges, id, op[1])
|
|
1045
|
-
break
|
|
1046
|
-
}
|
|
1047
|
-
case RecordOpType.Patch: {
|
|
1048
|
-
// Try to patch the document. If it fails, stop here.
|
|
1049
|
-
patchDocument(txn, docChanges, id, op[1])
|
|
1050
|
-
break
|
|
1051
|
-
}
|
|
1052
|
-
case RecordOpType.Remove: {
|
|
1053
|
-
const doc = txn.get(id)
|
|
1054
|
-
if (!doc) {
|
|
1055
|
-
// If the doc was already deleted, don't do anything, no need to propagate a delete op
|
|
1056
|
-
continue
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// Delete the document and propagate the delete op
|
|
1060
|
-
// delete automatically creates tombstones
|
|
1061
|
-
txn.delete(id)
|
|
1062
|
-
propagateOp(docChanges, id, op, doc, undefined)
|
|
1063
|
-
break
|
|
1362
|
+
case RecordOpType.Remove: {
|
|
1363
|
+
const doc = this.getDocument(id)
|
|
1364
|
+
if (!doc) {
|
|
1365
|
+
// If the doc was already deleted, don't do anything, no need to propagate a delete op
|
|
1366
|
+
continue
|
|
1064
1367
|
}
|
|
1368
|
+
|
|
1369
|
+
// Delete the document and propagate the delete op
|
|
1370
|
+
this.removeDocument(id, this.clock)
|
|
1371
|
+
// Schedule a pruneTombstones call to happen on the next call stack
|
|
1372
|
+
propagateOp(docChanges, id, op)
|
|
1373
|
+
break
|
|
1065
1374
|
}
|
|
1066
1375
|
}
|
|
1067
1376
|
}
|
|
1377
|
+
}
|
|
1068
1378
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1379
|
+
// Let the client know what action to take based on the results of the push
|
|
1380
|
+
if (
|
|
1381
|
+
// if there was only a presence push, the client doesn't need to do anything aside from
|
|
1382
|
+
// shift the push request.
|
|
1383
|
+
!message.diff ||
|
|
1384
|
+
isEqual(docChanges.diff, message.diff)
|
|
1385
|
+
) {
|
|
1386
|
+
// COMMIT
|
|
1387
|
+
// Applying the client's changes had the exact same effect on the server as
|
|
1388
|
+
// they had on the client, so the client should keep the diff
|
|
1389
|
+
if (session) {
|
|
1390
|
+
this.sendMessage(session.sessionId, {
|
|
1391
|
+
type: 'push_result',
|
|
1392
|
+
serverClock: this.clock,
|
|
1393
|
+
clientClock,
|
|
1394
|
+
action: 'commit',
|
|
1395
|
+
})
|
|
1396
|
+
}
|
|
1397
|
+
} else if (!docChanges.diff) {
|
|
1398
|
+
// DISCARD
|
|
1399
|
+
// Applying the client's changes had no effect, so the client should drop the diff
|
|
1400
|
+
if (session) {
|
|
1401
|
+
this.sendMessage(session.sessionId, {
|
|
1402
|
+
type: 'push_result',
|
|
1403
|
+
serverClock: this.clock,
|
|
1404
|
+
clientClock,
|
|
1405
|
+
action: 'discard',
|
|
1406
|
+
})
|
|
1407
|
+
}
|
|
1408
|
+
} else {
|
|
1409
|
+
// REBASE
|
|
1410
|
+
// Applying the client's changes had a different non-empty effect on the server,
|
|
1411
|
+
// so the client should rebase with our gold-standard / authoritative diff.
|
|
1412
|
+
// First we need to migrate the diff to the client's version
|
|
1413
|
+
if (session) {
|
|
1414
|
+
const migrateResult = this.migrateDiffForSession(
|
|
1415
|
+
session.serializedSchema,
|
|
1416
|
+
docChanges.diff
|
|
1417
|
+
)
|
|
1418
|
+
if (!migrateResult.ok) {
|
|
1419
|
+
return fail(
|
|
1420
|
+
migrateResult.error === MigrationFailureReason.TargetVersionTooNew
|
|
1421
|
+
? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
|
|
1422
|
+
: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
1423
|
+
)
|
|
1424
|
+
}
|
|
1425
|
+
// If the migration worked, send the rebased diff to the client
|
|
1426
|
+
this.sendMessage(session.sessionId, {
|
|
1427
|
+
type: 'push_result',
|
|
1428
|
+
serverClock: this.clock,
|
|
1429
|
+
clientClock,
|
|
1430
|
+
action: { rebaseWithDiff: migrateResult.value },
|
|
1431
|
+
})
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1081
1434
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1435
|
+
// If there are merged changes, broadcast them to all other clients
|
|
1436
|
+
if (docChanges.diff || presenceChanges.diff) {
|
|
1437
|
+
this.broadcastPatch({
|
|
1438
|
+
sourceSessionId: session?.sessionId,
|
|
1439
|
+
diff: {
|
|
1440
|
+
...docChanges.diff,
|
|
1441
|
+
...presenceChanges.diff,
|
|
1442
|
+
},
|
|
1443
|
+
})
|
|
1088
1444
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
clientClock: message.clientClock,
|
|
1093
|
-
serverClock: documentClock,
|
|
1094
|
-
action: 'discard',
|
|
1445
|
+
|
|
1446
|
+
if (docChanges.diff) {
|
|
1447
|
+
this.documentClock = this.clock
|
|
1095
1448
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
// so we can just use the diff directly
|
|
1099
|
-
const diff = this.migrateDiffOrRejectSession(
|
|
1100
|
-
session.sessionId,
|
|
1101
|
-
session.serializedSchema,
|
|
1102
|
-
session.requiresDownMigrations,
|
|
1103
|
-
result.docChanges.diffs.diff,
|
|
1104
|
-
result.docChanges.diffs.networkDiff
|
|
1105
|
-
)
|
|
1106
|
-
if (diff.ok) {
|
|
1107
|
-
pushResult = {
|
|
1108
|
-
type: 'push_result',
|
|
1109
|
-
clientClock: message.clientClock,
|
|
1110
|
-
serverClock: documentClock,
|
|
1111
|
-
action: { rebaseWithDiff: diff.value },
|
|
1112
|
-
}
|
|
1449
|
+
if (presenceChanges.diff) {
|
|
1450
|
+
didPresenceChange = true
|
|
1113
1451
|
}
|
|
1114
|
-
// if the difff was not ok then the session was rejected and it's ok to continue without a push result
|
|
1115
|
-
}
|
|
1116
1452
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
if
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
puts: {
|
|
1124
|
-
...result.docChanges.diffs?.diff.puts,
|
|
1125
|
-
...result.presenceChanges.diffs?.diff.puts,
|
|
1126
|
-
},
|
|
1127
|
-
deletes: [
|
|
1128
|
-
...(result.docChanges.diffs?.diff.deletes ?? []),
|
|
1129
|
-
...(result.presenceChanges.diffs?.diff.deletes ?? []),
|
|
1130
|
-
],
|
|
1131
|
-
},
|
|
1132
|
-
{
|
|
1133
|
-
...result.docChanges.diffs?.networkDiff,
|
|
1134
|
-
...result.presenceChanges.diffs?.networkDiff,
|
|
1135
|
-
},
|
|
1136
|
-
session?.sessionId
|
|
1137
|
-
)
|
|
1453
|
+
return
|
|
1454
|
+
})
|
|
1455
|
+
|
|
1456
|
+
// if it threw the changes will have been rolled back and the document clock will not have been incremented
|
|
1457
|
+
if (this.documentClock !== initialDocumentClock) {
|
|
1458
|
+
this.onDataChange?.()
|
|
1138
1459
|
}
|
|
1139
1460
|
|
|
1140
|
-
if (
|
|
1141
|
-
|
|
1142
|
-
this.onPresenceChange?.()
|
|
1143
|
-
})
|
|
1461
|
+
if (didPresenceChange) {
|
|
1462
|
+
this.onPresenceChange?.()
|
|
1144
1463
|
}
|
|
1145
1464
|
}
|
|
1146
1465
|
|
|
@@ -1159,32 +1478,153 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1159
1478
|
handleClose(sessionId: string) {
|
|
1160
1479
|
this.cancelSession(sessionId)
|
|
1161
1480
|
}
|
|
1162
|
-
}
|
|
1163
1481
|
|
|
1164
|
-
/**
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1482
|
+
/**
|
|
1483
|
+
* Apply changes to the room's store in a transactional way. Changes are
|
|
1484
|
+
* automatically synchronized to all connected clients.
|
|
1485
|
+
*
|
|
1486
|
+
* @param updater - Function that receives store methods to make changes
|
|
1487
|
+
* @returns Promise that resolves when the transaction is complete
|
|
1488
|
+
* @example
|
|
1489
|
+
* ```ts
|
|
1490
|
+
* // Add multiple shapes atomically
|
|
1491
|
+
* await room.updateStore((store) => {
|
|
1492
|
+
* store.put(createShape({ type: 'geo', x: 100, y: 100 }))
|
|
1493
|
+
* store.put(createShape({ type: 'text', x: 200, y: 200 }))
|
|
1494
|
+
* })
|
|
1495
|
+
*
|
|
1496
|
+
* // Async operations are supported
|
|
1497
|
+
* await room.updateStore(async (store) => {
|
|
1498
|
+
* const template = await loadTemplate()
|
|
1499
|
+
* template.shapes.forEach(shape => store.put(shape))
|
|
1500
|
+
* })
|
|
1501
|
+
* ```
|
|
1502
|
+
*/
|
|
1503
|
+
async updateStore(updater: (store: RoomStoreMethods<R>) => void | Promise<void>) {
|
|
1504
|
+
if (this._isClosed) {
|
|
1505
|
+
throw new Error('Cannot update store on a closed room')
|
|
1506
|
+
}
|
|
1507
|
+
const context = new StoreUpdateContext<R>(
|
|
1508
|
+
Object.fromEntries(this.getSnapshot().documents.map((d) => [d.state.id, d.state]))
|
|
1509
|
+
)
|
|
1510
|
+
try {
|
|
1511
|
+
await updater(context)
|
|
1512
|
+
} finally {
|
|
1513
|
+
context.close()
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const diff = context.toDiff()
|
|
1517
|
+
if (Object.keys(diff).length === 0) {
|
|
1518
|
+
return
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
this.handlePushRequest(null, { type: 'push', diff, clientClock: 0 })
|
|
1522
|
+
}
|
|
1169
1523
|
}
|
|
1170
1524
|
|
|
1171
|
-
/**
|
|
1172
|
-
|
|
1173
|
-
|
|
1525
|
+
/**
|
|
1526
|
+
* Interface for making transactional changes to room store data. Used within
|
|
1527
|
+
* updateStore transactions to modify documents atomically.
|
|
1528
|
+
*
|
|
1529
|
+
* @example
|
|
1530
|
+
* ```ts
|
|
1531
|
+
* await room.updateStore((store) => {
|
|
1532
|
+
* const shape = store.get('shape:123')
|
|
1533
|
+
* if (shape) {
|
|
1534
|
+
* store.put({ ...shape, x: shape.x + 10 })
|
|
1535
|
+
* }
|
|
1536
|
+
* store.delete('shape:456')
|
|
1537
|
+
* })
|
|
1538
|
+
* ```
|
|
1539
|
+
*
|
|
1540
|
+
* @public
|
|
1541
|
+
*/
|
|
1542
|
+
export interface RoomStoreMethods<R extends UnknownRecord = UnknownRecord> {
|
|
1543
|
+
/**
|
|
1544
|
+
* Add or update a record in the store.
|
|
1545
|
+
*
|
|
1546
|
+
* @param record - The record to store
|
|
1547
|
+
*/
|
|
1548
|
+
put(record: R): void
|
|
1549
|
+
/**
|
|
1550
|
+
* Delete a record from the store.
|
|
1551
|
+
*
|
|
1552
|
+
* @param recordOrId - The record or record ID to delete
|
|
1553
|
+
*/
|
|
1554
|
+
delete(recordOrId: R | string): void
|
|
1555
|
+
/**
|
|
1556
|
+
* Get a record by its ID.
|
|
1557
|
+
*
|
|
1558
|
+
* @param id - The record ID
|
|
1559
|
+
* @returns The record or null if not found
|
|
1560
|
+
*/
|
|
1561
|
+
get(id: string): R | null
|
|
1562
|
+
/**
|
|
1563
|
+
* Get all records in the store.
|
|
1564
|
+
*
|
|
1565
|
+
* @returns Array of all records
|
|
1566
|
+
*/
|
|
1567
|
+
getAll(): R[]
|
|
1568
|
+
}
|
|
1174
1569
|
|
|
1175
|
-
|
|
1176
|
-
|
|
1570
|
+
class StoreUpdateContext<R extends UnknownRecord> implements RoomStoreMethods<R> {
|
|
1571
|
+
constructor(private readonly snapshot: Record<string, UnknownRecord>) {}
|
|
1572
|
+
private readonly updates = {
|
|
1573
|
+
puts: {} as Record<string, UnknownRecord>,
|
|
1574
|
+
deletes: new Set<string>(),
|
|
1575
|
+
}
|
|
1576
|
+
put(record: R): void {
|
|
1577
|
+
if (this._isClosed) throw new Error('StoreUpdateContext is closed')
|
|
1578
|
+
if (record.id in this.snapshot && isEqual(this.snapshot[record.id], record)) {
|
|
1579
|
+
delete this.updates.puts[record.id]
|
|
1580
|
+
} else {
|
|
1581
|
+
this.updates.puts[record.id] = structuredClone(record)
|
|
1582
|
+
}
|
|
1583
|
+
this.updates.deletes.delete(record.id)
|
|
1584
|
+
}
|
|
1585
|
+
delete(recordOrId: R | string): void {
|
|
1586
|
+
if (this._isClosed) throw new Error('StoreUpdateContext is closed')
|
|
1587
|
+
const id = typeof recordOrId === 'string' ? recordOrId : recordOrId.id
|
|
1588
|
+
delete this.updates.puts[id]
|
|
1589
|
+
if (this.snapshot[id]) {
|
|
1590
|
+
this.updates.deletes.add(id)
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
get(id: string): R | null {
|
|
1594
|
+
if (this._isClosed) throw new Error('StoreUpdateContext is closed')
|
|
1595
|
+
if (hasOwnProperty(this.updates.puts, id)) {
|
|
1596
|
+
return structuredClone(this.updates.puts[id]) as R
|
|
1597
|
+
}
|
|
1598
|
+
if (this.updates.deletes.has(id)) {
|
|
1599
|
+
return null
|
|
1600
|
+
}
|
|
1601
|
+
return structuredClone(this.snapshot[id] ?? null) as R
|
|
1177
1602
|
}
|
|
1178
1603
|
|
|
1179
|
-
|
|
1180
|
-
this.
|
|
1604
|
+
getAll(): R[] {
|
|
1605
|
+
if (this._isClosed) throw new Error('StoreUpdateContext is closed')
|
|
1606
|
+
const result = Object.values(this.updates.puts)
|
|
1607
|
+
for (const [id, record] of Object.entries(this.snapshot)) {
|
|
1608
|
+
if (!this.updates.deletes.has(id) && !hasOwnProperty(this.updates.puts, id)) {
|
|
1609
|
+
result.push(record)
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return structuredClone(result) as R[]
|
|
1181
1613
|
}
|
|
1182
1614
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1615
|
+
toDiff(): NetworkDiff<any> {
|
|
1616
|
+
const diff: NetworkDiff<R> = {}
|
|
1617
|
+
for (const [id, record] of Object.entries(this.updates.puts)) {
|
|
1618
|
+
diff[id] = [RecordOpType.Put, record as R]
|
|
1619
|
+
}
|
|
1620
|
+
for (const id of this.updates.deletes) {
|
|
1621
|
+
diff[id] = [RecordOpType.Remove]
|
|
1622
|
+
}
|
|
1623
|
+
return diff
|
|
1185
1624
|
}
|
|
1186
1625
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1626
|
+
private _isClosed = false
|
|
1627
|
+
close() {
|
|
1628
|
+
this._isClosed = true
|
|
1189
1629
|
}
|
|
1190
1630
|
}
|