@tldraw/store 4.2.0-next.e824a30c434e → 4.2.0-next.f100cedfc45b

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.
@@ -22,6 +22,14 @@ interface Book extends BaseRecord<'book', RecordId<Book>> {
22
22
  rating: number
23
23
  category: string
24
24
  price: number
25
+ metadata: {
26
+ sessionId: string
27
+ status?: 'published' | 'draft' | 'archived'
28
+ copies?: number
29
+ extras: {
30
+ region: string
31
+ }
32
+ }
25
33
  }
26
34
 
27
35
  interface Review extends BaseRecord<'review', RecordId<Review>> {
@@ -62,6 +70,15 @@ const Book = createRecordType<Book>('book', {
62
70
  if (!book.authorId.startsWith('author')) throw Error('Invalid authorId')
63
71
  if (!Number.isFinite(book.publishedYear)) throw Error('Invalid publishedYear')
64
72
  if (typeof book.inStock !== 'boolean') throw Error('Invalid inStock')
73
+ if (typeof book.metadata !== 'object' || book.metadata === null)
74
+ throw Error('Invalid metadata')
75
+ if (typeof book.metadata.sessionId !== 'string') throw Error('Invalid sessionId')
76
+ if (
77
+ typeof book.metadata.extras !== 'object' ||
78
+ book.metadata.extras === null ||
79
+ typeof book.metadata.extras.region !== 'string'
80
+ )
81
+ throw Error('Invalid extras')
65
82
  return book
66
83
  },
67
84
  },
@@ -70,6 +87,12 @@ const Book = createRecordType<Book>('book', {
70
87
  rating: 0,
71
88
  category: 'fiction',
72
89
  price: 10.99,
90
+ metadata: {
91
+ sessionId: 'session:default',
92
+ extras: {
93
+ region: 'global',
94
+ },
95
+ },
73
96
  }))
74
97
 
