@tldraw/store 5.2.0-next.ee0fa4d6244f → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/DOCS.md +790 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/ImmutableMap.js +0 -13
  5. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  6. package/dist-cjs/lib/RecordType.js +1 -1
  7. package/dist-cjs/lib/RecordType.js.map +2 -2
  8. package/dist-cjs/lib/RecordsDiff.js +26 -14
  9. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  10. package/dist-cjs/lib/Store.js +6 -5
  11. package/dist-cjs/lib/Store.js.map +2 -2
  12. package/dist-cjs/lib/StoreSchema.js +1 -1
  13. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  14. package/dist-cjs/lib/executeQuery.js +1 -1
  15. package/dist-cjs/lib/executeQuery.js.map +2 -2
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/ImmutableMap.mjs +0 -13
  18. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  19. package/dist-esm/lib/RecordType.mjs +1 -1
  20. package/dist-esm/lib/RecordType.mjs.map +2 -2
  21. package/dist-esm/lib/RecordsDiff.mjs +26 -14
  22. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  23. package/dist-esm/lib/Store.mjs +7 -6
  24. package/dist-esm/lib/Store.mjs.map +2 -2
  25. package/dist-esm/lib/StoreSchema.mjs +1 -1
  26. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  27. package/dist-esm/lib/executeQuery.mjs +1 -1
  28. package/dist-esm/lib/executeQuery.mjs.map +2 -2
  29. package/package.json +9 -5
  30. package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
  31. package/src/lib/AtomSet.test.ts +116 -0
  32. package/src/lib/BaseRecord.test.ts +10 -22
  33. package/src/lib/ImmutableMap.test.ts +114 -71
  34. package/src/lib/ImmutableMap.ts +1 -0
  35. package/src/lib/IncrementalSetConstructor.test.ts +76 -81
  36. package/src/lib/RecordType.test.ts +216 -0
  37. package/src/lib/RecordType.ts +1 -1
  38. package/src/lib/RecordsDiff.test.ts +112 -106
  39. package/src/lib/RecordsDiff.ts +43 -18
  40. package/src/lib/Store.test.ts +570 -630
  41. package/src/lib/Store.ts +9 -10
  42. package/src/lib/StoreListeners.test.ts +462 -0
  43. package/src/lib/StoreQueries.test.ts +586 -434
  44. package/src/lib/StoreSchema.test.ts +1012 -174
  45. package/src/lib/StoreSchema.ts +1 -1
  46. package/src/lib/StoreSideEffects.test.ts +546 -158
  47. package/src/lib/devFreeze.test.ts +94 -124
  48. package/src/lib/executeQuery.test.ts +77 -31
  49. package/src/lib/executeQuery.ts +3 -1
  50. package/src/lib/migrate.test.ts +273 -296
  51. package/src/lib/setUtils.test.ts +38 -79
  52. package/src/lib/test/createMigrations.test.ts +0 -75
  53. package/src/lib/test/dependsOn.test.ts +0 -166
  54. package/src/lib/test/getMigrationsSince.test.ts +0 -121
  55. package/src/lib/test/migrate.test.ts +0 -118
  56. package/src/lib/test/migratePersistedRecord.test.ts +0 -265
  57. package/src/lib/test/migrationCaching.test.ts +0 -209
  58. package/src/lib/test/recordStore.test.ts +0 -1567
  59. package/src/lib/test/recordStoreQueries.test.ts +0 -814
  60. package/src/lib/test/recordType.test.ts +0 -19
  61. package/src/lib/test/sortMigrations.test.ts +0 -83
  62. package/src/lib/test/upgradeSchema.test.ts +0 -80
  63. package/src/lib/test/validate.test.ts +0 -178
  64. package/src/lib/test/validateMigrations.test.ts +0 -165
@@ -1,121 +1,67 @@
1
- import { atom, computed, RESET_VALUE } from '@tldraw/state'
1
+ import { atom, computed, EMPTY_ARRAY, RESET_VALUE } from '@tldraw/state'
2
2
  import { beforeEach, describe, expect, it } from 'vitest'
3
3
  import { BaseRecord, RecordId } from './BaseRecord'
4
4
  import { createRecordType } from './RecordType'
5
5
  import { Store } from './Store'
6
- import { StoreQueries } from './StoreQueries'
7
6
  import { StoreSchema } from './StoreSchema'
8
7
 
9
- // Test record types
8
+ // Tests for SPEC.md §11 (filtered history), §12 (indexes), and §13 (id, record, and exec
9
+ // queries). Rule IDs like [QI3] in test names refer to that document.
10
+
10
11
  interface Author extends BaseRecord<'author', RecordId<Author>> {
11
12
  name: string
12
13
  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
14
  }
25
-
26
15
  const Author = createRecordType<Author>('author', {
27
16
  validator: {
28
17
  validate(value) {
29
18
  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')
19
+ if (author.typeName !== 'author') throw Error()
20
+ if (!author.id.startsWith('author:')) throw Error()
21
+ if (!Number.isFinite(author.age)) throw Error()
22
+ if (author.age < 0) throw Error()
35
23
  return author
36
24
  },
37
25
  },
38
26
  scope: 'document',
39
- }).withDefaultProperties(() => ({ age: 25, isActive: true, publishedBooks: 0 }))
27
+ }).withDefaultProperties(() => ({ age: 23 }))
40
28
 
29
+ interface Book extends BaseRecord<'book', RecordId<Book>> {
30
+ title: string
31
+ authorId: RecordId<Author>
32
+ }
41
33
  const Book = createRecordType<Book>('book', {
42
34
  validator: {
43
35
  validate(value) {
44
36
  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')
37
+ if (!book.id.startsWith('book:')) throw Error()
38
+ if (book.typeName !== 'book') throw Error()
39
+ if (typeof book.title !== 'string') throw Error()
40
+ if (!book.authorId.startsWith('author')) throw Error()
51
41
  return book
52
42
  },
53
43
  },
54
44
  scope: 'document',
55
- }).withDefaultProperties(() => ({ rating: 0, category: 'fiction' }))
45
+ })
56
46
 
