@tldraw/sync-core 5.2.0-next.ee0fa4d6244f → 5.2.0

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 (54) hide show
  1. package/DOCS.md +662 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js +7 -1
  5. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  6. package/dist-cjs/lib/TLSocketRoom.js +4 -1
  7. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  8. package/dist-cjs/lib/TLSyncClient.js +2 -3
  9. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  10. package/dist-cjs/lib/TLSyncRoom.js +27 -3
  11. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  12. package/dist-esm/index.mjs +1 -1
  13. package/dist-esm/lib/ClientWebSocketAdapter.mjs +7 -1
  14. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  15. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  16. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  17. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  18. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  19. package/dist-esm/lib/TLSyncRoom.mjs +27 -3
  20. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  21. package/package.json +12 -8
  22. package/src/lib/ClientWebSocketAdapter.test.ts +502 -505
  23. package/src/lib/ClientWebSocketAdapter.ts +7 -1
  24. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  25. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  26. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  27. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  28. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  29. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  30. package/src/lib/TLSocketRoom.ts +6 -1
  31. package/src/lib/TLSyncClient.test.ts +1127 -846
  32. package/src/lib/TLSyncClient.ts +4 -4
  33. package/src/lib/TLSyncRoom.ts +34 -4
  34. package/src/lib/TLSyncStorage.test.ts +225 -0
  35. package/src/lib/chunk.test.ts +372 -0
  36. package/src/lib/diff.test.ts +885 -0
  37. package/src/lib/interval.test.ts +43 -0
  38. package/src/lib/protocol.test.ts +43 -0
  39. package/src/lib/recordDiff.test.ts +159 -0
  40. package/src/test/TLSocketRoom.test.ts +1109 -894
  41. package/src/test/TLSyncRoom.test.ts +1754 -870
  42. package/src/test/storageContractSuite.ts +618 -0
  43. package/src/test/upgradeDowngrade.test.ts +140 -40
  44. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  45. package/src/lib/RoomSession.test.ts +0 -101
  46. package/src/lib/computeTombstonePruning.test.ts +0 -352
  47. package/src/lib/server-types.test.ts +0 -44
  48. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  49. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  50. package/src/test/chunk.test.ts +0 -385
  51. package/src/test/customMessages.test.ts +0 -36
  52. package/src/test/diff.test.ts +0 -784
  53. package/src/test/presenceMode.test.ts +0 -149
  54. package/src/test/validation.test.ts +0 -186
@@ -212,7 +212,13 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
212
212
  this._ws === ws,
213
213
  "sockets must only be orphaned when they are CLOSING or CLOSED, so they can't receive messages"
214
214
  )
215
- const parsed = JSON.parse(ev.data.toString())
215
+ let parsed: TLSocketServerSentEvent<TLRecord>
216
+ try {
217
+ parsed = JSON.parse(ev.data.toString())
218
+ } catch {
219
+ warnOnce('Received malformed WebSocket message. Dropping message.')
220
+ return
221
+ }
216
222
  this.messageListeners.forEach((cb) => cb(parsed))
217
223
  }
