@tldraw/sync-core 5.3.0-canary.fceaae5e9feb → 5.3.0-internal.41291fb579ed

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 +154 -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 +27 -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 +160 -10
  14. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  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 +154 -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 +27 -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 +160 -10
  30. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  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 +3 -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 +61 -3
  40. package/src/lib/TLSyncClient.test.ts +9 -2
  41. package/src/lib/TLSyncClient.ts +15 -3
  42. package/src/lib/TLSyncRoom.ts +294 -10
  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 +630 -0
  50. package/src/test/storageContractSuite.ts +79 -0
  51. package/src/test/upgradeDowngrade.test.ts +144 -2
@@ -3,14 +3,15 @@ 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'
10
- import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom'
10
+ import { RoomSnapshot, TLRecordAuthorizers, 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,30 @@ 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[]
147
+ /**
148
+ * Per-type authorizers for client record writes (create, update, delete): veto writes the
149
+ * session isn't allowed to make, or rewrite the record on create — e.g. force a comment's
150
+ * `authorId` to the signed-in user. See {@link TLRecordAuthorizers}.
151
+ */
152
+ authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>
123
153
  /**
124
154
  * When set, the room will call {@link TLSocketRoom.getSessionSnapshot} after
125
155
  * no message activity for a session for 5s and pass the result to this callback.
@@ -230,6 +260,8 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
230
260
  // eslint-disable-next-line @typescript-eslint/no-deprecated
231
261
  opts.initialSnapshot ?? DEFAULT_INITIAL_SNAPSHOT
232
262
  ),
263
+ // keep the storage partition in step with the room's object lane
264
+ objectTypes: opts.objectTypes,
233
265
  })
234
266
 
235
267
  // eslint-disable-next-line @typescript-eslint/no-deprecated
@@ -243,6 +275,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
243
275
  }
244
276
  this.room = new TLSyncRoom<R, SessionMeta>({
245
277
  onPresenceChange: opts.onPresenceChange,
278
+ onCommittedChanges: opts.onCommittedChanges,
279
+ objectTypes: opts.objectTypes,
280
+ authorizeRecord: opts.authorizeRecord,
246
281
  schema: opts.schema ?? (createTLSchema() as any),
247
282
  log: opts.log,
248
283
  storage,
@@ -283,6 +318,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
283
318
  * - sessionId - Unique identifier for the client session (typically from browser tab)
284
319
  * - socket - WebSocket-like object for client communication
285
320
  * - isReadonly - Whether the client can modify the document (defaults to false)
321
+ * - objectAccess - Write access for object-store lane record types (defaults to 'write').
322
+ * Independent of isReadonly, so a session can be allowed to write objects (e.g. comments)
323
+ * without being allowed to edit the document.
286
324
  * - meta - Additional session metadata (required if SessionMeta is not void)
287
325
  *
288
326
  * @example
@@ -310,9 +348,10 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
310
348
  sessionId: string
311
349
  socket: WebSocketMinimal
312
350
  isReadonly?: boolean
351
+ objectAccess?: TLObjectStoreAccess
313
352
  } & (SessionMeta extends void ? object : { meta: SessionMeta })
314
353
  ) {
315
- const { sessionId, socket, isReadonly = false } = opts
354
+ const { sessionId, socket, isReadonly = false, objectAccess = 'write' } = opts
316
355
  const handleSocketMessage = (event: MessageEvent) =>
317
356
  this.handleSocketMessage(sessionId, event.data)
318
357
  const handleSocketError = this.handleSocketError.bind(this, sessionId)
@@ -331,6 +370,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
331
370
  this.room.handleNewSession({
332
371
  sessionId,
333
372
  isReadonly,
373
+ objectAccess,
334
374
  socket: new ServerSocketAdapter({
335
375
  ws: socket,
336
376
  onBeforeSendMessage: this.opts.onBeforeSendMessage
@@ -540,6 +580,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
540
580
  this.room.handleResumedSession({
541
581
  sessionId,
542
582
  isReadonly: snapshot.isReadonly,
583
+ // snapshots persisted before this field existed fail closed — those sessions predate
584
+ // the record types gated by objectAccess, so they have nothing to write anyway
585
+ objectAccess: snapshot.objectAccess ?? 'read',
543
586
  serializedSchema: snapshot.serializedSchema,
544
587
  presenceId: snapshot.presenceId,
545
588
  presenceRecord: snapshot.presenceRecord,
@@ -595,6 +638,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
595
638
  return {
596
639
  serializedSchema: session.serializedSchema,
597
640
  isReadonly: session.isReadonly,
641
+ objectAccess: session.objectAccess,
598
642
  presenceId: session.presenceId,
599
643
  presenceRecord,
600
644
  requiresLegacyRejection: session.requiresLegacyRejection,
@@ -670,6 +714,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
670
714
  sessionId: string
671
715
  isConnected: boolean
672
716
  isReadonly: boolean
717
+ objectAccess: TLObjectStoreAccess
673
718
  meta: SessionMeta
674
719
  }> {
675
720
  return [...this.room.sessions.values()].map((session) => {
@@ -677,6 +722,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
677
722
  sessionId: session.sessionId,
678
723
  isConnected: session.state === RoomSessionState.Connected,
679
724
  isReadonly: session.isReadonly,
725
+ objectAccess: session.objectAccess,
680
726
  meta: session.meta,
681
727
  }
682
728
  })
@@ -708,6 +754,18 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
708
754
  throw new Error('getCurrentSnapshot is not supported for this storage type')
709
755
  }
710
756
 
757
+ /**
758
+ * Returns a snapshot of the object-store lane (see `objectTypes`), for persisting
759
+ * object-lane records separately from the document. Same entry shape as
760
+ * {@link RoomSnapshot.documents}.
761
+ */
762
+ getCurrentObjectsSnapshot(): RoomSnapshot['documents'] {
763
+ if (this.storage.getObjectsSnapshot) {
764
+ return this.storage.getObjectsSnapshot()
765
+ }
766
+ throw new Error('getCurrentObjectsSnapshot is not supported for this storage type')
767
+ }
768
+
711
769
  /**
712
770
  * Retrieves all presence records from the document store. Presence records
713
771
  * 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)