57
- // Test data
58
47
  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 }),
48
+ tolkein: Author.create({ name: 'J.R.R. Tolkein' }),
49
+ bradbury: Author.create({ name: 'Ray Bradbury' }),
50
+ davidMitchellSerious: Author.create({ name: 'David Mitchell' }),
51
+ davidMitchellFunny: Author.create({ name: 'David Mitchell' }),
64
52
  }
65
53
 
66
54
  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',
55
+ cloudAtlas: Book.create({ title: 'Cloud Atlas', authorId: authors.davidMitchellSerious.id }),
56
+ myLifeInComedy: Book.create({
57
+ title: 'My Life in Comedy',
58
+ authorId: authors.davidMitchellFunny.id,
114
59
  }),
60
+ lotr: Book.create({ title: 'Lord of the Rings', authorId: authors.tolkein.id }),
61
+ farenheit: Book.create({ title: 'Farenheit 451', authorId: authors.bradbury.id }),
115
62
  }
116
63
 
117
64
  let store: Store<Author | Book>
118
- let queries: StoreQueries<Author | Book>
119
65
 
120
66
  beforeEach(() => {
121
67
  store = new Store({
@@ -125,503 +71,709 @@ beforeEach(() => {
125
71
  book: Book,
126
72
  }),
127
73
  })
128
- queries = store.query
129
-
130
- // Populate store with test data
131
- store.put([...Object.values(authors), ...Object.values(books)])
74
+ store.put([
75
+ authors.tolkein,
76
+ authors.bradbury,
77
+ books.lotr,
78
+ books.farenheit,
79
+ authors.davidMitchellFunny,
80
+ authors.davidMitchellSerious,
81
+ books.cloudAtlas,
82
+ books.myLifeInComedy,
83
+ ])
132
84
  })
133
85
 
134
- describe('filterHistory method', () => {
135
- it('should filter changes by type correctly', () => {
136
- const authorHistory = queries.filterHistory('author')
137
- const initialEpoch = authorHistory.get()
86
+ describe('filtered history (QH)', () => {
87
+ it('[QH1] is cached per type name', () => {
88
+ expect(store.query.filterHistory('author')).toBe(store.query.filterHistory('author'))
89
+ })
138
90
 
139
- // Add a new author
140
- const newAuthor = Author.create({ name: 'New Author', age: 40 })
141
- store.put([newAuthor])
91
+ it('[QH1] contains only changes to records of the given type', () => {
92
+ const authorHistory = store.query.filterHistory('author')
142
93
 
143
- const afterAddEpoch = authorHistory.get()
144
- expect(afterAddEpoch).toBeGreaterThan(initialEpoch)
94
+ authorHistory.get()
145
95
 
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])
96
+ let lastChangedEpoch = authorHistory.lastChangedEpoch
156
97
 
157
- // Author history should not change when book is added
158
- expect(authorHistory.lastChangedEpoch).toBe(beforeBookAdd)
159
- })
98
+ expect(authorHistory.getDiffSince(lastChangedEpoch - 1)).toBe(RESET_VALUE)
99
+
100
+ // updating an author changes the history
101
+ store.put([{ ...authors.davidMitchellFunny, age: 30 }])
160
102
 
161
- it('should handle record updates in filtered history', () => {
162
- const authorHistory = queries.filterHistory('author')
163
103
  authorHistory.get()
164
104
 
165
- const lastChangedEpoch = authorHistory.lastChangedEpoch
105
+ expect(lastChangedEpoch).toBeLessThan(authorHistory.lastChangedEpoch)
166
106
 
167
- // Update an author
168
- store.put([{ ...authors.asimov, age: 73 }])
107
+ expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
108
+ { updated: { [authors.davidMitchellFunny.id]: [{ age: 23 }, { age: 30 }] } },
109
+ ])
169
110
 
170
- // Access the computed to trigger update
171
- authorHistory.get()
111
+ lastChangedEpoch = authorHistory.lastChangedEpoch
172
112
 
173
- expect(lastChangedEpoch).toBeLessThan(authorHistory.lastChangedEpoch)
113
+ // adding and removing authors shows up too
114
+ const newAuthor = Author.create({ name: 'Stanley Briggs' })
115
+ store.put([newAuthor])
116
+ store.remove([authors.bradbury.id])
174
117
 
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
- }
118
+ expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
119
+ {
120
+ added: { [newAuthor.id]: { name: 'Stanley Briggs' } },
121
+ removed: { [authors.bradbury.id]: { name: 'Ray Bradbury' } },
122
+ },
123
+ ])
181
124
  })
182
125
 
183
- it('should handle record removals in filtered history', () => {
184
- const authorHistory = queries.filterHistory('author')
126
+ it('[QH3] changes to other types produce no observable change', () => {
127
+ const authorHistory = store.query.filterHistory('author')
185
128
  authorHistory.get()
186
129
 
187
130
  const lastChangedEpoch = authorHistory.lastChangedEpoch
188
131
 
189
- // Remove an author
190
- store.remove([authors.bradbury.id])
132
+ store.put([
133
+ { ...books.lotr, title: 'The Lord of the Rings Part I: The Fellowship of the Ring' },
134
+ ])
191
135
 
192
- // Access the computed to trigger update
193
136
  authorHistory.get()
194
137
 
195
- expect(lastChangedEpoch).toBeLessThan(authorHistory.lastChangedEpoch)
138
+ expect(authorHistory.lastChangedEpoch).toEqual(lastChangedEpoch)
139
+ expect(authorHistory.getDiffSince(lastChangedEpoch)).toEqual([])
140
+ })
196
141
 
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
- }
142
+ it('[QH2] changes that cancel out in a window produce no change', () => {
143
+ const authorHistory = store.query.filterHistory('author')
144
+
145
+ const epoch = authorHistory.get()
146
+ const newAuthor = Author.create({ name: 'Stanley Briggs' })
147
+
148
+ store.put([newAuthor])
149
+ store.put([{ ...newAuthor, age: 38 }])
150
+ store.remove([newAuthor.id])
151
+
152
+ expect(authorHistory.get()).toEqual(epoch)
153
+
154
+ store.remove([authors.tolkein.id])
155
+ store.put([authors.tolkein])
156
+
157
+ expect(authorHistory.get()).toEqual(epoch)
158
+ })
159
+
160
+ it('[QH2] update entries are removed when the record is deleted', () => {
161
+ const authorHistory = store.query.filterHistory('author')
162
+
163
+ authorHistory.get()
164
+
165
+ const lastChangedEpoch = authorHistory.lastChangedEpoch
166
+
167
+ store.put([{ ...authors.davidMitchellFunny, age: 38 }])
168
+ store.put([{ ...authors.davidMitchellFunny, age: 343 }])
169
+ store.remove([authors.davidMitchellFunny.id])
170
+
171
+ expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
172
+ {
173
+ // shows the original, not the updated version
174
+ removed: { [authors.davidMitchellFunny.id]: { age: 23 } },
175
+ },
176
+ ])
203
177
  })
