@tldraw/store 4.1.0-canary.c62140a07605 → 4.1.0-canary.caaa99b713a2
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,827 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { BaseRecord, RecordId } from './BaseRecord'
|
|
3
|
+
import { RecordsDiff } from './RecordsDiff'
|
|
4
|
+
import { createRecordType } from './RecordType'
|
|
5
|
+
import { createComputedCache, Store } from './Store'
|
|
6
|
+
import { StoreSchema } from './StoreSchema'
|
|
7
|
+
|
|
8
|
+
// Test record types
|
|
9
|
+
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
|
10
|
+
title: string
|
|
11
|
+
author: RecordId<Author>
|
|
12
|
+
numPages: number
|
|
13
|
+
genre?: string
|
|
14
|
+
inStock?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const Book = createRecordType<Book>('book', {
|
|
18
|
+
validator: {
|
|
19
|
+
validate: (record: unknown): Book => {
|
|
20
|
+
if (!record || typeof record !== 'object') {
|
|
21
|
+
throw new Error('Book record must be an object')
|
|
22
|
+
}
|
|
23
|
+
const r = record as any
|
|
24
|
+
if (typeof r.id !== 'string' || !r.id.startsWith('book:')) {
|
|
25
|
+
throw new Error('Book must have valid id starting with "book:"')
|
|
26
|
+
}
|
|
27
|
+
if (r.typeName !== 'book') {
|
|
28
|
+
throw new Error('Book typeName must be "book"')
|
|
29
|
+
}
|
|
30
|
+
if (typeof r.title !== 'string' || r.title.trim().length === 0) {
|
|
31
|
+
throw new Error('Book must have non-empty title string')
|
|
32
|
+
}
|
|
33
|
+
if (typeof r.author !== 'string' || !r.author.startsWith('author:')) {
|
|
34
|
+
throw new Error('Book must have valid author RecordId')
|
|
35
|
+
}
|
|
36
|
+
if (typeof r.numPages !== 'number' || r.numPages < 1) {
|
|
37
|
+
throw new Error('Book numPages must be positive number')
|
|
38
|
+
}
|
|
39
|
+
if (r.genre !== undefined && typeof r.genre !== 'string') {
|
|
40
|
+
throw new Error('Book genre must be string if provided')
|
|
41
|
+
}
|
|
42
|
+
if (r.inStock !== undefined && typeof r.inStock !== 'boolean') {
|
|
43
|
+
throw new Error('Book inStock must be boolean if provided')
|
|
44
|
+
}
|
|
45
|
+
return r as Book
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
scope: 'document',
|
|
49
|
+
}).withDefaultProperties(() => ({
|
|
50
|
+
inStock: true,
|
|
51
|
+
numPages: 100,
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
interface Author extends BaseRecord<'author', RecordId<Author>> {
|
|
55
|
+
name: string
|
|
56
|
+
isPseudonym: boolean
|
|
57
|
+
birthYear?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const Author = createRecordType<Author>('author', {
|
|
61
|
+
validator: {
|
|
62
|
+
validate: (record: unknown): Author => {
|
|
63
|
+
if (!record || typeof record !== 'object') {
|
|
64
|
+
throw new Error('Author record must be an object')
|
|
65
|
+
}
|
|
66
|
+
const r = record as any
|
|
67
|
+
if (typeof r.id !== 'string' || !r.id.startsWith('author:')) {
|
|
68
|
+
throw new Error('Author must have valid id starting with "author:"')
|
|
69
|
+
}
|
|
70
|
+
if (r.typeName !== 'author') {
|
|
71
|
+
throw new Error('Author typeName must be "author"')
|
|
72
|
+
}
|
|
73
|
+
if (typeof r.name !== 'string' || r.name.trim().length === 0) {
|
|
74
|
+
throw new Error('Author must have non-empty name string')
|
|
75
|
+
}
|
|
76
|
+
if (typeof r.isPseudonym !== 'boolean') {
|
|
77
|
+
throw new Error('Author isPseudonym must be boolean')
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
r.birthYear !== undefined &&
|
|
81
|
+
(typeof r.birthYear !== 'number' || r.birthYear < 1000 || r.birthYear > 2100)
|
|
82
|
+
) {
|
|
83
|
+
throw new Error('Author birthYear must be reasonable year number if provided')
|
|
84
|
+
}
|
|
85
|
+
return r as Author
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
scope: 'document',
|
|
89
|
+
}).withDefaultProperties(() => ({
|
|
90
|
+
isPseudonym: false,
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
|
|
94
|
+
visitorName: string
|
|
95
|
+
booksInBasket: RecordId<Book>[]
|
|
96
|
+
timestamp: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const Visit = createRecordType<Visit>('visit', {
|
|
100
|
+
validator: {
|
|
101
|
+
validate: (record: unknown): Visit => {
|
|
102
|
+
if (!record || typeof record !== 'object') {
|
|
103
|
+
throw new Error('Visit record must be an object')
|
|
104
|
+
}
|
|
105
|
+
const r = record as any
|
|
106
|
+
if (typeof r.id !== 'string' || !r.id.startsWith('visit:')) {
|
|
107
|
+
throw new Error('Visit must have valid id starting with "visit:"')
|
|
108
|
+
}
|
|
109
|
+
if (r.typeName !== 'visit') {
|
|
110
|
+
throw new Error('Visit typeName must be "visit"')
|
|
111
|
+
}
|
|
112
|
+
if (typeof r.visitorName !== 'string' || r.visitorName.trim().length === 0) {
|
|
113
|
+
throw new Error('Visit must have non-empty visitorName string')
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(r.booksInBasket)) {
|
|
116
|
+
throw new Error('Visit booksInBasket must be an array')
|
|
117
|
+
}
|
|
118
|
+
for (const bookId of r.booksInBasket) {
|
|
119
|
+
if (typeof bookId !== 'string' || !bookId.startsWith('book:')) {
|
|
120
|
+
throw new Error('Visit booksInBasket must contain valid book RecordIds')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (typeof r.timestamp !== 'number' || r.timestamp < 0) {
|
|
124
|
+
throw new Error('Visit timestamp must be non-negative number')
|
|
125
|
+
}
|
|
126
|
+
return r as Visit
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
scope: 'session',
|
|
130
|
+
}).withDefaultProperties(() => ({
|
|
131
|
+
visitorName: 'Anonymous',
|
|
132
|
+
booksInBasket: [],
|
|
133
|
+
timestamp: Date.now(),
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
interface Cursor extends BaseRecord<'cursor', RecordId<Cursor>> {
|
|
137
|
+
x: number
|
|
138
|
+
y: number
|
|
139
|
+
userId: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const Cursor = createRecordType<Cursor>('cursor', {
|
|
143
|
+
validator: {
|
|
144
|
+
validate: (record: unknown): Cursor => {
|
|
145
|
+
if (!record || typeof record !== 'object') {
|
|
146
|
+
throw new Error('Cursor record must be an object')
|
|
147
|
+
}
|
|
148
|
+
const r = record as any
|
|
149
|
+
if (typeof r.id !== 'string' || !r.id.startsWith('cursor:')) {
|
|
150
|
+
throw new Error('Cursor must have valid id starting with "cursor:"')
|
|
151
|
+
}
|
|
152
|
+
if (r.typeName !== 'cursor') {
|
|
153
|
+
throw new Error('Cursor typeName must be "cursor"')
|
|
154
|
+
}
|
|
155
|
+
if (typeof r.x !== 'number' || !isFinite(r.x)) {
|
|
156
|
+
throw new Error('Cursor x must be finite number')
|
|
157
|
+
}
|
|
158
|
+
if (typeof r.y !== 'number' || !isFinite(r.y)) {
|
|
159
|
+
throw new Error('Cursor y must be finite number')
|
|
160
|
+
}
|
|
161
|
+
if (typeof r.userId !== 'string' || r.userId.trim().length === 0) {
|
|
162
|
+
throw new Error('Cursor must have non-empty userId string')
|
|
163
|
+
}
|
|
164
|
+
return r as Cursor
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
scope: 'presence',
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
type LibraryType = Book | Author | Visit | Cursor
|
|
171
|
+
|
|
172
|
+
describe('Store', () => {
|
|
173
|
+
let store: Store<LibraryType>
|
|
174
|
+
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
store = new Store({
|
|
177
|
+
props: {},
|
|
178
|
+
schema: StoreSchema.create<LibraryType>({
|
|
179
|
+
book: Book,
|
|
180
|
+
author: Author,
|
|
181
|
+
visit: Visit,
|
|
182
|
+
cursor: Cursor,
|
|
183
|
+
}),
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
afterEach(() => {
|
|
188
|
+
store.dispose()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe('basic record operations', () => {
|
|
192
|
+
it('puts records into the store', () => {
|
|
193
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
194
|
+
const book = Book.create({
|
|
195
|
+
title: 'The Hobbit',
|
|
196
|
+
author: author.id,
|
|
197
|
+
numPages: 310,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
store.put([author, book])
|
|
201
|
+
|
|
202
|
+
expect(store.get(author.id)).toEqual(author)
|
|
203
|
+
expect(store.get(book.id)).toEqual(book)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('updates existing records', () => {
|
|
207
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
208
|
+
store.put([author])
|
|
209
|
+
|
|
210
|
+
const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' }
|
|
211
|
+
store.put([updatedAuthor])
|
|
212
|
+
|
|
213
|
+
expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('removes records from the store', () => {
|
|
217
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
218
|
+
const book = Book.create({
|
|
219
|
+
title: 'The Hobbit',
|
|
220
|
+
author: author.id,
|
|
221
|
+
numPages: 310,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
store.put([author, book])
|
|
225
|
+
expect(store.has(author.id)).toBe(true)
|
|
226
|
+
expect(store.has(book.id)).toBe(true)
|
|
227
|
+
|
|
228
|
+
store.remove([author.id, book.id])
|
|
229
|
+
expect(store.has(author.id)).toBe(false)
|
|
230
|
+
expect(store.has(book.id)).toBe(false)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('updates records using update method', () => {
|
|
234
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
235
|
+
store.put([author])
|
|
236
|
+
|
|
237
|
+
store.update(author.id, (current) => ({
|
|
238
|
+
...current,
|
|
239
|
+
name: 'John Ronald Reuel Tolkien',
|
|
240
|
+
}))
|
|
241
|
+
|
|
242
|
+
expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('clears all records', () => {
|
|
246
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
247
|
+
const book = Book.create({
|
|
248
|
+
title: 'The Hobbit',
|
|
249
|
+
author: author.id,
|
|
250
|
+
numPages: 310,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
store.put([author, book])
|
|
254
|
+
expect(store.allRecords()).toHaveLength(2)
|
|
255
|
+
|
|
256
|
+
store.clear()
|
|
257
|
+
expect(store.allRecords()).toHaveLength(0)
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('atomic operations', () => {
|
|
262
|
+
it('performs atomic operations', () => {
|
|
263
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
264
|
+
const book = Book.create({
|
|
265
|
+
title: 'The Hobbit',
|
|
266
|
+
author: author.id,
|
|
267
|
+
numPages: 310,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
const result = store.atomic(() => {
|
|
271
|
+
store.put([author])
|
|
272
|
+
store.put([book])
|
|
273
|
+
return 'completed'
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
expect(result).toBe('completed')
|
|
277
|
+
expect(store.get(author.id)).toEqual(author)
|
|
278
|
+
expect(store.get(book.id)).toEqual(book)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('handles nested atomic operations', () => {
|
|
282
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
283
|
+
const book = Book.create({
|
|
284
|
+
title: 'The Hobbit',
|
|
285
|
+
author: author.id,
|
|
286
|
+
numPages: 310,
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
store.atomic(() => {
|
|
290
|
+
store.put([author])
|
|
291
|
+
store.atomic(() => {
|
|
292
|
+
store.put([book])
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
expect(store.get(author.id)).toEqual(author)
|
|
297
|
+
expect(store.get(book.id)).toEqual(book)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
describe('history tracking', () => {
|
|
302
|
+
it('extracts changes from operations', () => {
|
|
303
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
304
|
+
const book = Book.create({
|
|
305
|
+
title: 'The Hobbit',
|
|
306
|
+
author: author.id,
|
|
307
|
+
numPages: 310,
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const changes = store.extractingChanges(() => {
|
|
311
|
+
store.put([author, book])
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
expect(changes.added).toHaveProperty(author.id)
|
|
315
|
+
expect(changes.added).toHaveProperty(book.id)
|
|
316
|
+
expect(Object.keys(changes.updated)).toHaveLength(0)
|
|
317
|
+
expect(Object.keys(changes.removed)).toHaveLength(0)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('extracts update changes', () => {
|
|
321
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
322
|
+
store.put([author])
|
|
323
|
+
|
|
324
|
+
const changes = store.extractingChanges(() => {
|
|
325
|
+
store.update(author.id, (a) => ({ ...a, name: 'John Ronald Reuel Tolkien' }))
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
expect(Object.keys(changes.added)).toHaveLength(0)
|
|
329
|
+
expect(changes.updated).toHaveProperty(author.id)
|
|
330
|
+
expect(Object.keys(changes.removed)).toHaveLength(0)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('extracts removal changes', () => {
|
|
334
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
335
|
+
store.put([author])
|
|
336
|
+
|
|
337
|
+
const changes = store.extractingChanges(() => {
|
|
338
|
+
store.remove([author.id])
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
expect(Object.keys(changes.added)).toHaveLength(0)
|
|
342
|
+
expect(Object.keys(changes.updated)).toHaveLength(0)
|
|
343
|
+
expect(changes.removed).toHaveProperty(author.id)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('listeners', () => {
|
|
348
|
+
it('adds and removes listeners', async () => {
|
|
349
|
+
const listener = vi.fn()
|
|
350
|
+
const removeListener = store.listen(listener)
|
|
351
|
+
|
|
352
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
353
|
+
store.put([author])
|
|
354
|
+
|
|
355
|
+
// Wait for async history flush
|
|
356
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
357
|
+
|
|
358
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
359
|
+
expect(listener).toHaveBeenCalledWith(
|
|
360
|
+
expect.objectContaining({
|
|
361
|
+
source: 'user',
|
|
362
|
+
changes: expect.objectContaining({
|
|
363
|
+
added: expect.objectContaining({
|
|
364
|
+
[author.id]: author,
|
|
365
|
+
}),
|
|
366
|
+
}),
|
|
367
|
+
})
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
removeListener()
|
|
371
|
+
|
|
372
|
+
const book = Book.create({
|
|
373
|
+
title: 'The Hobbit',
|
|
374
|
+
author: author.id,
|
|
375
|
+
numPages: 310,
|
|
376
|
+
})
|
|
377
|
+
store.put([book])
|
|
378
|
+
|
|
379
|
+
// Wait for async history flush
|
|
380
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
381
|
+
|
|
382
|
+
// Should still be called only once
|
|
383
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('filters listeners by source', async () => {
|
|
387
|
+
const userListener = vi.fn()
|
|
388
|
+
const remoteListener = vi.fn()
|
|
389
|
+
|
|
390
|
+
store.listen(userListener, { source: 'user', scope: 'all' })
|
|
391
|
+
store.listen(remoteListener, { source: 'remote', scope: 'all' })
|
|
392
|
+
|
|
393
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
394
|
+
|
|
395
|
+
// User change
|
|
396
|
+
store.put([author])
|
|
397
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
398
|
+
|
|
399
|
+
expect(userListener).toHaveBeenCalledTimes(1)
|
|
400
|
+
expect(remoteListener).not.toHaveBeenCalled()
|
|
401
|
+
|
|
402
|
+
// Remote change
|
|
403
|
+
store.mergeRemoteChanges(() => {
|
|
404
|
+
const book = Book.create({
|
|
405
|
+
title: 'The Hobbit',
|
|
406
|
+
author: author.id,
|
|
407
|
+
numPages: 310,
|
|
408
|
+
})
|
|
409
|
+
store.put([book])
|
|
410
|
+
})
|
|
411
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
412
|
+
|
|
413
|
+
expect(userListener).toHaveBeenCalledTimes(1)
|
|
414
|
+
expect(remoteListener).toHaveBeenCalledTimes(1)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('filters listeners by scope', async () => {
|
|
418
|
+
const documentListener = vi.fn()
|
|
419
|
+
const sessionListener = vi.fn()
|
|
420
|
+
const presenceListener = vi.fn()
|
|
421
|
+
|
|
422
|
+
store.listen(documentListener, { source: 'all', scope: 'document' })
|
|
423
|
+
store.listen(sessionListener, { source: 'all', scope: 'session' })
|
|
424
|
+
store.listen(presenceListener, { source: 'all', scope: 'presence' })
|
|
425
|
+
|
|
426
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' }) // document scope
|
|
427
|
+
const visit = Visit.create({ visitorName: 'John Doe' }) // session scope
|
|
428
|
+
const cursor = Cursor.create({ x: 100, y: 200, userId: 'user1' }) // presence scope
|
|
429
|
+
|
|
430
|
+
store.put([author, visit, cursor])
|
|
431
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
432
|
+
|
|
433
|
+
expect(documentListener).toHaveBeenCalledTimes(1)
|
|
434
|
+
expect(sessionListener).toHaveBeenCalledTimes(1)
|
|
435
|
+
expect(presenceListener).toHaveBeenCalledTimes(1)
|
|
436
|
+
|
|
437
|
+
// Check that each listener only received records from their scope
|
|
438
|
+
expect(documentListener.mock.calls[0][0].changes.added).toHaveProperty(author.id)
|
|
439
|
+
expect(documentListener.mock.calls[0][0].changes.added).not.toHaveProperty(visit.id)
|
|
440
|
+
expect(documentListener.mock.calls[0][0].changes.added).not.toHaveProperty(cursor.id)
|
|
441
|
+
|
|
442
|
+
expect(sessionListener.mock.calls[0][0].changes.added).not.toHaveProperty(author.id)
|
|
443
|
+
expect(sessionListener.mock.calls[0][0].changes.added).toHaveProperty(visit.id)
|
|
444
|
+
expect(sessionListener.mock.calls[0][0].changes.added).not.toHaveProperty(cursor.id)
|
|
445
|
+
|
|
446
|
+
expect(presenceListener.mock.calls[0][0].changes.added).not.toHaveProperty(author.id)
|
|
447
|
+
expect(presenceListener.mock.calls[0][0].changes.added).not.toHaveProperty(visit.id)
|
|
448
|
+
expect(presenceListener.mock.calls[0][0].changes.added).toHaveProperty(cursor.id)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('flushes history before adding listeners', async () => {
|
|
452
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
453
|
+
store.put([author])
|
|
454
|
+
|
|
455
|
+
// Add listener after changes
|
|
456
|
+
const listener = vi.fn()
|
|
457
|
+
store.listen(listener)
|
|
458
|
+
|
|
459
|
+
// Should not receive historical changes
|
|
460
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
461
|
+
expect(listener).not.toHaveBeenCalled()
|
|
462
|
+
|
|
463
|
+
// Should receive new changes
|
|
464
|
+
const book = Book.create({
|
|
465
|
+
title: 'The Hobbit',
|
|
466
|
+
author: author.id,
|
|
467
|
+
numPages: 310,
|
|
468
|
+
})
|
|
469
|
+
store.put([book])
|
|
470
|
+
|
|
471
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
472
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
473
|
+
})
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
describe('remote changes', () => {
|
|
477
|
+
it('merges remote changes with correct source', async () => {
|
|
478
|
+
const listener = vi.fn()
|
|
479
|
+
store.listen(listener)
|
|
480
|
+
|
|
481
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
482
|
+
|
|
483
|
+
store.mergeRemoteChanges(() => {
|
|
484
|
+
store.put([author])
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
488
|
+
|
|
489
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ source: 'remote' }))
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it('ensures store is usable after remote changes', () => {
|
|
493
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
494
|
+
|
|
495
|
+
store.mergeRemoteChanges(() => {
|
|
496
|
+
store.put([author])
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
expect(store.get(author.id)).toEqual(author)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('throws error when merging remote changes during atomic operation', () => {
|
|
503
|
+
expect(() => {
|
|
504
|
+
store.atomic(() => {
|
|
505
|
+
store.mergeRemoteChanges(() => {
|
|
506
|
+
// This should throw
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
}).toThrow('Cannot merge remote changes while in atomic operation')
|
|
510
|
+
})
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
describe('serialization and snapshots', () => {
|
|
514
|
+
beforeEach(() => {
|
|
515
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
516
|
+
const book = Book.create({
|
|
517
|
+
title: 'The Hobbit',
|
|
518
|
+
author: author.id,
|
|
519
|
+
numPages: 310,
|
|
520
|
+
})
|
|
521
|
+
const visit = Visit.create({ visitorName: 'John Doe' })
|
|
522
|
+
const cursor = Cursor.create({ x: 100, y: 200, userId: 'user1' })
|
|
523
|
+
|
|
524
|
+
store.put([author, book, visit, cursor])
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('serializes store with document scope by default', () => {
|
|
528
|
+
const serialized = store.serialize()
|
|
529
|
+
const records = Object.values(serialized)
|
|
530
|
+
|
|
531
|
+
// Should include document records only
|
|
532
|
+
expect(records.some((r) => r.typeName === 'author')).toBe(true)
|
|
533
|
+
expect(records.some((r) => r.typeName === 'book')).toBe(true)
|
|
534
|
+
expect(records.some((r) => r.typeName === 'visit')).toBe(false)
|
|
535
|
+
expect(records.some((r) => r.typeName === 'cursor')).toBe(false)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('serializes store with specific scope', () => {
|
|
539
|
+
const sessionSerialized = store.serialize('session')
|
|
540
|
+
const sessionRecords = Object.values(sessionSerialized)
|
|
541
|
+
|
|
542
|
+
expect(sessionRecords.some((r) => r.typeName === 'visit')).toBe(true)
|
|
543
|
+
expect(sessionRecords.some((r) => r.typeName === 'author')).toBe(false)
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('serializes store with all scopes', () => {
|
|
547
|
+
const allSerialized = store.serialize('all')
|
|
548
|
+
const allRecords = Object.values(allSerialized)
|
|
549
|
+
|
|
550
|
+
expect(allRecords.some((r) => r.typeName === 'author')).toBe(true)
|
|
551
|
+
expect(allRecords.some((r) => r.typeName === 'book')).toBe(true)
|
|
552
|
+
expect(allRecords.some((r) => r.typeName === 'visit')).toBe(true)
|
|
553
|
+
expect(allRecords.some((r) => r.typeName === 'cursor')).toBe(true)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('creates and loads store snapshots', () => {
|
|
557
|
+
const snapshot = store.getStoreSnapshot()
|
|
558
|
+
|
|
559
|
+
expect(snapshot).toHaveProperty('store')
|
|
560
|
+
expect(snapshot).toHaveProperty('schema')
|
|
561
|
+
|
|
562
|
+
const newStore = new Store({
|
|
563
|
+
props: {},
|
|
564
|
+
schema: StoreSchema.create<LibraryType>({
|
|
565
|
+
book: Book,
|
|
566
|
+
author: Author,
|
|
567
|
+
visit: Visit,
|
|
568
|
+
cursor: Cursor,
|
|
569
|
+
}),
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
newStore.loadStoreSnapshot(snapshot)
|
|
573
|
+
|
|
574
|
+
// Should have same document records (default scope)
|
|
575
|
+
const originalDocumentRecords = store.serialize('document')
|
|
576
|
+
const newDocumentRecords = newStore.serialize('document')
|
|
577
|
+
|
|
578
|
+
expect(newDocumentRecords).toEqual(originalDocumentRecords)
|
|
579
|
+
newStore.dispose()
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('migrates snapshots', () => {
|
|
583
|
+
const snapshot = store.getStoreSnapshot()
|
|
584
|
+
const migratedSnapshot = store.migrateSnapshot(snapshot)
|
|
585
|
+
|
|
586
|
+
expect(migratedSnapshot).toEqual(snapshot) // No migrations needed
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it('throws error on migration failure', () => {
|
|
590
|
+
const invalidSnapshot = {
|
|
591
|
+
store: {},
|
|
592
|
+
schema: { version: -1 }, // Invalid version
|
|
593
|
+
} as any
|
|
594
|
+
|
|
595
|
+
expect(() => store.migrateSnapshot(invalidSnapshot)).toThrow()
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
describe('computed caches', () => {
|
|
600
|
+
it('creates and uses computed cache', () => {
|
|
601
|
+
const computeExpensiveData = vi.fn((book: Book) => `expensive-${book.title}`)
|
|
602
|
+
|
|
603
|
+
const cache = store.createComputedCache('expensiveBook', computeExpensiveData)
|
|
604
|
+
|
|
605
|
+
const book = Book.create({
|
|
606
|
+
title: 'The Hobbit',
|
|
607
|
+
author: Author.createId('tolkien'),
|
|
608
|
+
numPages: 310,
|
|
609
|
+
})
|
|
610
|
+
store.put([book])
|
|
611
|
+
|
|
612
|
+
const result1 = cache.get(book.id)
|
|
613
|
+
expect(result1).toBe('expensive-The Hobbit')
|
|
614
|
+
expect(computeExpensiveData).toHaveBeenCalledTimes(1)
|
|
615
|
+
|
|
616
|
+
// Should use cached result
|
|
617
|
+
const result2 = cache.get(book.id)
|
|
618
|
+
expect(result2).toBe('expensive-The Hobbit')
|
|
619
|
+
expect(computeExpensiveData).toHaveBeenCalledTimes(1)
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('invalidates cache when record changes', () => {
|
|
623
|
+
const computeExpensiveData = vi.fn((book: Book) => `expensive-${book.title}`)
|
|
624
|
+
const cache = store.createComputedCache('expensiveBook', computeExpensiveData)
|
|
625
|
+
|
|
626
|
+
const book = Book.create({
|
|
627
|
+
title: 'The Hobbit',
|
|
628
|
+
author: Author.createId('tolkien'),
|
|
629
|
+
numPages: 310,
|
|
630
|
+
})
|
|
631
|
+
store.put([book])
|
|
632
|
+
|
|
633
|
+
cache.get(book.id)
|
|
634
|
+
expect(computeExpensiveData).toHaveBeenCalledTimes(1)
|
|
635
|
+
|
|
636
|
+
// Update the book
|
|
637
|
+
store.update(book.id, (b) => ({ ...b, title: 'The Hobbit: Updated' }))
|
|
638
|
+
|
|
639
|
+
// Should recompute
|
|
640
|
+
const result = cache.get(book.id)
|
|
641
|
+
expect(result).toBe('expensive-The Hobbit: Updated')
|
|
642
|
+
expect(computeExpensiveData).toHaveBeenCalledTimes(2)
|
|
643
|
+
})
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
describe('standalone createComputedCache', () => {
|
|
647
|
+
it('works with store objects', () => {
|
|
648
|
+
const derive = vi.fn(
|
|
649
|
+
(context: { store: Store<LibraryType> }, book: Book) => `${book.title}-${context.store.id}`
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
const cache = createComputedCache('standalone', derive)
|
|
653
|
+
|
|
654
|
+
const book = Book.create({
|
|
655
|
+
title: 'The Hobbit',
|
|
656
|
+
author: Author.createId('tolkien'),
|
|
657
|
+
numPages: 310,
|
|
658
|
+
})
|
|
659
|
+
store.put([book])
|
|
660
|
+
|
|
661
|
+
const result = cache.get({ store }, book.id)
|
|
662
|
+
expect(result).toBe(`The Hobbit-${store.id}`)
|
|
663
|
+
expect(derive).toHaveBeenCalledTimes(1)
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it('works directly with store instances', () => {
|
|
667
|
+
const derive = vi.fn((store: Store<LibraryType>, book: Book) => `${book.title}-${store.id}`)
|
|
668
|
+
|
|
669
|
+
const cache = createComputedCache('standalone', derive)
|
|
670
|
+
|
|
671
|
+
const book = Book.create({
|
|
672
|
+
title: 'The Hobbit',
|
|
673
|
+
author: Author.createId('tolkien'),
|
|
674
|
+
numPages: 310,
|
|
675
|
+
})
|
|
676
|
+
store.put([book])
|
|
677
|
+
|
|
678
|
+
const result = cache.get(store, book.id)
|
|
679
|
+
expect(result).toBe(`The Hobbit-${store.id}`)
|
|
680
|
+
expect(derive).toHaveBeenCalledTimes(1)
|
|
681
|
+
})
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
describe('diff application', () => {
|
|
685
|
+
it('applies diffs to the store', () => {
|
|
686
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
687
|
+
const book = Book.create({
|
|
688
|
+
title: 'The Hobbit',
|
|
689
|
+
author: author.id,
|
|
690
|
+
numPages: 310,
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
const diff: RecordsDiff<LibraryType> = {
|
|
694
|
+
added: {
|
|
695
|
+
[author.id]: author,
|
|
696
|
+
[book.id]: book,
|
|
697
|
+
},
|
|
698
|
+
updated: {},
|
|
699
|
+
removed: {},
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
store.applyDiff(diff)
|
|
703
|
+
|
|
704
|
+
expect(store.get(author.id)).toEqual(author)
|
|
705
|
+
expect(store.get(book.id)).toEqual(book)
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it('applies diffs with updates', () => {
|
|
709
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
710
|
+
store.put([author])
|
|
711
|
+
|
|
712
|
+
const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' }
|
|
713
|
+
const diff: RecordsDiff<LibraryType> = {
|
|
714
|
+
added: {},
|
|
715
|
+
updated: {
|
|
716
|
+
[author.id]: [author, updatedAuthor],
|
|
717
|
+
},
|
|
718
|
+
removed: {},
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
store.applyDiff(diff)
|
|
722
|
+
|
|
723
|
+
expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('applies diffs with removals', () => {
|
|
727
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
728
|
+
store.put([author])
|
|
729
|
+
|
|
730
|
+
const diff: RecordsDiff<LibraryType> = {
|
|
731
|
+
added: {},
|
|
732
|
+
updated: {},
|
|
733
|
+
removed: {
|
|
734
|
+
[author.id]: author,
|
|
735
|
+
},
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
store.applyDiff(diff)
|
|
739
|
+
|
|
740
|
+
expect(store.has(author.id)).toBe(false)
|
|
741
|
+
})
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
describe('validation', () => {
|
|
745
|
+
it('validates records during operations', () => {
|
|
746
|
+
expect(() => {
|
|
747
|
+
store.put([
|
|
748
|
+
{
|
|
749
|
+
id: Book.createId('test1'),
|
|
750
|
+
typeName: 'book',
|
|
751
|
+
title: '', // Invalid: empty title
|
|
752
|
+
author: Author.createId('tolkien'),
|
|
753
|
+
numPages: 100,
|
|
754
|
+
inStock: true,
|
|
755
|
+
} as any,
|
|
756
|
+
])
|
|
757
|
+
}).toThrow('Book must have non-empty title string')
|
|
758
|
+
})
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
describe('side effects integration', () => {
|
|
762
|
+
it('integrates with side effects for put operations', () => {
|
|
763
|
+
const beforeCreate = vi.fn((record: Author) => record)
|
|
764
|
+
const afterCreate = vi.fn()
|
|
765
|
+
|
|
766
|
+
store.sideEffects.registerBeforeCreateHandler('author', beforeCreate)
|
|
767
|
+
store.sideEffects.registerAfterCreateHandler('author', afterCreate)
|
|
768
|
+
|
|
769
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
770
|
+
store.put([author])
|
|
771
|
+
|
|
772
|
+
expect(beforeCreate).toHaveBeenCalledWith(author, 'user')
|
|
773
|
+
expect(afterCreate).toHaveBeenCalledWith(author, 'user')
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('integrates with side effects for update operations', () => {
|
|
777
|
+
const beforeChange = vi.fn((prev: Author, next: Author) => next)
|
|
778
|
+
const afterChange = vi.fn()
|
|
779
|
+
|
|
780
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
781
|
+
store.put([author])
|
|
782
|
+
|
|
783
|
+
store.sideEffects.registerBeforeChangeHandler('author', beforeChange)
|
|
784
|
+
store.sideEffects.registerAfterChangeHandler('author', afterChange)
|
|
785
|
+
|
|
786
|
+
const updatedAuthor = { ...author, name: 'John Ronald Reuel Tolkien' }
|
|
787
|
+
store.put([updatedAuthor])
|
|
788
|
+
|
|
789
|
+
expect(beforeChange).toHaveBeenCalledWith(author, updatedAuthor, 'user')
|
|
790
|
+
expect(afterChange).toHaveBeenCalledWith(author, updatedAuthor, 'user')
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
it('integrates with side effects for remove operations', () => {
|
|
794
|
+
const beforeDelete = vi.fn()
|
|
795
|
+
const afterDelete = vi.fn()
|
|
796
|
+
|
|
797
|
+
const author = Author.create({ name: 'J.R.R. Tolkien' })
|
|
798
|
+
store.put([author])
|
|
799
|
+
|
|
800
|
+
store.sideEffects.registerBeforeDeleteHandler('author', beforeDelete)
|
|
801
|
+
store.sideEffects.registerAfterDeleteHandler('author', afterDelete)
|
|
802
|
+
|
|
803
|
+
store.remove([author.id])
|
|
804
|
+
|
|
805
|
+
expect(beforeDelete).toHaveBeenCalledWith(author, 'user')
|
|
806
|
+
expect(afterDelete).toHaveBeenCalledWith(author, 'user')
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
it('can prevent deletion with beforeDelete handler', () => {
|
|
810
|
+
const beforeDelete = vi.fn().mockReturnValue(false)
|
|
811
|
+
const afterDelete = vi.fn()
|
|
812
|
+
|
|
813
|
+
store.sideEffects.registerBeforeDeleteHandler('author', beforeDelete)
|
|
814
|
+
store.sideEffects.registerAfterDeleteHandler('author', afterDelete)
|
|
815
|
+
|
|
816
|
+
const author = Author.create({ name: 'Protected Author' })
|
|
817
|
+
store.put([author])
|
|
818
|
+
|
|
819
|
+
// Try to delete - should be prevented
|
|
820
|
+
store.remove([author.id])
|
|
821
|
+
|
|
822
|
+
expect(beforeDelete).toHaveBeenCalledWith(author, 'user')
|
|
823
|
+
expect(afterDelete).not.toHaveBeenCalled()
|
|
824
|
+
expect(store.has(author.id)).toBe(true) // Should still exist
|
|
825
|
+
})
|
|
826
|
+
})
|
|
827
|
+
})
|