@tldraw/store 5.2.0-next.e2b8d10bf10e → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/DOCS.md +790 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/ImmutableMap.js +0 -13
  5. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  6. package/dist-cjs/lib/RecordType.js +1 -1
  7. package/dist-cjs/lib/RecordType.js.map +2 -2
  8. package/dist-cjs/lib/RecordsDiff.js +26 -14
  9. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  10. package/dist-cjs/lib/Store.js +6 -5
  11. package/dist-cjs/lib/Store.js.map +2 -2
  12. package/dist-cjs/lib/StoreSchema.js +1 -1
  13. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  14. package/dist-cjs/lib/executeQuery.js +1 -1
  15. package/dist-cjs/lib/executeQuery.js.map +2 -2
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/ImmutableMap.mjs +0 -13
  18. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  19. package/dist-esm/lib/RecordType.mjs +1 -1
  20. package/dist-esm/lib/RecordType.mjs.map +2 -2
  21. package/dist-esm/lib/RecordsDiff.mjs +26 -14
  22. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  23. package/dist-esm/lib/Store.mjs +7 -6
  24. package/dist-esm/lib/Store.mjs.map +2 -2
  25. package/dist-esm/lib/StoreSchema.mjs +1 -1
  26. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  27. package/dist-esm/lib/executeQuery.mjs +1 -1
  28. package/dist-esm/lib/executeQuery.mjs.map +2 -2
  29. package/package.json +9 -5
  30. package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
  31. package/src/lib/AtomSet.test.ts +116 -0
  32. package/src/lib/BaseRecord.test.ts +10 -22
  33. package/src/lib/ImmutableMap.test.ts +114 -71
  34. package/src/lib/ImmutableMap.ts +1 -0
  35. package/src/lib/IncrementalSetConstructor.test.ts +76 -81
  36. package/src/lib/RecordType.test.ts +216 -0
  37. package/src/lib/RecordType.ts +1 -1
  38. package/src/lib/RecordsDiff.test.ts +112 -106
  39. package/src/lib/RecordsDiff.ts +43 -18
  40. package/src/lib/Store.test.ts +570 -630
  41. package/src/lib/Store.ts +9 -10
  42. package/src/lib/StoreListeners.test.ts +462 -0
  43. package/src/lib/StoreQueries.test.ts +586 -434
  44. package/src/lib/StoreSchema.test.ts +1012 -174
  45. package/src/lib/StoreSchema.ts +1 -1
  46. package/src/lib/StoreSideEffects.test.ts +546 -158
  47. package/src/lib/devFreeze.test.ts +94 -124
  48. package/src/lib/executeQuery.test.ts +77 -31
  49. package/src/lib/executeQuery.ts +3 -1
  50. package/src/lib/migrate.test.ts +273 -296
  51. package/src/lib/setUtils.test.ts +38 -79
  52. package/src/lib/test/createMigrations.test.ts +0 -75
  53. package/src/lib/test/dependsOn.test.ts +0 -166
  54. package/src/lib/test/getMigrationsSince.test.ts +0 -121
  55. package/src/lib/test/migrate.test.ts +0 -118
  56. package/src/lib/test/migratePersistedRecord.test.ts +0 -265
  57. package/src/lib/test/migrationCaching.test.ts +0 -209
  58. package/src/lib/test/recordStore.test.ts +0 -1567
  59. package/src/lib/test/recordStoreQueries.test.ts +0 -814
  60. package/src/lib/test/recordType.test.ts +0 -19
  61. package/src/lib/test/sortMigrations.test.ts +0 -83
  62. package/src/lib/test/upgradeSchema.test.ts +0 -80
  63. package/src/lib/test/validate.test.ts +0 -178
  64. package/src/lib/test/validateMigrations.test.ts +0 -165
package/src/lib/Store.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import { AtomMap } from './AtomMap'
15
15
  import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
16
16
  import { devFreeze } from './devFreeze'
17
- import { RecordsDiff, squashRecordDiffs } from './RecordsDiff'
17
+ import { hasAnyKey, RecordsDiff, squashRecordDiffs } from './RecordsDiff'
18
18
  import { RecordScope } from './RecordType'
19
19
  import { StoreQueries } from './StoreQueries'
20
20
  import { SerializedSchema, StoreSchema } from './StoreSchema'