204
178
 
205
- it('should collapse add and remove operations in same batch', () => {
206
- const authorHistory = queries.filterHistory('author')
179
+ it('[QH2] multiple update entries collapse into one', () => {
180
+ const authorHistory = store.query.filterHistory('author')
181
+
207
182
  authorHistory.get()
208
183
 
209
- const lastEpoch = authorHistory.lastChangedEpoch
210
- const tempAuthor = Author.create({ name: 'Temp Author' })
184
+ const lastChangedEpoch = authorHistory.lastChangedEpoch
211
185
 
212
- // Add and then remove in same batch
213
- store.put([tempAuthor])
214
- store.remove([tempAuthor.id])
186
+ store.put([{ ...authors.davidMitchellFunny, age: 38 }])
187
+ store.put([{ ...authors.davidMitchellFunny, age: 343 }])
215
188
 
216
- // Should not change history if add and remove cancel out
217
- const diff = authorHistory.getDiffSince(lastEpoch)
218
- expect(diff).toEqual([])
189
+ expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
190
+ {
191
+ updated: { [authors.davidMitchellFunny.id]: [{ age: 23 }, { age: 343 }] },
192
+ },
193
+ ])
219
194
  })
220
195
 
221
- it('should handle complex diff sequences', () => {
222
- const authorHistory = queries.filterHistory('author')
196
+ it('[QH2] an add followed by an update folds into the add', () => {
197
+ const authorHistory = store.query.filterHistory('author')
198
+
223
199
  authorHistory.get()
224
200
 
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
- }
201
+ const lastChangedEpoch = authorHistory.lastChangedEpoch
202
+
203
+ const newAuthor = Author.create({ name: 'Stanley Briggs' })
204
+
205
+ store.put([newAuthor])
206
+ store.put([{ ...newAuthor, age: 38 }])
207
+
208
+ expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
209
+ {
210
+ added: { [newAuthor.id]: { age: 38 } },
211
+ },
212
+ ])
246
213
  })
247
214
  })
248
215
 
249
- describe('index method', () => {
250
- it('should create correct index mappings', () => {
251
- const nameIndex = queries.index('author', 'name')
252
- const index = nameIndex.get()
216
+ describe('indexes (QI)', () => {
217
+ it('[QI2] are cached per type and property', () => {
218
+ expect(store.query.index('author', 'name')).toBe(store.query.index('author', 'name'))
219
+ expect(store.query.index('author', 'name')).not.toBe(store.query.index('author', 'age'))
220
+ })
221
+
222
+ it('[QI1] map each property value to the ids of records with that value', () => {
223
+ const authorNameIndex = store.query.index('author', 'name')
224
+
225
+ expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
226
+ expect(authorNameIndex.get().get('David Mitchell')).toEqual(
227
+ new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
228
+ )
229
+ expect(authorNameIndex.get().get('Nonexistent')).toBeUndefined()
253
230
 
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()
231
+ const bookTitleIndex = store.query.index('book', 'title')
232
+ expect(bookTitleIndex.get().get('Cloud Atlas')).toEqual(new Set([books.cloudAtlas.id]))
233
+ expect(bookTitleIndex.get().get('Lord of the Rings')).toEqual(new Set([books.lotr.id]))
257
234
  })
258
235
 
259
- it('should update indexes when records are added', () => {
260
- const nameIndex = queries.index('author', 'name')
261
- const _initialIndex = nameIndex.get()
236
+ it('[QI1] records whose value is undefined are not indexed', () => {
237
+ const newAuthor = { ...Author.create({ name: 'No Name' }), name: undefined } as any
238
+ const index = store.query.index('author', 'name')
239
+ store.put([newAuthor])
240
+ expect(index.get().get(undefined)).toBeUndefined()
241
+ })
242
+
243
+ it('[QI3] adding records updates the index, with diffs', () => {
244
+ const authorNameIndex = store.query.index('author', 'name')
245
+ // deref to make it compute once
246
+ expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
262
247
 
248
+ let lastChangedEpoch = authorNameIndex.lastChangedEpoch
263
249
  const newAuthor = Author.create({ name: 'New Author' })
264
- const lastEpoch = nameIndex.lastChangedEpoch
265
250
 
266
251
  store.put([newAuthor])
267
252
 
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]))
253
+ expect(authorNameIndex.get().get('New Author')).toEqual(new Set([newAuthor.id]))
272
254
 
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
- })
255
+ const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
256
+ if (diff === RESET_VALUE) throw new Error('should not be reset')
257
+ expect(diff).toHaveLength(1)
258
+ expect(diff[0].get('New Author')).toEqual({
259
+ added: new Set([newAuthor.id]),
260
+ })
261
+ expect(diff[0].size).toBe(1)
280
262
 
281
- it('should update indexes when records are updated', () => {
282
- const nameIndex = queries.index('author', 'name')
283
- nameIndex.get() // Initialize
263
+ const moreNewAuthors = [
264
+ Author.create({ name: 'New Author' }),
265
+ Author.create({ name: 'New Author' }),
266
+ Author.create({ name: 'New Author' }),
267
+ ]
284
268
 
285
- const lastEpoch = nameIndex.lastChangedEpoch
269
+ lastChangedEpoch = authorNameIndex.lastChangedEpoch
270
+ store.put(moreNewAuthors)
286
271
 
287
- // Update author name
288
- store.put([{ ...authors.asimov, name: 'Dr. Isaac Asimov' }])
272
+ expect(authorNameIndex.get().get('New Author')).toEqual(
273
+ new Set([newAuthor.id, ...moreNewAuthors.map((a) => a.id)])
274
+ )
289
275
 
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]))
276
+ const diff2 = authorNameIndex.getDiffSince(lastChangedEpoch)
295
277
 
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
- }
278
+ if (diff2 === RESET_VALUE) throw new Error('should not be reset')
279
+
280
+ expect(diff2).toHaveLength(1)
281
+ expect(diff2[0].get('New Author')).toEqual({ added: new Set(moreNewAuthors.map((a) => a.id)) })
303
282
  })
304
- })
305
283
 
