@tldraw/sync-core 4.3.0-canary.c7096a59bf3b → 4.3.0-canary.d039f3a1ab8f

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 +239 -57
  2. package/dist-cjs/index.js +7 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/TLSocketRoom.js +117 -69
  8. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncClient.js +7 -0
  10. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncRoom.js +357 -688
  12. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  13. package/dist-cjs/lib/TLSyncStorage.js +76 -0
  14. package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
  15. package/dist-cjs/lib/recordDiff.js +52 -0
  16. package/dist-cjs/lib/recordDiff.js.map +7 -0
  17. package/dist-esm/index.d.mts +239 -57
  18. package/dist-esm/index.mjs +12 -5
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/TLSocketRoom.mjs +121 -70
  24. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncClient.mjs +7 -0
  26. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncRoom.mjs +370 -702
  28. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  29. package/dist-esm/lib/TLSyncStorage.mjs +56 -0
  30. package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
  31. package/dist-esm/lib/recordDiff.mjs +32 -0
  32. package/dist-esm/lib/recordDiff.mjs.map +7 -0
  33. package/package.json +6 -6
  34. package/src/index.ts +21 -3
  35. package/src/lib/InMemorySyncStorage.ts +357 -0
  36. package/src/lib/RoomSession.test.ts +1 -0
  37. package/src/lib/RoomSession.ts +2 -0
  38. package/src/lib/TLSocketRoom.ts +228 -114
  39. package/src/lib/TLSyncClient.ts +12 -0
  40. package/src/lib/TLSyncRoom.ts +473 -913
  41. package/src/lib/TLSyncStorage.ts +216 -0
  42. package/src/lib/recordDiff.ts +73 -0
  43. package/src/test/InMemorySyncStorage.test.ts +1674 -0
  44. package/src/test/TLSocketRoom.test.ts +255 -49
  45. package/src/test/TLSyncRoom.test.ts +1021 -533
  46. package/src/test/TestServer.ts +12 -1
  47. package/src/test/customMessages.test.ts +1 -1
  48. package/src/test/presenceMode.test.ts +6 -6
  49. package/src/test/upgradeDowngrade.test.ts +282 -8
  50. package/src/test/validation.test.ts +10 -10
  51. package/src/test/pruneTombstones.test.ts +0 -178
@@ -1,10 +1,16 @@
1
1
  import type { StoreSchema, UnknownRecord } from '@tldraw/store'
2
- import { TLStoreSnapshot, createTLSchema } from '@tldraw/tlschema'
3
- import { objectMapValues, structuredClone } from '@tldraw/utils'
2
+ import { createTLSchema, TLStoreSnapshot } from '@tldraw/tlschema'
3
+ import { getOwnProperty, hasOwnProperty, isEqual, structuredClone } from '@tldraw/utils'
4
+ import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from './InMemorySyncStorage'
4
5
  import { RoomSessionState } from './RoomSession'
5
6
  import { ServerSocketAdapter, WebSocketMinimal } from './ServerSocketAdapter'
6
7
  import { TLSyncErrorCloseEventReason } from './TLSyncClient'
7
- import { RoomSnapshot, RoomStoreMethods, TLSyncRoom } from './TLSyncRoom'
8
+ import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom'
9
+ import {
10
+ convertStoreSnapshotToRoomSnapshot,
11
+ loadSnapshotIntoStorage,
12
+ TLSyncStorage,
13
+ } from './TLSyncStorage'
8
14
  import { JsonChunkAssembler } from './chunk'
9
15
  import { TLSocketServerSentEvent } from './protocol'
10
16
 
@@ -37,6 +43,51 @@ export interface TLSyncLog {
37
43
  error?(...args: any[]): void
38
44
  }
39
45
 
