@tldraw/store 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b73a0d46b63f

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,627 @@
1
+ import { atom, computed, RESET_VALUE } from '@tldraw/state'
2
+ import { beforeEach, describe, expect, it } from 'vitest'
3
+ import { BaseRecord, RecordId } from './BaseRecord'
4
+ import { createRecordType } from './RecordType'
5
+ import { Store } from './Store'
6
+ import { StoreQueries } from './StoreQueries'
7
+ import { StoreSchema } from './StoreSchema'
8
+
9
+ // Test record types
10
+ interface Author extends BaseRecord<'author', RecordId<Author>> {
11
+ name: string
12
+ age: number
13
+ isActive: boolean
14
+ publishedBooks: number
15
+ }
16
+
17
+ interface Book extends BaseRecord<'book', RecordId<Book>> {
18
+ title: string
19
+ authorId: RecordId<Author>
20
+ publishedYear: number
21
+ inStock: boolean
22
+ rating: number
23
+ category: string
24
+ }
25
+
26
+ const Author = createRecordType<Author>('author', {
27
+ validator: {
28
+ validate(value) {
29
+ const author = value as Author
30
+ if (author.typeName !== 'author') throw Error('Invalid typeName')
31
+ if (!author.id.startsWith('author:')) throw Error('Invalid id')
32
+ if (!Number.isFinite(author.age)) throw Error('Invalid age')
33
+ if (author.age < 0) throw Error('Negative age')
34
+ if (typeof author.isActive !== 'boolean') throw Error('Invalid isActive')
35
+ return author
36
+ },
37
+ },
38
+ scope: 'document',
39
+ }).withDefaultProperties(() => ({ age: 25, isActive: true, publishedBooks: 0 }))
40
+
41
+ const Book = createRecordType<Book>('book', {
42
+ validator: {
43
+ validate(value) {
44
+ const book = value as Book
45
+ if (!book.id.startsWith('book:')) throw Error('Invalid book id')
46
+ if (book.typeName !== 'book') throw Error('Invalid book typeName')
47
+ if (typeof book.title !== 'string') throw Error('Invalid title')
48
+ if (!book.authorId.startsWith('author')) throw Error('Invalid authorId')
49
+ if (!Number.isFinite(book.publishedYear)) throw Error('Invalid publishedYear')
50
+ if (typeof book.inStock !== 'boolean') throw Error('Invalid inStock')
51
+ return book
52
+ },
53
+ },
54
+ scope: 'document',
55
+ }).withDefaultProperties(() => ({ rating: 0, category: 'fiction' }))
56
+
57
+ // Test data
58
+ const authors = {
59
+ asimov: Author.create({ name: 'Isaac Asimov', age: 72, publishedBooks: 200 }),
60
+ gibson: Author.create({ name: 'William Gibson', age: 75, publishedBooks: 15 }),
61
+ herbert: Author.create({ name: 'Frank Herbert', age: 65, publishedBooks: 30 }),
62
+ bradbury: Author.create({ name: 'Ray Bradbury', age: 91, publishedBooks: 100, isActive: false }),
63
+ clarke: Author.create({ name: 'Arthur C. Clarke', age: 90, publishedBooks: 80 }),
64
+ }
65
+
66
+ const books = {
67
+ foundation: Book.create({
68
+ title: 'Foundation',
69
+ authorId: authors.asimov.id,
70
+ publishedYear: 1951,
71
+ inStock: true,
72
+ rating: 5,
73
+ category: 'sci-fi',
74
+ }),
75
+ neuromancer: Book.create({
76
+ title: 'Neuromancer',
77
+ authorId: authors.gibson.id,
78
+ publishedYear: 1984,
79
+ inStock: true,
80
+ rating: 5,
81
+ category: 'cyberpunk',
82
+ }),
83
+ dune: Book.create({
84
+ title: 'Dune',
85
+ authorId: authors.herbert.id,
86
+ publishedYear: 1965,
87
+ inStock: false,
88
+ rating: 5,
89
+ category: 'sci-fi',
90
+ }),
91
+ fahrenheit451: Book.create({
92
+ title: 'Fahrenheit 451',
93
+ authorId: authors.bradbury.id,
94
+ publishedYear: 1953,
95
+ inStock: true,
96
+ rating: 4,
97
+ category: 'dystopian',
98
+ }),
99
+ childhood: Book.create({
100
+ title: "Childhood's End",
101
+ authorId: authors.clarke.id,
102
+ publishedYear: 1953,
103
+ inStock: true,
104
+ rating: 4,
105
+ category: 'sci-fi',
106
+ }),
107
+ robotSeries: Book.create({
108
+ title: 'I, Robot',
109
+ authorId: authors.asimov.id,
110
+ publishedYear: 1950,
111
+ inStock: false,
112
+ rating: 4,
113
+ category: 'sci-fi',
114
+ }),
115
+ }
116
+
117
+ let store: Store<Author | Book>
118
+ let queries: StoreQueries<Author | Book>
119
+
120
+ beforeEach(() => {
121
+ store = new Store({
122
+ props: {},
123
+ schema: StoreSchema.create<Author | Book>({
124
+ author: Author,
125
+ book: Book,
126
+ }),
127
+ })
128
+ queries = store.query
129
+
130
+ // Populate store with test data
131
+ store.put([...Object.values(authors), ...Object.values(books)])
132
+ })
133
+
134
+ describe('filterHistory method', () => {
135
+ it('should filter changes by type correctly', () => {
136
+ const authorHistory = queries.filterHistory('author')
137
+ const initialEpoch = authorHistory.get()
138
+
139
+ // Add a new author
140
+ const newAuthor = Author.create({ name: 'New Author', age: 40 })
141
+ store.put([newAuthor])
142
+
143
+ const afterAddEpoch = authorHistory.get()
144
+ expect(afterAddEpoch).toBeGreaterThan(initialEpoch)
145
+
146
+ // Add a book (should not affect author history)
147
+ const newBook = Book.create({
148
+ title: 'New Book',
149
+ authorId: newAuthor.id,
150
+ publishedYear: 2023,
151
+ inStock: true,
152
+ rating: 3,
153
+ })
154
+ const beforeBookAdd = authorHistory.lastChangedEpoch
155
+ store.put([newBook])
156
+
157
+ // Author history should not change when book is added
158
+ expect(authorHistory.lastChangedEpoch).toBe(beforeBookAdd)
159
+ })
160
+
161
+ it('should handle record updates in filtered history', () => {
162
+ const authorHistory = queries.filterHistory('author')
163
+ authorHistory.get()
164
+
165
+ const lastChangedEpoch = authorHistory.lastChangedEpoch
166
+
167
+ // Update an author
168
+ store.put([{ ...authors.asimov, age: 73 }])
169
+
170
+ // Access the computed to trigger update
171
+ authorHistory.get()
172
+
173
+ expect(lastChangedEpoch).toBeLessThan(authorHistory.lastChangedEpoch)
174
+
175
+ const diff = authorHistory.getDiffSince(lastChangedEpoch)
176
+ expect(diff).not.toBe(RESET_VALUE)
177
+ if (diff !== RESET_VALUE) {
178
+ expect(diff).toHaveLength(1)
179
+ expect(diff[0].updated).toHaveProperty(authors.asimov.id)
180
+ }
181
+ })
182
+
183
+ it('should handle record removals in filtered history', () => {
184
+ const authorHistory = queries.filterHistory('author')
185
+ authorHistory.get()
186
+
187
+ const lastChangedEpoch = authorHistory.lastChangedEpoch
188
+
189
+ // Remove an author
190
+ store.remove([authors.bradbury.id])
191
+
192
+ // Access the computed to trigger update
193
+ authorHistory.get()
194
+
195
+ expect(lastChangedEpoch).toBeLessThan(authorHistory.lastChangedEpoch)
196
+
197
+ const diff = authorHistory.getDiffSince(lastChangedEpoch)
198
+ expect(diff).not.toBe(RESET_VALUE)
199
+ if (diff !== RESET_VALUE) {
200
+ expect(diff).toHaveLength(1)
201
+ expect(diff[0].removed).toHaveProperty(authors.bradbury.id)
202
+ }
203
+ })
204
+
205
+ it('should collapse add and remove operations in same batch', () => {
206
+ const authorHistory = queries.filterHistory('author')
207
+ authorHistory.get()
208
+
209
+ const lastEpoch = authorHistory.lastChangedEpoch
210
+ const tempAuthor = Author.create({ name: 'Temp Author' })
211
+
212
+ // Add and then remove in same batch
213
+ store.put([tempAuthor])
214
+ store.remove([tempAuthor.id])
215
+
216
+ // Should not change history if add and remove cancel out
217
+ const diff = authorHistory.getDiffSince(lastEpoch)
218
+ expect(diff).toEqual([])
219
+ })
220
+
221
+ it('should handle complex diff sequences', () => {
222
+ const authorHistory = queries.filterHistory('author')
223
+ authorHistory.get()
224
+
225
+ const lastEpoch = authorHistory.lastChangedEpoch
226
+
227
+ // Multiple operations
228
+ const tempAuthor1 = Author.create({ name: 'Temp 1' })
229
+ const tempAuthor2 = Author.create({ name: 'Temp 2' })
230
+
231
+ store.put([tempAuthor1])
232
+ store.put([tempAuthor2])
233
+ store.put([{ ...tempAuthor1, age: 50 }]) // Update
234
+ store.remove([tempAuthor2.id]) // Remove
235
+
236
+ const diff = authorHistory.getDiffSince(lastEpoch)
237
+ expect(diff).not.toBe(RESET_VALUE)
238
+ if (diff !== RESET_VALUE) {
239
+ expect(diff).toHaveLength(1)
240
+ // Should have tempAuthor1 as added (with updated age)
241
+ expect(diff[0].added).toHaveProperty(tempAuthor1.id)
242
+ // Should not have tempAuthor2 since it was added then removed
243
+ expect(diff[0].added).not.toHaveProperty(tempAuthor2.id)
244
+ expect(diff[0].removed).not.toHaveProperty(tempAuthor2.id)
245
+ }
246
+ })
247
+ })
248
+
249
+ describe('index method', () => {
250
+ it('should create correct index mappings', () => {
251
+ const nameIndex = queries.index('author', 'name')
252
+ const index = nameIndex.get()
253
+
254
+ expect(index.get('Isaac Asimov')).toEqual(new Set([authors.asimov.id]))
255
+ expect(index.get('William Gibson')).toEqual(new Set([authors.gibson.id]))
256
+ expect(index.get('Nonexistent')).toBeUndefined()
257
+ })
258
+
259
+ it('should update indexes when records are added', () => {
260
+ const nameIndex = queries.index('author', 'name')
261
+ const _initialIndex = nameIndex.get()
262
+
263
+ const newAuthor = Author.create({ name: 'New Author' })
264
+ const lastEpoch = nameIndex.lastChangedEpoch
265
+
266
+ store.put([newAuthor])
267
+
268
+ // Access the index to trigger the update
269
+ const updatedIndex = nameIndex.get()
270
+ expect(nameIndex.lastChangedEpoch).toBeGreaterThan(lastEpoch)
271
+ expect(updatedIndex.get('New Author')).toEqual(new Set([newAuthor.id]))
272
+
273
+ const diff = nameIndex.getDiffSince(lastEpoch)
274
+ expect(diff).not.toBe(RESET_VALUE)
275
+ if (diff !== RESET_VALUE) {
276
+ expect(diff).toHaveLength(1)
277
+ expect(diff[0].get('New Author')).toEqual({ added: new Set([newAuthor.id]) })
278
+ }
279
+ })
280
+
281
+ it('should update indexes when records are updated', () => {
282
+ const nameIndex = queries.index('author', 'name')
283
+ nameIndex.get() // Initialize
284
+
285
+ const lastEpoch = nameIndex.lastChangedEpoch
286
+
287
+ // Update author name
288
+ store.put([{ ...authors.asimov, name: 'Dr. Isaac Asimov' }])
289
+
290
+ // Access the index to trigger the update
291
+ const updatedIndex = nameIndex.get()
292
+ expect(nameIndex.lastChangedEpoch).toBeGreaterThan(lastEpoch)
293
+ expect(updatedIndex.get('Isaac Asimov')).toBeUndefined()
294
+ expect(updatedIndex.get('Dr. Isaac Asimov')).toEqual(new Set([authors.asimov.id]))
295
+
296
+ const diff = nameIndex.getDiffSince(lastEpoch)
297
+ expect(diff).not.toBe(RESET_VALUE)
298
+ if (diff !== RESET_VALUE) {
299
+ expect(diff).toHaveLength(1)
300
+ expect(diff[0].get('Isaac Asimov')).toEqual({ removed: new Set([authors.asimov.id]) })
301
+ expect(diff[0].get('Dr. Isaac Asimov')).toEqual({ added: new Set([authors.asimov.id]) })
302
+ }
303
+ })
304
+ })
305
+
306
+ describe('record method', () => {
307
+ it('should return single matching record', () => {
308
+ const asimovQuery = queries.record('author', () => ({
309
+ name: { eq: 'Isaac Asimov' },
310
+ }))
311
+
312
+ expect(asimovQuery.get()).toEqual(authors.asimov)
313
+ })
314
+
315
+ it('should return undefined when no match found', () => {
316
+ const nonexistentQuery = queries.record('author', () => ({
317
+ name: { eq: 'Nonexistent Author' },
318
+ }))
319
+
320
+ expect(nonexistentQuery.get()).toBeUndefined()
321
+ })
322
+
323
+ it('should update reactively when matching record changes', () => {
324
+ const query = queries.record('author', () => ({
325
+ name: { eq: 'Isaac Asimov' },
326
+ }))
327
+
328
+ expect(query.get()?.age).toBe(72)
329
+
330
+ // Update the matching record
331
+ store.put([{ ...authors.asimov, age: 73 }])
332
+
333
+ expect(query.get()?.age).toBe(73)
334
+ })
335
+
336
+ it('should handle reactive query parameters', () => {
337
+ const targetName = atom('targetName', 'Isaac Asimov')
338
+
339
+ const query = queries.record('author', () => ({
340
+ name: { eq: targetName.get() },
341
+ }))
342
+
343
+ expect(query.get()).toEqual(authors.asimov)
344
+
345
+ targetName.set('William Gibson')
346
+ expect(query.get()).toEqual(authors.gibson)
347
+ })
348
+
349
+ it('should handle complex query conditions', () => {
350
+ const query = queries.record('author', () => ({
351
+ age: { gt: 70 },
352
+ isActive: { eq: true },
353
+ }))
354
+
355
+ const result = query.get()
356
+ expect(result).toBeDefined()
357
+ expect(result!.age).toBeGreaterThan(70)
358
+ expect(result!.isActive).toBe(true)
359
+ })
360
+ })
361
+
362
+ describe('records method', () => {
363
+ it('should return all records of type when no query provided', () => {
364
+ const allBooks = queries.records('book')
365
+ const books = allBooks.get()
366
+
367
+ expect(books).toHaveLength(6)
368
+ expect(books.every((book) => book.typeName === 'book')).toBe(true)
369
+ })
370
+
371
+ it('should filter records by query conditions', () => {
372
+ const inStockBooks = queries.records('book', () => ({
373
+ inStock: { eq: true },
374
+ }))
375
+
376
+ const books = inStockBooks.get()
377
+ expect(books.every((book) => book.inStock === true)).toBe(true)
378
+ expect(books.length).toBeGreaterThan(0)
379
+ })
380
+
381
+ it('should handle multiple query conditions', () => {
382
+ const query = queries.records('book', () => ({
383
+ category: { eq: 'sci-fi' },
384
+ rating: { eq: 5 },
385
+ }))
386
+
387
+ const books = query.get()
388
+ expect(books.every((book) => book.category === 'sci-fi' && book.rating === 5)).toBe(true)
389
+ })
390
+
391
+ it('should update reactively when records are added', () => {
392
+ const allBooks = queries.records('book')
393
+ const initialCount = allBooks.get().length
394
+
395
+ const newBook = Book.create({
396
+ title: 'New Book',
397
+ authorId: authors.asimov.id,
398
+ publishedYear: 2023,
399
+ inStock: true,
400
+ rating: 3,
401
+ })
402
+
403
+ store.put([newBook])
404
+
405
+ expect(allBooks.get()).toHaveLength(initialCount + 1)
406
+ expect(allBooks.get().some((book) => book.id === newBook.id)).toBe(true)
407
+ })
408
+
409
+ it('should update reactively when records are removed', () => {
410
+ const allBooks = queries.records('book')
411
+ const initialCount = allBooks.get().length
412
+
413
+ store.remove([books.dune.id])
414
+
415
+ expect(allBooks.get()).toHaveLength(initialCount - 1)
416
+ expect(allBooks.get().some((book) => book.id === books.dune.id)).toBe(false)
417
+ })
418
+
419
+ it('should update reactively when records are updated', () => {
420
+ const inStockBooks = queries.records('book', () => ({
421
+ inStock: { eq: true },
422
+ }))
423
+
424
+ const initialBooks = inStockBooks.get()
425
+ const initialCount = initialBooks.length
426
+
427
+ // Update a book to be out of stock
428
+ store.put([{ ...books.foundation, inStock: false }])
429
+
430
+ const updatedBooks = inStockBooks.get()
431
+ expect(updatedBooks).toHaveLength(initialCount - 1)
432
+ expect(updatedBooks.some((book) => book.id === books.foundation.id)).toBe(false)
433
+ })
434
+
435
+ it('should handle reactive query parameters', () => {
436
+ const targetCategory = atom('targetCategory', 'sci-fi')
437
+
438
+ const query = queries.records('book', () => ({
439
+ category: { eq: targetCategory.get() },
440
+ }))
441
+
442
+ const sciFiBooks = query.get()
443
+ expect(sciFiBooks.every((book) => book.category === 'sci-fi')).toBe(true)
444
+
445
+ targetCategory.set('cyberpunk')
446
+ const cyberpunkBooks = query.get()
447
+ expect(cyberpunkBooks.every((book) => book.category === 'cyberpunk')).toBe(true)
448
+ expect(cyberpunkBooks).toHaveLength(1)
449
+ expect(cyberpunkBooks[0]).toEqual(books.neuromancer)
450
+ })
451
+ })
452
+
453
+ describe('ids method', () => {
454
+ it('should return set of all record IDs when no query provided', () => {
455
+ const allBookIds = queries.ids('book')
456
+ const ids = allBookIds.get()
457
+
458
+ expect(ids).toBeInstanceOf(Set)
459
+ expect(ids.size).toBe(6)
460
+ Object.values(books).forEach((book) => {
461
+ expect(ids.has(book.id)).toBe(true)
462
+ })
463
+ })
464
+
465
+ it('should filter IDs by query conditions', () => {
466
+ const highRatedIds = queries.ids('book', () => ({
467
+ rating: { eq: 5 },
468
+ }))
469
+
470
+ const ids = highRatedIds.get()
471
+ expect(ids).toBeInstanceOf(Set)
472
+
473
+ // Verify all returned IDs match the criteria
474
+ ids.forEach((id) => {
475
+ const book = store.get(id)!
476
+ expect(book.rating).toBe(5)
477
+ })
478
+ })
479
+
480
+ it('should update with collection diffs when records change', () => {
481
+ const inStockIds = queries.ids('book', () => ({
482
+ inStock: { eq: true },
483
+ }))
484
+
485
+ const _initialIds = inStockIds.get()
486
+ const lastEpoch = inStockIds.lastChangedEpoch
487
+
488
+ // Add a new book
489
+ const newBook = Book.create({
490
+ title: 'New In-Stock Book',
491
+ authorId: authors.asimov.id,
492
+ publishedYear: 2023,
493
+ inStock: true,
494
+ rating: 4,
495
+ })
496
+ store.put([newBook])
497
+
498
+ const updatedIds = inStockIds.get()
499
+ expect(updatedIds.has(newBook.id)).toBe(true)
500
+
501
+ const diff = inStockIds.getDiffSince(lastEpoch)
502
+ expect(diff).not.toBe(RESET_VALUE)
503
+ if (diff !== RESET_VALUE) {
504
+ expect(diff).toHaveLength(1)
505
+ expect(diff[0].added?.has(newBook.id)).toBe(true)
506
+ }
507
+ })
508
+
509
+ it('should handle reactive query parameters with from-scratch rebuild', () => {
510
+ const targetYear = atom('targetYear', 1950)
511
+
512
+ const query = queries.ids('book', () => ({
513
+ publishedYear: { gt: targetYear.get() },
514
+ }))
515
+
516
+ const initialIds = query.get()
517
+ const initialSize = initialIds.size
518
+
519
+ // Change query parameter should trigger from-scratch rebuild
520
+ const lastEpoch = query.lastChangedEpoch
521
+ targetYear.set(1970)
522
+
523
+ const updatedIds = query.get()
524
+ expect(query.lastChangedEpoch).toBeGreaterThan(lastEpoch)
525
+
526
+ // Should have fewer results with higher year threshold
527
+ expect(updatedIds.size).toBeLessThan(initialSize)
528
+
529
+ updatedIds.forEach((id) => {
530
+ const book = store.get(id)!
531
+ expect(book.publishedYear).toBeGreaterThan(1970)
532
+ })
533
+ })
534
+
535
+ it('should efficiently handle incremental updates', () => {
536
+ const query = queries.ids('book', () => ({
537
+ inStock: { eq: true },
538
+ }))
539
+
540
+ query.get() // Initialize
541
+ const lastEpoch = query.lastChangedEpoch
542
+
543
+ // Update a book's stock status
544
+ store.put([{ ...books.dune, inStock: true }])
545
+
546
+ const diff = query.getDiffSince(lastEpoch)
547
+ expect(diff).not.toBe(RESET_VALUE)
548
+ if (diff !== RESET_VALUE) {
549
+ expect(diff).toHaveLength(1)
550
+ expect(diff[0].added?.has(books.dune.id)).toBe(true)
551
+ }
552
+ })
553
+ })
554
+
555
+ describe('exec method', () => {
556
+ it('should execute non-reactive queries', () => {
557
+ const result = queries.exec('author', {
558
+ isActive: { eq: true },
559
+ })
560
+
561
+ expect(Array.isArray(result)).toBe(true)
562
+ expect(result.every((author) => author.isActive === true)).toBe(true)
563
+ })
564
+
565
+ it('should handle complex query conditions', () => {
566
+ const result = queries.exec('book', {
567
+ rating: { eq: 5 },
568
+ category: { eq: 'sci-fi' },
569
+ })
570
+
571
+ expect(result.every((book) => book.rating === 5 && book.category === 'sci-fi')).toBe(true)
572
+ expect(result.length).toBeGreaterThan(0)
573
+ })
574
+ })
575
+
576
+ describe('integration with reactive state', () => {
577
+ it('should work with computed values', () => {
578
+ const booksByCategory = computed('books-by-category', () => {
579
+ const categoryIndex = queries.index('book', 'category')
580
+ const sciFiBooks = categoryIndex.get().get('sci-fi') || new Set()
581
+ return sciFiBooks.size
582
+ })
583
+
584
+ const initialCount = booksByCategory.get()
585
+ expect(initialCount).toBeGreaterThan(0)
586
+
587
+ // Add a sci-fi book
588
+ const newSciFiBook = Book.create({
589
+ title: 'New Sci-Fi',
590
+ authorId: authors.asimov.id,
591
+ publishedYear: 2023,
592
+ inStock: true,
593
+ rating: 4,
594
+ category: 'sci-fi',
595
+ })
596
+ store.put([newSciFiBook])
597
+
598
+ expect(booksByCategory.get()).toBe(initialCount + 1)
599
+ })
600
+
601
+ it('should propagate changes through multiple reactive layers', () => {
602
+ const authorBookCounts = computed('author-book-counts', () => {
603
+ const authorIndex = queries.index('book', 'authorId')
604
+ const counts = new Map()
605
+
606
+ for (const [authorId, bookIds] of authorIndex.get()) {
607
+ counts.set(authorId, bookIds.size)
608
+ }
609
+
610
+ return counts
611
+ })
612
+
613
+ const asimovInitialCount = authorBookCounts.get().get(authors.asimov.id) || 0
614
+
615
+ // Add book by Asimov
616
+ const newAsimovBook = Book.create({
617
+ title: 'New Asimov Book',
618
+ authorId: authors.asimov.id,
619
+ publishedYear: 2023,
620
+ inStock: true,
621
+ rating: 4,
622
+ })
623
+ store.put([newAsimovBook])
624
+
625
+ expect(authorBookCounts.get().get(authors.asimov.id)).toBe(asimovInitialCount + 1)
626
+ })
627
+ })