306
- describe('record method', () => {
307
- it('should return single matching record', () => {
308
- const asimovQuery = queries.record('author', () => ({
309
- name: { eq: 'Isaac Asimov' },
310
- }))
284
+ it('[QI3] updating a record moves its id between value sets', () => {
285
+ const authorNameIndex = store.query.index('author', 'name')
286
+
287
+ expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
311
288
 
312
- expect(asimovQuery.get()).toEqual(authors.asimov)
289
+ const lastChangedEpoch = authorNameIndex.lastChangedEpoch
290
+
291
+ store.put([{ ...authors.bradbury, name: 'J.R.R. Tolkein' }])
292
+
293
+ expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(
294
+ new Set([authors.tolkein.id, authors.bradbury.id])
295
+ )
296
+
297
+ const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
298
+ if (diff === RESET_VALUE) throw new Error('should not be reset')
299
+ expect(diff).toHaveLength(1)
300
+ expect(diff[0].size).toBe(2)
301
+ expect(diff[0].get('J.R.R. Tolkein')).toEqual({
302
+ added: new Set([authors.bradbury.id]),
303
+ })
304
+ expect(diff[0].get('Ray Bradbury')).toEqual({
305
+ removed: new Set([authors.bradbury.id]),
306
+ })
313
307
  })
314
308
 
315
- it('should return undefined when no match found', () => {
316
- const nonexistentQuery = queries.record('author', () => ({
317
- name: { eq: 'Nonexistent Author' },
318
- }))
309
+ it('[QI3] removing records updates the index; empty value sets are dropped', () => {
310
+ const authorNameIndex = store.query.index('author', 'name')
311
+
312
+ expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
313
+
314
+ let lastChangedEpoch = authorNameIndex.lastChangedEpoch
315
+
316
+ store.remove([authors.tolkein.id])
317
+
318
+ expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(undefined)
319
+
320
+ const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
321
+ if (diff === RESET_VALUE) throw new Error('should not be reset')
322
+ expect(diff).toHaveLength(1)
323
+ expect(diff[0].size).toBe(1)
324
+ expect(diff[0].get('J.R.R. Tolkein')).toEqual({
325
+ removed: new Set([authors.tolkein.id]),
326
+ })
327
+
328
+ lastChangedEpoch = authorNameIndex.lastChangedEpoch
329
+ store.remove([
330
+ authors.bradbury.id,
331
+ authors.davidMitchellFunny.id,
332
+ authors.davidMitchellSerious.id,
333
+ ])
334
+
335
+ expect(authorNameIndex.get().get('Ray Bradbury')).toEqual(undefined)
336
+ expect(authorNameIndex.get().get('David Mitchell')).toEqual(undefined)
337
+
338
+ const diff2 = authorNameIndex.getDiffSince(lastChangedEpoch)
339
+
340
+ if (diff2 === RESET_VALUE) throw new Error('should not be reset')
319
341
 
320
- expect(nonexistentQuery.get()).toBeUndefined()
342
+ expect(diff2).toHaveLength(1)
343
+ expect(diff2[0].size).toBe(2)
344
+
345
+ expect(diff2[0].get('Ray Bradbury')).toEqual({
346
+ removed: new Set([authors.bradbury.id]),
347
+ })
348
+ expect(diff2[0].get('David Mitchell')).toEqual({
349
+ removed: new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id]),
350
+ })
321
351
  })
322
352
 
323
- it('should update reactively when matching record changes', () => {
324
- const query = queries.record('author', () => ({
325
- name: { eq: 'Isaac Asimov' },
326
- }))
353
+ it('[QI3] removals and additions for the same value combine in one diff', () => {
354
+ const authorNameIndex = store.query.index('author', 'name')
355
+ expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
356
+
357
+ let lastChangedEpoch = authorNameIndex.lastChangedEpoch
358
+
359
+ // do remove first
360
+ store.remove([authors.tolkein.id])
361
+ const newAuthor = Author.create({ name: 'J.R.R. Tolkein' })
362
+ store.put([newAuthor])
363
+
364
+ expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([newAuthor.id]))
365
+
366
+ const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
367
+
368
+ if (diff === RESET_VALUE) throw new Error('should not be reset')
369
+
370
+ expect(diff).toHaveLength(1)
371
+ expect(diff[0].size).toBe(1)
327
372
 
328
- expect(query.get()?.age).toBe(72)
373
+ expect(diff[0].get('J.R.R. Tolkein')).toEqual({
374
+ removed: new Set([authors.tolkein.id]),
375
+ added: new Set([newAuthor.id]),
376
+ })
377
+
378
+ lastChangedEpoch = authorNameIndex.lastChangedEpoch
379
+ // do updates
380
+ store.put([{ ...authors.davidMitchellFunny, name: 'Ray Bradbury' }])
381
+ store.put([{ ...authors.bradbury, name: 'David Mitchell' }])
382
+
383
+ expect(authorNameIndex.get().get('Ray Bradbury')).toEqual(
384
+ new Set([authors.davidMitchellFunny.id])
385
+ )
386
+ expect(authorNameIndex.get().get('David Mitchell')).toEqual(
387
+ new Set([authors.bradbury.id, authors.davidMitchellSerious.id])
388
+ )
329
389
 
330
- // Update the matching record
331
- store.put([{ ...authors.asimov, age: 73 }])
390
+ const diff2 = authorNameIndex.getDiffSince(lastChangedEpoch)
332
391
 
333
- expect(query.get()?.age).toBe(73)
392
+ if (diff2 === RESET_VALUE) throw new Error('should not be reset')
393
+
394
+ expect(diff2).toHaveLength(1)
395
+ expect(diff2[0].size).toBe(2)
396
+
397
+ expect(diff2[0].get('Ray Bradbury')).toEqual({
398
+ added: new Set([authors.davidMitchellFunny.id]),
399
+ removed: new Set([authors.bradbury.id]),
400
+ })
401
+ expect(diff2[0].get('David Mitchell')).toEqual({
402
+ added: new Set([authors.bradbury.id]),
403
+ removed: new Set([authors.davidMitchellFunny.id]),
404
+ })
334
405
  })
335
406
 
336
- it('should handle reactive query parameters', () => {
337
- const targetName = atom('targetName', 'Isaac Asimov')
407
+ it('[QI4] keeps the same map object if the indexed values did not change', () => {
408
+ const authorNameIndex = store.query.index('author', 'name')
338
409
 
339
- const query = queries.record('author', () => ({
340
- name: { eq: targetName.get() },
341
- }))
410
+ const value = authorNameIndex.get()
411
+ const lastChangedEpoch = authorNameIndex.lastChangedEpoch
342
412
 
343
- expect(query.get()).toEqual(authors.asimov)
413
+ // change a property the index does not cover
414
+ store.put([{ ...authors.tolkein, age: 23 }])
415
+ store.put([{ ...authors.tolkein, age: 99 }])
344
416
 
345
- targetName.set('William Gibson')
346
- expect(query.get()).toEqual(authors.gibson)
417
+ expect(authorNameIndex.get()).toBe(value)
418
+ expect(authorNameIndex.lastChangedEpoch).toBe(lastChangedEpoch)
347
419
  })
348
420
 
349
- it('should handle complex query conditions', () => {
350
- const query = queries.record('author', () => ({
351
- age: { gt: 70 },
352
- isActive: { eq: true },
353
- }))
421
+ it('[QI5] a backslash-delimited property indexes a nested path', () => {
422
+ interface Doc extends BaseRecord<'doc', RecordId<Doc>> {
423
+ metadata: { sessionId?: string }
424
+ }
425
+ const Doc = createRecordType<Doc>('doc', { scope: 'document' })
426
+ const docStore = new Store({
427
+ props: {},
428
+ schema: StoreSchema.create<Doc>({ doc: Doc }),
429
+ })
354
430
 
355
- const result = query.get()
356
- expect(result).toBeDefined()
357
- expect(result!.age).toBeGreaterThan(70)
358
- expect(result!.isActive).toBe(true)
431
+ const docA = Doc.create({ metadata: { sessionId: 'alpha' } })
432
+ const docB = Doc.create({ metadata: { sessionId: 'beta' } })
433
+ const docNone = Doc.create({ metadata: {} })
434
+ docStore.put([docA, docB, docNone])
435
+
436
+ const index = docStore.query.index('doc', 'metadata\\sessionId')
437
+ expect(index.get().get('alpha')).toEqual(new Set([docA.id]))
438
+ expect(index.get().get('beta')).toEqual(new Set([docB.id]))
439
+ // missing nested values are not indexed
440
+ expect(index.get().get(undefined)).toBeUndefined()
441
+
442
+ // incremental updates work through the nested path
443
+ docStore.update(docA.id, (d) => ({ ...d, metadata: { sessionId: 'beta' } }))
444
+ expect(index.get().get('alpha')).toBeUndefined()
445
+ expect(index.get().get('beta')).toEqual(new Set([docB.id, docA.id]))
359
446
  })
360
447
  })
361
448
 
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()
449
+ describe('id queries (QQ)', () => {
450
+ it('[QQ1] with no query, contains all ids of the type', () => {
451
+ const bookQuery = store.query.ids('book')
366
452
 
367
- expect(books).toHaveLength(6)
368
- expect(books.every((book) => book.typeName === 'book')).toBe(true)
453
+ expect(bookQuery.get()).toEqual(
454
+ new Set([books.cloudAtlas.id, books.farenheit.id, books.lotr.id, books.myLifeInComedy.id])
455
+ )
456
+
457
+ const authorQuery = store.query.ids('author')
458
+
459
+ expect(authorQuery.get()).toEqual(
460
+ new Set([
461
+ authors.bradbury.id,
462
+ authors.davidMitchellFunny.id,
463
+ authors.davidMitchellSerious.id,
464
+ authors.tolkein.id,
465
+ ])
466
+ )
467
+
468
+ const newAuthor = Author.create({ name: 'J.R.R. Tolkein' })
469
+ const newBook = Book.create({ title: 'The Hobbit', authorId: newAuthor.id })
470
+ store.put([newAuthor, newBook])
471
+
472
+ expect(bookQuery.get()).toContain(newBook.id)
473
+ expect(authorQuery.get()).toContain(newAuthor.id)
369
474
  })
370
475
 
371
- it('should filter records by query conditions', () => {
372
- const inStockBooks = queries.records('book', () => ({
373
- inStock: { eq: true },
476
+ it('[QQ1] filters ids by the query expression', () => {
477
+ const jrr = store.query.ids('author', () => ({
478
+ name: { eq: 'J.R.R. Tolkein' },
374
479
  }))
480
+ expect(jrr.get()).toEqual(new Set([authors.tolkein.id]))
375
481
 
376
- const books = inStockBooks.get()
377
- expect(books.every((book) => book.inStock === true)).toBe(true)
378
- expect(books.length).toBeGreaterThan(0)
482
+ const mitchell = store.query.ids('author', () => ({
483
+ name: { eq: 'David Mitchell' },
484
+ }))
485
+ expect(mitchell.get()).toEqual(
486
+ new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
487
+ )
488
+
489
+ store.put([{ ...authors.davidMitchellFunny, age: 30 }])
490
+ const mitchell30 = store.query.ids('author', () => ({
491
+ name: { eq: 'David Mitchell' },
492
+ age: { eq: 30 },
493
+ }))
494
+ expect(mitchell30.get()).toEqual(new Set([authors.davidMitchellFunny.id]))
379
495
  })
380
496
 
381
- it('should handle multiple query conditions', () => {
382
- const query = queries.records('book', () => ({
383
- category: { eq: 'sci-fi' },
384
- rating: { eq: 5 },
497
+ it('[QQ1] emits collection diffs as records change', () => {
498
+ const mitchell = store.query.ids('author', () => ({
499
+ name: { eq: 'David Mitchell' },
385
500
  }))
386
501
 
387
- const books = query.get()
388
- expect(books.every((book) => book.category === 'sci-fi' && book.rating === 5)).toBe(true)
502
+ mitchell.get()
503
+ const lastEpoch = mitchell.lastChangedEpoch
504
+
505
+ const newAuthor = Author.create({ name: 'David Mitchell' })
506
+ store.put([newAuthor])
507
+ store.remove([authors.davidMitchellFunny.id])
508
+
509
+ const diff = mitchell.getDiffSince(lastEpoch)
510
+ if (diff === RESET_VALUE) throw new Error('should not be reset')
511
+ expect(diff).toHaveLength(1)
512
+ expect(diff[0]).toEqual({
513
+ added: new Set([newAuthor.id]),
514
+ removed: new Set([authors.davidMitchellFunny.id]),
515
+ })
389
516
  })
390
517
 
391
- it('should update reactively when records are added', () => {
392
- const allBooks = queries.records('book')
393
- const initialCount = allBooks.get().length
518
+ it('[QQ2] a reactive query expression rebuilds with a correct diff when it changes', () => {
519
+ store.put([{ ...authors.davidMitchellFunny, age: 30 }])
520
+
521
+ const currentAuthor = atom('currentAuthor', 'David Mitchell')
522
+ const currentAge = atom('currentAge', 30)
523
+
524
+ const mitchell30 = store.query.ids('author', () => ({
525
+ name: { eq: currentAuthor.get() },
526
+ age: { eq: currentAge.get() },
527
+ }))
528
+
529
+ expect(mitchell30.get()).toEqual(new Set([authors.davidMitchellFunny.id]))
530
+
531
+ let lastChangedEpoch = mitchell30.lastChangedEpoch
532
+ currentAge.set(23)
533
+
534
+ expect(mitchell30.get()).toEqual(new Set([authors.davidMitchellSerious.id]))
535
+
536
+ const diff = mitchell30.getDiffSince(lastChangedEpoch)
537
+
538
+ if (diff === RESET_VALUE) throw new Error('should not be reset')
394
539
 
395
- const newBook = Book.create({
396
- title: 'New Book',
397
- authorId: authors.asimov.id,
398
- publishedYear: 2023,
399
- inStock: true,
400
- rating: 3,
540
+ expect(diff).toHaveLength(1)
541
+ expect(diff[0]).toEqual({
542
+ added: new Set([authors.davidMitchellSerious.id]),
543
+ removed: new Set([authors.davidMitchellFunny.id]),
401
544
  })
402
545
 
403
- store.put([newBook])
546
+ currentAuthor.set('J.R.R. Tolkein')
404
547
 
405
- expect(allBooks.get()).toHaveLength(initialCount + 1)
406
- expect(allBooks.get().some((book) => book.id === newBook.id)).toBe(true)
407
- })
548
+ lastChangedEpoch = mitchell30.lastChangedEpoch
549
+
550
+ expect(mitchell30.get()).toEqual(new Set([authors.tolkein.id]))
408
551
 
409
- it('should update reactively when records are removed', () => {
410
- const allBooks = queries.records('book')
411
- const initialCount = allBooks.get().length
552
+ const diff2 = mitchell30.getDiffSince(lastChangedEpoch)
412
553
 
413
- store.remove([books.dune.id])
554
+ if (diff2 === RESET_VALUE) throw new Error('should not be reset')
414
555
 
415
- expect(allBooks.get()).toHaveLength(initialCount - 1)
416
- expect(allBooks.get().some((book) => book.id === books.dune.id)).toBe(false)
556
+ expect(diff2).toHaveLength(1)
557
+ expect(diff2[0]).toEqual({
558
+ added: new Set([authors.tolkein.id]),
559
+ removed: new Set([authors.davidMitchellSerious.id]),
560
+ })
417
561
  })
418
562
 
419
- it('should update reactively when records are updated', () => {
420
- const inStockBooks = queries.records('book', () => ({
421
- inStock: { eq: true },
563
+ it('[QQ1] supports not-equals matches', () => {
564
+ store.put([{ ...authors.davidMitchellFunny, age: 30 }])
565
+ const mitchell = store.query.ids('author', () => ({
566
+ name: { neq: 'David Mitchell' },
422
567
  }))
423
568
 
424
- const initialBooks = inStockBooks.get()
425
- const initialCount = initialBooks.length
569
+ expect(mitchell.get()).toEqual(new Set([authors.tolkein.id, authors.bradbury.id]))
426
570
 
427
- // Update a book to be out of stock
428
- store.put([{ ...books.foundation, inStock: false }])
571
+ const ageNot23 = store.query.ids('author', () => ({
572
+ age: { neq: 23 },
573
+ }))
429
574
 
430
- const updatedBooks = inStockBooks.get()
431
- expect(updatedBooks).toHaveLength(initialCount - 1)
432
- expect(updatedBooks.some((book) => book.id === books.foundation.id)).toBe(false)
575
+ expect(ageNot23.get()).toEqual(new Set([authors.davidMitchellFunny.id]))
433
576
  })
434
577
 
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() },
578
+ it('[QQ1] updates as records are added, removed, and updated', () => {
579
+ const mitchell = store.query.ids('author', () => ({
580
+ name: { eq: 'David Mitchell' },
581
+ age: { neq: 30 },
440
582
  }))
441
583
 
442
- const sciFiBooks = query.get()
443
- expect(sciFiBooks.every((book) => book.category === 'sci-fi')).toBe(true)
584
+ expect(mitchell.get()).toEqual(
585
+ new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
586
+ )
444
587
 
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
- })
588
+ store.put([{ ...authors.davidMitchellFunny, age: 30 }])
589
+ expect(mitchell.get()).toEqual(new Set([authors.davidMitchellSerious.id]))
452
590
 
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()
591
+ store.put([{ ...authors.davidMitchellFunny, age: 23 }])
592
+ expect(mitchell.get()).toEqual(
593
+ new Set([authors.davidMitchellSerious.id, authors.davidMitchellFunny.id])
594
+ )
457
595
 
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
- })
596
+ store.remove([authors.davidMitchellFunny.id])
597
+ expect(mitchell.get()).toEqual(new Set([authors.davidMitchellSerious.id]))
598
+
599
+ const newAuthor = Author.create({ name: 'David Mitchell' })
600
+ store.put([newAuthor])
601
+ expect(mitchell.get()).toEqual(new Set([authors.davidMitchellSerious.id, newAuthor.id]))
463
602
  })
464
603
 
465
- it('should filter IDs by query conditions', () => {
466
- const highRatedIds = queries.ids('book', () => ({
467
- rating: { eq: 5 },
604
+ it('[QQ3] does not change if unrelated records are added, updated, or removed', () => {
605
+ const mitchell = store.query.ids('author', () => ({
606
+ name: { eq: 'David Mitchell' },
468
607
  }))
469
608
 
470
- const ids = highRatedIds.get()
471
- expect(ids).toBeInstanceOf(Set)
609
+ expect(mitchell.get()).toEqual(
610
+ new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
611
+ )
472
612
 
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
- })
613
+ const lastChangedEpoch = mitchell.lastChangedEpoch
614
+
615
+ const newAuthor = Author.create({ name: 'William Shakespeare' })
616
+ store.put([newAuthor])
617
+
618
+ mitchell.get()
619
+ expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
620
+
621
+ store.remove([authors.tolkein.id])
622
+
623
+ mitchell.get()
624
+ expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
478
625
  })
479
626
 
480
- it('should update with collection diffs when records change', () => {
481
- const inStockIds = queries.ids('book', () => ({
482
- inStock: { eq: true },
627
+ it('[QQ3] does not change if matching records change in irrelevant ways', () => {
628
+ const mitchell = store.query.ids('author', () => ({
629
+ name: { eq: 'David Mitchell' },
483
630
  }))
484
631
 
485
- const _initialIds = inStockIds.get()
486
- const lastEpoch = inStockIds.lastChangedEpoch
632
+ expect(mitchell.get()).toEqual(
633
+ new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
634
+ )
487
635
 
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])
636
+ const lastChangedEpoch = mitchell.lastChangedEpoch
497
637
 
498
- const updatedIds = inStockIds.get()
499
- expect(updatedIds.has(newBook.id)).toBe(true)
638
+ store.put([{ ...authors.davidMitchellFunny, age: 30 }])
500
639
 
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
- })
640
+ mitchell.get()
641
+ expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
508
642
 
509
- it('should handle reactive query parameters with from-scratch rebuild', () => {
510
- const targetYear = atom('targetYear', 1950)
643
+ // a change that does affect the query, just to check
644
+ store.put([{ ...authors.davidMitchellFunny, name: 'steve' }])
511
645
 
512
- const query = queries.ids('book', () => ({
513
- publishedYear: { gt: targetYear.get() },
646
+ mitchell.get()
647
+ expect(mitchell.lastChangedEpoch).toBeGreaterThan(lastChangedEpoch)
648
+ })
649
+
650
+ it('[QQ3] round trips (remove/add-back, add/remove) leave the set unchanged', () => {
651
+ const mitchell = store.query.ids('author', () => ({
652
+ name: { eq: 'David Mitchell' },
514
653
  }))
515
654
 
516
- const initialIds = query.get()
517
- const initialSize = initialIds.size
655
+ expect(mitchell.get()).toEqual(
656
+ new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
657
+ )
518
658
 
519
- // Change query parameter should trigger from-scratch rebuild
520
- const lastEpoch = query.lastChangedEpoch
521
- targetYear.set(1970)
659
+ const lastChangedEpoch = mitchell.lastChangedEpoch
522
660
 
523
- const updatedIds = query.get()
524
- expect(query.lastChangedEpoch).toBeGreaterThan(lastEpoch)
661
+ store.remove([authors.davidMitchellFunny.id])
662
+ store.put([authors.davidMitchellFunny])
525
663
 
526
- // Should have fewer results with higher year threshold
527
- expect(updatedIds.size).toBeLessThan(initialSize)
664
+ mitchell.get()
665
+ expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
528
666
 
529
- updatedIds.forEach((id) => {
530
- const book = store.get(id)!
531
- expect(book.publishedYear).toBeGreaterThan(1970)
532
- })
667
+ const newMitchell = Author.create({ name: 'David Mitchell' })
668
+ store.put([newMitchell])
669
+ store.remove([newMitchell.id])
670
+
671
+ mitchell.get()
672
+ expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
533
673
  })
674
+ })
675
+
676
+ const bookComparator = (a: Book, b: Book) => a.title.localeCompare(b.title)
677
+
678
+ describe('record queries (QQ)', () => {
679
+ it('[QQ4] records() returns all matching records', () => {
680
+ const allBooks = store.query.records('book')
681
+
682
+ expect(allBooks.get().sort(bookComparator)).toEqual(
683
+ [books.cloudAtlas, books.farenheit, books.lotr, books.myLifeInComedy].sort(bookComparator)
684
+ )
534
685
 
535
- it('should efficiently handle incremental updates', () => {
536
- const query = queries.ids('book', () => ({
537
- inStock: { eq: true },
686
+ const farenheit = store.query.records('book', () => ({
687
+ title: { eq: 'Farenheit 451' },
538
688
  }))
689
+ expect(farenheit.get()).toEqual([books.farenheit])
539
690
 
540
- query.get() // Initialize
541
- const lastEpoch = query.lastChangedEpoch
691
+ const funnyGuide = Book.create({
692
+ title: 'How to be a funny man',
693
+ authorId: authors.davidMitchellFunny.id,
694
+ })
695
+ store.put([funnyGuide])
696
+ const mitchell = store.query.records('book', () => ({
697
+ authorId: { eq: authors.davidMitchellFunny.id },
698
+ }))
699
+ expect(mitchell.get().sort(bookComparator)).toEqual(
700
+ [books.myLifeInComedy, funnyGuide].sort(bookComparator)
701
+ )
702
+ })
542
703
 
543
- // Update a book's stock status
544
- store.put([{ ...books.dune, inStock: true }])
704
+ it('[QQ4] records() keeps the same array when the members did not change', () => {
705
+ const allBooks = store.query.records('book')
706
+ const value = allBooks.get()
545
707
 
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
- }
708
+ // updating an author does not change the book array members
709
+ store.put([{ ...authors.tolkein, age: 50 }])
710
+ expect(allBooks.get()).toBe(value)
552
711
  })
553
- })
554
712
 
555
- describe('exec method', () => {
556
- it('should execute non-reactive queries', () => {
557
- const result = queries.exec('author', {
558
- isActive: { eq: true },
559
- })
713
+ it('[QQ4] records() supports reactive query expressions', () => {
714
+ const currentAuthor = atom('currentAuthor', authors.davidMitchellFunny.id)
715
+ const booksQuery = store.query.records('book', () => ({
716
+ authorId: { eq: currentAuthor.get() },
717
+ }))
718
+
719
+ expect(booksQuery.get()).toEqual([books.myLifeInComedy])
720
+
721
+ currentAuthor.set(authors.tolkein.id)
560
722
 
561
- expect(Array.isArray(result)).toBe(true)
562
- expect(result.every((author) => author.isActive === true)).toBe(true)
723
+ expect(booksQuery.get()).toEqual([books.lotr])
563
724
  })
564
725
 
565
- it('should handle complex query conditions', () => {
566
- const result = queries.exec('book', {
567
- rating: { eq: 5 },
568
- category: { eq: 'sci-fi' },
569
- })
726
+ it('[QQ4] record() returns one matching record or undefined', () => {
727
+ const farenheit = store.query.record('book', () => ({
728
+ title: { eq: 'Farenheit 451' },
729
+ }))
730
+ expect(farenheit.get()).toEqual(books.farenheit)
570
731
 
571
- expect(result.every((book) => book.rating === 5 && book.category === 'sci-fi')).toBe(true)
572
- expect(result.length).toBeGreaterThan(0)
732
+ const nonexistent = store.query.record('book', () => ({
733
+ title: { eq: 'No Such Book' },
734
+ }))
735
+ expect(nonexistent.get()).toBeUndefined()
573
736
  })
574
- })
575
737
 
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
- })
738
+ it('[QQ4] record() updates reactively', () => {
739
+ const tolkein = store.query.record('author', () => ({
740
+ name: { eq: 'J.R.R. Tolkein' },
741
+ }))
742
+ expect(tolkein.get()?.age).toBe(23)
583
743
 
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])
744
+ store.put([{ ...authors.tolkein, age: 81 }])
745
+ expect(tolkein.get()?.age).toBe(81)
597
746
 
598
- expect(booksByCategory.get()).toBe(initialCount + 1)
747
+ store.remove([authors.tolkein.id])
748
+ expect(tolkein.get()).toBeUndefined()
599
749
  })
600
750
 
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
- }
751
+ it('[QQ5] exec runs a one-shot, non-reactive query', () => {
752
+ const mitchells = store.query.exec('author', { name: { eq: 'David Mitchell' } })
753
+ expect(new Set(mitchells)).toEqual(
754
+ new Set([authors.davidMitchellFunny, authors.davidMitchellSerious])
755
+ )
609
756
 
610
- return counts
611
- })
757
+ // not reactive: subsequent store changes don't affect the returned array
758
+ store.put([Author.create({ name: 'David Mitchell' })])
759
+ expect(mitchells).toHaveLength(2)
760
+ })
612
761
 
613
- const asimovInitialCount = authorBookCounts.get().get(authors.asimov.id) || 0
762
+ it('[QQ5] exec returns the shared empty array when nothing matches', () => {
763
+ expect(store.query.exec('author', { name: { eq: 'No Such Author' } })).toBe(EMPTY_ARRAY)
764
+ })
765
+ })
614
766
 
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,
767
+ describe('integration with reactive state (QI, QQ)', () => {
768
+ it('[QI3] computed values see index updates', () => {
769
+ const tolkeinBookCount = computed('tolkein-books', () => {
770
+ const byAuthor = store.query.index('book', 'authorId')
771
+ return byAuthor.get().get(authors.tolkein.id)?.size ?? 0
622
772
  })
623
- store.put([newAsimovBook])
624
773
 
625
- expect(authorBookCounts.get().get(authors.asimov.id)).toBe(asimovInitialCount + 1)
774
+ expect(tolkeinBookCount.get()).toBe(1)
775
+
776
+ store.put([Book.create({ title: 'The Hobbit', authorId: authors.tolkein.id })])
777
+ expect(tolkeinBookCount.get()).toBe(2)
626
778
  })
627
779
  })