46
+ /**
47
+ * Base options for TLSocketRoom.
48
+ * @public
49
+ */
50
+ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
51
+ storage?: TLSyncStorage<R>
52
+ /**
53
+ * @deprecated use the storage option instead
54
+ */
55
+ initialSnapshot?: RoomSnapshot | TLStoreSnapshot
56
+ /**
57
+ * @deprecated use the storage option with an onChange callback instead
58
+ */
59
+ onDataChange?(): void
60
+ schema?: StoreSchema<R, any>
61
+ // how long to wait for a client to communicate before disconnecting them
62
+ clientTimeout?: number
63
+ log?: TLSyncLog
64
+ // a callback that is called when a client is disconnected
65
+ // eslint-disable-next-line @typescript-eslint/method-signature-style
66
+ onSessionRemoved?: (
67
+ room: TLSocketRoom<R, SessionMeta>,
68
+ args: { sessionId: string; numSessionsRemaining: number; meta: SessionMeta }
69
+ ) => void
70
+ // a callback that is called whenever a message is sent
71
+ // eslint-disable-next-line @typescript-eslint/method-signature-style
72
+ onBeforeSendMessage?: (args: {
73
+ sessionId: string
74
+ /** @internal keep the protocol private for now */
75
+ message: TLSocketServerSentEvent<R>
76
+ stringified: string
77
+ meta: SessionMeta
78
+ }) => void
79
+ // eslint-disable-next-line @typescript-eslint/method-signature-style
80
+ onAfterReceiveMessage?: (args: {
81
+ sessionId: string
82
+ /** @internal keep the protocol private for now */
83
+ message: TLSocketServerSentEvent<R>
84
+ stringified: string
85
+ meta: SessionMeta
86
+ }) => void
87
+ /** @internal */
88
+ onPresenceChange?(): void
89
+ }
90
+
40
91
  /**
41
92
  * A server-side room that manages WebSocket connections and synchronizes tldraw document state
42
93
  * between multiple clients in real-time. Each room represents a collaborative document space
@@ -105,10 +156,10 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
105
156
  { assembler: JsonChunkAssembler; socket: WebSocketMinimal; unlisten: () => void }
106
157
  >()
107
158
  readonly log?: TLSyncLog
108
- private readonly syncCallbacks: {
109
- onDataChange?(): void
110
- onPresenceChange?(): void
111
- }
159
+
160
+ public storage: TLSyncStorage<R>
161
+
162
+ private disposables = new Set<() => void>()
112
163
 
113
164
  /**
114
165
  * Creates a new TLSocketRoom instance for managing collaborative document synchronization.
@@ -124,56 +175,36 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
124
175
  * - onDataChange - Called when document data changes
125
176
  * - onPresenceChange - Called when presence data changes
126
177
  */
