@tldraw/sync-core 4.3.0-canary.da35795ba8e2 → 4.3.0-canary.e1766dd4eab3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +239 -57
- package/dist-cjs/index.js +7 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +117 -69
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +7 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +357 -688
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js +76 -0
- package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
- package/dist-cjs/lib/recordDiff.js +52 -0
- package/dist-cjs/lib/recordDiff.js.map +7 -0
- package/dist-esm/index.d.mts +239 -57
- package/dist-esm/index.mjs +12 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +121 -70
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +7 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +370 -702
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs +56 -0
- package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
- package/dist-esm/lib/recordDiff.mjs +32 -0
- package/dist-esm/lib/recordDiff.mjs.map +7 -0
- package/package.json +6 -6
- package/src/index.ts +21 -3
- package/src/lib/InMemorySyncStorage.ts +357 -0
- package/src/lib/RoomSession.test.ts +1 -0
- package/src/lib/RoomSession.ts +2 -0
- package/src/lib/TLSocketRoom.ts +228 -114
- package/src/lib/TLSyncClient.ts +12 -0
- package/src/lib/TLSyncRoom.ts +473 -913
- package/src/lib/TLSyncStorage.ts +216 -0
- package/src/lib/recordDiff.ts +73 -0
- package/src/test/FuzzEditor.ts +4 -5
- package/src/test/InMemorySyncStorage.test.ts +1674 -0
- package/src/test/TLSocketRoom.test.ts +255 -49
- package/src/test/TLSyncRoom.test.ts +1022 -534
- package/src/test/TestServer.ts +12 -1
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/syncFuzz.test.ts +2 -4
- package/src/test/upgradeDowngrade.test.ts +282 -8
- package/src/test/validation.test.ts +10 -10
- package/src/test/pruneTombstones.test.ts +0 -178
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
import { transact, transaction } from '@tldraw/state'
|
|
2
1
|
import {
|
|
3
2
|
AtomMap,
|
|
4
|
-
IdOf,
|
|
5
3
|
MigrationFailureReason,
|
|
6
4
|
RecordType,
|
|
7
5
|
SerializedSchema,
|
|
8
6
|
StoreSchema,
|
|
9
7
|
UnknownRecord,
|
|
10
8
|
} from '@tldraw/store'
|
|
11
|
-
import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from '@tldraw/tlschema'
|
|
12
9
|
import {
|
|
13
|
-
IndexKey,
|
|
14
|
-
Result,
|
|
15
10
|
assert,
|
|
16
11
|
assertExists,
|
|
17
12
|
exhaustiveSwitchError,
|
|
18
13
|
getOwnProperty,
|
|
19
|
-
hasOwnProperty,
|
|
20
14
|
isEqual,
|
|
21
15
|
isNativeStructuredClone,
|
|
22
16
|
objectMapEntriesIterable,
|
|
23
|
-
|
|
17
|
+
Result,
|
|
24
18
|
} from '@tldraw/utils'
|
|
25
19
|
import { createNanoEvents } from 'nanoevents'
|
|
26
20
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
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 {
|
|
21
|
+
applyObjectDiff,
|
|
22
|
+
diffRecord,
|
|
36
23
|
NetworkDiff,
|
|
37
24
|
ObjectDiff,
|
|
38
25
|
RecordOp,
|
|
39
26
|
RecordOpType,
|
|
40
27
|
ValueOpType,
|
|
41
|
-
applyObjectDiff,
|
|
42
|
-
diffRecord,
|
|
43
28
|
} from './diff'
|
|
44
|
-
import { findMin } from './findMin'
|
|
45
29
|
import { interval } from './interval'
|
|
46
30
|
import {
|
|
31
|
+
getTlsyncProtocolVersion,
|
|
47
32
|
TLIncompatibilityReason,
|
|
48
33
|
TLSocketClientSentEvent,
|
|
49
34
|
TLSocketServerSentDataEvent,
|
|
50
35
|
TLSocketServerSentEvent,
|
|
51
|
-
getTlsyncProtocolVersion,
|
|
52
36
|
} 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,20 +77,6 @@ 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
|
-
|
|
94
80
|
/**
|
|
95
81
|
* The minimum time interval (in milliseconds) between sending batched data messages
|
|
96
82
|
* to clients. This debouncing prevents overwhelming clients with rapid updates.
|
|
@@ -100,97 +86,6 @@ export const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1000 / 60
|
|
|
100
86
|
|
|
101
87
|
const timeSince = (time: number) => Date.now() - time
|
|
102
88
|
|
|
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
|
-
|
|
194
89
|
/**
|
|
195
90
|
* Snapshot of a room's complete state that can be persisted and restored.
|
|
196
91
|
* Contains all documents, tombstones, and metadata needed to reconstruct the room.
|
|
@@ -201,7 +96,7 @@ export interface RoomSnapshot {
|
|
|
201
96
|
/**
|
|
202
97
|
* The current logical clock value for the room
|
|
203
98
|
*/
|
|
204
|
-
clock
|
|
99
|
+
clock?: number
|
|
205
100
|
/**
|
|
206
101
|
* Clock value when document data was last changed (optional for backwards compatibility)
|
|
207
102
|
*/
|
|
@@ -224,20 +119,6 @@ export interface RoomSnapshot {
|
|
|
224
119
|
schema?: SerializedSchema
|
|
225
120
|
}
|
|
226
121
|
|
|
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
|
-
|
|
241
122
|
/**
|
|
242
123
|
* A collaborative workspace that manages multiple client sessions and synchronizes
|
|
243
124
|
* document changes between them. The room serves as the authoritative source for
|
|
@@ -267,6 +148,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
267
148
|
// A table of connected clients
|
|
268
149
|
readonly sessions = new Map<string, RoomSession<R, SessionMeta>>()
|
|
269
150
|
|
|
151
|
+
private lastDocumentClock = 0
|
|
152
|
+
|
|
270
153
|
// eslint-disable-next-line local/prefer-class-methods
|
|
271
154
|
pruneSessions = () => {
|
|
272
155
|
for (const client of this.sessions.values()) {
|
|
@@ -300,6 +183,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
300
183
|
}
|
|
301
184
|
}
|
|
302
185
|
|
|
186
|
+
readonly presenceStore = new PresenceStore<R>()
|
|
187
|
+
|
|
303
188
|
private disposables: Array<() => void> = [interval(this.pruneSessions, 2000)]
|
|
304
189
|
|
|
305
190
|
private _isClosed = false
|
|
@@ -330,18 +215,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
330
215
|
session_removed(args: { sessionId: string; meta: SessionMeta }): void
|
|
331
216
|
}>()
|
|
332
217
|
|
|
333
|
-
//
|
|
334
|
-
|
|
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
|
|
218
|
+
// Storage layer for documents, tombstones, and clocks
|
|
219
|
+
private readonly storage: TLSyncStorage<R>
|
|
345
220
|
|
|
346
221
|
readonly serializedSchema: SerializedSchema
|
|
347
222
|
|
|
@@ -349,21 +224,18 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
349
224
|
readonly presenceType: RecordType<R, any> | null
|
|
350
225
|
private log?: TLSyncLog
|
|
351
226
|
public readonly schema: StoreSchema<R, any>
|
|
352
|
-
private onDataChange?(): void
|
|
353
227
|
private onPresenceChange?(): void
|
|
354
228
|
|
|
355
229
|
constructor(opts: {
|
|
356
230
|
log?: TLSyncLog
|
|
357
231
|
schema: StoreSchema<R, any>
|
|
358
|
-
snapshot?: RoomSnapshot
|
|
359
|
-
onDataChange?(): void
|
|
360
232
|
onPresenceChange?(): void
|
|
233
|
+
storage: TLSyncStorage<R>
|
|
361
234
|
}) {
|
|
362
235
|
this.schema = opts.schema
|
|
363
|
-
let snapshot = opts.snapshot
|
|
364
236
|
this.log = opts.log
|
|
365
|
-
this.onDataChange = opts.onDataChange
|
|
366
237
|
this.onPresenceChange = opts.onPresenceChange
|
|
238
|
+
this.storage = opts.storage
|
|
367
239
|
|
|
368
240
|
assert(
|
|
369
241
|
isNativeStructuredClone,
|
|
@@ -392,231 +264,34 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
392
264
|
|
|
393
265
|
this.presenceType = presenceTypes.values().next()?.value ?? null
|
|
394
266
|
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
}
|
|
411
|
-
|
|
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
|
-
}
|
|
421
|
-
|
|
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
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}.call(this)
|
|
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()
|
|
267
|
+
const { documentClock } = this.storage.transaction((txn) => {
|
|
268
|
+
this.schema.migrateStorage(txn)
|
|
516
269
|
})
|
|
517
270
|
|
|
518
|
-
|
|
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
|
-
}
|
|
271
|
+
this.lastDocumentClock = documentClock
|
|
551
272
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
clock,
|
|
559
|
-
assertExists(getOwnProperty(this.schema.types, state.typeName))
|
|
273
|
+
this.disposables.push(
|
|
274
|
+
this.storage.onChange(({ id }) => {
|
|
275
|
+
if (id !== this.internalTxnId) {
|
|
276
|
+
this.broadcastExternalStorageChanges()
|
|
277
|
+
}
|
|
278
|
+
})
|
|
560
279
|
)
|
|
561
|
-
if (!createResult.ok) return createResult
|
|
562
|
-
this.documents.set(id, createResult.value)
|
|
563
|
-
return Result.ok(undefined)
|
|
564
280
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
}
|
|
281
|
+
private broadcastExternalStorageChanges() {
|
|
282
|
+
this.storage.transaction((txn) => {
|
|
283
|
+
this.broadcastChanges(txn)
|
|
284
|
+
this.lastDocumentClock = txn.getClock()
|
|
285
|
+
}) // no id needed because this only reads, no writes.
|
|
611
286
|
}
|
|
612
287
|
|
|
613
288
|
/**
|
|
614
289
|
* Send a message to a particular client. Debounces data events
|
|
615
290
|
*
|
|
616
291
|
* @param sessionId - The id of the session to send the message to.
|
|
617
|
-
* @param message - The message to send.
|
|
292
|
+
* @param message - The message to send. UNSAFE Any diffs must have been downgraded already if necessary
|
|
618
293
|
*/
|
|
619
|
-
private
|
|
294
|
+
private _unsafe_sendMessage(
|
|
620
295
|
sessionId: string,
|
|
621
296
|
message: TLSocketServerSentEvent<R> | TLSocketServerSentDataEvent<R>
|
|
622
297
|
) {
|
|
@@ -683,8 +358,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
683
358
|
|
|
684
359
|
this.sessions.delete(sessionId)
|
|
685
360
|
|
|
686
|
-
const presence = this.getDocument(session.presenceId ?? '')
|
|
687
|
-
|
|
688
361
|
try {
|
|
689
362
|
if (fatalReason) {
|
|
690
363
|
session.socket.close(TLSyncErrorCloseEventCode, fatalReason)
|
|
@@ -695,12 +368,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
695
368
|
// noop, calling .close() multiple times is fine
|
|
696
369
|
}
|
|
697
370
|
|
|
371
|
+
const presence = this.presenceStore.get(session.presenceId ?? '')
|
|
698
372
|
if (presence) {
|
|
699
|
-
this.
|
|
700
|
-
|
|
373
|
+
this.presenceStore.delete(session.presenceId!)
|
|
374
|
+
// Broadcast presence removal - use RecordsDiff with the removed record
|
|
701
375
|
this.broadcastPatch({
|
|
702
|
-
|
|
703
|
-
|
|
376
|
+
puts: {},
|
|
377
|
+
deletes: [session.presenceId!],
|
|
704
378
|
})
|
|
705
379
|
}
|
|
706
380
|
|
|
@@ -740,24 +414,25 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
740
414
|
}
|
|
741
415
|
}
|
|
742
416
|
|
|
417
|
+
readonly internalTxnId = 'TLSyncRoom.txn'
|
|
418
|
+
|
|
743
419
|
/**
|
|
744
420
|
* Broadcast a patch to all connected clients except the one with the sessionId provided.
|
|
745
|
-
* Automatically handles schema migration for clients on different versions.
|
|
746
421
|
*
|
|
747
|
-
* @param
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
* @
|
|
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
|
-
* ```
|
|
422
|
+
* @param diff - The TLSyncForwardDiff with full records (used for migration)
|
|
423
|
+
* @param networkDiff - Optional pre-computed NetworkDiff for sessions not needing migration.
|
|
424
|
+
* If not provided, will be computed from recordsDiff.
|
|
425
|
+
* @param sourceSessionId - Optional session ID to exclude from the broadcast
|
|
758
426
|
*/
|
|
759
|
-
broadcastPatch(
|
|
760
|
-
|
|
427
|
+
private broadcastPatch(
|
|
428
|
+
diff: TLSyncForwardDiff<R>,
|
|
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
|
+
|
|
761
436
|
this.sessions.forEach((session) => {
|
|
762
437
|
if (session.state !== RoomSessionState.Connected) return
|
|
763
438
|
if (sourceSessionId === session.sessionId) return
|
|
@@ -766,23 +441,18 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
766
441
|
return
|
|
767
442
|
}
|
|
768
443
|
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
? TLSyncErrorCloseEventReason.SERVER_TOO_OLD
|
|
777
|
-
: TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
|
|
778
|
-
)
|
|
779
|
-
return
|
|
780
|
-
}
|
|
444
|
+
const diffResult = this.migrateDiffOrRejectSession(
|
|
445
|
+
session.sessionId,
|
|
446
|
+
session.serializedSchema,
|
|
447
|
+
session.requiresDownMigrations,
|
|
448
|
+
diff
|
|
449
|
+
)
|
|
450
|
+
if (!diffResult.ok) return
|
|
781
451
|
|
|
782
|
-
this.
|
|
452
|
+
this._unsafe_sendMessage(session.sessionId, {
|
|
783
453
|
type: 'patch',
|
|
784
|
-
diff:
|
|
785
|
-
serverClock: this.
|
|
454
|
+
diff: diffResult.value,
|
|
455
|
+
serverClock: this.lastDocumentClock,
|
|
786
456
|
})
|
|
787
457
|
})
|
|
788
458
|
return this
|
|
@@ -811,7 +481,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
811
481
|
* ```
|
|
812
482
|
*/
|
|
813
483
|
sendCustomMessage(sessionId: string, data: any): void {
|
|
814
|
-
this.
|
|
484
|
+
this._unsafe_sendMessage(sessionId, { type: 'custom', data })
|
|
815
485
|
}
|
|
816
486
|
|
|
817
487
|
/**
|
|
@@ -878,45 +548,67 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
878
548
|
|
|
879
549
|
/**
|
|
880
550
|
* When we send a diff to a client, if that client is on a lower version than us, we need to make
|
|
881
|
-
* the diff compatible with their version.
|
|
882
|
-
* to the client's version
|
|
883
|
-
*
|
|
884
|
-
*
|
|
551
|
+
* the diff compatible with their version. This method takes a TLSyncForwardDiff (which has full
|
|
552
|
+
* records) and migrates all records down to the client's schema version, returning a NetworkDiff.
|
|
553
|
+
*
|
|
554
|
+
* For updates (entries with [before, after] tuples), both records are migrated and a patch is
|
|
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
|
|
885
565
|
*/
|
|
886
|
-
private
|
|
566
|
+
private migrateDiffOrRejectSession(
|
|
567
|
+
sessionId: string,
|
|
887
568
|
serializedSchema: SerializedSchema,
|
|
888
|
-
|
|
569
|
+
requiresDownMigrations: boolean,
|
|
570
|
+
diff: TLSyncForwardDiff<R>,
|
|
571
|
+
unmigrated?: NetworkDiff<R>
|
|
889
572
|
): Result<NetworkDiff<R>, MigrationFailureReason> {
|
|
890
|
-
|
|
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)
|
|
573
|
+
if (!requiresDownMigrations) {
|
|
574
|
+
return Result.ok(unmigrated ?? toNetworkDiff(diff) ?? {})
|
|
896
575
|
}
|
|
897
576
|
|
|
898
577
|
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
|
-
}
|
|
904
578
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
579
|
+
// Migrate puts (either adds or updates)
|
|
580
|
+
for (const [id, put] of objectMapEntriesIterable(diff.puts)) {
|
|
581
|
+
if (Array.isArray(put)) {
|
|
582
|
+
// Update: [before, after] tuple - migrate both and compute patch
|
|
583
|
+
const [from, to] = put
|
|
584
|
+
const fromResult = this.schema.migratePersistedRecord(from, serializedSchema, 'down')
|
|
585
|
+
if (fromResult.type === 'error') {
|
|
586
|
+
this.rejectSession(sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
587
|
+
return Result.err(fromResult.reason)
|
|
588
|
+
}
|
|
589
|
+
const toResult = this.schema.migratePersistedRecord(to, serializedSchema, 'down')
|
|
590
|
+
if (toResult.type === 'error') {
|
|
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]
|
|
917
606
|
}
|
|
607
|
+
}
|
|
918
608
|
|
|
919
|
-
|
|
609
|
+
// Deletes don't need migration
|
|
610
|
+
for (const id of diff.deletes) {
|
|
611
|
+
result[id] = [RecordOpType.Remove]
|
|
920
612
|
}
|
|
921
613
|
|
|
922
614
|
return Result.ok(result)
|
|
@@ -943,21 +635,30 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
943
635
|
this.log?.warn?.('Received message from unknown session')
|
|
944
636
|
return
|
|
945
637
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
session.
|
|
638
|
+
try {
|
|
639
|
+
switch (message.type) {
|
|
640
|
+
case 'connect': {
|
|
641
|
+
return this.handleConnectRequest(session, message)
|
|
642
|
+
}
|
|
643
|
+
case 'push': {
|
|
644
|
+
return this.handlePushRequest(session, message)
|
|
645
|
+
}
|
|
646
|
+
case 'ping': {
|
|
647
|
+
if (session.state === RoomSessionState.Connected) {
|
|
648
|
+
session.lastInteractionTime = Date.now()
|
|
649
|
+
}
|
|
650
|
+
return this._unsafe_sendMessage(session.sessionId, { type: 'pong' })
|
|
651
|
+
}
|
|
652
|
+
default: {
|
|
653
|
+
exhaustiveSwitchError(message)
|
|
956
654
|
}
|
|
957
|
-
return this.sendMessage(session.sessionId, { type: 'pong' })
|
|
958
655
|
}
|
|
959
|
-
|
|
960
|
-
|
|
656
|
+
} catch (e) {
|
|
657
|
+
if (e instanceof TLSyncError) {
|
|
658
|
+
this.rejectSession(session.sessionId, e.reason)
|
|
659
|
+
} else {
|
|
660
|
+
// log error and reboot the room?
|
|
661
|
+
throw e
|
|
961
662
|
}
|
|
962
663
|
}
|
|
963
664
|
}
|
|
@@ -1022,6 +723,26 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1022
723
|
}
|
|
1023
724
|
}
|
|
1024
725
|
|
|
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
|
+
|
|
1025
746
|
private handleConnectRequest(
|
|
1026
747
|
session: RoomSession<R, SessionMeta>,
|
|
1027
748
|
message: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }>
|
|
@@ -1059,7 +780,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1059
780
|
}
|
|
1060
781
|
const migrations = this.schema.getMigrationsSince(message.schema)
|
|
1061
782
|
// if the client's store is at a different version to ours, we can't support them
|
|
1062
|
-
if (!migrations.ok || migrations.value.some((m) => m.scope
|
|
783
|
+
if (!migrations.ok || migrations.value.some((m) => m.scope !== 'record' || !m.down)) {
|
|
1063
784
|
this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
1064
785
|
return
|
|
1065
786
|
}
|
|
@@ -1068,6 +789,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1068
789
|
? this.serializedSchema
|
|
1069
790
|
: message.schema
|
|
1070
791
|
|
|
792
|
+
const requiresDownMigrations = migrations.value.length > 0
|
|
793
|
+
|
|
1071
794
|
const connect = async (msg: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) => {
|
|
1072
795
|
this.sessions.set(session.sessionId, {
|
|
1073
796
|
state: RoomSessionState.Connected,
|
|
@@ -1075,6 +798,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1075
798
|
presenceId: session.presenceId,
|
|
1076
799
|
socket: session.socket,
|
|
1077
800
|
serializedSchema: sessionSchema,
|
|
801
|
+
requiresDownMigrations,
|
|
1078
802
|
lastInteractionTime: Date.now(),
|
|
1079
803
|
debounceTimer: null,
|
|
1080
804
|
outstandingDataMessages: [],
|
|
@@ -1083,85 +807,54 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1083
807
|
isReadonly: session.isReadonly,
|
|
1084
808
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
1085
809
|
})
|
|
1086
|
-
this.
|
|
810
|
+
this._unsafe_sendMessage(session.sessionId, msg)
|
|
1087
811
|
}
|
|
1088
812
|
|
|
1089
|
-
transaction((
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
|
|
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
|
|
813
|
+
const { documentClock, result } = this.storage.transaction((txn) => {
|
|
814
|
+
this.broadcastChanges(txn)
|
|
815
|
+
const docChanges = txn.getChangesSince(message.lastServerClock)
|
|
816
|
+
const presenceDiff = this.migrateDiffOrRejectSession(
|
|
817
|
+
session.sessionId,
|
|
818
|
+
sessionSchema,
|
|
819
|
+
requiresDownMigrations,
|
|
820
|
+
{
|
|
821
|
+
puts: Object.fromEntries([...this.presenceStore.values()].map((p) => [p.id, p])),
|
|
822
|
+
deletes: [],
|
|
1151
823
|
}
|
|
824
|
+
)
|
|
825
|
+
if (!presenceDiff.ok) return null
|
|
1152
826
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
diff
|
|
1161
|
-
|
|
1162
|
-
|
|
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)
|
|
1163
840
|
}
|
|
1164
|
-
|
|
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
|
+
|
|
853
|
+
this.lastDocumentClock = documentClock
|
|
854
|
+
|
|
855
|
+
if (result) {
|
|
856
|
+
connect(result)
|
|
857
|
+
}
|
|
1165
858
|
}
|
|
1166
859
|
|
|
1167
860
|
private handlePushRequest(
|
|
@@ -1172,294 +865,282 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1172
865
|
if (session && session.state !== RoomSessionState.Connected) {
|
|
1173
866
|
return
|
|
1174
867
|
}
|
|
1175
|
-
|
|
1176
868
|
// update the last interaction time
|
|
1177
869
|
if (session) {
|
|
1178
870
|
session.lastInteractionTime = Date.now()
|
|
1179
871
|
}
|
|
1180
872
|
|
|
1181
|
-
|
|
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
|
|
1192
|
-
}
|
|
1193
|
-
const docChanges: ActualChanges = { diff: null }
|
|
1194
|
-
const presenceChanges: ActualChanges = { diff: null }
|
|
873
|
+
const legacyAppendMode = !this.getCanEmitStringAppend()
|
|
1195
874
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
875
|
+
interface ActualChanges {
|
|
876
|
+
diffs: {
|
|
877
|
+
networkDiff: NetworkDiff<R>
|
|
878
|
+
diff: TLSyncForwardDiff<R>
|
|
879
|
+
} | null
|
|
880
|
+
}
|
|
1200
881
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
882
|
+
const propagateOp = (
|
|
883
|
+
changes: ActualChanges,
|
|
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])
|
|
1215
904
|
}
|
|
905
|
+
}
|
|
1216
906
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
907
|
+
const addDocument = (
|
|
908
|
+
storage: MinimalDocStore<R>,
|
|
909
|
+
changes: ActualChanges,
|
|
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)
|
|
918
|
+
}
|
|
919
|
+
const { value: state } = res
|
|
920
|
+
|
|
921
|
+
// Get the existing document, if any
|
|
922
|
+
const doc = storage.get(id) as R | undefined
|
|
923
|
+
|
|
924
|
+
if (doc) {
|
|
925
|
+
// If there's an existing document, replace it with the new state
|
|
926
|
+
// but propagate a diff rather than the entire value
|
|
927
|
+
const recordType = assertExists(getOwnProperty(this.schema.types, doc.typeName))
|
|
928
|
+
const diff = diffAndValidateRecord(doc, state, recordType)
|
|
929
|
+
if (diff) {
|
|
930
|
+
storage.set(id, state)
|
|
931
|
+
propagateOp(changes, id, [RecordOpType.Patch, diff], doc, state)
|
|
1227
932
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
//
|
|
1231
|
-
|
|
933
|
+
} else {
|
|
934
|
+
// Otherwise, if we don't already have a document with this id
|
|
935
|
+
// create the document and propagate the put op
|
|
936
|
+
// set automatically clears tombstones if they exist
|
|
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)
|
|
941
|
+
}
|
|
1232
942
|
|
|
1233
|
-
|
|
1234
|
-
|
|
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])
|
|
1252
|
-
}
|
|
943
|
+
return Result.ok(undefined)
|
|
944
|
+
}
|
|
1253
945
|
|
|
1254
|
-
|
|
946
|
+
const patchDocument = (
|
|
947
|
+
storage: MinimalDocStore<R>,
|
|
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)
|
|
1255
964
|
}
|
|
1256
965
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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)
|
|
966
|
+
if (downgraded.value === doc) {
|
|
967
|
+
// If the versions are compatible, apply the patch and propagate the patch op
|
|
968
|
+
const diff = applyAndDiffRecord(doc, patch, recordType, legacyAppendMode)
|
|
969
|
+
if (diff) {
|
|
970
|
+
storage.set(id, diff[1])
|
|
971
|
+
propagateOp(changes, id, [RecordOpType.Patch, diff[0]], doc, diff[1])
|
|
1272
972
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
}
|
|
973
|
+
} else {
|
|
974
|
+
// need to apply the patch to the downgraded version and then upgrade it
|
|
975
|
+
|
|
976
|
+
// apply the patch to the downgraded version
|
|
977
|
+
const patched = applyObjectDiff(downgraded.value, patch)
|
|
978
|
+
// then upgrade the patched version and use that as the new state
|
|
979
|
+
const upgraded = session
|
|
980
|
+
? this.schema.migratePersistedRecord(patched, session.serializedSchema, 'up')
|
|
981
|
+
: { type: 'success' as const, value: patched }
|
|
982
|
+
// If the client's version is too old, we'll hit an error
|
|
983
|
+
if (upgraded.type === 'error') {
|
|
984
|
+
throw new TLSyncError(upgraded.reason, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
1306
985
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
}
|
|
986
|
+
// replace the state with the upgraded version and propagate the patch op
|
|
987
|
+
const diff = diffAndValidateRecord(doc, upgraded.value, recordType, legacyAppendMode)
|
|
988
|
+
if (diff) {
|
|
989
|
+
storage.set(id, upgraded.value)
|
|
990
|
+
propagateOp(changes, id, [RecordOpType.Patch, diff], doc, upgraded.value)
|
|
1338
991
|
}
|
|
1339
992
|
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const { result, documentClock, changes } = this.storage.transaction(
|
|
996
|
+
(txn) => {
|
|
997
|
+
this.broadcastChanges(txn)
|
|
998
|
+
// collect actual ops that resulted from the push
|
|
999
|
+
// these will be broadcast to other users
|
|
1000
|
+
|
|
1001
|
+
const docChanges: ActualChanges = { diffs: null }
|
|
1002
|
+
const presenceChanges: ActualChanges = { diffs: null }
|
|
1003
|
+
|
|
1004
|
+
if (this.presenceType && session?.presenceId && 'presence' in message && message.presence) {
|
|
1005
|
+
if (!session) throw new Error('session is required for presence pushes')
|
|
1006
|
+
// The push request was for the presence scope.
|
|
1007
|
+
const id = session.presenceId
|
|
1008
|
+
const [type, val] = message.presence
|
|
1009
|
+
const { typeName } = this.presenceType
|
|
1010
|
+
switch (type) {
|
|
1344
1011
|
case RecordOpType.Put: {
|
|
1345
|
-
// Try to
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
// if res.ok is false here then we already called `fail` and we should stop immediately
|
|
1352
|
-
if (!res.ok) return
|
|
1012
|
+
// Try to put the document. If it fails, stop here.
|
|
1013
|
+
addDocument(this.presenceStore, presenceChanges, id, {
|
|
1014
|
+
...val,
|
|
1015
|
+
id,
|
|
1016
|
+
typeName,
|
|
1017
|
+
})
|
|
1353
1018
|
break
|
|
1354
1019
|
}
|
|
1355
1020
|
case RecordOpType.Patch: {
|
|
1356
1021
|
// Try to patch the document. If it fails, stop here.
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1022
|
+
patchDocument(this.presenceStore, presenceChanges, id, {
|
|
1023
|
+
...val,
|
|
1024
|
+
id: [ValueOpType.Put, id],
|
|
1025
|
+
typeName: [ValueOpType.Put, typeName],
|
|
1026
|
+
})
|
|
1360
1027
|
break
|
|
1361
1028
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (message.diff && !session?.isReadonly) {
|
|
1032
|
+
// The push request was for the document scope.
|
|
1033
|
+
for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
|
|
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
|
|
1367
1064
|
}
|
|
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
|
|
1374
1065
|
}
|
|
1375
1066
|
}
|
|
1376
1067
|
}
|
|
1377
|
-
}
|
|
1378
1068
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
}
|
|
1069
|
+
return { docChanges, presenceChanges }
|
|
1070
|
+
},
|
|
1071
|
+
{ id: this.internalTxnId, emitChanges: 'when-different' }
|
|
1072
|
+
)
|
|
1434
1073
|
|
|
1435
|
-
|
|
1436
|
-
if (docChanges.diff || presenceChanges.diff) {
|
|
1437
|
-
this.broadcastPatch({
|
|
1438
|
-
sourceSessionId: session?.sessionId,
|
|
1439
|
-
diff: {
|
|
1440
|
-
...docChanges.diff,
|
|
1441
|
-
...presenceChanges.diff,
|
|
1442
|
-
},
|
|
1443
|
-
})
|
|
1444
|
-
}
|
|
1074
|
+
this.lastDocumentClock = documentClock
|
|
1445
1075
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1076
|
+
let pushResult: TLSocketServerSentEvent<R> | undefined
|
|
1077
|
+
if (changes && session) {
|
|
1078
|
+
// txn did not apply verbatim so we should broadcast the actual changes
|
|
1079
|
+
result.docChanges.diffs = { networkDiff: toNetworkDiff(changes) ?? {}, diff: changes }
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (isEqual(result.docChanges.diffs?.networkDiff, message.diff)) {
|
|
1083
|
+
pushResult = {
|
|
1084
|
+
type: 'push_result',
|
|
1085
|
+
clientClock: message.clientClock,
|
|
1086
|
+
serverClock: documentClock,
|
|
1087
|
+
action: 'commit',
|
|
1448
1088
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1089
|
+
} else if (!result.docChanges.diffs?.networkDiff) {
|
|
1090
|
+
pushResult = {
|
|
1091
|
+
type: 'push_result',
|
|
1092
|
+
clientClock: message.clientClock,
|
|
1093
|
+
serverClock: documentClock,
|
|
1094
|
+
action: 'discard',
|
|
1451
1095
|
}
|
|
1096
|
+
} else if (session) {
|
|
1097
|
+
// if recordsDiff is null but diff is not, then there are no clients that need down migrations
|
|
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
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
// if the difff was not ok then the session was rejected and it's ok to continue without a push result
|
|
1115
|
+
}
|
|
1452
1116
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1117
|
+
if (session && pushResult) {
|
|
1118
|
+
this._unsafe_sendMessage(session.sessionId, pushResult)
|
|
1119
|
+
}
|
|
1120
|
+
if (result.docChanges.diffs || result.presenceChanges.diffs) {
|
|
1121
|
+
this.broadcastPatch(
|
|
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
|
+
)
|
|
1459
1138
|
}
|
|
1460
1139
|
|
|
1461
|
-
if (
|
|
1462
|
-
|
|
1140
|
+
if (result.presenceChanges.diffs) {
|
|
1141
|
+
queueMicrotask(() => {
|
|
1142
|
+
this.onPresenceChange?.()
|
|
1143
|
+
})
|
|
1463
1144
|
}
|
|
1464
1145
|
}
|
|
1465
1146
|
|
|
@@ -1478,153 +1159,32 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1478
1159
|
handleClose(sessionId: string) {
|
|
1479
1160
|
this.cancelSession(sessionId)
|
|
1480
1161
|
}
|
|
1481
|
-
|
|
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
|
-
}
|
|
1523
1162
|
}
|
|
1524
1163
|
|
|
1525
|
-
/**
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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[]
|
|
1164
|
+
/** @internal */
|
|
1165
|
+
export interface MinimalDocStore<R extends UnknownRecord> {
|
|
1166
|
+
get(id: string): UnknownRecord | undefined
|
|
1167
|
+
set(id: string, record: R): void
|
|
1168
|
+
delete(id: string): void
|
|
1568
1169
|
}
|
|
1569
1170
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
private readonly
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
|
1171
|
+
/** @internal */
|
|
1172
|
+
export class PresenceStore<R extends UnknownRecord> implements MinimalDocStore<R> {
|
|
1173
|
+
private readonly presences = new AtomMap<string, R>('presences')
|
|
1174
|
+
|
|
1175
|
+
get(id: string): UnknownRecord | undefined {
|
|
1176
|
+
return this.presences.get(id)
|
|
1602
1177
|
}
|
|
1603
1178
|
|
|
1604
|
-
|
|
1605
|
-
|
|
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[]
|
|
1179
|
+
set(id: string, state: R): void {
|
|
1180
|
+
this.presences.set(id, state)
|
|
1613
1181
|
}
|
|
1614
1182
|
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
|
1183
|
+
delete(id: string): void {
|
|
1184
|
+
this.presences.delete(id)
|
|
1624
1185
|
}
|
|
1625
1186
|
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
this._isClosed = true
|
|
1187
|
+
values() {
|
|
1188
|
+
return this.presences.values()
|
|
1629
1189
|
}
|
|
1630
1190
|
}
|