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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist-cjs/index.d.ts +239 -57
  2. package/dist-cjs/index.js +7 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
  5. package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
  6. package/dist-cjs/lib/RoomSession.js.map +1 -1
  7. package/dist-cjs/lib/TLSocketRoom.js +117 -69
  8. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncClient.js +7 -0
  10. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncRoom.js +357 -688
  12. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  13. package/dist-cjs/lib/TLSyncStorage.js +76 -0
  14. package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
  15. package/dist-cjs/lib/recordDiff.js +52 -0
  16. package/dist-cjs/lib/recordDiff.js.map +7 -0
  17. package/dist-esm/index.d.mts +239 -57
  18. package/dist-esm/index.mjs +12 -5
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
  21. package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
  22. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  23. package/dist-esm/lib/TLSocketRoom.mjs +121 -70
  24. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncClient.mjs +7 -0
  26. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncRoom.mjs +370 -702
  28. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  29. package/dist-esm/lib/TLSyncStorage.mjs +56 -0
  30. package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
  31. package/dist-esm/lib/recordDiff.mjs +32 -0
  32. package/dist-esm/lib/recordDiff.mjs.map +7 -0
  33. package/package.json +6 -6
  34. package/src/index.ts +21 -3
  35. package/src/lib/InMemorySyncStorage.ts +357 -0
  36. package/src/lib/RoomSession.test.ts +1 -0
  37. package/src/lib/RoomSession.ts +2 -0
  38. package/src/lib/TLSocketRoom.ts +228 -114
  39. package/src/lib/TLSyncClient.ts +12 -0
  40. package/src/lib/TLSyncRoom.ts +473 -913
  41. package/src/lib/TLSyncStorage.ts +216 -0
  42. package/src/lib/recordDiff.ts +73 -0
  43. package/src/test/InMemorySyncStorage.test.ts +1674 -0
  44. package/src/test/TLSocketRoom.test.ts +255 -49
  45. package/src/test/TLSyncRoom.test.ts +1021 -533
  46. package/src/test/TestServer.ts +12 -1
  47. package/src/test/customMessages.test.ts +1 -1
  48. package/src/test/presenceMode.test.ts +6 -6
  49. package/src/test/upgradeDowngrade.test.ts +282 -8
  50. package/src/test/validation.test.ts +10 -10
  51. package/src/test/pruneTombstones.test.ts +0 -178