218
224
 
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { DurableObjectSqliteSyncWrapper } from './DurableObjectSqliteSyncWrapper'
3
+
4
+ // A minimal fake of the Durable Object storage API: `sql.exec` records each
5
+ // call and returns an iterable cursor with `toArray()`, and `transactionSync`
6
+ // just invokes the callback.
7
+ function makeFakeStorage(rows: unknown[] = []) {
8
+ const calls: { sql: string; bindings: unknown[] }[] = []
9
+ const storage = {
10
+ sql: {
11
+ exec(sql: string, ...bindings: unknown[]) {
12
+ calls.push({ sql, bindings })
13
+ return {
14
+ [Symbol.iterator]() {
15
+ return rows[Symbol.iterator]()
16
+ },
17
+ toArray() {
18
+ return [...rows]
19
+ },
20
+ }
21
+ },
22
+ },
23
+ transactionSync: vi.fn(<T>(callback: () => T): T => callback()),
24
+ }
25
+ return { storage, calls }
26
+ }
27
+
28
+ describe('DurableObjectSqliteSyncWrapper', () => {
29
+ describe('prepare', () => {
30
+ it('[DO1] iterate re-executes the stored SQL with the given bindings on every call', () => {
31
+ const { storage, calls } = makeFakeStorage([{ id: 1 }, { id: 2 }])
32
+ const wrapper = new DurableObjectSqliteSyncWrapper(storage)
33
+
34
+ const stmt = wrapper.prepare<{ id: number }, [number]>('SELECT id FROM test WHERE value > ?')
35
+ expect(calls).toHaveLength(0) // nothing executed at prepare time
36
+
37
+ expect([...stmt.iterate(100)]).toEqual([{ id: 1 }, { id: 2 }])
38
+ expect([...stmt.iterate(200)]).toEqual([{ id: 1 }, { id: 2 }])
39
+
40
+ expect(calls).toEqual([
41
+ { sql: 'SELECT id FROM test WHERE value > ?', bindings: [100] },
42
+ { sql: 'SELECT id FROM test WHERE value > ?', bindings: [200] },
43
+ ])
44
+ })
45
+
46
+ it('[DO1] all re-executes the stored SQL with the given bindings and returns toArray()', () => {
47
+ const { storage, calls } = makeFakeStorage([{ name: 'alice' }])
48
+ const wrapper = new DurableObjectSqliteSyncWrapper(storage)
49
+
50
+ const stmt = wrapper.prepare<{ name: string }, [number]>('SELECT name FROM test WHERE id = ?')
51
+ expect(stmt.all(1)).toEqual([{ name: 'alice' }])
52
+ expect(stmt.all(2)).toEqual([{ name: 'alice' }])
53
+
54
+ expect(calls).toEqual([
55
+ { sql: 'SELECT name FROM test WHERE id = ?', bindings: [1] },
56
+ { sql: 'SELECT name FROM test WHERE id = ?', bindings: [2] },
57
+ ])
58
+ })
59
+
60
+ it('[DO1] run re-executes the stored SQL with the given bindings', () => {
61
+ const { storage, calls } = makeFakeStorage()
62
+ const wrapper = new DurableObjectSqliteSyncWrapper(storage)
63
+
64
+ const stmt = wrapper.prepare<void, [number, string]>(
65
+ 'INSERT INTO test (id, name) VALUES (?, ?)'
66
+ )
67
+ stmt.run(1, 'alice')
68
+ stmt.run(2, 'bob')
69
+
70
+ expect(calls).toEqual([
71
+ { sql: 'INSERT INTO test (id, name) VALUES (?, ?)', bindings: [1, 'alice'] },
72
+ { sql: 'INSERT INTO test (id, name) VALUES (?, ?)', bindings: [2, 'bob'] },
73
+ ])
74
+ })
75
+ })
76
+
77
+ describe('exec', () => {
78
+ it('[DO1] forwards to sql.exec', () => {
79
+ const { storage, calls } = makeFakeStorage()
80
+ const wrapper = new DurableObjectSqliteSyncWrapper(storage)
81
+
82
+ wrapper.exec('CREATE TABLE test (id INTEGER PRIMARY KEY)')
83
+
84
+ expect(calls).toEqual([{ sql: 'CREATE TABLE test (id INTEGER PRIMARY KEY)', bindings: [] }])
85
+ })
86
+ })
87
+
88
+ describe('transaction', () => {
89
+ it('[DO1] delegates to transactionSync and returns its result', () => {
90
+ const { storage } = makeFakeStorage()
91
+ const wrapper = new DurableObjectSqliteSyncWrapper(storage)
92
+
93
+ const callback = vi.fn(() => 'done')
94
+ const result = wrapper.transaction(callback)
95
+
96
+ expect(result).toBe('done')
97
+ expect(callback).toHaveBeenCalledTimes(1)
98
+ expect(storage.transactionSync).toHaveBeenCalledTimes(1)
99
+ expect(storage.transactionSync).toHaveBeenCalledWith(callback)
100
+ })
101
+ })
102
+ })
@@ -0,0 +1,294 @@
1
+ import { TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
2
+ import { describe, expect, it } from 'vitest'
3
+ import {
4
+ contractRecords,
5
+ makeContractSnapshot,
6
+ makePage,
7
+ registerStorageContractTests,
8
+ } from '../test/storageContractSuite'
9
+ import {
10
+ computeTombstonePruning,
11
+ InMemorySyncStorage,
12
+ MAX_TOMBSTONES,
13
+ TOMBSTONE_PRUNE_BUFFER_SIZE,
14
+ } from './InMemorySyncStorage'
15
+
16
+ // Helper to create a sorted tombstone array for computeTombstonePruning
17
+ function makeTombstones(
18
+ count: number,
19
+ clockFn: (i: number) => number = (i) => i + 1
20
+ ): Array<{ id: string; clock: number }> {
21
+ return Array.from({ length: count }, (_, i) => ({ id: `doc${i}`, clock: clockFn(i) }))
22
+ }
23
+
24
+ describe('InMemorySyncStorage', () => {
25
+ registerStorageContractTests({
26
+ create: (opts) => new InMemorySyncStorage<TLRecord>(opts),
27
+ })
28
+
29
+ describe('constructor clock handling', () => {
30
+ it('[IM1] clamps the document clock up to the max lastChangedClock in the snapshot', () => {
31
+ const storage = new InMemorySyncStorage<TLRecord>({
32
+ snapshot: makeContractSnapshot(contractRecords, {
33
+ documents: [
34
+ { state: contractRecords[0], lastChangedClock: 10 },
35
+ { state: contractRecords[1], lastChangedClock: 3 },
36
+ ],
37
+ documentClock: 3,
38
+ }),
39
+ })
40
+ expect(storage.getClock()).toBe(10)
41
+ })
42
+
43
+ it('[IM1] clamps the document clock up to the max tombstone clock in the snapshot', () => {
44
+ const storage = new InMemorySyncStorage<TLRecord>({
45
+ snapshot: makeContractSnapshot(contractRecords, {
46
+ tombstones: { 'shape:gone': 25 },
47
+ documentClock: 3,
48
+ tombstoneHistoryStartsAtClock: 0,
49
+ }),
50
+ })
51
+ expect(storage.getClock()).toBe(25)
52
+ })
53
+
54
+ it('[IM2] clamps tombstoneHistoryStartsAtClock down to the document clock', () => {
55
+ const storage = new InMemorySyncStorage<TLRecord>({
56
+ snapshot: makeContractSnapshot(contractRecords, {
57
+ documentClock: 5,
58
+ tombstoneHistoryStartsAtClock: 10,
59
+ }),
60
+ })
61
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(5)
62
+ expect(storage.getClock()).toBe(5)
63
+ })
64
+
65
+ it('[IM3] discards snapshot tombstones when tombstoneHistoryStartsAtClock equals the document clock', () => {
66
+ const storage = new InMemorySyncStorage<TLRecord>({
67
+ snapshot: makeContractSnapshot(contractRecords, {
68
+ tombstones: { 'shape:deleted1': 5 },
69
+ tombstoneHistoryStartsAtClock: 15,
70
+ documentClock: 15,
71
+ }),
72
+ })
73
+ expect(storage.tombstones.size).toBe(0)
74
+ })
75
+
76
+ it('[IM3] keeps snapshot tombstones when there is usable history', () => {
77
+ const storage = new InMemorySyncStorage<TLRecord>({
78
+ snapshot: makeContractSnapshot(contractRecords, {
79
+ tombstones: { 'shape:deleted1': 5, 'shape:deleted2': 10 },
80
+ tombstoneHistoryStartsAtClock: 0,
81
+ documentClock: 15,
82
+ }),
83
+ })
84
+ expect(storage.tombstones.size).toBe(2)
85
+ expect(storage.tombstones.get('shape:deleted1')).toBe(5)
86
+ })
87
+ })
88
+
89
+ describe('record freezing', () => {
90
+ it('[IM4] freezes records stored via set()', () => {
91
+ const storage = new InMemorySyncStorage<TLRecord>({
92
+ snapshot: makeContractSnapshot(contractRecords),
93
+ })
94
+ const page = makePage('mutable')
95
+ storage.transaction((txn) => txn.set(page.id, page))
96
+ expect(Object.isFrozen(storage.documents.get(page.id)?.state)).toBe(true)
97
+ })
98
+
99
+ it('[IM4] freezes records loaded from the constructor snapshot', () => {
100
+ const storage = new InMemorySyncStorage<TLRecord>({
101
+ snapshot: makeContractSnapshot(contractRecords),
102
+ })
103
+ expect(Object.isFrozen(storage.documents.get(TLDOCUMENT_ID)?.state)).toBe(true)
104
+ })
105
+ })
106
+
107
+ describe('schema default', () => {
108
+ it('[IM5] assumes the earliest tldraw schema when the snapshot has none', () => {
109
+ const snapshot = makeContractSnapshot(contractRecords) as any
110
+ delete snapshot.schema
111
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot })
112
+ // the earliest serialized schema pins every sequence to version 0
113
+ const schema = storage.schema.get() as any
114
+ expect(Object.values(schema.sequences).every((v) => v === 0)).toBe(true)
115
+ })
116
+ })
117
+
118
+ describe('duplicate ids', () => {
119
+ it('[IM6] the last entry wins for duplicate document ids in a snapshot', () => {
120
+ const page1 = makePage('dupe', 'First', 'a1')
121
+ const page2 = makePage('dupe', 'Second', 'a2')
122
+ const storage = new InMemorySyncStorage<TLRecord>({
123
+ snapshot: {
124
+ documents: [
125
+ { state: page1, lastChangedClock: 1 },
126
+ { state: page2, lastChangedClock: 2 },
127
+ ],
128
+ documentClock: 5,
129
+ schema: makeContractSnapshot([]).schema,
130
+ },
131
+ })
132
+ expect(storage.documents.get('page:dupe')).toEqual({ state: page2, lastChangedClock: 2 })
133
+ })
134
+ })
135
+
136
+ describe('computeTombstonePruning', () => {
137
+ describe('threshold', () => {
138
+ it('[TP1] returns null when the count is below maxTombstones', () => {
139
+ expect(
140
+ computeTombstonePruning({
141
+ tombstones: makeTombstones(100),
142
+ documentClock: 1000,
143
+ maxTombstones: 500,
144
+ })
145
+ ).toBeNull()
146
+ })
147
+
148
+ it('[TP1] returns null when the count equals maxTombstones exactly', () => {
149
+ expect(
150
+ computeTombstonePruning({
151
+ tombstones: makeTombstones(500),
152
+ documentClock: 1000,
153
+ maxTombstones: 500,
154
+ })
155
+ ).toBeNull()
156
+ })
157
+
158
+ it('[TP1] returns null for an empty array', () => {
159
+ expect(computeTombstonePruning({ tombstones: [], documentClock: 1000 })).toBeNull()
160
+ })
161
+
162
+ it('[TP1] does not prune at exactly MAX_TOMBSTONES with default constants', () => {
163
+ expect(
164
+ computeTombstonePruning({
165
+ tombstones: makeTombstones(MAX_TOMBSTONES),
166
+ documentClock: 100000,
167
+ })
168
+ ).toBeNull()
169
+ })
170
+ })
171
+
172
+ describe('cutoff calculation', () => {
173
+ it('[TP2] deletes the oldest pruneBufferSize + excess tombstones', () => {
174
+ // 10 tombstones, max 5, buffer 2 => delete 2 + 10 - 5 = 7
175
+ const result = computeTombstonePruning({
176
+ tombstones: makeTombstones(10),
177
+ documentClock: 100,
178
+ maxTombstones: 5,
179
+ pruneBufferSize: 2,
180
+ })
181
+ expect(result).toEqual({
182
+ idsToDelete: ['doc0', 'doc1', 'doc2', 'doc3', 'doc4', 'doc5', 'doc6'],
183
+ newTombstoneHistoryStartsAtClock: 8, // clock of doc7, the oldest survivor
184
+ })
185
+ })
186
+
187
+ it('[TP2] one over MAX_TOMBSTONES deletes the buffer plus one with default constants', () => {
188
+ const result = computeTombstonePruning({
189
+ tombstones: makeTombstones(MAX_TOMBSTONES + 1),
190
+ documentClock: 100000,
191
+ })
192
+ expect(result!.idsToDelete.length).toBe(TOMBSTONE_PRUNE_BUFFER_SIZE + 1)
193
+ })
194
+
195
+ it('[TP2] works with a zero buffer size', () => {
196
+ const result = computeTombstonePruning({
197
+ tombstones: makeTombstones(101),
198
+ documentClock: 1000,
199
+ maxTombstones: 100,
200
+ pruneBufferSize: 0,
201
+ })
202
+ expect(result!.idsToDelete).toEqual(['doc0'])
203
+ expect(result!.newTombstoneHistoryStartsAtClock).toBe(2)
204
+ })
205
+
206
+ it('[TP2] extends the cutoff rather than splitting a clock value', () => {
207
+ const tombstones = [
208
+ { id: 'a', clock: 1 },
209
+ { id: 'b1', clock: 2 },
210
+ { id: 'b2', clock: 2 },
211
+ { id: 'b3', clock: 2 },
212
+ { id: 'b4', clock: 2 },
213
+ { id: 'c', clock: 3 },
214
+ ]
215
+ // max 4, buffer 1 => initial cutoff 3, which would split the clock-2 group
216
+ const result = computeTombstonePruning({
217
+ tombstones,
218
+ documentClock: 100,
219
+ maxTombstones: 4,
220
+ pruneBufferSize: 1,
221
+ })
222
+ expect(result!.idsToDelete).toEqual(['a', 'b1', 'b2', 'b3', 'b4'])
223
+ expect(result!.newTombstoneHistoryStartsAtClock).toBe(3)
224
+ })
225
+
226
+ it('[TP2] leaves the cutoff alone when it falls on a clock boundary', () => {
227
+ const tombstones = [
228
+ { id: 'a1', clock: 1 },
229
+ { id: 'a2', clock: 1 },
230
+ { id: 'a3', clock: 1 },
231
+ { id: 'b1', clock: 2 },
232
+ { id: 'b2', clock: 2 },
233
+ { id: 'b3', clock: 2 },
234
+ { id: 'c1', clock: 3 },
235
+ { id: 'c2', clock: 3 },
236
+ { id: 'c3', clock: 3 },
237
+ { id: 'd1', clock: 4 },
238
+ ]
239
+ // max 5, buffer 1 => cutoff 6, which lands exactly between clocks 2 and 3
240
+ const result = computeTombstonePruning({
241
+ tombstones,
242
+ documentClock: 100,
243
+ maxTombstones: 5,
244
+ pruneBufferSize: 1,
245
+ })
246
+ expect(result!.idsToDelete).toEqual(['a1', 'a2', 'a3', 'b1', 'b2', 'b3'])
247
+ expect(result!.newTombstoneHistoryStartsAtClock).toBe(3)
248
+ })
249
+
250
+ it('[TP2] handles non-contiguous clock values', () => {
251
+ const tombstones = [
252
+ { id: 'a', clock: 1 },
253
+ { id: 'b', clock: 5 },
254
+ { id: 'c', clock: 10 },
255
+ { id: 'd', clock: 50 },
256
+ { id: 'e', clock: 100 },
257
+ ]
258
+ const result = computeTombstonePruning({
259
+ tombstones,
260
+ documentClock: 200,
261
+ maxTombstones: 3,
262
+ pruneBufferSize: 1,
263
+ })
264
+ expect(result!.idsToDelete).toEqual(['a', 'b', 'c'])
265
+ expect(result!.newTombstoneHistoryStartsAtClock).toBe(50)
266
+ })
267
+ })
268
+
269
+ describe('history clock fallback', () => {
270
+ it('[TP3] uses documentClock when every tombstone is deleted', () => {
271
+ // max 5, buffer 10 => cutoff 15, but only 10 items exist
272
+ const result = computeTombstonePruning({
273
+ tombstones: makeTombstones(10),
274
+ documentClock: 999,
275
+ maxTombstones: 5,
276
+ pruneBufferSize: 10,
277
+ })
278
+ expect(result!.idsToDelete.length).toBe(10)
279
+ expect(result!.newTombstoneHistoryStartsAtClock).toBe(999)
280
+ })
281
+
282
+ it('[TP3] uses documentClock when a shared clock value forces deleting everything', () => {
283
+ const result = computeTombstonePruning({
284
+ tombstones: makeTombstones(100, () => 5),
285
+ documentClock: 1000,
286
+ maxTombstones: 50,
287
+ pruneBufferSize: 10,
288
+ })
289
+ expect(result!.idsToDelete.length).toBe(100)
290
+ expect(result!.newTombstoneHistoryStartsAtClock).toBe(1000)
291
+ })
292
+ })
293
+ })
294
+ })