@tldraw/store 5.2.0-next.ee0fa4d6244f → 5.2.1

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
@@ -1,141 +1,111 @@
1
- import { afterAll, beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'
1
+ import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'
2
2
  import { devFreeze } from './devFreeze'
3
3
  import { isDev } from './isDev'
4
4
 
5
- // Mock process.env for testing
6
- const originalEnv = process.env.NODE_ENV
5
+ // Tests for SPEC.md §24 (devFreeze).
6
+ // Rule IDs like [DF1] in test names refer to that document.
7
+
7
8
  vi.mock('./isDev', () => ({
8
9
  isDev: vi.fn(() => true),
9
10
  }))
10
11
 
11
- describe('devFreeze', () => {
12
+ describe('devFreeze in dev/test builds (DF)', () => {
12
13
  beforeEach(() => {
13
- // Reset any mocks
14
14
  vi.restoreAllMocks()
15
+ ;(isDev as MockedFunction<typeof isDev>).mockReturnValue(true)
16
+ })
17
+
18
+ it('[DF1] recursively freezes the object and returns it', () => {
19
+ const obj = {
20
+ a: 1,
21
+ b: { c: 2, d: { e: 3 } },
22
+ f: [1, { g: 4 }],
23
+ }
24
+
25
+ const result = devFreeze(obj)
26
+
27
+ expect(result).toBe(obj)
28
+ expect(Object.isFrozen(result)).toBe(true)
29
+ expect(Object.isFrozen(result.b)).toBe(true)
30
+ expect(Object.isFrozen(result.b.d)).toBe(true)
31
+ expect(Object.isFrozen(result.f)).toBe(true)
32
+ expect(Object.isFrozen(result.f[1])).toBe(true)
33
+ })
34
+
35
+ it('[DF1] mutating a frozen object afterwards throws in strict mode', () => {
36
+ 'use strict'
37
+ const obj = devFreeze({ a: 1 })
38
+ expect(() => {
39
+ ;(obj as any).a = 2
40
+ }).toThrow()
41
+ })
42
+
43
+ it('[DF2] allows arrays, plain objects, null-prototype objects, and structured-clone objects', () => {
44
+ const obj = { a: 1 }
45
+ expect(() => devFreeze(obj)).not.toThrow()
46
+ expect(Object.isFrozen(obj)).toBe(true)
47
+
48
+ const nullProtoObj = Object.create(null)
49
+ nullProtoObj.a = 1
50
+ expect(() => devFreeze(nullProtoObj)).not.toThrow()
51
+ expect(Object.isFrozen(nullProtoObj)).toBe(true)
52
+
53
+ const arr = [1, 2, 3]
54
+ expect(() => devFreeze(arr)).not.toThrow()
55
+ expect(Object.isFrozen(arr)).toBe(true)
56
+
57
+ const cloned = structuredClone({ a: 1 })
58
+ expect(() => devFreeze(cloned)).not.toThrow()
59
+ expect(Object.isFrozen(cloned)).toBe(true)
15
60
  })
16
61
 
17
- describe('production mode behavior', () => {
18
- beforeEach(() => {
19
- // Mock production environment
20
- ;(isDev as MockedFunction<typeof isDev>).mockReturnValue(false)
21
- })
22
-
23
- it('should return objects unchanged in production mode', () => {
24
- const obj = { a: 1, b: { c: 2 } }
25
- const result = devFreeze(obj)
26
-
27
- expect(result).toBe(obj) // Same reference
28
- expect(Object.isFrozen(result)).toBe(false)
29
- expect(Object.isFrozen(result.b)).toBe(false)
30
- })
31
-
32
- it('should not validate prototypes in production mode', () => {
33
- const _consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
34
-
35
- // Create object with custom prototype that would normally throw
36
- class CustomClass {
37
- value = 42
38
- }
39
- const obj = new CustomClass()
40
-
41
- expect(() => devFreeze(obj)).not.toThrow()
42
- expect(_consoleSpy).not.toHaveBeenCalled()
43
- })
62
+ it('[DF2] throws for objects with non-plain prototypes', () => {
63
+ vi.spyOn(console, 'error').mockImplementation(() => {})
64
+
65
+ class CustomClass {
66
+ value = 42
67
+ }
68
+ expect(() => devFreeze(new CustomClass())).toThrow('cannot include non-js data in a record')
69
+ expect(() => devFreeze(new Date())).toThrow('cannot include non-js data in a record')
70
+ expect(() => devFreeze(/regex/)).toThrow('cannot include non-js data in a record')
71
+ expect(() => devFreeze(new Map())).toThrow('cannot include non-js data in a record')
72
+ expect(() => devFreeze(new Set())).toThrow('cannot include non-js data in a record')
73
+
74
+ // also when nested inside an otherwise valid object
75
+ expect(() => devFreeze({ valid: { a: 1 }, invalid: new Date() })).toThrow(
76
+ 'cannot include non-js data in a record'
77
+ )
44
78
  })
45
79
 
46
- describe('development mode behavior', () => {
47
- beforeEach(() => {
48
- // Mock development environment
49
- vi.stubGlobal('process', { env: { NODE_ENV: 'development' } })
50
- })
51
-
52
- it('should freeze objects recursively', () => {
53
- const obj = {
54
- a: 1,
55
- b: {
56
- c: 2,
57
- d: {
58
- e: 3,
59
- },
60
- },
61
- f: [1, { g: 4 }],
62
- }
63
-
64
- const result = devFreeze(obj)
65
-
66
- expect(result).toBe(obj) // Same reference
67
- expect(Object.isFrozen(result)).toBe(true)
68
- expect(Object.isFrozen(result.b)).toBe(true)
69
- expect(Object.isFrozen(result.b.d)).toBe(true)
70
- expect(Object.isFrozen(result.f)).toBe(true)
71
- expect(Object.isFrozen(result.f[1])).toBe(true)
72
- })
73
-
74
- it('should reject primitives', () => {
75
- const _consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
76
-
77
- // Primitives have built-in prototypes that aren't allowed
78
- expect(() => devFreeze('string')).toThrow('cannot include non-js data in a record')
79
- expect(() => devFreeze(42)).toThrow('cannot include non-js data in a record')
80
- expect(() => devFreeze(true)).toThrow('cannot include non-js data in a record')
81
-
82
- // null and undefined cause TypeError when Object.getPrototypeOf is called
83
- expect(() => devFreeze(null)).toThrow('Cannot convert undefined or null to object')
84
- expect(() => devFreeze(undefined)).toThrow('Cannot convert undefined or null to object')
85
- })
86
-
87
- it('should allow valid prototypes', () => {
88
- // Object.prototype
89
- const obj = { a: 1 }
90
- expect(() => devFreeze(obj)).not.toThrow()
91
- expect(Object.isFrozen(obj)).toBe(true)
92
-
93
- // null prototype
94
- const nullProtoObj = Object.create(null)
95
- nullProtoObj.a = 1
96
- expect(() => devFreeze(nullProtoObj)).not.toThrow()
97
- expect(Object.isFrozen(nullProtoObj)).toBe(true)
98
-
99
- // Arrays
100
- const arr = [1, 2, 3]
101
- expect(() => devFreeze(arr)).not.toThrow()
102
- expect(Object.isFrozen(arr)).toBe(true)
103
-
104
- // structuredClone objects
105
- const cloned = structuredClone({ a: 1 })
106
- expect(() => devFreeze(cloned)).not.toThrow()
107
- expect(Object.isFrozen(cloned)).toBe(true)
108
- })
109
-
110
- it('should reject invalid prototypes', () => {
111
- const _consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
112
-
113
- // Custom class instances
114
- class CustomClass {
115
- value = 42
116
- }
117
- expect(() => devFreeze(new CustomClass())).toThrow('cannot include non-js data in a record')
118
-
119
- // Built-in object types
120
- expect(() => devFreeze(new Date())).toThrow('cannot include non-js data in a record')
121
- expect(() => devFreeze(/regex/)).toThrow('cannot include non-js data in a record')
122
- expect(() => devFreeze(new Map())).toThrow('cannot include non-js data in a record')
123
- expect(() => devFreeze(new Set())).toThrow('cannot include non-js data in a record')
124
-
125
- // Nested invalid objects
126
- const objWithInvalidNested = {
127
- valid: { a: 1 },
128
- invalid: new Date(),
129
- }
130
- expect(() => devFreeze(objWithInvalidNested)).toThrow(
131
- 'cannot include non-js data in a record'
132
- )
133
- })
80
+ it('[DF2] throws for primitives, whose prototypes are not plain', () => {
81
+ expect(() => devFreeze('string')).toThrow('cannot include non-js data in a record')
82
+ expect(() => devFreeze(42)).toThrow('cannot include non-js data in a record')
83
+ expect(() => devFreeze(true)).toThrow('cannot include non-js data in a record')
134
84
  })
85
+ })
86
+
87
+ describe('devFreeze in production builds (DF)', () => {
88
+ beforeEach(() => {
89
+ vi.restoreAllMocks()
90
+ ;(isDev as MockedFunction<typeof isDev>).mockReturnValue(false)
91
+ })
92
+
93
+ it('[DF3] returns the object unchanged without freezing', () => {
94
+ const obj = { a: 1, b: { c: 2 } }
95
+ const result = devFreeze(obj)
96
+
97
+ expect(result).toBe(obj)
98
+ expect(Object.isFrozen(result)).toBe(false)
99
+ expect(Object.isFrozen(result.b)).toBe(false)
100
+ })
101
+
102
+ it('[DF3] does not validate prototypes', () => {
103
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
135
104
 
136
- // Clean up after all tests
137
- afterAll(() => {
138
- // Restore original environment
139
- vi.stubGlobal('process', { env: { NODE_ENV: originalEnv } })
105
+ class CustomClass {
106
+ value = 42
107
+ }
108
+ expect(() => devFreeze(new CustomClass())).not.toThrow()
109
+ expect(consoleSpy).not.toHaveBeenCalled()
140
110
  })
141
111
  })
@@ -5,6 +5,10 @@ import { createRecordType } from './RecordType'
5
5
  import { Store } from './Store'
6
6
  import { StoreSchema } from './StoreSchema'
7
7
 
8
+ // Tests for SPEC.md §14 (query execution). Rule IDs like [QE1] in describe names refer to
9
+ // that document. The "reactive nested queries" section also exercises §12's nested-path
10
+ // indexes ([QI5]) through the reactive ids() machinery.
11
+
8
12
  // Test record types
9
13
  interface Author extends BaseRecord<'author', RecordId<Author>> {
10
14
  name: string
@@ -299,8 +303,8 @@ beforeEach(() => {
299
303
  store.put([...Object.values(authors), ...Object.values(books), ...Object.values(reviews)])
300
304
  })
301
305
 
302
- describe('objectMatchesQuery', () => {
303
- describe('equality matching (eq)', () => {
306
+ describe('objectMatchesQuery (QE)', () => {
307
+ describe('[QE1] equality matching (eq)', () => {
304
308
  it('should match when property equals the target value', () => {
305
309
  const book = books.foundation
306
310
  const query = { inStock: { eq: true } }
@@ -316,7 +320,7 @@ describe('objectMatchesQuery', () => {
316
320
  })
317
321
  })
318
322
 
319
- describe('inequality matching (neq)', () => {
323
+ describe('[QE1] inequality matching (neq)', () => {
320
324
  it('should match when property does not equal the target value', () => {
321
325
  const book = books.foundation // category: 'sci-fi'
322
326
  const query = { category: { neq: 'romance' } }
@@ -330,9 +334,22 @@ describe('objectMatchesQuery', () => {
330
334
 
331
335
  expect(objectMatchesQuery(query, book)).toBe(false)
332
336
  })
337
+
338
+ it('should not match when the property is missing, mirroring the indexes', () => {
339
+ const bookWithoutStatus = books.foundation // metadata.status is undefined
340
+ const query = { metadata: { status: { neq: 'published' } } }
341
+
342
+ expect(
343
+ objectMatchesQuery(
344
+ // @ts-expect-error - status is optional so the matcher type rejects it
345
+ query,
346
+ bookWithoutStatus
347
+ )
348
+ ).toBe(false)
349
+ })
333
350
  })
334
351
 
335
- describe('greater than matching (gt)', () => {
352
+ describe('[QE1] greater than matching (gt)', () => {
336
353
  it('should match when numeric property is greater than target', () => {
337
354
  const book = books.neuromancer // publishedYear: 1984
338
355
  const query = { publishedYear: { gt: 1980 } }
@@ -355,7 +372,7 @@ describe('objectMatchesQuery', () => {
355
372
  })
356
373
  })
357
374
 
358
- describe('multiple criteria matching', () => {
375
+ describe('[QE2] multiple criteria matching', () => {
359
376
  it('should match when all criteria are satisfied', () => {
360
377
  const book = books.foundation // inStock: true, publishedYear: 1951, category: 'sci-fi'
361
378
  const query = {
@@ -379,7 +396,7 @@ describe('objectMatchesQuery', () => {
379
396
  })
380
397
  })
381
398
 
382
- describe('nested object matching', () => {
399
+ describe('[QE3] nested object matching', () => {
383
400
  it('should match when nested property satisfies criteria', () => {
384
401
  const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
385
402
 
@@ -410,7 +427,7 @@ describe('objectMatchesQuery', () => {
410
427
  })
411
428
  })
412
429
 
413
- describe('edge cases', () => {
430
+ describe('[QE3] [QE4] edge cases', () => {
414
431
  it('should return true for empty query', () => {
415
432
  const book = books.foundation
416
433
  const query = {}
@@ -433,8 +450,8 @@ describe('objectMatchesQuery', () => {
433
450
  })
434
451
  })
435
452
 
436
- describe('executeQuery', () => {
437
- describe('equality queries (eq)', () => {
453
+ describe('executeQuery (QE)', () => {
454
+ describe('[QE1] [QE5] equality queries (eq)', () => {
438
455
  it('should find records with matching string values', () => {
439
456
  const query = { category: { eq: 'sci-fi' } }
440
457
  const result = executeQuery(store.query, 'book', query)
@@ -466,7 +483,7 @@ describe('executeQuery', () => {
466
483
  })
467
484
  })
468
485
 
469
- describe('inequality queries (neq)', () => {
486
+ describe('[QE1] [QE5] inequality queries (neq)', () => {
470
487
  it('should find records that do not match string values', () => {
471
488
  const query = { category: { neq: 'sci-fi' } }
472
489
  const result = executeQuery(store.query, 'book', query)
@@ -481,7 +498,7 @@ describe('executeQuery', () => {
481
498
  })
482
499
  })
483
500
 
484
- describe('greater than queries (gt)', () => {
501
+ describe('[QE1] [QE5] greater than queries (gt)', () => {
485
502
  it('should find records with values greater than threshold', () => {
486
503
  const query = { publishedYear: { gt: 1970 } }
487
504
  const result = executeQuery(store.query, 'book', query)
@@ -499,7 +516,7 @@ describe('executeQuery', () => {
499
516
  })
500
517
  })
501
518
 
502
- describe('combined queries', () => {
519
+ describe('[QE2] combined queries', () => {
503
520
  it('should handle mixed query types', () => {
504
521
  const query = {
505
522
  inStock: { eq: true },
@@ -519,7 +536,7 @@ describe('executeQuery', () => {
519
536
  })
520
537
  })
521
538
 
522
- describe('nested object queries', () => {
539
+ describe('[QE3] [QE5] nested object queries', () => {
523
540
  it('should filter records using nested properties', () => {
524
541
  const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
525
542
  const result = executeQuery(store.query, 'book', query)
@@ -558,7 +575,7 @@ describe('executeQuery', () => {
558
575
  })
559
576
  })
560
577
 
561
- describe('edge cases', () => {
578
+ describe('[QE4] edge cases', () => {
562
579
  it('should handle empty query', () => {
563
580
  const query = {}
564
581
  const result = executeQuery(store.query, 'book', query)
@@ -586,7 +603,7 @@ describe('executeQuery', () => {
586
603
  })
587
604
  })
588
605
 
589
- describe('store integration', () => {
606
+ describe('[QE5] store integration', () => {
590
607
  it('should update results when store changes', () => {
591
608
  const query = { category: { eq: 'mystery' } }
592
609
 
@@ -612,8 +629,8 @@ describe('executeQuery', () => {
612
629
  })
613
630
  })
614
631
 
615
- describe('reactive nested queries', () => {
616
- describe('adding records', () => {
632
+ describe('reactive nested queries (QE3, QI5, QQ)', () => {
633
+ describe('[QQ1] adding records', () => {
617
634
  it('should include newly added record that matches nested query', () => {
618
635
  const query = { metadata: { sessionId: { eq: 'session:delta' } } }
619
636
  const idsQuery = store.query.ids('book', () => query)
@@ -709,7 +726,7 @@ describe('reactive nested queries', () => {
709
726
  })
710
727
  })
711
728
 
712
- describe('removing records', () => {
729
+ describe('[QQ1] [QQ3] removing records', () => {
713
730
  it('should remove record from results when it is deleted', () => {
714
731
  const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
715
732
  const idsQuery = store.query.ids('book', () => query)
@@ -729,14 +746,14 @@ describe('reactive nested queries', () => {
729
746
  const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
730
747
  const idsQuery = store.query.ids('book', () => query)
731
748
 
732
- const initialIds = new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
733
- expect(idsQuery.get()).toEqual(initialIds)
749
+ const before = idsQuery.get()
750
+ expect(before).toEqual(new Set([books.foundation.id, books.hitchhiker.id, books.robots.id]))
734
751
 
735
752
  // Remove a non-matching book
736
753
  store.remove([books.neuromancer.id])
737
754
 
738
- // Results should be unchanged
739
- expect(idsQuery.get()).toEqual(initialIds)
755
+ // the same Set object stays in place — downstream consumers observe no change
756
+ expect(idsQuery.get()).toBe(before)
740
757
  })
741
758
 
742
759
  it('should handle removing all matching records', () => {
@@ -755,7 +772,7 @@ describe('reactive nested queries', () => {
755
772
  })
756
773
  })
757
774
 
758
- describe('updating records', () => {
775
+ describe('[QQ1] [QQ3] updating records', () => {
759
776
  it('should add record to results when nested property is updated to match', () => {
760
777
  const query = { metadata: { sessionId: { eq: 'session:omega' } } }
761
778
  const idsQuery = store.query.ids('book', () => query)
@@ -842,8 +859,8 @@ describe('reactive nested queries', () => {
842
859
  const query = { metadata: { sessionId: { eq: 'session:alpha' } } }
843
860
  const idsQuery = store.query.ids('book', () => query)
844
861
 
845
- const initialIds = new Set([books.foundation.id, books.hitchhiker.id, books.robots.id])
846
- expect(idsQuery.get()).toEqual(initialIds)
862
+ const before = idsQuery.get()
863
+ expect(before).toEqual(new Set([books.foundation.id, books.hitchhiker.id, books.robots.id]))
847
864
 
848
865
  // Update a non-nested property (title) but keep nested property the same
849
866
  store.put([
@@ -853,12 +870,12 @@ describe('reactive nested queries', () => {
853
870
  },
854
871
  ])
855
872
 
856
- // Results should be unchanged
857
- expect(idsQuery.get()).toEqual(initialIds)
873
+ // the same Set object stays in place — downstream consumers observe no change
874
+ expect(idsQuery.get()).toBe(before)
858
875
  })
859
876
  })
860
877
 
861
- describe('combined nested and top-level queries', () => {
878
+ describe('[QE2] [QE3] combined nested and top-level queries', () => {
862
879
  it('should correctly update when top-level property changes', () => {
863
880
  const query = {
864
881
  inStock: { eq: true },
@@ -982,7 +999,7 @@ describe('reactive nested queries', () => {
982
999
  })
983
1000
  })
984
1001
 
985
- describe('query operators with nested properties', () => {
1002
+ describe('[QE1] [QI5] query operators with nested properties', () => {
986
1003
  it('should handle gt operator on nested properties', () => {
987
1004
  // Add some books with different copy counts
988
1005
  const bookLowCopies = Book.create({
@@ -1162,6 +1179,35 @@ describe('reactive nested queries', () => {
1162
1179
  expect(idsQuery.get()).toEqual(new Set([bookWithArchivedStatus.id]))
1163
1180
  })
1164
1181
 
1182
+ it('[QE5] from-scratch and incremental evaluation agree for neq with missing values', () => {
1183
+ const bookWithoutStatus = Book.create({
1184
+ title: 'Book Without Status',
1185
+ authorId: authors.herbert.id,
1186
+ publishedYear: 2022,
1187
+ inStock: true,
1188
+ metadata: {
1189
+ sessionId: 'session:test',
1190
+ extras: { region: 'us' },
1191
+ },
1192
+ })
1193
+ store.put([bookWithoutStatus])
1194
+
1195
+ const query = { metadata: { status: { neq: 'published' } } }
1196
+ const idsQuery = store.query.ids(
1197
+ 'book',
1198
+ // @ts-expect-error - status is optional so the matcher type rejects it
1199
+ () => query
1200
+ )
1201
+
1202
+ // from scratch: the record without a status is not included
1203
+ expect(idsQuery.get().has(bookWithoutStatus.id)).toBe(false)
1204
+
1205
+ // an unrelated update to that record must not change its membership
1206
+ // through the incremental path either
1207
+ store.put([{ ...bookWithoutStatus, title: 'Renamed' }])
1208
+ expect(idsQuery.get().has(bookWithoutStatus.id)).toBe(false)
1209
+ })
1210
+
1165
1211
  it('should handle multiple nested criteria with updates', () => {
1166
1212
  const query = {
1167
1213
  metadata: {
@@ -1199,7 +1245,7 @@ describe('reactive nested queries', () => {
1199
1245
  })
1200
1246
  })
1201
1247
 
1202
- describe('multiple subscribers', () => {
1248
+ describe('[QQ1] multiple subscribers', () => {
1203
1249
  it('should update all subscribers when records change', () => {
1204
1250
  const query = { metadata: { sessionId: { eq: 'session:theta' } } }
1205
1251
  const idsQuery1 = store.query.ids('book', () => query)
@@ -1270,7 +1316,7 @@ describe('reactive nested queries', () => {
1270
1316
  })
1271
1317
  })
1272
1318
 
1273
- describe('batch operations', () => {
1319
+ describe('[QQ1] batch operations', () => {
1274
1320
  it('should handle batch updates affecting nested queries', () => {
1275
1321
  const query = { metadata: { extras: { region: { eq: 'canada' } } } }
1276
1322
  const idsQuery = store.query.ids('book', () => query)
@@ -90,7 +90,9 @@ export function objectMatchesQuery<T extends object>(query: QueryExpression<T>,
90
90
  // matching logic
91
91
  if (isQueryValueMatcher(matcher)) {
92
92
  if ('eq' in matcher && value !== matcher.eq) return false
93
- if ('neq' in matcher && value === matcher.neq) return false
93
+ // undefined values must not match neq: the indexes executeQuery reads only
94
+ // track defined values, and the two matching strategies have to agree
95
+ if ('neq' in matcher && (value === matcher.neq || value === undefined)) return false
94
96
  if ('gt' in matcher && (typeof value !== 'number' || value <= matcher.gt)) return false
95
97
  continue
96
98
  }