@@ -0,0 +1,1674 @@
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 { vi } from 'vitest'
10
+ import {
11
+ InMemorySyncStorage,
12
+ MAX_TOMBSTONES,
13
+ TOMBSTONE_PRUNE_BUFFER_SIZE,
14
+ } from '../lib/InMemorySyncStorage'
15
+ import { RoomSnapshot } from '../lib/TLSyncRoom'
16
+ import { convertStoreSnapshotToRoomSnapshot, loadSnapshotIntoStorage } from '../lib/TLSyncStorage'
17
+
18
+ const tlSchema = createTLSchema()
19
+
20
+ const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}): RoomSnapshot => ({
21
+ documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
22
+ clock: 0,
23
+ documentClock: 0,
24
+ schema: tlSchema.serialize(),
25
+ ...others,
26
+ })
27
+
28
+ // Helper to create legacy snapshots without documentClock field
29
+ const makeLegacySnapshot = (
30
+ records: TLRecord[],
31
+ others: Partial<Omit<RoomSnapshot, 'documentClock'>> = {}
32
+ ): Omit<RoomSnapshot, 'documentClock'> & { schema: RoomSnapshot['schema'] } => ({
33
+ documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
34
+ clock: 0,
35
+ schema: tlSchema.serialize(),
36
+ ...others,
37
+ })
38
+
39
+ const defaultRecords = [
40
+ DocumentRecordType.create({ id: TLDOCUMENT_ID }),
41
+ PageRecordType.create({
42
+ index: ZERO_INDEX_KEY,
43
+ name: 'Page 1',
44
+ id: PageRecordType.createId('page_1'),
45
+ }),
46
+ ]
47
+
48
+ describe('InMemorySyncStorage', () => {
49
+ describe('Constructor', () => {
50
+ it('initializes documents from snapshot', () => {
51
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(defaultRecords) })
52
+
53
+ expect(storage.documents.size).toBe(2)
54
+ expect(storage.documents.get(TLDOCUMENT_ID)?.state.id).toBe(TLDOCUMENT_ID)
55
+ })
56
+
57
+ it('initializes schema from snapshot', () => {
58
+ const snapshot = makeSnapshot(defaultRecords)
59
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot })
60
+
61
+ expect(storage.schema.get()).toEqual(snapshot.schema)
62
+ })
63
+
64
+ it('initializes documentClock from snapshot', () => {
65
+ const storage = new InMemorySyncStorage<TLRecord>({
66
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 42 }),
67
+ })
68
+
69
+ expect(storage.getClock()).toBe(42)
70
+ })
71
+
72
+ it('falls back to clock when documentClock is not present (legacy snapshot)', () => {
73
+ const storage = new InMemorySyncStorage<TLRecord>({
74
+ snapshot: makeLegacySnapshot(defaultRecords, { clock: 15 }) as RoomSnapshot,
75
+ })
76
+
77
+ expect(storage.getClock()).toBe(15)
78
+ })
79
+
80
+ it('falls back to 0 when neither documentClock nor clock is present', () => {
81
+ const snapshot = {
82
+ documents: defaultRecords.map((r) => ({ state: r, lastChangedClock: 0 })),
83
+ schema: tlSchema.serialize(),
84
+ } as RoomSnapshot
85
+
86
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot })
87
+
88
+ expect(storage.getClock()).toBe(0)
89
+ })
90
+
91
+ it('initializes tombstones from snapshot', () => {
92
+ const storage = new InMemorySyncStorage<TLRecord>({
93
+ snapshot: makeSnapshot(defaultRecords, {
94
+ tombstones: { 'shape:deleted1': 5, 'shape:deleted2': 10 },
95
+ tombstoneHistoryStartsAtClock: 0,
96
+ documentClock: 15,
97
+ }),
98
+ })
99
+
100
+ expect(storage.tombstones.size).toBe(2)
101
+ expect(storage.tombstones.get('shape:deleted1')).toBe(5)
102
+ expect(storage.tombstones.get('shape:deleted2')).toBe(10)
103
+ })
104
+
105
+ it('skips tombstones when tombstoneHistoryStartsAtClock equals documentClock', () => {
106
+ const storage = new InMemorySyncStorage<TLRecord>({
107
+ snapshot: makeSnapshot(defaultRecords, {
108
+ tombstones: { 'shape:deleted1': 5 },
109
+ tombstoneHistoryStartsAtClock: 15,
110
+ documentClock: 15,
111
+ }),
112
+ })
113
+
114
+ // Tombstones should be skipped since history starts at current clock
115
+ expect(storage.tombstones.size).toBe(0)
116
+ })
117
+
118
+ it('sets tombstoneHistoryStartsAtClock from snapshot', () => {
119
+ const storage = new InMemorySyncStorage<TLRecord>({
120
+ snapshot: makeSnapshot(defaultRecords, {
121
+ tombstoneHistoryStartsAtClock: 5,
122
+ documentClock: 10,
123
+ }),
124
+ })
125
+
126
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(5)
127
+ })
128
+
129
+ it('defaults tombstoneHistoryStartsAtClock to documentClock when not provided', () => {
130
+ const storage = new InMemorySyncStorage<TLRecord>({
131
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 20 }),
132
+ })
133
+
134
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(20)
135
+ })
136
+
137
+ it('handles empty documents array', () => {
138
+ const storage = new InMemorySyncStorage<TLRecord>({
139
+ snapshot: makeSnapshot([]),
140
+ })
141
+
142
+ expect(storage.documents.size).toBe(0)
143
+ })
144
+ })
145
+
146
+ describe('Transaction', () => {
147
+ describe('get()', () => {
148
+ it('returns record by id', () => {
149
+ const storage = new InMemorySyncStorage<TLRecord>({
150
+ snapshot: makeSnapshot(defaultRecords),
151
+ })
152
+
153
+ storage.transaction((txn) => {
154
+ const doc = txn.get(TLDOCUMENT_ID)
155
+ expect(doc).toBeDefined()
156
+ expect(doc?.id).toBe(TLDOCUMENT_ID)
157
+ })
158
+ })
159
+
160
+ it('returns undefined for non-existent record', () => {
161
+ const storage = new InMemorySyncStorage<TLRecord>({
162
+ snapshot: makeSnapshot(defaultRecords),
163
+ })
164
+
165
+ storage.transaction((txn) => {
166
+ expect(txn.get('nonexistent')).toBeUndefined()
167
+ })
168
+ })
169
+ })
170
+
171
+ describe('set()', () => {
172
+ it('creates new records', () => {
173
+ const storage = new InMemorySyncStorage<TLRecord>({
174
+ snapshot: makeSnapshot(defaultRecords),
175
+ })
176
+
177
+ const newPage = PageRecordType.create({
178
+ id: PageRecordType.createId('new_page'),
179
+ name: 'New Page',
180
+ index: 'a2' as IndexKey,
181
+ })
182
+
183
+ storage.transaction((txn) => {
184
+ txn.set(newPage.id, newPage)
185
+ })
186
+
187
+ expect(storage.documents.size).toBe(3)
188
+ expect(storage.documents.get(newPage.id)?.state).toEqual(newPage)
189
+ })
190
+
191
+ it('updates existing records', () => {
192
+ const storage = new InMemorySyncStorage<TLRecord>({
193
+ snapshot: makeSnapshot(defaultRecords),
194
+ })
195
+
196
+ const pageId = PageRecordType.createId('page_1')
197
+ const updatedPage = PageRecordType.create({
198
+ id: pageId,
199
+ name: 'Updated Page',
200
+ index: ZERO_INDEX_KEY,
201
+ })
202
+
203
+ storage.transaction((txn) => {
204
+ txn.set(pageId, updatedPage)
205
+ })
206
+
207
+ expect(storage.documents.get(pageId)?.state).toEqual(updatedPage)
208
+ })
209
+
210
+ it('clears tombstone when re-creating a deleted record', () => {
211
+ const pageId = PageRecordType.createId('page_to_delete')
212
+ const page = PageRecordType.create({
213
+ id: pageId,
214
+ name: 'Page',
215
+ index: 'a2' as IndexKey,
216
+ })
217
+
218
+ const storage = new InMemorySyncStorage<TLRecord>({
219
+ snapshot: makeSnapshot([...defaultRecords, page]),
220
+ })
221
+
222
+ // Delete the page
223
+ storage.transaction((txn) => {
224
+ txn.delete(pageId)
225
+ })
226
+
227
+ expect(storage.tombstones.has(pageId)).toBe(true)
228
+ expect(storage.documents.has(pageId)).toBe(false)
229
+
230
+ // Re-create the page
231
+ storage.transaction((txn) => {
232
+ txn.set(pageId, page)
233
+ })
234
+
235
+ expect(storage.tombstones.has(pageId)).toBe(false)
236
+ expect(storage.documents.has(pageId)).toBe(true)
237
+ })
238
+
239
+ it('sets lastChangedClock to the incremented clock', () => {
240
+ const storage = new InMemorySyncStorage<TLRecord>({
241
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 5 }),
242
+ })
243
+
244
+ const newPage = PageRecordType.create({
245
+ id: PageRecordType.createId('new_page'),
246
+ name: 'New Page',
247
+ index: 'a2' as IndexKey,
248
+ })
249
+
250
+ storage.transaction((txn) => {
251
+ txn.set(newPage.id, newPage)
252
+ })
253
+
254
+ expect(storage.documents.get(newPage.id)?.lastChangedClock).toBe(6)
255
+ })
256
+ })
257
+
258
+ describe('delete()', () => {
259
+ it('removes records', () => {
260
+ const storage = new InMemorySyncStorage<TLRecord>({
261
+ snapshot: makeSnapshot(defaultRecords),
262
+ })
263
+
264
+ const pageId = PageRecordType.createId('page_1')
265
+
266
+ storage.transaction((txn) => {
267
+ txn.delete(pageId)
268
+ })
269
+
270
+ expect(storage.documents.has(pageId)).toBe(false)
271
+ })
272
+
273
+ it('creates tombstones', () => {
274
+ const storage = new InMemorySyncStorage<TLRecord>({
275
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
276
+ })
277
+
278
+ const pageId = PageRecordType.createId('page_1')
279
+
280
+ storage.transaction((txn) => {
281
+ txn.delete(pageId)
282
+ })
283
+
284
+ expect(storage.tombstones.has(pageId)).toBe(true)
285
+ expect(storage.tombstones.get(pageId)).toBe(11) // clock incremented to 11
286
+ })
287
+ })
288
+
289
+ describe('getClock()', () => {
290
+ it('returns current clock at start of transaction', () => {
291
+ const storage = new InMemorySyncStorage<TLRecord>({
292
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 42 }),
293
+ })
294
+
295
+ storage.transaction((txn) => {
296
+ expect(txn.getClock()).toBe(42)
297
+ })
298
+ })
299
+
300
+ it('returns incremented clock after a write', () => {
301
+ const storage = new InMemorySyncStorage<TLRecord>({
302
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 42 }),
303
+ })
304
+
305
+ storage.transaction((txn) => {
306
+ expect(txn.getClock()).toBe(42)
307
+ const newPage = PageRecordType.create({
308
+ id: PageRecordType.createId('new'),
309
+ name: 'New',
310
+ index: 'a2' as IndexKey,
311
+ })
312
+ txn.set(newPage.id, newPage)
313
+ expect(txn.getClock()).toBe(43)
314
+ })
315
+ })
316
+
317
+ it('increments clock only once per transaction', () => {
318
+ const storage = new InMemorySyncStorage<TLRecord>({
319
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
320
+ })
321
+
322
+ storage.transaction((txn) => {
323
+ const page1 = PageRecordType.create({
324
+ id: PageRecordType.createId('p1'),
325
+ name: 'P1',
326
+ index: 'a2' as IndexKey,
327
+ })
328
+ const page2 = PageRecordType.create({
329
+ id: PageRecordType.createId('p2'),
330
+ name: 'P2',
331
+ index: 'a3' as IndexKey,
332
+ })
333
+
334
+ txn.set(page1.id, page1)
335
+ expect(txn.getClock()).toBe(11)
336
+
337
+ txn.set(page2.id, page2)
338
+ expect(txn.getClock()).toBe(11) // Still 11, not 12
339
+
340
+ txn.delete(PageRecordType.createId('page_1'))
341
+ expect(txn.getClock()).toBe(11) // Still 11
342
+ })
343
+
344
+ expect(storage.getClock()).toBe(11)
345
+ })
346
+ })
347
+
348
+ describe('entries()', () => {
349
+ it('iterates over all documents', () => {
350
+ const storage = new InMemorySyncStorage<TLRecord>({
351
+ snapshot: makeSnapshot(defaultRecords),
352
+ })
353
+
354
+ storage.transaction((txn) => {
355
+ const entries = Array.from(txn.entries())
356
+ expect(entries.length).toBe(2)
357
+ expect(entries.map(([id]) => id).sort()).toEqual(defaultRecords.map((r) => r.id).sort())
358
+ })
359
+ })
360
+ })
361
+
362
+ describe('keys()', () => {
363
+ it('iterates over all document ids', () => {
364
+ const storage = new InMemorySyncStorage<TLRecord>({
365
+ snapshot: makeSnapshot(defaultRecords),
366
+ })
367
+
368
+ storage.transaction((txn) => {
369
+ const keys = Array.from(txn.keys())
370
+ expect(keys.sort()).toEqual(defaultRecords.map((r) => r.id).sort())
371
+ })
372
+ })
373
+ })
374
+
375
+ describe('values()', () => {
376
+ it('iterates over all document states', () => {
377
+ const storage = new InMemorySyncStorage<TLRecord>({
378
+ snapshot: makeSnapshot(defaultRecords),
379
+ })
380
+
381
+ storage.transaction((txn) => {
382
+ const values = Array.from(txn.values())
383
+ expect(values.length).toBe(2)
384
+ expect(values.map((v) => v.id).sort()).toEqual(defaultRecords.map((r) => r.id).sort())
385
+ })
386
+ })
387
+ })
388
+
389
+ describe('iterator consumption after transaction ends', () => {
390
+ it('throws when entries() iterator is consumed after transaction ends', () => {
391
+ const storage = new InMemorySyncStorage<TLRecord>({
392
+ snapshot: makeSnapshot(defaultRecords),
393
+ })
394
+
395
+ let iterator: Iterator<[string, TLRecord]>
396
+
397
+ storage.transaction((txn) => {
398
+ iterator = txn.entries()[Symbol.iterator]()
399
+ // Consume one item inside the transaction - should work
400
+ iterator.next()
401
+ })
402
+
403
+ // Trying to consume more after transaction ends should throw
404
+ expect(() => iterator.next()).toThrow('Transaction has ended')
405
+ })
406
+
407
+ it('throws when keys() iterator is consumed after transaction ends', () => {
408
+ const storage = new InMemorySyncStorage<TLRecord>({
409
+ snapshot: makeSnapshot(defaultRecords),
410
+ })
411
+
412
+ let iterator: Iterator<string>
413
+
414
+ storage.transaction((txn) => {
415
+ iterator = txn.keys()[Symbol.iterator]()
416
+ iterator.next()
417
+ })
418
+
419
+ expect(() => iterator.next()).toThrow('Transaction has ended')
420
+ })
421
+
422
+ it('throws when values() iterator is consumed after transaction ends', () => {
423
+ const storage = new InMemorySyncStorage<TLRecord>({
424
+ snapshot: makeSnapshot(defaultRecords),
425
+ })
426
+
427
+ let iterator: Iterator<TLRecord>
428
+
429
+ storage.transaction((txn) => {
430
+ iterator = txn.values()[Symbol.iterator]()
431
+ iterator.next()
432
+ })
433
+
434
+ expect(() => iterator.next()).toThrow('Transaction has ended')
435
+ })
436
+
437
+ it('allows full consumption of iterator within transaction', () => {
438
+ const storage = new InMemorySyncStorage<TLRecord>({
439
+ snapshot: makeSnapshot(defaultRecords),
440
+ })
441
+
442
+ storage.transaction((txn) => {
443
+ // Should be able to fully consume all iterators
444
+ const entries = Array.from(txn.entries())
445
+ const keys = Array.from(txn.keys())
446
+ const values = Array.from(txn.values())
447
+
448
+ expect(entries.length).toBe(2)
449
+ expect(keys.length).toBe(2)
450
+ expect(values.length).toBe(2)
451
+ })
452
+ })
453
+ })
454
+
455
+ describe('getSchema() / setSchema()', () => {
456
+ it('gets the current schema', () => {
457
+ const snapshot = makeSnapshot(defaultRecords)
458
+ const storage = new InMemorySyncStorage<TLRecord>({ snapshot })
459
+
460
+ storage.transaction((txn) => {
461
+ expect(txn.getSchema()).toEqual(snapshot.schema)
462
+ })
463
+ })
464
+
465
+ it('sets the schema', () => {
466
+ const storage = new InMemorySyncStorage<TLRecord>({
467
+ snapshot: makeSnapshot(defaultRecords),
468
+ })
469
+
470
+ const newSchema = { ...tlSchema.serialize(), schemaVersion: 99 as any }
471
+
472
+ storage.transaction((txn) => {
473
+ txn.setSchema(newSchema)
474
+ })
475
+
476
+ expect(storage.schema.get().schemaVersion).toBe(99)
477
+ })
478
+ })
479
+
480
+ describe('transaction result', () => {
481
+ it('returns result from callback', () => {
482
+ const storage = new InMemorySyncStorage<TLRecord>({
483
+ snapshot: makeSnapshot(defaultRecords),
484
+ })
485
+
486
+ const { result } = storage.transaction((txn) => {
487
+ return txn.get(TLDOCUMENT_ID)
488
+ })
489
+
490
+ expect(result?.id).toBe(TLDOCUMENT_ID)
491
+ })
492
+
493
+ it('returns didChange: false when no writes occur', () => {
494
+ const storage = new InMemorySyncStorage<TLRecord>({
495
+ snapshot: makeSnapshot(defaultRecords),
496
+ })
497
+
498
+ const { didChange, documentClock } = storage.transaction((txn) => {
499
+ txn.get(TLDOCUMENT_ID)
500
+ })
501
+
502
+ expect(didChange).toBe(false)
503
+ expect(documentClock).toBe(0)
504
+ })
505
+
506
+ it('returns didChange: true when writes occur', () => {
507
+ const storage = new InMemorySyncStorage<TLRecord>({
508
+ snapshot: makeSnapshot(defaultRecords),
509
+ })
510
+
511
+ const newPage = PageRecordType.create({
512
+ id: PageRecordType.createId('new'),
513
+ name: 'New',
514
+ index: 'a2' as IndexKey,
515
+ })
516
+
517
+ const { didChange, documentClock } = storage.transaction((txn) => {
518
+ txn.set(newPage.id, newPage)
519
+ })
520
+
521
+ expect(didChange).toBe(true)
522
+ expect(documentClock).toBe(1)
523
+ })
524
+
525
+ it('throws when callback returns a promise', () => {
526
+ const storage = new InMemorySyncStorage<TLRecord>({
527
+ snapshot: makeSnapshot(defaultRecords),
528
+ })
529
+
530
+ expect(() => {
531
+ storage.transaction(() => Promise.resolve() as any)
532
+ }).toThrow('Transaction must return a value, not a promise')
533
+ })
534
+ })
535
+ })
536
+
537
+ describe('getChangesSince', () => {
538
+ it('returns puts for records changed after sinceClock', () => {
539
+ const storage = new InMemorySyncStorage<TLRecord>({
540
+ snapshot: makeSnapshot(defaultRecords, {
541
+ documents: [
542
+ { state: defaultRecords[0], lastChangedClock: 5 },
543
+ { state: defaultRecords[1], lastChangedClock: 10 },
544
+ ],
545
+ documentClock: 15,
546
+ tombstoneHistoryStartsAtClock: 0,
547
+ }),
548
+ })
549
+
550
+ storage.transaction((txn) => {
551
+ const changes = txn.getChangesSince(7)!
552
+ const puts = Object.values(changes.diff.puts)
553
+
554
+ expect(puts.length).toBe(1)
555
+ expect((puts[0] as TLRecord).id).toBe(defaultRecords[1].id) // only record with clock 10 > 7
556
+ expect(changes.wipeAll).toBe(false)
557
+ })
558
+ })
559
+
560
+ it('returns all records when sinceClock is before all changes', () => {
561
+ const storage = new InMemorySyncStorage<TLRecord>({
562
+ snapshot: makeSnapshot(defaultRecords, {
563
+ documents: [
564
+ { state: defaultRecords[0], lastChangedClock: 5 },
565
+ { state: defaultRecords[1], lastChangedClock: 10 },
566
+ ],
567
+ documentClock: 15,
568
+ tombstoneHistoryStartsAtClock: 0,
569
+ }),
570
+ })
571
+
572
+ storage.transaction((txn) => {
573
+ const changes = txn.getChangesSince(0)!
574
+ const puts = Object.values(changes.diff.puts)
575
+
576
+ expect(puts.length).toBe(2)
577
+ })
578
+ })
579
+
580
+ it('returns deletes for tombstones after sinceClock', () => {
581
+ const storage = new InMemorySyncStorage<TLRecord>({
582
+ snapshot: makeSnapshot(defaultRecords, {
583
+ tombstones: {
584
+ 'shape:deleted1': 5,
585
+ 'shape:deleted2': 12,
586
+ },
587
+ documentClock: 15,
588
+ tombstoneHistoryStartsAtClock: 0,
589
+ }),
590
+ })
591
+
592
+ storage.transaction((txn) => {
593
+ const changes = txn.getChangesSince(7)!
594
+
595
+ expect(changes.diff.deletes).toEqual(['shape:deleted2']) // only tombstone with clock 12 > 7
596
+ })
597
+ })
598
+
599
+ it('returns wipeAll: true when sinceClock < tombstoneHistoryStartsAtClock', () => {
600
+ const storage = new InMemorySyncStorage<TLRecord>({
601
+ snapshot: makeSnapshot(defaultRecords, {
602
+ documentClock: 20,
603
+ tombstoneHistoryStartsAtClock: 10,
604
+ }),
605
+ })
606
+
607
+ storage.transaction((txn) => {
608
+ const changes = txn.getChangesSince(5)! // 5 < 10
609
+
610
+ expect(changes.wipeAll).toBe(true)
611
+ // When wipeAll is true, all documents are returned
612
+ const puts = Object.values(changes.diff.puts)
613
+ expect(puts.length).toBe(2)
614
+ })
615
+ })
616
+
617
+ it('returns wipeAll: false when sinceClock >= tombstoneHistoryStartsAtClock', () => {
618
+ const storage = new InMemorySyncStorage<TLRecord>({
619
+ snapshot: makeSnapshot(defaultRecords, {
620
+ documentClock: 20,
621
+ tombstoneHistoryStartsAtClock: 10,
622
+ }),
623
+ })
624
+
625
+ storage.transaction((txn) => {
626
+ const changes = txn.getChangesSince(15)!
627
+
628
+ expect(changes.wipeAll).toBe(false)
629
+ })
630
+ })
631
+
632
+ it('returns undefined when no changes since clock', () => {
633
+ const storage = new InMemorySyncStorage<TLRecord>({
634
+ snapshot: makeSnapshot(defaultRecords, {
635
+ documents: [
636
+ { state: defaultRecords[0], lastChangedClock: 5 },
637
+ { state: defaultRecords[1], lastChangedClock: 10 },
638
+ ],
639
+ documentClock: 15,
640
+ tombstoneHistoryStartsAtClock: 0,
641
+ }),
642
+ })
643
+
644
+ storage.transaction((txn) => {
645
+ const changes = txn.getChangesSince(15)
646
+ expect(changes).toBeUndefined()
647
+ })
648
+ })
649
+ })
650
+
651
+ describe('onChange', () => {
652
+ it('notifies listeners after changes', async () => {
653
+ const storage = new InMemorySyncStorage<TLRecord>({
654
+ snapshot: makeSnapshot(defaultRecords),
655
+ })
656
+
657
+ const listener = vi.fn()
658
+ storage.onChange(listener)
659
+
660
+ // Wait for listener registration (microtask)
661
+ await Promise.resolve()
662
+
663
+ const newPage = PageRecordType.create({
664
+ id: PageRecordType.createId('new'),
665
+ name: 'New',
666
+ index: 'a2' as IndexKey,
667
+ })
668
+
669
+ storage.transaction((txn) => {
670
+ txn.set(newPage.id, newPage)
671
+ })
672
+
673
+ // Wait for notification (microtask)
674
+ await Promise.resolve()
675
+
676
+ expect(listener).toHaveBeenCalledTimes(1)
677
+ })
678
+
679
+ it('receives correct documentClock', async () => {
680
+ const storage = new InMemorySyncStorage<TLRecord>({
681
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
682
+ })
683
+
684
+ const listener = vi.fn()
685
+ storage.onChange(listener)
686
+
687
+ await Promise.resolve()
688
+
689
+ const newPage = PageRecordType.create({
690
+ id: PageRecordType.createId('new'),
691
+ name: 'New',
692
+ index: 'a2' as IndexKey,
693
+ })
694
+
695
+ storage.transaction((txn) => {
696
+ txn.set(newPage.id, newPage)
697
+ })
698
+
699
+ await Promise.resolve()
700
+
701
+ expect(listener).toHaveBeenCalledWith(
702
+ expect.objectContaining({
703
+ documentClock: 11,
704
+ })
705
+ )
706
+ })
707
+
708
+ it('receives transaction id when provided', async () => {
709
+ const storage = new InMemorySyncStorage<TLRecord>({
710
+ snapshot: makeSnapshot(defaultRecords),
711
+ })
712
+
713
+ const listener = vi.fn()
714
+ storage.onChange(listener)
715
+
716
+ await Promise.resolve()
717
+
718
+ const newPage = PageRecordType.create({
719
+ id: PageRecordType.createId('new'),
720
+ name: 'New',
721
+ index: 'a2' as IndexKey,
722
+ })
723
+
724
+ storage.transaction(
725
+ (txn) => {
726
+ txn.set(newPage.id, newPage)
727
+ },
728
+ { id: 'my-transaction-id' }
729
+ )
730
+
731
+ await Promise.resolve()
732
+
733
+ expect(listener).toHaveBeenCalledWith(
734
+ expect.objectContaining({
735
+ id: 'my-transaction-id',
736
+ })
737
+ )
738
+ })
739
+
740
+ it('unsubscribe prevents future notifications', async () => {
741
+ const storage = new InMemorySyncStorage<TLRecord>({
742
+ snapshot: makeSnapshot(defaultRecords),
743
+ })
744
+
745
+ const listener = vi.fn()
746
+ const unsubscribe = storage.onChange(listener)
747
+
748
+ await Promise.resolve()
749
+
750
+ // Unsubscribe immediately
751
+ unsubscribe()
752
+
753
+ const newPage = PageRecordType.create({
754
+ id: PageRecordType.createId('new'),
755
+ name: 'New',
756
+ index: 'a2' as IndexKey,
757
+ })
758
+
759
+ storage.transaction((txn) => {
760
+ txn.set(newPage.id, newPage)
761
+ })
762
+
763
+ await Promise.resolve()
764
+
765
+ expect(listener).not.toHaveBeenCalled()
766
+ })
767
+
768
+ it('does not notify for read-only transactions', async () => {
769
+ const storage = new InMemorySyncStorage<TLRecord>({
770
+ snapshot: makeSnapshot(defaultRecords),
771
+ })
772
+
773
+ const listener = vi.fn()
774
+ storage.onChange(listener)
775
+
776
+ await Promise.resolve()
777
+
778
+ storage.transaction((txn) => {
779
+ txn.get(TLDOCUMENT_ID) // read only
780
+ })
781
+
782
+ await Promise.resolve()
783
+
784
+ expect(listener).not.toHaveBeenCalled()
785
+ })
786
+
787
+ it('listener registered during same callstack does not receive preceding changes', async () => {
788
+ const storage = new InMemorySyncStorage<TLRecord>({
789
+ snapshot: makeSnapshot(defaultRecords),
790
+ })
791
+
792
+ const newPage = PageRecordType.create({
793
+ id: PageRecordType.createId('new'),
794
+ name: 'New',
795
+ index: 'a2' as IndexKey,
796
+ })
797
+
798
+ // Make a change
799
+ storage.transaction((txn) => {
800
+ txn.set(newPage.id, newPage)
801
+ })
802
+
803
+ // Register listener in same callstack as the change
804
+ const listener = vi.fn()
805
+ storage.onChange(listener)
806
+
807
+ // Wait for all microtasks
808
+ await Promise.resolve()
809
+ // Listener should not have received the earlier change
810
+ expect(listener).not.toHaveBeenCalled()
811
+
812
+ // perform a read-only transaction
813
+ storage.transaction((txn) => {
814
+ txn.get(newPage.id)
815
+ })
816
+ await Promise.resolve()
817
+
818
+ // Listener should still not have received the earlier change
819
+ expect(listener).not.toHaveBeenCalled()
820
+
821
+ // perform a write transaction with id
822
+ storage.transaction(
823
+ (txn) => {
824
+ txn.set(newPage.id, newPage)
825
+ },
826
+ { id: 'my-transaction-id' }
827
+ )
828
+
829
+ await Promise.resolve()
830
+
831
+ // Listener should have received the change
832
+ expect(listener).toHaveBeenCalledWith(
833
+ expect.objectContaining({
834
+ id: 'my-transaction-id',
835
+ })
836
+ )
837
+ })
838
+
839
+ it('unsubscribe before registration prevents registration', async () => {
840
+ const storage = new InMemorySyncStorage<TLRecord>({
841
+ snapshot: makeSnapshot(defaultRecords),
842
+ })
843
+
844
+ const listener = vi.fn()
845
+ const unsubscribe = storage.onChange(listener)
846
+
847
+ const newPage = PageRecordType.create({
848
+ id: PageRecordType.createId('new'),
849
+ name: 'New',
850
+ index: 'a2' as IndexKey,
851
+ })
852
+
853
+ storage.transaction((txn) => {
854
+ txn.set(newPage.id, newPage)
855
+ })
856
+
857
+ // Unsubscribe before the microtask runs
858
+ unsubscribe()
859
+
860
+ await Promise.resolve()
861
+
862
+ expect(listener).not.toHaveBeenCalled()
863
+
864
+ storage.transaction((txn) => {
865
+ txn.set(newPage.id, { ...newPage, name: 'Updated' } as TLRecord)
866
+ })
867
+
868
+ await Promise.resolve()
869
+
870
+ expect(listener).not.toHaveBeenCalled()
871
+ })
872
+ })
873
+
874
+ describe('getSnapshot', () => {
875
+ it('returns correct snapshot structure', () => {
876
+ const storage = new InMemorySyncStorage<TLRecord>({
877
+ snapshot: makeSnapshot(defaultRecords, {
878
+ documentClock: 15,
879
+ tombstoneHistoryStartsAtClock: 5,
880
+ tombstones: { 'shape:deleted': 10 },
881
+ }),
882
+ })
883
+
884
+ const snapshot = storage.getSnapshot()
885
+
886
+ expect(snapshot.documentClock).toBe(15)
887
+ expect(snapshot.tombstoneHistoryStartsAtClock).toBe(5)
888
+ expect(snapshot.documents.length).toBe(2)
889
+ expect(snapshot.tombstones).toEqual({ 'shape:deleted': 10 })
890
+ expect(snapshot.schema).toEqual(tlSchema.serialize())
891
+ })
892
+
893
+ it('reflects changes from transactions', () => {
894
+ const storage = new InMemorySyncStorage<TLRecord>({
895
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 0 }),
896
+ })
897
+
898
+ const newPage = PageRecordType.create({
899
+ id: PageRecordType.createId('new'),
900
+ name: 'New',
901
+ index: 'a2' as IndexKey,
902
+ })
903
+
904
+ storage.transaction((txn) => {
905
+ txn.set(newPage.id, newPage)
906
+ })
907
+
908
+ const snapshot = storage.getSnapshot()
909
+
910
+ expect(snapshot.documentClock).toBe(1)
911
+ expect(snapshot.documents.length).toBe(3)
912
+ expect(snapshot.documents.find((d) => d.state.id === newPage.id)).toBeDefined()
913
+ })
914
+ })
915
+
916
+ describe('pruneTombstones', () => {
917
+ it('does not prune when below MAX_TOMBSTONES', () => {
918
+ const storage = new InMemorySyncStorage<TLRecord>({
919
+ snapshot: makeSnapshot(defaultRecords, {
920
+ documentClock: 0,
921
+ tombstoneHistoryStartsAtClock: 0,
922
+ }),
923
+ })
924
+
925
+ // Add tombstones below threshold
926
+ for (let i = 0; i < Math.floor(MAX_TOMBSTONES / 2); i++) {
927
+ storage.tombstones.set(`doc${i}`, i + 1)
928
+ }
929
+
930
+ const initialSize = storage.tombstones.size
931
+ const initialHistoryClock = storage.tombstoneHistoryStartsAtClock.get()
932
+
933
+ // Schedule the throttled function then force it to run
934
+ // (leading: false means first call schedules, flush forces execution)
935
+ storage.pruneTombstones()
936
+ storage.pruneTombstones.flush()
937
+
938
+ expect(storage.tombstones.size).toBe(initialSize)
939
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(initialHistoryClock)
940
+ })
941
+
942
+ it('prunes when exceeding MAX_TOMBSTONES', () => {
943
+ const storage = new InMemorySyncStorage<TLRecord>({
944
+ snapshot: makeSnapshot(defaultRecords, {
945
+ documentClock: 0,
946
+ tombstoneHistoryStartsAtClock: 0,
947
+ }),
948
+ })
949
+
950
+ // Add more than MAX_TOMBSTONES
951
+ const totalTombstones = MAX_TOMBSTONES + 500
952
+ for (let i = 0; i < totalTombstones; i++) {
953
+ storage.tombstones.set(`doc${i}`, i + 1)
954
+ }
955
+
956
+ expect(storage.tombstones.size).toBe(totalTombstones)
957
+
958
+ // Schedule the throttled function then force it to run
959
+ storage.pruneTombstones()
960
+ storage.pruneTombstones.flush()
961
+
962
+ expect(storage.tombstones.size).toBeLessThan(totalTombstones)
963
+ expect(storage.tombstones.size).toBeLessThanOrEqual(
964
+ MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE
965
+ )
966
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBeGreaterThan(0)
967
+ })
968
+
969
+ it('updates tombstoneHistoryStartsAtClock correctly', () => {
970
+ const storage = new InMemorySyncStorage<TLRecord>({
971
+ snapshot: makeSnapshot(defaultRecords, {
972
+ documentClock: 10000,
973
+ tombstoneHistoryStartsAtClock: 0,
974
+ }),
975
+ })
976
+
977
+ // Add more than MAX_TOMBSTONES with sequential clock values
978
+ const totalTombstones = MAX_TOMBSTONES * 2
979
+ for (let i = 0; i < totalTombstones; i++) {
980
+ storage.tombstones.set(`doc${i}`, i + 1)
981
+ }
982
+
983
+ const initialHistoryClock = storage.tombstoneHistoryStartsAtClock.get()
984
+
985
+ // Schedule the throttled function then force it to run
986
+ storage.pruneTombstones()
987
+ storage.pruneTombstones.flush()
988
+
989
+ // History clock should have advanced
990
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBeGreaterThan(initialHistoryClock)
991
+
992
+ // The algorithm deletes the OLDEST tombstones and keeps the NEWEST ones.
993
+ // tombstoneHistoryStartsAtClock is set to the oldest REMAINING clock.
994
+ // Remaining tombstones have clocks >= tombstoneHistoryStartsAtClock.
995
+ const historyClock = storage.tombstoneHistoryStartsAtClock.get()
996
+ for (const [, clock] of storage.tombstones.entries()) {
997
+ expect(clock).toBeGreaterThanOrEqual(historyClock)
998
+ }
999
+ })
1000
+
1001
+ it('handles duplicate clock values across tombstones', () => {
1002
+ const storage = new InMemorySyncStorage<TLRecord>({
1003
+ snapshot: makeSnapshot(defaultRecords, {
1004
+ documentClock: 3,
1005
+ tombstoneHistoryStartsAtClock: 0,
1006
+ }),
1007
+ })
1008
+
1009
+ // Add tombstones with repeating clock values (10 per clock)
1010
+ const totalTombstones = MAX_TOMBSTONES + 1
1011
+ const expectedCutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + 1
1012
+ const overflow = 10
1013
+ const boundary = expectedCutoff + overflow
1014
+ const lowerClockVal = 1
1015
+ const upperClockVal = 2
1016
+ for (let i = 0; i < totalTombstones; i++) {
1017
+ if (i < boundary) {
1018
+ storage.tombstones.set(`doc${i}`, lowerClockVal)
1019
+ } else {
1020
+ storage.tombstones.set(`doc${i}`, upperClockVal)
1021
+ }
1022
+ }
1023
+
1024
+ // Schedule the throttled function then force it to run
1025
+ storage.pruneTombstones()
1026
+ storage.pruneTombstones.flush()
1027
+
1028
+ expect(storage.tombstones.size).toEqual(
1029
+ MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE - overflow
1030
+ )
1031
+ expect([...storage.tombstones.values()].every((clock) => clock === upperClockVal)).toBe(true)
1032
+ })
1033
+
1034
+ it('handles all tombstones with same clock value', () => {
1035
+ const storage = new InMemorySyncStorage<TLRecord>({
1036
+ snapshot: makeSnapshot(defaultRecords, {
1037
+ documentClock: 1000,
1038
+ tombstoneHistoryStartsAtClock: 0,
1039
+ }),
1040
+ })
1041
+
1042
+ // Add tombstones all with the same clock value
1043
+ const totalTombstones = MAX_TOMBSTONES * 2
1044
+ const sameClock = 100
1045
+ for (let i = 0; i < totalTombstones; i++) {
1046
+ storage.tombstones.set(`doc${i}`, sameClock)
1047
+ }
1048
+
1049
+ // Schedule the throttled function then force it to run
1050
+ storage.pruneTombstones()
1051
+ storage.pruneTombstones.flush()
1052
+
1053
+ // When all have same clock, the cutoff extends to include all of them,
1054
+ // so all are pruned and history starts at documentClock
1055
+ expect(storage.tombstones.size).toBe(0)
1056
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(1000) // documentClock
1057
+ })
1058
+
1059
+ it('does not prune at exactly MAX_TOMBSTONES', () => {
1060
+ const storage = new InMemorySyncStorage<TLRecord>({
1061
+ snapshot: makeSnapshot(defaultRecords, {
1062
+ documentClock: 0,
1063
+ tombstoneHistoryStartsAtClock: 0,
1064
+ }),
1065
+ })
1066
+
1067
+ // Add exactly MAX_TOMBSTONES
1068
+ for (let i = 0; i < MAX_TOMBSTONES; i++) {
1069
+ storage.tombstones.set(`doc${i}`, i + 1)
1070
+ }
1071
+
1072
+ // Schedule the throttled function then force it to run
1073
+ storage.pruneTombstones()
1074
+ storage.pruneTombstones.flush()
1075
+
1076
+ // Should not prune at exactly the threshold
1077
+ expect(storage.tombstones.size).toBe(MAX_TOMBSTONES)
1078
+ })
1079
+ })
1080
+
1081
+ describe('loadSnapshotIntoStorage', () => {
1082
+ it('loads records from snapshot', () => {
1083
+ const storage = new InMemorySyncStorage<TLRecord>({
1084
+ snapshot: makeSnapshot([]),
1085
+ })
1086
+
1087
+ const newSnapshot = makeSnapshot(defaultRecords)
1088
+
1089
+ storage.transaction((txn) => {
1090
+ loadSnapshotIntoStorage(txn, tlSchema, newSnapshot)
1091
+ })
1092
+
1093
+ expect(storage.documents.size).toBe(2)
1094
+ })
1095
+
1096
+ it('deletes records not in snapshot', () => {
1097
+ const extraPage = PageRecordType.create({
1098
+ id: PageRecordType.createId('extra'),
1099
+ name: 'Extra',
1100
+ index: 'a2' as IndexKey,
1101
+ })
1102
+
1103
+ const storage = new InMemorySyncStorage<TLRecord>({
1104
+ snapshot: makeSnapshot([...defaultRecords, extraPage]),
1105
+ })
1106
+
1107
+ expect(storage.documents.size).toBe(3)
1108
+
1109
+ // Load a snapshot without the extra page
1110
+ const newSnapshot = makeSnapshot(defaultRecords)
1111
+
1112
+ storage.transaction((txn) => {
1113
+ loadSnapshotIntoStorage(txn, tlSchema, newSnapshot)
1114
+ })
1115
+
1116
+ expect(storage.documents.size).toBe(2)
1117
+ expect(storage.documents.has(extraPage.id)).toBe(false)
1118
+ expect(storage.tombstones.has(extraPage.id)).toBe(true)
1119
+ })
1120
+
1121
+ it('sets schema from snapshot', () => {
1122
+ const storage = new InMemorySyncStorage<TLRecord>({
1123
+ snapshot: makeSnapshot(defaultRecords),
1124
+ })
1125
+
1126
+ const newSnapshot = makeSnapshot(defaultRecords)
1127
+
1128
+ storage.transaction((txn) => {
1129
+ loadSnapshotIntoStorage(txn, tlSchema, newSnapshot)
1130
+ })
1131
+
1132
+ expect(storage.schema.get()).toEqual(newSnapshot.schema)
1133
+ })
1134
+
1135
+ it('does not update unchanged records', () => {
1136
+ const storage = new InMemorySyncStorage<TLRecord>({
1137
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 5 }),
1138
+ })
1139
+
1140
+ const initialClock = storage.getClock()
1141
+
1142
+ // Load the same snapshot
1143
+ const sameSnapshot = makeSnapshot(defaultRecords)
1144
+
1145
+ storage.transaction((txn) => {
1146
+ loadSnapshotIntoStorage(txn, tlSchema, sameSnapshot)
1147
+ })
1148
+
1149
+ // Clock should not have changed since records were equal
1150
+ expect(storage.getClock()).toBe(initialClock)
1151
+ })
1152
+
1153
+ it('throws when snapshot has no schema', () => {
1154
+ const storage = new InMemorySyncStorage<TLRecord>({
1155
+ snapshot: makeSnapshot(defaultRecords),
1156
+ })
1157
+
1158
+ const invalidSnapshot = {
1159
+ documents: [],
1160
+ clock: 0,
1161
+ } as any
1162
+
1163
+ expect(() => {
1164
+ storage.transaction((txn) => {
1165
+ loadSnapshotIntoStorage(txn, tlSchema, invalidSnapshot)
1166
+ })
1167
+ }).toThrow('Schema is required')
1168
+ })
1169
+ })
1170
+
1171
+ describe('convertStoreSnapshotToRoomSnapshot', () => {
1172
+ it('passes through RoomSnapshot unchanged', () => {
1173
+ const roomSnapshot = makeSnapshot(defaultRecords, { documentClock: 42 })
1174
+
1175
+ const result = convertStoreSnapshotToRoomSnapshot(roomSnapshot)
1176
+
1177
+ expect(result).toBe(roomSnapshot)
1178
+ })
1179
+
1180
+ it('converts TLStoreSnapshot to RoomSnapshot', () => {
1181
+ const storeSnapshot = {
1182
+ store: {
1183
+ [TLDOCUMENT_ID]: defaultRecords[0],
1184
+ [defaultRecords[1].id]: defaultRecords[1],
1185
+ },
1186
+ schema: tlSchema.serialize(),
1187
+ }
1188
+
1189
+ const result = convertStoreSnapshotToRoomSnapshot(storeSnapshot)
1190
+
1191
+ expect(result.clock).toBe(0)
1192
+ expect(result.documentClock).toBe(0)
1193
+ expect(result.documents.length).toBe(2)
1194
+ expect(result.documents[0].lastChangedClock).toBe(0)
1195
+ expect(result.tombstones).toEqual({})
1196
+ expect(result.schema).toEqual(storeSnapshot.schema)
1197
+ })
1198
+
1199
+ it('sets all documents with lastChangedClock: 0', () => {
1200
+ const storeSnapshot = {
1201
+ store: {
1202
+ [TLDOCUMENT_ID]: defaultRecords[0],
1203
+ [defaultRecords[1].id]: defaultRecords[1],
1204
+ },
1205
+ schema: tlSchema.serialize(),
1206
+ }
1207
+
1208
+ const result = convertStoreSnapshotToRoomSnapshot(storeSnapshot)
1209
+
1210
+ for (const doc of result.documents) {
1211
+ expect(doc.lastChangedClock).toBe(0)
1212
+ }
1213
+ })
1214
+ })
1215
+
1216
+ describe('Edge cases', () => {
1217
+ describe('Transaction error handling', () => {
1218
+ it('does not increment clock if transaction throws', () => {
1219
+ const storage = new InMemorySyncStorage<TLRecord>({
1220
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
1221
+ })
1222
+
1223
+ expect(() => {
1224
+ storage.transaction(() => {
1225
+ throw new Error('Oops!')
1226
+ })
1227
+ }).toThrow('Oops!')
1228
+
1229
+ // Clock should not have changed
1230
+ expect(storage.getClock()).toBe(10)
1231
+ })
1232
+
1233
+ it('rolls back changes if transaction throws after a write', () => {
1234
+ const storage = new InMemorySyncStorage<TLRecord>({
1235
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
1236
+ })
1237
+
1238
+ const newPage = PageRecordType.create({
1239
+ id: PageRecordType.createId('new'),
1240
+ name: 'New',
1241
+ index: 'a2' as IndexKey,
1242
+ })
1243
+
1244
+ expect(() => {
1245
+ storage.transaction((txn) => {
1246
+ txn.set(newPage.id, newPage)
1247
+ throw new Error('Oops after write!')
1248
+ })
1249
+ }).toThrow('Oops after write!')
1250
+
1251
+ // Document should not have been added
1252
+ expect(storage.documents.has(newPage.id)).toBe(false)
1253
+ // Clock should not have changed
1254
+ expect(storage.getClock()).toBe(10)
1255
+ })
1256
+ })
1257
+
1258
+ describe('Deleting non-existent records', () => {
1259
+ it('does not create a tombstone for records that never existed', () => {
1260
+ const storage = new InMemorySyncStorage<TLRecord>({
1261
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 5 }),
1262
+ })
1263
+
1264
+ storage.transaction((txn) => {
1265
+ txn.delete('nonexistent:record')
1266
+ })
1267
+
1268
+ // No tombstone should be created for a record that never existed
1269
+ expect(storage.tombstones.has('nonexistent:record')).toBe(false)
1270
+ // Clock should not be incremented since nothing changed
1271
+ expect(storage.getClock()).toBe(5)
1272
+ })
1273
+
1274
+ it('does not increment clock when deleting non-existent record', () => {
1275
+ const storage = new InMemorySyncStorage<TLRecord>({
1276
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
1277
+ })
1278
+
1279
+ const { didChange, documentClock } = storage.transaction((txn) => {
1280
+ txn.delete('nonexistent:record')
1281
+ })
1282
+
1283
+ expect(didChange).toBe(false)
1284
+ expect(documentClock).toBe(10)
1285
+ })
1286
+ })
1287
+
1288
+ describe('Set with mismatched ID', () => {
1289
+ it('throws when key does not match record.id', () => {
1290
+ const storage = new InMemorySyncStorage<TLRecord>({
1291
+ snapshot: makeSnapshot(defaultRecords),
1292
+ })
1293
+
1294
+ const page = PageRecordType.create({
1295
+ id: PageRecordType.createId('actual_id'),
1296
+ name: 'Test',
1297
+ index: 'a2' as IndexKey,
1298
+ })
1299
+
1300
+ // Attempting to store with a different key than the record's id should throw
1301
+ expect(() => {
1302
+ storage.transaction((txn) => {
1303
+ txn.set('different:key', page)
1304
+ })
1305
+ }).toThrow('Record id mismatch: key does not match record.id')
1306
+ })
1307
+
1308
+ it('succeeds when key matches record.id', () => {
1309
+ const storage = new InMemorySyncStorage<TLRecord>({
1310
+ snapshot: makeSnapshot(defaultRecords),
1311
+ })
1312
+
1313
+ const page = PageRecordType.create({
1314
+ id: PageRecordType.createId('my_page'),
1315
+ name: 'Test',
1316
+ index: 'a2' as IndexKey,
1317
+ })
1318
+
1319
+ // Store with matching key
1320
+ storage.transaction((txn) => {
1321
+ txn.set(page.id, page)
1322
+ })
1323
+
1324
+ expect(storage.documents.has(page.id)).toBe(true)
1325
+ expect(storage.documents.get(page.id)?.state.id).toBe(page.id)
1326
+ })
1327
+ })
1328
+
1329
+ describe('getChangesSince boundary conditions', () => {
1330
+ it('sinceClock exactly equal to tombstoneHistoryStartsAtClock is NOT wipeAll', () => {
1331
+ const storage = new InMemorySyncStorage<TLRecord>({
1332
+ snapshot: makeSnapshot(defaultRecords, {
1333
+ documentClock: 20,
1334
+ tombstoneHistoryStartsAtClock: 10,
1335
+ }),
1336
+ })
1337
+
1338
+ storage.transaction((txn) => {
1339
+ // sinceClock === tombstoneHistoryStartsAtClock
1340
+ const changes = txn.getChangesSince(10)!
1341
+ expect(changes.wipeAll).toBe(false)
1342
+ })
1343
+ })
1344
+
1345
+ it('sinceClock one less than tombstoneHistoryStartsAtClock IS wipeAll', () => {
1346
+ const storage = new InMemorySyncStorage<TLRecord>({
1347
+ snapshot: makeSnapshot(defaultRecords, {
1348
+ documentClock: 20,
1349
+ tombstoneHistoryStartsAtClock: 10,
1350
+ }),
1351
+ })
1352
+
1353
+ storage.transaction((txn) => {
1354
+ const changes = txn.getChangesSince(9)!
1355
+ expect(changes.wipeAll).toBe(true)
1356
+ })
1357
+ })
1358
+
1359
+ it('handles negative sinceClock', () => {
1360
+ const storage = new InMemorySyncStorage<TLRecord>({
1361
+ snapshot: makeSnapshot(defaultRecords, {
1362
+ documentClock: 10,
1363
+ tombstoneHistoryStartsAtClock: 0,
1364
+ }),
1365
+ })
1366
+
1367
+ storage.transaction((txn) => {
1368
+ const changes = txn.getChangesSince(-1)!
1369
+ // -1 < 0, so wipeAll should be true
1370
+ expect(changes.wipeAll).toBe(true)
1371
+ // All documents should be returned
1372
+ expect(Object.values(changes.diff.puts).length).toBe(2)
1373
+ })
1374
+ })
1375
+
1376
+ it('returns undefined when sinceClock equals current documentClock', () => {
1377
+ const storage = new InMemorySyncStorage<TLRecord>({
1378
+ snapshot: makeSnapshot(defaultRecords, {
1379
+ documents: [
1380
+ { state: defaultRecords[0], lastChangedClock: 5 },
1381
+ { state: defaultRecords[1], lastChangedClock: 10 },
1382
+ ],
1383
+ documentClock: 10,
1384
+ tombstoneHistoryStartsAtClock: 0,
1385
+ }),
1386
+ })
1387
+
1388
+ storage.transaction((txn) => {
1389
+ const changes = txn.getChangesSince(10)
1390
+ expect(changes).toBeUndefined()
1391
+ })
1392
+ })
1393
+
1394
+ it('returns all changes when sinceClock is greater than documentClock', () => {
1395
+ const storage = new InMemorySyncStorage<TLRecord>({
1396
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
1397
+ })
1398
+
1399
+ storage.transaction((txn) => {
1400
+ const changes = txn.getChangesSince(100)!
1401
+ expect(Object.values(changes.diff.puts).length).toBe(2)
1402
+ expect(changes.wipeAll).toBe(true)
1403
+ })
1404
+ })
1405
+ })
1406
+
1407
+ describe('onChange callback edge cases', () => {
1408
+ it('handles unsubscribe called during callback', async () => {
1409
+ const storage = new InMemorySyncStorage<TLRecord>({
1410
+ snapshot: makeSnapshot(defaultRecords),
1411
+ })
1412
+
1413
+ let unsubscribe = () => {}
1414
+ const listener = vi.fn(() => {
1415
+ unsubscribe()
1416
+ })
1417
+
1418
+ unsubscribe = storage.onChange(listener)
1419
+
1420
+ await Promise.resolve()
1421
+
1422
+ const newPage = PageRecordType.create({
1423
+ id: PageRecordType.createId('new'),
1424
+ name: 'New',
1425
+ index: 'a2' as IndexKey,
1426
+ })
1427
+
1428
+ // First change
1429
+ storage.transaction((txn) => {
1430
+ txn.set(newPage.id, newPage)
1431
+ })
1432
+
1433
+ await Promise.resolve()
1434
+
1435
+ expect(listener).toHaveBeenCalledTimes(1)
1436
+
1437
+ // Second change - listener should NOT be called
1438
+ storage.transaction((txn) => {
1439
+ txn.delete(newPage.id)
1440
+ })
1441
+
1442
+ await Promise.resolve()
1443
+
1444
+ expect(listener).toHaveBeenCalledTimes(1)
1445
+ })
1446
+ })
1447
+
1448
+ describe('Snapshot consistency edge cases', () => {
1449
+ it('clamps tombstoneHistoryStartsAtClock to documentClock when greater', () => {
1450
+ // When tombstoneHistoryStartsAtClock > documentClock, it gets clamped
1451
+ const storage = new InMemorySyncStorage<TLRecord>({
1452
+ snapshot: makeSnapshot(defaultRecords, {
1453
+ documentClock: 5,
1454
+ tombstoneHistoryStartsAtClock: 10, // Invalid: greater than documentClock
1455
+ }),
1456
+ })
1457
+
1458
+ // Should be clamped to documentClock
1459
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(5)
1460
+ expect(storage.getClock()).toBe(5)
1461
+ })
1462
+
1463
+ it('accepts tombstoneHistoryStartsAtClock equal to documentClock', () => {
1464
+ const storage = new InMemorySyncStorage<TLRecord>({
1465
+ snapshot: makeSnapshot(defaultRecords, {
1466
+ documentClock: 10,
1467
+ tombstoneHistoryStartsAtClock: 10,
1468
+ }),
1469
+ })
1470
+
1471
+ expect(storage.getClock()).toBe(10)
1472
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(10)
1473
+ })
1474
+
1475
+ it('accepts tombstoneHistoryStartsAtClock less than documentClock', () => {
1476
+ const storage = new InMemorySyncStorage<TLRecord>({
1477
+ snapshot: makeSnapshot(defaultRecords, {
1478
+ documentClock: 20,
1479
+ tombstoneHistoryStartsAtClock: 5,
1480
+ }),
1481
+ })
1482
+
1483
+ expect(storage.getClock()).toBe(20)
1484
+ expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(5)
1485
+ })
1486
+
1487
+ it('handles duplicate document IDs in snapshot - last one wins', () => {
1488
+ const page1 = PageRecordType.create({
1489
+ id: PageRecordType.createId('dupe'),
1490
+ name: 'First',
1491
+ index: 'a1' as IndexKey,
1492
+ })
1493
+ const page2 = PageRecordType.create({
1494
+ id: PageRecordType.createId('dupe'),
1495
+ name: 'Second',
1496
+ index: 'a2' as IndexKey,
1497
+ })
1498
+
1499
+ const storage = new InMemorySyncStorage<TLRecord>({
1500
+ snapshot: {
1501
+ documents: [
1502
+ { state: page1, lastChangedClock: 1 },
1503
+ { state: page2, lastChangedClock: 2 },
1504
+ ],
1505
+ documentClock: 5,
1506
+ schema: tlSchema.serialize(),
1507
+ },
1508
+ })
1509
+
1510
+ // Last one wins due to Map behavior
1511
+ expect(storage.documents.get('page:dupe')?.state).toEqual(page2)
1512
+ expect(storage.documents.get('page:dupe')?.lastChangedClock).toBe(2)
1513
+ })
1514
+ })
1515
+
1516
+ describe('loadSnapshotIntoStorage edge cases', () => {
1517
+ it('deletes while iterating over keys', () => {
1518
+ const extraPage1 = PageRecordType.create({
1519
+ id: PageRecordType.createId('extra1'),
1520
+ name: 'Extra1',
1521
+ index: 'a2' as IndexKey,
1522
+ })
1523
+ const extraPage2 = PageRecordType.create({
1524
+ id: PageRecordType.createId('extra2'),
1525
+ name: 'Extra2',
1526
+ index: 'a3' as IndexKey,
1527
+ })
1528
+
1529
+ const storage = new InMemorySyncStorage<TLRecord>({
1530
+ snapshot: makeSnapshot([...defaultRecords, extraPage1, extraPage2]),
1531
+ })
1532
+
1533
+ expect(storage.documents.size).toBe(4)
1534
+
1535
+ // Load a snapshot with only the default records
1536
+ const newSnapshot = makeSnapshot(defaultRecords)
1537
+
1538
+ storage.transaction((txn) => {
1539
+ loadSnapshotIntoStorage(txn, tlSchema, newSnapshot)
1540
+ })
1541
+
1542
+ // Both extra pages should be deleted
1543
+ expect(storage.documents.size).toBe(2)
1544
+ expect(storage.documents.has(extraPage1.id)).toBe(false)
1545
+ expect(storage.documents.has(extraPage2.id)).toBe(false)
1546
+ })
1547
+ })
1548
+
1549
+ describe('Record immutability', () => {
1550
+ it('freezes records stored via set()', () => {
1551
+ const storage = new InMemorySyncStorage<TLRecord>({
1552
+ snapshot: makeSnapshot(defaultRecords),
1553
+ })
1554
+
1555
+ const page = PageRecordType.create({
1556
+ id: PageRecordType.createId('mutable'),
1557
+ name: 'Original',
1558
+ index: 'a2' as IndexKey,
1559
+ })
1560
+
1561
+ storage.transaction((txn) => {
1562
+ txn.set(page.id, page)
1563
+ })
1564
+
1565
+ const stored = storage.documents.get(page.id)?.state as any
1566
+
1567
+ // Records should be frozen
1568
+ expect(Object.isFrozen(stored)).toBe(true)
1569
+ })
1570
+
1571
+ it('freezes records from constructor snapshot', () => {
1572
+ const storage = new InMemorySyncStorage<TLRecord>({
1573
+ snapshot: makeSnapshot(defaultRecords),
1574
+ })
1575
+
1576
+ const stored = storage.documents.get(TLDOCUMENT_ID)?.state as any
1577
+
1578
+ expect(Object.isFrozen(stored)).toBe(true)
1579
+ })
1580
+ })
1581
+
1582
+ describe('Clock overflow', () => {
1583
+ it('handles clock at MAX_SAFE_INTEGER', () => {
1584
+ const storage = new InMemorySyncStorage<TLRecord>({
1585
+ snapshot: makeSnapshot(defaultRecords, {
1586
+ documentClock: Number.MAX_SAFE_INTEGER - 1,
1587
+ }),
1588
+ })
1589
+
1590
+ const newPage = PageRecordType.create({
1591
+ id: PageRecordType.createId('new'),
1592
+ name: 'New',
1593
+ index: 'a2' as IndexKey,
1594
+ })
1595
+
1596
+ storage.transaction((txn) => {
1597
+ txn.set(newPage.id, newPage)
1598
+ })
1599
+
1600
+ expect(storage.getClock()).toBe(Number.MAX_SAFE_INTEGER)
1601
+
1602
+ // What happens if we try to increment past MAX_SAFE_INTEGER?
1603
+ storage.transaction((txn) => {
1604
+ txn.set(newPage.id, { ...newPage, name: 'Updated' } as TLRecord)
1605
+ })
1606
+
1607
+ // Clock loses precision at MAX_SAFE_INTEGER + 1
1608
+ expect(storage.getClock()).toBe(Number.MAX_SAFE_INTEGER + 1)
1609
+ })
1610
+ })
1611
+
1612
+ describe('Empty and special IDs', () => {
1613
+ it('handles empty string as record ID', () => {
1614
+ const storage = new InMemorySyncStorage<TLRecord>({
1615
+ snapshot: makeSnapshot(defaultRecords),
1616
+ })
1617
+
1618
+ // Create a record with empty string id (unusual but possible)
1619
+ const weirdRecord = { ...defaultRecords[0], id: '' } as TLRecord
1620
+
1621
+ storage.transaction((txn) => {
1622
+ txn.set('', weirdRecord)
1623
+ })
1624
+
1625
+ expect(storage.documents.has('')).toBe(true)
1626
+ expect(storage.documents.get('')?.state.id).toBe('')
1627
+ })
1628
+ })
1629
+
1630
+ describe('Transaction result consistency', () => {
1631
+ it('didChange reflects whether clock was incremented', () => {
1632
+ const storage = new InMemorySyncStorage<TLRecord>({
1633
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
1634
+ })
1635
+
1636
+ // Read-only transaction
1637
+ const readResult = storage.transaction((txn) => {
1638
+ txn.get(TLDOCUMENT_ID)
1639
+ return 'read'
1640
+ })
1641
+
1642
+ expect(readResult.didChange).toBe(false)
1643
+ expect(readResult.documentClock).toBe(10)
1644
+
1645
+ // Write transaction
1646
+ const writeResult = storage.transaction((txn) => {
1647
+ txn.set(TLDOCUMENT_ID, defaultRecords[0])
1648
+ return 'write'
1649
+ })
1650
+
1651
+ expect(writeResult.didChange).toBe(true)
1652
+ expect(writeResult.documentClock).toBe(11)
1653
+ })
1654
+
1655
+ it('documentClock in result matches storage.getClock()', () => {
1656
+ const storage = new InMemorySyncStorage<TLRecord>({
1657
+ snapshot: makeSnapshot(defaultRecords, { documentClock: 5 }),
1658
+ })
1659
+
1660
+ const result = storage.transaction((txn) => {
1661
+ const page = PageRecordType.create({
1662
+ id: PageRecordType.createId('new'),
1663
+ name: 'New',
1664
+ index: 'a2' as IndexKey,
1665
+ })
1666
+ txn.set(page.id, page)
1667
+ })
1668
+
1669
+ expect(result.documentClock).toBe(storage.getClock())
1670
+ expect(result.documentClock).toBe(6)
1671
+ })
1672
+ })
1673
+ })
1674
+ })