@tldraw/sync-core 4.3.0-canary.cf5673a789a1 → 4.3.0-canary.d428e9e9a7c6

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
@@ -0,0 +1,357 @@
1
+ import { atom, Atom, transaction } from '@tldraw/state'
2
+ import { AtomMap, devFreeze, SerializedSchema, UnknownRecord } from '@tldraw/store'
3
+ import {
4
+ createTLSchema,
5
+ DocumentRecordType,
6
+ PageRecordType,
7
+ TLDOCUMENT_ID,
8
+ TLPageId,
9
+ } from '@tldraw/tlschema'
10
+ import { assert, IndexKey, objectMapEntries, throttle } from '@tldraw/utils'
11
+ import { RoomSnapshot } from './TLSyncRoom'
12
+ import {
13
+ TLSyncForwardDiff,
14
+ TLSyncStorage,
15
+ TLSyncStorageGetChangesSinceResult,
16
+ TLSyncStorageOnChangeCallbackProps,
17
+ TLSyncStorageTransaction,
18
+ TLSyncStorageTransactionCallback,
19
+ TLSyncStorageTransactionOptions,
20
+ TLSyncStorageTransactionResult,
21
+ } from './TLSyncStorage'
22
+
23
+ /** @internal */
24
+ export const TOMBSTONE_PRUNE_BUFFER_SIZE = 1000
25
+ /** @internal */
26
+ export const MAX_TOMBSTONES = 5000
27
+
28
+ /**
29
+ * Default initial snapshot for a new room.
30
+ * @public
31
+ */
32
+ export const DEFAULT_INITIAL_SNAPSHOT = {
33
+ documentClock: 0,
34
+ tombstoneHistoryStartsAtClock: 0,
35
+ schema: createTLSchema().serialize(),
36
+ documents: [
37
+ {
38
+ state: DocumentRecordType.create({ id: TLDOCUMENT_ID }),
39
+ lastChangedClock: 0,
40
+ },
41
+ {
42
+ state: PageRecordType.create({
43
+ id: 'page:page' as TLPageId,
44
+ name: 'Page 1',
45
+ index: 'a1' as IndexKey,
46
+ }),
47
+ lastChangedClock: 0,
48
+ },
49
+ ],
50
+ }
51
+
52
+ /**
53
+ * In-memory implementation of TLSyncStorage using AtomMap for documents and tombstones,
54
+ * and atoms for clock values. This is the default storage implementation used by TLSyncRoom.
55
+ *
56
+ * @public
57
+ */
58
+ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {
59
+ /** @internal */
60
+ documents: AtomMap<string, { state: R; lastChangedClock: number }>
61
+ /** @internal */
62
+ tombstones: AtomMap<string, number>
63
+ /** @internal */
64
+ schema: Atom<SerializedSchema>
65
+ /** @internal */
66
+ documentClock: Atom<number>
67
+ /** @internal */
68
+ tombstoneHistoryStartsAtClock: Atom<number>
69
+
70
+ private listeners = new Set<(arg: TLSyncStorageOnChangeCallbackProps) => unknown>()
71
+ onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void {
72
+ let didDelete = false
73
+ // we put the callback registration in a microtask because the callback is invoked
74
+ // in a microtask, and so this makes sure the callback is invoked after all the updates
75
+ // that happened in the current callstack before this onChange registration have been processed.
76
+ queueMicrotask(() => {
77
+ if (didDelete) return
78
+ this.listeners.add(callback)
79
+ })
80
+ return () => {
81
+ if (didDelete) return
82
+ didDelete = true
83
+ this.listeners.delete(callback)
84
+ }
85
+ }
86
+
87
+ constructor({
88
+ snapshot = DEFAULT_INITIAL_SNAPSHOT,
89
+ onChange,
90
+ }: {
91
+ snapshot?: RoomSnapshot
92
+ onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
93
+ } = {}) {
94
+ const maxClockValue = Math.max(
95
+ 0,
96
+ ...Object.values(snapshot.tombstones ?? {}),
97
+ ...Object.values(snapshot.documents.map((d) => d.lastChangedClock))
98
+ )
99
+ this.documents = new AtomMap(
100
+ 'room documents',
101
+ snapshot.documents.map((d) => [
102
+ d.state.id,
103
+ { state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
104
+ ])
105
+ )
106
+ const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)
107
+
108
+ this.documentClock = atom('document clock', documentClock)
109
+ // math.min to make sure the tombstone history starts at or before the document clock
110
+ const tombstoneHistoryStartsAtClock = Math.min(
111
+ snapshot.tombstoneHistoryStartsAtClock ?? documentClock,
112
+ documentClock
113
+ )
114
+ this.tombstoneHistoryStartsAtClock = atom(
115
+ 'tombstone history starts at clock',
116
+ tombstoneHistoryStartsAtClock
117
+ )
118
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
119
+ this.schema = atom('schema', snapshot.schema ?? createTLSchema().serializeEarliestVersion())
120
+ this.tombstones = new AtomMap(
121
+ 'room tombstones',
122
+ // If the tombstone history starts now (or we didn't have the
123
+ // tombstoneHistoryStartsAtClock) then there are no tombstones
124
+ tombstoneHistoryStartsAtClock === documentClock
125
+ ? []
126
+ : objectMapEntries(snapshot.tombstones ?? {})
127
+ )
128
+ if (onChange) {
129
+ this.onChange(onChange)
130
+ }
131
+ }
132
+
133
+ transaction<T>(
134
+ callback: TLSyncStorageTransactionCallback<R, T>,
135
+ opts?: TLSyncStorageTransactionOptions
136
+ ): TLSyncStorageTransactionResult<T, R> {
137
+ const clockBefore = this.documentClock.get()
138
+ const trackChanges = opts?.emitChanges === 'always'
139
+ const txn = new InMemorySyncStorageTransaction<R>(this)
140
+ let result: T
141
+ let changes: TLSyncForwardDiff<R> | undefined
142
+ try {
143
+ result = transaction(() => {
144
+ return callback(txn as any)
145
+ }) as T
146
+ if (trackChanges) {
147
+ changes = txn.getChangesSince(clockBefore)?.diff
148
+ }
149
+ } catch (error) {
150
+ console.error('Error in transaction', error)
151
+ throw error
152
+ } finally {
153
+ txn.close()
154
+ }
155
+ if (
156
+ typeof result === 'object' &&
157
+ result &&
158
+ 'then' in result &&
159
+ typeof result.then === 'function'
160
+ ) {
161
+ const err = new Error('Transaction must return a value, not a promise')
162
+ console.error(err)
163
+ throw err
164
+ }
165
+
166
+ const clockAfter = this.documentClock.get()
167
+ const didChange = clockAfter > clockBefore
168
+ if (didChange) {
169
+ // todo: batch these updates
170
+ queueMicrotask(() => {
171
+ const props: TLSyncStorageOnChangeCallbackProps = {
172
+ id: opts?.id,
173
+ documentClock: clockAfter,
174
+ }
175
+ for (const listener of this.listeners) {
176
+ try {
177
+ listener(props)
178
+ } catch (error) {
179
+ console.error('Error in onChange callback', error)
180
+ }
181
+ }
182
+ })
183
+ }
184
+ // InMemorySyncStorage applies changes verbatim, so we only emit changes
185
+ // when 'always' is specified (not for 'when-different')
186
+ return { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }
187
+ }
188
+
189
+ getClock(): number {
190
+ return this.documentClock.get()
191
+ }
192
+
193
+ /** @internal */
194
+ pruneTombstones = throttle(
195
+ () => {
196
+ if (this.tombstones.size > MAX_TOMBSTONES) {
197
+ const tombstones = Array.from(this.tombstones)
198
+ // sort entries in ascending order by clock (oldest first)
199
+ tombstones.sort((a, b) => a[1] - b[1])
200
+ // determine how many to delete, avoiding partial history for a clock value
201
+ let cutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + this.tombstones.size - MAX_TOMBSTONES
202
+ while (cutoff < tombstones.length && tombstones[cutoff - 1][1] === tombstones[cutoff][1]) {
203
+ cutoff++
204
+ }
205
+
206
+ // Set history start to the oldest remaining tombstone's clock
207
+ // (or documentClock if we're deleting everything)
208
+ const oldestRemaining = tombstones[cutoff]
209
+ this.tombstoneHistoryStartsAtClock.set(oldestRemaining?.[1] ?? this.documentClock.get())
210
+
211
+ // Delete the oldest tombstones (first cutoff entries)
212
+ const toDelete = tombstones.slice(0, cutoff)
213
+ this.tombstones.deleteMany(toDelete.map(([id]) => id))
214
+ }
215
+ },
216
+ 1000,
217
+ // prevent this from running synchronously to avoid blocking requests
218
+ { leading: false }
219
+ )
220
+
221
+ getSnapshot(): RoomSnapshot {
222
+ return {
223
+ tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),
224
+ documentClock: this.documentClock.get(),
225
+ documents: Array.from(this.documents.values()),
226
+ tombstones: Object.fromEntries(this.tombstones.entries()),
227
+ schema: this.schema.get(),
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Transaction implementation for InMemorySyncStorage.
234
+ * Provides access to documents, tombstones, and metadata within a transaction.
235
+ *
236
+ * @internal
237
+ */
238
+ class InMemorySyncStorageTransaction<R extends UnknownRecord>
239
+ implements TLSyncStorageTransaction<R>
240
+ {
241
+ private _clock
242
+ private _closed = false
243
+
244
+ constructor(private storage: InMemorySyncStorage<R>) {
245
+ this._clock = this.storage.documentClock.get()
246
+ }
247
+
248
+ /** @internal */
249
+ close() {
250
+ this._closed = true
251
+ }
252
+
253
+ private assertNotClosed() {
254
+ assert(!this._closed, 'Transaction has ended, iterator cannot be consumed')
255
+ }
256
+
257
+ getClock(): number {
258
+ return this._clock
259
+ }
260
+
261
+ private didIncrementClock: boolean = false
262
+ private getNextClock(): number {
263
+ if (!this.didIncrementClock) {
264
+ this.didIncrementClock = true
265
+ this._clock = this.storage.documentClock.set(this.storage.documentClock.get() + 1)
266
+ }
267
+ return this._clock
268
+ }
269
+
270
+ get(id: string): R | undefined {
271
+ this.assertNotClosed()
272
+ return this.storage.documents.get(id)?.state
273
+ }
274
+
275
+ set(id: string, record: R): void {
276
+ this.assertNotClosed()
277
+ assert(id === record.id, `Record id mismatch: key does not match record.id`)
278
+ const clock = this.getNextClock()
279
+ // Automatically clear tombstone if it exists
280
+ if (this.storage.tombstones.has(id)) {
281
+ this.storage.tombstones.delete(id)
282
+ }
283
+ this.storage.documents.set(id, {
284
+ state: devFreeze(record) as R,
285
+ lastChangedClock: clock,
286
+ })
287
+ }
288
+
289
+ delete(id: string): void {
290
+ this.assertNotClosed()
291
+ // Only create a tombstone if the record actually exists
292
+ if (!this.storage.documents.has(id)) return
293
+ const clock = this.getNextClock()
294
+ this.storage.documents.delete(id)
295
+ this.storage.tombstones.set(id, clock)
296
+ this.storage.pruneTombstones()
297
+ }
298
+
299
+ *entries(): IterableIterator<[string, R]> {
300
+ this.assertNotClosed()
301
+ for (const [id, record] of this.storage.documents.entries()) {
302
+ this.assertNotClosed()
303
+ yield [id, record.state]
304
+ }
305
+ }
306
+
307
+ *keys(): IterableIterator<string> {
308
+ this.assertNotClosed()
309
+ for (const key of this.storage.documents.keys()) {
310
+ this.assertNotClosed()
311
+ yield key
312
+ }
313
+ }
314
+
315
+ *values(): IterableIterator<R> {
316
+ this.assertNotClosed()
317
+ for (const record of this.storage.documents.values()) {
318
+ this.assertNotClosed()
319
+ yield record.state
320
+ }
321
+ }
322
+
323
+ getSchema(): SerializedSchema {
324
+ this.assertNotClosed()
325
+ return this.storage.schema.get()
326
+ }
327
+
328
+ setSchema(schema: SerializedSchema): void {
329
+ this.assertNotClosed()
330
+ this.storage.schema.set(schema)
331
+ }
332
+
333
+ getChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {
334
+ this.assertNotClosed()
335
+ const clock = this.storage.documentClock.get()
336
+ if (sinceClock === clock) return undefined
337
+ if (sinceClock > clock) {
338
+ // something went wrong, wipe the slate clean
339
+ sinceClock = -1
340
+ }
341
+ const diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }
342
+ const wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get()
343
+ for (const doc of this.storage.documents.values()) {
344
+ if (wipeAll || doc.lastChangedClock > sinceClock) {
345
+ // For historical changes, we don't have "from" state, so use added
346
+ diff.puts[doc.state.id] = doc.state as R
347
+ }
348
+ }
349
+ for (const [id, clock] of this.storage.tombstones.entries()) {
350
+ if (clock > sinceClock) {
351
+ // For tombstones, we don't have the removed record, use placeholder
352
+ diff.deletes.push(id)
353
+ }
354
+ }
355
+ return { diff, wipeAll }
356
+ }
357
+ }
@@ -68,6 +68,7 @@ describe('RoomSession state transitions', () => {
68
68
  isReadonly: initialSession.isReadonly,
69
69
  requiresLegacyRejection: initialSession.requiresLegacyRejection,
70
70
  serializedSchema: mockSerializedSchema,
71
+ requiresDownMigrations: false,
71
72
  supportsStringAppend: true,
72
73
  lastInteractionTime: Date.now(),
73
74
  debounceTimer: null,
@@ -132,6 +132,8 @@ export type RoomSession<R extends UnknownRecord, Meta> =
132
132
  state: typeof RoomSessionState.Connected
133
133
  /** Serialized schema information for this connected session */
134
134
  serializedSchema: SerializedSchema
135
+ /** Whether this session requires down migrations */
136
+ requiresDownMigrations: boolean
135
137
  /** Timestamp of the last interaction or message from this session */
136
138
  lastInteractionTime: number
137
139
  /** Timer for debouncing operations, if active */