@tldraw/store 4.1.0-canary.e2133d922c9e → 4.1.0-canary.e23ee15a46bc

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 (72) hide show
  1. package/dist-cjs/index.d.ts +1884 -153
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/AtomMap.js +241 -1
  4. package/dist-cjs/lib/AtomMap.js.map +2 -2
  5. package/dist-cjs/lib/BaseRecord.js.map +2 -2
  6. package/dist-cjs/lib/ImmutableMap.js +141 -0
  7. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  8. package/dist-cjs/lib/IncrementalSetConstructor.js +45 -5
  9. package/dist-cjs/lib/IncrementalSetConstructor.js.map +2 -2
  10. package/dist-cjs/lib/RecordType.js +116 -21
  11. package/dist-cjs/lib/RecordType.js.map +2 -2
  12. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  13. package/dist-cjs/lib/Store.js +233 -39
  14. package/dist-cjs/lib/Store.js.map +2 -2
  15. package/dist-cjs/lib/StoreQueries.js +135 -22
  16. package/dist-cjs/lib/StoreQueries.js.map +2 -2
  17. package/dist-cjs/lib/StoreSchema.js +207 -2
  18. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  19. package/dist-cjs/lib/StoreSideEffects.js +102 -10
  20. package/dist-cjs/lib/StoreSideEffects.js.map +2 -2
  21. package/dist-cjs/lib/executeQuery.js.map +2 -2
  22. package/dist-cjs/lib/migrate.js.map +2 -2
  23. package/dist-cjs/lib/setUtils.js.map +2 -2
  24. package/dist-esm/index.d.mts +1884 -153
  25. package/dist-esm/index.mjs +1 -1
  26. package/dist-esm/lib/AtomMap.mjs +241 -1
  27. package/dist-esm/lib/AtomMap.mjs.map +2 -2
  28. package/dist-esm/lib/BaseRecord.mjs.map +2 -2
  29. package/dist-esm/lib/ImmutableMap.mjs +141 -0
  30. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  31. package/dist-esm/lib/IncrementalSetConstructor.mjs +45 -5
  32. package/dist-esm/lib/IncrementalSetConstructor.mjs.map +2 -2
  33. package/dist-esm/lib/RecordType.mjs +116 -21
  34. package/dist-esm/lib/RecordType.mjs.map +2 -2
  35. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  36. package/dist-esm/lib/Store.mjs +233 -39
  37. package/dist-esm/lib/Store.mjs.map +2 -2
  38. package/dist-esm/lib/StoreQueries.mjs +135 -22
  39. package/dist-esm/lib/StoreQueries.mjs.map +2 -2
  40. package/dist-esm/lib/StoreSchema.mjs +207 -2
  41. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  42. package/dist-esm/lib/StoreSideEffects.mjs +102 -10
  43. package/dist-esm/lib/StoreSideEffects.mjs.map +2 -2
  44. package/dist-esm/lib/executeQuery.mjs.map +2 -2
  45. package/dist-esm/lib/migrate.mjs.map +2 -2
  46. package/dist-esm/lib/setUtils.mjs.map +2 -2
  47. package/package.json +3 -3
  48. package/src/lib/AtomMap.ts +241 -1
  49. package/src/lib/BaseRecord.test.ts +44 -0
  50. package/src/lib/BaseRecord.ts +118 -4
  51. package/src/lib/ImmutableMap.test.ts +103 -0
  52. package/src/lib/ImmutableMap.ts +212 -0
  53. package/src/lib/IncrementalSetConstructor.test.ts +111 -0
  54. package/src/lib/IncrementalSetConstructor.ts +63 -6
  55. package/src/lib/RecordType.ts +149 -25
  56. package/src/lib/RecordsDiff.test.ts +144 -0
  57. package/src/lib/RecordsDiff.ts +145 -10
  58. package/src/lib/Store.test.ts +827 -0
  59. package/src/lib/Store.ts +533 -67
  60. package/src/lib/StoreQueries.test.ts +627 -0
  61. package/src/lib/StoreQueries.ts +194 -27
  62. package/src/lib/StoreSchema.test.ts +226 -0
  63. package/src/lib/StoreSchema.ts +386 -8
  64. package/src/lib/StoreSideEffects.test.ts +239 -19
  65. package/src/lib/StoreSideEffects.ts +266 -19
  66. package/src/lib/devFreeze.test.ts +137 -0
  67. package/src/lib/executeQuery.test.ts +481 -0
  68. package/src/lib/executeQuery.ts +80 -2
  69. package/src/lib/migrate.test.ts +400 -0
  70. package/src/lib/migrate.ts +187 -14
  71. package/src/lib/setUtils.test.ts +105 -0
  72. package/src/lib/setUtils.ts +44 -4