127
- constructor(
128
- public readonly opts: {
129
- initialSnapshot?: RoomSnapshot | TLStoreSnapshot
130
- schema?: StoreSchema<R, any>
131
- // how long to wait for a client to communicate before disconnecting them
132
- clientTimeout?: number
133
- log?: TLSyncLog
134
- // a callback that is called when a client is disconnected
135
- // eslint-disable-next-line @typescript-eslint/method-signature-style
136
- onSessionRemoved?: (
137
- room: TLSocketRoom<R, SessionMeta>,
138
- args: { sessionId: string; numSessionsRemaining: number; meta: SessionMeta }
139
- ) => void
140
- // a callback that is called whenever a message is sent
141
- // eslint-disable-next-line @typescript-eslint/method-signature-style
142
- onBeforeSendMessage?: (args: {
143
- sessionId: string
144
- /** @internal keep the protocol private for now */
145
- message: TLSocketServerSentEvent<R>
146
- stringified: string
147
- meta: SessionMeta
148
- }) => void
149
- // eslint-disable-next-line @typescript-eslint/method-signature-style
150
- onAfterReceiveMessage?: (args: {
151
- sessionId: string
152
- /** @internal keep the protocol private for now */
153
- message: TLSocketServerSentEvent<R>
154
- stringified: string
155
- meta: SessionMeta
156
- }) => void
157
- onDataChange?(): void
158
- /** @internal */
159
- onPresenceChange?(): void
178
+ constructor(public readonly opts: TLSocketRoomOptions<R, SessionMeta>) {
179
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
180
+ if (opts.storage && opts.initialSnapshot) {
181
+ throw new Error('Cannot provide both storage and initialSnapshot options')
160
182
  }
161
- ) {
162
- const initialSnapshot =
163
- opts.initialSnapshot && 'store' in opts.initialSnapshot
164
- ? convertStoreSnapshotToRoomSnapshot(opts.initialSnapshot!)
165
- : opts.initialSnapshot
183
+ const storage = opts.storage
184
+ ? opts.storage
185
+ : new InMemorySyncStorage<R>({
186
+ snapshot: convertStoreSnapshotToRoomSnapshot(
187
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
188
+ opts.initialSnapshot ?? DEFAULT_INITIAL_SNAPSHOT
189
+ ),
190
+ })
166
191
 
167
- this.syncCallbacks = {
168
- onDataChange: opts.onDataChange,
169
- onPresenceChange: opts.onPresenceChange,
192
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
193
+ if ('onDataChange' in opts && opts.onDataChange) {
194
+ this.disposables.add(
195
+ storage.onChange(() => {
196
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
197
+ opts.onDataChange?.()
198
+ })
199
+ )
170
200
  }
171
201
  this.room = new TLSyncRoom<R, SessionMeta>({
172
- ...this.syncCallbacks,
202
+ onPresenceChange: opts.onPresenceChange,
173
203
  schema: opts.schema ?? (createTLSchema() as any),
174
- snapshot: initialSnapshot,
175
204
  log: opts.log,
205
+ storage,
176
206
  })
207
+ this.storage = storage
177
208
  this.room.events.on('session_removed', (args) => {
178
209
  this.sessions.delete(args.sessionId)
179
210
  if (this.opts.onSessionRemoved) {
@@ -396,7 +427,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
396
427
  * ```
397
428
  */
398
429
  getCurrentDocumentClock() {
399
- return this.room.documentClock
430
+ return this.storage.getClock()
400
431
  }
401
432
 
402
433
  /**
@@ -418,7 +449,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
418
449
  * ```
419
450
  */
420
451
  getRecord(id: string) {
421
- return structuredClone(this.room.documents.get(id)?.state)
452
+ return this.storage.transaction((txn) => {
453
+ return structuredClone(txn.get(id)) as any
454
+ }).result as R
422
455
  }
423
456
 
424
457
  /**
@@ -466,6 +499,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
466
499
  * to restore the room state later or revert to a previous version.
467
500
  *
468
501
  * @returns Complete room snapshot including documents, clock values, and tombstones
502
+ * @deprecated if you need to do this use
469
503
  *
470
504
  * @example
471
505
  * ```ts
@@ -479,7 +513,10 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
479
513
  * ```
480
514
  */
481
515
  getCurrentSnapshot() {
482
- return this.room.getSnapshot()
516
+ if (this.storage.getSnapshot) {
517
+ return this.storage.getSnapshot()
518
+ }
519
+ throw new Error('getCurrentSnapshot is not supported for this storage type')
483
520
  }
484
521
 
485
522
  /**
@@ -491,25 +528,12 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
491
528
  */
492
529
  getPresenceRecords() {
493
530
  const result = {} as Record<string, UnknownRecord>
494
- for (const document of this.room.documents.values()) {
495
- if (document.state.typeName === this.room.presenceType?.typeName) {
496
- result[document.state.id] = document.state
497
- }
531
+ for (const presence of this.room.presenceStore.values()) {
532
+ result[presence.id] = presence
498
533
  }
499
534
  return result
500
535
  }
501
536
 
502
- /**
503
- * Returns a JSON-serialized snapshot of the current document state. This is
504
- * equivalent to JSON.stringify(getCurrentSnapshot()) but provided as a convenience.
505
- *
506
- * @returns JSON string representation of the room snapshot
507
- * @internal
508
- */
509
- getCurrentSerializedSnapshot() {
510
- return JSON.stringify(this.room.getSnapshot())
511
- }
512
-
513
537
  /**
514
538
  * Loads a document snapshot, completely replacing the current room state.
515
539
  * This will disconnect all current clients and update the document to match
@@ -529,42 +553,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
529
553
  * ```
530
554
  */
531
555
  loadSnapshot(snapshot: RoomSnapshot | TLStoreSnapshot) {
532
- if ('store' in snapshot) {
533
- snapshot = convertStoreSnapshotToRoomSnapshot(snapshot)
534
- }
535
- const oldRoom = this.room
536
- const oldRoomSnapshot = oldRoom.getSnapshot()
537
- const oldIds = oldRoomSnapshot.documents.map((d) => d.state.id)
538
- const newIds = new Set(snapshot.documents.map((d) => d.state.id))
539
- const removedIds = oldIds.filter((id) => !newIds.has(id))
540
-
541
- const tombstones: RoomSnapshot['tombstones'] = { ...oldRoomSnapshot.tombstones }
542
- removedIds.forEach((id) => {
543
- tombstones[id] = oldRoom.clock + 1
556
+ this.storage.transaction((txn) => {
557
+ loadSnapshotIntoStorage(txn, this.room.schema, snapshot)
544
558
  })
545
- newIds.forEach((id) => {
546
- delete tombstones[id]
547
- })
548
-
549
- const newRoom = new TLSyncRoom<R, SessionMeta>({
550
- ...this.syncCallbacks,
551
- schema: oldRoom.schema,
552
- snapshot: {
553
- clock: oldRoom.clock + 1,
554
- documentClock: oldRoom.clock + 1,
555
- documents: snapshot.documents.map((d) => ({
556
- lastChangedClock: oldRoom.clock + 1,
557
- state: d.state,
558
- })),
559
- schema: snapshot.schema,
560
- tombstones,
561
- tombstoneHistoryStartsAtClock: oldRoomSnapshot.tombstoneHistoryStartsAtClock,
562
- },
563
- log: this.log,
564
- })
565
- // replace room with new one and kick out all the clients
566
- this.room = newRoom
567
- oldRoom.close()
568
559
  }
569
560
 
570
561
  /**
@@ -609,9 +600,32 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
609
600
  * }
610
601
  * })
611
602
  * ```
603
+ * @deprecated use the storage.transaction method instead
612
604
  */
605
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
613
606
  async updateStore(updater: (store: RoomStoreMethods<R>) => void | Promise<void>) {
614
- return this.room.updateStore(updater)
607
+ if (this.isClosed()) {
608
+ throw new Error('Cannot update store on a closed room')
609
+ }
610
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
611
+ const ctx = new StoreUpdateContext<R>(
612
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
613
+ Object.fromEntries(this.getCurrentSnapshot().documents.map((d) => [d.state.id, d.state])),
614
+ this.room.schema
615
+ )
616
+ try {
617
+ await updater(ctx)
618
+ } finally {
619
+ ctx.close()
620
+ }
621
+ this.storage.transaction((txn) => {
622
+ for (const [id, record] of Object.entries(ctx.updates.puts)) {
623
+ txn.set(id, record as R)
624
+ }
625
+ for (const id of ctx.updates.deletes) {
626
+ txn.delete(id)
627
+ }
628
+ })
615
629
  }
616
630
 
617
631
  /**
@@ -688,6 +702,8 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
688
702
  */
689
703
  close() {
690
704
  this.room.close()
705
+ this.disposables.forEach((d) => d())
706
+ this.disposables.clear()
691
707
  }
692
708
 
693
709
  /**
@@ -729,15 +745,113 @@ export type OmitVoid<T, KS extends keyof T = keyof T> = {
729
745
  [K in KS extends any ? (void extends T[KS] ? never : KS) : never]: T[K]
730
746
  }
731
747
 
732
- function convertStoreSnapshotToRoomSnapshot(snapshot: TLStoreSnapshot): RoomSnapshot {
733
- return {
734
- clock: 0,
735
- documentClock: 0,
736
- documents: objectMapValues(snapshot.store).map((state) => ({
737
- state,
738
- lastChangedClock: 0,
739
- })),
740
- schema: snapshot.schema,
741
- tombstones: {},
748
+ /**
749
+ * Interface for making transactional changes to room store data. Used within
750
+ * updateStore transactions to modify documents atomically.
751
+ *
752
+ * @example
753
+ * ```ts
754
+ * await room.updateStore((store) => {
755
+ * const shape = store.get('shape:123')
756
+ * if (shape) {
757
+ * store.put({ ...shape, x: shape.x + 10 })
758
+ * }
759
+ * store.delete('shape:456')
760
+ * })
761
+ * ```
762
+ *
763
+ * @public
764
+ * @deprecated use the storage.transaction method instead
765
+ */
766
+ export interface RoomStoreMethods<R extends UnknownRecord = UnknownRecord> {
767
+ /**
768
+ * Add or update a record in the store.
769
+ *
770
+ * @param record - The record to store
771
+ */
772
+ put(record: R): void
773
+ /**
774
+ * Delete a record from the store.
775
+ *
776
+ * @param recordOrId - The record or record ID to delete
777
+ */
778
+ delete(recordOrId: R | string): void
779
+ /**
780
+ * Get a record by its ID.
781
+ *
782
+ * @param id - The record ID
783
+ * @returns The record or null if not found
784
+ */
785
+ get(id: string): R | null
786
+ /**
787
+ * Get all records in the store.
788
+ *
789
+ * @returns Array of all records
790
+ */
791
+ getAll(): R[]
792
+ }
793
+
794
+ /**
795
+ * @deprecated use the storage.transaction method instead
796
+ */
797
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
798
+ class StoreUpdateContext<R extends UnknownRecord> implements RoomStoreMethods<R> {
799
+ constructor(
800
+ private readonly snapshot: Record<string, UnknownRecord>,
801
+ private readonly schema: StoreSchema<R, any>
802
+ ) {}
803
+ readonly updates = {
804
+ puts: {} as Record<string, UnknownRecord>,
805
+ deletes: new Set<string>(),
806
+ }
807
+ put(record: R): void {
808
+ if (this._isClosed) throw new Error('StoreUpdateContext is closed')
809
+ const recordType = getOwnProperty(this.schema.types, record.typeName)
810
+ if (!recordType) {
811
+ throw new Error(`Missing definition for record type ${record.typeName}`)
812
+ }
813
+ const recordBefore = this.snapshot[record.id] ?? undefined
814
+ recordType.validate(record, recordBefore as R)
815
+
816
+ if (record.id in this.snapshot && isEqual(this.snapshot[record.id], record)) {
817
+ delete this.updates.puts[record.id]
818
+ } else {
819
+ this.updates.puts[record.id] = structuredClone(record)
820
+ }
821
+ this.updates.deletes.delete(record.id)
822
+ }
823
+ delete(recordOrId: R | string): void {
824
+ if (this._isClosed) throw new Error('StoreUpdateContext is closed')
825
+ const id = typeof recordOrId === 'string' ? recordOrId : recordOrId.id
826
+ delete this.updates.puts[id]
827
+ if (this.snapshot[id]) {
828
+ this.updates.deletes.add(id)
829
+ }
830
+ }
831
+ get(id: string): R | null {
832
+ if (this._isClosed) throw new Error('StoreUpdateContext is closed')
833
+ if (hasOwnProperty(this.updates.puts, id)) {
834
+ return structuredClone(this.updates.puts[id]) as R
835
+ }
836
+ if (this.updates.deletes.has(id)) {
837
+ return null
838
+ }
839
+ return structuredClone(this.snapshot[id] ?? null) as R
840
+ }
841
+
842
+ getAll(): R[] {
843
+ if (this._isClosed) throw new Error('StoreUpdateContext is closed')
844
+ const result = Object.values(this.updates.puts)
845
+ for (const [id, record] of Object.entries(this.snapshot)) {
846
+ if (!this.updates.deletes.has(id) && !hasOwnProperty(this.updates.puts, id)) {
847
+ result.push(record)
848
+ }
849
+ }
850
+ return structuredClone(result) as R[]
851
+ }
852
+
853
+ private _isClosed = false
854
+ close() {
855
+ this._isClosed = true
742
856
  }
743
857
  }
@@ -109,6 +109,18 @@ export const TLSyncErrorCloseEventReason = {
109
109
  /** Room has reached maximum capacity */
110
110
  ROOM_FULL: 'ROOM_FULL',
111
111
  } as const
112
+
113
+ /**
114
+ * @internal
115
+ */
116
+ export class TLSyncError extends Error {
117
+ constructor(
118
+ message: string,
119
+ public reason: TLSyncErrorCloseEventReason
120
+ ) {
121
+ super(message)
122
+ }
123
+ }
112
124
  /**
113
125
  * Union type of all possible server connection close reasons.
114
126
  * Represents the string values that can be passed when a server closes