@@ -573,11 +573,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
573
573
  updated: filterEntries(change.updated, (_, r) => this.scopedTypes[scope].has(r[1].typeName)),
574
574
  removed: filterEntries(change.removed, (_, r) => this.scopedTypes[scope].has(r.typeName)),
575
575
  }
576
- if (
577
- Object.keys(result.added).length === 0 &&
578
- Object.keys(result.updated).length === 0 &&
579
- Object.keys(result.removed).length === 0
580
- ) {
576
+ if (!hasAnyKey(result.added) && !hasAnyKey(result.updated) && !hasAnyKey(result.removed)) {
581
577
  return null
582
578
  }
583
579
  return result
@@ -656,7 +652,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
656
652
 
657
653
  if (validated === initialValue) continue
658
654
 
659
- record = devFreeze(record)
655
+ record = devFreeze(validated)
660
656
  this.records.set(record.id, record)
661
657
 
662
658
  didChange = true
@@ -888,7 +884,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
888
884
  this.clear()
889
885
  this.put(Object.values(migrationResult.value))
890
886
  this.ensureStoreIsUsable()
891
- })
887
+ }, false)
892
888
  } finally {
893
889
  this.sideEffects.setIsEnabled(prevSideEffectsEnabled)
894
890
  }
@@ -1252,7 +1248,8 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
1252
1248
 
1253
1249
  this.pendingAfterEvents = new Map()
1254
1250
  const prevSideEffectsEnabled = this.sideEffects.isEnabled()
1255
- this.sideEffects.setIsEnabled(runCallbacks ?? prevSideEffectsEnabled)
1251
+ // an operation may switch side effects off, but never on while they are disabled
1252
+ this.sideEffects.setIsEnabled(runCallbacks && prevSideEffectsEnabled)
1256
1253
  this._isInAtomicOp = true
1257
1254
 