75
98
  const Review = createRecordType<Review>('review', {
@@ -142,6 +165,10 @@ const books = {
142
165
  rating: 5,
143
166
  category: 'sci-fi',
144
167
  price: 12.99,
168
+ metadata: {
169
+ sessionId: 'session:alpha',
170
+ extras: { region: 'us' },
171
+ },
145
172
  }),
146
173
  neuromancer: Book.create({
147
174
  title: 'Neuromancer',
@@ -151,6 +178,10 @@ const books = {
151
178
  rating: 5,
152
179
  category: 'cyberpunk',
153
180
  price: 14.99,
181
+ metadata: {
182
+ sessionId: 'session:beta',
183
+ extras: { region: 'us' },
184
+ },
154
185
  }),
155
186
  dune: Book.create({
156
187
  title: 'Dune',
@@ -160,6 +191,10 @@ const books = {
160
191
  rating: 5,
161
192
  category: 'sci-fi',
162
193
  price: 13.99,
194
+ metadata: {
195
+ sessionId: 'session:beta',
196
+ extras: { region: 'eu' },
197
+ },
163
198
  }),
164
199
  fahrenheit451: Book.create({
165
200
  title: 'Fahrenheit 451',
@@ -169,6 +204,10 @@ const books = {
169
204
  rating: 4,
170
205
  category: 'dystopian',
171
206
  price: 11.99,
207
+ metadata: {
208
+ sessionId: 'session:beta',
209
+ extras: { region: 'us' },
210
+ },
172
211
  }),
173
212
  childhood: Book.create({
174
213
  title: "Childhood's End",
@@ -178,6 +217,10 @@ const books = {
178
217
  rating: 4,
179
218
  category: 'sci-fi',
180
219
  price: 10.99,
220
+ metadata: {
221
+ sessionId: 'session:gamma',
222
+ extras: { region: 'global' },
223
+ },
181
224
  }),
182
225
  hitchhiker: Book.create({
183
226
  title: "The Hitchhiker's Guide to the Galaxy",
@@ -187,6 +230,10 @@ const books = {
187
230
  rating: 5,
188
231
  category: 'comedy-sci-fi',
189
232
  price: 9.99,
233
+ metadata: {
234
+ sessionId: 'session:alpha',
235
+ extras: { region: 'uk' },
236
+ },
190
237
  }),
191
238
  robots: Book.create({
192
239
  title: 'I, Robot',
@@ -196,6 +243,10 @@ const books = {
196
243
  rating: 4,
197
244
  category: 'sci-fi',
198
245
  price: 12.99,
246
+ metadata: {
247
+ sessionId: 'session:alpha',
248
+ extras: { region: 'us' },
249
+ },
199
250
  }),
200
251
  }
201
252
 
@@ -297,7 +348,7 @@ describe('objectMatchesQuery', () => {
297
348
  })
298
349
 
299
350
  it('should not match when property is not a number', () => {
300
- const book = { title: '1984', publishedYear: '1984' as any }
351
+ const book = { title: '1984', publishedYear: '1984' }
301
352
  const query = { publishedYear: { gt: 1980 } }
302
353
 
303
354
  expect(objectMatchesQuery(query, book)).toBe(false)
@@ -328,6 +379,37 @@ describe('objectMatchesQuery', () => {
328
379
  })
329
380
  })
330
381
 
382
+ describe('nested object matching', () => {
383
+ it('should match when nested property satisfies criteria', () => {
384
+ const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
385
+
386
+ expect(objectMatchesQuery(query, books.foundation)).toBe(true)
387
+ })
388
+
389
+ it('should not match when nested property fails criteria', () => {
390
+ const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
391
+
392
+ expect(objectMatchesQuery(query, books.neuromancer)).toBe(false)
393
+ })
394
+
395
+ it('should match deeply nested properties', () => {
396
+ const query = { metadata: { extras: { region: { eq: 'eu' } } } }
397
+
398
+ expect(objectMatchesQuery(query, books.dune)).toBe(true)
399
+ })
400
+
401
+ it('should return false when nested object is missing', () => {
402
+ const book = {
403
+ typeName: 'book',
404
+ id: 'book:custom',
405
+ metadata: {},
406
+ } as Book
407
+ const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
408
+
409
+ expect(objectMatchesQuery(query, book)).toBe(false)
410
+ })
411
+ })
412
+
331
413
  describe('edge cases', () => {
332
414
  it('should return true for empty query', () => {
333
415
  const book = books.foundation
@@ -337,10 +419,16 @@ describe('objectMatchesQuery', () => {
337
419
  })
338
420
 
339
421
  it('should handle missing properties gracefully', () => {
340
- const book = { title: 'Test' } as any
422
+ const book = { title: 'Test' }
341
423
  const query = { nonexistentProperty: { eq: 'value' } }
342
424
 
343
- expect(objectMatchesQuery(query, book)).toBe(false)
425
+ expect(
426
+ objectMatchesQuery(
427
+ // @ts-expect-error - query is not a valid query expression for books
428
+ query,
429
+ book
430
+ )
431
+ ).toBe(false)
344
432
  })
345
433
  })
346
434
  })
@@ -431,6 +519,45 @@ describe('executeQuery', () => {
431
519
  })
432
520
  })
433
521
 
522
+ describe('nested object queries', () => {
523
+ it('should filter records using nested properties', () => {
524
+ const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
525
+ const result = executeQuery(store.query, 'book', query)
526
+
527
+ const expectedIds = new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
528
+
529
+ expect(result).toEqual(expectedIds)
530
+ })
531
+
532
+ it('should combine nested and top-level criteria', () => {
533
+ const query = {
534
+ authorId: { eq: authors.asimov.id },
535
+ metadata: { sessionId: { eq: 'session:alpha' } },
536
+ }
537
+ const result = executeQuery(store.query, 'book', query)
538
+
539
+ const expectedIds = new Set([books.foundation.id, books.robots.id])
540
+
541
+ expect(result).toEqual(expectedIds)
542
+ })
543
+
544
+ it('should support deeper nested criteria', () => {
545
+ const query = { metadata: { extras: { region: { eq: 'eu' } } } }
546
+ const result = executeQuery(store.query, 'book', query)
547
+
548
+ expect(result).toEqual(new Set([books.dune.id]))
549
+ })
550
+
551
+ it('should work with reactive ids queries', () => {
552
+ const query = { metadata: { sessionId: { eq: 'session:beta' } } }
553
+ const idsQuery = store.query.ids('book', () => query)
554
+
555
+ expect(idsQuery.get()).toEqual(
556
+ new Set([books.neuromancer.id, books.dune.id, books.fahrenheit451.id])
557
+ )
558
+ })
559
+ })
560
+
434
561
  describe('edge cases', () => {
435
562
  it('should handle empty query', () => {
436
563
  const query = {}
@@ -449,7 +576,12 @@ describe('executeQuery', () => {
449
576
  expect(result).toEqual(expectedIds)
450
577
 
451
578
  // Same query on books should return empty
452
- const bookResult = executeQuery(store.query, 'book', query as any)
579
+ const bookResult = executeQuery(
580
+ store.query,
581
+ 'book',
582
+ // @ts-expect-error - query is not a valid query expression for books
583
+ query
584
+ )
453
585
  expect(bookResult).toEqual(new Set())
454
586
  })
455
587
  })
@@ -479,3 +611,795 @@ describe('executeQuery', () => {
479
611
  })
480
612
  })
481
613
  })
614
+
615
+ describe('reactive nested queries', () => {
616
+ describe('adding records', () => {
617
+ it('should include newly added record that matches nested query', () => {
618
+ const query = { metadata: { sessionId: { eq: 'session:delta' } } }
619
+ const idsQuery = store.query.ids('book', () => query)
620
+
621
+ // Initially empty
622
+ expect(idsQuery.get()).toEqual(new Set())
623
+
624
+ // Add a book with matching nested property
625
+ const newBook = Book.create({
626
+ title: 'New Book',
627
+ authorId: authors.asimov.id,
628
+ publishedYear: 2023,
629
+ inStock: true,
630
+ metadata: {
631
+ sessionId: 'session:delta',
632
+ extras: { region: 'us' },
633
+ },
634
+ })
635
+
636
+ store.put([newBook])
637
+
638
+ // Should now include the new book
639
+ expect(idsQuery.get()).toEqual(new Set([newBook.id]))
640
+ })
641
+
642
+ it('should not include newly added record that does not match nested query', () => {
643
+ const query = { metadata: { sessionId: { eq: 'session:delta' } } }
644
+ const idsQuery = store.query.ids('book', () => query)
645
+
646
+ expect(idsQuery.get()).toEqual(new Set())
647
+
648
+ // Add a book with non-matching nested property
649
+ const newBook = Book.create({
650
+ title: 'Another Book',
651
+ authorId: authors.asimov.id,
652
+ publishedYear: 2023,
653
+ inStock: true,
654
+ metadata: {
655
+ sessionId: 'session:epsilon',
656
+ extras: { region: 'us' },
657
+ },
658
+ })
659
+
660
+ store.put([newBook])
661
+
662
+ // Should still be empty
663
+ expect(idsQuery.get()).toEqual(new Set())
664
+ })
665
+
666
+ it('should handle adding multiple records with mixed nested matches', () => {
667
+ const query = { metadata: { extras: { region: { eq: 'asia' } } } }
668
+ const idsQuery = store.query.ids('book', () => query)
669
+
670
+ expect(idsQuery.get()).toEqual(new Set())
671
+
672
+ const book1 = Book.create({
673
+ title: 'Book 1',
674
+ authorId: authors.asimov.id,
675
+ publishedYear: 2023,
676
+ inStock: true,
677
+ metadata: {
678
+ sessionId: 'session:alpha',
679
+ extras: { region: 'asia' },
680
+ },
681
+ })
682
+
683
+ const book2 = Book.create({
684
+ title: 'Book 2',
685
+ authorId: authors.gibson.id,
686
+ publishedYear: 2023,
687
+ inStock: true,
688
+ metadata: {
689
+ sessionId: 'session:beta',
690
+ extras: { region: 'us' },
691
+ },
692
+ })
693
+
694
+ const book3 = Book.create({
695
+ title: 'Book 3',
696
+ authorId: authors.herbert.id,
697
+ publishedYear: 2023,
698
+ inStock: true,
699
+ metadata: {
700
+ sessionId: 'session:gamma',
701
+ extras: { region: 'asia' },
702
+ },
703
+ })
704
+
705
+ store.put([book1, book2, book3])
706
+
707
+ // Only books 1 and 3 should match
708
+ expect(idsQuery.get()).toEqual(new Set([book1.id, book3.id]))
709
+ })
710
+ })
711
+
712
+ describe('removing records', () => {
713
+ it('should remove record from results when it is deleted', () => {
714
+ const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
715
+ const idsQuery = store.query.ids('book', () => query)
716
+
717
+ // Initially should have foundation, hitchhiker, and robots
718
+ const initialIds = new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
719
+ expect(idsQuery.get()).toEqual(initialIds)
720
+
721
+ // Remove one of the matching books
722
+ store.remove([books.foundation.id])
723
+
724
+ // Should no longer include foundation
725
+ expect(idsQuery.get()).toEqual(new Set([books.hitchhiker.id, books.robots.id]))
726
+ })
727
+
728
+ it('should not affect results when removing non-matching record', () => {
729
+ const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
730
+ const idsQuery = store.query.ids('book', () => query)
731
+
732
+ const initialIds = new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
733
+ expect(idsQuery.get()).toEqual(initialIds)
734
+
735
+ // Remove a non-matching book
736
+ store.remove([books.neuromancer.id])
737
+
738
+ // Results should be unchanged
739
+ expect(idsQuery.get()).toEqual(initialIds)
740
+ })
741
+
742
+ it('should handle removing all matching records', () => {
743
+ const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
744
+ const idsQuery = store.query.ids('book', () => query)
745
+
746
+ expect(idsQuery.get()).toEqual(
747
+ new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
748
+ )
749
+
750
+ // Remove all matching books
751
+ store.remove([books.foundation.id, books.hitchhiker.id, books.robots.id])
752
+
753
+ // Should now be empty
754
+ expect(idsQuery.get()).toEqual(new Set())
755
+ })
756
+ })
757
+
758
+ describe('updating records', () => {
759
+ it('should add record to results when nested property is updated to match', () => {
760
+ const query = { metadata: { sessionId: { eq: 'session:omega' } } }
761
+ const idsQuery = store.query.ids('book', () => query)
762
+
763
+ // Initially empty (no books with session:omega)
764
+ expect(idsQuery.get()).toEqual(new Set())
765
+
766
+ // Update neuromancer to have session:omega
767
+ store.put([
768
+ {
769
+ ...books.neuromancer,
770
+ metadata: {
771
+ sessionId: 'session:omega',
772
+ extras: { region: 'us' },
773
+ },
774
+ },
775
+ ])
776
+
777
+ // Should now include neuromancer
778
+ expect(idsQuery.get()).toEqual(new Set([books.neuromancer.id]))
779
+ })
780
+
781
+ it('should remove record from results when nested property is updated to not match', () => {
782
+ const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
783
+ const idsQuery = store.query.ids('book', () => query)
784
+
785
+ // Initially should have foundation, hitchhiker, and robots
786
+ expect(idsQuery.get()).toEqual(
787
+ new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
788
+ )
789
+
790
+ // Update foundation to have a different session
791
+ store.put([
792
+ {
793
+ ...books.foundation,
794
+ metadata: {
795
+ sessionId: 'session:changed',
796
+ extras: { region: 'us' },
797
+ },
798
+ },
799
+ ])
800
+
801
+ // Should no longer include foundation
802
+ expect(idsQuery.get()).toEqual(new Set([books.hitchhiker.id, books.robots.id]))
803
+ })
804
+
805
+ it('should handle deeply nested property updates', () => {
806
+ const query = { metadata: { extras: { region: { eq: 'antarctica' } } } }
807
+ const idsQuery = store.query.ids('book', () => query)
808
+
809
+ // Initially empty
810
+ expect(idsQuery.get()).toEqual(new Set())
811
+
812
+ // Update dune to have antarctica region
813
+ store.put([
814
+ {
815
+ ...books.dune,
816
+ metadata: {
817
+ sessionId: 'session:beta',
818
+ extras: { region: 'antarctica' },
819
+ },
820
+ },
821
+ ])
822
+
823
+ // Should now include dune
824
+ expect(idsQuery.get()).toEqual(new Set([books.dune.id]))
825
+
826
+ // Update dune back to eu
827
+ store.put([
828
+ {
829
+ ...books.dune,
830
+ metadata: {
831
+ sessionId: 'session:beta',
832
+ extras: { region: 'eu' },
833
+ },
834
+ },
835
+ ])
836
+
837
+ // Should no longer include dune
838
+ expect(idsQuery.get()).toEqual(new Set())
839
+ })
840
+
841
+ it('should maintain correct results when updating non-nested properties', () => {
842
+ const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
843
+ const idsQuery = store.query.ids('book', () => query)
844
+
845
+ const initialIds = new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
846
+ expect(idsQuery.get()).toEqual(initialIds)
847
+
848
+ // Update a non-nested property (title) but keep nested property the same
849
+ store.put([
850
+ {
851
+ ...books.foundation,
852
+ title: 'Foundation - Updated Edition',
853
+ },
854
+ ])
855
+
856
+ // Results should be unchanged
857
+ expect(idsQuery.get()).toEqual(initialIds)
858
+ })
859
+ })
860
+
861
+ describe('combined nested and top-level queries', () => {
862
+ it('should correctly update when top-level property changes', () => {
863
+ const query = {
864
+ inStock: { eq: true },
865
+ metadata: { sessionId: { eq: 'session:alpha' } },
866
+ }
867
+ const idsQuery = store.query.ids('book', () => query)
868
+
869
+ // Initially foundation and robots are in stock with session:alpha
870
+ // hitchhiker is NOT in stock
871
+ expect(idsQuery.get()).toEqual(new Set([books.foundation.id, books.robots.id]))
872
+
873
+ // Update hitchhiker to be in stock
874
+ store.put([
875
+ {
876
+ ...books.hitchhiker,
877
+ inStock: true,
878
+ },
879
+ ])
880
+
881
+ // Should now include hitchhiker
882
+ expect(idsQuery.get()).toEqual(
883
+ new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
884
+ )
885
+ })
886
+
887
+ it('should correctly update when nested property changes in combined query', () => {
888
+ const query = {
889
+ inStock: { eq: true },
890
+ metadata: { sessionId: { eq: 'session:zeta' } },
891
+ }
892
+ const idsQuery = store.query.ids('book', () => query)
893
+
894
+ // Initially empty (no books with session:zeta)
895
+ expect(idsQuery.get()).toEqual(new Set())
896
+
897
+ // Update foundation to have session:zeta (it's already in stock)
898
+ store.put([
899
+ {
900
+ ...books.foundation,
901
+ metadata: {
902
+ sessionId: 'session:zeta',
903
+ extras: { region: 'us' },
904
+ },
905
+ },
906
+ ])
907
+
908
+ // Should now include foundation
909
+ expect(idsQuery.get()).toEqual(new Set([books.foundation.id]))
910
+
911
+ // Update foundation to be out of stock (but keep session:zeta)
912
+ store.put([
913
+ {
914
+ ...books.foundation,
915
+ inStock: false,
916
+ metadata: {
917
+ sessionId: 'session:zeta',
918
+ extras: { region: 'us' },
919
+ },
920
+ },
921
+ ])
922
+
923
+ // Should no longer include foundation
924
+ expect(idsQuery.get()).toEqual(new Set())
925
+ })
926
+
927
+ it('should handle complex updates with multiple nested levels', () => {
928
+ const query = {
929
+ category: { eq: 'sci-fi' },
930
+ metadata: { extras: { region: { eq: 'us' } } },
931
+ }
932
+ const idsQuery = store.query.ids('book', () => query)
933
+
934
+ // Initially foundation and robots are sci-fi with us region
935
+ expect(idsQuery.get()).toEqual(new Set([books.foundation.id, books.robots.id]))
936
+
937
+ // Add a new sci-fi book with us region
938
+ const newBook = Book.create({
939
+ title: 'New Sci-Fi',
940
+ authorId: authors.asimov.id,
941
+ publishedYear: 2023,
942
+ inStock: true,
943
+ category: 'sci-fi',
944
+ metadata: {
945
+ sessionId: 'session:new',
946
+ extras: { region: 'us' },
947
+ },
948
+ })
949
+
950
+ store.put([newBook])
951
+
952
+ expect(idsQuery.get()).toEqual(new Set([books.foundation.id, books.robots.id, newBook.id]))
953
+
954
+ // Change newBook's region
955
+ store.put([
956
+ {
957
+ ...newBook,
958
+ metadata: {
959
+ sessionId: 'session:new',
960
+ extras: { region: 'eu' },
961
+ },
962
+ },
963
+ ])
964
+
965
+ // Should no longer include newBook
966
+ expect(idsQuery.get()).toEqual(new Set([books.foundation.id, books.robots.id]))
967
+
968
+ // Change newBook's category but keep region as eu
969
+ store.put([
970
+ {
971
+ ...newBook,
972
+ category: 'fantasy',
973
+ metadata: {
974
+ sessionId: 'session:new',
975
+ extras: { region: 'us' },
976
+ },
977
+ },
978
+ ])
979
+
980
+ // Should still not include newBook (category doesn't match)
981
+ expect(idsQuery.get()).toEqual(new Set([books.foundation.id, books.robots.id]))
982
+ })
983
+ })
984
+
985
+ describe('query operators with nested properties', () => {
986
+ it('should handle gt operator on nested properties', () => {
987
+ // Add some books with different copy counts
988
+ const bookLowCopies = Book.create({
989
+ title: 'Low Copies Book',
990
+ authorId: authors.asimov.id,
991
+ publishedYear: 2020,
992
+ inStock: true,
993
+ metadata: {
994
+ sessionId: 'session:test',
995
+ copies: 5,
996
+ extras: { region: 'us' },
997
+ },
998
+ })
999
+
1000
+ const bookHighCopies = Book.create({
1001
+ title: 'High Copies Book',
1002
+ authorId: authors.gibson.id,
1003
+ publishedYear: 2021,
1004
+ inStock: true,
1005
+ metadata: {
1006
+ sessionId: 'session:test',
1007
+ copies: 100,
1008
+ extras: { region: 'us' },
1009
+ },
1010
+ })
1011
+
1012
+ const bookMidCopies = Book.create({
1013
+ title: 'Mid Copies Book',
1014
+ authorId: authors.herbert.id,
1015
+ publishedYear: 2022,
1016
+ inStock: true,
1017
+ metadata: {
1018
+ sessionId: 'session:test',
1019
+ copies: 50,
1020
+ extras: { region: 'us' },
1021
+ },
1022
+ })
1023
+
1024
+ store.put([bookLowCopies, bookHighCopies, bookMidCopies])
1025
+
1026
+ // Query for books with more than 25 copies
1027
+ const query = { metadata: { copies: { gt: 25 } } }
1028
+ const idsQuery = store.query.ids('book', () => query)
1029
+
1030
+ // Should include only books with > 25 copies
1031
+ const expectedIds = new Set([bookHighCopies.id, bookMidCopies.id])
1032
+ expect(idsQuery.get()).toEqual(expectedIds)
1033
+
1034
+ // Update one book to have fewer copies
1035
+ store.put([
1036
+ {
1037
+ ...bookMidCopies,
1038
+ metadata: {
1039
+ ...bookMidCopies.metadata,
1040
+ copies: 10,
1041
+ },
1042
+ },
1043
+ ])
1044
+
1045
+ // Should no longer include bookMidCopies
1046
+ expect(idsQuery.get()).toEqual(new Set([bookHighCopies.id]))
1047
+ })
1048
+
1049
+ it('should handle neq operator on nested properties with updates', () => {
1050
+ const query = { metadata: { sessionId: { neq: 'session:alpha' } } }
1051
+ const idsQuery = store.query.ids('book', () => query)
1052
+
1053
+ // Should include all books except those with session:alpha
1054
+ const expectedIds = new Set([
1055
+ books.neuromancer.id,
1056
+ books.dune.id,
1057
+ books.fahrenheit451.id,
1058
+ books.childhood.id,
1059
+ ])
1060
+ expect(idsQuery.get()).toEqual(expectedIds)
1061
+
1062
+ // Update one of the matching books to have session:alpha
1063
+ store.put([
1064
+ {
1065
+ ...books.neuromancer,
1066
+ metadata: {
1067
+ sessionId: 'session:alpha',
1068
+ extras: { region: 'us' },
1069
+ },
1070
+ },
1071
+ ])
1072
+
1073
+ // Should no longer include neuromancer
1074
+ expect(idsQuery.get()).toEqual(
1075
+ new Set([books.dune.id, books.fahrenheit451.id, books.childhood.id])
1076
+ )
1077
+ })
1078
+
1079
+ it('should handle neq with undefined nested values', () => {
1080
+ // Create books where some have a nested optional property and others don't
1081
+ const bookWithStatus = Book.create({
1082
+ title: 'Book With Status',
1083
+ authorId: authors.asimov.id,
1084
+ publishedYear: 2020,
1085
+ inStock: true,
1086
+ metadata: {
1087
+ sessionId: 'session:test',
1088
+ status: 'published',
1089
+ extras: { region: 'us' },
1090
+ },
1091
+ })
1092
+
1093
+ const bookWithDifferentStatus = Book.create({
1094
+ title: 'Book With Different Status',
1095
+ authorId: authors.gibson.id,
1096
+ publishedYear: 2021,
1097
+ inStock: true,
1098
+ metadata: {
1099
+ sessionId: 'session:test',
1100
+ status: 'draft',
1101
+ extras: { region: 'us' },
1102
+ },
1103
+ })
1104
+
1105
+ const bookWithoutStatus = Book.create({
1106
+ title: 'Book Without Status',
1107
+ authorId: authors.herbert.id,
1108
+ publishedYear: 2022,
1109
+ inStock: true,
1110
+ metadata: {
1111
+ sessionId: 'session:test',
1112
+ extras: { region: 'us' },
1113
+ },
1114
+ })
1115
+
1116
+ store.put([bookWithStatus, bookWithDifferentStatus, bookWithoutStatus])
1117
+
1118
+ // Query for books where status is not 'published'
1119
+ // Note: Records with undefined nested values are not indexed, so they won't appear in neq results
1120
+ // This is because the index only tracks records with defined values
1121
+ const query = { metadata: { status: { neq: 'published' } } }
1122
+ const idsQuery = store.query.ids(
1123
+ 'book',
1124
+ // @ts-expect-error - query is not a valid query expression for books
1125
+ () => query
1126
+ )
1127
+
1128
+ // Should include only books with a defined status that is not 'published'
1129
+ // bookWithoutStatus is not in the index since its status is undefined
1130
+ expect(idsQuery.get()).toEqual(new Set([bookWithDifferentStatus.id]))
1131
+
1132
+ // Update bookWithDifferentStatus to be 'published'
1133
+ store.put([
1134
+ {
1135
+ ...bookWithDifferentStatus,
1136
+ metadata: {
1137
+ ...bookWithDifferentStatus.metadata,
1138
+ status: 'published',
1139
+ },
1140
+ } as any,
1141
+ ])
1142
+
1143
+ // Should now be empty (bookWithoutStatus still not in index)
1144
+ expect(idsQuery.get()).toEqual(new Set())
1145
+
1146
+ // Add a third status to verify the index still works
1147
+ const bookWithArchivedStatus = Book.create({
1148
+ title: 'Book With Archived Status',
1149
+ authorId: authors.bradbury.id,
1150
+ publishedYear: 2023,
1151
+ inStock: true,
1152
+ metadata: {
1153
+ sessionId: 'session:test',
1154
+ status: 'archived',
1155
+ extras: { region: 'us' },
1156
+ },
1157
+ } as any)
1158
+
1159
+ store.put([bookWithArchivedStatus])
1160
+
1161
+ // Should now include only the archived book
1162
+ expect(idsQuery.get()).toEqual(new Set([bookWithArchivedStatus.id]))
1163
+ })
1164
+
1165
+ it('should handle multiple nested criteria with updates', () => {
1166
+ const query = {
1167
+ metadata: {
1168
+ sessionId: { neq: 'session:gamma' },
1169
+ extras: { region: { eq: 'us' } },
1170
+ },
1171
+ }
1172
+ const idsQuery = store.query.ids('book', () => query)
1173
+
1174
+ // Should include books with us region but not session:gamma
1175
+ expect(idsQuery.get()).toEqual(
1176
+ new Set([
1177
+ books.foundation.id,
1178
+ books.neuromancer.id,
1179
+ books.fahrenheit451.id,
1180
+ books.robots.id,
1181
+ ])
1182
+ )
1183
+
1184
+ // Change foundation's region to uk
1185
+ store.put([
1186
+ {
1187
+ ...books.foundation,
1188
+ metadata: {
1189
+ ...books.foundation.metadata,
1190
+ extras: { region: 'uk' },
1191
+ },
1192
+ },
1193
+ ])
1194
+
1195
+ // Should no longer include foundation
1196
+ expect(idsQuery.get()).toEqual(
1197
+ new Set([books.neuromancer.id, books.fahrenheit451.id, books.robots.id])
1198
+ )
1199
+ })
1200
+ })
1201
+
1202
+ describe('multiple subscribers', () => {
1203
+ it('should update all subscribers when records change', () => {
1204
+ const query = { metadata: { sessionId: { eq: 'session:theta' } } }
1205
+ const idsQuery1 = store.query.ids('book', () => query)
1206
+ const idsQuery2 = store.query.ids('book', () => query)
1207
+
1208
+ // Both should be empty initially
1209
+ expect(idsQuery1.get()).toEqual(new Set())
1210
+ expect(idsQuery2.get()).toEqual(new Set())
1211
+
1212
+ // Add a matching book
1213
+ const newBook = Book.create({
1214
+ title: 'Test Book',
1215
+ authorId: authors.asimov.id,
1216
+ publishedYear: 2023,
1217
+ inStock: true,
1218
+ metadata: {
1219
+ sessionId: 'session:theta',
1220
+ extras: { region: 'us' },
1221
+ },
1222
+ })
1223
+
1224
+ store.put([newBook])
1225
+
1226
+ // Both should now include the new book
1227
+ expect(idsQuery1.get()).toEqual(new Set([newBook.id]))
1228
+ expect(idsQuery2.get()).toEqual(new Set([newBook.id]))
1229
+
1230
+ // Remove the book
1231
+ store.remove([newBook.id])
1232
+
1233
+ // Both should be empty again
1234
+ expect(idsQuery1.get()).toEqual(new Set())
1235
+ expect(idsQuery2.get()).toEqual(new Set())
1236
+ })
1237
+
1238
+ it('should handle different queries on same nested properties', () => {
1239
+ const queryAlpha = { metadata: { sessionId: { eq: 'session:alpha' } } }
1240
+ const queryBeta = { metadata: { sessionId: { eq: 'session:beta' } } }
1241
+
1242
+ const idsQueryAlpha = store.query.ids('book', () => queryAlpha)
1243
+ const idsQueryBeta = store.query.ids('book', () => queryBeta)
1244
+
1245
+ expect(idsQueryAlpha.get()).toEqual(
1246
+ new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
1247
+ )
1248
+ expect(idsQueryBeta.get()).toEqual(
1249
+ new Set([books.neuromancer.id, books.dune.id, books.fahrenheit451.id])
1250
+ )
1251
+
1252
+ // Update foundation from alpha to beta
1253
+ store.put([
1254
+ {
1255
+ ...books.foundation,
1256
+ metadata: {
1257
+ sessionId: 'session:beta',
1258
+ extras: { region: 'us' },
1259
+ },
1260
+ },
1261
+ ])
1262
+
1263
+ // Alpha query should no longer include foundation
1264
+ expect(idsQueryAlpha.get()).toEqual(new Set([books.hitchhiker.id, books.robots.id]))
1265
+
1266
+ // Beta query should now include foundation
1267
+ expect(idsQueryBeta.get()).toEqual(
1268
+ new Set([books.foundation.id, books.neuromancer.id, books.dune.id, books.fahrenheit451.id])
1269
+ )
1270
+ })
1271
+ })
1272
+
1273
+ describe('batch operations', () => {
1274
+ it('should handle batch updates affecting nested queries', () => {
1275
+ const query = { metadata: { extras: { region: { eq: 'canada' } } } }
1276
+ const idsQuery = store.query.ids('book', () => query)
1277
+
1278
+ expect(idsQuery.get()).toEqual(new Set())
1279
+
1280
+ // Batch update multiple books to have canada region
1281
+ store.put([
1282
+ {
1283
+ ...books.foundation,
1284
+ metadata: {
1285
+ sessionId: 'session:alpha',
1286
+ extras: { region: 'canada' },
1287
+ },
1288
+ },
1289
+ {
1290
+ ...books.neuromancer,
1291
+ metadata: {
1292
+ sessionId: 'session:beta',
1293
+ extras: { region: 'canada' },
1294
+ },
1295
+ },
1296
+ {
1297
+ ...books.dune,
1298
+ metadata: {
1299
+ sessionId: 'session:beta',
1300
+ extras: { region: 'canada' },
1301
+ },
1302
+ },
1303
+ ])
1304
+
1305
+ // Should include all three books
1306
+ expect(idsQuery.get()).toEqual(
1307
+ new Set([books.foundation.id, books.neuromancer.id, books.dune.id])
1308
+ )
1309
+
1310
+ // Batch update to remove them all
1311
+ store.put([
1312
+ {
1313
+ ...books.foundation,
1314
+ metadata: {
1315
+ sessionId: 'session:alpha',
1316
+ extras: { region: 'us' },
1317
+ },
1318
+ },
1319
+ {
1320
+ ...books.neuromancer,
1321
+ metadata: {
1322
+ sessionId: 'session:beta',
1323
+ extras: { region: 'us' },
1324
+ },
1325
+ },
1326
+ {
1327
+ ...books.dune,
1328
+ metadata: {
1329
+ sessionId: 'session:beta',
1330
+ extras: { region: 'eu' },
1331
+ },
1332
+ },
1333
+ ])
1334
+
1335
+ // Should be empty again
1336
+ expect(idsQuery.get()).toEqual(new Set())
1337
+ })
1338
+
1339
+ it('should handle mixed batch operations (add, update, remove)', () => {
1340
+ const query = { metadata: { sessionId: { eq: 'session:mixed' } } }
1341
+ const idsQuery = store.query.ids('book', () => query)
1342
+
1343
+ expect(idsQuery.get()).toEqual(new Set())
1344
+
1345
+ const newBook1 = Book.create({
1346
+ title: 'New Book 1',
1347
+ authorId: authors.asimov.id,
1348
+ publishedYear: 2023,
1349
+ inStock: true,
1350
+ metadata: {
1351
+ sessionId: 'session:mixed',
1352
+ extras: { region: 'us' },
1353
+ },
1354
+ })
1355
+
1356
+ const newBook2 = Book.create({
1357
+ title: 'New Book 2',
1358
+ authorId: authors.gibson.id,
1359
+ publishedYear: 2023,
1360
+ inStock: true,
1361
+ metadata: {
1362
+ sessionId: 'session:mixed',
1363
+ extras: { region: 'us' },
1364
+ },
1365
+ })
1366
+
1367
+ // Add new books and update existing one in a single batch
1368
+ store.put([
1369
+ newBook1,
1370
+ newBook2,
1371
+ {
1372
+ ...books.foundation,
1373
+ metadata: {
1374
+ sessionId: 'session:mixed',
1375
+ extras: { region: 'us' },
1376
+ },
1377
+ },
1378
+ ])
1379
+
1380
+ expect(idsQuery.get()).toEqual(new Set([newBook1.id, newBook2.id, books.foundation.id]))
1381
+
1382
+ // Remove one new book, update another, and revert foundation
1383
+ store.remove([newBook1.id])
1384
+ store.put([
1385
+ {
1386
+ ...newBook2,
1387
+ metadata: {
1388
+ sessionId: 'session:different',
1389
+ extras: { region: 'us' },
1390
+ },
1391
+ },
1392
+ {
1393
+ ...books.foundation,
1394
+ metadata: {
1395
+ sessionId: 'session:alpha',
1396
+ extras: { region: 'us' },
1397
+ },
1398
+ },
1399
+ ])
1400
+
1401
+ // Should now be empty
1402
+ expect(idsQuery.get()).toEqual(new Set())
1403
+ })
1404
+ })
1405
+ })