@tldraw/sync-core 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.d205573d66cb
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 +76 -3
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +55 -20
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +115 -52
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +26 -2
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +6 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +43 -5
- 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 +76 -3
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +55 -20
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +115 -52
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +26 -2
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +6 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +43 -5
- 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 +1 -0
- package/src/lib/InMemorySyncStorage.ts +74 -21
- package/src/lib/RoomSession.ts +7 -2
- package/src/lib/SQLiteSyncStorage.test.ts +29 -2
- package/src/lib/SQLiteSyncStorage.ts +158 -59
- package/src/lib/TLSocketRoom.ts +53 -2
- package/src/lib/TLSyncClient.test.ts +9 -2
- package/src/lib/TLSyncClient.ts +15 -3
- package/src/lib/TLSyncRoom.ts +76 -4
- package/src/lib/TLSyncStorage.ts +8 -0
- package/src/lib/protocol.ts +17 -0
- package/src/test/TLSocketRoom.test.ts +37 -2
- package/src/test/TLSyncClientRebase.test.ts +285 -0
- package/src/test/TLSyncRoom.test.ts +25 -0
- package/src/test/TestServer.ts +14 -4
- package/src/test/objectStore.test.ts +387 -0
- package/src/test/storageContractSuite.ts +79 -0
- package/src/test/upgradeDowngrade.test.ts +47 -0
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 { TLSocketServerSentEvent } from './protocol'
|
|
6
|
+
import { TLObjectStoreAccess, TLSocketServerSentEvent } from './protocol'
|
|
7
7
|
import { RoomSessionState } from './RoomSession'
|
|
8
8
|
import { ServerSocketAdapter, WebSocketMinimal } from './ServerSocketAdapter'
|
|
9
9
|
import { TLSyncErrorCloseEventReason } from './TLSyncClient'
|
|
@@ -11,6 +11,7 @@ import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom'
|
|
|
11
11
|
import {
|
|
12
12
|
convertStoreSnapshotToRoomSnapshot,
|
|
13
13
|
loadSnapshotIntoStorage,
|
|
14
|
+
TLSyncForwardDiff,
|
|
14
15
|
TLSyncStorage,
|
|
15
16
|
} from './TLSyncStorage'
|
|
16
17
|
|
|
@@ -71,6 +72,11 @@ export interface TLSyncLog {
|
|
|
71
72
|
export interface SessionStateSnapshot {
|
|
72
73
|
serializedSchema: SerializedSchema
|
|
73
74
|
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
|
|
74
80
|
presenceId: string | null
|
|
75
81
|
presenceRecord: UnknownRecord | null
|
|
76
82
|
requiresLegacyRejection: boolean
|
|
@@ -120,6 +126,24 @@ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
|
|
|
120
126
|
}) => void
|
|
121
127
|
/** @internal */
|
|
122
128
|
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[]
|
|
123
147
|
/**
|
|
124
148
|
* When set, the room will call {@link TLSocketRoom.getSessionSnapshot} after
|
|
125
149
|
* no message activity for a session for 5s and pass the result to this callback.
|
|
@@ -230,6 +254,8 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
230
254
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
231
255
|
opts.initialSnapshot ?? DEFAULT_INITIAL_SNAPSHOT
|
|
232
256
|
),
|
|
257
|
+
// keep the storage partition in step with the room's object lane
|
|
258
|
+
objectTypes: opts.objectTypes,
|
|
233
259
|
})
|
|
234
260
|
|
|
235
261
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
@@ -243,6 +269,8 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
243
269
|
}
|
|
244
270
|
this.room = new TLSyncRoom<R, SessionMeta>({
|
|
245
271
|
onPresenceChange: opts.onPresenceChange,
|
|
272
|
+
onCommittedChanges: opts.onCommittedChanges,
|
|
273
|
+
objectTypes: opts.objectTypes,
|
|
246
274
|
schema: opts.schema ?? (createTLSchema() as any),
|
|
247
275
|
log: opts.log,
|
|
248
276
|
storage,
|
|
@@ -283,6 +311,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
283
311
|
* - sessionId - Unique identifier for the client session (typically from browser tab)
|
|
284
312
|
* - socket - WebSocket-like object for client communication
|
|
285
313
|
* - 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.
|
|
286
317
|
* - meta - Additional session metadata (required if SessionMeta is not void)
|
|
287
318
|
*
|
|
288
319
|
* @example
|
|
@@ -310,9 +341,10 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
310
341
|
sessionId: string
|
|
311
342
|
socket: WebSocketMinimal
|
|
312
343
|
isReadonly?: boolean
|
|
344
|
+
objectAccess?: TLObjectStoreAccess
|
|
313
345
|
} & (SessionMeta extends void ? object : { meta: SessionMeta })
|
|
314
346
|
) {
|
|
315
|
-
const { sessionId, socket, isReadonly = false } = opts
|
|
347
|
+
const { sessionId, socket, isReadonly = false, objectAccess = 'write' } = opts
|
|
316
348
|
const handleSocketMessage = (event: MessageEvent) =>
|
|
317
349
|
this.handleSocketMessage(sessionId, event.data)
|
|
318
350
|
const handleSocketError = this.handleSocketError.bind(this, sessionId)
|
|
@@ -331,6 +363,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
331
363
|
this.room.handleNewSession({
|
|
332
364
|
sessionId,
|
|
333
365
|
isReadonly,
|
|
366
|
+
objectAccess,
|
|
334
367
|
socket: new ServerSocketAdapter({
|
|
335
368
|
ws: socket,
|
|
336
369
|
onBeforeSendMessage: this.opts.onBeforeSendMessage
|
|
@@ -540,6 +573,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
540
573
|
this.room.handleResumedSession({
|
|
541
574
|
sessionId,
|
|
542
575
|
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',
|
|
543
579
|
serializedSchema: snapshot.serializedSchema,
|
|
544
580
|
presenceId: snapshot.presenceId,
|
|
545
581
|
presenceRecord: snapshot.presenceRecord,
|
|
@@ -595,6 +631,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
595
631
|
return {
|
|
596
632
|
serializedSchema: session.serializedSchema,
|
|
597
633
|
isReadonly: session.isReadonly,
|
|
634
|
+
objectAccess: session.objectAccess,
|
|
598
635
|
presenceId: session.presenceId,
|
|
599
636
|
presenceRecord,
|
|
600
637
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
@@ -670,6 +707,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
670
707
|
sessionId: string
|
|
671
708
|
isConnected: boolean
|
|
672
709
|
isReadonly: boolean
|
|
710
|
+
objectAccess: TLObjectStoreAccess
|
|
673
711
|
meta: SessionMeta
|
|
674
712
|
}> {
|
|
675
713
|
return [...this.room.sessions.values()].map((session) => {
|
|
@@ -677,6 +715,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
677
715
|
sessionId: session.sessionId,
|
|
678
716
|
isConnected: session.state === RoomSessionState.Connected,
|
|
679
717
|
isReadonly: session.isReadonly,
|
|
718
|
+
objectAccess: session.objectAccess,
|
|
680
719
|
meta: session.meta,
|
|
681
720
|
}
|
|
682
721
|
})
|
|
@@ -708,6 +747,18 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
708
747
|
throw new Error('getCurrentSnapshot is not supported for this storage type')
|
|
709
748
|
}
|
|
710
749
|
|
|
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
|
+
|
|
711
762
|
/**
|
|
712
763
|
* Retrieves all presence records from the document store. Presence records
|
|
713
764
|
* contain ephemeral user state like cursor positions and selections.
|
|
@@ -476,13 +476,20 @@ describe('TLSyncClient', () => {
|
|
|
476
476
|
client = createClient()
|
|
477
477
|
socket.mockServerMessage(createConnectMessage({ isReadonly: false }))
|
|
478
478
|
expect(client.isConnectedToRoom).toBe(true)
|
|
479
|
-
|
|
479
|
+
// objectAccess defaults to 'write' when the server doesn't send it
|
|
480
|
+
expect(onAfterConnect).toHaveBeenCalledWith(client, {
|
|
481
|
+
isReadonly: false,
|
|
482
|
+
objectAccess: 'write',
|
|
483
|
+
})
|
|
480
484
|
|
|
481
485
|
// reconnect as readonly
|
|
482
486
|
socket.mockConnectionStatus('offline')
|
|
483
487
|
socket.mockConnectionStatus('online')
|
|
484
488
|
socket.mockServerMessage(createConnectMessage({ isReadonly: true }))
|
|
485
|
-
expect(onAfterConnect).toHaveBeenLastCalledWith(client, {
|
|
489
|
+
expect(onAfterConnect).toHaveBeenLastCalledWith(client, {
|
|
490
|
+
isReadonly: true,
|
|
491
|
+
objectAccess: 'write',
|
|
492
|
+
})
|
|
486
493
|
})
|
|
487
494
|
|
|
488
495
|
it('[CL7] pushes the current presence state after connecting', () => {
|
package/src/lib/TLSyncClient.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from './diff'
|
|
25
25
|
import { interval } from './interval'
|
|
26
26
|
import {
|
|
27
|
+
TLObjectStoreAccess,
|
|
27
28
|
TLPushRequest,
|
|
28
29
|
TLSocketClientSentEvent,
|
|
29
30
|
TLSocketServerSentDataEvent,
|
|
@@ -437,8 +438,13 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
437
438
|
* @param self - The TLSyncClient instance that connected
|
|
438
439
|
* @param details - Connection details
|
|
439
440
|
* - 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)
|
|
440
443
|
*/
|
|
441
|
-
private readonly onAfterConnect?: (
|
|
444
|
+
private readonly onAfterConnect?: (
|
|
445
|
+
self: this,
|
|
446
|
+
details: { isReadonly: boolean; objectAccess: TLObjectStoreAccess }
|
|
447
|
+
) => void
|
|
442
448
|
|
|
443
449
|
private readonly onCustomMessageReceived?: TLCustomMessageHandler
|
|
444
450
|
|
|
@@ -478,7 +484,10 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
478
484
|
onLoad(self: TLSyncClient<R, S>): void
|
|
479
485
|
onSyncError(reason: string): void
|
|
480
486
|
onCustomMessageReceived?: TLCustomMessageHandler
|
|
481
|
-
onAfterConnect?(
|
|
487
|
+
onAfterConnect?(
|
|
488
|
+
self: TLSyncClient<R, S>,
|
|
489
|
+
details: { isReadonly: boolean; objectAccess: TLObjectStoreAccess }
|
|
490
|
+
): void
|
|
482
491
|
didCancel?(): boolean
|
|
483
492
|
}) {
|
|
484
493
|
this.didCancel = config.didCancel
|
|
@@ -757,7 +766,10 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
757
766
|
// this.isConnectedToRoom = true
|
|
758
767
|
// this.store.applyDiff(stashedChanges, false)
|
|
759
768
|
|
|
760
|
-
this.onAfterConnect?.(this, {
|
|
769
|
+
this.onAfterConnect?.(this, {
|
|
770
|
+
isReadonly: event.isReadonly,
|
|
771
|
+
objectAccess: event.objectAccess ?? 'write',
|
|
772
|
+
})
|
|
761
773
|
const presence = this.presenceState?.get()
|
|
762
774
|
if (presence) {
|
|
763
775
|
this.pushPresence(presence)
|
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { interval } from './interval'
|
|
|
31
31
|
import {
|
|
32
32
|
getTlsyncProtocolVersion,
|
|
33
33
|
TLIncompatibilityReason,
|
|
34
|
+
TLObjectStoreAccess,
|
|
34
35
|
TLSocketClientSentEvent,
|
|
35
36
|
TLSocketServerSentDataEvent,
|
|
36
37
|
TLSocketServerSentEvent,
|
|
@@ -236,22 +237,43 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
236
237
|
readonly serializedSchema: SerializedSchema
|
|
237
238
|
|
|
238
239
|
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>
|
|
239
246
|
readonly presenceType: RecordType<R, any> | null
|
|
240
247
|
private log?: TLSyncLog
|
|
241
248
|
public readonly schema: StoreSchema<R, any>
|
|
242
249
|
private onPresenceChange?(): void
|
|
250
|
+
private onCommittedChanges?(args: { diff: TLSyncForwardDiff<R>; documentClock: number }): void
|
|
243
251
|
private readonly sessionIdleTimeout: number
|
|
244
252
|
|
|
245
253
|
constructor(opts: {
|
|
246
254
|
log?: TLSyncLog
|
|
247
255
|
schema: StoreSchema<R, any>
|
|
248
256
|
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[]
|
|
249
270
|
storage: TLSyncStorage<R>
|
|
250
271
|
clientTimeout?: number
|
|
251
272
|
}) {
|
|
252
273
|
this.schema = opts.schema
|
|
253
274
|
this.log = opts.log
|
|
254
275
|
this.onPresenceChange = opts.onPresenceChange
|
|
276
|
+
this.onCommittedChanges = opts.onCommittedChanges
|
|
255
277
|
this.storage = opts.storage
|
|
256
278
|
this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
|
|
257
279
|
|
|
@@ -264,9 +286,20 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
264
286
|
// do a json serialization cycle to make sure the schema has no 'undefined' values
|
|
265
287
|
this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()))
|
|
266
288
|
|
|
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
|
|
267
300
|
this.documentTypes = new Set(
|
|
268
301
|
Object.values<RecordType<R, any>>(this.schema.types)
|
|
269
|
-
.filter((t) => t.scope === 'document')
|
|
302
|
+
.filter((t) => t.scope === 'document' && !this.objectTypes.has(t.typeName))
|
|
270
303
|
.map((t) => t.typeName)
|
|
271
304
|
)
|
|
272
305
|
|
|
@@ -442,6 +475,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
442
475
|
cancellationTime: Date.now(),
|
|
443
476
|
meta: session.meta,
|
|
444
477
|
isReadonly: session.isReadonly,
|
|
478
|
+
objectAccess: session.objectAccess,
|
|
445
479
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
446
480
|
supportsStringAppend: session.supportsStringAppend,
|
|
447
481
|
})
|
|
@@ -552,8 +586,9 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
552
586
|
socket: TLRoomSocket<R>
|
|
553
587
|
meta: SessionMeta
|
|
554
588
|
isReadonly: boolean
|
|
589
|
+
objectAccess?: TLObjectStoreAccess
|
|
555
590
|
}) {
|
|
556
|
-
const { sessionId, socket, meta, isReadonly } = opts
|
|
591
|
+
const { sessionId, socket, meta, isReadonly, objectAccess } = opts
|
|
557
592
|
const existing = this.sessions.get(sessionId)
|
|
558
593
|
this.sessions.set(sessionId, {
|
|
559
594
|
state: RoomSessionState.AwaitingConnectMessage,
|
|
@@ -563,6 +598,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
563
598
|
sessionStartTime: Date.now(),
|
|
564
599
|
meta,
|
|
565
600
|
isReadonly: isReadonly ?? false,
|
|
601
|
+
objectAccess: objectAccess ?? 'write',
|
|
566
602
|
// this gets set later during handleConnectMessage
|
|
567
603
|
requiresLegacyRejection: false,
|
|
568
604
|
supportsStringAppend: true,
|
|
@@ -582,6 +618,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
582
618
|
socket: TLRoomSocket<R>
|
|
583
619
|
meta: SessionMeta
|
|
584
620
|
isReadonly: boolean
|
|
621
|
+
objectAccess?: TLObjectStoreAccess
|
|
585
622
|
serializedSchema: SerializedSchema
|
|
586
623
|
presenceId: string | null
|
|
587
624
|
presenceRecord: UnknownRecord | null
|
|
@@ -593,6 +630,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
593
630
|
socket,
|
|
594
631
|
meta,
|
|
595
632
|
isReadonly,
|
|
633
|
+
objectAccess,
|
|
596
634
|
serializedSchema,
|
|
597
635
|
presenceId,
|
|
598
636
|
presenceRecord,
|
|
@@ -615,6 +653,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
615
653
|
outstandingDataMessages: [],
|
|
616
654
|
meta,
|
|
617
655
|
isReadonly,
|
|
656
|
+
objectAccess: objectAccess ?? 'write',
|
|
618
657
|
requiresLegacyRejection,
|
|
619
658
|
supportsStringAppend,
|
|
620
659
|
})
|
|
@@ -927,6 +966,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
927
966
|
supportsStringAppend: session.supportsStringAppend,
|
|
928
967
|
meta: session.meta,
|
|
929
968
|
isReadonly: session.isReadonly,
|
|
969
|
+
objectAccess: session.objectAccess,
|
|
930
970
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
931
971
|
})
|
|
932
972
|
this._unsafe_sendMessage(session.sessionId, msg)
|
|
@@ -977,6 +1017,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
977
1017
|
serverClock: txn.getClock(),
|
|
978
1018
|
diff: { ...presenceDiff.value, ...docDiff },
|
|
979
1019
|
isReadonly: session.isReadonly,
|
|
1020
|
+
objectAccess: session.objectAccess,
|
|
980
1021
|
} satisfies Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>
|
|
981
1022
|
}) // no id needed because this only reads, no writes.
|
|
982
1023
|
|
|
@@ -1158,14 +1199,29 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1158
1199
|
}
|
|
1159
1200
|
}
|
|
1160
1201
|
}
|
|
1161
|
-
|
|
1202
|
+
// Per-op write gate: document-lane records are gated by `isReadonly`, object-lane
|
|
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) {
|
|
1162
1211
|
// The push request was for the document scope.
|
|
1163
1212
|
for (const [id, op] of objectMapEntriesIterable(message.diff!)) {
|
|
1164
1213
|
switch (op[0]) {
|
|
1165
1214
|
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
|
|
1166
1219
|
// Try to add the document.
|
|
1167
1220
|
// If we're putting a record with a type that we don't recognize, fail
|
|
1168
|
-
if (
|
|
1221
|
+
if (
|
|
1222
|
+
!this.documentTypes.has(op[1].typeName) &&
|
|
1223
|
+
!this.objectTypes.has(op[1].typeName)
|
|
1224
|
+
) {
|
|
1169
1225
|
throw new TLSyncError(
|
|
1170
1226
|
'invalid record',
|
|
1171
1227
|
TLSyncErrorCloseEventReason.INVALID_RECORD
|
|
@@ -1175,6 +1231,10 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1175
1231
|
break
|
|
1176
1232
|
}
|
|
1177
1233
|
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
|
|
1178
1238
|
// Try to patch the document. If it fails, stop here.
|
|
1179
1239
|
patchDocument(txn, docChanges, id, op[1])
|
|
1180
1240
|
break
|
|
@@ -1185,6 +1245,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1185
1245
|
// If the doc was already deleted, don't do anything, no need to propagate a delete op
|
|
1186
1246
|
continue
|
|
1187
1247
|
}
|
|
1248
|
+
if (!canWrite(doc.typeName)) continue
|
|
1188
1249
|
|
|
1189
1250
|
// Delete the document and propagate the delete op
|
|
1190
1251
|
// delete automatically creates tombstones
|
|
@@ -1272,6 +1333,17 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1272
1333
|
this.onPresenceChange?.()
|
|
1273
1334
|
})
|
|
1274
1335
|
}
|
|
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
|
+
}
|
|
1275
1347
|
}
|
|
1276
1348
|
|
|
1277
1349
|
/**
|
package/src/lib/TLSyncStorage.ts
CHANGED
|
@@ -83,7 +83,15 @@ 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. */
|
|
86
87
|
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']
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
/**
|
package/src/lib/protocol.ts
CHANGED
|
@@ -24,6 +24,17 @@ 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
|
+
|
|
27
38
|
/**
|
|
28
39
|
* Constants defining the different types of protocol incompatibility reasons.
|
|
29
40
|
*
|
|
@@ -108,6 +119,12 @@ export type TLSocketServerSentEvent<R extends UnknownRecord> =
|
|
|
108
119
|
diff: NetworkDiff<R>
|
|
109
120
|
serverClock: number
|
|
110
121
|
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
|
|
111
128
|
}
|
|
112
129
|
| {
|
|
113
130
|
type: 'incompatibility_error'
|
|
@@ -48,8 +48,13 @@ 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
|
|
51
|
+
function connectSession(
|
|
52
|
+
room: TLSocketRoom<any, any>,
|
|
53
|
+
sessionId: string,
|
|
54
|
+
socket: WebSocketMinimal,
|
|
55
|
+
opts: { objectAccess?: 'read' | 'write' } = {}
|
|
56
|
+
) {
|
|
57
|
+
room.handleSocketConnect({ sessionId, socket, ...opts })
|
|
53
58
|
const connectRequest = {
|
|
54
59
|
type: 'connect' as const,
|
|
55
60
|
connectRequestId: `connect-${sessionId}`,
|
|
@@ -997,11 +1002,41 @@ describe('28. TLSocketRoom (SR)', () => {
|
|
|
997
1002
|
expect(snapshot).not.toBeNull()
|
|
998
1003
|
expect(snapshot!.serializedSchema).toBeDefined()
|
|
999
1004
|
expect(snapshot!.isReadonly).toBe(false)
|
|
1005
|
+
expect(snapshot!.objectAccess).toBe('write')
|
|
1000
1006
|
expect(snapshot!.presenceId).toBeDefined()
|
|
1001
1007
|
expect(snapshot!.requiresLegacyRejection).toBe(false)
|
|
1002
1008
|
expect(snapshot!.supportsStringAppend).toBe(true)
|
|
1003
1009
|
})
|
|
1004
1010
|
|
|
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
|
+
|
|
1005
1040
|
it('[SR13] includes presence record when present', () => {
|
|
1006
1041
|
const store = getStore()
|
|
1007
1042
|
store.ensureStoreIsUsable()
|