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