@tldraw/sync-core 4.2.0 → 4.2.2

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 (84) hide show
  1. package/dist-cjs/index.d.ts +483 -58
  2. package/dist-cjs/index.js +13 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +55 -0
  5. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +7 -0
  6. package/dist-cjs/lib/InMemorySyncStorage.js +287 -0
  7. package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
  8. package/dist-cjs/lib/MicrotaskNotifier.js +50 -0
  9. package/dist-cjs/lib/MicrotaskNotifier.js.map +7 -0
  10. package/dist-cjs/lib/NodeSqliteWrapper.js +48 -0
  11. package/dist-cjs/lib/NodeSqliteWrapper.js.map +7 -0
  12. package/dist-cjs/lib/RoomSession.js.map +1 -1
  13. package/dist-cjs/lib/SQLiteSyncStorage.js +428 -0
  14. package/dist-cjs/lib/SQLiteSyncStorage.js.map +7 -0
  15. package/dist-cjs/lib/TLSocketRoom.js +117 -69
  16. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  17. package/dist-cjs/lib/TLSyncClient.js +7 -0
  18. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  19. package/dist-cjs/lib/TLSyncRoom.js +357 -688
  20. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  21. package/dist-cjs/lib/TLSyncStorage.js +76 -0
  22. package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
  23. package/dist-cjs/lib/chunk.js +2 -2
  24. package/dist-cjs/lib/chunk.js.map +1 -1
  25. package/dist-cjs/lib/recordDiff.js +52 -0
  26. package/dist-cjs/lib/recordDiff.js.map +7 -0
  27. package/dist-esm/index.d.mts +483 -58
  28. package/dist-esm/index.mjs +20 -5
  29. package/dist-esm/index.mjs.map +2 -2
  30. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +35 -0
  31. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +7 -0
  32. package/dist-esm/lib/InMemorySyncStorage.mjs +272 -0
  33. package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
  34. package/dist-esm/lib/MicrotaskNotifier.mjs +30 -0
  35. package/dist-esm/lib/MicrotaskNotifier.mjs.map +7 -0
  36. package/dist-esm/lib/NodeSqliteWrapper.mjs +28 -0
  37. package/dist-esm/lib/NodeSqliteWrapper.mjs.map +7 -0
  38. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  39. package/dist-esm/lib/SQLiteSyncStorage.mjs +414 -0
  40. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +7 -0
  41. package/dist-esm/lib/TLSocketRoom.mjs +121 -70
  42. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  43. package/dist-esm/lib/TLSyncClient.mjs +7 -0
  44. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  45. package/dist-esm/lib/TLSyncRoom.mjs +370 -702
  46. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  47. package/dist-esm/lib/TLSyncStorage.mjs +56 -0
  48. package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
  49. package/dist-esm/lib/chunk.mjs +2 -2
  50. package/dist-esm/lib/chunk.mjs.map +1 -1
  51. package/dist-esm/lib/recordDiff.mjs +32 -0
  52. package/dist-esm/lib/recordDiff.mjs.map +7 -0
  53. package/package.json +12 -11
  54. package/src/index.ts +32 -3
  55. package/src/lib/ClientWebSocketAdapter.test.ts +3 -0
  56. package/src/lib/DurableObjectSqliteSyncWrapper.ts +95 -0
  57. package/src/lib/InMemorySyncStorage.ts +387 -0
  58. package/src/lib/MicrotaskNotifier.test.ts +429 -0
  59. package/src/lib/MicrotaskNotifier.ts +38 -0
  60. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +270 -0
  61. package/src/lib/NodeSqliteSyncWrapper.test.ts +272 -0
  62. package/src/lib/NodeSqliteWrapper.ts +99 -0
  63. package/src/lib/RoomSession.test.ts +1 -0
  64. package/src/lib/RoomSession.ts +2 -0
  65. package/src/lib/SQLiteSyncStorage.ts +627 -0
  66. package/src/lib/TLSocketRoom.ts +228 -114
  67. package/src/lib/TLSyncClient.ts +12 -0
  68. package/src/lib/TLSyncRoom.ts +473 -913
  69. package/src/lib/TLSyncStorage.ts +216 -0
  70. package/src/lib/chunk.ts +2 -2
  71. package/src/lib/computeTombstonePruning.test.ts +352 -0
  72. package/src/lib/recordDiff.ts +73 -0
  73. package/src/test/FuzzEditor.ts +4 -5
  74. package/src/test/InMemorySyncStorage.test.ts +1684 -0
  75. package/src/test/SQLiteSyncStorage.test.ts +1378 -0
  76. package/src/test/TLSocketRoom.test.ts +255 -49
  77. package/src/test/TLSyncRoom.test.ts +1024 -534
  78. package/src/test/TestServer.ts +12 -1
  79. package/src/test/customMessages.test.ts +1 -1
  80. package/src/test/presenceMode.test.ts +6 -6
  81. package/src/test/syncFuzz.test.ts +2 -4
  82. package/src/test/upgradeDowngrade.test.ts +290 -8
  83. package/src/test/validation.test.ts +15 -10
  84. package/src/test/pruneTombstones.test.ts +0 -178
