@tldraw/sync-core 5.3.0-internal.d205573d66cb → 5.3.0-next.299378752aaf
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 +3 -76
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +20 -55
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +52 -115
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +2 -26
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +1 -6
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +5 -43
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-esm/index.d.mts +3 -76
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +20 -55
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +52 -115
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +2 -26
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +1 -6
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +5 -43
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/index.ts +0 -1
- package/src/lib/InMemorySyncStorage.ts +21 -74
- package/src/lib/RoomSession.ts +2 -7
- package/src/lib/SQLiteSyncStorage.test.ts +2 -29
- package/src/lib/SQLiteSyncStorage.ts +59 -158
- package/src/lib/TLSocketRoom.ts +2 -53
- package/src/lib/TLSyncClient.test.ts +2 -9
- package/src/lib/TLSyncClient.ts +3 -15
- package/src/lib/TLSyncRoom.ts +4 -76
- package/src/lib/TLSyncStorage.ts +0 -8
- package/src/lib/protocol.ts +0 -17
- package/src/test/TLSocketRoom.test.ts +2 -37
- package/src/test/TLSyncRoom.test.ts +0 -25
- package/src/test/TestServer.ts +4 -14
- package/src/test/storageContractSuite.ts +0 -79
- package/src/test/upgradeDowngrade.test.ts +0 -47
- package/src/test/objectStore.test.ts +0 -387
package/src/lib/TLSocketRoom.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createTLSchema, TLInstancePresence, TLStoreSnapshot } from '@tldraw/tls
|
|
|
3
3
|
import { getOwnProperty, hasOwnProperty, isEqual, structuredClone } from '@tldraw/utils'
|
|
4
4
|
import { JsonChunkAssembler } from './chunk'
|
|
5
5
|
import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from './InMemorySyncStorage'
|
|
6
|
-
import {
|
|
6
|
+
import { TLSocketServerSentEvent } from './protocol'
|
|
7
7
|
import { RoomSessionState } from './RoomSession'
|
|
8
8
|
import { ServerSocketAdapter, WebSocketMinimal } from './ServerSocketAdapter'
|
|
9
9
|
import { TLSyncErrorCloseEventReason } from './TLSyncClient'
|
|
@@ -11,7 +11,6 @@ import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom'
|
|
|
11
11
|
import {
|
|
12
12
|
convertStoreSnapshotToRoomSnapshot,
|
|
13
13
|
loadSnapshotIntoStorage,
|
|
14
|
-
TLSyncForwardDiff,
|
|
15
14
|
TLSyncStorage,
|
|
16
15
|
} from './TLSyncStorage'
|
|
17
16
|
|
|
@@ -72,11 +71,6 @@ export interface TLSyncLog {
|
|
|
72
71
|
export interface SessionStateSnapshot {
|
|
73
72
|
serializedSchema: SerializedSchema
|
|
74
73
|
isReadonly: boolean
|
|
75
|
-
/**
|
|
76
|
-
* Write access for the record types listed in `objectTypes`. Optional so snapshots persisted
|
|
77
|
-
* before this field existed resume cleanly (they fail closed to 'read').
|
|
78
|
-
*/
|
|
79
|
-
objectAccess?: TLObjectStoreAccess
|
|
80
74
|
presenceId: string | null
|
|
81
75
|
presenceRecord: UnknownRecord | null
|
|
82
76
|
requiresLegacyRejection: boolean
|
|
@@ -126,24 +120,6 @@ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
|
|
|
126
120
|
}) => void
|
|
127
121
|
/** @internal */
|
|
128
122
|
onPresenceChange?(): void
|
|
129
|
-
/**
|
|
130
|
-
* Called once after a client push commits, with the committed document diff. Use to react to
|
|
131
|
-
* document changes as soon as they commit — e.g. project certain record types to an external
|
|
132
|
-
* store. Best-effort: do not throw and do not block.
|
|
133
|
-
*
|
|
134
|
-
* Only fires for client pushes. Server-initiated changes — `updateStore`, `loadSnapshot`, or
|
|
135
|
-
* writing to the storage directly — do not trigger it, so anything mirroring room state through
|
|
136
|
-
* this callback must handle those paths itself.
|
|
137
|
-
*/
|
|
138
|
-
// eslint-disable-next-line tldraw/method-signature-style
|
|
139
|
-
onCommittedChanges?: (args: { diff: TLSyncForwardDiff<R>; documentClock: number }) => void
|
|
140
|
-
/**
|
|
141
|
-
* Record type names to serve through the object-store lane instead of the document lane.
|
|
142
|
-
* Each must be a document-scoped type registered in the schema. Object-lane writes are gated
|
|
143
|
-
* per session by `objectAccess` (see {@link TLSocketRoom.handleSocketConnect}) rather than
|
|
144
|
-
* `isReadonly`, so e.g. a session can be allowed to comment without being allowed to edit.
|
|
145
|
-
*/
|
|
146
|
-
objectTypes?: readonly string[]
|
|
147
123
|
/**
|
|
148
124
|
* When set, the room will call {@link TLSocketRoom.getSessionSnapshot} after
|
|
149
125
|
* no message activity for a session for 5s and pass the result to this callback.
|
|
@@ -254,8 +230,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
254
230
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
255
231
|
opts.initialSnapshot ?? DEFAULT_INITIAL_SNAPSHOT
|
|
256
232
|
),
|
|
257
|
-
// keep the storage partition in step with the room's object lane
|
|
258
|
-
objectTypes: opts.objectTypes,
|
|
259
233
|
})
|
|
260
234
|
|
|
261
235
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
@@ -269,8 +243,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
269
243
|
}
|
|
270
244
|
this.room = new TLSyncRoom<R, SessionMeta>({
|
|
271
245
|
onPresenceChange: opts.onPresenceChange,
|
|
272
|
-
onCommittedChanges: opts.onCommittedChanges,
|
|
273
|
-
objectTypes: opts.objectTypes,
|
|
274
246
|
schema: opts.schema ?? (createTLSchema() as any),
|
|
275
247
|
log: opts.log,
|
|
276
248
|
storage,
|
|
@@ -311,9 +283,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
311
283
|
* - sessionId - Unique identifier for the client session (typically from browser tab)
|
|
312
284
|
* - socket - WebSocket-like object for client communication
|
|
313
285
|
* - isReadonly - Whether the client can modify the document (defaults to false)
|
|
314
|
-
* - objectAccess - Write access for object-store lane record types (defaults to 'write').
|
|
315
|
-
* Independent of isReadonly, so a session can be allowed to write objects (e.g. comments)
|
|
316
|
-
* without being allowed to edit the document.
|
|
317
286
|
* - meta - Additional session metadata (required if SessionMeta is not void)
|
|
318
287
|
*
|
|
319
288
|
* @example
|
|
@@ -341,10 +310,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
341
310
|
sessionId: string
|
|
342
311
|
socket: WebSocketMinimal
|
|
343
312
|
isReadonly?: boolean
|
|
344
|
-
objectAccess?: TLObjectStoreAccess
|
|
345
313
|
} & (SessionMeta extends void ? object : { meta: SessionMeta })
|
|
346
314
|
) {
|
|
347
|
-
const { sessionId, socket, isReadonly = false
|
|
315
|
+
const { sessionId, socket, isReadonly = false } = opts
|
|
348
316
|
const handleSocketMessage = (event: MessageEvent) =>
|
|
349
317
|
this.handleSocketMessage(sessionId, event.data)
|
|
350
318
|
const handleSocketError = this.handleSocketError.bind(this, sessionId)
|
|
@@ -363,7 +331,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
363
331
|
this.room.handleNewSession({
|
|
364
332
|
sessionId,
|
|
365
333
|
isReadonly,
|
|
366
|
-
objectAccess,
|
|
367
334
|
socket: new ServerSocketAdapter({
|
|
368
335
|
ws: socket,
|
|
369
336
|
onBeforeSendMessage: this.opts.onBeforeSendMessage
|
|
@@ -573,9 +540,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
573
540
|
this.room.handleResumedSession({
|
|
574
541
|
sessionId,
|
|
575
542
|
isReadonly: snapshot.isReadonly,
|
|
576
|
-
// snapshots persisted before this field existed fail closed — those sessions predate
|
|
577
|
-
// the record types gated by objectAccess, so they have nothing to write anyway
|
|
578
|
-
objectAccess: snapshot.objectAccess ?? 'read',
|
|
579
543
|
serializedSchema: snapshot.serializedSchema,
|
|
580
544
|
presenceId: snapshot.presenceId,
|
|
581
545
|
presenceRecord: snapshot.presenceRecord,
|
|
@@ -631,7 +595,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
631
595
|
return {
|
|
632
596
|
serializedSchema: session.serializedSchema,
|
|
633
597
|
isReadonly: session.isReadonly,
|
|
634
|
-
objectAccess: session.objectAccess,
|
|
635
598
|
presenceId: session.presenceId,
|
|
636
599
|
presenceRecord,
|
|
637
600
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
@@ -707,7 +670,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
707
670
|
sessionId: string
|
|
708
671
|
isConnected: boolean
|
|
709
672
|
isReadonly: boolean
|
|
710
|
-
objectAccess: TLObjectStoreAccess
|
|
711
673
|
meta: SessionMeta
|
|
712
674
|
}> {
|
|
713
675
|
return [...this.room.sessions.values()].map((session) => {
|
|
@@ -715,7 +677,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
715
677
|
sessionId: session.sessionId,
|
|
716
678
|
isConnected: session.state === RoomSessionState.Connected,
|
|
717
679
|
isReadonly: session.isReadonly,
|
|
718
|
-
objectAccess: session.objectAccess,
|
|
719
680
|
meta: session.meta,
|
|
720
681
|
}
|
|
721
682
|
})
|
|
@@ -747,18 +708,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
747
708
|
throw new Error('getCurrentSnapshot is not supported for this storage type')
|
|
748
709
|
}
|
|
749
710
|
|
|
750
|
-
/**
|
|
751
|
-
* Returns a snapshot of the object-store lane (see `objectTypes`), for persisting
|
|
752
|
-
* object-lane records separately from the document. Same entry shape as
|
|
753
|
-
* {@link RoomSnapshot.documents}.
|
|
754
|
-
*/
|
|
755
|
-
getCurrentObjectsSnapshot(): RoomSnapshot['documents'] {
|
|
756
|
-
if (this.storage.getObjectsSnapshot) {
|
|
757
|
-
return this.storage.getObjectsSnapshot()
|
|
758
|
-
}
|
|
759
|
-
throw new Error('getCurrentObjectsSnapshot is not supported for this storage type')
|
|
760
|
-
}
|
|
761
|
-
|
|
762
711
|
/**
|
|
763
712
|
* Retrieves all presence records from the document store. Presence records
|
|
764
713
|
* contain ephemeral user state like cursor positions and selections.
|
|
@@ -476,20 +476,13 @@ describe('TLSyncClient', () => {
|
|
|
476
476
|
client = createClient()
|
|
477
477
|
socket.mockServerMessage(createConnectMessage({ isReadonly: false }))
|
|
478
478
|
expect(client.isConnectedToRoom).toBe(true)
|
|
479
|
-
|
|
480
|
-
expect(onAfterConnect).toHaveBeenCalledWith(client, {
|
|
481
|
-
isReadonly: false,
|
|
482
|
-
objectAccess: 'write',
|
|
483
|
-
})
|
|
479
|
+
expect(onAfterConnect).toHaveBeenCalledWith(client, { isReadonly: false })
|
|
484
480
|
|
|
485
481
|
// reconnect as readonly
|
|
486
482
|
socket.mockConnectionStatus('offline')
|
|
487
483
|
socket.mockConnectionStatus('online')
|
|
488
484
|
socket.mockServerMessage(createConnectMessage({ isReadonly: true }))
|
|
489
|
-
expect(onAfterConnect).toHaveBeenLastCalledWith(client, {
|
|
490
|
-
isReadonly: true,
|
|
491
|
-
objectAccess: 'write',
|
|
492
|
-
})
|
|
485
|
+
expect(onAfterConnect).toHaveBeenLastCalledWith(client, { isReadonly: true })
|
|
493
486
|
})
|
|
494
487
|
|
|
495
488
|
it('[CL7] pushes the current presence state after connecting', () => {
|
package/src/lib/TLSyncClient.ts
CHANGED
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
} from './diff'
|
|
25
25
|
import { interval } from './interval'
|
|
26
26
|
import {
|
|
27
|
-
TLObjectStoreAccess,
|
|
28
27
|
TLPushRequest,
|
|
29
28
|
TLSocketClientSentEvent,
|
|
30
29
|
TLSocketServerSentDataEvent,
|
|
@@ -438,13 +437,8 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
438
437
|
* @param self - The TLSyncClient instance that connected
|
|
439
438
|
* @param details - Connection details
|
|
440
439
|
* - isReadonly - Whether the connection is in read-only mode
|
|
441
|
-
* - objectAccess - Write access for object-store lane record types (defaults to 'write'
|
|
442
|
-
* when the server doesn't send it, e.g. older servers or rooms with no object lane)
|
|
443
440
|
*/
|
|
444
|
-
private readonly onAfterConnect?: (
|
|
445
|
-
self: this,
|
|
446
|
-
details: { isReadonly: boolean; objectAccess: TLObjectStoreAccess }
|
|
447
|
-
) => void
|
|
441
|
+
private readonly onAfterConnect?: (self: this, details: { isReadonly: boolean }) => void
|
|
448
442
|
|
|
449
443
|
private readonly onCustomMessageReceived?: TLCustomMessageHandler
|
|
450
444
|
|
|
@@ -484,10 +478,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
484
478
|
onLoad(self: TLSyncClient<R, S>): void
|
|
485
479
|
onSyncError(reason: string): void
|
|
486
480
|
onCustomMessageReceived?: TLCustomMessageHandler
|
|
487
|
-
onAfterConnect?(
|
|
488
|
-
self: TLSyncClient<R, S>,
|
|
489
|
-
details: { isReadonly: boolean; objectAccess: TLObjectStoreAccess }
|
|
490
|
-
): void
|
|
481
|
+
onAfterConnect?(self: TLSyncClient<R, S>, details: { isReadonly: boolean }): void
|
|
491
482
|
didCancel?(): boolean
|
|
492
483
|
}) {
|
|
493
484
|
this.didCancel = config.didCancel
|
|
@@ -766,10 +757,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
766
757
|
// this.isConnectedToRoom = true
|
|
767
758
|
// this.store.applyDiff(stashedChanges, false)
|
|
768
759
|
|
|
769
|
-
this.onAfterConnect?.(this, {
|
|
770
|
-
isReadonly: event.isReadonly,
|
|
771
|
-
objectAccess: event.objectAccess ?? 'write',
|
|
772
|
-
})
|
|
760
|
+
this.onAfterConnect?.(this, { isReadonly: event.isReadonly })
|
|
773
761
|
const presence = this.presenceState?.get()
|
|
774
762
|
if (presence) {
|
|
775
763
|
this.pushPresence(presence)
|
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -31,7 +31,6 @@ import { interval } from './interval'
|
|
|
31
31
|
import {
|
|
32
32
|
getTlsyncProtocolVersion,
|
|
33
33
|
TLIncompatibilityReason,
|
|
34
|
-
TLObjectStoreAccess,
|
|
35
34
|
TLSocketClientSentEvent,
|
|
36
35
|
TLSocketServerSentDataEvent,
|
|
37
36
|
TLSocketServerSentEvent,
|
|
@@ -237,43 +236,22 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
237
236
|
readonly serializedSchema: SerializedSchema
|
|
238
237
|
|
|
239
238
|
readonly documentTypes: Set<string>
|
|
240
|
-
/**
|
|
241
|
-
* Record types served by the object-store lane. Object records ride the same wire messages
|
|
242
|
-
* as document records but are gated by the session's `objectAccess` instead of `isReadonly`,
|
|
243
|
-
* and are excluded from `documentTypes` so hosts can persist them in a separate lane.
|
|
244
|
-
*/
|
|
245
|
-
readonly objectTypes: Set<string>
|
|
246
239
|
readonly presenceType: RecordType<R, any> | null
|
|
247
240
|
private log?: TLSyncLog
|
|
248
241
|
public readonly schema: StoreSchema<R, any>
|
|
249
242
|
private onPresenceChange?(): void
|
|
250
|
-
private onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
|
|
251
243
|
private readonly sessionIdleTimeout: number
|
|
252
244
|
|
|
253
245
|
constructor(opts: {
|
|
254
246
|
log?: TLSyncLog
|
|
255
247
|
schema: StoreSchema<R, any>
|
|
256
248
|
onPresenceChange?(): void
|
|
257
|
-
/**
|
|
258
|
-
* Called once after a client push commits, with the committed document diff. Fires for
|
|
259
|
-
* local and remote pushes. Use this to react to document changes (e.g. persist certain
|
|
260
|
-
* record types to a separate lane, or project them to an external store) as soon as they
|
|
261
|
-
* commit. Best-effort — do not throw; do not block.
|
|
262
|
-
*/
|
|
263
|
-
onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
|
|
264
|
-
/**
|
|
265
|
-
* Record type names to serve through the object-store lane instead of the document lane.
|
|
266
|
-
* Each must be a document-scoped type registered in the schema. Object-lane writes are
|
|
267
|
-
* gated per session by `objectAccess` rather than `isReadonly`.
|
|
268
|
-
*/
|
|
269
|
-
objectTypes?: readonly string[]
|
|
270
249
|
storage: TLSyncStorage<R>
|
|
271
250
|
clientTimeout?: number
|
|
272
251
|
}) {
|
|
273
252
|
this.schema = opts.schema
|
|
274
253
|
this.log = opts.log
|
|
275
254
|
this.onPresenceChange = opts.onPresenceChange
|
|
276
|
-
this.onCommittedChanges = opts.onCommittedChanges
|
|
277
255
|
this.storage = opts.storage
|
|
278
256
|
this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
|
|
279
257
|
|
|
@@ -286,20 +264,9 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
286
264
|
// do a json serialization cycle to make sure the schema has no 'undefined' values
|
|
287
265
|
this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()))
|
|
288
266
|
|
|
289
|
-
this.objectTypes = new Set(opts.objectTypes ?? [])
|
|
290
|
-
for (const typeName of this.objectTypes) {
|
|
291
|
-
const type = getOwnProperty(this.schema.types, typeName)
|
|
292
|
-
assert(type, `TLSyncRoom: object type '${typeName}' is not registered in the schema`)
|
|
293
|
-
assert(
|
|
294
|
-
type.scope === 'document',
|
|
295
|
-
`TLSyncRoom: object type '${typeName}' must have scope 'document', got '${type.scope}'`
|
|
296
|
-
)
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// object-lane types are partitioned out of the document lane
|
|
300
267
|
this.documentTypes = new Set(
|
|
301
268
|
Object.values<RecordType<R, any>>(this.schema.types)
|
|
302
|
-
.filter((t) => t.scope === 'document'
|
|
269
|
+
.filter((t) => t.scope === 'document')
|
|
303
270
|
.map((t) => t.typeName)
|
|
304
271
|
)
|
|
305
272
|
|
|
@@ -475,7 +442,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
475
442
|
cancellationTime: Date.now(),
|
|
476
443
|
meta: session.meta,
|
|
477
444
|
isReadonly: session.isReadonly,
|
|
478
|
-
objectAccess: session.objectAccess,
|
|
479
445
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
480
446
|
supportsStringAppend: session.supportsStringAppend,
|
|
481
447
|
})
|
|
@@ -586,9 +552,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
586
552
|
socket: TLRoomSocket<R>
|
|
587
553
|
meta: SessionMeta
|
|
588
554
|
isReadonly: boolean
|
|
589
|
-
objectAccess?: TLObjectStoreAccess
|
|
590
555
|
}) {
|
|
591
|
-
const { sessionId, socket, meta, isReadonly
|
|
556
|
+
const { sessionId, socket, meta, isReadonly } = opts
|
|
592
557
|
const existing = this.sessions.get(sessionId)
|
|
593
558
|
this.sessions.set(sessionId, {
|
|
594
559
|
state: RoomSessionState.AwaitingConnectMessage,
|
|
@@ -598,7 +563,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
598
563
|
sessionStartTime: Date.now(),
|
|
599
564
|
meta,
|
|
600
565
|
isReadonly: isReadonly ?? false,
|
|
601
|
-
objectAccess: objectAccess ?? 'write',
|
|
602
566
|
// this gets set later during handleConnectMessage
|
|
603
567
|
requiresLegacyRejection: false,
|
|
604
568
|
supportsStringAppend: true,
|
|
@@ -618,7 +582,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
618
582
|
socket: TLRoomSocket<R>
|
|
619
583
|
meta: SessionMeta
|
|
620
584
|
isReadonly: boolean
|
|
621
|
-
objectAccess?: TLObjectStoreAccess
|
|
622
585
|
serializedSchema: SerializedSchema
|
|
623
586
|
presenceId: string | null
|
|
624
587
|
presenceRecord: UnknownRecord | null
|
|
@@ -630,7 +593,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
630
593
|
socket,
|
|
631
594
|
meta,
|
|
632
595
|
isReadonly,
|
|
633
|
-
objectAccess,
|
|
634
596
|
serializedSchema,
|
|
635
597
|
presenceId,
|
|
636
598
|
presenceRecord,
|
|
@@ -653,7 +615,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
653
615
|
outstandingDataMessages: [],
|
|
654
616
|
meta,
|
|
655
617
|
isReadonly,
|
|
656
|
-
objectAccess: objectAccess ?? 'write',
|
|
657
618
|
requiresLegacyRejection,
|
|
658
619
|
supportsStringAppend,
|
|
659
620
|
})
|
|
@@ -966,7 +927,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
966
927
|
supportsStringAppend: session.supportsStringAppend,
|
|
967
928
|
meta: session.meta,
|
|
968
929
|
isReadonly: session.isReadonly,
|
|
969
|
-
objectAccess: session.objectAccess,
|
|
970
930
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
971
931
|
})
|
|
972
932
|
this._unsafe_sendMessage(session.sessionId, msg)
|
|
@@ -1017,7 +977,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1017
977
|
serverClock: txn.getClock(),
|
|
1018
978
|
diff: { ...presenceDiff.value, ...docDiff },
|
|
1019
979
|
isReadonly: session.isReadonly,
|
|
1020
|
-
objectAccess: session.objectAccess,
|
|
1021
980
|
} satisfies Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>
|
|
1022
981
|
}) // no id needed because this only reads, no writes.
|
|
1023
982
|
|
|
@@ -1199,29 +1158,14 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1199
1158
|
}
|
|
1200
1159
|
}
|
|
1201
1160
|
}
|
|
1202
|
-
|
|
1203
|
-
// records by `objectAccess`. Denied ops are skipped (the client is corrected by the
|
|
1204
|
-
// resulting discard/rebase push_result, exactly as whole-diff readonly skips were).
|
|
1205
|
-
// Server-initiated pushes (no session) are always allowed.
|
|
1206
|
-
const canWrite = (typeName: string) =>
|
|
1207
|
-
!session ||
|
|
1208
|
-
(this.objectTypes.has(typeName) ? session.objectAccess !== 'read' : !session.isReadonly)
|
|
1209
|
-
|
|
1210
|
-
if (message.diff) {
|
|
1161
|
+
if (message.diff && !session?.isReadonly) {
|
|
1211
1162
|
// The push request was for the document scope.
|
|
1212
1163
|
for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
|
|
1213
1164
|
switch (op[0]) {
|
|
1214
1165
|
case RecordOpType.Put: {
|
|
1215
|
-
// The write gate is checked before type validation so that a denied
|
|
1216
|
-
// session's ops are skipped without rejection, matching the previous
|
|
1217
|
-
// whole-diff readonly behavior.
|
|
1218
|
-
if (!canWrite(op[1].typeName)) continue
|
|
1219
1166
|
// Try to add the document.
|
|
1220
1167
|
// If we're putting a record with a type that we don't recognize, fail
|
|
1221
|
-
if (
|
|
1222
|
-
!this.documentTypes.has(op[1].typeName) &&
|
|
1223
|
-
!this.objectTypes.has(op[1].typeName)
|
|
1224
|
-
) {
|
|
1168
|
+
if (!this.documentTypes.has(op[1].typeName)) {
|
|
1225
1169
|
throw new TLSyncError(
|
|
1226
1170
|
'invalid record',
|
|
1227
1171
|
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
@@ -1231,10 +1175,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1231
1175
|
break
|
|
1232
1176
|
}
|
|
1233
1177
|
case RecordOpType.Patch: {
|
|
1234
|
-
const doc = txn.get(id) as R | undefined
|
|
1235
|
-
// if it was already deleted, there's no need to apply the patch
|
|
1236
|
-
if (!doc) continue
|
|
1237
|
-
if (!canWrite(doc.typeName)) continue
|
|
1238
1178
|
// Try to patch the document. If it fails, stop here.
|
|
1239
1179
|
patchDocument(txn, docChanges, id, op[1])
|
|
1240
1180
|
break
|
|
@@ -1245,7 +1185,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1245
1185
|
// If the doc was already deleted, don't do anything, no need to propagate a delete op
|
|
1246
1186
|
continue
|
|
1247
1187
|
}
|
|
1248
|
-
if (!canWrite(doc.typeName)) continue
|
|
1249
1188
|
|
|
1250
1189
|
// Delete the document and propagate the delete op
|
|
1251
1190
|
// delete automatically creates tombstones
|
|
@@ -1333,17 +1272,6 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1333
1272
|
this.onPresenceChange?.()
|
|
1334
1273
|
})
|
|
1335
1274
|
}
|
|
1336
|
-
|
|
1337
|
-
if (result.docChanges.diffs && this.onCommittedChanges) {
|
|
1338
|
-
const diff = result.docChanges.diffs.diff
|
|
1339
|
-
queueMicrotask(() => {
|
|
1340
|
-
try {
|
|
1341
|
-
this.onCommittedChanges?.({ diff, documentClock })
|
|
1342
|
-
} catch (e) {
|
|
1343
|
-
this.log?.error?.('onCommittedChanges threw', e)
|
|
1344
|
-
}
|
|
1345
|
-
})
|
|
1346
|
-
}
|
|
1347
1275
|
}
|
|
1348
1276
|
|
|
1349
1277
|
/**
|
package/src/lib/TLSyncStorage.ts
CHANGED
|
@@ -83,15 +83,7 @@ export interface TLSyncStorage<R extends UnknownRecord> {
|
|
|
83
83
|
|
|
84
84
|
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void
|
|
85
85
|
|
|
86
|
-
/** A snapshot of the document lane only — never contains object-store lane records. */
|
|
87
86
|
getSnapshot?(): RoomSnapshot
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* A snapshot of the object-store lane (see `objectTypes` on the storage), for hosts that
|
|
91
|
-
* persist object-lane records separately from the document. Same entry shape as
|
|
92
|
-
* `RoomSnapshot['documents']` so a lane can be persisted and re-seeded via a merged snapshot.
|
|
93
|
-
*/
|
|
94
|
-
getObjectsSnapshot?(): RoomSnapshot['documents']
|
|
95
87
|
}
|
|
96
88
|
|
|
97
89
|
/**
|
package/src/lib/protocol.ts
CHANGED
|
@@ -24,17 +24,6 @@ export function getTlsyncProtocolVersion() {
|
|
|
24
24
|
return TLSYNC_PROTOCOL_VERSION
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
* Access level for object-store lane record types (see `objectTypes` on the room).
|
|
29
|
-
*
|
|
30
|
-
* Object-lane records (e.g. comments) are gated by this per-session value instead of the
|
|
31
|
-
* document-lane `isReadonly` flag, so a session can be allowed to write objects without
|
|
32
|
-
* being allowed to edit the document ("can comment but not edit"), or vice versa.
|
|
33
|
-
*
|
|
34
|
-
* @public
|
|
35
|
-
*/
|
|
36
|
-
export type TLObjectStoreAccess = 'read' | 'write'
|
|
37
|
-
|
|
38
27
|
/**
|
|
39
28
|
* Constants defining the different types of protocol incompatibility reasons.
|
|
40
29
|
*
|
|
@@ -119,12 +108,6 @@ export type TLSocketServerSentEvent<R extends UnknownRecord> =
|
|
|
119
108
|
diff: NetworkDiff<R>
|
|
120
109
|
serverClock: number
|
|
121
110
|
isReadonly: boolean
|
|
122
|
-
/**
|
|
123
|
-
* Write access for object-store lane record types, when the room has an object lane.
|
|
124
|
-
* Optional and additive: servers without an object lane (or older servers) omit it, and
|
|
125
|
-
* older clients ignore it — no protocol version bump needed.
|
|
126
|
-
*/
|
|
127
|
-
objectAccess?: TLObjectStoreAccess
|
|
128
111
|
}
|
|
129
112
|
| {
|
|
130
113
|
type: 'incompatibility_error'
|
|
@@ -48,13 +48,8 @@ function createMockSocket(overrides: Partial<WebSocketMinimal> = {}): WebSocketM
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// Connect a session and complete the connect handshake
|
|
51
|
-
function connectSession(
|
|
52
|
-
room
|
|
53
|
-
sessionId: string,
|
|
54
|
-
socket: WebSocketMinimal,
|
|
55
|
-
opts: { objectAccess?: 'read' | 'write' } = {}
|
|
56
|
-
) {
|
|
57
|
-
room.handleSocketConnect({ sessionId, socket, ...opts })
|
|
51
|
+
function connectSession(room: TLSocketRoom<any, any>, sessionId: string, socket: WebSocketMinimal) {
|
|
52
|
+
room.handleSocketConnect({ sessionId, socket })
|
|
58
53
|
const connectRequest = {
|
|
59
54
|
type: 'connect' as const,
|
|
60
55
|
connectRequestId: `connect-${sessionId}`,
|
|
@@ -1002,41 +997,11 @@ describe('28. TLSocketRoom (SR)', () => {
|
|
|
1002
997
|
expect(snapshot).not.toBeNull()
|
|
1003
998
|
expect(snapshot!.serializedSchema).toBeDefined()
|
|
1004
999
|
expect(snapshot!.isReadonly).toBe(false)
|
|
1005
|
-
expect(snapshot!.objectAccess).toBe('write')
|
|
1006
1000
|
expect(snapshot!.presenceId).toBeDefined()
|
|
1007
1001
|
expect(snapshot!.requiresLegacyRejection).toBe(false)
|
|
1008
1002
|
expect(snapshot!.supportsStringAppend).toBe(true)
|
|
1009
1003
|
})
|
|
1010
1004
|
|
|
1011
|
-
it('[SR13] round-trips objectAccess through snapshot and resume', () => {
|
|
1012
|
-
const room = new TLSocketRoom({})
|
|
1013
|
-
const socket = createMockSocket()
|
|
1014
|
-
connectSession(room, 'test', socket, { objectAccess: 'read' })
|
|
1015
|
-
|
|
1016
|
-
const snapshot = room.getSessionSnapshot('test')!
|
|
1017
|
-
expect(snapshot.objectAccess).toBe('read')
|
|
1018
|
-
|
|
1019
|
-
// Simulate hibernation: resume in a new room
|
|
1020
|
-
const room2 = new TLSocketRoom({})
|
|
1021
|
-
room2.handleSocketResume({ sessionId: 'test', socket: createMockSocket(), snapshot })
|
|
1022
|
-
expect(room2.getSessions()[0].objectAccess).toBe('read')
|
|
1023
|
-
})
|
|
1024
|
-
|
|
1025
|
-
it('[SR13] resumes legacy snapshots without objectAccess as read (fail closed)', () => {
|
|
1026
|
-
const room = new TLSocketRoom({})
|
|
1027
|
-
const socket = createMockSocket()
|
|
1028
|
-
connectSession(room, 'test', socket)
|
|
1029
|
-
|
|
1030
|
-
const snapshot = room.getSessionSnapshot('test')!
|
|
1031
|
-
// simulate a snapshot persisted before the objectAccess field existed
|
|
1032
|
-
delete snapshot.objectAccess
|
|
1033
|
-
|
|
1034
|
-
const room2 = new TLSocketRoom({})
|
|
1035
|
-
room2.handleSocketResume({ sessionId: 'test', socket: createMockSocket(), snapshot })
|
|
1036
|
-
// such sessions predate the record types gated by objectAccess, so they have nothing to write anyway
|
|
1037
|
-
expect(room2.getSessions()[0].objectAccess).toBe('read')
|
|
1038
|
-
})
|
|
1039
|
-
|
|
1040
1005
|
it('[SR13] includes presence record when present', () => {
|
|
1041
1006
|
const store = getStore()
|
|
1042
1007
|
store.ensureStoreIsUsable()
|
|
@@ -188,7 +188,6 @@ function makeRoom(
|
|
|
188
188
|
clientTimeout?: number
|
|
189
189
|
log?: { warn?: Mock; error?: Mock }
|
|
190
190
|
onPresenceChange?(): void
|
|
191
|
-
onCommittedChanges?(args: { diff: any; documentClock: number }): void
|
|
192
191
|
} = {}
|
|
193
192
|
) {
|
|
194
193
|
const storage = new InMemorySyncStorage<TLRecord>({
|
|
@@ -200,7 +199,6 @@ function makeRoom(
|
|
|
200
199
|
clientTimeout: opts.clientTimeout,
|
|
201
200
|
log: opts.log,
|
|
202
201
|
onPresenceChange: opts.onPresenceChange,
|
|
203
|
-
onCommittedChanges: opts.onCommittedChanges,
|
|
204
202
|
})
|
|
205
203
|
disposables.push(() => room.close())
|
|
206
204
|
return { storage, room }
|
|
@@ -636,29 +634,6 @@ describe('22. Room construction (RC)', () => {
|
|
|
636
634
|
expect(clientBMessages[0].data[0].type).toBe('patch')
|
|
637
635
|
})
|
|
638
636
|
|
|
639
|
-
it('fires onCommittedChanges once with the committed document diff after a push', async () => {
|
|
640
|
-
const onCommittedChanges = vi.fn()
|
|
641
|
-
const { room } = makeRoom({ onCommittedChanges })
|
|
642
|
-
connectSession(room, 'session-a')
|
|
643
|
-
|
|
644
|
-
const newPage = makePage('committed_page', 'Committed Page')
|
|
645
|
-
room.handleMessage('session-a', {
|
|
646
|
-
type: 'push',
|
|
647
|
-
clientClock: 1,
|
|
648
|
-
diff: {
|
|
649
|
-
[newPage.id]: ['put', newPage],
|
|
650
|
-
},
|
|
651
|
-
} as TLPushRequest<TLRecord>)
|
|
652
|
-
|
|
653
|
-
// the tap fires in a microtask, like onPresenceChange
|
|
654
|
-
await Promise.resolve()
|
|
655
|
-
|
|
656
|
-
expect(onCommittedChanges).toHaveBeenCalledTimes(1)
|
|
657
|
-
const arg = onCommittedChanges.mock.calls[0][0]
|
|
658
|
-
expect(arg.diff.puts[newPage.id]).toBeTruthy()
|
|
659
|
-
expect(typeof arg.documentClock).toBe('number')
|
|
660
|
-
})
|
|
661
|
-
|
|
662
637
|
it('[RC4] handles multiple rapid external changes', async () => {
|
|
663
638
|
const { room, storage } = makeRoom()
|
|
664
639
|
const socket = connectSession(room, 'test-session')
|
package/src/test/TestServer.ts
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import { StoreSchema, UnknownRecord } from '@tldraw/store'
|
|
2
2
|
import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
|
|
3
|
-
import { TLObjectStoreAccess } from '../lib/protocol'
|
|
4
3
|
import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom'
|
|
5
4
|
import { TestSocketPair } from './TestSocketPair'
|
|
6
5
|
|
|
7
|
-
type TestRoomOptions<R extends UnknownRecord> = Omit<
|
|
8
|
-
ConstructorParameters<typeof TLSyncRoom<R, undefined>>[0],
|
|
9
|
-
'schema' | 'storage'
|
|
10
|
-
>
|
|
11
|
-
|
|
12
6
|
export class TestServer<R extends UnknownRecord, P = unknown> {
|
|
13
7
|
room: TLSyncRoom<R, undefined>
|
|
14
8
|
storage: InMemorySyncStorage<R>
|
|
15
|
-
constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot
|
|
9
|
+
constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot) {
|
|
16
10
|
// Use provided snapshot or create an empty one with the current schema
|
|
17
11
|
this.storage = new InMemorySyncStorage<R>({
|
|
18
12
|
snapshot: snapshot ?? {
|
|
@@ -22,19 +16,15 @@ export class TestServer<R extends UnknownRecord, P = unknown> {
|
|
|
22
16
|
schema: schema.serialize(),
|
|
23
17
|
},
|
|
24
18
|
})
|
|
25
|
-
this.room = new TLSyncRoom<R, undefined>({ schema, storage: this.storage
|
|
19
|
+
this.room = new TLSyncRoom<R, undefined>({ schema, storage: this.storage })
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
connect(
|
|
29
|
-
socketPair: TestSocketPair<R>,
|
|
30
|
-
opts?: { isReadonly?: boolean; objectAccess?: TLObjectStoreAccess }
|
|
31
|
-
): void {
|
|
22
|
+
connect(socketPair: TestSocketPair<R>): void {
|
|
32
23
|
this.room.handleNewSession({
|
|
33
24
|
sessionId: socketPair.id,
|
|
34
25
|
socket: socketPair.roomSocket,
|
|
35
26
|
meta: undefined,
|
|
36
|
-
isReadonly:
|
|
37
|
-
objectAccess: opts?.objectAccess,
|
|
27
|
+
isReadonly: false,
|
|
38
28
|
})
|
|
39
29
|
|
|
40
30
|
socketPair.clientSocket.connectionStatus = 'online'
|