@tldraw/sync-core 4.3.0-next.2d181ae353a2 → 4.3.0-next.40e4536afc8e

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