@@ -0,0 +1,1378 @@
1
+ import {
2
+ createTLSchema,
3
+ DocumentRecordType,
4
+ PageRecordType,
5
+ TLDOCUMENT_ID,
6
+ TLRecord,
7
+ } from '@tldraw/tlschema'
8
+ import { IndexKey, ZERO_INDEX_KEY } from '@tldraw/utils'
9
+ import { DatabaseSync } from 'node:sqlite'
10
+ import { vi } from 'vitest'
11
+ import { MAX_TOMBSTONES, TOMBSTONE_PRUNE_BUFFER_SIZE } from '../lib/InMemorySyncStorage'
12
+ import { NodeSqliteWrapper } from '../lib/NodeSqliteWrapper'
13
+ import { SQLiteSyncStorage } from '../lib/SQLiteSyncStorage'
14
+ import { RoomSnapshot } from '../lib/TLSyncRoom'
15
+
16
+ const tlSchema = createTLSchema()
17
+
18
+ const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}): RoomSnapshot => ({
19
+ documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
20
+ clock: 0,
21
+ documentClock: 0,
22
+ schema: tlSchema.serialize(),
23
+ ...others,
24
+ })
25
+
26
+ // Helper to create legacy snapshots without documentClock field
27
+ const makeLegacySnapshot = (
28
+ records: TLRecord[],
29
+ others: Partial<Omit<RoomSnapshot, 'documentClock'>> = {}
30
+ ): Omit<RoomSnapshot, 'documentClock'> & { schema: RoomSnapshot['schema'] } => ({
31
+ documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
32
+ clock: 0,
33
+ schema: tlSchema.serialize(),
34
+ ...others,
35
+ })
36
+
37
+ const defaultRecords = [
38
+ DocumentRecordType.create({ id: TLDOCUMENT_ID }),
39
+ PageRecordType.create({
40
+ index: ZERO_INDEX_KEY,
41
+ name: 'Page 1',
42
+ id: PageRecordType.createId('page_1'),
43
+ }),
44
+ ]
45
+
46
+ function createWrapper(config?: { tablePrefix?: string }) {
47
+ const db = new DatabaseSync(':memory:')
48
+ return new NodeSqliteWrapper(db, config)
49
+ }
50
+
51
+ function getStorage(snapshot: RoomSnapshot, wrapperConfig?: { tablePrefix?: string }) {
52
+ const sql = createWrapper(wrapperConfig)
53
+ return new SQLiteSyncStorage<TLRecord>({ sql, snapshot })
54
+ }
55
+
56
+ describe('SQLiteSyncStorage', () => {
57
+ describe('Static methods', () => {
58
+ describe('hasBeenInitialized', () => {
59
+ it('returns false for empty database', () => {
60
+ const sql = createWrapper()
61
+ expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(false)
62
+ })
63
+
64
+ it('returns true after storage is initialized', () => {
65
+ const sql = createWrapper()
66
+ new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
67
+ expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
68
+ })
69
+
70
+ it('respects table prefix', () => {
71
+ const sql = createWrapper({ tablePrefix: 'test_' })
72
+ expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(false)
73
+ new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
74
+ expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
75
+ })
76
+ })
77
+
78
+ describe('getDocumentClock', () => {
79
+ it('returns null for empty database', () => {
80
+ const sql = createWrapper()
81
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(null)
82
+ })
83
+
84
+ it('returns 0 for newly initialized storage with default snapshot', () => {
85
+ const sql = createWrapper()
86
+ new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
87
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(0)
88
+ })
89
+
90
+ it('returns the documentClock value from snapshot', () => {
91
+ const sql = createWrapper()
92
+ const snapshot = makeSnapshot(defaultRecords)
93
+ snapshot.documentClock = 42
94
+ new SQLiteSyncStorage<TLRecord>({ sql, snapshot })
95
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(42)
96
+ })
97
+
98
+ it('returns updated clock after transactions', () => {
99
+ const sql = createWrapper()
100
+ const storage = new SQLiteSyncStorage<TLRecord>({
101
+ sql,
102
+ snapshot: makeSnapshot(defaultRecords),
103
+ })
104
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(0)
105
+
106
+ const newPage = PageRecordType.create({
107
+ id: PageRecordType.createId('test_page'),
108
+ name: 'Test Page',
109
+ index: 'a1' as IndexKey,
110
+ })
111
+ storage.transaction((txn) => {
112
+ txn.set(newPage.id, newPage)
113
+ })
114
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(1)
115
+ })
116
+
117
+ it('respects table prefix', () => {
118
+ const sql = createWrapper({ tablePrefix: 'test_' })
119
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(null)
120
+ new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
121
+ expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(0)
122
+ })
123
+ })
124
+ })
125
+
126
+ describe('Constructor', () => {
127
+ it('initializes documents from snapshot', () => {
128
+ const storage = getStorage(makeSnapshot(defaultRecords))
129
+
130
+ const snapshot = storage.getSnapshot()
131
+ expect(snapshot.documents.length).toBe(2)
132
+ expect(snapshot.documents.find((d) => d.state.id === TLDOCUMENT_ID)).toBeDefined()
133
+ })
134
+
135
+ it('initializes schema from snapshot', () => {
136
+ const snapshotIn = makeSnapshot(defaultRecords)
137
+ const storage = getStorage(snapshotIn)
138
+
139
+ const snapshot = storage.getSnapshot()
140
+ expect(snapshot.schema).toEqual(snapshotIn.schema)
141
+ })
142
+
143
+ it('initializes documentClock from snapshot', () => {
144
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 42 }))
145
+
146
+ expect(storage.getClock()).toBe(42)
147
+ })
148
+
149
+ it('falls back to clock when documentClock is not present (legacy snapshot)', () => {
150
+ const storage = getStorage(makeLegacySnapshot(defaultRecords, { clock: 15 }) as RoomSnapshot)
151
+
152
+ expect(storage.getClock()).toBe(15)
153
+ })
154
+
155
+ it('falls back to 0 when neither documentClock nor clock is present', () => {
156
+ const snapshot = {
157
+ documents: defaultRecords.map((r) => ({ state: r, lastChangedClock: 0 })),
158
+ schema: tlSchema.serialize(),
159
+ } as RoomSnapshot
160
+
161
+ const storage = getStorage(snapshot)
162
+
163
+ expect(storage.getClock()).toBe(0)
164
+ })
165
+
166
+ it('initializes tombstones from snapshot', () => {
167
+ const storage = getStorage(
168
+ makeSnapshot(defaultRecords, {
169
+ tombstones: { 'shape:deleted1': 5, 'shape:deleted2': 10 },
170
+ tombstoneHistoryStartsAtClock: 0,
171
+ documentClock: 15,
172
+ })
173
+ )
174
+
175
+ const snapshot = storage.getSnapshot()
176
+ expect(Object.keys(snapshot.tombstones!).length).toBe(2)
177
+ expect(snapshot.tombstones?.['shape:deleted1']).toBe(5)
178
+ expect(snapshot.tombstones?.['shape:deleted2']).toBe(10)
179
+ })
180
+
181
+ it('sets tombstoneHistoryStartsAtClock from snapshot', () => {
182
+ const storage = getStorage(
183
+ makeSnapshot(defaultRecords, {
184
+ tombstoneHistoryStartsAtClock: 5,
185
+ documentClock: 10,
186
+ })
187
+ )
188
+
189
+ const snapshot = storage.getSnapshot()
190
+ expect(snapshot.tombstoneHistoryStartsAtClock).toBe(5)
191
+ })
192
+
193
+ it('defaults tombstoneHistoryStartsAtClock to documentClock when not provided', () => {
194
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 20 }))
195
+
196
+ const snapshot = storage.getSnapshot()
197
+ expect(snapshot.tombstoneHistoryStartsAtClock).toBe(20)
198
+ })
199
+
200
+ it('handles empty documents array', () => {
201
+ const storage = getStorage(makeSnapshot([]))
202
+
203
+ const snapshot = storage.getSnapshot()
204
+ expect(snapshot.documents.length).toBe(0)
205
+ })
206
+
207
+ it('works with table prefix', () => {
208
+ const storage = getStorage(makeSnapshot(defaultRecords), { tablePrefix: 'myapp_' })
209
+
210
+ const snapshot = storage.getSnapshot()
211
+ expect(snapshot.documents.length).toBe(2)
212
+ })
213
+
214
+ it('reinitializes storage when snapshot provided to existing tables', () => {
215
+ const sql = createWrapper()
216
+
217
+ // First initialization
218
+ new SQLiteSyncStorage<TLRecord>({
219
+ sql,
220
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
221
+ })
222
+
223
+ // Second initialization with different data
224
+ const newRecords = [DocumentRecordType.create({ id: TLDOCUMENT_ID })]
225
+ const storage2 = new SQLiteSyncStorage<TLRecord>({
226
+ sql,
227
+ snapshot: makeSnapshot(newRecords, { documentClock: 20 }),
228
+ })
229
+
230
+ const snapshot = storage2.getSnapshot()
231
+ expect(snapshot.documents.length).toBe(1)
232
+ expect(snapshot.documentClock).toBe(20)
233
+ })
234
+ })
235
+
236
+ describe('Transaction', () => {
237
+ describe('get()', () => {
238
+ it('returns record by id', () => {
239
+ const storage = getStorage(makeSnapshot(defaultRecords))
240
+
241
+ storage.transaction((txn) => {
242
+ const doc = txn.get(TLDOCUMENT_ID)
243
+ expect(doc).toBeDefined()
244
+ expect(doc?.id).toBe(TLDOCUMENT_ID)
245
+ })
246
+ })
247
+
248
+ it('returns undefined for non-existent record', () => {
249
+ const storage = getStorage(makeSnapshot(defaultRecords))
250
+
251
+ storage.transaction((txn) => {
252
+ expect(txn.get('nonexistent')).toBeUndefined()
253
+ })
254
+ })
255
+ })
256
+
257
+ describe('set()', () => {
258
+ it('creates new records', () => {
259
+ const storage = getStorage(makeSnapshot(defaultRecords))
260
+
261
+ const newPage = PageRecordType.create({
262
+ id: PageRecordType.createId('new_page'),
263
+ name: 'New Page',
264
+ index: 'a2' as IndexKey,
265
+ })
266
+
267
+ storage.transaction((txn) => {
268
+ txn.set(newPage.id, newPage)
269
+ })
270
+
271
+ const snapshot = storage.getSnapshot()
272
+ expect(snapshot.documents.length).toBe(3)
273
+ expect(snapshot.documents.find((d) => d.state.id === newPage.id)?.state).toEqual(newPage)
274
+ })
275
+
276
+ it('updates existing records', () => {
277
+ const storage = getStorage(makeSnapshot(defaultRecords))
278
+
279
+ const pageId = PageRecordType.createId('page_1')
280
+ const updatedPage = PageRecordType.create({
281
+ id: pageId,
282
+ name: 'Updated Page',
283
+ index: ZERO_INDEX_KEY,
284
+ })
285
+
286
+ storage.transaction((txn) => {
287
+ txn.set(pageId, updatedPage)
288
+ })
289
+
290
+ const snapshot = storage.getSnapshot()
291
+ expect(snapshot.documents.find((d) => d.state.id === pageId)?.state).toEqual(updatedPage)
292
+ })
293
+
294
+ it('clears tombstone when re-creating a deleted record', () => {
295
+ const pageId = PageRecordType.createId('page_to_delete')
296
+ const page = PageRecordType.create({
297
+ id: pageId,
298
+ name: 'Page',
299
+ index: 'a2' as IndexKey,
300
+ })
301
+
302
+ const storage = getStorage(makeSnapshot([...defaultRecords, page]))
303
+
304
+ // Delete the page
305
+ storage.transaction((txn) => {
306
+ txn.delete(pageId)
307
+ })
308
+
309
+ let snapshot = storage.getSnapshot()
310
+ expect(snapshot.tombstones?.[pageId]).toBeDefined()
311
+ expect(snapshot.documents.find((d) => d.state.id === pageId)).toBeUndefined()
312
+
313
+ // Re-create the page
314
+ storage.transaction((txn) => {
315
+ txn.set(pageId, page)
316
+ })
317
+
318
+ snapshot = storage.getSnapshot()
319
+ expect(snapshot.tombstones?.[pageId]).toBeUndefined()
320
+ expect(snapshot.documents.find((d) => d.state.id === pageId)).toBeDefined()
321
+ })
322
+
323
+ it('sets lastChangedClock to the incremented clock', () => {
324
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 5 }))
325
+
326
+ const newPage = PageRecordType.create({
327
+ id: PageRecordType.createId('new_page'),
328
+ name: 'New Page',
329
+ index: 'a2' as IndexKey,
330
+ })
331
+
332
+ storage.transaction((txn) => {
333
+ txn.set(newPage.id, newPage)
334
+ })
335
+
336
+ const snapshot = storage.getSnapshot()
337
+ expect(snapshot.documents.find((d) => d.state.id === newPage.id)?.lastChangedClock).toBe(6)
338
+ })
339
+ })
340
+
341
+ describe('delete()', () => {
342
+ it('removes records', () => {
343
+ const storage = getStorage(makeSnapshot(defaultRecords))
344
+
345
+ const pageId = PageRecordType.createId('page_1')
346
+
347
+ storage.transaction((txn) => {
348
+ txn.delete(pageId)
349
+ })
350
+
351
+ const snapshot = storage.getSnapshot()
352
+ expect(snapshot.documents.find((d) => d.state.id === pageId)).toBeUndefined()
353
+ })
354
+
355
+ it('creates tombstones', () => {
356
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
357
+
358
+ const pageId = PageRecordType.createId('page_1')
359
+
360
+ storage.transaction((txn) => {
361
+ txn.delete(pageId)
362
+ })
363
+
364
+ const snapshot = storage.getSnapshot()
365
+ expect(snapshot.tombstones?.[pageId]).toBe(11) // clock incremented to 11
366
+ })
367
+ })
368
+
369
+ describe('getClock()', () => {
370
+ it('returns current clock at start of transaction', () => {
371
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 42 }))
372
+
373
+ storage.transaction((txn) => {
374
+ expect(txn.getClock()).toBe(42)
375
+ })
376
+ })
377
+
378
+ it('returns incremented clock after a write', () => {
379
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 42 }))
380
+
381
+ storage.transaction((txn) => {
382
+ expect(txn.getClock()).toBe(42)
383
+ const newPage = PageRecordType.create({
384
+ id: PageRecordType.createId('new'),
385
+ name: 'New',
386
+ index: 'a2' as IndexKey,
387
+ })
388
+ txn.set(newPage.id, newPage)
389
+ expect(txn.getClock()).toBe(43)
390
+ })
391
+ })
392
+
393
+ it('increments clock only once per transaction', () => {
394
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
395
+
396
+ storage.transaction((txn) => {
397
+ const page1 = PageRecordType.create({
398
+ id: PageRecordType.createId('p1'),
399
+ name: 'P1',
400
+ index: 'a2' as IndexKey,
401
+ })
402
+ const page2 = PageRecordType.create({
403
+ id: PageRecordType.createId('p2'),
404
+ name: 'P2',
405
+ index: 'a3' as IndexKey,
406
+ })
407
+
408
+ txn.set(page1.id, page1)
409
+ expect(txn.getClock()).toBe(11)
410
+
411
+ txn.set(page2.id, page2)
412
+ expect(txn.getClock()).toBe(11) // Still 11, not 12
413
+
414
+ txn.delete(PageRecordType.createId('page_1'))
415
+ expect(txn.getClock()).toBe(11) // Still 11
416
+ })
417
+
418
+ expect(storage.getClock()).toBe(11)
419
+ })
420
+ })
421
+
422
+ describe('entries()', () => {
423
+ it('iterates over all documents', () => {
424
+ const storage = getStorage(makeSnapshot(defaultRecords))
425
+
426
+ storage.transaction((txn) => {
427
+ const entries = Array.from(txn.entries())
428
+ expect(entries.length).toBe(2)
429
+ expect(entries.map(([id]) => id).sort()).toEqual(defaultRecords.map((r) => r.id).sort())
430
+ })
431
+ })
432
+ })
433
+
434
+ describe('keys()', () => {
435
+ it('iterates over all document ids', () => {
436
+ const storage = getStorage(makeSnapshot(defaultRecords))
437
+
438
+ storage.transaction((txn) => {
439
+ const keys = Array.from(txn.keys())
440
+ expect(keys.sort()).toEqual(defaultRecords.map((r) => r.id).sort())
441
+ })
442
+ })
443
+ })
444
+
445
+ describe('values()', () => {
446
+ it('iterates over all document states', () => {
447
+ const storage = getStorage(makeSnapshot(defaultRecords))
448
+
449
+ storage.transaction((txn) => {
450
+ const values = Array.from(txn.values())
451
+ expect(values.length).toBe(2)
452
+ expect(values.map((v) => v.id).sort()).toEqual(defaultRecords.map((r) => r.id).sort())
453
+ })
454
+ })
455
+ })
456
+
457
+ describe('iterator consumption after transaction ends', () => {
458
+ it('throws when entries() iterator is consumed after transaction ends', () => {
459
+ const storage = getStorage(makeSnapshot(defaultRecords))
460
+
461
+ let iterator: Iterator<[string, TLRecord]>
462
+
463
+ storage.transaction((txn) => {
464
+ iterator = txn.entries()[Symbol.iterator]()
465
+ // Consume one item inside the transaction - should work
466
+ iterator.next()
467
+ })
468
+
469
+ // Trying to consume more after transaction ends should throw
470
+ expect(() => iterator.next()).toThrow('Transaction has ended')
471
+ })
472
+
473
+ it('throws when keys() iterator is consumed after transaction ends', () => {
474
+ const storage = getStorage(makeSnapshot(defaultRecords))
475
+
476
+ let iterator: Iterator<string>
477
+
478
+ storage.transaction((txn) => {
479
+ iterator = txn.keys()[Symbol.iterator]()
480
+ iterator.next()
481
+ })
482
+
483
+ expect(() => iterator.next()).toThrow('Transaction has ended')
484
+ })
485
+
486
+ it('throws when values() iterator is consumed after transaction ends', () => {
487
+ const storage = getStorage(makeSnapshot(defaultRecords))
488
+
489
+ let iterator: Iterator<TLRecord>
490
+
491
+ storage.transaction((txn) => {
492
+ iterator = txn.values()[Symbol.iterator]()
493
+ iterator.next()
494
+ })
495
+
496
+ expect(() => iterator.next()).toThrow('Transaction has ended')
497
+ })
498
+
499
+ it('allows full consumption of iterator within transaction', () => {
500
+ const storage = getStorage(makeSnapshot(defaultRecords))
501
+
502
+ storage.transaction((txn) => {
503
+ // Should be able to fully consume all iterators
504
+ const entries = Array.from(txn.entries())
505
+ const keys = Array.from(txn.keys())
506
+ const values = Array.from(txn.values())
507
+
508
+ expect(entries.length).toBe(2)
509
+ expect(keys.length).toBe(2)
510
+ expect(values.length).toBe(2)
511
+ })
512
+ })
513
+ })
514
+
515
+ describe('getSchema() / setSchema()', () => {
516
+ it('gets the current schema', () => {
517
+ const snapshotIn = makeSnapshot(defaultRecords)
518
+ const storage = getStorage(snapshotIn)
519
+
520
+ storage.transaction((txn) => {
521
+ expect(txn.getSchema()).toEqual(snapshotIn.schema)
522
+ })
523
+ })
524
+
525
+ it('sets the schema', () => {
526
+ const storage = getStorage(makeSnapshot(defaultRecords))
527
+
528
+ const newSchema = { ...tlSchema.serialize(), schemaVersion: 99 as any }
529
+
530
+ storage.transaction((txn) => {
531
+ txn.setSchema(newSchema)
532
+ })
533
+
534
+ const snapshot = storage.getSnapshot()
535
+ expect(snapshot.schema?.schemaVersion).toBe(99)
536
+ })
537
+ })
538
+
539
+ describe('transaction result', () => {
540
+ it('returns result from callback', () => {
541
+ const storage = getStorage(makeSnapshot(defaultRecords))
542
+
543
+ const { result } = storage.transaction((txn) => {
544
+ return txn.get(TLDOCUMENT_ID)
545
+ })
546
+
547
+ expect(result?.id).toBe(TLDOCUMENT_ID)
548
+ })
549
+
550
+ it('returns didChange: false when no writes occur', () => {
551
+ const storage = getStorage(makeSnapshot(defaultRecords))
552
+
553
+ const { didChange, documentClock } = storage.transaction((txn) => {
554
+ txn.get(TLDOCUMENT_ID)
555
+ })
556
+
557
+ expect(didChange).toBe(false)
558
+ expect(documentClock).toBe(0)
559
+ })
560
+
561
+ it('returns didChange: true when writes occur', () => {
562
+ const storage = getStorage(makeSnapshot(defaultRecords))
563
+
564
+ const newPage = PageRecordType.create({
565
+ id: PageRecordType.createId('new'),
566
+ name: 'New',
567
+ index: 'a2' as IndexKey,
568
+ })
569
+
570
+ const { didChange, documentClock } = storage.transaction((txn) => {
571
+ txn.set(newPage.id, newPage)
572
+ })
573
+
574
+ expect(didChange).toBe(true)
575
+ expect(documentClock).toBe(1)
576
+ })
577
+
578
+ it('throws when callback returns a promise', () => {
579
+ const storage = getStorage(makeSnapshot(defaultRecords))
580
+
581
+ expect(() => {
582
+ storage.transaction(() => Promise.resolve() as any)
583
+ }).toThrow('Transaction must return a value, not a promise')
584
+ })
585
+ })
586
+ })
587
+
588
+ describe('getChangesSince', () => {
589
+ it('returns puts for records changed after sinceClock', () => {
590
+ const storage = getStorage(
591
+ makeSnapshot(defaultRecords, {
592
+ documents: [
593
+ { state: defaultRecords[0], lastChangedClock: 5 },
594
+ { state: defaultRecords[1], lastChangedClock: 10 },
595
+ ],
596
+ documentClock: 15,
597
+ tombstoneHistoryStartsAtClock: 0,
598
+ })
599
+ )
600
+
601
+ storage.transaction((txn) => {
602
+ const changes = txn.getChangesSince(7)!
603
+ const puts = Object.values(changes.diff.puts)
604
+
605
+ expect(puts.length).toBe(1)
606
+ expect((puts[0] as TLRecord).id).toBe(defaultRecords[1].id) // only record with clock 10 > 7
607
+ expect(changes.wipeAll).toBe(false)
608
+ })
609
+ })
610
+
611
+ it('returns all records when sinceClock is before all changes', () => {
612
+ const storage = getStorage(
613
+ makeSnapshot(defaultRecords, {
614
+ documents: [
615
+ { state: defaultRecords[0], lastChangedClock: 5 },
616
+ { state: defaultRecords[1], lastChangedClock: 10 },
617
+ ],
618
+ documentClock: 15,
619
+ tombstoneHistoryStartsAtClock: 0,
620
+ })
621
+ )
622
+
623
+ storage.transaction((txn) => {
624
+ const changes = txn.getChangesSince(0)!
625
+ const puts = Object.values(changes.diff.puts)
626
+
627
+ expect(puts.length).toBe(2)
628
+ })
629
+ })
630
+
631
+ it('returns deletes for tombstones after sinceClock', () => {
632
+ const storage = getStorage(
633
+ makeSnapshot(defaultRecords, {
634
+ tombstones: {
635
+ 'shape:deleted1': 5,
636
+ 'shape:deleted2': 12,
637
+ },
638
+ documentClock: 15,
639
+ tombstoneHistoryStartsAtClock: 0,
640
+ })
641
+ )
642
+
643
+ storage.transaction((txn) => {
644
+ const changes = txn.getChangesSince(7)!
645
+
646
+ expect(changes.diff.deletes).toEqual(['shape:deleted2']) // only tombstone with clock 12 > 7
647
+ })
648
+ })
649
+
650
+ it('returns wipeAll: true when sinceClock < tombstoneHistoryStartsAtClock', () => {
651
+ const storage = getStorage(
652
+ makeSnapshot(defaultRecords, {
653
+ documentClock: 20,
654
+ tombstoneHistoryStartsAtClock: 10,
655
+ })
656
+ )
657
+
658
+ storage.transaction((txn) => {
659
+ const changes = txn.getChangesSince(5)! // 5 < 10
660
+
661
+ expect(changes.wipeAll).toBe(true)
662
+ // When wipeAll is true, all documents are returned
663
+ const puts = Object.values(changes.diff.puts)
664
+ expect(puts.length).toBe(2)
665
+ })
666
+ })
667
+
668
+ it('returns wipeAll: false when sinceClock >= tombstoneHistoryStartsAtClock', () => {
669
+ const storage = getStorage(
670
+ makeSnapshot(defaultRecords, {
671
+ documentClock: 20,
672
+ tombstoneHistoryStartsAtClock: 10,
673
+ })
674
+ )
675
+
676
+ storage.transaction((txn) => {
677
+ const changes = txn.getChangesSince(15)!
678
+
679
+ expect(changes.wipeAll).toBe(false)
680
+ })
681
+ })
682
+
683
+ it('returns undefined when no changes since clock', () => {
684
+ const storage = getStorage(
685
+ makeSnapshot(defaultRecords, {
686
+ documents: [
687
+ { state: defaultRecords[0], lastChangedClock: 5 },
688
+ { state: defaultRecords[1], lastChangedClock: 10 },
689
+ ],
690
+ documentClock: 15,
691
+ tombstoneHistoryStartsAtClock: 0,
692
+ })
693
+ )
694
+
695
+ storage.transaction((txn) => {
696
+ const changes = txn.getChangesSince(15)
697
+ expect(changes).toBeUndefined()
698
+ })
699
+ })
700
+ })
701
+
702
+ describe('onChange', () => {
703
+ it('accepts onChange callback in constructor', async () => {
704
+ const listener = vi.fn()
705
+ const sql = createWrapper()
706
+ const storage = new SQLiteSyncStorage<TLRecord>({
707
+ sql,
708
+ snapshot: makeSnapshot(defaultRecords),
709
+ onChange: listener,
710
+ })
711
+
712
+ await Promise.resolve()
713
+
714
+ const newPage = PageRecordType.create({
715
+ id: PageRecordType.createId('new'),
716
+ name: 'New',
717
+ index: 'a2' as IndexKey,
718
+ })
719
+
720
+ storage.transaction((txn) => {
721
+ txn.set(newPage.id, newPage)
722
+ })
723
+
724
+ await Promise.resolve()
725
+
726
+ expect(listener).toHaveBeenCalledTimes(1)
727
+ expect(listener).toHaveBeenCalledWith(
728
+ expect.objectContaining({
729
+ documentClock: 1,
730
+ })
731
+ )
732
+ })
733
+
734
+ it('notifies listeners after changes', async () => {
735
+ const storage = getStorage(makeSnapshot(defaultRecords))
736
+
737
+ const listener = vi.fn()
738
+ storage.onChange(listener)
739
+
740
+ // Wait for listener registration (microtask)
741
+ await Promise.resolve()
742
+
743
+ const newPage = PageRecordType.create({
744
+ id: PageRecordType.createId('new'),
745
+ name: 'New',
746
+ index: 'a2' as IndexKey,
747
+ })
748
+
749
+ storage.transaction((txn) => {
750
+ txn.set(newPage.id, newPage)
751
+ })
752
+
753
+ // Wait for notification (microtask)
754
+ await Promise.resolve()
755
+
756
+ expect(listener).toHaveBeenCalledTimes(1)
757
+ })
758
+
759
+ it('receives correct documentClock', async () => {
760
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
761
+
762
+ const listener = vi.fn()
763
+ storage.onChange(listener)
764
+
765
+ await Promise.resolve()
766
+
767
+ const newPage = PageRecordType.create({
768
+ id: PageRecordType.createId('new'),
769
+ name: 'New',
770
+ index: 'a2' as IndexKey,
771
+ })
772
+
773
+ storage.transaction((txn) => {
774
+ txn.set(newPage.id, newPage)
775
+ })
776
+
777
+ await Promise.resolve()
778
+
779
+ expect(listener).toHaveBeenCalledWith(
780
+ expect.objectContaining({
781
+ documentClock: 11,
782
+ })
783
+ )
784
+ })
785
+
786
+ it('receives transaction id when provided', async () => {
787
+ const storage = getStorage(makeSnapshot(defaultRecords))
788
+
789
+ const listener = vi.fn()
790
+ storage.onChange(listener)
791
+
792
+ await Promise.resolve()
793
+
794
+ const newPage = PageRecordType.create({
795
+ id: PageRecordType.createId('new'),
796
+ name: 'New',
797
+ index: 'a2' as IndexKey,
798
+ })
799
+
800
+ storage.transaction(
801
+ (txn) => {
802
+ txn.set(newPage.id, newPage)
803
+ },
804
+ { id: 'my-transaction-id' }
805
+ )
806
+
807
+ await Promise.resolve()
808
+
809
+ expect(listener).toHaveBeenCalledWith(
810
+ expect.objectContaining({
811
+ id: 'my-transaction-id',
812
+ })
813
+ )
814
+ })
815
+
816
+ it('unsubscribe prevents future notifications', async () => {
817
+ const storage = getStorage(makeSnapshot(defaultRecords))
818
+
819
+ const listener = vi.fn()
820
+ const unsubscribe = storage.onChange(listener)
821
+
822
+ await Promise.resolve()
823
+
824
+ // Unsubscribe immediately
825
+ unsubscribe()
826
+
827
+ const newPage = PageRecordType.create({
828
+ id: PageRecordType.createId('new'),
829
+ name: 'New',
830
+ index: 'a2' as IndexKey,
831
+ })
832
+
833
+ storage.transaction((txn) => {
834
+ txn.set(newPage.id, newPage)
835
+ })
836
+
837
+ await Promise.resolve()
838
+
839
+ expect(listener).not.toHaveBeenCalled()
840
+ })
841
+
842
+ it('does not notify for read-only transactions', async () => {
843
+ const storage = getStorage(makeSnapshot(defaultRecords))
844
+
845
+ const listener = vi.fn()
846
+ storage.onChange(listener)
847
+
848
+ await Promise.resolve()
849
+
850
+ storage.transaction((txn) => {
851
+ txn.get(TLDOCUMENT_ID) // read only
852
+ })
853
+
854
+ await Promise.resolve()
855
+
856
+ expect(listener).not.toHaveBeenCalled()
857
+ })
858
+ })
859
+
860
+ describe('getSnapshot', () => {
861
+ it('returns correct snapshot structure', () => {
862
+ const storage = getStorage(
863
+ makeSnapshot(defaultRecords, {
864
+ documentClock: 15,
865
+ tombstoneHistoryStartsAtClock: 5,
866
+ tombstones: { 'shape:deleted': 10 },
867
+ })
868
+ )
869
+
870
+ const snapshot = storage.getSnapshot()
871
+
872
+ expect(snapshot.documentClock).toBe(15)
873
+ expect(snapshot.tombstoneHistoryStartsAtClock).toBe(5)
874
+ expect(snapshot.documents.length).toBe(2)
875
+ expect(snapshot.tombstones).toEqual({ 'shape:deleted': 10 })
876
+ expect(snapshot.schema).toEqual(tlSchema.serialize())
877
+ })
878
+
879
+ it('reflects changes from transactions', () => {
880
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 0 }))
881
+
882
+ const newPage = PageRecordType.create({
883
+ id: PageRecordType.createId('new'),
884
+ name: 'New',
885
+ index: 'a2' as IndexKey,
886
+ })
887
+
888
+ storage.transaction((txn) => {
889
+ txn.set(newPage.id, newPage)
890
+ })
891
+
892
+ const snapshot = storage.getSnapshot()
893
+
894
+ expect(snapshot.documentClock).toBe(1)
895
+ expect(snapshot.documents.length).toBe(3)
896
+ expect(snapshot.documents.find((d) => d.state.id === newPage.id)).toBeDefined()
897
+ })
898
+ })
899
+
900
+ describe('Edge cases', () => {
901
+ describe('Transaction error handling', () => {
902
+ it('does not increment clock if transaction throws', () => {
903
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
904
+
905
+ expect(() => {
906
+ storage.transaction(() => {
907
+ throw new Error('Oops!')
908
+ })
909
+ }).toThrow('Oops!')
910
+
911
+ // Clock should not have changed
912
+ expect(storage.getClock()).toBe(10)
913
+ })
914
+
915
+ it('rolls back changes if transaction throws after a write', () => {
916
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
917
+
918
+ const newPage = PageRecordType.create({
919
+ id: PageRecordType.createId('new'),
920
+ name: 'New',
921
+ index: 'a2' as IndexKey,
922
+ })
923
+
924
+ expect(() => {
925
+ storage.transaction((txn) => {
926
+ txn.set(newPage.id, newPage)
927
+ throw new Error('Oops after write!')
928
+ })
929
+ }).toThrow('Oops after write!')
930
+
931
+ // Document should not have been added (rolled back)
932
+ const snapshot = storage.getSnapshot()
933
+ expect(snapshot.documents.find((d) => d.state.id === newPage.id)).toBeUndefined()
934
+ // Clock should not have changed
935
+ expect(storage.getClock()).toBe(10)
936
+ })
937
+ })
938
+
939
+ describe('Deleting non-existent records', () => {
940
+ it('does not create a tombstone for records that never existed', () => {
941
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 5 }))
942
+
943
+ storage.transaction((txn) => {
944
+ txn.delete('nonexistent:record')
945
+ })
946
+
947
+ // No tombstone should be created for a record that never existed
948
+ const snapshot = storage.getSnapshot()
949
+ expect(snapshot.tombstones?.['nonexistent:record']).toBeUndefined()
950
+ // Clock should not be incremented since nothing changed
951
+ expect(storage.getClock()).toBe(5)
952
+ })
953
+
954
+ it('does not increment clock when deleting non-existent record', () => {
955
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
956
+
957
+ const { didChange, documentClock } = storage.transaction((txn) => {
958
+ txn.delete('nonexistent:record')
959
+ })
960
+
961
+ expect(didChange).toBe(false)
962
+ expect(documentClock).toBe(10)
963
+ })
964
+ })
965
+
966
+ describe('Set with mismatched ID', () => {
967
+ it('throws when key does not match record.id', () => {
968
+ const storage = getStorage(makeSnapshot(defaultRecords))
969
+
970
+ const page = PageRecordType.create({
971
+ id: PageRecordType.createId('actual_id'),
972
+ name: 'Test',
973
+ index: 'a2' as IndexKey,
974
+ })
975
+
976
+ // Attempting to store with a different key than the record's id should throw
977
+ expect(() => {
978
+ storage.transaction((txn) => {
979
+ txn.set('different:key', page)
980
+ })
981
+ }).toThrow('Record id mismatch: key does not match record.id')
982
+ })
983
+
984
+ it('succeeds when key matches record.id', () => {
985
+ const storage = getStorage(makeSnapshot(defaultRecords))
986
+
987
+ const page = PageRecordType.create({
988
+ id: PageRecordType.createId('my_page'),
989
+ name: 'Test',
990
+ index: 'a2' as IndexKey,
991
+ })
992
+
993
+ // Store with matching key
994
+ storage.transaction((txn) => {
995
+ txn.set(page.id, page)
996
+ })
997
+
998
+ const snapshot = storage.getSnapshot()
999
+ expect(snapshot.documents.find((d) => d.state.id === page.id)).toBeDefined()
1000
+ })
1001
+ })
1002
+
1003
+ describe('getChangesSince boundary conditions', () => {
1004
+ it('sinceClock exactly equal to tombstoneHistoryStartsAtClock is NOT wipeAll', () => {
1005
+ const storage = getStorage(
1006
+ makeSnapshot(defaultRecords, {
1007
+ documentClock: 20,
1008
+ tombstoneHistoryStartsAtClock: 10,
1009
+ })
1010
+ )
1011
+
1012
+ storage.transaction((txn) => {
1013
+ // sinceClock === tombstoneHistoryStartsAtClock
1014
+ const changes = txn.getChangesSince(10)!
1015
+ expect(changes.wipeAll).toBe(false)
1016
+ })
1017
+ })
1018
+
1019
+ it('sinceClock one less than tombstoneHistoryStartsAtClock IS wipeAll', () => {
1020
+ const storage = getStorage(
1021
+ makeSnapshot(defaultRecords, {
1022
+ documentClock: 20,
1023
+ tombstoneHistoryStartsAtClock: 10,
1024
+ })
1025
+ )
1026
+
1027
+ storage.transaction((txn) => {
1028
+ const changes = txn.getChangesSince(9)!
1029
+ expect(changes.wipeAll).toBe(true)
1030
+ })
1031
+ })
1032
+
1033
+ it('handles negative sinceClock', () => {
1034
+ const storage = getStorage(
1035
+ makeSnapshot(defaultRecords, {
1036
+ documentClock: 10,
1037
+ tombstoneHistoryStartsAtClock: 0,
1038
+ })
1039
+ )
1040
+
1041
+ storage.transaction((txn) => {
1042
+ const changes = txn.getChangesSince(-1)!
1043
+ // -1 < 0, so wipeAll should be true
1044
+ expect(changes.wipeAll).toBe(true)
1045
+ // All documents should be returned
1046
+ expect(Object.values(changes.diff.puts).length).toBe(2)
1047
+ })
1048
+ })
1049
+
1050
+ it('returns undefined when sinceClock equals current documentClock', () => {
1051
+ const storage = getStorage(
1052
+ makeSnapshot(defaultRecords, {
1053
+ documents: [
1054
+ { state: defaultRecords[0], lastChangedClock: 5 },
1055
+ { state: defaultRecords[1], lastChangedClock: 10 },
1056
+ ],
1057
+ documentClock: 10,
1058
+ tombstoneHistoryStartsAtClock: 0,
1059
+ })
1060
+ )
1061
+
1062
+ storage.transaction((txn) => {
1063
+ const changes = txn.getChangesSince(10)
1064
+ expect(changes).toBeUndefined()
1065
+ })
1066
+ })
1067
+
1068
+ it('returns all changes when sinceClock is greater than documentClock', () => {
1069
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
1070
+
1071
+ storage.transaction((txn) => {
1072
+ const changes = txn.getChangesSince(100)!
1073
+ expect(Object.values(changes.diff.puts).length).toBe(2)
1074
+ expect(changes.wipeAll).toBe(true)
1075
+ })
1076
+ })
1077
+ })
1078
+
1079
+ describe('Transaction result consistency', () => {
1080
+ it('didChange reflects whether clock was incremented', () => {
1081
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
1082
+
1083
+ // Read-only transaction
1084
+ const readResult = storage.transaction((txn) => {
1085
+ txn.get(TLDOCUMENT_ID)
1086
+ return 'read'
1087
+ })
1088
+
1089
+ expect(readResult.didChange).toBe(false)
1090
+ expect(readResult.documentClock).toBe(10)
1091
+
1092
+ // Write transaction
1093
+ const writeResult = storage.transaction((txn) => {
1094
+ txn.set(TLDOCUMENT_ID, defaultRecords[0])
1095
+ return 'write'
1096
+ })
1097
+
1098
+ expect(writeResult.didChange).toBe(true)
1099
+ expect(writeResult.documentClock).toBe(11)
1100
+ })
1101
+
1102
+ it('documentClock in result matches storage.getClock()', () => {
1103
+ const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 5 }))
1104
+
1105
+ const result = storage.transaction((txn) => {
1106
+ const page = PageRecordType.create({
1107
+ id: PageRecordType.createId('new'),
1108
+ name: 'New',
1109
+ index: 'a2' as IndexKey,
1110
+ })
1111
+ txn.set(page.id, page)
1112
+ })
1113
+
1114
+ expect(result.documentClock).toBe(storage.getClock())
1115
+ expect(result.documentClock).toBe(6)
1116
+ })
1117
+ })
1118
+ })
1119
+
1120
+ describe('pruneTombstones', () => {
1121
+ // Helper to create a snapshot with many tombstones
1122
+ function makeTombstoneMap(count: number, clockFn: (i: number) => number = (i) => i + 1) {
1123
+ const tombstones: Record<string, number> = {}
1124
+ for (let i = 0; i < count; i++) {
1125
+ tombstones[`doc${i}`] = clockFn(i)
1126
+ }
1127
+ return tombstones
1128
+ }
1129
+
1130
+ it('does not prune when below MAX_TOMBSTONES', () => {
1131
+ const tombstoneCount = Math.floor(MAX_TOMBSTONES / 2)
1132
+ const storage = getStorage(
1133
+ makeSnapshot(defaultRecords, {
1134
+ documentClock: tombstoneCount + 1,
1135
+ tombstoneHistoryStartsAtClock: 0,
1136
+ tombstones: makeTombstoneMap(tombstoneCount),
1137
+ })
1138
+ )
1139
+
1140
+ const snapshotBefore = storage.getSnapshot()
1141
+ const initialCount = Object.keys(snapshotBefore.tombstones!).length
1142
+ const initialHistoryClock = snapshotBefore.tombstoneHistoryStartsAtClock
1143
+
1144
+ // Schedule the throttled function then force it to run
1145
+ storage.pruneTombstones()
1146
+ storage.pruneTombstones.flush()
1147
+
1148
+ const snapshotAfter = storage.getSnapshot()
1149
+ expect(Object.keys(snapshotAfter.tombstones!).length).toBe(initialCount)
1150
+ expect(snapshotAfter.tombstoneHistoryStartsAtClock).toBe(initialHistoryClock)
1151
+ })
1152
+
1153
+ it('prunes when exceeding MAX_TOMBSTONES', () => {
1154
+ const totalTombstones = MAX_TOMBSTONES + 500
1155
+ const storage = getStorage(
1156
+ makeSnapshot(defaultRecords, {
1157
+ documentClock: totalTombstones + 1,
1158
+ tombstoneHistoryStartsAtClock: 0,
1159
+ tombstones: makeTombstoneMap(totalTombstones),
1160
+ })
1161
+ )
1162
+
1163
+ const snapshotBefore = storage.getSnapshot()
1164
+ expect(Object.keys(snapshotBefore.tombstones!).length).toBe(totalTombstones)
1165
+
1166
+ // Schedule the throttled function then force it to run
1167
+ storage.pruneTombstones()
1168
+ storage.pruneTombstones.flush()
1169
+
1170
+ const snapshotAfter = storage.getSnapshot()
1171
+ expect(Object.keys(snapshotAfter.tombstones!).length).toBeLessThan(totalTombstones)
1172
+ expect(Object.keys(snapshotAfter.tombstones!).length).toBeLessThanOrEqual(
1173
+ MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE
1174
+ )
1175
+ expect(snapshotAfter.tombstoneHistoryStartsAtClock).toBeGreaterThan(0)
1176
+ })
1177
+
1178
+ it('updates tombstoneHistoryStartsAtClock correctly', () => {
1179
+ const totalTombstones = MAX_TOMBSTONES * 2
1180
+ const storage = getStorage(
1181
+ makeSnapshot(defaultRecords, {
1182
+ documentClock: totalTombstones + 1000,
1183
+ tombstoneHistoryStartsAtClock: 0,
1184
+ tombstones: makeTombstoneMap(totalTombstones),
1185
+ })
1186
+ )
1187
+
1188
+ const snapshotBefore = storage.getSnapshot()
1189
+ const initialHistoryClock = snapshotBefore.tombstoneHistoryStartsAtClock
1190
+
1191
+ // Schedule the throttled function then force it to run
1192
+ storage.pruneTombstones()
1193
+ storage.pruneTombstones.flush()
1194
+
1195
+ const snapshotAfter = storage.getSnapshot()
1196
+
1197
+ // History clock should have advanced
1198
+ expect(snapshotAfter.tombstoneHistoryStartsAtClock).toBeGreaterThan(initialHistoryClock!)
1199
+
1200
+ // The algorithm deletes the OLDEST tombstones and keeps the NEWEST ones.
1201
+ // tombstoneHistoryStartsAtClock is set to the oldest REMAINING clock.
1202
+ // Remaining tombstones have clocks >= tombstoneHistoryStartsAtClock.
1203
+ const historyClock = snapshotAfter.tombstoneHistoryStartsAtClock
1204
+ for (const clock of Object.values(snapshotAfter.tombstones!)) {
1205
+ expect(clock).toBeGreaterThanOrEqual(historyClock!)
1206
+ }
1207
+ })
1208
+
1209
+ it('handles duplicate clock values across tombstones', () => {
1210
+ const totalTombstones = MAX_TOMBSTONES + 1
1211
+ const expectedCutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + 1
1212
+ const overflow = 10
1213
+ const boundary = expectedCutoff + overflow
1214
+ const lowerClockVal = 1
1215
+ const upperClockVal = 2
1216
+
1217
+ const tombstones: Record<string, number> = {}
1218
+ for (let i = 0; i < totalTombstones; i++) {
1219
+ tombstones[`doc${i}`] = i < boundary ? lowerClockVal : upperClockVal
1220
+ }
1221
+
1222
+ const storage = getStorage(
1223
+ makeSnapshot(defaultRecords, {
1224
+ documentClock: 3,
1225
+ tombstoneHistoryStartsAtClock: 0,
1226
+ tombstones,
1227
+ })
1228
+ )
1229
+
1230
+ // Schedule the throttled function then force it to run
1231
+ storage.pruneTombstones()
1232
+ storage.pruneTombstones.flush()
1233
+
1234
+ const snapshot = storage.getSnapshot()
1235
+ expect(Object.keys(snapshot.tombstones!).length).toEqual(
1236
+ MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE - overflow
1237
+ )
1238
+ expect(Object.values(snapshot.tombstones!).every((clock) => clock === upperClockVal)).toBe(
1239
+ true
1240
+ )
1241
+ })
1242
+
1243
+ it('handles all tombstones with same clock value', () => {
1244
+ const totalTombstones = MAX_TOMBSTONES * 2
1245
+ const sameClock = 100
1246
+
1247
+ const storage = getStorage(
1248
+ makeSnapshot(defaultRecords, {
1249
+ documentClock: 1000,
1250
+ tombstoneHistoryStartsAtClock: 0,
1251
+ tombstones: makeTombstoneMap(totalTombstones, () => sameClock),
1252
+ })
1253
+ )
1254
+
1255
+ // Schedule the throttled function then force it to run
1256
+ storage.pruneTombstones()
1257
+ storage.pruneTombstones.flush()
1258
+
1259
+ const snapshot = storage.getSnapshot()
1260
+ // When all have same clock, the cutoff extends to include all of them,
1261
+ // so all are pruned and history starts at documentClock
1262
+ expect(Object.keys(snapshot.tombstones!).length).toBe(0)
1263
+ expect(snapshot.tombstoneHistoryStartsAtClock).toBe(1000) // documentClock
1264
+ })
1265
+
1266
+ it('does not prune at exactly MAX_TOMBSTONES', () => {
1267
+ const storage = getStorage(
1268
+ makeSnapshot(defaultRecords, {
1269
+ documentClock: MAX_TOMBSTONES + 1,
1270
+ tombstoneHistoryStartsAtClock: 0,
1271
+ tombstones: makeTombstoneMap(MAX_TOMBSTONES),
1272
+ })
1273
+ )
1274
+
1275
+ // Schedule the throttled function then force it to run
1276
+ storage.pruneTombstones()
1277
+ storage.pruneTombstones.flush()
1278
+
1279
+ const snapshot = storage.getSnapshot()
1280
+ // Should not prune at exactly the threshold
1281
+ expect(Object.keys(snapshot.tombstones!).length).toBe(MAX_TOMBSTONES)
1282
+ })
1283
+ })
1284
+
1285
+ describe('Migration from TEXT to BLOB', () => {
1286
+ it('migrates existing TEXT data to BLOB format', () => {
1287
+ // Simulate a database created with the old schema (migration version 1, TEXT column)
1288
+ const db = new DatabaseSync(':memory:')
1289
+
1290
+ // Create old schema with TEXT column (migration version 1)
1291
+ db.exec(`
1292
+ CREATE TABLE documents (
1293
+ id TEXT PRIMARY KEY,
1294
+ state TEXT NOT NULL,
1295
+ lastChangedClock INTEGER NOT NULL
1296
+ );
1297
+ CREATE INDEX idx_documents_lastChangedClock ON documents(lastChangedClock);
1298
+ CREATE TABLE tombstones (
1299
+ id TEXT PRIMARY KEY,
1300
+ clock INTEGER NOT NULL
1301
+ );
1302
+ CREATE INDEX idx_tombstones_clock ON tombstones(clock);
1303
+ CREATE TABLE metadata (
1304
+ migrationVersion INTEGER NOT NULL,
1305
+ documentClock INTEGER NOT NULL,
1306
+ tombstoneHistoryStartsAtClock INTEGER NOT NULL,
1307
+ schema TEXT NOT NULL
1308
+ );
1309
+ INSERT INTO metadata (migrationVersion, documentClock, tombstoneHistoryStartsAtClock, schema)
1310
+ VALUES (1, 5, 0, '${JSON.stringify(tlSchema.serialize()).replace(/'/g, "''")}');
1311
+ `)
1312
+
1313
+ // Insert documents using the old TEXT format
1314
+ const doc1 = DocumentRecordType.create({ id: TLDOCUMENT_ID })
1315
+ const page1 = PageRecordType.create({
1316
+ id: PageRecordType.createId('migrated_page'),
1317
+ name: 'Migrated Page',
1318
+ index: ZERO_INDEX_KEY,
1319
+ })
1320
+
1321
+ const insertStmt = db.prepare(
1322
+ 'INSERT INTO documents (id, state, lastChangedClock) VALUES (?, ?, ?)'
1323
+ )
1324
+ insertStmt.run(doc1.id, JSON.stringify(doc1), 1)
1325
+ insertStmt.run(page1.id, JSON.stringify(page1), 2)
1326
+
1327
+ // Add a tombstone
1328
+ db.exec("INSERT INTO tombstones (id, clock) VALUES ('shape:deleted', 3)")
1329
+
1330
+ // Now create SQLiteSyncStorage which should trigger the migration
1331
+ const sql = new NodeSqliteWrapper(db)
1332
+ const storage = new SQLiteSyncStorage<TLRecord>({ sql })
1333
+
1334
+ // Verify data is accessible after migration
1335
+ const snapshot = storage.getSnapshot()
1336
+ expect(snapshot.documents.length).toBe(2)
1337
+ expect(snapshot.documentClock).toBe(5)
1338
+ expect(snapshot.tombstones?.['shape:deleted']).toBe(3)
1339
+
1340
+ // Verify we can read specific records
1341
+ storage.transaction((txn) => {
1342
+ const doc = txn.get(TLDOCUMENT_ID)
1343
+ expect(doc).toBeDefined()
1344
+ expect(doc?.id).toBe(TLDOCUMENT_ID)
1345
+
1346
+ const page = txn.get(page1.id)
1347
+ expect(page).toBeDefined()
1348
+ expect((page as any)?.name).toBe('Migrated Page')
1349
+ })
1350
+
1351
+ // Verify we can still write new records
1352
+ const newPage = PageRecordType.create({
1353
+ id: PageRecordType.createId('new_after_migration'),
1354
+ name: 'New After Migration',
1355
+ index: 'a2' as IndexKey,
1356
+ })
1357
+
1358
+ storage.transaction((txn) => {
1359
+ txn.set(newPage.id, newPage)
1360
+ })
1361
+
1362
+ const snapshotAfter = storage.getSnapshot()
1363
+ expect(snapshotAfter.documents.length).toBe(3)
1364
+ expect(snapshotAfter.documents.find((d) => d.state.id === newPage.id)).toBeDefined()
1365
+ })
1366
+
1367
+ it('preserves migration version 2 for fresh databases', () => {
1368
+ const sql = createWrapper()
1369
+ new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
1370
+
1371
+ // Check the migration version is 2
1372
+ const row = sql
1373
+ .prepare<{ migrationVersion: number }>('SELECT migrationVersion FROM metadata')
1374
+ .all()[0]
1375
+ expect(row?.migrationVersion).toBe(2)
1376
+ })
1377
+ })
1378
+ })