@@ -0,0 +1,827 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { BaseRecord, RecordId } from './BaseRecord'
3
+ import { RecordsDiff } from './RecordsDiff'
4
+ import { createRecordType } from './RecordType'
5
+ import { createComputedCache, Store } from './Store'
6
+ import { StoreSchema } from './StoreSchema'
7
+
8
+ // Test record types
9
+ interface Book extends BaseRecord<'book', RecordId<Book>> {
10
+ title: string
11
+ author: RecordId<Author>
12
+ numPages: number
13
+ genre?: string
14
+ inStock?: boolean
15
+ }
16
+
17
+ const Book = createRecordType<Book>('book', {
18
+ validator: {
19
+ validate: (record: unknown): Book => {
20
+ if (!record || typeof record !== 'object') {
21
+ throw new Error('Book record must be an object')
22
+ }
23
+ const r = record as any
24
+ if (typeof r.id !== 'string' || !r.id.startsWith('book:')) {
25
+ throw new Error('Book must have valid id starting with "book:"')
26
+ }
27
+ if (r.typeName !== 'book') {
28
+ throw new Error('Book typeName must be "book"')
29
+ }
30
+ if (typeof r.title !== 'string' || r.title.trim().length === 0) {
31
+ throw new Error('Book must have non-empty title string')
32
+ }
33
+ if (typeof r.author !== 'string' || !r.author.startsWith('author:')) {
34
+ throw new Error('Book must have valid author RecordId')
35
+ }
36
+ if (typeof r.numPages !== 'number' || r.numPages < 1) {
37
+ throw new Error('Book numPages must be positive number')
38
+ }
39
+ if (r.genre !== undefined && typeof r.genre !== 'string') {
40
+ throw new Error('Book genre must be string if provided')
41
+ }
42
+ if (r.inStock !== undefined && typeof r.inStock !== 'boolean') {
43
+ throw new Error('Book inStock must be boolean if provided')
44
+ }
45
+ return r as Book
46
+ },
47
+ },
48
+ scope: 'document',
49
+ }).withDefaultProperties(() => ({
50
+ inStock: true,
51
+ numPages: 100,
52
+ }))
53
+
54
+ interface Author extends BaseRecord<'author', RecordId<Author>> {
55
+ name: string
56
+ isPseudonym: boolean
57
+ birthYear?: number
58
+ }
59
+
60
+ const Author = createRecordType<Author>('author', {
61
+ validator: {
62
+ validate: (record: unknown): Author => {
63
+ if (!record || typeof record !== 'object') {
64
+ throw new Error('Author record must be an object')
65
+ }
66
+ const r = record as any
67
+ if (typeof r.id !== 'string' || !r.id.startsWith('author:')) {
68
+ throw new Error('Author must have valid id starting with "author:"')
69
+ }
70
+ if (r.typeName !== 'author') {
71
+ throw new Error('Author typeName must be "author"')
72
+ }
73
+ if (typeof r.name !== 'string' || r.name.trim().length === 0) {
74
+ throw new Error('Author must have non-empty name string')
75
+ }
76
+ if (typeof r.isPseudonym !== 'boolean') {
77
+ throw new Error('Author isPseudonym must be boolean')
78
+ }
79
+ if (
80
+ r.birthYear !== undefined &&
81
+ (typeof r.birthYear !== 'number' || r.birthYear < 1000 || r.birthYear > 2100)
82
+ ) {
83
+ throw new Error('Author birthYear must be reasonable year number if provided')
84
+ }
85
+ return r as Author
86
+ },
87
+ },
88
+ scope: 'document',
89
+ }).withDefaultProperties(() => ({
90
+ isPseudonym: false,
91
+ }))
92
+
93
+ interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
94
+ visitorName: string
95
+ booksInBasket: RecordId<Book>[]
96
+ timestamp: number
97
+ }
98
+
99
+ const Visit = createRecordType<Visit>('visit', {
100
+ validator: {
101
+ validate: (record: unknown): Visit => {
102
+ if (!record || typeof record !== 'object') {
103
+ throw new Error('Visit record must be an object')
104
+ }
105
+ const r = record as any
106
+ if (typeof r.id !== 'string' || !r.id.startsWith('visit:')) {
107
+ throw new Error('Visit must have valid id starting with "visit:"')
108
+ }
109
+ if (r.typeName !== 'visit') {
110
+ throw new Error('Visit typeName must be "visit"')
111
+ }
112
+ if (typeof r.visitorName !== 'string' || r.visitorName.trim().length === 0) {
113
+ throw new Error('Visit must have non-empty visitorName string')
114
+ }
115
+ if (!Array.isArray(r.booksInBasket)) {
116
+ throw new Error('Visit booksInBasket must be an array')
117
+ }
118
+ for (const bookId of r.booksInBasket) {
119
+ if (typeof bookId !== 'string' || !bookId.startsWith('book:')) {
120
+ throw new Error('Visit booksInBasket must contain valid book RecordIds')
121
+ }
122
+ }
123
+ if (typeof r.timestamp !== 'number' || r.timestamp < 0) {
124
+ throw new Error('Visit timestamp must be non-negative number')
125
+ }
126
+ return r as Visit
127
+ },
128
+ },
129
+ scope: 'session',
130
+ }).withDefaultProperties(() => ({
131
+ visitorName: 'Anonymous',
132
+ booksInBasket: [],
133
+ timestamp: Date.now(),
134
+ }))
135
+
136
+ interface Cursor extends BaseRecord<'cursor', RecordId<Cursor>> {
137
+ x: number
138
+ y: number
139
+ userId: string
140
+ }
141
+
142
+ const Cursor = createRecordType<Cursor>('cursor', {
143
+ validator: {
144
+ validate: (record: unknown): Cursor => {
145
+ if (!record || typeof record !== 'object') {
146
+ throw new Error('Cursor record must be an object')
147
+ }
148
+ const r = record as any
149
+ if (typeof r.id !== 'string' || !r.id.startsWith('cursor:')) {
150
+ throw new Error('Cursor must have valid id starting with "cursor:"')
151
+ }
152
+ if (r.typeName !== 'cursor') {
153
+ throw new Error('Cursor typeName must be "cursor"')
154
+ }
155
+ if (typeof r.x !== 'number' || !isFinite(r.x)) {
156
+ throw new Error('Cursor x must be finite number')
157
+ }
158
+ if (typeof r.y !== 'number' || !isFinite(r.y)) {
159
+ throw new Error('Cursor y must be finite number')
160
+ }
161
+ if (typeof r.userId !== 'string' || r.userId.trim().length === 0) {
162
+ throw new Error('Cursor must have non-empty userId string')
163
+ }
164
+ return r as Cursor
165
+ },
166
+ },
167
+ scope: 'presence',
168
+ })
169
+
170
+ type LibraryType = Book | Author | Visit | Cursor
171
+
172
+ describe('Store', () => {
173
+ let store: Store<LibraryType>
174
+
175
+ beforeEach(() => {
176
+ store = new Store({
177
+ props: {},
178
+ schema: StoreSchema.create<LibraryType>({
179
+ book: Book,
180
+ author: Author,
181
+ visit: Visit,
182
+ cursor: Cursor,
183
+ }),
184
+ })
185
+ })
186
+
187
+ afterEach(() => {
188
+ store.dispose()
189
+ })
190
+
191
+ describe('basic record operations', () => {
192
+ it('puts records into the store', () => {
193
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
194
+ const book = Book.create({
195
+ title: 'The Hobbit',
196
+ author: author.id,
197
+ numPages: 310,
198
+ })
199
+
200
+ store.put([author, book])
201
+
202
+ expect(store.get(author.id)).toEqual(author)
203
+ expect(store.get(book.id)).toEqual(book)
204
+ })
205
+
206
+ it('updates existing records', () => {
207
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
208
+ store.put([author])
209
+
210
+ const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' }
211
+ store.put([updatedAuthor])
212
+
213
+ expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
214
+ })
215
+
216
+ it('removes records from the store', () => {
217
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
218
+ const book = Book.create({
219
+ title: 'The Hobbit',
220
+ author: author.id,
221
+ numPages: 310,
222
+ })
223
+
224
+ store.put([author, book])
225
+ expect(store.has(author.id)).toBe(true)
226
+ expect(store.has(book.id)).toBe(true)
227
+
228
+ store.remove([author.id, book.id])
229
+ expect(store.has(author.id)).toBe(false)
230
+ expect(store.has(book.id)).toBe(false)
231
+ })
232
+
233
+ it('updates records using update method', () => {
234
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
235
+ store.put([author])
236
+
237
+ store.update(author.id, (current) => ({
238
+ ...current,
239
+ name: 'John Ronald Reuel Tolkien',
240
+ }))
241
+
242
+ expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
243
+ })
244
+
245
+ it('clears all records', () => {
246
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
247
+ const book = Book.create({
248
+ title: 'The Hobbit',
249
+ author: author.id,
250
+ numPages: 310,
251
+ })
252
+
253
+ store.put([author, book])
254
+ expect(store.allRecords()).toHaveLength(2)
255
+
256
+ store.clear()
257
+ expect(store.allRecords()).toHaveLength(0)
258
+ })
259
+ })
260
+
261
+ describe('atomic operations', () => {
262
+ it('performs atomic operations', () => {
263
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
264
+ const book = Book.create({
265
+ title: 'The Hobbit',
266
+ author: author.id,
267
+ numPages: 310,
268
+ })
269
+
270
+ const result = store.atomic(() => {
271
+ store.put([author])
272
+ store.put([book])
273
+ return 'completed'
274
+ })
275
+
276
+ expect(result).toBe('completed')
277
+ expect(store.get(author.id)).toEqual(author)
278
+ expect(store.get(book.id)).toEqual(book)
279
+ })
280
+
281
+ it('handles nested atomic operations', () => {
282
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
283
+ const book = Book.create({
284
+ title: 'The Hobbit',
285
+ author: author.id,
286
+ numPages: 310,
287
+ })
288
+
289
+ store.atomic(() => {
290
+ store.put([author])
291
+ store.atomic(() => {
292
+ store.put([book])
293
+ })
294
+ })
295
+
296
+ expect(store.get(author.id)).toEqual(author)
297
+ expect(store.get(book.id)).toEqual(book)
298
+ })
299
+ })
300
+
301
+ describe('history tracking', () => {
302
+ it('extracts changes from operations', () => {
303
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
304
+ const book = Book.create({
305
+ title: 'The Hobbit',
306
+ author: author.id,
307
+ numPages: 310,
308
+ })
309
+
310
+ const changes = store.extractingChanges(() => {
311
+ store.put([author, book])
312
+ })
313
+
314
+ expect(changes.added).toHaveProperty(author.id)
315
+ expect(changes.added).toHaveProperty(book.id)
316
+ expect(Object.keys(changes.updated)).toHaveLength(0)
317
+ expect(Object.keys(changes.removed)).toHaveLength(0)
318
+ })
319
+
320
+ it('extracts update changes', () => {
321
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
322
+ store.put([author])
323
+
324
+ const changes = store.extractingChanges(() => {
325
+ store.update(author.id, (a) => ({ ...a, name: 'John Ronald Reuel Tolkien' }))
326
+ })
327
+
328
+ expect(Object.keys(changes.added)).toHaveLength(0)
329
+ expect(changes.updated).toHaveProperty(author.id)
330
+ expect(Object.keys(changes.removed)).toHaveLength(0)
331
+ })
332
+
333
+ it('extracts removal changes', () => {
334
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
335
+ store.put([author])
336
+
337
+ const changes = store.extractingChanges(() => {
338
+ store.remove([author.id])
339
+ })
340
+
341
+ expect(Object.keys(changes.added)).toHaveLength(0)
342
+ expect(Object.keys(changes.updated)).toHaveLength(0)
343
+ expect(changes.removed).toHaveProperty(author.id)
344
+ })
345
+ })
346
+
347
+ describe('listeners', () => {
348
+ it('adds and removes listeners', async () => {
349
+ const listener = vi.fn()
350
+ const removeListener = store.listen(listener)
351
+
352
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
353
+ store.put([author])
354
+
355
+ // Wait for async history flush
356
+ await new Promise((resolve) => requestAnimationFrame(resolve))
357
+
358
+ expect(listener).toHaveBeenCalledTimes(1)
359
+ expect(listener).toHaveBeenCalledWith(
360
+ expect.objectContaining({
361
+ source: 'user',
362
+ changes: expect.objectContaining({
363
+ added: expect.objectContaining({
364
+ [author.id]: author,
365
+ }),
366
+ }),
367
+ })
368
+ )
369
+
370
+ removeListener()
371
+
372
+ const book = Book.create({
373
+ title: 'The Hobbit',
374
+ author: author.id,
375
+ numPages: 310,
376
+ })
377
+ store.put([book])
378
+
379
+ // Wait for async history flush
380
+ await new Promise((resolve) => requestAnimationFrame(resolve))
381
+
382
+ // Should still be called only once
383
+ expect(listener).toHaveBeenCalledTimes(1)
384
+ })
385
+
386
+ it('filters listeners by source', async () => {
387
+ const userListener = vi.fn()
388
+ const remoteListener = vi.fn()
389
+
390
+ store.listen(userListener, { source: 'user', scope: 'all' })
391
+ store.listen(remoteListener, { source: 'remote', scope: 'all' })
392
+
393
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
394
+
395
+ // User change
396
+ store.put([author])
397
+ await new Promise((resolve) => requestAnimationFrame(resolve))
398
+
399
+ expect(userListener).toHaveBeenCalledTimes(1)
400
+ expect(remoteListener).not.toHaveBeenCalled()
401
+
402
+ // Remote change
403
+ store.mergeRemoteChanges(() => {
404
+ const book = Book.create({
405
+ title: 'The Hobbit',
406
+ author: author.id,
407
+ numPages: 310,
408
+ })
409
+ store.put([book])
410
+ })
411
+ await new Promise((resolve) => requestAnimationFrame(resolve))
412
+
413
+ expect(userListener).toHaveBeenCalledTimes(1)
414
+ expect(remoteListener).toHaveBeenCalledTimes(1)
415
+ })
416
+
417
+ it('filters listeners by scope', async () => {
418
+ const documentListener = vi.fn()
419
+ const sessionListener = vi.fn()
420
+ const presenceListener = vi.fn()
421
+
422
+ store.listen(documentListener, { source: 'all', scope: 'document' })
423
+ store.listen(sessionListener, { source: 'all', scope: 'session' })
424
+ store.listen(presenceListener, { source: 'all', scope: 'presence' })
425
+
426
+ const author = Author.create({ name: 'J.R.R. Tolkien' }) // document scope
427
+ const visit = Visit.create({ visitorName: 'John Doe' }) // session scope
428
+ const cursor = Cursor.create({ x: 100, y: 200, userId: 'user1' }) // presence scope
429
+
430
+ store.put([author, visit, cursor])
431
+ await new Promise((resolve) => requestAnimationFrame(resolve))
432
+
433
+ expect(documentListener).toHaveBeenCalledTimes(1)
434
+ expect(sessionListener).toHaveBeenCalledTimes(1)
435
+ expect(presenceListener).toHaveBeenCalledTimes(1)
436
+
437
+ // Check that each listener only received records from their scope
438
+ expect(documentListener.mock.calls[0][0].changes.added).toHaveProperty(author.id)
439
+ expect(documentListener.mock.calls[0][0].changes.added).not.toHaveProperty(visit.id)
440
+ expect(documentListener.mock.calls[0][0].changes.added).not.toHaveProperty(cursor.id)
441
+
442
+ expect(sessionListener.mock.calls[0][0].changes.added).not.toHaveProperty(author.id)
443
+ expect(sessionListener.mock.calls[0][0].changes.added).toHaveProperty(visit.id)
444
+ expect(sessionListener.mock.calls[0][0].changes.added).not.toHaveProperty(cursor.id)
445
+
446
+ expect(presenceListener.mock.calls[0][0].changes.added).not.toHaveProperty(author.id)
447
+ expect(presenceListener.mock.calls[0][0].changes.added).not.toHaveProperty(visit.id)
448
+ expect(presenceListener.mock.calls[0][0].changes.added).toHaveProperty(cursor.id)
449
+ })
450
+
451
+ it('flushes history before adding listeners', async () => {
452
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
453
+ store.put([author])
454
+
455
+ // Add listener after changes
456
+ const listener = vi.fn()
457
+ store.listen(listener)
458
+
459
+ // Should not receive historical changes
460
+ await new Promise((resolve) => requestAnimationFrame(resolve))
461
+ expect(listener).not.toHaveBeenCalled()
462
+
463
+ // Should receive new changes
464
+ const book = Book.create({
465
+ title: 'The Hobbit',
466
+ author: author.id,
467
+ numPages: 310,
468
+ })
469
+ store.put([book])
470
+
471
+ await new Promise((resolve) => requestAnimationFrame(resolve))
472
+ expect(listener).toHaveBeenCalledTimes(1)
473
+ })
474
+ })
475
+
476
+ describe('remote changes', () => {
477
+ it('merges remote changes with correct source', async () => {
478
+ const listener = vi.fn()
479
+ store.listen(listener)
480
+
481
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
482
+
483
+ store.mergeRemoteChanges(() => {
484
+ store.put([author])
485
+ })
486
+
487
+ await new Promise((resolve) => requestAnimationFrame(resolve))
488
+
489
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({ source: 'remote' }))
490
+ })
491
+
492
+ it('ensures store is usable after remote changes', () => {
493
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
494
+
495
+ store.mergeRemoteChanges(() => {
496
+ store.put([author])
497
+ })
498
+
499
+ expect(store.get(author.id)).toEqual(author)
500
+ })
501
+
502
+ it('throws error when merging remote changes during atomic operation', () => {
503
+ expect(() => {
504
+ store.atomic(() => {
505
+ store.mergeRemoteChanges(() => {
506
+ // This should throw
507
+ })
508
+ })
509
+ }).toThrow('Cannot merge remote changes while in atomic operation')
510
+ })
511
+ })
512
+
513
+ describe('serialization and snapshots', () => {
514
+ beforeEach(() => {
515
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
516
+ const book = Book.create({
517
+ title: 'The Hobbit',
518
+ author: author.id,
519
+ numPages: 310,
520
+ })
521
+ const visit = Visit.create({ visitorName: 'John Doe' })
522
+ const cursor = Cursor.create({ x: 100, y: 200, userId: 'user1' })
523
+
524
+ store.put([author, book, visit, cursor])
525
+ })
526
+
527
+ it('serializes store with document scope by default', () => {
528
+ const serialized = store.serialize()
529
+ const records = Object.values(serialized)
530
+
531
+ // Should include document records only
532
+ expect(records.some((r) => r.typeName === 'author')).toBe(true)
533
+ expect(records.some((r) => r.typeName === 'book')).toBe(true)
534
+ expect(records.some((r) => r.typeName === 'visit')).toBe(false)
535
+ expect(records.some((r) => r.typeName === 'cursor')).toBe(false)
536
+ })
537
+
538
+ it('serializes store with specific scope', () => {
539
+ const sessionSerialized = store.serialize('session')
540
+ const sessionRecords = Object.values(sessionSerialized)
541
+
542
+ expect(sessionRecords.some((r) => r.typeName === 'visit')).toBe(true)
543
+ expect(sessionRecords.some((r) => r.typeName === 'author')).toBe(false)
544
+ })
545
+
546
+ it('serializes store with all scopes', () => {
547
+ const allSerialized = store.serialize('all')
548
+ const allRecords = Object.values(allSerialized)
549
+
550
+ expect(allRecords.some((r) => r.typeName === 'author')).toBe(true)
551
+ expect(allRecords.some((r) => r.typeName === 'book')).toBe(true)
552
+ expect(allRecords.some((r) => r.typeName === 'visit')).toBe(true)
553
+ expect(allRecords.some((r) => r.typeName === 'cursor')).toBe(true)
554
+ })
555
+
556
+ it('creates and loads store snapshots', () => {
557
+ const snapshot = store.getStoreSnapshot()
558
+
559
+ expect(snapshot).toHaveProperty('store')
560
+ expect(snapshot).toHaveProperty('schema')
561
+
562
+ const newStore = new Store({
563
+ props: {},
564
+ schema: StoreSchema.create<LibraryType>({
565
+ book: Book,
566
+ author: Author,
567
+ visit: Visit,
568
+ cursor: Cursor,
569
+ }),
570
+ })
571
+
572
+ newStore.loadStoreSnapshot(snapshot)
573
+
574
+ // Should have same document records (default scope)
575
+ const originalDocumentRecords = store.serialize('document')
576
+ const newDocumentRecords = newStore.serialize('document')
577
+
578
+ expect(newDocumentRecords).toEqual(originalDocumentRecords)
579
+ newStore.dispose()
580
+ })
581
+
582
+ it('migrates snapshots', () => {
583
+ const snapshot = store.getStoreSnapshot()
584
+ const migratedSnapshot = store.migrateSnapshot(snapshot)
585
+
586
+ expect(migratedSnapshot).toEqual(snapshot) // No migrations needed
587
+ })
588
+
589
+ it('throws error on migration failure', () => {
590
+ const invalidSnapshot = {
591
+ store: {},
592
+ schema: { version: -1 }, // Invalid version
593
+ } as any
594
+
595
+ expect(() => store.migrateSnapshot(invalidSnapshot)).toThrow()
596
+ })
597
+ })
598
+
599
+ describe('computed caches', () => {
600
+ it('creates and uses computed cache', () => {
601
+ const computeExpensiveData = vi.fn((book: Book) => `expensive-${book.title}`)
602
+
603
+ const cache = store.createComputedCache('expensiveBook', computeExpensiveData)
604
+
605
+ const book = Book.create({
606
+ title: 'The Hobbit',
607
+ author: Author.createId('tolkien'),
608
+ numPages: 310,
609
+ })
610
+ store.put([book])
611
+
612
+ const result1 = cache.get(book.id)
613
+ expect(result1).toBe('expensive-The Hobbit')
614
+ expect(computeExpensiveData).toHaveBeenCalledTimes(1)
615
+
616
+ // Should use cached result
617
+ const result2 = cache.get(book.id)
618
+ expect(result2).toBe('expensive-The Hobbit')
619
+ expect(computeExpensiveData).toHaveBeenCalledTimes(1)
620
+ })
621
+
622
+ it('invalidates cache when record changes', () => {
623
+ const computeExpensiveData = vi.fn((book: Book) => `expensive-${book.title}`)
624
+ const cache = store.createComputedCache('expensiveBook', computeExpensiveData)
625
+
626
+ const book = Book.create({
627
+ title: 'The Hobbit',
628
+ author: Author.createId('tolkien'),
629
+ numPages: 310,
630
+ })
631
+ store.put([book])
632
+
633
+ cache.get(book.id)
634
+ expect(computeExpensiveData).toHaveBeenCalledTimes(1)
635
+
636
+ // Update the book
637
+ store.update(book.id, (b) => ({ ...b, title: 'The Hobbit: Updated' }))
638
+
639
+ // Should recompute
640
+ const result = cache.get(book.id)
641
+ expect(result).toBe('expensive-The Hobbit: Updated')
642
+ expect(computeExpensiveData).toHaveBeenCalledTimes(2)
643
+ })
644
+ })
645
+
646
+ describe('standalone createComputedCache', () => {
647
+ it('works with store objects', () => {
648
+ const derive = vi.fn(
649
+ (context: { store: Store<LibraryType> }, book: Book) => `${book.title}-${context.store.id}`
650
+ )
651
+
652
+ const cache = createComputedCache('standalone', derive)
653
+
654
+ const book = Book.create({
655
+ title: 'The Hobbit',
656
+ author: Author.createId('tolkien'),
657
+ numPages: 310,
658
+ })
659
+ store.put([book])
660
+
661
+ const result = cache.get({ store }, book.id)
662
+ expect(result).toBe(`The Hobbit-${store.id}`)
663
+ expect(derive).toHaveBeenCalledTimes(1)
664
+ })
665
+
666
+ it('works directly with store instances', () => {
667
+ const derive = vi.fn((store: Store<LibraryType>, book: Book) => `${book.title}-${store.id}`)
668
+
669
+ const cache = createComputedCache('standalone', derive)
670
+
671
+ const book = Book.create({
672
+ title: 'The Hobbit',
673
+ author: Author.createId('tolkien'),
674
+ numPages: 310,
675
+ })
676
+ store.put([book])
677
+
678
+ const result = cache.get(store, book.id)
679
+ expect(result).toBe(`The Hobbit-${store.id}`)
680
+ expect(derive).toHaveBeenCalledTimes(1)
681
+ })
682
+ })
683
+
684
+ describe('diff application', () => {
685
+ it('applies diffs to the store', () => {
686
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
687
+ const book = Book.create({
688
+ title: 'The Hobbit',
689
+ author: author.id,
690
+ numPages: 310,
691
+ })
692
+
693
+ const diff: RecordsDiff<LibraryType> = {
694
+ added: {
695
+ [author.id]: author,
696
+ [book.id]: book,
697
+ },
698
+ updated: {},
699
+ removed: {},
700
+ }
701
+
702
+ store.applyDiff(diff)
703
+
704
+ expect(store.get(author.id)).toEqual(author)
705
+ expect(store.get(book.id)).toEqual(book)
706
+ })
707
+
708
+ it('applies diffs with updates', () => {
709
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
710
+ store.put([author])
711
+
712
+ const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' }
713
+ const diff: RecordsDiff<LibraryType> = {
714
+ added: {},
715
+ updated: {
716
+ [author.id]: [author, updatedAuthor],
717
+ },
718
+ removed: {},
719
+ }
720
+
721
+ store.applyDiff(diff)
722
+
723
+ expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
724
+ })
725
+
726
+ it('applies diffs with removals', () => {
727
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
728
+ store.put([author])
729
+
730
+ const diff: RecordsDiff<LibraryType> = {
731
+ added: {},
732
+ updated: {},
733
+ removed: {
734
+ [author.id]: author,
735
+ },
736
+ }
737
+
738
+ store.applyDiff(diff)
739
+
740
+ expect(store.has(author.id)).toBe(false)
741
+ })
742
+ })
743
+
744
+ describe('validation', () => {
745
+ it('validates records during operations', () => {
746
+ expect(() => {
747
+ store.put([
748
+ {
749
+ id: Book.createId('test1'),
750
+ typeName: 'book',
751
+ title: '', // Invalid: empty title
752
+ author: Author.createId('tolkien'),
753
+ numPages: 100,
754
+ inStock: true,
755
+ } as any,
756
+ ])
757
+ }).toThrow('Book must have non-empty title string')
758
+ })
759
+ })
760
+
761
+ describe('side effects integration', () => {
762
+ it('integrates with side effects for put operations', () => {
763
+ const beforeCreate = vi.fn((record: Author) => record)
764
+ const afterCreate = vi.fn()
765
+
766
+ store.sideEffects.registerBeforeCreateHandler('author', beforeCreate)
767
+ store.sideEffects.registerAfterCreateHandler('author', afterCreate)
768
+
769
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
770
+ store.put([author])
771
+
772
+ expect(beforeCreate).toHaveBeenCalledWith(author, 'user')
773
+ expect(afterCreate).toHaveBeenCalledWith(author, 'user')
774
+ })
775
+
776
+ it('integrates with side effects for update operations', () => {
777
+ const beforeChange = vi.fn((prev: Author, next: Author) => next)
778
+ const afterChange = vi.fn()
779
+
780
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
781
+ store.put([author])
782
+
783
+ store.sideEffects.registerBeforeChangeHandler('author', beforeChange)
784
+ store.sideEffects.registerAfterChangeHandler('author', afterChange)
785
+
786
+ const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' }
787
+ store.put([updatedAuthor])
788
+
789
+ expect(beforeChange).toHaveBeenCalledWith(author, updatedAuthor, 'user')
790
+ expect(afterChange).toHaveBeenCalledWith(author, updatedAuthor, 'user')
791
+ })
792
+
793
+ it('integrates with side effects for remove operations', () => {
794
+ const beforeDelete = vi.fn()
795
+ const afterDelete = vi.fn()
796
+
797
+ const author = Author.create({ name: 'J.R.R. Tolkien' })
798
+ store.put([author])
799
+
800
+ store.sideEffects.registerBeforeDeleteHandler('author', beforeDelete)
801
+ store.sideEffects.registerAfterDeleteHandler('author', afterDelete)
802
+
803
+ store.remove([author.id])
804
+
805
+ expect(beforeDelete).toHaveBeenCalledWith(author, 'user')
806
+ expect(afterDelete).toHaveBeenCalledWith(author, 'user')
807
+ })
808
+
809
+ it('can prevent deletion with beforeDelete handler', () => {
810
+ const beforeDelete = vi.fn().mockReturnValue(false)
811
+ const afterDelete = vi.fn()
812
+
813
+ store.sideEffects.registerBeforeDeleteHandler('author', beforeDelete)
814
+ store.sideEffects.registerAfterDeleteHandler('author', afterDelete)
815
+
816
+ const author = Author.create({ name: 'Protected Author' })
817
+ store.put([author])
818
+
819
+ // Try to delete - should be prevented
820
+ store.remove([author.id])
821
+
822
+ expect(beforeDelete).toHaveBeenCalledWith(author, 'user')
823
+ expect(afterDelete).not.toHaveBeenCalled()
824
+ expect(store.has(author.id)).toBe(true) // Should still exist
825
+ })
826
+ })
827
+ })