@tldraw/sync-core 5.2.0-canary.fe03bcdddf34 → 5.2.0-canary.fff413eea248

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 (49) 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/TLSocketRoom.js +4 -1
  5. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  6. package/dist-cjs/lib/TLSyncClient.js +2 -3
  7. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  8. package/dist-cjs/lib/TLSyncRoom.js +3 -2
  9. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  10. package/dist-esm/index.mjs +1 -1
  11. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  12. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  13. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  14. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  15. package/dist-esm/lib/TLSyncRoom.mjs +3 -2
  16. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  17. package/package.json +9 -8
  18. package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
  19. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  20. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  21. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  22. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  23. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  24. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  25. package/src/lib/TLSocketRoom.ts +6 -1
  26. package/src/lib/TLSyncClient.test.ts +1127 -846
  27. package/src/lib/TLSyncClient.ts +4 -4
  28. package/src/lib/TLSyncRoom.ts +5 -2
  29. package/src/lib/TLSyncStorage.test.ts +225 -0
  30. package/src/lib/chunk.test.ts +372 -0
  31. package/src/lib/diff.test.ts +885 -0
  32. package/src/lib/interval.test.ts +43 -0
  33. package/src/lib/protocol.test.ts +43 -0
  34. package/src/lib/recordDiff.test.ts +159 -0
  35. package/src/test/TLSocketRoom.test.ts +1109 -894
  36. package/src/test/TLSyncRoom.test.ts +1735 -893
  37. package/src/test/storageContractSuite.ts +618 -0
  38. package/src/test/upgradeDowngrade.test.ts +137 -37
  39. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  40. package/src/lib/RoomSession.test.ts +0 -101
  41. package/src/lib/computeTombstonePruning.test.ts +0 -352
  42. package/src/lib/server-types.test.ts +0 -44
  43. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  44. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  45. package/src/test/chunk.test.ts +0 -385
  46. package/src/test/customMessages.test.ts +0 -36
  47. package/src/test/diff.test.ts +0 -784
  48. package/src/test/presenceMode.test.ts +0 -149
  49. package/src/test/validation.test.ts +0 -186
@@ -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
+ })