1258
1255
  if (isMergingRemoteChanges) {
@@ -1328,7 +1325,9 @@ function squashHistoryEntries<T extends UnknownRecord>(
1328
1325
  return devFreeze(
1329
1326
  chunked.map((chunk) => ({
1330
1327
  source: chunk[0].source,
1331
- changes: squashRecordDiffs(chunk.map((e) => e.changes)),
1328
+ // a single-entry chunk needs no squashing — skip the O(N) copy of its diff
1329
+ changes:
1330
+ chunk.length === 1 ? chunk[0].changes : squashRecordDiffs(chunk.map((e) => e.changes)),
1332
1331
  }))
1333
1332
  )
1334
1333
  }
@@ -0,0 +1,462 @@
1
+ import { react, RESET_VALUE, transact } from '@tldraw/state'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { BaseRecord, RecordId } from './BaseRecord'
4
+ import { RecordsDiff, reverseRecordsDiff } from './RecordsDiff'
5
+ import { createRecordType } from './RecordType'
6
+ import { Store } from './Store'
7
+ import { StoreSchema } from './StoreSchema'
8
+
9
+ // Tests for SPEC.md §8 (history and listeners).
10
+ // Rule IDs like [H4] in test names refer to that document.
11
+
12
+ interface Book extends BaseRecord<'book', RecordId<Book>> {
13
+ title: string
14
+ author: RecordId<Author>
15
+ numPages: number
16
+ }
17
+
18
+ const Book = createRecordType<Book>('book', {
19
+ validator: { validate: (book) => book as Book },
20
+ scope: 'document',
21
+ }).withDefaultProperties(() => ({ numPages: 100 }))
22
+
23
+ interface Author extends BaseRecord<'author', RecordId<Author>> {
24
+ name: string
25
+ isPseudonym: boolean
26
+ }
27
+
28
+ const Author = createRecordType<Author>('author', {
29
+ validator: { validate: (author) => author as Author },
30
+ scope: 'document',
31
+ }).withDefaultProperties(() => ({ isPseudonym: false }))
32
+
33
+ interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
34
+ visitorName: string
35
+ lastActive: number
36
+ }
37
+
38
+ const Visit = createRecordType<Visit>('visit', {
39
+ validator: { validate: (visit) => visit as Visit },
40
+ scope: 'session',
41
+ ephemeralKeys: { visitorName: false, lastActive: true },
42
+ }).withDefaultProperties(() => ({ visitorName: 'Anonymous', lastActive: 0 }))
43
+
44
+ interface Cursor extends BaseRecord<'cursor', RecordId<Cursor>> {
45
+ x: number
46
+ }
47
+
48
+ const Cursor = createRecordType<Cursor>('cursor', {
49
+ scope: 'presence',
50
+ }).withDefaultProperties(() => ({ x: 0 }))
51
+
52
+ type LibraryType = Book | Author | Visit | Cursor
53
+
54
+ let store: Store<LibraryType>
55
+ beforeEach(() => {
56
+ store = new Store({
57
+ props: {},
58
+ schema: StoreSchema.create<LibraryType>({
59
+ book: Book,
60
+ author: Author,
61
+ visit: Visit,
62
+ cursor: Cursor,
63
+ }),
64
+ })
65
+ })
66
+
67
+ const tolkein = () => Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
68
+ const hobbit = () =>
69
+ Book.create({
70
+ title: 'The Hobbit',
71
+ id: Book.createId('hobbit'),
72
+ author: Author.createId('tolkein'),
73
+ numPages: 300,
74
+ })
75
+
76
+ describe('the history atom (H)', () => {
77
+ it('[H1] increments by exactly one per committed change-set, carrying the diff', () => {
78
+ let lastDiff: RecordsDiff<LibraryType>[] | typeof RESET_VALUE | undefined
79
+ const stop = react('history', (lastReactedEpoch) => {
80
+ store.history.get()
81
+ lastDiff = store.history.getDiffSince(lastReactedEpoch)
82
+ })
83
+
84
+ const before = store.history.get()
85
+ const author = tolkein()
86
+ store.put([author])
87
+
88
+ expect(store.history.get()).toBe(before + 1)
89
+ expect(lastDiff).toEqual([{ added: { [author.id]: author }, updated: {}, removed: {} }])
90
+
91
+ // a transaction with several mutations commits as one change per put/remove call
92
+ transact(() => {
93
+ store.put([hobbit()])
94
+ store.remove([author.id])
95
+ })
96
+ expect(store.history.get()).toBe(before + 3)
97
+ stop()
98
+ })
99
+ })
100
+
101
+ describe('listeners (H)', () => {
102
+ it('[H2] listen returns a remover and notification is deferred to the next frame', async () => {
103
+ try {
104
+ // @ts-expect-error - test-only escape hatch
105
+ globalThis.__FORCE_RAF_IN_TESTS__ = true
106
+ const listener = vi.fn()
107
+ const removeListener = store.listen(listener)
108
+
109
+ store.put([tolkein()])
110
+ // not called synchronously
111
+ expect(listener).toHaveBeenCalledTimes(0)
112
+
113
+ await new Promise((resolve) => requestAnimationFrame(resolve))
114
+ expect(listener).toHaveBeenCalledTimes(1)
115
+
116
+ removeListener()
117
+ store.put([hobbit()])
118
+ await new Promise((resolve) => requestAnimationFrame(resolve))
119
+ expect(listener).toHaveBeenCalledTimes(1)
120
+ } finally {
121
+ // @ts-expect-error - test-only escape hatch
122
+ globalThis.__FORCE_RAF_IN_TESTS__ = false
123
+ }
124
+ })
125
+
126
+ it('[H3] a flush squashes adjacent same-source entries, preserving source boundaries', async () => {
127
+ try {
128
+ // @ts-expect-error - test-only escape hatch
129
+ globalThis.__FORCE_RAF_IN_TESTS__ = true
130
+ const entries: Array<{ source: string; addedIds: string[] }> = []
131
+ store.listen((entry) => {
132
+ entries.push({ source: entry.source, addedIds: Object.keys(entry.changes.added) })
133
+ })
134
+
135
+ const author = tolkein()
136
+ const book = hobbit()
137
+ const remoteAuthor = Author.create({ name: 'Remote', id: Author.createId('remote') })
138
+ const lastAuthor = Author.create({ name: 'Last', id: Author.createId('last') })
139
+
140
+ store.put([author])
141
+ store.put([book])
142
+ store.mergeRemoteChanges(() => {
143
+ store.put([remoteAuthor])
144
+ })
145
+ store.put([lastAuthor])
146
+
147
+ await new Promise((resolve) => requestAnimationFrame(resolve))
148
+
149
+ // [user, user, remote, user] -> [user, remote, user]
150
+ expect(entries).toEqual([
151
+ { source: 'user', addedIds: [author.id, book.id] },
152
+ { source: 'remote', addedIds: [remoteAuthor.id] },
153
+ { source: 'user', addedIds: [lastAuthor.id] },
154
+ ])
155
+ } finally {
156
+ // @ts-expect-error - test-only escape hatch
157
+ globalThis.__FORCE_RAF_IN_TESTS__ = false
158
+ }
159
+ })
160
+
161
+ it('[H3] changes within one transaction arrive as one squashed entry', async () => {
162
+ const listener = vi.fn()
163
+ store.listen(listener)
164
+
165
+ const author = tolkein()
166
+ transact(() => {
167
+ store.put([author])
168
+ store.update(author.id, (r) => ({ ...r, name: 'Jimmy Tolks' }))
169
+ })
170
+
171
+ await new Promise((resolve) => requestAnimationFrame(resolve))
172
+ expect(listener).toHaveBeenCalledTimes(1)
173
+ expect(listener.mock.calls[0][0]).toEqual({
174
+ source: 'user',
175
+ changes: {
176
+ added: { [author.id]: { ...author, name: 'Jimmy Tolks' } },
177
+ updated: {},
178
+ removed: {},
179
+ },
180
+ })
181
+ })
182
+
183
+ it('[H4] source filters drop entries from other sources', () => {
184
+ const userListener = vi.fn()
185
+ const remoteListener = vi.fn()
186
+ store.listen(userListener, { source: 'user' })
187
+ store.listen(remoteListener, { source: 'remote' })
188
+
189
+ store.put([tolkein()])
190
+ expect(userListener).toHaveBeenCalledTimes(1)
191
+ expect(remoteListener).toHaveBeenCalledTimes(0)
192
+
193
+ store.mergeRemoteChanges(() => {
194
+ store.put([hobbit()])
195
+ })
196
+ expect(userListener).toHaveBeenCalledTimes(1)
197
+ expect(remoteListener).toHaveBeenCalledTimes(1)
198
+ expect(remoteListener.mock.calls[0][0].source).toBe('remote')
199
+ })
200
+
201
+ it('[H4] scope filters reduce each entry to records of that scope', () => {
202
+ const documentListener = vi.fn()
203
+ const sessionListener = vi.fn()
204
+ const presenceListener = vi.fn()
205
+
206
+ store.listen(documentListener, { scope: 'document' })
207
+ store.listen(sessionListener, { scope: 'session' })
208
+ store.listen(presenceListener, { scope: 'presence' })
209
+
210
+ const author = tolkein()
211
+ const visit = Visit.create({ visitorName: 'John Doe' })
212
+ const cursor = Cursor.create({ x: 1 })
213
+
214
+ store.put([author, visit, cursor])
215
+
216
+ expect(documentListener).toHaveBeenCalledTimes(1)
217
+ expect(Object.keys(documentListener.mock.calls[0][0].changes.added)).toEqual([author.id])
218
+
219
+ expect(sessionListener).toHaveBeenCalledTimes(1)
220
+ expect(Object.keys(sessionListener.mock.calls[0][0].changes.added)).toEqual([visit.id])
221
+
222
+ expect(presenceListener).toHaveBeenCalledTimes(1)
223
+ expect(Object.keys(presenceListener.mock.calls[0][0].changes.added)).toEqual([cursor.id])
224
+ })
225
+
226
+ it('[H4] a listener is not called when its scope filter leaves nothing', () => {
227
+ const sessionListener = vi.fn()
228
+ store.listen(sessionListener, { scope: 'session' })
229
+
230
+ // document-only change
231
+ store.put([tolkein(), hobbit()])
232
+ expect(sessionListener).toHaveBeenCalledTimes(0)
233
+
234
+ // mixed change still arrives, filtered
235
+ store.put([Visit.create({ visitorName: 'Jimmy Beans' }), Author.create({ name: 'Other' })])
236
+ expect(sessionListener).toHaveBeenCalledTimes(1)
237
+ })
238
+
239
+ it('[H5] a new listener never sees changes made before it subscribed', async () => {
240
+ try {
241
+ // @ts-expect-error - test-only escape hatch
242
+ globalThis.__FORCE_RAF_IN_TESTS__ = true
243
+ store.put([tolkein()])
244
+ const firstListener = vi.fn()
245
+ store.listen(firstListener)
246
+ expect(firstListener).toHaveBeenCalledTimes(0)
247
+
248
+ store.put([Author.create({ name: 'Chips McCoy', id: Author.createId('chips') })])
249
+
250
+ expect(firstListener).toHaveBeenCalledTimes(0)
251
+
252
+ // attaching a second listener flushes pending history to the first
253
+ const secondListener = vi.fn()
254
+ store.listen(secondListener)
255
+
256
+ expect(firstListener).toHaveBeenCalledTimes(1)
257
+ expect(secondListener).toHaveBeenCalledTimes(0)
258
+
259
+ await new Promise((resolve) => requestAnimationFrame(resolve))
260
+
261
+ expect(firstListener).toHaveBeenCalledTimes(1)
262
+ expect(secondListener).toHaveBeenCalledTimes(0)
263
+ } finally {
264
+ // @ts-expect-error - test-only escape hatch
265
+ globalThis.__FORCE_RAF_IN_TESTS__ = false
266
+ }
267
+ })
268
+
269
+ it('[H6] accumulated history is discarded while no listeners are attached', () => {
270
+ store.put([tolkein()])
271
+ // H6 has no public observation point, so this reaches into private state
272
+ expect((store as any).historyAccumulator._history).toHaveLength(0)
273
+ })
274
+ })
275
+
276
+ describe('extracting and intercepting changes (H)', () => {
277
+ const authorId = Author.createId('tolkein')
278
+ const bookId = Book.createId('hobbit')
279
+
280
+ it('[H7] extractingChanges returns the squashed diff of exactly the changes in fn', () => {
281
+ const author = tolkein()
282
+ const book = hobbit()
283
+
284
+ expect(
285
+ store.extractingChanges(() => {
286
+ store.put([author])
287
+ store.put([book])
288
+ })
289
+ ).toEqual({
290
+ added: { [author.id]: author, [book.id]: book },
291
+ updated: {},
292
+ removed: {},
293
+ })
294
+
295
+ const renamed = { ...book, title: 'The Hobbit: There and Back Again' }
296
+ expect(
297
+ store.extractingChanges(() => {
298
+ store.remove([authorId])
299
+ store.update(bookId, () => renamed)
300
+ })
301
+ ).toEqual({
302
+ added: {},
303
+ updated: { [book.id]: [book, renamed] },
304
+ removed: { [author.id]: author },
305
+ })
306
+ })
307
+
308
+ it('[H7] listeners still see changes made inside extractingChanges', () => {
309
+ const listener = vi.fn()
310
+ store.listen(listener)
311
+
312
+ store.extractingChanges(() => {
313
+ store.put([tolkein()])
314
+ })
315
+
316
+ expect(listener).toHaveBeenCalledTimes(1)
317
+ })
318
+
319
+ it('[H8] addHistoryInterceptor sees every change-set synchronously, with its source', () => {
320
+ const entries: any[] = []
321
+ const interceptor = vi.fn((entry, source) => entries.push({ entry, source }))
322
+ const remove = store.addHistoryInterceptor(interceptor)
323
+
324
+ const author = tolkein()
325
+ store.put([author])
326
+ expect(interceptor).toHaveBeenCalledTimes(1)
327
+ expect(entries[0].source).toBe('user')
328
+ expect(entries[0].entry.changes.added).toEqual({ [author.id]: author })
329
+
330
+ store.mergeRemoteChanges(() => {
331
+ store.put([hobbit()])
332
+ })
333
+ expect(interceptor).toHaveBeenCalledTimes(2)
334
+ expect(entries[1].source).toBe('remote')
335
+
336
+ remove()
337
+ store.remove([author.id])
338
+ expect(interceptor).toHaveBeenCalledTimes(2)
339
+ })
340
+ })
341
+
342
+ describe('applying diffs (H)', () => {
343
+ const authorId = Author.createId('tolkein')
344
+ const bookId = Book.createId('hobbit')
345
+
346
+ it('[H9] applyDiff puts added and updated records and removes removed ones', () => {
347
+ const author = tolkein()
348
+ store.applyDiff({
349
+ added: { [author.id]: author },
350
+ updated: {},
351
+ removed: {},
352
+ } as RecordsDiff<LibraryType>)
353
+ expect(store.get(authorId)).toEqual(author)
354
+
355
+ const renamed = { ...author, name: 'Jimmy Tolks' }
356
+ store.applyDiff({
357
+ added: {},
358
+ updated: { [author.id]: [author, renamed] },
359
+ removed: {},
360
+ } as RecordsDiff<LibraryType>)
361
+ expect(store.get(authorId)).toEqual(renamed)
362
+
363
+ store.applyDiff({
364
+ added: {},
365
+ updated: {},
366
+ removed: { [author.id]: renamed },
367
+ } as RecordsDiff<LibraryType>)
368
+ expect(store.has(authorId)).toBe(false)
369
+ })
370
+
371
+ it('[H9] applying a diff and then its reverse restores the prior state', () => {
372
+ store.put([tolkein(), hobbit()])
373
+
374
+ const checkpoint1 = store.getStoreSnapshot()
375
+
376
+ const forwardsDiff = store.extractingChanges(() => {
377
+ store.remove([authorId])
378
+ store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
379
+ })
380
+
381
+ const checkpoint2 = store.getStoreSnapshot()
382
+
383
+ store.applyDiff(reverseRecordsDiff(forwardsDiff))
384
+ expect(store.getStoreSnapshot()).toEqual(checkpoint1)
385
+
386
+ store.applyDiff(forwardsDiff)
387
+ expect(store.getStoreSnapshot()).toEqual(checkpoint2)
388
+ })
389
+
390
+ it('[H9] runCallbacks: false applies the diff without side effects', () => {
391
+ const afterCreate = vi.fn()
392
+ store.sideEffects.registerAfterCreateHandler('author', afterCreate)
393
+
394
+ const author = tolkein()
395
+ store.applyDiff(
396
+ { added: { [author.id]: author }, updated: {}, removed: {} } as RecordsDiff<LibraryType>,
397
+ { runCallbacks: false }
398
+ )
399
+
400
+ expect(store.get(authorId)).toEqual(author)
401
+ expect(afterCreate).not.toHaveBeenCalled()
402
+ })
403
+
404
+ describe('[H10] ignoreEphemeralKeys', () => {
405
+ const visitId = Visit.createId('jane')
406
+ const visit = () => Visit.create({ id: visitId, visitorName: 'Jane', lastActive: 100 })
407
+
408
+ it('drops updates that touch only ephemeral keys', () => {
409
+ store.put([visit()])
410
+ const listener = vi.fn()
411
+ store.listen(listener)
412
+
413
+ store.applyDiff(
414
+ {
415
+ added: {},
416
+ updated: { [visitId]: [visit(), { ...visit(), lastActive: 999 }] },
417
+ removed: {},
418
+ } as RecordsDiff<LibraryType>,
419
+ { ignoreEphemeralKeys: true }
420
+ )
421
+
422
+ expect((store.get(visitId) as Visit).lastActive).toBe(100)
423
+ expect(listener).not.toHaveBeenCalled()
424
+ })
425
+
426
+ it('merges non-ephemeral changes onto the stored record, keeping ephemeral values', () => {
427
+ store.put([visit()])
428
+
429
+ store.applyDiff(
430
+ {
431
+ added: {},
432
+ updated: {
433
+ [visitId]: [visit(), { ...visit(), visitorName: 'Janet', lastActive: 999 }],
434
+ },
435
+ removed: {},
436
+ } as RecordsDiff<LibraryType>,
437
+ { ignoreEphemeralKeys: true }
438
+ )
439
+
440
+ const result = store.get(visitId) as Visit
441
+ expect(result.visitorName).toBe('Janet')
442
+ expect(result.lastActive).toBe(100) // the stored ephemeral value is kept
443
+ })
444
+
445
+ it('applies updates for missing records, and added records, in full', () => {
446
+ store.applyDiff(
447
+ {
448
+ added: {},
449
+ updated: {
450
+ [visitId]: [visit(), { ...visit(), visitorName: 'Janet', lastActive: 999 }],
451
+ },
452
+ removed: {},
453
+ } as RecordsDiff<LibraryType>,
454
+ { ignoreEphemeralKeys: true }
455
+ )
456
+
457
+ const result = store.get(visitId) as Visit
458
+ expect(result.visitorName).toBe('Janet')
459
+ expect(result.lastActive).toBe(999)
460
+ })
461
+ })
462
+ })