@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.
Files changed (51) hide show
  1. package/dist-cjs/index.d.ts +76 -3
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +55 -20
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +115 -52
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +26 -2
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +6 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +43 -5
  14. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  15. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  16. package/dist-cjs/lib/protocol.js.map +2 -2
  17. package/dist-esm/index.d.mts +76 -3
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +55 -20
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/SQLiteSyncStorage.mjs +115 -52
  24. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  25. package/dist-esm/lib/TLSocketRoom.mjs +26 -2
  26. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncClient.mjs +6 -1
  28. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  29. package/dist-esm/lib/TLSyncRoom.mjs +43 -5
  30. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  31. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  32. package/dist-esm/lib/protocol.mjs.map +2 -2
  33. package/package.json +6 -6
  34. package/src/index.ts +1 -0
  35. package/src/lib/InMemorySyncStorage.ts +74 -21
  36. package/src/lib/RoomSession.ts +7 -2
  37. package/src/lib/SQLiteSyncStorage.test.ts +29 -2
  38. package/src/lib/SQLiteSyncStorage.ts +158 -59
  39. package/src/lib/TLSocketRoom.ts +53 -2
  40. package/src/lib/TLSyncClient.test.ts +9 -2
  41. package/src/lib/TLSyncClient.ts +15 -3
  42. package/src/lib/TLSyncRoom.ts +76 -4
  43. package/src/lib/TLSyncStorage.ts +8 -0
  44. package/src/lib/protocol.ts +17 -0
  45. package/src/test/TLSocketRoom.test.ts +37 -2
  46. package/src/test/TLSyncClientRebase.test.ts +285 -0
  47. package/src/test/TLSyncRoom.test.ts +25 -0
  48. package/src/test/TestServer.ts +14 -4
  49. package/src/test/objectStore.test.ts +387 -0
  50. package/src/test/storageContractSuite.ts +79 -0
  51. package/src/test/upgradeDowngrade.test.ts +47 -0
@@ -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
- expect(onAfterConnect).toHaveBeenCalledWith(client, { isReadonly: false })
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, { isReadonly: true })
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', () => {
@@ -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?: (self: this, details: { isReadonly: boolean }) => void
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?(self: TLSyncClient<R, S>, details: { isReadonly: boolean }): void
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, { isReadonly: event.isReadonly })
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)
@@ -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
- if (message.diff && !session?.isReadonly) {
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 (!this.documentTypes.has(op[1].typeName)) {
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
  /**
@@ -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
  /**
@@ -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(room: TLSocketRoom<any, any>, sessionId: string, socket: WebSocketMinimal) {
52
- room.handleSocketConnect({ sessionId, socket })
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()